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
@@ -289,4 +289,67 @@ describe("layoutStream", () => {
expect(findLayoutItem(layout, assistant.id).completedFooter).toBeNull();
expect(footerOwners(layout)).toEqual([assistant.id]);
});
it.each(["web", "android"] as const)(
"keeps inline footer on an assistant turn with trailing tool rows before the next user on %s",
(platform) => {
const assistant = assistantMessage("a1", 2);
const tool = toolCall("tool-1", 3);
const layout = layoutFor({
platform,
tail: [userMessage("u1", 1), assistant, tool, userMessage("u2", 4)],
timingIds: [assistant.id],
});
expect(layout.auxiliaryTurnFooter).toBeNull();
expect(findLayoutItem(layout, assistant.id).completedFooter?.itemId).toBe(assistant.id);
expect(footerOwners(layout)).toEqual([assistant.id]);
},
);
it.each(["web", "android"] as const)(
"uses the latest assistant for an inline footer when a turn has multiple assistant blocks on %s",
(platform) => {
const firstAssistant = assistantMessage("a1", 2);
const firstTool = toolCall("tool-1", 3);
const latestAssistant = assistantMessage("a2", 4);
const latestTool = toolCall("tool-2", 5);
const layout = layoutFor({
platform,
tail: [
userMessage("u1", 1),
firstAssistant,
firstTool,
latestAssistant,
latestTool,
userMessage("u2", 6),
],
timingIds: [firstAssistant.id, latestAssistant.id],
});
expect(layout.auxiliaryTurnFooter).toBeNull();
expect(findLayoutItem(layout, firstAssistant.id).completedFooter).toBeNull();
expect(findLayoutItem(layout, latestAssistant.id).completedFooter?.itemId).toBe(
latestAssistant.id,
);
expect(footerOwners(layout)).toEqual([latestAssistant.id]);
},
);
it.each(["web", "android"] as const)(
"keeps bottom footer on the latest assistant turn when trailing tool rows end the turn on %s",
(platform) => {
const assistant = assistantMessage("a1", 2);
const tool = toolCall("tool-1", 3);
const layout = layoutFor({
platform,
tail: [userMessage("u1", 1), assistant, tool],
timingIds: [assistant.id],
});
expect(layout.auxiliaryTurnFooter?.itemId).toBe(assistant.id);
expect(findLayoutItem(layout, assistant.id).completedFooter).toBeNull();
expect(footerOwners(layout)).toEqual([assistant.id]);
},
);
});
+97 -11
View File
@@ -68,18 +68,48 @@ function createTurnFooterHost(input: {
};
}
function findLatestAssistantIndexInTurn(input: {
strategy: StreamStrategy;
items: StreamItem[];
startIndex: number;
}): number | null {
for (
let index = input.startIndex;
index >= 0 && index < input.items.length;
index = input.strategy.getNeighborIndex(index, "above")
) {
const item = input.items[index];
if (!item || item.kind === "user_message") {
return null;
}
if (item.kind === "assistant_message") {
return index;
}
}
return null;
}
function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost | null {
if (input.agentStatus === "running") {
return null;
}
const footerItems = input.liveHead.length > 0 ? input.liveHead : input.history;
const startIndex = input.strategy.getLatestItemIndex(footerItems);
if (startIndex === null) {
const latestIndex = input.strategy.getLatestItemIndex(footerItems);
if (latestIndex === null) {
return null;
}
const item = footerItems[startIndex];
const assistantIndex = findLatestAssistantIndexInTurn({
strategy: input.strategy,
items: footerItems,
startIndex: latestIndex,
});
if (assistantIndex === null) {
return null;
}
const item = footerItems[assistantIndex];
if (!item || item.kind !== "assistant_message") {
return null;
}
@@ -87,26 +117,79 @@ function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost |
return createTurnFooterHost({
item,
items: footerItems,
index: startIndex,
index: assistantIndex,
timingByAssistantId: input.timingByAssistantId,
});
}
function findTurnEndIndexInSegment(input: {
strategy: StreamStrategy;
items: StreamItem[];
startIndex: number;
}): number {
let endIndex = input.startIndex;
for (
let index = input.strategy.getNeighborIndex(input.startIndex, "below");
index >= 0 && index < input.items.length;
index = input.strategy.getNeighborIndex(index, "below")
) {
const item = input.items[index];
if (!item || item.kind === "user_message") {
break;
}
endIndex = index;
}
return endIndex;
}
function shouldRenderCompletedFooter(input: {
strategy: StreamStrategy;
items: StreamItem[];
index: number;
item: StreamItem;
belowItem: StreamItem | null;
agentStatus: string;
auxiliaryTurnFooter: TurnFooterHost | null;
}): boolean {
return (
input.item.kind === "assistant_message" &&
input.auxiliaryTurnFooter?.itemId !== input.item.id &&
(input.belowItem?.kind === "user_message" ||
(input.belowItem === null && input.agentStatus !== "running"))
);
if (
input.item.kind !== "assistant_message" ||
input.auxiliaryTurnFooter?.itemId === input.item.id
) {
return false;
}
if (
input.belowItem?.kind === "user_message" ||
(input.belowItem === null && input.agentStatus !== "running")
) {
return true;
}
if (!isToolSequenceItem(input.belowItem)) {
return false;
}
const sameSegmentBelowItem = input.strategy.getNeighborItem(input.items, input.index, "below");
if (sameSegmentBelowItem?.id !== input.belowItem.id) {
return false;
}
const turnEndIndex = findTurnEndIndexInSegment({
strategy: input.strategy,
items: input.items,
startIndex: input.index,
});
const assistantIndex = findLatestAssistantIndexInTurn({
strategy: input.strategy,
items: input.items,
startIndex: turnEndIndex,
});
return assistantIndex === input.index;
}
function isToolSequenceItem(item: StreamItem | null): boolean {
function isToolSequenceItem(
item: StreamItem | null,
): item is Extract<StreamItem, { kind: "tool_call" | "thought" | "todo_list" }> {
return item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list";
}
@@ -175,6 +258,9 @@ function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] {
belowItem,
});
const completedFooter = shouldRenderCompletedFooter({
strategy: input.strategy,
items: input.items,
index,
item,
belowItem,
agentStatus: input.agentStatus,
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import type { StreamItem } from "@/types/stream";
import { resolveAssistantTurnBoundaryMessageId } from "./turn-boundary";
function timestamp(seed: number): Date {
return new Date(`2026-01-01T00:00:${seed.toString().padStart(2, "0")}.000Z`);
}
function userMessage(id: string, seed: number): Extract<StreamItem, { kind: "user_message" }> {
return {
kind: "user_message",
id,
text: id,
timestamp: timestamp(seed),
};
}
function assistantMessage(
id: string,
seed: number,
messageId?: string,
): Extract<StreamItem, { kind: "assistant_message" }> {
return {
kind: "assistant_message",
id,
text: id,
timestamp: timestamp(seed),
...(messageId ? { messageId } : {}),
};
}
describe("resolveAssistantTurnBoundaryMessageId", () => {
it("uses the selected assistant message id", () => {
const selected = assistantMessage("assistant-1", 2, "msg-assistant-1");
expect(
resolveAssistantTurnBoundaryMessageId({
items: [userMessage("user-1", 1), selected],
startIndex: 1,
}),
).toBe("msg-assistant-1");
});
it("does not borrow a boundary id from another assistant in the same turn", () => {
const first = assistantMessage("assistant-1", 2, "msg-assistant-1");
const selected = assistantMessage("assistant-2", 3);
expect(
resolveAssistantTurnBoundaryMessageId({
items: [userMessage("user-1", 1), first, selected],
startIndex: 2,
}),
).toBeUndefined();
});
it("requires the selected item to be an assistant message", () => {
expect(
resolveAssistantTurnBoundaryMessageId({
items: [userMessage("user-1", 1), assistantMessage("assistant-1", 2, "msg-assistant-1")],
startIndex: 0,
}),
).toBeUndefined();
});
});
@@ -0,0 +1,13 @@
import type { StreamItem } from "@/types/stream";
export function resolveAssistantTurnBoundaryMessageId(input: {
items: readonly StreamItem[];
startIndex: number;
}): string | undefined {
const item = input.items[input.startIndex];
if (item?.kind !== "assistant_message") {
return undefined;
}
// Forking without the selected assistant's durable message id would send the wrong slice.
return item.messageId || undefined;
}
+25 -1
View File
@@ -9,7 +9,13 @@ import {
collectAssistantTurnContentForStreamRenderStrategy,
type StreamStrategy,
} from "./strategy";
import { AssistantTurnFooter, LiveElapsed, STREAM_METADATA_FONT_SIZE } from "@/components/message";
import { resolveAssistantTurnBoundaryMessageId } from "./turn-boundary";
import {
AssistantTurnFooter,
LiveElapsed,
STREAM_METADATA_FONT_SIZE,
type AssistantForkTarget,
} from "@/components/message";
import type { TurnFooterHost } from "./layout";
import { SyncedLoader } from "@/components/synced-loader";
@@ -22,17 +28,23 @@ const workingIndicatorColorMapping = (theme: Theme) => ({
});
export type TurnContentStrategy = StreamStrategy;
export type AssistantTurnForkHandler = (input: {
target: AssistantForkTarget;
boundaryMessageId?: string;
}) => Promise<void> | void;
export const TurnFooter = memo(function TurnFooter({
isRunning,
inFlightTurnStartedAt,
host,
strategy,
onForkAssistantTurn,
}: {
isRunning: boolean;
inFlightTurnStartedAt: Date | null;
host: TurnFooterHost | null;
strategy: TurnContentStrategy;
onForkAssistantTurn?: AssistantTurnForkHandler;
}) {
if (isRunning) {
return (
@@ -50,6 +62,7 @@ export const TurnFooter = memo(function TurnFooter({
items={host.items}
timing={host.timing}
startIndex={host.startIndex}
onForkAssistantTurn={onForkAssistantTurn}
/>
);
});
@@ -59,11 +72,13 @@ export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({
items,
timing,
startIndex,
onForkAssistantTurn,
}: {
strategy: TurnContentStrategy;
items: StreamItem[];
timing?: TurnTiming;
startIndex: number;
onForkAssistantTurn?: AssistantTurnForkHandler;
}) {
return (
<TurnFooterRow>
@@ -72,6 +87,7 @@ export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({
items={items}
timing={timing}
startIndex={startIndex}
onForkAssistantTurn={onForkAssistantTurn}
/>
</TurnFooterRow>
);
@@ -111,11 +127,13 @@ function CompletedTurnFooter({
items,
timing,
startIndex,
onForkAssistantTurn,
}: {
strategy: TurnContentStrategy;
items: StreamItem[];
timing?: TurnTiming;
startIndex: number;
onForkAssistantTurn?: AssistantTurnForkHandler;
}) {
const getContent = useCallback(
() =>
@@ -126,12 +144,18 @@ function CompletedTurnFooter({
}),
[strategy, items, startIndex],
);
const boundaryMessageId = resolveAssistantTurnBoundaryMessageId({
items,
startIndex,
});
return (
<View style={stylesheet.turnFooterSlot}>
<AssistantTurnFooter
getContent={getContent}
completedAt={timing?.completedAt}
durationMs={timing?.durationMs}
forkBoundaryMessageId={boundaryMessageId}
onFork={onForkAssistantTurn}
/>
</View>
);
+191 -8
View File
@@ -11,6 +11,7 @@ import React, {
type ComponentProps,
type ReactNode,
} from "react";
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import {
View,
@@ -59,7 +60,12 @@ import { ToolCallSheetProvider } from "@/components/tool-call-sheet";
import { type AgentStreamRenderModel, buildAgentStreamRenderModel } from "./model";
import { resolveStreamRenderStrategy } from "./strategy-resolver";
import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy";
import { CompletedTurnFooterRow, TurnFooter, type TurnContentStrategy } from "./turn-footer";
import {
CompletedTurnFooterRow,
TurnFooter,
type AssistantTurnForkHandler,
type TurnContentStrategy,
} from "./turn-footer";
import { layoutStream, type StreamLayoutItem } from "./layout";
import {
type BottomAnchorLocalRequest,
@@ -76,11 +82,21 @@ import {
type WorkspaceFileOpenRequest,
} from "@/workspace/file-open";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import { buildNewWorkspaceRoute } from "@/utils/host-routes";
import { useStableEvent } from "@/hooks/use-stable-event";
import { isWeb } from "@/constants/platform";
import type { Theme } from "@/styles/theme";
import { recordRenderProfileReasons } from "@/utils/render-profiler";
import { MountedTabActiveContext } from "@/components/split-container";
import { generateDraftId } from "@/stores/draft-keys";
import {
buildDraftWorkspaceAttachmentScopeKey,
useWorkspaceAttachmentsStore,
} from "@/attachments/workspace-attachments-store";
import type { WorkspaceComposerAttachment } from "@/attachments/types";
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { toErrorMessage } from "@/utils/error-messages";
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
function renderLiveAuxiliaryNode(input: {
pendingPermissions: ReactNode;
@@ -121,6 +137,7 @@ function renderStreamItemWithTurnFooter(input: {
content: ReactNode;
layoutItem: StreamLayoutItem;
strategy: TurnContentStrategy;
onForkAssistantTurn?: AssistantTurnForkHandler;
}): ReactNode {
if (!input.content) {
return null;
@@ -133,6 +150,7 @@ function renderStreamItemWithTurnFooter(input: {
items={footerHost.items}
timing={footerHost.timing}
startIndex={footerHost.startIndex}
onForkAssistantTurn={input.onForkAssistantTurn}
/>
) : null;
const content = (
@@ -233,6 +251,56 @@ const AGENT_CAPABILITY_FLAG_KEYS: (keyof AgentCapabilityFlags)[] = [
const EMPTY_STREAM_HEAD: StreamItem[] = [];
function buildChatHistoryAttachment(input: {
draftId: string;
serverId: string;
agentId: string;
payload: Awaited<ReturnType<DaemonClient["buildAgentForkContext"]>>;
missingAttachmentMessage: string;
}): WorkspaceComposerAttachment {
if (!input.payload.attachment) {
throw new Error(input.missingAttachmentMessage);
}
return {
kind: "chat_history",
id: `chat_history:${input.draftId}`,
attachment: input.payload.attachment,
source: {
serverId: input.serverId,
agentId: input.agentId,
boundaryMessageId: input.payload.boundaryMessageId,
itemCount: input.payload.itemCount,
},
};
}
function buildForkDraftSetup(agent: AgentScreenAgent): WorkspaceDraftTabSetup | undefined {
if (!agent.provider) {
return undefined;
}
const featureValues: Record<string, unknown> = {};
for (const feature of agent.features ?? []) {
featureValues[feature.id] = feature.value;
}
return {
provider: agent.provider,
cwd: agent.cwd,
modeId: agent.currentModeId ?? agent.runtimeInfo?.modeId ?? null,
model: agent.model ?? agent.runtimeInfo?.model ?? null,
thinkingOptionId: agent.thinkingOptionId ?? agent.runtimeInfo?.thinkingOptionId ?? null,
featureValues,
};
}
function buildForkDraftTabTarget(
setup: WorkspaceDraftTabSetup | undefined,
draftId: string,
): WorkspaceTabTarget {
return setup ? { kind: "draft", draftId, setup } : { kind: "draft", draftId };
}
const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(
function AgentStreamView(
{
@@ -249,6 +317,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
ref,
) {
const { t } = useTranslation();
const router = useRouter();
const viewportRef = useRef<StreamViewportHandle | null>(null);
const isMobile = useIsCompactFormFactor();
const streamRenderStrategy = useMemo(
@@ -273,6 +342,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const streamHead = useSessionStore((state) =>
state.sessions[resolvedServerId]?.agentStreamHead?.get(agentId),
);
const supportsAgentForkContext = useSessionStore(
(state) => state.sessions[resolvedServerId]?.serverInfo?.features?.agentForkContext === true,
);
const workspaceRoot = agent.cwd?.trim() || "";
const { requestDirectoryListing } = useFileExplorerActions({
@@ -361,6 +433,76 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
handleInlinePathPress({ raw: filePath, path: filePath }, "main");
});
const handleForkAssistantTurn: AssistantTurnForkHandler = useStableEvent(
async ({ target, boundaryMessageId }) => {
try {
if (!supportsAgentForkContext) {
toast?.error(t("message.actions.forkUnavailable"));
return;
}
if (!client) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
const draftSetup = buildForkDraftSetup(agent);
const prepareForkDraft = async () => {
const draftId = generateDraftId();
const payload = await client.buildAgentForkContext(
agentId,
boundaryMessageId ? { boundaryMessageId } : {},
);
const attachment = buildChatHistoryAttachment({
draftId,
serverId: resolvedServerId,
agentId,
payload,
missingAttachmentMessage: t("message.actions.forkFailed"),
});
useWorkspaceAttachmentsStore.getState().setWorkspaceAttachments({
scopeKey: buildDraftWorkspaceAttachmentScopeKey(draftId),
attachments: [attachment],
});
return draftId;
};
if (target === "tab") {
const workspaceId = agent.workspaceId;
if (!workspaceId) {
throw new Error(t("message.actions.forkMissingWorkspace"));
}
const draftId = await prepareForkDraft();
navigateToPreparedWorkspaceTab({
serverId: resolvedServerId,
workspaceId,
target: buildForkDraftTabTarget(draftSetup, draftId),
});
return;
}
const draftId = await prepareForkDraft();
const sourceDirectory =
agent.projectPlacement?.checkout?.cwd?.trim() || agent.cwd.trim() || undefined;
if (draftSetup) {
useWorkspaceDraftSubmissionStore.getState().setDraftSetup({
draftId,
setup: draftSetup,
sourceDirectory,
});
}
router.push(
buildNewWorkspaceRoute({
serverId: resolvedServerId,
sourceDirectory,
displayName: agent.projectPlacement?.projectName,
projectId: agent.projectPlacement?.projectKey,
draftId,
}),
);
} catch (error) {
toast?.error(toErrorMessage(error) || t("message.actions.forkFailed"));
}
},
);
// Freeze stream data while this tab slot is hidden to prevent offscreen FlatList
// cell-window renders on every 48ms flush from background agents.
// When isActive flips back to true, the context change triggers a re-render and
@@ -602,9 +744,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
content,
layoutItem,
strategy: streamRenderStrategy,
onForkAssistantTurn: handleForkAssistantTurn,
});
},
[renderStreamItemContent, streamRenderStrategy],
[handleForkAssistantTurn, renderStreamItemContent, streamRenderStrategy],
);
const pendingPermissionItems = useMemo(
@@ -629,9 +772,11 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
inFlightTurnStartedAt={baseRenderModel.turnTiming.runningStartedAt}
host={bottomTurnFooterHost}
strategy={streamRenderStrategy}
onForkAssistantTurn={handleForkAssistantTurn}
/>
) : null,
[
handleForkAssistantTurn,
showRunningTurnFooter,
baseRenderModel.turnTiming.runningStartedAt,
bottomTurnFooterHost,
@@ -788,22 +933,60 @@ function agentCapabilityFlagsEqual(
return AGENT_CAPABILITY_FLAG_KEYS.every((key) => left?.[key] === right?.[key]);
}
function collectAgentProjectPlacementDiffs(
left: AgentScreenAgent["projectPlacement"],
right: AgentScreenAgent["projectPlacement"],
): string[] {
const reasons: string[] = [];
if (left?.checkout?.cwd !== right?.checkout?.cwd) {
reasons.push("agent.projectPlacement.checkout.cwd");
}
if (left?.checkout?.isGit !== right?.checkout?.isGit) {
reasons.push("agent.projectPlacement.checkout.isGit");
}
if (left?.projectName !== right?.projectName) {
reasons.push("agent.projectPlacement.projectName");
}
if (left?.projectKey !== right?.projectKey) {
reasons.push("agent.projectPlacement.projectKey");
}
return reasons;
}
function collectAgentSetupDiffs(left: AgentScreenAgent, right: AgentScreenAgent): string[] {
const reasons: string[] = [];
if (left.provider !== right.provider) reasons.push("agent.provider");
if (left.currentModeId !== right.currentModeId) reasons.push("agent.currentModeId");
if (left.model !== right.model) reasons.push("agent.model");
if (left.thinkingOptionId !== right.thinkingOptionId) {
reasons.push("agent.thinkingOptionId");
}
if (left.runtimeInfo?.modeId !== right.runtimeInfo?.modeId) {
reasons.push("agent.runtimeInfo.modeId");
}
if (left.runtimeInfo?.model !== right.runtimeInfo?.model) {
reasons.push("agent.runtimeInfo.model");
}
if (left.runtimeInfo?.thinkingOptionId !== right.runtimeInfo?.thinkingOptionId) {
reasons.push("agent.runtimeInfo.thinkingOptionId");
}
if (left.features !== right.features) reasons.push("agent.features");
return reasons;
}
function collectAgentScreenAgentDiffs(left: AgentScreenAgent, right: AgentScreenAgent): string[] {
const reasons: string[] = [];
if (left.serverId !== right.serverId) reasons.push("agent.serverId");
if (left.id !== right.id) reasons.push("agent.id");
if (left.workspaceId !== right.workspaceId) reasons.push("agent.workspaceId");
if (left.status !== right.status) reasons.push("agent.status");
if (left.cwd !== right.cwd) reasons.push("agent.cwd");
if (!agentCapabilityFlagsEqual(left.capabilities, right.capabilities)) {
reasons.push("agent.capabilities");
}
if (left.lastError !== right.lastError) reasons.push("agent.lastError");
if (left.projectPlacement?.checkout?.cwd !== right.projectPlacement?.checkout?.cwd) {
reasons.push("agent.projectPlacement.checkout.cwd");
}
if (left.projectPlacement?.checkout?.isGit !== right.projectPlacement?.checkout?.isGit) {
reasons.push("agent.projectPlacement.checkout.isGit");
}
reasons.push(...collectAgentSetupDiffs(left, right));
reasons.push(...collectAgentProjectPlacementDiffs(left.projectPlacement, right.projectPlacement));
return reasons;
}