paseo-plugin-chat-view 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/LICENSE +21 -0
  2. package/LICENSE.compact-agent-activity +21 -0
  3. package/LICENSE.mermaid +21 -0
  4. package/README.md +428 -2
  5. package/README_zh.md +429 -0
  6. package/client/activity/Activity.test.ts +220 -0
  7. package/client/activity/Activity.tsx +784 -0
  8. package/client/activity/detail/detail.test.ts +102 -0
  9. package/client/activity/detail/dispatch.tsx +94 -0
  10. package/client/activity/detail/file.tsx +93 -0
  11. package/client/activity/detail/index.tsx +39 -0
  12. package/client/activity/detail/parts.tsx +221 -0
  13. package/client/activity/detail/search.tsx +59 -0
  14. package/client/activity/detail/terminal.tsx +53 -0
  15. package/client/activity/detail/texts.tsx +42 -0
  16. package/client/activity/disclosure.ts +49 -0
  17. package/client/activity/dom.test.ts +52 -0
  18. package/client/activity/dom.ts +138 -0
  19. package/client/activity/group-store.test.ts +126 -0
  20. package/client/activity/group-store.ts +265 -0
  21. package/client/activity/highlight.test.ts +116 -0
  22. package/client/activity/highlight.ts +418 -0
  23. package/client/activity/history.test.ts +602 -0
  24. package/client/activity/history.ts +239 -0
  25. package/client/activity/index.ts +74 -0
  26. package/client/activity/latest-marker.test.ts +41 -0
  27. package/client/activity/latest-marker.ts +70 -0
  28. package/client/activity/styles.ts +535 -0
  29. package/client/activity/tools/child-agent.tsx +308 -0
  30. package/client/activity/tools/exa.tsx +99 -0
  31. package/client/activity/tools/github.tsx +501 -0
  32. package/client/activity/tools/list.tsx +102 -0
  33. package/client/activity/tools/lists.test.ts +193 -0
  34. package/client/activity/tools/paseo/agent.tsx +384 -0
  35. package/client/activity/tools/paseo/browser.tsx +322 -0
  36. package/client/activity/tools/paseo/index.tsx +79 -0
  37. package/client/activity/tools/paseo/parts.tsx +133 -0
  38. package/client/activity/tools/paseo/provider.tsx +220 -0
  39. package/client/activity/tools/paseo/schedule.tsx +246 -0
  40. package/client/activity/tools/paseo/speak.tsx +20 -0
  41. package/client/activity/tools/paseo/terminal.tsx +214 -0
  42. package/client/activity/tools/paseo/workspace.tsx +162 -0
  43. package/client/activity/tools/shared.tsx +431 -0
  44. package/client/activity/transform.test.ts +28 -0
  45. package/client/activity/transform.ts +43 -0
  46. package/client/agent-pills.ts +56 -0
  47. package/client/dom.ts +75 -0
  48. package/client/enhance/controller.ts +111 -0
  49. package/client/enhance/dom.test.ts +28 -0
  50. package/client/enhance/dom.ts +101 -0
  51. package/client/enhance/index.ts +95 -0
  52. package/client/enhance/pill.tsx +19 -0
  53. package/client/enhance/state.ts +58 -0
  54. package/client/feature.ts +22 -0
  55. package/client/features.test.ts +149 -0
  56. package/client/features.ts +83 -0
  57. package/client/locale.ts +45 -0
  58. package/client/mermaid/diagram.tsx +438 -0
  59. package/client/mermaid/index.ts +27 -0
  60. package/client/mermaid/item.tsx +28 -0
  61. package/client/mermaid/markdown.tsx +217 -0
  62. package/client/mermaid/registration.ts +32 -0
  63. package/client/mermaid/toolbar.tsx +175 -0
  64. package/client/mermaid/viewer.tsx +314 -0
  65. package/client/readonly/dom.test.ts +66 -0
  66. package/client/readonly/dom.ts +32 -0
  67. package/client/readonly/index.ts +27 -0
  68. package/client/registration.test.ts +243 -0
  69. package/client/settings.tsx +438 -0
  70. package/client/sticky/StickyBar.tsx +144 -0
  71. package/client/sticky/dom.test.ts +230 -0
  72. package/client/sticky/dom.ts +517 -0
  73. package/client/sticky/index.ts +38 -0
  74. package/client/sticky/pill.tsx +70 -0
  75. package/client/timeline-pager.test.ts +121 -0
  76. package/client/timeline-pager.ts +123 -0
  77. package/index.client.tsx +44 -0
  78. package/index.server.ts +12 -0
  79. package/package.json +42 -4
  80. package/paseo-plugin.json +6 -0
  81. package/server/enhance/index.ts +63 -0
  82. package/server/enhance/manifest.ts +176 -0
  83. package/server/enhance/probe.test.ts +215 -0
  84. package/server/enhance/probe.ts +118 -0
  85. package/server/enhance/provider.ts +149 -0
  86. package/shared/activity/child-agent.test.ts +27 -0
  87. package/shared/activity/child-agent.ts +26 -0
  88. package/shared/activity/diff.test.ts +74 -0
  89. package/shared/activity/diff.ts +207 -0
  90. package/shared/activity/exa.test.ts +66 -0
  91. package/shared/activity/exa.ts +183 -0
  92. package/shared/activity/file-kind.test.ts +13 -0
  93. package/shared/activity/file-kind.ts +112 -0
  94. package/shared/activity/github.test.ts +56 -0
  95. package/shared/activity/github.ts +174 -0
  96. package/shared/activity/palette.test.ts +26 -0
  97. package/shared/activity/palette.ts +106 -0
  98. package/shared/activity/parse.test.ts +39 -0
  99. package/shared/activity/parse.ts +115 -0
  100. package/shared/activity/paseo-tools.test.ts +163 -0
  101. package/shared/activity/paseo-tools.ts +497 -0
  102. package/shared/activity/text.test.ts +85 -0
  103. package/shared/activity/text.ts +202 -0
  104. package/shared/activity/timeline.test.ts +26 -0
  105. package/shared/activity/timeline.ts +108 -0
  106. package/shared/activity/tool-presentation.test.ts +154 -0
  107. package/shared/activity/tool-presentation.ts +352 -0
  108. package/shared/enhance/contract.ts +105 -0
  109. package/shared/enhance/decide.ts +97 -0
  110. package/shared/enhance/enhance.test.ts +188 -0
  111. package/shared/enhance/failure.ts +40 -0
  112. package/shared/enhance/index.ts +8 -0
  113. package/shared/enhance/normalize.ts +16 -0
  114. package/shared/enhance/parse.ts +79 -0
  115. package/shared/enhance/policy.ts +48 -0
  116. package/shared/enhance/prompt.ts +134 -0
  117. package/shared/enhance/run.ts +84 -0
  118. package/shared/i18n.test.ts +81 -0
  119. package/shared/i18n.ts +300 -0
  120. package/shared/mermaid/flowchart.test.ts +169 -0
  121. package/shared/mermaid/flowchart.ts +271 -0
  122. package/shared/mermaid/layout.test.ts +305 -0
  123. package/shared/mermaid/layout.ts +496 -0
  124. package/shared/mermaid/parse.test.ts +110 -0
  125. package/shared/mermaid/parse.ts +193 -0
  126. package/shared/mermaid/segment.test.ts +55 -0
  127. package/shared/mermaid/segment.ts +95 -0
  128. package/shared/mermaid/sequence.test.ts +68 -0
  129. package/shared/mermaid/sequence.ts +130 -0
  130. package/shared/mermaid/transform.test.ts +52 -0
  131. package/shared/mermaid/transform.ts +45 -0
  132. package/shared/mermaid/zoom.test.ts +134 -0
  133. package/shared/mermaid/zoom.ts +114 -0
  134. package/shared/settings.ts +41 -0
  135. package/shared/sticky/history.test.ts +91 -0
  136. package/shared/sticky/history.ts +86 -0
