Files
paseo/packages/app/src/plugins/timeline/projection.ts
T
99e4b53aa0 fix(app): restore outline jumps to prompts rendered by plugins (#6690)
* fix(app): jump the chat outline to prompts a timeline plugin draws

A timeline transformer replaces a stream item with plugin rows whose ids
are "<pluginId>/<itemId>". The chat outline, chat find and history reveal
look a message up by the source item's id, so once a plugin drew the
prompts the outline found no row: the rail showed the prompts and
clicking one did nothing, for mounted, virtualized and unloaded prompts
alike.

Projected plugin rows now carry the id of the item they replaced, and
getStreamItemMessageId answers with it. Row wrappers, the virtualized
index lookup, the visible-message set and history reveal all read that
function, so they now find the plugin row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(app): verify outline jumps to virtualized plugin prompts

Replacing every prompt keeps all loaded rows mounted, so the original plugin regression never exercised a virtualized target. Retain newer native prompts and verify that a loaded unmounted plugin prompt lands without fetching.

Co-authored-by: luopeihuan <luopeihuan@roundreddot.com>

* test(app): retain outline navigation screenshots

---------

Co-authored-by: luopeihuan <luopeihuan@roundreddot.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-12 12:10:54 +02:00

146 lines
4.8 KiB
TypeScript

import type { AgentTimelineItem, ToolCallTimelineItem } from "@getpaseo/protocol/agent-types";
import type { AgentToolCallData, PluginTimelineStreamItem, StreamItem } from "@/types/stream";
import type { TimelineItemTransform } from "./model";
type AssistantPhase = "streaming" | "complete";
const projectionCache = new WeakMap<
TimelineItemTransform,
WeakMap<StreamItem, Partial<Record<AssistantPhase, StreamItem[]>>>
>();
function cloneAndFreeze<T>(value: T): T {
if (Array.isArray(value)) {
return Object.freeze(value.map((entry) => cloneAndFreeze(entry))) as T;
}
if (value !== null && typeof value === "object") {
return Object.freeze(
Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, cloneAndFreeze(entry)])),
) as T;
}
return value;
}
function sourceToolCallTimelineItem(data: AgentToolCallData): ToolCallTimelineItem {
const { callId, name, status, error, detail, metadata } = data;
const base = {
type: "tool_call" as const,
callId,
name,
detail,
...(metadata ? { metadata } : {}),
};
switch (status) {
case "running":
case "completed":
case "canceled":
return { ...base, status, error: null };
case "failed":
return { ...base, status, error };
}
}
function sourceTimelineItem(item: StreamItem): AgentTimelineItem | null {
switch (item.kind) {
case "user_message":
return {
type: "user_message",
text: item.text,
...(item.messageId ? { messageId: item.messageId } : {}),
...(item.clientMessageId ? { clientMessageId: item.clientMessageId } : {}),
};
case "assistant_message":
return {
type: "assistant_message",
text: item.text,
...(item.messageId ? { messageId: item.messageId } : {}),
};
case "thought":
return { type: "reasoning", text: item.text };
case "tool_call": {
if (item.payload.source !== "agent") return null;
return sourceToolCallTimelineItem(item.payload.data);
}
case "todo_list":
return { type: "todo", items: item.items };
case "notification":
return item.sourceType === "error"
? { type: "error", message: item.message }
: { type: "notification", level: item.level, message: item.message };
case "compaction":
return {
type: "compaction",
status: item.status,
...(item.trigger ? { trigger: item.trigger } : {}),
...(item.preTokens !== undefined ? { preTokens: item.preTokens } : {}),
};
case "plugin":
return null;
}
}
function transformSourceItem(
item: StreamItem,
transformTimelineItem: TimelineItemTransform,
assistantPhase: AssistantPhase,
): StreamItem[] {
const source = sourceTimelineItem(item);
if (!source) return [item];
const isStreamingThought = item.kind === "thought" && item.status === "loading";
const isStreamingToolCall =
item.kind === "tool_call" &&
item.payload.source === "agent" &&
item.payload.data.status === "running";
const isStreamingAssistant = item.kind === "assistant_message" && assistantPhase === "streaming";
const phase =
isStreamingAssistant || isStreamingThought || isStreamingToolCall ? "streaming" : "complete";
const transformed = transformTimelineItem({
item: cloneAndFreeze(source),
phase,
sourceId: item.id,
});
if (transformed === undefined) return [item];
return transformed.map((pluginItem) => {
const projected: PluginTimelineStreamItem = {
kind: "plugin",
id: `${pluginItem.pluginId}/${pluginItem.id}`,
sourceId: item.id,
timestamp: item.timestamp,
pluginId: pluginItem.pluginId,
pluginItemId: pluginItem.id,
itemKind: pluginItem.kind,
version: pluginItem.version,
data: pluginItem.data,
};
if (item.timelineCursor) projected.timelineCursor = item.timelineCursor;
if (item.turnId) projected.turnId = item.turnId;
return projected;
});
}
export function projectPluginTimelineItems(
items: StreamItem[],
transformTimelineItem: TimelineItemTransform | undefined,
assistantPhase: AssistantPhase = "complete",
): StreamItem[] {
if (!transformTimelineItem) return items;
let bySource = projectionCache.get(transformTimelineItem);
if (!bySource) {
bySource = new WeakMap();
projectionCache.set(transformTimelineItem, bySource);
}
let changed = false;
const projected = items.flatMap((item) => {
const cached = bySource.get(item)?.[assistantPhase];
if (cached) {
changed = changed || cached.length !== 1 || cached[0] !== item;
return cached;
}
const output = transformSourceItem(item, transformTimelineItem, assistantPhase);
bySource.set(item, { ...bySource.get(item), [assistantPhase]: output });
changed = changed || output.length !== 1 || output[0] !== item;
return output;
});
return changed ? projected : items;
}