fix(app): clear new workspace drafts after submit
This commit is contained in:
@@ -15,6 +15,7 @@ const {
|
|||||||
mergeWorkspacesMock,
|
mergeWorkspacesMock,
|
||||||
navigateMock,
|
navigateMock,
|
||||||
saveDraftInputMock,
|
saveDraftInputMock,
|
||||||
|
clearDraftInputMock,
|
||||||
queueDraftSubmissionMock,
|
queueDraftSubmissionMock,
|
||||||
createdAgent,
|
createdAgent,
|
||||||
createdWorkspace,
|
createdWorkspace,
|
||||||
@@ -116,6 +117,7 @@ const {
|
|||||||
mergeWorkspacesMock: vi.fn(),
|
mergeWorkspacesMock: vi.fn(),
|
||||||
navigateMock: vi.fn(),
|
navigateMock: vi.fn(),
|
||||||
saveDraftInputMock: vi.fn(),
|
saveDraftInputMock: vi.fn(),
|
||||||
|
clearDraftInputMock: vi.fn(),
|
||||||
queueDraftSubmissionMock: vi.fn(),
|
queueDraftSubmissionMock: vi.fn(),
|
||||||
createdAgent,
|
createdAgent,
|
||||||
createdWorkspace,
|
createdWorkspace,
|
||||||
@@ -228,6 +230,7 @@ vi.mock("@/stores/draft-store", () => ({
|
|||||||
useDraftStore: {
|
useDraftStore: {
|
||||||
getState: () => ({
|
getState: () => ({
|
||||||
saveDraftInput: saveDraftInputMock,
|
saveDraftInput: saveDraftInputMock,
|
||||||
|
clearDraftInput: clearDraftInputMock,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -494,6 +497,7 @@ beforeEach(() => {
|
|||||||
mockClient.createPaseoWorktree.mockClear();
|
mockClient.createPaseoWorktree.mockClear();
|
||||||
mockClient.createAgent.mockClear();
|
mockClient.createAgent.mockClear();
|
||||||
saveDraftInputMock.mockClear();
|
saveDraftInputMock.mockClear();
|
||||||
|
clearDraftInputMock.mockClear();
|
||||||
queueDraftSubmissionMock.mockClear();
|
queueDraftSubmissionMock.mockClear();
|
||||||
navigateMock.mockClear();
|
navigateMock.mockClear();
|
||||||
initialAttachments.length = 0;
|
initialAttachments.length = 0;
|
||||||
@@ -646,6 +650,13 @@ describe("NewWorkspaceScreen picker payload", () => {
|
|||||||
target: { kind: "draft", draftId: "draft-new-workspace" },
|
target: { kind: "draft", draftId: "draft-new-workspace" },
|
||||||
navigationMethod: "replace",
|
navigationMethod: "replace",
|
||||||
});
|
});
|
||||||
|
expect(clearDraftInputMock).toHaveBeenCalledWith({
|
||||||
|
draftKey: "new-workspace:server:/repo",
|
||||||
|
lifecycle: "sent",
|
||||||
|
});
|
||||||
|
expect(navigateMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||||
|
clearDraftInputMock.mock.invocationCallOrder[0],
|
||||||
|
);
|
||||||
expect(document.querySelector("textarea")).toHaveProperty("disabled", true);
|
expect(document.querySelector("textarea")).toHaveProperty("disabled", true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -877,6 +888,7 @@ describe("NewWorkspaceScreen picker payload", () => {
|
|||||||
expect(queryByTestId("message-input-attach-button")).toHaveProperty("disabled", true);
|
expect(queryByTestId("message-input-attach-button")).toHaveProperty("disabled", true);
|
||||||
expect(queryByTestId("new-workspace-ref-picker-trigger")).toHaveProperty("disabled", true);
|
expect(queryByTestId("new-workspace-ref-picker-trigger")).toHaveProperty("disabled", true);
|
||||||
expect(queryByTestId("new-workspace-ref-picker-branch-dev")).toHaveProperty("disabled", true);
|
expect(queryByTestId("new-workspace-ref-picker-branch-dev")).toHaveProperty("disabled", true);
|
||||||
|
expect(clearDraftInputMock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
createWorktree.reject(new Error("Create worktree failed"));
|
createWorktree.reject(new Error("Create worktree failed"));
|
||||||
await flush();
|
await flush();
|
||||||
|
|||||||
@@ -135,8 +135,9 @@ export function NewWorkspaceScreen({
|
|||||||
const isPending = pendingAction !== null;
|
const isPending = pendingAction !== null;
|
||||||
const client = useHostRuntimeClient(serverId);
|
const client = useHostRuntimeClient(serverId);
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
|
const draftKey = `new-workspace:${serverId}:${sourceDirectory}`;
|
||||||
const chatDraft = useAgentInputDraft({
|
const chatDraft = useAgentInputDraft({
|
||||||
draftKey: `new-workspace:${serverId}:${sourceDirectory}`,
|
draftKey,
|
||||||
initialCwd: sourceDirectory,
|
initialCwd: sourceDirectory,
|
||||||
composer: {
|
composer: {
|
||||||
initialServerId: serverId || null,
|
initialServerId: serverId || null,
|
||||||
@@ -370,6 +371,7 @@ export function NewWorkspaceScreen({
|
|||||||
target: { kind: "draft", draftId },
|
target: { kind: "draft", draftId },
|
||||||
navigationMethod: "replace",
|
navigationMethod: "replace",
|
||||||
});
|
});
|
||||||
|
useDraftStore.getState().clearDraftInput({ draftKey, lifecycle: "sent" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = toErrorMessage(error);
|
const message = toErrorMessage(error);
|
||||||
setPendingAction(null);
|
setPendingAction(null);
|
||||||
@@ -377,7 +379,7 @@ export function NewWorkspaceScreen({
|
|||||||
toast.error(message);
|
toast.error(message);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[composerState, ensureWorkspace, serverId, toast],
|
[composerState, draftKey, ensureWorkspace, serverId, toast],
|
||||||
);
|
);
|
||||||
|
|
||||||
const workspaceTitle =
|
const workspaceTitle =
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
import React from "react";
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import type { ComposerAttachment } from "@/attachments/types";
|
||||||
|
import type { MessagePayload } from "@/components/message-input";
|
||||||
|
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||||
|
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
||||||
|
import type { StreamItem } from "@/types/stream";
|
||||||
|
import { WorkspaceDraftAgentTab } from "./workspace-draft-agent-tab";
|
||||||
|
|
||||||
|
const {
|
||||||
|
createAgentMock,
|
||||||
|
latestComposerText,
|
||||||
|
latestComposerDisabled,
|
||||||
|
latestStreamText,
|
||||||
|
onCreatedMock,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
createAgentMock: vi.fn(),
|
||||||
|
latestComposerText: { current: null as string | null },
|
||||||
|
latestComposerDisabled: { current: null as boolean | null },
|
||||||
|
latestStreamText: { current: null as string | null },
|
||||||
|
onCreatedMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-native-unistyles", () => ({
|
||||||
|
StyleSheet: {
|
||||||
|
create: (factory: unknown) =>
|
||||||
|
typeof factory === "function"
|
||||||
|
? factory({
|
||||||
|
colors: {
|
||||||
|
surface0: "#000",
|
||||||
|
surface2: "#222",
|
||||||
|
destructive: "#f00",
|
||||||
|
},
|
||||||
|
spacing: { 2: 8, 3: 12, 4: 16, 6: 24 },
|
||||||
|
borderRadius: { md: 6 },
|
||||||
|
fontSize: { sm: 13 },
|
||||||
|
})
|
||||||
|
: factory,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-native-safe-area-context", () => ({
|
||||||
|
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/constants/platform", () => ({
|
||||||
|
isWeb: true,
|
||||||
|
isNative: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/runtime/host-runtime", () => ({
|
||||||
|
useHostRuntimeClient: () => ({
|
||||||
|
createAgent: createAgentMock,
|
||||||
|
}),
|
||||||
|
useHostRuntimeIsConnected: () => true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/stores/session-store-hooks", () => ({
|
||||||
|
useWorkspaceExecutionAuthority: () => ({
|
||||||
|
ok: true,
|
||||||
|
authority: {
|
||||||
|
workspaceId: "workspace-1",
|
||||||
|
workspaceDirectory: "/repo/.paseo/worktrees/workspace-1",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-agent-input-draft", () => ({
|
||||||
|
useAgentInputDraft: () => {
|
||||||
|
const [text, setText] = React.useState("please review this change");
|
||||||
|
const [attachments, setAttachments] = React.useState<ComposerAttachment[]>([]);
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
setText,
|
||||||
|
attachments,
|
||||||
|
setAttachments,
|
||||||
|
cwd: "/repo/.paseo/worktrees/workspace-1",
|
||||||
|
clear: () => {
|
||||||
|
setText("");
|
||||||
|
setAttachments([]);
|
||||||
|
},
|
||||||
|
isHydrated: true,
|
||||||
|
composerState: {
|
||||||
|
providerDefinitions: [{ id: "codex", label: "Codex" }],
|
||||||
|
selectedProvider: "codex",
|
||||||
|
selectedMode: "",
|
||||||
|
modeOptions: [],
|
||||||
|
selectedModel: "gpt-5.4",
|
||||||
|
availableModels: [{ id: "gpt-5.4", label: "GPT-5.4" }],
|
||||||
|
allProviderModels: {},
|
||||||
|
isAllModelsLoading: false,
|
||||||
|
isModelLoading: false,
|
||||||
|
effectiveModelId: "gpt-5.4",
|
||||||
|
effectiveThinkingOptionId: "",
|
||||||
|
featureValues: undefined,
|
||||||
|
statusControls: {
|
||||||
|
providerDefinitions: [{ id: "codex", label: "Codex" }],
|
||||||
|
selectedProvider: "codex",
|
||||||
|
modeOptions: [],
|
||||||
|
selectedMode: "",
|
||||||
|
models: [{ id: "gpt-5.4", label: "GPT-5.4" }],
|
||||||
|
selectedModel: "gpt-5.4",
|
||||||
|
thinkingOptions: [],
|
||||||
|
selectedThinkingOptionId: "",
|
||||||
|
isModelLoading: false,
|
||||||
|
allProviderModels: {},
|
||||||
|
isAllModelsLoading: false,
|
||||||
|
features: [],
|
||||||
|
},
|
||||||
|
commandDraftConfig: undefined,
|
||||||
|
persistFormPreferences: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/utils/encode-images", () => ({
|
||||||
|
encodeImages: vi.fn(async () => []),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/composer-attachments", () => ({
|
||||||
|
splitComposerAttachmentsForSubmit: (attachments: ComposerAttachment[]) => ({
|
||||||
|
images: [],
|
||||||
|
attachments,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/composer", () => ({
|
||||||
|
Composer: ({
|
||||||
|
value,
|
||||||
|
isSubmitLoading,
|
||||||
|
}: {
|
||||||
|
onSubmitMessage: (payload: MessagePayload) => Promise<void>;
|
||||||
|
value: string;
|
||||||
|
isSubmitLoading?: boolean;
|
||||||
|
}) => {
|
||||||
|
latestComposerText.current = value;
|
||||||
|
latestComposerDisabled.current = isSubmitLoading ?? false;
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
aria-label="Message agent..."
|
||||||
|
data-testid="composer"
|
||||||
|
disabled={isSubmitLoading}
|
||||||
|
readOnly
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/agent-stream-view", () => ({
|
||||||
|
AgentStreamView: ({ streamItems }: { streamItems: StreamItem[] }) => {
|
||||||
|
latestStreamText.current =
|
||||||
|
streamItems.find((item) => item.kind === "user_message")?.text ?? null;
|
||||||
|
return <div data-testid="agent-stream-view">{latestStreamText.current}</div>;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/file-drop-zone", () => ({
|
||||||
|
FileDropZone: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function createDeferredPromise<T>() {
|
||||||
|
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||||
|
let reject!: (reason?: unknown) => void;
|
||||||
|
const promise = new Promise<T>((nextResolve, nextReject) => {
|
||||||
|
resolve = nextResolve;
|
||||||
|
reject = nextReject;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
let root: Root | null = null;
|
||||||
|
let container: HTMLElement | null = null;
|
||||||
|
let queryClient: QueryClient | null = null;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal("React", React);
|
||||||
|
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||||
|
vi.stubGlobal(
|
||||||
|
"ResizeObserver",
|
||||||
|
class ResizeObserver {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
createAgentMock.mockReset();
|
||||||
|
onCreatedMock.mockReset();
|
||||||
|
latestComposerText.current = null;
|
||||||
|
latestComposerDisabled.current = null;
|
||||||
|
latestStreamText.current = null;
|
||||||
|
useCreateFlowStore.getState().clearAll();
|
||||||
|
useWorkspaceDraftSubmissionStore.setState({ pendingByDraftId: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (root) {
|
||||||
|
act(() => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
queryClient?.clear();
|
||||||
|
root = null;
|
||||||
|
container?.remove();
|
||||||
|
container = null;
|
||||||
|
queryClient = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function flush() {
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDraftTab() {
|
||||||
|
act(() => {
|
||||||
|
root?.render(
|
||||||
|
<QueryClientProvider client={queryClient!}>
|
||||||
|
<WorkspaceDraftAgentTab
|
||||||
|
serverId="server"
|
||||||
|
workspaceId="workspace-1"
|
||||||
|
tabId="draft-1"
|
||||||
|
draftId="draft-1"
|
||||||
|
isPaneFocused={true}
|
||||||
|
onCreated={onCreatedMock}
|
||||||
|
onOpenWorkspaceFile={vi.fn()}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("WorkspaceDraftAgentTab", () => {
|
||||||
|
it("clears the composer while an auto-submitted new-workspace prompt is shown in the stream", async () => {
|
||||||
|
const createAgent = createDeferredPromise<{ id: string }>();
|
||||||
|
createAgentMock.mockImplementationOnce(async () => await createAgent.promise);
|
||||||
|
useWorkspaceDraftSubmissionStore.getState().setPending({
|
||||||
|
serverId: "server",
|
||||||
|
workspaceId: "workspace-1",
|
||||||
|
draftId: "draft-1",
|
||||||
|
text: "please review this change",
|
||||||
|
attachments: [],
|
||||||
|
cwd: "/repo/.paseo/worktrees/workspace-1",
|
||||||
|
provider: "codex",
|
||||||
|
model: "gpt-5.4",
|
||||||
|
allowEmptyText: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderDraftTab();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(createAgentMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(latestStreamText.current).toBe("please review this change");
|
||||||
|
expect(latestComposerDisabled.current).toBe(true);
|
||||||
|
expect(latestComposerText.current).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -81,6 +81,9 @@ export function WorkspaceDraftAgentTab({
|
|||||||
if (!composerState) {
|
if (!composerState) {
|
||||||
throw new Error("Workspace draft composer state is required");
|
throw new Error("Workspace draft composer state is required");
|
||||||
}
|
}
|
||||||
|
const clearDraftInput = draftInput.clear;
|
||||||
|
const setDraftText = draftInput.setText;
|
||||||
|
const setDraftAttachments = draftInput.setAttachments;
|
||||||
const pendingAutoSubmit = useWorkspaceDraftSubmissionStore((state) => {
|
const pendingAutoSubmit = useWorkspaceDraftSubmissionStore((state) => {
|
||||||
const pending = state.pendingByDraftId[draftId] ?? null;
|
const pending = state.pendingByDraftId[draftId] ?? null;
|
||||||
return pending?.serverId === serverId && pending.workspaceId === workspaceId ? pending : null;
|
return pending?.serverId === serverId && pending.workspaceId === workspaceId ? pending : null;
|
||||||
@@ -220,6 +223,7 @@ export function WorkspaceDraftAgentTab({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
onCreateSuccess: ({ result }) => {
|
onCreateSuccess: ({ result }) => {
|
||||||
|
clearDraftInput("sent");
|
||||||
onCreated(result);
|
onCreated(result);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -246,11 +250,15 @@ export function WorkspaceDraftAgentTab({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
autoSubmitKeyRef.current = submitKey;
|
autoSubmitKeyRef.current = submitKey;
|
||||||
|
setDraftText("");
|
||||||
|
setDraftAttachments([]);
|
||||||
void handleCreateFromInput({
|
void handleCreateFromInput({
|
||||||
text: submission.text,
|
text: submission.text,
|
||||||
attachments: submission.attachments,
|
attachments: submission.attachments,
|
||||||
cwd: submission.cwd,
|
cwd: submission.cwd,
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
|
setDraftText(submission.text);
|
||||||
|
setDraftAttachments(submission.attachments);
|
||||||
autoSubmitKeyRef.current = null;
|
autoSubmitKeyRef.current = null;
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
@@ -259,6 +267,8 @@ export function WorkspaceDraftAgentTab({
|
|||||||
handleCreateFromInput,
|
handleCreateFromInput,
|
||||||
isReadyForPendingAutoSubmit,
|
isReadyForPendingAutoSubmit,
|
||||||
serverId,
|
serverId,
|
||||||
|
setDraftAttachments,
|
||||||
|
setDraftText,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user