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.
- package/LICENSE +21 -0
- package/LICENSE.compact-agent-activity +21 -0
- package/LICENSE.mermaid +21 -0
- package/README.md +428 -2
- package/README_zh.md +429 -0
- package/client/activity/Activity.test.ts +220 -0
- package/client/activity/Activity.tsx +784 -0
- package/client/activity/detail/detail.test.ts +102 -0
- package/client/activity/detail/dispatch.tsx +94 -0
- package/client/activity/detail/file.tsx +93 -0
- package/client/activity/detail/index.tsx +39 -0
- package/client/activity/detail/parts.tsx +221 -0
- package/client/activity/detail/search.tsx +59 -0
- package/client/activity/detail/terminal.tsx +53 -0
- package/client/activity/detail/texts.tsx +42 -0
- package/client/activity/disclosure.ts +49 -0
- package/client/activity/dom.test.ts +52 -0
- package/client/activity/dom.ts +138 -0
- package/client/activity/group-store.test.ts +126 -0
- package/client/activity/group-store.ts +265 -0
- package/client/activity/highlight.test.ts +116 -0
- package/client/activity/highlight.ts +418 -0
- package/client/activity/history.test.ts +602 -0
- package/client/activity/history.ts +239 -0
- package/client/activity/index.ts +74 -0
- package/client/activity/latest-marker.test.ts +41 -0
- package/client/activity/latest-marker.ts +70 -0
- package/client/activity/styles.ts +535 -0
- package/client/activity/tools/child-agent.tsx +308 -0
- package/client/activity/tools/exa.tsx +99 -0
- package/client/activity/tools/github.tsx +501 -0
- package/client/activity/tools/list.tsx +102 -0
- package/client/activity/tools/lists.test.ts +193 -0
- package/client/activity/tools/paseo/agent.tsx +384 -0
- package/client/activity/tools/paseo/browser.tsx +322 -0
- package/client/activity/tools/paseo/index.tsx +79 -0
- package/client/activity/tools/paseo/parts.tsx +133 -0
- package/client/activity/tools/paseo/provider.tsx +220 -0
- package/client/activity/tools/paseo/schedule.tsx +246 -0
- package/client/activity/tools/paseo/speak.tsx +20 -0
- package/client/activity/tools/paseo/terminal.tsx +214 -0
- package/client/activity/tools/paseo/workspace.tsx +162 -0
- package/client/activity/tools/shared.tsx +431 -0
- package/client/activity/transform.test.ts +28 -0
- package/client/activity/transform.ts +43 -0
- package/client/agent-pills.ts +56 -0
- package/client/dom.ts +75 -0
- package/client/enhance/controller.ts +111 -0
- package/client/enhance/dom.test.ts +28 -0
- package/client/enhance/dom.ts +101 -0
- package/client/enhance/index.ts +95 -0
- package/client/enhance/pill.tsx +19 -0
- package/client/enhance/state.ts +58 -0
- package/client/feature.ts +22 -0
- package/client/features.test.ts +149 -0
- package/client/features.ts +83 -0
- package/client/locale.ts +45 -0
- package/client/mermaid/diagram.tsx +438 -0
- package/client/mermaid/index.ts +27 -0
- package/client/mermaid/item.tsx +28 -0
- package/client/mermaid/markdown.tsx +217 -0
- package/client/mermaid/registration.ts +32 -0
- package/client/mermaid/toolbar.tsx +175 -0
- package/client/mermaid/viewer.tsx +314 -0
- package/client/readonly/dom.test.ts +66 -0
- package/client/readonly/dom.ts +32 -0
- package/client/readonly/index.ts +27 -0
- package/client/registration.test.ts +243 -0
- package/client/settings.tsx +438 -0
- package/client/sticky/StickyBar.tsx +144 -0
- package/client/sticky/dom.test.ts +230 -0
- package/client/sticky/dom.ts +517 -0
- package/client/sticky/index.ts +38 -0
- package/client/sticky/pill.tsx +70 -0
- package/client/timeline-pager.test.ts +121 -0
- package/client/timeline-pager.ts +123 -0
- package/index.client.tsx +44 -0
- package/index.server.ts +12 -0
- package/package.json +42 -4
- package/paseo-plugin.json +6 -0
- package/server/enhance/index.ts +63 -0
- package/server/enhance/manifest.ts +176 -0
- package/server/enhance/probe.test.ts +215 -0
- package/server/enhance/probe.ts +118 -0
- package/server/enhance/provider.ts +149 -0
- package/shared/activity/child-agent.test.ts +27 -0
- package/shared/activity/child-agent.ts +26 -0
- package/shared/activity/diff.test.ts +74 -0
- package/shared/activity/diff.ts +207 -0
- package/shared/activity/exa.test.ts +66 -0
- package/shared/activity/exa.ts +183 -0
- package/shared/activity/file-kind.test.ts +13 -0
- package/shared/activity/file-kind.ts +112 -0
- package/shared/activity/github.test.ts +56 -0
- package/shared/activity/github.ts +174 -0
- package/shared/activity/palette.test.ts +26 -0
- package/shared/activity/palette.ts +106 -0
- package/shared/activity/parse.test.ts +39 -0
- package/shared/activity/parse.ts +115 -0
- package/shared/activity/paseo-tools.test.ts +163 -0
- package/shared/activity/paseo-tools.ts +497 -0
- package/shared/activity/text.test.ts +85 -0
- package/shared/activity/text.ts +202 -0
- package/shared/activity/timeline.test.ts +26 -0
- package/shared/activity/timeline.ts +108 -0
- package/shared/activity/tool-presentation.test.ts +154 -0
- package/shared/activity/tool-presentation.ts +352 -0
- package/shared/enhance/contract.ts +105 -0
- package/shared/enhance/decide.ts +97 -0
- package/shared/enhance/enhance.test.ts +188 -0
- package/shared/enhance/failure.ts +40 -0
- package/shared/enhance/index.ts +8 -0
- package/shared/enhance/normalize.ts +16 -0
- package/shared/enhance/parse.ts +79 -0
- package/shared/enhance/policy.ts +48 -0
- package/shared/enhance/prompt.ts +134 -0
- package/shared/enhance/run.ts +84 -0
- package/shared/i18n.test.ts +81 -0
- package/shared/i18n.ts +300 -0
- package/shared/mermaid/flowchart.test.ts +169 -0
- package/shared/mermaid/flowchart.ts +271 -0
- package/shared/mermaid/layout.test.ts +305 -0
- package/shared/mermaid/layout.ts +496 -0
- package/shared/mermaid/parse.test.ts +110 -0
- package/shared/mermaid/parse.ts +193 -0
- package/shared/mermaid/segment.test.ts +55 -0
- package/shared/mermaid/segment.ts +95 -0
- package/shared/mermaid/sequence.test.ts +68 -0
- package/shared/mermaid/sequence.ts +130 -0
- package/shared/mermaid/transform.test.ts +52 -0
- package/shared/mermaid/transform.ts +45 -0
- package/shared/mermaid/zoom.test.ts +134 -0
- package/shared/mermaid/zoom.ts +114 -0
- package/shared/settings.ts +41 -0
- package/shared/sticky/history.test.ts +91 -0
- package/shared/sticky/history.ts +86 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useSyncExternalStore } from "react";
|
|
2
|
+
import { usePaseo } from "@getpaseo/plugin/client";
|
|
3
|
+
import type { PaseoApi, PaseoAgentTimelineHandle } from "@getpaseo/client";
|
|
4
|
+
import type { ReasoningItemData, ToolCallItemData } from "../../shared/activity/timeline";
|
|
5
|
+
import { createTimelinePager, type TimelinePage } from "../timeline-pager";
|
|
6
|
+
import type { TurnGroup, ActivityEntry } from "./group-store";
|
|
7
|
+
import { buildActivityGroups } from "./group-store";
|
|
8
|
+
|
|
9
|
+
export interface ResolvedActivity {
|
|
10
|
+
group: TurnGroup;
|
|
11
|
+
isLeader: boolean;
|
|
12
|
+
itemId: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function mergeResolvedGroups(
|
|
16
|
+
target: Map<string, ResolvedActivity>,
|
|
17
|
+
freshGroups: Map<string, ResolvedActivity>,
|
|
18
|
+
) {
|
|
19
|
+
for (const [key, fresh] of freshGroups) {
|
|
20
|
+
const existing = target.get(key);
|
|
21
|
+
if (!existing) {
|
|
22
|
+
target.set(key, fresh);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
// 规则 1:一旦当前计算明确条目为 Follower,坚决更新为 Follower,彻底清除因过往分页截断残留的虚假 Leader
|
|
26
|
+
if (!fresh.isLeader) {
|
|
27
|
+
const mergedGroup =
|
|
28
|
+
existing.group.items.length > fresh.group.items.length ? existing.group : fresh.group;
|
|
29
|
+
target.set(key, { ...fresh, group: mergedGroup, isLeader: false });
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
// 规则 2:若当前 fresh 局部算出来是 Leader,但历史已有记录已经把它判为了 Follower:
|
|
33
|
+
// 这说明 fresh 是一次被分页截断的局部断层,坚决压制为 Follower!
|
|
34
|
+
if (!existing.isLeader && fresh.isLeader) {
|
|
35
|
+
const mergedGroup =
|
|
36
|
+
fresh.group.items.length >= existing.group.items.length ? fresh.group : existing.group;
|
|
37
|
+
target.set(key, { ...fresh, group: mergedGroup, isLeader: false });
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
// 规则 3:双方都是 Leader 时,保留 items 更多、更完整的组
|
|
41
|
+
if (existing.group.items.length > fresh.group.items.length) {
|
|
42
|
+
if (fresh.group.isFinished) existing.group.isFinished = true;
|
|
43
|
+
if (fresh.group.hasError) existing.group.hasError = true;
|
|
44
|
+
if (!fresh.group.isRunning) existing.group.isRunning = false;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
target.set(key, fresh);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function createHistory(timeline: PaseoAgentTimelineHandle) {
|
|
52
|
+
const entries = new Map<number, ActivityEntry>();
|
|
53
|
+
const resolvedMemory = new Map<string, ResolvedActivity>();
|
|
54
|
+
const listeners = new Set<() => void>();
|
|
55
|
+
let snapshot = buildActivityGroups([]);
|
|
56
|
+
let loaded = false;
|
|
57
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
58
|
+
const publish = () => {
|
|
59
|
+
const freshGroups = buildActivityGroups([...entries.values()]);
|
|
60
|
+
mergeResolvedGroups(resolvedMemory, freshGroups);
|
|
61
|
+
snapshot = new Map(resolvedMemory);
|
|
62
|
+
for (const listener of listeners) listener();
|
|
63
|
+
};
|
|
64
|
+
const pager = createTimelinePager(timeline, {
|
|
65
|
+
apply(page: TimelinePage, reset: boolean) {
|
|
66
|
+
let changed = !loaded;
|
|
67
|
+
loaded = true;
|
|
68
|
+
if (reset) {
|
|
69
|
+
changed = entries.size > 0 || resolvedMemory.size > 0;
|
|
70
|
+
entries.clear();
|
|
71
|
+
resolvedMemory.clear();
|
|
72
|
+
}
|
|
73
|
+
for (const entry of page.entries) {
|
|
74
|
+
const previous = entries.get(entry.seqStart);
|
|
75
|
+
if (
|
|
76
|
+
(!previous || previous.seqEnd <= entry.seqEnd) &&
|
|
77
|
+
JSON.stringify(previous) !== JSON.stringify(entry)
|
|
78
|
+
) {
|
|
79
|
+
entries.set(entry.seqStart, entry);
|
|
80
|
+
changed = true;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return changed;
|
|
84
|
+
},
|
|
85
|
+
onChange: publish,
|
|
86
|
+
});
|
|
87
|
+
let subscription: ReturnType<PaseoAgentTimelineHandle["subscribe"]> | undefined;
|
|
88
|
+
const start = () => {
|
|
89
|
+
if (subscription) return;
|
|
90
|
+
pager.resume();
|
|
91
|
+
subscription = timeline.subscribe(({ event }) => {
|
|
92
|
+
if (event.type === "replacement") {
|
|
93
|
+
// A replaced session makes every read in flight stale, so drop them, then read the new tail.
|
|
94
|
+
pager.invalidate();
|
|
95
|
+
entries.clear();
|
|
96
|
+
resolvedMemory.clear();
|
|
97
|
+
loaded = false;
|
|
98
|
+
publish();
|
|
99
|
+
pager.load(true);
|
|
100
|
+
} else if (event.type === "subscription_restored") {
|
|
101
|
+
// Restored delivery does not replace history. Keep cached row heights while checking
|
|
102
|
+
// for missed output; the epoch guard clears the window only if it actually changed.
|
|
103
|
+
pager.load(true);
|
|
104
|
+
} else if (event.type === "timeline" && !timer) {
|
|
105
|
+
timer = setTimeout(() => {
|
|
106
|
+
timer = undefined;
|
|
107
|
+
pager.load(true);
|
|
108
|
+
}, 100);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
void subscription.ready.then(() => pager.load(true)).catch(() => {});
|
|
112
|
+
};
|
|
113
|
+
return {
|
|
114
|
+
refs: 0,
|
|
115
|
+
start,
|
|
116
|
+
snapshot: () => snapshot,
|
|
117
|
+
getResolved: (key: string) => resolvedMemory.get(key),
|
|
118
|
+
loaded: () => loaded,
|
|
119
|
+
subscribe(listener: () => void) {
|
|
120
|
+
listeners.add(listener);
|
|
121
|
+
return () => {
|
|
122
|
+
listeners.delete(listener);
|
|
123
|
+
};
|
|
124
|
+
},
|
|
125
|
+
older() {
|
|
126
|
+
pager.load(false);
|
|
127
|
+
},
|
|
128
|
+
pause() {
|
|
129
|
+
pager.stop();
|
|
130
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
131
|
+
timer = undefined;
|
|
132
|
+
subscription?.();
|
|
133
|
+
subscription = undefined;
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let histories = new WeakMap<PaseoApi, Map<string, ReturnType<typeof createHistory>>>();
|
|
139
|
+
const active = new Set<ReturnType<typeof createHistory>>();
|
|
140
|
+
export function cleanupActivityHistory() {
|
|
141
|
+
for (const history of active) history.pause();
|
|
142
|
+
active.clear();
|
|
143
|
+
histories = new WeakMap();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function useActivityGroup(
|
|
147
|
+
agentId: string,
|
|
148
|
+
timestamp: Date,
|
|
149
|
+
data: ReasoningItemData | ToolCallItemData,
|
|
150
|
+
enabled: boolean,
|
|
151
|
+
) {
|
|
152
|
+
const paseo = usePaseo();
|
|
153
|
+
const history = useMemo(() => {
|
|
154
|
+
if (!enabled) return undefined;
|
|
155
|
+
let agents = histories.get(paseo);
|
|
156
|
+
if (!agents) {
|
|
157
|
+
agents = new Map();
|
|
158
|
+
histories.set(paseo, agents);
|
|
159
|
+
}
|
|
160
|
+
let session = agents.get(agentId);
|
|
161
|
+
if (!session) {
|
|
162
|
+
session = createHistory(paseo.agents.ref(agentId).timeline);
|
|
163
|
+
agents.set(agentId, session);
|
|
164
|
+
active.add(session);
|
|
165
|
+
}
|
|
166
|
+
return session;
|
|
167
|
+
}, [paseo, agentId, enabled]);
|
|
168
|
+
useEffect(() => {
|
|
169
|
+
if (!history) return;
|
|
170
|
+
history.refs++;
|
|
171
|
+
history.start();
|
|
172
|
+
return () => {
|
|
173
|
+
history.refs--;
|
|
174
|
+
void Promise.resolve().then(() => {
|
|
175
|
+
if (history.refs === 0) {
|
|
176
|
+
// Virtualization can remove every activity row while the agent is still open.
|
|
177
|
+
// Stop network work, but retain folding data so remounts do not grow then shrink rows.
|
|
178
|
+
history.pause();
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
};
|
|
182
|
+
}, [history, paseo, agentId]);
|
|
183
|
+
const snapshot = useSyncExternalStore(
|
|
184
|
+
history?.subscribe ?? noSubscribe,
|
|
185
|
+
history?.snapshot ?? emptySnapshot,
|
|
186
|
+
emptySnapshot,
|
|
187
|
+
);
|
|
188
|
+
// Keep this key aligned with activityIdentity so it stays stable while the text streams.
|
|
189
|
+
const key = "name" in data ? `tool:${data.callId}` : `reasoning:${timestamp.getTime()}`;
|
|
190
|
+
const previous = useRef<
|
|
191
|
+
| {
|
|
192
|
+
history: typeof history;
|
|
193
|
+
timestamp: number;
|
|
194
|
+
text: string;
|
|
195
|
+
key: string;
|
|
196
|
+
}
|
|
197
|
+
| undefined
|
|
198
|
+
>(undefined);
|
|
199
|
+
let result = snapshot.get(key) ?? history?.getResolved(key);
|
|
200
|
+
if (!("name" in data)) {
|
|
201
|
+
const matches = (entry: typeof result): boolean => {
|
|
202
|
+
const thought = entry?.group.items.find((item) => item.id === entry.itemId)?.reasoningData
|
|
203
|
+
?.text;
|
|
204
|
+
return thought !== undefined && (thought === data.text || data.text.startsWith(thought));
|
|
205
|
+
};
|
|
206
|
+
const cached = previous.current;
|
|
207
|
+
let resolvedKey = key;
|
|
208
|
+
if (!matches(result)) {
|
|
209
|
+
const candidates = [...snapshot].filter(
|
|
210
|
+
([identity, entry]) => identity.startsWith(`${key}:`) && matches(entry),
|
|
211
|
+
);
|
|
212
|
+
if (candidates.length === 1) {
|
|
213
|
+
[resolvedKey, result] = candidates[0]!;
|
|
214
|
+
} else if (
|
|
215
|
+
history &&
|
|
216
|
+
cached?.history === history &&
|
|
217
|
+
cached.timestamp === timestamp.getTime() &&
|
|
218
|
+
cached.text.length > 0 &&
|
|
219
|
+
data.text.startsWith(cached.text)
|
|
220
|
+
) {
|
|
221
|
+
resolvedKey = cached.key;
|
|
222
|
+
result = matches(snapshot.get(resolvedKey)) ? snapshot.get(resolvedKey) : undefined;
|
|
223
|
+
} else {
|
|
224
|
+
result = undefined;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
previous.current = result
|
|
228
|
+
? { history, timestamp: timestamp.getTime(), text: data.text, key: resolvedKey }
|
|
229
|
+
: undefined;
|
|
230
|
+
}
|
|
231
|
+
useEffect(() => {
|
|
232
|
+
// start() owns the initial tail read. Only paginate once that read has resolved.
|
|
233
|
+
if (history?.loaded() && !result) history.older();
|
|
234
|
+
}, [history, result, snapshot]);
|
|
235
|
+
return result;
|
|
236
|
+
}
|
|
237
|
+
const empty = buildActivityGroups([]);
|
|
238
|
+
const emptySnapshot = () => empty;
|
|
239
|
+
const noSubscribe = () => () => {};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
|
2
|
+
import type { FeatureDomain } from "../feature";
|
|
3
|
+
import {
|
|
4
|
+
REASONING_RENDERER_KIND,
|
|
5
|
+
TOOL_CALL_RENDERER_KIND,
|
|
6
|
+
reasoningItemDataSchema,
|
|
7
|
+
toolCallItemDataSchema,
|
|
8
|
+
} from "../../shared/activity/timeline";
|
|
9
|
+
import { ColorfulReasoning, ColorfulToolCall } from "./Activity";
|
|
10
|
+
import { cleanupActivityDisclosure } from "./disclosure";
|
|
11
|
+
import { setupCompactActivityStyles } from "./dom";
|
|
12
|
+
import { cleanupActivityHistory } from "./history";
|
|
13
|
+
import { transformReasoning, transformToolCall } from "./transform";
|
|
14
|
+
|
|
15
|
+
/** Each installation owns its registrations; disabling restores the host's native renderers. */
|
|
16
|
+
function registerActivity(client: PluginClientContext) {
|
|
17
|
+
const removeStyles = setupCompactActivityStyles();
|
|
18
|
+
const removeReasoningRenderer = client.addTimelineRenderer({
|
|
19
|
+
kind: REASONING_RENDERER_KIND,
|
|
20
|
+
version: 1,
|
|
21
|
+
schema: reasoningItemDataSchema,
|
|
22
|
+
Component: ColorfulReasoning,
|
|
23
|
+
});
|
|
24
|
+
const removeToolRenderer = client.addTimelineRenderer({
|
|
25
|
+
kind: TOOL_CALL_RENDERER_KIND,
|
|
26
|
+
version: 1,
|
|
27
|
+
schema: toolCallItemDataSchema,
|
|
28
|
+
Component: ColorfulToolCall,
|
|
29
|
+
});
|
|
30
|
+
const removeReasoning = client.addTimelineTransformer({
|
|
31
|
+
id: "reasoning",
|
|
32
|
+
query: { itemType: "reasoning" },
|
|
33
|
+
transform: transformReasoning,
|
|
34
|
+
});
|
|
35
|
+
const removeTools = client.addTimelineTransformer({
|
|
36
|
+
id: "tool-calls",
|
|
37
|
+
query: { itemType: "tool_call" },
|
|
38
|
+
transform: transformToolCall,
|
|
39
|
+
});
|
|
40
|
+
return () => {
|
|
41
|
+
removeReasoning();
|
|
42
|
+
removeTools();
|
|
43
|
+
removeReasoningRenderer();
|
|
44
|
+
removeToolRenderer();
|
|
45
|
+
removeStyles();
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createActivityDomain(
|
|
50
|
+
client: PluginClientContext,
|
|
51
|
+
): FeatureDomain<"compactActivityEnabled"> {
|
|
52
|
+
let detach: (() => void) | undefined;
|
|
53
|
+
return {
|
|
54
|
+
settingKey: "compactActivityEnabled",
|
|
55
|
+
setEnabled(enabled) {
|
|
56
|
+
if (enabled === (detach !== undefined)) return;
|
|
57
|
+
if (detach) {
|
|
58
|
+
detach();
|
|
59
|
+
detach = undefined;
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
detach = registerActivity(client);
|
|
63
|
+
},
|
|
64
|
+
dispose() {
|
|
65
|
+
// Deliberately not in the detach handle: mounted rows release their history in effect cleanup,
|
|
66
|
+
// and React can batch a rapid off/on without unmounting them, so disposing on disable would
|
|
67
|
+
// leave a mounted row holding dead history.
|
|
68
|
+
cleanupActivityHistory();
|
|
69
|
+
cleanupActivityDisclosure();
|
|
70
|
+
detach?.();
|
|
71
|
+
detach = undefined;
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createLatestMarker } from "./latest-marker";
|
|
3
|
+
|
|
4
|
+
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
5
|
+
|
|
6
|
+
describe("latest activity marker", () => {
|
|
7
|
+
it("keeps the newest timestamp per agent and ignores older writes", () => {
|
|
8
|
+
const marker = createLatestMarker();
|
|
9
|
+
marker.mark("agent-1", 200);
|
|
10
|
+
marker.mark("agent-1", 100);
|
|
11
|
+
marker.mark("agent-2", 50);
|
|
12
|
+
|
|
13
|
+
expect(marker.snapshot("agent-1")).toBe(200);
|
|
14
|
+
expect(marker.snapshot("agent-2")).toBe(50);
|
|
15
|
+
expect(marker.snapshot("unknown")).toBe(0);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("wakes subscribers once for every burst of markers", async () => {
|
|
19
|
+
const marker = createLatestMarker();
|
|
20
|
+
let notifications = 0;
|
|
21
|
+
const unsubscribe = marker.subscribe(() => {
|
|
22
|
+
notifications += 1;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
marker.mark("agent-1", 1);
|
|
26
|
+
marker.mark("agent-1", 2);
|
|
27
|
+
marker.mark("agent-1", 3);
|
|
28
|
+
expect(notifications).toBe(0);
|
|
29
|
+
await flush();
|
|
30
|
+
expect(notifications).toBe(1);
|
|
31
|
+
|
|
32
|
+
marker.mark("agent-1", 1);
|
|
33
|
+
await flush();
|
|
34
|
+
expect(notifications).toBe(1);
|
|
35
|
+
|
|
36
|
+
unsubscribe();
|
|
37
|
+
marker.mark("agent-1", 4);
|
|
38
|
+
await flush();
|
|
39
|
+
expect(notifications).toBe(1);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Latest-marker writes happen while rendering the row that owns them, so notifying subscribers
|
|
5
|
+
* inline would update sibling components mid-render. Defer and coalesce instead: one microtask
|
|
6
|
+
* wakes every subscriber once, no matter how many rows marked during the commit.
|
|
7
|
+
*/
|
|
8
|
+
function createMarkerNotifier() {
|
|
9
|
+
const listeners = new Set<() => void>();
|
|
10
|
+
let scheduled = false;
|
|
11
|
+
return {
|
|
12
|
+
subscribe(listener: () => void) {
|
|
13
|
+
listeners.add(listener);
|
|
14
|
+
return () => {
|
|
15
|
+
listeners.delete(listener);
|
|
16
|
+
};
|
|
17
|
+
},
|
|
18
|
+
schedule() {
|
|
19
|
+
if (scheduled) return;
|
|
20
|
+
scheduled = true;
|
|
21
|
+
// Deferred to a microtask: this module targets React Native too, where queueMicrotask and the
|
|
22
|
+
// DOM lib are both unavailable.
|
|
23
|
+
void Promise.resolve().then(() => {
|
|
24
|
+
scheduled = false;
|
|
25
|
+
for (const listener of listeners) listener();
|
|
26
|
+
});
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Both markers share one flush, so a commit that marks reasoning and a tool call costs one wake-up. */
|
|
32
|
+
const markerNotifier = createMarkerNotifier();
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Highest timestamp seen per agent, so a row can tell whether it is the newest activity of its
|
|
36
|
+
* agent on screen.
|
|
37
|
+
*/
|
|
38
|
+
export function createLatestMarker() {
|
|
39
|
+
const timestamps = new Map<string, number>();
|
|
40
|
+
return {
|
|
41
|
+
snapshot: (agentId: string) => timestamps.get(agentId) ?? 0,
|
|
42
|
+
subscribe: markerNotifier.subscribe,
|
|
43
|
+
/** Older timestamps never win: a replayed history must not move the marker backwards. */
|
|
44
|
+
mark(agentId: string, timestamp: number) {
|
|
45
|
+
if (timestamp <= (timestamps.get(agentId) ?? 0)) return;
|
|
46
|
+
timestamps.set(agentId, timestamp);
|
|
47
|
+
markerNotifier.schedule();
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type LatestMarker = ReturnType<typeof createLatestMarker>;
|
|
53
|
+
|
|
54
|
+
/** True while the row is active, and afterwards only for the row holding the newest timestamp. */
|
|
55
|
+
export function useIsLatestMarker(
|
|
56
|
+
marker: LatestMarker,
|
|
57
|
+
agentId: string,
|
|
58
|
+
timestamp: Date,
|
|
59
|
+
active: boolean,
|
|
60
|
+
): boolean {
|
|
61
|
+
const itemTime = timestamp.getTime();
|
|
62
|
+
if (active || itemTime > marker.snapshot(agentId)) marker.mark(agentId, itemTime);
|
|
63
|
+
|
|
64
|
+
const latestTime = useSyncExternalStore(
|
|
65
|
+
marker.subscribe,
|
|
66
|
+
() => marker.snapshot(agentId),
|
|
67
|
+
() => 0,
|
|
68
|
+
);
|
|
69
|
+
return active || (latestTime > 0 && itemTime >= latestTime);
|
|
70
|
+
}
|