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 { expect, type Page, test } from "./fixtures";
|
||||||
import { clickNewTerminal } from "./helpers/launcher";
|
import { clickNewChat, clickNewTerminal } from "./helpers/launcher";
|
||||||
import {
|
import {
|
||||||
expectTerminalSurfaceVisible,
|
expectTerminalSurfaceVisible,
|
||||||
focusTerminalSurface,
|
focusTerminalSurface,
|
||||||
@@ -8,6 +8,55 @@ import {
|
|||||||
waitForTerminalContent,
|
waitForTerminalContent,
|
||||||
} from "./helpers/terminal-perf";
|
} 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.describe("Workspace cwd correctness", () => {
|
||||||
test("main checkout workspace opens terminals in the project root", async ({
|
test("main checkout workspace opens terminals in the project root", async ({
|
||||||
page,
|
page,
|
||||||
@@ -26,6 +75,32 @@ test.describe("Workspace cwd correctness", () => {
|
|||||||
await waitForTerminalContent(page, (text) => text.includes(workspace.repoPath), 10_000);
|
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 ({
|
test("worktree workspace opens terminals in the worktree directory", async ({
|
||||||
page,
|
page,
|
||||||
withWorkspace,
|
withWorkspace,
|
||||||
|
|||||||
@@ -1541,7 +1541,6 @@ export function Composer({
|
|||||||
|
|
||||||
<View style={styles.messageInputContainer}>
|
<View style={styles.messageInputContainer}>
|
||||||
{autocompletePopover}
|
{autocompletePopover}
|
||||||
{attachmentPreviewList}
|
|
||||||
|
|
||||||
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
|
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
|
||||||
<StableMessageInput
|
<StableMessageInput
|
||||||
@@ -1581,6 +1580,7 @@ export function Composer({
|
|||||||
onFocusChange={handleFocusChange}
|
onFocusChange={handleFocusChange}
|
||||||
onHeightChange={onComposerHeightChange}
|
onHeightChange={onComposerHeightChange}
|
||||||
inputWrapperStyle={inputWrapperStyle}
|
inputWrapperStyle={inputWrapperStyle}
|
||||||
|
attachmentSlot={attachmentPreviewList}
|
||||||
/>
|
/>
|
||||||
<Combobox
|
<Combobox
|
||||||
options={githubSearchOptions}
|
options={githubSearchOptions}
|
||||||
@@ -1685,16 +1685,16 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
|||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
},
|
},
|
||||||
imageThumbnail: {
|
imageThumbnail: {
|
||||||
width: 48,
|
width: 32,
|
||||||
height: 48,
|
height: 32,
|
||||||
},
|
},
|
||||||
imageThumbnailPlaceholder: {
|
imageThumbnailPlaceholder: {
|
||||||
width: 48,
|
width: 32,
|
||||||
height: 48,
|
height: 32,
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
githubPillBody: {
|
githubPillBody: {
|
||||||
minHeight: 48,
|
minHeight: 32,
|
||||||
maxWidth: 260,
|
maxWidth: 260,
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -125,6 +125,8 @@ export interface MessageInputProps {
|
|||||||
onHeightChange?: (height: number) => void;
|
onHeightChange?: (height: number) => void;
|
||||||
/** Extra styles merged onto the input wrapper (e.g. elevated background). */
|
/** Extra styles merged onto the input wrapper (e.g. elevated background). */
|
||||||
inputWrapperStyle?: import("react-native").ViewStyle;
|
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 {
|
export interface MessageInputRef {
|
||||||
@@ -1094,6 +1096,7 @@ interface ResolvedMessageInputProps {
|
|||||||
onFocusChange: ((focused: boolean) => void) | undefined;
|
onFocusChange: ((focused: boolean) => void) | undefined;
|
||||||
onHeightChange: ((height: number) => void) | undefined;
|
onHeightChange: ((height: number) => void) | undefined;
|
||||||
inputWrapperStyle: import("react-native").ViewStyle | undefined;
|
inputWrapperStyle: import("react-native").ViewStyle | undefined;
|
||||||
|
attachmentSlot: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInputProps {
|
function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInputProps {
|
||||||
@@ -1133,6 +1136,7 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu
|
|||||||
onFocusChange: props.onFocusChange,
|
onFocusChange: props.onFocusChange,
|
||||||
onHeightChange: props.onHeightChange,
|
onHeightChange: props.onHeightChange,
|
||||||
inputWrapperStyle: props.inputWrapperStyle,
|
inputWrapperStyle: props.inputWrapperStyle,
|
||||||
|
attachmentSlot: props.attachmentSlot,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1180,6 +1184,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
onFocusChange,
|
onFocusChange,
|
||||||
onHeightChange,
|
onHeightChange,
|
||||||
inputWrapperStyle,
|
inputWrapperStyle,
|
||||||
|
attachmentSlot,
|
||||||
} = resolveMessageInputProps(props);
|
} = resolveMessageInputProps(props);
|
||||||
const buttonIconSize = isWeb ? ICON_SIZE.md : ICON_SIZE.lg;
|
const buttonIconSize = isWeb ? ICON_SIZE.md : ICON_SIZE.lg;
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
@@ -1697,6 +1702,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
<View ref={rootRef} style={styles.container} testID="message-input-root">
|
<View ref={rootRef} style={styles.container} testID="message-input-root">
|
||||||
{/* Regular input */}
|
{/* Regular input */}
|
||||||
<Animated.View ref={inputWrapperRef} style={inputWrapperCombinedStyle}>
|
<Animated.View ref={inputWrapperRef} style={inputWrapperCombinedStyle}>
|
||||||
|
{attachmentSlot}
|
||||||
{/* Text input */}
|
{/* Text input */}
|
||||||
<View style={styles.textInputScrollWrapper}>
|
<View style={styles.textInputScrollWrapper}>
|
||||||
<ThemedTextInput
|
<ThemedTextInput
|
||||||
|
|||||||
@@ -151,7 +151,6 @@ export function WorkspaceSetupDialog() {
|
|||||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
const chatDraft = useAgentInputDraft({
|
const chatDraft = useAgentInputDraft({
|
||||||
draftKey: `workspace-setup:${serverId}:${sourceDirectory}`,
|
draftKey: `workspace-setup:${serverId}:${sourceDirectory}`,
|
||||||
initialCwd: sourceDirectory,
|
|
||||||
composer: buildChatDraftComposerArgs({
|
composer: buildChatDraftComposerArgs({
|
||||||
serverId,
|
serverId,
|
||||||
isConnected,
|
isConnected,
|
||||||
@@ -393,7 +392,7 @@ export function WorkspaceSetupDialog() {
|
|||||||
onChangeText={chatDraft.setText}
|
onChangeText={chatDraft.setText}
|
||||||
attachments={chatDraft.attachments}
|
attachments={chatDraft.attachments}
|
||||||
onChangeAttachments={chatDraft.setAttachments}
|
onChangeAttachments={chatDraft.setAttachments}
|
||||||
cwd={chatDraft.cwd}
|
cwd={sourceDirectory}
|
||||||
clearDraft={chatDraft.clear}
|
clearDraft={chatDraft.clear}
|
||||||
autoFocus
|
autoFocus
|
||||||
commandDraftConfig={composerState?.commandDraftConfig}
|
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 { UseAgentFormStateResult } from "@/hooks/use-agent-form-state";
|
||||||
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
|
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 {
|
export interface DraftKeyContext {
|
||||||
selectedServerId: string | null;
|
selectedServerId: string | null;
|
||||||
}
|
}
|
||||||
@@ -152,11 +104,8 @@ export function buildDraftStatusControls(input: {
|
|||||||
export function hasDraftContent(input: {
|
export function hasDraftContent(input: {
|
||||||
text: string;
|
text: string;
|
||||||
attachments: UserComposerAttachment[];
|
attachments: UserComposerAttachment[];
|
||||||
cwd: string;
|
|
||||||
}): boolean {
|
}): boolean {
|
||||||
return (
|
return input.text.trim().length > 0 || input.attachments.length > 0;
|
||||||
input.text.trim().length > 0 || input.attachments.length > 0 || input.cwd.trim().length > 0
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function areAttachmentsEqual(input: {
|
export function areAttachmentsEqual(input: {
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ const { asyncStorage } = vi.hoisted(() => ({
|
|||||||
asyncStorage: new Map<string, string>(),
|
asyncStorage: new Map<string, string>(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.hoisted(() => {
|
||||||
|
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||||
default: {
|
default: {
|
||||||
getItem: async (key: string) => asyncStorage.get(key) ?? null,
|
getItem: async (key: string) => asyncStorage.get(key) ?? null,
|
||||||
@@ -250,7 +254,7 @@ describe("useAgentInputDraft live contract", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function Probe() {
|
function Probe() {
|
||||||
latest = useAgentInputDraft({ draftKey: "draft:legacy", initialCwd: "/repo" });
|
latest = useAgentInputDraft({ draftKey: "draft:legacy" });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,15 +275,13 @@ describe("useAgentInputDraft live contract", () => {
|
|||||||
|
|
||||||
expect(getLatest().text).toBe("legacy text");
|
expect(getLatest().text).toBe("legacy text");
|
||||||
expect(getLatest().attachments).toEqual([{ kind: "image", metadata: image }]);
|
expect(getLatest().attachments).toEqual([{ kind: "image", metadata: image }]);
|
||||||
expect(getLatest().cwd).toBe("/repo");
|
|
||||||
expect(useDraftStore.getState().drafts["draft:legacy"]?.input).toEqual({
|
expect(useDraftStore.getState().drafts["draft:legacy"]?.input).toEqual({
|
||||||
text: "legacy text",
|
text: "legacy text",
|
||||||
attachments: [{ kind: "image", metadata: image }],
|
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;
|
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
|
||||||
const githubIssue: ComposerAttachment = {
|
const githubIssue: ComposerAttachment = {
|
||||||
kind: "github_issue",
|
kind: "github_issue",
|
||||||
@@ -312,13 +314,13 @@ describe("useAgentInputDraft live contract", () => {
|
|||||||
lifecycle: "active",
|
lifecycle: "active",
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
version: 1,
|
version: 1,
|
||||||
},
|
} as unknown as DraftRecordForTest,
|
||||||
},
|
},
|
||||||
createModalDraft: null,
|
createModalDraft: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Probe() {
|
function Probe() {
|
||||||
latest = useAgentInputDraft({ draftKey: "draft:new-shape", initialCwd: "/initial" });
|
latest = useAgentInputDraft({ draftKey: "draft:new-shape" });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,7 +341,6 @@ describe("useAgentInputDraft live contract", () => {
|
|||||||
|
|
||||||
expect(getLatest().text).toBe("new text");
|
expect(getLatest().text).toBe("new text");
|
||||||
expect(getLatest().attachments).toEqual([githubIssue]);
|
expect(getLatest().attachments).toEqual([githubIssue]);
|
||||||
expect(getLatest().cwd).toBe("/persisted");
|
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.unmount();
|
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 () => {
|
it("updates and persists attachments through setAttachments", async () => {
|
||||||
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
|
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
|
||||||
const image: AttachmentMetadata = {
|
const image: AttachmentMetadata = {
|
||||||
@@ -476,7 +371,7 @@ describe("useAgentInputDraft live contract", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Probe() {
|
function Probe() {
|
||||||
latest = useAgentInputDraft({ draftKey: "draft:attachments", initialCwd: "/repo" });
|
latest = useAgentInputDraft({ draftKey: "draft:attachments" });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -504,11 +399,10 @@ describe("useAgentInputDraft live contract", () => {
|
|||||||
expect(useDraftStore.getState().drafts["draft:attachments"]?.input).toEqual({
|
expect(useDraftStore.getState().drafts["draft:attachments"]?.input).toEqual({
|
||||||
text: "with attachment",
|
text: "with attachment",
|
||||||
attachments: [{ kind: "image", metadata: image }],
|
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;
|
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
|
||||||
const image: AttachmentMetadata = {
|
const image: AttachmentMetadata = {
|
||||||
id: "clear-image",
|
id: "clear-image",
|
||||||
@@ -526,7 +420,7 @@ describe("useAgentInputDraft live contract", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Probe() {
|
function Probe() {
|
||||||
latest = useAgentInputDraft({ draftKey: "draft:clear", initialCwd: "/repo" });
|
latest = useAgentInputDraft({ draftKey: "draft:clear" });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -548,7 +442,6 @@ describe("useAgentInputDraft live contract", () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
getLatest().setText("queued message");
|
getLatest().setText("queued message");
|
||||||
getLatest().setAttachments([{ kind: "image", metadata: image }]);
|
getLatest().setAttachments([{ kind: "image", metadata: image }]);
|
||||||
getLatest().setCwd("/changed");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -557,11 +450,9 @@ describe("useAgentInputDraft live contract", () => {
|
|||||||
|
|
||||||
expect(getLatest().text).toBe("");
|
expect(getLatest().text).toBe("");
|
||||||
expect(getLatest().attachments).toEqual([]);
|
expect(getLatest().attachments).toEqual([]);
|
||||||
expect(getLatest().cwd).toBe("");
|
|
||||||
expect(useDraftStore.getState().drafts["draft:clear"]?.input).toEqual({
|
expect(useDraftStore.getState().drafts["draft:clear"]?.input).toEqual({
|
||||||
text: "",
|
text: "",
|
||||||
attachments: [],
|
attachments: [],
|
||||||
cwd: "",
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -615,7 +506,7 @@ describe("useAgentInputDraft live contract", () => {
|
|||||||
expect(getLatest().attachments).toEqual([]);
|
expect(getLatest().attachments).toEqual([]);
|
||||||
expect(useDraftStore.getState().drafts["draft:lifecycle"]).toMatchObject({
|
expect(useDraftStore.getState().drafts["draft:lifecycle"]).toMatchObject({
|
||||||
lifecycle: "sent",
|
lifecycle: "sent",
|
||||||
input: { text: "", attachments: [], cwd: "" },
|
input: { text: "", attachments: [] },
|
||||||
});
|
});
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -628,7 +519,7 @@ describe("useAgentInputDraft live contract", () => {
|
|||||||
|
|
||||||
expect(useDraftStore.getState().drafts["draft:lifecycle"]).toMatchObject({
|
expect(useDraftStore.getState().drafts["draft:lifecycle"]).toMatchObject({
|
||||||
lifecycle: "abandoned",
|
lifecycle: "abandoned",
|
||||||
input: { text: "", attachments: [], cwd: "" },
|
input: { text: "", attachments: [] },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,25 +1,11 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
buildDraftComposerCommandConfig,
|
buildDraftComposerCommandConfig,
|
||||||
createAgentInputDraftCore,
|
|
||||||
resolveDraftKey,
|
resolveDraftKey,
|
||||||
resolveEffectiveComposerModelId,
|
resolveEffectiveComposerModelId,
|
||||||
resolveEffectiveComposerThinkingOptionId,
|
resolveEffectiveComposerThinkingOptionId,
|
||||||
} from "./use-agent-input-draft-core";
|
} 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", () => {
|
describe("resolveDraftKey", () => {
|
||||||
it("returns a string draft key unchanged", () => {
|
it("returns a string draft key unchanged", () => {
|
||||||
expect(
|
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 {
|
interface UseAgentInputDraftInput {
|
||||||
draftKey: DraftKeyInput;
|
draftKey: DraftKeyInput;
|
||||||
initialCwd?: string;
|
|
||||||
composer?: AgentInputDraftComposerOptions;
|
composer?: AgentInputDraftComposerOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,8 +51,6 @@ interface AgentInputDraft {
|
|||||||
setText: (text: string) => void;
|
setText: (text: string) => void;
|
||||||
attachments: UserComposerAttachment[];
|
attachments: UserComposerAttachment[];
|
||||||
setAttachments: (updater: AttachmentUpdater) => void;
|
setAttachments: (updater: AttachmentUpdater) => void;
|
||||||
cwd: string;
|
|
||||||
setCwd: (cwd: string) => void;
|
|
||||||
clear: (lifecycle: "sent" | "abandoned") => void;
|
clear: (lifecycle: "sent" | "abandoned") => void;
|
||||||
isHydrated: boolean;
|
isHydrated: boolean;
|
||||||
composerState: DraftComposerState | null;
|
composerState: DraftComposerState | null;
|
||||||
@@ -78,7 +75,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
|||||||
);
|
);
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
const [attachments, setAttachmentsState] = useState<UserComposerAttachment[]>([]);
|
const [attachments, setAttachmentsState] = useState<UserComposerAttachment[]>([]);
|
||||||
const [cwd, setCwd] = useState(input.initialCwd ?? "");
|
|
||||||
const [isHydrated, setIsHydrated] = useState(false);
|
const [isHydrated, setIsHydrated] = useState(false);
|
||||||
const draftGenerationRef = useRef(0);
|
const draftGenerationRef = useRef(0);
|
||||||
const hydratedGenerationRef = useRef(0);
|
const hydratedGenerationRef = useRef(0);
|
||||||
@@ -103,7 +99,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
|||||||
|
|
||||||
setText("");
|
setText("");
|
||||||
setAttachmentsState([]);
|
setAttachmentsState([]);
|
||||||
setCwd("");
|
|
||||||
setIsHydrated(true);
|
setIsHydrated(true);
|
||||||
},
|
},
|
||||||
[draftKey],
|
[draftKey],
|
||||||
@@ -117,7 +112,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
|||||||
|
|
||||||
setText("");
|
setText("");
|
||||||
setAttachmentsState([]);
|
setAttachmentsState([]);
|
||||||
setCwd(input.initialCwd ?? "");
|
|
||||||
setIsHydrated(false);
|
setIsHydrated(false);
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -125,7 +119,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
const draft = await store.hydrateDraftInput({
|
const draft = await store.hydrateDraftInput({
|
||||||
draftKey,
|
draftKey,
|
||||||
initialCwd: input.initialCwd,
|
|
||||||
});
|
});
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
return;
|
return;
|
||||||
@@ -137,7 +130,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
|||||||
if (draft) {
|
if (draft) {
|
||||||
setText(draft.text);
|
setText(draft.text);
|
||||||
setAttachmentsState(draft.attachments);
|
setAttachmentsState(draft.attachments);
|
||||||
setCwd(draft.cwd);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
hydratedGenerationRef.current = generation;
|
hydratedGenerationRef.current = generation;
|
||||||
@@ -147,7 +139,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [draftKey, input.initialCwd]);
|
}, [draftKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentGeneration = draftGenerationRef.current;
|
const currentGeneration = draftGenerationRef.current;
|
||||||
@@ -171,7 +163,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
|||||||
const isSameDraft =
|
const isSameDraft =
|
||||||
existing !== undefined &&
|
existing !== undefined &&
|
||||||
existing.text === text &&
|
existing.text === text &&
|
||||||
existing.cwd === cwd &&
|
|
||||||
areAttachmentsEqual({
|
areAttachmentsEqual({
|
||||||
left: existing.attachments,
|
left: existing.attachments,
|
||||||
right: attachments,
|
right: attachments,
|
||||||
@@ -180,7 +171,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasDraftContent({ text, attachments, cwd })) {
|
if (!hasDraftContent({ text, attachments })) {
|
||||||
if (existing) {
|
if (existing) {
|
||||||
store.clearDraftInput({ draftKey, lifecycle: "abandoned" });
|
store.clearDraftInput({ draftKey, lifecycle: "abandoned" });
|
||||||
}
|
}
|
||||||
@@ -192,10 +183,9 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
|||||||
draft: {
|
draft: {
|
||||||
text,
|
text,
|
||||||
attachments,
|
attachments,
|
||||||
cwd,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [attachments, cwd, draftKey, text]);
|
}, [attachments, draftKey, text]);
|
||||||
|
|
||||||
const lockedWorkingDir = composerOptions?.lockedWorkingDir?.trim() ?? "";
|
const lockedWorkingDir = composerOptions?.lockedWorkingDir?.trim() ?? "";
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -301,8 +291,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
|||||||
setText,
|
setText,
|
||||||
attachments,
|
attachments,
|
||||||
setAttachments,
|
setAttachments,
|
||||||
cwd,
|
|
||||||
setCwd,
|
|
||||||
clear,
|
clear,
|
||||||
isHydrated,
|
isHydrated,
|
||||||
composerState,
|
composerState,
|
||||||
|
|||||||
@@ -948,6 +948,7 @@ function ChatAgentContent({
|
|||||||
});
|
});
|
||||||
if (nonReadyView) return nonReadyView;
|
if (nonReadyView) return nonReadyView;
|
||||||
invariant(effectiveAgent, "effectiveAgent is defined when the non-ready view is absent");
|
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 (
|
return (
|
||||||
<View style={styles.root}>
|
<View style={styles.root}>
|
||||||
@@ -977,7 +978,7 @@ function ChatAgentContent({
|
|||||||
isPaneFocused={isPaneFocused}
|
isPaneFocused={isPaneFocused}
|
||||||
isArchivingCurrentAgent={isArchivingCurrentAgent}
|
isArchivingCurrentAgent={isArchivingCurrentAgent}
|
||||||
archivedAt={agentState.archivedAt}
|
archivedAt={agentState.archivedAt}
|
||||||
initialCwd={agentState.cwd ?? ""}
|
cwd={agentState.cwd}
|
||||||
isSubmitLoading={showPendingCreateSubmitLoading}
|
isSubmitLoading={showPendingCreateSubmitLoading}
|
||||||
onAttentionInputFocus={attentionController.clearOnInputFocus}
|
onAttentionInputFocus={attentionController.clearOnInputFocus}
|
||||||
onAttentionPromptSend={attentionController.clearOnPromptSend}
|
onAttentionPromptSend={attentionController.clearOnPromptSend}
|
||||||
@@ -1182,7 +1183,7 @@ function AgentComposerSection({
|
|||||||
isPaneFocused,
|
isPaneFocused,
|
||||||
isArchivingCurrentAgent,
|
isArchivingCurrentAgent,
|
||||||
archivedAt,
|
archivedAt,
|
||||||
initialCwd,
|
cwd,
|
||||||
isSubmitLoading,
|
isSubmitLoading,
|
||||||
onAttentionInputFocus,
|
onAttentionInputFocus,
|
||||||
onAttentionPromptSend,
|
onAttentionPromptSend,
|
||||||
@@ -1195,7 +1196,7 @@ function AgentComposerSection({
|
|||||||
isPaneFocused: boolean;
|
isPaneFocused: boolean;
|
||||||
isArchivingCurrentAgent: boolean;
|
isArchivingCurrentAgent: boolean;
|
||||||
archivedAt: Date | null;
|
archivedAt: Date | null;
|
||||||
initialCwd: string;
|
cwd: string;
|
||||||
isSubmitLoading: boolean;
|
isSubmitLoading: boolean;
|
||||||
onAttentionInputFocus: () => void;
|
onAttentionInputFocus: () => void;
|
||||||
onAttentionPromptSend: () => void;
|
onAttentionPromptSend: () => void;
|
||||||
@@ -1218,7 +1219,7 @@ function AgentComposerSection({
|
|||||||
agentId={agentId}
|
agentId={agentId}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
isPaneFocused={isPaneFocused}
|
isPaneFocused={isPaneFocused}
|
||||||
initialCwd={initialCwd}
|
cwd={cwd}
|
||||||
isSubmitLoading={isSubmitLoading}
|
isSubmitLoading={isSubmitLoading}
|
||||||
onAttentionInputFocus={onAttentionInputFocus}
|
onAttentionInputFocus={onAttentionInputFocus}
|
||||||
onAttentionPromptSend={onAttentionPromptSend}
|
onAttentionPromptSend={onAttentionPromptSend}
|
||||||
@@ -1233,7 +1234,7 @@ function ActiveAgentComposer({
|
|||||||
agentId,
|
agentId,
|
||||||
serverId,
|
serverId,
|
||||||
isPaneFocused,
|
isPaneFocused,
|
||||||
initialCwd,
|
cwd,
|
||||||
isSubmitLoading,
|
isSubmitLoading,
|
||||||
onAttentionInputFocus,
|
onAttentionInputFocus,
|
||||||
onAttentionPromptSend,
|
onAttentionPromptSend,
|
||||||
@@ -1244,7 +1245,7 @@ function ActiveAgentComposer({
|
|||||||
agentId: string;
|
agentId: string;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
isPaneFocused: boolean;
|
isPaneFocused: boolean;
|
||||||
initialCwd: string;
|
cwd: string;
|
||||||
isSubmitLoading: boolean;
|
isSubmitLoading: boolean;
|
||||||
onAttentionInputFocus: () => void;
|
onAttentionInputFocus: () => void;
|
||||||
onAttentionPromptSend: () => void;
|
onAttentionPromptSend: () => void;
|
||||||
@@ -1272,11 +1273,10 @@ function ActiveAgentComposer({
|
|||||||
serverId,
|
serverId,
|
||||||
agentId,
|
agentId,
|
||||||
}),
|
}),
|
||||||
initialCwd,
|
|
||||||
});
|
});
|
||||||
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
|
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
|
||||||
serverId,
|
serverId,
|
||||||
cwd: agentInputDraft.cwd,
|
cwd,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
});
|
});
|
||||||
const workspaceAttachments = useWorkspaceAttachments(workspaceAttachmentScopeKey);
|
const workspaceAttachments = useWorkspaceAttachments(workspaceAttachmentScopeKey);
|
||||||
@@ -1326,7 +1326,7 @@ function ActiveAgentComposer({
|
|||||||
workspaceAttachments={workspaceAttachments}
|
workspaceAttachments={workspaceAttachments}
|
||||||
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
|
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
|
||||||
onChangeAttachments={agentInputDraft.setAttachments}
|
onChangeAttachments={agentInputDraft.setAttachments}
|
||||||
cwd={agentInputDraft.cwd}
|
cwd={cwd}
|
||||||
clearDraft={agentInputDraft.clear}
|
clearDraft={agentInputDraft.clear}
|
||||||
autoFocus={isPaneFocused}
|
autoFocus={isPaneFocused}
|
||||||
isSubmitLoading={isSubmitLoading}
|
isSubmitLoading={isSubmitLoading}
|
||||||
|
|||||||
@@ -353,7 +353,6 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void {
|
|||||||
attachments: attachments.filter(
|
attachments: attachments.filter(
|
||||||
(attachment): attachment is UserComposerAttachment => attachment.kind !== "review",
|
(attachment): attachment is UserComposerAttachment => attachment.kind !== "review",
|
||||||
),
|
),
|
||||||
cwd: workspaceDirectory,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
useWorkspaceDraftSubmissionStore.getState().setPending({
|
useWorkspaceDraftSubmissionStore.getState().setPending({
|
||||||
@@ -421,7 +420,6 @@ export function NewWorkspaceScreen({
|
|||||||
const draftKey = `new-workspace:${serverId}:${sourceDirectory}`;
|
const draftKey = `new-workspace:${serverId}:${sourceDirectory}`;
|
||||||
const chatDraft = useAgentInputDraft({
|
const chatDraft = useAgentInputDraft({
|
||||||
draftKey,
|
draftKey,
|
||||||
initialCwd: sourceDirectory,
|
|
||||||
composer: buildComposerConfig({
|
composer: buildComposerConfig({
|
||||||
serverId,
|
serverId,
|
||||||
isConnected,
|
isConnected,
|
||||||
@@ -751,7 +749,7 @@ export function NewWorkspaceScreen({
|
|||||||
onChangeText={chatDraft.setText}
|
onChangeText={chatDraft.setText}
|
||||||
attachments={chatDraft.attachments}
|
attachments={chatDraft.attachments}
|
||||||
onChangeAttachments={chatDraft.setAttachments}
|
onChangeAttachments={chatDraft.setAttachments}
|
||||||
cwd={chatDraft.cwd}
|
cwd={sourceDirectory}
|
||||||
clearDraft={handleClearDraft}
|
clearDraft={handleClearDraft}
|
||||||
autoFocus
|
autoFocus
|
||||||
commandDraftConfig={composerState?.commandDraftConfig}
|
commandDraftConfig={composerState?.commandDraftConfig}
|
||||||
|
|||||||
@@ -310,7 +310,6 @@ export function WorkspaceDraftAgentTab({
|
|||||||
);
|
);
|
||||||
const draftInput = useAgentInputDraft({
|
const draftInput = useAgentInputDraft({
|
||||||
draftKey: draftStoreKey,
|
draftKey: draftStoreKey,
|
||||||
initialCwd: workspaceDirectory ?? "",
|
|
||||||
composer: {
|
composer: {
|
||||||
initialServerId: serverId,
|
initialServerId: serverId,
|
||||||
initialValues: workspaceDirectory ? { workingDir: workspaceDirectory } : undefined,
|
initialValues: workspaceDirectory ? { workingDir: workspaceDirectory } : undefined,
|
||||||
@@ -338,7 +337,7 @@ export function WorkspaceDraftAgentTab({
|
|||||||
const isCompact = useIsCompactFormFactor();
|
const isCompact = useIsCompactFormFactor();
|
||||||
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
|
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
|
||||||
serverId,
|
serverId,
|
||||||
cwd: draftInput.cwd,
|
cwd: composerState.workingDir,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
});
|
});
|
||||||
const workspaceAttachments = useWorkspaceAttachments(workspaceAttachmentScopeKey);
|
const workspaceAttachments = useWorkspaceAttachments(workspaceAttachmentScopeKey);
|
||||||
@@ -613,7 +612,7 @@ export function WorkspaceDraftAgentTab({
|
|||||||
workspaceAttachments={workspaceAttachments}
|
workspaceAttachments={workspaceAttachments}
|
||||||
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
|
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
|
||||||
onChangeAttachments={draftInput.setAttachments}
|
onChangeAttachments={draftInput.setAttachments}
|
||||||
cwd={draftInput.cwd}
|
cwd={composerState.workingDir}
|
||||||
clearDraft={draftInput.clear}
|
clearDraft={draftInput.clear}
|
||||||
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
|
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
|
||||||
onAddImages={handleAddImagesCallback}
|
onAddImages={handleAddImagesCallback}
|
||||||
|
|||||||
@@ -7,19 +7,19 @@ describe("draft-store lifecycle", () => {
|
|||||||
const nowMs = 1_000_000;
|
const nowMs = 1_000_000;
|
||||||
const drafts = {
|
const drafts = {
|
||||||
oldSent: {
|
oldSent: {
|
||||||
input: { text: "", attachments: [], cwd: "" },
|
input: { text: "", attachments: [] },
|
||||||
lifecycle: "sent" as const,
|
lifecycle: "sent" as const,
|
||||||
updatedAt: 0,
|
updatedAt: 0,
|
||||||
version: 2,
|
version: 2,
|
||||||
},
|
},
|
||||||
recentAbandoned: {
|
recentAbandoned: {
|
||||||
input: { text: "", attachments: [], cwd: "" },
|
input: { text: "", attachments: [] },
|
||||||
lifecycle: "abandoned" as const,
|
lifecycle: "abandoned" as const,
|
||||||
updatedAt: nowMs + 2 * 60 * 1000,
|
updatedAt: nowMs + 2 * 60 * 1000,
|
||||||
version: 2,
|
version: 2,
|
||||||
},
|
},
|
||||||
active: {
|
active: {
|
||||||
input: { text: "a", attachments: [], cwd: "/repo" },
|
input: { text: "a", attachments: [] },
|
||||||
lifecycle: "active" as const,
|
lifecycle: "active" as const,
|
||||||
updatedAt: 0,
|
updatedAt: 0,
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -53,7 +53,6 @@ describe("draft-store lifecycle", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
cwd: "/repo",
|
|
||||||
},
|
},
|
||||||
lifecycle: "active",
|
lifecycle: "active",
|
||||||
updatedAt: 1,
|
updatedAt: 1,
|
||||||
@@ -64,7 +63,7 @@ describe("draft-store lifecycle", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(cleared).toEqual({
|
expect(cleared).toEqual({
|
||||||
input: { text: "", attachments: [], cwd: "" },
|
input: { text: "", attachments: [] },
|
||||||
lifecycle: "sent",
|
lifecycle: "sent",
|
||||||
updatedAt: 2,
|
updatedAt: 2,
|
||||||
version: 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 = {
|
const original = {
|
||||||
drafts: {
|
drafts: {
|
||||||
"agent:server:agent": {
|
"agent:server:agent": {
|
||||||
@@ -123,6 +122,21 @@ describe("draft-store migration", () => {
|
|||||||
const twice = await __draftStoreTestUtils.migratePersistedState(once);
|
const twice = await __draftStoreTestUtils.migratePersistedState(once);
|
||||||
|
|
||||||
expect(twice).toEqual(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 () => {
|
it("rejects workspace review attachments from migrated draft attachments", async () => {
|
||||||
@@ -132,7 +146,6 @@ describe("draft-store migration", () => {
|
|||||||
input: {
|
input: {
|
||||||
text: "hello",
|
text: "hello",
|
||||||
attachments: [workspaceReviewAttachment()],
|
attachments: [workspaceReviewAttachment()],
|
||||||
cwd: "/repo",
|
|
||||||
},
|
},
|
||||||
lifecycle: "active",
|
lifecycle: "active",
|
||||||
updatedAt: 1700000000001,
|
updatedAt: 1700000000001,
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ type PersistedDraftImage = AttachmentMetadata | LegacyDraftImage;
|
|||||||
export interface DraftInput {
|
export interface DraftInput {
|
||||||
text: string;
|
text: string;
|
||||||
attachments: UserComposerAttachment[];
|
attachments: UserComposerAttachment[];
|
||||||
cwd: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DraftLifecycleState = "active" | "abandoned" | "sent";
|
export type DraftLifecycleState = "active" | "abandoned" | "sent";
|
||||||
@@ -45,10 +44,7 @@ interface DraftStoreState {
|
|||||||
|
|
||||||
interface DraftStoreActions {
|
interface DraftStoreActions {
|
||||||
getDraftInput: (draftKey: string) => DraftInput | undefined;
|
getDraftInput: (draftKey: string) => DraftInput | undefined;
|
||||||
hydrateDraftInput: (input: {
|
hydrateDraftInput: (input: { draftKey: string }) => Promise<DraftInput | undefined>;
|
||||||
draftKey: string;
|
|
||||||
initialCwd?: string;
|
|
||||||
}) => Promise<DraftInput | undefined>;
|
|
||||||
saveDraftInput: (input: { draftKey: string; draft: DraftInput }) => void;
|
saveDraftInput: (input: { draftKey: string; draft: DraftInput }) => void;
|
||||||
markDraftLifecycle: (input: { draftKey: string; lifecycle: DraftLifecycleState }) => void;
|
markDraftLifecycle: (input: { draftKey: string; lifecycle: DraftLifecycleState }) => void;
|
||||||
clearDraftInput: (input: {
|
clearDraftInput: (input: {
|
||||||
@@ -76,7 +72,6 @@ function createDraftRecord(input: {
|
|||||||
input: {
|
input: {
|
||||||
text: input.draft.text,
|
text: input.draft.text,
|
||||||
attachments: input.draft.attachments.map(normalizeComposerAttachment),
|
attachments: input.draft.attachments.map(normalizeComposerAttachment),
|
||||||
cwd: input.draft.cwd,
|
|
||||||
},
|
},
|
||||||
lifecycle: input.lifecycle,
|
lifecycle: input.lifecycle,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
@@ -181,11 +176,11 @@ function isCanonicalDraftInput(value: unknown): value is CanonicalDraftInput {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const input = value as Record<string, unknown>;
|
const input = value as Record<string, unknown>;
|
||||||
|
// COMPAT(draft-cwd): accept legacy persisted drafts that include cwd. Stop accepting after 2026-11-09.
|
||||||
return (
|
return (
|
||||||
typeof input.text === "string" &&
|
typeof input.text === "string" &&
|
||||||
Array.isArray(input.attachments) &&
|
Array.isArray(input.attachments) &&
|
||||||
input.attachments.every(isUserComposerAttachment) &&
|
input.attachments.every(isUserComposerAttachment)
|
||||||
typeof input.cwd === "string"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,7 +197,6 @@ function toDraftInputIfReady(record: DraftRecord | null | undefined): DraftInput
|
|||||||
return {
|
return {
|
||||||
text: record.input.text,
|
text: record.input.text,
|
||||||
attachments: record.input.attachments.map(normalizeComposerAttachment),
|
attachments: record.input.attachments.map(normalizeComposerAttachment),
|
||||||
cwd: record.input.cwd,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,7 +256,7 @@ function applyClearDraftRecord(input: {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...input.record,
|
...input.record,
|
||||||
input: { text: "", attachments: [], cwd: "" },
|
input: { text: "", attachments: [] },
|
||||||
lifecycle: input.lifecycle,
|
lifecycle: input.lifecycle,
|
||||||
updatedAt: input.nowMs,
|
updatedAt: input.nowMs,
|
||||||
version: input.record.version + 1,
|
version: input.record.version + 1,
|
||||||
@@ -417,10 +411,7 @@ async function migrateLegacyImages(
|
|||||||
return migrated.filter((entry): entry is AttachmentMetadata => entry !== null);
|
return migrated.filter((entry): entry is AttachmentMetadata => entry !== null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function migrateDraftInput(input: {
|
async function migrateDraftInput(input: { rawInput: unknown }): Promise<CanonicalDraftInput> {
|
||||||
rawInput: unknown;
|
|
||||||
initialCwd?: string;
|
|
||||||
}): Promise<CanonicalDraftInput> {
|
|
||||||
const rawInput =
|
const rawInput =
|
||||||
input.rawInput && typeof input.rawInput === "object"
|
input.rawInput && typeof input.rawInput === "object"
|
||||||
? (input.rawInput as Record<string, unknown>)
|
? (input.rawInput as Record<string, unknown>)
|
||||||
@@ -440,7 +431,6 @@ async function migrateDraftInput(input: {
|
|||||||
return {
|
return {
|
||||||
text: typeof rawInput.text === "string" ? rawInput.text : "",
|
text: typeof rawInput.text === "string" ? rawInput.text : "",
|
||||||
attachments: [...attachments, ...legacyImagesToAttachments(migratedImages)],
|
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);
|
return toDraftInputIfReady(record);
|
||||||
},
|
},
|
||||||
|
|
||||||
hydrateDraftInput: async ({ draftKey, initialCwd }) => {
|
hydrateDraftInput: async ({ draftKey }) => {
|
||||||
const current = get().drafts[draftKey];
|
const current = get().drafts[draftKey];
|
||||||
if (!current) {
|
if (!current) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -520,7 +510,6 @@ export const useDraftStore = create<DraftStore>()(
|
|||||||
|
|
||||||
const migratedDraft = await migrateDraftInput({
|
const migratedDraft = await migrateDraftInput({
|
||||||
rawInput: current.input,
|
rawInput: current.input,
|
||||||
initialCwd,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user