diff --git a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts index 2f79ea9d5..947b9f185 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts @@ -43,6 +43,7 @@ function makeSnapshot(input: { id: input.threadId, projectId: input.projectId, title: "Thread", + pinned: false, model: "gpt-5-codex", interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "full-access", diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index bf5846782..2d1d97e52 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -73,6 +73,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, + { key: "mod+shift+p", command: "thread.togglePinned", when: "!terminalFocus" }, { key: "mod+o", command: "editor.openFavorite" }, ]; diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 6ae94105a..fdeb39ce4 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -420,6 +420,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { threadId: event.payload.threadId, projectId: event.payload.projectId, title: event.payload.title, + pinned: event.payload.pinned, model: event.payload.model, runtimeMode: event.payload.runtimeMode, interactionMode: event.payload.interactionMode, @@ -442,6 +443,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { yield* projectionThreadRepository.upsert({ ...existingRow.value, ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), + ...(event.payload.pinned !== undefined ? { pinned: event.payload.pinned } : {}), ...(event.payload.model !== undefined ? { model: event.payload.model } : {}), ...(event.payload.branch !== undefined ? { branch: event.payload.branch } : {}), ...(event.payload.worktreePath !== undefined diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index fc7db5480..fdddc46c3 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -226,6 +226,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { id: ThreadId.makeUnsafe("thread-1"), projectId: asProjectId("project-1"), title: "Thread 1", + pinned: false, model: "gpt-5-codex", interactionMode: "default", runtimeMode: "full-access", diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 5fd38a540..01fc9fcc2 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -53,7 +53,11 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( }), ); const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; -const ProjectionThreadDbRowSchema = ProjectionThread; +const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( + Struct.assign({ + pinned: Schema.Number, + }), +); const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( Struct.assign({ payload: Schema.fromJsonString(Schema.Unknown), @@ -156,6 +160,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { thread_id AS "threadId", project_id AS "projectId", title, + pinned, model, runtime_mode AS "runtimeMode", interaction_mode AS "interactionMode", @@ -528,6 +533,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { id: row.threadId, projectId: row.projectId, title: row.title, + pinned: row.pinned === 1, model: row.model, 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 f95e4db75..352d36108 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -50,6 +50,7 @@ const readModel: OrchestrationReadModel = { id: ThreadId.makeUnsafe("thread-1"), projectId: ProjectId.makeUnsafe("project-a"), title: "Thread A", + pinned: false, model: "gpt-5-codex", interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "full-access", @@ -69,6 +70,7 @@ const readModel: OrchestrationReadModel = { id: ThreadId.makeUnsafe("thread-2"), projectId: ProjectId.makeUnsafe("project-b"), title: "Thread B", + pinned: false, model: "gpt-5-codex", interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "full-access", diff --git a/apps/server/src/orchestration/decider.projectScripts.test.ts b/apps/server/src/orchestration/decider.projectScripts.test.ts index 516d8b2a2..1a4450e12 100644 --- a/apps/server/src/orchestration/decider.projectScripts.test.ts +++ b/apps/server/src/orchestration/decider.projectScripts.test.ts @@ -136,6 +136,7 @@ describe("decider project scripts", () => { threadId: ThreadId.makeUnsafe("thread-1"), projectId: asProjectId("project-1"), title: "Thread", + pinned: false, model: "gpt-5-codex", interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", @@ -243,6 +244,7 @@ describe("decider project scripts", () => { threadId: ThreadId.makeUnsafe("thread-1"), projectId: asProjectId("project-1"), title: "Thread", + pinned: false, model: "gpt-5-codex", interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "full-access", @@ -322,6 +324,7 @@ describe("decider project scripts", () => { threadId: ThreadId.makeUnsafe("thread-1"), projectId: asProjectId("project-1"), title: "Thread", + pinned: false, model: "gpt-5-codex", interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index eea41a2b3..337a7861a 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -156,6 +156,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" threadId: command.threadId, projectId: command.projectId, title: command.title, + pinned: false, model: command.model, runtimeMode: command.runtimeMode, interactionMode: command.interactionMode, @@ -207,6 +208,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" payload: { threadId: command.threadId, ...(command.title !== undefined ? { title: command.title } : {}), + ...(command.pinned !== undefined ? { pinned: command.pinned } : {}), ...(command.model !== undefined ? { model: command.model } : {}), ...(command.branch !== undefined ? { branch: command.branch } : {}), ...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}), diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index 71f5b6bd4..aca89530c 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -73,6 +73,7 @@ describe("orchestration projector", () => { id: "thread-1", projectId: "project-1", title: "demo", + pinned: false, model: "gpt-5-codex", runtimeMode: "full-access", interactionMode: "default", @@ -267,6 +268,59 @@ describe("orchestration projector", () => { expect(afterUpdate.threads[0]?.updatedAt).toBe(updatedAt); }); + it("updates canonical thread pinned state from thread.meta-updated", async () => { + const createdAt = "2026-02-23T08:00:00.000Z"; + const updatedAt = "2026-02-23T08:00:05.000Z"; + const model = createEmptyReadModel(createdAt); + + const afterCreate = await Effect.runPromise( + projectEvent( + model, + makeEvent({ + sequence: 1, + type: "thread.created", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: createdAt, + commandId: "cmd-create", + payload: { + threadId: "thread-1", + projectId: "project-1", + title: "demo", + model: "gpt-5.3-codex", + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }), + ), + ); + + const afterUpdate = await Effect.runPromise( + projectEvent( + afterCreate, + makeEvent({ + sequence: 2, + type: "thread.meta-updated", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: updatedAt, + commandId: "cmd-pin-thread", + payload: { + threadId: "thread-1", + pinned: true, + updatedAt, + }, + }), + ), + ); + + expect(afterUpdate.threads[0]?.pinned).toBe(true); + expect(afterUpdate.threads[0]?.updatedAt).toBe(updatedAt); + }); + it("marks assistant messages completed with non-streaming updates", async () => { const createdAt = "2026-02-23T09:00:00.000Z"; const deltaAt = "2026-02-23T09:00:01.000Z"; diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 015f82a67..1961a7435 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -252,6 +252,7 @@ export function projectEvent( id: payload.threadId, projectId: payload.projectId, title: payload.title, + pinned: payload.pinned, model: payload.model, runtimeMode: payload.runtimeMode, interactionMode: payload.interactionMode, @@ -295,6 +296,7 @@ export function projectEvent( ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { ...(payload.title !== undefined ? { title: payload.title } : {}), + ...(payload.pinned !== undefined ? { pinned: payload.pinned } : {}), ...(payload.model !== undefined ? { model: payload.model } : {}), ...(payload.branch !== undefined ? { branch: payload.branch } : {}), ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}), diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 10192697d..06f8fd557 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 } from "effect"; +import { Effect, Layer, Option, Schema } from "effect"; import { toPersistenceSqlError } from "../Errors.ts"; import { @@ -12,6 +12,40 @@ import { type ProjectionThreadRepositoryShape, } from "../Services/ProjectionThreads.ts"; +const ProjectionThreadDbRowSchema = Schema.Struct({ + threadId: ProjectionThread.fields.threadId, + projectId: ProjectionThread.fields.projectId, + title: ProjectionThread.fields.title, + pinned: Schema.Number, + model: ProjectionThread.fields.model, + runtimeMode: ProjectionThread.fields.runtimeMode, + interactionMode: ProjectionThread.fields.interactionMode, + branch: ProjectionThread.fields.branch, + worktreePath: ProjectionThread.fields.worktreePath, + latestTurnId: ProjectionThread.fields.latestTurnId, + createdAt: ProjectionThread.fields.createdAt, + updatedAt: ProjectionThread.fields.updatedAt, + deletedAt: ProjectionThread.fields.deletedAt, +}); + +function normalizeProjectionThreadRow(row: Schema.Schema.Type) { + return { + threadId: row.threadId, + projectId: row.projectId, + title: row.title, + pinned: row.pinned === 1, + model: row.model, + runtimeMode: row.runtimeMode, + interactionMode: row.interactionMode, + branch: row.branch, + worktreePath: row.worktreePath, + latestTurnId: row.latestTurnId, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + deletedAt: row.deletedAt, + } satisfies ProjectionThread; +} + const makeProjectionThreadRepository = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -23,6 +57,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { thread_id, project_id, title, + pinned, model, runtime_mode, interaction_mode, @@ -37,6 +72,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.threadId}, ${row.projectId}, ${row.title}, + ${row.pinned ? 1 : 0}, ${row.model}, ${row.runtimeMode}, ${row.interactionMode}, @@ -51,6 +87,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { DO UPDATE SET project_id = excluded.project_id, title = excluded.title, + pinned = excluded.pinned, model = excluded.model, runtime_mode = excluded.runtime_mode, interaction_mode = excluded.interaction_mode, @@ -65,13 +102,14 @@ const makeProjectionThreadRepository = Effect.gen(function* () { const getProjectionThreadRow = SqlSchema.findOneOption({ Request: GetProjectionThreadInput, - Result: ProjectionThread, + Result: ProjectionThreadDbRowSchema, execute: ({ threadId }) => sql` SELECT thread_id AS "threadId", project_id AS "projectId", title, + pinned, model, runtime_mode AS "runtimeMode", interaction_mode AS "interactionMode", @@ -88,13 +126,14 @@ const makeProjectionThreadRepository = Effect.gen(function* () { const listProjectionThreadRows = SqlSchema.findAll({ Request: ListProjectionThreadsByProjectInput, - Result: ProjectionThread, + Result: ProjectionThreadDbRowSchema, execute: ({ projectId }) => sql` SELECT thread_id AS "threadId", project_id AS "projectId", title, + pinned, model, runtime_mode AS "runtimeMode", interaction_mode AS "interactionMode", @@ -127,11 +166,13 @@ const makeProjectionThreadRepository = Effect.gen(function* () { const getById: ProjectionThreadRepositoryShape["getById"] = (input) => getProjectionThreadRow(input).pipe( Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.getById:query")), + Effect.map((row) => row.pipe(Option.map(normalizeProjectionThreadRow))), ); const listByProjectId: ProjectionThreadRepositoryShape["listByProjectId"] = (input) => listProjectionThreadRows(input).pipe( Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.listByProjectId:query")), + Effect.map((rows) => rows.map(normalizeProjectionThreadRow)), ); const deleteById: ProjectionThreadRepositoryShape["deleteById"] = (input) => diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 7deb890dd..f073e4222 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -25,6 +25,7 @@ import Migration0010 from "./Migrations/010_ProjectionThreadsRuntimeMode.ts"; import Migration0011 from "./Migrations/011_OrchestrationThreadCreatedRuntimeMode.ts"; import Migration0012 from "./Migrations/012_ProjectionThreadsInteractionMode.ts"; import Migration0013 from "./Migrations/013_ProjectionThreadProposedPlans.ts"; +import Migration0014 from "./Migrations/014_ProjectionThreadsPinned.ts"; import { Effect } from "effect"; /** @@ -51,6 +52,7 @@ const loader = Migrator.fromRecord({ "11_OrchestrationThreadCreatedRuntimeMode": Migration0011, "12_ProjectionThreadsInteractionMode": Migration0012, "13_ProjectionThreadProposedPlans": Migration0013, + "14_ProjectionThreadsPinned": Migration0014, }); /** diff --git a/apps/server/src/persistence/Migrations/014_ProjectionThreadsPinned.ts b/apps/server/src/persistence/Migrations/014_ProjectionThreadsPinned.ts new file mode 100644 index 000000000..7f5bc2525 --- /dev/null +++ b/apps/server/src/persistence/Migrations/014_ProjectionThreadsPinned.ts @@ -0,0 +1,11 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0 + `; +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 7a30870f2..e3eb4bb67 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -23,6 +23,7 @@ export const ProjectionThread = Schema.Struct({ threadId: ThreadId, projectId: ProjectId, title: Schema.String, + pinned: Schema.Boolean, model: Schema.String, runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index faecc7f51..449b60c44 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -209,6 +209,7 @@ function createSnapshotForTargetUser(options: { id: THREAD_ID, projectId: PROJECT_ID, title: "Browser test thread", + pinned: false, model: "gpt-5", interactionMode: "default", runtimeMode: "full-access", @@ -263,6 +264,7 @@ function addThreadToSnapshot( id: threadId, projectId: PROJECT_ID, title: "New thread", + pinned: false, model: "gpt-5", interactionMode: "default", runtimeMode: "full-access", diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 59e290431..f987327d8 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -21,6 +21,7 @@ export function buildLocalDraftThread( codexThreadId: null, projectId: draftThread.projectId, title: "New thread", + pinned: false, model: fallbackModel, runtimeMode: draftThread.runtimeMode, interactionMode: draftThread.interactionMode, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0a9c0371a..945eaf8d4 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2000,6 +2000,21 @@ export default function ChatView({ threadId }: ChatViewProps) { return; } + if (command === "thread.togglePinned") { + if (!serverThread) return; + const api = readNativeApi(); + if (!api) return; + event.preventDefault(); + event.stopPropagation(); + void api.orchestration.dispatchCommand({ + type: "thread.meta.update", + commandId: newCommandId(), + threadId: serverThread.id, + pinned: !serverThread.pinned, + }); + return; + } + const scriptId = projectScriptIdFromCommand(command); if (!scriptId || !activeProject) return; const script = activeProject.scripts.find((entry) => entry.id === scriptId); @@ -2015,6 +2030,7 @@ export default function ChatView({ threadId }: ChatViewProps) { terminalState.terminalOpen, terminalState.activeTerminalId, activeThreadId, + serverThread, closeTerminal, createNewTerminal, setTerminalOpen, diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index ba4c8f432..544dda53c 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: THREAD_ID, projectId: PROJECT_ID, title: "Test thread", + pinned: false, model: "gpt-5", interactionMode: "default", runtimeMode: "full-access", diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index f35f87826..bf0b5f7b3 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -4,6 +4,7 @@ import { hasUnseenCompletion, resolveThreadRowClassName, resolveThreadStatusPill, + sortThreadsForSidebar, shouldClearThreadSelectionOnMouseDown, } from "./Sidebar.logic"; @@ -156,6 +157,34 @@ describe("resolveThreadStatusPill", () => { }); }); +describe("sortThreadsForSidebar", () => { + it("prioritizes pinned threads before recency", () => { + const threads = sortThreadsForSidebar([ + { + id: "thread-1" as never, + pinned: false, + createdAt: "2026-03-09T10:10:00.000Z", + }, + { + id: "thread-2" as never, + pinned: true, + createdAt: "2026-03-09T10:00:00.000Z", + }, + { + id: "thread-3" as never, + pinned: true, + createdAt: "2026-03-09T10:20:00.000Z", + }, + ]); + + expect(threads.map((thread) => thread.id)).toEqual([ + "thread-3" as never, + "thread-2" as never, + "thread-1" as never, + ]); + }); +}); + describe("resolveThreadRowClassName", () => { it("uses the darker selected palette when a thread is both selected and active", () => { const className = resolveThreadRowClassName({ isActive: true, isSelected: true }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 8acbed63a..41c5dd8f5 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -4,6 +4,8 @@ import { findLatestProposedPlan, isLatestTurnSettled } from "../session-logic"; export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; +type SidebarThreadSortInput = Pick; + export interface ThreadStatusPill { label: | "Working" @@ -38,6 +40,19 @@ export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null return !target.closest(THREAD_SELECTION_SAFE_SELECTOR); } +export function sortThreadsForSidebar( + threads: ReadonlyArray, +): T[] { + return threads.toSorted((left, right) => { + if (left.pinned !== right.pinned) { + return Number(right.pinned) - Number(left.pinned); + } + const byDate = Date.parse(right.createdAt) - Date.parse(left.createdAt); + if (byDate !== 0) return byDate; + return right.id.localeCompare(left.id); + }); +} + export function resolveThreadRowClassName(input: { isActive: boolean; isSelected: boolean; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 5ffd6de92..ddd714480 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -3,6 +3,7 @@ import { ChevronRightIcon, FolderIcon, GitPullRequestIcon, + PinIcon, PlusIcon, RocketIcon, SettingsIcon, @@ -86,6 +87,7 @@ import { isNonEmpty as isNonEmptyString } from "effect/String"; import { resolveThreadRowClassName, resolveThreadStatusPill, + sortThreadsForSidebar, shouldClearThreadSelectionOnMouseDown, } from "./Sidebar.logic"; @@ -671,8 +673,11 @@ export default function Sidebar() { async (threadId: ThreadId, position: { x: number; y: number }) => { const api = readNativeApi(); if (!api) return; + const thread = threads.find((t) => t.id === threadId); + if (!thread) return; const clicked = await api.contextMenu.show( [ + { id: "toggle-pin", label: thread.pinned ? "Unpin thread" : "Pin thread" }, { id: "rename", label: "Rename thread" }, { id: "mark-unread", label: "Mark unread" }, { id: "copy-thread-id", label: "Copy Thread ID" }, @@ -680,8 +685,24 @@ export default function Sidebar() { ], position, ); - const thread = threads.find((t) => t.id === threadId); - if (!thread) return; + + if (clicked === "toggle-pin") { + try { + await api.orchestration.dispatchCommand({ + type: "thread.meta.update", + commandId: newCommandId(), + threadId, + pinned: !thread.pinned, + }); + } catch (error) { + toastManager.add({ + type: "error", + title: `Failed to ${thread.pinned ? "unpin" : "pin"} thread`, + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + return; + } if (clicked === "rename") { setRenamingThreadId(threadId); @@ -1292,14 +1313,9 @@ export default function Sidebar() { strategy={verticalListSortingStrategy} > {projects.map((project) => { - const projectThreads = threads - .filter((thread) => thread.projectId === project.id) - .toSorted((a, b) => { - const byDate = - new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); - if (byDate !== 0) return byDate; - return b.id.localeCompare(a.id); - }); + const projectThreads = sortThreadsForSidebar( + threads.filter((thread) => thread.projectId === project.id), + ); const isThreadListExpanded = expandedThreadListsByProject.has(project.id); const hasHiddenThreads = projectThreads.length > THREAD_PREVIEW_LIMIT; const visibleThreads = @@ -1446,6 +1462,12 @@ export default function Sidebar() { }} >
+ {thread.pinned && ( + + )} {prStatus && ( { it("returns labels for non-terminal commands", () => { assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⇧⌘O"); assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "diff.toggle", "Linux"), "Ctrl+D"); + assert.strictEqual( + shortcutLabelForCommand(DEFAULT_BINDINGS, "thread.togglePinned", "Linux"), + "Ctrl+Shift+P", + ); assert.strictEqual( shortcutLabelForCommand(DEFAULT_BINDINGS, "editor.openFavorite", "Linux"), "Ctrl+O", @@ -271,6 +280,29 @@ describe("chat/editor shortcuts", () => { ); }); + it("matches thread.togglePinned shortcut", () => { + assert.strictEqual( + resolveShortcutCommand(event({ key: "p", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false }, + }), + "thread.togglePinned", + ); + assert.strictEqual( + resolveShortcutCommand(event({ key: "p", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + platform: "Linux", + context: { terminalFocus: false }, + }), + "thread.togglePinned", + ); + assert.isNull( + resolveShortcutCommand(event({ key: "p", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + platform: "Linux", + context: { terminalFocus: true }, + }), + ); + }); + it("matches editor.openFavorite shortcut", () => { assert.isTrue( isOpenFavoriteEditorShortcut(event({ key: "o", metaKey: true }), DEFAULT_BINDINGS, { diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 92d084f2d..f43d00ab0 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -16,6 +16,7 @@ function makeThread(overrides: Partial = {}): Thread { codexThreadId: null, projectId: ProjectId.makeUnsafe("project-1"), title: "Thread", + pinned: false, model: "gpt-5-codex", runtimeMode: DEFAULT_RUNTIME_MODE, interactionMode: DEFAULT_INTERACTION_MODE, @@ -55,6 +56,7 @@ function makeReadModelThread(overrides: Partial { expect(next.projects.map((project) => project.id)).toEqual([project2, project1, project3]); }); + + it("syncs pinned thread state from the server read model", () => { + const initialState = makeState(makeThread({ pinned: false })); + const readModel = makeReadModel( + makeReadModelThread({ + pinned: true, + }), + ); + + const next = syncServerReadModel(initialState, readModel); + + expect(next.threads[0]?.pinned).toBe(true); + }); }); diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index faebe4b0f..daffa4d4b 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -260,6 +260,7 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea codexThreadId: null, projectId: thread.projectId, title: thread.title, + pinned: thread.pinned, model: resolveModelSlugForProvider( inferProviderForThreadModel({ model: thread.model, diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index c071fb3f6..6df2bb5fa 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -88,6 +88,7 @@ export interface Thread { codexThreadId: string | null; projectId: ProjectId; title: string; + pinned: boolean; model: string; runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode; diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index 516df6046..d374320ba 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, model: "gpt-5.3-codex", runtimeMode: DEFAULT_RUNTIME_MODE, interactionMode: DEFAULT_INTERACTION_MODE, diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 1b99362c5..4031ac5d8 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -46,6 +46,12 @@ it.effect("parses keybinding rules", () => command: "chat.newLocal", }); assert.strictEqual(parsedLocal.command, "chat.newLocal"); + + const parsedPinToggle = yield* decode(KeybindingRule, { + key: "mod+shift+p", + command: "thread.togglePinned", + }); + assert.strictEqual(parsedPinToggle.command, "thread.togglePinned"); }), ); diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 48821b182..2750fd2bf 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -15,6 +15,7 @@ const STATIC_KEYBINDING_COMMANDS = [ "diff.toggle", "chat.new", "chat.newLocal", + "thread.togglePinned", "editor.openFavorite", ] as const; diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 25a641edb..140cec907 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -155,6 +155,7 @@ it.effect("decodes thread.created runtime mode for historical events", () => }); assert.strictEqual(parsed.runtimeMode, DEFAULT_RUNTIME_MODE); + assert.strictEqual(parsed.pinned, false); }), ); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 17c5eb21d..f4d08e576 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -254,6 +254,7 @@ export const OrchestrationThread = Schema.Struct({ id: ThreadId, projectId: ProjectId, title: TrimmedNonEmptyString, + pinned: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), model: TrimmedNonEmptyString, runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode.pipe( @@ -334,6 +335,7 @@ const ThreadMetaUpdateCommand = Schema.Struct({ commandId: CommandId, threadId: ThreadId, title: Schema.optional(TrimmedNonEmptyString), + pinned: Schema.optional(Schema.Boolean), model: Schema.optional(TrimmedNonEmptyString), branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), @@ -613,6 +615,7 @@ export const ThreadCreatedPayload = Schema.Struct({ threadId: ThreadId, projectId: ProjectId, title: TrimmedNonEmptyString, + pinned: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), model: TrimmedNonEmptyString, runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), interactionMode: ProviderInteractionMode.pipe( @@ -632,6 +635,7 @@ export const ThreadDeletedPayload = Schema.Struct({ export const ThreadMetaUpdatedPayload = Schema.Struct({ threadId: ThreadId, title: Schema.optional(TrimmedNonEmptyString), + pinned: Schema.optional(Schema.Boolean), model: Schema.optional(TrimmedNonEmptyString), branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),