Fork assistant turns into new drafts (#1788)
* feat(chat): fork assistant turns into new drafts Builds chat-history attachments on the daemon and routes them into draft composers so users can continue an assistant turn in an existing workspace tab or New Workspace. * fix(chat): clean up fork draft handling * fix(chat): address fork draft review feedback * fix(server): build fork context from bounded timeline * fix(app): restore assistant turn footer in streams * fix(app): preserve fork draft setup * fix(app): tighten assistant fork footers * fix(app): lock active fork draft handoff * fix(app): preserve attachment-only draft retries * fix(app): unify composer attachment scopes * fix(app): align chat history attachment labels * fix(app): centralize attachment pill content * fix(app): trim fork context for workspace naming
This commit is contained in:
@@ -87,4 +87,63 @@ describe("useDraftAgentCreateFlow", () => {
|
||||
});
|
||||
expect(onCreateSuccess).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("allows retrying an empty prompt when the draft still has context attachments", async () => {
|
||||
const attachment = {
|
||||
kind: "chat_history",
|
||||
id: "chat-history-1",
|
||||
attachment: {
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
contextKind: "chat_history",
|
||||
title: "Chat history",
|
||||
text: "Previous conversation",
|
||||
},
|
||||
source: {
|
||||
serverId: "server-1",
|
||||
agentId: "agent-source",
|
||||
},
|
||||
} as const;
|
||||
const createRequest = vi.fn(async () => ({
|
||||
agentId: "agent-1",
|
||||
result: { id: "agent-1" },
|
||||
}));
|
||||
const onCreateSuccess = vi.fn();
|
||||
const validateBeforeSubmit = vi.fn(() => null);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDraftAgentCreateFlow({
|
||||
draftId: "draft-1",
|
||||
getPendingServerId: () => "server-1",
|
||||
buildDraftAgent: (currentAttempt) => ({ currentAttempt }),
|
||||
createRequest,
|
||||
onCreateSuccess,
|
||||
validateBeforeSubmit,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleCreateFromInput({
|
||||
text: " ",
|
||||
attachments: [attachment],
|
||||
cwd: "/repo",
|
||||
});
|
||||
});
|
||||
|
||||
expect(validateBeforeSubmit).toHaveBeenCalledWith({
|
||||
text: "",
|
||||
attachments: [attachment],
|
||||
cwd: "/repo",
|
||||
});
|
||||
expect(createRequest).toHaveBeenCalledWith({
|
||||
attempt: expect.objectContaining({
|
||||
text: "",
|
||||
attachments: [attachment.attachment],
|
||||
}),
|
||||
text: "",
|
||||
attachments: [attachment.attachment],
|
||||
cwd: "/repo",
|
||||
});
|
||||
expect(onCreateSuccess).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -241,7 +241,8 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
const images = wirePayload.images;
|
||||
|
||||
const trimmedPrompt = text.trim();
|
||||
if (!trimmedPrompt && !allowEmptyText) {
|
||||
const hasAttachmentContent = images.length > 0 || wirePayload.attachments.length > 0;
|
||||
if (!trimmedPrompt && !hasAttachmentContent && !allowEmptyText) {
|
||||
const error = new Error(t("composer.errors.initialPromptRequired"));
|
||||
dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
|
||||
throw error;
|
||||
|
||||
@@ -5,6 +5,13 @@ export interface WorkspaceDraftAutoSubmitConfig {
|
||||
model: string | null;
|
||||
}
|
||||
|
||||
export function shouldAllowEmptyDraftText(input: {
|
||||
allowsEmptyAutoSubmit: boolean;
|
||||
attachments: readonly unknown[];
|
||||
}): boolean {
|
||||
return input.allowsEmptyAutoSubmit || input.attachments.length > 0;
|
||||
}
|
||||
|
||||
export function validateDraftSubmission(input: {
|
||||
text: string;
|
||||
allowsEmptyAutoSubmit: boolean;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { validateDraftSubmission } from "./workspace-tab-core";
|
||||
import { shouldAllowEmptyDraftText, validateDraftSubmission } from "./workspace-tab-core";
|
||||
|
||||
const baseComposerState = {
|
||||
providerDefinitions: [{ id: "codewhale" }],
|
||||
@@ -49,3 +49,23 @@ describe("workspace draft agent model validation", () => {
|
||||
).toBe("No model is available for the selected provider");
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace draft empty text readiness", () => {
|
||||
test("allows attachment-only retries after a fork draft create fails", () => {
|
||||
expect(
|
||||
shouldAllowEmptyDraftText({
|
||||
allowsEmptyAutoSubmit: false,
|
||||
attachments: [{ kind: "chat_history" }],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("still rejects empty drafts with no auto-submit and no attachments", () => {
|
||||
expect(
|
||||
shouldAllowEmptyDraftText({
|
||||
allowsEmptyAutoSubmit: false,
|
||||
attachments: [],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,14 +27,18 @@ import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submi
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
|
||||
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
|
||||
import { validateDraftSubmission } from "@/composer/draft/workspace-tab-core";
|
||||
import {
|
||||
shouldAllowEmptyDraftText,
|
||||
validateDraftSubmission,
|
||||
} from "@/composer/draft/workspace-tab-core";
|
||||
import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
||||
import {
|
||||
useWorkspaceAttachments,
|
||||
useDraftWorkspaceAttachmentScopeKey,
|
||||
useWorkspaceAttachmentScopeKey,
|
||||
useWorkspaceAttachmentsStore,
|
||||
} from "@/attachments/workspace-attachments-store";
|
||||
import type { UserMessageImageAttachment } from "@/types/stream";
|
||||
import {
|
||||
@@ -118,6 +122,7 @@ async function submitDraftCreateRequest(input: {
|
||||
text: string;
|
||||
images?: UserMessageImageAttachment[];
|
||||
attachments?: unknown;
|
||||
cwd: string;
|
||||
client: DaemonClient | null;
|
||||
workspaceDirectory: string | null;
|
||||
workspaceId: string | null;
|
||||
@@ -138,6 +143,7 @@ async function submitDraftCreateRequest(input: {
|
||||
text,
|
||||
images,
|
||||
attachments,
|
||||
cwd,
|
||||
client,
|
||||
workspaceDirectory,
|
||||
workspaceId,
|
||||
@@ -162,7 +168,7 @@ async function submitDraftCreateRequest(input: {
|
||||
});
|
||||
const config = buildWorkspaceDraftAgentConfig({
|
||||
provider,
|
||||
cwd: workspaceDirectory,
|
||||
cwd,
|
||||
...modeIdOverride,
|
||||
model: autoSubmitConfig?.model ?? (composerState.effectiveModelId || undefined),
|
||||
thinkingOptionId:
|
||||
@@ -400,7 +406,14 @@ export function WorkspaceDraftAgentTab({
|
||||
cwd: composerState.workingDir,
|
||||
workspaceId,
|
||||
});
|
||||
const workspaceAttachments = useWorkspaceAttachments(workspaceAttachmentScopeKey);
|
||||
const draftAttachmentScopeKey = useDraftWorkspaceAttachmentScopeKey(draftId);
|
||||
const attachmentScopeKeys = useMemo(
|
||||
() => [draftAttachmentScopeKey, workspaceAttachmentScopeKey].filter(Boolean),
|
||||
[draftAttachmentScopeKey, workspaceAttachmentScopeKey],
|
||||
);
|
||||
const clearWorkspaceAttachments = useWorkspaceAttachmentsStore(
|
||||
(state) => state.clearWorkspaceAttachments,
|
||||
);
|
||||
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
|
||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||
const handleOpenWorkspaceAttachment = useCallback(
|
||||
@@ -437,15 +450,20 @@ export function WorkspaceDraftAgentTab({
|
||||
getPendingServerId: () => serverId,
|
||||
initialAttempt: initialCreateAttempt,
|
||||
allowEmptyText: allowsEmptyAutoSubmit,
|
||||
validateBeforeSubmit: ({ text }) =>
|
||||
validateDraftSubmission({
|
||||
text,
|
||||
validateBeforeSubmit: ({ text, attachments }) => {
|
||||
const allowsEmptyDraftText = shouldAllowEmptyDraftText({
|
||||
allowsEmptyAutoSubmit,
|
||||
attachments,
|
||||
});
|
||||
return validateDraftSubmission({
|
||||
text,
|
||||
allowsEmptyAutoSubmit: allowsEmptyDraftText,
|
||||
composerState,
|
||||
autoSubmitConfig,
|
||||
workspaceDirectory: draftWorkingDirectory,
|
||||
hasClient: Boolean(client),
|
||||
}),
|
||||
});
|
||||
},
|
||||
onBeforeSubmit: async () => {
|
||||
await composerState.persistFormPreferences();
|
||||
if (isWeb) {
|
||||
@@ -463,12 +481,13 @@ export function WorkspaceDraftAgentTab({
|
||||
composerState,
|
||||
selectModelMessage: t("workspaceSetup.errors.selectModel"),
|
||||
}),
|
||||
createRequest: async ({ attempt, text, images, attachments }) =>
|
||||
createRequest: async ({ attempt, text, images, attachments, cwd }) =>
|
||||
submitDraftCreateRequest({
|
||||
attempt,
|
||||
text,
|
||||
images,
|
||||
attachments,
|
||||
cwd,
|
||||
client,
|
||||
workspaceDirectory: draftWorkingDirectory,
|
||||
workspaceId: workspaceFields?.id ?? null,
|
||||
@@ -479,6 +498,8 @@ export function WorkspaceDraftAgentTab({
|
||||
}),
|
||||
onCreateSuccess: ({ result }) => {
|
||||
clearDraftInput("sent");
|
||||
clearWorkspaceAttachments({ scopeKey: draftAttachmentScopeKey });
|
||||
useWorkspaceDraftSubmissionStore.getState().clearDraftSetup({ draftId });
|
||||
onCreated(result);
|
||||
},
|
||||
});
|
||||
@@ -690,7 +711,7 @@ export function WorkspaceDraftAgentTab({
|
||||
value={draftInput.text}
|
||||
onChangeText={draftInput.setText}
|
||||
attachments={draftInput.attachments}
|
||||
workspaceAttachments={workspaceAttachments}
|
||||
attachmentScopeKeys={attachmentScopeKeys}
|
||||
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
|
||||
onChangeAttachments={draftInput.setAttachments}
|
||||
cwd={composerState.workingDir}
|
||||
|
||||
Reference in New Issue
Block a user