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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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