@@ -0,0 +1,134 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { MIN_SCALE, fitScale } from "./layout";
3
+ import {
4
+ ZOOM_MAX,
5
+ ZOOM_MIN,
6
+ canZoomIn,
7
+ canZoomOut,
8
+ fitBothScale,
9
+ fitRowZoom,
10
+ sameZoom,
11
+ toolbarState,
12
+ zoomIn,
13
+ zoomLabel,
14
+ zoomOut,
15
+ } from "./zoom";
16
+
17
+ describe("zoom steps", () => {
18
+ it("starts at 100% and steps by a quarter, the way the Paseo viewport does", () => {
19
+ expect(zoomIn(1)).toBeCloseTo(1.25);
20
+ expect(zoomOut(1)).toBeCloseTo(0.8);
21
+ });
22
+
23
+ it("returns exactly to 100% after stepping in and back out", () => {
24
+ // 1.25 * 0.8 is not 1 in floating point; the label and the disabled state
25
+ // of the fit button both compare against 1, so the step has to land on it.
26
+ expect(zoomOut(zoomIn(1))).toBe(1);
27
+ expect(zoomIn(zoomOut(1))).toBe(1);
28
+ });
29
+
30
+ it("stops at the limits instead of overshooting them", () => {
31
+ expect(zoomIn(ZOOM_MAX)).toBe(ZOOM_MAX);
32
+ expect(zoomIn(3.5)).toBe(ZOOM_MAX);
33
+ expect(zoomOut(ZOOM_MIN)).toBe(ZOOM_MIN);
34
+ expect(zoomOut(0.3)).toBe(ZOOM_MIN);
35
+ });
36
+
37
+ it("reports when a step is no longer possible, so the button can go quiet", () => {
38
+ expect(canZoomIn(1)).toBe(true);
39
+ expect(canZoomIn(ZOOM_MAX)).toBe(false);
40
+ expect(canZoomOut(1)).toBe(true);
41
+ expect(canZoomOut(ZOOM_MIN)).toBe(false);
42
+ });
43
+
44
+ it("labels the zoom as a whole percent", () => {
45
+ expect(zoomLabel(1)).toBe("100%");
46
+ expect(zoomLabel(0.8)).toBe("80%");
47
+ expect(zoomLabel(1.5625)).toBe("156%");
48
+ expect(zoomLabel(0.5454)).toBe("55%");
49
+ });
50
+
51
+ it("treats zooms that differ by less than a rounding error as the same", () => {
52
+ expect(sameZoom(1, 1.0000000000000002)).toBe(true);
53
+ expect(sameZoom(1, 1.01)).toBe(false);
54
+ });
55
+ });
56
+
57
+ describe("fitRowZoom", () => {
58
+ it("shrinks a wide drawing that hit the readability floor until all of it shows", () => {
59
+ // fitScale stops at MIN_SCALE and lets the row scroll; fit to view goes past that.
60
+ const rowScale = fitScale(600, 2000);
61
+ expect(rowScale).toBe(MIN_SCALE);
62
+ expect(fitRowZoom(rowScale, 2000, 600)).toBeCloseTo(600 / (2000 * MIN_SCALE));
63
+ expect(fitRowZoom(rowScale, 2000, 600)).toBeLessThan(1);
64
+ });
65
+
66
+ it("never grows a drawing past its default size", () => {
67
+ // A three-node chart in a wide row would otherwise become a poster.
68
+ expect(fitRowZoom(fitScale(900, 300), 300, 900)).toBe(1);
69
+ });
70
+
71
+ it("is a no-op when the drawing already fits the row", () => {
72
+ expect(fitRowZoom(fitScale(900, 1200), 1200, 900)).toBe(1);
73
+ });
74
+
75
+ it("leaves the drawing alone before the row has been measured", () => {
76
+ expect(fitRowZoom(0.75, 1200, 0)).toBe(1);
77
+ expect(fitRowZoom(0, 1200, 900)).toBe(1);
78
+ expect(fitRowZoom(0.75, 0, 900)).toBe(1);
79
+ });
80
+ });
81
+
82
+ describe("fitBothScale", () => {
83
+ it("fits both axes of the pop-out without changing the aspect ratio", () => {
84
+ expect(fitBothScale({ width: 1600, height: 800 }, { width: 800, height: 600 })).toBe(0.5);
85
+ expect(fitBothScale({ width: 400, height: 800 }, { width: 800, height: 600 })).toBe(0.75);
86
+ });
87
+
88
+ it("keeps the padding clear of the drawing", () => {
89
+ expect(fitBothScale({ width: 1600, height: 800 }, { width: 800, height: 600 }, 20)).toBe(
90
+ 760 / 1600,
91
+ );
92
+ });
93
+
94
+ it("grows a small drawing to fill the window, which is what popping out is for", () => {
95
+ expect(fitBothScale({ width: 300, height: 200 }, { width: 1200, height: 900 })).toBe(4);
96
+ });
97
+
98
+ it("leaves the drawing alone before the window has been measured", () => {
99
+ expect(fitBothScale({ width: 300, height: 200 }, { width: 0, height: 0 })).toBe(1);
100
+ expect(fitBothScale({ width: 0, height: 200 }, { width: 1200, height: 900 })).toBe(1);
101
+ });
102
+ });
103
+
104
+ describe("toolbarState", () => {
105
+ it("offers the zoom steps and the pop-out at the default zoom", () => {
106
+ expect(toolbarState({ zoom: 1, fitZoom: 1, showCode: false })).toEqual({
107
+ zoomIn: true,
108
+ zoomOut: true,
109
+ fit: false,
110
+ popOut: true,
111
+ });
112
+ });
113
+
114
+ it("offers fit to view only when it would change something", () => {
115
+ expect(toolbarState({ zoom: 1, fitZoom: 0.6, showCode: false }).fit).toBe(true);
116
+ expect(toolbarState({ zoom: 0.6, fitZoom: 0.6, showCode: false }).fit).toBe(false);
117
+ expect(toolbarState({ zoom: 0.6000001, fitZoom: 0.6, showCode: false }).fit).toBe(false);
118
+ });
119
+
120
+ it("quiets the step that has reached its limit", () => {
121
+ expect(toolbarState({ zoom: ZOOM_MAX, fitZoom: 1, showCode: false }).zoomIn).toBe(false);
122
+ expect(toolbarState({ zoom: ZOOM_MIN, fitZoom: 1, showCode: false }).zoomOut).toBe(false);
123
+ });
124
+
125
+ it("quiets every drawing control while the source is shown", () => {
126
+ // Zooming text that is not on screen would be a change the reader cannot see.
127
+ expect(toolbarState({ zoom: 1, fitZoom: 0.6, showCode: true })).toEqual({
128
+ zoomIn: false,
129
+ zoomOut: false,
130
+ fit: false,
131
+ popOut: false,
132
+ });
133
+ });
134
+ });
@@ -0,0 +1,114 @@
1
+ /**
2
+ * The zoom model behind the toolbar, kept pure so it can be tested without
3
+ * mounting anything.
4
+ *
5
+ * Zoom is a multiplier over the scale the drawing was fitted at: 1 is "as
6
+ * fitted", which is what the toolbar labels 100%. This is the same convention
7
+ * as Paseo's own diagram viewport, where the fit transform has scale 1, so a
8
+ * reader who knows one knows the other. The fitted scale itself depends on
9
+ * where the drawing sits: in the row it is `fitScale` from layout.ts, in the
10
+ * pop-out it is `fitBothScale` below.
11
+ */
12
+
13
+ export const ZOOM_MIN = 0.25;
14
+ export const ZOOM_MAX = 4;
15
+ /** Paseo zooms by 1.25 in and 0.8 out; the two are exact inverses. */
16
+ export const ZOOM_STEP = 1.25;
17
+
18
+ const EPSILON = 1e-3;
19
+
20
+ export interface Size {
21
+ width: number;
22
+ height: number;
23
+ }
24
+
25
+ export function clampZoom(zoom: number): number {
26
+ return Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom));
27
+ }
28
+
29
+ /**
30
+ * Snaps away the floating point residue of a multiply-then-divide, so stepping
31
+ * in and back out lands on exactly the value it started from.
32
+ */
33
+ function snap(zoom: number): number {
34
+ return Math.round(zoom * 1e6) / 1e6;
35
+ }
36
+
37
+ export function zoomIn(zoom: number): number {
38
+ return clampZoom(snap(zoom * ZOOM_STEP));
39
+ }
40
+
41
+ export function zoomOut(zoom: number): number {
42
+ return clampZoom(snap(zoom / ZOOM_STEP));
43
+ }
44
+
45
+ export function canZoomIn(zoom: number): boolean {
46
+ return zoom < ZOOM_MAX - EPSILON;
47
+ }
48
+
49
+ export function canZoomOut(zoom: number): boolean {
50
+ return zoom > ZOOM_MIN + EPSILON;
51
+ }
52
+
53
+ export function sameZoom(a: number, b: number): boolean {
54
+ return Math.abs(a - b) < EPSILON;
55
+ }
56
+
57
+ export function zoomLabel(zoom: number): string {
58
+ return `${Math.round(zoom * 100)}%`;
59
+ }
60
+
61
+ /**
62
+ * The zoom that shows the whole drawing across the row.
63
+ *
64
+ * The row's own fit stops shrinking at the readability floor and scrolls
65
+ * sideways past it; "fit to view" is the reader asking to see all of it anyway.
66
+ * It never goes above 1, because the default already grows small drawings as
67
+ * far as they should go, and 1 means the request was already satisfied.
68
+ */
69
+ export function fitRowZoom(rowScale: number, naturalWidth: number, available: number): number {
70
+ if (!(rowScale > 0) || !(naturalWidth > 0) || !(available > 0)) return 1;
71
+ return Math.min(1, snap(available / (naturalWidth * rowScale)));
72
+ }
73
+
74
+ /**
75
+ * The scale that fits a drawing inside both axes of a viewport, with `padding`
76
+ * kept clear on every side. Unlike the row, the pop-out grows a small drawing
77
+ * as far as the window allows: seeing it large is the point of popping out.
78
+ */
79
+ export function fitBothScale(natural: Size, viewport: Size, padding = 0): number {
80
+ const width = viewport.width - padding * 2;
81
+ const height = viewport.height - padding * 2;
82
+ if (!(natural.width > 0) || !(natural.height > 0) || !(width > 0) || !(height > 0)) return 1;
83
+ return Math.min(width / natural.width, height / natural.height);
84
+ }
85
+
86
+ export interface ToolbarState {
87
+ zoomIn: boolean;
88
+ zoomOut: boolean;
89
+ fit: boolean;
90
+ popOut: boolean;
91
+ }
92
+
93
+ /**
94
+ * Which drawing controls are worth pressing right now. A control that would
95
+ * change nothing is shown quiet rather than hidden, so the toolbar keeps its
96
+ * shape and the reader learns where the limits are.
97
+ */
98
+ export function toolbarState({
99
+ zoom,
100
+ fitZoom,
101
+ showCode,
102
+ }: {
103
+ zoom: number;
104
+ fitZoom: number;
105
+ showCode: boolean;
106
+ }): ToolbarState {
107
+ if (showCode) return { zoomIn: false, zoomOut: false, fit: false, popOut: false };
108
+ return {
109
+ zoomIn: canZoomIn(zoom),
110
+ zoomOut: canZoomOut(zoom),
111
+ fit: !sameZoom(zoom, fitZoom),
112
+ popOut: true,
113
+ };
114
+ }
@@ -0,0 +1,41 @@
1
+ import { defineSettings } from "@getpaseo/plugin";
2
+ import { z } from "zod";
3
+
4
+ export const paletteModeSchema = z.enum(["vivid", "soft", "high_contrast"]);
5
+ export type PaletteMode = z.output<typeof paletteModeSchema>;
6
+
7
+ export const displayModeSchema = z.preprocess(
8
+ (value) => (value === "codex" ? "folded" : value),
9
+ z.enum(["folded", "detailed"]),
10
+ );
11
+ export type DisplayMode = z.output<typeof displayModeSchema>;
12
+
13
+ export const languageModeSchema = z.enum(["auto", "zh-CN", "en-US"]);
14
+ export type LanguageMode = z.output<typeof languageModeSchema>;
15
+
16
+ export const activitySettings = defineSettings({
17
+ id: "display",
18
+ scope: "host",
19
+ version: 3,
20
+ schema: z.object({
21
+ mermaidEnabled: z.boolean().default(true),
22
+ compactActivityEnabled: z.boolean().default(true),
23
+ stickyEnabled: z.boolean().default(true),
24
+ readonlyMessagesEnabled: z.boolean().default(true),
25
+ // Off until a model endpoint is configured: an unconfigured button must not look actionable.
26
+ enhanceEnabled: z.boolean().default(false),
27
+ enhanceBaseUrl: z.string().default(""),
28
+ enhanceApiKey: z.string().default(""),
29
+ enhanceModel: z.string().default(""),
30
+ palette: paletteModeSchema.default("vivid"),
31
+ displayMode: displayModeSchema.default("folded"),
32
+ language: languageModeSchema.default("zh-CN"),
33
+ }),
34
+ // v1 stored none of the enhance fields, v2 none of readonly; defaults fill them in on next parse.
35
+ migrate: (values) => values,
36
+ });
37
+
38
+ export const DEFAULT_PALETTE_MODE: PaletteMode = "vivid";
39
+ export const DEFAULT_DISPLAY_MODE: DisplayMode = "folded";
40
+
41
+ export type ChatViewSettings = z.output<typeof activitySettings.schema>;
@@ -0,0 +1,91 @@
1
+ import { expect, it } from "vitest";
2
+ import { PromptHistory } from "./history";
3
+
4
+ const time = "2026-10-06T10:00:00.000Z";
5
+
6
+ it("retains the latest prompt at the bottom across older pages and clears replacement history", () => {
7
+ const history = new PromptHistory();
8
+ expect(history.latest()).toBeUndefined();
9
+ history.add({ type: "user_message", text: "current", messageId: "u2" }, time, 3);
10
+ history.add({ type: "user_message", text: "older", messageId: "u1" }, time, 1);
11
+ history.add({ type: "assistant_message", text: "answer", messageId: "a2" }, time, 4);
12
+ expect(history.latest()).toMatchObject({ id: "u2", text: "current" });
13
+ history.add({ type: "user_message", text: "new round", messageId: "u3" }, time, 5);
14
+ expect(history.latest()).toMatchObject({ id: "u3", text: "new round" });
15
+ history.clear();
16
+ expect(history.latest()).toBeUndefined();
17
+ });
18
+
19
+ it("associates virtualized native and plugin rows with their own prompt, not the latest prompt", () => {
20
+ const history = new PromptHistory();
21
+ history.add(
22
+ { type: "user_message", text: "first", messageId: "u1", clientMessageId: "local1" },
23
+ time,
24
+ 1,
25
+ );
26
+ history.add({ type: "assistant_message", text: "answer", messageId: "a1" }, time, 2);
27
+ history.add({ type: "user_message", text: "second", messageId: "u2" }, time, 3);
28
+ history.add({ type: "assistant_message", text: "answer", messageId: "a2" }, time, 4);
29
+ history.add({ type: "assistant_message", text: "answer delta", messageId: "a1" }, time, 5);
30
+ expect(history.resolve("chat-view/a1/0", "chat-view/a1/0")).toEqual({
31
+ id: "local1",
32
+ text: "first",
33
+ aliases: ["local1", "u1"],
34
+ });
35
+ expect(history.resolve("a2:segment:abc", "a2")).toMatchObject({ id: "u2", text: "second" });
36
+ expect(history.resolve("u2", "u2")).toMatchObject({ id: "u2", text: "second" });
37
+ });
38
+
39
+ it("waits for an older page instead of borrowing a newer prompt", () => {
40
+ const history = new PromptHistory();
41
+ const earlier = Date.parse(time) - 1000;
42
+ history.add({ type: "assistant_message", text: "tail", messageId: "a2" }, time, 20);
43
+ expect(history.resolve(`assistant_${earlier}_hash_0`, null)).toBeUndefined();
44
+ expect(history.resolve("a2", null)).toBeUndefined();
45
+ history.add(
46
+ { type: "user_message", text: "earlier question", messageId: "u1" },
47
+ new Date(earlier - 1000).toISOString(),
48
+ 1,
49
+ );
50
+ history.add({ type: "reasoning", text: "thinking" }, new Date(earlier).toISOString(), 2);
51
+ expect(history.resolve(`chat-view/thought_${earlier}_hash_0/0`, null)).toMatchObject({
52
+ id: "u1",
53
+ text: "earlier question",
54
+ });
55
+ });
56
+
57
+ it("keeps tool ownership across lifecycle updates and clears stale history after replacement", () => {
58
+ const history = new PromptHistory();
59
+ history.add({ type: "user_message", text: "first", messageId: "u1" }, time, 1);
60
+ history.add(
61
+ {
62
+ type: "tool_call",
63
+ callId: "tool1",
64
+ name: "read",
65
+ status: "running",
66
+ error: null,
67
+ detail: { type: "read", filePath: "a.ts" },
68
+ },
69
+ time,
70
+ 2,
71
+ );
72
+ history.add({ type: "user_message", text: "second", messageId: "u2" }, time, 3);
73
+ history.add(
74
+ {
75
+ type: "tool_call",
76
+ callId: "tool1",
77
+ name: "read",
78
+ status: "completed",
79
+ error: null,
80
+ detail: { type: "read", filePath: "a.ts" },
81
+ },
82
+ time,
83
+ 4,
84
+ );
85
+ expect(history.resolve("compact-agent-activity/agent_tool_tool1/0", null)).toMatchObject({
86
+ id: "u1",
87
+ text: "first",
88
+ });
89
+ history.clear();
90
+ expect(history.resolve("agent_tool_tool1", null)).toBeUndefined();
91
+ });
@@ -0,0 +1,86 @@
1
+ import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
2
+
3
+ export interface StickyPrompt {
4
+ id: string;
5
+ text: string;
6
+ aliases?: readonly string[];
7
+ }
8
+
9
+ interface PromptPosition extends StickyPrompt {
10
+ seq: number;
11
+ time: number;
12
+ }
13
+
14
+ /** Keeps prompt metadata, never assistant bodies or tool outputs. */
15
+ export class PromptHistory {
16
+ private prompts = new Map<number, PromptPosition>();
17
+ private identities = new Map<string, number>();
18
+ private oldestTime = Infinity;
19
+
20
+ clear() {
21
+ this.prompts.clear();
22
+ this.identities.clear();
23
+ this.oldestTime = Infinity;
24
+ }
25
+
26
+ latest(): StickyPrompt | undefined {
27
+ let selected: PromptPosition | undefined;
28
+ for (const prompt of this.prompts.values()) {
29
+ if (!selected || prompt.seq > selected.seq) selected = prompt;
30
+ }
31
+ return selected
32
+ ? { id: selected.id, text: selected.text, aliases: selected.aliases }
33
+ : undefined;
34
+ }
35
+
36
+ add(item: AgentTimelineItem, timestamp: string, seq: number) {
37
+ const time = Date.parse(timestamp);
38
+ this.oldestTime = Math.min(this.oldestTime, time);
39
+ if (item.type === "user_message") {
40
+ const id = item.clientMessageId ?? item.messageId;
41
+ if (id)
42
+ this.prompts.set(seq, {
43
+ id,
44
+ text: item.text,
45
+ seq,
46
+ time,
47
+ aliases: [item.clientMessageId, item.messageId].filter(
48
+ (value): value is string => !!value,
49
+ ),
50
+ });
51
+ if (item.clientMessageId) this.identities.set(item.clientMessageId, seq);
52
+ if (item.messageId) this.identities.set(item.messageId, seq);
53
+ } else if (item.type === "assistant_message" && item.messageId) {
54
+ const previous = this.identities.get(item.messageId);
55
+ this.identities.set(item.messageId, previous === undefined ? seq : Math.min(previous, seq));
56
+ } else if (item.type === "tool_call") {
57
+ // The host's tool stream row keeps the call identity across lifecycle updates.
58
+ const id = `agent_tool_${item.callId}`;
59
+ const previous = this.identities.get(id);
60
+ this.identities.set(id, previous === undefined ? seq : Math.min(previous, seq));
61
+ }
62
+ }
63
+
64
+ resolve(rowId: string, messageId: string | null): StickyPrompt | undefined {
65
+ const sourceId = sourceIdentity(rowId);
66
+ const identity = sourceIdentity(messageId ?? sourceId);
67
+ const seq =
68
+ this.identities.get(identity) ?? this.identities.get(identity.split(":segment:")[0]!);
69
+ const match = /^(?:assistant|thought|user)_(\d+)_/.exec(sourceId);
70
+ const time = match ? Number(match[1]) : undefined;
71
+ // A timestamp outside the fetched window needs an older page first.
72
+ if (seq === undefined && (time === undefined || time < this.oldestTime)) return undefined;
73
+ let selected: PromptPosition | undefined;
74
+ for (const prompt of this.prompts.values()) {
75
+ const before = seq !== undefined ? prompt.seq <= seq : prompt.time <= time!;
76
+ if (before && (!selected || prompt.seq > selected.seq)) selected = prompt;
77
+ }
78
+ return selected
79
+ ? { id: selected.id, text: selected.text, aliases: selected.aliases }
80
+ : undefined;
81
+ }
82
+ }
83
+
84
+ function sourceIdentity(id: string): string {
85
+ return /^[a-z][a-z0-9-]*\/(.+)\/\d+$/.exec(id)?.[1] ?? id;
86
+ }