From 30a8bffc2421a4050225692d46ea016b080bff8b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 25 May 2026 21:26:31 +0700 Subject: [PATCH] Show optimistic chat for new workspaces --- .../hooks/use-draft-agent-create-flow.test.ts | 90 +++++++++++ .../src/hooks/use-draft-agent-create-flow.ts | 150 +++++++++++------- .../panels/agent-panel-descriptor.test.tsx | 44 +++++ packages/app/src/panels/agent-panel.tsx | 75 ++++++++- .../app/src/panels/draft-panel-descriptor.ts | 28 ++++ packages/app/src/panels/draft-panel.tsx | 61 +------ .../app/src/screens/new-workspace-screen.tsx | 32 ++-- .../workspace/workspace-draft-agent-tab.tsx | 54 ++++++- .../workspace-draft-submission-store.ts | 2 + .../src/stores/workspace-layout-actions.ts | 6 +- .../src/stores/workspace-layout-store.test.ts | 50 ++++++ 11 files changed, 452 insertions(+), 140 deletions(-) create mode 100644 packages/app/src/hooks/use-draft-agent-create-flow.test.ts create mode 100644 packages/app/src/panels/agent-panel-descriptor.test.tsx create mode 100644 packages/app/src/panels/draft-panel-descriptor.ts diff --git a/packages/app/src/hooks/use-draft-agent-create-flow.test.ts b/packages/app/src/hooks/use-draft-agent-create-flow.test.ts new file mode 100644 index 000000000..ec79331c6 --- /dev/null +++ b/packages/app/src/hooks/use-draft-agent-create-flow.test.ts @@ -0,0 +1,90 @@ +/** + * @vitest-environment jsdom + */ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useCreateFlowStore } from "@/stores/create-flow-store"; +import type { UserMessageImageAttachment } from "@/types/stream"; +import type { AgentAttachment } from "@server/shared/messages"; +import { useDraftAgentCreateFlow, type DraftCreateAttempt } from "./use-draft-agent-create-flow"; + +describe("useDraftAgentCreateFlow", () => { + beforeEach(() => { + useCreateFlowStore.setState({ pendingByDraftId: {} }); + }); + + it("renders a prepared new-workspace create attempt as optimistic chat before continuing it", async () => { + const image: UserMessageImageAttachment = { + id: "image-1", + mimeType: "image/png", + storageType: "web-indexeddb", + storageKey: "image-key", + createdAt: 123, + }; + const attachment = { + type: "review", + cwd: "/repo", + summary: "Review", + } as unknown as AgentAttachment; + const attempt: DraftCreateAttempt = { + clientMessageId: "msg-prepared", + text: "build this", + timestamp: new Date("2026-05-25T00:00:00.000Z"), + images: [image], + attachments: [attachment], + }; + const createRequest = vi.fn( + async (ctx: { + attempt: DraftCreateAttempt; + text: string; + images?: UserMessageImageAttachment[]; + attachments?: AgentAttachment[]; + cwd: string; + }) => ({ + agentId: "agent-1", + result: { id: "agent-1", ctx }, + }), + ); + const onCreateSuccess = vi.fn(); + + const { result } = renderHook(() => + useDraftAgentCreateFlow({ + draftId: "draft-1", + getPendingServerId: () => "server-1", + initialAttempt: attempt, + buildDraftAgent: (currentAttempt) => ({ currentAttempt }), + createRequest, + onCreateSuccess, + }), + ); + + expect(result.current.isSubmitting).toBe(true); + expect(result.current.draftAgent).toEqual({ currentAttempt: attempt }); + expect(result.current.optimisticStreamItems).toEqual([ + { + kind: "user_message", + id: "msg-prepared", + text: "build this", + timestamp: attempt.timestamp, + optimistic: true, + images: [image], + attachments: [attachment], + }, + ]); + expect(createRequest).not.toHaveBeenCalled(); + + await act(async () => { + await result.current.continueCreateFromAttempt({ attempt, cwd: "/repo" }); + }); + + expect(createRequest).toHaveBeenCalledTimes(1); + expect(createRequest).toHaveBeenCalledWith({ + attempt, + text: "build this", + images: [image], + attachments: [attachment], + cwd: "/repo", + }); + expect(onCreateSuccess).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/app/src/hooks/use-draft-agent-create-flow.ts b/packages/app/src/hooks/use-draft-agent-create-flow.ts index 1c92c9454..e59dd13a3 100644 --- a/packages/app/src/hooks/use-draft-agent-create-flow.ts +++ b/packages/app/src/hooks/use-draft-agent-create-flow.ts @@ -81,6 +81,7 @@ interface CreateRequestContext { interface UseDraftAgentCreateFlowOptions { draftId: string; getPendingServerId: () => string | null; + initialAttempt?: CreateAttempt | null; allowEmptyText?: boolean; validateBeforeSubmit?: (ctx: SubmitContext) => string | null; onBeforeSubmit?: (ctx: CreateRequestContext) => void; @@ -94,6 +95,7 @@ interface UseDraftAgentCreateFlowOptions { export function useDraftAgentCreateFlow({ draftId, getPendingServerId, + initialAttempt = null, allowEmptyText = false, validateBeforeSubmit, onBeforeSubmit, @@ -103,10 +105,17 @@ export function useDraftAgentCreateFlow({ onCreateSuccess, onCreateError, }: UseDraftAgentCreateFlowOptions) { - const [machine, dispatch] = useReducer(reducer, { - tag: "draft", - errorMessage: "", - } as DraftAgentMachineState); + const [machine, dispatch] = useReducer( + reducer, + initialAttempt, + (attempt): DraftAgentMachineState => + attempt + ? { tag: "creating", attempt } + : { + tag: "draft", + errorMessage: "", + }, + ); const setPendingCreateAttempt = useCreateFlowStore((state) => state.setPending); const updatePendingAgentId = useCreateFlowStore((state) => state.updateAgentId); @@ -150,6 +159,73 @@ export function useDraftAgentCreateFlow({ return buildDraftAgent(machine.attempt); }, [buildDraftAgent, machine]); + const runCreateAttempt = useCallback( + async ({ attempt, cwd }: { attempt: CreateAttempt; cwd: string }) => { + const pendingServerId = getPendingServerId(); + if (!pendingServerId) { + const error = new Error("No host selected"); + dispatch({ type: "DRAFT_SET_ERROR", message: error.message }); + throw error; + } + + onBeforeSubmit?.({ + attempt, + text: attempt.text, + images: attempt.images, + attachments: attempt.attachments, + cwd, + }); + + try { + const createResult = await createRequest({ + attempt, + text: attempt.text, + images: attempt.images, + attachments: attempt.attachments, + cwd, + }); + + if (createResult.agentId) { + updatePendingAgentId({ draftId, agentId: createResult.agentId }); + appendOptimisticUserMessageToAgentStream( + pendingServerId, + createResult.agentId, + buildOptimisticUserMessage({ + id: attempt.clientMessageId, + text: attempt.text, + timestamp: attempt.timestamp, + images: attempt.images, + attachments: attempt.attachments, + }), + { placement: "tail", skipIfUserMessageExists: true }, + ); + markPendingCreateLifecycle({ draftId, lifecycle: "sent" }); + } + + await onCreateSuccess({ result: createResult.result, attempt }); + } catch (error) { + const resolved = error instanceof Error ? error : new Error("Failed to create agent"); + dispatch({ type: "CREATE_FAILED", message: resolved.message }); + markPendingCreateLifecycle({ draftId, lifecycle: "abandoned" }); + clearPendingCreateAttempt({ draftId }); + onCreateError?.(resolved); + throw error; + } + }, + [ + appendOptimisticUserMessageToAgentStream, + clearPendingCreateAttempt, + createRequest, + draftId, + getPendingServerId, + markPendingCreateLifecycle, + onBeforeSubmit, + onCreateError, + onCreateSuccess, + updatePendingAgentId, + ], + ); + const handleCreateFromInput = useCallback( async ({ text, attachments, cwd }: SubmitContext) => { if (isSubmitting) { @@ -206,71 +282,32 @@ export function useDraftAgentCreateFlow({ : {}), }); - onBeforeSubmit?.({ - attempt, - text: trimmedPrompt, - images, - attachments: wirePayload.attachments, - cwd, - }); dispatch({ type: "SUBMIT", attempt }); onCreateStart?.(); - - try { - const createResult = await createRequest({ - attempt, - text: trimmedPrompt, - images, - attachments: wirePayload.attachments, - cwd, - }); - - if (createResult.agentId) { - updatePendingAgentId({ draftId, agentId: createResult.agentId }); - appendOptimisticUserMessageToAgentStream( - pendingServerId, - createResult.agentId, - buildOptimisticUserMessage({ - id: attempt.clientMessageId, - text: attempt.text, - timestamp: attempt.timestamp, - images: attempt.images, - attachments: attempt.attachments, - }), - { placement: "tail", skipIfUserMessageExists: true }, - ); - markPendingCreateLifecycle({ draftId, lifecycle: "sent" }); - } - - await onCreateSuccess({ result: createResult.result, attempt }); - } catch (error) { - const resolved = error instanceof Error ? error : new Error("Failed to create agent"); - dispatch({ type: "CREATE_FAILED", message: resolved.message }); - markPendingCreateLifecycle({ draftId, lifecycle: "abandoned" }); - clearPendingCreateAttempt({ draftId }); - onCreateError?.(resolved); - throw error; - } + await runCreateAttempt({ attempt, cwd }); }, [ - appendOptimisticUserMessageToAgentStream, - clearPendingCreateAttempt, - createRequest, + allowEmptyText, draftId, getPendingServerId, isSubmitting, - markPendingCreateLifecycle, - onBeforeSubmit, - onCreateError, onCreateStart, - onCreateSuccess, + runCreateAttempt, setPendingCreateAttempt, - updatePendingAgentId, validateBeforeSubmit, - allowEmptyText, ], ); + const continueCreateFromAttempt = useCallback( + async ({ attempt, cwd }: { attempt: CreateAttempt; cwd: string }) => { + if (!isSubmitting) { + dispatch({ type: "SUBMIT", attempt }); + } + await runCreateAttempt({ attempt, cwd }); + }, + [isSubmitting, runCreateAttempt], + ); + return { machine, formErrorMessage, @@ -278,6 +315,7 @@ export function useDraftAgentCreateFlow({ optimisticStreamItems, draftAgent, handleCreateFromInput, + continueCreateFromAttempt, }; } diff --git a/packages/app/src/panels/agent-panel-descriptor.test.tsx b/packages/app/src/panels/agent-panel-descriptor.test.tsx new file mode 100644 index 000000000..db780e3d9 --- /dev/null +++ b/packages/app/src/panels/agent-panel-descriptor.test.tsx @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { buildDraftPanelDescriptor } from "@/panels/draft-panel-descriptor"; + +function TestIcon() { + return null; +} + +describe("buildDraftPanelDescriptor", () => { + it("uses the initial prompt title and running loader bucket during create", () => { + const descriptor = buildDraftPanelDescriptor({ + isCreating: true, + pendingPrompt: "Build the dashboard", + icon: TestIcon, + }); + + expect(descriptor).toMatchObject({ + label: "Build the dashboard", + subtitle: "Creating agent", + titleState: "ready", + statusBucket: "running", + }); + }); + + it("falls back to the draft title for empty create prompts", () => { + const descriptor = buildDraftPanelDescriptor({ + isCreating: true, + pendingPrompt: " ", + icon: TestIcon, + }); + + expect(descriptor.label).toBe("New Agent"); + }); + + it("keeps ordinary draft tabs labeled as new agents", () => { + const descriptor = buildDraftPanelDescriptor({ isCreating: false, icon: TestIcon }); + + expect(descriptor).toMatchObject({ + label: "New Agent", + subtitle: "New Agent", + titleState: "ready", + statusBucket: null, + }); + }); +}); diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index a8604eae0..3c4a51a02 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -1,4 +1,5 @@ import type { DaemonClient } from "@server/client/daemon-client"; +import { SquarePen } from "lucide-react-native"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ActivityIndicator, Text, View } from "react-native"; import ReanimatedAnimated from "react-native-reanimated"; @@ -36,6 +37,7 @@ import { useArchiveAgent } from "@/hooks/use-archive-agent"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { usePaneContext, usePaneFocus } from "@/panels/pane-context"; import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; +import { buildDraftPanelDescriptor } from "@/panels/draft-panel-descriptor"; import { type HostRuntimeConnectionStatus, useHostRuntimeClient, @@ -48,6 +50,7 @@ import { deriveRouteBottomAnchorIntent, deriveRouteBottomAnchorRequest, } from "@/screens/agent/agent-ready-screen-bottom-anchor"; +import { WorkspaceDraftAgentTab } from "@/screens/workspace/workspace-draft-agent-tab"; import { useCreateFlowStore } from "@/stores/create-flow-store"; import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys"; import { usePanelStore } from "@/stores/panel-store"; @@ -302,12 +305,82 @@ function AgentPanel() { ); } +function DraftPanel() { + const { + serverId, + workspaceId, + tabId, + target, + openFileInWorkspace, + openImportSheet, + retargetCurrentTab, + } = usePaneContext(); + const { isInteractive } = usePaneFocus(); + invariant(target.kind === "draft", "DraftPanel requires draft target"); + + const handleCreated = useCallback( + (agentSnapshot: Parameters[0]) => { + const normalized = normalizeAgentSnapshot(agentSnapshot, serverId); + useSessionStore.getState().setAgents(serverId, (prev) => { + const next = new Map(prev); + next.set(agentSnapshot.id, normalized); + return next; + }); + retargetCurrentTab({ kind: "agent", agentId: agentSnapshot.id }); + }, + [retargetCurrentTab, serverId], + ); + + return ( + + ); +} + +export function AgentConversationPanel() { + const { target } = usePaneContext(); + if (target.kind === "draft") { + return ; + } + if (target.kind === "agent") { + return ; + } + invariant(false, "AgentConversationPanel requires an agent or draft target"); +} + export const agentPanelRegistration: PanelRegistration<"agent"> = { kind: "agent", - component: AgentPanel, + component: AgentConversationPanel, useDescriptor: useAgentPanelDescriptor, }; +export function useDraftPanelDescriptor( + target: { kind: "draft"; draftId: string }, + context: { serverId: string }, +) { + const pendingCreate = useCreateFlowStore((state) => { + const pending = state.pendingByDraftId[target.draftId]; + return pending?.serverId === context.serverId && pending.lifecycle === "active" + ? pending + : null; + }); + + return buildDraftPanelDescriptor({ + isCreating: Boolean(pendingCreate), + pendingPrompt: pendingCreate?.text, + icon: SquarePen, + }); +} + const EMPTY_STREAM_ITEMS: StreamItem[] = []; const EMPTY_PENDING_PERMISSIONS = new Map(); const EMPTY_PENDING_PERMISSION_LIST: PendingPermission[] = []; diff --git a/packages/app/src/panels/draft-panel-descriptor.ts b/packages/app/src/panels/draft-panel-descriptor.ts new file mode 100644 index 000000000..a000b2322 --- /dev/null +++ b/packages/app/src/panels/draft-panel-descriptor.ts @@ -0,0 +1,28 @@ +import type { ComponentType } from "react"; +import type { PanelDescriptor, PanelIconProps } from "@/panels/panel-registry"; + +export function buildDraftPanelDescriptor(input: { + isCreating: boolean; + pendingPrompt?: string | null; + icon: ComponentType; +}): PanelDescriptor { + const { icon, isCreating, pendingPrompt } = input; + const creatingLabel = pendingPrompt?.trim() || "New Agent"; + if (isCreating) { + return { + label: creatingLabel, + subtitle: "Creating agent", + titleState: "ready", + icon, + statusBucket: "running", + }; + } + + return { + label: "New Agent", + subtitle: "New Agent", + titleState: "ready", + icon, + statusBucket: null, + }; +} diff --git a/packages/app/src/panels/draft-panel.tsx b/packages/app/src/panels/draft-panel.tsx index adc8813ca..6bba35683 100644 --- a/packages/app/src/panels/draft-panel.tsx +++ b/packages/app/src/panels/draft-panel.tsx @@ -1,65 +1,8 @@ -import { SquarePen } from "lucide-react-native"; -import { useCallback } from "react"; -import invariant from "tiny-invariant"; -import { WorkspaceDraftAgentTab } from "@/screens/workspace/workspace-draft-agent-tab"; -import { usePaneContext, usePaneFocus } from "@/panels/pane-context"; +import { AgentConversationPanel, useDraftPanelDescriptor } from "@/panels/agent-panel"; import type { PanelRegistration } from "@/panels/panel-registry"; -import { useSessionStore } from "@/stores/session-store"; -import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; - -function useDraftPanelDescriptor() { - return { - label: "New Agent", - subtitle: "New Agent", - titleState: "ready" as const, - icon: SquarePen, - statusBucket: null, - }; -} - -function DraftPanel() { - const { - serverId, - workspaceId, - tabId, - target, - openFileInWorkspace, - openImportSheet, - retargetCurrentTab, - } = usePaneContext(); - const { isInteractive } = usePaneFocus(); - invariant(target.kind === "draft", "DraftPanel requires draft target"); - - const handleCreated = useCallback( - (agentSnapshot: Parameters[0]) => { - const normalized = normalizeAgentSnapshot(agentSnapshot, serverId); - useSessionStore.getState().setAgents(serverId, (prev) => { - const next = new Map(prev); - next.set(agentSnapshot.id, normalized); - return next; - }); - retargetCurrentTab({ kind: "agent", agentId: agentSnapshot.id }); - }, - [retargetCurrentTab, serverId], - ); - - return ( - - ); -} export const draftPanelRegistration: PanelRegistration<"draft"> = { kind: "draft", - component: DraftPanel, + component: AgentConversationPanel, useDescriptor: useDraftPanelDescriptor, }; diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 01ea165b4..00ba9125a 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -23,9 +23,11 @@ import { useGithubSearchQuery } from "@/git/use-github-search-query"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store"; -import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys"; +import { generateDraftId } from "@/stores/draft-keys"; import { useDraftStore } from "@/stores/draft-store"; +import { useCreateFlowStore } from "@/stores/create-flow-store"; import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store"; +import { generateMessageId } from "@/types/stream"; import { toErrorMessage } from "@/utils/error-messages"; import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types"; @@ -458,27 +460,29 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void { composerState, } = input; const draftId = generateDraftId(); - useDraftStore.getState().saveDraftInput({ - draftKey: buildDraftStoreKey({ - serverId, - agentId: draftId, - draftId, - }), - draft: { - text, - attachments: attachments.filter( - (attachment): attachment is UserComposerAttachment => attachment.kind !== "review", - ), - }, + const clientMessageId = generateMessageId(); + const timestamp = Date.now(); + const wirePayload = splitComposerAttachmentsForSubmit(attachments); + useCreateFlowStore.getState().setPending({ + serverId, + draftId, + agentId: null, + clientMessageId, + text: text.trim(), + timestamp, + ...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}), + ...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}), }); useWorkspaceDraftSubmissionStore.getState().setPending({ serverId, workspaceId, draftId, - text, + text: text.trim(), attachments, cwd: workspaceDirectory, provider, + clientMessageId, + timestamp, ...(composerState.modeOptions.length > 0 && composerState.selectedMode !== "" ? { modeId: composerState.selectedMode } : {}), diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx index fdf5024f9..9754320ba 100644 --- a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx +++ b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx @@ -11,11 +11,15 @@ import { composerWorkspaceAttachment } from "@/attachments/composer-workspace-at import type { ImageAttachment } from "@/components/message-input"; import { useAgentInputDraft } from "@/hooks/use-agent-input-draft"; import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state"; -import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow"; +import { + useDraftAgentCreateFlow, + type DraftCreateAttempt, +} from "@/hooks/use-draft-agent-create-flow"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config"; import { buildDraftStoreKey } from "@/stores/draft-keys"; import { usePanelStore } from "@/stores/panel-store"; +import { useCreateFlowStore } from "@/stores/create-flow-store"; import type { Agent } from "@/stores/session-store"; import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks"; import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store"; @@ -348,10 +352,33 @@ export function WorkspaceDraftAgentTab({ const pending = state.pendingByDraftId[draftId] ?? null; return pending?.serverId === serverId && pending.workspaceId === workspaceId ? pending : null; }); + const pendingCreateAttempt = useCreateFlowStore((state) => { + const pending = state.pendingByDraftId[draftId] ?? null; + return pending?.serverId === serverId && pending.lifecycle === "active" ? pending : null; + }); const consumePendingAutoSubmit = useWorkspaceDraftSubmissionStore( (state) => state.consumePending, ); const autoSubmitConfig = resolveAutoSubmitConfig(pendingAutoSubmit); + const initialCreateAttempt = useMemo(() => { + if (!pendingAutoSubmit || !pendingCreateAttempt) { + return null; + } + if (pendingAutoSubmit.clientMessageId !== pendingCreateAttempt.clientMessageId) { + return null; + } + return { + clientMessageId: pendingCreateAttempt.clientMessageId, + text: pendingCreateAttempt.text, + timestamp: new Date(pendingCreateAttempt.timestamp), + ...(pendingCreateAttempt.images && pendingCreateAttempt.images.length > 0 + ? { images: pendingCreateAttempt.images } + : {}), + ...(pendingCreateAttempt.attachments && pendingCreateAttempt.attachments.length > 0 + ? { attachments: pendingCreateAttempt.attachments } + : {}), + }; + }, [pendingAutoSubmit, pendingCreateAttempt]); const allowsEmptyAutoSubmit = pendingAutoSubmit?.allowEmptyText === true; const isCompact = useIsCompactFormFactor(); const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({ @@ -390,9 +417,11 @@ export function WorkspaceDraftAgentTab({ optimisticStreamItems, draftAgent, handleCreateFromInput, + continueCreateFromAttempt, } = useDraftAgentCreateFlow({ draftId, getPendingServerId: () => serverId, + initialAttempt: initialCreateAttempt, allowEmptyText: allowsEmptyAutoSubmit, validateBeforeSubmit: ({ text }) => validateDraftSubmission({ @@ -442,7 +471,6 @@ export function WorkspaceDraftAgentTab({ draftInput.isHydrated && draftWorkingDirectory && client && - !isSubmitting && !composerState.isModelLoading, ); const autoSubmitKeyRef = useRef(null); @@ -461,19 +489,31 @@ export function WorkspaceDraftAgentTab({ autoSubmitKeyRef.current = submitKey; setDraftText(""); setDraftAttachments([]); - void handleCreateFromInput({ - text: submission.text, - attachments: submission.attachments, - cwd: submission.cwd, - }).catch(() => { + const preparedAttempt = + initialCreateAttempt?.clientMessageId === submission.clientMessageId + ? initialCreateAttempt + : null; + const createPromise = preparedAttempt + ? continueCreateFromAttempt({ + attempt: preparedAttempt, + cwd: submission.cwd, + }) + : handleCreateFromInput({ + text: submission.text, + attachments: submission.attachments, + cwd: submission.cwd, + }); + void createPromise.catch(() => { setDraftText(submission.text); setDraftAttachments(composerWorkspaceAttachment.userAttachmentsOnly(submission.attachments)); autoSubmitKeyRef.current = null; }); }, [ + continueCreateFromAttempt, consumePendingAutoSubmit, draftId, handleCreateFromInput, + initialCreateAttempt, isReadyForPendingAutoSubmit, serverId, setDraftAttachments, diff --git a/packages/app/src/stores/workspace-draft-submission-store.ts b/packages/app/src/stores/workspace-draft-submission-store.ts index 865a28b90..0857d956e 100644 --- a/packages/app/src/stores/workspace-draft-submission-store.ts +++ b/packages/app/src/stores/workspace-draft-submission-store.ts @@ -10,6 +10,8 @@ export interface PendingWorkspaceDraftSubmission { attachments: ComposerAttachment[]; cwd: string; provider: AgentProvider; + clientMessageId: string; + timestamp: number; modeId?: string; model?: string; thinkingOptionId?: string; diff --git a/packages/app/src/stores/workspace-layout-actions.ts b/packages/app/src/stores/workspace-layout-actions.ts index f62301319..cd6fb0532 100644 --- a/packages/app/src/stores/workspace-layout-actions.ts +++ b/packages/app/src/stores/workspace-layout-actions.ts @@ -1772,13 +1772,13 @@ export function reconcileWorkspaceTabs( continue; } if (group.tabs.some((tab) => tab.tabId === originalFocusedTabId)) { - reconciledFocusedTabId = canonicalTabId; + reconciledFocusedTabId = keeper.tabId; } - if (keeper.tabId !== canonicalTabId || !workspaceTabTargetsEqual(keeper.target, group.target)) { + if (!workspaceTabTargetsEqual(keeper.target, group.target)) { nextLayout = withNormalizedParentTabMap({ root: replaceTabInTree(asInternalLayout(nextLayout).root, { tabId: keeper.tabId, - nextTabId: canonicalTabId, + nextTabId: keeper.tabId, target: group.target, }), focusedPaneId: nextLayout.focusedPaneId, diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index ed5db7afc..546016b26 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -1354,6 +1354,56 @@ describe("workspace-layout-store actions", () => { expect(findPaneById(layout.root, "main")?.focusedTabId).toBe("agent_agent-1"); }); + it("reconcileTabs preserves a draft-origin agent tab id when there is no duplicate", () => { + const workspaceKey = createWorkspaceKey(); + + workspaceLayoutStore.setState((state) => ({ + ...state, + layoutByWorkspace: { + ...state.layoutByWorkspace, + [workspaceKey]: { + root: { + kind: "pane", + pane: { + id: "main", + tabIds: ["draft-agent"], + focusedTabId: "draft-agent", + tabs: [ + { + tabId: "draft-agent", + target: { kind: "agent", agentId: "agent-1" }, + createdAt: 1, + }, + ], + } as SplitPane, + }, + focusedPaneId: "main", + }, + }, + })); + + workspaceLayoutStore.getState().reconcileTabs(workspaceKey, { + agentsHydrated: true, + terminalsHydrated: true, + activeAgentIds: ["agent-1"], + autoOpenAgentIds: ["agent-1"], + knownAgentIds: ["agent-1"], + standaloneTerminalIds: [], + hasActivePendingDraftCreate: false, + }); + + const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + + expect(collectAllTabs(layout.root)).toEqual([ + { + tabId: "draft-agent", + target: { kind: "agent", agentId: "agent-1" }, + createdAt: 1, + }, + ]); + expect(findPaneById(layout.root, "main")?.focusedTabId).toBe("draft-agent"); + }); + it("reconcileTabs does not re-add locally hidden agent tabs", () => { const workspaceKey = createWorkspaceKey();