From 835d693a92a7edb66d3dd1760b60fe945d099f7d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 30 Mar 2026 13:04:14 -0700 Subject: [PATCH 1/8] Prioritize pinned projects in sidebar sorting - Add project pin state persistence and toggle actions - Show pin affordance in the sidebar and keep pinned projects first --- apps/web/src/components/Sidebar.logic.test.ts | 63 ++++++++++++++++++- apps/web/src/components/Sidebar.logic.ts | 6 ++ apps/web/src/components/Sidebar.tsx | 44 ++++++++++--- apps/web/src/uiStateStore.test.ts | 39 ++++++++++++ apps/web/src/uiStateStore.ts | 46 ++++++++++++++ 5 files changed, 189 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index d4cd25db4c..7947b8ad3b 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -586,7 +586,9 @@ describe("getVisibleThreadsForProject", () => { }); }); -function makeProject(overrides: Partial = {}): Project { +function makeProject( + overrides: Partial & { pinned?: boolean } = {}, +): Project & { pinned?: boolean } { const { defaultModelSelection, ...rest } = overrides; return { id: ProjectId.makeUnsafe("project-1"), @@ -900,6 +902,65 @@ describe("sortProjectsForSidebar", () => { ]); }); + it("puts pinned projects ahead of unpinned projects in auto-sort modes", () => { + const sorted = sortProjectsForSidebar( + [ + makeProject({ + id: ProjectId.makeUnsafe("project-1"), + name: "Pinned older project", + pinned: true, + updatedAt: "2026-03-09T10:01:00.000Z", + }), + makeProject({ + id: ProjectId.makeUnsafe("project-2"), + name: "Unpinned newer project", + pinned: false, + updatedAt: "2026-03-09T10:05:00.000Z", + }), + ], + [], + "updated_at", + ); + + expect(sorted.map((project) => project.id)).toEqual([ + ProjectId.makeUnsafe("project-1"), + ProjectId.makeUnsafe("project-2"), + ]); + }); + + it("still auto-sorts within the pinned bucket", () => { + const sorted = sortProjectsForSidebar( + [ + makeProject({ + id: ProjectId.makeUnsafe("project-1"), + name: "Pinned older project", + pinned: true, + updatedAt: "2026-03-09T10:01:00.000Z", + }), + makeProject({ + id: ProjectId.makeUnsafe("project-2"), + name: "Pinned newer project", + pinned: true, + updatedAt: "2026-03-09T10:05:00.000Z", + }), + makeProject({ + id: ProjectId.makeUnsafe("project-3"), + name: "Unpinned project", + pinned: false, + updatedAt: "2026-03-09T10:10:00.000Z", + }), + ], + [], + "updated_at", + ); + + expect(sorted.map((project) => project.id)).toEqual([ + ProjectId.makeUnsafe("project-2"), + ProjectId.makeUnsafe("project-1"), + ProjectId.makeUnsafe("project-3"), + ]); + }); + it("falls back to name and id ordering when projects have no sortable timestamps", () => { const sorted = sortProjectsForSidebar( [ diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 16c7752f74..5b36a930e6 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -16,6 +16,7 @@ type SidebarProject = { name: string; createdAt?: string | undefined; updatedAt?: string | undefined; + pinned?: boolean | undefined; }; type SidebarThreadSortInput = Pick & { latestUserMessageAt?: string | null; @@ -526,6 +527,11 @@ export function sortProjectsForSidebar< } return [...projects].toSorted((left, right) => { + const leftPinned = left.pinned === true; + const rightPinned = right.pinned === true; + if (leftPinned !== rightPinned) { + return rightPinned ? 1 : -1; + } const rightTimestamp = getProjectSortTimestamp( right, threadsByProjectId.get(right.id) ?? [], diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index efa5124288..670854d5db 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -4,6 +4,7 @@ import { ChevronRightIcon, FolderIcon, GitPullRequestIcon, + PinIcon, PlusIcon, SettingsIcon, SquarePenIcon, @@ -165,6 +166,7 @@ type SidebarThreadSnapshot = Pick< type SidebarProjectSnapshot = Project & { expanded: boolean; + pinned: boolean; }; const sidebarThreadSnapshotCache = new WeakMap< @@ -440,15 +442,18 @@ function SortableProjectItem({ export default function Sidebar() { const projects = useStore((store) => store.projects); const serverThreads = useStore((store) => store.threads); - const { projectExpandedById, projectOrder, threadLastVisitedAtById } = useUiStateStore( - useShallow((store) => ({ - projectExpandedById: store.projectExpandedById, - projectOrder: store.projectOrder, - threadLastVisitedAtById: store.threadLastVisitedAtById, - })), - ); + const { projectExpandedById, projectOrder, projectPinnedById, threadLastVisitedAtById } = + useUiStateStore( + useShallow((store) => ({ + projectExpandedById: store.projectExpandedById, + projectOrder: store.projectOrder, + projectPinnedById: store.projectPinnedById, + threadLastVisitedAtById: store.threadLastVisitedAtById, + })), + ); const markThreadUnread = useUiStateStore((store) => store.markThreadUnread); const toggleProject = useUiStateStore((store) => store.toggleProject); + const toggleProjectPinned = useUiStateStore((store) => store.toggleProjectPinned); const reorderProjects = useUiStateStore((store) => store.reorderProjects); const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); const getDraftThreadByProjectId = useComposerDraftStore( @@ -515,8 +520,9 @@ export default function Sidebar() { orderedProjects.map((project) => ({ ...project, expanded: projectExpandedById[project.id] ?? true, + pinned: projectPinnedById[project.id] ?? false, })), - [orderedProjects, projectExpandedById], + [orderedProjects, projectExpandedById, projectPinnedById], ); const threads = useMemo( () => @@ -1018,11 +1024,19 @@ export default function Sidebar() { const clicked = await api.contextMenu.show( [ + { + id: "toggle-pin", + label: projectPinnedById[projectId] ? "Unpin project" : "Pin project", + }, { id: "copy-path", label: "Copy Project Path" }, { id: "delete", label: "Remove project", destructive: true }, ], position, ); + if (clicked === "toggle-pin") { + toggleProjectPinned(projectId); + return; + } if (clicked === "copy-path") { copyPathToClipboard(project.cwd, { path: project.cwd }); return; @@ -1068,8 +1082,10 @@ export default function Sidebar() { clearProjectDraftThreadId, copyPathToClipboard, getDraftThreadByProjectId, + projectPinnedById, projects, threads, + toggleProjectPinned, ], ); @@ -1669,6 +1685,18 @@ export default function Sidebar() { {project.name} + {project.pinned ? ( + + + + ) : null} = {}): UiState { return { projectExpandedById: {}, projectOrder: [], + projectPinnedById: {}, threadLastVisitedAtById: {}, ...overrides, }; @@ -110,6 +112,32 @@ describe("uiStateStore pure functions", () => { expect(next.projectExpandedById[recreatedProject2]).toBe(false); }); + it("syncProjects preserves pinned state when a project is recreated with the same cwd", () => { + const oldProject1 = ProjectId.makeUnsafe("project-1"); + const oldProject2 = ProjectId.makeUnsafe("project-2"); + const recreatedProject2 = ProjectId.makeUnsafe("project-2b"); + const initialState = syncProjects( + makeUiState({ + projectPinnedById: { + [oldProject2]: true, + }, + }), + [ + { id: oldProject1, cwd: "/tmp/project-1" }, + { id: oldProject2, cwd: "/tmp/project-2" }, + ], + ); + + const next = syncProjects(initialState, [ + { id: oldProject1, cwd: "/tmp/project-1" }, + { id: recreatedProject2, cwd: "/tmp/project-2" }, + ]); + + expect(next.projectPinnedById).toEqual({ + [recreatedProject2]: true, + }); + }); + it("syncProjects returns a new state when only project cwd changes", () => { const project1 = ProjectId.makeUnsafe("project-1"); const initialState = syncProjects( @@ -177,6 +205,17 @@ describe("uiStateStore pure functions", () => { expect(next.projectOrder).toEqual([project1]); }); + it("toggleProjectPinned adds and removes pinned state", () => { + const project1 = ProjectId.makeUnsafe("project-1"); + const pinned = toggleProjectPinned(makeUiState(), project1); + + expect(pinned.projectPinnedById).toEqual({ [project1]: true }); + + const unpinned = toggleProjectPinned(pinned, project1); + + expect(unpinned.projectPinnedById).toEqual({}); + }); + it("clearThreadUi removes visit state for deleted threads", () => { const thread1 = ThreadId.makeUnsafe("thread-1"); const initialState = makeUiState({ diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 342f2db18f..0e2c54da45 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -19,11 +19,13 @@ const LEGACY_PERSISTED_STATE_KEYS = [ interface PersistedUiState { expandedProjectCwds?: string[]; projectOrderCwds?: string[]; + pinnedProjectCwds?: string[]; } export interface UiProjectState { projectExpandedById: Record; projectOrder: ProjectId[]; + projectPinnedById: Record; } export interface UiThreadState { @@ -45,11 +47,13 @@ export interface SyncThreadInput { const initialState: UiState = { projectExpandedById: {}, projectOrder: [], + projectPinnedById: {}, threadLastVisitedAtById: {}, }; const persistedExpandedProjectCwds = new Set(); const persistedProjectOrderCwds: string[] = []; +const persistedPinnedProjectCwds = new Set(); const currentProjectCwdById = new Map(); let legacyKeysCleanedUp = false; @@ -80,6 +84,7 @@ function readPersistedState(): UiState { function hydratePersistedProjectState(parsed: PersistedUiState): void { persistedExpandedProjectCwds.clear(); persistedProjectOrderCwds.length = 0; + persistedPinnedProjectCwds.clear(); for (const cwd of parsed.expandedProjectCwds ?? []) { if (typeof cwd === "string" && cwd.length > 0) { persistedExpandedProjectCwds.add(cwd); @@ -90,6 +95,11 @@ function hydratePersistedProjectState(parsed: PersistedUiState): void { persistedProjectOrderCwds.push(cwd); } } + for (const cwd of parsed.pinnedProjectCwds ?? []) { + if (typeof cwd === "string" && cwd.length > 0) { + persistedPinnedProjectCwds.add(cwd); + } + } } function persistState(state: UiState): void { @@ -107,11 +117,18 @@ function persistState(state: UiState): void { const cwd = currentProjectCwdById.get(projectId); return cwd ? [cwd] : []; }); + const pinnedProjectCwds = Object.entries(state.projectPinnedById) + .filter(([, pinned]) => pinned) + .flatMap(([projectId]) => { + const cwd = currentProjectCwdById.get(projectId as ProjectId); + return cwd ? [cwd] : []; + }); window.localStorage.setItem( PERSISTED_STATE_KEY, JSON.stringify({ expandedProjectCwds, projectOrderCwds, + pinnedProjectCwds, } satisfies PersistedUiState), ); if (!legacyKeysCleanedUp) { @@ -161,7 +178,9 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput projects.some((project) => previousProjectCwdById.get(project.id) !== project.cwd); const nextExpandedById: Record = {}; + const nextPinnedById: Record = {}; const previousExpandedById = state.projectExpandedById; + const previousPinnedById = state.projectPinnedById; const persistedOrderByCwd = new Map( persistedProjectOrderCwds.map((cwd, index) => [cwd, index] as const), ); @@ -173,7 +192,14 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput (persistedExpandedProjectCwds.size > 0 ? persistedExpandedProjectCwds.has(project.cwd) : true); + const pinned = + previousPinnedById[project.id] ?? + (previousProjectIdForCwd ? previousPinnedById[previousProjectIdForCwd] : undefined) ?? + persistedPinnedProjectCwds.has(project.cwd); nextExpandedById[project.id] = expanded; + if (pinned) { + nextPinnedById[project.id] = true; + } return { id: project.id, cwd: project.cwd, @@ -233,6 +259,7 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput if ( recordsEqual(state.projectExpandedById, nextExpandedById) && projectOrdersEqual(state.projectOrder, nextProjectOrder) && + recordsEqual(state.projectPinnedById, nextPinnedById) && !cwdMappingChanged ) { return state; @@ -242,6 +269,7 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput ...state, projectExpandedById: nextExpandedById, projectOrder: nextProjectOrder, + projectPinnedById: nextPinnedById, }; } @@ -381,6 +409,22 @@ export function reorderProjects( }; } +export function toggleProjectPinned(state: UiState, projectId: ProjectId): UiState { + const nextProjectPinnedById = { ...state.projectPinnedById }; + if (nextProjectPinnedById[projectId]) { + delete nextProjectPinnedById[projectId]; + } else { + nextProjectPinnedById[projectId] = true; + } + if (recordsEqual(state.projectPinnedById, nextProjectPinnedById)) { + return state; + } + return { + ...state, + projectPinnedById: nextProjectPinnedById, + }; +} + interface UiStateStore extends UiState { syncProjects: (projects: readonly SyncProjectInput[]) => void; syncThreads: (threads: readonly SyncThreadInput[]) => void; @@ -388,6 +432,7 @@ interface UiStateStore extends UiState { markThreadUnread: (threadId: ThreadId, latestTurnCompletedAt: string | null | undefined) => void; clearThreadUi: (threadId: ThreadId) => void; toggleProject: (projectId: ProjectId) => void; + toggleProjectPinned: (projectId: ProjectId) => void; setProjectExpanded: (projectId: ProjectId, expanded: boolean) => void; reorderProjects: (draggedProjectId: ProjectId, targetProjectId: ProjectId) => void; } @@ -402,6 +447,7 @@ export const useUiStateStore = create((set) => ({ set((state) => markThreadUnread(state, threadId, latestTurnCompletedAt)), clearThreadUi: (threadId) => set((state) => clearThreadUi(state, threadId)), toggleProject: (projectId) => set((state) => toggleProject(state, projectId)), + toggleProjectPinned: (projectId) => set((state) => toggleProjectPinned(state, projectId)), setProjectExpanded: (projectId, expanded) => set((state) => setProjectExpanded(state, projectId, expanded)), reorderProjects: (draggedProjectId, targetProjectId) => From d32da3cb9a321a4944ef4cfed2dcddbe436de80e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 30 Mar 2026 13:30:06 -0700 Subject: [PATCH 2/8] Prioritize pinned threads in sidebar sorting - Sort pinned threads ahead of unpinned ones in auto-sort modes - Persist thread pin state and surface pin/unpin actions in the sidebar - Keep delete fallback selection aware of pinned threads --- apps/web/src/components/Sidebar.logic.test.ts | 102 ++++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 6 ++ apps/web/src/components/Sidebar.tsx | 51 ++++++--- apps/web/src/hooks/useThreadActions.ts | 6 +- apps/web/src/uiStateStore.test.ts | 34 ++++++ apps/web/src/uiStateStore.ts | 51 ++++++++- 6 files changed, 235 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 7947b8ad3b..34e441f553 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -760,6 +760,73 @@ describe("sortThreadsForSidebar", () => { ThreadId.makeUnsafe("thread-2"), ]); }); + + it("puts pinned threads ahead of unpinned threads in auto-sort modes", () => { + const sorted = sortThreadsForSidebar( + [ + { + ...makeThread({ + id: ThreadId.makeUnsafe("thread-1"), + createdAt: "2026-03-09T10:00:00.000Z", + updatedAt: "2026-03-09T10:01:00.000Z", + }), + pinned: true, + }, + { + ...makeThread({ + id: ThreadId.makeUnsafe("thread-2"), + createdAt: "2026-03-09T10:05:00.000Z", + updatedAt: "2026-03-09T10:05:00.000Z", + }), + pinned: false, + }, + ], + "updated_at", + ); + + expect(sorted.map((thread) => thread.id)).toEqual([ + ThreadId.makeUnsafe("thread-1"), + ThreadId.makeUnsafe("thread-2"), + ]); + }); + + it("still auto-sorts within the pinned thread bucket", () => { + const sorted = sortThreadsForSidebar( + [ + { + ...makeThread({ + id: ThreadId.makeUnsafe("thread-1"), + createdAt: "2026-03-09T10:01:00.000Z", + updatedAt: "2026-03-09T10:01:00.000Z", + }), + pinned: true, + }, + { + ...makeThread({ + id: ThreadId.makeUnsafe("thread-2"), + createdAt: "2026-03-09T10:05:00.000Z", + updatedAt: "2026-03-09T10:05:00.000Z", + }), + pinned: true, + }, + { + ...makeThread({ + id: ThreadId.makeUnsafe("thread-3"), + createdAt: "2026-03-09T10:10:00.000Z", + updatedAt: "2026-03-09T10:10:00.000Z", + }), + pinned: false, + }, + ], + "updated_at", + ); + + expect(sorted.map((thread) => thread.id)).toEqual([ + ThreadId.makeUnsafe("thread-2"), + ThreadId.makeUnsafe("thread-1"), + ThreadId.makeUnsafe("thread-3"), + ]); + }); }); describe("getFallbackThreadIdAfterDelete", () => { @@ -798,6 +865,41 @@ describe("getFallbackThreadIdAfterDelete", () => { expect(fallbackThreadId).toBe(ThreadId.makeUnsafe("thread-newest")); }); + it("respects pinning when choosing the fallback thread after delete", () => { + const fallbackThreadId = getFallbackThreadIdAfterDelete({ + threads: [ + { + ...makeThread({ + id: ThreadId.makeUnsafe("thread-active"), + projectId: ProjectId.makeUnsafe("project-1"), + updatedAt: "2026-03-09T10:05:00.000Z", + }), + pinned: false, + }, + { + ...makeThread({ + id: ThreadId.makeUnsafe("thread-pinned"), + projectId: ProjectId.makeUnsafe("project-1"), + updatedAt: "2026-03-09T10:03:00.000Z", + }), + pinned: true, + }, + { + ...makeThread({ + id: ThreadId.makeUnsafe("thread-newer"), + projectId: ProjectId.makeUnsafe("project-1"), + updatedAt: "2026-03-09T10:10:00.000Z", + }), + pinned: false, + }, + ], + deletedThreadId: ThreadId.makeUnsafe("thread-active"), + sortOrder: "updated_at", + }); + + expect(fallbackThreadId).toBe(ThreadId.makeUnsafe("thread-pinned")); + }); + it("skips other threads being deleted in the same action", () => { const fallbackThreadId = getFallbackThreadIdAfterDelete({ threads: [ diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 5b36a930e6..7e0a31ce36 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -21,6 +21,7 @@ type SidebarProject = { type SidebarThreadSortInput = Pick & { latestUserMessageAt?: string | null; messages?: Pick[]; + pinned?: boolean | undefined; }; export type ThreadTraversalDirection = "previous" | "next"; @@ -453,6 +454,11 @@ export function sortThreadsForSidebar< T extends Pick & SidebarThreadSortInput, >(threads: readonly T[], sortOrder: SidebarThreadSortOrder): T[] { return threads.toSorted((left, right) => { + const leftPinned = left.pinned === true; + const rightPinned = right.pinned === true; + if (leftPinned !== rightPinned) { + return rightPinned ? 1 : -1; + } const rightTimestamp = getThreadSortTimestamp(right, sortOrder); const leftTimestamp = getThreadSortTimestamp(left, sortOrder); const byTimestamp = diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 670854d5db..0afa6429e7 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -162,6 +162,7 @@ type SidebarThreadSnapshot = Pick< > & { lastVisitedAt?: string | undefined; latestUserMessageAt: string | null; + pinned: boolean; }; type SidebarProjectSnapshot = Project & { @@ -192,9 +193,10 @@ function getLatestUserMessageAt(thread: Thread): string | null { function toSidebarThreadSnapshot( thread: Thread, lastVisitedAt: string | undefined, + pinned: boolean, ): SidebarThreadSnapshot { const cached = sidebarThreadSnapshotCache.get(thread); - if (cached && cached.lastVisitedAt === lastVisitedAt) { + if (cached && cached.lastVisitedAt === lastVisitedAt && cached.snapshot.pinned === pinned) { return cached.snapshot; } @@ -214,6 +216,7 @@ function toSidebarThreadSnapshot( activities: thread.activities, proposedPlans: thread.proposedPlans, latestUserMessageAt: getLatestUserMessageAt(thread), + pinned, }; sidebarThreadSnapshotCache.set(thread, { lastVisitedAt, snapshot }); return snapshot; @@ -442,16 +445,23 @@ function SortableProjectItem({ export default function Sidebar() { const projects = useStore((store) => store.projects); const serverThreads = useStore((store) => store.threads); - const { projectExpandedById, projectOrder, projectPinnedById, threadLastVisitedAtById } = - useUiStateStore( - useShallow((store) => ({ - projectExpandedById: store.projectExpandedById, - projectOrder: store.projectOrder, - projectPinnedById: store.projectPinnedById, - threadLastVisitedAtById: store.threadLastVisitedAtById, - })), - ); + const { + projectExpandedById, + projectOrder, + projectPinnedById, + threadLastVisitedAtById, + threadPinnedById, + } = useUiStateStore( + useShallow((store) => ({ + projectExpandedById: store.projectExpandedById, + projectOrder: store.projectOrder, + projectPinnedById: store.projectPinnedById, + threadLastVisitedAtById: store.threadLastVisitedAtById, + threadPinnedById: store.threadPinnedById, + })), + ); const markThreadUnread = useUiStateStore((store) => store.markThreadUnread); + const toggleThreadPinned = useUiStateStore((store) => store.toggleThreadPinned); const toggleProject = useUiStateStore((store) => store.toggleProject); const toggleProjectPinned = useUiStateStore((store) => store.toggleProjectPinned); const reorderProjects = useUiStateStore((store) => store.reorderProjects); @@ -527,9 +537,13 @@ export default function Sidebar() { const threads = useMemo( () => serverThreads.map((thread) => - toSidebarThreadSnapshot(thread, threadLastVisitedAtById[thread.id]), + toSidebarThreadSnapshot( + thread, + threadLastVisitedAtById[thread.id], + threadPinnedById[thread.id] ?? false, + ), ), - [serverThreads, threadLastVisitedAtById], + [serverThreads, threadLastVisitedAtById, threadPinnedById], ); const projectCwdById = useMemo( () => new Map(projects.map((project) => [project.id, project.cwd] as const)), @@ -848,6 +862,7 @@ export default function Sidebar() { thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null; const clicked = await api.contextMenu.show( [ + { id: "toggle-pin", label: threadPinnedById[threadId] ? "Unpin thread" : "Pin thread" }, { id: "rename", label: "Rename thread" }, { id: "mark-unread", label: "Mark unread" }, { id: "copy-path", label: "Copy Path" }, @@ -857,6 +872,11 @@ export default function Sidebar() { position, ); + if (clicked === "toggle-pin") { + toggleThreadPinned(threadId); + return; + } + if (clicked === "rename") { setRenamingThreadId(threadId); setRenamingTitle(thread.title); @@ -905,7 +925,9 @@ export default function Sidebar() { deleteThread, markThreadUnread, projectCwdById, + threadPinnedById, threads, + toggleThreadPinned, ], ); @@ -1459,6 +1481,11 @@ export default function Sidebar() { }} >
+ {thread.pinned ? ( + + + + ) : null} {prStatus && ( (); const shouldNavigateToFallback = routeThreadId === threadId; + const threadPinnedById = useUiStateStore.getState().threadPinnedById; const fallbackThreadId = getFallbackThreadIdAfterDelete({ - threads, + threads: threads.map((entry) => + Object.assign({}, entry, { pinned: threadPinnedById[entry.id] ?? false }), + ), deletedThreadId: threadId, deletedThreadIds, sortOrder: appSettings.sidebarThreadSortOrder, diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index 9e462dc8f6..c0b1f52f5d 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -9,6 +9,7 @@ import { syncProjects, syncThreads, toggleProjectPinned, + toggleThreadPinned, type UiState, } from "./uiStateStore"; @@ -18,6 +19,7 @@ function makeUiState(overrides: Partial = {}): UiState { projectOrder: [], projectPinnedById: {}, threadLastVisitedAtById: {}, + threadPinnedById: {}, ...overrides, }; } @@ -190,6 +192,23 @@ describe("uiStateStore pure functions", () => { }); }); + it("syncThreads preserves pinned state for retained threads", () => { + const thread1 = ThreadId.makeUnsafe("thread-1"); + const thread2 = ThreadId.makeUnsafe("thread-2"); + const initialState = makeUiState({ + threadPinnedById: { + [thread1]: true, + [thread2]: true, + }, + }); + + const next = syncThreads(initialState, [{ id: thread1 }]); + + expect(next.threadPinnedById).toEqual({ + [thread1]: true, + }); + }); + it("setProjectExpanded updates expansion without touching order", () => { const project1 = ProjectId.makeUnsafe("project-1"); const initialState = makeUiState({ @@ -216,16 +235,31 @@ describe("uiStateStore pure functions", () => { expect(unpinned.projectPinnedById).toEqual({}); }); + it("toggleThreadPinned adds and removes pinned state", () => { + const thread1 = ThreadId.makeUnsafe("thread-1"); + const pinned = toggleThreadPinned(makeUiState(), thread1); + + expect(pinned.threadPinnedById).toEqual({ [thread1]: true }); + + const unpinned = toggleThreadPinned(pinned, thread1); + + expect(unpinned.threadPinnedById).toEqual({}); + }); + it("clearThreadUi removes visit state for deleted threads", () => { const thread1 = ThreadId.makeUnsafe("thread-1"); const initialState = makeUiState({ threadLastVisitedAtById: { [thread1]: "2026-02-25T12:35:00.000Z", }, + threadPinnedById: { + [thread1]: true, + }, }); const next = clearThreadUi(initialState, thread1); expect(next.threadLastVisitedAtById).toEqual({}); + expect(next.threadPinnedById).toEqual({}); }); }); diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 0e2c54da45..c1370fcf91 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -20,6 +20,7 @@ interface PersistedUiState { expandedProjectCwds?: string[]; projectOrderCwds?: string[]; pinnedProjectCwds?: string[]; + pinnedThreadIds?: string[]; } export interface UiProjectState { @@ -30,6 +31,7 @@ export interface UiProjectState { export interface UiThreadState { threadLastVisitedAtById: Record; + threadPinnedById: Record; } export interface UiState extends UiProjectState, UiThreadState {} @@ -49,11 +51,13 @@ const initialState: UiState = { projectOrder: [], projectPinnedById: {}, threadLastVisitedAtById: {}, + threadPinnedById: {}, }; const persistedExpandedProjectCwds = new Set(); const persistedProjectOrderCwds: string[] = []; const persistedPinnedProjectCwds = new Set(); +const persistedPinnedThreadIds = new Set(); const currentProjectCwdById = new Map(); let legacyKeysCleanedUp = false; @@ -85,6 +89,7 @@ function hydratePersistedProjectState(parsed: PersistedUiState): void { persistedExpandedProjectCwds.clear(); persistedProjectOrderCwds.length = 0; persistedPinnedProjectCwds.clear(); + persistedPinnedThreadIds.clear(); for (const cwd of parsed.expandedProjectCwds ?? []) { if (typeof cwd === "string" && cwd.length > 0) { persistedExpandedProjectCwds.add(cwd); @@ -100,6 +105,11 @@ function hydratePersistedProjectState(parsed: PersistedUiState): void { persistedPinnedProjectCwds.add(cwd); } } + for (const threadId of parsed.pinnedThreadIds ?? []) { + if (typeof threadId === "string" && threadId.length > 0) { + persistedPinnedThreadIds.add(threadId as ThreadId); + } + } } function persistState(state: UiState): void { @@ -123,12 +133,16 @@ function persistState(state: UiState): void { const cwd = currentProjectCwdById.get(projectId as ProjectId); return cwd ? [cwd] : []; }); + const pinnedThreadIds = Object.entries(state.threadPinnedById) + .filter(([, pinned]) => pinned) + .map(([threadId]) => threadId); window.localStorage.setItem( PERSISTED_STATE_KEY, JSON.stringify({ expandedProjectCwds, projectOrderCwds, pinnedProjectCwds, + pinnedThreadIds, } satisfies PersistedUiState), ); if (!legacyKeysCleanedUp) { @@ -280,6 +294,11 @@ export function syncThreads(state: UiState, threads: readonly SyncThreadInput[]) retainedThreadIds.has(threadId as ThreadId), ), ); + const nextThreadPinnedById = Object.fromEntries( + Object.entries(state.threadPinnedById).filter(([threadId]) => + retainedThreadIds.has(threadId as ThreadId), + ), + ); for (const thread of threads) { if ( nextThreadLastVisitedAtById[thread.id] === undefined && @@ -288,13 +307,20 @@ export function syncThreads(state: UiState, threads: readonly SyncThreadInput[]) ) { nextThreadLastVisitedAtById[thread.id] = thread.seedVisitedAt; } + if (nextThreadPinnedById[thread.id] === undefined && persistedPinnedThreadIds.has(thread.id)) { + nextThreadPinnedById[thread.id] = true; + } } - if (recordsEqual(state.threadLastVisitedAtById, nextThreadLastVisitedAtById)) { + if ( + recordsEqual(state.threadLastVisitedAtById, nextThreadLastVisitedAtById) && + recordsEqual(state.threadPinnedById, nextThreadPinnedById) + ) { return state; } return { ...state, threadLastVisitedAtById: nextThreadLastVisitedAtById, + threadPinnedById: nextThreadPinnedById, }; } @@ -345,14 +371,33 @@ export function markThreadUnread( } export function clearThreadUi(state: UiState, threadId: ThreadId): UiState { - if (!(threadId in state.threadLastVisitedAtById)) { + if (!(threadId in state.threadLastVisitedAtById) && !(threadId in state.threadPinnedById)) { return state; } const nextThreadLastVisitedAtById = { ...state.threadLastVisitedAtById }; + const nextThreadPinnedById = { ...state.threadPinnedById }; delete nextThreadLastVisitedAtById[threadId]; + delete nextThreadPinnedById[threadId]; return { ...state, threadLastVisitedAtById: nextThreadLastVisitedAtById, + threadPinnedById: nextThreadPinnedById, + }; +} + +export function toggleThreadPinned(state: UiState, threadId: ThreadId): UiState { + const nextThreadPinnedById = { ...state.threadPinnedById }; + if (nextThreadPinnedById[threadId]) { + delete nextThreadPinnedById[threadId]; + } else { + nextThreadPinnedById[threadId] = true; + } + if (recordsEqual(state.threadPinnedById, nextThreadPinnedById)) { + return state; + } + return { + ...state, + threadPinnedById: nextThreadPinnedById, }; } @@ -431,6 +476,7 @@ interface UiStateStore extends UiState { markThreadVisited: (threadId: ThreadId, visitedAt?: string) => void; markThreadUnread: (threadId: ThreadId, latestTurnCompletedAt: string | null | undefined) => void; clearThreadUi: (threadId: ThreadId) => void; + toggleThreadPinned: (threadId: ThreadId) => void; toggleProject: (projectId: ProjectId) => void; toggleProjectPinned: (projectId: ProjectId) => void; setProjectExpanded: (projectId: ProjectId, expanded: boolean) => void; @@ -446,6 +492,7 @@ export const useUiStateStore = create((set) => ({ markThreadUnread: (threadId, latestTurnCompletedAt) => set((state) => markThreadUnread(state, threadId, latestTurnCompletedAt)), clearThreadUi: (threadId) => set((state) => clearThreadUi(state, threadId)), + toggleThreadPinned: (threadId) => set((state) => toggleThreadPinned(state, threadId)), toggleProject: (projectId) => set((state) => toggleProject(state, projectId)), toggleProjectPinned: (projectId) => set((state) => toggleProjectPinned(state, projectId)), setProjectExpanded: (projectId, expanded) => From 3083d30b1cc67074f96036c3b05e5beec9d7d451 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 30 Mar 2026 14:13:22 -0700 Subject: [PATCH 3/8] Add pin support to orchestration and persistence - Thread and project pin state now flows through decider, projector, snapshots, and SQL persistence - Updates UI, contracts, and tests to cover pinned read models and mutations --- .../orchestrationEngine.integration.test.ts | 4 + .../Layers/CheckpointDiffQuery.test.ts | 2 + .../Layers/CheckpointReactor.test.ts | 2 + .../Layers/OrchestrationEngine.test.ts | 20 ++ .../Layers/ProjectionPipeline.test.ts | 2 + .../Layers/ProjectionPipeline.ts | 4 + .../Layers/ProjectionSnapshotQuery.test.ts | 2 + .../Layers/ProjectionSnapshotQuery.ts | 22 ++- .../Layers/ProviderCommandReactor.test.ts | 2 + .../Layers/ProviderRuntimeIngestion.test.ts | 7 + .../orchestration/commandInvariants.test.ts | 6 + .../decider.projectScripts.test.ts | 89 ++++++++- apps/server/src/orchestration/decider.ts | 4 + .../src/orchestration/projector.test.ts | 94 ++++++++++ apps/server/src/orchestration/projector.ts | 4 + .../persistence/Layers/ProjectionProjects.ts | 16 +- .../Layers/ProjectionRepositories.test.ts | 4 + .../persistence/Layers/ProjectionThreads.ts | 16 +- apps/server/src/persistence/Migrations.ts | 2 + .../persistence/Migrations/005_Projections.ts | 2 + .../Migrations/019_ProjectionPins.ts | 26 +++ .../Services/ProjectionProjects.ts | 1 + .../persistence/Services/ProjectionThreads.ts | 1 + apps/server/src/wsServer.test.ts | 4 + apps/server/src/wsServer.ts | 2 + apps/web/src/components/ChatView.browser.tsx | 4 + .../web/src/components/ChatView.logic.test.ts | 4 + apps/web/src/components/ChatView.logic.ts | 1 + apps/web/src/components/ChatView.tsx | 2 + .../components/KeybindingsToast.browser.tsx | 2 + apps/web/src/components/Sidebar.logic.test.ts | 6 +- apps/web/src/components/Sidebar.tsx | 172 +++++++++++------- apps/web/src/hooks/useThreadActions.ts | 6 +- .../web/src/orchestrationEventEffects.test.ts | 2 + apps/web/src/store.test.ts | 9 + apps/web/src/store.ts | 6 + apps/web/src/types.ts | 2 + apps/web/src/uiStateStore.test.ts | 73 -------- apps/web/src/uiStateStore.ts | 97 +--------- apps/web/src/worktreeCleanup.test.ts | 1 + apps/web/src/wsNativeApi.test.ts | 2 + packages/contracts/src/orchestration.test.ts | 3 + packages/contracts/src/orchestration.ts | 10 + 43 files changed, 492 insertions(+), 248 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/019_ProjectionPins.ts diff --git a/apps/server/integration/orchestrationEngine.integration.test.ts b/apps/server/integration/orchestrationEngine.integration.test.ts index d6b1004749..7360df8511 100644 --- a/apps/server/integration/orchestrationEngine.integration.test.ts +++ b/apps/server/integration/orchestrationEngine.integration.test.ts @@ -116,6 +116,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => projectId: PROJECT_ID, title: "Integration Project", workspaceRoot: harness.workspaceDir, + pinned: false, defaultModelSelection: { provider, model: defaultModel, @@ -129,6 +130,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => threadId: THREAD_ID, projectId: PROJECT_ID, title: "Integration Thread", + pinned: false, modelSelection: { provider, model: defaultModel, @@ -265,6 +267,7 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( projectId: PROJECT_ID, title: "Integration Project", workspaceRoot: harness.workspaceDir, + pinned: false, defaultModelSelection: { provider: "codex", model: "gpt-5.3-codex", @@ -278,6 +281,7 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( threadId: THREAD_ID, projectId: PROJECT_ID, title: "Integration Thread", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5.3-codex", diff --git a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts index 9fb2500ce4..6a9ff24bb0 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts @@ -31,6 +31,7 @@ function makeSnapshot(input: { id: input.projectId, title: "Project", workspaceRoot: input.workspaceRoot, + pinned: false, defaultModelSelection: null, scripts: [], createdAt: "2026-01-01T00:00:00.000Z", @@ -43,6 +44,7 @@ function makeSnapshot(input: { id: input.threadId, projectId: input.projectId, title: "Thread", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 075f62f889..2e18356ad9 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -282,6 +282,7 @@ describe("CheckpointReactor", () => { projectId: asProjectId("project-1"), title: "Test Project", workspaceRoot: options?.projectWorkspaceRoot ?? cwd, + pinned: false, defaultModelSelection: { provider: "codex", model: "gpt-5-codex", @@ -296,6 +297,7 @@ describe("CheckpointReactor", () => { threadId: ThreadId.makeUnsafe("thread-1"), projectId: asProjectId("project-1"), title: "Thread", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 6aa889991e..6df6e2481b 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -72,6 +72,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-1"), title: "Project 1", workspaceRoot: "/tmp/project-1", + pinned: false, defaultModelSelection: { provider: "codex", model: "gpt-5-codex", @@ -86,6 +87,7 @@ describe("OrchestrationEngine", () => { threadId: ThreadId.makeUnsafe("thread-1"), projectId: asProjectId("project-1"), title: "Thread", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -132,6 +134,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-archive"), title: "Project Archive", workspaceRoot: "/tmp/project-archive", + pinned: false, defaultModelSelection: { provider: "codex", model: "gpt-5-codex", @@ -146,6 +149,7 @@ describe("OrchestrationEngine", () => { threadId: ThreadId.makeUnsafe("thread-archive"), projectId: asProjectId("project-archive"), title: "Archive me", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -199,6 +203,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-replay"), title: "Replay Project", workspaceRoot: "/tmp/project-replay", + pinned: false, defaultModelSelection: { provider: "codex", model: "gpt-5-codex", @@ -213,6 +218,7 @@ describe("OrchestrationEngine", () => { threadId: ThreadId.makeUnsafe("thread-replay"), projectId: asProjectId("project-replay"), title: "replay", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -257,6 +263,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-stream"), title: "Stream Project", workspaceRoot: "/tmp/project-stream", + pinned: false, defaultModelSelection: { provider: "codex", model: "gpt-5-codex", @@ -281,6 +288,7 @@ describe("OrchestrationEngine", () => { threadId: ThreadId.makeUnsafe("thread-stream"), projectId: asProjectId("project-stream"), title: "domain-stream", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -318,6 +326,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-turn-diff"), title: "Turn Diff Project", workspaceRoot: "/tmp/project-turn-diff", + pinned: false, defaultModelSelection: { provider: "codex", model: "gpt-5-codex", @@ -332,6 +341,7 @@ describe("OrchestrationEngine", () => { threadId: ThreadId.makeUnsafe("thread-turn-diff"), projectId: asProjectId("project-turn-diff"), title: "Turn diff thread", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -435,6 +445,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-flaky"), title: "Flaky Project", workspaceRoot: "/tmp/project-flaky", + pinned: false, defaultModelSelection: { provider: "codex", model: "gpt-5-codex", @@ -451,6 +462,7 @@ describe("OrchestrationEngine", () => { threadId: ThreadId.makeUnsafe("thread-flaky-fail"), projectId: asProjectId("project-flaky"), title: "flaky-fail", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -471,6 +483,7 @@ describe("OrchestrationEngine", () => { threadId: ThreadId.makeUnsafe("thread-flaky-ok"), projectId: asProjectId("project-flaky"), title: "flaky-ok", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -528,6 +541,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-atomic"), title: "Atomic Project", workspaceRoot: "/tmp/project-atomic", + pinned: false, defaultModelSelection: { provider: "codex", model: "gpt-5-codex", @@ -542,6 +556,7 @@ describe("OrchestrationEngine", () => { threadId: ThreadId.makeUnsafe("thread-atomic"), projectId: asProjectId("project-atomic"), title: "atomic", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -669,6 +684,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-sync"), title: "Sync Project", workspaceRoot: "/tmp/project-sync", + pinned: false, defaultModelSelection: { provider: "codex", model: "gpt-5-codex", @@ -683,6 +699,7 @@ describe("OrchestrationEngine", () => { threadId: ThreadId.makeUnsafe("thread-sync"), projectId: asProjectId("project-sync"), title: "sync-before", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -754,6 +771,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-duplicate"), title: "Duplicate Project", workspaceRoot: "/tmp/project-duplicate", + pinned: false, defaultModelSelection: { provider: "codex", model: "gpt-5-codex", @@ -769,6 +787,7 @@ describe("OrchestrationEngine", () => { threadId: ThreadId.makeUnsafe("thread-duplicate"), projectId: asProjectId("project-duplicate"), title: "duplicate", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -789,6 +808,7 @@ describe("OrchestrationEngine", () => { threadId: ThreadId.makeUnsafe("thread-duplicate"), projectId: asProjectId("project-duplicate"), title: "duplicate", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 77b5d4d619..f59d7f1c94 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -1867,6 +1867,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { projectId: ProjectId.makeUnsafe("project-live"), title: "Live Project", workspaceRoot: "/tmp/project-live", + pinned: false, defaultModelSelection: { provider: "codex", model: "gpt-5-codex", @@ -1905,6 +1906,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { projectId: ProjectId.makeUnsafe("project-scripts"), title: "Scripts Project", workspaceRoot: "/tmp/project-scripts", + pinned: false, defaultModelSelection: { provider: "codex", model: "gpt-5-codex", diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index f0f2ccabee..358c3e1a1c 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -383,6 +383,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti projectId: event.payload.projectId, title: event.payload.title, workspaceRoot: event.payload.workspaceRoot, + pinned: event.payload.pinned, defaultModelSelection: event.payload.defaultModelSelection, scripts: event.payload.scripts, createdAt: event.payload.createdAt, @@ -404,6 +405,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...(event.payload.workspaceRoot !== undefined ? { workspaceRoot: event.payload.workspaceRoot } : {}), + ...(event.payload.pinned !== undefined ? { pinned: event.payload.pinned } : {}), ...(event.payload.defaultModelSelection !== undefined ? { defaultModelSelection: event.payload.defaultModelSelection } : {}), @@ -442,6 +444,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti threadId: event.payload.threadId, projectId: event.payload.projectId, title: event.payload.title, + pinned: event.payload.pinned, modelSelection: event.payload.modelSelection, runtimeMode: event.payload.runtimeMode, interactionMode: event.payload.interactionMode, @@ -495,6 +498,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti yield* projectionThreadRepository.upsert({ ...existingRow.value, ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), + ...(event.payload.pinned !== undefined ? { pinned: event.payload.pinned } : {}), ...(event.payload.modelSelection !== undefined ? { modelSelection: event.payload.modelSelection } : {}), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 32143d751f..dd256b5214 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -234,6 +234,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { id: asProjectId("project-1"), title: "Project 1", workspaceRoot: "/tmp/project-1", + pinned: false, defaultModelSelection: { provider: "codex", model: "gpt-5-codex", @@ -257,6 +258,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { id: ThreadId.makeUnsafe("thread-1"), projectId: asProjectId("project-1"), title: "Thread 1", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index f951c54b5b..38b5188a9b 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -19,7 +19,7 @@ import { type OrchestrationThreadActivity, ModelSelection, } from "@t3tools/contracts"; -import { Effect, Layer, Schema, Struct } from "effect"; +import { Effect, Layer, Schema, SchemaTransformation, Struct } from "effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; @@ -46,6 +46,15 @@ import { const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel); const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( Struct.assign({ + pinned: Schema.Number.pipe( + Schema.decodeTo( + Schema.Boolean, + SchemaTransformation.transformOrFail({ + decode: (value) => Effect.succeed(value !== 0), + encode: (value) => Effect.succeed(value ? 1 : 0), + }), + ), + ), defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), }), @@ -59,6 +68,15 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( Struct.assign({ + pinned: Schema.Number.pipe( + Schema.decodeTo( + Schema.Boolean, + SchemaTransformation.transformOrFail({ + decode: (value) => Effect.succeed(value !== 0), + encode: (value) => Effect.succeed(value ? 1 : 0), + }), + ), + ), modelSelection: Schema.fromJsonString(ModelSelection), }), ); @@ -542,6 +560,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { id: row.projectId, title: row.title, workspaceRoot: row.workspaceRoot, + pinned: row.pinned, defaultModelSelection: row.defaultModelSelection, scripts: row.scripts, createdAt: row.createdAt, @@ -553,6 +572,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { id: row.threadId, projectId: row.projectId, title: row.title, + pinned: row.pinned, modelSelection: row.modelSelection, runtimeMode: row.runtimeMode, interactionMode: row.interactionMode, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index ed7037695f..0d7e1f7123 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -245,6 +245,7 @@ describe("ProviderCommandReactor", () => { projectId: asProjectId("project-1"), title: "Provider Project", workspaceRoot: "/tmp/provider-project", + pinned: false, defaultModelSelection: modelSelection, createdAt: now, }), @@ -256,6 +257,7 @@ describe("ProviderCommandReactor", () => { threadId: ThreadId.makeUnsafe("thread-1"), projectId: asProjectId("project-1"), title: "Thread", + pinned: false, modelSelection: modelSelection, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 529eae2444..5f8e134e7a 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -225,6 +225,7 @@ describe("ProviderRuntimeIngestion", () => { projectId: asProjectId("project-1"), title: "Provider Project", workspaceRoot, + pinned: false, defaultModelSelection: { provider: "codex", model: "gpt-5-codex", @@ -239,6 +240,7 @@ describe("ProviderRuntimeIngestion", () => { threadId: ThreadId.makeUnsafe("thread-1"), projectId: asProjectId("project-1"), title: "Thread", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -768,6 +770,7 @@ describe("ProviderRuntimeIngestion", () => { threadId: sourceThreadId, projectId: asProjectId("project-1"), title: "Plan Source", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -803,6 +806,7 @@ describe("ProviderRuntimeIngestion", () => { threadId: targetThreadId, projectId: asProjectId("project-1"), title: "Plan Target", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -955,6 +959,7 @@ describe("ProviderRuntimeIngestion", () => { threadId: sourceThreadId, projectId: asProjectId("project-1"), title: "Plan Source", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -1108,6 +1113,7 @@ describe("ProviderRuntimeIngestion", () => { threadId: sourceThreadId, projectId: asProjectId("project-1"), title: "Plan Source", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -1143,6 +1149,7 @@ describe("ProviderRuntimeIngestion", () => { threadId: targetThreadId, projectId: asProjectId("project-1"), title: "Plan Target", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index 43d665a2c9..9ba7f7e68b 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -28,6 +28,7 @@ const readModel: OrchestrationReadModel = { id: ProjectId.makeUnsafe("project-a"), title: "Project A", workspaceRoot: "/tmp/project-a", + pinned: false, defaultModelSelection: { provider: "codex", model: "gpt-5-codex", @@ -41,6 +42,7 @@ const readModel: OrchestrationReadModel = { id: ProjectId.makeUnsafe("project-b"), title: "Project B", workspaceRoot: "/tmp/project-b", + pinned: false, defaultModelSelection: { provider: "codex", model: "gpt-5-codex", @@ -56,6 +58,7 @@ const readModel: OrchestrationReadModel = { id: ThreadId.makeUnsafe("thread-1"), projectId: ProjectId.makeUnsafe("project-a"), title: "Thread A", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -79,6 +82,7 @@ const readModel: OrchestrationReadModel = { id: ThreadId.makeUnsafe("thread-2"), projectId: ProjectId.makeUnsafe("project-b"), title: "Thread B", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -158,6 +162,7 @@ describe("commandInvariants", () => { threadId: ThreadId.makeUnsafe("thread-3"), projectId: ProjectId.makeUnsafe("project-a"), title: "new", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -182,6 +187,7 @@ describe("commandInvariants", () => { threadId: ThreadId.makeUnsafe("thread-1"), projectId: ProjectId.makeUnsafe("project-a"), title: "dup", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", diff --git a/apps/server/src/orchestration/decider.projectScripts.test.ts b/apps/server/src/orchestration/decider.projectScripts.test.ts index 465865549b..92bf6809e6 100644 --- a/apps/server/src/orchestration/decider.projectScripts.test.ts +++ b/apps/server/src/orchestration/decider.projectScripts.test.ts @@ -29,6 +29,7 @@ describe("decider project scripts", () => { projectId: asProjectId("project-scripts"), title: "Scripts", workspaceRoot: "/tmp/scripts", + pinned: false, createdAt: now, }, readModel, @@ -37,7 +38,10 @@ describe("decider project scripts", () => { const event = Array.isArray(result) ? result[0] : result; expect(event.type).toBe("project.created"); - expect((event.payload as { scripts: unknown[] }).scripts).toEqual([]); + expect(event.payload).toMatchObject({ + pinned: false, + scripts: [], + }); }); it("propagates scripts in project.meta.update payload", async () => { @@ -59,6 +63,7 @@ describe("decider project scripts", () => { projectId: asProjectId("project-scripts"), title: "Scripts", workspaceRoot: "/tmp/scripts", + pinned: false, defaultModelSelection: null, scripts: [], createdAt: now, @@ -94,6 +99,82 @@ describe("decider project scripts", () => { expect((event.payload as { scripts?: unknown[] }).scripts).toEqual(scripts); }); + it("propagates pin state through thread create and meta update", async () => { + const now = new Date().toISOString(); + const initial = createEmptyReadModel(now); + const withProject = await Effect.runPromise( + projectEvent(initial, { + sequence: 1, + eventId: asEventId("evt-project-create-pin"), + aggregateKind: "project", + aggregateId: asProjectId("project-pin"), + type: "project.created", + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-project-create-pin"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-project-create-pin"), + metadata: {}, + payload: { + projectId: asProjectId("project-pin"), + title: "Project", + workspaceRoot: "/tmp/project-pin", + pinned: false, + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }), + ); + + const created = await Effect.runPromise( + decideOrchestrationCommand({ + command: { + type: "thread.create", + commandId: CommandId.makeUnsafe("cmd-thread-create-pin"), + threadId: ThreadId.makeUnsafe("thread-pin"), + projectId: asProjectId("project-pin"), + title: "Pinned thread", + pinned: true, + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + branch: null, + worktreePath: null, + createdAt: now, + }, + readModel: withProject, + }), + ); + const createdEvent = Array.isArray(created) ? created[0] : created; + expect(createdEvent.type).toBe("thread.created"); + expect(createdEvent.payload).toMatchObject({ pinned: true }); + + const withThread = await Effect.runPromise( + projectEvent(withProject, { + ...createdEvent, + sequence: 2, + }), + ); + const updated = await Effect.runPromise( + decideOrchestrationCommand({ + command: { + type: "thread.meta.update", + commandId: CommandId.makeUnsafe("cmd-thread-update-pin"), + threadId: ThreadId.makeUnsafe("thread-pin"), + pinned: false, + }, + readModel: withThread, + }), + ); + const updatedEvent = Array.isArray(updated) ? updated[0] : updated; + expect(updatedEvent.type).toBe("thread.meta-updated"); + expect(updatedEvent.payload).toMatchObject({ pinned: false }); + }); + it("emits user message and turn-start-requested events for thread.turn.start", async () => { const now = new Date().toISOString(); const initial = createEmptyReadModel(now); @@ -113,6 +194,7 @@ describe("decider project scripts", () => { projectId: asProjectId("project-1"), title: "Project", workspaceRoot: "/tmp/project", + pinned: false, defaultModelSelection: null, scripts: [], createdAt: now, @@ -136,6 +218,7 @@ describe("decider project scripts", () => { threadId: ThreadId.makeUnsafe("thread-1"), projectId: asProjectId("project-1"), title: "Thread", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -222,6 +305,7 @@ describe("decider project scripts", () => { projectId: asProjectId("project-1"), title: "Project", workspaceRoot: "/tmp/project", + pinned: false, defaultModelSelection: null, scripts: [], createdAt: now, @@ -245,6 +329,7 @@ describe("decider project scripts", () => { threadId: ThreadId.makeUnsafe("thread-1"), projectId: asProjectId("project-1"), title: "Thread", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -304,6 +389,7 @@ describe("decider project scripts", () => { projectId: asProjectId("project-1"), title: "Project", workspaceRoot: "/tmp/project", + pinned: false, defaultModelSelection: null, scripts: [], createdAt: now, @@ -327,6 +413,7 @@ describe("decider project scripts", () => { threadId: ThreadId.makeUnsafe("thread-1"), projectId: asProjectId("project-1"), title: "Thread", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 22f5bcb280..87e2fb5e5b 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -77,6 +77,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" projectId: command.projectId, title: command.title, workspaceRoot: command.workspaceRoot, + pinned: command.pinned ?? false, defaultModelSelection: command.defaultModelSelection ?? null, scripts: [], createdAt: command.createdAt, @@ -104,6 +105,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" projectId: command.projectId, ...(command.title !== undefined ? { title: command.title } : {}), ...(command.workspaceRoot !== undefined ? { workspaceRoot: command.workspaceRoot } : {}), + ...(command.pinned !== undefined ? { pinned: command.pinned } : {}), ...(command.defaultModelSelection !== undefined ? { defaultModelSelection: command.defaultModelSelection } : {}), @@ -158,6 +160,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" threadId: command.threadId, projectId: command.projectId, title: command.title, + pinned: command.pinned ?? false, modelSelection: command.modelSelection, runtimeMode: command.runtimeMode, interactionMode: command.interactionMode, @@ -254,6 +257,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" payload: { threadId: command.threadId, ...(command.title !== undefined ? { title: command.title } : {}), + ...(command.pinned !== undefined ? { pinned: command.pinned } : {}), ...(command.modelSelection !== undefined ? { modelSelection: command.modelSelection } : {}), diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index 3dcdd19250..0ac6f8bb0b 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -76,6 +76,7 @@ describe("orchestration projector", () => { id: "thread-1", projectId: "project-1", title: "demo", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -98,6 +99,99 @@ describe("orchestration projector", () => { ]); }); + it("applies pin updates for projects and threads", async () => { + const now = new Date().toISOString(); + const later = new Date(Date.parse(now) + 1_000).toISOString(); + const withProject = await Effect.runPromise( + projectEvent( + createEmptyReadModel(now), + makeEvent({ + sequence: 1, + type: "project.created", + aggregateKind: "project", + aggregateId: "project-1", + occurredAt: now, + commandId: "cmd-project-create", + payload: { + projectId: "project-1", + title: "demo", + workspaceRoot: "/tmp/project-1", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }), + ), + ); + const withPinnedProject = await Effect.runPromise( + projectEvent( + withProject, + makeEvent({ + sequence: 2, + type: "project.meta-updated", + aggregateKind: "project", + aggregateId: "project-1", + occurredAt: later, + commandId: "cmd-project-pin", + payload: { + projectId: "project-1", + pinned: true, + updatedAt: later, + }, + }), + ), + ); + expect(withPinnedProject.projects[0]?.pinned).toBe(true); + + const withThread = await Effect.runPromise( + projectEvent( + withPinnedProject, + makeEvent({ + sequence: 3, + type: "thread.created", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: now, + commandId: "cmd-thread-create", + payload: { + threadId: "thread-1", + projectId: "project-1", + title: "demo", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ), + ); + const withPinnedThread = await Effect.runPromise( + projectEvent( + withThread, + makeEvent({ + sequence: 4, + type: "thread.meta-updated", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: later, + commandId: "cmd-thread-pin", + payload: { + threadId: "thread-1", + pinned: true, + updatedAt: later, + }, + }), + ), + ); + expect(withPinnedThread.threads[0]?.pinned).toBe(true); + }); + it("fails when event payload cannot be decoded by runtime schema", async () => { const now = new Date().toISOString(); const model = createEmptyReadModel(now); diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index deb8a6d44d..4a51f8053e 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -183,6 +183,7 @@ export function projectEvent( id: payload.projectId, title: payload.title, workspaceRoot: payload.workspaceRoot, + pinned: payload.pinned, defaultModelSelection: payload.defaultModelSelection, scripts: payload.scripts, createdAt: payload.createdAt, @@ -213,6 +214,7 @@ export function projectEvent( ...(payload.workspaceRoot !== undefined ? { workspaceRoot: payload.workspaceRoot } : {}), + ...(payload.pinned !== undefined ? { pinned: payload.pinned } : {}), ...(payload.defaultModelSelection !== undefined ? { defaultModelSelection: payload.defaultModelSelection } : {}), @@ -254,6 +256,7 @@ export function projectEvent( id: payload.threadId, projectId: payload.projectId, title: payload.title, + pinned: payload.pinned, modelSelection: payload.modelSelection, runtimeMode: payload.runtimeMode, interactionMode: payload.interactionMode, @@ -320,6 +323,7 @@ export function projectEvent( ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { ...(payload.title !== undefined ? { title: payload.title } : {}), + ...(payload.pinned !== undefined ? { pinned: payload.pinned } : {}), ...(payload.modelSelection !== undefined ? { modelSelection: payload.modelSelection } : {}), diff --git a/apps/server/src/persistence/Layers/ProjectionProjects.ts b/apps/server/src/persistence/Layers/ProjectionProjects.ts index 7ff19f55ae..0b6ea12d89 100644 --- a/apps/server/src/persistence/Layers/ProjectionProjects.ts +++ b/apps/server/src/persistence/Layers/ProjectionProjects.ts @@ -1,6 +1,6 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import { Effect, Layer, Schema, Struct } from "effect"; +import { Effect, Layer, Schema, SchemaTransformation, Struct } from "effect"; import { ModelSelection, ProjectScript } from "@t3tools/contracts"; import { toPersistenceSqlError } from "../Errors.ts"; @@ -14,6 +14,15 @@ import { const ProjectionProjectDbRow = ProjectionProject.mapFields( Struct.assign({ + pinned: Schema.Number.pipe( + Schema.decodeTo( + Schema.Boolean, + SchemaTransformation.transformOrFail({ + decode: (value) => Effect.succeed(value !== 0), + encode: (value) => Effect.succeed(value ? 1 : 0), + }), + ), + ), defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), }), @@ -31,6 +40,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { project_id, title, workspace_root, + pinned, default_model_selection_json, scripts_json, created_at, @@ -41,6 +51,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { ${row.projectId}, ${row.title}, ${row.workspaceRoot}, + ${row.pinned ? 1 : 0}, ${row.defaultModelSelection !== null ? JSON.stringify(row.defaultModelSelection) : null}, ${JSON.stringify(row.scripts)}, ${row.createdAt}, @@ -51,6 +62,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { DO UPDATE SET title = excluded.title, workspace_root = excluded.workspace_root, + pinned = excluded.pinned, default_model_selection_json = excluded.default_model_selection_json, scripts_json = excluded.scripts_json, created_at = excluded.created_at, @@ -68,6 +80,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { project_id AS "projectId", title, workspace_root AS "workspaceRoot", + pinned, default_model_selection_json AS "defaultModelSelection", scripts_json AS "scripts", created_at AS "createdAt", @@ -87,6 +100,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { project_id AS "projectId", title, workspace_root AS "workspaceRoot", + pinned, default_model_selection_json AS "defaultModelSelection", scripts_json AS "scripts", created_at AS "createdAt", diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index b0e1774837..dc96e1ade9 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -27,6 +27,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { projectId: ProjectId.makeUnsafe("project-null-options"), title: "Null options project", workspaceRoot: "/tmp/project-null-options", + pinned: true, defaultModelSelection: { provider: "codex", model: "gpt-5.4", @@ -60,6 +61,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { const persisted = yield* projects.getById({ projectId: ProjectId.makeUnsafe("project-null-options"), }); + assert.strictEqual(Option.getOrNull(persisted)?.pinned, true); assert.deepStrictEqual(Option.getOrNull(persisted)?.defaultModelSelection, { provider: "codex", model: "gpt-5.4", @@ -76,6 +78,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { threadId: ThreadId.makeUnsafe("thread-null-options"), projectId: ProjectId.makeUnsafe("project-null-options"), title: "Null options thread", + pinned: true, modelSelection: { provider: "claudeAgent", model: "claude-opus-4-6", @@ -114,6 +117,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { const persisted = yield* threads.getById({ threadId: ThreadId.makeUnsafe("thread-null-options"), }); + assert.strictEqual(Option.getOrNull(persisted)?.pinned, true); assert.deepStrictEqual(Option.getOrNull(persisted)?.modelSelection, { provider: "claudeAgent", model: "claude-opus-4-6", diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 48dd51fdca..b28a123af4 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -1,6 +1,6 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import { Effect, Layer, Schema, Struct } from "effect"; +import { Effect, Layer, Schema, SchemaTransformation, Struct } from "effect"; import { toPersistenceSqlError } from "../Errors.ts"; import { @@ -15,6 +15,15 @@ import { ModelSelection } from "@t3tools/contracts"; const ProjectionThreadDbRow = ProjectionThread.mapFields( Struct.assign({ + pinned: Schema.Number.pipe( + Schema.decodeTo( + Schema.Boolean, + SchemaTransformation.transformOrFail({ + decode: (value) => Effect.succeed(value !== 0), + encode: (value) => Effect.succeed(value ? 1 : 0), + }), + ), + ), modelSelection: Schema.fromJsonString(ModelSelection), }), ); @@ -31,6 +40,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { thread_id, project_id, title, + pinned, model_selection_json, runtime_mode, interaction_mode, @@ -46,6 +56,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.threadId}, ${row.projectId}, ${row.title}, + ${row.pinned ? 1 : 0}, ${JSON.stringify(row.modelSelection)}, ${row.runtimeMode}, ${row.interactionMode}, @@ -61,6 +72,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { DO UPDATE SET project_id = excluded.project_id, title = excluded.title, + pinned = excluded.pinned, model_selection_json = excluded.model_selection_json, runtime_mode = excluded.runtime_mode, interaction_mode = excluded.interaction_mode, @@ -83,6 +95,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { thread_id AS "threadId", project_id AS "projectId", title, + pinned, model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", interaction_mode AS "interactionMode", @@ -107,6 +120,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { thread_id AS "threadId", project_id AS "projectId", title, + pinned, model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", interaction_mode AS "interactionMode", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index c759665f06..6827c42b20 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -31,6 +31,7 @@ import Migration0015 from "./Migrations/015_ProjectionTurnsSourceProposedPlan.ts import Migration0016 from "./Migrations/016_CanonicalizeModelSelections.ts"; import Migration0017 from "./Migrations/017_ProjectionThreadsArchivedAt.ts"; import Migration0018 from "./Migrations/018_ProjectionThreadsArchivedAtIndex.ts"; +import Migration0019 from "./Migrations/019_ProjectionPins.ts"; /** * Migration loader with all migrations defined inline. @@ -61,6 +62,7 @@ export const migrationEntries = [ [16, "CanonicalizeModelSelections", Migration0016], [17, "ProjectionThreadsArchivedAt", Migration0017], [18, "ProjectionThreadsArchivedAtIndex", Migration0018], + [19, "ProjectionPins", Migration0019], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/005_Projections.ts b/apps/server/src/persistence/Migrations/005_Projections.ts index c950da76a1..f92abf6d4a 100644 --- a/apps/server/src/persistence/Migrations/005_Projections.ts +++ b/apps/server/src/persistence/Migrations/005_Projections.ts @@ -9,6 +9,7 @@ export default Effect.gen(function* () { project_id TEXT PRIMARY KEY, title TEXT NOT NULL, workspace_root TEXT NOT NULL, + pinned INTEGER NOT NULL DEFAULT 0, default_model TEXT, scripts_json TEXT NOT NULL, created_at TEXT NOT NULL, @@ -22,6 +23,7 @@ export default Effect.gen(function* () { thread_id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, + pinned INTEGER NOT NULL DEFAULT 0, model TEXT NOT NULL, branch TEXT, worktree_path TEXT, diff --git a/apps/server/src/persistence/Migrations/019_ProjectionPins.ts b/apps/server/src/persistence/Migrations/019_ProjectionPins.ts new file mode 100644 index 0000000000..f2ecca3ce9 --- /dev/null +++ b/apps/server/src/persistence/Migrations/019_ProjectionPins.ts @@ -0,0 +1,26 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const projectColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_projects) + `; + if (!projectColumns.some((column) => column.name === "pinned")) { + yield* sql` + ALTER TABLE projection_projects + ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0 + `; + } + + const threadColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + if (!threadColumns.some((column) => column.name === "pinned")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0 + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionProjects.ts b/apps/server/src/persistence/Services/ProjectionProjects.ts index 996ffe6e7b..bb216dc7c9 100644 --- a/apps/server/src/persistence/Services/ProjectionProjects.ts +++ b/apps/server/src/persistence/Services/ProjectionProjects.ts @@ -16,6 +16,7 @@ export const ProjectionProject = Schema.Struct({ projectId: ProjectId, title: Schema.String, workspaceRoot: Schema.String, + pinned: Schema.Boolean, defaultModelSelection: Schema.NullOr(ModelSelection), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 59505c1253..fb6777331c 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -24,6 +24,7 @@ export const ProjectionThread = Schema.Struct({ threadId: ThreadId, projectId: ProjectId, title: Schema.String, + pinned: Schema.Boolean, modelSelection: ModelSelection, runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 0e06650fe6..8227a45718 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -1221,6 +1221,7 @@ describe("WebSocket Server", () => { projectId: "project-diff", title: "Diff Project", workspaceRoot, + pinned: false, defaultModelSelection: { provider: "codex", model: "gpt-5-codex", @@ -1234,6 +1235,7 @@ describe("WebSocket Server", () => { threadId: "thread-diff", projectId: "project-diff", title: "Diff Thread", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -1306,6 +1308,7 @@ describe("WebSocket Server", () => { projectId: "project-1", title: "WS Project", workspaceRoot, + pinned: false, defaultModelSelection: { provider: "codex", model: "gpt-5-codex", @@ -1319,6 +1322,7 @@ describe("WebSocket Server", () => { threadId: "thread-1", projectId: "project-1", title: "Thread 1", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 25f8158926..eee9e41c8b 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -675,6 +675,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< projectId: bootstrapProjectId, title: bootstrapProjectTitle, workspaceRoot: cwd, + pinned: false, defaultModelSelection: bootstrapProjectDefaultModelSelection, createdAt, }); @@ -698,6 +699,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< threadId, projectId: bootstrapProjectId, title: "New thread", + pinned: false, modelSelection: bootstrapProjectDefaultModelSelection, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "full-access", diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 20816d6935..1f39bd331e 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -237,6 +237,7 @@ function createSnapshotForTargetUser(options: { id: PROJECT_ID, title: "Project", workspaceRoot: "/repo/project", + pinned: false, defaultModelSelection: { provider: "codex", model: "gpt-5", @@ -252,6 +253,7 @@ function createSnapshotForTargetUser(options: { id: THREAD_ID, projectId: PROJECT_ID, title: "Browser test thread", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5", @@ -310,6 +312,7 @@ function addThreadToSnapshot( id: threadId, projectId: PROJECT_ID, title: "New thread", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5", @@ -357,6 +360,7 @@ function createThreadCreatedEvent(threadId: ThreadId, sequence: number): Orchest threadId, projectId: PROJECT_ID, title: "New thread", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5", diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 80c842d91f..f694f44385 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -89,6 +89,7 @@ const makeThread = (input?: { codexThreadId: null, projectId: ProjectId.makeUnsafe("project-1"), title: "Thread", + pinned: false, modelSelection: { provider: "codex" as const, model: "gpt-5.4" }, runtimeMode: "full-access" as const, interactionMode: "default" as const, @@ -247,6 +248,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { codexThreadId: null, projectId, title: "Thread", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5.4" }, runtimeMode: "full-access", interactionMode: "default", @@ -283,6 +285,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { codexThreadId: null, projectId, title: "Thread", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5.4" }, runtimeMode: "full-access", interactionMode: "default", @@ -328,6 +331,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { codexThreadId: null, projectId, title: "Thread", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5.4" }, runtimeMode: "full-access", interactionMode: "default", diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 1821c65ed9..acc2966655 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -26,6 +26,7 @@ export function buildLocalDraftThread( codexThreadId: null, projectId: draftThread.projectId, title: "New thread", + pinned: false, modelSelection: fallbackModelSelection, runtimeMode: draftThread.runtimeMode, interactionMode: draftThread.interactionMode, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7562f845e2..8cc60ae17b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2774,6 +2774,7 @@ export default function ChatView({ threadId }: ChatViewProps) { threadId: threadIdForSend, projectId: activeProject.id, title, + pinned: false, modelSelection: threadCreateModelSelection, runtimeMode, interactionMode, @@ -3221,6 +3222,7 @@ export default function ChatView({ threadId }: ChatViewProps) { threadId: nextThreadId, projectId: activeProject.id, title: nextThreadTitle, + pinned: false, modelSelection: nextThreadModelSelection, runtimeMode, interactionMode: "default", diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 224bd2f887..f20884d5e3 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -76,6 +76,7 @@ function createMinimalSnapshot(): OrchestrationReadModel { id: PROJECT_ID, title: "Project", workspaceRoot: "/repo/project", + pinned: false, defaultModelSelection: { provider: "codex", model: "gpt-5", @@ -91,6 +92,7 @@ function createMinimalSnapshot(): OrchestrationReadModel { id: THREAD_ID, projectId: PROJECT_ID, title: "Test thread", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5", diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 34e441f553..f71e3c533d 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -586,14 +586,13 @@ describe("getVisibleThreadsForProject", () => { }); }); -function makeProject( - overrides: Partial & { pinned?: boolean } = {}, -): Project & { pinned?: boolean } { +function makeProject(overrides: Partial = {}): Project { const { defaultModelSelection, ...rest } = overrides; return { id: ProjectId.makeUnsafe("project-1"), name: "Project", cwd: "/tmp/project", + pinned: false, defaultModelSelection: { provider: "codex", model: "gpt-5.4", @@ -612,6 +611,7 @@ function makeThread(overrides: Partial = {}): Thread { codexThreadId: null, projectId: ProjectId.makeUnsafe("project-1"), title: "Thread", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5.4", diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 0afa6429e7..13d7c74d78 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -153,6 +153,7 @@ type SidebarThreadSnapshot = Pick< | "id" | "interactionMode" | "latestTurn" + | "pinned" | "projectId" | "proposedPlans" | "session" @@ -162,13 +163,9 @@ type SidebarThreadSnapshot = Pick< > & { lastVisitedAt?: string | undefined; latestUserMessageAt: string | null; - pinned: boolean; }; -type SidebarProjectSnapshot = Project & { - expanded: boolean; - pinned: boolean; -}; +type SidebarProjectSnapshot = Project & { expanded: boolean }; const sidebarThreadSnapshotCache = new WeakMap< Thread, @@ -193,10 +190,9 @@ function getLatestUserMessageAt(thread: Thread): string | null { function toSidebarThreadSnapshot( thread: Thread, lastVisitedAt: string | undefined, - pinned: boolean, ): SidebarThreadSnapshot { const cached = sidebarThreadSnapshotCache.get(thread); - if (cached && cached.lastVisitedAt === lastVisitedAt && cached.snapshot.pinned === pinned) { + if (cached && cached.lastVisitedAt === lastVisitedAt) { return cached.snapshot; } @@ -210,13 +206,13 @@ function toSidebarThreadSnapshot( updatedAt: thread.updatedAt, archivedAt: thread.archivedAt, latestTurn: thread.latestTurn, + pinned: thread.pinned, lastVisitedAt, branch: thread.branch, worktreePath: thread.worktreePath, activities: thread.activities, proposedPlans: thread.proposedPlans, latestUserMessageAt: getLatestUserMessageAt(thread), - pinned, }; sidebarThreadSnapshotCache.set(thread, { lastVisitedAt, snapshot }); return snapshot; @@ -445,25 +441,15 @@ function SortableProjectItem({ export default function Sidebar() { const projects = useStore((store) => store.projects); const serverThreads = useStore((store) => store.threads); - const { - projectExpandedById, - projectOrder, - projectPinnedById, - threadLastVisitedAtById, - threadPinnedById, - } = useUiStateStore( + const { projectExpandedById, projectOrder, threadLastVisitedAtById } = useUiStateStore( useShallow((store) => ({ projectExpandedById: store.projectExpandedById, projectOrder: store.projectOrder, - projectPinnedById: store.projectPinnedById, threadLastVisitedAtById: store.threadLastVisitedAtById, - threadPinnedById: store.threadPinnedById, })), ); const markThreadUnread = useUiStateStore((store) => store.markThreadUnread); - const toggleThreadPinned = useUiStateStore((store) => store.toggleThreadPinned); const toggleProject = useUiStateStore((store) => store.toggleProject); - const toggleProjectPinned = useUiStateStore((store) => store.toggleProjectPinned); const reorderProjects = useUiStateStore((store) => store.reorderProjects); const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); const getDraftThreadByProjectId = useComposerDraftStore( @@ -530,20 +516,15 @@ export default function Sidebar() { orderedProjects.map((project) => ({ ...project, expanded: projectExpandedById[project.id] ?? true, - pinned: projectPinnedById[project.id] ?? false, })), - [orderedProjects, projectExpandedById, projectPinnedById], + [orderedProjects, projectExpandedById], ); const threads = useMemo( () => serverThreads.map((thread) => - toSidebarThreadSnapshot( - thread, - threadLastVisitedAtById[thread.id], - threadPinnedById[thread.id] ?? false, - ), + toSidebarThreadSnapshot(thread, threadLastVisitedAtById[thread.id]), ), - [serverThreads, threadLastVisitedAtById, threadPinnedById], + [serverThreads, threadLastVisitedAtById], ); const projectCwdById = useMemo( () => new Map(projects.map((project) => [project.id, project.cwd] as const)), @@ -695,6 +676,7 @@ export default function Sidebar() { projectId, title, workspaceRoot: cwd, + pinned: false, defaultModelSelection: { provider: "codex", model: DEFAULT_MODEL_BY_PROVIDER.codex, @@ -852,6 +834,42 @@ export default function Sidebar() { }); }, }); + const setThreadPinned = useCallback(async (thread: SidebarThreadSnapshot, pinned: boolean) => { + const api = readNativeApi(); + if (!api) return; + try { + await api.orchestration.dispatchCommand({ + type: "thread.meta.update", + commandId: newCommandId(), + threadId: thread.id, + pinned, + }); + } catch (error) { + toastManager.add({ + type: "error", + title: `${pinned ? "Pin" : "Unpin"} thread failed`, + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + }, []); + const setProjectPinned = useCallback(async (project: Project, pinned: boolean) => { + const api = readNativeApi(); + if (!api) return; + try { + await api.orchestration.dispatchCommand({ + type: "project.meta.update", + commandId: newCommandId(), + projectId: project.id, + pinned, + }); + } catch (error) { + toastManager.add({ + type: "error", + title: `${pinned ? "Pin" : "Unpin"} project failed`, + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + }, []); const handleThreadContextMenu = useCallback( async (threadId: ThreadId, position: { x: number; y: number }) => { const api = readNativeApi(); @@ -862,7 +880,7 @@ export default function Sidebar() { thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null; const clicked = await api.contextMenu.show( [ - { id: "toggle-pin", label: threadPinnedById[threadId] ? "Unpin thread" : "Pin thread" }, + { id: "toggle-pin", label: thread.pinned ? "Unpin thread" : "Pin thread" }, { id: "rename", label: "Rename thread" }, { id: "mark-unread", label: "Mark unread" }, { id: "copy-path", label: "Copy Path" }, @@ -873,7 +891,7 @@ export default function Sidebar() { ); if (clicked === "toggle-pin") { - toggleThreadPinned(threadId); + await setThreadPinned(thread, !thread.pinned); return; } @@ -925,9 +943,8 @@ export default function Sidebar() { deleteThread, markThreadUnread, projectCwdById, - threadPinnedById, threads, - toggleThreadPinned, + setThreadPinned, ], ); @@ -1048,7 +1065,7 @@ export default function Sidebar() { [ { id: "toggle-pin", - label: projectPinnedById[projectId] ? "Unpin project" : "Pin project", + label: project.pinned ? "Unpin project" : "Pin project", }, { id: "copy-path", label: "Copy Project Path" }, { id: "delete", label: "Remove project", destructive: true }, @@ -1056,7 +1073,7 @@ export default function Sidebar() { position, ); if (clicked === "toggle-pin") { - toggleProjectPinned(projectId); + await setProjectPinned(project, !project.pinned); return; } if (clicked === "copy-path") { @@ -1104,10 +1121,9 @@ export default function Sidebar() { clearProjectDraftThreadId, copyPathToClipboard, getDraftThreadByProjectId, - projectPinnedById, projects, + setProjectPinned, threads, - toggleProjectPinned, ], ); @@ -1426,6 +1442,11 @@ export default function Sidebar() { : !isThreadRunning ? "pointer-events-none transition-opacity duration-150 group-hover/menu-sub-item:opacity-0 group-focus-within/menu-sub-item:opacity-0" : "pointer-events-none"; + const hoverActionClassName = + "inline-flex size-5 cursor-pointer items-center justify-center text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"; + const hoverActionGroupClassName = + "pointer-events-none absolute top-1/2 right-1 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity duration-150 group-hover/menu-sub-item:pointer-events-auto group-hover/menu-sub-item:opacity-100 group-focus-within/menu-sub-item:pointer-events-auto group-focus-within/menu-sub-item:opacity-100"; + const pinActionLabel = thread.pinned ? "Unpin" : "Pin"; return ( Confirm - ) : !isThreadRunning ? ( - appSettings.confirmThreadArchive ? ( -
- -
- ) : ( + ) : null} + {!isConfirmingArchive ? ( +
+ + } + /> + {pinActionLabel} + + {!isThreadRunning ? ( + + { + event.preventDefault(); event.stopPropagation(); }} onClick={(event) => { event.preventDefault(); event.stopPropagation(); + if (appSettings.confirmThreadArchive) { + setConfirmingArchiveThreadId(thread.id); + requestAnimationFrame(() => { + confirmArchiveButtonRefs.current.get(thread.id)?.focus(); + }); + return; + } void attemptArchiveThread(thread.id); }} > -
- } - /> - Archive -
- ) + } + /> + Archive + + ) : null} +
) : null} {showThreadJumpHints && jumpLabel ? ( @@ -1738,6 +1770,10 @@ export default function Sidebar() { } showOnHover className="top-1 right-1.5 size-5 rounded-md p-0 text-muted-foreground/70 hover:bg-secondary hover:text-foreground" + onPointerDown={(event) => { + event.preventDefault(); + event.stopPropagation(); + }} onClick={(event) => { event.preventDefault(); event.stopPropagation(); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 8737c5a9cd..d5557b4a96 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -11,7 +11,6 @@ import { newCommandId } from "../lib/utils"; import { readNativeApi } from "../nativeApi"; import { useStore } from "../store"; import { useTerminalStateStore } from "../terminalStateStore"; -import { useUiStateStore } from "../uiStateStore"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { toastManager } from "../components/ui/toast"; import { useSettings } from "./useSettings"; @@ -113,11 +112,8 @@ export function useThreadActions() { const deletedThreadIds = opts.deletedThreadIds ?? new Set(); const shouldNavigateToFallback = routeThreadId === threadId; - const threadPinnedById = useUiStateStore.getState().threadPinnedById; const fallbackThreadId = getFallbackThreadIdAfterDelete({ - threads: threads.map((entry) => - Object.assign({}, entry, { pinned: threadPinnedById[entry.id] ?? false }), - ), + threads, deletedThreadId: threadId, deletedThreadIds, sortOrder: appSettings.sidebarThreadSortOrder, diff --git a/apps/web/src/orchestrationEventEffects.test.ts b/apps/web/src/orchestrationEventEffects.test.ts index 263610bb95..3a64706877 100644 --- a/apps/web/src/orchestrationEventEffects.test.ts +++ b/apps/web/src/orchestrationEventEffects.test.ts @@ -48,6 +48,7 @@ describe("deriveOrchestrationBatchEffects", () => { threadId: createdThreadId, projectId: ProjectId.makeUnsafe("project-1"), title: "Created thread", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex" }, runtimeMode: "full-access", interactionMode: "default", @@ -80,6 +81,7 @@ describe("deriveOrchestrationBatchEffects", () => { threadId, projectId: ProjectId.makeUnsafe("project-1"), title: "Recreated thread", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex" }, runtimeMode: "full-access", interactionMode: "default", diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 6e909b38f0..dd0bfa6e15 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -25,6 +25,7 @@ function makeThread(overrides: Partial = {}): Thread { codexThreadId: null, projectId: ProjectId.makeUnsafe("project-1"), title: "Thread", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -53,6 +54,7 @@ function makeState(thread: Thread): AppState { id: ProjectId.makeUnsafe("project-1"), name: "Project", cwd: "/tmp/project", + pinned: false, defaultModelSelection: { provider: "codex", model: "gpt-5-codex", @@ -97,6 +99,7 @@ function makeReadModelThread(overrides: Partial { id: project2, name: "Project 2", cwd: "/tmp/project-2", + pinned: false, defaultModelSelection: { provider: "codex", model: DEFAULT_MODEL_BY_PROVIDER.codex, @@ -263,6 +269,7 @@ describe("store read model sync", () => { id: project1, name: "Project 1", cwd: "/tmp/project-1", + pinned: false, defaultModelSelection: { provider: "codex", model: DEFAULT_MODEL_BY_PROVIDER.codex, @@ -353,6 +360,7 @@ describe("incremental orchestration updates", () => { id: originalProjectId, name: "Project", cwd: "/tmp/project", + pinned: false, defaultModelSelection: { provider: "codex", model: DEFAULT_MODEL_BY_PROVIDER.codex, @@ -370,6 +378,7 @@ describe("incremental orchestration updates", () => { projectId: recreatedProjectId, title: "Project Recreated", workspaceRoot: "/tmp/project", + pinned: false, defaultModelSelection: { provider: "codex", model: DEFAULT_MODEL_BY_PROVIDER.codex, diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index eff6a6fd07..082e130d3d 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -147,6 +147,7 @@ function mapThread(thread: OrchestrationThread): Thread { codexThreadId: null, projectId: thread.projectId, title: thread.title, + pinned: thread.pinned, modelSelection: normalizeModelSelection(thread.modelSelection), runtimeMode: thread.runtimeMode, interactionMode: thread.interactionMode, @@ -171,6 +172,7 @@ function mapProject(project: OrchestrationReadModel["projects"][number]): Projec id: project.id, name: project.title, cwd: project.workspaceRoot, + pinned: project.pinned, defaultModelSelection: project.defaultModelSelection ? normalizeModelSelection(project.defaultModelSelection) : null, @@ -424,6 +426,7 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve id: event.payload.projectId, title: event.payload.title, workspaceRoot: event.payload.workspaceRoot, + pinned: event.payload.pinned, defaultModelSelection: event.payload.defaultModelSelection, scripts: event.payload.scripts, createdAt: event.payload.createdAt, @@ -444,6 +447,7 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve ...project, ...(event.payload.title !== undefined ? { name: event.payload.title } : {}), ...(event.payload.workspaceRoot !== undefined ? { cwd: event.payload.workspaceRoot } : {}), + ...(event.payload.pinned !== undefined ? { pinned: event.payload.pinned } : {}), ...(event.payload.defaultModelSelection !== undefined ? { defaultModelSelection: event.payload.defaultModelSelection @@ -470,6 +474,7 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve id: event.payload.threadId, projectId: event.payload.projectId, title: event.payload.title, + pinned: event.payload.pinned, modelSelection: event.payload.modelSelection, runtimeMode: event.payload.runtimeMode, interactionMode: event.payload.interactionMode, @@ -519,6 +524,7 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({ ...thread, ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), + ...(event.payload.pinned !== undefined ? { pinned: event.payload.pinned } : {}), ...(event.payload.modelSelection !== undefined ? { modelSelection: normalizeModelSelection(event.payload.modelSelection) } : {}), diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 0ebf150310..ae2d43d789 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -82,6 +82,7 @@ export interface Project { id: ProjectId; name: string; cwd: string; + pinned: boolean; defaultModelSelection: ModelSelection | null; createdAt?: string | undefined; updatedAt?: string | undefined; @@ -93,6 +94,7 @@ export interface Thread { codexThreadId: string | null; projectId: ProjectId; title: string; + pinned: boolean; modelSelection: ModelSelection; runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode; diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index c0b1f52f5d..b0b19f763a 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -8,8 +8,6 @@ import { setProjectExpanded, syncProjects, syncThreads, - toggleProjectPinned, - toggleThreadPinned, type UiState, } from "./uiStateStore"; @@ -17,9 +15,7 @@ function makeUiState(overrides: Partial = {}): UiState { return { projectExpandedById: {}, projectOrder: [], - projectPinnedById: {}, threadLastVisitedAtById: {}, - threadPinnedById: {}, ...overrides, }; } @@ -114,32 +110,6 @@ describe("uiStateStore pure functions", () => { expect(next.projectExpandedById[recreatedProject2]).toBe(false); }); - it("syncProjects preserves pinned state when a project is recreated with the same cwd", () => { - const oldProject1 = ProjectId.makeUnsafe("project-1"); - const oldProject2 = ProjectId.makeUnsafe("project-2"); - const recreatedProject2 = ProjectId.makeUnsafe("project-2b"); - const initialState = syncProjects( - makeUiState({ - projectPinnedById: { - [oldProject2]: true, - }, - }), - [ - { id: oldProject1, cwd: "/tmp/project-1" }, - { id: oldProject2, cwd: "/tmp/project-2" }, - ], - ); - - const next = syncProjects(initialState, [ - { id: oldProject1, cwd: "/tmp/project-1" }, - { id: recreatedProject2, cwd: "/tmp/project-2" }, - ]); - - expect(next.projectPinnedById).toEqual({ - [recreatedProject2]: true, - }); - }); - it("syncProjects returns a new state when only project cwd changes", () => { const project1 = ProjectId.makeUnsafe("project-1"); const initialState = syncProjects( @@ -192,23 +162,6 @@ describe("uiStateStore pure functions", () => { }); }); - it("syncThreads preserves pinned state for retained threads", () => { - const thread1 = ThreadId.makeUnsafe("thread-1"); - const thread2 = ThreadId.makeUnsafe("thread-2"); - const initialState = makeUiState({ - threadPinnedById: { - [thread1]: true, - [thread2]: true, - }, - }); - - const next = syncThreads(initialState, [{ id: thread1 }]); - - expect(next.threadPinnedById).toEqual({ - [thread1]: true, - }); - }); - it("setProjectExpanded updates expansion without touching order", () => { const project1 = ProjectId.makeUnsafe("project-1"); const initialState = makeUiState({ @@ -224,42 +177,16 @@ describe("uiStateStore pure functions", () => { expect(next.projectOrder).toEqual([project1]); }); - it("toggleProjectPinned adds and removes pinned state", () => { - const project1 = ProjectId.makeUnsafe("project-1"); - const pinned = toggleProjectPinned(makeUiState(), project1); - - expect(pinned.projectPinnedById).toEqual({ [project1]: true }); - - const unpinned = toggleProjectPinned(pinned, project1); - - expect(unpinned.projectPinnedById).toEqual({}); - }); - - it("toggleThreadPinned adds and removes pinned state", () => { - const thread1 = ThreadId.makeUnsafe("thread-1"); - const pinned = toggleThreadPinned(makeUiState(), thread1); - - expect(pinned.threadPinnedById).toEqual({ [thread1]: true }); - - const unpinned = toggleThreadPinned(pinned, thread1); - - expect(unpinned.threadPinnedById).toEqual({}); - }); - it("clearThreadUi removes visit state for deleted threads", () => { const thread1 = ThreadId.makeUnsafe("thread-1"); const initialState = makeUiState({ threadLastVisitedAtById: { [thread1]: "2026-02-25T12:35:00.000Z", }, - threadPinnedById: { - [thread1]: true, - }, }); const next = clearThreadUi(initialState, thread1); expect(next.threadLastVisitedAtById).toEqual({}); - expect(next.threadPinnedById).toEqual({}); }); }); diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index c1370fcf91..342f2db18f 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -19,19 +19,15 @@ const LEGACY_PERSISTED_STATE_KEYS = [ interface PersistedUiState { expandedProjectCwds?: string[]; projectOrderCwds?: string[]; - pinnedProjectCwds?: string[]; - pinnedThreadIds?: string[]; } export interface UiProjectState { projectExpandedById: Record; projectOrder: ProjectId[]; - projectPinnedById: Record; } export interface UiThreadState { threadLastVisitedAtById: Record; - threadPinnedById: Record; } export interface UiState extends UiProjectState, UiThreadState {} @@ -49,15 +45,11 @@ export interface SyncThreadInput { const initialState: UiState = { projectExpandedById: {}, projectOrder: [], - projectPinnedById: {}, threadLastVisitedAtById: {}, - threadPinnedById: {}, }; const persistedExpandedProjectCwds = new Set(); const persistedProjectOrderCwds: string[] = []; -const persistedPinnedProjectCwds = new Set(); -const persistedPinnedThreadIds = new Set(); const currentProjectCwdById = new Map(); let legacyKeysCleanedUp = false; @@ -88,8 +80,6 @@ function readPersistedState(): UiState { function hydratePersistedProjectState(parsed: PersistedUiState): void { persistedExpandedProjectCwds.clear(); persistedProjectOrderCwds.length = 0; - persistedPinnedProjectCwds.clear(); - persistedPinnedThreadIds.clear(); for (const cwd of parsed.expandedProjectCwds ?? []) { if (typeof cwd === "string" && cwd.length > 0) { persistedExpandedProjectCwds.add(cwd); @@ -100,16 +90,6 @@ function hydratePersistedProjectState(parsed: PersistedUiState): void { persistedProjectOrderCwds.push(cwd); } } - for (const cwd of parsed.pinnedProjectCwds ?? []) { - if (typeof cwd === "string" && cwd.length > 0) { - persistedPinnedProjectCwds.add(cwd); - } - } - for (const threadId of parsed.pinnedThreadIds ?? []) { - if (typeof threadId === "string" && threadId.length > 0) { - persistedPinnedThreadIds.add(threadId as ThreadId); - } - } } function persistState(state: UiState): void { @@ -127,22 +107,11 @@ function persistState(state: UiState): void { const cwd = currentProjectCwdById.get(projectId); return cwd ? [cwd] : []; }); - const pinnedProjectCwds = Object.entries(state.projectPinnedById) - .filter(([, pinned]) => pinned) - .flatMap(([projectId]) => { - const cwd = currentProjectCwdById.get(projectId as ProjectId); - return cwd ? [cwd] : []; - }); - const pinnedThreadIds = Object.entries(state.threadPinnedById) - .filter(([, pinned]) => pinned) - .map(([threadId]) => threadId); window.localStorage.setItem( PERSISTED_STATE_KEY, JSON.stringify({ expandedProjectCwds, projectOrderCwds, - pinnedProjectCwds, - pinnedThreadIds, } satisfies PersistedUiState), ); if (!legacyKeysCleanedUp) { @@ -192,9 +161,7 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput projects.some((project) => previousProjectCwdById.get(project.id) !== project.cwd); const nextExpandedById: Record = {}; - const nextPinnedById: Record = {}; const previousExpandedById = state.projectExpandedById; - const previousPinnedById = state.projectPinnedById; const persistedOrderByCwd = new Map( persistedProjectOrderCwds.map((cwd, index) => [cwd, index] as const), ); @@ -206,14 +173,7 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput (persistedExpandedProjectCwds.size > 0 ? persistedExpandedProjectCwds.has(project.cwd) : true); - const pinned = - previousPinnedById[project.id] ?? - (previousProjectIdForCwd ? previousPinnedById[previousProjectIdForCwd] : undefined) ?? - persistedPinnedProjectCwds.has(project.cwd); nextExpandedById[project.id] = expanded; - if (pinned) { - nextPinnedById[project.id] = true; - } return { id: project.id, cwd: project.cwd, @@ -273,7 +233,6 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput if ( recordsEqual(state.projectExpandedById, nextExpandedById) && projectOrdersEqual(state.projectOrder, nextProjectOrder) && - recordsEqual(state.projectPinnedById, nextPinnedById) && !cwdMappingChanged ) { return state; @@ -283,7 +242,6 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput ...state, projectExpandedById: nextExpandedById, projectOrder: nextProjectOrder, - projectPinnedById: nextPinnedById, }; } @@ -294,11 +252,6 @@ export function syncThreads(state: UiState, threads: readonly SyncThreadInput[]) retainedThreadIds.has(threadId as ThreadId), ), ); - const nextThreadPinnedById = Object.fromEntries( - Object.entries(state.threadPinnedById).filter(([threadId]) => - retainedThreadIds.has(threadId as ThreadId), - ), - ); for (const thread of threads) { if ( nextThreadLastVisitedAtById[thread.id] === undefined && @@ -307,20 +260,13 @@ export function syncThreads(state: UiState, threads: readonly SyncThreadInput[]) ) { nextThreadLastVisitedAtById[thread.id] = thread.seedVisitedAt; } - if (nextThreadPinnedById[thread.id] === undefined && persistedPinnedThreadIds.has(thread.id)) { - nextThreadPinnedById[thread.id] = true; - } } - if ( - recordsEqual(state.threadLastVisitedAtById, nextThreadLastVisitedAtById) && - recordsEqual(state.threadPinnedById, nextThreadPinnedById) - ) { + if (recordsEqual(state.threadLastVisitedAtById, nextThreadLastVisitedAtById)) { return state; } return { ...state, threadLastVisitedAtById: nextThreadLastVisitedAtById, - threadPinnedById: nextThreadPinnedById, }; } @@ -371,33 +317,14 @@ export function markThreadUnread( } export function clearThreadUi(state: UiState, threadId: ThreadId): UiState { - if (!(threadId in state.threadLastVisitedAtById) && !(threadId in state.threadPinnedById)) { + if (!(threadId in state.threadLastVisitedAtById)) { return state; } const nextThreadLastVisitedAtById = { ...state.threadLastVisitedAtById }; - const nextThreadPinnedById = { ...state.threadPinnedById }; delete nextThreadLastVisitedAtById[threadId]; - delete nextThreadPinnedById[threadId]; return { ...state, threadLastVisitedAtById: nextThreadLastVisitedAtById, - threadPinnedById: nextThreadPinnedById, - }; -} - -export function toggleThreadPinned(state: UiState, threadId: ThreadId): UiState { - const nextThreadPinnedById = { ...state.threadPinnedById }; - if (nextThreadPinnedById[threadId]) { - delete nextThreadPinnedById[threadId]; - } else { - nextThreadPinnedById[threadId] = true; - } - if (recordsEqual(state.threadPinnedById, nextThreadPinnedById)) { - return state; - } - return { - ...state, - threadPinnedById: nextThreadPinnedById, }; } @@ -454,31 +381,13 @@ export function reorderProjects( }; } -export function toggleProjectPinned(state: UiState, projectId: ProjectId): UiState { - const nextProjectPinnedById = { ...state.projectPinnedById }; - if (nextProjectPinnedById[projectId]) { - delete nextProjectPinnedById[projectId]; - } else { - nextProjectPinnedById[projectId] = true; - } - if (recordsEqual(state.projectPinnedById, nextProjectPinnedById)) { - return state; - } - return { - ...state, - projectPinnedById: nextProjectPinnedById, - }; -} - interface UiStateStore extends UiState { syncProjects: (projects: readonly SyncProjectInput[]) => void; syncThreads: (threads: readonly SyncThreadInput[]) => void; markThreadVisited: (threadId: ThreadId, visitedAt?: string) => void; markThreadUnread: (threadId: ThreadId, latestTurnCompletedAt: string | null | undefined) => void; clearThreadUi: (threadId: ThreadId) => void; - toggleThreadPinned: (threadId: ThreadId) => void; toggleProject: (projectId: ProjectId) => void; - toggleProjectPinned: (projectId: ProjectId) => void; setProjectExpanded: (projectId: ProjectId, expanded: boolean) => void; reorderProjects: (draggedProjectId: ProjectId, targetProjectId: ProjectId) => void; } @@ -492,9 +401,7 @@ export const useUiStateStore = create((set) => ({ markThreadUnread: (threadId, latestTurnCompletedAt) => set((state) => markThreadUnread(state, threadId, latestTurnCompletedAt)), clearThreadUi: (threadId) => set((state) => clearThreadUi(state, threadId)), - toggleThreadPinned: (threadId) => set((state) => toggleThreadPinned(state, threadId)), toggleProject: (projectId) => set((state) => toggleProject(state, projectId)), - toggleProjectPinned: (projectId) => set((state) => toggleProjectPinned(state, projectId)), setProjectExpanded: (projectId, expanded) => set((state) => setProjectExpanded(state, projectId, expanded)), reorderProjects: (draggedProjectId, targetProjectId) => diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index 723661ccbb..095e549931 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -10,6 +10,7 @@ function makeThread(overrides: Partial = {}): Thread { codexThreadId: null, projectId: ProjectId.makeUnsafe("project-1"), title: "Thread", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5.3-codex", diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index be53eefd9b..65193b100f 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -289,6 +289,7 @@ describe("wsNativeApi", () => { projectId: ProjectId.makeUnsafe("project-1"), title: "Project", workspaceRoot: "/tmp/workspace", + pinned: false, defaultModelSelection: null, scripts: [], createdAt: "2026-02-24T00:00:00.000Z", @@ -331,6 +332,7 @@ describe("wsNativeApi", () => { projectId: ProjectId.makeUnsafe("project-1"), title: "Project", workspaceRoot: "/tmp/project", + pinned: false, defaultModelSelection: { provider: "codex", model: "gpt-5-codex", diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 06bb35038d..db759f8876 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -95,6 +95,7 @@ it.effect("trims branded ids and command string fields at decode boundaries", () assert.strictEqual(parsed.projectId, "project-1"); assert.strictEqual(parsed.title, "Project Title"); assert.strictEqual(parsed.workspaceRoot, "/tmp/workspace"); + assert.strictEqual(parsed.pinned, false); assert.deepStrictEqual(parsed.defaultModelSelection, { provider: "codex", model: "gpt-5.2", @@ -117,6 +118,7 @@ it.effect("decodes historical project.created payloads with a default provider", updatedAt: "2026-01-01T00:00:00.000Z", }); assert.strictEqual(parsed.defaultModelSelection?.provider, "codex"); + assert.strictEqual(parsed.pinned, false); }), ); @@ -214,6 +216,7 @@ it.effect("decodes thread.created runtime mode for historical events", () => assert.strictEqual(parsed.runtimeMode, DEFAULT_RUNTIME_MODE); assert.strictEqual(parsed.modelSelection.provider, "codex"); + assert.strictEqual(parsed.pinned, false); }), ); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index a780a55c78..f9a94ac08a 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -145,6 +145,7 @@ export const OrchestrationProject = Schema.Struct({ id: ProjectId, title: TrimmedNonEmptyString, workspaceRoot: TrimmedNonEmptyString, + pinned: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), defaultModelSelection: Schema.NullOr(ModelSelection), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, @@ -274,6 +275,7 @@ export const OrchestrationThread = Schema.Struct({ id: ThreadId, projectId: ProjectId, title: TrimmedNonEmptyString, + pinned: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), modelSelection: ModelSelection, runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode.pipe( @@ -308,6 +310,7 @@ export const ProjectCreateCommand = Schema.Struct({ projectId: ProjectId, title: TrimmedNonEmptyString, workspaceRoot: TrimmedNonEmptyString, + pinned: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), createdAt: IsoDateTime, }); @@ -318,6 +321,7 @@ const ProjectMetaUpdateCommand = Schema.Struct({ projectId: ProjectId, title: Schema.optional(TrimmedNonEmptyString), workspaceRoot: Schema.optional(TrimmedNonEmptyString), + pinned: Schema.optional(Schema.Boolean), defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), scripts: Schema.optional(Schema.Array(ProjectScript)), }); @@ -334,6 +338,7 @@ const ThreadCreateCommand = Schema.Struct({ threadId: ThreadId, projectId: ProjectId, title: TrimmedNonEmptyString, + pinned: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), modelSelection: ModelSelection, runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode.pipe( @@ -367,6 +372,7 @@ const ThreadMetaUpdateCommand = Schema.Struct({ commandId: CommandId, threadId: ThreadId, title: Schema.optional(TrimmedNonEmptyString), + pinned: Schema.optional(Schema.Boolean), modelSelection: Schema.optional(ModelSelection), branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), @@ -624,6 +630,7 @@ export const ProjectCreatedPayload = Schema.Struct({ projectId: ProjectId, title: TrimmedNonEmptyString, workspaceRoot: TrimmedNonEmptyString, + pinned: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), defaultModelSelection: Schema.NullOr(ModelSelection), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, @@ -634,6 +641,7 @@ export const ProjectMetaUpdatedPayload = Schema.Struct({ projectId: ProjectId, title: Schema.optional(TrimmedNonEmptyString), workspaceRoot: Schema.optional(TrimmedNonEmptyString), + pinned: Schema.optional(Schema.Boolean), defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), scripts: Schema.optional(Schema.Array(ProjectScript)), updatedAt: IsoDateTime, @@ -648,6 +656,7 @@ export const ThreadCreatedPayload = Schema.Struct({ threadId: ThreadId, projectId: ProjectId, title: TrimmedNonEmptyString, + pinned: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), modelSelection: ModelSelection, runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), interactionMode: ProviderInteractionMode.pipe( @@ -678,6 +687,7 @@ export const ThreadUnarchivedPayload = Schema.Struct({ export const ThreadMetaUpdatedPayload = Schema.Struct({ threadId: ThreadId, title: Schema.optional(TrimmedNonEmptyString), + pinned: Schema.optional(Schema.Boolean), modelSelection: Schema.optional(ModelSelection), branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), From c09e21da383c8551a5701131cb5ca2503fd4c260 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 30 Mar 2026 15:43:43 -0700 Subject: [PATCH 4/8] Prioritize pinned projects in sidebar - Include project pin state in projection snapshots - Show the pin indicator before project names in the sidebar --- .../src/orchestration/Layers/ProjectionSnapshotQuery.ts | 2 ++ apps/web/src/components/Sidebar.tsx | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 38b5188a9b..9e541648b4 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -165,6 +165,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { project_id AS "projectId", title, workspace_root AS "workspaceRoot", + pinned, default_model_selection_json AS "defaultModelSelection", scripts_json AS "scripts", created_at AS "createdAt", @@ -184,6 +185,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { thread_id AS "threadId", project_id AS "projectId", title, + pinned, model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", interaction_mode AS "interactionMode", diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 13d7c74d78..50cb64faa8 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1741,9 +1741,6 @@ export default function Sidebar() { /> )} - - {project.name} - {project.pinned ? ( ) : null} + + {project.name} + Date: Mon, 30 Mar 2026 15:53:43 -0700 Subject: [PATCH 5/8] Use boolean bit schema for pinned flags - Replace manual bit transforms with `Schema.BooleanFromBit` - Keep project and thread projection mapping consistent --- .../Layers/ProjectionSnapshotQuery.ts | 22 +++---------------- .../persistence/Layers/ProjectionProjects.ts | 12 ++-------- .../persistence/Layers/ProjectionThreads.ts | 12 ++-------- 3 files changed, 7 insertions(+), 39 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 9e541648b4..7c9f3d9dcc 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -19,7 +19,7 @@ import { type OrchestrationThreadActivity, ModelSelection, } from "@t3tools/contracts"; -import { Effect, Layer, Schema, SchemaTransformation, Struct } from "effect"; +import { Effect, Layer, Schema, Struct } from "effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; @@ -46,15 +46,7 @@ import { const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel); const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( Struct.assign({ - pinned: Schema.Number.pipe( - Schema.decodeTo( - Schema.Boolean, - SchemaTransformation.transformOrFail({ - decode: (value) => Effect.succeed(value !== 0), - encode: (value) => Effect.succeed(value ? 1 : 0), - }), - ), - ), + pinned: Schema.BooleanFromBit, defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), }), @@ -68,15 +60,7 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( Struct.assign({ - pinned: Schema.Number.pipe( - Schema.decodeTo( - Schema.Boolean, - SchemaTransformation.transformOrFail({ - decode: (value) => Effect.succeed(value !== 0), - encode: (value) => Effect.succeed(value ? 1 : 0), - }), - ), - ), + pinned: Schema.BooleanFromBit, modelSelection: Schema.fromJsonString(ModelSelection), }), ); diff --git a/apps/server/src/persistence/Layers/ProjectionProjects.ts b/apps/server/src/persistence/Layers/ProjectionProjects.ts index 0b6ea12d89..45e3925a7a 100644 --- a/apps/server/src/persistence/Layers/ProjectionProjects.ts +++ b/apps/server/src/persistence/Layers/ProjectionProjects.ts @@ -1,6 +1,6 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import { Effect, Layer, Schema, SchemaTransformation, Struct } from "effect"; +import { Effect, Layer, Schema, Struct } from "effect"; import { ModelSelection, ProjectScript } from "@t3tools/contracts"; import { toPersistenceSqlError } from "../Errors.ts"; @@ -14,15 +14,7 @@ import { const ProjectionProjectDbRow = ProjectionProject.mapFields( Struct.assign({ - pinned: Schema.Number.pipe( - Schema.decodeTo( - Schema.Boolean, - SchemaTransformation.transformOrFail({ - decode: (value) => Effect.succeed(value !== 0), - encode: (value) => Effect.succeed(value ? 1 : 0), - }), - ), - ), + pinned: Schema.BooleanFromBit, defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), }), diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index b28a123af4..51ffea3316 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -1,6 +1,6 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import { Effect, Layer, Schema, SchemaTransformation, Struct } from "effect"; +import { Effect, Layer, Schema, Struct } from "effect"; import { toPersistenceSqlError } from "../Errors.ts"; import { @@ -15,15 +15,7 @@ import { ModelSelection } from "@t3tools/contracts"; const ProjectionThreadDbRow = ProjectionThread.mapFields( Struct.assign({ - pinned: Schema.Number.pipe( - Schema.decodeTo( - Schema.Boolean, - SchemaTransformation.transformOrFail({ - decode: (value) => Effect.succeed(value !== 0), - encode: (value) => Effect.succeed(value ? 1 : 0), - }), - ), - ), + pinned: Schema.BooleanFromBit, modelSelection: Schema.fromJsonString(ModelSelection), }), ); From f48fcb8afe71446f41591f68845707e9ec5e410f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 30 Mar 2026 17:56:06 -0700 Subject: [PATCH 6/8] Move pinned threads into a global sidebar section - Render pinned threads above projects - Remove pin indicators from project rows - Keep archive and status actions working in the shared row renderer --- apps/web/src/components/Sidebar.tsx | 587 ++++++++++++++-------------- 1 file changed, 291 insertions(+), 296 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 50cb64faa8..5c54c54fe3 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -4,7 +4,6 @@ import { ChevronRightIcon, FolderIcon, GitPullRequestIcon, - PinIcon, PlusIcon, SettingsIcon, SquarePenIcon, @@ -1214,6 +1213,32 @@ export default function Sidebar() { () => threads.filter((thread) => thread.archivedAt === null), [threads], ); + const threadStatusById = useMemo( + () => + new Map( + visibleThreads.map((thread) => [ + thread.id, + resolveThreadStatusPill({ + thread, + hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0, + hasPendingUserInput: derivePendingUserInputs(thread.activities).length > 0, + }), + ]), + ), + [visibleThreads], + ); + const globalPinnedThreads = useMemo( + () => + sortThreadsForSidebar( + visibleThreads.filter((thread) => thread.pinned), + appSettings.sidebarThreadSortOrder, + ), + [appSettings.sidebarThreadSortOrder, visibleThreads], + ); + const globalPinnedThreadIds = useMemo( + () => globalPinnedThreads.map((thread) => thread.id), + [globalPinnedThreads], + ); const sortedProjects = useMemo( () => sortProjectsForSidebar(sidebarProjects, visibleThreads, appSettings.sidebarProjectSortOrder), @@ -1223,30 +1248,17 @@ export default function Sidebar() { const renderedProjects = useMemo( () => sortedProjects.map((project) => { - const projectThreads = sortThreadsForSidebar( + const allProjectThreads = sortThreadsForSidebar( visibleThreads.filter((thread) => thread.projectId === project.id), appSettings.sidebarThreadSortOrder, ); - const threadStatuses = new Map( - projectThreads.map((thread) => [ - thread.id, - resolveThreadStatusPill({ - thread, - hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0, - hasPendingUserInput: derivePendingUserInputs(thread.activities).length > 0, - }), - ]), - ); + const projectThreads = allProjectThreads.filter((thread) => !thread.pinned); const projectStatus = resolveProjectStatusIndicator( - projectThreads.map((thread) => threadStatuses.get(thread.id) ?? null), + allProjectThreads.map((thread) => threadStatusById.get(thread.id) ?? null), ); const activeThreadId = routeThreadId ?? undefined; const isThreadListExpanded = expandedThreadListsByProject.has(project.id); - const pinnedCollapsedThread = - !project.expanded && activeThreadId - ? (projectThreads.find((thread) => thread.id === activeThreadId) ?? null) - : null; - const shouldShowThreadPanel = project.expanded || pinnedCollapsedThread !== null; + const shouldShowThreadPanel = project.expanded; const { hasHiddenThreads, hiddenThreads, @@ -1258,13 +1270,11 @@ export default function Sidebar() { previewLimit: THREAD_PREVIEW_LIMIT, }); const hiddenThreadStatus = resolveProjectStatusIndicator( - hiddenThreads.map((thread) => threadStatuses.get(thread.id) ?? null), + hiddenThreads.map((thread) => threadStatusById.get(thread.id) ?? null), ); const orderedProjectThreadIds = projectThreads.map((thread) => thread.id); - const renderedThreads = pinnedCollapsedThread - ? [pinnedCollapsedThread] - : visibleProjectThreads; - const showEmptyThreadState = project.expanded && projectThreads.length === 0; + const renderedThreads = visibleProjectThreads; + const showEmptyThreadState = project.expanded && allProjectThreads.length === 0; return { hasHiddenThreads, @@ -1272,8 +1282,6 @@ export default function Sidebar() { orderedProjectThreadIds, project, projectStatus, - projectThreads, - threadStatuses, renderedThreads, showEmptyThreadState, shouldShowThreadPanel, @@ -1285,12 +1293,13 @@ export default function Sidebar() { expandedThreadListsByProject, routeThreadId, sortedProjects, + threadStatusById, visibleThreads, ], ); const visibleSidebarThreadIds = useMemo( - () => getVisibleSidebarThreadIds(renderedProjects), - [renderedProjects], + () => [...globalPinnedThreadIds, ...getVisibleSidebarThreadIds(renderedProjects)], + [globalPinnedThreadIds, renderedProjects], ); const threadJumpCommandById = useMemo(() => { const mapping = new Map>>(); @@ -1407,214 +1416,204 @@ export default function Sidebar() { updateThreadJumpHintsVisibility, ]); - function renderProjectItem( - renderedProject: (typeof renderedProjects)[number], - dragHandleProps: SortableProjectHandleProps | null, - ) { - const { - hasHiddenThreads, - hiddenThreadStatus, - orderedProjectThreadIds, - project, - projectStatus, - projectThreads, - threadStatuses, - renderedThreads, - showEmptyThreadState, - shouldShowThreadPanel, - isThreadListExpanded, - } = renderedProject; - const renderThreadRow = (thread: (typeof projectThreads)[number]) => { - const isActive = routeThreadId === thread.id; - const isSelected = selectedThreadIds.has(thread.id); - const isHighlighted = isActive || isSelected; - const jumpLabel = threadJumpLabelById.get(thread.id) ?? null; - const isThreadRunning = - thread.session?.status === "running" && thread.session.activeTurnId != null; - const threadStatus = threadStatuses.get(thread.id) ?? null; - const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null); - const terminalStatus = terminalStatusFromRunningIds( - selectThreadTerminalState(terminalStateByThreadId, thread.id).runningTerminalIds, - ); - const isConfirmingArchive = confirmingArchiveThreadId === thread.id && !isThreadRunning; - const threadMetaClassName = isConfirmingArchive - ? "pointer-events-none opacity-0" - : !isThreadRunning - ? "pointer-events-none transition-opacity duration-150 group-hover/menu-sub-item:opacity-0 group-focus-within/menu-sub-item:opacity-0" - : "pointer-events-none"; - const hoverActionClassName = - "inline-flex size-5 cursor-pointer items-center justify-center text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"; - const hoverActionGroupClassName = - "pointer-events-none absolute top-1/2 right-1 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity duration-150 group-hover/menu-sub-item:pointer-events-auto group-hover/menu-sub-item:opacity-100 group-focus-within/menu-sub-item:pointer-events-auto group-focus-within/menu-sub-item:opacity-100"; - const pinActionLabel = thread.pinned ? "Unpin" : "Pin"; - - return ( - { + const renderThreadRow = ( + thread: SidebarThreadSnapshot, + options: { + leadingVisual?: "project-favicon"; + orderedThreadIds: readonly ThreadId[]; + }, + ) => { + const isActive = routeThreadId === thread.id; + const isSelected = selectedThreadIds.has(thread.id); + const isHighlighted = isActive || isSelected; + const jumpLabel = threadJumpLabelById.get(thread.id) ?? null; + const isThreadRunning = + thread.session?.status === "running" && thread.session.activeTurnId != null; + const threadStatus = threadStatusById.get(thread.id) ?? null; + const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null); + const terminalStatus = terminalStatusFromRunningIds( + selectThreadTerminalState(terminalStateByThreadId, thread.id).runningTerminalIds, + ); + const isConfirmingArchive = confirmingArchiveThreadId === thread.id && !isThreadRunning; + const threadMetaClassName = isConfirmingArchive + ? "pointer-events-none opacity-0" + : !isThreadRunning + ? "pointer-events-none transition-opacity duration-150 group-hover/menu-sub-item:opacity-0 group-focus-within/menu-sub-item:opacity-0" + : "pointer-events-none"; + const hoverActionClassName = + "inline-flex size-5 cursor-pointer items-center justify-center text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"; + const hoverActionGroupClassName = + "pointer-events-none absolute top-1/2 right-1 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity duration-150 group-hover/menu-sub-item:pointer-events-auto group-hover/menu-sub-item:opacity-100 group-focus-within/menu-sub-item:pointer-events-auto group-focus-within/menu-sub-item:opacity-100"; + const projectCwd = projectCwdById.get(thread.projectId); + + return ( + { + setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current)); + }} + onBlurCapture={(event) => { + const currentTarget = event.currentTarget; + requestAnimationFrame(() => { + if (currentTarget.contains(document.activeElement)) { + return; + } setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current)); + }); + }} + > + } + size="sm" + isActive={isActive} + data-testid={`thread-row-${thread.id}`} + className={`${resolveThreadRowClassName({ + isActive, + isSelected, + })} relative isolate`} + onClick={(event) => { + handleThreadClick(event, thread.id, options.orderedThreadIds); }} - onBlurCapture={(event) => { - const currentTarget = event.currentTarget; - requestAnimationFrame(() => { - if (currentTarget.contains(document.activeElement)) { - return; + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + navigateToThread(thread.id); + }} + onContextMenu={(event) => { + event.preventDefault(); + if (selectedThreadIds.size > 0 && selectedThreadIds.has(thread.id)) { + void handleMultiSelectContextMenu({ + x: event.clientX, + y: event.clientY, + }); + } else { + if (selectedThreadIds.size > 0) { + clearSelection(); } - setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current)); - }); + void handleThreadContextMenu(thread.id, { + x: event.clientX, + y: event.clientY, + }); + } }} > - } - size="sm" - isActive={isActive} - data-testid={`thread-row-${thread.id}`} - className={`${resolveThreadRowClassName({ - isActive, - isSelected, - })} relative isolate`} - onClick={(event) => { - handleThreadClick(event, thread.id, orderedProjectThreadIds); - }} - onKeyDown={(event) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - navigateToThread(thread.id); - }} - onContextMenu={(event) => { - event.preventDefault(); - if (selectedThreadIds.size > 0 && selectedThreadIds.has(thread.id)) { - void handleMultiSelectContextMenu({ - x: event.clientX, - y: event.clientY, - }); - } else { - if (selectedThreadIds.size > 0) { - clearSelection(); - } - void handleThreadContextMenu(thread.id, { - x: event.clientX, - y: event.clientY, - }); - } - }} - > -
- {thread.pinned ? ( - - - - ) : null} - {prStatus && ( - - { - openPrLink(event, prStatus.url); - }} - > - - - } - /> - {prStatus.tooltip} - - )} - {threadStatus && } - {renamingThreadId === thread.id ? ( - { - if (el && renamingInputRef.current !== el) { - renamingInputRef.current = el; - el.focus(); - el.select(); +
+ {options.leadingVisual === "project-favicon" ? ( + projectCwd ? ( + + ) : ( + + ) + ) : null} + {prStatus && ( + + { + openPrLink(event, prStatus.url); + }} + > + + + } + /> + {prStatus.tooltip} + + )} + {threadStatus && } + {renamingThreadId === thread.id ? ( + { + if (el && renamingInputRef.current !== el) { + renamingInputRef.current = el; + el.focus(); + el.select(); + } + }} + className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5" + value={renamingTitle} + onChange={(e) => setRenamingTitle(e.target.value)} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === "Enter") { + e.preventDefault(); + renamingCommittedRef.current = true; + void commitRename(thread.id, renamingTitle, thread.title); + } else if (e.key === "Escape") { + e.preventDefault(); + renamingCommittedRef.current = true; + cancelRename(); + } + }} + onBlur={() => { + if (!renamingCommittedRef.current) { + void commitRename(thread.id, renamingTitle, thread.title); + } + }} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + {thread.title} + )} +
+
+ {terminalStatus && ( + + + + )} +
+ {isConfirmingArchive ? ( +
-
- {terminalStatus && ( - - - - )} -
- {isConfirmingArchive ? ( - - ) : null} - {!isConfirmingArchive ? ( -
+ Confirm + + ) : null} + {!isConfirmingArchive ? ( +
+ {!isThreadRunning ? ( { event.preventDefault(); @@ -1623,77 +1622,67 @@ export default function Sidebar() { onClick={(event) => { event.preventDefault(); event.stopPropagation(); - void setThreadPinned(thread, !thread.pinned); + if (appSettings.confirmThreadArchive) { + setConfirmingArchiveThreadId(thread.id); + requestAnimationFrame(() => { + confirmArchiveButtonRefs.current.get(thread.id)?.focus(); + }); + return; + } + void attemptArchiveThread(thread.id); }} > - + } /> - {pinActionLabel} + Archive - {!isThreadRunning ? ( - - { - event.preventDefault(); - event.stopPropagation(); - }} - onClick={(event) => { - event.preventDefault(); - event.stopPropagation(); - if (appSettings.confirmThreadArchive) { - setConfirmingArchiveThreadId(thread.id); - requestAnimationFrame(() => { - confirmArchiveButtonRefs.current.get(thread.id)?.focus(); - }); - return; - } - void attemptArchiveThread(thread.id); - }} - > - - - } - /> - Archive - - ) : null} -
- ) : null} - - {showThreadJumpHints && jumpLabel ? ( - - {jumpLabel} - - ) : ( - - {formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt)} - - )} - -
+ ) : null} +
+ ) : null} + + {showThreadJumpHints && jumpLabel ? ( + + {jumpLabel} + + ) : ( + + {formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt)} + + )} +
- - - ); - }; +
+ + + ); + }; + + function renderProjectItem( + renderedProject: (typeof renderedProjects)[number], + dragHandleProps: SortableProjectHandleProps | null, + ) { + const { + hasHiddenThreads, + hiddenThreadStatus, + orderedProjectThreadIds, + project, + projectStatus, + renderedThreads, + showEmptyThreadState, + shouldShowThreadPanel, + isThreadListExpanded, + } = renderedProject; return ( <> @@ -1741,18 +1730,6 @@ export default function Sidebar() { /> )} - {project.pinned ? ( - - - - ) : null} {project.name} @@ -1808,7 +1785,10 @@ export default function Sidebar() {
) : null} - {shouldShowThreadPanel && renderedThreads.map((thread) => renderThreadRow(thread))} + {shouldShowThreadPanel && + renderedThreads.map((thread) => + renderThreadRow(thread, { orderedThreadIds: orderedProjectThreadIds }), + )} {project.expanded && hasHiddenThreads && !isThreadListExpanded && ( @@ -2093,6 +2073,21 @@ export default function Sidebar() { ) : null} + {globalPinnedThreads.length > 0 ? ( + + + {globalPinnedThreads.map((thread) => + renderThreadRow(thread, { + leadingVisual: "project-favicon", + orderedThreadIds: globalPinnedThreadIds, + }), + )} + + + ) : null}
From 3a199bc71981adabb18d132701a2cc9e22d09ea1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 30 Mar 2026 18:12:43 -0700 Subject: [PATCH 7/8] Track pin timestamps for projects and threads - replace boolean pin state with pinnedAt timestamps - update orchestration, persistence, and sidebar sorting --- .../Layers/CheckpointDiffQuery.test.ts | 4 +- .../Layers/ProjectionPipeline.ts | 8 ++-- .../Layers/ProjectionSnapshotQuery.test.ts | 4 +- .../Layers/ProjectionSnapshotQuery.ts | 12 +++--- .../orchestration/commandInvariants.test.ts | 8 ++-- .../decider.projectScripts.test.ts | 22 +++++------ apps/server/src/orchestration/decider.ts | 8 ++-- .../src/orchestration/projector.test.ts | 10 ++--- apps/server/src/orchestration/projector.ts | 8 ++-- .../persistence/Layers/ProjectionProjects.ts | 11 ++++-- .../Layers/ProjectionRepositories.test.ts | 8 ++-- .../persistence/Layers/ProjectionThreads.ts | 11 ++++-- apps/server/src/persistence/Migrations.ts | 2 + .../Migrations/020_ProjectionPinTimestamps.ts | 38 +++++++++++++++++++ .../Services/ProjectionProjects.ts | 2 +- .../persistence/Services/ProjectionThreads.ts | 2 +- apps/web/src/components/ChatView.browser.tsx | 8 ++-- .../web/src/components/ChatView.logic.test.ts | 8 ++-- apps/web/src/components/ChatView.logic.ts | 2 +- .../components/KeybindingsToast.browser.tsx | 4 +- apps/web/src/components/Sidebar.logic.test.ts | 34 ++++++++--------- apps/web/src/components/Sidebar.logic.ts | 26 ++++++++++--- apps/web/src/components/Sidebar.tsx | 29 ++++++++++---- .../web/src/orchestrationEventEffects.test.ts | 4 +- apps/web/src/store.test.ts | 18 ++++----- apps/web/src/store.ts | 12 +++--- apps/web/src/types.ts | 4 +- apps/web/src/worktreeCleanup.test.ts | 2 +- apps/web/src/wsNativeApi.test.ts | 2 +- packages/contracts/src/orchestration.test.ts | 4 +- packages/contracts/src/orchestration.ts | 12 +++--- 31 files changed, 200 insertions(+), 127 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/020_ProjectionPinTimestamps.ts diff --git a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts index 6a9ff24bb0..1f8b4793c1 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts @@ -31,7 +31,7 @@ function makeSnapshot(input: { id: input.projectId, title: "Project", workspaceRoot: input.workspaceRoot, - pinned: false, + pinnedAt: null, defaultModelSelection: null, scripts: [], createdAt: "2026-01-01T00:00:00.000Z", @@ -44,7 +44,7 @@ function makeSnapshot(input: { id: input.threadId, projectId: input.projectId, title: "Thread", - pinned: false, + pinnedAt: null, modelSelection: { provider: "codex", model: "gpt-5-codex", diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 358c3e1a1c..5eb050269b 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -383,7 +383,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti projectId: event.payload.projectId, title: event.payload.title, workspaceRoot: event.payload.workspaceRoot, - pinned: event.payload.pinned, + pinnedAt: event.payload.pinnedAt, defaultModelSelection: event.payload.defaultModelSelection, scripts: event.payload.scripts, createdAt: event.payload.createdAt, @@ -405,7 +405,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...(event.payload.workspaceRoot !== undefined ? { workspaceRoot: event.payload.workspaceRoot } : {}), - ...(event.payload.pinned !== undefined ? { pinned: event.payload.pinned } : {}), + ...(event.payload.pinnedAt !== undefined ? { pinnedAt: event.payload.pinnedAt } : {}), ...(event.payload.defaultModelSelection !== undefined ? { defaultModelSelection: event.payload.defaultModelSelection } : {}), @@ -444,7 +444,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti threadId: event.payload.threadId, projectId: event.payload.projectId, title: event.payload.title, - pinned: event.payload.pinned, + pinnedAt: event.payload.pinnedAt, modelSelection: event.payload.modelSelection, runtimeMode: event.payload.runtimeMode, interactionMode: event.payload.interactionMode, @@ -498,7 +498,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti yield* projectionThreadRepository.upsert({ ...existingRow.value, ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), - ...(event.payload.pinned !== undefined ? { pinned: event.payload.pinned } : {}), + ...(event.payload.pinnedAt !== undefined ? { pinnedAt: event.payload.pinnedAt } : {}), ...(event.payload.modelSelection !== undefined ? { modelSelection: event.payload.modelSelection } : {}), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index dd256b5214..9ec55d56cd 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -234,7 +234,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { id: asProjectId("project-1"), title: "Project 1", workspaceRoot: "/tmp/project-1", - pinned: false, + pinnedAt: null, defaultModelSelection: { provider: "codex", model: "gpt-5-codex", @@ -258,7 +258,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { id: ThreadId.makeUnsafe("thread-1"), projectId: asProjectId("project-1"), title: "Thread 1", - pinned: false, + pinnedAt: null, modelSelection: { provider: "codex", model: "gpt-5-codex", diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 7c9f3d9dcc..f24ae6ff40 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -46,7 +46,7 @@ import { const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel); const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( Struct.assign({ - pinned: Schema.BooleanFromBit, + pinnedAt: Schema.NullOr(IsoDateTime), defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), }), @@ -60,7 +60,7 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( Struct.assign({ - pinned: Schema.BooleanFromBit, + pinnedAt: Schema.NullOr(IsoDateTime), modelSelection: Schema.fromJsonString(ModelSelection), }), ); @@ -149,7 +149,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { project_id AS "projectId", title, workspace_root AS "workspaceRoot", - pinned, + pinned_at AS "pinnedAt", default_model_selection_json AS "defaultModelSelection", scripts_json AS "scripts", created_at AS "createdAt", @@ -169,7 +169,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { thread_id AS "threadId", project_id AS "projectId", title, - pinned, + pinned_at AS "pinnedAt", model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", interaction_mode AS "interactionMode", @@ -546,7 +546,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { id: row.projectId, title: row.title, workspaceRoot: row.workspaceRoot, - pinned: row.pinned, + pinnedAt: row.pinnedAt, defaultModelSelection: row.defaultModelSelection, scripts: row.scripts, createdAt: row.createdAt, @@ -558,7 +558,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { id: row.threadId, projectId: row.projectId, title: row.title, - pinned: row.pinned, + pinnedAt: row.pinnedAt, modelSelection: row.modelSelection, runtimeMode: row.runtimeMode, interactionMode: row.interactionMode, diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index 9ba7f7e68b..376e3f1480 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -28,7 +28,7 @@ const readModel: OrchestrationReadModel = { id: ProjectId.makeUnsafe("project-a"), title: "Project A", workspaceRoot: "/tmp/project-a", - pinned: false, + pinnedAt: null, defaultModelSelection: { provider: "codex", model: "gpt-5-codex", @@ -42,7 +42,7 @@ const readModel: OrchestrationReadModel = { id: ProjectId.makeUnsafe("project-b"), title: "Project B", workspaceRoot: "/tmp/project-b", - pinned: false, + pinnedAt: null, defaultModelSelection: { provider: "codex", model: "gpt-5-codex", @@ -58,7 +58,7 @@ const readModel: OrchestrationReadModel = { id: ThreadId.makeUnsafe("thread-1"), projectId: ProjectId.makeUnsafe("project-a"), title: "Thread A", - pinned: false, + pinnedAt: null, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -82,7 +82,7 @@ const readModel: OrchestrationReadModel = { id: ThreadId.makeUnsafe("thread-2"), projectId: ProjectId.makeUnsafe("project-b"), title: "Thread B", - pinned: false, + pinnedAt: null, modelSelection: { provider: "codex", model: "gpt-5-codex", diff --git a/apps/server/src/orchestration/decider.projectScripts.test.ts b/apps/server/src/orchestration/decider.projectScripts.test.ts index 92bf6809e6..f25a3f2f94 100644 --- a/apps/server/src/orchestration/decider.projectScripts.test.ts +++ b/apps/server/src/orchestration/decider.projectScripts.test.ts @@ -39,7 +39,7 @@ describe("decider project scripts", () => { const event = Array.isArray(result) ? result[0] : result; expect(event.type).toBe("project.created"); expect(event.payload).toMatchObject({ - pinned: false, + pinnedAt: null, scripts: [], }); }); @@ -63,7 +63,7 @@ describe("decider project scripts", () => { projectId: asProjectId("project-scripts"), title: "Scripts", workspaceRoot: "/tmp/scripts", - pinned: false, + pinnedAt: null, defaultModelSelection: null, scripts: [], createdAt: now, @@ -118,7 +118,7 @@ describe("decider project scripts", () => { projectId: asProjectId("project-pin"), title: "Project", workspaceRoot: "/tmp/project-pin", - pinned: false, + pinnedAt: null, defaultModelSelection: null, scripts: [], createdAt: now, @@ -151,7 +151,7 @@ describe("decider project scripts", () => { ); const createdEvent = Array.isArray(created) ? created[0] : created; expect(createdEvent.type).toBe("thread.created"); - expect(createdEvent.payload).toMatchObject({ pinned: true }); + expect(createdEvent.payload).toMatchObject({ pinnedAt: now }); const withThread = await Effect.runPromise( projectEvent(withProject, { @@ -172,7 +172,7 @@ describe("decider project scripts", () => { ); const updatedEvent = Array.isArray(updated) ? updated[0] : updated; expect(updatedEvent.type).toBe("thread.meta-updated"); - expect(updatedEvent.payload).toMatchObject({ pinned: false }); + expect(updatedEvent.payload).toMatchObject({ pinnedAt: null }); }); it("emits user message and turn-start-requested events for thread.turn.start", async () => { @@ -194,7 +194,7 @@ describe("decider project scripts", () => { projectId: asProjectId("project-1"), title: "Project", workspaceRoot: "/tmp/project", - pinned: false, + pinnedAt: null, defaultModelSelection: null, scripts: [], createdAt: now, @@ -218,7 +218,7 @@ describe("decider project scripts", () => { threadId: ThreadId.makeUnsafe("thread-1"), projectId: asProjectId("project-1"), title: "Thread", - pinned: false, + pinnedAt: null, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -305,7 +305,7 @@ describe("decider project scripts", () => { projectId: asProjectId("project-1"), title: "Project", workspaceRoot: "/tmp/project", - pinned: false, + pinnedAt: null, defaultModelSelection: null, scripts: [], createdAt: now, @@ -329,7 +329,7 @@ describe("decider project scripts", () => { threadId: ThreadId.makeUnsafe("thread-1"), projectId: asProjectId("project-1"), title: "Thread", - pinned: false, + pinnedAt: null, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -389,7 +389,7 @@ describe("decider project scripts", () => { projectId: asProjectId("project-1"), title: "Project", workspaceRoot: "/tmp/project", - pinned: false, + pinnedAt: null, defaultModelSelection: null, scripts: [], createdAt: now, @@ -413,7 +413,7 @@ describe("decider project scripts", () => { threadId: ThreadId.makeUnsafe("thread-1"), projectId: asProjectId("project-1"), title: "Thread", - pinned: false, + pinnedAt: null, modelSelection: { provider: "codex", model: "gpt-5-codex", diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 87e2fb5e5b..9a7dff04ca 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -77,7 +77,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" projectId: command.projectId, title: command.title, workspaceRoot: command.workspaceRoot, - pinned: command.pinned ?? false, + pinnedAt: command.pinned ? command.createdAt : null, defaultModelSelection: command.defaultModelSelection ?? null, scripts: [], createdAt: command.createdAt, @@ -105,7 +105,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" projectId: command.projectId, ...(command.title !== undefined ? { title: command.title } : {}), ...(command.workspaceRoot !== undefined ? { workspaceRoot: command.workspaceRoot } : {}), - ...(command.pinned !== undefined ? { pinned: command.pinned } : {}), + ...(command.pinned !== undefined ? { pinnedAt: command.pinned ? occurredAt : null } : {}), ...(command.defaultModelSelection !== undefined ? { defaultModelSelection: command.defaultModelSelection } : {}), @@ -160,7 +160,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" threadId: command.threadId, projectId: command.projectId, title: command.title, - pinned: command.pinned ?? false, + pinnedAt: command.pinned ? command.createdAt : null, modelSelection: command.modelSelection, runtimeMode: command.runtimeMode, interactionMode: command.interactionMode, @@ -257,7 +257,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" payload: { threadId: command.threadId, ...(command.title !== undefined ? { title: command.title } : {}), - ...(command.pinned !== undefined ? { pinned: command.pinned } : {}), + ...(command.pinned !== undefined ? { pinnedAt: command.pinned ? occurredAt : null } : {}), ...(command.modelSelection !== undefined ? { modelSelection: command.modelSelection } : {}), diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index 0ac6f8bb0b..eef819cb6c 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -76,7 +76,7 @@ describe("orchestration projector", () => { id: "thread-1", projectId: "project-1", title: "demo", - pinned: false, + pinnedAt: null, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -136,13 +136,13 @@ describe("orchestration projector", () => { commandId: "cmd-project-pin", payload: { projectId: "project-1", - pinned: true, + pinnedAt: later, updatedAt: later, }, }), ), ); - expect(withPinnedProject.projects[0]?.pinned).toBe(true); + expect(withPinnedProject.projects[0]?.pinnedAt).toBe(later); const withThread = await Effect.runPromise( projectEvent( @@ -183,13 +183,13 @@ describe("orchestration projector", () => { commandId: "cmd-thread-pin", payload: { threadId: "thread-1", - pinned: true, + pinnedAt: later, updatedAt: later, }, }), ), ); - expect(withPinnedThread.threads[0]?.pinned).toBe(true); + expect(withPinnedThread.threads[0]?.pinnedAt).toBe(later); }); it("fails when event payload cannot be decoded by runtime schema", async () => { diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 4a51f8053e..dd41062266 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -183,7 +183,7 @@ export function projectEvent( id: payload.projectId, title: payload.title, workspaceRoot: payload.workspaceRoot, - pinned: payload.pinned, + pinnedAt: payload.pinnedAt, defaultModelSelection: payload.defaultModelSelection, scripts: payload.scripts, createdAt: payload.createdAt, @@ -214,7 +214,7 @@ export function projectEvent( ...(payload.workspaceRoot !== undefined ? { workspaceRoot: payload.workspaceRoot } : {}), - ...(payload.pinned !== undefined ? { pinned: payload.pinned } : {}), + ...(payload.pinnedAt !== undefined ? { pinnedAt: payload.pinnedAt } : {}), ...(payload.defaultModelSelection !== undefined ? { defaultModelSelection: payload.defaultModelSelection } : {}), @@ -256,7 +256,7 @@ export function projectEvent( id: payload.threadId, projectId: payload.projectId, title: payload.title, - pinned: payload.pinned, + pinnedAt: payload.pinnedAt, modelSelection: payload.modelSelection, runtimeMode: payload.runtimeMode, interactionMode: payload.interactionMode, @@ -323,7 +323,7 @@ export function projectEvent( ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { ...(payload.title !== undefined ? { title: payload.title } : {}), - ...(payload.pinned !== undefined ? { pinned: payload.pinned } : {}), + ...(payload.pinnedAt !== undefined ? { pinnedAt: payload.pinnedAt } : {}), ...(payload.modelSelection !== undefined ? { modelSelection: payload.modelSelection } : {}), diff --git a/apps/server/src/persistence/Layers/ProjectionProjects.ts b/apps/server/src/persistence/Layers/ProjectionProjects.ts index 45e3925a7a..4eacf84056 100644 --- a/apps/server/src/persistence/Layers/ProjectionProjects.ts +++ b/apps/server/src/persistence/Layers/ProjectionProjects.ts @@ -14,7 +14,7 @@ import { const ProjectionProjectDbRow = ProjectionProject.mapFields( Struct.assign({ - pinned: Schema.BooleanFromBit, + pinnedAt: Schema.NullOr(Schema.String), defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), }), @@ -33,6 +33,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { title, workspace_root, pinned, + pinned_at, default_model_selection_json, scripts_json, created_at, @@ -43,7 +44,8 @@ const makeProjectionProjectRepository = Effect.gen(function* () { ${row.projectId}, ${row.title}, ${row.workspaceRoot}, - ${row.pinned ? 1 : 0}, + ${row.pinnedAt !== null ? 1 : 0}, + ${row.pinnedAt}, ${row.defaultModelSelection !== null ? JSON.stringify(row.defaultModelSelection) : null}, ${JSON.stringify(row.scripts)}, ${row.createdAt}, @@ -55,6 +57,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { title = excluded.title, workspace_root = excluded.workspace_root, pinned = excluded.pinned, + pinned_at = excluded.pinned_at, default_model_selection_json = excluded.default_model_selection_json, scripts_json = excluded.scripts_json, created_at = excluded.created_at, @@ -72,7 +75,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { project_id AS "projectId", title, workspace_root AS "workspaceRoot", - pinned, + pinned_at AS "pinnedAt", default_model_selection_json AS "defaultModelSelection", scripts_json AS "scripts", created_at AS "createdAt", @@ -92,7 +95,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { project_id AS "projectId", title, workspace_root AS "workspaceRoot", - pinned, + pinned_at AS "pinnedAt", default_model_selection_json AS "defaultModelSelection", scripts_json AS "scripts", created_at AS "createdAt", diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index dc96e1ade9..a9525e5dde 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -27,7 +27,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { projectId: ProjectId.makeUnsafe("project-null-options"), title: "Null options project", workspaceRoot: "/tmp/project-null-options", - pinned: true, + pinnedAt: "2026-03-24T00:00:00.000Z", defaultModelSelection: { provider: "codex", model: "gpt-5.4", @@ -61,7 +61,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { const persisted = yield* projects.getById({ projectId: ProjectId.makeUnsafe("project-null-options"), }); - assert.strictEqual(Option.getOrNull(persisted)?.pinned, true); + assert.strictEqual(Option.getOrNull(persisted)?.pinnedAt, "2026-03-24T00:00:00.000Z"); assert.deepStrictEqual(Option.getOrNull(persisted)?.defaultModelSelection, { provider: "codex", model: "gpt-5.4", @@ -78,7 +78,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { threadId: ThreadId.makeUnsafe("thread-null-options"), projectId: ProjectId.makeUnsafe("project-null-options"), title: "Null options thread", - pinned: true, + pinnedAt: "2026-03-24T00:00:00.000Z", modelSelection: { provider: "claudeAgent", model: "claude-opus-4-6", @@ -117,7 +117,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { const persisted = yield* threads.getById({ threadId: ThreadId.makeUnsafe("thread-null-options"), }); - assert.strictEqual(Option.getOrNull(persisted)?.pinned, true); + assert.strictEqual(Option.getOrNull(persisted)?.pinnedAt, "2026-03-24T00:00:00.000Z"); assert.deepStrictEqual(Option.getOrNull(persisted)?.modelSelection, { provider: "claudeAgent", model: "claude-opus-4-6", diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 51ffea3316..a06a5f187b 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -15,7 +15,7 @@ import { ModelSelection } from "@t3tools/contracts"; const ProjectionThreadDbRow = ProjectionThread.mapFields( Struct.assign({ - pinned: Schema.BooleanFromBit, + pinnedAt: Schema.NullOr(Schema.String), modelSelection: Schema.fromJsonString(ModelSelection), }), ); @@ -33,6 +33,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { project_id, title, pinned, + pinned_at, model_selection_json, runtime_mode, interaction_mode, @@ -48,7 +49,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.threadId}, ${row.projectId}, ${row.title}, - ${row.pinned ? 1 : 0}, + ${row.pinnedAt !== null ? 1 : 0}, + ${row.pinnedAt}, ${JSON.stringify(row.modelSelection)}, ${row.runtimeMode}, ${row.interactionMode}, @@ -65,6 +67,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { project_id = excluded.project_id, title = excluded.title, pinned = excluded.pinned, + pinned_at = excluded.pinned_at, model_selection_json = excluded.model_selection_json, runtime_mode = excluded.runtime_mode, interaction_mode = excluded.interaction_mode, @@ -87,7 +90,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { thread_id AS "threadId", project_id AS "projectId", title, - pinned, + pinned_at AS "pinnedAt", model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", interaction_mode AS "interactionMode", @@ -112,7 +115,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { thread_id AS "threadId", project_id AS "projectId", title, - pinned, + pinned_at AS "pinnedAt", model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", interaction_mode AS "interactionMode", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 6827c42b20..4750273549 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -32,6 +32,7 @@ import Migration0016 from "./Migrations/016_CanonicalizeModelSelections.ts"; import Migration0017 from "./Migrations/017_ProjectionThreadsArchivedAt.ts"; import Migration0018 from "./Migrations/018_ProjectionThreadsArchivedAtIndex.ts"; import Migration0019 from "./Migrations/019_ProjectionPins.ts"; +import Migration0020 from "./Migrations/020_ProjectionPinTimestamps.ts"; /** * Migration loader with all migrations defined inline. @@ -63,6 +64,7 @@ export const migrationEntries = [ [17, "ProjectionThreadsArchivedAt", Migration0017], [18, "ProjectionThreadsArchivedAtIndex", Migration0018], [19, "ProjectionPins", Migration0019], + [20, "ProjectionPinTimestamps", Migration0020], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/020_ProjectionPinTimestamps.ts b/apps/server/src/persistence/Migrations/020_ProjectionPinTimestamps.ts new file mode 100644 index 0000000000..5bef1a75f1 --- /dev/null +++ b/apps/server/src/persistence/Migrations/020_ProjectionPinTimestamps.ts @@ -0,0 +1,38 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const projectColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_projects) + `; + if (!projectColumns.some((column) => column.name === "pinned_at")) { + yield* sql` + ALTER TABLE projection_projects + ADD COLUMN pinned_at TEXT + `; + } + + yield* sql` + UPDATE projection_projects + SET pinned_at = CASE WHEN pinned != 0 THEN updated_at ELSE NULL END + WHERE pinned_at IS NULL + `; + + const threadColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + if (!threadColumns.some((column) => column.name === "pinned_at")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN pinned_at TEXT + `; + } + + yield* sql` + UPDATE projection_threads + SET pinned_at = CASE WHEN pinned != 0 THEN updated_at ELSE NULL END + WHERE pinned_at IS NULL + `; +}); diff --git a/apps/server/src/persistence/Services/ProjectionProjects.ts b/apps/server/src/persistence/Services/ProjectionProjects.ts index bb216dc7c9..17b1353c08 100644 --- a/apps/server/src/persistence/Services/ProjectionProjects.ts +++ b/apps/server/src/persistence/Services/ProjectionProjects.ts @@ -16,7 +16,7 @@ export const ProjectionProject = Schema.Struct({ projectId: ProjectId, title: Schema.String, workspaceRoot: Schema.String, - pinned: Schema.Boolean, + pinnedAt: Schema.NullOr(IsoDateTime), defaultModelSelection: Schema.NullOr(ModelSelection), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index fb6777331c..7937b2a66e 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -24,7 +24,7 @@ export const ProjectionThread = Schema.Struct({ threadId: ThreadId, projectId: ProjectId, title: Schema.String, - pinned: Schema.Boolean, + pinnedAt: Schema.NullOr(IsoDateTime), modelSelection: ModelSelection, runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 1f39bd331e..a70ff3fea0 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -237,7 +237,7 @@ function createSnapshotForTargetUser(options: { id: PROJECT_ID, title: "Project", workspaceRoot: "/repo/project", - pinned: false, + pinnedAt: null, defaultModelSelection: { provider: "codex", model: "gpt-5", @@ -253,7 +253,7 @@ function createSnapshotForTargetUser(options: { id: THREAD_ID, projectId: PROJECT_ID, title: "Browser test thread", - pinned: false, + pinnedAt: null, modelSelection: { provider: "codex", model: "gpt-5", @@ -312,7 +312,7 @@ function addThreadToSnapshot( id: threadId, projectId: PROJECT_ID, title: "New thread", - pinned: false, + pinnedAt: null, modelSelection: { provider: "codex", model: "gpt-5", @@ -360,7 +360,7 @@ function createThreadCreatedEvent(threadId: ThreadId, sequence: number): Orchest threadId, projectId: PROJECT_ID, title: "New thread", - pinned: false, + pinnedAt: null, modelSelection: { provider: "codex", model: "gpt-5", diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index f694f44385..45e0efdac9 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -89,7 +89,7 @@ const makeThread = (input?: { codexThreadId: null, projectId: ProjectId.makeUnsafe("project-1"), title: "Thread", - pinned: false, + pinnedAt: null, modelSelection: { provider: "codex" as const, model: "gpt-5.4" }, runtimeMode: "full-access" as const, interactionMode: "default" as const, @@ -248,7 +248,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { codexThreadId: null, projectId, title: "Thread", - pinned: false, + pinnedAt: null, modelSelection: { provider: "codex", model: "gpt-5.4" }, runtimeMode: "full-access", interactionMode: "default", @@ -285,7 +285,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { codexThreadId: null, projectId, title: "Thread", - pinned: false, + pinnedAt: null, modelSelection: { provider: "codex", model: "gpt-5.4" }, runtimeMode: "full-access", interactionMode: "default", @@ -331,7 +331,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { codexThreadId: null, projectId, title: "Thread", - pinned: false, + pinnedAt: null, modelSelection: { provider: "codex", model: "gpt-5.4" }, runtimeMode: "full-access", interactionMode: "default", diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index acc2966655..a1d70b0485 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -26,7 +26,7 @@ export function buildLocalDraftThread( codexThreadId: null, projectId: draftThread.projectId, title: "New thread", - pinned: false, + pinnedAt: null, modelSelection: fallbackModelSelection, runtimeMode: draftThread.runtimeMode, interactionMode: draftThread.interactionMode, diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index f20884d5e3..613591c6f0 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -76,7 +76,7 @@ function createMinimalSnapshot(): OrchestrationReadModel { id: PROJECT_ID, title: "Project", workspaceRoot: "/repo/project", - pinned: false, + pinnedAt: null, defaultModelSelection: { provider: "codex", model: "gpt-5", @@ -92,7 +92,7 @@ function createMinimalSnapshot(): OrchestrationReadModel { id: THREAD_ID, projectId: PROJECT_ID, title: "Test thread", - pinned: false, + pinnedAt: null, modelSelection: { provider: "codex", model: "gpt-5", diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index f71e3c533d..308689ee92 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -592,7 +592,7 @@ function makeProject(overrides: Partial = {}): Project { id: ProjectId.makeUnsafe("project-1"), name: "Project", cwd: "/tmp/project", - pinned: false, + pinnedAt: null, defaultModelSelection: { provider: "codex", model: "gpt-5.4", @@ -611,7 +611,7 @@ function makeThread(overrides: Partial = {}): Thread { codexThreadId: null, projectId: ProjectId.makeUnsafe("project-1"), title: "Thread", - pinned: false, + pinnedAt: null, modelSelection: { provider: "codex", model: "gpt-5.4", @@ -770,7 +770,7 @@ describe("sortThreadsForSidebar", () => { createdAt: "2026-03-09T10:00:00.000Z", updatedAt: "2026-03-09T10:01:00.000Z", }), - pinned: true, + pinnedAt: "2026-03-09T10:20:00.000Z", }, { ...makeThread({ @@ -778,7 +778,7 @@ describe("sortThreadsForSidebar", () => { createdAt: "2026-03-09T10:05:00.000Z", updatedAt: "2026-03-09T10:05:00.000Z", }), - pinned: false, + pinnedAt: null, }, ], "updated_at", @@ -790,7 +790,7 @@ describe("sortThreadsForSidebar", () => { ]); }); - it("still auto-sorts within the pinned thread bucket", () => { + it("sorts pinned threads by latest pin timestamp before activity recency", () => { const sorted = sortThreadsForSidebar( [ { @@ -799,7 +799,7 @@ describe("sortThreadsForSidebar", () => { createdAt: "2026-03-09T10:01:00.000Z", updatedAt: "2026-03-09T10:01:00.000Z", }), - pinned: true, + pinnedAt: "2026-03-09T10:10:00.000Z", }, { ...makeThread({ @@ -807,7 +807,7 @@ describe("sortThreadsForSidebar", () => { createdAt: "2026-03-09T10:05:00.000Z", updatedAt: "2026-03-09T10:05:00.000Z", }), - pinned: true, + pinnedAt: "2026-03-09T10:20:00.000Z", }, { ...makeThread({ @@ -815,7 +815,7 @@ describe("sortThreadsForSidebar", () => { createdAt: "2026-03-09T10:10:00.000Z", updatedAt: "2026-03-09T10:10:00.000Z", }), - pinned: false, + pinnedAt: null, }, ], "updated_at", @@ -874,7 +874,7 @@ describe("getFallbackThreadIdAfterDelete", () => { projectId: ProjectId.makeUnsafe("project-1"), updatedAt: "2026-03-09T10:05:00.000Z", }), - pinned: false, + pinnedAt: null, }, { ...makeThread({ @@ -882,7 +882,7 @@ describe("getFallbackThreadIdAfterDelete", () => { projectId: ProjectId.makeUnsafe("project-1"), updatedAt: "2026-03-09T10:03:00.000Z", }), - pinned: true, + pinnedAt: "2026-03-09T10:20:00.000Z", }, { ...makeThread({ @@ -890,7 +890,7 @@ describe("getFallbackThreadIdAfterDelete", () => { projectId: ProjectId.makeUnsafe("project-1"), updatedAt: "2026-03-09T10:10:00.000Z", }), - pinned: false, + pinnedAt: null, }, ], deletedThreadId: ThreadId.makeUnsafe("thread-active"), @@ -1010,13 +1010,13 @@ describe("sortProjectsForSidebar", () => { makeProject({ id: ProjectId.makeUnsafe("project-1"), name: "Pinned older project", - pinned: true, + pinnedAt: "2026-03-09T10:20:00.000Z", updatedAt: "2026-03-09T10:01:00.000Z", }), makeProject({ id: ProjectId.makeUnsafe("project-2"), name: "Unpinned newer project", - pinned: false, + pinnedAt: null, updatedAt: "2026-03-09T10:05:00.000Z", }), ], @@ -1030,25 +1030,25 @@ describe("sortProjectsForSidebar", () => { ]); }); - it("still auto-sorts within the pinned bucket", () => { + it("sorts pinned projects by latest pin timestamp before project recency", () => { const sorted = sortProjectsForSidebar( [ makeProject({ id: ProjectId.makeUnsafe("project-1"), name: "Pinned older project", - pinned: true, + pinnedAt: "2026-03-09T10:10:00.000Z", updatedAt: "2026-03-09T10:01:00.000Z", }), makeProject({ id: ProjectId.makeUnsafe("project-2"), name: "Pinned newer project", - pinned: true, + pinnedAt: "2026-03-09T10:20:00.000Z", updatedAt: "2026-03-09T10:05:00.000Z", }), makeProject({ id: ProjectId.makeUnsafe("project-3"), name: "Unpinned project", - pinned: false, + pinnedAt: null, updatedAt: "2026-03-09T10:10:00.000Z", }), ], diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 7e0a31ce36..1a3c6f8a5a 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -16,12 +16,12 @@ type SidebarProject = { name: string; createdAt?: string | undefined; updatedAt?: string | undefined; - pinned?: boolean | undefined; + pinnedAt?: string | null | undefined; }; type SidebarThreadSortInput = Pick & { latestUserMessageAt?: string | null; messages?: Pick[]; - pinned?: boolean | undefined; + pinnedAt?: string | null | undefined; }; export type ThreadTraversalDirection = "previous" | "next"; @@ -450,15 +450,24 @@ function getThreadSortTimestamp( return getLatestUserMessageTimestamp(thread); } +function getPinnedSortTimestamp(input: { pinnedAt?: string | null | undefined }): number | null { + return toSortableTimestamp(input.pinnedAt ?? undefined); +} + export function sortThreadsForSidebar< T extends Pick & SidebarThreadSortInput, >(threads: readonly T[], sortOrder: SidebarThreadSortOrder): T[] { return threads.toSorted((left, right) => { - const leftPinned = left.pinned === true; - const rightPinned = right.pinned === true; + const leftPinnedAt = getPinnedSortTimestamp(left); + const rightPinnedAt = getPinnedSortTimestamp(right); + const leftPinned = leftPinnedAt !== null; + const rightPinned = rightPinnedAt !== null; if (leftPinned !== rightPinned) { return rightPinned ? 1 : -1; } + if (leftPinnedAt !== null && rightPinnedAt !== null && leftPinnedAt !== rightPinnedAt) { + return rightPinnedAt > leftPinnedAt ? 1 : -1; + } const rightTimestamp = getThreadSortTimestamp(right, sortOrder); const leftTimestamp = getThreadSortTimestamp(left, sortOrder); const byTimestamp = @@ -533,11 +542,16 @@ export function sortProjectsForSidebar< } return [...projects].toSorted((left, right) => { - const leftPinned = left.pinned === true; - const rightPinned = right.pinned === true; + const leftPinnedAt = getPinnedSortTimestamp(left); + const rightPinnedAt = getPinnedSortTimestamp(right); + const leftPinned = leftPinnedAt !== null; + const rightPinned = rightPinnedAt !== null; if (leftPinned !== rightPinned) { return rightPinned ? 1 : -1; } + if (leftPinnedAt !== null && rightPinnedAt !== null && leftPinnedAt !== rightPinnedAt) { + return rightPinnedAt > leftPinnedAt ? 1 : -1; + } const rightTimestamp = getProjectSortTimestamp( right, threadsByProjectId.get(right.id) ?? [], diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 5c54c54fe3..b2428471c0 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -4,6 +4,7 @@ import { ChevronRightIcon, FolderIcon, GitPullRequestIcon, + PinIcon, PlusIcon, SettingsIcon, SquarePenIcon, @@ -152,7 +153,7 @@ type SidebarThreadSnapshot = Pick< | "id" | "interactionMode" | "latestTurn" - | "pinned" + | "pinnedAt" | "projectId" | "proposedPlans" | "session" @@ -205,7 +206,7 @@ function toSidebarThreadSnapshot( updatedAt: thread.updatedAt, archivedAt: thread.archivedAt, latestTurn: thread.latestTurn, - pinned: thread.pinned, + pinnedAt: thread.pinnedAt, lastVisitedAt, branch: thread.branch, worktreePath: thread.worktreePath, @@ -879,7 +880,10 @@ export default function Sidebar() { thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null; const clicked = await api.contextMenu.show( [ - { id: "toggle-pin", label: thread.pinned ? "Unpin thread" : "Pin thread" }, + { + id: "toggle-pin", + label: thread.pinnedAt !== null ? "Unpin thread" : "Pin thread", + }, { id: "rename", label: "Rename thread" }, { id: "mark-unread", label: "Mark unread" }, { id: "copy-path", label: "Copy Path" }, @@ -890,7 +894,7 @@ export default function Sidebar() { ); if (clicked === "toggle-pin") { - await setThreadPinned(thread, !thread.pinned); + await setThreadPinned(thread, thread.pinnedAt === null); return; } @@ -1064,7 +1068,7 @@ export default function Sidebar() { [ { id: "toggle-pin", - label: project.pinned ? "Unpin project" : "Pin project", + label: project.pinnedAt !== null ? "Unpin project" : "Pin project", }, { id: "copy-path", label: "Copy Project Path" }, { id: "delete", label: "Remove project", destructive: true }, @@ -1072,7 +1076,7 @@ export default function Sidebar() { position, ); if (clicked === "toggle-pin") { - await setProjectPinned(project, !project.pinned); + await setProjectPinned(project, project.pinnedAt === null); return; } if (clicked === "copy-path") { @@ -1230,7 +1234,7 @@ export default function Sidebar() { const globalPinnedThreads = useMemo( () => sortThreadsForSidebar( - visibleThreads.filter((thread) => thread.pinned), + visibleThreads.filter((thread) => thread.pinnedAt !== null), appSettings.sidebarThreadSortOrder, ), [appSettings.sidebarThreadSortOrder, visibleThreads], @@ -1252,7 +1256,7 @@ export default function Sidebar() { visibleThreads.filter((thread) => thread.projectId === project.id), appSettings.sidebarThreadSortOrder, ); - const projectThreads = allProjectThreads.filter((thread) => !thread.pinned); + const projectThreads = allProjectThreads.filter((thread) => thread.pinnedAt === null); const projectStatus = resolveProjectStatusIndicator( allProjectThreads.map((thread) => threadStatusById.get(thread.id) ?? null), ); @@ -1734,6 +1738,15 @@ export default function Sidebar() { {project.name} + {project.pinnedAt !== null ? ( + + ) : null} { threadId: createdThreadId, projectId: ProjectId.makeUnsafe("project-1"), title: "Created thread", - pinned: false, + pinnedAt: null, modelSelection: { provider: "codex", model: "gpt-5-codex" }, runtimeMode: "full-access", interactionMode: "default", @@ -81,7 +81,7 @@ describe("deriveOrchestrationBatchEffects", () => { threadId, projectId: ProjectId.makeUnsafe("project-1"), title: "Recreated thread", - pinned: false, + pinnedAt: null, modelSelection: { provider: "codex", model: "gpt-5-codex" }, runtimeMode: "full-access", interactionMode: "default", diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index dd0bfa6e15..bbfa9caaf8 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -25,7 +25,7 @@ function makeThread(overrides: Partial = {}): Thread { codexThreadId: null, projectId: ProjectId.makeUnsafe("project-1"), title: "Thread", - pinned: false, + pinnedAt: null, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -54,7 +54,7 @@ function makeState(thread: Thread): AppState { id: ProjectId.makeUnsafe("project-1"), name: "Project", cwd: "/tmp/project", - pinned: false, + pinnedAt: null, defaultModelSelection: { provider: "codex", model: "gpt-5-codex", @@ -99,7 +99,7 @@ function makeReadModelThread(overrides: Partial { id: project2, name: "Project 2", cwd: "/tmp/project-2", - pinned: false, + pinnedAt: null, defaultModelSelection: { provider: "codex", model: DEFAULT_MODEL_BY_PROVIDER.codex, @@ -269,7 +269,7 @@ describe("store read model sync", () => { id: project1, name: "Project 1", cwd: "/tmp/project-1", - pinned: false, + pinnedAt: null, defaultModelSelection: { provider: "codex", model: DEFAULT_MODEL_BY_PROVIDER.codex, @@ -360,7 +360,7 @@ describe("incremental orchestration updates", () => { id: originalProjectId, name: "Project", cwd: "/tmp/project", - pinned: false, + pinnedAt: null, defaultModelSelection: { provider: "codex", model: DEFAULT_MODEL_BY_PROVIDER.codex, @@ -378,7 +378,7 @@ describe("incremental orchestration updates", () => { projectId: recreatedProjectId, title: "Project Recreated", workspaceRoot: "/tmp/project", - pinned: false, + pinnedAt: null, defaultModelSelection: { provider: "codex", model: DEFAULT_MODEL_BY_PROVIDER.codex, diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 082e130d3d..f7003eb3b2 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -147,7 +147,7 @@ function mapThread(thread: OrchestrationThread): Thread { codexThreadId: null, projectId: thread.projectId, title: thread.title, - pinned: thread.pinned, + pinnedAt: thread.pinnedAt, modelSelection: normalizeModelSelection(thread.modelSelection), runtimeMode: thread.runtimeMode, interactionMode: thread.interactionMode, @@ -172,7 +172,7 @@ function mapProject(project: OrchestrationReadModel["projects"][number]): Projec id: project.id, name: project.title, cwd: project.workspaceRoot, - pinned: project.pinned, + pinnedAt: project.pinnedAt, defaultModelSelection: project.defaultModelSelection ? normalizeModelSelection(project.defaultModelSelection) : null, @@ -426,7 +426,7 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve id: event.payload.projectId, title: event.payload.title, workspaceRoot: event.payload.workspaceRoot, - pinned: event.payload.pinned, + pinnedAt: event.payload.pinnedAt, defaultModelSelection: event.payload.defaultModelSelection, scripts: event.payload.scripts, createdAt: event.payload.createdAt, @@ -447,7 +447,7 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve ...project, ...(event.payload.title !== undefined ? { name: event.payload.title } : {}), ...(event.payload.workspaceRoot !== undefined ? { cwd: event.payload.workspaceRoot } : {}), - ...(event.payload.pinned !== undefined ? { pinned: event.payload.pinned } : {}), + ...(event.payload.pinnedAt !== undefined ? { pinnedAt: event.payload.pinnedAt } : {}), ...(event.payload.defaultModelSelection !== undefined ? { defaultModelSelection: event.payload.defaultModelSelection @@ -474,7 +474,7 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve id: event.payload.threadId, projectId: event.payload.projectId, title: event.payload.title, - pinned: event.payload.pinned, + pinnedAt: event.payload.pinnedAt, modelSelection: event.payload.modelSelection, runtimeMode: event.payload.runtimeMode, interactionMode: event.payload.interactionMode, @@ -524,7 +524,7 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({ ...thread, ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), - ...(event.payload.pinned !== undefined ? { pinned: event.payload.pinned } : {}), + ...(event.payload.pinnedAt !== undefined ? { pinnedAt: event.payload.pinnedAt } : {}), ...(event.payload.modelSelection !== undefined ? { modelSelection: normalizeModelSelection(event.payload.modelSelection) } : {}), diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index ae2d43d789..f089a46f60 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -82,7 +82,7 @@ export interface Project { id: ProjectId; name: string; cwd: string; - pinned: boolean; + pinnedAt: string | null; defaultModelSelection: ModelSelection | null; createdAt?: string | undefined; updatedAt?: string | undefined; @@ -94,7 +94,7 @@ export interface Thread { codexThreadId: string | null; projectId: ProjectId; title: string; - pinned: boolean; + pinnedAt: string | null; modelSelection: ModelSelection; runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode; diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index 095e549931..ac8370dccd 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -10,7 +10,7 @@ function makeThread(overrides: Partial = {}): Thread { codexThreadId: null, projectId: ProjectId.makeUnsafe("project-1"), title: "Thread", - pinned: false, + pinnedAt: null, modelSelection: { provider: "codex", model: "gpt-5.3-codex", diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index 65193b100f..dafaff2e23 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -289,7 +289,7 @@ describe("wsNativeApi", () => { projectId: ProjectId.makeUnsafe("project-1"), title: "Project", workspaceRoot: "/tmp/workspace", - pinned: false, + pinnedAt: null, defaultModelSelection: null, scripts: [], createdAt: "2026-02-24T00:00:00.000Z", diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index db759f8876..4ac287f2fd 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -118,7 +118,7 @@ it.effect("decodes historical project.created payloads with a default provider", updatedAt: "2026-01-01T00:00:00.000Z", }); assert.strictEqual(parsed.defaultModelSelection?.provider, "codex"); - assert.strictEqual(parsed.pinned, false); + assert.strictEqual(parsed.pinnedAt, null); }), ); @@ -216,7 +216,7 @@ it.effect("decodes thread.created runtime mode for historical events", () => assert.strictEqual(parsed.runtimeMode, DEFAULT_RUNTIME_MODE); assert.strictEqual(parsed.modelSelection.provider, "codex"); - assert.strictEqual(parsed.pinned, false); + assert.strictEqual(parsed.pinnedAt, null); }), ); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index f9a94ac08a..cb28011c3d 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -145,7 +145,7 @@ export const OrchestrationProject = Schema.Struct({ id: ProjectId, title: TrimmedNonEmptyString, workspaceRoot: TrimmedNonEmptyString, - pinned: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), + pinnedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(() => null)), defaultModelSelection: Schema.NullOr(ModelSelection), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, @@ -275,7 +275,7 @@ export const OrchestrationThread = Schema.Struct({ id: ThreadId, projectId: ProjectId, title: TrimmedNonEmptyString, - pinned: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), + pinnedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(() => null)), modelSelection: ModelSelection, runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode.pipe( @@ -630,7 +630,7 @@ export const ProjectCreatedPayload = Schema.Struct({ projectId: ProjectId, title: TrimmedNonEmptyString, workspaceRoot: TrimmedNonEmptyString, - pinned: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), + pinnedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(() => null)), defaultModelSelection: Schema.NullOr(ModelSelection), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, @@ -641,7 +641,7 @@ export const ProjectMetaUpdatedPayload = Schema.Struct({ projectId: ProjectId, title: Schema.optional(TrimmedNonEmptyString), workspaceRoot: Schema.optional(TrimmedNonEmptyString), - pinned: Schema.optional(Schema.Boolean), + pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)), defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), scripts: Schema.optional(Schema.Array(ProjectScript)), updatedAt: IsoDateTime, @@ -656,7 +656,7 @@ export const ThreadCreatedPayload = Schema.Struct({ threadId: ThreadId, projectId: ProjectId, title: TrimmedNonEmptyString, - pinned: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), + pinnedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(() => null)), modelSelection: ModelSelection, runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), interactionMode: ProviderInteractionMode.pipe( @@ -687,7 +687,7 @@ export const ThreadUnarchivedPayload = Schema.Struct({ export const ThreadMetaUpdatedPayload = Schema.Struct({ threadId: ThreadId, title: Schema.optional(TrimmedNonEmptyString), - pinned: Schema.optional(Schema.Boolean), + pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)), modelSelection: Schema.optional(ModelSelection), branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), From c8e866813d6d46345fc69e673c2cd85e0b1df5f6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 30 Mar 2026 18:22:32 -0700 Subject: [PATCH 8/8] Migrate projection pins to pinned_at timestamps - Drop legacy pinned columns from projection tables - Consolidate pin timestamp migration and update projection writes - Fix sidebar pin icon hover state --- .../persistence/Layers/ProjectionProjects.ts | 3 -- .../persistence/Layers/ProjectionThreads.ts | 3 -- apps/server/src/persistence/Migrations.ts | 2 - .../persistence/Migrations/005_Projections.ts | 2 - .../Migrations/019_ProjectionPins.ts | 8 ++-- .../Migrations/020_ProjectionPinTimestamps.ts | 38 ------------------- apps/web/src/components/Sidebar.tsx | 2 +- 7 files changed, 5 insertions(+), 53 deletions(-) delete mode 100644 apps/server/src/persistence/Migrations/020_ProjectionPinTimestamps.ts diff --git a/apps/server/src/persistence/Layers/ProjectionProjects.ts b/apps/server/src/persistence/Layers/ProjectionProjects.ts index 4eacf84056..b31bb5c9a5 100644 --- a/apps/server/src/persistence/Layers/ProjectionProjects.ts +++ b/apps/server/src/persistence/Layers/ProjectionProjects.ts @@ -32,7 +32,6 @@ const makeProjectionProjectRepository = Effect.gen(function* () { project_id, title, workspace_root, - pinned, pinned_at, default_model_selection_json, scripts_json, @@ -44,7 +43,6 @@ const makeProjectionProjectRepository = Effect.gen(function* () { ${row.projectId}, ${row.title}, ${row.workspaceRoot}, - ${row.pinnedAt !== null ? 1 : 0}, ${row.pinnedAt}, ${row.defaultModelSelection !== null ? JSON.stringify(row.defaultModelSelection) : null}, ${JSON.stringify(row.scripts)}, @@ -56,7 +54,6 @@ const makeProjectionProjectRepository = Effect.gen(function* () { DO UPDATE SET title = excluded.title, workspace_root = excluded.workspace_root, - pinned = excluded.pinned, pinned_at = excluded.pinned_at, default_model_selection_json = excluded.default_model_selection_json, scripts_json = excluded.scripts_json, diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index a06a5f187b..e65cea7ada 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -32,7 +32,6 @@ const makeProjectionThreadRepository = Effect.gen(function* () { thread_id, project_id, title, - pinned, pinned_at, model_selection_json, runtime_mode, @@ -49,7 +48,6 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.threadId}, ${row.projectId}, ${row.title}, - ${row.pinnedAt !== null ? 1 : 0}, ${row.pinnedAt}, ${JSON.stringify(row.modelSelection)}, ${row.runtimeMode}, @@ -66,7 +64,6 @@ const makeProjectionThreadRepository = Effect.gen(function* () { DO UPDATE SET project_id = excluded.project_id, title = excluded.title, - pinned = excluded.pinned, pinned_at = excluded.pinned_at, model_selection_json = excluded.model_selection_json, runtime_mode = excluded.runtime_mode, diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 4750273549..6827c42b20 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -32,7 +32,6 @@ import Migration0016 from "./Migrations/016_CanonicalizeModelSelections.ts"; import Migration0017 from "./Migrations/017_ProjectionThreadsArchivedAt.ts"; import Migration0018 from "./Migrations/018_ProjectionThreadsArchivedAtIndex.ts"; import Migration0019 from "./Migrations/019_ProjectionPins.ts"; -import Migration0020 from "./Migrations/020_ProjectionPinTimestamps.ts"; /** * Migration loader with all migrations defined inline. @@ -64,7 +63,6 @@ export const migrationEntries = [ [17, "ProjectionThreadsArchivedAt", Migration0017], [18, "ProjectionThreadsArchivedAtIndex", Migration0018], [19, "ProjectionPins", Migration0019], - [20, "ProjectionPinTimestamps", Migration0020], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/005_Projections.ts b/apps/server/src/persistence/Migrations/005_Projections.ts index f92abf6d4a..c950da76a1 100644 --- a/apps/server/src/persistence/Migrations/005_Projections.ts +++ b/apps/server/src/persistence/Migrations/005_Projections.ts @@ -9,7 +9,6 @@ export default Effect.gen(function* () { project_id TEXT PRIMARY KEY, title TEXT NOT NULL, workspace_root TEXT NOT NULL, - pinned INTEGER NOT NULL DEFAULT 0, default_model TEXT, scripts_json TEXT NOT NULL, created_at TEXT NOT NULL, @@ -23,7 +22,6 @@ export default Effect.gen(function* () { thread_id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, - pinned INTEGER NOT NULL DEFAULT 0, model TEXT NOT NULL, branch TEXT, worktree_path TEXT, diff --git a/apps/server/src/persistence/Migrations/019_ProjectionPins.ts b/apps/server/src/persistence/Migrations/019_ProjectionPins.ts index f2ecca3ce9..257a31e5e1 100644 --- a/apps/server/src/persistence/Migrations/019_ProjectionPins.ts +++ b/apps/server/src/persistence/Migrations/019_ProjectionPins.ts @@ -7,20 +7,20 @@ export default Effect.gen(function* () { const projectColumns = yield* sql<{ readonly name: string }>` PRAGMA table_info(projection_projects) `; - if (!projectColumns.some((column) => column.name === "pinned")) { + if (!projectColumns.some((column) => column.name === "pinned_at")) { yield* sql` ALTER TABLE projection_projects - ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0 + ADD COLUMN pinned_at TEXT `; } const threadColumns = yield* sql<{ readonly name: string }>` PRAGMA table_info(projection_threads) `; - if (!threadColumns.some((column) => column.name === "pinned")) { + if (!threadColumns.some((column) => column.name === "pinned_at")) { yield* sql` ALTER TABLE projection_threads - ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0 + ADD COLUMN pinned_at TEXT `; } }); diff --git a/apps/server/src/persistence/Migrations/020_ProjectionPinTimestamps.ts b/apps/server/src/persistence/Migrations/020_ProjectionPinTimestamps.ts deleted file mode 100644 index 5bef1a75f1..0000000000 --- a/apps/server/src/persistence/Migrations/020_ProjectionPinTimestamps.ts +++ /dev/null @@ -1,38 +0,0 @@ -import * as Effect from "effect/Effect"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; - -export default Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - const projectColumns = yield* sql<{ readonly name: string }>` - PRAGMA table_info(projection_projects) - `; - if (!projectColumns.some((column) => column.name === "pinned_at")) { - yield* sql` - ALTER TABLE projection_projects - ADD COLUMN pinned_at TEXT - `; - } - - yield* sql` - UPDATE projection_projects - SET pinned_at = CASE WHEN pinned != 0 THEN updated_at ELSE NULL END - WHERE pinned_at IS NULL - `; - - const threadColumns = yield* sql<{ readonly name: string }>` - PRAGMA table_info(projection_threads) - `; - if (!threadColumns.some((column) => column.name === "pinned_at")) { - yield* sql` - ALTER TABLE projection_threads - ADD COLUMN pinned_at TEXT - `; - } - - yield* sql` - UPDATE projection_threads - SET pinned_at = CASE WHEN pinned != 0 THEN updated_at ELSE NULL END - WHERE pinned_at IS NULL - `; -}); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index b2428471c0..200569500e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1742,7 +1742,7 @@ export default function Sidebar() {