From ebb63fd14a0d9343f742814d06c293f02003be76 Mon Sep 17 00:00:00 2001 From: Emanuele Di Pietro Date: Sun, 5 Apr 2026 23:06:03 +0200 Subject: [PATCH 1/4] Add thread handoff metadata and message source tracking --- .../Layers/CheckpointDiffQuery.test.ts | 105 +- .../Layers/ProjectionPipeline.ts | 521 ++-- .../Layers/ProjectionSnapshotQuery.test.ts | 2 + .../Layers/ProjectionSnapshotQuery.ts | 6 + .../Layers/ProviderCommandReactor.ts | 427 ++- .../orchestration/commandInvariants.test.ts | 2 + .../decider.projectScripts.test.ts | 3 + apps/server/src/orchestration/decider.ts | 86 + apps/server/src/orchestration/handoff.ts | 111 + apps/server/src/orchestration/projector.ts | 4 + .../Layers/ProjectionRepositories.test.ts | 1 + .../Layers/ProjectionThreadMessages.test.ts | 4 + .../Layers/ProjectionThreadMessages.ts | 68 +- .../persistence/Layers/ProjectionThreads.ts | 8 +- apps/server/src/persistence/Migrations.ts | 35 +- .../Migrations/017_ThreadHandoffMetadata.ts | 16 + .../Services/ProjectionThreadMessages.ts | 17 +- .../persistence/Services/ProjectionThreads.ts | 2 + apps/web/src/components/ChatView.browser.tsx | 4 + apps/web/src/components/ChatView.logic.ts | 1 + apps/web/src/components/ChatView.tsx | 2632 +++++++++-------- .../components/KeybindingsToast.browser.tsx | 2 + apps/web/src/components/Sidebar.tsx | 2009 ++++++------- apps/web/src/components/chat/ChatHeader.tsx | 328 +- apps/web/src/hooks/useThreadHandoff.ts | 75 + apps/web/src/lib/threadHandoff.ts | 89 + apps/web/src/store.test.ts | 2 + apps/web/src/store.ts | 1309 ++------ apps/web/src/types.ts | 4 + packages/contracts/src/orchestration.ts | 48 + 30 files changed, 3898 insertions(+), 4023 deletions(-) create mode 100644 apps/server/src/orchestration/handoff.ts create mode 100644 apps/server/src/persistence/Migrations/017_ThreadHandoffMetadata.ts create mode 100644 apps/web/src/hooks/useThreadHandoff.ts create mode 100644 apps/web/src/lib/threadHandoff.ts diff --git a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts index c66c529b9a..66c617ad98 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts @@ -1,38 +1,83 @@ -import { CheckpointRef, ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; -import { Effect, Layer, Option } from "effect"; +import { + CheckpointRef, + DEFAULT_PROVIDER_INTERACTION_MODE, + ProjectId, + ThreadId, + TurnId, + type OrchestrationReadModel, +} from "@t3tools/contracts"; +import { Effect, Layer } from "effect"; import { describe, expect, it } from "vitest"; -import { - ProjectionSnapshotQuery, - type ProjectionThreadCheckpointContext, -} from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; import { checkpointRefForThreadTurn } from "../Utils.ts"; import { CheckpointDiffQueryLive } from "./CheckpointDiffQuery.ts"; import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts"; import { CheckpointDiffQuery } from "../Services/CheckpointDiffQuery.ts"; -function makeThreadCheckpointContext(input: { +function makeSnapshot(input: { readonly projectId: ProjectId; readonly threadId: ThreadId; readonly workspaceRoot: string; readonly worktreePath: string | null; readonly checkpointTurnCount: number; readonly checkpointRef: CheckpointRef; -}): ProjectionThreadCheckpointContext { +}): OrchestrationReadModel { return { - threadId: input.threadId, - projectId: input.projectId, - workspaceRoot: input.workspaceRoot, - worktreePath: input.worktreePath, - checkpoints: [ + snapshotSequence: 0, + updatedAt: "2026-01-01T00:00:00.000Z", + projects: [ + { + id: input.projectId, + title: "Project", + workspaceRoot: input.workspaceRoot, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: null, + }, + ], + threads: [ { - turnId: TurnId.makeUnsafe("turn-1"), - checkpointTurnCount: input.checkpointTurnCount, - checkpointRef: input.checkpointRef, - status: "ready", - files: [], - assistantMessageId: null, - completedAt: "2026-01-01T00:00:00.000Z", + id: input.threadId, + projectId: input.projectId, + title: "Thread", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: input.worktreePath, + latestTurn: { + turnId: TurnId.makeUnsafe("turn-1"), + state: "completed", + requestedAt: "2026-01-01T00:00:00.000Z", + startedAt: "2026-01-01T00:00:00.000Z", + completedAt: "2026-01-01T00:00:00.000Z", + assistantMessageId: null, + }, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: null, + handoff: null, + messages: [], + activities: [], + proposedPlans: [], + checkpoints: [ + { + turnId: TurnId.makeUnsafe("turn-1"), + checkpointTurnCount: input.checkpointTurnCount, + checkpointRef: input.checkpointRef, + status: "ready", + files: [], + assistantMessageId: null, + completedAt: "2026-01-01T00:00:00.000Z", + }, + ], + session: null, }, ], }; @@ -50,7 +95,7 @@ describe("CheckpointDiffQueryLive", () => { readonly cwd: string; }> = []; - const threadCheckpointContext = makeThreadCheckpointContext({ + const snapshot = makeSnapshot({ projectId, threadId, workspaceRoot: "/tmp/workspace", @@ -80,12 +125,7 @@ describe("CheckpointDiffQueryLive", () => { Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)), Layer.provideMerge( Layer.succeed(ProjectionSnapshotQuery, { - getSnapshot: () => - Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), - getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), - getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), - getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), + getSnapshot: () => Effect.succeed(snapshot), }), ), ); @@ -135,11 +175,12 @@ describe("CheckpointDiffQueryLive", () => { Layer.provideMerge( Layer.succeed(ProjectionSnapshotQuery, { getSnapshot: () => - Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), - getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), - getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), - getThreadCheckpointContext: () => Effect.succeed(Option.none()), + Effect.succeed({ + snapshotSequence: 0, + projects: [], + threads: [], + updatedAt: "2026-01-01T00:00:00.000Z", + } satisfies OrchestrationReadModel), }), ), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 0844cf8bb0..133e0eff8f 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -3,6 +3,7 @@ import { type ChatAttachment, type OrchestrationEvent, } from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import { Effect, FileSystem, Layer, Option, Path, Stream } from "effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -76,7 +77,7 @@ interface AttachmentSideEffects { readonly prunedThreadRelativePaths: Map>; } -const materializeAttachmentsForProjection = Effect.fn("materializeAttachmentsForProjection")( +const materializeAttachmentsForProjection = Effect.fn( (input: { readonly attachments: ReadonlyArray }) => Effect.succeed(input.attachments.length === 0 ? [] : input.attachments), ); @@ -237,145 +238,124 @@ function collectThreadAttachmentRelativePaths( return relativePaths; } -const runAttachmentSideEffects = Effect.fn("runAttachmentSideEffects")(function* ( - sideEffects: AttachmentSideEffects, -) { +const runAttachmentSideEffects = Effect.fn(function* (sideEffects: AttachmentSideEffects) { const serverConfig = yield* Effect.service(ServerConfig); const fileSystem = yield* Effect.service(FileSystem.FileSystem); const path = yield* Effect.service(Path.Path); const attachmentsRootDir = serverConfig.attachmentsDir; - const readAttachmentRootEntries = fileSystem - .readDirectory(attachmentsRootDir, { recursive: false }) - .pipe(Effect.catch(() => Effect.succeed([] as Array))); - - const removeDeletedThreadAttachmentEntry = Effect.fn("removeDeletedThreadAttachmentEntry")( - function* (threadSegment: string, entry: string) { - const normalizedEntry = entry.replace(/^[/\\]+/, "").replace(/\\/g, "/"); - if (normalizedEntry.length === 0 || normalizedEntry.includes("/")) { - return; - } - const attachmentId = parseAttachmentIdFromRelativePath(normalizedEntry); - if (!attachmentId) { - return; - } - const attachmentThreadSegment = parseThreadSegmentFromAttachmentId(attachmentId); - if (!attachmentThreadSegment || attachmentThreadSegment !== threadSegment) { - return; - } - yield* fileSystem.remove(path.join(attachmentsRootDir, normalizedEntry), { - force: true, - }); - }, - ); - - const deleteThreadAttachments = Effect.fn("deleteThreadAttachments")(function* ( - threadId: string, - ) { - const threadSegment = toSafeThreadAttachmentSegment(threadId); - if (!threadSegment) { - yield* Effect.logWarning("skipping attachment cleanup for unsafe thread id", { - threadId, - }); - return; - } - - const entries = yield* readAttachmentRootEntries; - yield* Effect.forEach( - entries, - (entry) => removeDeletedThreadAttachmentEntry(threadSegment, entry), - { - concurrency: 1, - }, - ); - }); - - const pruneThreadAttachmentEntry = Effect.fn("pruneThreadAttachmentEntry")(function* ( - threadSegment: string, - keptThreadRelativePaths: Set, - entry: string, - ) { - const relativePath = entry.replace(/^[/\\]+/, "").replace(/\\/g, "/"); - if (relativePath.length === 0 || relativePath.includes("/")) { - return; - } - const attachmentId = parseAttachmentIdFromRelativePath(relativePath); - if (!attachmentId) { - return; - } - const attachmentThreadSegment = parseThreadSegmentFromAttachmentId(attachmentId); - if (!attachmentThreadSegment || attachmentThreadSegment !== threadSegment) { - return; - } - - const absolutePath = path.join(attachmentsRootDir, relativePath); - const fileInfo = yield* fileSystem - .stat(absolutePath) - .pipe(Effect.catch(() => Effect.succeed(null))); - if (!fileInfo || fileInfo.type !== "File") { - return; - } - - if (!keptThreadRelativePaths.has(relativePath)) { - yield* fileSystem.remove(absolutePath, { force: true }); - } - }); - - const pruneThreadAttachments = Effect.fn("pruneThreadAttachments")(function* ( - threadId: string, - keptThreadRelativePaths: Set, - ) { - if (sideEffects.deletedThreadIds.has(threadId)) { - return; - } - - const threadSegment = toSafeThreadAttachmentSegment(threadId); - if (!threadSegment) { - yield* Effect.logWarning("skipping attachment prune for unsafe thread id", { threadId }); - return; - } - - const entries = yield* readAttachmentRootEntries; - yield* Effect.forEach( - entries, - (entry) => pruneThreadAttachmentEntry(threadSegment, keptThreadRelativePaths, entry), - { concurrency: 1 }, - ); - }); - yield* Effect.forEach(sideEffects.deletedThreadIds, deleteThreadAttachments, { - concurrency: 1, - }); + yield* Effect.forEach( + sideEffects.deletedThreadIds, + (threadId) => + Effect.gen(function* () { + const threadSegment = toSafeThreadAttachmentSegment(threadId); + if (!threadSegment) { + yield* Effect.logWarning("skipping attachment cleanup for unsafe thread id", { + threadId, + }); + return; + } + const entries = yield* fileSystem + .readDirectory(attachmentsRootDir, { recursive: false }) + .pipe(Effect.catch(() => Effect.succeed([] as Array))); + yield* Effect.forEach( + entries, + (entry) => + Effect.gen(function* () { + const normalizedEntry = entry.replace(/^[/\\]+/, "").replace(/\\/g, "/"); + if (normalizedEntry.length === 0 || normalizedEntry.includes("/")) { + return; + } + const attachmentId = parseAttachmentIdFromRelativePath(normalizedEntry); + if (!attachmentId) { + return; + } + const attachmentThreadSegment = parseThreadSegmentFromAttachmentId(attachmentId); + if (!attachmentThreadSegment || attachmentThreadSegment !== threadSegment) { + return; + } + yield* fileSystem.remove(path.join(attachmentsRootDir, normalizedEntry), { + force: true, + }); + }), + { concurrency: 1 }, + ); + }), + { concurrency: 1 }, + ); yield* Effect.forEach( sideEffects.prunedThreadRelativePaths.entries(), - ([threadId, keptThreadRelativePaths]) => - pruneThreadAttachments(threadId, keptThreadRelativePaths), + ([threadId, keptThreadRelativePaths]) => { + if (sideEffects.deletedThreadIds.has(threadId)) { + return Effect.void; + } + return Effect.gen(function* () { + const threadSegment = toSafeThreadAttachmentSegment(threadId); + if (!threadSegment) { + yield* Effect.logWarning("skipping attachment prune for unsafe thread id", { threadId }); + return; + } + const entries = yield* fileSystem + .readDirectory(attachmentsRootDir, { recursive: false }) + .pipe(Effect.catch(() => Effect.succeed([] as Array))); + yield* Effect.forEach( + entries, + (entry) => + Effect.gen(function* () { + const relativePath = entry.replace(/^[/\\]+/, "").replace(/\\/g, "/"); + if (relativePath.length === 0 || relativePath.includes("/")) { + return; + } + const attachmentId = parseAttachmentIdFromRelativePath(relativePath); + if (!attachmentId) { + return; + } + const attachmentThreadSegment = parseThreadSegmentFromAttachmentId(attachmentId); + if (!attachmentThreadSegment || attachmentThreadSegment !== threadSegment) { + return; + } + + const absolutePath = path.join(attachmentsRootDir, relativePath); + const fileInfo = yield* fileSystem + .stat(absolutePath) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (!fileInfo || fileInfo.type !== "File") { + return; + } + + if (!keptThreadRelativePaths.has(relativePath)) { + yield* fileSystem.remove(absolutePath, { force: true }); + } + }), + { concurrency: 1 }, + ); + }); + }, { concurrency: 1 }, ); }); -const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjectionPipeline")( - function* () { - const sql = yield* SqlClient.SqlClient; - const eventStore = yield* OrchestrationEventStore; - const projectionStateRepository = yield* ProjectionStateRepository; - const projectionProjectRepository = yield* ProjectionProjectRepository; - const projectionThreadRepository = yield* ProjectionThreadRepository; - const projectionThreadMessageRepository = yield* ProjectionThreadMessageRepository; - const projectionThreadProposedPlanRepository = yield* ProjectionThreadProposedPlanRepository; - const projectionThreadActivityRepository = yield* ProjectionThreadActivityRepository; - const projectionThreadSessionRepository = yield* ProjectionThreadSessionRepository; - const projectionTurnRepository = yield* ProjectionTurnRepository; - const projectionPendingApprovalRepository = yield* ProjectionPendingApprovalRepository; - - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const serverConfig = yield* ServerConfig; - - const applyProjectsProjection: ProjectorDefinition["apply"] = Effect.fn( - "applyProjectsProjection", - )(function* (event, _attachmentSideEffects) { +const makeOrchestrationProjectionPipeline = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const eventStore = yield* OrchestrationEventStore; + const projectionStateRepository = yield* ProjectionStateRepository; + const projectionProjectRepository = yield* ProjectionProjectRepository; + const projectionThreadRepository = yield* ProjectionThreadRepository; + const projectionThreadMessageRepository = yield* ProjectionThreadMessageRepository; + const projectionThreadProposedPlanRepository = yield* ProjectionThreadProposedPlanRepository; + const projectionThreadActivityRepository = yield* ProjectionThreadActivityRepository; + const projectionThreadSessionRepository = yield* ProjectionThreadSessionRepository; + const projectionTurnRepository = yield* ProjectionTurnRepository; + const projectionPendingApprovalRepository = yield* ProjectionPendingApprovalRepository; + + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const serverConfig = yield* ServerConfig; + + const applyProjectsProjection: ProjectorDefinition["apply"] = (event, _attachmentSideEffects) => + Effect.gen(function* () { switch (event.type) { case "project.created": yield* projectionProjectRepository.upsert({ @@ -432,9 +412,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti } }); - const applyThreadsProjection: ProjectorDefinition["apply"] = Effect.fn( - "applyThreadsProjection", - )(function* (event, attachmentSideEffects) { + const applyThreadsProjection: ProjectorDefinition["apply"] = (event, attachmentSideEffects) => + Effect.gen(function* () { switch (event.type) { case "thread.created": yield* projectionThreadRepository.upsert({ @@ -447,43 +426,13 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti branch: event.payload.branch, worktreePath: event.payload.worktreePath, latestTurnId: null, + handoff: event.payload.handoff, createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, - archivedAt: null, deletedAt: null, }); return; - case "thread.archived": { - const existingRow = yield* projectionThreadRepository.getById({ - threadId: event.payload.threadId, - }); - if (Option.isNone(existingRow)) { - return; - } - yield* projectionThreadRepository.upsert({ - ...existingRow.value, - archivedAt: event.payload.archivedAt, - updatedAt: event.payload.updatedAt, - }); - return; - } - - case "thread.unarchived": { - const existingRow = yield* projectionThreadRepository.getById({ - threadId: event.payload.threadId, - }); - if (Option.isNone(existingRow)) { - return; - } - yield* projectionThreadRepository.upsert({ - ...existingRow.value, - archivedAt: null, - updatedAt: event.payload.updatedAt, - }); - return; - } - case "thread.meta-updated": { const existingRow = yield* projectionThreadRepository.getById({ threadId: event.payload.threadId, @@ -501,6 +450,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...(event.payload.worktreePath !== undefined ? { worktreePath: event.payload.worktreePath } : {}), + ...(event.payload.handoff !== undefined ? { handoff: event.payload.handoff } : {}), updatedAt: event.payload.updatedAt, }); return; @@ -618,33 +568,31 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti } }); - const applyThreadMessagesProjection: ProjectorDefinition["apply"] = Effect.fn( - "applyThreadMessagesProjection", - )(function* (event, attachmentSideEffects) { + const applyThreadMessagesProjection: ProjectorDefinition["apply"] = ( + event, + attachmentSideEffects, + ) => + Effect.gen(function* () { switch (event.type) { case "thread.message-sent": { - const existingMessage = yield* projectionThreadMessageRepository.getByMessageId({ - messageId: event.payload.messageId, - }); - const previousMessage = Option.getOrUndefined(existingMessage); - const nextText = Option.match(existingMessage, { - onNone: () => event.payload.text, - onSome: (message) => { - if (event.payload.streaming) { - return `${message.text}${event.payload.text}`; - } - if (event.payload.text.length === 0) { - return message.text; - } - return event.payload.text; - }, + const existingRows = yield* projectionThreadMessageRepository.listByThreadId({ + threadId: event.payload.threadId, }); + const existingMessage = existingRows.find( + (row) => row.messageId === event.payload.messageId, + ); + const nextText = + existingMessage && event.payload.streaming + ? `${existingMessage.text}${event.payload.text}` + : existingMessage && event.payload.text.length === 0 + ? existingMessage.text + : event.payload.text; const nextAttachments = event.payload.attachments !== undefined ? yield* materializeAttachmentsForProjection({ attachments: event.payload.attachments, }) - : previousMessage?.attachments; + : existingMessage?.attachments; yield* projectionThreadMessageRepository.upsert({ messageId: event.payload.messageId, threadId: event.payload.threadId, @@ -653,7 +601,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti text: nextText, ...(nextAttachments !== undefined ? { attachments: [...nextAttachments] } : {}), isStreaming: event.payload.streaming, - createdAt: previousMessage?.createdAt ?? event.payload.createdAt, + source: event.payload.source, + createdAt: existingMessage?.createdAt ?? event.payload.createdAt, updatedAt: event.payload.updatedAt, }); return; @@ -697,9 +646,11 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti } }); - const applyThreadProposedPlansProjection: ProjectorDefinition["apply"] = Effect.fn( - "applyThreadProposedPlansProjection", - )(function* (event, _attachmentSideEffects) { + const applyThreadProposedPlansProjection: ProjectorDefinition["apply"] = ( + event, + _attachmentSideEffects, + ) => + Effect.gen(function* () { switch (event.type) { case "thread.proposed-plan-upserted": yield* projectionThreadProposedPlanRepository.upsert({ @@ -748,9 +699,11 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti } }); - const applyThreadActivitiesProjection: ProjectorDefinition["apply"] = Effect.fn( - "applyThreadActivitiesProjection", - )(function* (event, _attachmentSideEffects) { + const applyThreadActivitiesProjection: ProjectorDefinition["apply"] = ( + event, + _attachmentSideEffects, + ) => + Effect.gen(function* () { switch (event.type) { case "thread.activity-appended": yield* projectionThreadActivityRepository.upsert({ @@ -800,9 +753,11 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti } }); - const applyThreadSessionsProjection: ProjectorDefinition["apply"] = Effect.fn( - "applyThreadSessionsProjection", - )(function* (event, _attachmentSideEffects) { + const applyThreadSessionsProjection: ProjectorDefinition["apply"] = ( + event, + _attachmentSideEffects, + ) => + Effect.gen(function* () { if (event.type !== "thread.session-set") { return; } @@ -817,9 +772,11 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti }); }); - const applyThreadTurnsProjection: ProjectorDefinition["apply"] = Effect.fn( - "applyThreadTurnsProjection", - )(function* (event, _attachmentSideEffects) { + const applyThreadTurnsProjection: ProjectorDefinition["apply"] = ( + event, + _attachmentSideEffects, + ) => + Effect.gen(function* () { switch (event.type) { case "thread.turn-start-requested": { yield* projectionTurnRepository.replacePendingTurnStart({ @@ -1073,11 +1030,13 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti } }); - const applyCheckpointsProjection: ProjectorDefinition["apply"] = () => Effect.void; + const applyCheckpointsProjection: ProjectorDefinition["apply"] = () => Effect.void; - const applyPendingApprovalsProjection: ProjectorDefinition["apply"] = Effect.fn( - "applyPendingApprovalsProjection", - )(function* (event, _attachmentSideEffects) { + const applyPendingApprovalsProjection: ProjectorDefinition["apply"] = ( + event, + _attachmentSideEffects, + ) => + Effect.gen(function* () { switch (event.type) { case "thread.activity-appended": { const requestId = @@ -1163,49 +1122,47 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti } }); - const projectors: ReadonlyArray = [ - { - name: ORCHESTRATION_PROJECTOR_NAMES.projects, - apply: applyProjectsProjection, - }, - { - name: ORCHESTRATION_PROJECTOR_NAMES.threadMessages, - apply: applyThreadMessagesProjection, - }, - { - name: ORCHESTRATION_PROJECTOR_NAMES.threadProposedPlans, - apply: applyThreadProposedPlansProjection, - }, - { - name: ORCHESTRATION_PROJECTOR_NAMES.threadActivities, - apply: applyThreadActivitiesProjection, - }, - { - name: ORCHESTRATION_PROJECTOR_NAMES.threadSessions, - apply: applyThreadSessionsProjection, - }, - { - name: ORCHESTRATION_PROJECTOR_NAMES.threadTurns, - apply: applyThreadTurnsProjection, - }, - { - name: ORCHESTRATION_PROJECTOR_NAMES.checkpoints, - apply: applyCheckpointsProjection, - }, - { - name: ORCHESTRATION_PROJECTOR_NAMES.pendingApprovals, - apply: applyPendingApprovalsProjection, - }, - { - name: ORCHESTRATION_PROJECTOR_NAMES.threads, - apply: applyThreadsProjection, - }, - ]; - - const runProjectorForEvent = Effect.fn("runProjectorForEvent")(function* ( - projector: ProjectorDefinition, - event: OrchestrationEvent, - ) { + const projectors: ReadonlyArray = [ + { + name: ORCHESTRATION_PROJECTOR_NAMES.projects, + apply: applyProjectsProjection, + }, + { + name: ORCHESTRATION_PROJECTOR_NAMES.threadMessages, + apply: applyThreadMessagesProjection, + }, + { + name: ORCHESTRATION_PROJECTOR_NAMES.threadProposedPlans, + apply: applyThreadProposedPlansProjection, + }, + { + name: ORCHESTRATION_PROJECTOR_NAMES.threadActivities, + apply: applyThreadActivitiesProjection, + }, + { + name: ORCHESTRATION_PROJECTOR_NAMES.threadSessions, + apply: applyThreadSessionsProjection, + }, + { + name: ORCHESTRATION_PROJECTOR_NAMES.threadTurns, + apply: applyThreadTurnsProjection, + }, + { + name: ORCHESTRATION_PROJECTOR_NAMES.checkpoints, + apply: applyCheckpointsProjection, + }, + { + name: ORCHESTRATION_PROJECTOR_NAMES.pendingApprovals, + apply: applyPendingApprovalsProjection, + }, + { + name: ORCHESTRATION_PROJECTOR_NAMES.threads, + apply: applyThreadsProjection, + }, + ]; + + const runProjectorForEvent = (projector: ProjectorDefinition, event: OrchestrationEvent) => + Effect.gen(function* () { const attachmentSideEffects: AttachmentSideEffects = { deletedThreadIds: new Set(), prunedThreadRelativePaths: new Map>(), @@ -1235,65 +1192,65 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ); }); - const bootstrapProjector = (projector: ProjectorDefinition) => - projectionStateRepository - .getByProjector({ - projector: projector.name, - }) - .pipe( - Effect.flatMap((stateRow) => - Stream.runForEach( - eventStore.readFromSequence( - Option.isSome(stateRow) ? stateRow.value.lastAppliedSequence : 0, - ), - (event) => runProjectorForEvent(projector, event), + const bootstrapProjector = (projector: ProjectorDefinition) => + projectionStateRepository + .getByProjector({ + projector: projector.name, + }) + .pipe( + Effect.flatMap((stateRow) => + Stream.runForEach( + eventStore.readFromSequence( + Option.isSome(stateRow) ? stateRow.value.lastAppliedSequence : 0, ), + (event) => runProjectorForEvent(projector, event), ), - ); - - const projectEvent: OrchestrationProjectionPipelineShape["projectEvent"] = (event) => - Effect.forEach(projectors, (projector) => runProjectorForEvent(projector, event), { - concurrency: 1, - }).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - Effect.provideService(ServerConfig, serverConfig), - Effect.asVoid, - Effect.catchTag("SqlError", (sqlError) => - Effect.fail(toPersistenceSqlError("ProjectionPipeline.projectEvent:query")(sqlError)), ), ); - const bootstrap: OrchestrationProjectionPipelineShape["bootstrap"] = Effect.forEach( - projectors, - bootstrapProjector, - { concurrency: 1 }, - ).pipe( + const projectEvent: OrchestrationProjectionPipelineShape["projectEvent"] = (event) => + Effect.forEach(projectors, (projector) => runProjectorForEvent(projector, event), { + concurrency: 1, + }).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), Effect.provideService(ServerConfig, serverConfig), Effect.asVoid, - Effect.tap(() => - Effect.logDebug("orchestration projection pipeline bootstrapped").pipe( - Effect.annotateLogs({ projectors: projectors.length }), - ), - ), Effect.catchTag("SqlError", (sqlError) => - Effect.fail(toPersistenceSqlError("ProjectionPipeline.bootstrap:query")(sqlError)), + Effect.fail(toPersistenceSqlError("ProjectionPipeline.projectEvent:query")(sqlError)), ), ); - return { - bootstrap, - projectEvent, - } satisfies OrchestrationProjectionPipelineShape; - }, -); + const bootstrap: OrchestrationProjectionPipelineShape["bootstrap"] = Effect.forEach( + projectors, + bootstrapProjector, + { concurrency: 1 }, + ).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.provideService(ServerConfig, serverConfig), + Effect.asVoid, + Effect.tap(() => + Effect.log("orchestration projection pipeline bootstrapped").pipe( + Effect.annotateLogs({ projectors: projectors.length }), + ), + ), + Effect.catchTag("SqlError", (sqlError) => + Effect.fail(toPersistenceSqlError("ProjectionPipeline.bootstrap:query")(sqlError)), + ), + ); + + return { + bootstrap, + projectEvent, + } satisfies OrchestrationProjectionPipelineShape; +}); export const OrchestrationProjectionPipelineLive = Layer.effect( OrchestrationProjectionPipeline, - makeOrchestrationProjectionPipeline(), + makeOrchestrationProjectionPipeline, ).pipe( + Layer.provideMerge(NodeServices.layer), Layer.provideMerge(ProjectionProjectRepositoryLive), Layer.provideMerge(ProjectionThreadRepositoryLive), Layer.provideMerge(ProjectionThreadMessageRepositoryLive), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index c038bc9d2c..5269e8f9b3 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -281,6 +281,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { updatedAt: "2026-02-24T00:00:03.000Z", archivedAt: null, deletedAt: null, + handoff: null, messages: [ { id: asMessageId("message-1"), @@ -288,6 +289,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { text: "hello from projection", turnId: asTurnId("turn-1"), streaming: false, + source: "native", createdAt: "2026-02-24T00:00:04.000Z", updatedAt: "2026-02-24T00:00:05.000Z", }, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index da7c695674..171ded39eb 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -16,6 +16,7 @@ import { type OrchestrationSession, type OrchestrationThread, type OrchestrationThreadActivity, + ThreadHandoff, ModelSelection, ProjectId, ThreadId, @@ -62,6 +63,7 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( Struct.assign({ + handoff: Schema.NullOr(Schema.fromJsonString(ThreadHandoff)), modelSelection: Schema.fromJsonString(ModelSelection), }), ); @@ -198,6 +200,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { branch, worktree_path AS "worktreePath", latest_turn_id AS "latestTurnId", + handoff_json AS "handoff", created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", @@ -220,6 +223,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { text, attachments_json AS "attachments", is_streaming AS "isStreaming", + source, created_at AS "createdAt", updated_at AS "updatedAt" FROM projection_thread_messages @@ -551,6 +555,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ...(row.attachments !== null ? { attachments: row.attachments } : {}), turnId: row.turnId, streaming: row.isStreaming === 1, + source: row.source, createdAt: row.createdAt, updatedAt: row.updatedAt, }); @@ -677,6 +682,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { updatedAt: row.updatedAt, archivedAt: row.archivedAt, deletedAt: row.deletedAt, + handoff: row.handoff, messages: messagesByThread.get(row.threadId) ?? [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], activities: activitiesByThread.get(row.threadId) ?? [], diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 419e3f3bf2..5b02f4f3d2 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -1,10 +1,13 @@ import { type ChatAttachment, CommandId, + DEFAULT_GIT_TEXT_GENERATION_MODEL, EventId, type ModelSelection, type OrchestrationEvent, + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, ProviderKind, + type ProviderStartOptions, type OrchestrationSession, ThreadId, type ProviderSession, @@ -16,16 +19,15 @@ import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; import { GitCore } from "../../git/Services/GitCore.ts"; -import { increment, orchestrationEventsProcessedTotal } from "../../observability/Metrics.ts"; import { ProviderAdapterRequestError, ProviderServiceError } from "../../provider/Errors.ts"; import { TextGeneration } from "../../git/Services/TextGeneration.ts"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import { buildHandoffBootstrapText, hasNativeAssistantMessagesBefore } from "../handoff.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProviderCommandReactor, type ProviderCommandReactorShape, } from "../Services/ProviderCommandReactor.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; type ProviderIntentEvent = Extract< OrchestrationEvent, @@ -74,19 +76,9 @@ const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30); const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; const WORKTREE_BRANCH_PREFIX = "t3code"; const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`); -const DEFAULT_THREAD_TITLE = "New thread"; - -function canReplaceThreadTitle(currentTitle: string, titleSeed?: string): boolean { - const trimmedCurrentTitle = currentTitle.trim(); - if (trimmedCurrentTitle === DEFAULT_THREAD_TITLE) { - return true; - } - - const trimmedTitleSeed = titleSeed?.trim(); - return trimmedTitleSeed !== undefined && trimmedTitleSeed.length > 0 - ? trimmedCurrentTitle === trimmedTitleSeed - : false; -} +const HANDOFF_CONTEXT_WRAPPER_OVERHEAD = + "\n\n\n\n\n\n" + .length; function isUnknownPendingApprovalRequestError(cause: Cause.Cause): boolean { const error = Cause.squash(cause); @@ -151,7 +143,6 @@ const make = Effect.gen(function* () { const providerService = yield* ProviderService; const git = yield* GitCore; const textGeneration = yield* TextGeneration; - const serverSettingsService = yield* ServerSettingsService; const handledTurnStartKeys = yield* Cache.make({ capacity: HANDLED_TURN_START_KEY_MAX, timeToLive: HANDLED_TURN_START_KEY_TTL, @@ -165,6 +156,7 @@ const make = Effect.gen(function* () { ), ); + const threadProviderOptions = new Map(); const threadModelSelections = new Map(); const appendProviderFailureActivity = (input: { @@ -213,16 +205,17 @@ const make = Effect.gen(function* () { createdAt: input.createdAt, }); - const resolveThread = Effect.fn("resolveThread")(function* (threadId: ThreadId) { + const resolveThread = Effect.fnUntraced(function* (threadId: ThreadId) { const readModel = yield* orchestrationEngine.getReadModel(); return readModel.threads.find((entry) => entry.id === threadId); }); - const ensureSessionForThread = Effect.fn("ensureSessionForThread")(function* ( + const ensureSessionForThread = Effect.fnUntraced(function* ( threadId: ThreadId, createdAt: string, options?: { readonly modelSelection?: ModelSelection; + readonly providerOptions?: ProviderStartOptions; }, ) { const readModel = yield* orchestrationEngine.getReadModel(); @@ -270,6 +263,9 @@ const make = Effect.gen(function* () { ...(preferredProvider ? { provider: preferredProvider } : {}), ...(effectiveCwd ? { cwd: effectiveCwd } : {}), modelSelection: desiredModelSelection, + ...(options?.providerOptions !== undefined + ? { providerOptions: options.providerOptions } + : {}), ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), runtimeMode: desiredRuntimeMode, }); @@ -358,11 +354,13 @@ const make = Effect.gen(function* () { return startedSession.threadId; }); - const sendTurnForThread = Effect.fn("sendTurnForThread")(function* (input: { + const sendTurnForThread = Effect.fnUntraced(function* (input: { readonly threadId: ThreadId; + readonly messageId: string; readonly messageText: string; readonly attachments?: ReadonlyArray; readonly modelSelection?: ModelSelection; + readonly providerOptions?: ProviderStartOptions; readonly interactionMode?: "default" | "plan"; readonly createdAt: string; }) { @@ -370,15 +368,33 @@ const make = Effect.gen(function* () { if (!thread) { return; } - yield* ensureSessionForThread( - input.threadId, - input.createdAt, - input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}, - ); + yield* ensureSessionForThread(input.threadId, input.createdAt, { + ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}), + ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}), + }); + if (input.providerOptions !== undefined) { + threadProviderOptions.set(input.threadId, input.providerOptions); + } if (input.modelSelection !== undefined) { threadModelSelections.set(input.threadId, input.modelSelection); } - const normalizedInput = toNonEmptyProviderInput(input.messageText); + const shouldBootstrapHandoff = + thread.handoff?.bootstrapStatus === "pending" && + !hasNativeAssistantMessagesBefore(thread, input.messageId); + const availableBootstrapChars = Math.max( + 0, + PROVIDER_SEND_TURN_MAX_INPUT_CHARS - + input.messageText.length - + HANDOFF_CONTEXT_WRAPPER_OVERHEAD, + ); + const handoffBootstrapText = + shouldBootstrapHandoff && availableBootstrapChars > 0 + ? buildHandoffBootstrapText(thread, availableBootstrapChars) + : null; + const providerInput = handoffBootstrapText + ? `\n${handoffBootstrapText}\n\n\n\n${input.messageText}\n` + : input.messageText; + const normalizedInput = toNonEmptyProviderInput(providerInput); const normalizedAttachments = input.attachments ?? []; const activeSession = yield* providerService .listSessions() @@ -408,14 +424,24 @@ const make = Effect.gen(function* () { ...(modelForTurn !== undefined ? { modelSelection: modelForTurn } : {}), ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}), }); + if (handoffBootstrapText && thread.handoff !== null) { + yield* orchestrationEngine.dispatch({ + type: "thread.meta.update", + commandId: serverCommandId("handoff-bootstrap-complete"), + threadId: input.threadId, + handoff: { + ...thread.handoff, + bootstrapStatus: "completed", + }, + }); + } }); - const maybeGenerateAndRenameWorktreeBranchForFirstTurn = Effect.fn( - "maybeGenerateAndRenameWorktreeBranchForFirstTurn", - )(function* (input: { + const maybeGenerateAndRenameWorktreeBranchForFirstTurn = Effect.fnUntraced(function* (input: { readonly threadId: ThreadId; readonly branch: string | null; readonly worktreePath: string | null; + readonly messageId: string; readonly messageText: string; readonly attachments?: ReadonlyArray; }) { @@ -426,90 +452,61 @@ const make = Effect.gen(function* () { return; } + const thread = yield* resolveThread(input.threadId); + if (!thread) { + return; + } + + const userMessages = thread.messages.filter((message) => message.role === "user"); + if (userMessages.length !== 1 || userMessages[0]?.id !== input.messageId) { + return; + } + const oldBranch = input.branch; const cwd = input.worktreePath; const attachments = input.attachments ?? []; - yield* Effect.gen(function* () { - const { textGenerationModelSelection: modelSelection } = - yield* serverSettingsService.getSettings; - - const generated = yield* textGeneration.generateBranchName({ + yield* textGeneration + .generateBranchName({ cwd, message: input.messageText, ...(attachments.length > 0 ? { attachments } : {}), - modelSelection, - }); - if (!generated) return; - - const targetBranch = buildGeneratedWorktreeBranchName(generated.branch); - if (targetBranch === oldBranch) return; - - const renamed = yield* git.renameBranch({ cwd, oldBranch, newBranch: targetBranch }); - yield* orchestrationEngine.dispatch({ - type: "thread.meta.update", - commandId: serverCommandId("worktree-branch-rename"), - threadId: input.threadId, - branch: renamed.branch, - worktreePath: cwd, - }); - }).pipe( - Effect.catchCause((cause) => - Effect.logWarning("provider command reactor failed to generate or rename worktree branch", { - threadId: input.threadId, - cwd, - oldBranch, - cause: Cause.pretty(cause), + model: DEFAULT_GIT_TEXT_GENERATION_MODEL, + }) + .pipe( + Effect.catch((error) => + Effect.logWarning( + "provider command reactor failed to generate worktree branch name; skipping rename", + { threadId: input.threadId, cwd, oldBranch, reason: error.message }, + ), + ), + Effect.flatMap((generated) => { + if (!generated) return Effect.void; + + const targetBranch = buildGeneratedWorktreeBranchName(generated.branch); + if (targetBranch === oldBranch) return Effect.void; + + return Effect.flatMap( + git.renameBranch({ cwd, oldBranch, newBranch: targetBranch }), + (renamed) => + orchestrationEngine.dispatch({ + type: "thread.meta.update", + commandId: serverCommandId("worktree-branch-rename"), + threadId: input.threadId, + branch: renamed.branch, + worktreePath: cwd, + }), + ); }), - ), - ); - }); - - const maybeGenerateThreadTitleForFirstTurn = Effect.fn("maybeGenerateThreadTitleForFirstTurn")( - function* (input: { - readonly threadId: ThreadId; - readonly cwd: string; - readonly messageText: string; - readonly attachments?: ReadonlyArray; - readonly titleSeed?: string; - }) { - const attachments = input.attachments ?? []; - yield* Effect.gen(function* () { - const { textGenerationModelSelection: modelSelection } = - yield* serverSettingsService.getSettings; - - const generated = yield* textGeneration.generateThreadTitle({ - cwd: input.cwd, - message: input.messageText, - ...(attachments.length > 0 ? { attachments } : {}), - modelSelection, - }); - if (!generated) return; - - const thread = yield* resolveThread(input.threadId); - if (!thread) return; - if (!canReplaceThreadTitle(thread.title, input.titleSeed)) { - return; - } - - yield* orchestrationEngine.dispatch({ - type: "thread.meta.update", - commandId: serverCommandId("thread-title-rename"), - threadId: input.threadId, - title: generated.title, - }); - }).pipe( Effect.catchCause((cause) => - Effect.logWarning("provider command reactor failed to generate or rename thread title", { - threadId: input.threadId, - cwd: input.cwd, - cause: Cause.pretty(cause), - }), + Effect.logWarning( + "provider command reactor failed to generate or rename worktree branch", + { threadId: input.threadId, cwd, oldBranch, cause: Cause.pretty(cause) }, + ), ), ); - }, - ); + }); - const processTurnStartRequested = Effect.fn("processTurnStartRequested")(function* ( + const processTurnStartRequested = Effect.fnUntraced(function* ( event: Extract, ) { const key = turnStartKeyForEvent(event); @@ -535,43 +532,26 @@ const make = Effect.gen(function* () { return; } - const isFirstUserMessageTurn = - thread.messages.filter((entry) => entry.role === "user").length === 1; - if (isFirstUserMessageTurn) { - const generationCwd = - resolveThreadWorkspaceCwd({ - thread, - projects: (yield* orchestrationEngine.getReadModel()).projects, - }) ?? process.cwd(); - const generationInput = { - messageText: message.text, - ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), - ...(event.payload.titleSeed !== undefined ? { titleSeed: event.payload.titleSeed } : {}), - }; - - yield* maybeGenerateAndRenameWorktreeBranchForFirstTurn({ - threadId: event.payload.threadId, - branch: thread.branch, - worktreePath: thread.worktreePath, - ...generationInput, - }).pipe(Effect.forkScoped); - - if (canReplaceThreadTitle(thread.title, event.payload.titleSeed)) { - yield* maybeGenerateThreadTitleForFirstTurn({ - threadId: event.payload.threadId, - cwd: generationCwd, - ...generationInput, - }).pipe(Effect.forkScoped); - } - } + yield* maybeGenerateAndRenameWorktreeBranchForFirstTurn({ + threadId: event.payload.threadId, + branch: thread.branch, + worktreePath: thread.worktreePath, + messageId: message.id, + messageText: message.text, + ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), + }).pipe(Effect.forkScoped); yield* sendTurnForThread({ threadId: event.payload.threadId, + messageId: message.id, messageText: message.text, ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), ...(event.payload.modelSelection !== undefined ? { modelSelection: event.payload.modelSelection } : {}), + ...(event.payload.providerOptions !== undefined + ? { providerOptions: event.payload.providerOptions } + : {}), interactionMode: event.payload.interactionMode, createdAt: event.payload.createdAt, }).pipe( @@ -588,7 +568,7 @@ const make = Effect.gen(function* () { ); }); - const processTurnInterruptRequested = Effect.fn("processTurnInterruptRequested")(function* ( + const processTurnInterruptRequested = Effect.fnUntraced(function* ( event: Extract, ) { const thread = yield* resolveThread(event.payload.threadId); @@ -611,7 +591,7 @@ const make = Effect.gen(function* () { yield* providerService.interruptTurn({ threadId: event.payload.threadId }); }); - const processApprovalResponseRequested = Effect.fn("processApprovalResponseRequested")(function* ( + const processApprovalResponseRequested = Effect.fnUntraced(function* ( event: Extract, ) { const thread = yield* resolveThread(event.payload.threadId); @@ -658,52 +638,50 @@ const make = Effect.gen(function* () { ); }); - const processUserInputResponseRequested = Effect.fn("processUserInputResponseRequested")( - function* ( - event: Extract, - ) { - const thread = yield* resolveThread(event.payload.threadId); - if (!thread) { - return; - } - const hasSession = thread.session && thread.session.status !== "stopped"; - if (!hasSession) { - return yield* appendProviderFailureActivity({ - threadId: event.payload.threadId, - kind: "provider.user-input.respond.failed", - summary: "Provider user input response failed", - detail: "No active provider session is bound to this thread.", - turnId: null, - createdAt: event.payload.createdAt, - requestId: event.payload.requestId, - }); - } + const processUserInputResponseRequested = Effect.fnUntraced(function* ( + event: Extract, + ) { + const thread = yield* resolveThread(event.payload.threadId); + if (!thread) { + return; + } + const hasSession = thread.session && thread.session.status !== "stopped"; + if (!hasSession) { + return yield* appendProviderFailureActivity({ + threadId: event.payload.threadId, + kind: "provider.user-input.respond.failed", + summary: "Provider user input response failed", + detail: "No active provider session is bound to this thread.", + turnId: null, + createdAt: event.payload.createdAt, + requestId: event.payload.requestId, + }); + } - yield* providerService - .respondToUserInput({ - threadId: event.payload.threadId, - requestId: event.payload.requestId, - answers: event.payload.answers, - }) - .pipe( - Effect.catchCause((cause) => - appendProviderFailureActivity({ - threadId: event.payload.threadId, - kind: "provider.user-input.respond.failed", - summary: "Provider user input response failed", - detail: isUnknownPendingUserInputRequestError(cause) - ? stalePendingRequestDetail("user-input", event.payload.requestId) - : Cause.pretty(cause), - turnId: null, - createdAt: event.payload.createdAt, - requestId: event.payload.requestId, - }), - ), - ); - }, - ); + yield* providerService + .respondToUserInput({ + threadId: event.payload.threadId, + requestId: event.payload.requestId, + answers: event.payload.answers, + }) + .pipe( + Effect.catchCause((cause) => + appendProviderFailureActivity({ + threadId: event.payload.threadId, + kind: "provider.user-input.respond.failed", + summary: "Provider user input response failed", + detail: isUnknownPendingUserInputRequestError(cause) + ? stalePendingRequestDetail("user-input", event.payload.requestId) + : Cause.pretty(cause), + turnId: null, + createdAt: event.payload.createdAt, + requestId: event.payload.requestId, + }), + ), + ); + }); - const processSessionStopRequested = Effect.fn("processSessionStopRequested")(function* ( + const processSessionStopRequested = Effect.fnUntraced(function* ( event: Extract, ) { const thread = yield* resolveThread(event.payload.threadId); @@ -731,48 +709,41 @@ const make = Effect.gen(function* () { }); }); - const processDomainEvent = Effect.fn("processDomainEvent")(function* ( - event: ProviderIntentEvent, - ) { - yield* Effect.annotateCurrentSpan({ - "orchestration.event_type": event.type, - "orchestration.thread_id": event.payload.threadId, - ...(event.commandId ? { "orchestration.command_id": event.commandId } : {}), - }); - yield* increment(orchestrationEventsProcessedTotal, { - eventType: event.type, - }); - switch (event.type) { - case "thread.runtime-mode-set": { - const thread = yield* resolveThread(event.payload.threadId); - if (!thread?.session || thread.session.status === "stopped") { + const processDomainEvent = (event: ProviderIntentEvent) => + Effect.gen(function* () { + switch (event.type) { + case "thread.runtime-mode-set": { + const thread = yield* resolveThread(event.payload.threadId); + if (!thread?.session || thread.session.status === "stopped") { + return; + } + const cachedProviderOptions = threadProviderOptions.get(event.payload.threadId); + const cachedModelSelection = threadModelSelections.get(event.payload.threadId); + yield* ensureSessionForThread(event.payload.threadId, event.occurredAt, { + ...(cachedProviderOptions !== undefined + ? { providerOptions: cachedProviderOptions } + : {}), + ...(cachedModelSelection !== undefined ? { modelSelection: cachedModelSelection } : {}), + }); return; } - const cachedModelSelection = threadModelSelections.get(event.payload.threadId); - yield* ensureSessionForThread( - event.payload.threadId, - event.occurredAt, - cachedModelSelection !== undefined ? { modelSelection: cachedModelSelection } : {}, - ); - return; + case "thread.turn-start-requested": + yield* processTurnStartRequested(event); + return; + case "thread.turn-interrupt-requested": + yield* processTurnInterruptRequested(event); + return; + case "thread.approval-response-requested": + yield* processApprovalResponseRequested(event); + return; + case "thread.user-input-response-requested": + yield* processUserInputResponseRequested(event); + return; + case "thread.session-stop-requested": + yield* processSessionStopRequested(event); + return; } - case "thread.turn-start-requested": - yield* processTurnStartRequested(event); - return; - case "thread.turn-interrupt-requested": - yield* processTurnInterruptRequested(event); - return; - case "thread.approval-response-requested": - yield* processApprovalResponseRequested(event); - return; - case "thread.user-input-response-requested": - yield* processUserInputResponseRequested(event); - return; - case "thread.session-stop-requested": - yield* processSessionStopRequested(event); - return; - } - }); + }); const processDomainEventSafely = (event: ProviderIntentEvent) => processDomainEvent(event).pipe( @@ -789,24 +760,22 @@ const make = Effect.gen(function* () { const worker = yield* makeDrainableWorker(processDomainEventSafely); - const start: ProviderCommandReactorShape["start"] = Effect.fn("start")(function* () { - const processEvent = Effect.fn("processEvent")(function* (event: OrchestrationEvent) { + const start: ProviderCommandReactorShape["start"] = Effect.forkScoped( + Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { if ( - event.type === "thread.runtime-mode-set" || - event.type === "thread.turn-start-requested" || - event.type === "thread.turn-interrupt-requested" || - event.type === "thread.approval-response-requested" || - event.type === "thread.user-input-response-requested" || - event.type === "thread.session-stop-requested" + event.type !== "thread.runtime-mode-set" && + event.type !== "thread.turn-start-requested" && + event.type !== "thread.turn-interrupt-requested" && + event.type !== "thread.approval-response-requested" && + event.type !== "thread.user-input-response-requested" && + event.type !== "thread.session-stop-requested" ) { - return yield* worker.enqueue(event); + return Effect.void; } - }); - yield* Effect.forkScoped( - Stream.runForEach(orchestrationEngine.streamDomainEvents, processEvent), - ); - }); + return worker.enqueue(event); + }), + ).pipe(Effect.asVoid); return { start, diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index 43d665a2c9..9858706e8e 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -68,6 +68,7 @@ const readModel: OrchestrationReadModel = { updatedAt: now, archivedAt: null, latestTurn: null, + handoff: null, messages: [], session: null, activities: [], @@ -91,6 +92,7 @@ const readModel: OrchestrationReadModel = { updatedAt: now, archivedAt: null, latestTurn: null, + handoff: null, messages: [], session: null, activities: [], diff --git a/apps/server/src/orchestration/decider.projectScripts.test.ts b/apps/server/src/orchestration/decider.projectScripts.test.ts index 465865549b..14cc9ec0e0 100644 --- a/apps/server/src/orchestration/decider.projectScripts.test.ts +++ b/apps/server/src/orchestration/decider.projectScripts.test.ts @@ -144,6 +144,7 @@ describe("decider project scripts", () => { runtimeMode: "approval-required", branch: null, worktreePath: null, + handoff: null, createdAt: now, updatedAt: now, }, @@ -253,6 +254,7 @@ describe("decider project scripts", () => { runtimeMode: "full-access", branch: null, worktreePath: null, + handoff: null, createdAt: now, updatedAt: now, }, @@ -335,6 +337,7 @@ describe("decider project scripts", () => { runtimeMode: "approval-required", branch: null, worktreePath: null, + handoff: null, createdAt: now, updatedAt: now, }, diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 22f5bcb280..3897095593 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -163,12 +163,96 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" interactionMode: command.interactionMode, branch: command.branch, worktreePath: command.worktreePath, + handoff: null, createdAt: command.createdAt, updatedAt: command.createdAt, }, }; } + case "thread.handoff.create": { + yield* requireProject({ + readModel, + command, + projectId: command.projectId, + }); + yield* requireThread({ + readModel, + command, + threadId: command.sourceThreadId, + }); + yield* requireThreadAbsent({ + readModel, + command, + threadId: command.threadId, + }); + + const sourceThread = yield* requireThread({ + readModel, + command, + threadId: command.sourceThreadId, + }); + if (sourceThread.projectId !== command.projectId) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Source thread '${command.sourceThreadId}' belongs to a different project.`, + }); + } + + const createdEvent: Omit = { + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + }), + type: "thread.created", + payload: { + threadId: command.threadId, + projectId: command.projectId, + title: command.title, + modelSelection: command.modelSelection, + runtimeMode: command.runtimeMode, + interactionMode: command.interactionMode, + branch: command.branch, + worktreePath: command.worktreePath, + handoff: { + sourceThreadId: command.sourceThreadId, + sourceProvider: sourceThread.modelSelection.provider, + importedAt: command.createdAt, + bootstrapStatus: "pending", + }, + createdAt: command.createdAt, + updatedAt: command.createdAt, + }, + }; + + const importedMessageEvents: ReadonlyArray> = + command.importedMessages.map((message) => ({ + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + }), + type: "thread.message-sent", + payload: { + threadId: command.threadId, + messageId: message.messageId, + role: message.role, + text: message.text, + ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), + turnId: null, + streaming: false, + source: "handoff-import", + createdAt: message.createdAt, + updatedAt: message.updatedAt, + }, + })); + + return [createdEvent, ...importedMessageEvents]; + } + case "thread.delete": { yield* requireThread({ readModel, @@ -259,6 +343,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" : {}), ...(command.branch !== undefined ? { branch: command.branch } : {}), ...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}), + ...(command.handoff !== undefined ? { handoff: command.handoff } : {}), updatedAt: occurredAt, }, }; @@ -356,6 +441,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" attachments: command.message.attachments, turnId: null, streaming: false, + source: "native", createdAt: command.createdAt, updatedAt: command.createdAt, }, diff --git a/apps/server/src/orchestration/handoff.ts b/apps/server/src/orchestration/handoff.ts new file mode 100644 index 0000000000..d87ae0a505 --- /dev/null +++ b/apps/server/src/orchestration/handoff.ts @@ -0,0 +1,111 @@ +import { + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, + type OrchestrationMessage, + type OrchestrationThread, +} from "@t3tools/contracts"; + +const RECENT_MESSAGE_COUNT = 6; +const EARLIER_MESSAGE_CHAR_LIMIT = 320; +const RECENT_MESSAGE_CHAR_LIMIT = 2_400; +const HANDOFF_BOOTSTRAP_CHAR_BUDGET = Math.floor(PROVIDER_SEND_TURN_MAX_INPUT_CHARS * 0.75); + +function normalizeMessageText(value: string): string { + return value + .replace(/\s+\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +function truncateText(value: string, maxChars: number): string { + if (value.length <= maxChars) { + return value; + } + return `${value.slice(0, Math.max(0, maxChars - 3)).trimEnd()}...`; +} + +function roleLabel(message: Pick): "User" | "Assistant" { + return message.role === "assistant" ? "Assistant" : "User"; +} + +export function listImportedHandoffMessages( + thread: Pick, +): ReadonlyArray { + return thread.messages.filter( + (message) => + message.source === "handoff-import" && + (message.role === "user" || message.role === "assistant") && + message.streaming === false, + ); +} + +export function hasNativeAssistantMessagesBefore( + thread: Pick, + currentMessageId: string, +): boolean { + const currentIndex = thread.messages.findIndex((message) => message.id === currentMessageId); + if (currentIndex <= 0) { + return false; + } + return thread.messages.slice(0, currentIndex).some((message) => { + return ( + message.role === "assistant" && + message.source !== "handoff-import" && + message.streaming === false + ); + }); +} + +export function buildHandoffBootstrapText( + thread: Pick, + maxChars = HANDOFF_BOOTSTRAP_CHAR_BUDGET, +): string | null { + const importedMessages = listImportedHandoffMessages(thread); + if (importedMessages.length === 0 || thread.handoff === null) { + return null; + } + + const earlierMessages = importedMessages.slice(0, -RECENT_MESSAGE_COUNT); + const recentMessages = importedMessages.slice(-RECENT_MESSAGE_COUNT); + const sections: string[] = [ + `This conversation was handed off from ${thread.handoff.sourceProvider}.`, + `Original conversation title: ${thread.title}`, + ]; + + if (thread.branch) { + sections.push(`Git branch: ${thread.branch}`); + } + if (thread.worktreePath) { + sections.push(`Worktree path: ${thread.worktreePath}`); + } + + if (earlierMessages.length > 0) { + sections.push( + "Earlier conversation summary:\n" + + earlierMessages + .map((message) => { + const normalized = truncateText( + normalizeMessageText(message.text), + EARLIER_MESSAGE_CHAR_LIMIT, + ); + return `- ${roleLabel(message)}: ${normalized}`; + }) + .join("\n"), + ); + } + + sections.push( + "Most recent imported messages:\n" + + recentMessages + .map((message) => { + const normalized = truncateText( + normalizeMessageText(message.text), + RECENT_MESSAGE_CHAR_LIMIT, + ); + return `${roleLabel(message)}:\n${normalized}`; + }) + .join("\n\n"), + ); + + const joined = sections.join("\n\n").trim(); + return truncateText(joined, Math.max(0, maxChars)); +} diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index deb8a6d44d..5d824850ab 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -264,6 +264,7 @@ export function projectEvent( updatedAt: payload.updatedAt, archivedAt: null, deletedAt: null, + handoff: payload.handoff, messages: [], activities: [], checkpoints: [], @@ -325,6 +326,7 @@ export function projectEvent( : {}), ...(payload.branch !== undefined ? { branch: payload.branch } : {}), ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}), + ...(payload.handoff !== undefined ? { handoff: payload.handoff } : {}), updatedAt: payload.updatedAt, }), })), @@ -379,6 +381,7 @@ export function projectEvent( ...(payload.attachments !== undefined ? { attachments: payload.attachments } : {}), turnId: payload.turnId, streaming: payload.streaming, + source: payload.source, createdAt: payload.createdAt, updatedAt: payload.updatedAt, }, @@ -398,6 +401,7 @@ export function projectEvent( ? message.text : entry.text, streaming: message.streaming, + source: message.source, updatedAt: message.updatedAt, turnId: message.turnId, ...(message.attachments !== undefined diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index b0e1774837..6816b43272 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -85,6 +85,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { branch: null, worktreePath: null, latestTurnId: null, + handoff: null, createdAt: "2026-03-24T00:00:00.000Z", updatedAt: "2026-03-24T00:00:00.000Z", archivedAt: null, diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts index 5993ad6c20..834beb8216 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts @@ -36,6 +36,7 @@ layer("ProjectionThreadMessageRepository", (it) => { text: "initial", attachments: persistedAttachments, isStreaming: false, + source: "native", createdAt, updatedAt, }); @@ -47,6 +48,7 @@ layer("ProjectionThreadMessageRepository", (it) => { role: "user", text: "updated", isStreaming: false, + source: "native", createdAt, updatedAt: "2026-02-28T19:00:02.000Z", }); @@ -88,6 +90,7 @@ layer("ProjectionThreadMessageRepository", (it) => { }, ], isStreaming: false, + source: "native", createdAt, updatedAt: "2026-02-28T19:10:01.000Z", }); @@ -100,6 +103,7 @@ layer("ProjectionThreadMessageRepository", (it) => { text: "cleared", attachments: [], isStreaming: false, + source: "native", createdAt, updatedAt: "2026-02-28T19:10:02.000Z", }); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts index 13b7086cec..1c83de9cc0 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts @@ -1,11 +1,10 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import { Effect, Layer, Option, Schema, Struct } from "effect"; +import { Effect, Layer, Schema, Struct } from "effect"; import { ChatAttachment } from "@t3tools/contracts"; import { toPersistenceSqlError } from "../Errors.ts"; import { - GetProjectionThreadMessageInput, ProjectionThreadMessageRepository, type ProjectionThreadMessageRepositoryShape, DeleteProjectionThreadMessagesInput, @@ -20,22 +19,6 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( }), ); -function toProjectionThreadMessage( - row: Schema.Schema.Type, -): ProjectionThreadMessage { - return { - messageId: row.messageId, - threadId: row.threadId, - turnId: row.turnId, - role: row.role, - text: row.text, - isStreaming: row.isStreaming === 1, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - ...(row.attachments !== null ? { attachments: row.attachments } : {}), - }; -} - const makeProjectionThreadMessageRepository = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -53,6 +36,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { text, attachments_json, is_streaming, + source, created_at, updated_at ) @@ -71,6 +55,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { ) ), ${row.isStreaming ? 1 : 0}, + ${row.source}, ${row.createdAt}, ${row.updatedAt} ) @@ -85,33 +70,13 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { projection_thread_messages.attachments_json ), is_streaming = excluded.is_streaming, + source = excluded.source, created_at = excluded.created_at, updated_at = excluded.updated_at `; }, }); - const getProjectionThreadMessageRow = SqlSchema.findOneOption({ - Request: GetProjectionThreadMessageInput, - Result: ProjectionThreadMessageDbRowSchema, - execute: ({ messageId }) => - sql` - SELECT - message_id AS "messageId", - thread_id AS "threadId", - turn_id AS "turnId", - role, - text, - attachments_json AS "attachments", - is_streaming AS "isStreaming", - created_at AS "createdAt", - updated_at AS "updatedAt" - FROM projection_thread_messages - WHERE message_id = ${messageId} - LIMIT 1 - `, - }); - const listProjectionThreadMessageRows = SqlSchema.findAll({ Request: ListProjectionThreadMessagesInput, Result: ProjectionThreadMessageDbRowSchema, @@ -125,6 +90,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { text, attachments_json AS "attachments", is_streaming AS "isStreaming", + source, created_at AS "createdAt", updated_at AS "updatedAt" FROM projection_thread_messages @@ -147,20 +113,25 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { Effect.mapError(toPersistenceSqlError("ProjectionThreadMessageRepository.upsert:query")), ); - const getByMessageId: ProjectionThreadMessageRepositoryShape["getByMessageId"] = (input) => - getProjectionThreadMessageRow(input).pipe( - Effect.mapError( - toPersistenceSqlError("ProjectionThreadMessageRepository.getByMessageId:query"), - ), - Effect.map(Option.map(toProjectionThreadMessage)), - ); - const listByThreadId: ProjectionThreadMessageRepositoryShape["listByThreadId"] = (input) => listProjectionThreadMessageRows(input).pipe( Effect.mapError( toPersistenceSqlError("ProjectionThreadMessageRepository.listByThreadId:query"), ), - Effect.map((rows) => rows.map(toProjectionThreadMessage)), + Effect.map((rows) => + rows.map((row) => ({ + messageId: row.messageId, + threadId: row.threadId, + turnId: row.turnId, + role: row.role, + text: row.text, + isStreaming: row.isStreaming === 1, + source: row.source, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + ...(row.attachments !== null ? { attachments: row.attachments } : {}), + })), + ), ); const deleteByThreadId: ProjectionThreadMessageRepositoryShape["deleteByThreadId"] = (input) => @@ -172,7 +143,6 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { return { upsert, - getByMessageId, listByThreadId, deleteByThreadId, } satisfies ProjectionThreadMessageRepositoryShape; diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 48dd51fdca..3ac826b8d2 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -11,10 +11,11 @@ import { ProjectionThreadRepository, type ProjectionThreadRepositoryShape, } from "../Services/ProjectionThreads.ts"; -import { ModelSelection } from "@t3tools/contracts"; +import { ModelSelection, ThreadHandoff } from "@t3tools/contracts"; const ProjectionThreadDbRow = ProjectionThread.mapFields( Struct.assign({ + handoff: Schema.NullOr(Schema.fromJsonString(ThreadHandoff)), modelSelection: Schema.fromJsonString(ModelSelection), }), ); @@ -37,6 +38,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { branch, worktree_path, latest_turn_id, + handoff_json, created_at, updated_at, archived_at, @@ -52,6 +54,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.branch}, ${row.worktreePath}, ${row.latestTurnId}, + ${row.handoff === null ? null : JSON.stringify(row.handoff)}, ${row.createdAt}, ${row.updatedAt}, ${row.archivedAt}, @@ -67,6 +70,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { branch = excluded.branch, worktree_path = excluded.worktree_path, latest_turn_id = excluded.latest_turn_id, + handoff_json = excluded.handoff_json, created_at = excluded.created_at, updated_at = excluded.updated_at, archived_at = excluded.archived_at, @@ -89,6 +93,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { branch, worktree_path AS "worktreePath", latest_turn_id AS "latestTurnId", + handoff_json AS "handoff", created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", @@ -113,6 +118,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { branch, worktree_path AS "worktreePath", latest_turn_id AS "latestTurnId", + handoff_json AS "handoff", created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index a03c3c2d18..1425c9bf08 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -29,9 +29,7 @@ import Migration0013 from "./Migrations/013_ProjectionThreadProposedPlans.ts"; import Migration0014 from "./Migrations/014_ProjectionThreadProposedPlanImplementation.ts"; 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_ProjectionSnapshotLookupIndexes.ts"; +import Migration0017 from "./Migrations/017_ThreadHandoffMetadata.ts"; /** * Migration loader with all migrations defined inline. @@ -60,9 +58,7 @@ export const migrationEntries = [ [14, "ProjectionThreadProposedPlanImplementation", Migration0014], [15, "ProjectionTurnsSourceProposedPlan", Migration0015], [16, "CanonicalizeModelSelections", Migration0016], - [17, "ProjectionThreadsArchivedAt", Migration0017], - [18, "ProjectionThreadsArchivedAtIndex", Migration0018], - [19, "ProjectionSnapshotLookupIndexes", Migration0019], + [17, "ThreadHandoffMetadata", Migration0017], ] as const; export const makeMigrationLoader = (throughId?: number) => @@ -94,20 +90,19 @@ export interface RunMigrationsOptions { * * @returns Effect containing array of executed migrations */ -export const runMigrations = Effect.fn("runMigrations")(function* ({ - toMigrationInclusive, -}: RunMigrationsOptions = {}) { - yield* Effect.log( - toMigrationInclusive === undefined - ? "Running all migrations..." - : `Running migrations 1 through ${toMigrationInclusive}...`, - ); - const executedMigrations = yield* run({ loader: makeMigrationLoader(toMigrationInclusive) }); - yield* Effect.log("Migrations ran successfully").pipe( - Effect.annotateLogs({ migrations: executedMigrations.map(([id, name]) => `${id}_${name}`) }), - ); - return executedMigrations; -}); +export const runMigrations = ({ toMigrationInclusive }: RunMigrationsOptions = {}) => + Effect.gen(function* () { + yield* Effect.log( + toMigrationInclusive === undefined + ? "Running all migrations..." + : `Running migrations 1 through ${toMigrationInclusive}...`, + ); + const executedMigrations = yield* run({ loader: makeMigrationLoader(toMigrationInclusive) }); + yield* Effect.log("Migrations ran successfully").pipe( + Effect.annotateLogs({ migrations: executedMigrations.map(([id, name]) => `${id}_${name}`) }), + ); + return executedMigrations; + }); /** * Layer that runs migrations when the layer is built. diff --git a/apps/server/src/persistence/Migrations/017_ThreadHandoffMetadata.ts b/apps/server/src/persistence/Migrations/017_ThreadHandoffMetadata.ts new file mode 100644 index 0000000000..52fba19c35 --- /dev/null +++ b/apps/server/src/persistence/Migrations/017_ThreadHandoffMetadata.ts @@ -0,0 +1,16 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN handoff_json TEXT + `.pipe(Effect.catchTag("SqlError", () => Effect.void)); + + yield* sql` + ALTER TABLE projection_thread_messages + ADD COLUMN source TEXT NOT NULL DEFAULT 'native' + `.pipe(Effect.catchTag("SqlError", () => Effect.void)); +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts index b1a769cd91..9732719302 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts @@ -8,14 +8,14 @@ */ import { ChatAttachment, - MessageId, OrchestrationMessageRole, + OrchestrationMessageSource, + MessageId, ThreadId, TurnId, IsoDateTime, } from "@t3tools/contracts"; import { Schema, ServiceMap } from "effect"; -import type { Option } from "effect"; import type { Effect } from "effect"; import type { ProjectionRepositoryError } from "../Errors.ts"; @@ -28,6 +28,7 @@ export const ProjectionThreadMessage = Schema.Struct({ text: Schema.String, attachments: Schema.optional(Schema.Array(ChatAttachment)), isStreaming: Schema.Boolean, + source: OrchestrationMessageSource, createdAt: IsoDateTime, updatedAt: IsoDateTime, }); @@ -38,11 +39,6 @@ export const ListProjectionThreadMessagesInput = Schema.Struct({ }); export type ListProjectionThreadMessagesInput = typeof ListProjectionThreadMessagesInput.Type; -export const GetProjectionThreadMessageInput = Schema.Struct({ - messageId: MessageId, -}); -export type GetProjectionThreadMessageInput = typeof GetProjectionThreadMessageInput.Type; - export const DeleteProjectionThreadMessagesInput = Schema.Struct({ threadId: ThreadId, }); @@ -61,13 +57,6 @@ export interface ProjectionThreadMessageRepositoryShape { message: ProjectionThreadMessage, ) => Effect.Effect; - /** - * Read a projected thread message by id. - */ - readonly getByMessageId: ( - input: GetProjectionThreadMessageInput, - ) => Effect.Effect, ProjectionRepositoryError>; - /** * List projected thread messages for a thread. * diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 59505c1253..15e405b793 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -9,6 +9,7 @@ import { IsoDateTime, ModelSelection, + ThreadHandoff, ProjectId, ProviderInteractionMode, RuntimeMode, @@ -30,6 +31,7 @@ export const ProjectionThread = Schema.Struct({ branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), latestTurnId: Schema.NullOr(TurnId), + handoff: Schema.NullOr(ThreadHandoff), createdAt: IsoDateTime, updatedAt: IsoDateTime, archivedAt: Schema.NullOr(IsoDateTime), diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index a727b89ea3..0021a8dcb7 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -168,6 +168,7 @@ function createUserMessage(options: { ...(options.attachments ? { attachments: options.attachments } : {}), turnId: null, streaming: false, + source: "native" as const, createdAt: isoAt(options.offsetSeconds), updatedAt: isoAt(options.offsetSeconds + 1), }; @@ -180,6 +181,7 @@ function createAssistantMessage(options: { id: MessageId; text: string; offsetSe text: options.text, turnId: null, streaming: false, + source: "native" as const, createdAt: isoAt(options.offsetSeconds), updatedAt: isoAt(options.offsetSeconds + 1), }; @@ -279,6 +281,7 @@ function createSnapshotForTargetUser(options: { updatedAt: NOW_ISO, archivedAt: null, deletedAt: null, + handoff: null, messages, activities: [], proposedPlans: [], @@ -337,6 +340,7 @@ function addThreadToSnapshot( updatedAt: NOW_ISO, archivedAt: null, deletedAt: null, + handoff: null, messages: [], activities: [], proposedPlans: [], diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index ca2a671c11..2ee56ca0a4 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -38,6 +38,7 @@ export function buildLocalDraftThread( latestTurn: null, branch: draftThread.branch, worktreePath: draftThread.worktreePath, + handoff: null, turnDiffSummaries: [], activities: [], proposedPlans: [], diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index aeab2d083a..b1809b343b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5,30 +5,44 @@ import { type MessageId, type ModelSelection, type ProjectScript, + type ModelSlug, type ProviderKind, type ProjectEntry, type ProjectId, type ProviderApprovalDecision, + type ProviderSkillDescriptor, + type ProviderSkillReference, + PROVIDER_DISPLAY_NAMES, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, - type ServerProvider, + type ResolvedKeybindingsConfig, + type ServerProviderStatus, type ThreadId, type TurnId, + type EditorId, type KeybindingCommand, OrchestrationThreadActivity, ProviderInteractionMode, RuntimeMode, - TerminalOpenInput, } from "@t3tools/contracts"; -import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model"; -import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; -import { truncate } from "@t3tools/shared/String"; +import { + applyClaudePromptEffortPrefix, + getModelCapabilities, + normalizeModelSlug, +} from "@t3tools/shared/model"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { GoTasklist } from "react-icons/go"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; -import { gitStatusQueryOptions } from "~/lib/gitReactQuery"; +import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; +import { + providerComposerCapabilitiesQueryOptions, + providerSkillsQueryOptions, + supportsSkillDiscovery, +} from "~/lib/providerDiscoveryReactQuery"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; +import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; import { isElectron } from "../env"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { @@ -41,7 +55,6 @@ import { replaceTextRange, } from "../composer-logic"; import { - deriveCompletionDividerBeforeEntryId, derivePendingApprovals, derivePendingUserInputs, derivePhase, @@ -64,46 +77,41 @@ import { type PendingUserInputDraftAnswer, } from "../pendingUserInput"; import { useStore } from "../store"; -import { useProjectById, useThreadById } from "../storeSelectors"; -import { useUiStateStore } from "../uiStateStore"; import { buildPlanImplementationThreadTitle, buildPlanImplementationPrompt, proposedPlanTitle, resolvePlanFollowUpSubmission, } from "../proposedPlan"; +import { truncateTitle } from "../truncateTitle"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, DEFAULT_THREAD_TERMINAL_ID, MAX_TERMINALS_PER_GROUP, type ChatMessage, - type SessionPhase, - type Thread, type TurnDiffSummary, } from "../types"; -import { LRUCache } from "../lib/lruCache"; - import { basenameOfPath } from "../vscode-icons"; import { useTheme } from "../hooks/useTheme"; +import { useThreadHandoff } from "../hooks/useThreadHandoff"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import BranchToolbar from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; +import TerminalWorkspaceTabs from "./TerminalWorkspaceTabs"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { - BotIcon, ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, CircleAlertIcon, ListTodoIcon, - LockIcon, - LockOpenIcon, XIcon, -} from "lucide-react"; +} from "~/lib/icons"; import { Button } from "./ui/button"; import { Separator } from "./ui/separator"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; import { cn, randomUUID } from "~/lib/utils"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { toastManager } from "./ui/toast"; @@ -112,18 +120,21 @@ import { type NewProjectScriptInput } from "./ProjectScriptsControl"; import { commandForProjectScript, nextProjectScriptId, + projectScriptCwd, + projectScriptRuntimeEnv, projectScriptIdFromCommand, + setupProjectScript, } from "~/projectScripts"; import { SidebarTrigger } from "./ui/sidebar"; import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; import { - getProviderModelCapabilities, - getProviderModels, - resolveSelectableProvider, -} from "../providerModels"; -import { useSettings } from "../hooks/useSettings"; -import { resolveAppModelSelection } from "../modelSelection"; + getCustomModelOptionsByProvider, + getCustomModelsByProvider, + getProviderStartOptions, + resolveAppModelSelection, + useAppSettings, +} from "../appSettings"; import { isTerminalFocused } from "../lib/terminalFocus"; import { type ComposerImageAttachment, @@ -142,15 +153,11 @@ import { type TerminalContextSelection, } from "../lib/terminalContext"; import { deriveLatestContextWindowSnapshot } from "../lib/contextWindow"; -import { - resolveComposerFooterContentWidth, - shouldForceCompactComposerFooterForFit, - shouldUseCompactComposerPrimaryActions, - shouldUseCompactComposerFooter, -} from "./composerFooterLayout"; +import { shouldUseCompactComposerFooter } from "./composerFooterLayout"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; +import { ChatEmptyStateHero } from "./chat/ChatEmptyStateHero"; import { MessagesTimeline } from "./chat/MessagesTimeline"; import { ChatHeader } from "./chat/ChatHeader"; import { ContextWindowMeter } from "./chat/ContextWindowMeter"; @@ -159,7 +166,6 @@ import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/Provider import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu"; import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions"; import { CompactComposerControlsMenu } from "./chat/CompactComposerControlsMenu"; -import { ComposerPrimaryActions } from "./chat/ComposerPrimaryActions"; import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel"; import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel"; import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner"; @@ -168,138 +174,102 @@ import { renderProviderTraitsMenuContent, renderProviderTraitsPicker, } from "./chat/composerProviderRegistry"; -import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; +import { ProviderHealthBanner } from "./chat/ProviderHealthBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; import { - MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, buildExpiredTerminalContextToastCopy, buildLocalDraftThread, + shouldRenderTerminalWorkspace, buildTemporaryWorktreeBranchName, cloneComposerImageForRetry, collectUserMessageBlobPreviewUrls, - createLocalDispatchSnapshot, deriveComposerSendState, - hasServerAcknowledgedLocalDispatch, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, - type LocalDispatchSnapshot, PullRequestDialogState, readFileAsDataUrl, - reconcileMountedTerminalThreadIds, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, - threadHasStarted, - waitForStartedServerThread, + SendPhase, } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; import { - useServerAvailableEditors, - useServerConfig, - useServerKeybindings, -} from "~/rpc/serverState"; -import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; + canCreateThreadHandoff, + resolveHandoffTargetProvider, + resolveThreadHandoffBadgeLabel, +} from "../lib/threadHandoff"; const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; const IMAGE_ONLY_BOOTSTRAP_PROMPT = "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; +const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; -const EMPTY_PROVIDERS: ServerProvider[] = []; +const EMPTY_PROVIDER_SKILLS: ProviderSkillDescriptor[] = []; +const EMPTY_AVAILABLE_EDITORS: EditorId[] = []; +const EMPTY_PROVIDER_STATUSES: ServerProviderStatus[] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; -type ThreadPlanCatalogEntry = Pick; - -const MAX_THREAD_PLAN_CATALOG_CACHE_ENTRIES = 500; -const MAX_THREAD_PLAN_CATALOG_CACHE_MEMORY_BYTES = 512 * 1024; -const threadPlanCatalogCache = new LRUCache<{ - proposedPlans: Thread["proposedPlans"]; - entry: ThreadPlanCatalogEntry; -}>(MAX_THREAD_PLAN_CATALOG_CACHE_ENTRIES, MAX_THREAD_PLAN_CATALOG_CACHE_MEMORY_BYTES); - -function estimateThreadPlanCatalogEntrySize(thread: Thread): number { - return Math.max( - 64, - thread.id.length + - thread.proposedPlans.reduce( - (total, plan) => - total + - plan.id.length + - plan.planMarkdown.length + - plan.updatedAt.length + - (plan.turnId?.length ?? 0), - 0, - ), - ); -} - -function toThreadPlanCatalogEntry(thread: Thread): ThreadPlanCatalogEntry { - const cached = threadPlanCatalogCache.get(thread.id); - if (cached && cached.proposedPlans === thread.proposedPlans) { - return cached.entry; - } - - const entry: ThreadPlanCatalogEntry = { - id: thread.id, - proposedPlans: thread.proposedPlans, - }; - threadPlanCatalogCache.set( - thread.id, - { - proposedPlans: thread.proposedPlans, - entry, - }, - estimateThreadPlanCatalogEntrySize(thread), - ); - return entry; -} - -function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalogEntry[] { - const selector = useMemo(() => { - let previousThreads: Array | null = null; - let previousEntries: ThreadPlanCatalogEntry[] = []; - - return (state: { threads: Thread[] }): ThreadPlanCatalogEntry[] => { - const nextThreads = threadIds.map((threadId) => - state.threads.find((thread) => thread.id === threadId), - ); - const cachedThreads = previousThreads; - if ( - cachedThreads && - nextThreads.length === cachedThreads.length && - nextThreads.every((thread, index) => thread === cachedThreads[index]) - ) { - return previousEntries; - } - - previousThreads = nextThreads; - previousEntries = nextThreads.flatMap((thread) => - thread ? [toThreadPlanCatalogEntry(thread)] : [], - ); - return previousEntries; - }; - }, [threadIds]); - - return useStore(selector); -} - function formatOutgoingPrompt(params: { provider: ProviderKind; model: string | null; - models: ReadonlyArray; effort: string | null; text: string; }): string { - const caps = getProviderModelCapabilities(params.models, params.model, params.provider); + const caps = getModelCapabilities(params.provider, params.model); if (params.effort && caps.promptInjectedEffortLevels.includes(params.effort)) { return applyClaudePromptEffortPrefix(params.text, params.effort as ClaudeCodeEffort | null); } return params.text; } const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; + +function resolveComposerSkillCwd(options: { + activeThreadWorktreePath: string | null; + activeProjectCwd: string | null; + serverCwd: string | null; +}): string | null { + return options.activeThreadWorktreePath ?? options.activeProjectCwd ?? options.serverCwd; +} const SCRIPT_TERMINAL_COLS = 120; const SCRIPT_TERMINAL_ROWS = 30; +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function promptIncludesSkillMention(prompt: string, skillName: string): boolean { + const pattern = new RegExp(`(^|\\s)\\$${escapeRegExp(skillName)}(?=\\s|$)`, "i"); + return pattern.test(prompt); +} + +function normalizeSkillSearchText(value: string | undefined): string { + if (!value) return ""; + return value + .toLowerCase() + .replace(/[:/_-]+/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function buildSkillSearchBlob(skill: { + name: string; + description?: string | undefined; + interface?: + | { + displayName?: string | undefined; + shortDescription?: string | undefined; + } + | undefined; +}): string { + return normalizeSkillSearchText( + [skill.name, skill.interface?.displayName, skill.interface?.shortDescription, skill.description] + .filter((value) => typeof value === "string" && value.trim().length > 0) + .join("\n"), + ); +} + const extendReplacementRangeForTrailingSpace = ( text: string, rangeEnd: number, @@ -332,266 +302,27 @@ interface ChatViewProps { threadId: ThreadId; } -interface TerminalLaunchContext { - threadId: ThreadId; - cwd: string; - worktreePath: string | null; -} - -type PersistentTerminalLaunchContext = Pick; - -function useLocalDispatchState(input: { - activeThread: Thread | undefined; - activeLatestTurn: Thread["latestTurn"] | null; - phase: SessionPhase; - activePendingApproval: ApprovalRequestId | null; - activePendingUserInput: ApprovalRequestId | null; - threadError: string | null | undefined; -}) { - const [localDispatch, setLocalDispatch] = useState(null); - - const beginLocalDispatch = useCallback( - (options?: { preparingWorktree?: boolean }) => { - const preparingWorktree = Boolean(options?.preparingWorktree); - setLocalDispatch((current) => { - if (current) { - return current.preparingWorktree === preparingWorktree - ? current - : { ...current, preparingWorktree }; - } - return createLocalDispatchSnapshot(input.activeThread, options); - }); - }, - [input.activeThread], - ); - - const resetLocalDispatch = useCallback(() => { - setLocalDispatch(null); - }, []); - - const serverAcknowledgedLocalDispatch = useMemo( - () => - hasServerAcknowledgedLocalDispatch({ - localDispatch, - phase: input.phase, - latestTurn: input.activeLatestTurn, - session: input.activeThread?.session ?? null, - hasPendingApproval: input.activePendingApproval !== null, - hasPendingUserInput: input.activePendingUserInput !== null, - threadError: input.threadError, - }), - [ - input.activeLatestTurn, - input.activePendingApproval, - input.activePendingUserInput, - input.activeThread?.session, - input.phase, - input.threadError, - localDispatch, - ], - ); - - useEffect(() => { - if (!serverAcknowledgedLocalDispatch) { - return; - } - resetLocalDispatch(); - }, [resetLocalDispatch, serverAcknowledgedLocalDispatch]); - - return { - beginLocalDispatch, - resetLocalDispatch, - localDispatchStartedAt: localDispatch?.startedAt ?? null, - isPreparingWorktree: localDispatch?.preparingWorktree ?? false, - isSendBusy: localDispatch !== null && !serverAcknowledgedLocalDispatch, - }; -} - -interface PersistentThreadTerminalDrawerProps { - threadId: ThreadId; - visible: boolean; - launchContext: PersistentTerminalLaunchContext | null; - focusRequestId: number; - splitShortcutLabel: string | undefined; - newShortcutLabel: string | undefined; - closeShortcutLabel: string | undefined; - onAddTerminalContext: (selection: TerminalContextSelection) => void; -} - -function PersistentThreadTerminalDrawer({ - threadId, - visible, - launchContext, - focusRequestId, - splitShortcutLabel, - newShortcutLabel, - closeShortcutLabel, - onAddTerminalContext, -}: PersistentThreadTerminalDrawerProps) { - const serverThread = useThreadById(threadId); - const draftThread = useComposerDraftStore( - (store) => store.draftThreadsByThreadId[threadId] ?? null, - ); - const project = useProjectById(serverThread?.projectId ?? draftThread?.projectId); - const terminalState = useTerminalStateStore((state) => - selectThreadTerminalState(state.terminalStateByThreadId, threadId), - ); - const storeSetTerminalHeight = useTerminalStateStore((state) => state.setTerminalHeight); - const storeSplitTerminal = useTerminalStateStore((state) => state.splitTerminal); - const storeNewTerminal = useTerminalStateStore((state) => state.newTerminal); - const storeSetActiveTerminal = useTerminalStateStore((state) => state.setActiveTerminal); - const storeCloseTerminal = useTerminalStateStore((state) => state.closeTerminal); - const [localFocusRequestId, setLocalFocusRequestId] = useState(0); - const worktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; - const effectiveWorktreePath = useMemo(() => { - if (launchContext !== null) { - return launchContext.worktreePath; - } - return worktreePath; - }, [launchContext, worktreePath]); - const cwd = useMemo( - () => - launchContext?.cwd ?? - (project - ? projectScriptCwd({ - project: { cwd: project.cwd }, - worktreePath: effectiveWorktreePath, - }) - : null), - [effectiveWorktreePath, launchContext?.cwd, project], - ); - const runtimeEnv = useMemo( - () => - project - ? projectScriptRuntimeEnv({ - project: { cwd: project.cwd }, - worktreePath: effectiveWorktreePath, - }) - : {}, - [effectiveWorktreePath, project], - ); - - const bumpFocusRequestId = useCallback(() => { - if (!visible) { - return; - } - setLocalFocusRequestId((value) => value + 1); - }, [visible]); - - const setTerminalHeight = useCallback( - (height: number) => { - storeSetTerminalHeight(threadId, height); - }, - [storeSetTerminalHeight, threadId], - ); - - const splitTerminal = useCallback(() => { - storeSplitTerminal(threadId, `terminal-${randomUUID()}`); - bumpFocusRequestId(); - }, [bumpFocusRequestId, storeSplitTerminal, threadId]); - - const createNewTerminal = useCallback(() => { - storeNewTerminal(threadId, `terminal-${randomUUID()}`); - bumpFocusRequestId(); - }, [bumpFocusRequestId, storeNewTerminal, threadId]); - - const activateTerminal = useCallback( - (terminalId: string) => { - storeSetActiveTerminal(threadId, terminalId); - bumpFocusRequestId(); - }, - [bumpFocusRequestId, storeSetActiveTerminal, threadId], - ); - - const closeTerminal = useCallback( - (terminalId: string) => { - const api = readNativeApi(); - if (!api) return; - const isFinalTerminal = terminalState.terminalIds.length <= 1; - const fallbackExitWrite = () => - api.terminal.write({ threadId, terminalId, data: "exit\n" }).catch(() => undefined); - - if ("close" in api.terminal && typeof api.terminal.close === "function") { - void (async () => { - if (isFinalTerminal) { - await api.terminal.clear({ threadId, terminalId }).catch(() => undefined); - } - await api.terminal.close({ - threadId, - terminalId, - deleteHistory: true, - }); - })().catch(() => fallbackExitWrite()); - } else { - void fallbackExitWrite(); - } - - storeCloseTerminal(threadId, terminalId); - bumpFocusRequestId(); - }, - [bumpFocusRequestId, storeCloseTerminal, terminalState.terminalIds.length, threadId], - ); - - const handleAddTerminalContext = useCallback( - (selection: TerminalContextSelection) => { - if (!visible) { - return; - } - onAddTerminalContext(selection); - }, - [onAddTerminalContext, visible], - ); - - if (!project || !terminalState.terminalOpen || !cwd) { - return null; - } - - return ( -
- -
- ); -} - export default function ChatView({ threadId }: ChatViewProps) { - const serverThread = useThreadById(threadId); + const threads = useStore((store) => store.threads); + const projects = useStore((store) => store.projects); + const markThreadVisited = useStore((store) => store.markThreadVisited); + const syncServerReadModel = useStore((store) => store.syncServerReadModel); const setStoreThreadError = useStore((store) => store.setError); - const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); - const activeThreadLastVisitedAt = useUiStateStore( - (store) => store.threadLastVisitedAtById[threadId], - ); - const settings = useSettings(); + const setStoreThreadBranch = useStore((store) => store.setThreadBranch); + const { settings } = useAppSettings(); const setStickyComposerModelSelection = useComposerDraftStore( (store) => store.setStickyModelSelection, ); const timestampFormat = settings.timestampFormat; const navigate = useNavigate(); + const { createThreadHandoff } = useThreadHandoff(); const rawSearch = useSearch({ strict: false, select: (params) => parseDiffRouteSearch(params), }); const { resolvedTheme } = useTheme(); + const queryClient = useQueryClient(); + const createWorktreeMutation = useMutation(gitCreateWorktreeMutationOptions({ queryClient })); const composerDraft = useComposerThreadDraft(threadId); const prompt = composerDraft.prompt; const composerImages = composerDraft.images; @@ -657,6 +388,8 @@ export default function ChatView({ threadId }: ChatViewProps) { const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState< Record >({}); + const [sendPhase, setSendPhase] = useState("idle"); + const [sendStartedAt, setSendStartedAt] = useState(null); const [isConnecting, _setIsConnecting] = useState(false); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); const [respondingRequestIds, setRespondingRequestIds] = useState([]); @@ -671,7 +404,6 @@ export default function ChatView({ threadId }: ChatViewProps) { const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); const [planSidebarOpen, setPlanSidebarOpen] = useState(false); const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); - const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false); // Tracks whether the user explicitly dismissed the sidebar for the active turn. const planSidebarDismissedForTurnRef = useRef(null); // When set, the thread-change reset effect will open the sidebar instead of closing it. @@ -682,9 +414,6 @@ export default function ChatView({ threadId }: ChatViewProps) { const [composerHighlightedItemId, setComposerHighlightedItemId] = useState(null); const [pullRequestDialogState, setPullRequestDialogState] = useState(null); - const [terminalLaunchContext, setTerminalLaunchContext] = useState( - null, - ); const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< Record >({}); @@ -694,6 +423,9 @@ export default function ChatView({ threadId }: ChatViewProps) { const [composerTrigger, setComposerTrigger] = useState(() => detectComposerTrigger(prompt, prompt.length), ); + const [selectedComposerSkills, setSelectedComposerSkills] = useState( + [], + ); const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage( LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, {}, @@ -715,9 +447,6 @@ export default function ChatView({ threadId }: ChatViewProps) { const composerEditorRef = useRef(null); const composerFormRef = useRef(null); const composerFormHeightRef = useRef(0); - const composerFooterRef = useRef(null); - const composerFooterLeadingRef = useRef(null); - const composerFooterActionsRef = useRef(null); const composerImagesRef = useRef([]); const composerSelectLockRef = useRef(false); const composerMenuOpenRef = useRef(false); @@ -728,42 +457,31 @@ export default function ChatView({ threadId }: ChatViewProps) { const sendInFlightRef = useRef(false); const dragDepthRef = useRef(0); const terminalOpenByThreadRef = useRef>({}); + const activatedThreadIdRef = useRef(null); const setMessagesScrollContainerRef = useCallback((element: HTMLDivElement | null) => { messagesScrollRef.current = element; setMessagesScrollElement(element); }, []); - const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId); - const terminalState = useMemo( - () => selectThreadTerminalState(terminalStateByThreadId, threadId), - [terminalStateByThreadId, threadId], - ); - const openTerminalThreadIds = useMemo( - () => - Object.entries(terminalStateByThreadId).flatMap(([nextThreadId, nextTerminalState]) => - nextTerminalState.terminalOpen ? [nextThreadId as ThreadId] : [], - ), - [terminalStateByThreadId], + const terminalState = useTerminalStateStore((state) => + selectThreadTerminalState(state.terminalStateByThreadId, threadId), ); const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen); + const storeSetTerminalPresentationMode = useTerminalStateStore( + (s) => s.setTerminalPresentationMode, + ); + const storeOpenTerminalThreadPage = useTerminalStateStore((s) => s.openTerminalThreadPage); + const storeSetTerminalWorkspaceLayout = useTerminalStateStore( + (s) => s.setTerminalWorkspaceLayout, + ); + const storeSetTerminalWorkspaceTab = useTerminalStateStore((s) => s.setTerminalWorkspaceTab); + const storeSetTerminalHeight = useTerminalStateStore((s) => s.setTerminalHeight); const storeSplitTerminal = useTerminalStateStore((s) => s.splitTerminal); const storeNewTerminal = useTerminalStateStore((s) => s.newTerminal); + const storeOpenNewFullWidthTerminal = useTerminalStateStore((s) => s.openNewFullWidthTerminal); + const storeCloseWorkspaceChat = useTerminalStateStore((s) => s.closeWorkspaceChat); const storeSetActiveTerminal = useTerminalStateStore((s) => s.setActiveTerminal); const storeCloseTerminal = useTerminalStateStore((s) => s.closeTerminal); - const storeServerTerminalLaunchContext = useTerminalStateStore( - (s) => s.terminalLaunchContextByThreadId[threadId] ?? null, - ); - const storeClearTerminalLaunchContext = useTerminalStateStore( - (s) => s.clearTerminalLaunchContext, - ); - const threads = useStore((state) => state.threads); - const serverThreadIds = useMemo(() => threads.map((thread) => thread.id), [threads]); - const draftThreadsByThreadId = useComposerDraftStore((store) => store.draftThreadsByThreadId); - const draftThreadIds = useMemo( - () => Object.keys(draftThreadsByThreadId) as ThreadId[], - [draftThreadsByThreadId], - ); - const [mountedTerminalThreadIds, setMountedTerminalThreadIds] = useState([]); const setPrompt = useCallback( (nextPrompt: string) => { @@ -818,7 +536,8 @@ export default function ChatView({ threadId }: ChatViewProps) { [composerTerminalContexts, removeComposerDraftTerminalContext, setPrompt, threadId], ); - const fallbackDraftProject = useProjectById(draftThread?.projectId); + const serverThread = threads.find((t) => t.id === threadId); + const fallbackDraftProject = projects.find((project) => project.id === draftThread?.projectId); const localDraftError = serverThread ? null : (localDraftErrorsByThreadId[threadId] ?? null); const localDraftThread = useMemo( () => @@ -843,47 +562,16 @@ export default function ChatView({ threadId }: ChatViewProps) { const isServerThread = serverThread !== undefined; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const canCheckoutPullRequestIntoThread = isLocalDraftThread; - const diffOpen = rawSearch.diff === "1"; + const diffOpen = rawSearch.panel === "diff"; + const browserOpen = rawSearch.panel === "browser"; const activeThreadId = activeThread?.id ?? null; - const existingOpenTerminalThreadIds = useMemo(() => { - const existingThreadIds = new Set([...serverThreadIds, ...draftThreadIds]); - return openTerminalThreadIds.filter((nextThreadId) => existingThreadIds.has(nextThreadId)); - }, [draftThreadIds, openTerminalThreadIds, serverThreadIds]); const activeLatestTurn = activeThread?.latestTurn ?? null; - const threadPlanCatalog = useThreadPlanCatalog( - useMemo(() => { - const threadIds: ThreadId[] = []; - if (activeThread?.id) { - threadIds.push(activeThread.id); - } - const sourceThreadId = activeLatestTurn?.sourceProposedPlan?.threadId; - if (sourceThreadId && sourceThreadId !== activeThread?.id) { - threadIds.push(sourceThreadId); - } - return threadIds; - }, [activeLatestTurn?.sourceProposedPlan?.threadId, activeThread?.id]), - ); const activeContextWindow = useMemo( () => deriveLatestContextWindowSnapshot(activeThread?.activities ?? []), [activeThread?.activities], ); - useEffect(() => { - setMountedTerminalThreadIds((currentThreadIds) => { - const nextThreadIds = reconcileMountedTerminalThreadIds({ - currentThreadIds, - openThreadIds: existingOpenTerminalThreadIds, - activeThreadId, - activeThreadTerminalOpen: Boolean(activeThreadId && terminalState.terminalOpen), - maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, - }); - return currentThreadIds.length === nextThreadIds.length && - currentThreadIds.every((nextThreadId, index) => nextThreadId === nextThreadIds[index]) - ? currentThreadIds - : nextThreadIds; - }); - }, [activeThreadId, existingOpenTerminalThreadIds, terminalState.terminalOpen]); const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); - const activeProject = useProjectById(activeThread?.projectId); + const activeProject = projects.find((p) => p.id === activeThread?.projectId); const openPullRequestDialog = useCallback( (reference?: string) => { @@ -918,14 +606,18 @@ export default function ChatView({ threadId }: ChatViewProps) { params: { threadId: storedDraftThread.threadId }, }); } - return storedDraftThread.threadId; + return; } const activeDraftThread = getDraftThread(threadId); - if (!isServerThread && activeDraftThread?.projectId === activeProject.id) { + if ( + !isServerThread && + activeDraftThread?.projectId === activeProject.id && + activeDraftThread.entryPoint === "chat" + ) { setDraftThreadContext(threadId, input); setProjectDraftThreadId(activeProject.id, threadId, input); - return threadId; + return; } clearProjectDraftThreadId(activeProject.id); @@ -940,7 +632,6 @@ export default function ChatView({ threadId }: ChatViewProps) { to: "/$threadId", params: { threadId: nextThreadId }, }); - return nextThreadId; }, [ activeProject, @@ -967,57 +658,55 @@ export default function ChatView({ threadId }: ChatViewProps) { ); useEffect(() => { - if (!serverThread?.id) return; + if (!activeThread?.id) return; if (!latestTurnSettled) return; if (!activeLatestTurn?.completedAt) return; const turnCompletedAt = Date.parse(activeLatestTurn.completedAt); if (Number.isNaN(turnCompletedAt)) return; - const lastVisitedAt = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN; + const lastVisitedAt = activeThread.lastVisitedAt ? Date.parse(activeThread.lastVisitedAt) : NaN; if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return; - markThreadVisited(serverThread.id); + markThreadVisited(activeThread.id); }, [ + activeThread?.id, + activeThread?.lastVisitedAt, activeLatestTurn?.completedAt, - activeThreadLastVisitedAt, latestTurnSettled, markThreadVisited, - serverThread?.id, ]); const sessionProvider = activeThread?.session?.provider ?? null; const selectedProviderByThreadId = composerDraft.activeProvider ?? null; const threadProvider = activeThread?.modelSelection.provider ?? activeProject?.defaultModelSelection?.provider ?? null; - const hasThreadStarted = threadHasStarted(activeThread); + const hasThreadStarted = Boolean( + activeThread && + (activeThread.latestTurn !== null || + activeThread.messages.length > 0 || + activeThread.session !== null), + ); const lockedProvider: ProviderKind | null = hasThreadStarted ? (sessionProvider ?? threadProvider ?? selectedProviderByThreadId ?? null) : null; - const serverConfig = useServerConfig(); - const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; - const unlockedSelectedProvider = resolveSelectableProvider( - providerStatuses, - selectedProviderByThreadId ?? threadProvider ?? "codex", - ); - const selectedProvider: ProviderKind = lockedProvider ?? unlockedSelectedProvider; + const selectedProvider: ProviderKind = + lockedProvider ?? selectedProviderByThreadId ?? threadProvider ?? "codex"; + const customModelsByProvider = useMemo(() => getCustomModelsByProvider(settings), [settings]); const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ threadId, - providers: providerStatuses, selectedProvider, threadModelSelection: activeThread?.modelSelection, projectModelSelection: activeProject?.defaultModelSelection, - settings, + customModelsByProvider, }); - const selectedProviderModels = getProviderModels(providerStatuses, selectedProvider); const composerProviderState = useMemo( () => getComposerProviderState({ provider: selectedProvider, model: selectedModel, - models: selectedProviderModels, prompt, modelOptions: composerModelOptions, }), - [composerModelOptions, prompt, selectedModel, selectedProvider, selectedProviderModels], + [composerModelOptions, prompt, selectedModel, selectedProvider], ); const selectedPromptEffort = composerProviderState.promptEffort; const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; @@ -1029,8 +718,45 @@ export default function ChatView({ threadId }: ChatViewProps) { }), [selectedModel, selectedModelOptionsForDispatch, selectedProvider], ); + const providerOptionsForDispatch = useMemo(() => getProviderStartOptions(settings), [settings]); const selectedModelForPicker = selectedModel; + const modelOptionsByProvider = useMemo( + () => getCustomModelOptionsByProvider(settings), + [settings], + ); + const selectedModelForPickerWithCustomFallback = useMemo(() => { + const currentOptions = modelOptionsByProvider[selectedProvider]; + return currentOptions.some((option) => option.slug === selectedModelForPicker) + ? selectedModelForPicker + : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker); + }, [modelOptionsByProvider, selectedModelForPicker, selectedProvider]); + const searchableModelOptions = useMemo( + () => + AVAILABLE_PROVIDER_OPTIONS.filter( + (option) => lockedProvider === null || option.value === lockedProvider, + ).flatMap((option) => + modelOptionsByProvider[option.value].map(({ slug, name }) => ({ + provider: option.value, + providerLabel: option.label, + slug, + name, + searchSlug: slug.toLowerCase(), + searchName: name.toLowerCase(), + searchProvider: option.label.toLowerCase(), + })), + ), + [lockedProvider, modelOptionsByProvider], + ); const phase = derivePhase(activeThread?.session ?? null); + const isSendBusy = sendPhase !== "idle"; + const isPreparingWorktree = sendPhase === "preparing-worktree"; + const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; + const nowIso = new Date(nowTick).toISOString(); + const activeWorkStartedAt = deriveActiveWorkStartedAt( + activeLatestTurn, + activeThread?.session ?? null, + sendStartedAt, + ); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; const workLogEntries = useMemo( () => deriveWorkLogEntries(threadActivities, activeLatestTurn?.turnId ?? undefined), @@ -1078,6 +804,25 @@ export default function ChatView({ threadId }: ChatViewProps) { : null, [activePendingDraftAnswers, activePendingUserInput], ); + const handoffBadgeLabel = useMemo( + () => (activeThread ? resolveThreadHandoffBadgeLabel(activeThread) : null), + [activeThread], + ); + const handoffBadgeSourceProvider = activeThread?.handoff?.sourceProvider ?? null; + const handoffBadgeTargetProvider = activeThread?.handoff + ? activeThread.modelSelection.provider + : null; + const handoffTargetProvider = useMemo( + () => + activeThread ? resolveHandoffTargetProvider(activeThread.modelSelection.provider) : null, + [activeThread], + ); + const handoffActionLabel = useMemo(() => { + if (!activeThread) { + return "Create handoff thread"; + } + return `Handoff to ${PROVIDER_DISPLAY_NAMES[handoffTargetProvider ?? "codex"]}`; + }, [activeThread, handoffTargetProvider]); const activePendingIsResponding = activePendingUserInput ? respondingUserInputRequestIds.includes(activePendingUserInput.requestId) : false; @@ -1093,12 +838,12 @@ export default function ChatView({ threadId }: ChatViewProps) { const sidebarProposedPlan = useMemo( () => findSidebarProposedPlan({ - threads: threadPlanCatalog, + threads, latestTurn: activeLatestTurn, latestTurnSettled, threadId: activeThread?.id ?? null, }), - [activeLatestTurn, activeThread?.id, latestTurnSettled, threadPlanCatalog], + [activeLatestTurn, activeThread?.id, latestTurnSettled, threads], ); const activePlan = useMemo( () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined), @@ -1110,55 +855,23 @@ export default function ChatView({ threadId }: ChatViewProps) { latestTurnSettled && hasActionableProposedPlan(activeProposedPlan); const activePendingApproval = pendingApprovals[0] ?? null; - const { - beginLocalDispatch, - resetLocalDispatch, - localDispatchStartedAt, - isPreparingWorktree, - isSendBusy, - } = useLocalDispatchState({ - activeThread, - activeLatestTurn, - phase, - activePendingApproval: activePendingApproval?.requestId ?? null, - activePendingUserInput: activePendingUserInput?.requestId ?? null, - threadError: activeThread?.error, - }); - const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; - const nowIso = new Date(nowTick).toISOString(); - const activeWorkStartedAt = deriveActiveWorkStartedAt( - activeLatestTurn, - activeThread?.session ?? null, - localDispatchStartedAt, - ); const isComposerApprovalState = activePendingApproval !== null; const hasComposerHeader = isComposerApprovalState || pendingUserInputs.length > 0 || (showPlanFollowUpPrompt && activeProposedPlan !== null); const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null; - const composerFooterActionLayoutKey = useMemo(() => { - if (activePendingProgress) { - return `pending:${activePendingProgress.questionIndex}:${activePendingProgress.isLastQuestion}:${activePendingIsResponding}`; - } - if (phase === "running") { - return "running"; - } - if (showPlanFollowUpPrompt) { - return prompt.trim().length > 0 ? "plan:refine" : "plan:implement"; - } - return `idle:${composerSendState.hasSendableContent}:${isSendBusy}:${isConnecting}:${isPreparingWorktree}`; - }, [ - activePendingIsResponding, - activePendingProgress, - composerSendState.hasSendableContent, - isConnecting, - isPreparingWorktree, - isSendBusy, - phase, - prompt, - showPlanFollowUpPrompt, - ]); + const handoffDisabled = !( + activeThread && + activeProject && + isServerThread && + canCreateThreadHandoff({ + thread: activeThread, + isBusy: isWorking, + hasPendingApprovals: pendingApprovals.length > 0, + hasPendingUserInput: pendingUserInputs.length > 0, + }) + ); const lastSyncedPendingInputRef = useRef<{ requestId: string | null; questionId: string | null; @@ -1381,9 +1094,35 @@ export default function ChatView({ threadId }: ChatViewProps) { ]); const completionDividerBeforeEntryId = useMemo(() => { if (!latestTurnSettled) return null; + if (!activeLatestTurn?.startedAt) return null; + if (!activeLatestTurn.completedAt) return null; if (!completionSummary) return null; - return deriveCompletionDividerBeforeEntryId(timelineEntries, activeLatestTurn); - }, [activeLatestTurn, completionSummary, latestTurnSettled, timelineEntries]); + + const turnStartedAt = Date.parse(activeLatestTurn.startedAt); + const turnCompletedAt = Date.parse(activeLatestTurn.completedAt); + if (Number.isNaN(turnStartedAt)) return null; + if (Number.isNaN(turnCompletedAt)) return null; + + let inRangeMatch: string | null = null; + let fallbackMatch: string | null = null; + for (const timelineEntry of timelineEntries) { + if (timelineEntry.kind !== "message") continue; + if (timelineEntry.message.role !== "assistant") continue; + const messageAt = Date.parse(timelineEntry.message.createdAt); + if (Number.isNaN(messageAt) || messageAt < turnStartedAt) continue; + fallbackMatch = timelineEntry.id; + if (messageAt <= turnCompletedAt) { + inRangeMatch = timelineEntry.id; + } + } + return inRangeMatch ?? fallbackMatch; + }, [ + activeLatestTurn?.completedAt, + activeLatestTurn?.startedAt, + completionSummary, + latestTurnSettled, + timelineEntries, + ]); const gitCwd = activeProject ? projectScriptCwd({ project: { cwd: activeProject.cwd }, @@ -1393,45 +1132,35 @@ export default function ChatView({ threadId }: ChatViewProps) { const composerTriggerKind = composerTrigger?.kind ?? null; const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; const isPathTrigger = composerTriggerKind === "path"; + const skillTriggerQuery = composerTrigger?.kind === "skill" ? composerTrigger.query : ""; + const isSkillTrigger = composerTriggerKind === "skill"; const [debouncedPathQuery, composerPathQueryDebouncer] = useDebouncedValue( pathTriggerQuery, { wait: COMPOSER_PATH_QUERY_DEBOUNCE_MS }, (debouncerState) => ({ isPending: debouncerState.isPending }), ); const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; - const gitStatusQuery = useQuery(gitStatusQueryOptions(gitCwd)); - const keybindings = useServerKeybindings(); - const availableEditors = useServerAvailableEditors(); - const modelOptionsByProvider = useMemo( - () => ({ - codex: providerStatuses.find((provider) => provider.provider === "codex")?.models ?? [], - claudeAgent: - providerStatuses.find((provider) => provider.provider === "claudeAgent")?.models ?? [], - }), - [providerStatuses], + const branchesQuery = useQuery(gitBranchesQueryOptions(gitCwd)); + const serverConfigQuery = useQuery(serverConfigQueryOptions()); + const composerSkillCwd = resolveComposerSkillCwd({ + activeThreadWorktreePath: activeThread?.worktreePath ?? null, + activeProjectCwd: activeProject?.cwd ?? null, + serverCwd: serverConfigQuery.data?.cwd ?? null, + }); + const providerComposerCapabilitiesQuery = useQuery( + providerComposerCapabilitiesQueryOptions(selectedProvider), ); - const selectedModelForPickerWithCustomFallback = useMemo(() => { - const currentOptions = modelOptionsByProvider[selectedProvider]; - return currentOptions.some((option) => option.slug === selectedModelForPicker) - ? selectedModelForPicker - : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker); - }, [modelOptionsByProvider, selectedModelForPicker, selectedProvider]); - const searchableModelOptions = useMemo( - () => - AVAILABLE_PROVIDER_OPTIONS.filter( - (option) => lockedProvider === null || option.value === lockedProvider, - ).flatMap((option) => - modelOptionsByProvider[option.value].map(({ slug, name }) => ({ - provider: option.value, - providerLabel: option.label, - slug, - name, - searchSlug: slug.toLowerCase(), - searchName: name.toLowerCase(), - searchProvider: option.label.toLowerCase(), - })), - ), - [lockedProvider, modelOptionsByProvider], + const providerSkillsQuery = useQuery( + providerSkillsQueryOptions({ + provider: selectedProvider, + cwd: composerSkillCwd, + threadId, + query: skillTriggerQuery, + enabled: + isSkillTrigger && + supportsSkillDiscovery(providerComposerCapabilitiesQuery.data) && + composerSkillCwd !== null, + }), ); const workspaceEntriesQuery = useQuery( projectSearchEntriesQueryOptions({ @@ -1442,6 +1171,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }), ); const workspaceEntries = workspaceEntriesQuery.data?.entries ?? EMPTY_PROJECT_ENTRIES; + const providerSkills = providerSkillsQuery.data?.skills ?? EMPTY_PROVIDER_SKILLS; const composerMenuItems = useMemo(() => { if (!composerTrigger) return []; if (composerTrigger.kind === "path") { @@ -1488,6 +1218,23 @@ export default function ChatView({ threadId }: ChatViewProps) { ); } + if (composerTrigger.kind === "skill") { + if (selectedProvider !== "codex") return []; + const query = normalizeSkillSearchText(composerTrigger.query); + return providerSkills + .filter((skill) => { + if (!query) return true; + return buildSkillSearchBlob(skill).includes(query); + }) + .map((skill) => ({ + id: `skill:${skill.path}`, + type: "skill", + skill, + label: skill.interface?.displayName ?? skill.name, + description: skill.interface?.shortDescription ?? skill.description ?? skill.path, + })); + } + return searchableModelOptions .filter(({ searchSlug, searchName, searchProvider }) => { const query = composerTrigger.query.trim().toLowerCase(); @@ -1504,8 +1251,10 @@ export default function ChatView({ threadId }: ChatViewProps) { label: name, description: `${providerLabel} · ${slug}`, })); - }, [composerTrigger, searchableModelOptions, workspaceEntries]); - const composerMenuOpen = Boolean(composerTrigger); + }, [composerTrigger, providerSkills, searchableModelOptions, selectedProvider, workspaceEntries]); + const composerMenuOpen = + Boolean(composerTrigger) && + !(composerTrigger?.kind === "skill" && selectedProvider !== "codex"); const activeComposerMenuItem = useMemo( () => composerMenuItems.find((item) => item.id === composerHighlightedItemId) ?? @@ -1520,56 +1269,53 @@ export default function ChatView({ threadId }: ChatViewProps) { () => new Set(nonPersistedComposerImageIds), [nonPersistedComposerImageIds], ); + const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS; + const availableEditors = serverConfigQuery.data?.availableEditors ?? EMPTY_AVAILABLE_EDITORS; + const providerStatuses = serverConfigQuery.data?.providers ?? EMPTY_PROVIDER_STATUSES; const activeProviderStatus = useMemo( () => providerStatuses.find((status) => status.provider === selectedProvider) ?? null, [selectedProvider, providerStatuses], ); const activeProjectCwd = activeProject?.cwd ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; - const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; - const activeTerminalLaunchContext = - terminalLaunchContext?.threadId === activeThreadId - ? terminalLaunchContext - : (storeServerTerminalLaunchContext ?? null); - // Default true while loading to avoid toolbar flicker. - const isGitRepo = gitStatusQuery.data?.isRepo ?? true; - const terminalShortcutLabelOptions = useMemo( - () => ({ - context: { - terminalFocus: true, - terminalOpen: Boolean(terminalState.terminalOpen), - }, - }), - [terminalState.terminalOpen], - ); - const nonTerminalShortcutLabelOptions = useMemo( - () => ({ - context: { - terminalFocus: false, - terminalOpen: Boolean(terminalState.terminalOpen), + const threadTerminalRuntimeEnv = useMemo(() => { + if (!activeProjectCwd) return {}; + return projectScriptRuntimeEnv({ + project: { + cwd: activeProjectCwd, }, - }), - [terminalState.terminalOpen], - ); + worktreePath: activeThreadWorktreePath, + }); + }, [activeProjectCwd, activeThreadWorktreePath]); + // Default true while loading to avoid toolbar flicker. + const isGitRepo = branchesQuery.data?.isRepo ?? true; const terminalToggleShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "terminal.toggle"), [keybindings], ); const splitTerminalShortcutLabel = useMemo( - () => shortcutLabelForCommand(keybindings, "terminal.split", terminalShortcutLabelOptions), - [keybindings, terminalShortcutLabelOptions], + () => shortcutLabelForCommand(keybindings, "terminal.split"), + [keybindings], ); const newTerminalShortcutLabel = useMemo( - () => shortcutLabelForCommand(keybindings, "terminal.new", terminalShortcutLabelOptions), - [keybindings, terminalShortcutLabelOptions], + () => shortcutLabelForCommand(keybindings, "terminal.new"), + [keybindings], ); const closeTerminalShortcutLabel = useMemo( - () => shortcutLabelForCommand(keybindings, "terminal.close", terminalShortcutLabelOptions), - [keybindings, terminalShortcutLabelOptions], + () => shortcutLabelForCommand(keybindings, "terminal.close"), + [keybindings], + ); + const closeWorkspaceShortcutLabel = useMemo( + () => shortcutLabelForCommand(keybindings, "terminal.workspace.closeActive"), + [keybindings], ); const diffPanelShortcutLabel = useMemo( - () => shortcutLabelForCommand(keybindings, "diff.toggle", nonTerminalShortcutLabelOptions), - [keybindings, nonTerminalShortcutLabelOptions], + () => shortcutLabelForCommand(keybindings, "diff.toggle"), + [keybindings], + ); + const browserPanelShortcutLabel = useMemo( + () => shortcutLabelForCommand(keybindings, "browser.toggle"), + [keybindings], ); const onToggleDiff = useCallback(() => { void navigate({ @@ -1578,10 +1324,23 @@ export default function ChatView({ threadId }: ChatViewProps) { replace: true, search: (previous) => { const rest = stripDiffSearchParams(previous); - return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" }; + return diffOpen + ? { ...rest, panel: undefined, diff: undefined } + : { ...rest, panel: "diff", diff: "1" }; }, }); }, [diffOpen, navigate, threadId]); + const onToggleBrowser = useCallback(() => { + void navigate({ + to: "/$threadId", + params: { threadId }, + replace: true, + search: (previous) => { + const rest = stripDiffSearchParams(previous); + return browserOpen ? { ...rest, panel: undefined } : { ...rest, panel: "browser" }; + }, + }); + }, [browserOpen, navigate, threadId]); const envLocked = Boolean( activeThread && @@ -1598,25 +1357,37 @@ export default function ChatView({ threadId }: ChatViewProps) { null; const hasReachedSplitLimit = (activeTerminalGroup?.terminalIds.length ?? 0) >= MAX_TERMINALS_PER_GROUP; + const terminalWorkspaceOpen = shouldRenderTerminalWorkspace({ + activeProjectExists: activeProject !== undefined, + presentationMode: terminalState.presentationMode, + terminalOpen: terminalState.terminalOpen, + }); + const terminalWorkspaceTerminalTabActive = + terminalWorkspaceOpen && + (terminalState.workspaceLayout === "terminal-only" || + terminalState.workspaceActiveTab === "terminal"); + const terminalWorkspaceChatTabActive = + terminalWorkspaceOpen && + terminalState.workspaceLayout === "both" && + terminalState.workspaceActiveTab === "chat"; const setThreadError = useCallback( (targetThreadId: ThreadId | null, error: string | null) => { if (!targetThreadId) return; - const nextError = sanitizeThreadErrorMessage(error); - if (useStore.getState().threads.some((thread) => thread.id === targetThreadId)) { - setStoreThreadError(targetThreadId, nextError); + if (threads.some((thread) => thread.id === targetThreadId)) { + setStoreThreadError(targetThreadId, error); return; } setLocalDraftErrorsByThreadId((existing) => { - if ((existing[targetThreadId] ?? null) === nextError) { + if ((existing[targetThreadId] ?? null) === error) { return existing; } return { ...existing, - [targetThreadId]: nextError, + [targetThreadId]: error, }; }); }, - [setStoreThreadError], + [setStoreThreadError, threads], ); const focusComposer = useCallback(() => { @@ -1676,10 +1447,56 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [activeThreadId, storeSetTerminalOpen], ); + const setTerminalPresentationMode = useCallback( + (mode: "drawer" | "workspace") => { + if (!activeThreadId) return; + storeSetTerminalPresentationMode(activeThreadId, mode); + }, + [activeThreadId, storeSetTerminalPresentationMode], + ); + const setTerminalWorkspaceLayout = useCallback( + (layout: "both" | "terminal-only") => { + if (!activeThreadId) return; + storeSetTerminalWorkspaceLayout(activeThreadId, layout); + }, + [activeThreadId, storeSetTerminalWorkspaceLayout], + ); + const setTerminalWorkspaceTab = useCallback( + (tab: "terminal" | "chat") => { + if (!activeThreadId) return; + storeSetTerminalWorkspaceTab(activeThreadId, tab); + }, + [activeThreadId, storeSetTerminalWorkspaceTab], + ); + const setTerminalHeight = useCallback( + (height: number) => { + if (!activeThreadId) return; + storeSetTerminalHeight(activeThreadId, height); + }, + [activeThreadId, storeSetTerminalHeight], + ); const toggleTerminalVisibility = useCallback(() => { if (!activeThreadId) return; + if (!terminalState.terminalOpen) { + setTerminalPresentationMode("drawer"); + } setTerminalOpen(!terminalState.terminalOpen); - }, [activeThreadId, setTerminalOpen, terminalState.terminalOpen]); + }, [activeThreadId, setTerminalOpen, setTerminalPresentationMode, terminalState.terminalOpen]); + const expandTerminalWorkspace = useCallback(() => { + if (!activeThreadId) return; + setTerminalPresentationMode("workspace"); + setTerminalWorkspaceLayout("both"); + setTerminalWorkspaceTab("terminal"); + }, [ + activeThreadId, + setTerminalPresentationMode, + setTerminalWorkspaceLayout, + setTerminalWorkspaceTab, + ]); + const collapseTerminalWorkspace = useCallback(() => { + if (!activeThreadId) return; + setTerminalPresentationMode("drawer"); + }, [activeThreadId, setTerminalPresentationMode]); const splitTerminal = useCallback(() => { if (!activeThreadId || hasReachedSplitLimit) return; const terminalId = `terminal-${randomUUID()}`; @@ -1692,6 +1509,20 @@ export default function ChatView({ threadId }: ChatViewProps) { storeNewTerminal(activeThreadId, terminalId); setTerminalFocusRequestId((value) => value + 1); }, [activeThreadId, storeNewTerminal]); + const openNewFullWidthTerminal = useCallback(() => { + if (!activeThreadId || !activeProject) return; + const terminalId = `terminal-${randomUUID()}`; + storeOpenNewFullWidthTerminal(activeThreadId, terminalId); + setTerminalFocusRequestId((value) => value + 1); + }, [activeProject, activeThreadId, storeOpenNewFullWidthTerminal]); + const activateTerminal = useCallback( + (terminalId: string) => { + if (!activeThreadId) return; + storeSetActiveTerminal(activeThreadId, terminalId); + setTerminalFocusRequestId((value) => value + 1); + }, + [activeThreadId, storeSetActiveTerminal], + ); const closeTerminal = useCallback( (terminalId: string) => { const api = readNativeApi(); @@ -1722,6 +1553,69 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [activeThreadId, storeCloseTerminal, terminalState.terminalIds.length], ); + const closeActiveWorkspaceView = useCallback(() => { + if (!activeThreadId || !terminalWorkspaceOpen) { + return; + } + if (terminalState.workspaceLayout === "both" && terminalState.workspaceActiveTab === "chat") { + storeCloseWorkspaceChat(activeThreadId); + return; + } + closeTerminal(terminalState.activeTerminalId); + }, [ + activeThreadId, + closeTerminal, + storeCloseWorkspaceChat, + terminalState.activeTerminalId, + terminalState.workspaceActiveTab, + terminalState.workspaceLayout, + terminalWorkspaceOpen, + ]); + const terminalDrawerProps = useMemo( + () => ({ + threadId, + cwd: gitCwd ?? activeProject?.cwd ?? "", + runtimeEnv: threadTerminalRuntimeEnv, + height: terminalState.terminalHeight, + terminalIds: terminalState.terminalIds, + activeTerminalId: terminalState.activeTerminalId, + terminalGroups: terminalState.terminalGroups, + activeTerminalGroupId: terminalState.activeTerminalGroupId, + focusRequestId: terminalFocusRequestId, + onSplitTerminal: splitTerminal, + onNewTerminal: createNewTerminal, + splitShortcutLabel: splitTerminalShortcutLabel ?? undefined, + newShortcutLabel: newTerminalShortcutLabel ?? undefined, + closeShortcutLabel: closeTerminalShortcutLabel ?? undefined, + workspaceCloseShortcutLabel: closeWorkspaceShortcutLabel ?? undefined, + onActiveTerminalChange: activateTerminal, + onCloseTerminal: closeTerminal, + onHeightChange: setTerminalHeight, + onAddTerminalContext: addTerminalContextToDraft, + }), + [ + activeProject?.cwd, + activateTerminal, + addTerminalContextToDraft, + closeTerminal, + closeTerminalShortcutLabel, + closeWorkspaceShortcutLabel, + createNewTerminal, + gitCwd, + newTerminalShortcutLabel, + setTerminalHeight, + splitTerminal, + splitTerminalShortcutLabel, + terminalFocusRequestId, + terminalState.activeTerminalGroupId, + terminalState.activeTerminalId, + terminalState.terminalGroups, + terminalState.terminalHeight, + terminalState.terminalIds, + threadId, + threadTerminalRuntimeEnv, + ], + ); const runProjectScript = useCallback( async ( script: ProjectScript, @@ -1752,13 +1646,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const targetTerminalId = shouldCreateNewTerminal ? `terminal-${randomUUID()}` : baseTerminalId; - const targetWorktreePath = options?.worktreePath ?? activeThread.worktreePath ?? null; - setTerminalLaunchContext({ - threadId: activeThreadId, - cwd: targetCwd, - worktreePath: targetWorktreePath, - }); setTerminalOpen(true); if (shouldCreateNewTerminal) { storeNewTerminal(activeThreadId, targetTerminalId); @@ -1771,15 +1659,14 @@ export default function ChatView({ threadId }: ChatViewProps) { project: { cwd: activeProject.cwd, }, - worktreePath: targetWorktreePath, + worktreePath: options?.worktreePath ?? activeThread.worktreePath ?? null, ...(options?.env ? { extraEnv: options.env } : {}), }); - const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal + const openTerminalInput: Parameters[0] = shouldCreateNewTerminal ? { threadId: activeThreadId, terminalId: targetTerminalId, cwd: targetCwd, - ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}), env: runtimeEnv, cols: SCRIPT_TERMINAL_COLS, rows: SCRIPT_TERMINAL_ROWS, @@ -1788,7 +1675,6 @@ export default function ChatView({ threadId }: ChatViewProps) { threadId: activeThreadId, terminalId: targetTerminalId, cwd: targetCwd, - ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}), env: runtimeEnv, }; @@ -1821,7 +1707,6 @@ export default function ChatView({ threadId }: ChatViewProps) { terminalState.terminalIds, ], ); - const persistProjectScripts = useCallback( async (input: { projectId: ProjectId; @@ -1848,9 +1733,10 @@ export default function ChatView({ threadId }: ChatViewProps) { if (isElectron && keybindingRule) { await api.server.upsertKeybinding(keybindingRule); + await queryClient.invalidateQueries({ queryKey: serverQueryKeys.all }); } }, - [], + [queryClient], ); const saveProjectScript = useCallback( async (input: NewProjectScriptInput) => { @@ -2143,13 +2029,13 @@ export default function ChatView({ threadId }: ChatViewProps) { pendingUserScrollUpIntentRef.current = false; } else if (shouldAutoScrollRef.current && pendingUserScrollUpIntentRef.current) { const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; - if (scrolledUp && !isNearBottom) { + if (scrolledUp) { shouldAutoScrollRef.current = false; } pendingUserScrollUpIntentRef.current = false; } else if (shouldAutoScrollRef.current && isPointerScrollActiveRef.current) { const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; - if (scrolledUp && !isNearBottom) { + if (scrolledUp) { shouldAutoScrollRef.current = false; } } else if (shouldAutoScrollRef.current && !isNearBottom) { @@ -2218,56 +2104,23 @@ export default function ChatView({ threadId }: ChatViewProps) { const composerForm = composerFormRef.current; if (!composerForm) return; const measureComposerFormWidth = () => composerForm.clientWidth; - const measureFooterCompactness = () => { - const composerFormWidth = measureComposerFormWidth(); - const heuristicFooterCompact = shouldUseCompactComposerFooter(composerFormWidth, { - hasWideActions: composerFooterHasWideActions, - }); - const footer = composerFooterRef.current; - const footerStyle = footer ? window.getComputedStyle(footer) : null; - const footerContentWidth = resolveComposerFooterContentWidth({ - footerWidth: footer?.clientWidth ?? null, - paddingLeft: footerStyle ? Number.parseFloat(footerStyle.paddingLeft) : null, - paddingRight: footerStyle ? Number.parseFloat(footerStyle.paddingRight) : null, - }); - const fitInput = { - footerContentWidth, - leadingContentWidth: composerFooterLeadingRef.current?.scrollWidth ?? null, - actionsWidth: composerFooterActionsRef.current?.scrollWidth ?? null, - }; - const nextFooterCompact = - heuristicFooterCompact || shouldForceCompactComposerFooterForFit(fitInput); - const nextPrimaryActionsCompact = - nextFooterCompact && - shouldUseCompactComposerPrimaryActions(composerFormWidth, { - hasWideActions: composerFooterHasWideActions, - }); - - return { - primaryActionsCompact: nextPrimaryActionsCompact, - footerCompact: nextFooterCompact, - }; - }; composerFormHeightRef.current = composerForm.getBoundingClientRect().height; - const initialCompactness = measureFooterCompactness(); - setIsComposerPrimaryActionsCompact(initialCompactness.primaryActionsCompact); - setIsComposerFooterCompact(initialCompactness.footerCompact); + setIsComposerFooterCompact( + shouldUseCompactComposerFooter(measureComposerFormWidth(), { + hasWideActions: composerFooterHasWideActions, + }), + ); if (typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver((entries) => { const [entry] = entries; if (!entry) return; - const nextCompactness = measureFooterCompactness(); - setIsComposerPrimaryActionsCompact((previous) => - previous === nextCompactness.primaryActionsCompact - ? previous - : nextCompactness.primaryActionsCompact, - ); - setIsComposerFooterCompact((previous) => - previous === nextCompactness.footerCompact ? previous : nextCompactness.footerCompact, - ); + const nextCompact = shouldUseCompactComposerFooter(measureComposerFormWidth(), { + hasWideActions: composerFooterHasWideActions, + }); + setIsComposerFooterCompact((previous) => (previous === nextCompact ? previous : nextCompact)); const nextHeight = entry.contentRect.height; const previousHeight = composerFormHeightRef.current; @@ -2282,12 +2135,7 @@ export default function ChatView({ threadId }: ChatViewProps) { return () => { observer.disconnect(); }; - }, [ - activeThread?.id, - composerFooterActionLayoutKey, - composerFooterHasWideActions, - scheduleStickToBottom, - ]); + }, [activeThread?.id, composerFooterHasWideActions, scheduleStickToBottom]); useEffect(() => { if (!shouldAutoScrollRef.current) return; scheduleStickToBottom(); @@ -2377,6 +2225,18 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing)); }, [prompt]); + useEffect(() => { + setSelectedComposerSkills((existing) => + existing.filter((skill) => promptIncludesSkillMention(prompt, skill.name)), + ); + }, [prompt]); + + useEffect(() => { + if (selectedProvider !== "codex") { + setSelectedComposerSkills([]); + } + }, [selectedProvider]); + useEffect(() => { setOptimisticUserMessages((existing) => { for (const message of existing) { @@ -2384,14 +2244,16 @@ export default function ChatView({ threadId }: ChatViewProps) { } return []; }); - resetLocalDispatch(); + setSendPhase("idle"); + setSendStartedAt(null); setComposerHighlightedItemId(null); setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length)); setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); + setSelectedComposerSkills([]); dragDepthRef.current = 0; setIsDragOverComposer(false); setExpandedImage(null); - }, [resetLocalDispatch, threadId]); + }, [threadId]); useEffect(() => { let cancelled = false; @@ -2515,104 +2377,100 @@ export default function ChatView({ threadId }: ChatViewProps) { : "local"; useEffect(() => { - if (!activeThreadId) { - setTerminalLaunchContext(null); - storeClearTerminalLaunchContext(threadId); - return; - } - setTerminalLaunchContext((current) => { - if (!current) return current; - if (current.threadId === activeThreadId) return current; - return null; - }); - }, [activeThreadId, storeClearTerminalLaunchContext, threadId]); + if (phase !== "running") return; + const timer = window.setInterval(() => { + setNowTick(Date.now()); + }, 1000); + return () => { + window.clearInterval(timer); + }; + }, [phase]); - useEffect(() => { - if (!activeThreadId || !activeProjectCwd) { - return; - } - setTerminalLaunchContext((current) => { - if (!current || current.threadId !== activeThreadId) { - return current; - } - const settledCwd = projectScriptCwd({ - project: { cwd: activeProjectCwd }, - worktreePath: activeThreadWorktreePath, - }); - if ( - settledCwd === current.cwd && - (activeThreadWorktreePath ?? null) === current.worktreePath - ) { - storeClearTerminalLaunchContext(activeThreadId); - return null; - } - return current; - }); - }, [activeProjectCwd, activeThreadId, activeThreadWorktreePath, storeClearTerminalLaunchContext]); + const beginSendPhase = useCallback((nextPhase: Exclude) => { + setSendStartedAt((current) => current ?? new Date().toISOString()); + setSendPhase(nextPhase); + }, []); + + const resetSendPhase = useCallback(() => { + setSendPhase("idle"); + setSendStartedAt(null); + }, []); useEffect(() => { - if (!activeThreadId || !activeProjectCwd || !storeServerTerminalLaunchContext) { + if (sendPhase === "idle") { return; } - const settledCwd = projectScriptCwd({ - project: { cwd: activeProjectCwd }, - worktreePath: activeThreadWorktreePath, - }); if ( - settledCwd === storeServerTerminalLaunchContext.cwd && - (activeThreadWorktreePath ?? null) === storeServerTerminalLaunchContext.worktreePath + phase === "running" || + activePendingApproval !== null || + activePendingUserInput !== null || + activeThread?.error ) { - storeClearTerminalLaunchContext(activeThreadId); + resetSendPhase(); } }, [ - activeProjectCwd, - activeThreadId, - activeThreadWorktreePath, - storeClearTerminalLaunchContext, - storeServerTerminalLaunchContext, + activePendingApproval, + activePendingUserInput, + activeThread?.error, + phase, + resetSendPhase, + sendPhase, ]); useEffect(() => { - if (terminalState.terminalOpen) { + if (!activeThreadId) return; + const previous = terminalOpenByThreadRef.current[activeThreadId] ?? false; + const current = Boolean(terminalState.terminalOpen); + + if (!previous && current) { + terminalOpenByThreadRef.current[activeThreadId] = current; + setTerminalFocusRequestId((value) => value + 1); return; + } else if (previous && !current) { + terminalOpenByThreadRef.current[activeThreadId] = current; + const frame = window.requestAnimationFrame(() => { + focusComposer(); + }); + return () => { + window.cancelAnimationFrame(frame); + }; } - if (activeThreadId) { - storeClearTerminalLaunchContext(activeThreadId); - } - setTerminalLaunchContext((current) => (current?.threadId === activeThreadId ? null : current)); - }, [activeThreadId, storeClearTerminalLaunchContext, terminalState.terminalOpen]); + + terminalOpenByThreadRef.current[activeThreadId] = current; + }, [activeThreadId, focusComposer, terminalState.terminalOpen]); useEffect(() => { - if (phase !== "running") return; - const timer = window.setInterval(() => { - setNowTick(Date.now()); - }, 1000); - return () => { - window.clearInterval(timer); - }; - }, [phase]); + if (!activeThreadId) { + activatedThreadIdRef.current = null; + return; + } + if (activatedThreadIdRef.current === activeThreadId) { + return; + } + activatedThreadIdRef.current = activeThreadId; + if (terminalState.entryPoint !== "terminal") { + return; + } + storeOpenTerminalThreadPage(activeThreadId); + }, [activeThreadId, storeOpenTerminalThreadPage, terminalState.entryPoint]); useEffect(() => { - if (!activeThreadId) return; - const previous = terminalOpenByThreadRef.current[activeThreadId] ?? false; - const current = Boolean(terminalState.terminalOpen); + if (!terminalWorkspaceOpen) { + return; + } - if (!previous && current) { - terminalOpenByThreadRef.current[activeThreadId] = current; + if (terminalState.workspaceActiveTab === "terminal") { setTerminalFocusRequestId((value) => value + 1); return; - } else if (previous && !current) { - terminalOpenByThreadRef.current[activeThreadId] = current; - const frame = window.requestAnimationFrame(() => { - focusComposer(); - }); - return () => { - window.cancelAnimationFrame(frame); - }; } - terminalOpenByThreadRef.current[activeThreadId] = current; - }, [activeThreadId, focusComposer, terminalState.terminalOpen]); + const frame = window.requestAnimationFrame(() => { + focusComposer(); + }); + return () => { + window.cancelAnimationFrame(frame); + }; + }, [focusComposer, terminalState.workspaceActiveTab, terminalWorkspaceOpen]); useEffect(() => { const handler = (event: globalThis.KeyboardEvent) => { @@ -2620,6 +2478,10 @@ export default function ChatView({ threadId }: ChatViewProps) { const shortcutContext = { terminalFocus: isTerminalFocused(), terminalOpen: Boolean(terminalState.terminalOpen), + terminalWorkspaceOpen, + terminalWorkspaceTerminalOnly: terminalState.workspaceLayout === "terminal-only", + terminalWorkspaceTerminalTabActive, + terminalWorkspaceChatTabActive, }; const command = resolveShortcutCommand(event, keybindings, { @@ -2662,6 +2524,36 @@ export default function ChatView({ threadId }: ChatViewProps) { return; } + if (command === "terminal.workspace.newFullWidth") { + event.preventDefault(); + event.stopPropagation(); + openNewFullWidthTerminal(); + return; + } + + if (command === "terminal.workspace.closeActive") { + event.preventDefault(); + event.stopPropagation(); + closeActiveWorkspaceView(); + return; + } + + if (command === "terminal.workspace.terminal") { + event.preventDefault(); + event.stopPropagation(); + if (!terminalWorkspaceOpen) return; + setTerminalWorkspaceTab("terminal"); + return; + } + + if (command === "terminal.workspace.chat") { + event.preventDefault(); + event.stopPropagation(); + if (!terminalWorkspaceOpen) return; + setTerminalWorkspaceTab("chat"); + return; + } + if (command === "diff.toggle") { event.preventDefault(); event.stopPropagation(); @@ -2669,6 +2561,14 @@ export default function ChatView({ threadId }: ChatViewProps) { return; } + if (command === "browser.toggle") { + event.preventDefault(); + event.stopPropagation(); + if (!isElectron) return; + onToggleBrowser(); + return; + } + const scriptId = projectScriptIdFromCommand(command); if (!scriptId || !activeProject) return; const script = activeProject.scripts.find((entry) => entry.id === scriptId); @@ -2677,20 +2577,28 @@ export default function ChatView({ threadId }: ChatViewProps) { event.stopPropagation(); void runProjectScript(script); }; - window.addEventListener("keydown", handler); - return () => window.removeEventListener("keydown", handler); + window.addEventListener("keydown", handler, { capture: true }); + return () => window.removeEventListener("keydown", handler, { capture: true }); }, [ activeProject, terminalState.terminalOpen, terminalState.activeTerminalId, + terminalState.workspaceLayout, activeThreadId, closeTerminal, + closeActiveWorkspaceView, createNewTerminal, setTerminalOpen, + openNewFullWidthTerminal, runProjectScript, splitTerminal, keybindings, + terminalWorkspaceChatTabActive, + terminalWorkspaceOpen, + terminalWorkspaceTerminalTabActive, + onToggleBrowser, onToggleDiff, + setTerminalWorkspaceTab, toggleTerminalVisibility, ]); @@ -2846,6 +2754,25 @@ export default function ChatView({ threadId }: ChatViewProps) { [activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError], ); + const onCreateHandoffThread = useCallback(async () => { + if (!activeThread || handoffDisabled) { + return; + } + + try { + await createThreadHandoff(activeThread); + } catch (error) { + toastManager.add({ + type: "error", + title: "Could not create handoff thread", + description: + error instanceof Error + ? error.message + : "An error occurred while creating the handoff thread.", + }); + } + }, [activeThread, createThreadHandoff, handoffDisabled]); + const onSend = async (e?: { preventDefault: () => void }) => { e?.preventDefault(); const api = readNativeApi(); @@ -2929,10 +2856,11 @@ export default function ChatView({ threadId }: ChatViewProps) { } sendInFlightRef.current = true; - beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) }); + beginSendPhase(baseBranchForWorktree ? "preparing-worktree" : "sending-turn"); const composerImagesSnapshot = [...composerImages]; const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; + const composerSkillsSnapshot = [...selectedComposerSkills]; const messageTextForSend = appendTerminalContextsToPrompt( promptForSend, composerTerminalContextsSnapshot, @@ -2942,10 +2870,15 @@ export default function ChatView({ threadId }: ChatViewProps) { const outgoingMessageText = formatOutgoingPrompt({ provider: selectedProvider, model: selectedModel, - models: selectedProviderModels, effort: selectedPromptEffort, text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, }); + const selectedSkillsForSend = + selectedProvider === "codex" + ? selectedComposerSkills.filter((skill) => + promptIncludesSkillMention(outgoingMessageText, skill.name), + ) + : []; const turnAttachmentsPromise = Promise.all( composerImagesSnapshot.map(async (image) => ({ type: "image" as const, @@ -2972,6 +2905,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}), createdAt: messageCreatedAt, streaming: false, + source: "native", }, ]); // Sending a message should always bring the latest user turn into view. @@ -2996,8 +2930,36 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerCursor(0); setComposerTrigger(null); + let createdServerThreadForLocalDraft = false; let turnStartSucceeded = false; + let nextThreadBranch = activeThread.branch; + let nextThreadWorktreePath = activeThread.worktreePath; await (async () => { + // On first message: lock in branch + create worktree if needed. + if (baseBranchForWorktree) { + beginSendPhase("preparing-worktree"); + const newBranch = buildTemporaryWorktreeBranchName(); + const result = await createWorktreeMutation.mutateAsync({ + cwd: activeProject.cwd, + branch: baseBranchForWorktree, + newBranch, + }); + nextThreadBranch = result.worktree.branch; + nextThreadWorktreePath = result.worktree.path; + if (isServerThread) { + await api.orchestration.dispatchCommand({ + type: "thread.meta.update", + commandId: newCommandId(), + threadId: threadIdForSend, + branch: result.worktree.branch, + worktreePath: result.worktree.path, + }); + // Keep local thread state in sync immediately so terminal drawer opens + // with the worktree cwd/env instead of briefly using the project root. + setStoreThreadBranch(threadIdForSend, result.worktree.branch, result.worktree.path); + } + } + let firstComposerImageName: string | null = null; if (composerImagesSnapshot.length > 0) { const firstComposerImage = composerImagesSnapshot[0]; @@ -3015,7 +2977,7 @@ export default function ChatView({ threadId }: ChatViewProps) { titleSeed = "New thread"; } } - const title = truncate(titleSeed); + const title = truncateTitle(titleSeed); const threadCreateModelSelection: ModelSelection = { provider: selectedProvider, model: @@ -3025,6 +2987,48 @@ export default function ChatView({ threadId }: ChatViewProps) { ...(selectedModelSelection.options ? { options: selectedModelSelection.options } : {}), }; + if (isLocalDraftThread) { + await api.orchestration.dispatchCommand({ + type: "thread.create", + commandId: newCommandId(), + threadId: threadIdForSend, + projectId: activeProject.id, + title, + modelSelection: threadCreateModelSelection, + runtimeMode, + interactionMode, + branch: nextThreadBranch, + worktreePath: nextThreadWorktreePath, + createdAt: activeThread.createdAt, + }); + createdServerThreadForLocalDraft = true; + } + + let setupScript: ProjectScript | null = null; + if (baseBranchForWorktree) { + setupScript = setupProjectScript(activeProject.scripts); + } + if (setupScript) { + let shouldRunSetupScript = false; + if (isServerThread) { + shouldRunSetupScript = true; + } else { + if (createdServerThreadForLocalDraft) { + shouldRunSetupScript = true; + } + } + if (shouldRunSetupScript) { + const setupScriptOptions: Parameters[1] = { + worktreePath: nextThreadWorktreePath, + rememberAsLastInvoked: false, + }; + if (nextThreadWorktreePath) { + setupScriptOptions.cwd = nextThreadWorktreePath; + } + await runProjectScript(setupScript, setupScriptOptions); + } + } + // Auto-title from first message if (isFirstMessage && isServerThread) { await api.orchestration.dispatchCommand({ @@ -3045,37 +3049,8 @@ export default function ChatView({ threadId }: ChatViewProps) { }); } + beginSendPhase("sending-turn"); const turnAttachments = await turnAttachmentsPromise; - const bootstrap = - isLocalDraftThread || baseBranchForWorktree - ? { - ...(isLocalDraftThread - ? { - createThread: { - projectId: activeProject.id, - title, - modelSelection: threadCreateModelSelection, - runtimeMode, - interactionMode, - branch: activeThread.branch, - worktreePath: activeThread.worktreePath, - createdAt: activeThread.createdAt, - }, - } - : {}), - ...(baseBranchForWorktree - ? { - prepareWorktree: { - projectCwd: activeProject.cwd, - baseBranch: baseBranchForWorktree, - branch: buildTemporaryWorktreeBranchName(), - }, - runSetupScript: true, - } - : {}), - } - : undefined; - beginLocalDispatch({ preparingWorktree: false }); await api.orchestration.dispatchCommand({ type: "thread.turn.start", commandId: newCommandId(), @@ -3085,16 +3060,26 @@ export default function ChatView({ threadId }: ChatViewProps) { role: "user", text: outgoingMessageText, attachments: turnAttachments, + ...(selectedSkillsForSend.length > 0 ? { skills: selectedSkillsForSend } : {}), }, modelSelection: selectedModelSelection, - titleSeed: title, + ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), + assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode, - ...(bootstrap ? { bootstrap } : {}), createdAt: messageCreatedAt, }); turnStartSucceeded = true; })().catch(async (err: unknown) => { + if (createdServerThreadForLocalDraft && !turnStartSucceeded) { + await api.orchestration + .dispatchCommand({ + type: "thread.delete", + commandId: newCommandId(), + threadId: threadIdForSend, + }) + .catch(() => undefined); + } if ( !turnStartSucceeded && promptRef.current.length === 0 && @@ -3114,6 +3099,7 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerCursor(collapseExpandedComposerCursor(promptForSend, promptForSend.length)); addComposerImagesToDraft(composerImagesSnapshot.map(cloneComposerImageForRetry)); addComposerTerminalContextsToDraft(composerTerminalContextsSnapshot); + setSelectedComposerSkills(composerSkillsSnapshot); setComposerTrigger(detectComposerTrigger(promptForSend, promptForSend.length)); } setThreadError( @@ -3123,7 +3109,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }); sendInFlightRef.current = false; if (!turnStartSucceeded) { - resetLocalDispatch(); + resetSendPhase(); } }; @@ -3156,14 +3142,14 @@ export default function ChatView({ threadId }: ChatViewProps) { createdAt: new Date().toISOString(), }) .catch((err: unknown) => { - setThreadError( + setStoreThreadError( activeThreadId, err instanceof Error ? err.message : "Failed to submit approval decision.", ); }); setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); }, - [activeThreadId, setThreadError], + [activeThreadId, setStoreThreadError], ); const onRespondToUserInput = useCallback( @@ -3184,14 +3170,14 @@ export default function ChatView({ threadId }: ChatViewProps) { createdAt: new Date().toISOString(), }) .catch((err: unknown) => { - setThreadError( + setStoreThreadError( activeThreadId, err instanceof Error ? err.message : "Failed to submit user input.", ); }); setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId)); }, - [activeThreadId, setThreadError], + [activeThreadId, setStoreThreadError], ); const setActivePendingUserInputQuestionIndex = useCallback( @@ -3316,13 +3302,12 @@ export default function ChatView({ threadId }: ChatViewProps) { const outgoingMessageText = formatOutgoingPrompt({ provider: selectedProvider, model: selectedModel, - models: selectedProviderModels, effort: selectedPromptEffort, text: trimmed, }); sendInFlightRef.current = true; - beginLocalDispatch({ preparingWorktree: false }); + beginSendPhase("sending-turn"); setThreadError(threadIdForSend, null); setOptimisticUserMessages((existing) => [ ...existing, @@ -3332,6 +3317,7 @@ export default function ChatView({ threadId }: ChatViewProps) { text: outgoingMessageText, createdAt: messageCreatedAt, streaming: false, + source: "native", }, ]); shouldAutoScrollRef.current = true; @@ -3361,7 +3347,8 @@ export default function ChatView({ threadId }: ChatViewProps) { attachments: [], }, modelSelection: selectedModelSelection, - titleSeed: activeThread.title, + ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), + assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode: nextInteractionMode, ...(nextInteractionMode === "default" && activeProposedPlan @@ -3391,26 +3378,27 @@ export default function ChatView({ threadId }: ChatViewProps) { err instanceof Error ? err.message : "Failed to send plan follow-up.", ); sendInFlightRef.current = false; - resetLocalDispatch(); + resetSendPhase(); } }, [ activeThread, activeProposedPlan, - beginLocalDispatch, + beginSendPhase, forceStickToBottom, isConnecting, isSendBusy, isServerThread, persistThreadSettingsForNextTurn, - resetLocalDispatch, + resetSendPhase, runtimeMode, selectedPromptEffort, selectedModelSelection, + providerOptionsForDispatch, selectedProvider, - selectedProviderModels, setComposerDraftInteractionMode, setThreadError, + settings.enableAssistantStreaming, selectedModel, ], ); @@ -3437,18 +3425,17 @@ export default function ChatView({ threadId }: ChatViewProps) { const outgoingImplementationPrompt = formatOutgoingPrompt({ provider: selectedProvider, model: selectedModel, - models: selectedProviderModels, effort: selectedPromptEffort, text: implementationPrompt, }); - const nextThreadTitle = truncate(buildPlanImplementationThreadTitle(planMarkdown)); + const nextThreadTitle = truncateTitle(buildPlanImplementationThreadTitle(planMarkdown)); const nextThreadModelSelection: ModelSelection = selectedModelSelection; sendInFlightRef.current = true; - beginLocalDispatch({ preparingWorktree: false }); + beginSendPhase("sending-turn"); const finish = () => { sendInFlightRef.current = false; - resetLocalDispatch(); + resetSendPhase(); }; await api.orchestration @@ -3477,20 +3464,16 @@ export default function ChatView({ threadId }: ChatViewProps) { attachments: [], }, modelSelection: selectedModelSelection, - titleSeed: nextThreadTitle, + ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), + assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode: "default", - sourceProposedPlan: { - threadId: activeThread.id, - planId: activeProposedPlan.id, - }, createdAt, }); }) - .then(() => { - return waitForStartedServerThread(nextThreadId); - }) - .then(() => { + .then(() => api.orchestration.getSnapshot()) + .then((snapshot) => { + syncServerReadModel(snapshot); // Signal that the plan sidebar should open on the new thread. planSidebarOpenOnNextThreadRef.current = true; return navigate({ @@ -3506,6 +3489,12 @@ export default function ChatView({ threadId }: ChatViewProps) { threadId: nextThreadId, }) .catch(() => undefined); + await api.orchestration + .getSnapshot() + .then((snapshot) => { + syncServerReadModel(snapshot); + }) + .catch(() => undefined); toastManager.add({ type: "error", title: "Could not start implementation thread", @@ -3518,36 +3507,32 @@ export default function ChatView({ threadId }: ChatViewProps) { activeProject, activeProposedPlan, activeThread, - beginLocalDispatch, + beginSendPhase, isConnecting, isSendBusy, isServerThread, navigate, - resetLocalDispatch, + resetSendPhase, runtimeMode, selectedPromptEffort, selectedModelSelection, + providerOptionsForDispatch, selectedProvider, - selectedProviderModels, + settings.enableAssistantStreaming, + syncServerReadModel, selectedModel, ]); const onProviderModelSelect = useCallback( - (provider: ProviderKind, model: string) => { + (provider: ProviderKind, model: ModelSlug) => { if (!activeThread) return; if (lockedProvider !== null && provider !== lockedProvider) { scheduleComposerFocus(); return; } - const resolvedProvider = resolveSelectableProvider(providerStatuses, provider); - const resolvedModel = resolveAppModelSelection( - resolvedProvider, - settings, - providerStatuses, - model, - ); + const resolvedModel = resolveAppModelSelection(provider, customModelsByProvider, model); const nextModelSelection: ModelSelection = { - provider: resolvedProvider, + provider, model: resolvedModel, }; setComposerDraftModelSelection(activeThread.id, nextModelSelection); @@ -3560,8 +3545,7 @@ export default function ChatView({ threadId }: ChatViewProps) { scheduleComposerFocus, setComposerDraftModelSelection, setStickyComposerModelSelection, - providerStatuses, - settings, + customModelsByProvider, ], ); const setPromptFromTraits = useCallback( @@ -3584,7 +3568,6 @@ export default function ChatView({ threadId }: ChatViewProps) { provider: selectedProvider, threadId, model: selectedModel, - models: selectedProviderModels, modelOptions: composerModelOptions?.[selectedProvider], prompt, onPromptChange: setPromptFromTraits, @@ -3593,7 +3576,6 @@ export default function ChatView({ threadId }: ChatViewProps) { provider: selectedProvider, threadId, model: selectedModel, - models: selectedProviderModels, modelOptions: composerModelOptions?.[selectedProvider], prompt, onPromptChange: setPromptFromTraits, @@ -3738,6 +3720,35 @@ export default function ChatView({ threadId }: ChatViewProps) { } return; } + if (item.type === "skill") { + const replacement = `$${item.skill.name} `; + const replacementRangeEnd = extendReplacementRangeForTrailingSpace( + snapshot.value, + trigger.rangeEnd, + replacement, + ); + const applied = applyPromptReplacement( + trigger.rangeStart, + replacementRangeEnd, + replacement, + { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, + ); + if (applied) { + setSelectedComposerSkills((existing) => { + const nextSkill = { + name: item.skill.name, + path: item.skill.path, + } satisfies ProviderSkillReference; + return existing.some( + (skill) => skill.name === nextSkill.name && skill.path === nextSkill.path, + ) + ? existing + : [...existing, nextSkill]; + }); + setComposerHighlightedItemId(null); + } + return; + } onProviderModelSelect(item.provider, item.model); const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), @@ -3750,6 +3761,7 @@ export default function ChatView({ threadId }: ChatViewProps) { applyPromptReplacement, handleInteractionModeChange, onProviderModelSelect, + setSelectedComposerSkills, resolveActiveComposerTrigger, ], ); @@ -3877,8 +3889,8 @@ export default function ChatView({ threadId }: ChatViewProps) { search: (previous) => { const rest = stripDiffSearchParams(previous); return filePath - ? { ...rest, diff: "1", diffTurnId: turnId, diffFilePath: filePath } - : { ...rest, diff: "1", diffTurnId: turnId }; + ? { ...rest, panel: "diff", diff: "1", diffTurnId: turnId, diffFilePath: filePath } + : { ...rest, panel: "diff", diff: "1", diffTurnId: turnId }; }, }); }, @@ -3919,7 +3931,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } return ( -
+
{/* Top bar */}
{ @@ -3953,482 +3972,613 @@ export default function ChatView({ threadId }: ChatViewProps) { onDeleteProjectScript={deleteProjectScript} onToggleTerminal={toggleTerminalVisibility} onToggleDiff={onToggleDiff} + onToggleBrowser={onToggleBrowser} + onCreateHandoff={onCreateHandoffThread} />
{/* Error banner */} - + setThreadError(activeThread.id, null)} /> + {terminalWorkspaceOpen ? ( + + ) : null} {/* Main content area with optional plan sidebar */} -
+
{/* Chat column */} -
- {/* Messages Wrapper */} -
- {/* Messages */} -
- 0} - isWorking={isWorking} - activeTurnInProgress={isWorking || !latestTurnSettled} - activeTurnStartedAt={activeWorkStartedAt} - scrollContainer={messagesScrollElement} - timelineEntries={timelineEntries} - completionDividerBeforeEntryId={completionDividerBeforeEntryId} - completionSummary={completionSummary} - turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} - nowIso={nowIso} - expandedWorkGroups={expandedWorkGroups} - onToggleWorkGroup={onToggleWorkGroup} - onOpenTurnDiff={onOpenTurnDiff} - revertTurnCountByUserMessageId={revertTurnCountByUserMessageId} - onRevertUserMessage={onRevertUserMessage} - isRevertingCheckpoint={isRevertingCheckpoint} - onImageExpand={onExpandTimelineImage} - markdownCwd={gitCwd ?? undefined} - resolvedTheme={resolvedTheme} - timestampFormat={timestampFormat} - workspaceRoot={activeWorkspaceRoot} - /> -
- - {/* scroll to bottom pill — shown when user has scrolled away from the bottom */} - {showScrollToBottom && ( -
- -
+
+
+ > + {/* Messages Wrapper */} +
+ {/* Messages */} +
+ 0} + isWorking={isWorking} + activeTurnInProgress={isWorking || !latestTurnSettled} + activeTurnStartedAt={activeWorkStartedAt} + scrollContainer={messagesScrollElement} + timelineEntries={timelineEntries} + completionDividerBeforeEntryId={completionDividerBeforeEntryId} + completionSummary={completionSummary} + turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} + nowIso={nowIso} + expandedWorkGroups={expandedWorkGroups} + onToggleWorkGroup={onToggleWorkGroup} + onOpenTurnDiff={onOpenTurnDiff} + revertTurnCountByUserMessageId={revertTurnCountByUserMessageId} + onRevertUserMessage={onRevertUserMessage} + isRevertingCheckpoint={isRevertingCheckpoint} + onImageExpand={onExpandTimelineImage} + markdownCwd={gitCwd ?? undefined} + resolvedTheme={resolvedTheme} + timestampFormat={timestampFormat} + workspaceRoot={activeProject?.cwd ?? undefined} + emptyStateContent={} + /> +
+ + {/* scroll to bottom pill — shown when user has scrolled away from the bottom */} + {showScrollToBottom && ( +
+ +
+ )} +
- {/* Input bar */} -
-
-
- {activePendingApproval ? ( -
- -
- ) : pendingUserInputs.length > 0 ? ( -
- -
- ) : showPlanFollowUpPrompt && activeProposedPlan ? ( -
- -
- ) : null}
- {composerMenuOpen && !isComposerApprovalState && ( -
- +
- )} - - {!isComposerApprovalState && - pendingUserInputs.length === 0 && - composerImages.length > 0 && ( -
- {composerImages.map((image) => ( -
- {image.previewUrl ? ( - - ) : ( -
- {image.name} -
- )} - {nonPersistedComposerImageIdSet.has(image.id) && ( - - - - - } - /> - - Draft attachment could not be saved locally and may be lost on - navigation. - - - )} - -
- ))} + ) : pendingUserInputs.length > 0 ? ( +
+ +
+ ) : showPlanFollowUpPrompt && activeProposedPlan ? ( +
+ +
+ ) : null} +
+ {composerMenuOpen && !isComposerApprovalState && ( +
+
)} - -
- {/* Bottom toolbar */} - {activePendingApproval ? ( -
- 0 && ( +
+ {composerImages.map((image) => ( +
+ {image.previewUrl ? ( + + ) : ( +
+ {image.name} +
+ )} + {nonPersistedComposerImageIdSet.has(image.id) && ( + + + + + } + /> + + Draft attachment could not be saved locally and may be lost on + navigation. + + + )} + +
+ ))} +
)} - onRespondToApproval={onRespondToApproval} +
- ) : ( -
+ + {/* Bottom toolbar */} + {activePendingApproval ? ( +
+ +
+ ) : (
- {/* Provider/model picker */} - - - {isComposerFooterCompact ? ( - + {/* Provider/model picker */} + - ) : ( - <> - {providerTraitsPicker ? ( - <> - - {providerTraitsPicker} - - ) : null} - - - - - + ) : ( + <> + {providerTraitsPicker ? ( + <> + + {providerTraitsPicker} + + ) : null} + + {interactionMode === "plan" ? ( + <> + + + + + ) : null} + + {activePlan || sidebarProposedPlan || planSidebarOpen ? ( + <> + + + + ) : null} + + )} +
- + ) : null} + +
+ ) : phase === "running" ? ( + - - {activePlan || sidebarProposedPlan || planSidebarOpen ? ( - <> - + + + ) : pendingUserInputs.length === 0 ? ( + showPlanFollowUpPrompt ? ( + prompt.trim().length > 0 ? ( - - ) : null} - - )} -
- - {/* Right side: send / stop button */} -
- {activeContextWindow ? ( - - ) : null} - {isPreparingWorktree ? ( - - Preparing worktree... - - ) : null} - + + + + } + > + + + + void onImplementPlanInNewThread()} + > + Implement in a new thread + + + +
+ ) + ) : ( + + ) + ) : null} +
-
- )} + )} +
-
- + +
+ + {isGitRepo && ( + + )} + {pullRequestDialogState ? ( + { + if (!open) { + closePullRequestDialog(); + } + }} + onPrepared={handlePreparedPullRequestThread} + /> + ) : null}
- {isGitRepo && ( - - )} - {pullRequestDialogState ? ( - { - if (!open) { - closePullRequestDialog(); - } - }} - onPrepared={handlePreparedPullRequestThread} - /> + {terminalWorkspaceOpen ? ( +
+ +
) : null}
{/* end chat column */} @@ -4439,7 +4589,7 @@ export default function ChatView({ threadId }: ChatViewProps) { activePlan={activePlan} activeProposedPlan={sidebarProposedPlan} markdownCwd={gitCwd ?? undefined} - workspaceRoot={activeWorkspaceRoot} + workspaceRoot={activeProject?.cwd ?? undefined} timestampFormat={timestampFormat} onClose={() => { setPlanSidebarOpen(false); @@ -4454,21 +4604,19 @@ export default function ChatView({ threadId }: ChatViewProps) {
{/* end horizontal flex container */} - {mountedTerminalThreadIds.map((mountedThreadId) => ( - - ))} + {(() => { + if (!terminalState.terminalOpen || !activeProject || terminalWorkspaceOpen) { + return null; + } + return ( + + ); + })()} {expandedImage && expandedImageItem && (
= { updated_at: "Last user message", @@ -146,9 +130,48 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = { easing: "ease-out", } as const; -type SidebarProjectSnapshot = Project & { - expanded: boolean; -}; +function ProviderGlyph({ + provider, + className, +}: { + provider: "codex" | "claudeAgent"; + className?: string; +}) { + if (provider === "claudeAgent") { + return
- - - ); -} - function T3Wordmark() { return ( - - - + DP + ); } @@ -628,6 +313,35 @@ function ProjectSortMenu({ ); } +function SidebarPrimaryAction({ + icon: Icon, + label, + onClick, + disabled = false, +}: { + icon: typeof SquarePenIcon; + label: string; + onClick?: () => void; + disabled?: boolean; +}) { + return ( + + + + + + {label} + + + ); +} + function SortableProjectItem({ projectId, disabled = false, @@ -635,7 +349,7 @@ function SortableProjectItem({ }: { projectId: ProjectId; disabled?: boolean; - children: (handleProps: SortableProjectHandleProps) => ReactNode; + children: (handleProps: SortableProjectHandleProps) => React.ReactNode; }) { const { attributes, @@ -667,38 +381,34 @@ function SortableProjectItem({ export default function Sidebar() { const projects = useStore((store) => store.projects); - const sidebarThreadsById = useStore((store) => store.sidebarThreadsById); - const threadIdsByProjectId = useStore((store) => store.threadIdsByProjectId); - const { projectExpandedById, projectOrder, threadLastVisitedAtById } = useUiStateStore( - useShallow((store) => ({ - projectExpandedById: store.projectExpandedById, - projectOrder: store.projectOrder, - threadLastVisitedAtById: store.threadLastVisitedAtById, - })), - ); - const markThreadUnread = useUiStateStore((store) => store.markThreadUnread); - const toggleProject = useUiStateStore((store) => store.toggleProject); - const reorderProjects = useUiStateStore((store) => store.reorderProjects); + const threads = useStore((store) => store.threads); + const markThreadUnread = useStore((store) => store.markThreadUnread); + const toggleProject = useStore((store) => store.toggleProject); + const reorderProjects = useStore((store) => store.reorderProjects); const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); - const getDraftThreadByProjectId = useComposerDraftStore( - (store) => store.getDraftThreadByProjectId, - ); const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId); - const clearProjectDraftThreadId = useComposerDraftStore( - (store) => store.clearProjectDraftThreadId, + const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState); + const openChatThreadPage = useTerminalStateStore((state) => state.openChatThreadPage); + const openTerminalThreadPage = useTerminalStateStore((state) => state.openTerminalThreadPage); + const clearProjectDraftThreads = useComposerDraftStore((store) => store.clearProjectDraftThreads); + const clearProjectDraftThreadById = useComposerDraftStore( + (store) => store.clearProjectDraftThreadById, ); const navigate = useNavigate(); - const pathname = useLocation({ select: (loc) => loc.pathname }); - const isOnSettings = pathname.startsWith("/settings"); - const appSettings = useSettings(); - const { updateSettings } = useUpdateSettings(); - const { activeDraftThread, activeThread, handleNewThread } = useHandleNewThread(); - const { archiveThread, deleteThread } = useThreadActions(); + const isOnSettings = useLocation({ select: (loc) => loc.pathname === "/settings" }); + const { settings: appSettings, updateSettings } = useAppSettings(); + const { handleNewThread } = useHandleNewThread(); + const { createThreadHandoff } = useThreadHandoff(); const routeThreadId = useParams({ strict: false, select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), }); - const keybindings = useServerKeybindings(); + const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({ + ...serverConfigQueryOptions(), + select: (config) => config.keybindings, + }); + const queryClient = useQueryClient(); + const removeWorktreeMutation = useMutation(gitRemoveWorktreeMutationOptions({ queryClient })); const [addingProject, setAddingProject] = useState(false); const [newCwd, setNewCwd] = useState(""); const [isPickingFolder, setIsPickingFolder] = useState(false); @@ -707,17 +417,13 @@ export default function Sidebar() { const addProjectInputRef = useRef(null); const [renamingThreadId, setRenamingThreadId] = useState(null); const [renamingTitle, setRenamingTitle] = useState(""); - const [confirmingArchiveThreadId, setConfirmingArchiveThreadId] = useState(null); const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< ReadonlySet >(() => new Set()); - const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); const renamingCommittedRef = useRef(false); const renamingInputRef = useRef(null); - const confirmArchiveButtonRefs = useRef(new Map()); const dragInProgressRef = useRef(false); const suppressProjectClickAfterDragRef = useRef(false); - const suppressProjectClickForContextMenuRef = useRef(false); const [desktopUpdateState, setDesktopUpdateState] = useState(null); const selectedThreadIds = useThreadSelectionStore((s) => s.selectedThreadIds); const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread); @@ -726,50 +432,20 @@ export default function Sidebar() { const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); const isLinuxDesktop = isElectron && isLinuxPlatform(navigator.platform); - const platform = navigator.platform; const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop; const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; - const orderedProjects = useMemo(() => { - return orderItemsByPreferredIds({ - items: projects, - preferredIds: projectOrder, - getId: (project) => project.id, - }); - }, [projectOrder, projects]); - const sidebarProjects = useMemo( - () => - orderedProjects.map((project) => ({ - ...project, - expanded: projectExpandedById[project.id] ?? true, - })), - [orderedProjects, projectExpandedById], - ); - const sidebarThreads = useMemo(() => Object.values(sidebarThreadsById), [sidebarThreadsById]); const projectCwdById = useMemo( () => new Map(projects.map((project) => [project.id, project.cwd] as const)), [projects], ); - const routeTerminalOpen = routeThreadId - ? selectThreadTerminalState(terminalStateByThreadId, routeThreadId).terminalOpen - : false; - const sidebarShortcutLabelOptions = useMemo( - () => ({ - platform, - context: { - terminalFocus: false, - terminalOpen: routeTerminalOpen, - }, - }), - [platform, routeTerminalOpen], - ); const threadGitTargets = useMemo( () => - sidebarThreads.map((thread) => ({ + threads.map((thread) => ({ threadId: thread.id, branch: thread.branch, cwd: thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null, })), - [projectCwdById, sidebarThreads], + [projectCwdById, threads], ); const threadGitStatusCwds = useMemo( () => [ @@ -810,7 +486,7 @@ export default function Sidebar() { return map; }, [threadGitStatusCwds, threadGitStatusQueries, threadGitTargets]); - const openPrLink = useCallback((event: MouseEvent, prUrl: string) => { + const openPrLink = useCallback((event: React.MouseEvent, prUrl: string) => { event.preventDefault(); event.stopPropagation(); @@ -832,28 +508,10 @@ export default function Sidebar() { }); }, []); - const attemptArchiveThread = useCallback( - async (threadId: ThreadId) => { - try { - await archiveThread(threadId); - } catch (error) { - toastManager.add({ - type: "error", - title: "Failed to archive thread", - description: error instanceof Error ? error.message : "An error occurred.", - }); - } - }, - [archiveThread], - ); - const focusMostRecentThreadForProject = useCallback( (projectId: ProjectId) => { const latestThread = sortThreadsForSidebar( - (threadIdsByProjectId[projectId] ?? []) - .map((threadId) => sidebarThreadsById[threadId]) - .filter((thread): thread is NonNullable => thread !== undefined) - .filter((thread) => thread.archivedAt === null), + threads.filter((thread) => thread.projectId === projectId), appSettings.sidebarThreadSortOrder, )[0]; if (!latestThread) return; @@ -863,7 +521,7 @@ export default function Sidebar() { params: { threadId: latestThread.id }, }); }, - [appSettings.sidebarThreadSortOrder, navigate, sidebarThreadsById, threadIdsByProjectId], + [appSettings.sidebarThreadSortOrder, navigate, threads], ); const addProjectFromPath = useCallback( @@ -940,7 +598,7 @@ export default function Sidebar() { const canAddProject = newCwd.trim().length > 0 && !isAddingProject; - const handlePickFolder = async () => { + const handlePickFolder = useCallback(async () => { const api = readNativeApi(); if (!api || isPickingFolder) return; setIsPickingFolder(true); @@ -956,16 +614,41 @@ export default function Sidebar() { addProjectInputRef.current?.focus(); } setIsPickingFolder(false); - }; + }, [addProjectFromPath, isPickingFolder, shouldBrowseForProjectImmediately]); - const handleStartAddProject = () => { + const handleStartAddProject = useCallback(() => { setAddProjectError(null); if (shouldBrowseForProjectImmediately) { void handlePickFolder(); return; } setAddingProject((prev) => !prev); - }; + }, [handlePickFolder, shouldBrowseForProjectImmediately]); + + const handlePrimaryNewThread = useCallback(() => { + const activeProjectId = + (routeThreadId ? threads.find((thread) => thread.id === routeThreadId)?.projectId : null) ?? + projects[0]?.id ?? + null; + + if (activeProjectId) { + void handleNewThread(activeProjectId, { + envMode: resolveSidebarNewThreadEnvMode({ + defaultEnvMode: appSettings.defaultThreadEnvMode, + }), + }); + return; + } + + handleStartAddProject(); + }, [ + appSettings.defaultThreadEnvMode, + handleNewThread, + handleStartAddProject, + projects, + routeThreadId, + threads, + ]); const cancelRename = useCallback(() => { setRenamingThreadId(null); @@ -984,10 +667,7 @@ export default function Sidebar() { const trimmed = newTitle.trim(); if (trimmed.length === 0) { - toastManager.add({ - type: "warning", - title: "Thread title cannot be empty", - }); + toastManager.add({ type: "warning", title: "Thread title cannot be empty" }); finishRename(); return; } @@ -1019,9 +699,129 @@ export default function Sidebar() { [], ); - const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{ - threadId: ThreadId; - }>({ + /** + * Delete a single thread: stop session, close terminal, dispatch delete, + * clean up drafts/state, and optionally remove orphaned worktree. + * Callers handle thread-level confirmation; this still prompts for worktree removal. + */ + const deleteThread = useCallback( + async ( + threadId: ThreadId, + opts: { deletedThreadIds?: ReadonlySet } = {}, + ): Promise => { + const api = readNativeApi(); + if (!api) return; + const thread = threads.find((t) => t.id === threadId); + if (!thread) return; + const threadProject = projects.find((project) => project.id === thread.projectId); + // When bulk-deleting, exclude the other threads being deleted so + // getOrphanedWorktreePathForThread correctly detects that no surviving + // threads will reference this worktree. + const deletedIds = opts.deletedThreadIds; + const survivingThreads = + deletedIds && deletedIds.size > 0 + ? threads.filter((t) => t.id === threadId || !deletedIds.has(t.id)) + : threads; + const orphanedWorktreePath = getOrphanedWorktreePathForThread(survivingThreads, threadId); + const displayWorktreePath = orphanedWorktreePath + ? formatWorktreePathForDisplay(orphanedWorktreePath) + : null; + const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== undefined; + const shouldDeleteWorktree = + canDeleteWorktree && + (await api.dialogs.confirm( + [ + "This thread is the only one linked to this worktree:", + displayWorktreePath ?? orphanedWorktreePath, + "", + "Delete the worktree too?", + ].join("\n"), + )); + + if (thread.session && thread.session.status !== "closed") { + await api.orchestration + .dispatchCommand({ + type: "thread.session.stop", + commandId: newCommandId(), + threadId, + createdAt: new Date().toISOString(), + }) + .catch(() => undefined); + } + + try { + await api.terminal.close({ threadId, deleteHistory: true }); + } catch { + // Terminal may already be closed + } + + const allDeletedIds = deletedIds ?? new Set(); + const shouldNavigateToFallback = routeThreadId === threadId; + const fallbackThreadId = getFallbackThreadIdAfterDelete({ + threads, + deletedThreadId: threadId, + deletedThreadIds: allDeletedIds, + sortOrder: appSettings.sidebarThreadSortOrder, + }); + await api.orchestration.dispatchCommand({ + type: "thread.delete", + commandId: newCommandId(), + threadId, + }); + clearComposerDraftForThread(threadId); + clearProjectDraftThreadById(thread.projectId, thread.id); + clearTerminalState(threadId); + if (shouldNavigateToFallback) { + if (fallbackThreadId) { + void navigate({ + to: "/$threadId", + params: { threadId: fallbackThreadId }, + replace: true, + }); + } else { + void navigate({ to: "/", replace: true }); + } + } + + if (!shouldDeleteWorktree || !orphanedWorktreePath || !threadProject) { + return; + } + + try { + await removeWorktreeMutation.mutateAsync({ + cwd: threadProject.cwd, + path: orphanedWorktreePath, + force: true, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error removing worktree."; + console.error("Failed to remove orphaned worktree after thread deletion", { + threadId, + projectCwd: threadProject.cwd, + worktreePath: orphanedWorktreePath, + error, + }); + toastManager.add({ + type: "error", + title: "Thread deleted, but worktree removal failed", + description: `Could not remove ${displayWorktreePath ?? orphanedWorktreePath}. ${message}`, + }); + } + }, + [ + appSettings.sidebarThreadSortOrder, + clearComposerDraftForThread, + clearProjectDraftThreadById, + clearTerminalState, + navigate, + projects, + removeWorktreeMutation, + routeThreadId, + threads, + ], + ); + + const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{ threadId: ThreadId }>({ onCopy: (ctx) => { toastManager.add({ type: "success", @@ -1037,9 +837,7 @@ export default function Sidebar() { }); }, }); - const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ - path: string; - }>({ + const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ onCopy: (ctx) => { toastManager.add({ type: "success", @@ -1055,18 +853,46 @@ export default function Sidebar() { }); }, }); + const handoffThread = useCallback( + async (thread: (typeof threads)[number]) => { + try { + await createThreadHandoff(thread); + } catch (error) { + toastManager.add({ + type: "error", + title: "Could not create handoff thread", + description: + error instanceof Error + ? error.message + : "An error occurred while creating the handoff thread.", + }); + } + }, + [createThreadHandoff], + ); const handleThreadContextMenu = useCallback( async (threadId: ThreadId, position: { x: number; y: number }) => { const api = readNativeApi(); if (!api) return; - const thread = sidebarThreadsById[threadId]; + const thread = threads.find((t) => t.id === threadId); if (!thread) return; + const hasPendingApprovals = derivePendingApprovals(thread.activities).length > 0; + const hasPendingUserInput = derivePendingUserInputs(thread.activities).length > 0; + const canHandoff = canCreateThreadHandoff({ + thread, + hasPendingApprovals, + hasPendingUserInput, + }); + const handoffLabel = canHandoff + ? `Handoff to ${PROVIDER_DISPLAY_NAMES[resolveHandoffTargetProvider(thread.modelSelection.provider)]}` + : null; const threadWorkspacePath = thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null; const clicked = await api.contextMenu.show( [ { id: "rename", label: "Rename thread" }, { id: "mark-unread", label: "Mark unread" }, + ...(handoffLabel ? [{ id: "handoff", label: handoffLabel }] : []), { id: "copy-path", label: "Copy Path" }, { id: "copy-thread-id", label: "Copy Thread ID" }, { id: "delete", label: "Delete", destructive: true }, @@ -1082,7 +908,11 @@ export default function Sidebar() { } if (clicked === "mark-unread") { - markThreadUnread(threadId, thread.latestTurn?.completedAt); + markThreadUnread(threadId); + return; + } + if (clicked === "handoff") { + await handoffThread(thread); return; } if (clicked === "copy-path") { @@ -1120,9 +950,10 @@ export default function Sidebar() { copyPathToClipboard, copyThreadIdToClipboard, deleteThread, + handoffThread, markThreadUnread, projectCwdById, - sidebarThreadsById, + threads, ], ); @@ -1144,8 +975,7 @@ export default function Sidebar() { if (clicked === "mark-unread") { for (const id of ids) { - const thread = sidebarThreadsById[id]; - markThreadUnread(id, thread?.latestTurn?.completedAt); + markThreadUnread(id); } clearSelection(); return; @@ -1176,7 +1006,6 @@ export default function Sidebar() { markThreadUnread, removeFromSelection, selectedThreadIds, - sidebarThreadsById, ], ); @@ -1218,42 +1047,21 @@ export default function Sidebar() { ], ); - const navigateToThread = useCallback( - (threadId: ThreadId) => { - if (selectedThreadIds.size > 0) { - clearSelection(); - } - setSelectionAnchor(threadId); - void navigate({ - to: "/$threadId", - params: { threadId }, - }); - }, - [clearSelection, navigate, selectedThreadIds.size, setSelectionAnchor], - ); - const handleProjectContextMenu = useCallback( async (projectId: ProjectId, position: { x: number; y: number }) => { const api = readNativeApi(); if (!api) return; - const project = projects.find((entry) => entry.id === projectId); - if (!project) return; - const clicked = await api.contextMenu.show( - [ - { id: "copy-path", label: "Copy Project Path" }, - { id: "delete", label: "Remove project", destructive: true }, - ], + [{ id: "delete", label: "Remove project", destructive: true }], position, ); - if (clicked === "copy-path") { - copyPathToClipboard(project.cwd, { path: project.cwd }); - return; - } if (clicked !== "delete") return; - const projectThreadIds = threadIdsByProjectId[projectId] ?? []; - if (projectThreadIds.length > 0) { + const project = projects.find((entry) => entry.id === projectId); + if (!project) return; + + const projectThreads = threads.filter((thread) => thread.projectId === projectId); + if (projectThreads.length > 0) { toastManager.add({ type: "warning", title: "Project is not empty", @@ -1266,16 +1074,12 @@ export default function Sidebar() { if (!confirmed) return; try { - const projectDraftThread = getDraftThreadByProjectId(projectId); - if (projectDraftThread) { - clearComposerDraftForThread(projectDraftThread.threadId); - } - clearProjectDraftThreadId(projectId); await api.orchestration.dispatchCommand({ type: "project.delete", commandId: newCommandId(), projectId, }); + clearProjectDraftThreads(projectId); } catch (error) { const message = error instanceof Error ? error.message : "Unknown error removing project."; console.error("Failed to remove project", { projectId, error }); @@ -1286,14 +1090,7 @@ export default function Sidebar() { }); } }, - [ - clearComposerDraftForThread, - clearProjectDraftThreadId, - copyPathToClipboard, - getDraftThreadByProjectId, - projects, - threadIdsByProjectId, - ], + [clearProjectDraftThreads, projects, threads], ); const projectDnDSensors = useSensors( @@ -1319,12 +1116,12 @@ export default function Sidebar() { dragInProgressRef.current = false; const { active, over } = event; if (!over || active.id === over.id) return; - const activeProject = sidebarProjects.find((project) => project.id === active.id); - const overProject = sidebarProjects.find((project) => project.id === over.id); + const activeProject = projects.find((project) => project.id === active.id); + const overProject = projects.find((project) => project.id === over.id); if (!activeProject || !overProject) return; reorderProjects(activeProject.id, overProject.id); }, - [appSettings.sidebarProjectSortOrder, reorderProjects, sidebarProjects], + [appSettings.sidebarProjectSortOrder, projects, reorderProjects], ); const handleProjectDragStart = useCallback( @@ -1360,244 +1157,256 @@ export default function Sidebar() { animatedThreadListsRef.current.add(node); }, []); - const handleProjectTitlePointerDownCapture = useCallback( - (event: PointerEvent) => { - suppressProjectClickForContextMenuRef.current = false; - if ( - isContextMenuPointerDown({ - button: event.button, - ctrlKey: event.ctrlKey, - isMac: isMacPlatform(navigator.platform), - }) - ) { - // Keep context-menu gestures from arming the sortable drag sensor. - event.stopPropagation(); - } - - suppressProjectClickAfterDragRef.current = false; - }, - [], - ); + const handleProjectTitlePointerDownCapture = useCallback(() => { + suppressProjectClickAfterDragRef.current = false; + }, []); - const visibleThreads = useMemo( - () => sidebarThreads.filter((thread) => thread.archivedAt === null), - [sidebarThreads], - ); const sortedProjects = useMemo( - () => - sortProjectsForSidebar(sidebarProjects, visibleThreads, appSettings.sidebarProjectSortOrder), - [appSettings.sidebarProjectSortOrder, sidebarProjects, visibleThreads], + () => sortProjectsForSidebar(projects, threads, appSettings.sidebarProjectSortOrder), + [appSettings.sidebarProjectSortOrder, projects, threads], ); const isManualProjectSorting = appSettings.sidebarProjectSortOrder === "manual"; - const renderedProjects = useMemo( - () => - sortedProjects.map((project) => { - const resolveProjectThreadStatus = (thread: (typeof visibleThreads)[number]) => - resolveThreadStatusPill({ - thread: { - ...thread, - lastVisitedAt: threadLastVisitedAtById[thread.id], - }, - }); - const projectThreads = sortThreadsForSidebar( - (threadIdsByProjectId[project.id] ?? []) - .map((threadId) => sidebarThreadsById[threadId]) - .filter((thread): thread is NonNullable => thread !== undefined) - .filter((thread) => thread.archivedAt === null), - appSettings.sidebarThreadSortOrder, - ); - const projectStatus = resolveProjectStatusIndicator( - projectThreads.map((thread) => resolveProjectThreadStatus(thread)), - ); - 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 { - hasHiddenThreads, - hiddenThreads, - visibleThreads: visibleProjectThreads, - } = getVisibleThreadsForProject({ - threads: projectThreads, - activeThreadId, - isThreadListExpanded, - previewLimit: THREAD_PREVIEW_LIMIT, - }); - const hiddenThreadStatus = resolveProjectStatusIndicator( - hiddenThreads.map((thread) => resolveProjectThreadStatus(thread)), - ); - const orderedProjectThreadIds = projectThreads.map((thread) => thread.id); - const renderedThreadIds = pinnedCollapsedThread - ? [pinnedCollapsedThread.id] - : visibleProjectThreads.map((thread) => thread.id); - const showEmptyThreadState = project.expanded && projectThreads.length === 0; - - return { - hasHiddenThreads, - hiddenThreadStatus, - orderedProjectThreadIds, - project, - projectStatus, - renderedThreadIds, - showEmptyThreadState, - shouldShowThreadPanel, - isThreadListExpanded, - }; - }), - [ - appSettings.sidebarThreadSortOrder, - expandedThreadListsByProject, - routeThreadId, - sortedProjects, - sidebarThreadsById, - threadIdsByProjectId, - threadLastVisitedAtById, - ], - ); - const visibleSidebarThreadIds = useMemo( - () => getVisibleSidebarThreadIds(renderedProjects), - [renderedProjects], - ); - const threadJumpCommandById = useMemo(() => { - const mapping = new Map>>(); - for (const [visibleThreadIndex, threadId] of visibleSidebarThreadIds.entries()) { - const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex); - if (!jumpCommand) { - return mapping; - } - mapping.set(threadId, jumpCommand); - } - - return mapping; - }, [visibleSidebarThreadIds]); - const threadJumpThreadIds = useMemo( - () => [...threadJumpCommandById.keys()], - [threadJumpCommandById], - ); - const threadJumpLabelById = useMemo(() => { - const mapping = new Map(); - for (const [threadId, command] of threadJumpCommandById) { - const label = shortcutLabelForCommand(keybindings, command, sidebarShortcutLabelOptions); - if (label) { - mapping.set(threadId, label); - } - } - return mapping; - }, [keybindings, sidebarShortcutLabelOptions, threadJumpCommandById]); - const orderedSidebarThreadIds = visibleSidebarThreadIds; - - useEffect(() => { - const getShortcutContext = () => ({ - terminalFocus: isTerminalFocused(), - terminalOpen: routeTerminalOpen, - }); - const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { - updateThreadJumpHintsVisibility( - shouldShowThreadJumpHints(event, keybindings, { - platform, - context: getShortcutContext(), + function renderProjectItem( + project: (typeof sortedProjects)[number], + dragHandleProps: SortableProjectHandleProps | null, + ) { + const projectThreads = sortThreadsForSidebar( + threads.filter((thread) => thread.projectId === project.id), + appSettings.sidebarThreadSortOrder, + ); + const projectStatus = resolveProjectStatusIndicator( + projectThreads.map((thread) => + resolveThreadStatusPill({ + thread, + hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0, + hasPendingUserInput: derivePendingUserInputs(thread.activities).length > 0, }), - ); - - if (event.defaultPrevented || event.repeat) { - return; - } - - const command = resolveShortcutCommand(event, keybindings, { - platform, - context: getShortcutContext(), + ), + ); + 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 { hasHiddenThreads, visibleThreads } = getVisibleThreadsForProject({ + threads: projectThreads, + activeThreadId, + isThreadListExpanded, + previewLimit: THREAD_PREVIEW_LIMIT, + }); + const orderedProjectThreadIds = projectThreads.map((thread) => thread.id); + const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : visibleThreads; + const renderThreadRow = (thread: (typeof projectThreads)[number]) => { + const threadTerminalState = selectThreadTerminalState(terminalStateByThreadId, thread.id); + const threadEntryPoint = threadTerminalState.entryPoint; + const isActive = routeThreadId === thread.id; + const isSelected = selectedThreadIds.has(thread.id); + const isHighlighted = isActive || isSelected; + const hasPendingApprovals = derivePendingApprovals(thread.activities).length > 0; + const hasPendingUserInput = derivePendingUserInputs(thread.activities).length > 0; + const threadStatus = resolveThreadStatusPill({ + thread, + hasPendingApprovals, + hasPendingUserInput, }); - const traversalDirection = threadTraversalDirectionFromCommand(command); - if (traversalDirection !== null) { - const targetThreadId = resolveAdjacentThreadId({ - threadIds: orderedSidebarThreadIds, - currentThreadId: routeThreadId, - direction: traversalDirection, - }); - if (!targetThreadId) { + const handoffBadgeLabel = resolveThreadHandoffBadgeLabel(thread); + const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null); + const terminalStatus = terminalStatusFromRunningIds(threadTerminalState.runningTerminalIds); + const openThreadPrimarySurface = () => { + if (threadEntryPoint === "terminal") { + openTerminalThreadPage(thread.id); return; } + openChatThreadPage(thread.id); + }; - event.preventDefault(); - event.stopPropagation(); - navigateToThread(targetThreadId); - return; - } - - const jumpIndex = threadJumpIndexFromCommand(command ?? ""); - if (jumpIndex === null) { - return; - } - - const targetThreadId = threadJumpThreadIds[jumpIndex]; - if (!targetThreadId) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - navigateToThread(targetThreadId); - }; - - const onWindowKeyUp = (event: globalThis.KeyboardEvent) => { - updateThreadJumpHintsVisibility( - shouldShowThreadJumpHints(event, keybindings, { - platform, - context: getShortcutContext(), - }), + return ( + + {threadStatus && ( + + )} + } + data-thread-entry-point={threadEntryPoint} + size="sm" + isActive={isActive} + className={resolveThreadRowClassName({ + isActive, + isSelected, + })} + onClick={(event) => { + const isMac = isMacPlatform(navigator.platform); + const isModClick = isMac ? event.metaKey : event.ctrlKey; + const isShiftClick = event.shiftKey; + if (!isModClick && !isShiftClick) { + openThreadPrimarySurface(); + } + handleThreadClick(event, thread.id, orderedProjectThreadIds); + }} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + if (selectedThreadIds.size > 0) { + clearSelection(); + } + setSelectionAnchor(thread.id); + openThreadPrimarySurface(); + void navigate({ + to: "/$threadId", + params: { threadId: 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, + }); + } + }} + > + {threadEntryPoint === "terminal" ? ( + + } + /> + {handoffBadgeLabel} + + ) : ( + + )} +
+ {prStatus && ( + + { + openPrLink(event, prStatus.url); + }} + > + + + } + /> + {prStatus.tooltip} + + )} + {renamingThreadId === thread.id ? ( + { + if (el && renamingInputRef.current !== el) { + renamingInputRef.current = el; + el.focus(); + el.select(); + } + }} + className="min-w-0 flex-1 truncate rounded-md border border-ring bg-transparent px-1.5 py-0.5 text-[13px] outline-none" + 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} + + )} + {handoffBadgeLabel ? ( + + + + + } + /> + {handoffBadgeLabel} + + ) : null} +
+
+ {terminalStatus && ( + + + + )} + + {formatRelativeTime(thread.updatedAt ?? thread.createdAt)} + +
+ +
); }; - const onWindowBlur = () => { - updateThreadJumpHintsVisibility(false); - }; - - window.addEventListener("keydown", onWindowKeyDown); - window.addEventListener("keyup", onWindowKeyUp); - window.addEventListener("blur", onWindowBlur); - - return () => { - window.removeEventListener("keydown", onWindowKeyDown); - window.removeEventListener("keyup", onWindowKeyUp); - window.removeEventListener("blur", onWindowBlur); - }; - }, [ - keybindings, - navigateToThread, - orderedSidebarThreadIds, - platform, - routeTerminalOpen, - routeThreadId, - threadJumpThreadIds, - updateThreadJumpHintsVisibility, - ]); - - function renderProjectItem( - renderedProject: (typeof renderedProjects)[number], - dragHandleProps: SortableProjectHandleProps | null, - ) { - const { - hasHiddenThreads, - hiddenThreadStatus, - orderedProjectThreadIds, - project, - projectStatus, - renderedThreadIds, - showEmptyThreadState, - shouldShowThreadPanel, - isThreadListExpanded, - } = renderedProject; return ( - <> +
handleProjectTitleKeyDown(event, project.id)} onContextMenu={(event) => { event.preventDefault(); - suppressProjectClickForContextMenuRef.current = true; void handleProjectContextMenu(project.id, { x: event.clientX, y: event.clientY, }); }} > - {!project.expanded && projectStatus ? ( - @@ -1648,44 +1449,52 @@ export default function Sidebar() { render={
- - {shouldShowThreadPanel && showEmptyThreadState ? ( - -
- No threads yet -
-
- ) : null} - {shouldShowThreadPanel && - renderedThreadIds.map((threadId) => ( - - ))} + + + {renderedThreads.map((thread) => renderThreadRow(thread))} - {project.expanded && hasHiddenThreads && !isThreadListExpanded && ( - - } - data-thread-selection-safe - size="sm" - className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80" - onClick={() => { - expandThreadListForProject(project.id); - }} - > - - {hiddenThreadStatus && } + {project.expanded && hasHiddenThreads && !isThreadListExpanded && ( + + } + data-thread-selection-safe + size="sm" + className="h-7 w-full translate-x-0 justify-start rounded-lg pr-2 pl-8 text-left text-[13px] text-muted-foreground/72 hover:bg-accent/55 hover:text-foreground" + onClick={() => { + expandThreadListForProject(project.id); + }} + > Show more - - - - )} - {project.expanded && hasHiddenThreads && isThreadListExpanded && ( - - } - data-thread-selection-safe - size="sm" - className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80" - onClick={() => { - collapseThreadListForProject(project.id); - }} - > - Show less - - - )} - - + + + )} + {project.expanded && hasHiddenThreads && isThreadListExpanded && ( + + } + data-thread-selection-safe + size="sm" + className="h-7 w-full translate-x-0 justify-start rounded-lg pr-2 pl-8 text-left text-[13px] text-muted-foreground/72 hover:bg-accent/55 hover:text-foreground" + onClick={() => { + collapseThreadListForProject(project.id); + }} + > + Show less + + + )} +
+ +
); } const handleProjectTitleClick = useCallback( - (event: MouseEvent, projectId: ProjectId) => { - if (suppressProjectClickForContextMenuRef.current) { - suppressProjectClickForContextMenuRef.current = false; - event.preventDefault(); - event.stopPropagation(); - return; - } + (event: React.MouseEvent, projectId: ProjectId) => { if (dragInProgressRef.current) { event.preventDefault(); event.stopPropagation(); @@ -1812,7 +1574,7 @@ export default function Sidebar() { ); const handleProjectTitleKeyDown = useCallback( - (event: KeyboardEvent, projectId: ProjectId) => { + (event: React.KeyboardEvent, projectId: ProjectId) => { if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); if (dragInProgressRef.current) { @@ -1870,6 +1632,12 @@ export default function Sidebar() { }; }, []); + const showDesktopUpdateButton = isElectron && shouldShowDesktopUpdateButton(desktopUpdateState); + + const desktopUpdateTooltip = desktopUpdateState + ? getDesktopUpdateButtonTooltip(desktopUpdateState) + : "Update available"; + const desktopUpdateButtonDisabled = isDesktopUpdateButtonDisabled(desktopUpdateState); const desktopUpdateButtonAction = desktopUpdateState ? resolveDesktopUpdateButtonAction(desktopUpdateState) @@ -1880,9 +1648,21 @@ export default function Sidebar() { desktopUpdateState && showArm64IntelBuildWarning ? getArm64IntelBuildWarningDescription(desktopUpdateState) : null; + const desktopUpdateButtonInteractivityClasses = desktopUpdateButtonDisabled + ? "cursor-not-allowed opacity-60" + : "hover:bg-accent hover:text-foreground"; + const desktopUpdateButtonClasses = + desktopUpdateState?.status === "downloaded" + ? "text-emerald-500" + : desktopUpdateState?.status === "downloading" + ? "text-sky-400" + : shouldHighlightDesktopUpdateError(desktopUpdateState) + ? "text-rose-500 animate-pulse" + : "text-amber-500 animate-pulse"; const newThreadShortcutLabel = - shortcutLabelForCommand(keybindings, "chat.newLocal", sidebarShortcutLabelOptions) ?? - shortcutLabelForCommand(keybindings, "chat.new", sidebarShortcutLabelOptions); + shortcutLabelForCommand(keybindings, "chat.newLocal") ?? + shortcutLabelForCommand(keybindings, "chat.new"); + const newTerminalThreadShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newTerminal"); const handleDesktopUpdateButtonClick = useCallback(() => { const bridge = window.desktopBridge; @@ -1920,10 +1700,6 @@ export default function Sidebar() { } if (desktopUpdateButtonAction === "install") { - const confirmed = window.confirm( - getDesktopUpdateInstallConfirmationMessage(desktopUpdateState), - ); - if (!confirmed) return; void bridge .installUpdate() .then((result) => { @@ -1970,19 +1746,16 @@ export default function Sidebar() { +
- + Code - - {APP_STAGE_LABEL} - - + +
} /> @@ -1995,199 +1768,241 @@ export default function Sidebar() { return ( <> {isElectron ? ( - - {wordmark} - + <> + + {wordmark} + {showDesktopUpdateButton && ( + + + + + } + /> + {desktopUpdateTooltip} + + )} + + ) : ( - + {wordmark} )} - {isOnSettings ? ( - - ) : ( - <> - - {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( - - - - Intel build on Apple Silicon - {arm64IntelBuildWarningDescription} - {desktopUpdateButtonAction !== "none" ? ( - - - - ) : null} - - - ) : null} - -
- - Projects - -
- { - updateSettings({ sidebarProjectSortOrder: sortOrder }); - }} - onThreadSortOrderChange={(sortOrder) => { - updateSettings({ sidebarThreadSortOrder: sortOrder }); - }} - /> - - - } - > - - - - {shouldShowProjectPathEntry ? "Cancel add project" : "Add project"} - - -
-
- {shouldShowProjectPathEntry && ( -
- {isElectron && ( + + {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( + + + + Intel build on Apple Silicon + {arm64IntelBuildWarningDescription} + {desktopUpdateButtonAction !== "none" ? ( + + + + ) : null} + + + ) : null} + {/* Primary sidebar actions stay limited to features we currently ship. */} + + + + + + + +
+ + Threads + +
+ { + updateSettings({ sidebarProjectSortOrder: sortOrder }); + }} + onThreadSortOrderChange={(sortOrder) => { + updateSettings({ sidebarThreadSortOrder: sortOrder }); + }} + /> + + void handlePickFolder()} - disabled={isPickingFolder || isAddingProject} - > - - {isPickingFolder ? "Picking folder..." : "Browse for folder"} - - )} -
- { - setNewCwd(event.target.value); - setAddProjectError(null); - }} - onKeyDown={(event) => { - if (event.key === "Enter") handleAddProject(); - if (event.key === "Escape") { - setAddingProject(false); - setAddProjectError(null); - } - }} - autoFocus + aria-label={shouldShowProjectPathEntry ? "Cancel add project" : "Add project"} + aria-pressed={shouldShowProjectPathEntry} + className="inline-flex size-7 cursor-pointer items-center justify-center rounded-lg text-muted-foreground/70 transition-colors hover:bg-accent hover:text-foreground" + onClick={handleStartAddProject} /> - -
- {addProjectError && ( -

- {addProjectError} -

- )} -
- )} + } + > + + + + {shouldShowProjectPathEntry ? "Cancel add project" : "Add project"} + + +
+
- {isManualProjectSorting ? ( - + {isElectron && ( + )} - - {projects.length === 0 && !shouldShowProjectPathEntry && ( -
- No projects yet -
+
+ { + setNewCwd(event.target.value); + setAddProjectError(null); + }} + onKeyDown={(event) => { + if (event.key === "Enter") handleAddProject(); + if (event.key === "Escape") { + setAddingProject(false); + setAddProjectError(null); + } + }} + autoFocus + /> + +
+ {addProjectError && ( +

{addProjectError}

)} -
-
- - - - - - - void navigate({ to: "/settings" })} +
+ +
+
+ )} + + {isManualProjectSorting ? ( + + + project.id)} + strategy={verticalListSortingStrategy} + > + {sortedProjects.map((project) => ( + + {(dragHandleProps) => renderProjectItem(project, dragHandleProps)} + + ))} + + + + ) : ( + + {sortedProjects.map((project) => ( + + {renderProjectItem(project, null)} + + ))} - - - )} + )} + + {projects.length === 0 && !shouldShowProjectPathEntry && ( +
+ No projects yet +
+ )} + + + + + + + + {isOnSettings ? ( + window.history.back()} + > + + Back + + ) : ( + void navigate({ to: "/settings" })} + > + + Settings + + )} + + + ); } diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index f04c9879fa..55350901f0 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -1,18 +1,49 @@ +// FILE: ChatHeader.tsx +// Purpose: Renders the chat top bar with project actions and panel toggles. +// Layer: Chat shell header +// Depends on: project action controls, git actions, and panel toggle callbacks + import { type EditorId, type ProjectScript, + type ProviderKind, type ResolvedKeybindingsConfig, type ThreadId, } from "@t3tools/contracts"; -import { memo } from "react"; +import React, { memo, useEffect, useRef, useState } from "react"; +import { VscRepoForked } from "react-icons/vsc"; import GitActionsControl from "../GitActionsControl"; -import { DiffIcon, TerminalSquareIcon } from "lucide-react"; +import { + ArrowRightIcon, + DiffIcon, + EllipsisIcon, + GlobeIcon, + PlusIcon, + TerminalSquareIcon, +} from "~/lib/icons"; +import { Button } from "../ui/button"; import { Badge } from "../ui/badge"; +import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from "../ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import ProjectScriptsControl, { type NewProjectScriptInput } from "../ProjectScriptsControl"; import { Toggle } from "../ui/toggle"; -import { SidebarTrigger } from "../ui/sidebar"; +import { SidebarTrigger, useSidebar } from "../ui/sidebar"; import { OpenInPicker } from "./OpenInPicker"; +import { isElectron } from "~/env"; +import { cn } from "~/lib/utils"; +import { readNativeApi } from "~/nativeApi"; +import { usePreferredEditor } from "../../editorPreferences"; +import { AntigravityIcon, ClaudeAI, CursorIcon, OpenAI, VisualStudioCode, Zed } from "../Icons"; + +/** Width (px) below which collapsible header controls fold into the ellipsis menu. */ +const HEADER_COMPACT_BREAKPOINT = 480; + +const EDITOR_ICONS: Record> = { + cursor: CursorIcon, + vscode: VisualStudioCode, + zed: Zed, + antigravity: AntigravityIcon, +}; interface ChatHeaderProps { activeThreadId: ThreadId; @@ -27,7 +58,14 @@ interface ChatHeaderProps { terminalAvailable: boolean; terminalOpen: boolean; terminalToggleShortcutLabel: string | null; + browserToggleShortcutLabel: string | null; diffToggleShortcutLabel: string | null; + handoffBadgeLabel: string | null; + handoffActionLabel: string; + handoffDisabled: boolean; + handoffBadgeSourceProvider: ProviderKind | null; + handoffBadgeTargetProvider: ProviderKind | null; + browserOpen: boolean; gitCwd: string | null; diffOpen: boolean; onRunProjectScript: (script: ProjectScript) => void; @@ -36,6 +74,8 @@ interface ChatHeaderProps { onDeleteProjectScript: (scriptId: string) => Promise; onToggleTerminal: () => void; onToggleDiff: () => void; + onToggleBrowser: () => void; + onCreateHandoff: () => void; } export const ChatHeader = memo(function ChatHeader({ @@ -51,7 +91,14 @@ export const ChatHeader = memo(function ChatHeader({ terminalAvailable, terminalOpen, terminalToggleShortcutLabel, + browserToggleShortcutLabel, diffToggleShortcutLabel, + handoffBadgeLabel, + handoffActionLabel, + handoffDisabled, + handoffBadgeSourceProvider, + handoffBadgeTargetProvider, + browserOpen, gitCwd, diffOpen, onRunProjectScript, @@ -60,72 +107,239 @@ export const ChatHeader = memo(function ChatHeader({ onDeleteProjectScript, onToggleTerminal, onToggleDiff, + onToggleBrowser, + onCreateHandoff, }: ChatHeaderProps) { + const { isMobile, state } = useSidebar(); + const needsDesktopTrafficLightInset = isElectron && !isMobile && state === "collapsed"; + const headerRef = useRef(null); + const [compact, setCompact] = useState(false); + const [preferredEditor] = usePreferredEditor(availableEditors); + const EditorIcon = preferredEditor ? EDITOR_ICONS[preferredEditor] : null; + + useEffect(() => { + const el = headerRef.current; + if (!el) return; + const measure = () => setCompact(el.clientWidth < HEADER_COMPACT_BREAKPOINT); + measure(); + const observer = new ResizeObserver(() => measure()); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + const hasCollapsibleControls = Boolean( + activeProjectScripts || activeProjectName || terminalAvailable, + ); + const renderProviderIcon = (provider: ProviderKind | null, className: string) => { + if (provider === "claudeAgent") { + return ; + } + if (provider === "codex") { + return ; + } + return ; + }; + return ( -
-
- -

- {activeThreadTitle} -

- {activeProjectName && ( - - {activeProjectName} - - )} - {activeProjectName && !isGitRepo && ( - - No Git - +
+
+
+ +
+
+

+ {activeThreadTitle} +

+ {handoffBadgeLabel ? ( + + + + {renderProviderIcon(handoffBadgeSourceProvider, "size-3.5 shrink-0")} + + + + {renderProviderIcon(handoffBadgeTargetProvider, "size-3.5 shrink-0")} + + + } + /> + {handoffBadgeLabel} + + ) : null} +
-
- {activeProjectScripts && ( - - )} - {activeProjectName && ( - - )} - {activeProjectName && } +
- - + + {handoffActionLabel} + } /> - - {!terminalAvailable - ? "Terminal is unavailable until this thread has an active project." - : terminalToggleShortcutLabel - ? `Toggle terminal drawer (${terminalToggleShortcutLabel})` - : "Toggle terminal drawer"} - + {handoffActionLabel} + {/* Inline controls — shown when there's enough room. */} + {!compact && ( + <> + {activeProjectScripts ? ( + + ) : null} + {activeProjectName ? ( + + ) : null} + {terminalAvailable ? ( + + + + + } + /> + + {terminalToggleShortcutLabel + ? `Toggle terminal (${terminalToggleShortcutLabel})` + : "Toggle terminal"} + + + ) : null} + + )} + + {/* Overflow ellipsis — shown only when compact. */} + {compact && hasCollapsibleControls ? ( + + + } + > + + + + {activeProjectScripts + ? activeProjectScripts.map((script) => ( + onRunProjectScript(script)}> + {script.name} + + )) + : null} + {activeProjectScripts ? ( + { + setCompact(false); + }} + > + + Add action + + ) : null} + {activeProjectName ? ( + <> + + { + const api = readNativeApi(); + if (api && openInCwd && preferredEditor) { + void api.shell.openInEditor(openInCwd, preferredEditor); + } + }} + disabled={!preferredEditor || !openInCwd} + > + {EditorIcon ? ( + + ) : null} + Open in editor + + + ) : null} + + + + Terminal + {terminalToggleShortcutLabel && ( + + {terminalToggleShortcutLabel} + + )} + + + + ) : null} + + {activeProjectName ? ( + + ) : null} + {isElectron ? ( + + + + + } + /> + + {browserToggleShortcutLabel + ? `Toggle in-app browser (${browserToggleShortcutLabel})` + : "Toggle in-app browser"} + + + ) : null} store.projects); + const syncServerReadModel = useStore((store) => store.syncServerReadModel); + + const createThreadHandoff = useCallback( + async (thread: Thread): Promise => { + const api = readNativeApi(); + if (!api) { + throw new Error("Native API not found"); + } + + const project = projects.find((entry) => entry.id === thread.projectId); + if (!project) { + throw new Error("Project not found for handoff thread."); + } + + if (!canCreateThreadHandoff({ thread })) { + throw new Error("This thread cannot be handed off yet."); + } + + const nextThreadId = newThreadId(); + const createdAt = new Date().toISOString(); + const importedMessages = buildThreadHandoffImportedMessages(thread); + const { stickyModelSelectionByProvider } = useComposerDraftStore.getState(); + + await api.orchestration.dispatchCommand({ + type: "thread.handoff.create", + commandId: newCommandId(), + threadId: nextThreadId, + sourceThreadId: thread.id, + projectId: thread.projectId, + title: thread.title, + modelSelection: resolveThreadHandoffModelSelection({ + sourceThread: thread, + projectDefaultModelSelection: project.defaultModelSelection, + stickyModelSelectionByProvider, + }), + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + branch: thread.branch, + worktreePath: thread.worktreePath, + importedMessages: [...importedMessages], + createdAt, + }); + + const snapshot = await api.orchestration.getSnapshot(); + syncServerReadModel(snapshot); + await navigate({ + to: "/$threadId", + params: { threadId: nextThreadId }, + }); + + return nextThreadId; + }, + [navigate, projects, syncServerReadModel], + ); + + return { + createThreadHandoff, + }; +} diff --git a/apps/web/src/lib/threadHandoff.ts b/apps/web/src/lib/threadHandoff.ts new file mode 100644 index 0000000000..8c8fa7b7ee --- /dev/null +++ b/apps/web/src/lib/threadHandoff.ts @@ -0,0 +1,89 @@ +import { + DEFAULT_MODEL_BY_PROVIDER, + MessageId, + PROVIDER_DISPLAY_NAMES, + type ModelSelection, + type ProviderKind, + type ThreadHandoffImportedMessage, +} from "@t3tools/contracts"; +import { type Thread } from "../types"; +import { randomUUID } from "./utils"; + +export function resolveHandoffTargetProvider(sourceProvider: ProviderKind): ProviderKind { + return sourceProvider === "claudeAgent" ? "codex" : "claudeAgent"; +} + +export function resolveThreadHandoffBadgeLabel(thread: Pick): string | null { + if (!thread.handoff) { + return null; + } + return `Handoff from ${PROVIDER_DISPLAY_NAMES[thread.handoff.sourceProvider]}`; +} + +export function buildThreadHandoffImportedMessages( + thread: Pick, +): ReadonlyArray { + return thread.messages + .filter( + ( + message, + ): message is Thread["messages"][number] & { + role: "user" | "assistant"; + } => (message.role === "user" || message.role === "assistant") && message.streaming === false, + ) + .map((message) => { + const importedMessage: ThreadHandoffImportedMessage = { + messageId: MessageId.makeUnsafe(randomUUID()), + role: message.role, + text: message.text, + createdAt: message.createdAt, + updatedAt: message.completedAt ?? message.createdAt, + }; + const attachments = + message.attachments && message.attachments.length > 0 + ? message.attachments.map((attachment) => ({ + type: attachment.type, + id: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + })) + : null; + return attachments ? Object.assign(importedMessage, { attachments }) : importedMessage; + }); +} + +export function canCreateThreadHandoff(input: { + readonly thread: Pick; + readonly isBusy?: boolean; + readonly hasPendingApprovals?: boolean; + readonly hasPendingUserInput?: boolean; +}): boolean { + if (input.isBusy || input.hasPendingApprovals || input.hasPendingUserInput) { + return false; + } + const sessionStatus = input.thread.session?.orchestrationStatus; + if (sessionStatus === "starting" || sessionStatus === "running") { + return false; + } + return buildThreadHandoffImportedMessages(input.thread).length > 0; +} + +export function resolveThreadHandoffModelSelection(input: { + readonly sourceThread: Pick; + readonly projectDefaultModelSelection: ModelSelection | null | undefined; + readonly stickyModelSelectionByProvider: Partial>; +}): ModelSelection { + const targetProvider = resolveHandoffTargetProvider(input.sourceThread.modelSelection.provider); + const stickySelection = input.stickyModelSelectionByProvider[targetProvider]; + if (stickySelection) { + return stickySelection; + } + if (input.projectDefaultModelSelection?.provider === targetProvider) { + return input.projectDefaultModelSelection; + } + return { + provider: targetProvider, + model: DEFAULT_MODEL_BY_PROVIDER[targetProvider], + }; +} diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index da1498d494..6e1e8c72ac 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -42,6 +42,7 @@ function makeThread(overrides: Partial = {}): Thread { latestTurn: null, branch: null, worktreePath: null, + handoff: null, ...overrides, }; } @@ -115,6 +116,7 @@ function makeReadModelThread(overrides: Partial; - threadIdsByProjectId: Record; - bootstrapComplete: boolean; -} + threadsHydrated: boolean; +} + +const PERSISTED_STATE_KEY = "t3code:renderer-state:v8"; +const LEGACY_PERSISTED_STATE_KEYS = [ + "t3code:renderer-state:v7", + "t3code:renderer-state:v6", + "t3code:renderer-state:v5", + "t3code:renderer-state:v4", + "t3code:renderer-state:v3", + "codething:renderer-state:v4", + "codething:renderer-state:v3", + "codething:renderer-state:v2", + "codething:renderer-state:v1", +] as const; const initialState: AppState = { projects: [], threads: [], - sidebarThreadsById: {}, - threadIdsByProjectId: {}, - bootstrapComplete: false, + threadsHydrated: false, }; -const MAX_THREAD_MESSAGES = 2_000; -const MAX_THREAD_CHECKPOINTS = 500; -const MAX_THREAD_PROPOSED_PLANS = 200; -const MAX_THREAD_ACTIVITIES = 500; -const EMPTY_THREAD_IDS: ThreadId[] = []; +const persistedExpandedProjectCwds = new Set(); +const persistedProjectOrderCwds: string[] = []; + +// ── Persist helpers ────────────────────────────────────────────────── + +function readPersistedState(): AppState { + if (typeof window === "undefined") return initialState; + try { + const raw = window.localStorage.getItem(PERSISTED_STATE_KEY); + if (!raw) return initialState; + const parsed = JSON.parse(raw) as { + expandedProjectCwds?: string[]; + projectOrderCwds?: string[]; + }; + persistedExpandedProjectCwds.clear(); + persistedProjectOrderCwds.length = 0; + for (const cwd of parsed.expandedProjectCwds ?? []) { + if (typeof cwd === "string" && cwd.length > 0) { + persistedExpandedProjectCwds.add(cwd); + } + } + for (const cwd of parsed.projectOrderCwds ?? []) { + if (typeof cwd === "string" && cwd.length > 0 && !persistedProjectOrderCwds.includes(cwd)) { + persistedProjectOrderCwds.push(cwd); + } + } + return { ...initialState }; + } catch { + return initialState; + } +} + +let legacyKeysCleanedUp = false; + +function persistState(state: AppState): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem( + PERSISTED_STATE_KEY, + JSON.stringify({ + expandedProjectCwds: state.projects + .filter((project) => project.expanded) + .map((project) => project.cwd), + projectOrderCwds: state.projects.map((project) => project.cwd), + }), + ); + if (!legacyKeysCleanedUp) { + legacyKeysCleanedUp = true; + for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) { + window.localStorage.removeItem(legacyKey); + } + } + } catch { + // Ignore quota/storage errors to avoid breaking chat UX. + } +} +const debouncedPersistState = new Debouncer(persistState, { wait: 500 }); // ── Pure helpers ────────────────────────────────────────────────────── @@ -62,416 +111,66 @@ function updateThread( return changed ? next : threads; } -function updateProject( - projects: Project[], - projectId: Project["id"], - updater: (project: Project) => Project, +function mapProjectsFromReadModel( + incoming: OrchestrationReadModel["projects"], + previous: Project[], ): Project[] { - let changed = false; - const next = projects.map((project) => { - if (project.id !== projectId) { - return project; - } - const updated = updater(project); - if (updated !== project) { - changed = true; - } - return updated; - }); - return changed ? next : projects; -} - -function normalizeModelSelection( - selection: T, -): T { - return { - ...selection, - model: resolveModelSlugForProvider(selection.provider, selection.model), - }; -} - -function mapProjectScripts(scripts: ReadonlyArray): Project["scripts"] { - return scripts.map((script) => ({ ...script })); -} - -function mapSession(session: OrchestrationSession): Thread["session"] { - return { - provider: toLegacyProvider(session.providerName), - status: toLegacySessionStatus(session.status), - orchestrationStatus: session.status, - activeTurnId: session.activeTurnId ?? undefined, - createdAt: session.updatedAt, - updatedAt: session.updatedAt, - ...(session.lastError ? { lastError: session.lastError } : {}), - }; -} - -function mapMessage(message: OrchestrationMessage): ChatMessage { - const attachments = message.attachments?.map((attachment) => ({ - type: "image" as const, - id: attachment.id, - name: attachment.name, - mimeType: attachment.mimeType, - sizeBytes: attachment.sizeBytes, - previewUrl: toAttachmentPreviewUrl(attachmentPreviewRoutePath(attachment.id)), - })); - - return { - id: message.id, - role: message.role, - text: message.text, - turnId: message.turnId, - createdAt: message.createdAt, - streaming: message.streaming, - ...(message.streaming ? {} : { completedAt: message.updatedAt }), - ...(attachments && attachments.length > 0 ? { attachments } : {}), - }; -} - -function mapProposedPlan(proposedPlan: OrchestrationProposedPlan): Thread["proposedPlans"][number] { - return { - id: proposedPlan.id, - turnId: proposedPlan.turnId, - planMarkdown: proposedPlan.planMarkdown, - implementedAt: proposedPlan.implementedAt, - implementationThreadId: proposedPlan.implementationThreadId, - createdAt: proposedPlan.createdAt, - updatedAt: proposedPlan.updatedAt, - }; -} - -function mapTurnDiffSummary( - checkpoint: OrchestrationCheckpointSummary, -): Thread["turnDiffSummaries"][number] { - return { - turnId: checkpoint.turnId, - completedAt: checkpoint.completedAt, - status: checkpoint.status, - assistantMessageId: checkpoint.assistantMessageId ?? undefined, - checkpointTurnCount: checkpoint.checkpointTurnCount, - checkpointRef: checkpoint.checkpointRef, - files: checkpoint.files.map((file) => ({ ...file })), - }; -} - -function mapThread(thread: OrchestrationThread): Thread { - return { - id: thread.id, - codexThreadId: null, - projectId: thread.projectId, - title: thread.title, - modelSelection: normalizeModelSelection(thread.modelSelection), - runtimeMode: thread.runtimeMode, - interactionMode: thread.interactionMode, - session: thread.session ? mapSession(thread.session) : null, - messages: thread.messages.map(mapMessage), - proposedPlans: thread.proposedPlans.map(mapProposedPlan), - error: sanitizeThreadErrorMessage(thread.session?.lastError), - createdAt: thread.createdAt, - archivedAt: thread.archivedAt, - updatedAt: thread.updatedAt, - latestTurn: thread.latestTurn, - pendingSourceProposedPlan: thread.latestTurn?.sourceProposedPlan, - branch: thread.branch, - worktreePath: thread.worktreePath, - turnDiffSummaries: thread.checkpoints.map(mapTurnDiffSummary), - activities: thread.activities.map((activity) => ({ ...activity })), - }; -} - -function mapProject(project: OrchestrationReadModel["projects"][number]): Project { - return { - id: project.id, - name: project.title, - cwd: project.workspaceRoot, - defaultModelSelection: project.defaultModelSelection - ? normalizeModelSelection(project.defaultModelSelection) - : null, - createdAt: project.createdAt, - updatedAt: project.updatedAt, - scripts: mapProjectScripts(project.scripts), - }; -} - -function getLatestUserMessageAt( - messages: ReadonlyArray, -): string | null { - let latestUserMessageAt: string | null = null; - - for (const message of messages) { - if (message.role !== "user") { - continue; - } - if (latestUserMessageAt === null || message.createdAt > latestUserMessageAt) { - latestUserMessageAt = message.createdAt; - } - } - - return latestUserMessageAt; -} - -function buildSidebarThreadSummary(thread: Thread): SidebarThreadSummary { - return { - id: thread.id, - projectId: thread.projectId, - title: thread.title, - interactionMode: thread.interactionMode, - session: thread.session, - createdAt: thread.createdAt, - archivedAt: thread.archivedAt, - updatedAt: thread.updatedAt, - latestTurn: thread.latestTurn, - branch: thread.branch, - worktreePath: thread.worktreePath, - latestUserMessageAt: getLatestUserMessageAt(thread.messages), - hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0, - hasPendingUserInput: derivePendingUserInputs(thread.activities).length > 0, - hasActionableProposedPlan: hasActionableProposedPlan( - findLatestProposedPlan(thread.proposedPlans, thread.latestTurn?.turnId ?? null), - ), - }; -} - -function sidebarThreadSummariesEqual( - left: SidebarThreadSummary | undefined, - right: SidebarThreadSummary, -): boolean { - return ( - left !== undefined && - left.id === right.id && - left.projectId === right.projectId && - left.title === right.title && - left.interactionMode === right.interactionMode && - left.session === right.session && - left.createdAt === right.createdAt && - left.archivedAt === right.archivedAt && - left.updatedAt === right.updatedAt && - left.latestTurn === right.latestTurn && - left.branch === right.branch && - left.worktreePath === right.worktreePath && - left.latestUserMessageAt === right.latestUserMessageAt && - left.hasPendingApprovals === right.hasPendingApprovals && - left.hasPendingUserInput === right.hasPendingUserInput && - left.hasActionableProposedPlan === right.hasActionableProposedPlan + const previousById = new Map(previous.map((project) => [project.id, project] as const)); + const previousByCwd = new Map(previous.map((project) => [project.cwd, project] as const)); + const previousOrderById = new Map(previous.map((project, index) => [project.id, index] as const)); + const previousOrderByCwd = new Map( + previous.map((project, index) => [project.cwd, index] as const), ); -} - -function appendThreadIdByProjectId( - threadIdsByProjectId: Record, - projectId: ProjectId, - threadId: ThreadId, -): Record { - const existingThreadIds = threadIdsByProjectId[projectId] ?? EMPTY_THREAD_IDS; - if (existingThreadIds.includes(threadId)) { - return threadIdsByProjectId; - } - return { - ...threadIdsByProjectId, - [projectId]: [...existingThreadIds, threadId], - }; -} - -function removeThreadIdByProjectId( - threadIdsByProjectId: Record, - projectId: ProjectId, - threadId: ThreadId, -): Record { - const existingThreadIds = threadIdsByProjectId[projectId] ?? EMPTY_THREAD_IDS; - if (!existingThreadIds.includes(threadId)) { - return threadIdsByProjectId; - } - const nextThreadIds = existingThreadIds.filter( - (existingThreadId) => existingThreadId !== threadId, - ); - if (nextThreadIds.length === existingThreadIds.length) { - return threadIdsByProjectId; - } - if (nextThreadIds.length === 0) { - const nextThreadIdsByProjectId = { ...threadIdsByProjectId }; - delete nextThreadIdsByProjectId[projectId]; - return nextThreadIdsByProjectId; - } - return { - ...threadIdsByProjectId, - [projectId]: nextThreadIds, - }; -} - -function buildThreadIdsByProjectId(threads: ReadonlyArray): Record { - const threadIdsByProjectId: Record = {}; - for (const thread of threads) { - const existingThreadIds = threadIdsByProjectId[thread.projectId] ?? EMPTY_THREAD_IDS; - threadIdsByProjectId[thread.projectId] = [...existingThreadIds, thread.id]; - } - return threadIdsByProjectId; -} - -function buildSidebarThreadsById( - threads: ReadonlyArray, -): Record { - return Object.fromEntries( - threads.map((thread) => [thread.id, buildSidebarThreadSummary(thread)]), + const persistedOrderByCwd = new Map( + persistedProjectOrderCwds.map((cwd, index) => [cwd, index] as const), ); -} - -function checkpointStatusToLatestTurnState(status: "ready" | "missing" | "error") { - if (status === "error") { - return "error" as const; - } - if (status === "missing") { - return "interrupted" as const; - } - return "completed" as const; -} + const usePersistedOrder = previous.length === 0; -function compareActivities( - left: Thread["activities"][number], - right: Thread["activities"][number], -): number { - if (left.sequence !== undefined && right.sequence !== undefined) { - if (left.sequence !== right.sequence) { - return left.sequence - right.sequence; - } - } else if (left.sequence !== undefined) { - return 1; - } else if (right.sequence !== undefined) { - return -1; - } - - return left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id); -} - -function buildLatestTurn(params: { - previous: Thread["latestTurn"]; - turnId: NonNullable["turnId"]; - state: NonNullable["state"]; - requestedAt: string; - startedAt: string | null; - completedAt: string | null; - assistantMessageId: NonNullable["assistantMessageId"]; - sourceProposedPlan?: Thread["pendingSourceProposedPlan"]; -}): NonNullable { - const resolvedPlan = - params.previous?.turnId === params.turnId - ? params.previous.sourceProposedPlan - : params.sourceProposedPlan; - return { - turnId: params.turnId, - state: params.state, - requestedAt: params.requestedAt, - startedAt: params.startedAt, - completedAt: params.completedAt, - assistantMessageId: params.assistantMessageId, - ...(resolvedPlan ? { sourceProposedPlan: resolvedPlan } : {}), - }; -} - -function rebindTurnDiffSummariesForAssistantMessage( - turnDiffSummaries: ReadonlyArray, - turnId: Thread["turnDiffSummaries"][number]["turnId"], - assistantMessageId: NonNullable["assistantMessageId"], -): Thread["turnDiffSummaries"] { - let changed = false; - const nextSummaries = turnDiffSummaries.map((summary) => { - if (summary.turnId !== turnId || summary.assistantMessageId === assistantMessageId) { - return summary; - } - changed = true; + const mappedProjects = incoming.map((project) => { + const existing = previousById.get(project.id) ?? previousByCwd.get(project.workspaceRoot); return { - ...summary, - assistantMessageId: assistantMessageId ?? undefined, - }; + id: project.id, + name: project.title, + cwd: project.workspaceRoot, + defaultModelSelection: + existing?.defaultModelSelection ?? + (project.defaultModelSelection + ? { + ...project.defaultModelSelection, + model: resolveModelSlugForProvider( + project.defaultModelSelection.provider, + project.defaultModelSelection.model, + ), + } + : null), + expanded: + existing?.expanded ?? + (persistedExpandedProjectCwds.size > 0 + ? persistedExpandedProjectCwds.has(project.workspaceRoot) + : true), + createdAt: project.createdAt, + updatedAt: project.updatedAt, + scripts: project.scripts.map((script) => ({ ...script })), + } satisfies Project; }); - return changed ? nextSummaries : [...turnDiffSummaries]; -} - -function retainThreadMessagesAfterRevert( - messages: ReadonlyArray, - retainedTurnIds: ReadonlySet, - turnCount: number, -): ChatMessage[] { - const retainedMessageIds = new Set(); - for (const message of messages) { - if (message.role === "system") { - retainedMessageIds.add(message.id); - continue; - } - if ( - message.turnId !== undefined && - message.turnId !== null && - retainedTurnIds.has(message.turnId) - ) { - retainedMessageIds.add(message.id); - } - } - - const retainedUserCount = messages.filter( - (message) => message.role === "user" && retainedMessageIds.has(message.id), - ).length; - const missingUserCount = Math.max(0, turnCount - retainedUserCount); - if (missingUserCount > 0) { - const fallbackUserMessages = messages - .filter( - (message) => - message.role === "user" && - !retainedMessageIds.has(message.id) && - (message.turnId === undefined || - message.turnId === null || - retainedTurnIds.has(message.turnId)), - ) - .toSorted( - (left, right) => - left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), - ) - .slice(0, missingUserCount); - for (const message of fallbackUserMessages) { - retainedMessageIds.add(message.id); - } - } - const retainedAssistantCount = messages.filter( - (message) => message.role === "assistant" && retainedMessageIds.has(message.id), - ).length; - const missingAssistantCount = Math.max(0, turnCount - retainedAssistantCount); - if (missingAssistantCount > 0) { - const fallbackAssistantMessages = messages - .filter( - (message) => - message.role === "assistant" && - !retainedMessageIds.has(message.id) && - (message.turnId === undefined || - message.turnId === null || - retainedTurnIds.has(message.turnId)), - ) - .toSorted( - (left, right) => - left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), - ) - .slice(0, missingAssistantCount); - for (const message of fallbackAssistantMessages) { - retainedMessageIds.add(message.id); - } - } - - return messages.filter((message) => retainedMessageIds.has(message.id)); -} - -function retainThreadActivitiesAfterRevert( - activities: ReadonlyArray, - retainedTurnIds: ReadonlySet, -): Thread["activities"] { - return activities.filter( - (activity) => activity.turnId === null || retainedTurnIds.has(activity.turnId), - ); -} - -function retainThreadProposedPlansAfterRevert( - proposedPlans: ReadonlyArray, - retainedTurnIds: ReadonlySet, -): Thread["proposedPlans"] { - return proposedPlans.filter( - (proposedPlan) => proposedPlan.turnId === null || retainedTurnIds.has(proposedPlan.turnId), - ); + return mappedProjects + .map((project, incomingIndex) => { + const previousIndex = + previousOrderById.get(project.id) ?? previousOrderByCwd.get(project.cwd); + const persistedIndex = usePersistedOrder ? persistedOrderByCwd.get(project.cwd) : undefined; + const orderIndex = + previousIndex ?? + persistedIndex ?? + (usePersistedOrder ? persistedProjectOrderCwds.length : previous.length) + incomingIndex; + return { project, incomingIndex, orderIndex }; + }) + .toSorted((a, b) => { + const byOrder = a.orderIndex - b.orderIndex; + if (byOrder !== 0) return byOrder; + return a.incomingIndex - b.incomingIndex; + }) + .map((entry) => entry.project); } function toLegacySessionStatus( @@ -532,594 +231,177 @@ function attachmentPreviewRoutePath(attachmentId: string): string { return `/attachments/${encodeURIComponent(attachmentId)}`; } -function updateThreadState( - state: AppState, - threadId: ThreadId, - updater: (thread: Thread) => Thread, -): AppState { - let updatedThread: Thread | null = null; - const threads = updateThread(state.threads, threadId, (thread) => { - const nextThread = updater(thread); - if (nextThread !== thread) { - updatedThread = nextThread; - } - return nextThread; - }); - if (threads === state.threads || updatedThread === null) { - return state; - } - - const nextSummary = buildSidebarThreadSummary(updatedThread); - const previousSummary = state.sidebarThreadsById[threadId]; - const sidebarThreadsById = sidebarThreadSummariesEqual(previousSummary, nextSummary) - ? state.sidebarThreadsById - : { - ...state.sidebarThreadsById, - [threadId]: nextSummary, - }; - - if (sidebarThreadsById === state.sidebarThreadsById) { - return { - ...state, - threads, - }; - } - - return { - ...state, - threads, - sidebarThreadsById, - }; -} - // ── Pure state transition functions ──────────────────────────────────── export function syncServerReadModel(state: AppState, readModel: OrchestrationReadModel): AppState { - const projects = readModel.projects - .filter((project) => project.deletedAt === null) - .map(mapProject); - const threads = readModel.threads.filter((thread) => thread.deletedAt === null).map(mapThread); - const sidebarThreadsById = buildSidebarThreadsById(threads); - const threadIdsByProjectId = buildThreadIdsByProjectId(threads); + const projects = mapProjectsFromReadModel( + readModel.projects.filter((project) => project.deletedAt === null), + state.projects, + ); + const existingThreadById = new Map(state.threads.map((thread) => [thread.id, thread] as const)); + const threads = readModel.threads + .filter((thread) => thread.deletedAt === null) + .map((thread) => { + const existing = existingThreadById.get(thread.id); + return { + id: thread.id, + codexThreadId: null, + projectId: thread.projectId, + title: thread.title, + modelSelection: { + ...thread.modelSelection, + model: resolveModelSlugForProvider( + thread.modelSelection.provider, + thread.modelSelection.model, + ), + }, + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + session: thread.session + ? { + provider: toLegacyProvider(thread.session.providerName), + status: toLegacySessionStatus(thread.session.status), + orchestrationStatus: thread.session.status, + activeTurnId: thread.session.activeTurnId ?? undefined, + createdAt: thread.session.updatedAt, + updatedAt: thread.session.updatedAt, + ...(thread.session.lastError ? { lastError: thread.session.lastError } : {}), + } + : null, + messages: thread.messages.map((message) => { + const attachments = message.attachments?.map((attachment) => ({ + type: "image" as const, + id: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + previewUrl: toAttachmentPreviewUrl(attachmentPreviewRoutePath(attachment.id)), + })); + const normalizedMessage: ChatMessage = { + id: message.id, + role: message.role, + text: message.text, + createdAt: message.createdAt, + streaming: message.streaming, + source: message.source, + ...(message.streaming ? {} : { completedAt: message.updatedAt }), + ...(attachments && attachments.length > 0 ? { attachments } : {}), + }; + return normalizedMessage; + }), + proposedPlans: thread.proposedPlans.map((proposedPlan) => ({ + id: proposedPlan.id, + turnId: proposedPlan.turnId, + planMarkdown: proposedPlan.planMarkdown, + implementedAt: proposedPlan.implementedAt, + implementationThreadId: proposedPlan.implementationThreadId, + createdAt: proposedPlan.createdAt, + updatedAt: proposedPlan.updatedAt, + })), + error: thread.session?.lastError ?? null, + createdAt: thread.createdAt, + updatedAt: thread.updatedAt, + latestTurn: thread.latestTurn, + lastVisitedAt: existing?.lastVisitedAt ?? thread.updatedAt, + branch: thread.branch, + worktreePath: thread.worktreePath, + handoff: thread.handoff, + turnDiffSummaries: thread.checkpoints.map((checkpoint) => ({ + turnId: checkpoint.turnId, + completedAt: checkpoint.completedAt, + status: checkpoint.status, + assistantMessageId: checkpoint.assistantMessageId ?? undefined, + checkpointTurnCount: checkpoint.checkpointTurnCount, + checkpointRef: checkpoint.checkpointRef, + files: checkpoint.files.map((file) => ({ ...file })), + })), + activities: thread.activities.map((activity) => ({ ...activity })), + }; + }); return { ...state, projects, threads, - sidebarThreadsById, - threadIdsByProjectId, - bootstrapComplete: true, + threadsHydrated: true, }; } -export function applyOrchestrationEvent(state: AppState, event: OrchestrationEvent): AppState { - switch (event.type) { - case "project.created": { - const existingIndex = state.projects.findIndex( - (project) => - project.id === event.payload.projectId || project.cwd === event.payload.workspaceRoot, - ); - const nextProject = mapProject({ - id: event.payload.projectId, - title: event.payload.title, - workspaceRoot: event.payload.workspaceRoot, - defaultModelSelection: event.payload.defaultModelSelection, - scripts: event.payload.scripts, - createdAt: event.payload.createdAt, - updatedAt: event.payload.updatedAt, - deletedAt: null, - }); - const projects = - existingIndex >= 0 - ? state.projects.map((project, index) => - index === existingIndex ? nextProject : project, - ) - : [...state.projects, nextProject]; - return { ...state, projects }; - } - - case "project.meta-updated": { - const projects = updateProject(state.projects, event.payload.projectId, (project) => ({ - ...project, - ...(event.payload.title !== undefined ? { name: event.payload.title } : {}), - ...(event.payload.workspaceRoot !== undefined ? { cwd: event.payload.workspaceRoot } : {}), - ...(event.payload.defaultModelSelection !== undefined - ? { - defaultModelSelection: event.payload.defaultModelSelection - ? normalizeModelSelection(event.payload.defaultModelSelection) - : null, - } - : {}), - ...(event.payload.scripts !== undefined - ? { scripts: mapProjectScripts(event.payload.scripts) } - : {}), - updatedAt: event.payload.updatedAt, - })); - return projects === state.projects ? state : { ...state, projects }; - } - - case "project.deleted": { - const projects = state.projects.filter((project) => project.id !== event.payload.projectId); - return projects.length === state.projects.length ? state : { ...state, projects }; - } - - case "thread.created": { - const existing = state.threads.find((thread) => thread.id === event.payload.threadId); - const nextThread = mapThread({ - id: event.payload.threadId, - projectId: event.payload.projectId, - title: event.payload.title, - modelSelection: event.payload.modelSelection, - runtimeMode: event.payload.runtimeMode, - interactionMode: event.payload.interactionMode, - branch: event.payload.branch, - worktreePath: event.payload.worktreePath, - latestTurn: null, - createdAt: event.payload.createdAt, - updatedAt: event.payload.updatedAt, - archivedAt: null, - deletedAt: null, - messages: [], - proposedPlans: [], - activities: [], - checkpoints: [], - session: null, - }); - const threads = existing - ? state.threads.map((thread) => (thread.id === nextThread.id ? nextThread : thread)) - : [...state.threads, nextThread]; - const nextSummary = buildSidebarThreadSummary(nextThread); - const previousSummary = state.sidebarThreadsById[nextThread.id]; - const sidebarThreadsById = sidebarThreadSummariesEqual(previousSummary, nextSummary) - ? state.sidebarThreadsById - : { - ...state.sidebarThreadsById, - [nextThread.id]: nextSummary, - }; - const nextThreadIdsByProjectId = - existing !== undefined && existing.projectId !== nextThread.projectId - ? removeThreadIdByProjectId(state.threadIdsByProjectId, existing.projectId, existing.id) - : state.threadIdsByProjectId; - const threadIdsByProjectId = appendThreadIdByProjectId( - nextThreadIdsByProjectId, - nextThread.projectId, - nextThread.id, - ); - return { - ...state, - threads, - sidebarThreadsById, - threadIdsByProjectId, - }; - } - - case "thread.deleted": { - const threads = state.threads.filter((thread) => thread.id !== event.payload.threadId); - if (threads.length === state.threads.length) { - return state; - } - const deletedThread = state.threads.find((thread) => thread.id === event.payload.threadId); - const sidebarThreadsById = { ...state.sidebarThreadsById }; - delete sidebarThreadsById[event.payload.threadId]; - const threadIdsByProjectId = deletedThread - ? removeThreadIdByProjectId( - state.threadIdsByProjectId, - deletedThread.projectId, - deletedThread.id, - ) - : state.threadIdsByProjectId; - return { - ...state, - threads, - sidebarThreadsById, - threadIdsByProjectId, - }; - } - - case "thread.archived": { - return updateThreadState(state, event.payload.threadId, (thread) => ({ - ...thread, - archivedAt: event.payload.archivedAt, - updatedAt: event.payload.updatedAt, - })); - } - - case "thread.unarchived": { - return updateThreadState(state, event.payload.threadId, (thread) => ({ - ...thread, - archivedAt: null, - updatedAt: event.payload.updatedAt, - })); - } - - case "thread.meta-updated": { - return updateThreadState(state, event.payload.threadId, (thread) => ({ - ...thread, - ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), - ...(event.payload.modelSelection !== undefined - ? { modelSelection: normalizeModelSelection(event.payload.modelSelection) } - : {}), - ...(event.payload.branch !== undefined ? { branch: event.payload.branch } : {}), - ...(event.payload.worktreePath !== undefined - ? { worktreePath: event.payload.worktreePath } - : {}), - updatedAt: event.payload.updatedAt, - })); - } - - case "thread.runtime-mode-set": { - return updateThreadState(state, event.payload.threadId, (thread) => ({ - ...thread, - runtimeMode: event.payload.runtimeMode, - updatedAt: event.payload.updatedAt, - })); - } - - case "thread.interaction-mode-set": { - return updateThreadState(state, event.payload.threadId, (thread) => ({ - ...thread, - interactionMode: event.payload.interactionMode, - updatedAt: event.payload.updatedAt, - })); - } - - case "thread.turn-start-requested": { - return updateThreadState(state, event.payload.threadId, (thread) => ({ - ...thread, - ...(event.payload.modelSelection !== undefined - ? { modelSelection: normalizeModelSelection(event.payload.modelSelection) } - : {}), - runtimeMode: event.payload.runtimeMode, - interactionMode: event.payload.interactionMode, - pendingSourceProposedPlan: event.payload.sourceProposedPlan, - updatedAt: event.occurredAt, - })); - } - - case "thread.turn-interrupt-requested": { - if (event.payload.turnId === undefined) { - return state; - } - return updateThreadState(state, event.payload.threadId, (thread) => { - const latestTurn = thread.latestTurn; - if (latestTurn === null || latestTurn.turnId !== event.payload.turnId) { - return thread; - } - return { - ...thread, - latestTurn: buildLatestTurn({ - previous: latestTurn, - turnId: event.payload.turnId, - state: "interrupted", - requestedAt: latestTurn.requestedAt, - startedAt: latestTurn.startedAt ?? event.payload.createdAt, - completedAt: latestTurn.completedAt ?? event.payload.createdAt, - assistantMessageId: latestTurn.assistantMessageId, - }), - updatedAt: event.occurredAt, - }; - }); - } - - case "thread.message-sent": { - return updateThreadState(state, event.payload.threadId, (thread) => { - const message = mapMessage({ - id: event.payload.messageId, - role: event.payload.role, - text: event.payload.text, - ...(event.payload.attachments !== undefined - ? { attachments: event.payload.attachments } - : {}), - turnId: event.payload.turnId, - streaming: event.payload.streaming, - createdAt: event.payload.createdAt, - updatedAt: event.payload.updatedAt, - }); - const existingMessage = thread.messages.find((entry) => entry.id === message.id); - const messages = existingMessage - ? thread.messages.map((entry) => - entry.id !== message.id - ? entry - : { - ...entry, - text: message.streaming - ? `${entry.text}${message.text}` - : message.text.length > 0 - ? message.text - : entry.text, - streaming: message.streaming, - ...(message.turnId !== undefined ? { turnId: message.turnId } : {}), - ...(message.streaming - ? entry.completedAt !== undefined - ? { completedAt: entry.completedAt } - : {} - : message.completedAt !== undefined - ? { completedAt: message.completedAt } - : {}), - ...(message.attachments !== undefined - ? { attachments: message.attachments } - : {}), - }, - ) - : [...thread.messages, message]; - const cappedMessages = messages.slice(-MAX_THREAD_MESSAGES); - const turnDiffSummaries = - event.payload.role === "assistant" && event.payload.turnId !== null - ? rebindTurnDiffSummariesForAssistantMessage( - thread.turnDiffSummaries, - event.payload.turnId, - event.payload.messageId, - ) - : thread.turnDiffSummaries; - const latestTurn: Thread["latestTurn"] = - event.payload.role === "assistant" && - event.payload.turnId !== null && - (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId) - ? buildLatestTurn({ - previous: thread.latestTurn, - turnId: event.payload.turnId, - state: event.payload.streaming - ? "running" - : thread.latestTurn?.state === "interrupted" - ? "interrupted" - : thread.latestTurn?.state === "error" - ? "error" - : "completed", - requestedAt: - thread.latestTurn?.turnId === event.payload.turnId - ? thread.latestTurn.requestedAt - : event.payload.createdAt, - startedAt: - thread.latestTurn?.turnId === event.payload.turnId - ? (thread.latestTurn.startedAt ?? event.payload.createdAt) - : event.payload.createdAt, - sourceProposedPlan: thread.pendingSourceProposedPlan, - completedAt: event.payload.streaming - ? thread.latestTurn?.turnId === event.payload.turnId - ? (thread.latestTurn.completedAt ?? null) - : null - : event.payload.updatedAt, - assistantMessageId: event.payload.messageId, - }) - : thread.latestTurn; - return { - ...thread, - messages: cappedMessages, - turnDiffSummaries, - latestTurn, - updatedAt: event.occurredAt, - }; - }); - } - - case "thread.session-set": { - return updateThreadState(state, event.payload.threadId, (thread) => ({ - ...thread, - session: mapSession(event.payload.session), - error: sanitizeThreadErrorMessage(event.payload.session.lastError), - latestTurn: - event.payload.session.status === "running" && event.payload.session.activeTurnId !== null - ? buildLatestTurn({ - previous: thread.latestTurn, - turnId: event.payload.session.activeTurnId, - state: "running", - requestedAt: - thread.latestTurn?.turnId === event.payload.session.activeTurnId - ? thread.latestTurn.requestedAt - : event.payload.session.updatedAt, - startedAt: - thread.latestTurn?.turnId === event.payload.session.activeTurnId - ? (thread.latestTurn.startedAt ?? event.payload.session.updatedAt) - : event.payload.session.updatedAt, - completedAt: null, - assistantMessageId: - thread.latestTurn?.turnId === event.payload.session.activeTurnId - ? thread.latestTurn.assistantMessageId - : null, - sourceProposedPlan: thread.pendingSourceProposedPlan, - }) - : thread.latestTurn, - updatedAt: event.occurredAt, - })); - } - - case "thread.session-stop-requested": { - return updateThreadState(state, event.payload.threadId, (thread) => - thread.session === null - ? thread - : { - ...thread, - session: { - ...thread.session, - status: "closed", - orchestrationStatus: "stopped", - activeTurnId: undefined, - updatedAt: event.payload.createdAt, - }, - updatedAt: event.occurredAt, - }, - ); - } - - case "thread.proposed-plan-upserted": { - return updateThreadState(state, event.payload.threadId, (thread) => { - const proposedPlan = mapProposedPlan(event.payload.proposedPlan); - const proposedPlans = [ - ...thread.proposedPlans.filter((entry) => entry.id !== proposedPlan.id), - proposedPlan, - ] - .toSorted( - (left, right) => - left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), - ) - .slice(-MAX_THREAD_PROPOSED_PLANS); - return { - ...thread, - proposedPlans, - updatedAt: event.occurredAt, - }; - }); - } - - case "thread.turn-diff-completed": { - return updateThreadState(state, event.payload.threadId, (thread) => { - const checkpoint = mapTurnDiffSummary({ - turnId: event.payload.turnId, - checkpointTurnCount: event.payload.checkpointTurnCount, - checkpointRef: event.payload.checkpointRef, - status: event.payload.status, - files: event.payload.files, - assistantMessageId: event.payload.assistantMessageId, - completedAt: event.payload.completedAt, - }); - const existing = thread.turnDiffSummaries.find( - (entry) => entry.turnId === checkpoint.turnId, - ); - if (existing && existing.status !== "missing" && checkpoint.status === "missing") { - return thread; - } - const turnDiffSummaries = [ - ...thread.turnDiffSummaries.filter((entry) => entry.turnId !== checkpoint.turnId), - checkpoint, - ] - .toSorted( - (left, right) => - (left.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER) - - (right.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER), - ) - .slice(-MAX_THREAD_CHECKPOINTS); - const latestTurn = - thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId - ? buildLatestTurn({ - previous: thread.latestTurn, - turnId: event.payload.turnId, - state: checkpointStatusToLatestTurnState(event.payload.status), - requestedAt: thread.latestTurn?.requestedAt ?? event.payload.completedAt, - startedAt: thread.latestTurn?.startedAt ?? event.payload.completedAt, - completedAt: event.payload.completedAt, - assistantMessageId: event.payload.assistantMessageId, - sourceProposedPlan: thread.pendingSourceProposedPlan, - }) - : thread.latestTurn; - return { - ...thread, - turnDiffSummaries, - latestTurn, - updatedAt: event.occurredAt, - }; - }); - } - - case "thread.reverted": { - return updateThreadState(state, event.payload.threadId, (thread) => { - const turnDiffSummaries = thread.turnDiffSummaries - .filter( - (entry) => - entry.checkpointTurnCount !== undefined && - entry.checkpointTurnCount <= event.payload.turnCount, - ) - .toSorted( - (left, right) => - (left.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER) - - (right.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER), - ) - .slice(-MAX_THREAD_CHECKPOINTS); - const retainedTurnIds = new Set(turnDiffSummaries.map((entry) => entry.turnId)); - const messages = retainThreadMessagesAfterRevert( - thread.messages, - retainedTurnIds, - event.payload.turnCount, - ).slice(-MAX_THREAD_MESSAGES); - const proposedPlans = retainThreadProposedPlansAfterRevert( - thread.proposedPlans, - retainedTurnIds, - ).slice(-MAX_THREAD_PROPOSED_PLANS); - const activities = retainThreadActivitiesAfterRevert(thread.activities, retainedTurnIds); - const latestCheckpoint = turnDiffSummaries.at(-1) ?? null; - - return { - ...thread, - turnDiffSummaries, - messages, - proposedPlans, - activities, - pendingSourceProposedPlan: undefined, - latestTurn: - latestCheckpoint === null - ? null - : { - turnId: latestCheckpoint.turnId, - state: checkpointStatusToLatestTurnState( - (latestCheckpoint.status ?? "ready") as "ready" | "missing" | "error", - ), - requestedAt: latestCheckpoint.completedAt, - startedAt: latestCheckpoint.completedAt, - completedAt: latestCheckpoint.completedAt, - assistantMessageId: latestCheckpoint.assistantMessageId ?? null, - }, - updatedAt: event.occurredAt, - }; - }); - } - - case "thread.activity-appended": { - return updateThreadState(state, event.payload.threadId, (thread) => { - const activities = [ - ...thread.activities.filter((activity) => activity.id !== event.payload.activity.id), - { ...event.payload.activity }, - ] - .toSorted(compareActivities) - .slice(-MAX_THREAD_ACTIVITIES); - return { - ...thread, - activities, - updatedAt: event.occurredAt, - }; - }); +export function markThreadVisited( + state: AppState, + threadId: ThreadId, + visitedAt?: string, +): AppState { + const at = visitedAt ?? new Date().toISOString(); + const visitedAtMs = Date.parse(at); + const threads = updateThread(state.threads, threadId, (thread) => { + const previousVisitedAtMs = thread.lastVisitedAt ? Date.parse(thread.lastVisitedAt) : NaN; + if ( + Number.isFinite(previousVisitedAtMs) && + Number.isFinite(visitedAtMs) && + previousVisitedAtMs >= visitedAtMs + ) { + return thread; } + return { ...thread, lastVisitedAt: at }; + }); + return threads === state.threads ? state : { ...state, threads }; +} - case "thread.approval-response-requested": - case "thread.user-input-response-requested": - return state; - } +export function markThreadUnread(state: AppState, threadId: ThreadId): AppState { + const threads = updateThread(state.threads, threadId, (thread) => { + if (!thread.latestTurn?.completedAt) return thread; + const latestTurnCompletedAtMs = Date.parse(thread.latestTurn.completedAt); + if (Number.isNaN(latestTurnCompletedAtMs)) return thread; + const unreadVisitedAt = new Date(latestTurnCompletedAtMs - 1).toISOString(); + if (thread.lastVisitedAt === unreadVisitedAt) return thread; + return { ...thread, lastVisitedAt: unreadVisitedAt }; + }); + return threads === state.threads ? state : { ...state, threads }; +} - return state; +export function toggleProject(state: AppState, projectId: Project["id"]): AppState { + return { + ...state, + projects: state.projects.map((p) => (p.id === projectId ? { ...p, expanded: !p.expanded } : p)), + }; } -export function applyOrchestrationEvents( +export function setProjectExpanded( state: AppState, - events: ReadonlyArray, + projectId: Project["id"], + expanded: boolean, ): AppState { - if (events.length === 0) { - return state; - } - return events.reduce((nextState, event) => applyOrchestrationEvent(nextState, event), state); + let changed = false; + const projects = state.projects.map((p) => { + if (p.id !== projectId || p.expanded === expanded) return p; + changed = true; + return { ...p, expanded }; + }); + return changed ? { ...state, projects } : state; } -export const selectProjectById = - (projectId: Project["id"] | null | undefined) => - (state: AppState): Project | undefined => - projectId ? state.projects.find((project) => project.id === projectId) : undefined; - -export const selectThreadById = - (threadId: ThreadId | null | undefined) => - (state: AppState): Thread | undefined => - threadId ? state.threads.find((thread) => thread.id === threadId) : undefined; - -export const selectSidebarThreadSummaryById = - (threadId: ThreadId | null | undefined) => - (state: AppState): SidebarThreadSummary | undefined => - threadId ? state.sidebarThreadsById[threadId] : undefined; - -export const selectThreadIdsByProjectId = - (projectId: ProjectId | null | undefined) => - (state: AppState): ThreadId[] => - projectId ? (state.threadIdsByProjectId[projectId] ?? EMPTY_THREAD_IDS) : EMPTY_THREAD_IDS; +export function reorderProjects( + state: AppState, + draggedProjectId: Project["id"], + targetProjectId: Project["id"], +): AppState { + if (draggedProjectId === targetProjectId) return state; + const draggedIndex = state.projects.findIndex((project) => project.id === draggedProjectId); + const targetIndex = state.projects.findIndex((project) => project.id === targetProjectId); + if (draggedIndex < 0 || targetIndex < 0) return state; + const projects = [...state.projects]; + const [draggedProject] = projects.splice(draggedIndex, 1); + if (!draggedProject) return state; + projects.splice(targetIndex, 0, draggedProject); + return { ...state, projects }; +} export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState { - return updateThreadState(state, threadId, (t) => { + const threads = updateThread(state.threads, threadId, (t) => { if (t.error === error) return t; return { ...t, error }; }); + return threads === state.threads ? state : { ...state, threads }; } export function setThreadBranch( @@ -1128,7 +410,7 @@ export function setThreadBranch( branch: string | null, worktreePath: string | null, ): AppState { - return updateThreadState(state, threadId, (t) => { + const threads = updateThread(state.threads, threadId, (t) => { if (t.branch === branch && t.worktreePath === worktreePath) return t; const cwdChanged = t.worktreePath !== worktreePath; return { @@ -1138,24 +420,51 @@ export function setThreadBranch( ...(cwdChanged ? { session: null } : {}), }; }); + return threads === state.threads ? state : { ...state, threads }; } // ── Zustand store ──────────────────────────────────────────────────── interface AppStore extends AppState { syncServerReadModel: (readModel: OrchestrationReadModel) => void; - applyOrchestrationEvent: (event: OrchestrationEvent) => void; - applyOrchestrationEvents: (events: ReadonlyArray) => void; + markThreadVisited: (threadId: ThreadId, visitedAt?: string) => void; + markThreadUnread: (threadId: ThreadId) => void; + toggleProject: (projectId: Project["id"]) => void; + setProjectExpanded: (projectId: Project["id"], expanded: boolean) => void; + reorderProjects: (draggedProjectId: Project["id"], targetProjectId: Project["id"]) => void; setError: (threadId: ThreadId, error: string | null) => void; setThreadBranch: (threadId: ThreadId, branch: string | null, worktreePath: string | null) => void; } export const useStore = create((set) => ({ - ...initialState, + ...readPersistedState(), syncServerReadModel: (readModel) => set((state) => syncServerReadModel(state, readModel)), - applyOrchestrationEvent: (event) => set((state) => applyOrchestrationEvent(state, event)), - applyOrchestrationEvents: (events) => set((state) => applyOrchestrationEvents(state, events)), + markThreadVisited: (threadId, visitedAt) => + set((state) => markThreadVisited(state, threadId, visitedAt)), + markThreadUnread: (threadId) => set((state) => markThreadUnread(state, threadId)), + toggleProject: (projectId) => set((state) => toggleProject(state, projectId)), + setProjectExpanded: (projectId, expanded) => + set((state) => setProjectExpanded(state, projectId, expanded)), + reorderProjects: (draggedProjectId, targetProjectId) => + set((state) => reorderProjects(state, draggedProjectId, targetProjectId)), setError: (threadId, error) => set((state) => setError(state, threadId, error)), setThreadBranch: (threadId, branch, worktreePath) => set((state) => setThreadBranch(state, threadId, branch, worktreePath)), })); + +// Persist state changes with debouncing to avoid localStorage thrashing +useStore.subscribe((state) => debouncedPersistState.maybeExecute(state)); + +// Flush pending writes synchronously before page unload to prevent data loss. +if (typeof window !== "undefined") { + window.addEventListener("beforeunload", () => { + debouncedPersistState.flush(); + }); +} + +export function StoreProvider({ children }: { children: ReactNode }) { + useEffect(() => { + persistState(useStore.getState()); + }, []); + return createElement(Fragment, null, children); +} diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 0599b9c989..f0dfe0cde9 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -1,9 +1,11 @@ import type { ModelSelection, + OrchestrationMessageSource, OrchestrationLatestTurn, OrchestrationProposedPlanId, OrchestrationSessionStatus, OrchestrationThreadActivity, + ThreadHandoff, ProjectScript as ContractProjectScript, ThreadId, ProjectId, @@ -49,6 +51,7 @@ export interface ChatMessage { createdAt: string; completedAt?: string | undefined; streaming: boolean; + source?: OrchestrationMessageSource; } export interface ProposedPlan { @@ -107,6 +110,7 @@ export interface Thread { pendingSourceProposedPlan?: OrchestrationLatestTurn["sourceProposedPlan"]; branch: string | null; worktreePath: string | null; + handoff?: ThreadHandoff | null; turnDiffSummaries: TurnDiffSummary[]; activities: OrchestrationThreadActivity[]; } diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 6c7f073612..3e135c81c5 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -77,6 +77,10 @@ export const ProviderApprovalDecision = Schema.Literals([ export type ProviderApprovalDecision = typeof ProviderApprovalDecision.Type; export const ProviderUserInputAnswers = Schema.Record(Schema.String, Schema.Unknown); export type ProviderUserInputAnswers = typeof ProviderUserInputAnswers.Type; +export const ThreadHandoffBootstrapStatus = Schema.Literals(["pending", "completed"]); +export type ThreadHandoffBootstrapStatus = typeof ThreadHandoffBootstrapStatus.Type; +export const OrchestrationMessageSource = Schema.Literals(["native", "handoff-import"]); +export type OrchestrationMessageSource = typeof OrchestrationMessageSource.Type; export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000; export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8; @@ -159,11 +163,20 @@ export const OrchestrationMessage = Schema.Struct({ attachments: Schema.optional(Schema.Array(ChatAttachment)), turnId: Schema.NullOr(TurnId), streaming: Schema.Boolean, + source: OrchestrationMessageSource.pipe(Schema.withDecodingDefault(() => "native")), createdAt: IsoDateTime, updatedAt: IsoDateTime, }); export type OrchestrationMessage = typeof OrchestrationMessage.Type; +export const ThreadHandoff = Schema.Struct({ + sourceThreadId: ThreadId, + sourceProvider: ProviderKind, + importedAt: IsoDateTime, + bootstrapStatus: ThreadHandoffBootstrapStatus, +}); +export type ThreadHandoff = typeof ThreadHandoff.Type; + export const OrchestrationProposedPlanId = TrimmedNonEmptyString; export type OrchestrationProposedPlanId = typeof OrchestrationProposedPlanId.Type; @@ -282,6 +295,7 @@ export const OrchestrationThread = Schema.Struct({ updatedAt: IsoDateTime, archivedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(() => null)), deletedAt: Schema.NullOr(IsoDateTime), + handoff: Schema.NullOr(ThreadHandoff).pipe(Schema.withDecodingDefault(() => null)), messages: Schema.Array(OrchestrationMessage), proposedPlans: Schema.Array(OrchestrationProposedPlan).pipe(Schema.withDecodingDefault(() => [])), activities: Schema.Array(OrchestrationThreadActivity), @@ -340,6 +354,34 @@ const ThreadCreateCommand = Schema.Struct({ createdAt: IsoDateTime, }); +export const ThreadHandoffImportedMessage = Schema.Struct({ + messageId: MessageId, + role: Schema.Literals(["user", "assistant"]), + text: Schema.String, + attachments: Schema.optional(Schema.Array(ChatAttachment)), + createdAt: IsoDateTime, + updatedAt: IsoDateTime, +}); +export type ThreadHandoffImportedMessage = typeof ThreadHandoffImportedMessage.Type; + +const ThreadHandoffCreateCommand = Schema.Struct({ + type: Schema.Literal("thread.handoff.create"), + commandId: CommandId, + threadId: ThreadId, + sourceThreadId: ThreadId, + projectId: ProjectId, + title: TrimmedNonEmptyString, + modelSelection: ModelSelection, + runtimeMode: RuntimeMode, + interactionMode: ProviderInteractionMode.pipe( + Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), + ), + branch: Schema.NullOr(TrimmedNonEmptyString), + worktreePath: Schema.NullOr(TrimmedNonEmptyString), + importedMessages: Schema.Array(ThreadHandoffImportedMessage), + createdAt: IsoDateTime, +}); + const ThreadDeleteCommand = Schema.Struct({ type: Schema.Literal("thread.delete"), commandId: CommandId, @@ -366,6 +408,7 @@ const ThreadMetaUpdateCommand = Schema.Struct({ modelSelection: Schema.optional(ModelSelection), branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + handoff: Schema.optional(Schema.NullOr(ThreadHandoff)), }); const ThreadRuntimeModeSetCommand = Schema.Struct({ @@ -495,6 +538,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ProjectMetaUpdateCommand, ProjectDeleteCommand, ThreadCreateCommand, + ThreadHandoffCreateCommand, ThreadDeleteCommand, ThreadArchiveCommand, ThreadUnarchiveCommand, @@ -516,6 +560,7 @@ export const ClientOrchestrationCommand = Schema.Union([ ProjectMetaUpdateCommand, ProjectDeleteCommand, ThreadCreateCommand, + ThreadHandoffCreateCommand, ThreadDeleteCommand, ThreadArchiveCommand, ThreadUnarchiveCommand, @@ -678,6 +723,7 @@ export const ThreadCreatedPayload = Schema.Struct({ ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + handoff: Schema.NullOr(ThreadHandoff).pipe(Schema.withDecodingDefault(() => null)), createdAt: IsoDateTime, updatedAt: IsoDateTime, }); @@ -704,6 +750,7 @@ export const ThreadMetaUpdatedPayload = Schema.Struct({ modelSelection: Schema.optional(ModelSelection), branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + handoff: Schema.optional(Schema.NullOr(ThreadHandoff)), updatedAt: IsoDateTime, }); @@ -729,6 +776,7 @@ export const ThreadMessageSentPayload = Schema.Struct({ attachments: Schema.optional(Schema.Array(ChatAttachment)), turnId: Schema.NullOr(TurnId), streaming: Schema.Boolean, + source: OrchestrationMessageSource.pipe(Schema.withDecodingDefault(() => "native")), createdAt: IsoDateTime, updatedAt: IsoDateTime, }); From e7ba59f590abeaf469619b6eb721e8a0bcc0137b Mon Sep 17 00:00:00 2001 From: Emanuele Di Pietro Date: Sun, 5 Apr 2026 23:28:13 +0200 Subject: [PATCH 2/4] Prevent re-handoff before native chat messages exist --- .../decider.projectScripts.test.ts | 278 ++++++++++++++++++ apps/server/src/orchestration/decider.ts | 7 + apps/server/src/orchestration/handoff.ts | 9 + apps/web/src/components/ChatView.tsx | 1 + apps/web/src/components/Sidebar.tsx | 4 +- apps/web/src/components/chat/ChatHeader.tsx | 25 +- apps/web/src/lib/threadHandoff.ts | 20 +- 7 files changed, 331 insertions(+), 13 deletions(-) diff --git a/apps/server/src/orchestration/decider.projectScripts.test.ts b/apps/server/src/orchestration/decider.projectScripts.test.ts index 14cc9ec0e0..06728aa93e 100644 --- a/apps/server/src/orchestration/decider.projectScripts.test.ts +++ b/apps/server/src/orchestration/decider.projectScripts.test.ts @@ -369,4 +369,282 @@ describe("decider project scripts", () => { }, }); }); + + it("rejects re-handoff when the source handoff thread has no native chat messages yet", async () => { + const now = new Date().toISOString(); + const initial = createEmptyReadModel(now); + const withProject = await Effect.runPromise( + projectEvent(initial, { + sequence: 1, + eventId: asEventId("evt-project-create-handoff"), + aggregateKind: "project", + aggregateId: asProjectId("project-handoff"), + type: "project.created", + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-project-create-handoff"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-project-create-handoff"), + metadata: {}, + payload: { + projectId: asProjectId("project-handoff"), + title: "Project", + workspaceRoot: "/tmp/project", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }), + ); + const withThread = await Effect.runPromise( + projectEvent(withProject, { + sequence: 2, + eventId: asEventId("evt-thread-create-handoff"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-handoff"), + type: "thread.created", + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-thread-create-handoff"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-thread-create-handoff"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-handoff"), + projectId: asProjectId("project-handoff"), + title: "Handoff", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + handoff: { + sourceThreadId: ThreadId.makeUnsafe("thread-original"), + sourceProvider: "claudeAgent", + importedAt: now, + bootstrapStatus: "pending", + }, + createdAt: now, + updatedAt: now, + }, + }), + ); + const readModel = await Effect.runPromise( + projectEvent(withThread, { + sequence: 3, + eventId: asEventId("evt-thread-imported-message"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-handoff"), + type: "thread.message-sent", + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-thread-imported-message"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-thread-imported-message"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-handoff"), + messageId: asMessageId("message-imported-1"), + role: "user", + text: "Imported history", + turnId: null, + streaming: false, + source: "handoff-import", + createdAt: now, + updatedAt: now, + }, + }), + ); + + await expect( + Effect.runPromise( + decideOrchestrationCommand({ + command: { + type: "thread.handoff.create", + commandId: CommandId.makeUnsafe("cmd-thread-rehandoff"), + threadId: ThreadId.makeUnsafe("thread-handoff-copy"), + sourceThreadId: ThreadId.makeUnsafe("thread-handoff"), + projectId: asProjectId("project-handoff"), + title: "Handoff Copy", + modelSelection: { + provider: "claudeAgent", + model: "sonnet", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + importedMessages: [ + { + messageId: asMessageId("message-imported-2"), + role: "user", + text: "Imported history", + createdAt: now, + updatedAt: now, + }, + ], + createdAt: now, + }, + readModel, + }), + ), + ).rejects.toThrow("must contain at least one native chat message after handoff"); + }); + + it("allows re-handoff after the handoff thread has native chat messages", async () => { + const now = new Date().toISOString(); + const initial = createEmptyReadModel(now); + const withProject = await Effect.runPromise( + projectEvent(initial, { + sequence: 1, + eventId: asEventId("evt-project-create-native-handoff"), + aggregateKind: "project", + aggregateId: asProjectId("project-native-handoff"), + type: "project.created", + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-project-create-native-handoff"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-project-create-native-handoff"), + metadata: {}, + payload: { + projectId: asProjectId("project-native-handoff"), + title: "Project", + workspaceRoot: "/tmp/project", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }), + ); + const withThread = await Effect.runPromise( + projectEvent(withProject, { + sequence: 2, + eventId: asEventId("evt-thread-create-native-handoff"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-native-handoff"), + type: "thread.created", + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-thread-create-native-handoff"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-thread-create-native-handoff"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-native-handoff"), + projectId: asProjectId("project-native-handoff"), + title: "Handoff", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + handoff: { + sourceThreadId: ThreadId.makeUnsafe("thread-original"), + sourceProvider: "claudeAgent", + importedAt: now, + bootstrapStatus: "completed", + }, + createdAt: now, + updatedAt: now, + }, + }), + ); + const withImportedMessage = await Effect.runPromise( + projectEvent(withThread, { + sequence: 3, + eventId: asEventId("evt-thread-native-imported-message"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-native-handoff"), + type: "thread.message-sent", + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-thread-native-imported-message"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-thread-native-imported-message"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-native-handoff"), + messageId: asMessageId("message-native-imported-1"), + role: "user", + text: "Imported history", + turnId: null, + streaming: false, + source: "handoff-import", + createdAt: now, + updatedAt: now, + }, + }), + ); + const readModel = await Effect.runPromise( + projectEvent(withImportedMessage, { + sequence: 4, + eventId: asEventId("evt-thread-native-user-message"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-native-handoff"), + type: "thread.message-sent", + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-thread-native-user-message"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-thread-native-user-message"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-native-handoff"), + messageId: asMessageId("message-native-user-1"), + role: "user", + text: "A real new follow-up", + turnId: null, + streaming: false, + source: "native", + createdAt: now, + updatedAt: now, + }, + }), + ); + + const result = await Effect.runPromise( + decideOrchestrationCommand({ + command: { + type: "thread.handoff.create", + commandId: CommandId.makeUnsafe("cmd-thread-native-rehandoff"), + threadId: ThreadId.makeUnsafe("thread-native-handoff-copy"), + sourceThreadId: ThreadId.makeUnsafe("thread-native-handoff"), + projectId: asProjectId("project-native-handoff"), + title: "Handoff Copy", + modelSelection: { + provider: "claudeAgent", + model: "sonnet", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + importedMessages: [ + { + messageId: asMessageId("message-native-imported-2"), + role: "user", + text: "Imported history", + createdAt: now, + updatedAt: now, + }, + { + messageId: asMessageId("message-native-imported-3"), + role: "user", + text: "A real new follow-up", + createdAt: now, + updatedAt: now, + }, + ], + createdAt: now, + }, + readModel, + }), + ); + + const events = Array.isArray(result) ? result : [result]; + expect(events[0]?.type).toBe("thread.created"); + expect(events[1]?.type).toBe("thread.message-sent"); + }); }); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 3897095593..2c073b2b95 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -6,6 +6,7 @@ import type { import { Effect } from "effect"; import { OrchestrationCommandInvariantError } from "./Errors.ts"; +import { hasNativeHandoffMessages } from "./handoff.ts"; import { requireProject, requireProjectAbsent, @@ -198,6 +199,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" detail: `Source thread '${command.sourceThreadId}' belongs to a different project.`, }); } + if (sourceThread.handoff !== null && !hasNativeHandoffMessages(sourceThread)) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Source thread '${command.sourceThreadId}' must contain at least one native chat message after handoff before it can be handed off again.`, + }); + } const createdEvent: Omit = { ...withEventBase({ diff --git a/apps/server/src/orchestration/handoff.ts b/apps/server/src/orchestration/handoff.ts index d87ae0a505..0e4d87ebad 100644 --- a/apps/server/src/orchestration/handoff.ts +++ b/apps/server/src/orchestration/handoff.ts @@ -38,6 +38,15 @@ export function listImportedHandoffMessages( ); } +export function hasNativeHandoffMessages(thread: Pick): boolean { + return thread.messages.some( + (message) => + (message.role === "user" || message.role === "assistant") && + message.source !== "handoff-import" && + message.streaming === false, + ); +} + export function hasNativeAssistantMessagesBefore( thread: Pick, currentMessageId: string, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b1809b343b..0c805f42ba 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3959,6 +3959,7 @@ export default function ChatView({ threadId }: ChatViewProps) { handoffBadgeLabel={handoffBadgeLabel} handoffActionLabel={handoffActionLabel} handoffDisabled={handoffDisabled} + handoffActionTargetProvider={handoffTargetProvider} handoffBadgeSourceProvider={handoffBadgeSourceProvider} handoffBadgeTargetProvider={handoffBadgeTargetProvider} browserOpen={browserOpen} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index b4c9c2551e..78453a956b 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -11,8 +11,8 @@ import { TriangleAlertIcon, } from "~/lib/icons"; import { autoAnimate } from "@formkit/auto-animate"; +import { FiGitBranch } from "react-icons/fi"; import { IoFolderOutline } from "react-icons/io5"; -import { VscRepoForked } from "react-icons/vsc"; import { HiOutlineFolderOpen } from "react-icons/hi2"; import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from "react"; import { @@ -1364,7 +1364,7 @@ export default function Sidebar() { - + } /> diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 55350901f0..76f1b33ef6 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -6,12 +6,13 @@ import { type EditorId, type ProjectScript, + PROVIDER_DISPLAY_NAMES, type ProviderKind, type ResolvedKeybindingsConfig, type ThreadId, } from "@t3tools/contracts"; import React, { memo, useEffect, useRef, useState } from "react"; -import { VscRepoForked } from "react-icons/vsc"; +import { FiGitBranch } from "react-icons/fi"; import GitActionsControl from "../GitActionsControl"; import { ArrowRightIcon, @@ -63,6 +64,7 @@ interface ChatHeaderProps { handoffBadgeLabel: string | null; handoffActionLabel: string; handoffDisabled: boolean; + handoffActionTargetProvider: ProviderKind | null; handoffBadgeSourceProvider: ProviderKind | null; handoffBadgeTargetProvider: ProviderKind | null; browserOpen: boolean; @@ -96,6 +98,7 @@ export const ChatHeader = memo(function ChatHeader({ handoffBadgeLabel, handoffActionLabel, handoffDisabled, + handoffActionTargetProvider, handoffBadgeSourceProvider, handoffBadgeTargetProvider, browserOpen, @@ -137,7 +140,7 @@ export const ChatHeader = memo(function ChatHeader({ if (provider === "codex") { return ; } - return ; + return ; }; return ( @@ -164,14 +167,14 @@ export const ChatHeader = memo(function ChatHeader({ render={ - - {renderProviderIcon(handoffBadgeSourceProvider, "size-3.5 shrink-0")} + + {renderProviderIcon(handoffBadgeSourceProvider, "size-3")} - - {renderProviderIcon(handoffBadgeTargetProvider, "size-3.5 shrink-0")} + + {renderProviderIcon(handoffBadgeTargetProvider, "size-3")} } @@ -194,8 +197,12 @@ export const ChatHeader = memo(function ChatHeader({ disabled={handoffDisabled} onClick={onCreateHandoff} > - - {handoffActionLabel} + + Hand off to + {renderProviderIcon(handoffActionTargetProvider, "size-3.5 shrink-0")} + + {PROVIDER_DISPLAY_NAMES[handoffActionTargetProvider ?? "codex"]} + } /> diff --git a/apps/web/src/lib/threadHandoff.ts b/apps/web/src/lib/threadHandoff.ts index 8c8fa7b7ee..2e99a53020 100644 --- a/apps/web/src/lib/threadHandoff.ts +++ b/apps/web/src/lib/threadHandoff.ts @@ -53,8 +53,17 @@ export function buildThreadHandoffImportedMessages( }); } +export function hasNativeThreadHandoffMessages(thread: Pick): boolean { + return thread.messages.some( + (message) => + (message.role === "user" || message.role === "assistant") && + message.source !== "handoff-import" && + message.streaming === false, + ); +} + export function canCreateThreadHandoff(input: { - readonly thread: Pick; + readonly thread: Pick; readonly isBusy?: boolean; readonly hasPendingApprovals?: boolean; readonly hasPendingUserInput?: boolean; @@ -66,7 +75,14 @@ export function canCreateThreadHandoff(input: { if (sessionStatus === "starting" || sessionStatus === "running") { return false; } - return buildThreadHandoffImportedMessages(input.thread).length > 0; + const importedMessages = buildThreadHandoffImportedMessages(input.thread); + if (importedMessages.length === 0) { + return false; + } + if (input.thread.handoff !== null) { + return hasNativeThreadHandoffMessages(input.thread); + } + return true; } export function resolveThreadHandoffModelSelection(input: { From c5cea3fe3c474aee801bea3d2e22077321f079a4 Mon Sep 17 00:00:00 2001 From: Emanuele Di Pietro Date: Mon, 6 Apr 2026 01:13:12 +0200 Subject: [PATCH 3/4] Add visible chat shortcuts and Claude skill discovery --- apps/server/src/keybindings.ts | 69 +- .../src/provider/Layers/ClaudeAdapter.test.ts | 6 + .../src/provider/Layers/ClaudeAdapter.ts | 4175 +++++++++-------- apps/web/src/components/ChatView.tsx | 32 +- .../src/components/ComposerPromptEditor.tsx | 138 +- apps/web/src/components/Sidebar.logic.test.ts | 107 + apps/web/src/components/Sidebar.logic.ts | 384 +- apps/web/src/components/Sidebar.tsx | 147 +- .../components/chat/ComposerCommandMenu.tsx | 108 +- apps/web/src/composer-editor-mentions.test.ts | 32 + apps/web/src/composer-editor-mentions.ts | 79 +- apps/web/src/composer-logic.test.ts | 9 + apps/web/src/composer-logic.ts | 9 +- apps/web/src/keybindings.test.ts | 380 +- apps/web/src/keybindings.ts | 2 + apps/web/src/lib/icons.tsx | 140 + packages/contracts/src/keybindings.test.ts | 69 +- packages/contracts/src/keybindings.ts | 58 +- 18 files changed, 3352 insertions(+), 2592 deletions(-) create mode 100644 apps/web/src/lib/icons.tsx diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 086d795c0c..5fdf00865b 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -9,14 +9,12 @@ import { KeybindingRule, KeybindingsConfig, - KeybindingsConfigError, KeybindingShortcut, KeybindingWhenNode, MAX_KEYBINDINGS_COUNT, MAX_WHEN_EXPRESSION_DEPTH, ResolvedKeybindingRule, ResolvedKeybindingsConfig, - THREAD_JUMP_KEYBINDING_COMMANDS, type ServerConfigIssue, } from "@t3tools/contracts"; import { Mutable } from "effect/Types"; @@ -25,7 +23,6 @@ import { Cache, Cause, Deferred, - Duration, Effect, Exit, FileSystem, @@ -45,7 +42,19 @@ import { } from "effect"; import * as Semaphore from "effect/Semaphore"; import { ServerConfig } from "./config"; -import { fromLenientJson } from "@t3tools/shared/schemaJson"; + +export class KeybindingsConfigError extends Schema.TaggedErrorClass()( + "KeybindingsConfigParseError", + { + configPath: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Unable to parse keybindings config at ${this.configPath}: ${this.detail}`; + } +} type WhenToken = | { type: "identifier"; value: string } @@ -56,21 +65,25 @@ type WhenToken = | { type: "rparen" }; export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ + { key: "mod+b", command: "sidebar.toggle", when: "!terminalFocus" }, { key: "mod+j", command: "terminal.toggle" }, { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, - { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, + // Keep Cmd/Ctrl+N available for new chats even while the terminal is focused. + { key: "mod+shift+n", command: "terminal.new", when: "terminalFocus" }, { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, + { key: "mod+shift+j", command: "terminal.workspace.newFullWidth" }, + { key: "mod+w", command: "terminal.workspace.closeActive", when: "terminalWorkspaceOpen" }, + { key: "mod+1", command: "terminal.workspace.terminal", when: "terminalWorkspaceOpen" }, + { key: "mod+2", command: "terminal.workspace.chat", when: "terminalWorkspaceOpen" }, + { key: "mod+shift+b", command: "browser.toggle", when: "!terminalFocus" }, { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" }, - { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, + { key: "mod+n", command: "chat.new" }, { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, + { key: "mod+shift+t", command: "chat.newTerminal", when: "!terminalFocus" }, + { key: "mod+shift+]", command: "chat.visible.next", when: "!terminalFocus" }, + { key: "mod+shift+[", command: "chat.visible.previous", when: "!terminalFocus" }, { key: "mod+o", command: "editor.openFavorite" }, - { key: "mod+shift+[", command: "thread.previous" }, - { key: "mod+shift+]", command: "thread.next" }, - ...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({ - key: `mod+${index + 1}`, - command, - })), ]; function normalizeKeyToken(token: string): string { @@ -405,7 +418,7 @@ function encodeWhenAst(node: KeybindingWhenNode): string { const DEFAULT_RESOLVED_KEYBINDINGS = compileResolvedKeybindingsConfig(DEFAULT_KEYBINDINGS); -const RawKeybindingsEntries = fromLenientJson(Schema.Array(Schema.Unknown)); +const RawKeybindingsEntries = Schema.fromJsonString(Schema.Array(Schema.Unknown)); const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig); const PrettyJsonString = SchemaGetter.parseJson().compose( SchemaGetter.stringifyJson({ space: 2 }), @@ -669,7 +682,6 @@ const makeKeybindings = Effect.gen(function* () { Effect.tap(() => fs.makeDirectory(path.dirname(keybindingsConfigPath), { recursive: true })), Effect.tap((encoded) => fs.writeFileString(tempPath, encoded)), Effect.flatMap(() => fs.rename(tempPath, keybindingsConfigPath)), - Effect.ensuring(fs.remove(tempPath, { force: true }).pipe(Effect.ignore({ log: true }))), Effect.mapError( (cause) => new KeybindingsConfigError({ @@ -815,25 +827,16 @@ const makeKeybindings = Effect.gen(function* () { const revalidateAndEmitSafely = revalidateAndEmit.pipe(Effect.ignoreCause({ log: true })); - // Debounce watch events so the file is fully written before we read it. - // Editors emit multiple events per save (truncate, write, rename) and - // `fs.watch` can fire before the content has been flushed to disk. - const debouncedKeybindingsEvents = fs.watch(keybindingsConfigDir).pipe( - Stream.filter((event) => { - return ( - event.path === keybindingsConfigFile || - event.path === keybindingsConfigPath || - path.resolve(keybindingsConfigDir, event.path) === keybindingsConfigPathResolved - ); - }), - Stream.debounce(Duration.millis(100)), - ); - - yield* Stream.runForEach(debouncedKeybindingsEvents, () => revalidateAndEmitSafely).pipe( - Effect.ignoreCause({ log: true }), - Effect.forkIn(watcherScope), - Effect.asVoid, - ); + yield* Stream.runForEach(fs.watch(keybindingsConfigDir), (event) => { + const isTargetConfigEvent = + event.path === keybindingsConfigFile || + event.path === keybindingsConfigPath || + path.resolve(keybindingsConfigDir, event.path) === keybindingsConfigPathResolved; + if (!isTargetConfigEvent) { + return Effect.void; + } + return revalidateAndEmitSafely; + }).pipe(Effect.ignoreCause({ log: true }), Effect.forkIn(watcherScope), Effect.asVoid); }); const start = Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 5a09d8b6ba..69d1712654 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -91,6 +91,12 @@ class FakeClaudeQuery implements AsyncIterable { this.setMaxThinkingTokensCalls.push(maxThinkingTokens); }; + readonly supportedCommands = async (): Promise< + Array<{ name: string; description: string; argumentHint: string }> + > => { + return []; + }; + readonly close = (): void => { this.closeCalls += 1; this.finish(); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 9f2eeb014e..b5f9a8321c 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -17,6 +17,7 @@ import { type SDKResultMessage, type SettingSource, type SDKUserMessage, + type SlashCommand, } from "@anthropic-ai/claude-agent-sdk"; import { ApprovalRequestId, @@ -39,11 +40,14 @@ import { TurnId, type UserInputQuestion, ClaudeCodeEffort, + type ProviderComposerCapabilities, + type ProviderListSkillsInput, + type ProviderListSkillsResult, } from "@t3tools/contracts"; import { + hasEffortLevel, applyClaudePromptEffortPrefix, - resolveApiModelId, - resolveEffort, + getModelCapabilities, trimOrNull, } from "@t3tools/shared/model"; import { @@ -63,8 +67,6 @@ import { import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; -import { getClaudeModelCapabilities } from "./ClaudeProvider.ts"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, @@ -148,7 +150,6 @@ interface ClaudeSessionContext { streamFiber: Fiber.Fiber | undefined; readonly startedAt: string; readonly basePermissionMode: PermissionMode | undefined; - currentApiModelId: string | undefined; resumeSessionId: string | undefined; readonly pendingApprovals: Map; readonly pendingUserInputs: Map; @@ -170,6 +171,7 @@ interface ClaudeQueryRuntime extends AsyncIterable { readonly setModel: (model?: string) => Promise; readonly setPermissionMode: (mode: PermissionMode) => Promise; readonly setMaxThinkingTokens: (maxThinkingTokens: number | null) => Promise; + readonly supportedCommands: () => Promise; readonly close: () => void; } @@ -356,6 +358,19 @@ function asRuntimeRequestId(value: ApprovalRequestId): RuntimeRequestId { return RuntimeRequestId.makeUnsafe(value); } +function toPermissionMode(value: unknown): PermissionMode | undefined { + switch (value) { + case "default": + case "acceptEdits": + case "bypassPermissions": + case "plan": + case "dontAsk": + return value; + default: + return undefined; + } +} + function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undefined { if (!resumeCursor || typeof resumeCursor !== "object") { return undefined; @@ -512,15 +527,16 @@ const CLAUDE_SETTING_SOURCES = [ function buildPromptText(input: ProviderSendTurnInput): string { const rawEffort = input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.options?.effort : null; + const requestedEffort = trimOrNull(rawEffort); const claudeModel = input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.model : undefined; - const caps = getClaudeModelCapabilities(claudeModel); - - // For prompt injection, we check if the raw effort is a prompt-injected level (e.g. "ultrathink"). - // resolveEffort strips prompt-injected values (returning the default instead), so we check the raw value directly. - const trimmedEffort = trimOrNull(rawEffort); + const caps = getModelCapabilities("claudeAgent", claudeModel); const promptEffort = - trimmedEffort && caps.promptInjectedEffortLevels.includes(trimmedEffort) ? trimmedEffort : null; + requestedEffort === "ultrathink" && caps.reasoningEffortLevels.length > 0 + ? "ultrathink" + : requestedEffort && hasEffortLevel(caps, requestedEffort) + ? requestedEffort + : null; return applyClaudePromptEffortPrefix(input.input?.trim() ?? "", promptEffort); } @@ -533,7 +549,7 @@ function buildUserMessage(input: { parent_tool_use_id: null, message: { role: "user", - content: input.sdkContent as unknown as SDKUserMessage["message"]["content"], + content: input.sdkContent, }, } as SDKUserMessage; } @@ -552,67 +568,69 @@ function buildClaudeImageContentBlock(input: { }; } -const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* ( +function buildUserMessageEffect( input: ProviderSendTurnInput, dependencies: { readonly fileSystem: FileSystem.FileSystem; readonly attachmentsDir: string; }, -) { - const text = buildPromptText(input); - const sdkContent: Array> = []; - - if (text.length > 0) { - sdkContent.push({ type: "text", text }); - } +): Effect.Effect { + return Effect.gen(function* () { + const text = buildPromptText(input); + const sdkContent: Array> = []; - for (const attachment of input.attachments ?? []) { - if (attachment.type !== "image") { - continue; + if (text.length > 0) { + sdkContent.push({ type: "text", text }); } - if (!SUPPORTED_CLAUDE_IMAGE_MIME_TYPES.has(attachment.mimeType)) { - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "turn/start", - detail: `Unsupported Claude image attachment type '${attachment.mimeType}'.`, - }); - } + for (const attachment of input.attachments ?? []) { + if (attachment.type !== "image") { + continue; + } - const attachmentPath = resolveAttachmentPath({ - attachmentsDir: dependencies.attachmentsDir, - attachment, - }); - if (!attachmentPath) { - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "turn/start", - detail: `Invalid attachment id '${attachment.id}'.`, + if (!SUPPORTED_CLAUDE_IMAGE_MIME_TYPES.has(attachment.mimeType)) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "turn/start", + detail: `Unsupported Claude image attachment type '${attachment.mimeType}'.`, + }); + } + + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: dependencies.attachmentsDir, + attachment, }); - } + if (!attachmentPath) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "turn/start", + detail: `Invalid attachment id '${attachment.id}'.`, + }); + } - const bytes = yield* dependencies.fileSystem.readFile(attachmentPath).pipe( - Effect.mapError( - (cause) => - new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "turn/start", - detail: toMessage(cause, "Failed to read attachment file."), - cause, - }), - ), - ); + const bytes = yield* dependencies.fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "turn/start", + detail: toMessage(cause, "Failed to read attachment file."), + cause, + }), + ), + ); - sdkContent.push( - buildClaudeImageContentBlock({ - mimeType: attachment.mimeType, - bytes, - }), - ); - } + sdkContent.push( + buildClaudeImageContentBlock({ + mimeType: attachment.mimeType, + bytes, + }), + ); + } - return buildUserMessage({ sdkContent }); -}); + return buildUserMessage({ sdkContent }); + }); +} function turnStatusFromResult(result: SDKResultMessage): ProviderRuntimeTurnStatus { if (result.subtype === "success") { @@ -909,2156 +927,2307 @@ function sdkNativeItemId(message: SDKMessage): string | undefined { return undefined; } -const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( - options?: ClaudeAdapterLiveOptions, -) { - const fileSystem = yield* FileSystem.FileSystem; - const serverConfig = yield* ServerConfig; - const nativeEventLogger = - options?.nativeEventLogger ?? - (options?.nativeEventLogPath !== undefined - ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { - stream: "native", - }) - : undefined); - - const createQuery = - options?.createQuery ?? - ((input: { - readonly prompt: AsyncIterable; - readonly options: ClaudeQueryOptions; - }) => query({ prompt: input.prompt, options: input.options }) as ClaudeQueryRuntime); - - const sessions = new Map(); - const runtimeEventQueue = yield* Queue.unbounded(); - const serverSettingsService = yield* ServerSettingsService; - - const nowIso = Effect.map(DateTime.now, DateTime.formatIso); - const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.makeUnsafe(id)); - const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); - - const offerRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect => - Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid); - - const logNativeSdkMessage = Effect.fn("logNativeSdkMessage")(function* ( - context: ClaudeSessionContext, - message: SDKMessage, - ) { - if (!nativeEventLogger) { - return; - } +function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const serverConfig = yield* ServerConfig; + const nativeEventLogger = + options?.nativeEventLogger ?? + (options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { + stream: "native", + }) + : undefined); + + const createQuery = + options?.createQuery ?? + ((input: { + readonly prompt: AsyncIterable; + readonly options: ClaudeQueryOptions; + }) => query({ prompt: input.prompt, options: input.options }) as ClaudeQueryRuntime); + + const sessions = new Map(); + const runtimeEventQueue = yield* Queue.unbounded(); + + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.makeUnsafe(id)); + const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + + const offerRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect => + Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid); + + const logNativeSdkMessage = ( + context: ClaudeSessionContext, + message: SDKMessage, + ): Effect.Effect => + Effect.gen(function* () { + if (!nativeEventLogger) { + return; + } - const observedAt = new Date().toISOString(); - const itemId = sdkNativeItemId(message); + const observedAt = new Date().toISOString(); + const itemId = sdkNativeItemId(message); + + yield* nativeEventLogger.write( + { + observedAt, + event: { + id: + "uuid" in message && typeof message.uuid === "string" + ? message.uuid + : crypto.randomUUID(), + kind: "notification", + provider: PROVIDER, + createdAt: observedAt, + method: sdkNativeMethod(message), + ...(typeof message.session_id === "string" + ? { providerThreadId: message.session_id } + : {}), + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + ...(itemId ? { itemId: ProviderItemId.makeUnsafe(itemId) } : {}), + payload: message, + }, + }, + context.session.threadId, + ); + }); - yield* nativeEventLogger.write( + const snapshotThread = ( + context: ClaudeSessionContext, + ): Effect.Effect< { - observedAt, - event: { - id: - "uuid" in message && typeof message.uuid === "string" - ? message.uuid - : crypto.randomUUID(), - kind: "notification", - provider: PROVIDER, - createdAt: observedAt, - method: sdkNativeMethod(message), - ...(typeof message.session_id === "string" - ? { providerThreadId: message.session_id } - : {}), - ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), - ...(itemId ? { itemId: ProviderItemId.makeUnsafe(itemId) } : {}), - payload: message, - }, + threadId: ThreadId; + turns: ReadonlyArray<{ + id: TurnId; + items: ReadonlyArray; + }>; }, - context.session.threadId, - ); - }); - - const snapshotThread = Effect.fn("snapshotThread")(function* (context: ClaudeSessionContext) { - const threadId = context.session.threadId; - if (!threadId) { - return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "readThread", - issue: "Session thread id is not initialized yet.", - }); - } - return { - threadId, - turns: context.turns.map((turn) => ({ - id: turn.id, - items: [...turn.items], - })), - }; - }); - - const updateResumeCursor = Effect.fn("updateResumeCursor")(function* ( - context: ClaudeSessionContext, - ) { - const threadId = context.session.threadId; - if (!threadId) return; - - const resumeCursor = { - threadId, - ...(context.resumeSessionId ? { resume: context.resumeSessionId } : {}), - ...(context.lastAssistantUuid ? { resumeSessionAt: context.lastAssistantUuid } : {}), - turnCount: context.turns.length, - }; - - context.session = { - ...context.session, - resumeCursor, - updatedAt: yield* nowIso, - }; - }); - - const ensureAssistantTextBlock = Effect.fn("ensureAssistantTextBlock")(function* ( - context: ClaudeSessionContext, - blockIndex: number, - options?: { - readonly fallbackText?: string; - readonly streamClosed?: boolean; - }, - ) { - const turnState = context.turnState; - if (!turnState) { - return undefined; - } - - const existing = turnState.assistantTextBlocks.get(blockIndex); - if (existing && !existing.completionEmitted) { - if (existing.fallbackText.length === 0 && options?.fallbackText) { - existing.fallbackText = options.fallbackText; - } - if (options?.streamClosed) { - existing.streamClosed = true; - } - return { blockIndex, block: existing }; - } - - const block: AssistantTextBlockState = { - itemId: yield* Random.nextUUIDv4, - blockIndex, - emittedTextDelta: false, - fallbackText: options?.fallbackText ?? "", - streamClosed: options?.streamClosed ?? false, - completionEmitted: false, - }; - turnState.assistantTextBlocks.set(blockIndex, block); - turnState.assistantTextBlockOrder.push(block); - return { blockIndex, block }; - }); - - const createSyntheticAssistantTextBlock = Effect.fn("createSyntheticAssistantTextBlock")( - function* (context: ClaudeSessionContext, fallbackText: string) { - const turnState = context.turnState; - if (!turnState) { - return undefined; - } - - const blockIndex = turnState.nextSyntheticAssistantBlockIndex; - turnState.nextSyntheticAssistantBlockIndex -= 1; - return yield* ensureAssistantTextBlock(context, blockIndex, { - fallbackText, - streamClosed: true, + ProviderAdapterValidationError + > => + Effect.gen(function* () { + const threadId = context.session.threadId; + if (!threadId) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "readThread", + issue: "Session thread id is not initialized yet.", + }); + } + return { + threadId, + turns: context.turns.map((turn) => ({ + id: turn.id, + items: [...turn.items], + })), + }; }); - }, - ); - const completeAssistantTextBlock = Effect.fn("completeAssistantTextBlock")(function* ( - context: ClaudeSessionContext, - block: AssistantTextBlockState, - options?: { - readonly force?: boolean; - readonly rawMethod?: string; - readonly rawPayload?: unknown; - }, - ) { - const turnState = context.turnState; - if (!turnState || block.completionEmitted) { - return; - } + const updateResumeCursor = (context: ClaudeSessionContext): Effect.Effect => + Effect.gen(function* () { + const threadId = context.session.threadId; + if (!threadId) return; - if (!options?.force && !block.streamClosed) { - return; - } + const resumeCursor = { + threadId, + ...(context.resumeSessionId ? { resume: context.resumeSessionId } : {}), + ...(context.lastAssistantUuid ? { resumeSessionAt: context.lastAssistantUuid } : {}), + turnCount: context.turns.length, + }; - if (!block.emittedTextDelta && block.fallbackText.length > 0) { - const deltaStamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "content.delta", - eventId: deltaStamp.eventId, - provider: PROVIDER, - createdAt: deltaStamp.createdAt, - threadId: context.session.threadId, - turnId: turnState.turnId, - itemId: asRuntimeItemId(block.itemId), - payload: { - streamKind: "assistant_text", - delta: block.fallbackText, - }, - providerRefs: nativeProviderRefs(context), - ...(options?.rawMethod || options?.rawPayload - ? { - raw: { - source: "claude.sdk.message" as const, - ...(options.rawMethod ? { method: options.rawMethod } : {}), - payload: options?.rawPayload, - }, - } - : {}), + context.session = { + ...context.session, + resumeCursor, + updatedAt: yield* nowIso, + }; }); - } - block.completionEmitted = true; - if (turnState.assistantTextBlocks.get(block.blockIndex) === block) { - turnState.assistantTextBlocks.delete(block.blockIndex); - } - - const stamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "item.completed", - eventId: stamp.eventId, - provider: PROVIDER, - createdAt: stamp.createdAt, - itemId: asRuntimeItemId(block.itemId), - threadId: context.session.threadId, - turnId: turnState.turnId, - payload: { - itemType: "assistant_message", - status: "completed", - title: "Assistant message", - ...(block.fallbackText.length > 0 ? { detail: block.fallbackText } : {}), + const ensureAssistantTextBlock = ( + context: ClaudeSessionContext, + blockIndex: number, + options?: { + readonly fallbackText?: string; + readonly streamClosed?: boolean; }, - providerRefs: nativeProviderRefs(context), - ...(options?.rawMethod || options?.rawPayload - ? { - raw: { - source: "claude.sdk.message" as const, - ...(options.rawMethod ? { method: options.rawMethod } : {}), - payload: options?.rawPayload, - }, - } - : {}), - }); - }); - - const backfillAssistantTextBlocksFromSnapshot = Effect.fn( - "backfillAssistantTextBlocksFromSnapshot", - )(function* (context: ClaudeSessionContext, message: SDKMessage) { - const turnState = context.turnState; - if (!turnState) { - return; - } + ): Effect.Effect< + | { + readonly blockIndex: number; + readonly block: AssistantTextBlockState; + } + | undefined + > => + Effect.gen(function* () { + const turnState = context.turnState; + if (!turnState) { + return undefined; + } - const snapshotTextBlocks = extractAssistantTextBlocks(message); - if (snapshotTextBlocks.length === 0) { - return; - } + const existing = turnState.assistantTextBlocks.get(blockIndex); + if (existing && !existing.completionEmitted) { + if (existing.fallbackText.length === 0 && options?.fallbackText) { + existing.fallbackText = options.fallbackText; + } + if (options?.streamClosed) { + existing.streamClosed = true; + } + return { blockIndex, block: existing }; + } - const orderedBlocks = turnState.assistantTextBlockOrder.map((block) => ({ - blockIndex: block.blockIndex, - block, - })); - - for (const [position, text] of snapshotTextBlocks.entries()) { - const existingEntry = orderedBlocks[position]; - const entry = - existingEntry ?? - (yield* createSyntheticAssistantTextBlock(context, text).pipe( - Effect.map((created) => { - if (!created) { - return undefined; - } - orderedBlocks.push(created); - return created; - }), - )); - if (!entry) { - continue; - } + const block: AssistantTextBlockState = { + itemId: yield* Random.nextUUIDv4, + blockIndex, + emittedTextDelta: false, + fallbackText: options?.fallbackText ?? "", + streamClosed: options?.streamClosed ?? false, + completionEmitted: false, + }; + turnState.assistantTextBlocks.set(blockIndex, block); + turnState.assistantTextBlockOrder.push(block); + return { blockIndex, block }; + }); - if (entry.block.fallbackText.length === 0) { - entry.block.fallbackText = text; - } + const createSyntheticAssistantTextBlock = ( + context: ClaudeSessionContext, + fallbackText: string, + ): Effect.Effect< + | { + readonly blockIndex: number; + readonly block: AssistantTextBlockState; + } + | undefined + > => + Effect.gen(function* () { + const turnState = context.turnState; + if (!turnState) { + return undefined; + } - if (entry.block.streamClosed && !entry.block.completionEmitted) { - yield* completeAssistantTextBlock(context, entry.block, { - rawMethod: "claude/assistant", - rawPayload: message, + const blockIndex = turnState.nextSyntheticAssistantBlockIndex; + turnState.nextSyntheticAssistantBlockIndex -= 1; + return yield* ensureAssistantTextBlock(context, blockIndex, { + fallbackText, + streamClosed: true, }); - } - } - }); - - const ensureThreadId = Effect.fn("ensureThreadId")(function* ( - context: ClaudeSessionContext, - message: SDKMessage, - ) { - if (typeof message.session_id !== "string" || message.session_id.length === 0) { - return; - } - const nextThreadId = message.session_id; - context.resumeSessionId = message.session_id; - yield* updateResumeCursor(context); - - if (context.lastThreadStartedId !== nextThreadId) { - context.lastThreadStartedId = nextThreadId; - const stamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "thread.started", - eventId: stamp.eventId, - provider: PROVIDER, - createdAt: stamp.createdAt, - threadId: context.session.threadId, - payload: { - providerThreadId: nextThreadId, - }, - providerRefs: {}, - raw: { - source: "claude.sdk.message", - method: "claude/thread/started", - payload: { - session_id: message.session_id, - }, - }, }); - } - }); - - const emitRuntimeError = Effect.fn("emitRuntimeError")(function* ( - context: ClaudeSessionContext, - message: string, - cause?: unknown, - ) { - if (cause !== undefined) { - void cause; - } - const turnState = context.turnState; - const stamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "runtime.error", - eventId: stamp.eventId, - provider: PROVIDER, - createdAt: stamp.createdAt, - threadId: context.session.threadId, - ...(turnState ? { turnId: asCanonicalTurnId(turnState.turnId) } : {}), - payload: { - message, - class: "provider_error", - ...(cause !== undefined ? { detail: cause } : {}), - }, - providerRefs: nativeProviderRefs(context), - }); - }); - const emitRuntimeWarning = Effect.fn("emitRuntimeWarning")(function* ( - context: ClaudeSessionContext, - message: string, - detail?: unknown, - ) { - const turnState = context.turnState; - const stamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "runtime.warning", - eventId: stamp.eventId, - provider: PROVIDER, - createdAt: stamp.createdAt, - threadId: context.session.threadId, - ...(turnState ? { turnId: asCanonicalTurnId(turnState.turnId) } : {}), - payload: { - message, - ...(detail !== undefined ? { detail } : {}), + const completeAssistantTextBlock = ( + context: ClaudeSessionContext, + block: AssistantTextBlockState, + options?: { + readonly force?: boolean; + readonly rawMethod?: string; + readonly rawPayload?: unknown; }, - providerRefs: nativeProviderRefs(context), - }); - }); - - const emitProposedPlanCompleted = Effect.fn("emitProposedPlanCompleted")(function* ( - context: ClaudeSessionContext, - input: { - readonly planMarkdown: string; - readonly toolUseId?: string | undefined; - readonly rawSource: "claude.sdk.message" | "claude.sdk.permission"; - readonly rawMethod: string; - readonly rawPayload: unknown; - }, - ) { - const turnState = context.turnState; - const planMarkdown = input.planMarkdown.trim(); - if (!turnState || planMarkdown.length === 0) { - return; - } - - const captureKey = exitPlanCaptureKey({ - toolUseId: input.toolUseId, - planMarkdown, - }); - if (turnState.capturedProposedPlanKeys.has(captureKey)) { - return; - } - turnState.capturedProposedPlanKeys.add(captureKey); + ): Effect.Effect => + Effect.gen(function* () { + const turnState = context.turnState; + if (!turnState || block.completionEmitted) { + return; + } - const stamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "turn.proposed.completed", - eventId: stamp.eventId, - provider: PROVIDER, - createdAt: stamp.createdAt, - threadId: context.session.threadId, - turnId: turnState.turnId, - payload: { - planMarkdown, - }, - providerRefs: nativeProviderRefs(context, { - providerItemId: input.toolUseId, - }), - raw: { - source: input.rawSource, - method: input.rawMethod, - payload: input.rawPayload, - }, - }); - }); + if (!options?.force && !block.streamClosed) { + return; + } - const completeTurn = Effect.fn("completeTurn")(function* ( - context: ClaudeSessionContext, - status: ProviderRuntimeTurnStatus, - errorMessage?: string, - result?: SDKResultMessage, - ) { - const resultUsage = - result?.usage && typeof result.usage === "object" ? { ...result.usage } : undefined; - const resultContextWindow = maxClaudeContextWindowFromModelUsage(result?.modelUsage); - if (resultContextWindow !== undefined) { - context.lastKnownContextWindow = resultContextWindow; - } + if (!block.emittedTextDelta && block.fallbackText.length > 0) { + const deltaStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "content.delta", + eventId: deltaStamp.eventId, + provider: PROVIDER, + createdAt: deltaStamp.createdAt, + threadId: context.session.threadId, + turnId: turnState.turnId, + itemId: asRuntimeItemId(block.itemId), + payload: { + streamKind: "assistant_text", + delta: block.fallbackText, + }, + providerRefs: nativeProviderRefs(context), + ...(options?.rawMethod || options?.rawPayload + ? { + raw: { + source: "claude.sdk.message" as const, + ...(options.rawMethod ? { method: options.rawMethod } : {}), + payload: options?.rawPayload, + }, + } + : {}), + }); + } - // The SDK result.usage contains *accumulated* totals across all API calls - // (input_tokens, cache_read_input_tokens, etc. summed over every request). - // This does NOT represent the current context window size. - // Instead, use the last known context-window-accurate usage from task_progress - // events and treat the accumulated total as totalProcessedTokens. - const accumulatedSnapshot = normalizeClaudeTokenUsage( - resultUsage, - resultContextWindow ?? context.lastKnownContextWindow, - ); - const lastGoodUsage = context.lastKnownTokenUsage; - const maxTokens = resultContextWindow ?? context.lastKnownContextWindow; - const usageSnapshot: ThreadTokenUsageSnapshot | undefined = lastGoodUsage - ? { - ...lastGoodUsage, - ...(typeof maxTokens === "number" && Number.isFinite(maxTokens) && maxTokens > 0 - ? { maxTokens } - : {}), - ...(accumulatedSnapshot && accumulatedSnapshot.usedTokens > lastGoodUsage.usedTokens - ? { totalProcessedTokens: accumulatedSnapshot.usedTokens } - : {}), + block.completionEmitted = true; + if (turnState.assistantTextBlocks.get(block.blockIndex) === block) { + turnState.assistantTextBlocks.delete(block.blockIndex); } - : accumulatedSnapshot; - const turnState = context.turnState; - if (!turnState) { - if (usageSnapshot) { - const usageStamp = yield* makeEventStamp(); + const stamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ - type: "thread.token-usage.updated", - eventId: usageStamp.eventId, + type: "item.completed", + eventId: stamp.eventId, provider: PROVIDER, - createdAt: usageStamp.createdAt, + createdAt: stamp.createdAt, + itemId: asRuntimeItemId(block.itemId), threadId: context.session.threadId, + turnId: turnState.turnId, payload: { - usage: usageSnapshot, + itemType: "assistant_message", + status: "completed", + title: "Assistant message", + ...(block.fallbackText.length > 0 ? { detail: block.fallbackText } : {}), }, - providerRefs: {}, - }); - } - - const stamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "turn.completed", - eventId: stamp.eventId, - provider: PROVIDER, - createdAt: stamp.createdAt, - threadId: context.session.threadId, - payload: { - state: status, - ...(result?.stop_reason !== undefined ? { stopReason: result.stop_reason } : {}), - ...(result?.usage ? { usage: result.usage } : {}), - ...(result?.modelUsage ? { modelUsage: result.modelUsage } : {}), - ...(typeof result?.total_cost_usd === "number" - ? { totalCostUsd: result.total_cost_usd } + providerRefs: nativeProviderRefs(context), + ...(options?.rawMethod || options?.rawPayload + ? { + raw: { + source: "claude.sdk.message" as const, + ...(options.rawMethod ? { method: options.rawMethod } : {}), + payload: options?.rawPayload, + }, + } : {}), - ...(errorMessage ? { errorMessage } : {}), - }, - providerRefs: {}, - }); - return; - } - - for (const [index, tool] of context.inFlightTools.entries()) { - const toolStamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "item.completed", - eventId: toolStamp.eventId, - provider: PROVIDER, - createdAt: toolStamp.createdAt, - threadId: context.session.threadId, - turnId: turnState.turnId, - itemId: asRuntimeItemId(tool.itemId), - payload: { - itemType: tool.itemType, - status: status === "completed" ? "completed" : "failed", - title: tool.title, - ...(tool.detail ? { detail: tool.detail } : {}), - data: { - toolName: tool.toolName, - input: tool.input, - }, - }, - providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }), - raw: { - source: "claude.sdk.message", - method: "claude/result", - payload: result ?? { status }, - }, - }); - context.inFlightTools.delete(index); - } - // Clear any remaining stale entries (e.g. from interrupted content blocks) - context.inFlightTools.clear(); - - for (const block of turnState.assistantTextBlockOrder) { - yield* completeAssistantTextBlock(context, block, { - force: true, - rawMethod: "claude/result", - rawPayload: result ?? { status }, + }); }); - } - context.turns.push({ - id: turnState.turnId, - items: [...turnState.items], - }); + const backfillAssistantTextBlocksFromSnapshot = ( + context: ClaudeSessionContext, + message: SDKMessage, + ): Effect.Effect => + Effect.gen(function* () { + const turnState = context.turnState; + if (!turnState) { + return; + } - if (usageSnapshot) { - const usageStamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "thread.token-usage.updated", - eventId: usageStamp.eventId, - provider: PROVIDER, - createdAt: usageStamp.createdAt, - threadId: context.session.threadId, - turnId: turnState.turnId, - payload: { - usage: usageSnapshot, - }, - providerRefs: nativeProviderRefs(context), - }); - } + const snapshotTextBlocks = extractAssistantTextBlocks(message); + if (snapshotTextBlocks.length === 0) { + return; + } - const stamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "turn.completed", - eventId: stamp.eventId, - provider: PROVIDER, - createdAt: stamp.createdAt, - threadId: context.session.threadId, - turnId: turnState.turnId, - payload: { - state: status, - ...(result?.stop_reason !== undefined ? { stopReason: result.stop_reason } : {}), - ...(result?.usage ? { usage: result.usage } : {}), - ...(result?.modelUsage ? { modelUsage: result.modelUsage } : {}), - ...(typeof result?.total_cost_usd === "number" - ? { totalCostUsd: result.total_cost_usd } - : {}), - ...(errorMessage ? { errorMessage } : {}), - }, - providerRefs: nativeProviderRefs(context), - }); + const orderedBlocks = turnState.assistantTextBlockOrder.map((block) => ({ + blockIndex: block.blockIndex, + block, + })); + + for (const [position, text] of snapshotTextBlocks.entries()) { + const existingEntry = orderedBlocks[position]; + const entry = + existingEntry ?? + (yield* createSyntheticAssistantTextBlock(context, text).pipe( + Effect.map((created) => { + if (!created) { + return undefined; + } + orderedBlocks.push(created); + return created; + }), + )); + if (!entry) { + continue; + } - const updatedAt = yield* nowIso; - context.turnState = undefined; - context.session = { - ...context.session, - status: "ready", - activeTurnId: undefined, - updatedAt, - ...(status === "failed" && errorMessage ? { lastError: errorMessage } : {}), - }; - yield* updateResumeCursor(context); - }); + if (entry.block.fallbackText.length === 0) { + entry.block.fallbackText = text; + } - const handleStreamEvent = Effect.fn("handleStreamEvent")(function* ( - context: ClaudeSessionContext, - message: SDKMessage, - ) { - if (message.type !== "stream_event") { - return; - } + if (entry.block.streamClosed && !entry.block.completionEmitted) { + yield* completeAssistantTextBlock(context, entry.block, { + rawMethod: "claude/assistant", + rawPayload: message, + }); + } + } + }); - const { event } = message; - - if (event.type === "content_block_delta") { - if ( - (event.delta.type === "text_delta" || event.delta.type === "thinking_delta") && - context.turnState - ) { - const deltaText = - event.delta.type === "text_delta" - ? event.delta.text - : typeof event.delta.thinking === "string" - ? event.delta.thinking - : ""; - if (deltaText.length === 0) { + const ensureThreadId = ( + context: ClaudeSessionContext, + message: SDKMessage, + ): Effect.Effect => + Effect.gen(function* () { + if (typeof message.session_id !== "string" || message.session_id.length === 0) { return; } - const streamKind = streamKindFromDeltaType(event.delta.type); - const assistantBlockEntry = - event.delta.type === "text_delta" - ? yield* ensureAssistantTextBlock(context, event.index) - : context.turnState.assistantTextBlocks.get(event.index) - ? { - blockIndex: event.index, - block: context.turnState.assistantTextBlocks.get( - event.index, - ) as AssistantTextBlockState, - } - : undefined; - if (assistantBlockEntry?.block && event.delta.type === "text_delta") { - assistantBlockEntry.block.emittedTextDelta = true; + const nextThreadId = message.session_id; + context.resumeSessionId = message.session_id; + yield* updateResumeCursor(context); + + if (context.lastThreadStartedId !== nextThreadId) { + context.lastThreadStartedId = nextThreadId; + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "thread.started", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + payload: { + providerThreadId: nextThreadId, + }, + providerRefs: {}, + raw: { + source: "claude.sdk.message", + method: "claude/thread/started", + payload: { + session_id: message.session_id, + }, + }, + }); + } + }); + + const emitRuntimeError = ( + context: ClaudeSessionContext, + message: string, + cause?: unknown, + ): Effect.Effect => + Effect.gen(function* () { + if (cause !== undefined) { + void cause; } + const turnState = context.turnState; const stamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ - type: "content.delta", + type: "runtime.error", eventId: stamp.eventId, provider: PROVIDER, createdAt: stamp.createdAt, threadId: context.session.threadId, - turnId: context.turnState.turnId, - ...(assistantBlockEntry?.block - ? { itemId: asRuntimeItemId(assistantBlockEntry.block.itemId) } - : {}), + ...(turnState ? { turnId: asCanonicalTurnId(turnState.turnId) } : {}), payload: { - streamKind, - delta: deltaText, + message, + class: "provider_error", + ...(cause !== undefined ? { detail: cause } : {}), }, providerRefs: nativeProviderRefs(context), - raw: { - source: "claude.sdk.message", - method: "claude/stream_event/content_block_delta", - payload: message, - }, }); - return; - } - - if (event.delta.type === "input_json_delta") { - const tool = context.inFlightTools.get(event.index); - if (!tool || typeof event.delta.partial_json !== "string") { - return; - } - - const partialInputJson = tool.partialInputJson + event.delta.partial_json; - const parsedInput = tryParseJsonRecord(partialInputJson); - const detail = parsedInput ? summarizeToolRequest(tool.toolName, parsedInput) : tool.detail; - let nextTool: ToolInFlight = { - ...tool, - partialInputJson, - ...(parsedInput ? { input: parsedInput } : {}), - ...(detail ? { detail } : {}), - }; - - const nextFingerprint = - parsedInput && Object.keys(parsedInput).length > 0 - ? toolInputFingerprint(parsedInput) - : undefined; - context.inFlightTools.set(event.index, nextTool); - - if ( - !parsedInput || - !nextFingerprint || - tool.lastEmittedInputFingerprint === nextFingerprint - ) { - return; - } - - nextTool = { - ...nextTool, - lastEmittedInputFingerprint: nextFingerprint, - }; - context.inFlightTools.set(event.index, nextTool); + }); + const emitRuntimeWarning = ( + context: ClaudeSessionContext, + message: string, + detail?: unknown, + ): Effect.Effect => + Effect.gen(function* () { + const turnState = context.turnState; const stamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ - type: "item.updated", + type: "runtime.warning", eventId: stamp.eventId, provider: PROVIDER, createdAt: stamp.createdAt, threadId: context.session.threadId, - ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), - itemId: asRuntimeItemId(nextTool.itemId), + ...(turnState ? { turnId: asCanonicalTurnId(turnState.turnId) } : {}), payload: { - itemType: nextTool.itemType, - status: "inProgress", - title: nextTool.title, - ...(nextTool.detail ? { detail: nextTool.detail } : {}), - data: { - toolName: nextTool.toolName, - input: nextTool.input, - }, - }, - providerRefs: nativeProviderRefs(context, { providerItemId: nextTool.itemId }), - raw: { - source: "claude.sdk.message", - method: "claude/stream_event/content_block_delta/input_json_delta", - payload: message, + message, + ...(detail !== undefined ? { detail } : {}), }, + providerRefs: nativeProviderRefs(context), }); - } - return; - } + }); - if (event.type === "content_block_start") { - const { index, content_block: block } = event; - if (block.type === "text") { - yield* ensureAssistantTextBlock(context, index, { - fallbackText: extractContentBlockText(block), + const emitProposedPlanCompleted = ( + context: ClaudeSessionContext, + input: { + readonly planMarkdown: string; + readonly toolUseId?: string | undefined; + readonly rawSource: "claude.sdk.message" | "claude.sdk.permission"; + readonly rawMethod: string; + readonly rawPayload: unknown; + }, + ): Effect.Effect => + Effect.gen(function* () { + const turnState = context.turnState; + const planMarkdown = input.planMarkdown.trim(); + if (!turnState || planMarkdown.length === 0) { + return; + } + + const captureKey = exitPlanCaptureKey({ + toolUseId: input.toolUseId, + planMarkdown, }); - return; - } - if ( - block.type !== "tool_use" && - block.type !== "server_tool_use" && - block.type !== "mcp_tool_use" - ) { - return; - } + if (turnState.capturedProposedPlanKeys.has(captureKey)) { + return; + } + turnState.capturedProposedPlanKeys.add(captureKey); - const toolName = block.name; - const itemType = classifyToolItemType(toolName); - const toolInput = - typeof block.input === "object" && block.input !== null - ? (block.input as Record) - : {}; - const itemId = block.id; - const detail = summarizeToolRequest(toolName, toolInput); - const inputFingerprint = - Object.keys(toolInput).length > 0 ? toolInputFingerprint(toolInput) : undefined; - - const tool: ToolInFlight = { - itemId, - itemType, - toolName, - title: titleForTool(itemType), - detail, - input: toolInput, - partialInputJson: "", - ...(inputFingerprint ? { lastEmittedInputFingerprint: inputFingerprint } : {}), - }; - context.inFlightTools.set(index, tool); - - const stamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "item.started", - eventId: stamp.eventId, - provider: PROVIDER, - createdAt: stamp.createdAt, - threadId: context.session.threadId, - ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), - itemId: asRuntimeItemId(tool.itemId), - payload: { - itemType: tool.itemType, - status: "inProgress", - title: tool.title, - ...(tool.detail ? { detail: tool.detail } : {}), - data: { - toolName: tool.toolName, - input: toolInput, + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "turn.proposed.completed", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + turnId: turnState.turnId, + payload: { + planMarkdown, }, - }, - providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }), - raw: { - source: "claude.sdk.message", - method: "claude/stream_event/content_block_start", - payload: message, - }, + providerRefs: nativeProviderRefs(context, { + providerItemId: input.toolUseId, + }), + raw: { + source: input.rawSource, + method: input.rawMethod, + payload: input.rawPayload, + }, + }); }); - return; - } - if (event.type === "content_block_stop") { - const { index } = event; - const assistantBlock = context.turnState?.assistantTextBlocks.get(index); - if (assistantBlock) { - assistantBlock.streamClosed = true; - yield* completeAssistantTextBlock(context, assistantBlock, { - rawMethod: "claude/stream_event/content_block_stop", - rawPayload: message, - }); - return; - } - const tool = context.inFlightTools.get(index); - if (!tool) { - return; - } - } - }); + const completeTurn = ( + context: ClaudeSessionContext, + status: ProviderRuntimeTurnStatus, + errorMessage?: string, + result?: SDKResultMessage, + ): Effect.Effect => + Effect.gen(function* () { + const resultUsage = + result?.usage && typeof result.usage === "object" ? { ...result.usage } : undefined; + const resultContextWindow = maxClaudeContextWindowFromModelUsage(result?.modelUsage); + if (resultContextWindow !== undefined) { + context.lastKnownContextWindow = resultContextWindow; + } - const handleUserMessage = Effect.fn("handleUserMessage")(function* ( - context: ClaudeSessionContext, - message: SDKMessage, - ) { - if (message.type !== "user") { - return; - } + // The SDK result.usage contains *accumulated* totals across all API calls + // (input_tokens, cache_read_input_tokens, etc. summed over every request). + // This does NOT represent the current context window size. + // Instead, use the last known context-window-accurate usage from task_progress + // events and treat the accumulated total as totalProcessedTokens. + const accumulatedSnapshot = normalizeClaudeTokenUsage( + resultUsage, + resultContextWindow ?? context.lastKnownContextWindow, + ); + const lastGoodUsage = context.lastKnownTokenUsage; + const maxTokens = resultContextWindow ?? context.lastKnownContextWindow; + const usageSnapshot: ThreadTokenUsageSnapshot | undefined = lastGoodUsage + ? { + ...lastGoodUsage, + ...(typeof maxTokens === "number" && Number.isFinite(maxTokens) && maxTokens > 0 + ? { maxTokens } + : {}), + ...(accumulatedSnapshot && accumulatedSnapshot.usedTokens > lastGoodUsage.usedTokens + ? { totalProcessedTokens: accumulatedSnapshot.usedTokens } + : {}), + } + : accumulatedSnapshot; - if (context.turnState) { - context.turnState.items.push(message.message); - } + const turnState = context.turnState; + if (!turnState) { + if (usageSnapshot) { + const usageStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "thread.token-usage.updated", + eventId: usageStamp.eventId, + provider: PROVIDER, + createdAt: usageStamp.createdAt, + threadId: context.session.threadId, + payload: { + usage: usageSnapshot, + }, + providerRefs: {}, + }); + } - for (const toolResult of toolResultBlocksFromUserMessage(message)) { - const toolEntry = Array.from(context.inFlightTools.entries()).find( - ([, tool]) => tool.itemId === toolResult.toolUseId, - ); - if (!toolEntry) { - continue; - } + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "turn.completed", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + payload: { + state: status, + ...(result?.stop_reason !== undefined ? { stopReason: result.stop_reason } : {}), + ...(result?.usage ? { usage: result.usage } : {}), + ...(result?.modelUsage ? { modelUsage: result.modelUsage } : {}), + ...(typeof result?.total_cost_usd === "number" + ? { totalCostUsd: result.total_cost_usd } + : {}), + ...(errorMessage ? { errorMessage } : {}), + }, + providerRefs: {}, + }); + return; + } - const [index, tool] = toolEntry; - const itemStatus = toolResult.isError ? "failed" : "completed"; - const toolData = { - toolName: tool.toolName, - input: tool.input, - result: toolResult.block, - }; + for (const [index, tool] of context.inFlightTools.entries()) { + const toolStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "item.completed", + eventId: toolStamp.eventId, + provider: PROVIDER, + createdAt: toolStamp.createdAt, + threadId: context.session.threadId, + turnId: turnState.turnId, + itemId: asRuntimeItemId(tool.itemId), + payload: { + itemType: tool.itemType, + status: status === "completed" ? "completed" : "failed", + title: tool.title, + ...(tool.detail ? { detail: tool.detail } : {}), + data: { + toolName: tool.toolName, + input: tool.input, + }, + }, + providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }), + raw: { + source: "claude.sdk.message", + method: "claude/result", + payload: result ?? { status }, + }, + }); + context.inFlightTools.delete(index); + } + // Clear any remaining stale entries (e.g. from interrupted content blocks) + context.inFlightTools.clear(); + + for (const block of turnState.assistantTextBlockOrder) { + yield* completeAssistantTextBlock(context, block, { + force: true, + rawMethod: "claude/result", + rawPayload: result ?? { status }, + }); + } - const updatedStamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "item.updated", - eventId: updatedStamp.eventId, - provider: PROVIDER, - createdAt: updatedStamp.createdAt, - threadId: context.session.threadId, - ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), - itemId: asRuntimeItemId(tool.itemId), - payload: { - itemType: tool.itemType, - status: toolResult.isError ? "failed" : "inProgress", - title: tool.title, - ...(tool.detail ? { detail: tool.detail } : {}), - data: toolData, - }, - providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }), - raw: { - source: "claude.sdk.message", - method: "claude/user", - payload: message, - }, - }); + context.turns.push({ + id: turnState.turnId, + items: [...turnState.items], + }); + + if (usageSnapshot) { + const usageStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "thread.token-usage.updated", + eventId: usageStamp.eventId, + provider: PROVIDER, + createdAt: usageStamp.createdAt, + threadId: context.session.threadId, + turnId: turnState.turnId, + payload: { + usage: usageSnapshot, + }, + providerRefs: nativeProviderRefs(context), + }); + } - const streamKind = toolResultStreamKind(tool.itemType); - if (streamKind && toolResult.text.length > 0 && context.turnState) { - const deltaStamp = yield* makeEventStamp(); + const stamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ - type: "content.delta", - eventId: deltaStamp.eventId, + type: "turn.completed", + eventId: stamp.eventId, provider: PROVIDER, - createdAt: deltaStamp.createdAt, + createdAt: stamp.createdAt, threadId: context.session.threadId, - turnId: context.turnState.turnId, - itemId: asRuntimeItemId(tool.itemId), + turnId: turnState.turnId, payload: { - streamKind, - delta: toolResult.text, - }, - providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }), - raw: { - source: "claude.sdk.message", - method: "claude/user", - payload: message, + state: status, + ...(result?.stop_reason !== undefined ? { stopReason: result.stop_reason } : {}), + ...(result?.usage ? { usage: result.usage } : {}), + ...(result?.modelUsage ? { modelUsage: result.modelUsage } : {}), + ...(typeof result?.total_cost_usd === "number" + ? { totalCostUsd: result.total_cost_usd } + : {}), + ...(errorMessage ? { errorMessage } : {}), }, + providerRefs: nativeProviderRefs(context), }); - } - const completedStamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "item.completed", - eventId: completedStamp.eventId, - provider: PROVIDER, - createdAt: completedStamp.createdAt, - threadId: context.session.threadId, - ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), - itemId: asRuntimeItemId(tool.itemId), - payload: { - itemType: tool.itemType, - status: itemStatus, - title: tool.title, - ...(tool.detail ? { detail: tool.detail } : {}), - data: toolData, - }, - providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }), - raw: { - source: "claude.sdk.message", - method: "claude/user", - payload: message, - }, + const updatedAt = yield* nowIso; + context.turnState = undefined; + context.session = { + ...context.session, + status: "ready", + activeTurnId: undefined, + updatedAt, + ...(status === "failed" && errorMessage ? { lastError: errorMessage } : {}), + }; + yield* updateResumeCursor(context); }); - context.inFlightTools.delete(index); - } - }); + const handleStreamEvent = ( + context: ClaudeSessionContext, + message: SDKMessage, + ): Effect.Effect => + Effect.gen(function* () { + if (message.type !== "stream_event") { + return; + } - const handleAssistantMessage = Effect.fn("handleAssistantMessage")(function* ( - context: ClaudeSessionContext, - message: SDKMessage, - ) { - if (message.type !== "assistant") { - return; - } + const { event } = message; + + if (event.type === "content_block_delta") { + if ( + (event.delta.type === "text_delta" || event.delta.type === "thinking_delta") && + context.turnState + ) { + const deltaText = + event.delta.type === "text_delta" + ? event.delta.text + : typeof event.delta.thinking === "string" + ? event.delta.thinking + : ""; + if (deltaText.length === 0) { + return; + } + const streamKind = streamKindFromDeltaType(event.delta.type); + const assistantBlockEntry = + event.delta.type === "text_delta" + ? yield* ensureAssistantTextBlock(context, event.index) + : context.turnState.assistantTextBlocks.get(event.index) + ? { + blockIndex: event.index, + block: context.turnState.assistantTextBlocks.get( + event.index, + ) as AssistantTextBlockState, + } + : undefined; + if (assistantBlockEntry?.block && event.delta.type === "text_delta") { + assistantBlockEntry.block.emittedTextDelta = true; + } + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "content.delta", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + turnId: context.turnState.turnId, + ...(assistantBlockEntry?.block + ? { itemId: asRuntimeItemId(assistantBlockEntry.block.itemId) } + : {}), + payload: { + streamKind, + delta: deltaText, + }, + providerRefs: nativeProviderRefs(context), + raw: { + source: "claude.sdk.message", + method: "claude/stream_event/content_block_delta", + payload: message, + }, + }); + return; + } - // Auto-start a synthetic turn for assistant messages that arrive without - // an active turn (e.g., background agent/subagent responses between user prompts). - if (!context.turnState) { - const turnId = TurnId.makeUnsafe(yield* Random.nextUUIDv4); - const startedAt = yield* nowIso; - context.turnState = { - turnId, - startedAt, - items: [], - assistantTextBlocks: new Map(), - assistantTextBlockOrder: [], - capturedProposedPlanKeys: new Set(), - nextSyntheticAssistantBlockIndex: -1, - }; - context.session = { - ...context.session, - status: "running", - activeTurnId: turnId, - updatedAt: startedAt, - }; - const turnStartedStamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "turn.started", - eventId: turnStartedStamp.eventId, - provider: PROVIDER, - createdAt: turnStartedStamp.createdAt, - threadId: context.session.threadId, - turnId, - payload: {}, - providerRefs: { - ...nativeProviderRefs(context), - providerTurnId: turnId, - }, - raw: { - source: "claude.sdk.message", - method: "claude/synthetic-turn-start", - payload: {}, - }, + if (event.delta.type === "input_json_delta") { + const tool = context.inFlightTools.get(event.index); + if (!tool || typeof event.delta.partial_json !== "string") { + return; + } + + const partialInputJson = tool.partialInputJson + event.delta.partial_json; + const parsedInput = tryParseJsonRecord(partialInputJson); + const detail = parsedInput + ? summarizeToolRequest(tool.toolName, parsedInput) + : tool.detail; + let nextTool: ToolInFlight = { + ...tool, + partialInputJson, + ...(parsedInput ? { input: parsedInput } : {}), + ...(detail ? { detail } : {}), + }; + + const nextFingerprint = + parsedInput && Object.keys(parsedInput).length > 0 + ? toolInputFingerprint(parsedInput) + : undefined; + context.inFlightTools.set(event.index, nextTool); + + if ( + !parsedInput || + !nextFingerprint || + tool.lastEmittedInputFingerprint === nextFingerprint + ) { + return; + } + + nextTool = { + ...nextTool, + lastEmittedInputFingerprint: nextFingerprint, + }; + context.inFlightTools.set(event.index, nextTool); + + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "item.updated", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + itemId: asRuntimeItemId(nextTool.itemId), + payload: { + itemType: nextTool.itemType, + status: "inProgress", + title: nextTool.title, + ...(nextTool.detail ? { detail: nextTool.detail } : {}), + data: { + toolName: nextTool.toolName, + input: nextTool.input, + }, + }, + providerRefs: nativeProviderRefs(context, { providerItemId: nextTool.itemId }), + raw: { + source: "claude.sdk.message", + method: "claude/stream_event/content_block_delta/input_json_delta", + payload: message, + }, + }); + } + return; + } + + if (event.type === "content_block_start") { + const { index, content_block: block } = event; + if (block.type === "text") { + yield* ensureAssistantTextBlock(context, index, { + fallbackText: extractContentBlockText(block), + }); + return; + } + if ( + block.type !== "tool_use" && + block.type !== "server_tool_use" && + block.type !== "mcp_tool_use" + ) { + return; + } + + const toolName = block.name; + const itemType = classifyToolItemType(toolName); + const toolInput = + typeof block.input === "object" && block.input !== null + ? (block.input as Record) + : {}; + const itemId = block.id; + const detail = summarizeToolRequest(toolName, toolInput); + const inputFingerprint = + Object.keys(toolInput).length > 0 ? toolInputFingerprint(toolInput) : undefined; + + const tool: ToolInFlight = { + itemId, + itemType, + toolName, + title: titleForTool(itemType), + detail, + input: toolInput, + partialInputJson: "", + ...(inputFingerprint ? { lastEmittedInputFingerprint: inputFingerprint } : {}), + }; + context.inFlightTools.set(index, tool); + + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "item.started", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + itemId: asRuntimeItemId(tool.itemId), + payload: { + itemType: tool.itemType, + status: "inProgress", + title: tool.title, + ...(tool.detail ? { detail: tool.detail } : {}), + data: { + toolName: tool.toolName, + input: toolInput, + }, + }, + providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }), + raw: { + source: "claude.sdk.message", + method: "claude/stream_event/content_block_start", + payload: message, + }, + }); + return; + } + + if (event.type === "content_block_stop") { + const { index } = event; + const assistantBlock = context.turnState?.assistantTextBlocks.get(index); + if (assistantBlock) { + assistantBlock.streamClosed = true; + yield* completeAssistantTextBlock(context, assistantBlock, { + rawMethod: "claude/stream_event/content_block_stop", + rawPayload: message, + }); + return; + } + const tool = context.inFlightTools.get(index); + if (!tool) { + return; + } + } }); - } - const content = message.message?.content; - if (Array.isArray(content)) { - for (const block of content) { - if (!block || typeof block !== "object") { - continue; + const handleUserMessage = ( + context: ClaudeSessionContext, + message: SDKMessage, + ): Effect.Effect => + Effect.gen(function* () { + if (message.type !== "user") { + return; } - const toolUse = block as { - type?: unknown; - id?: unknown; - name?: unknown; - input?: unknown; - }; - if (toolUse.type !== "tool_use" || toolUse.name !== "ExitPlanMode") { - continue; + + if (context.turnState) { + context.turnState.items.push(message.message); } - const planMarkdown = extractExitPlanModePlan(toolUse.input); - if (!planMarkdown) { - continue; + + for (const toolResult of toolResultBlocksFromUserMessage(message)) { + const toolEntry = Array.from(context.inFlightTools.entries()).find( + ([, tool]) => tool.itemId === toolResult.toolUseId, + ); + if (!toolEntry) { + continue; + } + + const [index, tool] = toolEntry; + const itemStatus = toolResult.isError ? "failed" : "completed"; + const toolData = { + toolName: tool.toolName, + input: tool.input, + result: toolResult.block, + }; + + const updatedStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "item.updated", + eventId: updatedStamp.eventId, + provider: PROVIDER, + createdAt: updatedStamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + itemId: asRuntimeItemId(tool.itemId), + payload: { + itemType: tool.itemType, + status: toolResult.isError ? "failed" : "inProgress", + title: tool.title, + ...(tool.detail ? { detail: tool.detail } : {}), + data: toolData, + }, + providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }), + raw: { + source: "claude.sdk.message", + method: "claude/user", + payload: message, + }, + }); + + const streamKind = toolResultStreamKind(tool.itemType); + if (streamKind && toolResult.text.length > 0 && context.turnState) { + const deltaStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "content.delta", + eventId: deltaStamp.eventId, + provider: PROVIDER, + createdAt: deltaStamp.createdAt, + threadId: context.session.threadId, + turnId: context.turnState.turnId, + itemId: asRuntimeItemId(tool.itemId), + payload: { + streamKind, + delta: toolResult.text, + }, + providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }), + raw: { + source: "claude.sdk.message", + method: "claude/user", + payload: message, + }, + }); + } + + const completedStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "item.completed", + eventId: completedStamp.eventId, + provider: PROVIDER, + createdAt: completedStamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + itemId: asRuntimeItemId(tool.itemId), + payload: { + itemType: tool.itemType, + status: itemStatus, + title: tool.title, + ...(tool.detail ? { detail: tool.detail } : {}), + data: toolData, + }, + providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }), + raw: { + source: "claude.sdk.message", + method: "claude/user", + payload: message, + }, + }); + + context.inFlightTools.delete(index); } - yield* emitProposedPlanCompleted(context, { - planMarkdown, - toolUseId: typeof toolUse.id === "string" ? toolUse.id : undefined, - rawSource: "claude.sdk.message", - rawMethod: "claude/assistant", - rawPayload: message, - }); - } - } + }); - if (context.turnState) { - context.turnState.items.push(message.message); - yield* backfillAssistantTextBlocksFromSnapshot(context, message); - } + const handleAssistantMessage = ( + context: ClaudeSessionContext, + message: SDKMessage, + ): Effect.Effect => + Effect.gen(function* () { + if (message.type !== "assistant") { + return; + } - context.lastAssistantUuid = message.uuid; - yield* updateResumeCursor(context); - }); + // Auto-start a synthetic turn for assistant messages that arrive without + // an active turn (e.g., background agent/subagent responses between user prompts). + if (!context.turnState) { + const turnId = TurnId.makeUnsafe(yield* Random.nextUUIDv4); + const startedAt = yield* nowIso; + context.turnState = { + turnId, + startedAt, + items: [], + assistantTextBlocks: new Map(), + assistantTextBlockOrder: [], + capturedProposedPlanKeys: new Set(), + nextSyntheticAssistantBlockIndex: -1, + }; + context.session = { + ...context.session, + status: "running", + activeTurnId: turnId, + updatedAt: startedAt, + }; + const turnStartedStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "turn.started", + eventId: turnStartedStamp.eventId, + provider: PROVIDER, + createdAt: turnStartedStamp.createdAt, + threadId: context.session.threadId, + turnId, + payload: {}, + providerRefs: { + ...nativeProviderRefs(context), + providerTurnId: turnId, + }, + raw: { + source: "claude.sdk.message", + method: "claude/synthetic-turn-start", + payload: {}, + }, + }); + } - const handleResultMessage = Effect.fn("handleResultMessage")(function* ( - context: ClaudeSessionContext, - message: SDKMessage, - ) { - if (message.type !== "result") { - return; - } + const content = message.message?.content; + if (Array.isArray(content)) { + for (const block of content) { + if (!block || typeof block !== "object") { + continue; + } + const toolUse = block as { + type?: unknown; + id?: unknown; + name?: unknown; + input?: unknown; + }; + if (toolUse.type !== "tool_use" || toolUse.name !== "ExitPlanMode") { + continue; + } + const planMarkdown = extractExitPlanModePlan(toolUse.input); + if (!planMarkdown) { + continue; + } + yield* emitProposedPlanCompleted(context, { + planMarkdown, + toolUseId: typeof toolUse.id === "string" ? toolUse.id : undefined, + rawSource: "claude.sdk.message", + rawMethod: "claude/assistant", + rawPayload: message, + }); + } + } - const status = turnStatusFromResult(message); - const errorMessage = message.subtype === "success" ? undefined : message.errors[0]; + if (context.turnState) { + context.turnState.items.push(message.message); + yield* backfillAssistantTextBlocksFromSnapshot(context, message); + } - if (status === "failed") { - yield* emitRuntimeError(context, errorMessage ?? "Claude turn failed."); - } + context.lastAssistantUuid = message.uuid; + yield* updateResumeCursor(context); + }); - yield* completeTurn(context, status, errorMessage, message); - }); + const handleResultMessage = ( + context: ClaudeSessionContext, + message: SDKMessage, + ): Effect.Effect => + Effect.gen(function* () { + if (message.type !== "result") { + return; + } - const handleSystemMessage = Effect.fn("handleSystemMessage")(function* ( - context: ClaudeSessionContext, - message: SDKMessage, - ) { - if (message.type !== "system") { - return; - } + const status = turnStatusFromResult(message); + const errorMessage = message.subtype === "success" ? undefined : message.errors[0]; - const stamp = yield* makeEventStamp(); - const base = { - eventId: stamp.eventId, - provider: PROVIDER, - createdAt: stamp.createdAt, - threadId: context.session.threadId, - ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), - providerRefs: nativeProviderRefs(context), - raw: { - source: "claude.sdk.message" as const, - method: sdkNativeMethod(message), - messageType: `${message.type}:${message.subtype}`, - payload: message, - }, - }; + if (status === "failed") { + yield* emitRuntimeError(context, errorMessage ?? "Claude turn failed."); + } - switch (message.subtype) { - case "init": - yield* offerRuntimeEvent({ - ...base, - type: "session.configured", - payload: { - config: message as Record, - }, - }); - return; - case "status": - yield* offerRuntimeEvent({ - ...base, - type: "session.state.changed", - payload: { - state: message.status === "compacting" ? "waiting" : "running", - reason: `status:${message.status ?? "active"}`, - detail: message, - }, - }); - return; - case "compact_boundary": - yield* offerRuntimeEvent({ - ...base, - type: "thread.state.changed", - payload: { - state: "compacted", - detail: message, - }, - }); - return; - case "hook_started": - yield* offerRuntimeEvent({ - ...base, - type: "hook.started", - payload: { - hookId: message.hook_id, - hookName: message.hook_name, - hookEvent: message.hook_event, - }, - }); - return; - case "hook_progress": - yield* offerRuntimeEvent({ - ...base, - type: "hook.progress", - payload: { - hookId: message.hook_id, - output: message.output, - stdout: message.stdout, - stderr: message.stderr, - }, - }); - return; - case "hook_response": - yield* offerRuntimeEvent({ - ...base, - type: "hook.completed", - payload: { - hookId: message.hook_id, - outcome: message.outcome, - output: message.output, - stdout: message.stdout, - stderr: message.stderr, - ...(typeof message.exit_code === "number" ? { exitCode: message.exit_code } : {}), - }, - }); - return; - case "task_started": - yield* offerRuntimeEvent({ - ...base, - type: "task.started", - payload: { - taskId: RuntimeTaskId.makeUnsafe(message.task_id), - description: message.description, - ...(message.task_type ? { taskType: message.task_type } : {}), + yield* completeTurn(context, status, errorMessage, message); + }); + + const handleSystemMessage = ( + context: ClaudeSessionContext, + message: SDKMessage, + ): Effect.Effect => + Effect.gen(function* () { + if (message.type !== "system") { + return; + } + + const stamp = yield* makeEventStamp(); + const base = { + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + providerRefs: nativeProviderRefs(context), + raw: { + source: "claude.sdk.message" as const, + method: sdkNativeMethod(message), + messageType: `${message.type}:${message.subtype}`, + payload: message, }, - }); - return; - case "task_progress": - if (message.usage) { - const normalizedUsage = normalizeClaudeTokenUsage( - message.usage, - context.lastKnownContextWindow, - ); - if (normalizedUsage) { - context.lastKnownTokenUsage = normalizedUsage; - const usageStamp = yield* makeEventStamp(); + }; + + switch (message.subtype) { + case "init": yield* offerRuntimeEvent({ ...base, - eventId: usageStamp.eventId, - createdAt: usageStamp.createdAt, - type: "thread.token-usage.updated", + type: "session.configured", payload: { - usage: normalizedUsage, + config: message as Record, }, }); - } - } - yield* offerRuntimeEvent({ - ...base, - type: "task.progress", - payload: { - taskId: RuntimeTaskId.makeUnsafe(message.task_id), - description: message.description, - ...(message.summary ? { summary: message.summary } : {}), - ...(message.usage ? { usage: message.usage } : {}), - ...(message.last_tool_name ? { lastToolName: message.last_tool_name } : {}), - }, - }); - return; - case "task_notification": - if (message.usage) { - const normalizedUsage = normalizeClaudeTokenUsage( - message.usage, - context.lastKnownContextWindow, - ); - if (normalizedUsage) { - context.lastKnownTokenUsage = normalizedUsage; - const usageStamp = yield* makeEventStamp(); + return; + case "status": yield* offerRuntimeEvent({ ...base, - eventId: usageStamp.eventId, - createdAt: usageStamp.createdAt, - type: "thread.token-usage.updated", + type: "session.state.changed", payload: { - usage: normalizedUsage, + state: message.status === "compacting" ? "waiting" : "running", + reason: `status:${message.status ?? "active"}`, + detail: message, }, }); - } + return; + case "compact_boundary": + yield* offerRuntimeEvent({ + ...base, + type: "thread.state.changed", + payload: { + state: "compacted", + detail: message, + }, + }); + return; + case "hook_started": + yield* offerRuntimeEvent({ + ...base, + type: "hook.started", + payload: { + hookId: message.hook_id, + hookName: message.hook_name, + hookEvent: message.hook_event, + }, + }); + return; + case "hook_progress": + yield* offerRuntimeEvent({ + ...base, + type: "hook.progress", + payload: { + hookId: message.hook_id, + output: message.output, + stdout: message.stdout, + stderr: message.stderr, + }, + }); + return; + case "hook_response": + yield* offerRuntimeEvent({ + ...base, + type: "hook.completed", + payload: { + hookId: message.hook_id, + outcome: message.outcome, + output: message.output, + stdout: message.stdout, + stderr: message.stderr, + ...(typeof message.exit_code === "number" ? { exitCode: message.exit_code } : {}), + }, + }); + return; + case "task_started": + yield* offerRuntimeEvent({ + ...base, + type: "task.started", + payload: { + taskId: RuntimeTaskId.makeUnsafe(message.task_id), + description: message.description, + ...(message.task_type ? { taskType: message.task_type } : {}), + }, + }); + return; + case "task_progress": + if (message.usage) { + const normalizedUsage = normalizeClaudeTokenUsage( + message.usage, + context.lastKnownContextWindow, + ); + if (normalizedUsage) { + context.lastKnownTokenUsage = normalizedUsage; + const usageStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + ...base, + eventId: usageStamp.eventId, + createdAt: usageStamp.createdAt, + type: "thread.token-usage.updated", + payload: { + usage: normalizedUsage, + }, + }); + } + } + yield* offerRuntimeEvent({ + ...base, + type: "task.progress", + payload: { + taskId: RuntimeTaskId.makeUnsafe(message.task_id), + description: message.description, + ...(message.summary ? { summary: message.summary } : {}), + ...(message.usage ? { usage: message.usage } : {}), + ...(message.last_tool_name ? { lastToolName: message.last_tool_name } : {}), + }, + }); + return; + case "task_notification": + if (message.usage) { + const normalizedUsage = normalizeClaudeTokenUsage( + message.usage, + context.lastKnownContextWindow, + ); + if (normalizedUsage) { + context.lastKnownTokenUsage = normalizedUsage; + const usageStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + ...base, + eventId: usageStamp.eventId, + createdAt: usageStamp.createdAt, + type: "thread.token-usage.updated", + payload: { + usage: normalizedUsage, + }, + }); + } + } + yield* offerRuntimeEvent({ + ...base, + type: "task.completed", + payload: { + taskId: RuntimeTaskId.makeUnsafe(message.task_id), + status: message.status, + ...(message.summary ? { summary: message.summary } : {}), + ...(message.usage ? { usage: message.usage } : {}), + }, + }); + return; + case "files_persisted": + yield* offerRuntimeEvent({ + ...base, + type: "files.persisted", + payload: { + files: Array.isArray(message.files) + ? message.files.map((file: { filename: string; file_id: string }) => ({ + filename: file.filename, + fileId: file.file_id, + })) + : [], + ...(Array.isArray(message.failed) + ? { + failed: message.failed.map((entry: { filename: string; error: string }) => ({ + filename: entry.filename, + error: entry.error, + })), + } + : {}), + }, + }); + return; + default: + yield* emitRuntimeWarning( + context, + `Unhandled Claude system message subtype '${message.subtype}'.`, + message, + ); + return; } - yield* offerRuntimeEvent({ - ...base, - type: "task.completed", - payload: { - taskId: RuntimeTaskId.makeUnsafe(message.task_id), - status: message.status, - ...(message.summary ? { summary: message.summary } : {}), - ...(message.usage ? { usage: message.usage } : {}), - }, - }); - return; - case "files_persisted": - yield* offerRuntimeEvent({ - ...base, - type: "files.persisted", - payload: { - files: Array.isArray(message.files) - ? message.files.map((file: { filename: string; file_id: string }) => ({ - filename: file.filename, - fileId: file.file_id, - })) - : [], - ...(Array.isArray(message.failed) - ? { - failed: message.failed.map((entry: { filename: string; error: string }) => ({ - filename: entry.filename, - error: entry.error, - })), - } - : {}), + }); + + const handleSdkTelemetryMessage = ( + context: ClaudeSessionContext, + message: SDKMessage, + ): Effect.Effect => + Effect.gen(function* () { + const stamp = yield* makeEventStamp(); + const base = { + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + providerRefs: nativeProviderRefs(context), + raw: { + source: "claude.sdk.message" as const, + method: sdkNativeMethod(message), + messageType: message.type, + payload: message, }, - }); - return; - default: - yield* emitRuntimeWarning( - context, - `Unhandled Claude system message subtype '${message.subtype}'.`, - message, - ); - return; - } - }); + }; - const handleSdkTelemetryMessage = Effect.fn("handleSdkTelemetryMessage")(function* ( - context: ClaudeSessionContext, - message: SDKMessage, - ) { - const stamp = yield* makeEventStamp(); - const base = { - eventId: stamp.eventId, - provider: PROVIDER, - createdAt: stamp.createdAt, - threadId: context.session.threadId, - ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), - providerRefs: nativeProviderRefs(context), - raw: { - source: "claude.sdk.message" as const, - method: sdkNativeMethod(message), - messageType: message.type, - payload: message, - }, - }; + if (message.type === "tool_progress") { + yield* offerRuntimeEvent({ + ...base, + type: "tool.progress", + payload: { + toolUseId: message.tool_use_id, + toolName: message.tool_name, + elapsedSeconds: message.elapsed_time_seconds, + ...(message.task_id ? { summary: `task:${message.task_id}` } : {}), + }, + }); + return; + } - if (message.type === "tool_progress") { - yield* offerRuntimeEvent({ - ...base, - type: "tool.progress", - payload: { - toolUseId: message.tool_use_id, - toolName: message.tool_name, - elapsedSeconds: message.elapsed_time_seconds, - ...(message.task_id ? { summary: `task:${message.task_id}` } : {}), - }, - }); - return; - } + if (message.type === "tool_use_summary") { + yield* offerRuntimeEvent({ + ...base, + type: "tool.summary", + payload: { + summary: message.summary, + ...(message.preceding_tool_use_ids.length > 0 + ? { precedingToolUseIds: message.preceding_tool_use_ids } + : {}), + }, + }); + return; + } - if (message.type === "tool_use_summary") { - yield* offerRuntimeEvent({ - ...base, - type: "tool.summary", - payload: { - summary: message.summary, - ...(message.preceding_tool_use_ids.length > 0 - ? { precedingToolUseIds: message.preceding_tool_use_ids } - : {}), - }, - }); - return; - } + if (message.type === "auth_status") { + yield* offerRuntimeEvent({ + ...base, + type: "auth.status", + payload: { + isAuthenticating: message.isAuthenticating, + output: message.output, + ...(message.error ? { error: message.error } : {}), + }, + }); + return; + } - if (message.type === "auth_status") { - yield* offerRuntimeEvent({ - ...base, - type: "auth.status", - payload: { - isAuthenticating: message.isAuthenticating, - output: message.output, - ...(message.error ? { error: message.error } : {}), - }, + if (message.type === "rate_limit_event") { + yield* offerRuntimeEvent({ + ...base, + type: "account.rate-limits.updated", + payload: { + rateLimits: message, + }, + }); + return; + } }); - return; - } - if (message.type === "rate_limit_event") { - yield* offerRuntimeEvent({ - ...base, - type: "account.rate-limits.updated", - payload: { - rateLimits: message, - }, + const handleSdkMessage = ( + context: ClaudeSessionContext, + message: SDKMessage, + ): Effect.Effect => + Effect.gen(function* () { + yield* logNativeSdkMessage(context, message); + yield* ensureThreadId(context, message); + + switch (message.type) { + case "stream_event": + yield* handleStreamEvent(context, message); + return; + case "user": + yield* handleUserMessage(context, message); + return; + case "assistant": + yield* handleAssistantMessage(context, message); + return; + case "result": + yield* handleResultMessage(context, message); + return; + case "system": + yield* handleSystemMessage(context, message); + return; + case "tool_progress": + case "tool_use_summary": + case "auth_status": + case "rate_limit_event": + yield* handleSdkTelemetryMessage(context, message); + return; + default: + yield* emitRuntimeWarning( + context, + `Unhandled Claude SDK message type '${message.type}'.`, + message, + ); + return; + } }); - return; - } - }); - const handleSdkMessage = Effect.fn("handleSdkMessage")(function* ( - context: ClaudeSessionContext, - message: SDKMessage, - ) { - yield* logNativeSdkMessage(context, message); - yield* ensureThreadId(context, message); - - switch (message.type) { - case "stream_event": - yield* handleStreamEvent(context, message); - return; - case "user": - yield* handleUserMessage(context, message); - return; - case "assistant": - yield* handleAssistantMessage(context, message); - return; - case "result": - yield* handleResultMessage(context, message); - return; - case "system": - yield* handleSystemMessage(context, message); - return; - case "tool_progress": - case "tool_use_summary": - case "auth_status": - case "rate_limit_event": - yield* handleSdkTelemetryMessage(context, message); - return; - default: - yield* emitRuntimeWarning( - context, - `Unhandled Claude SDK message type '${message.type}'.`, - message, - ); - return; - } - }); + const runSdkStream = (context: ClaudeSessionContext): Effect.Effect => + Stream.fromAsyncIterable(context.query, (cause) => + toError(cause, "Claude runtime stream failed."), + ).pipe( + Stream.takeWhile(() => !context.stopped), + Stream.runForEach((message) => handleSdkMessage(context, message)), + ); - const runSdkStream = (context: ClaudeSessionContext): Effect.Effect => - Stream.fromAsyncIterable(context.query, (cause) => - toError(cause, "Claude runtime stream failed."), - ).pipe( - Stream.takeWhile(() => !context.stopped), - Stream.runForEach((message) => handleSdkMessage(context, message)), - ); + const handleStreamExit = ( + context: ClaudeSessionContext, + exit: Exit.Exit, + ): Effect.Effect => + Effect.gen(function* () { + if (context.stopped) { + return; + } - const handleStreamExit = Effect.fn("handleStreamExit")(function* ( - context: ClaudeSessionContext, - exit: Exit.Exit, - ) { - if (context.stopped) { - return; - } + if (Exit.isFailure(exit)) { + if (isClaudeInterruptedCause(exit.cause)) { + if (context.turnState) { + yield* completeTurn( + context, + "interrupted", + interruptionMessageFromClaudeCause(exit.cause), + ); + } + } else { + const message = messageFromClaudeStreamCause( + exit.cause, + "Claude runtime stream failed.", + ); + yield* emitRuntimeError(context, message, Cause.pretty(exit.cause)); + yield* completeTurn(context, "failed", message); + } + } else if (context.turnState) { + yield* completeTurn(context, "interrupted", "Claude runtime stream ended."); + } + + yield* stopSessionInternal(context, { + emitExitEvent: true, + }); + }); + + const stopSessionInternal = ( + context: ClaudeSessionContext, + options?: { readonly emitExitEvent?: boolean }, + ): Effect.Effect => + Effect.gen(function* () { + if (context.stopped) return; + + context.stopped = true; + + for (const [requestId, pending] of context.pendingApprovals) { + yield* Deferred.succeed(pending.decision, "cancel"); + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "request.resolved", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + requestId: asRuntimeRequestId(requestId), + payload: { + requestType: pending.requestType, + decision: "cancel", + }, + providerRefs: nativeProviderRefs(context), + }); + } + context.pendingApprovals.clear(); - if (Exit.isFailure(exit)) { - if (isClaudeInterruptedCause(exit.cause)) { if (context.turnState) { - yield* completeTurn( - context, - "interrupted", - interruptionMessageFromClaudeCause(exit.cause), - ); + yield* completeTurn(context, "interrupted", "Session stopped."); } - } else { - const message = messageFromClaudeStreamCause(exit.cause, "Claude runtime stream failed."); - yield* emitRuntimeError(context, message, Cause.pretty(exit.cause)); - yield* completeTurn(context, "failed", message); - } - } else if (context.turnState) { - yield* completeTurn(context, "interrupted", "Claude runtime stream ended."); - } - yield* stopSessionInternal(context, { - emitExitEvent: true, - }); - }); + yield* Queue.shutdown(context.promptQueue); - const stopSessionInternal = Effect.fn("stopSessionInternal")(function* ( - context: ClaudeSessionContext, - options?: { readonly emitExitEvent?: boolean }, - ) { - if (context.stopped) return; - - context.stopped = true; - - for (const [requestId, pending] of context.pendingApprovals) { - yield* Deferred.succeed(pending.decision, "cancel"); - const stamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "request.resolved", - eventId: stamp.eventId, - provider: PROVIDER, - createdAt: stamp.createdAt, - threadId: context.session.threadId, - ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), - requestId: asRuntimeRequestId(requestId), - payload: { - requestType: pending.requestType, - decision: "cancel", - }, - providerRefs: nativeProviderRefs(context), + const streamFiber = context.streamFiber; + context.streamFiber = undefined; + if (streamFiber && streamFiber.pollUnsafe() === undefined) { + yield* Fiber.interrupt(streamFiber); + } + + // @effect-diagnostics-next-line tryCatchInEffectGen:off + try { + context.query.close(); + } catch (cause) { + yield* emitRuntimeError(context, "Failed to close Claude runtime query.", cause); + } + + const updatedAt = yield* nowIso; + context.session = { + ...context.session, + status: "closed", + activeTurnId: undefined, + updatedAt, + }; + + if (options?.emitExitEvent !== false) { + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.exited", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + payload: { + reason: "Session stopped", + exitKind: "graceful", + }, + providerRefs: {}, + }); + } + + sessions.delete(context.session.threadId); }); - } - context.pendingApprovals.clear(); - if (context.turnState) { - yield* completeTurn(context, "interrupted", "Session stopped."); - } + const requireSession = ( + threadId: ThreadId, + ): Effect.Effect => { + const context = sessions.get(threadId); + if (!context) { + return Effect.fail( + new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + }), + ); + } + if (context.stopped || context.session.status === "closed") { + return Effect.fail( + new ProviderAdapterSessionClosedError({ + provider: PROVIDER, + threadId, + }), + ); + } + return Effect.succeed(context); + }; + + const startSession: ClaudeAdapterShape["startSession"] = (input) => + Effect.gen(function* () { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + }); + } + + const startedAt = yield* nowIso; + const resumeState = readClaudeResumeState(input.resumeCursor); + const threadId = input.threadId; + const existingResumeSessionId = resumeState?.resume; + const newSessionId = + existingResumeSessionId === undefined ? yield* Random.nextUUIDv4 : undefined; + const sessionId = existingResumeSessionId ?? newSessionId; + + const promptQueue = yield* Queue.unbounded(); + const prompt = Stream.fromQueue(promptQueue).pipe( + Stream.filter((item) => item.type === "message"), + Stream.map((item) => item.message), + Stream.catchCause((cause) => + Cause.hasInterruptsOnly(cause) ? Stream.empty : Stream.failCause(cause), + ), + Stream.toAsyncIterable, + ); + + const pendingApprovals = new Map(); + const pendingUserInputs = new Map(); + const inFlightTools = new Map(); + + const contextRef = yield* Ref.make(undefined); + + /** + * Handle AskUserQuestion tool calls by emitting a `user-input.requested` + * runtime event and waiting for the user to respond via `respondToUserInput`. + */ + const handleAskUserQuestion = ( + context: ClaudeSessionContext, + toolInput: Record, + callbackOptions: { readonly signal: AbortSignal; readonly toolUseID?: string }, + ) => + Effect.gen(function* () { + const requestId = ApprovalRequestId.makeUnsafe(yield* Random.nextUUIDv4); + + // Parse questions from the SDK's AskUserQuestion input. + const rawQuestions = Array.isArray(toolInput.questions) ? toolInput.questions : []; + const questions: Array = rawQuestions.map( + (q: Record, idx: number) => ({ + id: typeof q.header === "string" ? q.header : `q-${idx}`, + header: typeof q.header === "string" ? q.header : `Question ${idx + 1}`, + question: typeof q.question === "string" ? q.question : "", + options: Array.isArray(q.options) + ? q.options.map((opt: Record) => ({ + label: typeof opt.label === "string" ? opt.label : "", + description: typeof opt.description === "string" ? opt.description : "", + })) + : [], + multiSelect: typeof q.multiSelect === "boolean" ? q.multiSelect : false, + }), + ); + + const answersDeferred = yield* Deferred.make(); + let aborted = false; + const pendingInput: PendingUserInput = { + questions, + answers: answersDeferred, + }; + + // Emit user-input.requested so the UI can present the questions. + const requestedStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "user-input.requested", + eventId: requestedStamp.eventId, + provider: PROVIDER, + createdAt: requestedStamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + requestId: asRuntimeRequestId(requestId), + payload: { questions }, + providerRefs: nativeProviderRefs(context, { + providerItemId: callbackOptions.toolUseID, + }), + raw: { + source: "claude.sdk.permission", + method: "canUseTool/AskUserQuestion", + payload: { toolName: "AskUserQuestion", input: toolInput }, + }, + }); + + pendingUserInputs.set(requestId, pendingInput); + + // Handle abort (e.g. turn interrupted while waiting for user input). + const onAbort = () => { + if (!pendingUserInputs.has(requestId)) { + return; + } + aborted = true; + pendingUserInputs.delete(requestId); + Effect.runFork(Deferred.succeed(answersDeferred, {} as ProviderUserInputAnswers)); + }; + callbackOptions.signal.addEventListener("abort", onAbort, { once: true }); + + // Block until the user provides answers. + const answers = yield* Deferred.await(answersDeferred); + pendingUserInputs.delete(requestId); + + // Emit user-input.resolved so the UI knows the interaction completed. + const resolvedStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "user-input.resolved", + eventId: resolvedStamp.eventId, + provider: PROVIDER, + createdAt: resolvedStamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + requestId: asRuntimeRequestId(requestId), + payload: { answers }, + providerRefs: nativeProviderRefs(context, { + providerItemId: callbackOptions.toolUseID, + }), + raw: { + source: "claude.sdk.permission", + method: "canUseTool/AskUserQuestion/resolved", + payload: { answers }, + }, + }); - yield* Queue.shutdown(context.promptQueue); + if (aborted) { + return { + behavior: "deny", + message: "User cancelled tool execution.", + } satisfies PermissionResult; + } - const streamFiber = context.streamFiber; - context.streamFiber = undefined; - if (streamFiber && streamFiber.pollUnsafe() === undefined) { - yield* Fiber.interrupt(streamFiber); - } + // Return the answers to the SDK in the expected format: + // { questions: [...], answers: { questionText: selectedLabel } } + return { + behavior: "allow", + updatedInput: { + questions: toolInput.questions, + answers, + }, + } satisfies PermissionResult; + }); + + const canUseTool: CanUseTool = (toolName, toolInput, callbackOptions) => + Effect.runPromise( + Effect.gen(function* () { + const context = yield* Ref.get(contextRef); + if (!context) { + return { + behavior: "deny", + message: "Claude session context is unavailable.", + } satisfies PermissionResult; + } + + // Handle AskUserQuestion: surface clarifying questions to the + // user via the user-input runtime event channel, regardless of + // runtime mode (plan mode relies on this heavily). + if (toolName === "AskUserQuestion") { + return yield* handleAskUserQuestion(context, toolInput, callbackOptions); + } + + if (toolName === "ExitPlanMode") { + const planMarkdown = extractExitPlanModePlan(toolInput); + if (planMarkdown) { + yield* emitProposedPlanCompleted(context, { + planMarkdown, + toolUseId: callbackOptions.toolUseID, + rawSource: "claude.sdk.permission", + rawMethod: "canUseTool/ExitPlanMode", + rawPayload: { + toolName, + input: toolInput, + }, + }); + } - // @effect-diagnostics-next-line tryCatchInEffectGen:off - try { - context.query.close(); - } catch (cause) { - yield* emitRuntimeError(context, "Failed to close Claude runtime query.", cause); - } + return { + behavior: "deny", + message: + "The client captured your proposed plan. Stop here and wait for the user's feedback or implementation request in a later turn.", + } satisfies PermissionResult; + } + + const runtimeMode = input.runtimeMode ?? "full-access"; + if (runtimeMode === "full-access") { + return { + behavior: "allow", + updatedInput: toolInput, + } satisfies PermissionResult; + } + + const requestId = ApprovalRequestId.makeUnsafe(yield* Random.nextUUIDv4); + const requestType = classifyRequestType(toolName); + const detail = summarizeToolRequest(toolName, toolInput); + const decisionDeferred = yield* Deferred.make(); + const pendingApproval: PendingApproval = { + requestType, + detail, + decision: decisionDeferred, + ...(callbackOptions.suggestions + ? { suggestions: callbackOptions.suggestions } + : {}), + }; + + const requestedStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "request.opened", + eventId: requestedStamp.eventId, + provider: PROVIDER, + createdAt: requestedStamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState + ? { turnId: asCanonicalTurnId(context.turnState.turnId) } + : {}), + requestId: asRuntimeRequestId(requestId), + payload: { + requestType, + detail, + args: { + toolName, + input: toolInput, + ...(callbackOptions.toolUseID ? { toolUseId: callbackOptions.toolUseID } : {}), + }, + }, + providerRefs: nativeProviderRefs(context, { + providerItemId: callbackOptions.toolUseID, + }), + raw: { + source: "claude.sdk.permission", + method: "canUseTool/request", + payload: { + toolName, + input: toolInput, + }, + }, + }); + + pendingApprovals.set(requestId, pendingApproval); + + const onAbort = () => { + if (!pendingApprovals.has(requestId)) { + return; + } + pendingApprovals.delete(requestId); + Effect.runFork(Deferred.succeed(decisionDeferred, "cancel")); + }; + + callbackOptions.signal.addEventListener("abort", onAbort, { + once: true, + }); + + const decision = yield* Deferred.await(decisionDeferred); + pendingApprovals.delete(requestId); + + const resolvedStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "request.resolved", + eventId: resolvedStamp.eventId, + provider: PROVIDER, + createdAt: resolvedStamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState + ? { turnId: asCanonicalTurnId(context.turnState.turnId) } + : {}), + requestId: asRuntimeRequestId(requestId), + payload: { + requestType, + decision, + }, + providerRefs: nativeProviderRefs(context, { + providerItemId: callbackOptions.toolUseID, + }), + raw: { + source: "claude.sdk.permission", + method: "canUseTool/decision", + payload: { + decision, + }, + }, + }); + + if (decision === "accept" || decision === "acceptForSession") { + return { + behavior: "allow", + updatedInput: toolInput, + ...(decision === "acceptForSession" && pendingApproval.suggestions + ? { updatedPermissions: [...pendingApproval.suggestions] } + : {}), + } satisfies PermissionResult; + } + + return { + behavior: "deny", + message: + decision === "cancel" + ? "User cancelled tool execution." + : "User declined tool execution.", + } satisfies PermissionResult; + }), + ); - const updatedAt = yield* nowIso; - context.session = { - ...context.session, - status: "closed", - activeTurnId: undefined, - updatedAt, - }; + const providerOptions = input.providerOptions?.claudeAgent; + const modelSelection = + input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined; + const requestedEffort = trimOrNull(modelSelection?.options?.effort ?? null); + const caps = getModelCapabilities("claudeAgent", modelSelection?.model); + const effort = + requestedEffort && hasEffortLevel(caps, requestedEffort) ? requestedEffort : null; + const fastMode = modelSelection?.options?.fastMode === true && caps.supportsFastMode; + const thinking = + typeof modelSelection?.options?.thinking === "boolean" && caps.supportsThinkingToggle + ? modelSelection.options.thinking + : undefined; + const effectiveEffort = getEffectiveClaudeCodeEffort(effort); + const permissionMode = + toPermissionMode(providerOptions?.permissionMode) ?? + (input.runtimeMode === "full-access" ? "bypassPermissions" : undefined); + const settings = { + ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), + ...(fastMode ? { fastMode: true } : {}), + }; - if (options?.emitExitEvent !== false) { - const stamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "session.exited", - eventId: stamp.eventId, - provider: PROVIDER, - createdAt: stamp.createdAt, - threadId: context.session.threadId, - payload: { - reason: "Session stopped", - exitKind: "graceful", - }, - providerRefs: {}, - }); - } + const queryOptions: ClaudeQueryOptions = { + ...(input.cwd ? { cwd: input.cwd } : {}), + ...(modelSelection?.model ? { model: modelSelection.model } : {}), + pathToClaudeCodeExecutable: providerOptions?.binaryPath ?? "claude", + settingSources: [...CLAUDE_SETTING_SOURCES], + ...(effectiveEffort ? { effort: effectiveEffort } : {}), + ...(permissionMode ? { permissionMode } : {}), + ...(permissionMode === "bypassPermissions" + ? { allowDangerouslySkipPermissions: true } + : {}), + ...(providerOptions?.maxThinkingTokens !== undefined + ? { maxThinkingTokens: providerOptions.maxThinkingTokens } + : {}), + ...(Object.keys(settings).length > 0 ? { settings } : {}), + ...(existingResumeSessionId ? { resume: existingResumeSessionId } : {}), + ...(newSessionId ? { sessionId: newSessionId } : {}), + includePartialMessages: true, + canUseTool, + env: process.env, + ...(input.cwd ? { additionalDirectories: [input.cwd] } : {}), + }; - sessions.delete(context.session.threadId); - }); + const queryRuntime = yield* Effect.try({ + try: () => + createQuery({ + prompt, + options: queryOptions, + }), + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId, + detail: toMessage(cause, "Failed to start Claude runtime session."), + cause, + }), + }); - const requireSession = ( - threadId: ThreadId, - ): Effect.Effect => { - const context = sessions.get(threadId); - if (!context) { - return Effect.fail( - new ProviderAdapterSessionNotFoundError({ - provider: PROVIDER, + const session: ProviderSession = { threadId, - }), - ); - } - if (context.stopped || context.session.status === "closed") { - return Effect.fail( - new ProviderAdapterSessionClosedError({ provider: PROVIDER, - threadId, - }), - ); - } - return Effect.succeed(context); - }; + status: "ready", + runtimeMode: input.runtimeMode, + ...(input.cwd ? { cwd: input.cwd } : {}), + ...(modelSelection?.model ? { model: modelSelection.model } : {}), + ...(threadId ? { threadId } : {}), + resumeCursor: { + ...(threadId ? { threadId } : {}), + ...(sessionId ? { resume: sessionId } : {}), + ...(resumeState?.resumeSessionAt + ? { resumeSessionAt: resumeState.resumeSessionAt } + : {}), + turnCount: resumeState?.turnCount ?? 0, + }, + createdAt: startedAt, + updatedAt: startedAt, + }; - const startSession: ClaudeAdapterShape["startSession"] = Effect.fn("startSession")( - function* (input) { - if (input.provider !== undefined && input.provider !== PROVIDER) { - return yield* new ProviderAdapterValidationError({ + const context: ClaudeSessionContext = { + session, + promptQueue, + query: queryRuntime, + streamFiber: undefined, + startedAt, + basePermissionMode: permissionMode, + resumeSessionId: sessionId, + pendingApprovals, + pendingUserInputs, + turns: [], + inFlightTools, + turnState: undefined, + lastKnownContextWindow: undefined, + lastKnownTokenUsage: undefined, + lastAssistantUuid: resumeState?.resumeSessionAt, + lastThreadStartedId: undefined, + stopped: false, + }; + yield* Ref.set(contextRef, context); + sessions.set(threadId, context); + + const sessionStartedStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.started", + eventId: sessionStartedStamp.eventId, provider: PROVIDER, - operation: "startSession", - issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + createdAt: sessionStartedStamp.createdAt, + threadId, + payload: input.resumeCursor !== undefined ? { resume: input.resumeCursor } : {}, + providerRefs: {}, }); - } - - const startedAt = yield* nowIso; - const resumeState = readClaudeResumeState(input.resumeCursor); - const threadId = input.threadId; - const existingResumeSessionId = resumeState?.resume; - const newSessionId = - existingResumeSessionId === undefined ? yield* Random.nextUUIDv4 : undefined; - const sessionId = existingResumeSessionId ?? newSessionId; - - const services = yield* Effect.services(); - const runFork = Effect.runForkWith(services); - const runPromise = Effect.runPromiseWith(services); - - const promptQueue = yield* Queue.unbounded(); - const prompt = Stream.fromQueue(promptQueue).pipe( - Stream.filter((item) => item.type === "message"), - Stream.map((item) => item.message), - Stream.catchCause((cause) => - Cause.hasInterruptsOnly(cause) ? Stream.empty : Stream.failCause(cause), - ), - Stream.toAsyncIterable, - ); - - const pendingApprovals = new Map(); - const pendingUserInputs = new Map(); - const inFlightTools = new Map(); - - const contextRef = yield* Ref.make(undefined); - - /** - * Handle AskUserQuestion tool calls by emitting a `user-input.requested` - * runtime event and waiting for the user to respond via `respondToUserInput`. - */ - const handleAskUserQuestion = Effect.fn("handleAskUserQuestion")(function* ( - context: ClaudeSessionContext, - toolInput: Record, - callbackOptions: { readonly signal: AbortSignal; readonly toolUseID?: string }, - ) { - const requestId = ApprovalRequestId.makeUnsafe(yield* Random.nextUUIDv4); - - // Parse questions from the SDK's AskUserQuestion input. - const rawQuestions = Array.isArray(toolInput.questions) ? toolInput.questions : []; - const questions: Array = rawQuestions.map( - (q: Record, idx: number) => ({ - id: typeof q.header === "string" ? q.header : `q-${idx}`, - header: typeof q.header === "string" ? q.header : `Question ${idx + 1}`, - question: typeof q.question === "string" ? q.question : "", - options: Array.isArray(q.options) - ? q.options.map((opt: Record) => ({ - label: typeof opt.label === "string" ? opt.label : "", - description: typeof opt.description === "string" ? opt.description : "", - })) - : [], - multiSelect: typeof q.multiSelect === "boolean" ? q.multiSelect : false, - }), - ); - const answersDeferred = yield* Deferred.make(); - let aborted = false; - const pendingInput: PendingUserInput = { - questions, - answers: answersDeferred, - }; - - // Emit user-input.requested so the UI can present the questions. - const requestedStamp = yield* makeEventStamp(); + const configuredStamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ - type: "user-input.requested", - eventId: requestedStamp.eventId, + type: "session.configured", + eventId: configuredStamp.eventId, provider: PROVIDER, - createdAt: requestedStamp.createdAt, - threadId: context.session.threadId, - ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), - requestId: asRuntimeRequestId(requestId), - payload: { questions }, - providerRefs: nativeProviderRefs(context, { - providerItemId: callbackOptions.toolUseID, - }), - raw: { - source: "claude.sdk.permission", - method: "canUseTool/AskUserQuestion", - payload: { toolName: "AskUserQuestion", input: toolInput }, + createdAt: configuredStamp.createdAt, + threadId, + payload: { + config: { + ...(modelSelection?.model ? { model: modelSelection.model } : {}), + ...(input.cwd ? { cwd: input.cwd } : {}), + ...(effectiveEffort ? { effort: effectiveEffort } : {}), + ...(permissionMode ? { permissionMode } : {}), + ...(providerOptions?.maxThinkingTokens !== undefined + ? { maxThinkingTokens: providerOptions.maxThinkingTokens } + : {}), + ...(fastMode ? { fastMode: true } : {}), + }, }, + providerRefs: {}, }); - pendingUserInputs.set(requestId, pendingInput); - - // Handle abort (e.g. turn interrupted while waiting for user input). - const onAbort = () => { - if (!pendingUserInputs.has(requestId)) { - return; - } - aborted = true; - pendingUserInputs.delete(requestId); - runFork(Deferred.succeed(answersDeferred, {} as ProviderUserInputAnswers)); - }; - callbackOptions.signal.addEventListener("abort", onAbort, { once: true }); - - // Block until the user provides answers. - const answers = yield* Deferred.await(answersDeferred); - pendingUserInputs.delete(requestId); - - // Emit user-input.resolved so the UI knows the interaction completed. - const resolvedStamp = yield* makeEventStamp(); + const readyStamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ - type: "user-input.resolved", - eventId: resolvedStamp.eventId, + type: "session.state.changed", + eventId: readyStamp.eventId, provider: PROVIDER, - createdAt: resolvedStamp.createdAt, - threadId: context.session.threadId, - ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), - requestId: asRuntimeRequestId(requestId), - payload: { answers }, - providerRefs: nativeProviderRefs(context, { - providerItemId: callbackOptions.toolUseID, - }), - raw: { - source: "claude.sdk.permission", - method: "canUseTool/AskUserQuestion/resolved", - payload: { answers }, + createdAt: readyStamp.createdAt, + threadId, + payload: { + state: "ready", }, + providerRefs: {}, }); - if (aborted) { - return { - behavior: "deny", - message: "User cancelled tool execution.", - } satisfies PermissionResult; - } + const streamFiber = Effect.runFork(runSdkStream(context)); + context.streamFiber = streamFiber; + streamFiber.addObserver((exit) => { + if (context.stopped) { + return; + } + if (context.streamFiber === streamFiber) { + context.streamFiber = undefined; + } + Effect.runFork(handleStreamExit(context, exit)); + }); - // Return the answers to the SDK in the expected format: - // { questions: [...], answers: { questionText: selectedLabel } } return { - behavior: "allow", - updatedInput: { - questions: toolInput.questions, - answers, - }, - } satisfies PermissionResult; + ...session, + }; }); - const canUseToolEffect = Effect.fn("canUseTool")(function* ( - toolName: Parameters[0], - toolInput: Parameters[1], - callbackOptions: Parameters[2], - ) { - const context = yield* Ref.get(contextRef); - if (!context) { - return { - behavior: "deny", - message: "Claude session context is unavailable.", - } satisfies PermissionResult; - } + const sendTurn: ClaudeAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const context = yield* requireSession(input.threadId); + const modelSelection = + input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined; - // Handle AskUserQuestion: surface clarifying questions to the - // user via the user-input runtime event channel, regardless of - // runtime mode (plan mode relies on this heavily). - if (toolName === "AskUserQuestion") { - return yield* handleAskUserQuestion(context, toolInput, callbackOptions); + if (context.turnState) { + // Auto-close a stale synthetic turn (from background agent responses + // between user prompts) to prevent blocking the user's next turn. + yield* completeTurn(context, "completed"); } - if (toolName === "ExitPlanMode") { - const planMarkdown = extractExitPlanModePlan(toolInput); - if (planMarkdown) { - yield* emitProposedPlanCompleted(context, { - planMarkdown, - toolUseId: callbackOptions.toolUseID, - rawSource: "claude.sdk.permission", - rawMethod: "canUseTool/ExitPlanMode", - rawPayload: { - toolName, - input: toolInput, - }, - }); - } - - return { - behavior: "deny", - message: - "The client captured your proposed plan. Stop here and wait for the user's feedback or implementation request in a later turn.", - } satisfies PermissionResult; + if (modelSelection?.model) { + yield* Effect.tryPromise({ + try: () => context.query.setModel(modelSelection.model), + catch: (cause) => toRequestError(input.threadId, "turn/setModel", cause), + }); } - const runtimeMode = input.runtimeMode ?? "full-access"; - if (runtimeMode === "full-access") { - return { - behavior: "allow", - updatedInput: toolInput, - } satisfies PermissionResult; + // Apply interaction mode by switching the SDK's permission mode. + // "plan" maps directly to the SDK's "plan" permission mode; + // "default" restores the session's original permission mode. + // When interactionMode is absent we leave the current mode unchanged. + if (input.interactionMode === "plan") { + yield* Effect.tryPromise({ + try: () => context.query.setPermissionMode("plan"), + catch: (cause) => toRequestError(input.threadId, "turn/setPermissionMode", cause), + }); + } else if (input.interactionMode === "default") { + yield* Effect.tryPromise({ + try: () => + context.query.setPermissionMode(context.basePermissionMode ?? "bypassPermissions"), + catch: (cause) => toRequestError(input.threadId, "turn/setPermissionMode", cause), + }); } - const requestId = ApprovalRequestId.makeUnsafe(yield* Random.nextUUIDv4); - const requestType = classifyRequestType(toolName); - const detail = summarizeToolRequest(toolName, toolInput); - const decisionDeferred = yield* Deferred.make(); - const pendingApproval: PendingApproval = { - requestType, - detail, - decision: decisionDeferred, - ...(callbackOptions.suggestions ? { suggestions: callbackOptions.suggestions } : {}), + const turnId = TurnId.makeUnsafe(yield* Random.nextUUIDv4); + const turnState: ClaudeTurnState = { + turnId, + startedAt: yield* nowIso, + items: [], + assistantTextBlocks: new Map(), + assistantTextBlockOrder: [], + capturedProposedPlanKeys: new Set(), + nextSyntheticAssistantBlockIndex: -1, }; - const requestedStamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "request.opened", - eventId: requestedStamp.eventId, - provider: PROVIDER, - createdAt: requestedStamp.createdAt, - threadId: context.session.threadId, - ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), - requestId: asRuntimeRequestId(requestId), - payload: { - requestType, - detail, - args: { - toolName, - input: toolInput, - ...(callbackOptions.toolUseID ? { toolUseId: callbackOptions.toolUseID } : {}), - }, - }, - providerRefs: nativeProviderRefs(context, { - providerItemId: callbackOptions.toolUseID, - }), - raw: { - source: "claude.sdk.permission", - method: "canUseTool/request", - payload: { - toolName, - input: toolInput, - }, - }, - }); - - pendingApprovals.set(requestId, pendingApproval); - - const onAbort = () => { - if (!pendingApprovals.has(requestId)) { - return; - } - pendingApprovals.delete(requestId); - runFork(Deferred.succeed(decisionDeferred, "cancel")); + const updatedAt = yield* nowIso; + context.turnState = turnState; + context.session = { + ...context.session, + status: "running", + activeTurnId: turnId, + updatedAt, }; - callbackOptions.signal.addEventListener("abort", onAbort, { - once: true, - }); - - const decision = yield* Deferred.await(decisionDeferred); - pendingApprovals.delete(requestId); - - const resolvedStamp = yield* makeEventStamp(); + const turnStartedStamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ - type: "request.resolved", - eventId: resolvedStamp.eventId, + type: "turn.started", + eventId: turnStartedStamp.eventId, provider: PROVIDER, - createdAt: resolvedStamp.createdAt, + createdAt: turnStartedStamp.createdAt, threadId: context.session.threadId, - ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), - requestId: asRuntimeRequestId(requestId), - payload: { - requestType, - decision, - }, - providerRefs: nativeProviderRefs(context, { - providerItemId: callbackOptions.toolUseID, - }), - raw: { - source: "claude.sdk.permission", - method: "canUseTool/decision", - payload: { - decision, - }, - }, + turnId, + payload: modelSelection?.model ? { model: modelSelection.model } : {}, + providerRefs: {}, }); - if (decision === "accept" || decision === "acceptForSession") { - return { - behavior: "allow", - updatedInput: toolInput, - ...(decision === "acceptForSession" && pendingApproval.suggestions - ? { updatedPermissions: [...pendingApproval.suggestions] } - : {}), - } satisfies PermissionResult; - } + const message = yield* buildUserMessageEffect(input, { + fileSystem, + attachmentsDir: serverConfig.attachmentsDir, + }); + + yield* Queue.offer(context.promptQueue, { + type: "message", + message, + }).pipe(Effect.mapError((cause) => toRequestError(input.threadId, "turn/start", cause))); return { - behavior: "deny", - message: - decision === "cancel" - ? "User cancelled tool execution." - : "User declined tool execution.", - } satisfies PermissionResult; + threadId: context.session.threadId, + turnId, + ...(context.session.resumeCursor !== undefined + ? { resumeCursor: context.session.resumeCursor } + : {}), + }; }); - const canUseTool: CanUseTool = (toolName, toolInput, callbackOptions) => - runPromise(canUseToolEffect(toolName, toolInput, callbackOptions)); - - const claudeSettings = yield* serverSettingsService.getSettings.pipe( - Effect.map((settings) => settings.providers.claudeAgent), - Effect.mapError( - (error) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: input.threadId, - detail: error.message, - cause: error, - }), - ), - ); - const claudeBinaryPath = claudeSettings.binaryPath; - const modelSelection = - input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined; - const caps = getClaudeModelCapabilities(modelSelection?.model); - const apiModelId = modelSelection ? resolveApiModelId(modelSelection) : undefined; - const effort = (resolveEffort(caps, modelSelection?.options?.effort) ?? - null) as ClaudeCodeEffort | null; - const fastMode = modelSelection?.options?.fastMode === true && caps.supportsFastMode; - const thinking = - typeof modelSelection?.options?.thinking === "boolean" && caps.supportsThinkingToggle - ? modelSelection.options.thinking - : undefined; - const effectiveEffort = getEffectiveClaudeCodeEffort(effort); - const permissionMode = input.runtimeMode === "full-access" ? "bypassPermissions" : undefined; - const settings = { - ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), - ...(fastMode ? { fastMode: true } : {}), - }; + const interruptTurn: ClaudeAdapterShape["interruptTurn"] = (threadId, _turnId) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + yield* Effect.tryPromise({ + try: () => context.query.interrupt(), + catch: (cause) => toRequestError(threadId, "turn/interrupt", cause), + }); + }); - const queryOptions: ClaudeQueryOptions = { - ...(input.cwd ? { cwd: input.cwd } : {}), - ...(apiModelId ? { model: apiModelId } : {}), - pathToClaudeCodeExecutable: claudeBinaryPath, - settingSources: [...CLAUDE_SETTING_SOURCES], - ...(effectiveEffort ? { effort: effectiveEffort } : {}), - ...(permissionMode ? { permissionMode } : {}), - ...(permissionMode === "bypassPermissions" - ? { allowDangerouslySkipPermissions: true } - : {}), - ...(Object.keys(settings).length > 0 ? { settings } : {}), - ...(existingResumeSessionId ? { resume: existingResumeSessionId } : {}), - ...(newSessionId ? { sessionId: newSessionId } : {}), - includePartialMessages: true, - canUseTool, - env: process.env, - ...(input.cwd ? { additionalDirectories: [input.cwd] } : {}), - }; + const readThread: ClaudeAdapterShape["readThread"] = (threadId) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + return yield* snapshotThread(context); + }); - const queryRuntime = yield* Effect.try({ - try: () => - createQuery({ - prompt, - options: queryOptions, - }), - catch: (cause) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId, - detail: toMessage(cause, "Failed to start Claude runtime session."), - cause, - }), + const rollbackThread: ClaudeAdapterShape["rollbackThread"] = (threadId, numTurns) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + const nextLength = Math.max(0, context.turns.length - numTurns); + context.turns.splice(nextLength); + yield* updateResumeCursor(context); + return yield* snapshotThread(context); }); - const session: ProviderSession = { - threadId, - provider: PROVIDER, - status: "ready", - runtimeMode: input.runtimeMode, - ...(input.cwd ? { cwd: input.cwd } : {}), - ...(modelSelection?.model ? { model: modelSelection.model } : {}), - ...(threadId ? { threadId } : {}), - resumeCursor: { - ...(threadId ? { threadId } : {}), - ...(sessionId ? { resume: sessionId } : {}), - ...(resumeState?.resumeSessionAt ? { resumeSessionAt: resumeState.resumeSessionAt } : {}), - turnCount: resumeState?.turnCount ?? 0, - }, - createdAt: startedAt, - updatedAt: startedAt, - }; + const respondToRequest: ClaudeAdapterShape["respondToRequest"] = ( + threadId, + requestId, + decision, + ) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + const pending = context.pendingApprovals.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "item/requestApproval/decision", + detail: `Unknown pending approval request: ${requestId}`, + }); + } - const context: ClaudeSessionContext = { - session, - promptQueue, - query: queryRuntime, - streamFiber: undefined, - startedAt, - basePermissionMode: permissionMode, - currentApiModelId: apiModelId, - resumeSessionId: sessionId, - pendingApprovals, - pendingUserInputs, - turns: [], - inFlightTools, - turnState: undefined, - lastKnownContextWindow: undefined, - lastKnownTokenUsage: undefined, - lastAssistantUuid: resumeState?.resumeSessionAt, - lastThreadStartedId: undefined, - stopped: false, - }; - yield* Ref.set(contextRef, context); - sessions.set(threadId, context); - - const sessionStartedStamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "session.started", - eventId: sessionStartedStamp.eventId, - provider: PROVIDER, - createdAt: sessionStartedStamp.createdAt, - threadId, - payload: input.resumeCursor !== undefined ? { resume: input.resumeCursor } : {}, - providerRefs: {}, + context.pendingApprovals.delete(requestId); + yield* Deferred.succeed(pending.decision, decision); }); - const configuredStamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "session.configured", - eventId: configuredStamp.eventId, - provider: PROVIDER, - createdAt: configuredStamp.createdAt, - threadId, - payload: { - config: { - ...(apiModelId ? { model: apiModelId } : {}), - ...(input.cwd ? { cwd: input.cwd } : {}), - ...(effectiveEffort ? { effort: effectiveEffort } : {}), - ...(permissionMode ? { permissionMode } : {}), - ...(fastMode ? { fastMode: true } : {}), - }, - }, - providerRefs: {}, - }); + const respondToUserInput: ClaudeAdapterShape["respondToUserInput"] = ( + threadId, + requestId, + answers, + ) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + const pending = context.pendingUserInputs.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "item/tool/respondToUserInput", + detail: `Unknown pending user-input request: ${requestId}`, + }); + } - const readyStamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "session.state.changed", - eventId: readyStamp.eventId, - provider: PROVIDER, - createdAt: readyStamp.createdAt, - threadId, - payload: { - state: "ready", - }, - providerRefs: {}, + context.pendingUserInputs.delete(requestId); + yield* Deferred.succeed(pending.answers, answers); }); - let streamFiber: Fiber.Fiber; - streamFiber = runFork( - Effect.exit(runSdkStream(context)).pipe( - Effect.flatMap((exit) => { - if (context.stopped) { - return Effect.void; - } - if (context.streamFiber === streamFiber) { - context.streamFiber = undefined; - } - return handleStreamExit(context, exit); - }), - ), - ); - context.streamFiber = streamFiber; - streamFiber.addObserver(() => { - if (context.streamFiber === streamFiber) { - context.streamFiber = undefined; - } + const stopSession: ClaudeAdapterShape["stopSession"] = (threadId) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + yield* stopSessionInternal(context, { + emitExitEvent: true, + }); }); - return { - ...session, - }; - }, - ); + const listSessions: ClaudeAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), ({ session }) => ({ ...session }))); - const sendTurn: ClaudeAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) { - const context = yield* requireSession(input.threadId); - const modelSelection = - input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined; + const hasSession: ClaudeAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => { + const context = sessions.get(threadId); + return context !== undefined && !context.stopped; + }); - if (context.turnState) { - // Auto-close a stale synthetic turn (from background agent responses - // between user prompts) to prevent blocking the user's next turn. - yield* completeTurn(context, "completed"); - } + // Skill discovery cache — avoids spawning a process per query. + let skillsCache: { result: ProviderListSkillsResult; cwd: string } | null = null; + let pendingDiscovery: Promise | null = null; - if (modelSelection?.model) { - const apiModelId = resolveApiModelId(modelSelection); - if (context.currentApiModelId !== apiModelId) { - yield* Effect.tryPromise({ - try: () => context.query.setModel(apiModelId), - catch: (cause) => toRequestError(input.threadId, "turn/setModel", cause), - }); - context.currentApiModelId = apiModelId; - } - context.session = { - ...context.session, - model: modelSelection.model, + function mapCommandsToSkills(commands: SlashCommand[]): ProviderListSkillsResult { + return { + skills: commands.map((cmd) => ({ + name: cmd.name, + description: cmd.description || undefined, + path: cmd.name, + enabled: true, + })), + source: "claudeAgent", + cached: false, }; } - // Apply interaction mode by switching the SDK's permission mode. - // "plan" maps directly to the SDK's "plan" permission mode; - // "default" restores the session's original permission mode. - // When interactionMode is absent we leave the current mode unchanged. - if (input.interactionMode === "plan") { - yield* Effect.tryPromise({ - try: () => context.query.setPermissionMode("plan"), - catch: (cause) => toRequestError(input.threadId, "turn/setPermissionMode", cause), - }); - } else if (input.interactionMode === "default") { - yield* Effect.tryPromise({ - try: () => - context.query.setPermissionMode(context.basePermissionMode ?? "bypassPermissions"), - catch: (cause) => toRequestError(input.threadId, "turn/setPermissionMode", cause), + async function discoverSkillsViaTemporaryProcess( + cwd: string, + ): Promise { + // Spawn a lightweight Claude Code process for skill discovery. + // The SDK's supportedCommands() awaits an internal initialization promise + // that only resolves when the async generator is iterated (driving the + // subprocess handshake). We iterate in the background to unblock it. + const tempQuery = createQuery({ + prompt: (async function* (): AsyncIterable { + await new Promise(() => {}); + })(), + options: { + cwd, + pathToClaudeCodeExecutable: "claude", + settingSources: [...CLAUDE_SETTING_SOURCES], + permissionMode: "plan" as PermissionMode, + persistSession: false, + }, }); - } - const turnId = TurnId.makeUnsafe(yield* Random.nextUUIDv4); - const turnState: ClaudeTurnState = { - turnId, - startedAt: yield* nowIso, - items: [], - assistantTextBlocks: new Map(), - assistantTextBlockOrder: [], - capturedProposedPlanKeys: new Set(), - nextSyntheticAssistantBlockIndex: -1, - }; + try { + // Drive the iterator so the subprocess completes its init handshake. + // This runs in the background; close() in the finally block stops it. + void (async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _ of tempQuery) { + /* consume until closed */ + } + })(); - const updatedAt = yield* nowIso; - context.turnState = turnState; - context.session = { - ...context.session, - status: "running", - activeTurnId: turnId, - updatedAt, - }; + const commands = await tempQuery.supportedCommands(); + return mapCommandsToSkills(commands); + } finally { + tempQuery.close(); + } + } - const turnStartedStamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "turn.started", - eventId: turnStartedStamp.eventId, - provider: PROVIDER, - createdAt: turnStartedStamp.createdAt, - threadId: context.session.threadId, - turnId, - payload: modelSelection?.model ? { model: modelSelection.model } : {}, - providerRefs: {}, - }); + const listSkills: NonNullable = ( + input: ProviderListSkillsInput, + ) => + Effect.gen(function* () { + // 1. Try an active session first (cheapest path). + const context = input.threadId + ? sessions.get(ThreadId.makeUnsafe(input.threadId)) + : [...sessions.values()].find((s) => !s.stopped); + + if (context && !context.stopped) { + const commands = yield* Effect.tryPromise({ + try: () => context.query.supportedCommands(), + catch: (cause) => toRequestError(context.session.threadId, "listSkills", cause), + }); + const result = mapCommandsToSkills(commands); + skillsCache = { result, cwd: input.cwd }; + return result; + } - const message = yield* buildUserMessageEffect(input, { - fileSystem, - attachmentsDir: serverConfig.attachmentsDir, - }); + // 2. Return from cache if valid and not force-reloading. + if (skillsCache && skillsCache.cwd === input.cwd && !input.forceReload) { + return { ...skillsCache.result, cached: true } satisfies ProviderListSkillsResult; + } - yield* Queue.offer(context.promptQueue, { - type: "message", - message, - }).pipe(Effect.mapError((cause) => toRequestError(input.threadId, "turn/start", cause))); + // 3. Spawn a temporary process for discovery (deduplicating concurrent requests). + const discoveryPromise = pendingDiscovery ?? discoverSkillsViaTemporaryProcess(input.cwd); + pendingDiscovery = discoveryPromise; - return { - threadId: context.session.threadId, - turnId, - ...(context.session.resumeCursor !== undefined - ? { resumeCursor: context.session.resumeCursor } - : {}), - }; - }); + const result = yield* Effect.tryPromise({ + try: () => discoveryPromise, + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: ThreadId.makeUnsafe("discovery"), + detail: toMessage(cause, "Failed to discover Claude skills."), + cause, + }), + }).pipe( + Effect.tap(() => + Effect.sync(() => { + pendingDiscovery = null; + }), + ), + Effect.tapError(() => + Effect.sync(() => { + pendingDiscovery = null; + }), + ), + ); - const interruptTurn: ClaudeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( - function* (threadId, _turnId) { - const context = yield* requireSession(threadId); - yield* Effect.tryPromise({ - try: () => context.query.interrupt(), - catch: (cause) => toRequestError(threadId, "turn/interrupt", cause), + skillsCache = { result, cwd: input.cwd }; + return result; }); - }, - ); - - const readThread: ClaudeAdapterShape["readThread"] = Effect.fn("readThread")( - function* (threadId) { - const context = yield* requireSession(threadId); - return yield* snapshotThread(context); - }, - ); - const rollbackThread: ClaudeAdapterShape["rollbackThread"] = Effect.fn("rollbackThread")( - function* (threadId, numTurns) { - const context = yield* requireSession(threadId); - const nextLength = Math.max(0, context.turns.length - numTurns); - context.turns.splice(nextLength); - yield* updateResumeCursor(context); - return yield* snapshotThread(context); - }, - ); + const stopAll: ClaudeAdapterShape["stopAll"] = () => + Effect.forEach( + sessions, + ([, context]) => + stopSessionInternal(context, { + emitExitEvent: true, + }), + { discard: true }, + ); - const respondToRequest: ClaudeAdapterShape["respondToRequest"] = Effect.fn("respondToRequest")( - function* (threadId, requestId, decision) { - const context = yield* requireSession(threadId); - const pending = context.pendingApprovals.get(requestId); - if (!pending) { - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "item/requestApproval/decision", - detail: `Unknown pending approval request: ${requestId}`, - }); - } + yield* Effect.addFinalizer(() => + Effect.forEach( + sessions, + ([, context]) => + stopSessionInternal(context, { + emitExitEvent: false, + }), + { discard: true }, + ).pipe(Effect.tap(() => Queue.shutdown(runtimeEventQueue))), + ); - context.pendingApprovals.delete(requestId); - yield* Deferred.succeed(pending.decision, decision); - }, - ); + const composerCapabilities: ProviderComposerCapabilities = { + provider: PROVIDER, + supportsSkillMentions: true, + supportsSkillDiscovery: true, + supportsRuntimeModelList: false, + }; - const respondToUserInput: ClaudeAdapterShape["respondToUserInput"] = Effect.fn( - "respondToUserInput", - )(function* (threadId, requestId, answers) { - const context = yield* requireSession(threadId); - const pending = context.pendingUserInputs.get(requestId); - if (!pending) { - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "item/tool/respondToUserInput", - detail: `Unknown pending user-input request: ${requestId}`, - }); - } + const getComposerCapabilities: NonNullable< + ClaudeAdapterShape["getComposerCapabilities"] + > = () => Effect.succeed(composerCapabilities); - context.pendingUserInputs.delete(requestId); - yield* Deferred.succeed(pending.answers, answers); + return { + provider: PROVIDER, + capabilities: { + sessionModelSwitch: "in-session", + supportsSkillMentions: true, + supportsSkillDiscovery: true, + supportsRuntimeModelList: false, + }, + startSession, + sendTurn, + interruptTurn, + readThread, + rollbackThread, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + stopAll, + getComposerCapabilities, + listSkills, + streamEvents: Stream.fromQueue(runtimeEventQueue), + } satisfies ClaudeAdapterShape; }); - - const stopSession: ClaudeAdapterShape["stopSession"] = Effect.fn("stopSession")( - function* (threadId) { - const context = yield* requireSession(threadId); - yield* stopSessionInternal(context, { - emitExitEvent: true, - }); - }, - ); - - const listSessions: ClaudeAdapterShape["listSessions"] = () => - Effect.sync(() => Array.from(sessions.values(), ({ session }) => ({ ...session }))); - - const hasSession: ClaudeAdapterShape["hasSession"] = (threadId) => - Effect.sync(() => { - const context = sessions.get(threadId); - return context !== undefined && !context.stopped; - }); - - const stopAll: ClaudeAdapterShape["stopAll"] = () => - Effect.forEach( - sessions, - ([, context]) => - stopSessionInternal(context, { - emitExitEvent: true, - }), - { discard: true }, - ); - - yield* Effect.addFinalizer(() => - Effect.forEach( - sessions, - ([, context]) => - stopSessionInternal(context, { - emitExitEvent: false, - }), - { discard: true }, - ).pipe(Effect.tap(() => Queue.shutdown(runtimeEventQueue))), - ); - - return { - provider: PROVIDER, - capabilities: { - sessionModelSwitch: "in-session", - }, - startSession, - sendTurn, - interruptTurn, - readThread, - rollbackThread, - respondToRequest, - respondToUserInput, - stopSession, - listSessions, - hasSession, - stopAll, - get streamEvents() { - return Stream.fromQueue(runtimeEventQueue); - }, - } satisfies ClaudeAdapterShape; -}); +} export const ClaudeAdapterLive = Layer.effect(ClaudeAdapter, makeClaudeAdapter()); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0c805f42ba..0c0d55ea6c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -239,8 +239,13 @@ function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -function promptIncludesSkillMention(prompt: string, skillName: string): boolean { - const pattern = new RegExp(`(^|\\s)\\$${escapeRegExp(skillName)}(?=\\s|$)`, "i"); +function skillMentionPrefix(provider: string): string { + return provider === "claudeAgent" ? "/" : "$"; +} + +function promptIncludesSkillMention(prompt: string, skillName: string, provider: string): boolean { + const prefix = escapeRegExp(skillMentionPrefix(provider)); + const pattern = new RegExp(`(^|\\s)${prefix}${escapeRegExp(skillName)}(?=\\s|$)`, "i"); return pattern.test(prompt); } @@ -1219,7 +1224,6 @@ export default function ChatView({ threadId }: ChatViewProps) { } if (composerTrigger.kind === "skill") { - if (selectedProvider !== "codex") return []; const query = normalizeSkillSearchText(composerTrigger.query); return providerSkills .filter((skill) => { @@ -1252,9 +1256,7 @@ export default function ChatView({ threadId }: ChatViewProps) { description: `${providerLabel} · ${slug}`, })); }, [composerTrigger, providerSkills, searchableModelOptions, selectedProvider, workspaceEntries]); - const composerMenuOpen = - Boolean(composerTrigger) && - !(composerTrigger?.kind === "skill" && selectedProvider !== "codex"); + const composerMenuOpen = Boolean(composerTrigger); const activeComposerMenuItem = useMemo( () => composerMenuItems.find((item) => item.id === composerHighlightedItemId) ?? @@ -2227,14 +2229,13 @@ export default function ChatView({ threadId }: ChatViewProps) { useEffect(() => { setSelectedComposerSkills((existing) => - existing.filter((skill) => promptIncludesSkillMention(prompt, skill.name)), + existing.filter((skill) => promptIncludesSkillMention(prompt, skill.name, selectedProvider)), ); }, [prompt]); + // Clear selected skills when switching providers — skills are provider-specific. useEffect(() => { - if (selectedProvider !== "codex") { - setSelectedComposerSkills([]); - } + setSelectedComposerSkills([]); }, [selectedProvider]); useEffect(() => { @@ -2873,12 +2874,9 @@ export default function ChatView({ threadId }: ChatViewProps) { effort: selectedPromptEffort, text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, }); - const selectedSkillsForSend = - selectedProvider === "codex" - ? selectedComposerSkills.filter((skill) => - promptIncludesSkillMention(outgoingMessageText, skill.name), - ) - : []; + const selectedSkillsForSend = selectedComposerSkills.filter((skill) => + promptIncludesSkillMention(outgoingMessageText, skill.name, selectedProvider), + ); const turnAttachmentsPromise = Promise.all( composerImagesSnapshot.map(async (image) => ({ type: "image" as const, @@ -3721,7 +3719,7 @@ export default function ChatView({ threadId }: ChatViewProps) { return; } if (item.type === "skill") { - const replacement = `$${item.skill.name} `; + const replacement = `${skillMentionPrefix(selectedProvider)}${item.skill.name} `; const replacementRangeEnd = extendReplacementRangeForTrailingSpace( snapshot.value, trigger.rangeEnd, diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 338d9f7bf1..4df6b45c58 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -69,6 +69,8 @@ import { COMPOSER_INLINE_CHIP_CLASS_NAME, COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, + COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME, + COMPOSER_INLINE_SKILL_CHIP_ICON_CLASS_NAME, } from "./composerInlineChip"; import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts"; @@ -83,6 +85,15 @@ type SerializedComposerMentionNode = Spread< SerializedTextNode >; +type SerializedComposerSkillNode = Spread< + { + skillName: string; + type: "composer-skill"; + version: 1; + }, + SerializedTextNode +>; + type SerializedComposerTerminalContextNode = Spread< { context: TerminalContextDraft; @@ -170,6 +181,107 @@ function $createComposerMentionNode(path: string): ComposerMentionNode { return $applyNodeReplacement(new ComposerMentionNode(path)); } +function skillDisplayName(name: string): string { + return name + .split(/[-_]/) + .map((segment) => + segment.length > 0 ? segment.charAt(0).toUpperCase() + segment.slice(1) : segment, + ) + .join(" "); +} + +const SKILL_CHIP_ICON_SVG = ``; + +function renderSkillChipDom(container: HTMLElement, name: string): void { + container.textContent = ""; + container.style.setProperty("user-select", "none"); + container.style.setProperty("-webkit-user-select", "none"); + + const icon = document.createElement("span"); + icon.ariaHidden = "true"; + icon.className = COMPOSER_INLINE_SKILL_CHIP_ICON_CLASS_NAME; + icon.innerHTML = SKILL_CHIP_ICON_SVG; + + const label = document.createElement("span"); + label.className = COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME; + label.textContent = skillDisplayName(name); + + container.append(icon, label); +} + +class ComposerSkillNode extends TextNode { + __skillName: string; + + static override getType(): string { + return "composer-skill"; + } + + static override clone(node: ComposerSkillNode): ComposerSkillNode { + return new ComposerSkillNode(node.__skillName, node.__key); + } + + static override importJSON(serializedNode: SerializedComposerSkillNode): ComposerSkillNode { + return $createComposerSkillNode(serializedNode.skillName); + } + + constructor(name: string, key?: NodeKey) { + const normalizedName = name.startsWith("$") || name.startsWith("/") ? name.slice(1) : name; + const prefix = name.startsWith("/") ? "/" : "$"; + super(`${prefix}${normalizedName}`, key); + this.__skillName = normalizedName; + } + + override exportJSON(): SerializedComposerSkillNode { + return { + ...super.exportJSON(), + skillName: this.__skillName, + type: "composer-skill", + version: 1, + }; + } + + override createDOM(_config: EditorConfig): HTMLElement { + const dom = document.createElement("span"); + dom.className = COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME; + dom.contentEditable = "false"; + dom.setAttribute("spellcheck", "false"); + renderSkillChipDom(dom, this.__skillName); + return dom; + } + + override updateDOM( + prevNode: ComposerSkillNode, + dom: HTMLElement, + _config: EditorConfig, + ): boolean { + dom.contentEditable = "false"; + if (prevNode.__text !== this.__text || prevNode.__skillName !== this.__skillName) { + renderSkillChipDom(dom, this.__skillName); + } + return false; + } + + override canInsertTextBefore(): false { + return false; + } + + override canInsertTextAfter(): false { + return false; + } + + override isTextEntity(): true { + return true; + } + + override isToken(): true { + return true; + } +} + +function $createComposerSkillNode(name: string): ComposerSkillNode { + return $applyNodeReplacement(new ComposerSkillNode(name)); +} + function ComposerTerminalContextDecorator(props: { context: TerminalContextDraft }) { return ; } @@ -234,11 +346,16 @@ function $createComposerTerminalContextNode( return $applyNodeReplacement(new ComposerTerminalContextNode(context)); } -type ComposerInlineTokenNode = ComposerMentionNode | ComposerTerminalContextNode; +type ComposerInlineTokenNode = + | ComposerMentionNode + | ComposerSkillNode + | ComposerTerminalContextNode; function isComposerInlineTokenNode(candidate: unknown): candidate is ComposerInlineTokenNode { return ( - candidate instanceof ComposerMentionNode || candidate instanceof ComposerTerminalContextNode + candidate instanceof ComposerMentionNode || + candidate instanceof ComposerSkillNode || + candidate instanceof ComposerTerminalContextNode ); } @@ -391,7 +508,7 @@ function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): numb } if ($isTextNode(node)) { - if (node instanceof ComposerMentionNode) { + if (node instanceof ComposerMentionNode || node instanceof ComposerSkillNode) { return getAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); } return offset + Math.min(pointOffset, node.getTextContentSize()); @@ -438,7 +555,7 @@ function getExpandedAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: numbe } if ($isTextNode(node)) { - if (node instanceof ComposerMentionNode) { + if (node instanceof ComposerMentionNode || node instanceof ComposerSkillNode) { return getExpandedAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); } return offset + Math.min(pointOffset, node.getTextContentSize()); @@ -469,7 +586,7 @@ function findSelectionPointAtOffset( node: LexicalNode, remainingRef: { value: number }, ): { key: string; offset: number; type: "text" | "element" } | null { - if (node instanceof ComposerMentionNode) { + if (node instanceof ComposerMentionNode || node instanceof ComposerSkillNode) { return findSelectionPointForInlineToken(node, remainingRef); } if (node instanceof ComposerTerminalContextNode) { @@ -603,6 +720,11 @@ function $setComposerEditorPrompt( paragraph.append($createComposerMentionNode(segment.path)); continue; } + if (segment.type === "skill") { + const prefixedName = `${segment.prefix ?? "$"}${segment.name}`; + paragraph.append($createComposerSkillNode(prefixedName)); + continue; + } if (segment.type === "terminal-context") { if (segment.context) { paragraph.append($createComposerTerminalContextNode(segment.context)); @@ -1093,7 +1215,7 @@ function ComposerPromptEditorInner({ contentEditable={ 0 ? null : ( -
+
{placeholder}
) @@ -1146,7 +1268,7 @@ export const ComposerPromptEditor = forwardRef< () => ({ namespace: "t3tools-composer-editor", editable: true, - nodes: [ComposerMentionNode, ComposerTerminalContextNode], + nodes: [ComposerMentionNode, ComposerSkillNode, ComposerTerminalContextNode], editorState: () => { $setComposerEditorPrompt(initialValueRef.current, initialTerminalContextsRef.current); }, diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index a4f4468279..470e116052 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -5,6 +5,9 @@ import { getVisibleSidebarThreadIds, resolveAdjacentThreadId, getFallbackThreadIdAfterDelete, + getNextVisibleSidebarThreadId, + getRenderedThreadsForSidebarProject, + getVisibleSidebarThreadIds, getVisibleThreadsForProject, getProjectSortTimestamp, hasUnseenCompletion, @@ -621,6 +624,110 @@ describe("getVisibleThreadsForProject", () => { }); }); +describe("getRenderedThreadsForSidebarProject", () => { + it("pins only the active thread when the parent project is collapsed", () => { + const threads = Array.from({ length: 4 }, (_, index) => + makeThread({ + id: ThreadId.makeUnsafe(`thread-${index + 1}`), + title: `Thread ${index + 1}`, + }), + ); + + const result = getRenderedThreadsForSidebarProject({ + project: makeProject({ expanded: false }), + threads, + activeThreadId: ThreadId.makeUnsafe("thread-4"), + isThreadListExpanded: false, + previewLimit: 2, + }); + + expect(result.hasHiddenThreads).toBe(true); + expect(result.renderedThreads.map((thread) => thread.id)).toEqual([ + ThreadId.makeUnsafe("thread-4"), + ]); + }); +}); + +describe("getVisibleSidebarThreadIds", () => { + it("flattens only the sidebar-visible threads in render order", () => { + const projects = [ + makeProject({ id: ProjectId.makeUnsafe("project-1"), expanded: true }), + makeProject({ id: ProjectId.makeUnsafe("project-2"), expanded: false }), + ]; + const threads = [ + makeThread({ + id: ThreadId.makeUnsafe("thread-1"), + projectId: ProjectId.makeUnsafe("project-1"), + createdAt: "2026-03-09T10:01:00.000Z", + }), + makeThread({ + id: ThreadId.makeUnsafe("thread-2"), + projectId: ProjectId.makeUnsafe("project-1"), + createdAt: "2026-03-09T10:02:00.000Z", + }), + makeThread({ + id: ThreadId.makeUnsafe("thread-3"), + projectId: ProjectId.makeUnsafe("project-1"), + createdAt: "2026-03-09T10:03:00.000Z", + }), + makeThread({ + id: ThreadId.makeUnsafe("thread-4"), + projectId: ProjectId.makeUnsafe("project-2"), + createdAt: "2026-03-09T10:04:00.000Z", + }), + makeThread({ + id: ThreadId.makeUnsafe("thread-5"), + projectId: ProjectId.makeUnsafe("project-2"), + createdAt: "2026-03-09T10:05:00.000Z", + }), + ]; + + const visibleThreadIds = getVisibleSidebarThreadIds({ + projects, + threads, + activeThreadId: ThreadId.makeUnsafe("thread-4"), + expandedThreadListsByProject: new Set([ProjectId.makeUnsafe("project-1")]), + previewLimit: 2, + threadSortOrder: "created_at", + }); + + expect(visibleThreadIds).toEqual([ + ThreadId.makeUnsafe("thread-3"), + ThreadId.makeUnsafe("thread-2"), + ThreadId.makeUnsafe("thread-1"), + ThreadId.makeUnsafe("thread-4"), + ]); + }); +}); + +describe("getNextVisibleSidebarThreadId", () => { + const visibleThreadIds = [ + ThreadId.makeUnsafe("thread-1"), + ThreadId.makeUnsafe("thread-2"), + ThreadId.makeUnsafe("thread-3"), + ]; + + it("advances to the next visible thread and wraps at the end", () => { + expect( + getNextVisibleSidebarThreadId({ + visibleThreadIds, + activeThreadId: ThreadId.makeUnsafe("thread-3"), + direction: "forward", + }), + ).toBe(ThreadId.makeUnsafe("thread-1")); + }); + + it("moves backward through the visible list and wraps at the start", () => { + expect( + getNextVisibleSidebarThreadId({ + visibleThreadIds, + activeThreadId: ThreadId.makeUnsafe("thread-1"), + direction: "backward", + }), + ).toBe(ThreadId.makeUnsafe("thread-3")); + }); +}); + function makeProject(overrides: Partial = {}): Project { const { defaultModelSelection, ...rest } = overrides; return { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index ed151c6da8..d148a85c86 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,11 +1,13 @@ -import * as React from "react"; -import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; -import type { SidebarThreadSummary, Thread } from "../types"; +import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "../appSettings"; +import type { Project, Thread } from "../types"; import { cn } from "../lib/utils"; -import { isLatestTurnSettled } from "../session-logic"; +import { + findLatestProposedPlan, + hasActionableProposedPlan, + isLatestTurnSettled, +} from "../session-logic"; export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; -export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100; export type SidebarNewThreadEnvMode = "local" | "worktree"; type SidebarProject = { id: string; @@ -13,12 +15,7 @@ type SidebarProject = { createdAt?: string | undefined; updatedAt?: string | undefined; }; -type SidebarThreadSortInput = Pick & { - latestUserMessageAt?: string | null; - messages?: Pick[]; -}; - -export type ThreadTraversalDirection = "previous" | "next"; +type SidebarThreadSortInput = Pick; export interface ThreadStatusPill { label: @@ -43,101 +40,9 @@ const THREAD_STATUS_PRIORITY: Record = { }; type ThreadStatusInput = Pick< - SidebarThreadSummary, - | "hasActionableProposedPlan" - | "hasPendingApprovals" - | "hasPendingUserInput" - | "interactionMode" - | "latestTurn" - | "session" -> & { - lastVisitedAt?: string | undefined; -}; - -export interface ThreadJumpHintVisibilityController { - sync: (shouldShow: boolean) => void; - dispose: () => void; -} - -export function createThreadJumpHintVisibilityController(input: { - delayMs: number; - onVisibilityChange: (visible: boolean) => void; - setTimeoutFn?: typeof globalThis.setTimeout; - clearTimeoutFn?: typeof globalThis.clearTimeout; -}): ThreadJumpHintVisibilityController { - const setTimeoutFn = input.setTimeoutFn ?? globalThis.setTimeout; - const clearTimeoutFn = input.clearTimeoutFn ?? globalThis.clearTimeout; - let isVisible = false; - let timeoutId: NodeJS.Timeout | null = null; - - const clearPendingShow = () => { - if (timeoutId === null) { - return; - } - clearTimeoutFn(timeoutId); - timeoutId = null; - }; - - return { - sync: (shouldShow) => { - if (!shouldShow) { - clearPendingShow(); - if (isVisible) { - isVisible = false; - input.onVisibilityChange(false); - } - return; - } - - if (isVisible || timeoutId !== null) { - return; - } - - timeoutId = setTimeoutFn(() => { - timeoutId = null; - isVisible = true; - input.onVisibilityChange(true); - }, input.delayMs); - }, - dispose: () => { - clearPendingShow(); - }, - }; -} - -export function useThreadJumpHintVisibility(): { - showThreadJumpHints: boolean; - updateThreadJumpHintsVisibility: (shouldShow: boolean) => void; -} { - const [showThreadJumpHints, setShowThreadJumpHints] = React.useState(false); - const controllerRef = React.useRef(null); - - React.useEffect(() => { - const controller = createThreadJumpHintVisibilityController({ - delayMs: THREAD_JUMP_HINT_SHOW_DELAY_MS, - onVisibilityChange: (visible) => { - setShowThreadJumpHints(visible); - }, - setTimeoutFn: window.setTimeout.bind(window), - clearTimeoutFn: window.clearTimeout.bind(window), - }); - controllerRef.current = controller; - - return () => { - controller.dispose(); - controllerRef.current = null; - }; - }, []); - - const updateThreadJumpHintsVisibility = React.useCallback((shouldShow: boolean) => { - controllerRef.current?.sync(shouldShow); - }, []); - - return { - showThreadJumpHints, - updateThreadJumpHintsVisibility, - }; -} + Thread, + "interactionMode" | "latestTurn" | "lastVisitedAt" | "proposedPlans" | "session" +>; export function hasUnseenCompletion(thread: ThreadStatusInput): boolean { if (!thread.latestTurn?.completedAt) return false; @@ -162,158 +67,45 @@ export function resolveSidebarNewThreadEnvMode(input: { return input.requestedEnvMode ?? input.defaultEnvMode; } -export function resolveSidebarNewThreadSeedContext(input: { - projectId: string; - defaultEnvMode: SidebarNewThreadEnvMode; - activeThread?: { - projectId: string; - branch: string | null; - worktreePath: string | null; - } | null; - activeDraftThread?: { - projectId: string; - branch: string | null; - worktreePath: string | null; - envMode: SidebarNewThreadEnvMode; - } | null; -}): { - branch?: string | null; - worktreePath?: string | null; - envMode: SidebarNewThreadEnvMode; -} { - if (input.activeDraftThread?.projectId === input.projectId) { - return { - branch: input.activeDraftThread.branch, - worktreePath: input.activeDraftThread.worktreePath, - envMode: input.activeDraftThread.envMode, - }; - } - - if (input.activeThread?.projectId === input.projectId) { - return { - branch: input.activeThread.branch, - worktreePath: input.activeThread.worktreePath, - envMode: input.activeThread.worktreePath ? "worktree" : "local", - }; - } - - return { - envMode: input.defaultEnvMode, - }; -} - -export function orderItemsByPreferredIds(input: { - items: readonly TItem[]; - preferredIds: readonly TId[]; - getId: (item: TItem) => TId; -}): TItem[] { - const { getId, items, preferredIds } = input; - if (preferredIds.length === 0) { - return [...items]; - } - - const itemsById = new Map(items.map((item) => [getId(item), item] as const)); - const preferredIdSet = new Set(preferredIds); - const emittedPreferredIds = new Set(); - const ordered = preferredIds.flatMap((id) => { - if (emittedPreferredIds.has(id)) { - return []; - } - const item = itemsById.get(id); - if (!item) { - return []; - } - emittedPreferredIds.add(id); - return [item]; - }); - const remaining = items.filter((item) => !preferredIdSet.has(getId(item))); - return [...ordered, ...remaining]; -} - -export function getVisibleSidebarThreadIds( - renderedProjects: readonly { - shouldShowThreadPanel?: boolean; - renderedThreadIds: readonly TThreadId[]; - }[], -): TThreadId[] { - return renderedProjects.flatMap((renderedProject) => - renderedProject.shouldShowThreadPanel === false ? [] : renderedProject.renderedThreadIds, - ); -} - -export function resolveAdjacentThreadId(input: { - threadIds: readonly T[]; - currentThreadId: T | null; - direction: ThreadTraversalDirection; -}): T | null { - const { currentThreadId, direction, threadIds } = input; - - if (threadIds.length === 0) { - return null; - } - - if (currentThreadId === null) { - return direction === "previous" ? (threadIds.at(-1) ?? null) : (threadIds[0] ?? null); - } - - const currentIndex = threadIds.indexOf(currentThreadId); - if (currentIndex === -1) { - return null; - } - - if (direction === "previous") { - return currentIndex > 0 ? (threadIds[currentIndex - 1] ?? null) : null; - } - - return currentIndex < threadIds.length - 1 ? (threadIds[currentIndex + 1] ?? null) : null; -} - -export function isContextMenuPointerDown(input: { - button: number; - ctrlKey: boolean; - isMac: boolean; -}): boolean { - if (input.button === 2) return true; - return input.isMac && input.button === 0 && input.ctrlKey; -} - export function resolveThreadRowClassName(input: { isActive: boolean; isSelected: boolean; }): string { const baseClassName = - "h-7 w-full translate-x-0 cursor-pointer justify-start px-2 text-left select-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring"; + "h-8 w-full translate-x-0 cursor-pointer justify-start rounded-lg pr-4 pl-8 text-left text-[13px] select-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring"; if (input.isSelected && input.isActive) { return cn( baseClassName, - "bg-primary/22 text-foreground font-medium hover:bg-primary/26 hover:text-foreground dark:bg-primary/30 dark:hover:bg-primary/36", + "bg-primary/16 text-foreground/90 hover:bg-primary/20 hover:text-foreground dark:bg-primary/22 dark:hover:bg-primary/28", ); } if (input.isSelected) { return cn( baseClassName, - "bg-primary/15 text-foreground hover:bg-primary/19 hover:text-foreground dark:bg-primary/22 dark:hover:bg-primary/28", + "bg-primary/12 text-foreground/88 hover:bg-primary/16 hover:text-foreground dark:bg-primary/18 dark:hover:bg-primary/24", ); } if (input.isActive) { return cn( baseClassName, - "bg-accent/85 text-foreground font-medium hover:bg-accent hover:text-foreground dark:bg-accent/55 dark:hover:bg-accent/70", + "bg-accent/62 text-foreground/90 hover:bg-accent/72 hover:text-foreground dark:bg-accent/42 dark:hover:bg-accent/56", ); } - return cn(baseClassName, "text-muted-foreground hover:bg-accent hover:text-foreground"); + return cn(baseClassName, "text-foreground/78 hover:bg-accent/45 hover:text-foreground"); } export function resolveThreadStatusPill(input: { thread: ThreadStatusInput; + hasPendingApprovals: boolean; + hasPendingUserInput: boolean; }): ThreadStatusPill | null { - const { thread } = input; + const { hasPendingApprovals, hasPendingUserInput, thread } = input; - if (thread.hasPendingApprovals) { + if (hasPendingApprovals) { return { label: "Pending Approval", colorClass: "text-amber-600 dark:text-amber-300/90", @@ -322,7 +114,7 @@ export function resolveThreadStatusPill(input: { }; } - if (thread.hasPendingUserInput) { + if (hasPendingUserInput) { return { label: "Awaiting Input", colorClass: "text-indigo-600 dark:text-indigo-300/90", @@ -350,10 +142,12 @@ export function resolveThreadStatusPill(input: { } const hasPlanReadyPrompt = - !thread.hasPendingUserInput && + !hasPendingUserInput && thread.interactionMode === "plan" && isLatestTurnSettled(thread.latestTurn, thread.session) && - thread.hasActionableProposedPlan; + hasActionableProposedPlan( + findLatestProposedPlan(thread.proposedPlans, thread.latestTurn?.turnId ?? null), + ); if (hasPlanReadyPrompt) { return { label: "Plan Ready", @@ -393,15 +187,14 @@ export function resolveProjectStatusIndicator( return highestPriorityStatus; } -export function getVisibleThreadsForProject>(input: { - threads: readonly T[]; - activeThreadId: T["id"] | undefined; +export function getVisibleThreadsForProject(input: { + threads: readonly Thread[]; + activeThreadId: Thread["id"] | undefined; isThreadListExpanded: boolean; previewLimit: number; }): { hasHiddenThreads: boolean; - visibleThreads: T[]; - hiddenThreads: T[]; + visibleThreads: Thread[]; } { const { activeThreadId, isThreadListExpanded, previewLimit, threads } = input; const hasHiddenThreads = threads.length > previewLimit; @@ -409,7 +202,6 @@ export function getVisibleThreadsForProject>(input: if (!hasHiddenThreads || isThreadListExpanded) { return { hasHiddenThreads, - hiddenThreads: [], visibleThreads: [...threads], }; } @@ -418,7 +210,6 @@ export function getVisibleThreadsForProject>(input: if (!activeThreadId || previewThreads.some((thread) => thread.id === activeThreadId)) { return { hasHiddenThreads: true, - hiddenThreads: threads.slice(previewLimit), visibleThreads: previewThreads, }; } @@ -427,7 +218,6 @@ export function getVisibleThreadsForProject>(input: if (!activeThread) { return { hasHiddenThreads: true, - hiddenThreads: threads.slice(previewLimit), visibleThreads: previewThreads, }; } @@ -436,11 +226,110 @@ export function getVisibleThreadsForProject>(input: return { hasHiddenThreads: true, - hiddenThreads: threads.filter((thread) => !visibleThreadIds.has(thread.id)), visibleThreads: threads.filter((thread) => visibleThreadIds.has(thread.id)), }; } +// Match the exact rows the sidebar renders for one project, including folded previews. +export function getRenderedThreadsForSidebarProject(input: { + project: Pick; + threads: readonly Thread[]; + activeThreadId: Thread["id"] | undefined; + isThreadListExpanded: boolean; + previewLimit: number; +}): { + hasHiddenThreads: boolean; + renderedThreads: Thread[]; +} { + const { activeThreadId, isThreadListExpanded, previewLimit, project, threads } = input; + const pinnedCollapsedThread = + !project.expanded && activeThreadId + ? (threads.find((thread) => thread.id === activeThreadId) ?? null) + : null; + const { hasHiddenThreads, visibleThreads } = getVisibleThreadsForProject({ + threads, + activeThreadId, + isThreadListExpanded, + previewLimit, + }); + + return { + hasHiddenThreads, + renderedThreads: pinnedCollapsedThread ? [pinnedCollapsedThread] : visibleThreads, + }; +} + +// Flatten the sidebar's current project/thread visibility into the same order the user sees. +export function getVisibleSidebarThreadIds(input: { + projects: readonly Pick[]; + threads: readonly Thread[]; + activeThreadId: Thread["id"] | undefined; + expandedThreadListsByProject: ReadonlySet; + previewLimit: number; + threadSortOrder: SidebarThreadSortOrder; +}): Thread["id"][] { + const { + activeThreadId, + expandedThreadListsByProject, + previewLimit, + projects, + threadSortOrder, + threads, + } = input; + const visibleThreadIds: Thread["id"][] = []; + + for (const project of projects) { + const projectThreads = sortThreadsForSidebar( + threads.filter((thread) => thread.projectId === project.id), + threadSortOrder, + ); + const { renderedThreads } = getRenderedThreadsForSidebarProject({ + project, + threads: projectThreads, + activeThreadId, + isThreadListExpanded: expandedThreadListsByProject.has(project.id), + previewLimit, + }); + for (const thread of renderedThreads) { + visibleThreadIds.push(thread.id); + } + } + + return visibleThreadIds; +} + +// Resolve the next sidebar-visible thread for keyboard cycling with wraparound. +export function getNextVisibleSidebarThreadId(input: { + visibleThreadIds: readonly Thread["id"][]; + activeThreadId: Thread["id"] | undefined; + direction: "forward" | "backward"; +}): Thread["id"] | null { + const { activeThreadId, direction, visibleThreadIds } = input; + if (visibleThreadIds.length === 0) { + return null; + } + + if (!activeThreadId) { + return direction === "forward" + ? (visibleThreadIds[0] ?? null) + : (visibleThreadIds.at(-1) ?? null); + } + + const activeIndex = visibleThreadIds.findIndex((threadId) => threadId === activeThreadId); + if (activeIndex === -1) { + return direction === "forward" + ? (visibleThreadIds[0] ?? null) + : (visibleThreadIds.at(-1) ?? null); + } + + const nextIndex = + direction === "forward" + ? (activeIndex + 1) % visibleThreadIds.length + : (activeIndex - 1 + visibleThreadIds.length) % visibleThreadIds.length; + + return visibleThreadIds[nextIndex] ?? null; +} + function toSortableTimestamp(iso: string | undefined): number | null { if (!iso) return null; const ms = Date.parse(iso); @@ -448,13 +337,9 @@ function toSortableTimestamp(iso: string | undefined): number | null { } function getLatestUserMessageTimestamp(thread: SidebarThreadSortInput): number { - if (thread.latestUserMessageAt) { - return toSortableTimestamp(thread.latestUserMessageAt) ?? Number.NEGATIVE_INFINITY; - } - let latestUserMessageTimestamp: number | null = null; - for (const message of thread.messages ?? []) { + for (const message of thread.messages) { if (message.role !== "user") continue; const messageTimestamp = toSortableTimestamp(message.createdAt); if (messageTimestamp === null) continue; @@ -482,7 +367,7 @@ function getThreadSortTimestamp( } export function sortThreadsForSidebar< - T extends Pick & SidebarThreadSortInput, + T extends Pick, >(threads: readonly T[], sortOrder: SidebarThreadSortOrder): T[] { return threads.toSorted((left, right) => { const rightTimestamp = getThreadSortTimestamp(right, sortOrder); @@ -495,7 +380,7 @@ export function sortThreadsForSidebar< } export function getFallbackThreadIdAfterDelete< - T extends Pick & SidebarThreadSortInput, + T extends Pick, >(input: { threads: readonly T[]; deletedThreadId: T["id"]; @@ -539,10 +424,7 @@ export function getProjectSortTimestamp( return toSortableTimestamp(project.updatedAt ?? project.createdAt) ?? Number.NEGATIVE_INFINITY; } -export function sortProjectsForSidebar< - TProject extends SidebarProject, - TThread extends Pick & SidebarThreadSortInput, ->( +export function sortProjectsForSidebar( projects: readonly TProject[], threads: readonly TThread[], sortOrder: SidebarProjectSortOrder, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 78453a956b..6c006ea37e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -50,7 +50,7 @@ import { isElectron } from "../env"; import { APP_VERSION } from "../branding"; import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; import { useStore } from "../store"; -import { shortcutLabelForCommand } from "../keybindings"; +import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic"; import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; @@ -97,7 +97,9 @@ import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from " import { isNonEmpty as isNonEmptyString } from "effect/String"; import { getFallbackThreadIdAfterDelete, - getVisibleThreadsForProject, + getNextVisibleSidebarThreadId, + getRenderedThreadsForSidebarProject, + getVisibleSidebarThreadIds, resolveProjectStatusIndicator, resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, @@ -113,6 +115,7 @@ import { resolveHandoffTargetProvider, resolveThreadHandoffBadgeLabel, } from "../lib/threadHandoff"; +import { isTerminalFocused } from "../lib/terminalFocus"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const THREAD_PREVIEW_LIMIT = 6; @@ -434,6 +437,9 @@ export default function Sidebar() { const isLinuxDesktop = isElectron && isLinuxPlatform(navigator.platform); const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop; const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; + const terminalOpen = routeThreadId + ? selectThreadTerminalState(terminalStateByThreadId, routeThreadId).terminalOpen + : false; const projectCwdById = useMemo( () => new Map(projects.map((project) => [project.id, project.cwd] as const)), [projects], @@ -1009,6 +1015,38 @@ export default function Sidebar() { ], ); + // Keep clicks, keyboard activation, and Alt+Tab cycling aligned on the same thread-open path. + const activateThread = useCallback( + (threadId: ThreadId) => { + if (selectedThreadIds.size > 0) { + clearSelection(); + } + setSelectionAnchor(threadId); + const threadEntryPoint = selectThreadTerminalState( + terminalStateByThreadId, + threadId, + ).entryPoint; + if (threadEntryPoint === "terminal") { + openTerminalThreadPage(threadId); + } else { + openChatThreadPage(threadId); + } + void navigate({ + to: "/$threadId", + params: { threadId }, + }); + }, + [ + clearSelection, + navigate, + openChatThreadPage, + openTerminalThreadPage, + selectedThreadIds.size, + setSelectionAnchor, + terminalStateByThreadId, + ], + ); + const handleThreadClick = useCallback( (event: MouseEvent, threadId: ThreadId, orderedProjectThreadIds: readonly ThreadId[]) => { const isMac = isMacPlatform(navigator.platform); @@ -1027,24 +1065,9 @@ export default function Sidebar() { return; } - // Plain click — clear selection, set anchor for future shift-clicks, and navigate - if (selectedThreadIds.size > 0) { - clearSelection(); - } - setSelectionAnchor(threadId); - void navigate({ - to: "/$threadId", - params: { threadId }, - }); + activateThread(threadId); }, - [ - clearSelection, - navigate, - rangeSelectTo, - selectedThreadIds.size, - setSelectionAnchor, - toggleThreadSelection, - ], + [activateThread, rangeSelectTo, toggleThreadSelection], ); const handleProjectContextMenu = useCallback( @@ -1165,6 +1188,24 @@ export default function Sidebar() { () => sortProjectsForSidebar(projects, threads, appSettings.sidebarProjectSortOrder), [appSettings.sidebarProjectSortOrder, projects, threads], ); + const visibleSidebarThreadIds = useMemo( + () => + getVisibleSidebarThreadIds({ + projects: sortedProjects, + threads, + activeThreadId: routeThreadId ?? undefined, + expandedThreadListsByProject, + previewLimit: THREAD_PREVIEW_LIMIT, + threadSortOrder: appSettings.sidebarThreadSortOrder, + }), + [ + appSettings.sidebarThreadSortOrder, + expandedThreadListsByProject, + routeThreadId, + sortedProjects, + threads, + ], + ); const isManualProjectSorting = appSettings.sidebarProjectSortOrder === "manual"; function renderProjectItem( @@ -1186,19 +1227,15 @@ export default function Sidebar() { ); 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 { hasHiddenThreads, visibleThreads } = getVisibleThreadsForProject({ + const { hasHiddenThreads, renderedThreads } = getRenderedThreadsForSidebarProject({ + project, threads: projectThreads, activeThreadId, isThreadListExpanded, previewLimit: THREAD_PREVIEW_LIMIT, }); + const shouldShowThreadPanel = project.expanded || renderedThreads.length > 0; const orderedProjectThreadIds = projectThreads.map((thread) => thread.id); - const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : visibleThreads; const renderThreadRow = (thread: (typeof projectThreads)[number]) => { const threadTerminalState = selectThreadTerminalState(terminalStateByThreadId, thread.id); const threadEntryPoint = threadTerminalState.entryPoint; @@ -1215,13 +1252,6 @@ export default function Sidebar() { const handoffBadgeLabel = resolveThreadHandoffBadgeLabel(thread); const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null); const terminalStatus = terminalStatusFromRunningIds(threadTerminalState.runningTerminalIds); - const openThreadPrimarySurface = () => { - if (threadEntryPoint === "terminal") { - openTerminalThreadPage(thread.id); - return; - } - openChatThreadPage(thread.id); - }; return ( @@ -1241,27 +1271,11 @@ export default function Sidebar() { isActive, isSelected, })} - onClick={(event) => { - const isMac = isMacPlatform(navigator.platform); - const isModClick = isMac ? event.metaKey : event.ctrlKey; - const isShiftClick = event.shiftKey; - if (!isModClick && !isShiftClick) { - openThreadPrimarySurface(); - } - handleThreadClick(event, thread.id, orderedProjectThreadIds); - }} + onClick={(event) => handleThreadClick(event, thread.id, orderedProjectThreadIds)} onKeyDown={(event) => { if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); - if (selectedThreadIds.size > 0) { - clearSelection(); - } - setSelectionAnchor(thread.id); - openThreadPrimarySurface(); - void navigate({ - to: "/$threadId", - params: { threadId: thread.id }, - }); + activateThread(thread.id); }} onContextMenu={(event) => { event.preventDefault(); @@ -1599,6 +1613,37 @@ export default function Sidebar() { }; }, [clearSelection, selectedThreadIds.size]); + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) return; + const command = resolveShortcutCommand(event, keybindings, { + context: { + terminalFocus: isTerminalFocused(), + terminalOpen, + }, + }); + if (command !== "chat.visible.next" && command !== "chat.visible.previous") { + return; + } + + const nextThreadId = getNextVisibleSidebarThreadId({ + visibleThreadIds: visibleSidebarThreadIds, + activeThreadId: routeThreadId ?? undefined, + direction: command === "chat.visible.previous" ? "backward" : "forward", + }); + if (!nextThreadId || nextThreadId === routeThreadId) return; + + event.preventDefault(); + event.stopPropagation(); + activateThread(nextThreadId); + }; + + window.addEventListener("keydown", onKeyDown, { capture: true }); + return () => { + window.removeEventListener("keydown", onKeyDown, { capture: true }); + }; + }, [activateThread, keybindings, routeThreadId, terminalOpen, visibleSidebarThreadIds]); + useEffect(() => { if (!isElectron) return; const bridge = window.desktopBridge; diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index fc7ea27c29..8b938093ea 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -1,7 +1,12 @@ -import { type ProjectEntry, type ProviderKind } from "@t3tools/contracts"; -import { memo, useLayoutEffect, useRef } from "react"; +import { + type ProjectEntry, + type ModelSlug, + type ProviderKind, + type ProviderSkillDescriptor, +} from "@t3tools/contracts"; +import { memo } from "react"; import { type ComposerSlashCommand, type ComposerTriggerKind } from "../../composer-logic"; -import { BotIcon } from "lucide-react"; +import { BotIcon, CubeIcon } from "~/lib/icons"; import { cn } from "~/lib/utils"; import { Badge } from "../ui/badge"; import { Command, CommandItem, CommandList } from "../ui/command"; @@ -27,7 +32,14 @@ export type ComposerCommandItem = id: string; type: "model"; provider: ProviderKind; - model: string; + model: ModelSlug; + label: string; + description: string; + } + | { + id: string; + type: "skill"; + skill: ProviderSkillDescriptor; label: string; description: string; }; @@ -41,19 +53,8 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { onHighlightedItemChange: (itemId: string | null) => void; onSelect: (item: ComposerCommandItem) => void; }) { - const listRef = useRef(null); - - useLayoutEffect(() => { - if (!props.activeItemId || !listRef.current) return; - const el = listRef.current.querySelector( - `[data-composer-item-id="${CSS.escape(props.activeItemId)}"]`, - ); - el?.scrollIntoView({ block: "nearest" }); - }, [props.activeItemId]); - return ( { props.onHighlightedItemChange( @@ -61,29 +62,27 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { ); }} > -
- +
+ {props.items.map((item) => ( ))} {props.items.length === 0 && ( -

+

{props.isLoading ? "Searching workspace files..." : props.triggerKind === "path" ? "No matching files or folders." - : "No matching command."} + : props.triggerKind === "skill" + ? "No matching skill." + : "No matching command."}

)}
@@ -91,24 +90,63 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { ); }); +function formatSkillScope(scope: string | undefined): string { + if (!scope) return "Personal"; + const normalized = scope.trim(); + if (normalized.length === 0) return "Personal"; + return normalized.charAt(0).toUpperCase() + normalized.slice(1); +} + const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { item: ComposerCommandItem; resolvedTheme: "light" | "dark"; isActive: boolean; - onHighlight: (itemId: string | null) => void; onSelect: (item: ComposerCommandItem) => void; }) { + if (props.item.type === "skill") { + return ( + { + event.preventDefault(); + }} + onClick={() => { + props.onSelect(props.item); + }} + > +
+
+ +
+
+ + {props.item.label} + + + {props.item.description} + +
+
+ {formatSkillScope(props.item.skill.scope)} +
+
+
+ ); + } + return ( { - if (!props.isActive) props.onHighlight(props.item.id); - }} onMouseDown={(event) => { event.preventDefault(); }} @@ -124,17 +162,19 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { /> ) : null} {props.item.type === "slash-command" ? ( - + ) : null} {props.item.type === "model" ? ( - + model ) : null} - + {props.item.label} - {props.item.description} + + {props.item.description} + ); }); diff --git a/apps/web/src/composer-editor-mentions.test.ts b/apps/web/src/composer-editor-mentions.test.ts index 1f0a07e096..67f9016f78 100644 --- a/apps/web/src/composer-editor-mentions.test.ts +++ b/apps/web/src/composer-editor-mentions.test.ts @@ -18,6 +18,38 @@ describe("splitPromptIntoComposerSegments", () => { ]); }); + it("does not convert an incomplete trailing dollar skill token", () => { + expect(splitPromptIntoComposerSegments("Use $check-code")).toEqual([ + { type: "text", text: "Use $check-code" }, + ]); + }); + + it("does not convert an incomplete trailing slash skill token", () => { + expect(splitPromptIntoComposerSegments("Use /check-code")).toEqual([ + { type: "text", text: "Use /check-code" }, + ]); + }); + + it("converts completed skill tokens once a trailing delimiter exists", () => { + expect(splitPromptIntoComposerSegments("Use $check-code please")).toEqual([ + { type: "text", text: "Use " }, + { type: "skill", name: "check-code", prefix: "$" }, + { type: "text", text: " please" }, + ]); + expect(splitPromptIntoComposerSegments("Use /check-code please")).toEqual([ + { type: "text", text: "Use " }, + { type: "skill", name: "check-code", prefix: "/" }, + { type: "text", text: " please" }, + ]); + }); + + it("keeps built-in slash commands as plain text", () => { + expect(splitPromptIntoComposerSegments("/plan ")).toEqual([{ type: "text", text: "/plan " }]); + expect(splitPromptIntoComposerSegments("/model spark")).toEqual([ + { type: "text", text: "/model spark" }, + ]); + }); + it("keeps newlines around mention tokens", () => { expect(splitPromptIntoComposerSegments("one\n@src/index.ts \ntwo")).toEqual([ { type: "text", text: "one\n" }, diff --git a/apps/web/src/composer-editor-mentions.ts b/apps/web/src/composer-editor-mentions.ts index fa1761480c..492c676325 100644 --- a/apps/web/src/composer-editor-mentions.ts +++ b/apps/web/src/composer-editor-mentions.ts @@ -12,12 +12,19 @@ export type ComposerPromptSegment = type: "mention"; path: string; } + | { + type: "skill"; + name: string; + prefix?: string; + } | { type: "terminal-context"; context: TerminalContextDraft | null; }; const MENTION_TOKEN_REGEX = /(^|\s)@([^\s@]+)(?=\s)/g; +const SKILL_TOKEN_REGEX = /(^|\s)([$/])([a-zA-Z][a-zA-Z0-9_:-]*)(?=\s)/g; +const BUILT_IN_SLASH_COMMANDS = new Set(["default", "model", "plan"]); function pushTextSegment(segments: ComposerPromptSegment[], text: string): void { if (!text) return; @@ -29,32 +36,78 @@ function pushTextSegment(segments: ComposerPromptSegment[], text: string): void segments.push({ type: "text", text }); } +type InlineTokenMatch = { + kind: "mention" | "skill"; + value: string; + skillPrefix?: string; + start: number; + end: number; +}; + +function collectInlineTokenMatches(text: string): InlineTokenMatch[] { + const matches: InlineTokenMatch[] = []; + + for (const match of text.matchAll(MENTION_TOKEN_REGEX)) { + const fullMatch = match[0]; + const prefix = match[1] ?? ""; + const path = match[2] ?? ""; + const matchIndex = match.index ?? 0; + const start = matchIndex + prefix.length; + const end = start + fullMatch.length - prefix.length; + if (path.length > 0) { + matches.push({ kind: "mention", value: path, start, end }); + } + } + + for (const match of text.matchAll(SKILL_TOKEN_REGEX)) { + const fullMatch = match[0]; + const whitespace = match[1] ?? ""; + const skillPrefix = match[2] ?? "$"; + const name = match[3] ?? ""; + const matchIndex = match.index ?? 0; + const start = matchIndex + whitespace.length; + const end = start + fullMatch.length - whitespace.length; + // Keep raw `$foo` and `/foo` text editable while the user is still typing. + // We only chipify skill mentions once a delimiter exists, and we never + // reinterpret built-in slash commands as provider skills. + if ( + name.length > 0 && + !(skillPrefix === "/" && BUILT_IN_SLASH_COMMANDS.has(name.toLowerCase())) + ) { + matches.push({ kind: "skill", value: name, skillPrefix, start, end }); + } + } + + matches.sort((a, b) => a.start - b.start); + return matches; +} + function splitPromptTextIntoComposerSegments(text: string): ComposerPromptSegment[] { const segments: ComposerPromptSegment[] = []; if (!text) { return segments; } + const matches = collectInlineTokenMatches(text); let cursor = 0; - for (const match of text.matchAll(MENTION_TOKEN_REGEX)) { - const fullMatch = match[0]; - const prefix = match[1] ?? ""; - const path = match[2] ?? ""; - const matchIndex = match.index ?? 0; - const mentionStart = matchIndex + prefix.length; - const mentionEnd = mentionStart + fullMatch.length - prefix.length; - if (mentionStart > cursor) { - pushTextSegment(segments, text.slice(cursor, mentionStart)); + for (const match of matches) { + if (match.start < cursor) continue; + + if (match.start > cursor) { + pushTextSegment(segments, text.slice(cursor, match.start)); } - if (path.length > 0) { - segments.push({ type: "mention", path }); + if (match.kind === "mention") { + segments.push({ type: "mention", path: match.value }); } else { - pushTextSegment(segments, text.slice(mentionStart, mentionEnd)); + const skillSegment: ComposerPromptSegment = match.skillPrefix + ? { type: "skill", name: match.value, prefix: match.skillPrefix } + : { type: "skill", name: match.value }; + segments.push(skillSegment); } - cursor = mentionEnd; + cursor = match.end; } if (cursor < text.length) { diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index 44f32bef9a..2e731557ea 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -205,6 +205,15 @@ describe("isCollapsedCursorAdjacentToInlineToken", () => { expect(isCollapsedCursorAdjacentToInlineToken(text, text.length, "right")).toBe(false); }); + it("keeps raw skill triggers non-adjacent while typing", () => { + expect(isCollapsedCursorAdjacentToInlineToken("hello $che", "hello $che".length, "left")).toBe( + false, + ); + expect(isCollapsedCursorAdjacentToInlineToken("hello /che", "hello /che".length, "right")).toBe( + false, + ); + }); + it("detects left adjacency only when cursor is directly after a mention", () => { const text = "open @AGENTS.md next"; const mentionStart = "open ".length; diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index c8e62ebdcc..40868c7c25 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -209,7 +209,14 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos rangeEnd: cursor, }; } - return null; + // `/query` that doesn't match a built-in command → treat as skill trigger + // so providers with slash-command skills (e.g. Claude) can show suggestions. + return { + kind: "skill", + query: commandQuery, + rangeStart: lineStart, + rangeEnd: cursor, + }; } const modelMatch = /^\/model(?:\s+(.*))?$/.exec(linePrefix); diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index eba0bd3b46..37e0eae452 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -8,22 +8,20 @@ import { } from "@t3tools/contracts"; import { formatShortcutLabel, + isBrowserToggleShortcut, isChatNewShortcut, isChatNewLocalShortcut, isDiffToggleShortcut, isOpenFavoriteEditorShortcut, + isSidebarToggleShortcut, isTerminalClearShortcut, isTerminalCloseShortcut, isTerminalNewShortcut, isTerminalSplitShortcut, isTerminalToggleShortcut, resolveShortcutCommand, - shouldShowThreadJumpHints, shortcutLabelForCommand, terminalNavigationShortcutData, - threadJumpCommandForIndex, - threadJumpIndexFromCommand, - threadTraversalDirectionFromCommand, type ShortcutEventLike, } from "./keybindings"; @@ -80,6 +78,11 @@ function compile(bindings: TestBinding[]): ResolvedKeybindingsConfig { } const DEFAULT_BINDINGS = compile([ + { + shortcut: modShortcut("b"), + command: "sidebar.toggle", + whenAst: whenNot(whenIdentifier("terminalFocus")), + }, { shortcut: modShortcut("j"), command: "terminal.toggle" }, { shortcut: modShortcut("d"), @@ -87,7 +90,7 @@ const DEFAULT_BINDINGS = compile([ whenAst: whenIdentifier("terminalFocus"), }, { - shortcut: modShortcut("d", { shiftKey: true }), + shortcut: modShortcut("n", { shiftKey: true }), command: "terminal.new", whenAst: whenIdentifier("terminalFocus"), }, @@ -96,19 +99,62 @@ const DEFAULT_BINDINGS = compile([ command: "terminal.close", whenAst: whenIdentifier("terminalFocus"), }, + { + shortcut: modShortcut("j", { shiftKey: true }), + command: "terminal.workspace.newFullWidth", + }, + { + shortcut: modShortcut("w"), + command: "terminal.workspace.closeActive", + whenAst: whenIdentifier("terminalWorkspaceOpen"), + }, + { + shortcut: modShortcut("1"), + command: "terminal.workspace.terminal", + whenAst: whenIdentifier("terminalWorkspaceOpen"), + }, + { + shortcut: modShortcut("2"), + command: "terminal.workspace.chat", + whenAst: whenIdentifier("terminalWorkspaceOpen"), + }, { shortcut: modShortcut("d"), command: "diff.toggle", whenAst: whenNot(whenIdentifier("terminalFocus")), }, - { shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" }, - { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newLocal" }, + { + shortcut: modShortcut("b", { shiftKey: true }), + command: "browser.toggle", + whenAst: whenNot(whenIdentifier("terminalFocus")), + }, + { + shortcut: modShortcut("o", { shiftKey: true }), + command: "chat.new", + whenAst: whenNot(whenIdentifier("terminalFocus")), + }, + { shortcut: modShortcut("n"), command: "chat.new" }, + { + shortcut: modShortcut("n", { shiftKey: true }), + command: "chat.newLocal", + whenAst: whenNot(whenIdentifier("terminalFocus")), + }, + { + shortcut: modShortcut("t", { shiftKey: true }), + command: "chat.newTerminal", + whenAst: whenNot(whenIdentifier("terminalFocus")), + }, + { + shortcut: modShortcut("]", { shiftKey: true }), + command: "chat.visible.next", + whenAst: whenNot(whenIdentifier("terminalFocus")), + }, + { + shortcut: modShortcut("[", { shiftKey: true }), + command: "chat.visible.previous", + whenAst: whenNot(whenIdentifier("terminalFocus")), + }, { shortcut: modShortcut("o"), command: "editor.openFavorite" }, - { shortcut: modShortcut("[", { shiftKey: true }), command: "thread.previous" }, - { shortcut: modShortcut("]", { shiftKey: true }), command: "thread.next" }, - { shortcut: modShortcut("1"), command: "thread.jump.1" }, - { shortcut: modShortcut("2"), command: "thread.jump.2" }, - { shortcut: modShortcut("3"), command: "thread.jump.3" }, ]); describe("isTerminalToggleShortcut", () => { @@ -157,7 +203,7 @@ describe("split/new/close terminal shortcuts", () => { }), ); assert.isTrue( - isTerminalNewShortcut(event({ key: "d", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + isTerminalNewShortcut(event({ key: "n", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { platform: "Linux", context: { terminalFocus: true }, }), @@ -223,8 +269,93 @@ describe("split/new/close terminal shortcuts", () => { }); }); +describe("workspace terminal tab shortcuts", () => { + it("resolves the full-width terminal shortcut", () => { + assert.strictEqual( + resolveShortcutCommand(event({ key: "j", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + }), + "terminal.workspace.newFullWidth", + ); + }); + + it("resolves the active workspace close shortcut only while the terminal workspace is open", () => { + assert.strictEqual( + resolveShortcutCommand(event({ key: "w", metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalWorkspaceOpen: true, terminalFocus: true }, + }), + "terminal.workspace.closeActive", + ); + assert.isNull( + resolveShortcutCommand(event({ key: "w", metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalWorkspaceOpen: false, terminalFocus: false }, + }), + ); + }); + + it("resolves Cmd/Ctrl+1 and Cmd/Ctrl+2 only while the terminal workspace is open", () => { + assert.strictEqual( + resolveShortcutCommand(event({ key: "1", metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalWorkspaceOpen: true }, + }), + "terminal.workspace.terminal", + ); + assert.strictEqual( + resolveShortcutCommand(event({ key: "2", ctrlKey: true }), DEFAULT_BINDINGS, { + platform: "Linux", + context: { terminalWorkspaceOpen: true }, + }), + "terminal.workspace.chat", + ); + assert.isNull( + resolveShortcutCommand(event({ key: "1", metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalWorkspaceOpen: false }, + }), + ); + }); + + it("falls back to workspace defaults when the runtime config is missing them", () => { + const legacyBindings = DEFAULT_BINDINGS.filter( + (binding) => + binding.command !== "terminal.workspace.newFullWidth" && + binding.command !== "terminal.workspace.closeActive" && + binding.command !== "terminal.workspace.terminal" && + binding.command !== "terminal.workspace.chat", + ); + + assert.strictEqual( + resolveShortcutCommand(event({ key: "j", metaKey: true, shiftKey: true }), legacyBindings, { + platform: "MacIntel", + }), + "terminal.workspace.newFullWidth", + ); + assert.strictEqual( + resolveShortcutCommand(event({ key: "w", metaKey: true }), legacyBindings, { + platform: "MacIntel", + context: { terminalWorkspaceOpen: true }, + }), + "terminal.workspace.closeActive", + ); + assert.strictEqual( + resolveShortcutCommand(event({ key: "1", metaKey: true }), legacyBindings, { + platform: "MacIntel", + context: { terminalWorkspaceOpen: true }, + }), + "terminal.workspace.terminal", + ); + assert.strictEqual( + shortcutLabelForCommand(legacyBindings, "terminal.workspace.chat", "Linux"), + "Ctrl+2", + ); + }); +}); + describe("shortcutLabelForCommand", () => { - it("returns the effective binding label", () => { + it("returns the most recent binding label", () => { const bindings = compile([ { shortcut: modShortcut("\\"), @@ -238,106 +369,49 @@ describe("shortcutLabelForCommand", () => { }, ]); assert.strictEqual( - shortcutLabelForCommand(bindings, "terminal.split", { - platform: "Linux", - context: { terminalFocus: false }, - }), + shortcutLabelForCommand(bindings, "terminal.split", "Linux"), "Ctrl+Shift+\\", ); }); - it("returns effective labels for non-terminal commands", () => { - assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⇧⌘O"); - assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "diff.toggle", "Linux"), "Ctrl+D"); + it("returns labels for non-terminal commands", () => { + assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⌘N"); assert.strictEqual( - shortcutLabelForCommand(DEFAULT_BINDINGS, "editor.openFavorite", "Linux"), - "Ctrl+O", + shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.newTerminal", "MacIntel"), + "⇧⌘T", ); + assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "diff.toggle", "Linux"), "Ctrl+D"); assert.strictEqual( - shortcutLabelForCommand(DEFAULT_BINDINGS, "thread.jump.3", "MacIntel"), - "⌘3", + shortcutLabelForCommand(DEFAULT_BINDINGS, "sidebar.toggle", "MacIntel"), + "⌘B", ); assert.strictEqual( - shortcutLabelForCommand(DEFAULT_BINDINGS, "thread.previous", "Linux"), - "Ctrl+Shift+[", + shortcutLabelForCommand(DEFAULT_BINDINGS, "browser.toggle", "MacIntel"), + "⇧⌘B", ); - }); - - it("returns null for commands shadowed by a later conflicting shortcut", () => { - const bindings = compile([ - { shortcut: modShortcut("1", { shiftKey: true }), command: "thread.jump.1" }, - { shortcut: modShortcut("1", { shiftKey: true }), command: "thread.jump.7" }, - ]); - - assert.isNull(shortcutLabelForCommand(bindings, "thread.jump.1", "MacIntel")); - assert.strictEqual(shortcutLabelForCommand(bindings, "thread.jump.7", "MacIntel"), "⇧⌘1"); - }); - - it("respects when-context while resolving labels", () => { - const bindings = compile([ - { shortcut: modShortcut("d"), command: "diff.toggle" }, - { - shortcut: modShortcut("d"), - command: "terminal.split", - whenAst: whenIdentifier("terminalFocus"), - }, - ]); - assert.strictEqual( - shortcutLabelForCommand(bindings, "diff.toggle", { - platform: "Linux", - context: { terminalFocus: false }, - }), - "Ctrl+D", + shortcutLabelForCommand(DEFAULT_BINDINGS, "terminal.workspace.terminal", "MacIntel"), + "⌘1", ); - assert.isNull( - shortcutLabelForCommand(bindings, "diff.toggle", { - platform: "Linux", - context: { terminalFocus: true }, - }), + assert.strictEqual( + shortcutLabelForCommand(DEFAULT_BINDINGS, "terminal.workspace.newFullWidth", "MacIntel"), + "⇧⌘J", ); assert.strictEqual( - shortcutLabelForCommand(bindings, "terminal.split", { - platform: "Linux", - context: { terminalFocus: true }, - }), - "Ctrl+D", + shortcutLabelForCommand(DEFAULT_BINDINGS, "terminal.workspace.chat", "Linux"), + "Ctrl+2", ); - }); -}); - -describe("thread navigation helpers", () => { - it("maps jump commands to visible thread indices", () => { - assert.strictEqual(threadJumpCommandForIndex(0), "thread.jump.1"); - assert.strictEqual(threadJumpCommandForIndex(2), "thread.jump.3"); - assert.isNull(threadJumpCommandForIndex(9)); - assert.strictEqual(threadJumpIndexFromCommand("thread.jump.1"), 0); - assert.strictEqual(threadJumpIndexFromCommand("thread.jump.3"), 2); - assert.isNull(threadJumpIndexFromCommand("thread.next")); - }); - - it("maps traversal commands to directions", () => { - assert.strictEqual(threadTraversalDirectionFromCommand("thread.previous"), "previous"); - assert.strictEqual(threadTraversalDirectionFromCommand("thread.next"), "next"); - assert.isNull(threadTraversalDirectionFromCommand("thread.jump.1")); - assert.isNull(threadTraversalDirectionFromCommand(null)); - }); - - it("shows jump hints only when configured modifiers match", () => { - assert.isTrue( - shouldShowThreadJumpHints(event({ metaKey: true }), DEFAULT_BINDINGS, { - platform: "MacIntel", - }), + assert.strictEqual( + shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.visible.next", "MacIntel"), + "⇧⌘]", ); - assert.isFalse( - shouldShowThreadJumpHints(event({ metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { - platform: "MacIntel", - }), + assert.strictEqual( + shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.visible.previous", "MacIntel"), + "⇧⌘[", ); - assert.isTrue( - shouldShowThreadJumpHints(event({ ctrlKey: true }), DEFAULT_BINDINGS, { - platform: "Linux", - }), + assert.strictEqual( + shortcutLabelForCommand(DEFAULT_BINDINGS, "editor.openFavorite", "Linux"), + "Ctrl+O", ); }); }); @@ -345,12 +419,12 @@ describe("thread navigation helpers", () => { describe("chat/editor shortcuts", () => { it("matches chat.new shortcut", () => { assert.isTrue( - isChatNewShortcut(event({ key: "o", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + isChatNewShortcut(event({ key: "n", metaKey: true }), DEFAULT_BINDINGS, { platform: "MacIntel", }), ); assert.isTrue( - isChatNewShortcut(event({ key: "o", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + isChatNewShortcut(event({ key: "n", ctrlKey: true }), DEFAULT_BINDINGS, { platform: "Linux", }), ); @@ -369,6 +443,53 @@ describe("chat/editor shortcuts", () => { ); }); + it("resolves chat.newTerminal shortcut", () => { + assert.strictEqual( + resolveShortcutCommand(event({ key: "t", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false }, + }), + "chat.newTerminal", + ); + }); + + it("resolves visible chat cycle shortcuts", () => { + assert.strictEqual( + resolveShortcutCommand(event({ key: "]", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false }, + }), + "chat.visible.next", + ); + assert.strictEqual( + resolveShortcutCommand(event({ key: "[", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false }, + }), + "chat.visible.previous", + ); + assert.strictEqual( + resolveShortcutCommand(event({ key: "}", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false }, + }), + "chat.visible.next", + ); + assert.strictEqual( + resolveShortcutCommand(event({ key: "{", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false }, + }), + "chat.visible.previous", + ); + assert.isNull( + resolveShortcutCommand(event({ key: "]", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: true }, + }), + ); + }); + it("matches editor.openFavorite shortcut", () => { assert.isTrue( isOpenFavoriteEditorShortcut(event({ key: "o", metaKey: true }), DEFAULT_BINDINGS, { @@ -396,6 +517,44 @@ describe("chat/editor shortcuts", () => { }), ); }); + + it("matches sidebar.toggle shortcut outside terminal focus", () => { + assert.isTrue( + isSidebarToggleShortcut(event({ key: "b", metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false }, + }), + ); + assert.isFalse( + isSidebarToggleShortcut(event({ key: "b", metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: true }, + }), + ); + }); + + it("matches browser.toggle shortcut outside terminal focus", () => { + assert.isTrue( + isBrowserToggleShortcut( + event({ key: "b", metaKey: true, shiftKey: true }), + DEFAULT_BINDINGS, + { + platform: "MacIntel", + context: { terminalFocus: false }, + }, + ), + ); + assert.isFalse( + isBrowserToggleShortcut( + event({ key: "b", metaKey: true, shiftKey: true }), + DEFAULT_BINDINGS, + { + platform: "MacIntel", + context: { terminalFocus: true }, + }, + ), + ); + }); }); describe("cross-command precedence", () => { @@ -471,29 +630,6 @@ describe("resolveShortcutCommand", () => { "script.setup.run", ); }); - - it("matches bracket shortcuts using the physical key code", () => { - assert.strictEqual( - resolveShortcutCommand( - event({ key: "{", code: "BracketLeft", metaKey: true, shiftKey: true }), - DEFAULT_BINDINGS, - { - platform: "MacIntel", - }, - ), - "thread.previous", - ); - assert.strictEqual( - resolveShortcutCommand( - event({ key: "}", code: "BracketRight", ctrlKey: true, shiftKey: true }), - DEFAULT_BINDINGS, - { - platform: "Linux", - }, - ), - "thread.next", - ); - }); }); describe("formatShortcutLabel", () => { diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 286454dc05..e50d5585d1 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -55,6 +55,8 @@ const EVENT_CODE_KEY_ALIASES: Readonly> = { function normalizeEventKey(key: string): string { const normalized = key.toLowerCase(); if (normalized === "esc") return "escape"; + if (normalized === "{") return "["; + if (normalized === "}") return "]"; return normalized; } diff --git a/apps/web/src/lib/icons.tsx b/apps/web/src/lib/icons.tsx new file mode 100644 index 0000000000..aebb7f769d --- /dev/null +++ b/apps/web/src/lib/icons.tsx @@ -0,0 +1,140 @@ +import { type FC, type SVGProps } from "react"; +import { PiGitCommit } from "react-icons/pi"; +import { + IconAlertCircle, + IconAlertTriangle, + IconArrowBackUp, + IconArrowLeft, + IconArrowRight, + IconArrowsSplit2, + IconArrowsUpDown, + IconBolt, + IconBug, + IconCheck, + IconChevronDown, + IconChevronLeft, + IconChevronRight, + IconChevronUp, + IconCircleCheck, + IconCloudUpload, + IconColumns2, + IconCopy, + IconCube, + IconDots, + IconExternalLink, + IconEye, + IconFile, + IconFlask2, + IconFolder, + IconFolderOpen, + IconGitCompare, + IconGitFork, + IconGitPullRequest, + IconEdit, + IconInfoCircle, + IconLayoutSidebarLeftCollapse, + IconLayoutSidebarLeftExpand, + IconLayoutSidebarRightCollapse, + IconLayoutDistributeHorizontal, + IconListCheck, + IconListDetails, + IconLoader2, + IconLock, + IconLockOpen, + IconMaximize, + IconMinimize, + IconPlayerPlay, + IconPlus, + IconRefresh, + IconRocket, + IconRobot, + IconRotate2, + IconSearch, + IconSelector, + IconSettings, + IconTerminal, + IconTerminal2, + IconTextWrap, + IconTool, + IconTrash, + IconWorld, + IconX, + type TablerIcon, +} from "@tabler/icons-react"; + +// Keep the existing icon API stable while the app moves from Lucide to Tabler. +export type LucideIcon = FC>; + +function adaptIcon(Component: TablerIcon): LucideIcon { + return function AdaptedIcon(props) { + return ; + }; +} + +export const ArrowLeftIcon = adaptIcon(IconArrowLeft); +export const ArrowRightIcon = adaptIcon(IconArrowRight); +export const ArrowUpDownIcon = adaptIcon(IconArrowsUpDown); +export const BotIcon = adaptIcon(IconRobot); +export const BugIcon = adaptIcon(IconBug); +export const CheckIcon = adaptIcon(IconCheck); +export const ChevronDownIcon = adaptIcon(IconChevronDown); +export const ChevronLeftIcon = adaptIcon(IconChevronLeft); +export const ChevronRightIcon = adaptIcon(IconChevronRight); +export const ChevronUpIcon = adaptIcon(IconChevronUp); +export const ChevronsUpDownIcon = adaptIcon(IconSelector); +export const CircleAlertIcon = adaptIcon(IconAlertCircle); +export const CircleCheckIcon = adaptIcon(IconCircleCheck); +export const CloudUploadIcon = adaptIcon(IconCloudUpload); +export const Columns2Icon = adaptIcon(IconColumns2); +export const CopyIcon = adaptIcon(IconCopy); +export const DiffIcon = adaptIcon(IconGitCompare); +export const EllipsisIcon = adaptIcon(IconDots); +export const ExternalLinkIcon = adaptIcon(IconExternalLink); +export const EyeIcon = adaptIcon(IconEye); +export const FileIcon = adaptIcon(IconFile); +export const FlaskConicalIcon = adaptIcon(IconFlask2); +export const FolderClosedIcon = adaptIcon(IconFolder); +export const FolderIcon = adaptIcon(IconFolder); +export const FolderOpenIcon = adaptIcon(IconFolderOpen); +export const GitCommitIcon: LucideIcon = (props) => ( + +); +export const GitForkIcon = adaptIcon(IconGitFork); +export const GitPullRequestIcon = adaptIcon(IconGitPullRequest); +export const GlobeIcon = adaptIcon(IconWorld); +export const CubeIcon = adaptIcon(IconCube); +export const HammerIcon = adaptIcon(IconTool); +export const InfoIcon = adaptIcon(IconInfoCircle); +export const ListChecksIcon = adaptIcon(IconListCheck); +export const ListTodoIcon = adaptIcon(IconListDetails); +export const Loader2Icon = adaptIcon(IconLoader2); +export const LoaderCircleIcon = adaptIcon(IconLoader2); +export const LoaderIcon = adaptIcon(IconLoader2); +export const LockIcon = adaptIcon(IconLock); +export const LockOpenIcon = adaptIcon(IconLockOpen); +export const Maximize2 = adaptIcon(IconMaximize); +export const Minimize2 = adaptIcon(IconMinimize); +export const PanelLeftCloseIcon = adaptIcon(IconLayoutSidebarLeftCollapse); +export const PanelLeftIcon = adaptIcon(IconLayoutSidebarLeftExpand); +export const PanelRightCloseIcon = adaptIcon(IconLayoutSidebarRightCollapse); +export const PlayIcon = adaptIcon(IconPlayerPlay); +export const Plus = adaptIcon(IconPlus); +export const PlusIcon = adaptIcon(IconPlus); +export const RefreshCwIcon = adaptIcon(IconRefresh); +export const RocketIcon = adaptIcon(IconRocket); +export const RotateCcwIcon = adaptIcon(IconRotate2); +export const Rows3Icon = adaptIcon(IconLayoutDistributeHorizontal); +export const SearchIcon = adaptIcon(IconSearch); +export const SettingsIcon = adaptIcon(IconSettings); +export const SquarePenIcon = adaptIcon(IconEdit); +export const SquareSplitHorizontal = adaptIcon(IconArrowsSplit2); +export const TerminalIcon = adaptIcon(IconTerminal); +export const TerminalSquare = adaptIcon(IconTerminal2); +export const TerminalSquareIcon = adaptIcon(IconTerminal2); +export const TextWrapIcon = adaptIcon(IconTextWrap); +export const Trash2 = adaptIcon(IconTrash); +export const TriangleAlertIcon = adaptIcon(IconAlertTriangle); +export const Undo2Icon = adaptIcon(IconArrowBackUp); +export const WrenchIcon = adaptIcon(IconTool); +export const XIcon = adaptIcon(IconX); +export const ZapIcon = adaptIcon(IconBolt); diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index c3a7d9f00e..7ebf10d535 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -24,10 +24,16 @@ const decodeResolvedRule = Schema.decodeUnknownEffect(ResolvedKeybindingRule as it.effect("parses keybinding rules", () => Effect.gen(function* () { const parsed = yield* decode(KeybindingRule, { + key: "mod+b", + command: "sidebar.toggle", + }); + assert.strictEqual(parsed.command, "sidebar.toggle"); + + const parsedTerminalToggle = yield* decode(KeybindingRule, { key: "mod+j", command: "terminal.toggle", }); - assert.strictEqual(parsed.command, "terminal.toggle"); + assert.strictEqual(parsedTerminalToggle.command, "terminal.toggle"); const parsedClose = yield* decode(KeybindingRule, { key: "mod+w", @@ -35,23 +41,65 @@ it.effect("parses keybinding rules", () => }); assert.strictEqual(parsedClose.command, "terminal.close"); + const parsedWorkspaceNew = yield* decode(KeybindingRule, { + key: "mod+shift+j", + command: "terminal.workspace.newFullWidth", + }); + assert.strictEqual(parsedWorkspaceNew.command, "terminal.workspace.newFullWidth"); + + const parsedWorkspaceClose = yield* decode(KeybindingRule, { + key: "mod+w", + command: "terminal.workspace.closeActive", + }); + assert.strictEqual(parsedWorkspaceClose.command, "terminal.workspace.closeActive"); + + const parsedWorkspaceTerminal = yield* decode(KeybindingRule, { + key: "mod+1", + command: "terminal.workspace.terminal", + }); + assert.strictEqual(parsedWorkspaceTerminal.command, "terminal.workspace.terminal"); + + const parsedWorkspaceChat = yield* decode(KeybindingRule, { + key: "mod+2", + command: "terminal.workspace.chat", + }); + assert.strictEqual(parsedWorkspaceChat.command, "terminal.workspace.chat"); + const parsedDiffToggle = yield* decode(KeybindingRule, { key: "mod+d", command: "diff.toggle", }); assert.strictEqual(parsedDiffToggle.command, "diff.toggle"); + const parsedBrowserToggle = yield* decode(KeybindingRule, { + key: "mod+shift+b", + command: "browser.toggle", + }); + assert.strictEqual(parsedBrowserToggle.command, "browser.toggle"); + const parsedLocal = yield* decode(KeybindingRule, { key: "mod+shift+n", command: "chat.newLocal", }); assert.strictEqual(parsedLocal.command, "chat.newLocal"); - const parsedThreadPrevious = yield* decode(KeybindingRule, { + const parsedTerminal = yield* decode(KeybindingRule, { + key: "mod+shift+t", + command: "chat.newTerminal", + }); + assert.strictEqual(parsedTerminal.command, "chat.newTerminal"); + + const parsedVisibleNext = yield* decode(KeybindingRule, { + key: "mod+shift+]", + command: "chat.visible.next", + }); + assert.strictEqual(parsedVisibleNext.command, "chat.visible.next"); + + const parsedVisiblePrevious = yield* decode(KeybindingRule, { key: "mod+shift+[", - command: "thread.previous", + command: "chat.visible.previous", }); - assert.strictEqual(parsedThreadPrevious.command, "thread.previous"); + assert.strictEqual(parsedVisiblePrevious.command, "chat.visible.previous"); }), ); @@ -126,19 +174,8 @@ it.effect("parses resolved keybindings arrays", () => modKey: true, }, }, - { - command: "thread.jump.3", - shortcut: { - key: "3", - metaKey: false, - ctrlKey: false, - shiftKey: false, - altKey: false, - modKey: true, - }, - }, ]); - assert.lengthOf(parsed, 2); + assert.lengthOf(parsed, 1); }), ); diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index b08fff8679..1f053f05dd 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -7,36 +7,24 @@ export const MAX_WHEN_EXPRESSION_DEPTH = 64; export const MAX_SCRIPT_ID_LENGTH = 24; export const MAX_KEYBINDINGS_COUNT = 256; -export const THREAD_JUMP_KEYBINDING_COMMANDS = [ - "thread.jump.1", - "thread.jump.2", - "thread.jump.3", - "thread.jump.4", - "thread.jump.5", - "thread.jump.6", - "thread.jump.7", - "thread.jump.8", - "thread.jump.9", -] as const; -export type ThreadJumpKeybindingCommand = (typeof THREAD_JUMP_KEYBINDING_COMMANDS)[number]; - -export const THREAD_KEYBINDING_COMMANDS = [ - "thread.previous", - "thread.next", - ...THREAD_JUMP_KEYBINDING_COMMANDS, -] as const; -export type ThreadKeybindingCommand = (typeof THREAD_KEYBINDING_COMMANDS)[number]; - const STATIC_KEYBINDING_COMMANDS = [ + "sidebar.toggle", "terminal.toggle", "terminal.split", "terminal.new", "terminal.close", + "terminal.workspace.newFullWidth", + "terminal.workspace.closeActive", + "terminal.workspace.terminal", + "terminal.workspace.chat", + "browser.toggle", "diff.toggle", "chat.new", "chat.newLocal", + "chat.newTerminal", + "chat.visible.next", + "chat.visible.previous", "editor.openFavorite", - ...THREAD_KEYBINDING_COMMANDS, ] as const; export const SCRIPT_RUN_COMMAND_PATTERN = Schema.TemplateLiteral([ @@ -85,27 +73,24 @@ export const KeybindingShortcut = Schema.Struct({ }); export type KeybindingShortcut = typeof KeybindingShortcut.Type; -const KeybindingWhenNodeRef = Schema.suspend( - (): Schema.Codec => KeybindingWhenNode, -); -export const KeybindingWhenNode = Schema.Union([ +export const KeybindingWhenNode: Schema.Schema = Schema.Union([ Schema.Struct({ type: Schema.Literal("identifier"), name: Schema.NonEmptyString, }), Schema.Struct({ type: Schema.Literal("not"), - node: KeybindingWhenNodeRef, + node: Schema.suspend((): Schema.Schema => KeybindingWhenNode), }), Schema.Struct({ type: Schema.Literal("and"), - left: KeybindingWhenNodeRef, - right: KeybindingWhenNodeRef, + left: Schema.suspend((): Schema.Schema => KeybindingWhenNode), + right: Schema.suspend((): Schema.Schema => KeybindingWhenNode), }), Schema.Struct({ type: Schema.Literal("or"), - left: KeybindingWhenNodeRef, - right: KeybindingWhenNodeRef, + left: Schema.suspend((): Schema.Schema => KeybindingWhenNode), + right: Schema.suspend((): Schema.Schema => KeybindingWhenNode), }), ]); export type KeybindingWhenNode = @@ -125,16 +110,3 @@ export const ResolvedKeybindingsConfig = Schema.Array(ResolvedKeybindingRule).ch Schema.isMaxLength(MAX_KEYBINDINGS_COUNT), ); export type ResolvedKeybindingsConfig = typeof ResolvedKeybindingsConfig.Type; - -export class KeybindingsConfigError extends Schema.TaggedErrorClass()( - "KeybindingsConfigParseError", - { - configPath: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect), - }, -) { - override get message(): string { - return `Unable to parse keybindings config at ${this.configPath}: ${this.detail}`; - } -} From 5e5f8d286af182411102d59054c783a041288ff7 Mon Sep 17 00:00:00 2001 From: Emanuele Di Pietro Date: Mon, 6 Apr 2026 01:37:08 +0200 Subject: [PATCH 4/4] Port Codex app-server discovery and manager updates --- apps/server/src/codexAppServerManager.test.ts | 286 ++++++++--- apps/server/src/codexAppServerManager.ts | 464 ++++++++++++++++-- .../Layers/ProviderDiscoveryService.ts | 103 ++++ .../Services/ProviderDiscoveryService.ts | 38 ++ apps/server/src/provider/codexCliVersion.ts | 2 +- 5 files changed, 782 insertions(+), 111 deletions(-) create mode 100644 apps/server/src/provider/Layers/ProviderDiscoveryService.ts create mode 100644 apps/server/src/provider/Services/ProviderDiscoveryService.ts diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index 2f740a7407..210723254e 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -207,42 +207,6 @@ describe("classifyCodexStderrLine", () => { }); }); -describe("process stderr events", () => { - it("emits classified stderr lines as notifications", () => { - const manager = new CodexAppServerManager(); - const emitEvent = vi - .spyOn(manager as unknown as { emitEvent: (...args: unknown[]) => void }, "emitEvent") - .mockImplementation(() => {}); - - ( - manager as unknown as { - emitNotificationEvent: ( - context: { session: { threadId: ThreadId } }, - method: string, - message: string, - ) => void; - } - ).emitNotificationEvent( - { - session: { - threadId: asThreadId("thread-1"), - }, - }, - "process/stderr", - "fatal: permission denied", - ); - - expect(emitEvent).toHaveBeenCalledWith( - expect.objectContaining({ - kind: "notification", - method: "process/stderr", - threadId: "thread-1", - message: "fatal: permission denied", - }), - ); - }); -}); - describe("normalizeCodexModelSlug", () => { it("maps 5.3 aliases to gpt-5.3-codex", () => { expect(normalizeCodexModelSlug("5.3")).toBe("gpt-5.3-codex"); @@ -310,7 +274,7 @@ describe("readCodexAccountSnapshot", () => { }); }); - it("disables spark for api key accounts", () => { + it("keeps spark enabled for api key accounts", () => { expect( readCodexAccountSnapshot({ type: "apiKey", @@ -318,20 +282,7 @@ describe("readCodexAccountSnapshot", () => { ).toEqual({ type: "apiKey", planType: null, - sparkEnabled: false, - }); - }); - - it("disables spark for unknown chatgpt plans", () => { - expect( - readCodexAccountSnapshot({ - type: "chatgpt", - email: "unknown@example.com", - }), - ).toEqual({ - type: "chatgpt", - planType: "unknown", - sparkEnabled: false, + sparkEnabled: true, }); }); }); @@ -356,16 +307,6 @@ describe("resolveCodexModelForAccount", () => { }), ).toBe("gpt-5.3-codex-spark"); }); - - it("falls back from spark to default for api key auth", () => { - expect( - resolveCodexModelForAccount("gpt-5.3-codex-spark", { - type: "apiKey", - planType: null, - sparkEnabled: false, - }), - ).toBe("gpt-5.3-codex"); - }); }); describe("startSession", () => { @@ -373,7 +314,7 @@ describe("startSession", () => { expect(buildCodexInitializeParams()).toEqual({ clientInfo: { name: "t3code_desktop", - title: "T3 Code Desktop", + title: "DP Code Desktop", version: "0.1.0", }, capabilities: { @@ -401,7 +342,6 @@ describe("startSession", () => { manager.startSession({ threadId: asThreadId("thread-1"), provider: "codex", - binaryPath: "codex", runtimeMode: "full-access", }), ).rejects.toThrow("cwd missing"); @@ -441,7 +381,7 @@ describe("startSession", () => { ) .mockImplementation(() => { throw new Error( - "Codex CLI v0.36.0 is too old for T3 Code. Upgrade to v0.37.0 or newer and restart T3 Code.", + "Codex CLI v0.36.0 is too old for DP Code. Upgrade to v0.37.0 or newer and restart DP Code.", ); }); @@ -450,11 +390,10 @@ describe("startSession", () => { manager.startSession({ threadId: asThreadId("thread-1"), provider: "codex", - binaryPath: "codex", runtimeMode: "full-access", }), ).rejects.toThrow( - "Codex CLI v0.36.0 is too old for T3 Code. Upgrade to v0.37.0 or newer and restart T3 Code.", + "Codex CLI v0.36.0 is too old for DP Code. Upgrade to v0.37.0 or newer and restart DP Code.", ); expect(versionCheck).toHaveBeenCalledTimes(1); expect(events).toEqual([ @@ -462,7 +401,7 @@ describe("startSession", () => { method: "session/startFailed", kind: "error", message: - "Codex CLI v0.36.0 is too old for T3 Code. Upgrade to v0.37.0 or newer and restart T3 Code.", + "Codex CLI v0.36.0 is too old for DP Code. Upgrade to v0.37.0 or newer and restart DP Code.", }, ]); } finally { @@ -546,6 +485,38 @@ describe("sendTurn", () => { }); }); + it("adds selected skills as structured turn/start input items", async () => { + const { manager, context, sendRequest } = createSendTurnHarness(); + + await manager.sendTurn({ + threadId: asThreadId("thread_1"), + input: "Use $check-code for this repo", + skills: [ + { + name: "check-code", + path: "/Users/test/.codex/skills/check-code/SKILL.md", + }, + ], + }); + + expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", { + threadId: "thread_1", + input: [ + { + type: "text", + text: "Use $check-code for this repo", + text_elements: [], + }, + { + type: "skill", + name: "check-code", + path: "/Users/test/.codex/skills/check-code/SKILL.md", + }, + ], + model: "gpt-5.3-codex", + }); + }); + it("passes Codex plan mode as a collaboration preset on turn/start", async () => { const { manager, context, sendRequest } = createSendTurnHarness(); @@ -648,6 +619,171 @@ describe("sendTurn", () => { }); }); +describe("CodexAppServerManager discovery", () => { + it("parses bucketed skills/list responses for the requested cwd", async () => { + const manager = new CodexAppServerManager(); + const context = { + session: { + provider: "codex", + status: "ready", + threadId: "thread_1", + runtimeMode: "full-access", + model: "gpt-5.3-codex", + resumeCursor: { threadId: "thread_1" }, + createdAt: "2026-02-10T00:00:00.000Z", + updatedAt: "2026-02-10T00:00:00.000Z", + }, + account: { + type: "unknown", + planType: null, + sparkEnabled: true, + }, + collabReceiverTurns: new Map(), + }; + + const resolveContextForDiscovery = vi + .spyOn( + manager as unknown as { + resolveContextForDiscovery: (threadId?: string) => unknown; + }, + "resolveContextForDiscovery", + ) + .mockReturnValue(context); + const sendRequest = vi + .spyOn( + manager as unknown as { + sendRequest: (...args: unknown[]) => Promise; + }, + "sendRequest", + ) + .mockResolvedValue({ + result: { + data: [ + { + cwd: "/other", + skills: [ + { + name: "ignore-me", + path: "/ignore", + }, + ], + }, + { + cwd: "/repo", + skills: [ + { + name: "check-code", + description: "Review repo changes for bugs and risks.", + path: "/Users/test/.codex/skills/check-code/SKILL.md", + scope: "project", + interface: { + displayName: "Check Code", + shortDescription: "Review code changes", + }, + dependencies: ["rg"], + }, + ], + }, + ], + }, + }); + + const result = await manager.listSkills({ + cwd: "/repo", + threadId: "thread_1", + }); + + expect(resolveContextForDiscovery).toHaveBeenCalledWith("thread_1"); + expect(sendRequest).toHaveBeenCalledWith(context, "skills/list", { + cwds: ["/repo"], + }); + expect(result).toEqual({ + skills: [ + { + name: "check-code", + description: "Review repo changes for bugs and risks.", + path: "/Users/test/.codex/skills/check-code/SKILL.md", + enabled: true, + scope: "project", + interface: { + displayName: "Check Code", + shortDescription: "Review code changes", + }, + dependencies: ["rg"], + }, + ], + source: "codex-app-server", + cached: false, + }); + }); + + it("retries skills/list with cwd when a runtime rejects cwds", async () => { + const manager = new CodexAppServerManager(); + const context = { + session: { + provider: "codex", + status: "ready", + threadId: "thread_1", + runtimeMode: "full-access", + model: "gpt-5.3-codex", + resumeCursor: { threadId: "thread_1" }, + createdAt: "2026-02-10T00:00:00.000Z", + updatedAt: "2026-02-10T00:00:00.000Z", + }, + account: { + type: "unknown", + planType: null, + sparkEnabled: true, + }, + collabReceiverTurns: new Map(), + }; + + vi.spyOn( + manager as unknown as { + resolveContextForDiscovery: (threadId?: string) => unknown; + }, + "resolveContextForDiscovery", + ).mockReturnValue(context); + const sendRequest = vi + .spyOn( + manager as unknown as { + sendRequest: (...args: unknown[]) => Promise; + }, + "sendRequest", + ) + .mockRejectedValueOnce(new Error('skills/list failed: invalid params: unknown field "cwds"')) + .mockResolvedValueOnce({ + result: { + skills: [ + { + name: "check-code", + path: "/Users/test/.codex/skills/check-code/SKILL.md", + }, + ], + }, + }); + + const result = await manager.listSkills({ + cwd: "/repo", + threadId: "thread_1", + }); + + expect(sendRequest).toHaveBeenNthCalledWith(1, context, "skills/list", { + cwds: ["/repo"], + }); + expect(sendRequest).toHaveBeenNthCalledWith(2, context, "skills/list", { + cwd: "/repo", + }); + expect(result.skills).toEqual([ + { + name: "check-code", + path: "/Users/test/.codex/skills/check-code/SKILL.md", + enabled: true, + }, + ]); + }); +}); + describe("thread checkpoint control", () => { it("reads thread turns from thread/read", async () => { const { manager, context, requireSession, sendRequest } = createThreadControlHarness(); @@ -1009,8 +1145,12 @@ describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume" provider: "codex", cwd: workspaceDir, runtimeMode: "full-access", - binaryPath: process.env.CODEX_BINARY_PATH!, - ...(process.env.CODEX_HOME_PATH ? { homePath: process.env.CODEX_HOME_PATH } : {}), + providerOptions: { + codex: { + ...(process.env.CODEX_BINARY_PATH ? { binaryPath: process.env.CODEX_BINARY_PATH } : {}), + ...(process.env.CODEX_HOME_PATH ? { homePath: process.env.CODEX_HOME_PATH } : {}), + }, + }, }); const firstTurn = await manager.sendTurn({ @@ -1040,8 +1180,12 @@ describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume" cwd: workspaceDir, runtimeMode: "approval-required", resumeCursor: firstSession.resumeCursor, - binaryPath: process.env.CODEX_BINARY_PATH!, - ...(process.env.CODEX_HOME_PATH ? { homePath: process.env.CODEX_HOME_PATH } : {}), + providerOptions: { + codex: { + ...(process.env.CODEX_BINARY_PATH ? { binaryPath: process.env.CODEX_BINARY_PATH } : {}), + ...(process.env.CODEX_HOME_PATH ? { homePath: process.env.CODEX_HOME_PATH } : {}), + }, + }, }); expect(resumedSession.threadId).toBe(originalThreadId); diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index 3145038647..39c50fe599 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -6,7 +6,12 @@ import readline from "node:readline"; import { ApprovalRequestId, EventId, + type ProviderComposerCapabilities, ProviderItemId, + type ProviderListModelsResult, + type ProviderListSkillsResult, + type ProviderSkillDescriptor, + type ProviderSkillReference, ProviderRequestKind, type ProviderUserInputAnswers, ThreadId, @@ -14,6 +19,7 @@ import { type ProviderApprovalDecision, type ProviderEvent, type ProviderSession, + type ProviderSessionStartInput, type ProviderTurnStartResult, RuntimeMode, ProviderInteractionMode, @@ -26,15 +32,6 @@ import { isCodexCliVersionSupported, parseCodexCliVersion, } from "./provider/codexCliVersion"; -import { - readCodexAccountSnapshot, - resolveCodexModelForAccount, - type CodexAccountSnapshot, -} from "./provider/codexAccount"; -import { buildCodexInitializeParams, killCodexChildProcess } from "./provider/codexAppServer"; - -export { buildCodexInitializeParams } from "./provider/codexAppServer"; -export { readCodexAccountSnapshot, resolveCodexModelForAccount } from "./provider/codexAccount"; type PendingRequestKey = string; @@ -81,6 +78,13 @@ interface CodexSessionContext { collabReceiverTurns: Map; nextRequestId: number; stopping: boolean; + discovery?: boolean; +} + +interface CodexSkillListInput { + readonly cwd: string; + readonly forceReload?: boolean; + readonly threadId?: string; } interface JsonRpcError { @@ -105,10 +109,41 @@ interface JsonRpcNotification { params?: unknown; } +function shouldRetrySkillsListWithCwdFallback(error: unknown): boolean { + const message = error instanceof Error ? error.message.toLowerCase() : ""; + return ( + message.includes("skills/list failed") && + (message.includes("invalid") || + message.includes("unknown field") || + message.includes("unrecognized field") || + message.includes("missing field") || + message.includes("expected") || + message.includes("cwds")) + ); +} + +type CodexPlanType = + | "free" + | "go" + | "plus" + | "pro" + | "team" + | "business" + | "enterprise" + | "edu" + | "unknown"; + +interface CodexAccountSnapshot { + readonly type: "apiKey" | "chatgpt" | "unknown"; + readonly planType: CodexPlanType | null; + readonly sparkEnabled: boolean; +} + export interface CodexAppServerSendTurnInput { readonly threadId: ThreadId; readonly input?: string; readonly attachments?: ReadonlyArray<{ type: "image"; url: string }>; + readonly skills?: ReadonlyArray; readonly model?: string; readonly serviceTier?: string | null; readonly effort?: string; @@ -122,8 +157,7 @@ export interface CodexAppServerStartSessionInput { readonly model?: string; readonly serviceTier?: string; readonly resumeCursor?: unknown; - readonly binaryPath: string; - readonly homePath?: string; + readonly providerOptions?: ProviderSessionStartInput["providerOptions"]; readonly runtimeMode: RuntimeMode; } @@ -154,6 +188,50 @@ const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [ "unknown thread", "does not exist", ]; +const CODEX_DEFAULT_MODEL = "gpt-5.3-codex"; +const CODEX_SPARK_MODEL = "gpt-5.3-codex-spark"; +const CODEX_SPARK_DISABLED_PLAN_TYPES = new Set(["free", "go", "plus"]); + +function asObject(value: unknown): Record | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + return value as Record; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +export function readCodexAccountSnapshot(response: unknown): CodexAccountSnapshot { + const record = asObject(response); + const account = asObject(record?.account) ?? record; + const accountType = asString(account?.type); + + if (accountType === "apiKey") { + return { + type: "apiKey", + planType: null, + sparkEnabled: true, + }; + } + + if (accountType === "chatgpt") { + const planType = (account?.planType as CodexPlanType | null) ?? "unknown"; + return { + type: "chatgpt", + planType, + sparkEnabled: !CODEX_SPARK_DISABLED_PLAN_TYPES.has(planType), + }; + } + + return { + type: "unknown", + planType: null, + sparkEnabled: true, + }; +} + export const CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS = `# Plan Mode (Conversational) You work in 3 phases, and you should *chat your way* to a great plan before finalizing it. A great plan is very detailed-intent- and implementation-wise-so that it can be handed to another engineer or agent to be implemented right away. It must be **decision complete**, where the implementer does not need to make any decisions. @@ -306,13 +384,32 @@ function mapCodexRuntimeMode(runtimeMode: RuntimeMode): { }; } +export function resolveCodexModelForAccount( + model: string | undefined, + account: CodexAccountSnapshot, +): string | undefined { + if (model !== CODEX_SPARK_MODEL || account.sparkEnabled) { + return model; + } + + return CODEX_DEFAULT_MODEL; +} + /** * On Windows with `shell: true`, `child.kill()` only terminates the `cmd.exe` * wrapper, leaving the actual command running. Use `taskkill /T` to kill the * entire process tree instead. */ function killChildTree(child: ChildProcessWithoutNullStreams): void { - killCodexChildProcess(child); + if (process.platform === "win32" && child.pid !== undefined) { + try { + spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" }); + return; + } catch { + // fallback to direct kill + } + } + child.kill(); } export function normalizeCodexModelSlug( @@ -331,6 +428,19 @@ export function normalizeCodexModelSlug( return normalized; } +export function buildCodexInitializeParams() { + return { + clientInfo: { + name: "t3code_desktop", + title: "DP Code Desktop", + version: "0.1.0", + }, + capabilities: { + experimentalApi: true, + }, + } as const; +} + function buildCodexCollaborationMode(input: { readonly interactionMode?: "default" | "plan"; readonly model?: string; @@ -431,6 +541,9 @@ export interface CodexAppServerManagerEvents { export class CodexAppServerManager extends EventEmitter { private readonly sessions = new Map(); + private readonly discoverySessions = new Map(); + private readonly skillsCache = new Map(); + private readonly modelCache = new Map(); private runPromise: (effect: Effect.Effect) => Promise; constructor(services?: ServiceMap.ServiceMap) { @@ -457,8 +570,9 @@ export class CodexAppServerManager extends EventEmitter = []; if (input.input) { turnInput.push({ @@ -669,6 +785,13 @@ export class CodexAppServerManager extends EventEmitter; model?: string; serviceTier?: string | null; @@ -933,6 +1058,82 @@ export class CodexAppServerManager extends EventEmitter { + const cwd = input.cwd.trim(); + const cacheKey = JSON.stringify({ + cwd, + threadId: input.threadId?.trim() || null, + }); + if (!input.forceReload) { + const cached = this.skillsCache.get(cacheKey); + if (cached) { + return { + ...cached, + cached: true, + }; + } + } + + const context = await this.resolveContextForDiscovery(input.threadId, cwd); + let response: Record; + try { + response = await this.sendRequest>(context, "skills/list", { + cwds: [cwd], + ...(input.forceReload ? { forceReload: true } : {}), + }); + } catch (error) { + if (!shouldRetrySkillsListWithCwdFallback(error)) { + throw error; + } + response = await this.sendRequest>(context, "skills/list", { + cwd, + ...(input.forceReload ? { forceReload: true } : {}), + }); + } + const skills = this.parseSkillsListResponse(response, cwd); + const result: ProviderListSkillsResult = { + skills, + source: "codex-app-server", + cached: false, + }; + this.skillsCache.set(cacheKey, result); + return result; + } + + async listModels(threadId?: string): Promise { + const cacheKey = threadId?.trim() || "__default__"; + const cached = this.modelCache.get(cacheKey); + if (cached) { + return { + ...cached, + cached: true, + }; + } + + const context = await this.resolveContextForDiscovery(threadId); + const response = await this.sendRequest>(context, "model/list", {}); + const models = this.parseModelListResponse(response); + const result: ProviderListModelsResult = { + models, + source: "codex-app-server", + cached: false, + }; + this.modelCache.set(cacheKey, result); + return result; + } + + getComposerCapabilities(): ProviderComposerCapabilities { + return { + provider: "codex", + supportsSkillMentions: true, + supportsSkillDiscovery: true, + supportsRuntimeModelList: true, + }; } private requireSession(threadId: ThreadId): CodexSessionContext { @@ -948,6 +1149,113 @@ export class CodexAppServerManager extends EventEmitter { + const normalizedThreadId = threadId?.trim(); + if (normalizedThreadId) { + try { + return this.requireSession(ThreadId.makeUnsafe(normalizedThreadId)); + } catch { + // Discovery is read-only metadata, so if the current draft thread does not + // have a live Codex session yet we can safely fall back to any active + // Codex session instead of disabling skill autocomplete outright. + } + } + const firstActive = this.sessions.values().next().value; + if (firstActive) { + return firstActive; + } + return this.getOrCreateDiscoverySession(cwd?.trim() || process.cwd()); + } + + private async getOrCreateDiscoverySession(cwd: string): Promise { + const normalizedCwd = cwd.trim() || process.cwd(); + const existing = this.discoverySessions.get(normalizedCwd); + if (existing && !existing.stopping && !existing.child.killed) { + return existing; + } + + const now = new Date().toISOString(); + this.assertSupportedCodexCliVersion({ + binaryPath: "codex", + cwd: normalizedCwd, + }); + const child = spawn("codex", ["app-server"], { + cwd: normalizedCwd, + env: { ...process.env }, + stdio: ["pipe", "pipe", "pipe"], + shell: process.platform === "win32", + }); + const output = readline.createInterface({ input: child.stdout }); + const context: CodexSessionContext = { + session: { + provider: "codex", + status: "connecting", + runtimeMode: "full-access", + model: CODEX_DEFAULT_MODEL, + cwd: normalizedCwd, + threadId: ThreadId.makeUnsafe(`__codex_discovery__:${normalizedCwd}`), + createdAt: now, + updatedAt: now, + }, + account: { + type: "unknown", + planType: null, + sparkEnabled: true, + }, + child, + output, + pending: new Map(), + pendingApprovals: new Map(), + pendingUserInputs: new Map(), + collabReceiverTurns: new Map(), + nextRequestId: 1, + stopping: false, + discovery: true, + }; + + this.discoverySessions.set(normalizedCwd, context); + this.attachProcessListeners(context); + try { + await this.sendRequest(context, "initialize", buildCodexInitializeParams()); + this.writeMessage(context, { method: "initialized" }); + try { + const accountReadResponse = await this.sendRequest(context, "account/read", {}); + context.account = readCodexAccountSnapshot(accountReadResponse); + } catch { + // Discovery can still function without account metadata. + } + this.updateSession(context, { status: "ready" }); + return context; + } catch (error) { + this.stopDiscoverySession(normalizedCwd); + throw error; + } + } + + private stopDiscoverySession(discoveryKey: string): void { + const context = this.discoverySessions.get(discoveryKey); + if (!context) { + return; + } + + context.stopping = true; + for (const pending of context.pending.values()) { + clearTimeout(pending.timeout); + pending.reject(new Error("Discovery session stopped before request completed.")); + } + context.pending.clear(); + context.output.close(); + + if (!context.child.killed) { + killChildTree(context.child); + } + + this.discoverySessions.delete(discoveryKey); + } + private attachProcessListeners(context: CodexSessionContext): void { context.output.on("line", (line) => { this.handleStdoutLine(context, line); @@ -962,7 +1270,7 @@ export class CodexAppServerManager extends EventEmitter)[key]; return typeof candidate === "boolean" ? candidate : undefined; } + + private parseSkillsListResponse(response: unknown, cwd: string): ProviderSkillDescriptor[] { + const responseRecord = this.readObject(response); + const resultRecord = this.readObject(responseRecord, "result") ?? responseRecord; + const dataItems = this.readArray(resultRecord, "data") ?? []; + const scopedData = dataItems.find((value) => { + const item = this.readObject(value); + const itemCwd = this.readString(item, "cwd"); + return itemCwd === cwd; + }); + const scopedSkills = this.readArray(this.readObject(scopedData), "skills"); + const directSkills = this.readArray(resultRecord, "skills"); + const rawSkills = scopedSkills ?? directSkills ?? []; + + const parsedSkills = rawSkills + .map((value) => this.readObject(value)) + .flatMap((skill) => { + if (!skill) return []; + const name = this.readString(skill, "name")?.trim(); + const path = this.readString(skill, "path")?.trim(); + if (!name || !path) { + return []; + } + const description = this.readString(skill, "description")?.trim(); + const scope = this.readString(skill, "scope")?.trim(); + const display = this.readObject(skill, "interface"); + return [ + { + name, + path, + enabled: skill.enabled !== false, + ...(description ? { description } : {}), + ...(scope ? { scope } : {}), + ...(display + ? { + interface: { + ...(this.readString(display, "displayName") + ? { displayName: this.readString(display, "displayName") } + : {}), + ...(this.readString(display, "shortDescription") + ? { shortDescription: this.readString(display, "shortDescription") } + : {}), + }, + } + : {}), + ...(skill.dependencies !== undefined ? { dependencies: skill.dependencies } : {}), + } satisfies ProviderSkillDescriptor, + ]; + }); + + return parsedSkills.toSorted((a, b) => a.name.localeCompare(b.name)); + } + + private parseModelListResponse(response: unknown): ProviderListModelsResult["models"] { + const responseRecord = this.readObject(response); + const resultRecord = this.readObject(responseRecord, "result") ?? responseRecord; + const rawModels = + this.readArray(resultRecord, "models") ?? this.readArray(resultRecord, "data") ?? []; + + return rawModels + .map((value) => this.readObject(value)) + .flatMap((model) => { + if (!model) return []; + const slug = this.readString(model, "id") ?? this.readString(model, "slug"); + const name = this.readString(model, "name") ?? slug; + if (!slug || !name) { + return []; + } + return [{ slug, name }]; + }); + } } function brandIfNonEmpty( @@ -1522,6 +1898,20 @@ function normalizeProviderThreadId(value: string | undefined): string | undefine return brandIfNonEmpty(value, (normalized) => normalized); } +function readCodexProviderOptions(input: CodexAppServerStartSessionInput): { + readonly binaryPath?: string; + readonly homePath?: string; +} { + const options = input.providerOptions?.codex; + if (!options) { + return {}; + } + return { + ...(options.binaryPath ? { binaryPath: options.binaryPath } : {}), + ...(options.homePath ? { homePath: options.homePath } : {}), + }; +} + function assertSupportedCodexCliVersion(input: { readonly binaryPath: string; readonly cwd: string; @@ -1575,11 +1965,7 @@ function readResumeCursorThreadId(resumeCursor: unknown): string | undefined { return typeof rawThreadId === "string" ? normalizeProviderThreadId(rawThreadId) : undefined; } -function readResumeThreadId(input: { - readonly resumeCursor?: unknown; - readonly threadId?: ThreadId; - readonly runtimeMode?: RuntimeMode; -}): string | undefined { +function readResumeThreadId(input: CodexAppServerStartSessionInput): string | undefined { return readResumeCursorThreadId(input.resumeCursor); } diff --git a/apps/server/src/provider/Layers/ProviderDiscoveryService.ts b/apps/server/src/provider/Layers/ProviderDiscoveryService.ts new file mode 100644 index 0000000000..9c30357fa1 --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderDiscoveryService.ts @@ -0,0 +1,103 @@ +import { + type ProviderComposerCapabilities, + ProviderGetComposerCapabilitiesInput, + ProviderListModelsInput, + ProviderListSkillsInput, +} from "@t3tools/contracts"; +import { Effect, Layer, Schema, SchemaIssue } from "effect"; + +import { ProviderValidationError } from "../Errors.ts"; +import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; +import { + ProviderDiscoveryService, + type ProviderDiscoveryServiceShape, +} from "../Services/ProviderDiscoveryService.ts"; + +const decodeInputOrValidationError = (input: { + readonly operation: string; + readonly schema: S; + readonly payload: unknown; +}) => + Schema.decodeUnknownEffect(input.schema)(input.payload).pipe( + Effect.mapError( + (schemaError) => + new ProviderValidationError({ + operation: input.operation, + issue: SchemaIssue.makeFormatterDefault()(schemaError.issue), + cause: schemaError, + }), + ), + ); + +const disabledCapabilitiesForProvider = ( + provider: ProviderComposerCapabilities["provider"], +): ProviderComposerCapabilities => ({ + provider, + supportsSkillMentions: false, + supportsSkillDiscovery: false, + supportsRuntimeModelList: false, +}); + +const make = Effect.gen(function* () { + const registry = yield* ProviderAdapterRegistry; + + const getComposerCapabilities: ProviderDiscoveryServiceShape["getComposerCapabilities"] = ( + input, + ) => + Effect.gen(function* () { + const parsed = yield* decodeInputOrValidationError({ + operation: "ProviderDiscoveryService.getComposerCapabilities", + schema: ProviderGetComposerCapabilitiesInput, + payload: input, + }); + const adapter = yield* registry.getByProvider(parsed.provider); + if (adapter.getComposerCapabilities) { + return yield* adapter.getComposerCapabilities(); + } + return disabledCapabilitiesForProvider(parsed.provider); + }); + + const listSkills: ProviderDiscoveryServiceShape["listSkills"] = (input) => + Effect.gen(function* () { + const parsed = yield* decodeInputOrValidationError({ + operation: "ProviderDiscoveryService.listSkills", + schema: ProviderListSkillsInput, + payload: input, + }); + const adapter = yield* registry.getByProvider(parsed.provider); + if (!adapter.listSkills) { + return { + skills: [], + source: "unsupported", + cached: false, + }; + } + return yield* adapter.listSkills(parsed); + }); + + const listModels: ProviderDiscoveryServiceShape["listModels"] = (input) => + Effect.gen(function* () { + const parsed = yield* decodeInputOrValidationError({ + operation: "ProviderDiscoveryService.listModels", + schema: ProviderListModelsInput, + payload: input, + }); + const adapter = yield* registry.getByProvider(parsed.provider); + if (!adapter.listModels) { + return { + models: [], + source: "unsupported", + cached: false, + }; + } + return yield* adapter.listModels(); + }); + + return { + getComposerCapabilities, + listSkills, + listModels, + } satisfies ProviderDiscoveryServiceShape; +}); + +export const ProviderDiscoveryServiceLive = Layer.effect(ProviderDiscoveryService, make); diff --git a/apps/server/src/provider/Services/ProviderDiscoveryService.ts b/apps/server/src/provider/Services/ProviderDiscoveryService.ts new file mode 100644 index 0000000000..f9b0c12d45 --- /dev/null +++ b/apps/server/src/provider/Services/ProviderDiscoveryService.ts @@ -0,0 +1,38 @@ +import type { + ProviderComposerCapabilities, + ProviderGetComposerCapabilitiesInput, + ProviderListModelsInput, + ProviderListModelsResult, + ProviderListSkillsInput, + ProviderListSkillsResult, +} from "@t3tools/contracts"; +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { + ProviderAdapterError, + ProviderUnsupportedError, + ProviderValidationError, +} from "../Errors.ts"; + +export type ProviderDiscoveryError = + | ProviderValidationError + | ProviderUnsupportedError + | ProviderAdapterError; + +export interface ProviderDiscoveryServiceShape { + readonly getComposerCapabilities: ( + input: ProviderGetComposerCapabilitiesInput, + ) => Effect.Effect; + readonly listSkills: ( + input: ProviderListSkillsInput, + ) => Effect.Effect; + readonly listModels: ( + input: ProviderListModelsInput, + ) => Effect.Effect; +} + +export class ProviderDiscoveryService extends ServiceMap.Service< + ProviderDiscoveryService, + ProviderDiscoveryServiceShape +>()("t3/provider/Services/ProviderDiscoveryService") {} diff --git a/apps/server/src/provider/codexCliVersion.ts b/apps/server/src/provider/codexCliVersion.ts index 544020016c..e76e9df77b 100644 --- a/apps/server/src/provider/codexCliVersion.ts +++ b/apps/server/src/provider/codexCliVersion.ts @@ -137,5 +137,5 @@ export function isCodexCliVersionSupported(version: string): boolean { export function formatCodexCliUpgradeMessage(version: string | null): string { const versionLabel = version ? `v${version}` : "the installed version"; - return `Codex CLI ${versionLabel} is too old for T3 Code. Upgrade to v${MINIMUM_CODEX_CLI_VERSION} or newer and restart T3 Code.`; + return `Codex CLI ${versionLabel} is too old for DP Code. Upgrade to v${MINIMUM_CODEX_CLI_VERSION} or newer and restart DP Code.`; }