mirror of
https://github.com/getpaseo/paseo.git
synced 2026-08-14 12:23:16 +00:00
* 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
367 lines
10 KiB
TypeScript
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,
|
|
};
|
|
}
|