Files
paseo/packages/app/src/agent-stream/layout.ts
Mohamed Boudra 85acaceb16 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
2026-06-29 18:29:59 +02:00

367 lines
10 KiB
TypeScript

import type { TurnTiming } from "@/timeline/turn-time";
import type { StreamItem } from "@/types/stream";
import { getAssistantBlockSpacing, getGapBetweenStreamItems } from "./spacing";
import type { StreamFrameChildOrder, StreamStrategy } from "./strategy";
export type StreamToolSequence = "single" | "first" | "middle" | "last" | "none";
export interface TurnFooterHost {
itemId: string;
items: StreamItem[];
timing?: TurnTiming;
startIndex: number;
}
export interface StreamLayoutItem {
item: StreamItem;
index: number;
items: StreamItem[];
aboveItem: StreamItem | null;
belowItem: StreamItem | null;
gapBelow: number;
assistantSpacing: "default" | "compactTop" | "compactBottom" | "compactBoth";
completedFooter: TurnFooterHost | null;
toolSequence: StreamToolSequence;
isFirstInUserGroup: boolean;
isLastInUserGroup: boolean;
isLastInToolSequence: boolean;
frameOrder: StreamFrameChildOrder;
}
export interface StreamLayout {
history: StreamLayoutItem[];
liveHead: StreamLayoutItem[];
auxiliaryTurnFooter: TurnFooterHost | null;
}
export interface StreamLayoutInput {
strategy: StreamStrategy;
agentStatus: string;
history: StreamItem[];
liveHead: StreamItem[];
timingByAssistantId: Map<string, TurnTiming>;
}
interface LayoutSegmentInput {
strategy: StreamStrategy;
agentStatus: string;
items: StreamItem[];
timingByAssistantId: Map<string, TurnTiming>;
auxiliaryTurnFooter: TurnFooterHost | null;
frameOrder: StreamFrameChildOrder;
boundaryIndex: number | null;
boundaryAboveItem: StreamItem | null;
boundaryBelowItem: StreamItem | null;
}
function createTurnFooterHost(input: {
item: StreamItem;
items: StreamItem[];
index: number;
timingByAssistantId: Map<string, TurnTiming>;
}): TurnFooterHost {
return {
itemId: input.item.id,
items: input.items,
timing: input.timingByAssistantId.get(input.item.id),
startIndex: input.index,
};
}
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 latestIndex = input.strategy.getLatestItemIndex(footerItems);
if (latestIndex === null) {
return null;
}
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;
}
return createTurnFooterHost({
item,
items: footerItems,
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 {
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,
): item is Extract<StreamItem, { kind: "tool_call" | "thought" | "todo_list" }> {
return item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list";
}
function getToolSequence(input: {
item: StreamItem;
aboveItem: StreamItem | null;
belowItem: StreamItem | null;
}): StreamToolSequence {
if (!isToolSequenceItem(input.item)) {
return "none";
}
const hasAbove = isToolSequenceItem(input.aboveItem);
const hasBelow = isToolSequenceItem(input.belowItem);
if (hasAbove && hasBelow) {
return "middle";
}
if (hasAbove) {
return "last";
}
if (hasBelow) {
return "first";
}
return "single";
}
function getSegmentNeighbor(input: {
strategy: StreamStrategy;
items: StreamItem[];
index: number;
relation: "above" | "below";
boundaryIndex: number | null;
boundaryItem: StreamItem | null;
}): StreamItem | null {
const neighbor = input.strategy.getNeighborItem(input.items, input.index, input.relation);
if (neighbor) {
return neighbor;
}
if (input.index === input.boundaryIndex) {
return input.boundaryItem;
}
return null;
}
function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] {
return input.items.map((item, index) => {
const aboveItem = getSegmentNeighbor({
strategy: input.strategy,
items: input.items,
index,
relation: "above",
boundaryIndex: input.boundaryIndex,
boundaryItem: input.boundaryAboveItem,
});
const belowItem = getSegmentNeighbor({
strategy: input.strategy,
items: input.items,
index,
relation: "below",
boundaryIndex: input.boundaryIndex,
boundaryItem: input.boundaryBelowItem,
});
const assistantSpacing = getAssistantBlockSpacing({
item,
aboveItem,
belowItem,
});
const completedFooter = shouldRenderCompletedFooter({
strategy: input.strategy,
items: input.items,
index,
item,
belowItem,
agentStatus: input.agentStatus,
auxiliaryTurnFooter: input.auxiliaryTurnFooter,
})
? createTurnFooterHost({
item,
items: input.items,
index,
timingByAssistantId: input.timingByAssistantId,
})
: null;
return {
item,
index,
items: input.items,
aboveItem,
belowItem,
gapBelow: completedFooter ? 0 : getGapBetweenStreamItems(item, belowItem),
assistantSpacing,
completedFooter,
toolSequence: getToolSequence({ item, aboveItem, belowItem }),
isFirstInUserGroup: item.kind === "user_message" && aboveItem?.kind !== "user_message",
isLastInUserGroup: item.kind === "user_message" && belowItem?.kind !== "user_message",
isLastInToolSequence: isToolSequenceItem(item) && !isToolSequenceItem(belowItem),
frameOrder: input.frameOrder,
};
});
}
// Keyed by history array identity; inner key encodes the inputs that affect history layout.
// History layout is stable across text-chunk flushes because the liveHead boundary item's
// kind and id don't change when only its text grows.
const historyLayoutCache = new WeakMap<StreamItem[], Map<string, StreamLayoutItem[]>>();
export function layoutStream(input: StreamLayoutInput): StreamLayout {
const auxiliaryTurnFooter = resolveAuxiliaryTurnFooter(input);
const historyBoundaryIndex = input.strategy.getHistoryLiveBoundaryIndex(input.history);
const liveHeadBoundaryIndex = input.strategy.getLiveHeadHistoryBoundaryIndex(input.liveHead);
const historyBoundaryItem =
historyBoundaryIndex === null ? null : (input.history[historyBoundaryIndex] ?? null);
const liveHeadBoundaryItem =
liveHeadBoundaryIndex === null ? null : (input.liveHead[liveHeadBoundaryIndex] ?? null);
const frameOrder = input.strategy.getFrameChildOrder();
let history: StreamLayoutItem[];
if (input.history.length > 0) {
// The cache key encodes every input that can change history layout. liveHeadBoundaryItem.id
// and .kind are stable across text-only flushes (text growth doesn't change what kind of
// item borders history), so cached layout stays valid between flushes.
const historyCacheKey = [
input.agentStatus,
frameOrder,
historyBoundaryIndex ?? "null",
liveHeadBoundaryItem?.id ?? "null",
liveHeadBoundaryItem?.kind ?? "null",
auxiliaryTurnFooter?.itemId ?? "null",
].join(":");
let byKey = historyLayoutCache.get(input.history);
if (!byKey) {
byKey = new Map();
historyLayoutCache.set(input.history, byKey);
}
const cached = byKey.get(historyCacheKey);
if (cached) {
history = cached;
} else {
history = layoutSegment({
strategy: input.strategy,
agentStatus: input.agentStatus,
items: input.history,
timingByAssistantId: input.timingByAssistantId,
auxiliaryTurnFooter,
frameOrder,
boundaryIndex: historyBoundaryIndex,
boundaryAboveItem: null,
boundaryBelowItem: liveHeadBoundaryItem,
});
byKey.set(historyCacheKey, history);
}
} else {
history = [];
}
const liveHead = layoutSegment({
strategy: input.strategy,
agentStatus: input.agentStatus,
items: input.liveHead,
timingByAssistantId: input.timingByAssistantId,
auxiliaryTurnFooter,
frameOrder,
boundaryIndex: liveHeadBoundaryIndex,
boundaryAboveItem: historyBoundaryItem,
boundaryBelowItem: null,
});
return {
history,
liveHead,
auxiliaryTurnFooter,
};
}