mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 02:15:02 +00:00
* 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>
146 lines
4.8 KiB
TypeScript
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;
|
|
}
|