From bbc2db07af8ce668a1375c3e64c88bcedac0e558 Mon Sep 17 00:00:00 2001 From: NikitaMGrimm <65914808+NikitaMGrimm@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:09:36 +0200 Subject: [PATCH 1/4] fix(composer): steer instead of queueing during automatic compaction --- .../src/state/use-thread-composer-state.ts | 40 +++++++++++++------ apps/web/src/components/ChatView.tsx | 14 ++++++- apps/web/src/components/chat/ChatComposer.tsx | 14 +++---- .../components/chat/useComposerCommandKey.ts | 6 +-- .../src/state/composerDispatch.test.ts | 12 ++++++ .../src/state/composerDispatch.ts | 11 ++++- 6 files changed, 71 insertions(+), 26 deletions(-) create mode 100644 packages/client-runtime/src/state/composerDispatch.test.ts diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index ec71677013b44..9e849d4e6fef5 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -81,6 +81,7 @@ import { useComposerDraftSettings, } from "./use-composer-drafts"; import { + isCompactCommandText, resolveComposerDispatchMode, type ActiveTurnComposerAction, } from "@t3tools/client-runtime/state/composer-dispatch"; @@ -333,7 +334,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 && @@ -341,16 +346,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 && @@ -369,8 +365,26 @@ 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; + // Only an explicit compaction refuses steering. Automatic compaction runs + // inside an ordinary turn, which still accepts it. + const explicitCompactionIsActive = + compactCommandIsActive || + (compactionRunning && + selectedThreadVisibleTurnItems.some( + ({ item }) => + item.runId === activeRunId && + item.type === "user_message" && + isCompactCommandText(item.text), + )); + 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 62223ba82096a..f6da3777d9234 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -84,6 +84,7 @@ import { workflowOpeningParentThreadId, } from "@t3tools/client-runtime/state/subagentRuntime"; import { threadSupportsProviderHandoff } from "@t3tools/client-runtime/state/thread-workflows"; +import { isCompactCommandText } from "@t3tools/client-runtime/state/composer-dispatch"; import { resolvePendingHandoff } from "@t3tools/client-runtime/handoff"; import { HandoffRow } from "./chat/V2LifecycleRow"; import { @@ -1398,9 +1399,18 @@ 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); + // Only an explicit compaction refuses steering. Automatic compaction runs + // inside an ordinary turn, which still accepts it. + const explicitCompactionIsActive = + compactCommandIsActive || + (compactionRunning && + latestServerUserItem?.type === "user_message" && + latestServerUserItem.runId === activeRuntime?.activeRunId && + isCompactCommandText(latestServerUserItem.text)); const isWorking = phase === "running" || isSendBusy || @@ -3523,7 +3533,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 162069163f9b4..13154915a8d6f 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -398,7 +398,7 @@ export interface ChatComposerProps { isRevertingCheckpoint?: boolean; sendDisabledReason: string | null; isPreparingWorktree: boolean; - isCompacting: boolean; + canSteer: boolean; bannerItems: readonly ComposerBannerStackItem[]; resumeCompactionTokens: number | null; keepFullHistory: boolean; @@ -562,7 +562,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isRevertingCheckpoint = false, sendDisabledReason: externalSendDisabledReason, isPreparingWorktree, - isCompacting, + canSteer, environmentUnavailable, activePendingApproval: incomingPendingApproval, pendingApprovals, @@ -2154,7 +2154,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) running: phase === "running", alternateModifier: false, activeTurnDefault: followUpBehavior, - canSteer: !isCompacting, + canSteer, }), submissionIntent, ); @@ -2177,7 +2177,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) running: phase === "running", alternateModifier: event.metaKey || event.ctrlKey, activeTurnDefault: followUpBehavior, - canSteer: !isCompacting, + canSteer, }), ); }; @@ -2189,7 +2189,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) running: phase === "running", alternateModifier: false, activeTurnDefault: followUpBehavior, - canSteer: !isCompacting, + canSteer, }), ); }; @@ -2248,7 +2248,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) sendShortcut: settings.sendShortcut, routeKind, phase, - isCompacting, + canSteer, followUpBehavior, isSoftwareKeyboardOpen, planModeUiEnabled, @@ -3913,7 +3913,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/useComposerCommandKey.ts b/apps/web/src/components/chat/useComposerCommandKey.ts index c4166b4370530..3b6d14e6831e6 100644 --- a/apps/web/src/components/chat/useComposerCommandKey.ts +++ b/apps/web/src/components/chat/useComposerCommandKey.ts @@ -32,7 +32,7 @@ export function useComposerCommandKey(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; @@ -84,7 +84,7 @@ export function useComposerCommandKey(input: { sendShortcut, routeKind, phase, - isCompacting, + canSteer, followUpBehavior, isSoftwareKeyboardOpen, planModeUiEnabled, @@ -226,7 +226,7 @@ export function useComposerCommandKey(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..1eecca0e55106 --- /dev/null +++ b/packages/client-runtime/src/state/composerDispatch.test.ts @@ -0,0 +1,12 @@ +import { expect, it } from "vite-plus/test"; + +import { isCompactCommandText } from "./composerDispatch.ts"; + +it("recognizes explicit compaction commands, with or without instructions", () => { + expect(isCompactCommandText("/compact")).toBe(true); + expect(isCompactCommandText(" /COMPACT ")).toBe(true); + expect(isCompactCommandText("/compact keep the debugging notes")).toBe(true); + expect(isCompactCommandText("/compactify")).toBe(false); + expect(isCompactCommandText("please /compact")).toBe(false); + expect(isCompactCommandText("Also end your reply with PINEAPPLE.")).toBe(false); +}); diff --git a/packages/client-runtime/src/state/composerDispatch.ts b/packages/client-runtime/src/state/composerDispatch.ts index 145b1ba508147..ebab3de0def0b 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,15 @@ export function resolveComposerDispatchMode(input: { return defaultAction; } +/** + * Whether a run's user message asks for an explicit compaction. Its running + * compaction refuses steering, unlike automatic compaction inside an ordinary + * turn. Pi and Claude also compact for `/compact `. + */ +export function isCompactCommandText(text: string): boolean { + return /^\/compact(?:\s|$)/i.test(text.trim()); +} + export function alternateComposerDispatchAction( activeTurnDefault?: ActiveTurnComposerAction, ): ActiveTurnComposerAction { From e5cd68c6d28707aa26a862626efee9656d9294e7 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 07:13:08 +0000 Subject: [PATCH 2/4] fix(composer): share explicit compaction guard across clients --- .../src/state/use-thread-composer-state.ts | 22 +++--- apps/web/src/components/ChatView.tsx | 19 +++--- .../src/state/composerDispatch.test.ts | 68 ++++++++++++++++--- .../src/state/composerDispatch.ts | 18 +++-- 4 files changed, 91 insertions(+), 36 deletions(-) diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 3d3ae8151a81b..7dc8eefe6bcca 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -81,7 +81,7 @@ import { useComposerDraftSettings, } from "./use-composer-drafts"; import { - isCompactCommandText, + isExplicitCompactionActive, resolveComposerDispatchMode, type ActiveTurnComposerAction, } from "@t3tools/client-runtime/state/composer-dispatch"; @@ -369,17 +369,15 @@ export function useThreadComposerState() { item.runId === activeRunId && item.type === "compaction" && item.status === "running", ); const isCompacting = compactCommandIsActive || compactionRunning; - // Only an explicit compaction refuses steering. Automatic compaction runs - // inside an ordinary turn, which still accepts it. - const explicitCompactionIsActive = - compactCommandIsActive || - (compactionRunning && - selectedThreadVisibleTurnItems.some( - ({ item }) => - item.runId === activeRunId && - item.type === "user_message" && - isCompactCommandText(item.text), - )); + const activeRunUserItem = selectedThreadVisibleTurnItems.find( + ({ item }) => item.type === "user_message" && item.runId === activeRunId, + )?.item; + const explicitCompactionIsActive = isExplicitCompactionActive({ + compactCommandIsActive, + compactionRunning, + activeRunUserMessageText: + activeRunUserItem?.type === "user_message" ? activeRunUserItem.text : undefined, + }); const canSteerActiveTurn = queueWorkflow?.canPromoteToSteer === true && !explicitCompactionIsActive; // A stop or a restart holds the queue, completion wakes included, until it diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 198b1403dce5d..59fdddf690862 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -86,7 +86,7 @@ import { workflowOpeningParentThreadId, } from "@t3tools/client-runtime/state/subagentRuntime"; import { threadSupportsProviderHandoff } from "@t3tools/client-runtime/state/thread-workflows"; -import { isCompactCommandText } from "@t3tools/client-runtime/state/composer-dispatch"; +import { isExplicitCompactionActive } from "@t3tools/client-runtime/state/composer-dispatch"; import { resolvePendingHandoff } from "@t3tools/client-runtime/handoff"; import { HandoffRow } from "./chat/V2LifecycleRow"; import { @@ -1367,14 +1367,15 @@ export default function ChatView(props: ChatViewProps) { const isCompacting = (isSendBusy || phase === "connecting" || phase === "running") && (compactionRunning || compactCommandIsActive); - // Only an explicit compaction refuses steering. Automatic compaction runs - // inside an ordinary turn, which still accepts it. - const explicitCompactionIsActive = - compactCommandIsActive || - (compactionRunning && - latestServerUserItem?.type === "user_message" && - latestServerUserItem.runId === activeRuntime?.activeRunId && - isCompactCommandText(latestServerUserItem.text)); + const activeRunUserItem = serverVisibleTurnItems.find( + ({ item }) => item.type === "user_message" && item.runId === activeRuntime?.activeRunId, + )?.item; + const explicitCompactionIsActive = isExplicitCompactionActive({ + compactCommandIsActive, + compactionRunning, + activeRunUserMessageText: + activeRunUserItem?.type === "user_message" ? activeRunUserItem.text : undefined, + }); const isWorking = phase === "running" || isSendBusy || diff --git a/packages/client-runtime/src/state/composerDispatch.test.ts b/packages/client-runtime/src/state/composerDispatch.test.ts index 1eecca0e55106..4edf9bc4cf883 100644 --- a/packages/client-runtime/src/state/composerDispatch.test.ts +++ b/packages/client-runtime/src/state/composerDispatch.test.ts @@ -1,12 +1,64 @@ import { expect, it } from "vite-plus/test"; -import { isCompactCommandText } from "./composerDispatch.ts"; +import { isExplicitCompactionActive, resolveComposerDispatchMode } from "./composerDispatch.ts"; -it("recognizes explicit compaction commands, with or without instructions", () => { - expect(isCompactCommandText("/compact")).toBe(true); - expect(isCompactCommandText(" /COMPACT ")).toBe(true); - expect(isCompactCommandText("/compact keep the debugging notes")).toBe(true); - expect(isCompactCommandText("/compactify")).toBe(false); - expect(isCompactCommandText("please /compact")).toBe(false); - expect(isCompactCommandText("Also end your reply with PINEAPPLE.")).toBe(false); +it("allows steering during automatic compaction and preserves Queue and alternate dispatch", () => { + const canSteer = !isExplicitCompactionActive({ + compactCommandIsActive: false, + compactionRunning: true, + activeRunUserMessageText: "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, + activeRunUserMessageText: undefined, + }), + ).toBe(true); + for (const text of ["/compact", " /COMPACT ", "/compact keep the debugging notes"]) { + const canSteer = !isExplicitCompactionActive({ + compactCommandIsActive: false, + compactionRunning: true, + activeRunUserMessageText: 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, + activeRunUserMessageText: text, + }), + ).toBe(false); + } + for (const text of ["/compactify", "please /compact", undefined]) { + expect( + isExplicitCompactionActive({ + compactCommandIsActive: false, + compactionRunning: true, + activeRunUserMessageText: text, + }), + ).toBe(false); + } }); diff --git a/packages/client-runtime/src/state/composerDispatch.ts b/packages/client-runtime/src/state/composerDispatch.ts index ebab3de0def0b..e1f77240ea5c7 100644 --- a/packages/client-runtime/src/state/composerDispatch.ts +++ b/packages/client-runtime/src/state/composerDispatch.ts @@ -15,13 +15,17 @@ export function resolveComposerDispatchMode(input: { return defaultAction; } -/** - * Whether a run's user message asks for an explicit compaction. Its running - * compaction refuses steering, unlike automatic compaction inside an ordinary - * turn. Pi and Claude also compact for `/compact `. - */ -export function isCompactCommandText(text: string): boolean { - return /^\/compact(?:\s|$)/i.test(text.trim()); +// Automatic compaction keeps accepting input; only explicit compaction blocks it. +export function isExplicitCompactionActive(input: { + readonly compactCommandIsActive: boolean; + readonly compactionRunning: boolean; + readonly activeRunUserMessageText: string | undefined; +}): boolean { + return ( + input.compactCommandIsActive || + (input.compactionRunning && + /^\/compact(?:\s|$)/i.test(input.activeRunUserMessageText?.trim() ?? "")) + ); } export function alternateComposerDispatchAction( From a5d900de598e7f90bd9cd23cd0829932d6b8655b Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 07:25:48 +0000 Subject: [PATCH 3/4] fix(composer): retain manual compaction guard across history paging --- .../src/state/use-thread-composer-state.ts | 7 ++--- apps/web/src/components/ChatView.tsx | 7 ++--- .../src/state/composerDispatch.test.ts | 29 +++++++++++++++---- .../src/state/composerDispatch.ts | 12 ++++---- 4 files changed, 34 insertions(+), 21 deletions(-) diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 7dc8eefe6bcca..45df8d48baac4 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -369,14 +369,11 @@ export function useThreadComposerState() { item.runId === activeRunId && item.type === "compaction" && item.status === "running", ); const isCompacting = compactCommandIsActive || compactionRunning; - const activeRunUserItem = selectedThreadVisibleTurnItems.find( - ({ item }) => item.type === "user_message" && item.runId === activeRunId, - )?.item; const explicitCompactionIsActive = isExplicitCompactionActive({ compactCommandIsActive, compactionRunning, - activeRunUserMessageText: - activeRunUserItem?.type === "user_message" ? activeRunUserItem.text : undefined, + run: selectedThreadProjection?.projection.runs.find((run) => run.id === activeRunId), + messages: selectedThreadMessages, }); const canSteerActiveTurn = queueWorkflow?.canPromoteToSteer === true && !explicitCompactionIsActive; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 59fdddf690862..4e8e124daf85c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1367,14 +1367,11 @@ export default function ChatView(props: ChatViewProps) { const isCompacting = (isSendBusy || phase === "connecting" || phase === "running") && (compactionRunning || compactCommandIsActive); - const activeRunUserItem = serverVisibleTurnItems.find( - ({ item }) => item.type === "user_message" && item.runId === activeRuntime?.activeRunId, - )?.item; const explicitCompactionIsActive = isExplicitCompactionActive({ compactCommandIsActive, compactionRunning, - activeRunUserMessageText: - activeRunUserItem?.type === "user_message" ? activeRunUserItem.text : undefined, + run: serverProjection?.runs.find((run) => run.id === activeRuntime?.activeRunId), + messages: serverProjection?.messages, }); const isWorking = phase === "running" || diff --git a/packages/client-runtime/src/state/composerDispatch.test.ts b/packages/client-runtime/src/state/composerDispatch.test.ts index 4edf9bc4cf883..8b2347e940bf3 100644 --- a/packages/client-runtime/src/state/composerDispatch.test.ts +++ b/packages/client-runtime/src/state/composerDispatch.test.ts @@ -2,11 +2,26 @@ 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, - activeRunUserMessageText: "Read the code", + run: { userMessageId: "user" }, + messages: [{ id: "user", text: "Read the code" }], }); expect(resolveComposerDispatchMode({ running: true, alternateModifier: false, canSteer })).toBe( "steer", @@ -29,14 +44,16 @@ it("blocks steering only for a pending compact command or a running explicit com isExplicitCompactionActive({ compactCommandIsActive: true, compactionRunning: false, - activeRunUserMessageText: undefined, + run: undefined, + messages: undefined, }), ).toBe(true); for (const text of ["/compact", " /COMPACT ", "/compact keep the debugging notes"]) { const canSteer = !isExplicitCompactionActive({ compactCommandIsActive: false, compactionRunning: true, - activeRunUserMessageText: text, + run: { userMessageId: "user" }, + messages: text === undefined ? [] : [{ id: "user", text }], }); expect(resolveComposerDispatchMode({ running: true, alternateModifier: false, canSteer })).toBe( "queue", @@ -48,7 +65,8 @@ it("blocks steering only for a pending compact command or a running explicit com isExplicitCompactionActive({ compactCommandIsActive: false, compactionRunning: false, - activeRunUserMessageText: text, + run: { userMessageId: "user" }, + messages: text === undefined ? [] : [{ id: "user", text }], }), ).toBe(false); } @@ -57,7 +75,8 @@ it("blocks steering only for a pending compact command or a running explicit com isExplicitCompactionActive({ compactCommandIsActive: false, compactionRunning: true, - activeRunUserMessageText: text, + run: { userMessageId: "user" }, + messages: text === undefined ? [] : [{ id: "user", text }], }), ).toBe(false); } diff --git a/packages/client-runtime/src/state/composerDispatch.ts b/packages/client-runtime/src/state/composerDispatch.ts index e1f77240ea5c7..d22d7036629a2 100644 --- a/packages/client-runtime/src/state/composerDispatch.ts +++ b/packages/client-runtime/src/state/composerDispatch.ts @@ -19,13 +19,13 @@ export function resolveComposerDispatchMode(input: { export function isExplicitCompactionActive(input: { readonly compactCommandIsActive: boolean; readonly compactionRunning: boolean; - readonly activeRunUserMessageText: string | undefined; + readonly run: { readonly userMessageId: string } | undefined; + readonly messages: ReadonlyArray<{ readonly id: string; readonly text: string }> | undefined; }): boolean { - return ( - input.compactCommandIsActive || - (input.compactionRunning && - /^\/compact(?:\s|$)/i.test(input.activeRunUserMessageText?.trim() ?? "")) - ); + 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 /^\/compact(?:\s|$)/i.test(message?.text.trim() ?? ""); } export function alternateComposerDispatchAction( From ef4cfa7fa1cb67abd460ae95ca2a7f1cc963b4ad Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 27 Oct 2026 01:35:37 +0000 Subject: [PATCH 4/4] fix(composer): allow steering for attached compact prompts --- .../src/state/composerDispatch.test.ts | 13 +++++++++++++ .../client-runtime/src/state/composerDispatch.ts | 13 +++++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/client-runtime/src/state/composerDispatch.test.ts b/packages/client-runtime/src/state/composerDispatch.test.ts index 8b2347e940bf3..a6dbee0d7c134 100644 --- a/packages/client-runtime/src/state/composerDispatch.test.ts +++ b/packages/client-runtime/src/state/composerDispatch.test.ts @@ -81,3 +81,16 @@ it("blocks steering only for a pending compact command or a running explicit com ).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 d22d7036629a2..2836d22c36bca 100644 --- a/packages/client-runtime/src/state/composerDispatch.ts +++ b/packages/client-runtime/src/state/composerDispatch.ts @@ -20,12 +20,21 @@ 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 }> | 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 /^\/compact(?:\s|$)/i.test(message?.text.trim() ?? ""); + return ( + (message?.attachments?.length ?? 0) === 0 && + /^\/compact(?:\s|$)/i.test(message?.text.trim() ?? "") + ); } export function alternateComposerDispatchAction(