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,42 @@
1
+ import React from "react";
2
+ import { Text } from "react-native";
3
+
4
+ import { DetailRenderProps, HighlightedCodeBlock } from "./parts";
5
+
6
+ /** Details that are text themselves: sub-agent runs, plain messages, and plans. */
7
+ export function SubAgentDetail({ detail, theme, styles }: DetailRenderProps<"sub_agent">) {
8
+ return (
9
+ <>
10
+ {detail.subAgentType ? <Text style={styles.detailText}>{detail.subAgentType}</Text> : null}
11
+ {detail.description ? <Text style={styles.mutedText}>{detail.description}</Text> : null}
12
+ {detail.childSessionId ? (
13
+ <Text style={styles.mutedText}>Session {detail.childSessionId}</Text>
14
+ ) : null}
15
+ {detail.log ? (
16
+ <HighlightedCodeBlock
17
+ code={detail.log}
18
+ language="ansi"
19
+ label="Activity log"
20
+ styles={styles}
21
+ theme={theme}
22
+ />
23
+ ) : null}
24
+ </>
25
+ );
26
+ }
27
+
28
+ export function PlainTextDetail({ detail, styles }: DetailRenderProps<"plain_text">) {
29
+ return (
30
+ <Text selectable style={styles.detailText}>
31
+ {detail.text ?? ""}
32
+ </Text>
33
+ );
34
+ }
35
+
36
+ export function PlanDetail({ detail, styles }: DetailRenderProps<"plan">) {
37
+ return (
38
+ <Text selectable style={styles.detailText}>
39
+ {detail.text}
40
+ </Text>
41
+ );
42
+ }
@@ -0,0 +1,49 @@
1
+ import { useMemo, useSyncExternalStore } from "react";
2
+ import { usePaseo } from "@getpaseo/plugin/client";
3
+ import type { PaseoApi } from "@getpaseo/client";
4
+
5
+ function createDisclosure() {
6
+ let expanded: boolean | undefined;
7
+ const listeners = new Set<() => void>();
8
+ return {
9
+ snapshot: () => expanded,
10
+ subscribe(listener: () => void) {
11
+ listeners.add(listener);
12
+ return () => {
13
+ listeners.delete(listener);
14
+ };
15
+ },
16
+ set(value: boolean) {
17
+ if (expanded === value) return;
18
+ expanded = value;
19
+ for (const listener of listeners) listener();
20
+ },
21
+ };
22
+ }
23
+ let disclosures = new WeakMap<PaseoApi, Map<string, ReturnType<typeof createDisclosure>>>();
24
+ export function cleanupActivityDisclosure() {
25
+ disclosures = new WeakMap();
26
+ }
27
+
28
+ /** Retain explicit choices across virtualized row remounts, scoped to the host connection. */
29
+ export function useActivityDisclosure(agentId: string, rowId: string) {
30
+ const paseo = usePaseo();
31
+ const state = useMemo(() => {
32
+ let rows = disclosures.get(paseo);
33
+ if (!rows) {
34
+ rows = new Map();
35
+ disclosures.set(paseo, rows);
36
+ }
37
+ const key = JSON.stringify([agentId, rowId]);
38
+ let row = rows.get(key);
39
+ if (!row) {
40
+ row = createDisclosure();
41
+ rows.set(key, row);
42
+ }
43
+ return row;
44
+ }, [paseo, agentId, rowId]);
45
+ return [
46
+ useSyncExternalStore(state.subscribe, state.snapshot, state.snapshot),
47
+ state.set,
48
+ ] as const;
49
+ }
@@ -0,0 +1,52 @@
1
+ import { afterEach, expect, it, vi } from "vitest";
2
+ import { setupCompactActivityStyles } from "./dom";
3
+
4
+ vi.mock("react-native", () => ({ Platform: { OS: "web" } }));
5
+
6
+ /** The activity stylesheet only touches `document.body` and `createElement`, so the fake stays minimal. */
7
+ class StyleNode {
8
+ textContent = "";
9
+ isConnected = true;
10
+ parentElement: StyleNode | null = null;
11
+ children: StyleNode[] = [];
12
+ attributes = new Map<string, string>();
13
+ setAttribute(name: string, value: string) {
14
+ this.attributes.set(name, value);
15
+ }
16
+ getAttribute(name: string) {
17
+ return this.attributes.get(name) ?? null;
18
+ }
19
+ appendChild(child: StyleNode) {
20
+ this.children.push(child);
21
+ child.parentElement = this;
22
+ }
23
+ remove() {
24
+ this.isConnected = false;
25
+ if (this.parentElement)
26
+ this.parentElement.children = this.parentElement.children.filter((child) => child !== this);
27
+ }
28
+ }
29
+
30
+ afterEach(() => {
31
+ vi.unstubAllGlobals();
32
+ });
33
+
34
+ it("injects compact message and paragraph styling rules and removes them on cleanup", () => {
35
+ const body = new StyleNode();
36
+ vi.stubGlobal("document", { body, createElement: () => new StyleNode() });
37
+ const remove = setupCompactActivityStyles();
38
+ const styleNode = body.children.find(
39
+ (child) => child.getAttribute("data-plugin") === "chat-view-activity",
40
+ );
41
+ expect(styleNode).toBeDefined();
42
+ expect(styleNode?.textContent).toContain('data-testid="assistant-message"');
43
+ expect(styleNode?.textContent).toContain('data-paseo-markdown-tag="p"');
44
+ expect(styleNode?.textContent).toContain("margin-bottom: 8px !important;");
45
+ expect(styleNode?.textContent).toContain("margin-bottom: 0 !important;");
46
+
47
+ remove();
48
+ expect(styleNode?.isConnected).toBe(false);
49
+ expect(
50
+ body.children.some((child) => child.getAttribute("data-plugin") === "chat-view-activity"),
51
+ ).toBe(false);
52
+ });
@@ -0,0 +1,138 @@
1
+ import { Platform } from "react-native";
2
+ import type { Document, Element, Window } from "../dom";
3
+
4
+ // Compact-activity DOM access: the scoped stylesheet tightening the host conversation rhythm, and
5
+ // the scroll-into-view bridge the detail panels use. Neither is observable from the sticky
6
+ // viewport module, so the two features cannot grow entangled selectors.
7
+ declare const document: Document;
8
+ declare const window: Window;
9
+
10
+ /** Scoped CSS also applies to rows mounted later by the virtual list. */
11
+ export function setupCompactActivityStyles(): () => void {
12
+ if (Platform.OS !== "web" || typeof document === "undefined") return () => {};
13
+ const style = document.createElement("style");
14
+ style.setAttribute("data-plugin", "chat-view-activity");
15
+ style.textContent = `
16
+ /* 活动折叠组间距优化 */
17
+ [data-testid="agent-chat-scroll"] [data-history-row-id]:has([data-testid="folded-activity-group"]) > div {
18
+ margin-top: 1px !important;
19
+ margin-bottom: 2px !important;
20
+ }
21
+ [data-testid="agent-chat-scroll"] [data-history-row-id]:has([data-testid="folded-activity-follower"]) > div {
22
+ margin-bottom: 0 !important;
23
+ }
24
+
25
+ /* 用户提问气泡底部间距适度紧凑化 */
26
+ [data-testid="agent-chat-scroll"] [data-history-row-id]:has([data-testid="user-message"]) > div {
27
+ margin-bottom: 8px !important;
28
+ }
29
+
30
+ /* 助手消息容器垂直 padding 紧凑化,消除原生默认上下 12px 的过大留白 */
31
+ [data-testid="agent-chat-scroll"] [data-testid="assistant-message"] {
32
+ padding-top: 4px !important;
33
+ padding-bottom: 4px !important;
34
+ }
35
+
36
+ /* 助手消息直接子块容器间距优化 */
37
+ [data-testid="agent-chat-scroll"] [data-testid="assistant-message"] > div {
38
+ margin-bottom: 8px !important;
39
+ }
40
+ [data-testid="agent-chat-scroll"] [data-testid="assistant-message"] > div:last-child {
41
+ margin-bottom: 0 !important;
42
+ }
43
+
44
+ /* Markdown 段落间距紧凑优化与专业美化,消除末尾段落多余的底部留白 */
45
+ [data-testid="agent-chat-scroll"] [data-testid="assistant-message"] :is(p, [data-paseo-markdown-tag="p"]) {
46
+ margin-top: 0 !important;
47
+ margin-bottom: 8px !important;
48
+ }
49
+ [data-testid="agent-chat-scroll"] [data-testid="assistant-message"] :is(p, [data-paseo-markdown-tag="p"]):last-child {
50
+ margin-bottom: 0 !important;
51
+ }
52
+
53
+ /* 列表块及列表项间距优化 */
54
+ [data-testid="agent-chat-scroll"] [data-testid="assistant-message"] :is([data-paseo-markdown-tag="ul"], [data-paseo-markdown-tag="ol"]) {
55
+ margin-top: 4px !important;
56
+ margin-bottom: 8px !important;
57
+ }
58
+ [data-testid="agent-chat-scroll"] [data-testid="assistant-message"] :is([data-paseo-markdown-tag="ul"], [data-paseo-markdown-tag="ol"]):last-child {
59
+ margin-bottom: 0 !important;
60
+ }
61
+ [data-testid="agent-chat-scroll"] [data-testid="assistant-message"] [data-paseo-markdown-tag="li"] {
62
+ margin-bottom: 3px !important;
63
+ }
64
+ [data-testid="agent-chat-scroll"] [data-testid="assistant-message"] [data-paseo-markdown-tag="li"]:last-child {
65
+ margin-bottom: 0 !important;
66
+ }
67
+
68
+ /* 代码块与引用块间距紧凑化 */
69
+ [data-testid="agent-chat-scroll"] [data-testid="assistant-message"] [data-paseo-markdown-tag="pre"] {
70
+ margin-top: 6px !important;
71
+ margin-bottom: 8px !important;
72
+ }
73
+ [data-testid="agent-chat-scroll"] [data-testid="assistant-message"] [data-paseo-markdown-tag="pre"]:first-child {
74
+ margin-top: 0 !important;
75
+ }
76
+ [data-testid="agent-chat-scroll"] [data-testid="assistant-message"] [data-paseo-markdown-tag="pre"]:last-child {
77
+ margin-bottom: 0 !important;
78
+ }
79
+ [data-testid="agent-chat-scroll"] [data-testid="assistant-message"] [data-paseo-markdown-tag="blockquote"] {
80
+ margin-top: 6px !important;
81
+ margin-bottom: 8px !important;
82
+ }
83
+ [data-testid="agent-chat-scroll"] [data-testid="assistant-message"] [data-paseo-markdown-tag="blockquote"]:first-child {
84
+ margin-top: 0 !important;
85
+ }
86
+ [data-testid="agent-chat-scroll"] [data-testid="assistant-message"] [data-paseo-markdown-tag="blockquote"]:last-child {
87
+ margin-bottom: 0 !important;
88
+ }
89
+
90
+ /* 标题间距优化 */
91
+ [data-testid="agent-chat-scroll"] [data-testid="assistant-message"] [data-paseo-markdown-tag="h1"],
92
+ [data-testid="agent-chat-scroll"] [data-testid="assistant-message"] [data-paseo-markdown-tag="h2"] {
93
+ margin-top: 14px !important;
94
+ margin-bottom: 6px !important;
95
+ }
96
+ [data-testid="agent-chat-scroll"] [data-testid="assistant-message"] :is([data-paseo-markdown-tag="h3"], [data-paseo-markdown-tag="h4"], [data-paseo-markdown-tag="h5"], [data-paseo-markdown-tag="h6"]) {
97
+ margin-top: 10px !important;
98
+ margin-bottom: 4px !important;
99
+ }
100
+ [data-testid="agent-chat-scroll"] [data-testid="assistant-message"] :is([data-paseo-markdown-tag="h1"], [data-paseo-markdown-tag="h2"], [data-paseo-markdown-tag="h3"], [data-paseo-markdown-tag="h4"], [data-paseo-markdown-tag="h5"], [data-paseo-markdown-tag="h6"]):first-child {
101
+ margin-top: 0 !important;
102
+ }
103
+ [data-testid="agent-chat-scroll"] [data-testid="assistant-message"] :is([data-paseo-markdown-tag="h1"], [data-paseo-markdown-tag="h2"], [data-paseo-markdown-tag="h3"], [data-paseo-markdown-tag="h4"], [data-paseo-markdown-tag="h5"], [data-paseo-markdown-tag="h6"]):last-child {
104
+ margin-bottom: 0 !important;
105
+ }
106
+
107
+ /* 表格与水平分割线间距紧凑化 */
108
+ [data-testid="agent-chat-scroll"] [data-testid="assistant-message"] [data-paseo-markdown-tag="table"] {
109
+ margin-top: 6px !important;
110
+ margin-bottom: 8px !important;
111
+ }
112
+ [data-testid="agent-chat-scroll"] [data-testid="assistant-message"] [data-paseo-markdown-tag="table"]:last-child {
113
+ margin-bottom: 0 !important;
114
+ }
115
+ [data-testid="agent-chat-scroll"] [data-testid="assistant-message"] [data-paseo-markdown-tag="hr"] {
116
+ margin-top: 8px !important;
117
+ margin-bottom: 8px !important;
118
+ }
119
+
120
+ /* 图片尺寸自适应约束 */
121
+ [data-testid="agent-chat-scroll"] [role="img"]:not([role="dialog"] *):not([data-testid="attachment-lightbox"] *) {
122
+ max-width: min(440px, 100%) !important; max-height: 320px !important; align-self: flex-start !important;
123
+ }
124
+ [data-testid="agent-chat-scroll"] img:not([role="dialog"] *):not([data-testid="attachment-lightbox"] *) {
125
+ max-width: 100% !important; max-height: 320px !important; width: auto !important; height: auto !important; object-fit: contain !important;
126
+ }
127
+ `;
128
+ document.body.appendChild(style);
129
+ return () => style.remove();
130
+ }
131
+
132
+ export function scrollActivityIntoView(anchor: unknown): void {
133
+ if (Platform.OS !== "web" || typeof window === "undefined") return;
134
+ const element = anchor as Element | null;
135
+ window.requestAnimationFrame(() => {
136
+ if (element?.isConnected) element.scrollIntoView?.({ block: "nearest", behavior: "auto" });
137
+ });
138
+ }
@@ -0,0 +1,126 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
3
+ import {
4
+ buildActivityGroups,
5
+ formatGroupSummary,
6
+ updateGroupStats,
7
+ type ActivityEntry,
8
+ } from "./group-store";
9
+ const entry = (seq: number, item: AgentTimelineItem, seqEnd = seq): ActivityEntry => ({
10
+ item,
11
+ seqStart: seq,
12
+ seqEnd,
13
+ timestamp: new Date(seq * 1000).toISOString(),
14
+ });
15
+ const tool = (status: "running" | "completed" = "completed"): AgentTimelineItem => ({
16
+ type: "tool_call",
17
+ callId: "shell-1",
18
+ name: "exec_command",
19
+ status,
20
+ error: null,
21
+ detail: { type: "shell", command: "pwd", cwd: "/tmp" },
22
+ });
23
+
24
+ describe("activity grouping", () => {
25
+ it("folds contiguous thoughts and tools, preserving visible message boundaries", () => {
26
+ const groups = buildActivityGroups([
27
+ entry(1, { type: "user_message", text: "question" }),
28
+ entry(2, { type: "reasoning", text: "thinking" }),
29
+ entry(3, tool()),
30
+ entry(4, { type: "assistant_message", text: "answer" }),
31
+ entry(5, { type: "reasoning", text: "next thought" }),
32
+ ]);
33
+ const first = groups.get("reasoning:2000")!;
34
+ expect(first.isLeader).toBe(true);
35
+ expect(groups.get("tool:shell-1")!.isLeader).toBe(false);
36
+ expect(first.group.items).toHaveLength(2);
37
+ expect(first.group.isFinished).toBe(true);
38
+ expect(formatGroupSummary(first.group)).toBe("Thought · Ran 1 command");
39
+ expect(groups.get("reasoning:5000")!.group).not.toBe(first.group);
40
+ });
41
+ it("uses the latest lifecycle output without counting a call twice", () => {
42
+ const entries = [entry(1, tool("running")), entry(2, tool(), 3)];
43
+ const group = buildActivityGroups(entries).get("tool:shell-1")!.group;
44
+ expect(group.items).toHaveLength(1);
45
+ expect(group.commandCount).toBe(1);
46
+ expect(group.isRunning).toBe(false);
47
+ expect(group.items[0]!.toolCallData!.status).toBe("completed");
48
+ expect(buildActivityGroups(entries).get("tool:shell-1")!.group).toEqual(group);
49
+ });
50
+ it("keeps separate histories independent even with overlapping identities", () => {
51
+ const first = buildActivityGroups([entry(1, tool("running"))]);
52
+ const second = buildActivityGroups([entry(1, tool())]);
53
+ expect(first.get("tool:shell-1")!.group.isRunning).toBe(true);
54
+ expect(second.get("tool:shell-1")!.group.isRunning).toBe(false);
55
+ });
56
+ it("keeps speak messages visible and splits groups around them", () => {
57
+ const groups = buildActivityGroups([
58
+ entry(1, { type: "reasoning", text: "before" }),
59
+ entry(2, {
60
+ type: "tool_call",
61
+ callId: "speak",
62
+ name: "speak",
63
+ status: "completed",
64
+ error: null,
65
+ detail: { type: "unknown", input: "hello", output: null },
66
+ }),
67
+ entry(3, tool()),
68
+ ]);
69
+ expect(groups.has("tool:speak")).toBe(false);
70
+ expect(groups.get("tool:shell-1")!.isLeader).toBe(true);
71
+ });
72
+ });
73
+
74
+ it("recomputes counters from the items when a call changes in place", () => {
75
+ const group = buildActivityGroups([entry(1, tool("running"))]).get("tool:shell-1")!.group;
76
+ expect(group.isRunning).toBe(true);
77
+ expect(group.commandCount).toBe(1);
78
+ expect(group.hasError).toBe(false);
79
+
80
+ group.items[0]!.toolCallData!.status = "failed";
81
+ updateGroupStats(group);
82
+
83
+ expect(group.isRunning).toBe(false);
84
+ expect(group.hasError).toBe(true);
85
+ expect(group.commandCount).toBe(1);
86
+ updateGroupStats(group);
87
+ expect(group.commandCount).toBe(1);
88
+ });
89
+
90
+ it("keeps a sealed group from running again after a rescan", () => {
91
+ const group = buildActivityGroups([
92
+ entry(1, tool("running")),
93
+ entry(2, {
94
+ type: "assistant_message",
95
+ text: "answer",
96
+ }),
97
+ ]).get("tool:shell-1")!.group;
98
+ expect(group.isFinished).toBe(true);
99
+ updateGroupStats(group);
100
+ expect(group.isRunning).toBe(false);
101
+ });
102
+
103
+ it("keeps distinct sequence identities when two thoughts share a timestamp", () => {
104
+ const rows = [
105
+ entry(1, { type: "reasoning", text: "first" }),
106
+ entry(2, { type: "assistant_message", text: "boundary" }),
107
+ { ...entry(3, { type: "reasoning", text: "second" }), timestamp: new Date(1000).toISOString() },
108
+ ];
109
+ // Timestamp-only lookup is ambiguous; retain separate sequence keys for text matching.
110
+ const groups = buildActivityGroups(rows);
111
+ expect(groups.get("reasoning:1000")).toBeUndefined();
112
+ expect(groups.get("reasoning:1000:1")?.group).not.toBe(groups.get("reasoning:1000:3")?.group);
113
+ });
114
+
115
+ it("keeps a reasoning identity stable while its text streams", () => {
116
+ const short = buildActivityGroups([entry(7, { type: "reasoning", text: "think" })]).get(
117
+ "reasoning:7000",
118
+ )!;
119
+ const streamed = buildActivityGroups([
120
+ entry(7, { type: "reasoning", text: "thinking out loud" }, 9),
121
+ ]).get("reasoning:7000")!;
122
+ // The row key must survive growth, otherwise React remounts the row on every token.
123
+ expect(streamed.group.items[0]!.id).toBe(short.group.items[0]!.id);
124
+ expect(streamed.group.leaderId).toBe(short.group.leaderId);
125
+ expect(streamed.group.items[0]!.reasoningData!.text).toBe("thinking out loud");
126
+ });
@@ -0,0 +1,265 @@
1
+ import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
2
+ import { createReasoningData, createToolCallData } from "../../shared/activity/timeline";
3
+ import type { ReasoningItemData, ToolCallItemData } from "../../shared/activity/timeline";
4
+
5
+ export interface GroupItemData {
6
+ id: string;
7
+ type: "reasoning" | "tool_call";
8
+ timestamp: number;
9
+ reasoningData?: ReasoningItemData;
10
+ toolCallData?: ToolCallItemData;
11
+ }
12
+
13
+ export interface TurnGroup {
14
+ turnIndex: number;
15
+ agentId?: string;
16
+ leaderId: string;
17
+ items: GroupItemData[];
18
+ hasReasoning: boolean;
19
+ commandCount: number;
20
+ editCount: number;
21
+ readCount: number;
22
+ searchCount: number;
23
+ otherToolCount: number;
24
+ isRunning: boolean;
25
+ isFinished?: boolean;
26
+ hasError: boolean;
27
+ startedAt: number;
28
+ completedAt?: number;
29
+ }
30
+
31
+ export function isFileEditAction(tc: ToolCallItemData): boolean {
32
+ if (tc.detail && typeof tc.detail === "object") {
33
+ const detailType = (tc.detail as { type?: string }).type;
34
+ if (
35
+ detailType === "edit" ||
36
+ detailType === "write" ||
37
+ detailType === "patch" ||
38
+ detailType === "create"
39
+ ) {
40
+ return true;
41
+ }
42
+ if (detailType === "read") {
43
+ return false;
44
+ }
45
+ }
46
+
47
+ if (tc.presentation.diffStats) {
48
+ return true;
49
+ }
50
+
51
+ const label = tc.presentation.label.toLowerCase();
52
+ if (
53
+ label.includes("write") ||
54
+ label.includes("edit") ||
55
+ label.includes("patch") ||
56
+ label.includes("create")
57
+ ) {
58
+ return true;
59
+ }
60
+
61
+ const name = tc.name.toLowerCase();
62
+ if (
63
+ name.includes("write") ||
64
+ name.includes("edit") ||
65
+ name.includes("replace") ||
66
+ name.includes("patch") ||
67
+ name.includes("create")
68
+ ) {
69
+ return true;
70
+ }
71
+
72
+ return false;
73
+ }
74
+
75
+ export function createActivityGroup(
76
+ turnIndex: number,
77
+ leaderId: string,
78
+ startedAt: number,
79
+ items: GroupItemData[] = [],
80
+ ): TurnGroup {
81
+ return {
82
+ turnIndex,
83
+ leaderId,
84
+ items,
85
+ hasReasoning: false,
86
+ commandCount: 0,
87
+ editCount: 0,
88
+ readCount: 0,
89
+ searchCount: 0,
90
+ otherToolCount: 0,
91
+ isRunning: false,
92
+ hasError: false,
93
+ startedAt,
94
+ };
95
+ }
96
+
97
+ /**
98
+ * Fold one item into the running totals. Build visits every entry exactly once, so accumulating per
99
+ * item keeps grouping linear instead of rescanning the whole group per entry. Returns true when the
100
+ * item is still active, which the caller tracks to derive isRunning.
101
+ */
102
+ function accumulateItemStats(group: TurnGroup, item: GroupItemData): boolean {
103
+ if (item.type === "reasoning") {
104
+ group.hasReasoning = true;
105
+ return item.reasoningData?.phase === "streaming";
106
+ }
107
+ if (item.type !== "tool_call" || !item.toolCallData) return false;
108
+ const tc = item.toolCallData;
109
+ if (tc.status === "failed" || tc.errorText) group.hasError = true;
110
+ const cat = tc.presentation.category;
111
+ if (cat === "shell") {
112
+ group.commandCount++;
113
+ } else if (cat === "file") {
114
+ if (isFileEditAction(tc)) {
115
+ group.editCount++;
116
+ } else {
117
+ group.readCount++;
118
+ }
119
+ } else if (cat === "search") {
120
+ group.searchCount++;
121
+ } else {
122
+ group.otherToolCount++;
123
+ }
124
+ return tc.status === "running";
125
+ }
126
+
127
+ /** Recompute every counter from the items, so a group whose item changed in place stays consistent. */
128
+ export function updateGroupStats(group: TurnGroup): void {
129
+ group.hasReasoning = false;
130
+ group.commandCount = 0;
131
+ group.editCount = 0;
132
+ group.readCount = 0;
133
+ group.searchCount = 0;
134
+ group.otherToolCount = 0;
135
+ group.hasError = false;
136
+ let activeCount = 0;
137
+ for (const item of group.items) {
138
+ if (accumulateItemStats(group, item)) activeCount++;
139
+ }
140
+ group.isRunning = group.isFinished ? false : activeCount > 0;
141
+ }
142
+
143
+ export function formatGroupSummary(group: TurnGroup): string {
144
+ const parts: string[] = [];
145
+
146
+ if (group.hasReasoning) {
147
+ parts.push("Thought");
148
+ }
149
+ if (group.commandCount > 0) {
150
+ parts.push(`Ran ${group.commandCount} command${group.commandCount > 1 ? "s" : ""}`);
151
+ }
152
+ if (group.editCount > 0) {
153
+ parts.push(`Edited ${group.editCount} file${group.editCount > 1 ? "s" : ""}`);
154
+ }
155
+ if (group.readCount > 0) {
156
+ parts.push(`Read ${group.readCount} file${group.readCount > 1 ? "s" : ""}`);
157
+ }
158
+ if (group.searchCount > 0) {
159
+ parts.push(`Searched ${group.searchCount} time${group.searchCount > 1 ? "s" : ""}`);
160
+ }
161
+ if (group.otherToolCount > 0) {
162
+ parts.push(`Called ${group.otherToolCount} tool${group.otherToolCount > 1 ? "s" : ""}`);
163
+ }
164
+
165
+ return parts.length > 0 ? parts.join(" · ") : "Activity";
166
+ }
167
+
168
+ export interface ActivityEntry {
169
+ item: AgentTimelineItem;
170
+ timestamp: string;
171
+ seqStart: number;
172
+ seqEnd: number;
173
+ }
174
+
175
+ /**
176
+ * Identity must stay stable while streaming text grows: it becomes the React key of the rendered
177
+ * activity row, so embedding the reasoning text would remount the subtree on every token and
178
+ * restart reveal state, scroll anchoring and measured row heights.
179
+ */
180
+ export function activityIdentity(item: AgentTimelineItem, timestamp: string): string {
181
+ return item.type === "tool_call"
182
+ ? `tool:${item.callId}`
183
+ : `${item.type}:${Date.parse(timestamp)}`;
184
+ }
185
+
186
+ /** Fold contiguous activity only: never move details across a visible message. */
187
+ export function buildActivityGroups(entries: readonly ActivityEntry[]) {
188
+ const groups = new Map<string, { group: TurnGroup; isLeader: boolean; itemId: string }>();
189
+ let group: TurnGroup | undefined;
190
+ const tools = new Map<string, ActivityEntry>();
191
+ for (const entry of entries) {
192
+ if (entry.item.type !== "tool_call") continue;
193
+ const previous = tools.get(entry.item.callId);
194
+ if (!previous || previous.seqEnd < entry.seqEnd)
195
+ tools.set(entry.item.callId, {
196
+ ...entry,
197
+ seqStart: previous?.seqStart ?? entry.seqStart,
198
+ });
199
+ }
200
+ const ordered = entries.filter((entry) => entry.item.type !== "tool_call");
201
+ ordered.push(...tools.values());
202
+ ordered.sort((a, b) => a.seqStart - b.seqStart);
203
+ const identityCounts = new Map<string, number>();
204
+ for (const entry of ordered) {
205
+ if (entry.item.type !== "reasoning") continue;
206
+ const identity = activityIdentity(entry.item, entry.timestamp);
207
+ identityCounts.set(identity, (identityCounts.get(identity) ?? 0) + 1);
208
+ }
209
+ let activeCount = 0;
210
+ const seal = (finished: boolean) => {
211
+ if (!group) return;
212
+ group.isFinished = finished;
213
+ group.isRunning = !finished && activeCount > 0;
214
+ activeCount = 0;
215
+ };
216
+ for (const entry of ordered) {
217
+ const { item, timestamp } = entry;
218
+ const startedAt = Date.parse(timestamp);
219
+ // Visible messages end contiguous activity, so folded details never cross native prose.
220
+ if (item.type === "user_message") {
221
+ seal(true);
222
+ group = undefined;
223
+ continue;
224
+ }
225
+ if (item.type === "assistant_message") {
226
+ seal(true);
227
+ group = undefined;
228
+ continue;
229
+ }
230
+ const speak =
231
+ item.type === "tool_call" &&
232
+ item.name === "speak" &&
233
+ item.detail.type === "unknown" &&
234
+ typeof item.detail.input === "string" &&
235
+ item.detail.input.trim();
236
+ if (speak) {
237
+ seal(true);
238
+ group = undefined;
239
+ continue;
240
+ }
241
+ if (item.type !== "reasoning" && item.type !== "tool_call") {
242
+ seal(true);
243
+ group = undefined;
244
+ continue;
245
+ }
246
+ const identity = activityIdentity(item, timestamp);
247
+ // Sequence positions keep thought identities stable even when provider timestamps collide.
248
+ const id = item.type === "reasoning" ? `${identity}:${entry.seqStart}` : identity;
249
+ const lookup = (identityCounts.get(identity) ?? 0) > 1 ? id : identity;
250
+ if (!group) group = createActivityGroup(entry.seqStart, id, startedAt);
251
+ const groupItem: GroupItemData = {
252
+ id,
253
+ type: item.type,
254
+ timestamp: startedAt,
255
+ ...(item.type === "reasoning"
256
+ ? { reasoningData: createReasoningData(item, "complete") }
257
+ : { toolCallData: createToolCallData(item) }),
258
+ };
259
+ group.items.push(groupItem);
260
+ if (accumulateItemStats(group, groupItem)) activeCount++;
261
+ groups.set(lookup, { group, isLeader: group.leaderId === id, itemId: id });
262
+ }
263
+ seal(false);
264
+ return groups;
265
+ }