From 11ec9d73f30ab7be91146636cc3887ed0216d143 Mon Sep 17 00:00:00 2001 From: Xavier Date: Fri, 27 Mar 2026 23:44:37 -0300 Subject: [PATCH 1/7] feat(web): add queue and steer follow-up behavior --- apps/web/src/components/ChatView.browser.tsx | 521 +++++++++++++ .../web/src/components/ChatView.logic.test.ts | 109 ++- apps/web/src/components/ChatView.logic.ts | 107 ++- apps/web/src/components/ChatView.tsx | 709 +++++++++++++++--- .../src/components/ComposerPromptEditor.tsx | 29 +- .../chat/ComposerQueuedFollowUpsPanel.tsx | 230 ++++++ apps/web/src/composerDraftStore.test.ts | 274 +++++++ apps/web/src/composerDraftStore.ts | 537 ++++++++++++- apps/web/src/routes/_chat.settings.tsx | 49 ++ packages/contracts/src/settings.test.ts | 15 + packages/contracts/src/settings.ts | 7 + 11 files changed, 2453 insertions(+), 134 deletions(-) create mode 100644 apps/web/src/components/chat/ComposerQueuedFollowUpsPanel.tsx create mode 100644 packages/contracts/src/settings.test.ts diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 7bf3ecf26c..48bc79d5aa 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -619,6 +619,163 @@ async function waitForSendButton(): Promise { ); } +async function waitForComposerSubmitButton(label: string): Promise { + return waitForElement( + () => + document.querySelector(`button[type="submit"][aria-label="${label}"]`) ?? + Array.from(document.querySelectorAll('button[type="submit"]')).find( + (button) => button.textContent?.trim() === label, + ) ?? + null, + `Unable to find ${label} composer submit button.`, + ); +} + +async function waitForQueuedFollowUpsPanel(): Promise { + return waitForElement( + () => document.querySelector('[data-testid="queued-follow-ups-panel"]'), + "Unable to find queued follow-ups panel.", + ); +} + +async function openQueuedFollowUpActionsMenu(index: number): Promise { + const button = await waitForElement( + () => + document.querySelectorAll( + 'button[aria-label^="More queued follow-up actions"]', + )[index] ?? null, + `Unable to find queued follow-up actions button at index ${index}.`, + ); + button.click(); + return button; +} + +async function dragQueuedFollowUp(options: { + fromPrompt: string; + toPrompt: string; + position: "before" | "after"; +}): Promise { + const fromItem = await waitForElement( + () => + Array.from(document.querySelectorAll('[data-testid^="queued-follow-up-"]')).find( + (element) => element.textContent?.includes(options.fromPrompt), + ) ?? null, + `Unable to find queued follow-up row for ${options.fromPrompt}.`, + ); + const toItem = await waitForElement( + () => + Array.from(document.querySelectorAll('[data-testid^="queued-follow-up-"]')).find( + (element) => element.textContent?.includes(options.toPrompt), + ) ?? null, + `Unable to find queued follow-up row for ${options.toPrompt}.`, + ); + const dragHandle = fromItem.querySelector('[draggable="true"]'); + if (!dragHandle) { + throw new Error(`Unable to find drag handle for queued follow-up ${options.fromPrompt}.`); + } + + const dataTransfer = new DataTransfer(); + const targetBounds = toItem.getBoundingClientRect(); + const clientY = options.position === "before" ? targetBounds.top + 2 : targetBounds.bottom - 2; + + dragHandle.dispatchEvent( + new DragEvent("dragstart", { + bubbles: true, + cancelable: true, + dataTransfer, + }), + ); + await waitForLayout(); + toItem.dispatchEvent( + new DragEvent("dragover", { + bubbles: true, + cancelable: true, + dataTransfer, + clientY, + }), + ); + await waitForLayout(); + toItem.dispatchEvent( + new DragEvent("drop", { + bubbles: true, + cancelable: true, + dataTransfer, + clientY, + }), + ); + await waitForLayout(); + dragHandle.dispatchEvent( + new DragEvent("dragend", { + bubbles: true, + cancelable: true, + dataTransfer, + }), + ); +} + +async function waitForDraftPrompt(prompt: string): Promise { + await vi.waitFor( + () => { + expect(useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]?.prompt ?? "").toBe( + prompt, + ); + }, + { timeout: 8_000, interval: 16 }, + ); +} + +async function setComposerPrompt(prompt: string): Promise { + useComposerDraftStore.getState().setPrompt(THREAD_ID, prompt); + await waitForDraftPrompt(prompt); + await vi.waitFor( + () => { + expect(document.body.textContent ?? "").toContain(prompt); + }, + { timeout: 8_000, interval: 16 }, + ); + await waitForLayout(); +} + +async function queueFollowUpFromComposer(prompt: string): Promise { + await setComposerPrompt(prompt); + const submitButton = await waitForComposerSubmitButton("Queue follow-up"); + await vi.waitFor( + () => { + expect(submitButton.disabled).toBe(false); + }, + { timeout: 8_000, interval: 16 }, + ); + submitButton.click(); + await waitForDraftPrompt(""); + await waitForLayout(); +} + +function setClientSettings(settings: Partial): void { + localStorage.setItem("t3code:client-settings:v1", JSON.stringify(settings)); +} + +function getTurnStartRequests(): Array { + return wsRequests.flatMap((request) => { + const command = (request as { command?: { type?: string } }).command; + if ( + request._tag !== ORCHESTRATION_WS_METHODS.dispatchCommand || + !command || + command.type !== "thread.turn.start" + ) { + return []; + } + return [request as WsRequestEnvelope["body"] & { command: { type: string } }]; + }); +} + +function getQueuedFollowUpPrompts(): string[] { + return ( + useComposerDraftStore + .getState() + .queuedFollowUpsByThreadId[THREAD_ID]?.map((followUp) => followUp.prompt) ?? [] + ); +} + async function waitForInteractionModeButton( expectedLabel: "Chat" | "Plan", ): Promise { @@ -867,6 +1024,7 @@ describe("ChatView timeline estimator parity (full app)", () => { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, + queuedFollowUpsByThreadId: {}, stickyModelSelectionByProvider: {}, stickyActiveProvider: null, }); @@ -1758,6 +1916,369 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("persists the running follow-up behavior setting across remounts", async () => { + setClientSettings({ + followUpBehavior: "queue", + }); + const snapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-follow-up-setting-persist" as MessageId, + targetText: "follow-up setting persist target", + sessionStatus: "running", + }); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot, + }); + + try { + await vi.waitFor( + () => { + expect( + useComposerDraftStore.getState().queuedFollowUpsByThreadId[THREAD_ID] ?? [], + ).toHaveLength(0); + expect( + JSON.parse(localStorage.getItem("t3code:client-settings:v1") ?? "{}").followUpBehavior, + ).toBe("queue"); + }, + { timeout: 8_000, interval: 16 }, + ); + await setComposerPrompt("queued setting persists"); + await waitForComposerSubmitButton("Queue follow-up"); + } finally { + await mounted.cleanup(); + } + + const remounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot, + }); + + try { + await setComposerPrompt("queued setting persists"); + await waitForComposerSubmitButton("Queue follow-up"); + } finally { + await remounted.cleanup(); + } + }); + + it("steers follow-ups by default while a turn is running", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-follow-up-steer-default" as MessageId, + targetText: "follow-up steer default target", + sessionStatus: "running", + }), + }); + + try { + await setComposerPrompt("steer this run"); + const submitButton = await waitForComposerSubmitButton("Steer follow-up"); + submitButton.click(); + + await vi.waitFor( + () => { + expect(getTurnStartRequests()).toHaveLength(1); + expect(document.body.textContent ?? "").not.toContain("steer this run"); + }, + { timeout: 8_000, interval: 16 }, + ); + expect(getQueuedFollowUpPrompts()).toEqual([]); + } finally { + await mounted.cleanup(); + } + }); + + it("auto-sends only the queued head after the current run settles", async () => { + setClientSettings({ + followUpBehavior: "queue", + }); + const runningSnapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-follow-up-auto-send" as MessageId, + targetText: "follow-up auto-send target", + sessionStatus: "running", + }); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: runningSnapshot, + }); + + try { + await setComposerPrompt("queued head"); + await waitForComposerSubmitButton("Queue follow-up"); + + await queueFollowUpFromComposer("queued head"); + await queueFollowUpFromComposer("queued tail"); + + await vi.waitFor( + () => { + expect(getQueuedFollowUpPrompts()).toEqual(["queued head", "queued tail"]); + }, + { timeout: 8_000, interval: 16 }, + ); + + const readySnapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-follow-up-auto-send" as MessageId, + targetText: "follow-up auto-send target", + sessionStatus: "ready", + }); + fixture.snapshot = readySnapshot; + useStore.getState().syncServerReadModel(readySnapshot); + + await vi.waitFor( + () => { + expect(getTurnStartRequests()).toHaveLength(1); + expect(getQueuedFollowUpPrompts()).toEqual(["queued tail"]); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("supports steering and deleting queued follow-ups from the panel", async () => { + setClientSettings({ + followUpBehavior: "queue", + }); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-follow-up-panel-actions" as MessageId, + targetText: "follow-up panel actions target", + sessionStatus: "running", + }), + }); + + try { + await setComposerPrompt("panel first"); + await waitForComposerSubmitButton("Queue follow-up"); + + await queueFollowUpFromComposer("panel first"); + await queueFollowUpFromComposer("panel second"); + + const panel = await waitForQueuedFollowUpsPanel(); + const steerButton = Array.from(panel.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Steer", + ); + expect(steerButton).toBeTruthy(); + steerButton?.click(); + + await vi.waitFor( + () => { + expect(getTurnStartRequests()).toHaveLength(1); + expect(getQueuedFollowUpPrompts()).toEqual(["panel second"]); + expect(document.body.textContent ?? "").not.toContain("panel first"); + }, + { timeout: 8_000, interval: 16 }, + ); + + const deleteButton = await waitForElement( + () => + document.querySelector( + 'button[aria-label^="Delete queued follow-up"]', + ), + "Unable to find delete queued follow-up button.", + ); + deleteButton.click(); + + await vi.waitFor( + () => { + expect(getQueuedFollowUpPrompts()).toEqual([]); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("restores a queued follow-up into the composer from the panel", async () => { + setClientSettings({ + followUpBehavior: "queue", + }); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-follow-up-panel-edit" as MessageId, + targetText: "follow-up panel edit target", + sessionStatus: "running", + }), + }); + + try { + await setComposerPrompt("queued edit item"); + await waitForComposerSubmitButton("Queue follow-up"); + await queueFollowUpFromComposer("queued edit item"); + + await openQueuedFollowUpActionsMenu(0); + const editButton = await waitForElement( + () => + document.querySelector('button[aria-label^="Edit queued follow-up"]'), + "Unable to find edit queued follow-up button.", + ); + editButton.dispatchEvent( + new MouseEvent("click", { + bubbles: true, + cancelable: true, + }), + ); + + await vi.waitFor( + () => { + expect(getQueuedFollowUpPrompts()).toEqual([]); + expect(useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]?.prompt).toBe( + "queued edit item", + ); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("requeues an edited follow-up back near its original queued position", async () => { + setClientSettings({ + followUpBehavior: "queue", + }); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-follow-up-panel-edit-position" as MessageId, + targetText: "follow-up panel edit order target", + sessionStatus: "running", + }), + }); + + try { + await queueFollowUpFromComposer("queue first"); + await queueFollowUpFromComposer("queue second"); + await queueFollowUpFromComposer("queue third"); + + await openQueuedFollowUpActionsMenu(1); + const editButton = await waitForElement( + () => + document.querySelector('button[aria-label^="Edit queued follow-up"]'), + "Unable to find the queued follow-up edit button.", + ); + editButton.click(); + + await vi.waitFor( + () => { + expect(getQueuedFollowUpPrompts()).toEqual(["queue first", "queue third"]); + expect(useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]?.prompt).toBe( + "queue second", + ); + }, + { timeout: 8_000, interval: 16 }, + ); + + await queueFollowUpFromComposer("queue second edited"); + + await vi.waitFor( + () => { + expect(getQueuedFollowUpPrompts()).toEqual([ + "queue first", + "queue second edited", + "queue third", + ]); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("lets queued follow-ups reorder by dragging from the panel handle", async () => { + setClientSettings({ + followUpBehavior: "queue", + }); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-follow-up-panel-move" as MessageId, + targetText: "follow-up panel move target", + sessionStatus: "running", + }), + }); + + try { + await queueFollowUpFromComposer("move first"); + await queueFollowUpFromComposer("move second"); + await queueFollowUpFromComposer("move third"); + + await dragQueuedFollowUp({ + fromPrompt: "move second", + toPrompt: "move first", + position: "before", + }); + + await vi.waitFor( + () => { + expect(getQueuedFollowUpPrompts()).toEqual(["move second", "move first", "move third"]); + }, + { timeout: 8_000, interval: 16 }, + ); + + await dragQueuedFollowUp({ + fromPrompt: "move first", + toPrompt: "move third", + position: "after", + }); + + await vi.waitFor( + () => { + expect(getQueuedFollowUpPrompts()).toEqual(["move second", "move third", "move first"]); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("uses Ctrl+Shift+Enter to submit the opposite follow-up behavior once", async () => { + setClientSettings({ + followUpBehavior: "queue", + }); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-follow-up-shortcut-opposite" as MessageId, + targetText: "follow-up shortcut target", + sessionStatus: "running", + }), + }); + + try { + await setComposerPrompt("shortcut steer once"); + await waitForComposerSubmitButton("Queue follow-up"); + + const composerEditor = await waitForComposerEditor(); + composerEditor.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Enter", + ctrlKey: true, + shiftKey: true, + bubbles: true, + cancelable: true, + }), + ); + + await vi.waitFor( + () => { + expect(getTurnStartRequests()).toHaveLength(1); + expect(getQueuedFollowUpPrompts()).toEqual([]); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + it("keeps the new thread selected after clicking the new-thread button", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index bf72ec0b84..1eff5912a4 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1,7 +1,15 @@ import { ThreadId } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; -import { buildExpiredTerminalContextToastCopy, deriveComposerSendState } from "./ChatView.logic"; +import { + buildExpiredTerminalContextToastCopy, + buildQueuedFollowUpDraft, + canAutoDispatchQueuedFollowUp, + deriveComposerSendState, + followUpBehaviorShortcutLabel, + resolveFollowUpBehavior, + shouldInvertFollowUpBehaviorFromKeyEvent, +} from "./ChatView.logic"; describe("deriveComposerSendState", () => { it("treats expired terminal pills as non-sendable content", () => { @@ -67,3 +75,102 @@ describe("buildExpiredTerminalContextToastCopy", () => { }); }); }); + +describe("follow-up behavior helpers", () => { + it("inverts the configured behavior when requested", () => { + expect(resolveFollowUpBehavior("steer", false)).toBe("steer"); + expect(resolveFollowUpBehavior("steer", true)).toBe("queue"); + expect(resolveFollowUpBehavior("queue", true)).toBe("steer"); + }); + + it("detects the opposite-submit keyboard shortcut across platforms", () => { + expect( + shouldInvertFollowUpBehaviorFromKeyEvent( + { + ctrlKey: true, + metaKey: false, + shiftKey: true, + altKey: false, + }, + "Win32", + ), + ).toBe(true); + expect( + shouldInvertFollowUpBehaviorFromKeyEvent( + { + ctrlKey: false, + metaKey: true, + shiftKey: true, + altKey: false, + }, + "MacIntel", + ), + ).toBe(true); + expect( + shouldInvertFollowUpBehaviorFromKeyEvent( + { + ctrlKey: false, + metaKey: false, + shiftKey: true, + altKey: false, + }, + "Win32", + ), + ).toBe(false); + expect(followUpBehaviorShortcutLabel("MacIntel")).toBe("Cmd+Shift+Enter"); + expect(followUpBehaviorShortcutLabel("Win32")).toBe("Ctrl+Shift+Enter"); + }); + + it("builds a queued follow-up snapshot and auto-dispatch rules", () => { + const snapshot = buildQueuedFollowUpDraft({ + prompt: "next step", + attachments: [], + terminalContexts: [ + { + id: "ctx-1", + threadId: ThreadId.makeUnsafe("thread-1"), + terminalId: "default", + terminalLabel: "Terminal 1", + lineStart: 1, + lineEnd: 1, + text: "hello", + createdAt: "2026-03-27T12:00:00.000Z", + }, + ], + modelSelection: { + provider: "codex", + model: "gpt-5.3-codex", + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: "2026-03-27T12:00:00.000Z", + }); + + expect(snapshot.id).toBeTruthy(); + expect(snapshot.terminalContexts[0]?.text).toBe("hello"); + expect( + canAutoDispatchQueuedFollowUp({ + phase: "ready", + queuedFollowUpCount: 2, + isConnecting: false, + isSendBusy: false, + isRevertingCheckpoint: false, + hasThreadError: false, + hasPendingApproval: false, + hasPendingUserInput: false, + }), + ).toBe(true); + expect( + canAutoDispatchQueuedFollowUp({ + phase: "running", + queuedFollowUpCount: 2, + isConnecting: false, + isSendBusy: false, + isRevertingCheckpoint: false, + hasThreadError: false, + hasPendingApproval: false, + hasPendingUserInput: false, + }), + ).toBe(false); + }); +}); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 0a9f242ed0..b548c678c9 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,13 +1,26 @@ -import { ProjectId, type ModelSelection, type ThreadId } from "@t3tools/contracts"; +import { + ProjectId, + ProviderInteractionMode, + RuntimeMode, + type ModelSelection, + type ThreadId, +} from "@t3tools/contracts"; +import { type FollowUpBehavior } from "@t3tools/contracts/settings"; import { type ChatMessage, type Thread } from "../types"; import { randomUUID } from "~/lib/utils"; -import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; +import { + type ComposerImageAttachment, + type DraftThreadState, + type PersistedComposerImageAttachment, + type QueuedFollowUpDraft, +} from "../composerDraftStore"; import { Schema } from "effect"; import { filterTerminalContextsWithText, stripInlineTerminalContextPlaceholders, type TerminalContextDraft, } from "../lib/terminalContext"; +import { isMacPlatform } from "../lib/utils"; export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; const WORKTREE_BRANCH_PREFIX = "t3code"; @@ -160,3 +173,93 @@ export function buildExpiredTerminalContextToastCopy( description: "Re-add it if you want that terminal output included.", }; } + +export function resolveFollowUpBehavior( + followUpBehavior: FollowUpBehavior, + invert: boolean, +): FollowUpBehavior { + if (!invert) { + return followUpBehavior; + } + return followUpBehavior === "queue" ? "steer" : "queue"; +} + +export function shouldInvertFollowUpBehaviorFromKeyEvent( + event: Pick, + platform = navigator.platform, +): boolean { + if (!event.shiftKey || event.altKey) { + return false; + } + if (isMacPlatform(platform)) { + return event.metaKey && !event.ctrlKey; + } + return event.ctrlKey && !event.metaKey; +} + +export function followUpBehaviorShortcutLabel(platform = navigator.platform): string { + return isMacPlatform(platform) ? "Cmd+Shift+Enter" : "Ctrl+Shift+Enter"; +} + +export function buildQueuedFollowUpDraft(input: { + prompt: string; + attachments: ReadonlyArray; + terminalContexts: ReadonlyArray; + modelSelection: ModelSelection; + runtimeMode: RuntimeMode; + interactionMode: ProviderInteractionMode; + createdAt: string; +}): QueuedFollowUpDraft { + return { + id: randomUUID(), + createdAt: input.createdAt, + prompt: input.prompt, + attachments: [...input.attachments], + terminalContexts: input.terminalContexts.map((context) => ({ ...context })), + modelSelection: input.modelSelection, + runtimeMode: input.runtimeMode, + interactionMode: input.interactionMode, + }; +} + +export function canAutoDispatchQueuedFollowUp(input: { + phase: "disconnected" | "connecting" | "ready" | "running"; + queuedFollowUpCount: number; + isConnecting: boolean; + isSendBusy: boolean; + isRevertingCheckpoint: boolean; + hasThreadError: boolean; + hasPendingApproval: boolean; + hasPendingUserInput: boolean; +}): boolean { + return ( + input.phase === "ready" && + input.queuedFollowUpCount > 0 && + !input.isConnecting && + !input.isSendBusy && + !input.isRevertingCheckpoint && + !input.hasThreadError && + !input.hasPendingApproval && + !input.hasPendingUserInput + ); +} + +export function describeQueuedFollowUp( + followUp: Pick, +): string { + const trimmedPrompt = stripInlineTerminalContextPlaceholders(followUp.prompt).trim(); + if (trimmedPrompt.length > 0) { + return trimmedPrompt; + } + if (followUp.attachments.length > 0) { + return followUp.attachments.length === 1 + ? "1 image attached" + : `${followUp.attachments.length} images attached`; + } + if (followUp.terminalContexts.length > 0) { + return followUp.terminalContexts.length === 1 + ? (followUp.terminalContexts[0]?.terminalLabel ?? "1 terminal context") + : `${followUp.terminalContexts.length} terminal contexts`; + } + return "Follow-up"; +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1d926bf308..173c567953 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -87,6 +87,8 @@ import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { BotIcon, + Clock3Icon, + CornerDownRightIcon, ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, @@ -126,10 +128,13 @@ import { isTerminalFocused } from "../lib/terminalFocus"; import { type ComposerImageAttachment, type DraftThreadEnvMode, + hydrateComposerImagesFromPersistedAttachments, type PersistedComposerImageAttachment, + type QueuedFollowUpDraft, useComposerDraftStore, useEffectiveComposerModelState, useComposerThreadDraft, + useQueuedFollowUps, } from "../composerDraftStore"; import { appendTerminalContextsToPrompt, @@ -155,6 +160,7 @@ import { CompactComposerControlsMenu } from "./chat/CompactComposerControlsMenu" import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel"; import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel"; import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner"; +import { ComposerQueuedFollowUpsPanel } from "./chat/ComposerQueuedFollowUpsPanel"; import { getComposerProviderState, renderProviderTraitsMenuContent, @@ -164,18 +170,23 @@ import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; import { buildExpiredTerminalContextToastCopy, + buildQueuedFollowUpDraft, buildLocalDraftThread, buildTemporaryWorktreeBranchName, + canAutoDispatchQueuedFollowUp, cloneComposerImageForRetry, collectUserMessageBlobPreviewUrls, deriveComposerSendState, + followUpBehaviorShortcutLabel, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, PullRequestDialogState, readFileAsDataUrl, + resolveFollowUpBehavior, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, SendPhase, + shouldInvertFollowUpBehaviorFromKeyEvent, } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; @@ -245,6 +256,18 @@ interface PendingPullRequestSetupRequest { scriptId: string; } +type FollowUpSubmissionSnapshot = QueuedFollowUpDraft; + +function persistedModelOptionsFromSelection( + modelSelection: ModelSelection, +): Parameters[0]["modelOptions"] { + return modelSelection.options + ? { + [modelSelection.provider]: modelSelection.options, + } + : null; +} + export default function ChatView({ threadId }: ChatViewProps) { const threads = useStore((store) => store.threads); const projects = useStore((store) => store.projects); @@ -257,6 +280,7 @@ export default function ChatView({ threadId }: ChatViewProps) { (store) => store.setStickyModelSelection, ); const timestampFormat = settings.timestampFormat; + const queuedFollowUps = useQueuedFollowUps(threadId); const navigate = useNavigate(); const rawSearch = useSearch({ strict: false, @@ -306,6 +330,14 @@ export default function ChatView({ threadId }: ChatViewProps) { const syncComposerDraftPersistedAttachments = useComposerDraftStore( (store) => store.syncPersistedAttachments, ); + const enqueueQueuedFollowUp = useComposerDraftStore((store) => store.enqueueQueuedFollowUp); + const reorderQueuedFollowUp = useComposerDraftStore((store) => store.reorderQueuedFollowUp); + const updateQueuedFollowUp = useComposerDraftStore((store) => store.updateQueuedFollowUp); + const removeQueuedFollowUp = useComposerDraftStore((store) => store.removeQueuedFollowUp); + const shiftQueuedFollowUp = useComposerDraftStore((store) => store.shiftQueuedFollowUp); + const restoreQueuedFollowUpToDraft = useComposerDraftStore( + (store) => store.restoreQueuedFollowUpToDraft, + ); const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const getDraftThreadByProjectId = useComposerDraftStore( @@ -324,6 +356,9 @@ export default function ChatView({ threadId }: ChatViewProps) { const [isDragOverComposer, setIsDragOverComposer] = useState(false); const [expandedImage, setExpandedImage] = useState(null); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); + const [hiddenTimelineMessageIds, setHiddenTimelineMessageIds] = useState>( + () => new Set(), + ); const optimisticUserMessagesRef = useRef(optimisticUserMessages); optimisticUserMessagesRef.current = optimisticUserMessages; const composerTerminalContextsRef = useRef(composerTerminalContexts); @@ -396,6 +431,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const attachmentPreviewHandoffByMessageIdRef = useRef>({}); const attachmentPreviewHandoffTimeoutByMessageIdRef = useRef>({}); const sendInFlightRef = useRef(false); + const autoDispatchQueuedFollowUpIdRef = useRef(null); const dragDepthRef = useRef(0); const terminalOpenByThreadRef = useRef>({}); const setMessagesScrollContainerRef = useCallback((element: HTMLDivElement | null) => { @@ -667,6 +703,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const isSendBusy = sendPhase !== "idle"; const isPreparingWorktree = sendPhase === "preparing-worktree"; const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; + const followUpBehavior = settings.followUpBehavior; const nowIso = new Date(nowTick).toISOString(); const activeWorkStartedAt = deriveActiveWorkStartedAt( activeLatestTurn, @@ -753,11 +790,33 @@ export default function ChatView({ threadId }: ChatViewProps) { hasActionableProposedPlan(activeProposedPlan); const activePendingApproval = pendingApprovals[0] ?? null; const isComposerApprovalState = activePendingApproval !== null; + const canUseRunningFollowUps = + phase === "running" && !isComposerApprovalState && pendingUserInputs.length === 0; const hasComposerHeader = isComposerApprovalState || pendingUserInputs.length > 0 || (showPlanFollowUpPrompt && activeProposedPlan !== null); const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null; + const runningFollowUpActionLabel = + followUpBehavior === "queue" ? "Queue follow-up" : "Steer follow-up"; + const runningFollowUpShortcutRows = + followUpBehavior === "queue" + ? [ + { label: "Queue", shortcut: "Enter", active: true }, + { + label: "Steer", + shortcut: followUpBehaviorShortcutLabel(), + active: false, + }, + ] + : [ + { label: "Steer", shortcut: "Enter", active: true }, + { + label: "Queue", + shortcut: followUpBehaviorShortcutLabel(), + active: false, + }, + ]; const lastSyncedPendingInputRef = useRef<{ requestId: string | null; questionId: string | null; @@ -823,6 +882,9 @@ export default function ChatView({ threadId }: ChatViewProps) { } }; }, [clearAttachmentPreviewHandoffs]); + useEffect(() => { + setHiddenTimelineMessageIds(new Set()); + }, [activeThread?.id]); const handoffAttachmentPreviews = useCallback((messageId: MessageId, previewUrls: string[]) => { if (previewUrls.length === 0) return; @@ -907,15 +969,30 @@ export default function ChatView({ threadId }: ChatViewProps) { }); if (optimisticUserMessages.length === 0) { - return serverMessagesWithPreviewHandoff; + return hiddenTimelineMessageIds.size === 0 + ? serverMessagesWithPreviewHandoff + : serverMessagesWithPreviewHandoff.filter( + (message) => !hiddenTimelineMessageIds.has(message.id), + ); } - const serverIds = new Set(serverMessagesWithPreviewHandoff.map((message) => message.id)); + const visibleServerMessages = + hiddenTimelineMessageIds.size === 0 + ? serverMessagesWithPreviewHandoff + : serverMessagesWithPreviewHandoff.filter( + (message) => !hiddenTimelineMessageIds.has(message.id), + ); + const serverIds = new Set(visibleServerMessages.map((message) => message.id)); const pendingMessages = optimisticUserMessages.filter((message) => !serverIds.has(message.id)); if (pendingMessages.length === 0) { - return serverMessagesWithPreviewHandoff; + return visibleServerMessages; } - return [...serverMessagesWithPreviewHandoff, ...pendingMessages]; - }, [serverMessages, attachmentPreviewHandoffByMessageId, optimisticUserMessages]); + return [...visibleServerMessages, ...pendingMessages]; + }, [ + serverMessages, + attachmentPreviewHandoffByMessageId, + hiddenTimelineMessageIds, + optimisticUserMessages, + ]); const timelineEntries = useMemo( () => deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), @@ -2428,8 +2505,8 @@ export default function ChatView({ threadId }: ChatViewProps) { [activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError], ); - const onSend = async (e?: { preventDefault: () => void }) => { - e?.preventDefault(); + const onSend = async (input?: { preventDefault?: () => void; keyboardEvent?: KeyboardEvent }) => { + input?.preventDefault?.(); const api = readNativeApi(); if (!api || !activeThread || isSendBusy || isConnecting || sendInFlightRef.current) return; if (activePendingProgress) { @@ -2463,6 +2540,47 @@ export default function ChatView({ threadId }: ChatViewProps) { }); return; } + if (canUseRunningFollowUps && isServerThread) { + const createdAt = new Date().toISOString(); + const followUpSnapshot = await createFollowUpSnapshotFromComposer(createdAt); + if (!followUpSnapshot) { + return; + } + const effectiveBehavior = resolveFollowUpBehavior( + followUpBehavior, + Boolean( + input?.keyboardEvent && shouldInvertFollowUpBehaviorFromKeyEvent(input.keyboardEvent), + ), + ); + + if (effectiveBehavior === "queue") { + enqueueQueuedFollowUp(activeThread.id, followUpSnapshot); + promptRef.current = ""; + clearComposerDraftContent(activeThread.id); + setComposerHighlightedItemId(null); + setComposerCursor(0); + setComposerTrigger(null); + return; + } + + promptRef.current = ""; + clearComposerDraftContent(activeThread.id); + setComposerHighlightedItemId(null); + setComposerCursor(0); + setComposerTrigger(null); + + const dispatched = await dispatchServerThreadSnapshot({ + threadId: activeThread.id, + snapshot: followUpSnapshot, + errorMessage: "Failed to send follow-up.", + suppressOptimisticMessage: true, + hideServerMessage: true, + }); + if (!dispatched) { + restoreFollowUpSnapshotToComposer(followUpSnapshot); + } + return; + } const standaloneSlashCommand = composerImages.length === 0 && sendableComposerTerminalContexts.length === 0 ? parseStandaloneComposerSlashCommand(trimmed) @@ -2915,6 +3033,224 @@ export default function ChatView({ threadId }: ChatViewProps) { setActivePendingUserInputQuestionIndex(Math.max(activePendingProgress.questionIndex - 1, 0)); }, [activePendingProgress, setActivePendingUserInputQuestionIndex]); + const createFollowUpSnapshotFromComposer = useCallback( + async (createdAt: string): Promise => { + const promptForSend = promptRef.current; + const { + trimmedPrompt, + sendableTerminalContexts, + expiredTerminalContextCount, + hasSendableContent, + } = deriveComposerSendState({ + prompt: promptForSend, + imageCount: composerImagesRef.current.length, + terminalContexts: composerTerminalContextsRef.current, + }); + if (!hasSendableContent) { + if (expiredTerminalContextCount > 0) { + const toastCopy = buildExpiredTerminalContextToastCopy( + expiredTerminalContextCount, + "empty", + ); + toastManager.add({ + type: "warning", + title: toastCopy.title, + description: toastCopy.description, + }); + } + return null; + } + const persistedAttachments = await Promise.all( + composerImagesRef.current.map(async (image) => ({ + id: image.id, + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + dataUrl: await readFileAsDataUrl(image.file), + })), + ); + return buildQueuedFollowUpDraft({ + prompt: trimmedPrompt, + attachments: persistedAttachments, + terminalContexts: sendableTerminalContexts, + modelSelection: selectedModelSelection, + runtimeMode, + interactionMode, + createdAt, + }); + }, + [interactionMode, runtimeMode, selectedModelSelection], + ); + + const restoreFollowUpSnapshotToComposer = useCallback( + (snapshot: FollowUpSubmissionSnapshot) => { + setComposerDraftPrompt(threadId, snapshot.prompt); + useComposerDraftStore.setState((state) => { + const currentDraft = state.draftsByThreadId[threadId]; + return { + draftsByThreadId: { + ...state.draftsByThreadId, + [threadId]: { + ...(currentDraft ?? composerDraft), + prompt: snapshot.prompt, + images: hydrateComposerImagesFromPersistedAttachments(snapshot.attachments), + nonPersistedImageIds: [], + persistedAttachments: [...snapshot.attachments], + terminalContexts: snapshot.terminalContexts.map((context) => ({ ...context })), + modelSelectionByProvider: { + ...(currentDraft?.modelSelectionByProvider ?? + composerDraft.modelSelectionByProvider), + [snapshot.modelSelection.provider]: snapshot.modelSelection, + }, + activeProvider: snapshot.modelSelection.provider, + runtimeMode: snapshot.runtimeMode, + interactionMode: snapshot.interactionMode, + }, + }, + }; + }); + promptRef.current = snapshot.prompt; + setComposerCursor(collapseExpandedComposerCursor(snapshot.prompt, snapshot.prompt.length)); + setComposerTrigger(detectComposerTrigger(snapshot.prompt, snapshot.prompt.length)); + }, + [composerDraft, setComposerDraftPrompt, threadId], + ); + + const dispatchServerThreadSnapshot = useCallback( + async (input: { + threadId: ThreadId; + snapshot: FollowUpSubmissionSnapshot; + errorMessage: string; + suppressOptimisticMessage?: boolean; + hideServerMessage?: boolean; + sourceProposedPlan?: { + threadId: ThreadId; + planId: string; + }; + onAfterDispatch?: () => void; + }) => { + const api = readNativeApi(); + if (!api) { + return false; + } + const snapshotProvider = input.snapshot.modelSelection.provider; + const snapshotModel = input.snapshot.modelSelection.model; + const snapshotModels = getProviderModels(providerStatuses, snapshotProvider); + const snapshotProviderState = getComposerProviderState({ + provider: snapshotProvider, + model: snapshotModel, + models: snapshotModels, + prompt: input.snapshot.prompt, + modelOptions: persistedModelOptionsFromSelection(input.snapshot.modelSelection), + }); + const promptWithTerminalContexts = appendTerminalContextsToPrompt( + input.snapshot.prompt, + input.snapshot.terminalContexts, + ); + const outgoingMessageText = formatOutgoingPrompt({ + provider: snapshotProvider, + model: snapshotModel, + models: snapshotModels, + effort: snapshotProviderState.promptEffort, + text: promptWithTerminalContexts || IMAGE_ONLY_BOOTSTRAP_PROMPT, + }); + const optimisticAttachments = input.snapshot.attachments.map((attachment) => ({ + type: "image" as const, + id: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + previewUrl: attachment.dataUrl, + })); + const messageIdForSend = newMessageId(); + const dispatchCreatedAt = new Date().toISOString(); + + sendInFlightRef.current = true; + beginSendPhase("sending-turn"); + setThreadError(input.threadId, null); + if (input.hideServerMessage) { + setHiddenTimelineMessageIds((existing) => new Set(existing).add(messageIdForSend)); + } + if (!input.suppressOptimisticMessage) { + setOptimisticUserMessages((existing) => [ + ...existing, + { + id: messageIdForSend, + role: "user", + text: outgoingMessageText, + ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}), + createdAt: dispatchCreatedAt, + streaming: false, + }, + ]); + } + shouldAutoScrollRef.current = true; + forceStickToBottom(); + + try { + await persistThreadSettingsForNextTurn({ + threadId: input.threadId, + createdAt: dispatchCreatedAt, + modelSelection: input.snapshot.modelSelection, + runtimeMode: input.snapshot.runtimeMode, + interactionMode: input.snapshot.interactionMode, + }); + setComposerDraftInteractionMode(input.threadId, input.snapshot.interactionMode); + await api.orchestration.dispatchCommand({ + type: "thread.turn.start", + commandId: newCommandId(), + threadId: input.threadId, + message: { + messageId: messageIdForSend, + role: "user", + text: outgoingMessageText, + attachments: input.snapshot.attachments.map((attachment) => ({ + type: "image" as const, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + dataUrl: attachment.dataUrl, + })), + }, + modelSelection: input.snapshot.modelSelection, + runtimeMode: input.snapshot.runtimeMode, + interactionMode: input.snapshot.interactionMode, + ...(input.sourceProposedPlan ? { sourceProposedPlan: input.sourceProposedPlan } : {}), + createdAt: dispatchCreatedAt, + }); + input.onAfterDispatch?.(); + sendInFlightRef.current = false; + return true; + } catch (err) { + if (input.hideServerMessage) { + setHiddenTimelineMessageIds((existing) => { + const next = new Set(existing); + next.delete(messageIdForSend); + return next; + }); + } + if (!input.suppressOptimisticMessage) { + setOptimisticUserMessages((existing) => + existing.filter((message) => message.id !== messageIdForSend), + ); + } + setThreadError(input.threadId, err instanceof Error ? err.message : input.errorMessage); + sendInFlightRef.current = false; + resetSendPhase(); + return false; + } + }, + [ + beginSendPhase, + forceStickToBottom, + persistThreadSettingsForNextTurn, + providerStatuses, + resetSendPhase, + setComposerDraftInteractionMode, + setThreadError, + ], + ); + const onSubmitPlanFollowUp = useCallback( async ({ text, @@ -2940,110 +3276,189 @@ export default function ChatView({ threadId }: ChatViewProps) { return; } - const threadIdForSend = activeThread.id; - const messageIdForSend = newMessageId(); - const messageCreatedAt = new Date().toISOString(); - const outgoingMessageText = formatOutgoingPrompt({ - provider: selectedProvider, - model: selectedModel, - models: selectedProviderModels, - effort: selectedPromptEffort, - text: trimmed, - }); - - sendInFlightRef.current = true; - beginSendPhase("sending-turn"); - setThreadError(threadIdForSend, null); - setOptimisticUserMessages((existing) => [ - ...existing, - { - id: messageIdForSend, - role: "user", - text: outgoingMessageText, - createdAt: messageCreatedAt, - streaming: false, - }, - ]); - shouldAutoScrollRef.current = true; - forceStickToBottom(); - - try { - await persistThreadSettingsForNextTurn({ - threadId: threadIdForSend, - createdAt: messageCreatedAt, + await dispatchServerThreadSnapshot({ + threadId: activeThread.id, + snapshot: { + id: randomUUID(), + createdAt: new Date().toISOString(), + prompt: trimmed, + attachments: [], + terminalContexts: [], modelSelection: selectedModelSelection, runtimeMode, interactionMode: nextInteractionMode, - }); + }, + errorMessage: "Failed to send plan follow-up.", + ...(nextInteractionMode === "default" && activeProposedPlan + ? { + sourceProposedPlan: { + threadId: activeThread.id, + planId: activeProposedPlan.id, + }, + } + : {}), + onAfterDispatch: () => { + if (nextInteractionMode === "default") { + planSidebarDismissedForTurnRef.current = null; + setPlanSidebarOpen(true); + } + }, + }); + }, + [ + activeThread, + activeProposedPlan, + dispatchServerThreadSnapshot, + isConnecting, + isSendBusy, + isServerThread, + runtimeMode, + selectedModelSelection, + ], + ); - // Keep the mode toggle and plan-follow-up banner in sync immediately - // while the same-thread implementation turn is starting. - setComposerDraftInteractionMode(threadIdForSend, nextInteractionMode); + const onDeleteQueuedFollowUp = useCallback( + (followUpId: string) => { + removeQueuedFollowUp(threadId, followUpId); + }, + [removeQueuedFollowUp, threadId], + ); - await api.orchestration.dispatchCommand({ - type: "thread.turn.start", - commandId: newCommandId(), - threadId: threadIdForSend, - message: { - messageId: messageIdForSend, - role: "user", - text: outgoingMessageText, - attachments: [], - }, - modelSelection: selectedModelSelection, - runtimeMode, - interactionMode: nextInteractionMode, - ...(nextInteractionMode === "default" && activeProposedPlan - ? { - sourceProposedPlan: { - threadId: activeThread.id, - planId: activeProposedPlan.id, - }, - } - : {}), - createdAt: messageCreatedAt, - }); - // Optimistically open the plan sidebar when implementing (not refining). - // "default" mode here means the agent is executing the plan, which produces - // step-tracking activities that the sidebar will display. - if (nextInteractionMode === "default") { - planSidebarDismissedForTurnRef.current = null; - setPlanSidebarOpen(true); - } - sendInFlightRef.current = false; - } catch (err) { - setOptimisticUserMessages((existing) => - existing.filter((message) => message.id !== messageIdForSend), - ); - setThreadError( - threadIdForSend, - err instanceof Error ? err.message : "Failed to send plan follow-up.", + const onReorderQueuedFollowUp = useCallback( + (followUpId: string, targetIndex: number) => { + reorderQueuedFollowUp(threadId, followUpId, targetIndex); + }, + [reorderQueuedFollowUp, threadId], + ); + + const onEditQueuedFollowUp = useCallback( + (followUpId: string) => { + const followUp = queuedFollowUps.find((entry) => entry.id === followUpId); + if (!followUp) { + return; + } + restoreQueuedFollowUpToDraft(threadId, followUpId); + promptRef.current = followUp.prompt; + setComposerCursor(collapseExpandedComposerCursor(followUp.prompt, followUp.prompt.length)); + setComposerTrigger(detectComposerTrigger(followUp.prompt, followUp.prompt.length)); + window.requestAnimationFrame(() => { + composerEditorRef.current?.focusAt( + collapseExpandedComposerCursor(followUp.prompt, followUp.prompt.length), ); - sendInFlightRef.current = false; - resetSendPhase(); + }); + }, + [queuedFollowUps, restoreQueuedFollowUpToDraft, threadId], + ); + + const onSteerQueuedFollowUp = useCallback( + async (followUpId: string) => { + if ( + !activeThread || + !isServerThread || + isSendBusy || + isConnecting || + sendInFlightRef.current + ) { + return; + } + const followUp = queuedFollowUps.find((entry) => entry.id === followUpId); + if (!followUp) { + return; + } + updateQueuedFollowUp(threadId, followUpId, (existing) => { + const { lastSendError: _lastSendError, ...rest } = existing; + return { ...rest }; + }); + const dispatched = await dispatchServerThreadSnapshot({ + threadId: activeThread.id, + snapshot: followUp, + errorMessage: "Failed to steer queued follow-up.", + suppressOptimisticMessage: phase === "running", + hideServerMessage: true, + }); + if (dispatched) { + removeQueuedFollowUp(threadId, followUpId); + } else { + updateQueuedFollowUp(threadId, followUpId, (existing) => ({ + ...existing, + lastSendError: "Steer failed. Review the thread error or edit this follow-up.", + })); } }, [ activeThread, - activeProposedPlan, - beginSendPhase, - forceStickToBottom, + dispatchServerThreadSnapshot, isConnecting, isSendBusy, isServerThread, - persistThreadSettingsForNextTurn, - resetSendPhase, - runtimeMode, - selectedPromptEffort, - selectedModelSelection, - selectedProvider, - selectedProviderModels, - setComposerDraftInteractionMode, - setThreadError, - selectedModel, + phase, + queuedFollowUps, + removeQueuedFollowUp, + threadId, + updateQueuedFollowUp, ], ); + useEffect(() => { + if (!activeThread || !isServerThread) { + autoDispatchQueuedFollowUpIdRef.current = null; + return; + } + const nextQueuedFollowUp = queuedFollowUps[0]; + if (!nextQueuedFollowUp || nextQueuedFollowUp.lastSendError) { + autoDispatchQueuedFollowUpIdRef.current = null; + return; + } + if ( + !canAutoDispatchQueuedFollowUp({ + phase, + queuedFollowUpCount: queuedFollowUps.length, + isConnecting, + isSendBusy, + isRevertingCheckpoint, + hasThreadError: Boolean(activeThread.error), + hasPendingApproval: activePendingApproval !== null, + hasPendingUserInput: activePendingUserInput !== null, + }) + ) { + return; + } + if (autoDispatchQueuedFollowUpIdRef.current === nextQueuedFollowUp.id) { + return; + } + autoDispatchQueuedFollowUpIdRef.current = nextQueuedFollowUp.id; + void (async () => { + const dispatched = await dispatchServerThreadSnapshot({ + threadId: activeThread.id, + snapshot: nextQueuedFollowUp, + errorMessage: "Failed to send queued follow-up.", + }); + if (dispatched) { + shiftQueuedFollowUp(threadId); + } else { + updateQueuedFollowUp(threadId, nextQueuedFollowUp.id, (existing) => ({ + ...existing, + lastSendError: "Auto-send failed. Steer, edit, or delete this follow-up.", + })); + } + autoDispatchQueuedFollowUpIdRef.current = null; + })(); + }, [ + activePendingApproval, + activePendingUserInput, + activeThread, + dispatchServerThreadSnapshot, + isConnecting, + isRevertingCheckpoint, + isSendBusy, + isServerThread, + phase, + queuedFollowUps, + shiftQueuedFollowUp, + threadId, + updateQueuedFollowUp, + ]); + const onImplementPlanInNewThread = useCallback(async () => { const api = readNativeApi(); if ( @@ -3483,8 +3898,8 @@ export default function ChatView({ threadId }: ChatViewProps) { } } - if (key === "Enter" && !event.shiftKey) { - void onSend(); + if (key === "Enter" && (!event.shiftKey || shouldInvertFollowUpBehaviorFromKeyEvent(event))) { + void onSend({ keyboardEvent: event }); return true; } return false; @@ -3662,9 +4077,18 @@ export default function ChatView({ threadId }: ChatViewProps) { className="mx-auto w-full min-w-0 max-w-3xl" data-chat-composer-form="true" > + { + void onSteerQueuedFollowUp(followUpId); + }} + />
) : phase === "running" ? ( - + } + /> + +
+
+ {runningFollowUpShortcutRows.map((row) => ( +
+ + {row.label} + + + {row.shortcut} + +
+ ))} +
+
+
+ + ) : ( + + + + ) ) : pendingUserInputs.length === 0 ? ( showPlanFollowUpPrompt ? ( prompt.trim().length > 0 ? ( diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 338d9f7bf1..6b5d5f26f0 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -896,6 +896,8 @@ function ComposerPromptEditorInner({ const initialCursor = clampCollapsedComposerCursor(value, cursor); const terminalContextsSignature = terminalContextSignature(terminalContexts); const terminalContextsSignatureRef = useRef(terminalContextsSignature); + const controlledValueRef = useRef(value); + const controlledTerminalContextIdsRef = useRef(terminalContexts.map((context) => context.id)); const snapshotRef = useRef({ value, cursor: initialCursor, @@ -912,6 +914,11 @@ function ComposerPromptEditorInner({ onChangeRef.current = onChange; }, [onChange]); + useEffect(() => { + controlledValueRef.current = value; + controlledTerminalContextIdsRef.current = terminalContexts.map((context) => context.id); + }, [terminalContexts, value]); + useEffect(() => { editor.setEditable(!disabled); }, [disabled, editor]); @@ -961,23 +968,25 @@ function ComposerPromptEditorInner({ (nextCursor: number) => { const rootElement = editor.getRootElement(); if (!rootElement) return; - const boundedCursor = clampCollapsedComposerCursor(snapshotRef.current.value, nextCursor); + const controlledValue = controlledValueRef.current; + const controlledTerminalContextIds = controlledTerminalContextIdsRef.current; + const boundedCursor = clampCollapsedComposerCursor(controlledValue, nextCursor); rootElement.focus(); editor.update(() => { $setSelectionAtComposerOffset(boundedCursor); }); snapshotRef.current = { - value: snapshotRef.current.value, + value: controlledValue, cursor: boundedCursor, - expandedCursor: expandCollapsedComposerCursor(snapshotRef.current.value, boundedCursor), - terminalContextIds: snapshotRef.current.terminalContextIds, + expandedCursor: expandCollapsedComposerCursor(controlledValue, boundedCursor), + terminalContextIds: controlledTerminalContextIds, }; onChangeRef.current( - snapshotRef.current.value, + controlledValue, boundedCursor, snapshotRef.current.expandedCursor, false, - snapshotRef.current.terminalContextIds, + controlledTerminalContextIds, ); }, [editor], @@ -1025,12 +1034,8 @@ function ComposerPromptEditorInner({ }, focusAt, focusAtEnd: () => { - focusAt( - collapseExpandedComposerCursor( - snapshotRef.current.value, - snapshotRef.current.value.length, - ), - ); + const controlledValue = controlledValueRef.current; + focusAt(collapseExpandedComposerCursor(controlledValue, controlledValue.length)); }, readSnapshot, }), diff --git a/apps/web/src/components/chat/ComposerQueuedFollowUpsPanel.tsx b/apps/web/src/components/chat/ComposerQueuedFollowUpsPanel.tsx new file mode 100644 index 0000000000..ea5532cf01 --- /dev/null +++ b/apps/web/src/components/chat/ComposerQueuedFollowUpsPanel.tsx @@ -0,0 +1,230 @@ +import type * as React from "react"; +import { memo, useEffect, useRef, useState } from "react"; +import { CornerDownRightIcon, EllipsisIcon, PencilIcon, Trash2Icon } from "lucide-react"; +import { type QueuedFollowUpDraft } from "../../composerDraftStore"; +import { describeQueuedFollowUp } from "../ChatView.logic"; +import { Button } from "../ui/button"; + +function resolveDropPosition( + event: Pick, "clientY" | "currentTarget">, +): "before" | "after" { + const bounds = event.currentTarget.getBoundingClientRect(); + return event.clientY <= bounds.top + bounds.height / 2 ? "before" : "after"; +} + +function resolveTargetIndex( + currentIndex: number, + hoveredIndex: number, + position: "before" | "after", +): number { + if (position === "before") { + return currentIndex < hoveredIndex ? hoveredIndex - 1 : hoveredIndex; + } + return currentIndex < hoveredIndex ? hoveredIndex : hoveredIndex + 1; +} + +function QueuedFollowUpSummaryIcon() { + return ( + + ); +} + +function DragGripDots() { + return ( +