diff --git a/packages/app/e2e/workspace-cwd.spec.ts b/packages/app/e2e/workspace-cwd.spec.ts index daa0b5db7..bbfc7978d 100644 --- a/packages/app/e2e/workspace-cwd.spec.ts +++ b/packages/app/e2e/workspace-cwd.spec.ts @@ -1,5 +1,5 @@ -import { test } from "./fixtures"; -import { clickNewTerminal } from "./helpers/launcher"; +import { expect, type Page, test } from "./fixtures"; +import { clickNewChat, clickNewTerminal } from "./helpers/launcher"; import { expectTerminalSurfaceVisible, focusTerminalSurface, @@ -8,6 +8,55 @@ import { waitForTerminalContent, } from "./helpers/terminal-perf"; +async function installCreateAgentRequestRecorder(page: Page): Promise { + await page.addInitScript(() => { + const requests: unknown[] = []; + ( + window as typeof window & { + __paseoE2eCreateAgentRequests?: unknown[]; + } + ).__paseoE2eCreateAgentRequests = requests; + const originalSend = WebSocket.prototype.send; + WebSocket.prototype.send = function (data) { + if (typeof data === "string") { + try { + const parsed = JSON.parse(data) as { + type?: unknown; + message?: { type?: unknown }; + }; + if (parsed.type === "session" && parsed.message?.type === "create_agent_request") { + requests.push(parsed.message); + } + } catch { + // Ignore non-JSON frames. + } + } + return originalSend.call(this, data); + }; + }); +} + +async function getRecordedCreateAgentCwd(page: Page, message: string): Promise { + return page.evaluate((expectedMessage) => { + const requests = + ( + window as typeof window & { + __paseoE2eCreateAgentRequests?: Array<{ + initialPrompt?: string; + config?: { cwd?: string }; + }>; + } + ).__paseoE2eCreateAgentRequests ?? []; + + for (const request of requests) { + if (request.initialPrompt === expectedMessage) { + return request.config?.cwd ?? null; + } + } + return null; + }, message); +} + test.describe("Workspace cwd correctness", () => { test("main checkout workspace opens terminals in the project root", async ({ page, @@ -26,6 +75,32 @@ test.describe("Workspace cwd correctness", () => { await waitForTerminalContent(page, (text) => text.includes(workspace.repoPath), 10_000); }); + test("draft tab creates an agent in the workspace cwd", async ({ page, withWorkspace }) => { + test.setTimeout(60_000); + + await installCreateAgentRequestRecorder(page); + const workspace = await withWorkspace({ prefix: "workspace-cwd-draft-agent-" }); + await workspace.navigateTo(); + + await clickNewChat(page); + const composer = page.getByRole("textbox", { name: "Message agent..." }).first(); + const message = `cwd draft create ${Date.now()}`; + await expect(composer).toBeEditable({ timeout: 15_000 }); + await composer.fill(message); + await composer.press("Enter"); + await expect(page.getByText(message, { exact: true }).first()).toBeVisible({ + timeout: 30_000, + }); + + await expect(page.locator('[data-testid^="workspace-tab-agent_"]').first()).toBeVisible({ + timeout: 30_000, + }); + + await expect + .poll(async () => getRecordedCreateAgentCwd(page, message), { timeout: 30_000 }) + .toBe(workspace.repoPath); + }); + test("worktree workspace opens terminals in the worktree directory", async ({ page, withWorkspace, diff --git a/packages/app/src/components/composer.tsx b/packages/app/src/components/composer.tsx index 1b584d4ee..198837421 100644 --- a/packages/app/src/components/composer.tsx +++ b/packages/app/src/components/composer.tsx @@ -1541,7 +1541,6 @@ export function Composer({ {autocompletePopover} - {attachmentPreviewList} {/* MessageInput handles everything: text, dictation, attachments, all buttons */} ({ flexWrap: "wrap", }, imageThumbnail: { - width: 48, - height: 48, + width: 32, + height: 32, }, imageThumbnailPlaceholder: { - width: 48, - height: 48, + width: 32, + height: 32, backgroundColor: theme.colors.surface2, }, githubPillBody: { - minHeight: 48, + minHeight: 32, maxWidth: 260, flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index 651523573..242809eb6 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -125,6 +125,8 @@ export interface MessageInputProps { onHeightChange?: (height: number) => void; /** Extra styles merged onto the input wrapper (e.g. elevated background). */ inputWrapperStyle?: import("react-native").ViewStyle; + /** Content rendered inside the bordered input surface, above the text input (e.g. attachment pills). */ + attachmentSlot?: React.ReactNode; } export interface MessageInputRef { @@ -1094,6 +1096,7 @@ interface ResolvedMessageInputProps { onFocusChange: ((focused: boolean) => void) | undefined; onHeightChange: ((height: number) => void) | undefined; inputWrapperStyle: import("react-native").ViewStyle | undefined; + attachmentSlot: React.ReactNode; } function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInputProps { @@ -1133,6 +1136,7 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu onFocusChange: props.onFocusChange, onHeightChange: props.onHeightChange, inputWrapperStyle: props.inputWrapperStyle, + attachmentSlot: props.attachmentSlot, }; } @@ -1180,6 +1184,7 @@ export const MessageInput = forwardRef( onFocusChange, onHeightChange, inputWrapperStyle, + attachmentSlot, } = resolveMessageInputProps(props); const buttonIconSize = isWeb ? ICON_SIZE.md : ICON_SIZE.lg; const toast = useToast(); @@ -1697,6 +1702,7 @@ export const MessageInput = forwardRef( {/* Regular input */} + {attachmentSlot} {/* Text input */} ; - setItem(key: string, value: string): Promise; - removeItem(key: string): Promise; -} - -export interface StoredDraft { - text: string; - attachments: UserComposerAttachment[]; - cwd: string; -} - -export interface AgentInputDraftCore { - load(initialCwd?: string): Promise; - save(draft: StoredDraft): Promise; - clear(): Promise; -} - -export function createAgentInputDraftCore(input: { - storage: DraftStorage; - storageKey: string; -}): AgentInputDraftCore { - return { - async load(initialCwd?: string) { - const json = await input.storage.getItem(input.storageKey); - if (!json) return null; - try { - const parsed = JSON.parse(json) as Record; - return { - text: typeof parsed.text === "string" ? parsed.text : "", - attachments: Array.isArray(parsed.attachments) - ? (parsed.attachments as UserComposerAttachment[]) - : [], - cwd: typeof parsed.cwd === "string" ? parsed.cwd : (initialCwd ?? ""), - }; - } catch { - return null; - } - }, - async save(draft) { - await input.storage.setItem(input.storageKey, JSON.stringify(draft)); - }, - async clear() { - await input.storage.removeItem(input.storageKey); - }, - }; -} - export interface DraftKeyContext { selectedServerId: string | null; } @@ -152,11 +104,8 @@ export function buildDraftStatusControls(input: { export function hasDraftContent(input: { text: string; attachments: UserComposerAttachment[]; - cwd: string; }): boolean { - return ( - input.text.trim().length > 0 || input.attachments.length > 0 || input.cwd.trim().length > 0 - ); + return input.text.trim().length > 0 || input.attachments.length > 0; } export function areAttachmentsEqual(input: { diff --git a/packages/app/src/hooks/use-agent-input-draft.live.test.tsx b/packages/app/src/hooks/use-agent-input-draft.live.test.tsx index 9d2edbd51..abcfe5815 100644 --- a/packages/app/src/hooks/use-agent-input-draft.live.test.tsx +++ b/packages/app/src/hooks/use-agent-input-draft.live.test.tsx @@ -10,6 +10,10 @@ const { asyncStorage } = vi.hoisted(() => ({ asyncStorage: new Map(), })); +vi.hoisted(() => { + (globalThis as unknown as { __DEV__: boolean }).__DEV__ = false; +}); + vi.mock("@react-native-async-storage/async-storage", () => ({ default: { getItem: async (key: string) => asyncStorage.get(key) ?? null, @@ -250,7 +254,7 @@ describe("useAgentInputDraft live contract", () => { }); function Probe() { - latest = useAgentInputDraft({ draftKey: "draft:legacy", initialCwd: "/repo" }); + latest = useAgentInputDraft({ draftKey: "draft:legacy" }); return null; } @@ -271,15 +275,13 @@ describe("useAgentInputDraft live contract", () => { expect(getLatest().text).toBe("legacy text"); expect(getLatest().attachments).toEqual([{ kind: "image", metadata: image }]); - expect(getLatest().cwd).toBe("/repo"); expect(useDraftStore.getState().drafts["draft:legacy"]?.input).toEqual({ text: "legacy text", attachments: [{ kind: "image", metadata: image }], - cwd: "/repo", }); }); - it("round-trips persisted attachments and cwd unchanged", async () => { + it("hydrates drafts saved by old builds with cwd", async () => { let latest: ReturnType | null = null; const githubIssue: ComposerAttachment = { kind: "github_issue", @@ -312,13 +314,13 @@ describe("useAgentInputDraft live contract", () => { lifecycle: "active", updatedAt: Date.now(), version: 1, - }, + } as unknown as DraftRecordForTest, }, createModalDraft: null, }); function Probe() { - latest = useAgentInputDraft({ draftKey: "draft:new-shape", initialCwd: "/initial" }); + latest = useAgentInputDraft({ draftKey: "draft:new-shape" }); return null; } @@ -339,7 +341,6 @@ describe("useAgentInputDraft live contract", () => { expect(getLatest().text).toBe("new text"); expect(getLatest().attachments).toEqual([githubIssue]); - expect(getLatest().cwd).toBe("/persisted"); await act(async () => { root.unmount(); @@ -352,112 +353,6 @@ describe("useAgentInputDraft live contract", () => { }); }); - it("seeds cwd from initialCwd when persisted cwd is missing", async () => { - let latest: ReturnType | null = null; - - function getLatest(): ReturnType { - if (!latest) { - throw new Error("Expected hook result"); - } - return latest; - } - - useDraftStore.setState({ - drafts: { - "draft:seed-cwd": { - input: { - text: "seed", - attachments: [], - }, - lifecycle: "active", - updatedAt: Date.now(), - version: 1, - } as unknown as DraftRecordForTest, - }, - createModalDraft: null, - }); - - function Probe() { - latest = useAgentInputDraft({ draftKey: "draft:seed-cwd", initialCwd: "/initial" }); - return null; - } - - const queryClient = new QueryClient(); - const container = document.getElementById("root"); - if (!container) { - throw new Error("Missing root container"); - } - - const root = createRoot(container); - await act(async () => { - root.render( - - - , - ); - }); - - expect(getLatest().cwd).toBe("/initial"); - expect(useDraftStore.getState().drafts["draft:seed-cwd"]?.input).toEqual({ - text: "seed", - attachments: [], - cwd: "/initial", - }); - }); - - it("seeds cwd as empty string when persisted cwd and initialCwd are missing", async () => { - let latest: ReturnType | null = null; - - function getLatest(): ReturnType { - if (!latest) { - throw new Error("Expected hook result"); - } - return latest; - } - - useDraftStore.setState({ - drafts: { - "draft:empty-cwd": { - input: { - text: "seed", - attachments: [], - }, - lifecycle: "active", - updatedAt: Date.now(), - version: 1, - } as unknown as DraftRecordForTest, - }, - createModalDraft: null, - }); - - function Probe() { - latest = useAgentInputDraft({ draftKey: "draft:empty-cwd" }); - return null; - } - - const queryClient = new QueryClient(); - const container = document.getElementById("root"); - if (!container) { - throw new Error("Missing root container"); - } - - const root = createRoot(container); - await act(async () => { - root.render( - - - , - ); - }); - - expect(getLatest().cwd).toBe(""); - expect(useDraftStore.getState().drafts["draft:empty-cwd"]?.input).toEqual({ - text: "seed", - attachments: [], - cwd: "", - }); - }); - it("updates and persists attachments through setAttachments", async () => { let latest: ReturnType | null = null; const image: AttachmentMetadata = { @@ -476,7 +371,7 @@ describe("useAgentInputDraft live contract", () => { } function Probe() { - latest = useAgentInputDraft({ draftKey: "draft:attachments", initialCwd: "/repo" }); + latest = useAgentInputDraft({ draftKey: "draft:attachments" }); return null; } @@ -504,11 +399,10 @@ describe("useAgentInputDraft live contract", () => { expect(useDraftStore.getState().drafts["draft:attachments"]?.input).toEqual({ text: "with attachment", attachments: [{ kind: "image", metadata: image }], - cwd: "/repo", }); }); - it("clear resets text, attachments, and cwd", async () => { + it("clear resets text and attachments", async () => { let latest: ReturnType | null = null; const image: AttachmentMetadata = { id: "clear-image", @@ -526,7 +420,7 @@ describe("useAgentInputDraft live contract", () => { } function Probe() { - latest = useAgentInputDraft({ draftKey: "draft:clear", initialCwd: "/repo" }); + latest = useAgentInputDraft({ draftKey: "draft:clear" }); return null; } @@ -548,7 +442,6 @@ describe("useAgentInputDraft live contract", () => { await act(async () => { getLatest().setText("queued message"); getLatest().setAttachments([{ kind: "image", metadata: image }]); - getLatest().setCwd("/changed"); }); await act(async () => { @@ -557,11 +450,9 @@ describe("useAgentInputDraft live contract", () => { expect(getLatest().text).toBe(""); expect(getLatest().attachments).toEqual([]); - expect(getLatest().cwd).toBe(""); expect(useDraftStore.getState().drafts["draft:clear"]?.input).toEqual({ text: "", attachments: [], - cwd: "", }); }); @@ -615,7 +506,7 @@ describe("useAgentInputDraft live contract", () => { expect(getLatest().attachments).toEqual([]); expect(useDraftStore.getState().drafts["draft:lifecycle"]).toMatchObject({ lifecycle: "sent", - input: { text: "", attachments: [], cwd: "" }, + input: { text: "", attachments: [] }, }); await act(async () => { @@ -628,7 +519,7 @@ describe("useAgentInputDraft live contract", () => { expect(useDraftStore.getState().drafts["draft:lifecycle"]).toMatchObject({ lifecycle: "abandoned", - input: { text: "", attachments: [], cwd: "" }, + input: { text: "", attachments: [] }, }); }); }); diff --git a/packages/app/src/hooks/use-agent-input-draft.test.ts b/packages/app/src/hooks/use-agent-input-draft.test.ts index b373626fb..ee9cec7d7 100644 --- a/packages/app/src/hooks/use-agent-input-draft.test.ts +++ b/packages/app/src/hooks/use-agent-input-draft.test.ts @@ -1,25 +1,11 @@ import { describe, expect, it } from "vitest"; import { buildDraftComposerCommandConfig, - createAgentInputDraftCore, resolveDraftKey, resolveEffectiveComposerModelId, resolveEffectiveComposerThinkingOptionId, } from "./use-agent-input-draft-core"; -function makeStorage() { - const map = new Map(); - return { - getItem: async (key: string) => map.get(key) ?? null, - setItem: async (key: string, value: string) => { - map.set(key, value); - }, - removeItem: async (key: string) => { - map.delete(key); - }, - }; -} - describe("resolveDraftKey", () => { it("returns a string draft key unchanged", () => { expect( @@ -129,60 +115,3 @@ describe("buildDraftComposerCommandConfig", () => { }); }); }); - -describe("createAgentInputDraftCore", () => { - it("load returns null when nothing has been saved", async () => { - const core = createAgentInputDraftCore({ storage: makeStorage(), storageKey: "test" }); - expect(await core.load()).toBeNull(); - }); - - it("load returns the draft that was saved", async () => { - const core = createAgentInputDraftCore({ storage: makeStorage(), storageKey: "test" }); - await core.save({ text: "hello world", attachments: [], cwd: "/repo" }); - expect(await core.load()).toEqual({ text: "hello world", attachments: [], cwd: "/repo" }); - }); - - it("clear removes the persisted draft", async () => { - const core = createAgentInputDraftCore({ storage: makeStorage(), storageKey: "test" }); - await core.save({ text: "hello", attachments: [], cwd: "/repo" }); - await core.clear(); - expect(await core.load()).toBeNull(); - }); - - it("load seeds cwd from initialCwd when cwd is absent from stored data", async () => { - const storage = makeStorage(); - await storage.setItem("test", JSON.stringify({ text: "hello", attachments: [] })); - - const core = createAgentInputDraftCore({ storage, storageKey: "test" }); - const draft = await core.load("/initial"); - expect(draft?.cwd).toBe("/initial"); - }); - - it("round-trips attachments unchanged", async () => { - const attachment = { - kind: "github_issue" as const, - item: { - kind: "issue" as const, - number: 42, - title: "Unify attachments", - url: "https://github.com/example/repo/issues/42", - state: "open" as const, - body: "body", - labels: ["composer"], - }, - }; - const core = createAgentInputDraftCore({ storage: makeStorage(), storageKey: "test" }); - await core.save({ text: "", attachments: [attachment], cwd: "/repo" }); - const draft = await core.load(); - expect(draft?.attachments).toEqual([attachment]); - }); - - it("isolated keys do not share state", async () => { - const storage = makeStorage(); - const a = createAgentInputDraftCore({ storage, storageKey: "key-a" }); - const b = createAgentInputDraftCore({ storage, storageKey: "key-b" }); - await a.save({ text: "from a", attachments: [], cwd: "" }); - expect(await b.load()).toBeNull(); - expect(await a.load()).toMatchObject({ text: "from a" }); - }); -}); diff --git a/packages/app/src/hooks/use-agent-input-draft.ts b/packages/app/src/hooks/use-agent-input-draft.ts index d1b0c1ef9..5962b548f 100644 --- a/packages/app/src/hooks/use-agent-input-draft.ts +++ b/packages/app/src/hooks/use-agent-input-draft.ts @@ -34,7 +34,6 @@ interface AgentInputDraftComposerOptions { interface UseAgentInputDraftInput { draftKey: DraftKeyInput; - initialCwd?: string; composer?: AgentInputDraftComposerOptions; } @@ -52,8 +51,6 @@ interface AgentInputDraft { setText: (text: string) => void; attachments: UserComposerAttachment[]; setAttachments: (updater: AttachmentUpdater) => void; - cwd: string; - setCwd: (cwd: string) => void; clear: (lifecycle: "sent" | "abandoned") => void; isHydrated: boolean; composerState: DraftComposerState | null; @@ -78,7 +75,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr ); const [text, setText] = useState(""); const [attachments, setAttachmentsState] = useState([]); - const [cwd, setCwd] = useState(input.initialCwd ?? ""); const [isHydrated, setIsHydrated] = useState(false); const draftGenerationRef = useRef(0); const hydratedGenerationRef = useRef(0); @@ -103,7 +99,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr setText(""); setAttachmentsState([]); - setCwd(""); setIsHydrated(true); }, [draftKey], @@ -117,7 +112,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr setText(""); setAttachmentsState([]); - setCwd(input.initialCwd ?? ""); setIsHydrated(false); let cancelled = false; @@ -125,7 +119,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr void (async () => { const draft = await store.hydrateDraftInput({ draftKey, - initialCwd: input.initialCwd, }); if (cancelled) { return; @@ -137,7 +130,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr if (draft) { setText(draft.text); setAttachmentsState(draft.attachments); - setCwd(draft.cwd); } hydratedGenerationRef.current = generation; @@ -147,7 +139,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr return () => { cancelled = true; }; - }, [draftKey, input.initialCwd]); + }, [draftKey]); useEffect(() => { const currentGeneration = draftGenerationRef.current; @@ -171,7 +163,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr const isSameDraft = existing !== undefined && existing.text === text && - existing.cwd === cwd && areAttachmentsEqual({ left: existing.attachments, right: attachments, @@ -180,7 +171,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr return; } - if (!hasDraftContent({ text, attachments, cwd })) { + if (!hasDraftContent({ text, attachments })) { if (existing) { store.clearDraftInput({ draftKey, lifecycle: "abandoned" }); } @@ -192,10 +183,9 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr draft: { text, attachments, - cwd, }, }); - }, [attachments, cwd, draftKey, text]); + }, [attachments, draftKey, text]); const lockedWorkingDir = composerOptions?.lockedWorkingDir?.trim() ?? ""; useEffect(() => { @@ -301,8 +291,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr setText, attachments, setAttachments, - cwd, - setCwd, clear, isHydrated, composerState, diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index a1fce2c1f..42e2e45c3 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -948,6 +948,7 @@ function ChatAgentContent({ }); if (nonReadyView) return nonReadyView; invariant(effectiveAgent, "effectiveAgent is defined when the non-ready view is absent"); + invariant(agentState.cwd, "agent cwd is defined when agent content is ready"); return ( @@ -977,7 +978,7 @@ function ChatAgentContent({ isPaneFocused={isPaneFocused} isArchivingCurrentAgent={isArchivingCurrentAgent} archivedAt={agentState.archivedAt} - initialCwd={agentState.cwd ?? ""} + cwd={agentState.cwd} isSubmitLoading={showPendingCreateSubmitLoading} onAttentionInputFocus={attentionController.clearOnInputFocus} onAttentionPromptSend={attentionController.clearOnPromptSend} @@ -1182,7 +1183,7 @@ function AgentComposerSection({ isPaneFocused, isArchivingCurrentAgent, archivedAt, - initialCwd, + cwd, isSubmitLoading, onAttentionInputFocus, onAttentionPromptSend, @@ -1195,7 +1196,7 @@ function AgentComposerSection({ isPaneFocused: boolean; isArchivingCurrentAgent: boolean; archivedAt: Date | null; - initialCwd: string; + cwd: string; isSubmitLoading: boolean; onAttentionInputFocus: () => void; onAttentionPromptSend: () => void; @@ -1218,7 +1219,7 @@ function AgentComposerSection({ agentId={agentId} serverId={serverId} isPaneFocused={isPaneFocused} - initialCwd={initialCwd} + cwd={cwd} isSubmitLoading={isSubmitLoading} onAttentionInputFocus={onAttentionInputFocus} onAttentionPromptSend={onAttentionPromptSend} @@ -1233,7 +1234,7 @@ function ActiveAgentComposer({ agentId, serverId, isPaneFocused, - initialCwd, + cwd, isSubmitLoading, onAttentionInputFocus, onAttentionPromptSend, @@ -1244,7 +1245,7 @@ function ActiveAgentComposer({ agentId: string; serverId: string; isPaneFocused: boolean; - initialCwd: string; + cwd: string; isSubmitLoading: boolean; onAttentionInputFocus: () => void; onAttentionPromptSend: () => void; @@ -1272,11 +1273,10 @@ function ActiveAgentComposer({ serverId, agentId, }), - initialCwd, }); const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({ serverId, - cwd: agentInputDraft.cwd, + cwd, workspaceId, }); const workspaceAttachments = useWorkspaceAttachments(workspaceAttachmentScopeKey); @@ -1326,7 +1326,7 @@ function ActiveAgentComposer({ workspaceAttachments={workspaceAttachments} onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment} onChangeAttachments={agentInputDraft.setAttachments} - cwd={agentInputDraft.cwd} + cwd={cwd} clearDraft={agentInputDraft.clear} autoFocus={isPaneFocused} isSubmitLoading={isSubmitLoading} diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index fe2ce1baf..1bcf7ac48 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -353,7 +353,6 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void { attachments: attachments.filter( (attachment): attachment is UserComposerAttachment => attachment.kind !== "review", ), - cwd: workspaceDirectory, }, }); useWorkspaceDraftSubmissionStore.getState().setPending({ @@ -421,7 +420,6 @@ export function NewWorkspaceScreen({ const draftKey = `new-workspace:${serverId}:${sourceDirectory}`; const chatDraft = useAgentInputDraft({ draftKey, - initialCwd: sourceDirectory, composer: buildComposerConfig({ serverId, isConnected, @@ -751,7 +749,7 @@ export function NewWorkspaceScreen({ onChangeText={chatDraft.setText} attachments={chatDraft.attachments} onChangeAttachments={chatDraft.setAttachments} - cwd={chatDraft.cwd} + cwd={sourceDirectory} clearDraft={handleClearDraft} autoFocus commandDraftConfig={composerState?.commandDraftConfig} 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 6c2837052..2d1c10aa7 100644 --- a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx +++ b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx @@ -310,7 +310,6 @@ export function WorkspaceDraftAgentTab({ ); const draftInput = useAgentInputDraft({ draftKey: draftStoreKey, - initialCwd: workspaceDirectory ?? "", composer: { initialServerId: serverId, initialValues: workspaceDirectory ? { workingDir: workspaceDirectory } : undefined, @@ -338,7 +337,7 @@ export function WorkspaceDraftAgentTab({ const isCompact = useIsCompactFormFactor(); const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({ serverId, - cwd: draftInput.cwd, + cwd: composerState.workingDir, workspaceId, }); const workspaceAttachments = useWorkspaceAttachments(workspaceAttachmentScopeKey); @@ -613,7 +612,7 @@ export function WorkspaceDraftAgentTab({ workspaceAttachments={workspaceAttachments} onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment} onChangeAttachments={draftInput.setAttachments} - cwd={draftInput.cwd} + cwd={composerState.workingDir} clearDraft={draftInput.clear} autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })} onAddImages={handleAddImagesCallback} diff --git a/packages/app/src/stores/draft-store.lifecycle.test.ts b/packages/app/src/stores/draft-store.lifecycle.test.ts index 6d841ff31..f79fdf13e 100644 --- a/packages/app/src/stores/draft-store.lifecycle.test.ts +++ b/packages/app/src/stores/draft-store.lifecycle.test.ts @@ -7,19 +7,19 @@ describe("draft-store lifecycle", () => { const nowMs = 1_000_000; const drafts = { oldSent: { - input: { text: "", attachments: [], cwd: "" }, + input: { text: "", attachments: [] }, lifecycle: "sent" as const, updatedAt: 0, version: 2, }, recentAbandoned: { - input: { text: "", attachments: [], cwd: "" }, + input: { text: "", attachments: [] }, lifecycle: "abandoned" as const, updatedAt: nowMs + 2 * 60 * 1000, version: 2, }, active: { - input: { text: "a", attachments: [], cwd: "/repo" }, + input: { text: "a", attachments: [] }, lifecycle: "active" as const, updatedAt: 0, version: 1, @@ -53,7 +53,6 @@ describe("draft-store lifecycle", () => { }, }, ], - cwd: "/repo", }, lifecycle: "active", updatedAt: 1, @@ -64,7 +63,7 @@ describe("draft-store lifecycle", () => { }); expect(cleared).toEqual({ - input: { text: "", attachments: [], cwd: "" }, + input: { text: "", attachments: [] }, lifecycle: "sent", updatedAt: 2, version: 2, diff --git a/packages/app/src/stores/draft-store.migration.test.ts b/packages/app/src/stores/draft-store.migration.test.ts index dc7abf507..b445ede65 100644 --- a/packages/app/src/stores/draft-store.migration.test.ts +++ b/packages/app/src/stores/draft-store.migration.test.ts @@ -87,11 +87,10 @@ describe("draft-store migration", () => { }, }, ], - cwd: "", }); }); - it("is idempotent for already-migrated shapes", async () => { + it("hydrates old persisted drafts that still include cwd", async () => { const original = { drafts: { "agent:server:agent": { @@ -123,6 +122,21 @@ describe("draft-store migration", () => { const twice = await __draftStoreTestUtils.migratePersistedState(once); expect(twice).toEqual(once); + expect(twice.drafts["agent:server:agent"]?.input).toEqual({ + text: "hello", + attachments: [ + { + kind: "image", + metadata: { + id: "att-1", + mimeType: "image/jpeg", + storageType: "web-indexeddb", + storageKey: "att-1", + createdAt: 1700000000000, + }, + }, + ], + }); }); it("rejects workspace review attachments from migrated draft attachments", async () => { @@ -132,7 +146,6 @@ describe("draft-store migration", () => { input: { text: "hello", attachments: [workspaceReviewAttachment()], - cwd: "/repo", }, lifecycle: "active", updatedAt: 1700000000001, diff --git a/packages/app/src/stores/draft-store.ts b/packages/app/src/stores/draft-store.ts index 5520ed4c6..5b278e47d 100644 --- a/packages/app/src/stores/draft-store.ts +++ b/packages/app/src/stores/draft-store.ts @@ -24,7 +24,6 @@ type PersistedDraftImage = AttachmentMetadata | LegacyDraftImage; export interface DraftInput { text: string; attachments: UserComposerAttachment[]; - cwd: string; } export type DraftLifecycleState = "active" | "abandoned" | "sent"; @@ -45,10 +44,7 @@ interface DraftStoreState { interface DraftStoreActions { getDraftInput: (draftKey: string) => DraftInput | undefined; - hydrateDraftInput: (input: { - draftKey: string; - initialCwd?: string; - }) => Promise; + hydrateDraftInput: (input: { draftKey: string }) => Promise; saveDraftInput: (input: { draftKey: string; draft: DraftInput }) => void; markDraftLifecycle: (input: { draftKey: string; lifecycle: DraftLifecycleState }) => void; clearDraftInput: (input: { @@ -76,7 +72,6 @@ function createDraftRecord(input: { input: { text: input.draft.text, attachments: input.draft.attachments.map(normalizeComposerAttachment), - cwd: input.draft.cwd, }, lifecycle: input.lifecycle, updatedAt: Date.now(), @@ -181,11 +176,11 @@ function isCanonicalDraftInput(value: unknown): value is CanonicalDraftInput { return false; } const input = value as Record; + // COMPAT(draft-cwd): accept legacy persisted drafts that include cwd. Stop accepting after 2026-11-09. return ( typeof input.text === "string" && Array.isArray(input.attachments) && - input.attachments.every(isUserComposerAttachment) && - typeof input.cwd === "string" + input.attachments.every(isUserComposerAttachment) ); } @@ -202,7 +197,6 @@ function toDraftInputIfReady(record: DraftRecord | null | undefined): DraftInput return { text: record.input.text, attachments: record.input.attachments.map(normalizeComposerAttachment), - cwd: record.input.cwd, }; } @@ -262,7 +256,7 @@ function applyClearDraftRecord(input: { return { ...input.record, - input: { text: "", attachments: [], cwd: "" }, + input: { text: "", attachments: [] }, lifecycle: input.lifecycle, updatedAt: input.nowMs, version: input.record.version + 1, @@ -417,10 +411,7 @@ async function migrateLegacyImages( return migrated.filter((entry): entry is AttachmentMetadata => entry !== null); } -async function migrateDraftInput(input: { - rawInput: unknown; - initialCwd?: string; -}): Promise { +async function migrateDraftInput(input: { rawInput: unknown }): Promise { const rawInput = input.rawInput && typeof input.rawInput === "object" ? (input.rawInput as Record) @@ -440,7 +431,6 @@ async function migrateDraftInput(input: { return { text: typeof rawInput.text === "string" ? rawInput.text : "", attachments: [...attachments, ...legacyImagesToAttachments(migratedImages)], - cwd: typeof rawInput.cwd === "string" ? rawInput.cwd : (input.initialCwd ?? ""), }; } @@ -505,7 +495,7 @@ export const useDraftStore = create()( return toDraftInputIfReady(record); }, - hydrateDraftInput: async ({ draftKey, initialCwd }) => { + hydrateDraftInput: async ({ draftKey }) => { const current = get().drafts[draftKey]; if (!current) { return undefined; @@ -520,7 +510,6 @@ export const useDraftStore = create()( const migratedDraft = await migrateDraftInput({ rawInput: current.input, - initialCwd, }); set((state) => {