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
+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,