diff --git a/apps/server/src/orchestration/commandInvariants.ts b/apps/server/src/orchestration/commandInvariants.ts index f06219b98d44..8391d72958a6 100644 --- a/apps/server/src/orchestration/commandInvariants.ts +++ b/apps/server/src/orchestration/commandInvariants.ts @@ -219,6 +219,21 @@ export function requireThreadAbsent(input: { ); } +export function requireTurnNotActive(input: { + readonly thread: OrchestrationThread; + readonly command: OrchestrationCommand; +}): Effect.Effect { + if (input.thread.latestTurn?.state === "running") { + return Effect.fail( + invariantError( + input.command.type, + `Thread '${input.thread.id}' has an active turn. Wait for generation to complete before trimming context or starting a new thread.`, + ), + ); + } + return Effect.void; +} + export function requireNonNegativeInteger(input: { readonly commandType: OrchestrationCommand["type"]; readonly field: string; diff --git a/apps/server/src/orchestration/decider.archiveAndNew.test.ts b/apps/server/src/orchestration/decider.archiveAndNew.test.ts index 3ee7c225688c..a31297740927 100644 --- a/apps/server/src/orchestration/decider.archiveAndNew.test.ts +++ b/apps/server/src/orchestration/decider.archiveAndNew.test.ts @@ -1,9 +1,12 @@ import { + CheckpointRef, CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, ProjectId, ProviderInstanceId, ThreadId, + TurnId, type OrchestrationCommand, type OrchestrationReadModel, } from "@t3tools/contracts"; @@ -19,6 +22,7 @@ import { createEmptyReadModel, projectEvent } from "./projector.ts"; const asCommandId = (value: string): CommandId => CommandId.make(value); const asProjectId = (value: string): ProjectId => ProjectId.make(value); const asThreadId = (value: string): ThreadId => ThreadId.make(value); +const asTurnId = (value: string): TurnId => TurnId.make(value); function seedThreadEffect(readModel: OrchestrationReadModel, overrides?: { archivedAt?: string | null; @@ -229,4 +233,145 @@ describe("thread.archive-and-new decider", () => { assert.strictEqual(result._tag, "Failure"); }).pipe(Effect.provide(NodeCrypto.layer))); + + it.effect("rejects archive-and-new when thread has an active turn", () => + Effect.gen(function* () { + const readModel = yield* seedThreadEffect( + createEmptyReadModel("2026-01-01T00:00:00.000Z"), + ); + + const modelWithActiveTurn = yield* projectEvent(readModel, { + sequence: readModel.snapshotSequence + 1, + eventId: CommandId.make("evt-session-active") as unknown as never, + aggregateKind: "thread", + aggregateId: asThreadId("thread-1"), + type: "thread.session-set", + occurredAt: "2026-01-01T00:05:00.000Z", + commandId: asCommandId("cmd-session-active"), + causationEventId: null, + correlationId: asCommandId("cmd-session-active"), + metadata: {}, + payload: { + threadId: asThreadId("thread-1"), + session: { + threadId: asThreadId("thread-1"), + status: "running" as const, + providerName: "codex", + runtimeMode: DEFAULT_RUNTIME_MODE, + activeTurnId: asTurnId("turn-active"), + lastError: null, + updatedAt: "2026-01-01T00:05:00.000Z", + }, + }, + } as never); + + const result = yield* Effect.exit( + decideOrchestrationCommand({ + command: { + type: "thread.archive-and-new", + commandId: asCommandId("cmd-archive-new-active"), + threadId: asThreadId("thread-1"), + newThreadId: asThreadId("thread-new"), + createdAt: "2026-01-02T00:00:00.000Z", + } as Extract, + readModel: modelWithActiveTurn, + }), + ); + + assert.strictEqual(result._tag, "Failure"); + }).pipe(Effect.provide(NodeCrypto.layer))); + + it.effect("accepts archive-and-new when thread has a completed turn", () => + Effect.gen(function* () { + const readModel = yield* seedThreadEffect( + createEmptyReadModel("2026-01-01T00:00:00.000Z"), + ); + + let model = yield* projectEvent(readModel, { + sequence: readModel.snapshotSequence + 1, + eventId: CommandId.make("evt-session-running") as unknown as never, + aggregateKind: "thread", + aggregateId: asThreadId("thread-1"), + type: "thread.session-set", + occurredAt: "2026-01-01T00:01:00.000Z", + commandId: asCommandId("cmd-session-running"), + causationEventId: null, + correlationId: asCommandId("cmd-session-running"), + metadata: {}, + payload: { + threadId: asThreadId("thread-1"), + session: { + threadId: asThreadId("thread-1"), + status: "running" as const, + providerName: "codex", + runtimeMode: DEFAULT_RUNTIME_MODE, + activeTurnId: asTurnId("turn-done"), + lastError: null, + updatedAt: "2026-01-01T00:01:00.000Z", + }, + }, + } as never); + + model = yield* projectEvent(model, { + sequence: readModel.snapshotSequence + 2, + eventId: CommandId.make("evt-turn-diff-completed") as unknown as never, + aggregateKind: "thread", + aggregateId: asThreadId("thread-1"), + type: "thread.turn-diff-completed", + occurredAt: "2026-01-01T00:02:00.000Z", + commandId: asCommandId("cmd-turn-diff-completed"), + causationEventId: null, + correlationId: asCommandId("cmd-turn-diff-completed"), + metadata: {}, + payload: { + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-done"), + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.make("checkpoint-1"), + status: "ready" as const, + files: [], + assistantMessageId: null, + completedAt: "2026-01-01T00:02:00.000Z", + }, + } as never); + + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.archive-and-new", + commandId: asCommandId("cmd-archive-new-done"), + threadId: asThreadId("thread-1"), + newThreadId: asThreadId("thread-new"), + createdAt: "2026-01-02T00:00:00.000Z", + } as Extract, + readModel: model, + }); + + const events = Array.isArray(result) ? result : [result]; + assert.strictEqual(events.length, 2); + assert.strictEqual(events[0]?.type, "thread.archived-and-new-created"); + assert.strictEqual(events[1]?.type, "thread.session-stop-requested"); + }).pipe(Effect.provide(NodeCrypto.layer))); + + it.effect("accepts archive-and-new when thread has no latestTurn (idle)", () => + Effect.gen(function* () { + const readModel = yield* seedThreadEffect( + createEmptyReadModel("2026-01-01T00:00:00.000Z"), + ); + + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.archive-and-new", + commandId: asCommandId("cmd-archive-new-idle"), + threadId: asThreadId("thread-1"), + newThreadId: asThreadId("thread-new"), + createdAt: "2026-01-02T00:00:00.000Z", + } as Extract, + readModel, + }); + + const events = Array.isArray(result) ? result : [result]; + assert.strictEqual(events.length, 2); + assert.strictEqual(events[0]?.type, "thread.archived-and-new-created"); + assert.strictEqual(events[1]?.type, "thread.session-stop-requested"); + }).pipe(Effect.provide(NodeCrypto.layer))); }); diff --git a/apps/server/src/orchestration/decider.contextTrim.test.ts b/apps/server/src/orchestration/decider.contextTrim.test.ts index eef685b6e88e..63c36aec44d5 100644 --- a/apps/server/src/orchestration/decider.contextTrim.test.ts +++ b/apps/server/src/orchestration/decider.contextTrim.test.ts @@ -1,6 +1,8 @@ import { + CheckpointRef, CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, EventId, ProjectId, ProviderInstanceId, @@ -11,11 +13,17 @@ import { type OrchestrationReadModel, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import { NodeCrypto } from "@effect/platform-node"; import { describe, expect, it } from "vitest"; import { decideOrchestrationCommand } from "./decider.ts"; import { createEmptyReadModel, projectEvent } from "./projector.ts"; +const runDecide = (input: Parameters[0]) => + Effect.runPromise( + decideOrchestrationCommand(input).pipe(Effect.provide(NodeCrypto.layer)), + ); + const asCommandId = (value: string): CommandId => CommandId.make(value); const asEventId = (value: string): EventId => EventId.make(value); const asProjectId = (value: string): ProjectId => ProjectId.make(value); @@ -234,8 +242,7 @@ describe("thread.context.trim decider", () => { it("emits trim-point-created and session-stop-requested events for a thread with messages", async () => { const readModel = await seedThreadWithMessages(); - const result = await Effect.runPromise( - decideOrchestrationCommand({ + const result = await runDecide({ command: { type: "thread.context.trim", commandId: asCommandId("cmd-trim-all"), @@ -243,8 +250,7 @@ describe("thread.context.trim decider", () => { createdAt: "2026-01-02T00:00:00.000Z", } as Extract, readModel, - }), - ); + }); const events = Array.isArray(result) ? result : [result]; expect(events.map((e) => e.type)).toEqual([ @@ -314,8 +320,7 @@ describe("thread.context.trim decider", () => { }), ); - const result = await Effect.runPromise( - decideOrchestrationCommand({ + const result = await runDecide({ command: { type: "thread.context.trim", commandId: asCommandId("cmd-trim-empty"), @@ -323,8 +328,7 @@ describe("thread.context.trim decider", () => { createdAt: "2026-01-02T00:00:00.000Z", } as Extract, readModel: model, - }), - ); + }); const events = Array.isArray(result) ? result : [result]; const trimEvent = events.find((e) => e.type === "thread.trim-point-created"); @@ -335,8 +339,7 @@ describe("thread.context.trim decider", () => { it("keeps only the last N turns when keepLastNTurns is specified", async () => { const readModel = await seedThreadWithMessages(); - const result = await Effect.runPromise( - decideOrchestrationCommand({ + const result = await runDecide({ command: { type: "thread.context.trim", commandId: asCommandId("cmd-trim-3"), @@ -345,8 +348,7 @@ describe("thread.context.trim decider", () => { createdAt: "2026-01-02T00:00:00.000Z", } as Extract, readModel, - }), - ); + }); const events = Array.isArray(result) ? result : [result]; const trimEvent = events.find((e) => e.type === "thread.trim-point-created"); @@ -359,8 +361,7 @@ describe("thread.context.trim decider", () => { it("survives all turns when keepLastNTurns >= total turns", async () => { const readModel = await seedThreadWithMessages(); - const result = await Effect.runPromise( - decideOrchestrationCommand({ + const result = await runDecide({ command: { type: "thread.context.trim", commandId: asCommandId("cmd-trim-99"), @@ -369,8 +370,7 @@ describe("thread.context.trim decider", () => { createdAt: "2026-01-02T00:00:00.000Z", } as Extract, readModel, - }), - ); + }); const events = Array.isArray(result) ? result : [result]; const trimEvent = events.find((e) => e.type === "thread.trim-point-created"); @@ -381,25 +381,22 @@ describe("thread.context.trim decider", () => { const readModel = createEmptyReadModel("2026-01-01T00:00:00.000Z"); await expect( - Effect.runPromise( - decideOrchestrationCommand({ - command: { - type: "thread.context.trim", - commandId: asCommandId("cmd-trim-unknown"), - threadId: asThreadId("thread-unknown"), - createdAt: "2026-01-02T00:00:00.000Z", - } as Extract, - readModel, - }), - ), + runDecide({ + command: { + type: "thread.context.trim", + commandId: asCommandId("cmd-trim-unknown"), + threadId: asThreadId("thread-unknown"), + createdAt: "2026-01-02T00:00:00.000Z", + } as Extract, + readModel, + }), ).rejects.toBeDefined(); }); it("prunes all messages when keepLastNTurns is not specified (/clear without N)", async () => { const readModel = await seedThreadWithMessages(); - const result = await Effect.runPromise( - decideOrchestrationCommand({ + const result = await runDecide({ command: { type: "thread.context.trim", commandId: asCommandId("cmd-trim-clear-all"), @@ -407,8 +404,7 @@ describe("thread.context.trim decider", () => { createdAt: "2026-01-02T00:00:00.000Z", } as Extract, readModel, - }), - ); + }); const events = Array.isArray(result) ? result : [result]; const trimEvent = events.find((e) => e.type === "thread.trim-point-created"); @@ -419,4 +415,304 @@ describe("thread.context.trim decider", () => { asTurnId("turn-3"), ]); }); + + it("rejects trim when thread has an active turn (latestTurn.state = running)", async () => { + const now = "2026-01-01T00:00:00.000Z"; + let model = createEmptyReadModel(now); + + model = await Effect.runPromise( + projectEvent(model, { + sequence: 1, + eventId: asEventId("evt-project-active"), + aggregateKind: "project", + aggregateId: asProjectId("project-active"), + type: "project.created", + occurredAt: now, + commandId: asCommandId("cmd-project-active"), + causationEventId: null, + correlationId: asCommandId("cmd-project-active"), + metadata: {}, + payload: { + projectId: asProjectId("project-active"), + title: "Project Active", + workspaceRoot: "/tmp/project-active", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }), + ); + + model = await Effect.runPromise( + projectEvent(model, { + sequence: 2, + eventId: asEventId("evt-thread-active"), + aggregateKind: "thread", + aggregateId: asThreadId("thread-active"), + type: "thread.created", + occurredAt: now, + commandId: asCommandId("cmd-thread-active"), + causationEventId: null, + correlationId: asCommandId("cmd-thread-active"), + metadata: {}, + payload: { + threadId: asThreadId("thread-active"), + projectId: asProjectId("project-active"), + title: "Active Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: DEFAULT_RUNTIME_MODE, + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ); + + model = await Effect.runPromise( + projectEvent(model, { + sequence: 3, + eventId: asEventId("evt-session-active"), + aggregateKind: "thread", + aggregateId: asThreadId("thread-active"), + type: "thread.session-set", + occurredAt: "2026-01-01T00:05:00.000Z", + commandId: asCommandId("cmd-session-active"), + causationEventId: null, + correlationId: asCommandId("cmd-session-active"), + metadata: {}, + payload: { + threadId: asThreadId("thread-active"), + session: { + threadId: asThreadId("thread-active"), + status: "running" as const, + providerName: "codex", + runtimeMode: DEFAULT_RUNTIME_MODE, + activeTurnId: asTurnId("turn-active"), + lastError: null, + updatedAt: "2026-01-01T00:05:00.000Z", + }, + }, + }), + ); + + await expect( + runDecide({ + command: { + type: "thread.context.trim", + commandId: asCommandId("cmd-trim-active"), + threadId: asThreadId("thread-active"), + createdAt: "2026-01-02T00:00:00.000Z", + } as Extract, + readModel: model, + }), + ).rejects.toBeDefined(); + }); + + it("accepts trim when thread has no latestTurn (idle)", async () => { + const now = "2026-01-01T00:00:00.000Z"; + let model = createEmptyReadModel(now); + + model = await Effect.runPromise( + projectEvent(model, { + sequence: 1, + eventId: asEventId("evt-project-idle"), + aggregateKind: "project", + aggregateId: asProjectId("project-idle"), + type: "project.created", + occurredAt: now, + commandId: asCommandId("cmd-project-idle"), + causationEventId: null, + correlationId: asCommandId("cmd-project-idle"), + metadata: {}, + payload: { + projectId: asProjectId("project-idle"), + title: "Project Idle", + workspaceRoot: "/tmp/project-idle", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }), + ); + + model = await Effect.runPromise( + projectEvent(model, { + sequence: 2, + eventId: asEventId("evt-thread-idle"), + aggregateKind: "thread", + aggregateId: asThreadId("thread-idle"), + type: "thread.created", + occurredAt: now, + commandId: asCommandId("cmd-thread-idle"), + causationEventId: null, + correlationId: asCommandId("cmd-thread-idle"), + metadata: {}, + payload: { + threadId: asThreadId("thread-idle"), + projectId: asProjectId("project-idle"), + title: "Idle Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: DEFAULT_RUNTIME_MODE, + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ); + + const result = await runDecide({ + command: { + type: "thread.context.trim", + commandId: asCommandId("cmd-trim-idle"), + threadId: asThreadId("thread-idle"), + createdAt: "2026-01-02T00:00:00.000Z", + } as Extract, + readModel: model, + }); + + const events = Array.isArray(result) ? result : [result]; + expect(events.map((e) => e.type)).toEqual([ + "thread.trim-point-created", + "thread.session-stop-requested", + ]); + }); + + it("accepts trim when thread has a completed turn", async () => { + const now = "2026-01-01T00:00:00.000Z"; + let model = createEmptyReadModel(now); + + model = await Effect.runPromise( + projectEvent(model, { + sequence: 1, + eventId: asEventId("evt-project-done"), + aggregateKind: "project", + aggregateId: asProjectId("project-done"), + type: "project.created", + occurredAt: now, + commandId: asCommandId("cmd-project-done"), + causationEventId: null, + correlationId: asCommandId("cmd-project-done"), + metadata: {}, + payload: { + projectId: asProjectId("project-done"), + title: "Project Done", + workspaceRoot: "/tmp/project-done", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }), + ); + + model = await Effect.runPromise( + projectEvent(model, { + sequence: 2, + eventId: asEventId("evt-thread-done"), + aggregateKind: "thread", + aggregateId: asThreadId("thread-done"), + type: "thread.created", + occurredAt: now, + commandId: asCommandId("cmd-thread-done"), + causationEventId: null, + correlationId: asCommandId("cmd-thread-done"), + metadata: {}, + payload: { + threadId: asThreadId("thread-done"), + projectId: asProjectId("project-done"), + title: "Done Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: DEFAULT_RUNTIME_MODE, + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ); + + model = await Effect.runPromise( + projectEvent(model, { + sequence: 3, + eventId: asEventId("evt-session-running"), + aggregateKind: "thread", + aggregateId: asThreadId("thread-done"), + type: "thread.session-set", + occurredAt: "2026-01-01T00:01:00.000Z", + commandId: asCommandId("cmd-session-running"), + causationEventId: null, + correlationId: asCommandId("cmd-session-running"), + metadata: {}, + payload: { + threadId: asThreadId("thread-done"), + session: { + threadId: asThreadId("thread-done"), + status: "running" as const, + providerName: "codex", + runtimeMode: DEFAULT_RUNTIME_MODE, + activeTurnId: asTurnId("turn-done"), + lastError: null, + updatedAt: "2026-01-01T00:01:00.000Z", + }, + }, + }), + ); + + model = await Effect.runPromise( + projectEvent(model, { + sequence: 4, + eventId: asEventId("evt-turn-diff-completed"), + aggregateKind: "thread", + aggregateId: asThreadId("thread-done"), + type: "thread.turn-diff-completed", + occurredAt: "2026-01-01T00:02:00.000Z", + commandId: asCommandId("cmd-turn-diff-completed"), + causationEventId: null, + correlationId: asCommandId("cmd-turn-diff-completed"), + metadata: {}, + payload: { + threadId: asThreadId("thread-done"), + turnId: asTurnId("turn-done"), + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.make("checkpoint-1"), + status: "ready" as const, + files: [], + assistantMessageId: null, + completedAt: "2026-01-01T00:02:00.000Z", + }, + }), + ); + + const result = await runDecide({ + command: { + type: "thread.context.trim", + commandId: asCommandId("cmd-trim-done"), + threadId: asThreadId("thread-done"), + createdAt: "2026-01-02T00:00:00.000Z", + } as Extract, + readModel: model, + }); + + const events = Array.isArray(result) ? result : [result]; + expect(events.map((e) => e.type)).toEqual([ + "thread.trim-point-created", + "thread.session-stop-requested", + ]); + }); }); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index e3070d8a71a1..f075ecf39361 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -27,6 +27,7 @@ import { requireThreadArchived, requireThreadAbsent, requireThreadNotArchived, + requireTurnNotActive, } from "./commandInvariants.ts"; import { applyRefinementHandoffToSeededWorkItem, @@ -746,6 +747,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, threadId: command.threadId, }); + yield* requireTurnNotActive({ thread, command }); yield* requireThreadAbsent({ readModel, command, @@ -1082,6 +1084,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, threadId: command.threadId, }); + yield* requireTurnNotActive({ thread, command }); const messagesByTurn = new Map< string, @@ -1565,6 +1568,8 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + // NOTE: thread.context.summarize (issue #87) must also guard against + // active turns using requireTurnNotActive when the command is implemented. default: { command satisfies never; const fallback = command as never as { type: string }; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 4418d0e1bd0b..6d0c2c450ca4 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2930,8 +2930,18 @@ export default function ChatView(props: ChatViewProps) { ? parseStandaloneComposerSlashCommand(trimmed) : null; if (standaloneSlashCommand) { + if (!latestTurnSettled) { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Turn in progress", + description: "Wait for the current generation to complete before clearing context or starting a new thread.", + }), + ); + return; + } if (typeof standaloneSlashCommand === "object" && standaloneSlashCommand.command === "clear") { - void api.orchestration.dispatchCommand({ + api.orchestration.dispatchCommand({ type: "thread.context.trim", commandId: newCommandId(), threadId: activeThread.id, @@ -2939,15 +2949,31 @@ export default function ChatView(props: ChatViewProps) { ? { keepLastNTurns: standaloneSlashCommand.keepLastNTurns } : {}), createdAt: new Date().toISOString(), + }).catch((error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to clear context", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }), + ); }); } else if (standaloneSlashCommand === "new") { const nextThreadId = newThreadId(); - void api.orchestration.dispatchCommand({ + api.orchestration.dispatchCommand({ type: "thread.archive-and-new", commandId: newCommandId(), threadId: activeThread.id, newThreadId: nextThreadId, createdAt: new Date().toISOString(), + }).catch((error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to start new thread", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }), + ); }); void navigate({ to: "/thread/$threadId", @@ -3983,6 +4009,7 @@ export default function ChatView(props: ChatViewProps) { scheduleComposerFocus={scheduleComposerFocus} setThreadError={setThreadError} onExpandImage={onExpandTimelineImage} + activeTurnInProgress={isWorking || !latestTurnSettled} /> diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 6bb3de72db24..e894e68af193 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -485,6 +485,9 @@ export interface ChatComposerProps { scheduleComposerFocus: () => void; setThreadError: (threadId: ThreadId | null, error: string | null) => void; onExpandImage: (preview: ExpandedImagePreview) => void; + + // Active turn guard + activeTurnInProgress: boolean; } // -------------------------------------------------------------------------- @@ -889,6 +892,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) command: "clear", label: "/clear", description: "Clear conversation context and restart session", + disabled: props.activeTurnInProgress, }, { id: "slash:new", @@ -896,6 +900,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) command: "new", label: "/new", description: "Archive this thread and start a new one inheriting worktree", + disabled: props.activeTurnInProgress, }, ] satisfies ReadonlyArray>; const providerSlashCommandItems = (selectedProviderStatus?.slashCommands ?? []).map( diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index f687ec7ba239..e0e80ebced55 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -35,6 +35,7 @@ export type ComposerCommandItem = command: ComposerSlashCommand; label: string; description: string; + disabled?: boolean; } | { id: string; @@ -212,6 +213,7 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {