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