diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 74206557b0ad2..56d12586cfbd6 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -84,6 +84,7 @@ import { useComposerDraftSettings, } from "./use-composer-drafts"; import { + isExplicitCompactionActive, resolveComposerDispatchMode, type ActiveTurnComposerAction, } from "@t3tools/client-runtime/state/composer-dispatch"; @@ -334,7 +335,11 @@ export function useThreadComposerState() { ? deriveThreadActivityRun(selectedThreadProjection.projection) : (selectedThreadShell?.latestRun ?? null); - const isCompacting = (() => { + const activeRunId = + selectedThreadRuntime?.activeRunId && threadRuntimeIsActive(selectedThreadRuntime) + ? selectedThreadRuntime.activeRunId + : null; + const compactCommandIsActive = (() => { const queuedCompact = selectedThreadQueuedMessages.some( (message) => message.messageId === dispatchingQueuedMessageId && @@ -342,16 +347,7 @@ export function useThreadComposerState() { message.attachments.length === 0, ); if (queuedCompact) return true; - const activeRunId = selectedThreadRuntime?.activeRunId; - if (!activeRunId || !threadRuntimeIsActive(selectedThreadRuntime)) return false; - if ( - selectedThreadVisibleTurnItems.some( - ({ item }) => - item.runId === activeRunId && item.type === "compaction" && item.status === "running", - ) - ) { - return true; - } + if (!activeRunId) return false; const compactMessage = selectedThreadVisibleTurnItems.findLast( ({ item }) => item.runId === activeRunId && @@ -370,8 +366,21 @@ export function useThreadComposerState() { item.status === "cancelled"), ); })(); - // A compacting turn cannot take steering input, so follow-ups queue meanwhile. - const canSteerActiveTurn = queueWorkflow?.canPromoteToSteer === true && !isCompacting; + const compactionRunning = + activeRunId !== null && + selectedThreadVisibleTurnItems.some( + ({ item }) => + item.runId === activeRunId && item.type === "compaction" && item.status === "running", + ); + const isCompacting = compactCommandIsActive || compactionRunning; + const explicitCompactionIsActive = isExplicitCompactionActive({ + compactCommandIsActive, + compactionRunning, + run: selectedThreadProjection?.projection.runs.find((run) => run.id === activeRunId), + messages: selectedThreadMessages, + }); + const canSteerActiveTurn = + queueWorkflow?.canPromoteToSteer === true && !explicitCompactionIsActive; // A stop or a restart holds the queue, completion wakes included, until it // is resumed; the queue sheet lists only the user's own messages. const queueHeld = queueWorkflow?.isHeld === true; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8bf2ab68558ca..82db14738ebf7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -90,6 +90,7 @@ import { getUserQueuedThreadRuns, threadSupportsProviderHandoff, } from "@t3tools/client-runtime/state/thread-workflows"; +import { isExplicitCompactionActive } from "@t3tools/client-runtime/state/composer-dispatch"; import { resolvePendingHandoff } from "@t3tools/client-runtime/handoff"; import { HandoffRow } from "./chat/V2LifecycleRow"; import { @@ -1345,9 +1346,16 @@ export default function ChatView(props: ChatViewProps) { item.runId === activeRuntime?.activeRunId && item.status === "running", ); + const compactCommandIsActive = compactRequestIsActive && !compactionSettled; const isCompacting = (isSendBusy || phase === "connecting" || phase === "running") && - (compactionRunning || (compactRequestIsActive && !compactionSettled)); + (compactionRunning || compactCommandIsActive); + const explicitCompactionIsActive = isExplicitCompactionActive({ + compactCommandIsActive, + compactionRunning, + run: serverProjection?.runs.find((run) => run.id === activeRuntime?.activeRunId), + messages: serverProjection?.messages, + }); const isWorking = phase === "running" || isSendBusy || @@ -3494,7 +3502,7 @@ export default function ChatView(props: ChatViewProps) { isLocalDraftThread && activeProject === null } phase={phase} - isCompacting={isCompacting} + canSteer={!explicitCompactionIsActive} canInterrupt={canInterruptRunningThread} isConnecting={isConnecting} isSendBusy={ diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index d7b46eea0abc0..6d741ca6722d1 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -387,7 +387,7 @@ export interface ChatComposerProps { isRevertingCheckpoint?: boolean; sendDisabledReason: string | null; isPreparingWorktree: boolean; - isCompacting: boolean; + canSteer: boolean; bannerItems: readonly ComposerBannerStackItem[]; resumeCompactionTokens: number | null; keepFullHistory: boolean; @@ -548,7 +548,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isRevertingCheckpoint = false, sendDisabledReason: externalSendDisabledReason, isPreparingWorktree, - isCompacting, + canSteer, environmentUnavailable, activePendingApproval: incomingPendingApproval, pendingApprovals, @@ -2123,7 +2123,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) running: phase === "running", alternateModifier: false, activeTurnDefault: followUpBehavior, - canSteer: !isCompacting, + canSteer, }), submissionIntent, ); @@ -2146,7 +2146,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) running: phase === "running", alternateModifier: event.metaKey || event.ctrlKey, activeTurnDefault: followUpBehavior, - canSteer: !isCompacting, + canSteer, }), ); }; @@ -2158,7 +2158,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) running: phase === "running", alternateModifier: false, activeTurnDefault: followUpBehavior, - canSteer: !isCompacting, + canSteer, }), ); }; @@ -2218,7 +2218,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) sendShortcut: settings.sendShortcut, routeKind, phase, - isCompacting, + canSteer, followUpBehavior, isSoftwareKeyboardOpen, planModeUiEnabled, @@ -3785,7 +3785,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isRunning={phase === "running"} canInterrupt={canInterrupt} followUpBehavior={followUpBehavior} - canSteer={!isCompacting} + canSteer={canSteer} alternateShortcutLabel={shortcutLabelForCommand( keybindings, "composer.sendAlternate", diff --git a/apps/web/src/components/chat/composerCommandKey.ts b/apps/web/src/components/chat/composerCommandKey.ts index 477485472914d..90c345cac6b6e 100644 --- a/apps/web/src/components/chat/composerCommandKey.ts +++ b/apps/web/src/components/chat/composerCommandKey.ts @@ -33,7 +33,7 @@ export function composerCommandKeyHandler(input: { readonly sendShortcut: ChatComposerProps["settings"]["sendShortcut"]; readonly routeKind: ChatComposerProps["routeKind"]; readonly phase: ChatComposerProps["phase"]; - readonly isCompacting: boolean; + readonly canSteer: boolean; readonly followUpBehavior: ChatComposerProps["settings"]["followUpBehavior"]; readonly isSoftwareKeyboardOpen: boolean; readonly planModeUiEnabled: boolean; @@ -85,7 +85,7 @@ export function composerCommandKeyHandler(input: { sendShortcut, routeKind, phase, - isCompacting, + canSteer, followUpBehavior, isSoftwareKeyboardOpen, planModeUiEnabled, @@ -222,7 +222,7 @@ export function composerCommandKeyHandler(input: { running: phase === "running", alternateModifier: submissionIntent === "alternate", activeTurnDefault: followUpBehavior, - canSteer: !isCompacting, + canSteer, }), submissionIntent, ); diff --git a/packages/client-runtime/src/state/composerDispatch.test.ts b/packages/client-runtime/src/state/composerDispatch.test.ts new file mode 100644 index 0000000000000..a6dbee0d7c134 --- /dev/null +++ b/packages/client-runtime/src/state/composerDispatch.test.ts @@ -0,0 +1,96 @@ +import { expect, it } from "vite-plus/test"; + +import { isExplicitCompactionActive, resolveComposerDispatchMode } from "./composerDispatch.ts"; + +it("uses the run's retained message when its initiating timeline item is paged out", () => { + expect( + isExplicitCompactionActive({ + compactCommandIsActive: false, + compactionRunning: true, + run: { userMessageId: "command" }, + messages: [ + { id: "command", text: "/compact keep the debugging notes" }, + { id: "steer", text: "A later follow-up" }, + ], + }), + ).toBe(true); +}); + +it("allows steering during automatic compaction and preserves Queue and alternate dispatch", () => { + const canSteer = !isExplicitCompactionActive({ + compactCommandIsActive: false, + compactionRunning: true, + run: { userMessageId: "user" }, + messages: [{ id: "user", text: "Read the code" }], + }); + expect(resolveComposerDispatchMode({ running: true, alternateModifier: false, canSteer })).toBe( + "steer", + ); + expect(resolveComposerDispatchMode({ running: true, alternateModifier: true, canSteer })).toBe( + "queue", + ); + expect( + resolveComposerDispatchMode({ + running: true, + alternateModifier: false, + activeTurnDefault: "queue", + canSteer, + }), + ).toBe("queue"); +}); + +it("blocks steering only for a pending compact command or a running explicit compaction", () => { + expect( + isExplicitCompactionActive({ + compactCommandIsActive: true, + compactionRunning: false, + run: undefined, + messages: undefined, + }), + ).toBe(true); + for (const text of ["/compact", " /COMPACT ", "/compact keep the debugging notes"]) { + const canSteer = !isExplicitCompactionActive({ + compactCommandIsActive: false, + compactionRunning: true, + run: { userMessageId: "user" }, + messages: text === undefined ? [] : [{ id: "user", text }], + }); + expect(resolveComposerDispatchMode({ running: true, alternateModifier: false, canSteer })).toBe( + "queue", + ); + expect(resolveComposerDispatchMode({ running: true, alternateModifier: true, canSteer })).toBe( + "queue", + ); + expect( + isExplicitCompactionActive({ + compactCommandIsActive: false, + compactionRunning: false, + run: { userMessageId: "user" }, + messages: text === undefined ? [] : [{ id: "user", text }], + }), + ).toBe(false); + } + for (const text of ["/compactify", "please /compact", undefined]) { + expect( + isExplicitCompactionActive({ + compactCommandIsActive: false, + compactionRunning: true, + run: { userMessageId: "user" }, + messages: text === undefined ? [] : [{ id: "user", text }], + }), + ).toBe(false); + } +}); + +it("allows steering when an attached compact prompt compacts automatically", () => { + const message = { id: "user", text: "/compact", attachments: [{}] }; + const canSteer = !isExplicitCompactionActive({ + compactCommandIsActive: false, + compactionRunning: true, + run: { userMessageId: "user" }, + messages: [message], + }); + expect(resolveComposerDispatchMode({ running: true, alternateModifier: false, canSteer })).toBe( + "steer", + ); +}); diff --git a/packages/client-runtime/src/state/composerDispatch.ts b/packages/client-runtime/src/state/composerDispatch.ts index 145b1ba508147..2836d22c36bca 100644 --- a/packages/client-runtime/src/state/composerDispatch.ts +++ b/packages/client-runtime/src/state/composerDispatch.ts @@ -5,7 +5,7 @@ export function resolveComposerDispatchMode(input: { readonly running: boolean; readonly alternateModifier: boolean; readonly activeTurnDefault?: ActiveTurnComposerAction; - /** False while the active turn cannot take steering input, such as during compaction. */ + /** False while the active turn cannot take steering input, such as an explicit /compact. */ readonly canSteer?: boolean; }): ComposerDispatchMode { if (!input.running) return "auto"; @@ -15,6 +15,28 @@ export function resolveComposerDispatchMode(input: { return defaultAction; } +// Automatic compaction keeps accepting input; only explicit compaction blocks it. +export function isExplicitCompactionActive(input: { + readonly compactCommandIsActive: boolean; + readonly compactionRunning: boolean; + readonly run: { readonly userMessageId: string } | undefined; + readonly messages: + | ReadonlyArray<{ + readonly id: string; + readonly text: string; + readonly attachments?: ReadonlyArray; + }> + | undefined; +}): boolean { + if (input.compactCommandIsActive) return true; + if (!input.compactionRunning || input.run === undefined) return false; + const message = input.messages?.find((message) => message.id === input.run?.userMessageId); + return ( + (message?.attachments?.length ?? 0) === 0 && + /^\/compact(?:\s|$)/i.test(message?.text.trim() ?? "") + ); +} + export function alternateComposerDispatchAction( activeTurnDefault?: ActiveTurnComposerAction, ): ActiveTurnComposerAction {