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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user