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:
Mohamed Boudra
2026-06-29 18:29:59 +02:00
committed by GitHub
parent e63a971968
commit 85acaceb16
54 changed files with 2364 additions and 312 deletions
@@ -0,0 +1,70 @@
import type { ComposerAttachment, WorkspaceComposerAttachment } from "@/attachments/types";
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
import { isPullRequestContextAttachment } from "@/attachments/workspace-attachment-utils";
export function getAttachmentKey(attachment: WorkspaceComposerAttachment): string {
if (attachment.kind === "browser_element") {
return JSON.stringify({
type: "browser_element",
url: attachment.attachment.url,
selector: attachment.attachment.selector,
tag: attachment.attachment.tag,
text: attachment.attachment.text,
html: attachment.attachment.outerHTML,
});
}
if (isPullRequestContextAttachment(attachment)) {
return JSON.stringify({
kind: attachment.kind,
id: attachment.id,
});
}
if (attachment.kind === "chat_history") {
return JSON.stringify({
kind: attachment.kind,
id: attachment.id,
});
}
return JSON.stringify({
type: "review",
cwd: attachment.attachment.cwd,
mode: attachment.attachment.mode,
baseRef: attachment.attachment.baseRef ?? null,
reviewDraftKey: attachment.reviewDraftKey,
comments: attachment.attachment.comments.map((comment) => ({
filePath: comment.filePath,
side: comment.side,
lineNumber: comment.lineNumber,
body: comment.body,
})),
});
}
export function removeWorkspaceAttachmentsMatching(selectedKey: string): void {
const { attachmentsByScope, setWorkspaceAttachments } = useWorkspaceAttachmentsStore.getState();
for (const [scopeKey, attachments] of Object.entries(attachmentsByScope)) {
const nextAttachments = attachments.filter(
(attachment) => getAttachmentKey(attachment) !== selectedKey,
);
if (nextAttachments.length !== attachments.length) {
setWorkspaceAttachments({ scopeKey, attachments: nextAttachments });
}
}
}
function isSentContextAttachment(
attachment: ComposerAttachment,
): attachment is WorkspaceComposerAttachment {
return (
attachment.kind === "browser_element" ||
attachment.kind === "chat_history" ||
isPullRequestContextAttachment(attachment)
);
}
export function removeSentContextAttachments(attachments: readonly ComposerAttachment[]): void {
const sentContextKeys = attachments.filter(isSentContextAttachment).map(getAttachmentKey);
for (const key of sentContextKeys) {
removeWorkspaceAttachmentsMatching(key);
}
}
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import type { WorkspaceComposerAttachment } from "@/attachments/types";
import {
buildDraftWorkspaceAttachmentScopeKey,
resetWorkspaceAttachmentsStore,
useWorkspaceAttachmentsStore,
} from "@/attachments/workspace-attachments-store";
import { removeSentContextAttachments } from "./workspace-cleanup";
function chatHistoryAttachment(): WorkspaceComposerAttachment {
return {
kind: "chat_history",
id: "chat_history:draft-1",
attachment: {
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
title: "Chat history",
text: "Previous chat.",
},
source: {
serverId: "local",
agentId: "agent-1",
},
};
}
function pullRequestContextAttachment(): WorkspaceComposerAttachment {
return {
kind: "github.pull_request_comment",
id: "comment-1",
title: "Comment",
text: "Please check this.",
};
}
function browserElementAttachment(): WorkspaceComposerAttachment {
return {
kind: "browser_element",
attachment: {
url: "https://example.com",
selector: "button.primary",
tag: "button",
text: "Click me",
outerHTML: '<button class="primary">Click me</button>',
computedStyles: {},
boundingRect: { x: 0, y: 0, width: 100, height: 40 },
reactSource: null,
parentChain: [],
children: [],
formatted: "button.primary\nClick me",
},
};
}
describe("workspace composer attachment cleanup", () => {
it("clears sent scoped context attachments from their stores", () => {
resetWorkspaceAttachmentsStore();
const scopeKey = buildDraftWorkspaceAttachmentScopeKey("draft-1");
const chatHistory = chatHistoryAttachment();
const pullRequestContext = pullRequestContextAttachment();
const browserElement = browserElementAttachment();
useWorkspaceAttachmentsStore.getState().setWorkspaceAttachments({
scopeKey,
attachments: [chatHistory, pullRequestContext, browserElement],
});
removeSentContextAttachments([chatHistory, pullRequestContext, browserElement]);
expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[scopeKey]).toBeUndefined();
});
});
@@ -1,25 +1,25 @@
import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import { withUnistyles } from "react-native-unistyles";
import { FileText, MessageSquareCode, MousePointer2 } from "lucide-react-native";
import type {
ComposerAttachment,
UserComposerAttachment,
WorkspaceComposerAttachment,
} from "@/attachments/types";
import { getWorkspaceAttachmentPillContent } from "@/attachments/attachment-pill-content";
import { AttachmentLabel, AttachmentPill } from "@/components/attachment-pill";
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
import {
isWorkspaceAttachment,
isPullRequestContextAttachment,
userAttachmentsOnly,
workspaceAttachmentToSubmitAttachment,
} from "@/attachments/workspace-attachment-utils";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import {
getAttachmentKey,
removeSentContextAttachments,
removeWorkspaceAttachmentsMatching,
} from "./workspace-cleanup";
import { useClearReviewDraft } from "@/review/store";
type TranslationFn = ReturnType<typeof useTranslation>["t"];
interface WorkspaceAttachmentBindingInput {
normalAttachments: UserComposerAttachment[];
workspaceAttachments: readonly WorkspaceComposerAttachment[];
@@ -50,97 +50,9 @@ interface ComposerWorkspaceAttachmentBinding {
resetSuppression: () => void;
}
function getAttachmentKey(attachment: WorkspaceComposerAttachment): string {
if (attachment.kind === "browser_element") {
return JSON.stringify({
type: "browser_element",
url: attachment.attachment.url,
selector: attachment.attachment.selector,
tag: attachment.attachment.tag,
text: attachment.attachment.text,
html: attachment.attachment.outerHTML,
});
}
if (isPullRequestContextAttachment(attachment)) {
return JSON.stringify({
kind: attachment.kind,
id: attachment.id,
});
}
return JSON.stringify({
type: "review",
cwd: attachment.attachment.cwd,
mode: attachment.attachment.mode,
baseRef: attachment.attachment.baseRef ?? null,
reviewDraftKey: attachment.reviewDraftKey,
comments: attachment.attachment.comments.map((comment) => ({
filePath: comment.filePath,
side: comment.side,
lineNumber: comment.lineNumber,
body: comment.body,
})),
});
}
function removeWorkspaceAttachmentsMatching(selectedKey: string): void {
const { attachmentsByScope, setWorkspaceAttachments } = useWorkspaceAttachmentsStore.getState();
for (const [scopeKey, attachments] of Object.entries(attachmentsByScope)) {
const nextAttachments = attachments.filter(
(attachment) => getAttachmentKey(attachment) !== selectedKey,
);
if (nextAttachments.length !== attachments.length) {
setWorkspaceAttachments({ scopeKey, attachments: nextAttachments });
}
}
}
function removeSentContextAttachments(attachments: readonly ComposerAttachment[]): void {
const sentContextKeys = attachments.filter(isPullRequestContextAttachment).map(getAttachmentKey);
for (const key of sentContextKeys) {
removeWorkspaceAttachmentsMatching(key);
}
}
function getContextSourceLabel(attachment: WorkspaceComposerAttachment): string {
if (attachment.kind === "github.pull_request_check") {
return "Check logs";
}
if (attachment.kind === "github.pull_request_comment") {
return "Comment";
}
return "Review";
}
interface PillContent {
title: string;
subtitle: string;
}
function getPillContent(attachment: WorkspaceComposerAttachment, t: TranslationFn): PillContent {
if (attachment.kind === "browser_element") {
return {
title: attachment.attachment.tag,
subtitle: t("composer.attachments.element"),
};
}
if (isPullRequestContextAttachment(attachment)) {
return {
title: attachment.title,
subtitle: getContextSourceLabel(attachment),
};
}
return {
title: t("message.attachments.review"),
subtitle:
attachment.commentCount === 1
? t("message.attachments.commentsOne")
: t("message.attachments.commentsMany", { count: attachment.commentCount }),
};
}
function getOpenAccessibilityLabel(
attachment: WorkspaceComposerAttachment,
t: TranslationFn,
t: ReturnType<typeof useTranslation>["t"],
): string {
if (attachment.kind === "browser_element") {
return t("composer.attachments.openBrowserElement");
@@ -148,12 +60,15 @@ function getOpenAccessibilityLabel(
if (isPullRequestContextAttachment(attachment)) {
return "Open context attachment";
}
if (attachment.kind === "chat_history") {
return "Open chat history attachment";
}
return t("composer.attachments.openReview");
}
function getRemoveAccessibilityLabel(
attachment: WorkspaceComposerAttachment,
t: TranslationFn,
t: ReturnType<typeof useTranslation>["t"],
): string {
if (attachment.kind === "browser_element") {
return t("composer.attachments.removeBrowserElement");
@@ -161,17 +76,17 @@ function getRemoveAccessibilityLabel(
if (isPullRequestContextAttachment(attachment)) {
return "Remove context attachment";
}
if (attachment.kind === "chat_history") {
return "Remove chat history attachment";
}
return t("composer.attachments.removeReview");
}
function renderPillIcon(attachment: WorkspaceComposerAttachment): ReactElement {
if (attachment.kind === "browser_element") {
return <ThemedMousePointer2 size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />;
function getPillTestID(attachment: WorkspaceComposerAttachment): string {
if (attachment.kind === "chat_history") {
return "composer-chat-history-attachment-pill";
}
if (isPullRequestContextAttachment(attachment)) {
return <ThemedFileText size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />;
}
return <ThemedMessageSquareCode size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />;
return "composer-review-attachment-pill";
}
function renderPill(args: RenderWorkspaceAttachmentPillArgs): ReactElement {
@@ -249,7 +164,11 @@ function useWorkspaceAttachmentBinding({
({ selectedAttachments: current, index }: RemoveWorkspaceAttachmentInput) => {
const selected = current[index];
if (isWorkspaceAttachment(selected)) {
if (selected.kind === "browser_element" || isPullRequestContextAttachment(selected)) {
if (
selected.kind === "browser_element" ||
selected.kind === "chat_history" ||
isPullRequestContextAttachment(selected)
) {
const selectedKey = getAttachmentKey(selected);
removeWorkspaceAttachmentsMatching(selectedKey);
return true;
@@ -323,7 +242,7 @@ function WorkspaceAttachmentPill({
onRemove,
}: WorkspaceAttachmentPillProps) {
const { t } = useTranslation();
const content = getPillContent(attachment, t);
const content = getWorkspaceAttachmentPillContent(attachment, t);
const handleOpen = useCallback(() => {
onOpen(attachment);
}, [onOpen, attachment]);
@@ -332,18 +251,14 @@ function WorkspaceAttachmentPill({
}, [onRemove, index]);
return (
<AttachmentPill
testID="composer-review-attachment-pill"
testID={getPillTestID(attachment)}
onOpen={handleOpen}
onRemove={handleRemove}
openAccessibilityLabel={getOpenAccessibilityLabel(attachment, t)}
removeAccessibilityLabel={getRemoveAccessibilityLabel(attachment, t)}
disabled={disabled}
>
<AttachmentLabel
icon={renderPillIcon(attachment)}
title={content.title}
subtitle={content.subtitle}
/>
<AttachmentLabel icon={content.icon} title={content.title} subtitle={content.subtitle} />
</AttachmentPill>
);
}
@@ -355,8 +270,3 @@ export const composerWorkspaceAttachment = {
userAttachmentsOnly,
useBinding: useWorkspaceAttachmentBinding,
};
const ThemedMousePointer2 = withUnistyles(MousePointer2);
const ThemedMessageSquareCode = withUnistyles(MessageSquareCode);
const ThemedFileText = withUnistyles(FileText);
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
@@ -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}
+6 -2
View File
@@ -103,6 +103,7 @@ import type {
} from "@/attachments/types";
import type { PickedFile } from "@/attachments/picked-file";
import { composerWorkspaceAttachment } from "@/composer/attachments/workspace";
import { useWorkspaceAttachmentsForScopes } from "@/attachments/workspace-attachments-store";
import { droppedItemsToPickedFiles } from "@/composer/attachments/drop";
import { getFileTypeLabel } from "@/attachments/file-types";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
@@ -121,6 +122,8 @@ type AttachmentListUpdater =
| UserComposerAttachment[]
| ((prev: UserComposerAttachment[]) => UserComposerAttachment[]);
const EMPTY_ATTACHMENT_SCOPE_KEYS: readonly string[] = [];
function noop() {}
const noopCallback = () => {};
@@ -759,7 +762,7 @@ interface ComposerProps {
value: string;
onChangeText: (text: string) => void;
attachments: UserComposerAttachment[];
workspaceAttachments?: readonly WorkspaceComposerAttachment[];
attachmentScopeKeys?: readonly string[];
onOpenWorkspaceAttachment?: (attachment: WorkspaceComposerAttachment) => void;
onChangeAttachments: (updater: AttachmentListUpdater) => void;
cwd: string;
@@ -971,7 +974,7 @@ export function Composer({
value,
onChangeText,
attachments,
workspaceAttachments = [],
attachmentScopeKeys = EMPTY_ATTACHMENT_SCOPE_KEYS,
onOpenWorkspaceAttachment,
onChangeAttachments,
cwd,
@@ -1026,6 +1029,7 @@ export function Composer({
const messagePlaceholder = resolveMessagePlaceholder(isDesktopLayout, t);
const userInput = value;
const setUserInput = onChangeText;
const workspaceAttachments = useWorkspaceAttachmentsForScopes(attachmentScopeKeys);
const {
selectedAttachments,
buildOutgoingAttachments,