fix(app): source composer cwd from agent state, not draft hook

The GitHub picker showed "No results found." for any running agent after
the first message was sent. useAgentInputDraft owned a local cwd state
that clear("sent") wiped to "", so the composer read cwd: "" even though
the agent's real cwd was correct on the wire.

Drop cwd ownership from the hook. Composer takes cwd as a prop sourced
by the parent — agentState.cwd for running agents, composerState.workingDir
for drafts, sourceDirectory for setup flows. The hook now persists only
{text, attachments}. Legacy persisted drafts with a cwd field still
hydrate; tagged COMPAT(draft-cwd) for removal after 2026-11-09.

Also moves attachment pills back inside the composer's bordered surface
via a new attachmentSlot prop on MessageInput, and shrinks pill height
from 48 to 32 — the subagents-track tuck previously had attachments
floating between it and the input.

Adds one Playwright spec asserting the draft-create-agent path submits
the workspace cwd.
This commit is contained in:
Mohamed Boudra
2026-05-09 18:32:43 +07:00
parent d241c24776
commit d1bda25a0b
14 changed files with 145 additions and 310 deletions
+77 -2
View File
@@ -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<void> {
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<string | null> {
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,
+6 -6
View File
@@ -1541,7 +1541,6 @@ export function Composer({
<View style={styles.messageInputContainer}>
{autocompletePopover}
{attachmentPreviewList}
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
<StableMessageInput
@@ -1581,6 +1580,7 @@ export function Composer({
onFocusChange={handleFocusChange}
onHeightChange={onComposerHeightChange}
inputWrapperStyle={inputWrapperStyle}
attachmentSlot={attachmentPreviewList}
/>
<Combobox
options={githubSearchOptions}
@@ -1685,16 +1685,16 @@ const styles = StyleSheet.create((theme: Theme) => ({
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",
@@ -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<MessageInputRef, MessageInputProps>(
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<MessageInputRef, MessageInputProps>(
<View ref={rootRef} style={styles.container} testID="message-input-root">
{/* Regular input */}
<Animated.View ref={inputWrapperRef} style={inputWrapperCombinedStyle}>
{attachmentSlot}
{/* Text input */}
<View style={styles.textInputScrollWrapper}>
<ThemedTextInput
@@ -151,7 +151,6 @@ export function WorkspaceSetupDialog() {
const isConnected = useHostRuntimeIsConnected(serverId);
const chatDraft = useAgentInputDraft({
draftKey: `workspace-setup:${serverId}:${sourceDirectory}`,
initialCwd: sourceDirectory,
composer: buildChatDraftComposerArgs({
serverId,
isConnected,
@@ -393,7 +392,7 @@ export function WorkspaceSetupDialog() {
onChangeText={chatDraft.setText}
attachments={chatDraft.attachments}
onChangeAttachments={chatDraft.setAttachments}
cwd={chatDraft.cwd}
cwd={sourceDirectory}
clearDraft={chatDraft.clear}
autoFocus
commandDraftConfig={composerState?.commandDraftConfig}
@@ -4,54 +4,6 @@ import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
import type { UseAgentFormStateResult } from "@/hooks/use-agent-form-state";
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
export interface DraftStorage {
getItem(key: string): Promise<string | null>;
setItem(key: string, value: string): Promise<void>;
removeItem(key: string): Promise<void>;
}
export interface StoredDraft {
text: string;
attachments: UserComposerAttachment[];
cwd: string;
}
export interface AgentInputDraftCore {
load(initialCwd?: string): Promise<StoredDraft | null>;
save(draft: StoredDraft): Promise<void>;
clear(): Promise<void>;
}
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<string, unknown>;
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: {
@@ -10,6 +10,10 @@ const { asyncStorage } = vi.hoisted(() => ({
asyncStorage: new Map<string, string>(),
}));
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<typeof useAgentInputDraft> | 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<typeof useAgentInputDraft> | null = null;
function getLatest(): ReturnType<typeof useAgentInputDraft> {
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(
<QueryClientProvider client={queryClient}>
<Probe />
</QueryClientProvider>,
);
});
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<typeof useAgentInputDraft> | null = null;
function getLatest(): ReturnType<typeof useAgentInputDraft> {
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(
<QueryClientProvider client={queryClient}>
<Probe />
</QueryClientProvider>,
);
});
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<typeof useAgentInputDraft> | 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<typeof useAgentInputDraft> | 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: [] },
});
});
});
@@ -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<string, string>();
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" });
});
});
@@ -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<UserComposerAttachment[]>([]);
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,
+9 -9
View File
@@ -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 (
<View style={styles.root}>
@@ -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}
@@ -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}
@@ -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}
@@ -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,
@@ -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,
+6 -17
View File
@@ -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<DraftInput | undefined>;
hydrateDraftInput: (input: { draftKey: string }) => Promise<DraftInput | undefined>;
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<string, unknown>;
// 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<CanonicalDraftInput> {
async function migrateDraftInput(input: { rawInput: unknown }): Promise<CanonicalDraftInput> {
const rawInput =
input.rawInput && typeof input.rawInput === "object"
? (input.rawInput as Record<string, unknown>)
@@ -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<DraftStore>()(
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<DraftStore>()(
const migratedDraft = await migrateDraftInput({
rawInput: current.input,
initialCwd,
});
set((state) => {