From 2df68fc52a1b715e89f26cd8be7fcaaabc1b9487 Mon Sep 17 00:00:00 2001 From: Pieter Venter Date: Fri, 30 Oct 2026 13:01:43 +0200 Subject: [PATCH 1/5] feat(web): show the active goal above the composer The goal lived at the end of the timeline, so /goal resume did not name the goal it continues. Codex keeps that goal, and a pause or play control, on the composer itself. Co-authored-by: Cursor --- apps/web/src/components/ChatView.tsx | 7 +- apps/web/src/components/chat/ChatComposer.tsx | 19 ++++ .../chat/ComposerGoalBar.browser.test.tsx | 58 ++++++++++++ .../src/components/chat/ComposerGoalBar.tsx | 94 +++++++++++++++++++ .../components/chat/ThreadActivityBanners.tsx | 49 +--------- apps/web/src/components/chat/ThreadNow.tsx | 5 +- .../chat/presentComposerGoalBar.test.ts | 80 ++++++++++++++++ .../components/chat/presentComposerGoalBar.ts | 62 ++++++++++++ .../components/chat/useThreadLifecycle.tsx | 22 +---- docs/user/composer.md | 11 ++- 10 files changed, 329 insertions(+), 78 deletions(-) create mode 100644 apps/web/src/components/chat/ComposerGoalBar.browser.test.tsx create mode 100644 apps/web/src/components/chat/ComposerGoalBar.tsx create mode 100644 apps/web/src/components/chat/presentComposerGoalBar.test.ts create mode 100644 apps/web/src/components/chat/presentComposerGoalBar.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cf48ddcb842d..6f92fe9bbedf 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2357,7 +2357,6 @@ export default function ChatView(props: ChatViewProps) { attentionNow, workNow, childInputNow, - goalNow, handleSwitchCheckoutToThread, handleSwitchCheckoutToThreadStable, isRestoringThreadBranch, @@ -2998,8 +2997,7 @@ export default function ChatView(props: ChatViewProps) { }; const onExpandTimelineImageStable = useStableCallback(onExpandTimelineImage); const shownThreadSyncPhase = activeEnvironmentUnavailable ? null : delayedThreadSyncPhase; - const hasThreadNow = - attentionNow !== null || workNow !== null || goalNow !== null || shownThreadSyncPhase !== null; + const hasThreadNow = attentionNow !== null || workNow !== null || shownThreadSyncPhase !== null; const queuedRunCount = serverProjection ? getUserQueuedThreadRuns(serverProjection).length : 0; // The queue control is mounted for every server thread but joins the footer only while it shows // something, by its own rule: an optimistic message stops counting once the server has it. @@ -3041,7 +3039,6 @@ export default function ChatView(props: ChatViewProps) { attention={attentionNow} work={workNow} syncPhase={shownThreadSyncPhase} - goal={goalNow} /> {threadStatusLine} {pendingGuestMessageCount > 0 && isServerThread && activeThread ? ( @@ -3498,6 +3495,7 @@ export default function ChatView(props: ChatViewProps) { isLocalDraftThread && activeProject === null } phase={phase} + threadWorking={isWorking} isCompacting={isCompacting} canInterrupt={canInterruptRunningThread} isSendBusy={ @@ -3590,6 +3588,7 @@ export default function ChatView(props: ChatViewProps) { onPageScrollRelease={onComposerPageScrollReleaseStable} onCompactContext={onComposerCompactContextStable} onSend={onComposerSendStable} + onSendStandaloneCommand={sendStandaloneCommand} onResume={onComposerResumeStable} onInterrupt={onInterruptStable} onImplementPlanInNewThread={onImplementPlanInNewThreadStable} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index d7878cc29a16..e160617974b5 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -114,6 +114,8 @@ import { } from "./composerContextUndo"; import type { ThreadSyncPhase } from "../../threadSync"; import { ComposerBanner } from "./ComposerBanner"; +import { ComposerGoalBar } from "./ComposerGoalBar"; +import { presentComposerGoalBar } from "./presentComposerGoalBar"; import { ComposerSurface } from "./ComposerSurface"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./ComposerBannerStack"; import { @@ -370,6 +372,8 @@ export interface ChatComposerProps { projectSelectionRequired: boolean; phase: SessionPhase; + /** The thread is working, including runless work the session phase does not show. */ + threadWorking: boolean; canInterrupt: boolean; isSendBusy: boolean; canResume: boolean; @@ -463,6 +467,8 @@ export interface ChatComposerProps { dispatchMode?: ComposerDispatchMode, submissionIntent?: ComposerSubmissionIntent, ) => void; + /** Sends a composer command without replacing the draft, such as `/goal resume`. */ + onSendStandaloneCommand: (text: string, failureMessage: string) => Promise; onResume: () => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; @@ -3079,6 +3085,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onRemoveFile={removeComposerFileFromDraft} /> ) : null; + const composerGoal = presentComposerGoalBar({ + goal: props.activeThreadShell?.goal ?? null, + working: props.threadWorking, + prompt: activePendingProgress ? activePendingProgress.customAnswer : prompt, + canPauseResume: selectedProvider === "codex", + }); const composerAttachButton = showComposerAttachAction ? ( + {composerGoal === null ? null : ( + + )} {showCollapsedMobilePromptRow ? (
+ )} + {showClear ? ( + void onSendCommand("/goal clear", "Failed to clear goal.")} + > + Clear + + ) : null} + + ) : null} +
+ ); +} diff --git a/apps/web/src/components/chat/ThreadActivityBanners.tsx b/apps/web/src/components/chat/ThreadActivityBanners.tsx index 8bd26e21a04d..ba5e4ae75779 100644 --- a/apps/web/src/components/chat/ThreadActivityBanners.tsx +++ b/apps/web/src/components/chat/ThreadActivityBanners.tsx @@ -1,12 +1,7 @@ -import type { - OrchestrationV2PendingBackgroundTask, - OrchestrationV2ProviderGoal, - ThreadId, -} from "@t3tools/contracts"; +import type { OrchestrationV2PendingBackgroundTask, ThreadId } from "@t3tools/contracts"; import type { EnvironmentThreadShell, ThreadStructure } from "@t3tools/client-runtime/state/shell"; import { presentPendingBackgroundWork, - presentProviderGoal, type RunStopPresentation, } from "@t3tools/client-runtime/state/thread-execution"; import { childThreadInputsTitle } from "@t3tools/client-runtime/state/threads"; @@ -18,7 +13,6 @@ import { GitBranchIcon, MessageCircleIcon, ShieldQuestionIcon, - TargetIcon, TerminalIcon, TriangleAlertIcon, } from "lucide-react"; @@ -67,47 +61,6 @@ export function childInputNowItem(input: { }; } -/** The thread's goal, for the end of the timeline. */ -export function goalNowItem(input: { - readonly threadId: ThreadId; - readonly goal: OrchestrationV2ProviderGoal; - readonly isWorking: boolean; - readonly onSendCommand: (text: string, failureMessage: string) => Promise; -}): ThreadNowItem { - const { isWorking, onSendCommand } = input; - const presentation = presentProviderGoal(input.goal, isWorking); - return { - id: `goal:${input.threadId}`, - icon: , - title: - presentation.usage === null - ? presentation.title - : `${presentation.title} · ${presentation.usage}`, - description: presentation.objective, - // Clear stops goal work; Resume would stop unrelated work, so it waits. - actions: ( - <> - {presentation.canResume && !isWorking ? ( - <> - void onSendCommand("/goal resume", "Failed to resume goal.")} - > - Resume - - · - - ) : null} - void onSendCommand("/goal clear", "Failed to clear goal.")} - > - Clear - - - ), - }; -} - /** A Stop the provider has not finished; Force stop once it stops answering. */ export function runStopNowItem(input: { readonly threadId: ThreadId; diff --git a/apps/web/src/components/chat/ThreadNow.tsx b/apps/web/src/components/chat/ThreadNow.tsx index f31344cadcfa..1dc45d47442a 100644 --- a/apps/web/src/components/chat/ThreadNow.tsx +++ b/apps/web/src/components/chat/ThreadNow.tsx @@ -78,26 +78,23 @@ function ThreadSyncLine({ phase }: { readonly phase: ThreadSyncPhase }) { /** * What the thread is doing now, at the end of the timeline: what needs you, background work, - * the sync status while messages load, then the goal. + * then the sync status while messages load. The goal sits on the composer, above the field. */ export function ThreadNow({ attention, work, syncPhase, - goal, }: { /** Something that needs you elsewhere, such as a subagent's question. */ readonly attention: ThreadNowItem | null; readonly work: ThreadNowItem | null; readonly syncPhase: ThreadSyncPhase | null; - readonly goal: ThreadNowItem | null; }): ReactNode { return ( <> {attention ? : null} {work ? : null} {syncPhase ? : null} - {goal ? : null} ); } diff --git a/apps/web/src/components/chat/presentComposerGoalBar.test.ts b/apps/web/src/components/chat/presentComposerGoalBar.test.ts new file mode 100644 index 000000000000..787ac26ddb01 --- /dev/null +++ b/apps/web/src/components/chat/presentComposerGoalBar.test.ts @@ -0,0 +1,80 @@ +import type { OrchestrationV2ProviderGoal } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { presentComposerGoalBar } from "./presentComposerGoalBar"; + +function goal( + status: OrchestrationV2ProviderGoal["status"], + objective = "All tests in packages/api pass", +): OrchestrationV2ProviderGoal { + return { objective, status }; +} + +function present( + status: OrchestrationV2ProviderGoal["status"], + prompt: string, + options?: { readonly working?: boolean; readonly canPauseResume?: boolean }, +) { + return presentComposerGoalBar({ + goal: goal(status), + working: options?.working ?? false, + prompt, + canPauseResume: options?.canPauseResume ?? true, + }); +} + +describe("presentComposerGoalBar", () => { + it("hides a missing or finished goal", () => { + expect( + presentComposerGoalBar({ goal: null, working: false, prompt: "", canPauseResume: true }), + ).toBeNull(); + expect(present("complete", "")).toBeNull(); + }); + + it("shows the current goal above the composer", () => { + expect(present("paused", "")).toMatchObject({ + title: "Goal paused", + objective: "All tests in packages/api pass", + playback: "play", + emphasizing: null, + }); + expect(present("active", "next", { working: true })).toMatchObject({ + title: "Pursuing goal", + playback: "pause", + emphasizing: null, + }); + }); + + it("names the goal a /goal resume draft will continue", () => { + expect(present("paused", " /goal resume ")).toMatchObject({ + title: "Resuming goal", + objective: "All tests in packages/api pass", + playback: "play", + emphasizing: "resume", + }); + }); + + it("keeps pause and play for any way the goal was started", () => { + expect(present("active", "/goal ship the fix")?.playback).toBe("pause"); + expect(present("active", "")?.playback).toBe("pause"); + expect(present("paused", "/goal resume")?.playback).toBe("play"); + expect(present("paused", "/goal all tests pass")?.playback).toBe("play"); + expect(present("usage_limited", "")?.playback).toBe("play"); + expect(present("active", "", { canPauseResume: false })?.playback).toBeNull(); + }); + + it("names pause, clear, and a replacement the same way", () => { + expect(present("active", "/goal pause", { working: true })?.title).toBe("Pausing goal"); + expect(present("active", "/goal clear")?.emphasizing).toBe("clear"); + expect(present("active", "/goal ship the fix")).toMatchObject({ + title: "Replacing goal", + objective: "All tests in packages/api pass", + playback: "pause", + emphasizing: "replace", + }); + }); + + it("ignores a /goal command that is only part of a message", () => { + expect(present("paused", "please /goal resume")?.title).toBe("Goal paused"); + }); +}); diff --git a/apps/web/src/components/chat/presentComposerGoalBar.ts b/apps/web/src/components/chat/presentComposerGoalBar.ts new file mode 100644 index 000000000000..96e054d76358 --- /dev/null +++ b/apps/web/src/components/chat/presentComposerGoalBar.ts @@ -0,0 +1,62 @@ +import { parseGoalCommand, type OrchestrationV2ProviderGoal } from "@t3tools/contracts"; +import { presentProviderGoal } from "@t3tools/client-runtime/state/thread-execution"; + +/** A `/goal` command filling the composer, when the whole draft is that command. */ +type ComposerGoalCommandIntent = "show" | "clear" | "pause" | "resume" | "set"; + +function composerGoalCommandIntent(prompt: string): ComposerGoalCommandIntent | null { + return parseGoalCommand({ text: prompt })?.type ?? null; +} + +/** Pause an active goal, or play a goal that is waiting. Codex only; Claude has neither. */ +export type ComposerGoalPlayback = "pause" | "play"; + +export interface ComposerGoalBarModel { + readonly title: string; + /** The goal this command applies to, or the goal currently set. */ + readonly objective: string; + readonly usage: string | null; + /** + * The pause/play control. It follows the goal's status, not the draft: an active + * goal pauses, and any other open goal plays, whether it was set with `/goal resume` + * or `/goal` plus an objective. + */ + readonly playback: ComposerGoalPlayback | null; + /** The draft is a goal command, so the bar names that action instead of the resting status. */ + readonly emphasizing: "resume" | "pause" | "clear" | "replace" | null; +} + +/** + * The line above the composer. A finished goal leaves the field. Typing `/goal resume` + * (or pause, clear, or a new objective) names the goal that command will change. + */ +export function presentComposerGoalBar(input: { + readonly goal: OrchestrationV2ProviderGoal | null; + readonly working: boolean; + readonly prompt: string; + /** Codex can pause and resume. Claude treats those words as a new objective. */ + readonly canPauseResume: boolean; +}): ComposerGoalBarModel | null { + const { goal } = input; + if (goal === null || goal.status === "complete") return null; + const presentation = presentProviderGoal(goal, input.working); + const intent = composerGoalCommandIntent(input.prompt); + const resting = { + objective: presentation.objective, + usage: presentation.usage, + playback: input.canPauseResume ? (goal.status === "active" ? "pause" : "play") : null, + } as const; + switch (intent) { + case "resume": + return { ...resting, title: "Resuming goal", emphasizing: "resume" }; + case "pause": + return { ...resting, title: "Pausing goal", emphasizing: "pause" }; + case "clear": + return { ...resting, title: "Clearing goal", emphasizing: "clear" }; + case "set": + return { ...resting, title: "Replacing goal", emphasizing: "replace" }; + case "show": + case null: + return { ...resting, title: presentation.title, emphasizing: null }; + } +} diff --git a/apps/web/src/components/chat/useThreadLifecycle.tsx b/apps/web/src/components/chat/useThreadLifecycle.tsx index 0ad8acacf71c..f43f56f93168 100644 --- a/apps/web/src/components/chat/useThreadLifecycle.tsx +++ b/apps/web/src/components/chat/useThreadLifecycle.tsx @@ -58,12 +58,7 @@ import { appAtomRegistry } from "../../rpc/atomRegistry"; import { readEnvironmentScope } from "../../state/session"; import { useAtomCommand } from "../../state/use-atom-command"; import { useOrchestrationCommand } from "../../state/use-orchestration-command"; -import { - backgroundWorkNowItem, - childInputNowItem, - goalNowItem, - runStopNowItem, -} from "./ThreadActivityBanners"; +import { backgroundWorkNowItem, childInputNowItem, runStopNowItem } from "./ThreadActivityBanners"; import { useForceStopThread } from "./useForceStopThread"; import { useAtomValue } from "@effect/atom-react"; import type { RunStopPresentation } from "@t3tools/client-runtime/state/thread-execution"; @@ -125,7 +120,7 @@ interface ThreadLifecycleInput { /** * Thread state the chat shows and changes outside a normal send: snooze and settle with - * their status line, the local checkout's branch, background work, child-thread and goal + * their status line, the local checkout's branch, background work, child-thread * banners, opening related threads, and standalone slash commands. */ export function useThreadLifecycle(input: ThreadLifecycleInput) { @@ -511,16 +506,6 @@ export function useThreadLifecycle(input: ThreadLifecycleInput) { sendInFlightRef.current = false; }; const sendStandaloneCommandStable = useStableCallback(sendStandaloneCommand); - const activeGoal = activeThreadShell?.goal ?? null; - const goalNow = - activeThread && activeGoal !== null - ? goalNowItem({ - threadId: activeThread.id, - goal: activeGoal, - isWorking, - onSendCommand: sendStandaloneCommandStable, - }) - : null; const forceStop = useForceStopThread(environmentId); const anyRunStop = useAtomValue( @@ -625,13 +610,12 @@ export function useThreadLifecycle(input: ThreadLifecycleInput) { attentionNow: runStop?.state === "unresponsive" ? runStopNow : childInputNow, workNow: runStop?.state === "stopping" ? runStopNow : backgroundWorkNow, childInputNow, - goalNow, handleSwitchCheckoutToThread, handleSwitchCheckoutToThreadStable, isRestoringThreadBranch, onOpenRelatedThread, onOpenRelatedThreadStable, - sendStandaloneCommand, + sendStandaloneCommand: sendStandaloneCommandStable, setBranchMismatchDismissTick, showBranchMismatchBanner, threadStatusLine, diff --git a/docs/user/composer.md b/docs/user/composer.md index 714572dfd500..4e5b87b17cb8 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -249,9 +249,14 @@ Claude instances can also compact idle threads for you. See With Codex and Claude, send `/goal` followed by what "done" means, for example `/goal all tests in packages/api pass`. The agent keeps working across turns -until it judges the goal met. The thread shows **Goal** while it works, and a -line at the end of the thread (above the composer on mobile) shows the goal and -its progress. +until it judges the goal met. While a goal is set, a line on the top edge of +the composer names it and its progress. Typing `/goal resume`, `/goal pause`, +or `/goal clear` changes that line to the action and the goal it applies to, +so the command shows which goal it changes. On Codex, that line also has a +pause or play control. Pause stops the goal that is running, and play continues +a goal that is waiting. Both work the same way whether the goal was started +with `/goal resume` or with `/goal` and an objective. Claude keeps the line, +without pause or play. - `/goal` alone shows the current goal. `/goal clear` removes it. - Codex also supports `/goal pause` and `/goal resume`. Stopping a Codex goal From 799d1659684de4b02788f7e2634f979e70755cbf Mon Sep 17 00:00:00 2001 From: Pieter Venter Date: Fri, 30 Oct 2026 13:44:46 +0200 Subject: [PATCH 2/5] fix(web): only preview goal commands the send will run Claude rejects pause and resume, and a draft with an attachment is an ordinary prompt. The bar was labeling both as goal actions. Usage text can also shrink so the controls stay on the row. Co-authored-by: Cursor --- apps/web/src/components/chat/ChatComposer.tsx | 1 + .../src/components/chat/ComposerGoalBar.tsx | 2 +- .../chat/presentComposerGoalBar.test.ts | 21 +++++++++++++++++++ .../components/chat/presentComposerGoalBar.ts | 21 +++++++++++++++---- 4 files changed, 40 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index e160617974b5..155f7e488355 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -3090,6 +3090,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) working: props.threadWorking, prompt: activePendingProgress ? activePendingProgress.customAnswer : prompt, canPauseResume: selectedProvider === "codex", + hasAttachments: composerImages.length + composerFiles.length > 0, }); const composerAttachButton = showComposerAttachAction ? ( {model.objective} {model.usage === null ? null : ( - {model.usage} + {model.usage} )} {model.playback !== null || showClear ? ( diff --git a/apps/web/src/components/chat/presentComposerGoalBar.test.ts b/apps/web/src/components/chat/presentComposerGoalBar.test.ts index 787ac26ddb01..4ed13d4b7864 100644 --- a/apps/web/src/components/chat/presentComposerGoalBar.test.ts +++ b/apps/web/src/components/chat/presentComposerGoalBar.test.ts @@ -77,4 +77,25 @@ describe("presentComposerGoalBar", () => { it("ignores a /goal command that is only part of a message", () => { expect(present("paused", "please /goal resume")?.title).toBe("Goal paused"); }); + + it("does not advertise pause or resume for a provider that rejects them", () => { + expect(present("active", "/goal pause", { canPauseResume: false })?.title).toBe("Goal set"); + expect(present("paused", "/goal resume", { canPauseResume: false })).toMatchObject({ + title: "Goal paused", + emphasizing: null, + playback: null, + }); + }); + + it("ignores a goal command when the draft has an attachment", () => { + expect( + presentComposerGoalBar({ + goal: goal("active"), + working: false, + prompt: "/goal clear", + canPauseResume: true, + hasAttachments: true, + }), + ).toMatchObject({ title: "Goal set", emphasizing: null }); + }); }); diff --git a/apps/web/src/components/chat/presentComposerGoalBar.ts b/apps/web/src/components/chat/presentComposerGoalBar.ts index 96e054d76358..3a67c4f56b31 100644 --- a/apps/web/src/components/chat/presentComposerGoalBar.ts +++ b/apps/web/src/components/chat/presentComposerGoalBar.ts @@ -4,8 +4,13 @@ import { presentProviderGoal } from "@t3tools/client-runtime/state/thread-execut /** A `/goal` command filling the composer, when the whole draft is that command. */ type ComposerGoalCommandIntent = "show" | "clear" | "pause" | "resume" | "set"; -function composerGoalCommandIntent(prompt: string): ComposerGoalCommandIntent | null { - return parseGoalCommand({ text: prompt })?.type ?? null; +function composerGoalCommandIntent(input: { + readonly prompt: string; + readonly hasAttachments: boolean; +}): ComposerGoalCommandIntent | null { + // Attachments make the message an ordinary prompt, matching parseGoalCommand. + if (input.hasAttachments) return null; + return parseGoalCommand({ text: input.prompt })?.type ?? null; } /** Pause an active goal, or play a goal that is waiting. Codex only; Claude has neither. */ @@ -34,13 +39,21 @@ export function presentComposerGoalBar(input: { readonly goal: OrchestrationV2ProviderGoal | null; readonly working: boolean; readonly prompt: string; - /** Codex can pause and resume. Claude treats those words as a new objective. */ + /** Codex can pause and resume. Claude rejects those commands. */ readonly canPauseResume: boolean; + /** The draft includes an image or file, so `/goal` is sent as an ordinary prompt. */ + readonly hasAttachments?: boolean; }): ComposerGoalBarModel | null { const { goal } = input; if (goal === null || goal.status === "complete") return null; const presentation = presentProviderGoal(goal, input.working); - const intent = composerGoalCommandIntent(input.prompt); + const parsed = composerGoalCommandIntent({ + prompt: input.prompt, + hasAttachments: input.hasAttachments === true, + }); + // Claude rejects pause and resume; do not label a draft the server will refuse. + const intent = + !input.canPauseResume && (parsed === "pause" || parsed === "resume") ? null : parsed; const resting = { objective: presentation.objective, usage: presentation.usage, From a93a67e3365c90d8980dcd08d30e15edb797c783 Mon Sep 17 00:00:00 2001 From: Pieter Venter Date: Fri, 30 Oct 2026 13:53:44 +0200 Subject: [PATCH 3/5] fix(web): keep the goal bar resting while answering a question Sending during a pending question advances that question. It does not run /goal, so the bar should not preview a goal command from the answer draft. Co-authored-by: Cursor --- apps/web/src/components/chat/ChatComposer.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 155f7e488355..bb3b2a09d3cc 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -3088,7 +3088,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const composerGoal = presentComposerGoalBar({ goal: props.activeThreadShell?.goal ?? null, working: props.threadWorking, - prompt: activePendingProgress ? activePendingProgress.customAnswer : prompt, + // A pending answer advances that question. It does not send `/goal`. + prompt: activePendingProgress ? "" : prompt, canPauseResume: selectedProvider === "codex", hasAttachments: composerImages.length + composerFiles.length > 0, }); From 6d7e6a94e9935367f53fb30fc39d7aa5ba045871 Mon Sep 17 00:00:00 2001 From: Pieter Venter Date: Fri, 30 Oct 2026 14:11:03 +0200 Subject: [PATCH 4/5] fix(web): keep goal controls from sending when they cannot run Play stays hidden while the thread is working so resume does not stop unrelated work. Pause, play, and clear also stay disabled when the provider cannot send. Co-authored-by: Cursor --- apps/web/src/components/chat/ChatComposer.tsx | 9 +++++---- .../src/components/chat/presentComposerGoalBar.test.ts | 2 ++ apps/web/src/components/chat/presentComposerGoalBar.ts | 10 +++++++++- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index bb3b2a09d3cc..6ea4b687ca5f 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -920,6 +920,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedProviderEntry?.snapshot, selectedModel, ); + const providerAvailable = + multipleModelSelections !== null || (!noProviderAvailable && providerSendBlockReason === null); const sendDisabledReason = externalSendDisabledReason ?? (multipleModelSelections?.length === 0 ? "Select at least one model." : null) ?? @@ -2965,9 +2967,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) : selection, ) : null, - providerAvailable: - multipleModelSelections !== null || - (!noProviderAvailable && providerSendBlockReason === null), + providerAvailable, selectedProvider, selectedModel, selectedProviderModels, @@ -3032,6 +3032,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setMultipleModelSelections, routeKind, noProviderAvailable, + providerAvailable, providerSendBlockReason, selectedPromptEffort, selectedProvider, @@ -3213,7 +3214,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) {composerGoal === null ? null : ( )} diff --git a/apps/web/src/components/chat/presentComposerGoalBar.test.ts b/apps/web/src/components/chat/presentComposerGoalBar.test.ts index 4ed13d4b7864..d42df3007516 100644 --- a/apps/web/src/components/chat/presentComposerGoalBar.test.ts +++ b/apps/web/src/components/chat/presentComposerGoalBar.test.ts @@ -61,6 +61,8 @@ describe("presentComposerGoalBar", () => { expect(present("paused", "/goal all tests pass")?.playback).toBe("play"); expect(present("usage_limited", "")?.playback).toBe("play"); expect(present("active", "", { canPauseResume: false })?.playback).toBeNull(); + expect(present("paused", "", { working: true })?.playback).toBeNull(); + expect(present("active", "", { working: true })?.playback).toBe("pause"); }); it("names pause, clear, and a replacement the same way", () => { diff --git a/apps/web/src/components/chat/presentComposerGoalBar.ts b/apps/web/src/components/chat/presentComposerGoalBar.ts index 3a67c4f56b31..a33d2e0c1c83 100644 --- a/apps/web/src/components/chat/presentComposerGoalBar.ts +++ b/apps/web/src/components/chat/presentComposerGoalBar.ts @@ -57,7 +57,15 @@ export function presentComposerGoalBar(input: { const resting = { objective: presentation.objective, usage: presentation.usage, - playback: input.canPauseResume ? (goal.status === "active" ? "pause" : "play") : null, + // Play resumes a waiting goal. Hide it while the thread is working so it does not + // stop unrelated work. Pause stays available on an active goal. + playback: !input.canPauseResume + ? null + : goal.status === "active" + ? "pause" + : input.working + ? null + : "play", } as const; switch (intent) { case "resume": From 7dd88cd7485bc1d07daf1324b81d9a7510354f81 Mon Sep 17 00:00:00 2001 From: Pieter Venter Date: Fri, 30 Oct 2026 14:20:35 +0200 Subject: [PATCH 5/5] fix(web): hide goal resume while a run is still starting Preparing, starting, and queued runs use the connecting phase, which the goal bar did not treat as work. Play could resume and interrupt that run. Co-authored-by: Cursor --- apps/web/src/components/ChatView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6f92fe9bbedf..e6cb48552608 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3495,7 +3495,7 @@ export default function ChatView(props: ChatViewProps) { isLocalDraftThread && activeProject === null } phase={phase} - threadWorking={isWorking} + threadWorking={isWorking || phase === "connecting"} isCompacting={isCompacting} canInterrupt={canInterruptRunningThread} isSendBusy={