canopy-ui 0.3.0 → 0.6.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.
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import type { Message } from "./protocol"
3
+ import { prependHistory } from "./history"
4
+
5
+ function msg(turn_index: number, plaintext = `m${turn_index}`): Message {
6
+ return {
7
+ id: `t${turn_index}`, turn_index, role: "user", content: {}, plaintext,
8
+ status: "complete", error_detail: null, started_at: null, completed_at: null,
9
+ created_at: "",
10
+ }
11
+ }
12
+
13
+ describe("prependHistory", () => {
14
+ it("prepends older messages ahead of current, chronological", () => {
15
+ const current = [msg(30), msg(31)]
16
+ const older = [msg(28), msg(29)]
17
+ expect(prependHistory(current, older).map((m) => m.turn_index)).toEqual([28, 29, 30, 31])
18
+ })
19
+
20
+ it("dedupes on turn_index (an overlapping page is not double-inserted)", () => {
21
+ const current = [msg(30), msg(31)]
22
+ const older = [msg(29), msg(30)] // 30 overlaps
23
+ expect(prependHistory(current, older).map((m) => m.turn_index)).toEqual([29, 30, 31])
24
+ })
25
+
26
+ it("returns the same reference when older is empty", () => {
27
+ const current = [msg(30)]
28
+ expect(prependHistory(current, [])).toBe(current)
29
+ })
30
+
31
+ it("returns the same reference when every older row already exists", () => {
32
+ const current = [msg(30), msg(31)]
33
+ expect(prependHistory(current, [msg(30)])).toBe(current)
34
+ })
35
+ })
@@ -0,0 +1,16 @@
1
+ import type { Message } from "./protocol";
2
+
3
+ /**
4
+ * Merge an older page (from the REST scroll-back endpoint) into the current
5
+ * transcript: prepend, dedupe by turn_index, keep chronological. Pure — no React,
6
+ * no WebSocket — so the container can apply "Load earlier" to the socket's
7
+ * SessionState without a new WS frame. Returns `current` unchanged (same
8
+ * reference) when nothing new prepends, so callers can skip a re-render.
9
+ */
10
+ export function prependHistory(current: Message[], older: Message[]): Message[] {
11
+ if (older.length === 0) return current;
12
+ const seen = new Set(current.map((m) => m.turn_index));
13
+ const fresh = older.filter((m) => !seen.has(m.turn_index));
14
+ if (fresh.length === 0) return current;
15
+ return [...fresh, ...current].sort((a, b) => a.turn_index - b.turn_index);
16
+ }
@@ -0,0 +1,56 @@
1
+ // canopy-ui/chat — a reusable, app-agnostic multiplayer chat kit.
2
+ //
3
+ // Speaks the canonical chat WebSocket protocol (ace-web's contract; string
4
+ // ids). App specifics — the ws URL builder, the markdown renderer, session
5
+ // meta — are injected. The presentational tree is props-in / callbacks-out.
6
+
7
+ // Protocol types
8
+ export type {
9
+ Message,
10
+ MessageRole,
11
+ MessageStatus,
12
+ Draft,
13
+ Participant,
14
+ SessionState,
15
+ WsAction,
16
+ WsEvent,
17
+ } from "./protocol";
18
+
19
+ // Reducer (pure)
20
+ export { sessionReducer } from "./sessionReducer";
21
+ export { prependHistory } from "./history";
22
+
23
+ // Hooks
24
+ export {
25
+ useSessionSocket,
26
+ type UseSessionSocketOptions,
27
+ type UseSessionSocketResult,
28
+ } from "./useSessionSocket";
29
+ export { useStickyBottom } from "./useStickyBottom";
30
+
31
+ // Draft idle helpers
32
+ export { IDLE_THRESHOLD_MS, isDraftIdle, msUntilDraftIdle } from "./drafts";
33
+
34
+ // Tool-message pairing helpers
35
+ export {
36
+ pairToolMessages,
37
+ deriveToolStatus,
38
+ toolPreview,
39
+ toolDisplayName,
40
+ type ChatRow,
41
+ type ToolCallStatus,
42
+ } from "./pairToolMessages";
43
+
44
+ // Presentational components
45
+ export { ChatPanel, type ChatPanelProps } from "./ChatPanel";
46
+ export { MessageList } from "./MessageList";
47
+ export { MessageItem, type RenderMarkdown } from "./MessageItem";
48
+ export { ToolCallPair } from "./ToolCallPair";
49
+ export { SendBox, type PendingAttachment } from "./SendBox";
50
+ export { PresenceChips } from "./PresenceChips";
51
+ export { ConnectionStatus } from "./ConnectionStatus";
52
+ export {
53
+ PlacementBanner,
54
+ type PlacementBannerProps,
55
+ type PlacementRunner,
56
+ } from "./PlacementBanner";
@@ -0,0 +1,360 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import type { Message } from "./protocol";
4
+ import {
5
+ deriveToolStatus,
6
+ pairToolMessages,
7
+ toolDisplayName,
8
+ toolPreview,
9
+ } from "./pairToolMessages";
10
+
11
+ let seq = 0;
12
+ function msg(
13
+ partial: Partial<Message> & { id: string; role: Message["role"] },
14
+ ): Message {
15
+ return {
16
+ turn_index: seq++,
17
+ content: {},
18
+ plaintext: "",
19
+ status: "complete",
20
+ error_detail: null,
21
+ started_at: null,
22
+ completed_at: null,
23
+ created_at: "2026-05-13T00:00:00Z",
24
+ ...partial,
25
+ };
26
+ }
27
+
28
+ describe("pairToolMessages", () => {
29
+ it("pairs consecutive tool_use + tool_result by id/tool_use_id", () => {
30
+ const rows = pairToolMessages([
31
+ msg({ id: "1", role: "user", plaintext: "hi" }),
32
+ msg({
33
+ id: "2",
34
+ role: "tool_use",
35
+ content: { id: "t1", name: "Bash", input: { command: "ls" } },
36
+ }),
37
+ msg({
38
+ id: "3",
39
+ role: "tool_result",
40
+ content: { tool_use_id: "t1" },
41
+ plaintext: "out",
42
+ }),
43
+ msg({ id: "4", role: "assistant", plaintext: "done" }),
44
+ ]);
45
+ expect(rows).toHaveLength(3);
46
+ expect(rows[0].kind).toBe("message");
47
+ expect(rows[1].kind).toBe("tool_pair");
48
+ if (rows[1].kind === "tool_pair") {
49
+ expect(rows[1].use.id).toBe("2");
50
+ expect(rows[1].result?.id).toBe("3");
51
+ }
52
+ expect(rows[2].kind).toBe("message");
53
+ });
54
+
55
+ it("pairs tool_use with a later non-adjacent tool_result", () => {
56
+ const rows = pairToolMessages([
57
+ msg({ id: "1", role: "tool_use", content: { id: "a", name: "X" } }),
58
+ msg({ id: "2", role: "tool_use", content: { id: "b", name: "Y" } }),
59
+ msg({ id: "3", role: "tool_result", content: { tool_use_id: "a" } }),
60
+ msg({ id: "4", role: "tool_result", content: { tool_use_id: "b" } }),
61
+ ]);
62
+ expect(rows).toHaveLength(2);
63
+ expect(rows[0].kind).toBe("tool_pair");
64
+ expect(rows[1].kind).toBe("tool_pair");
65
+ if (rows[0].kind === "tool_pair" && rows[1].kind === "tool_pair") {
66
+ expect(rows[0].use.id).toBe("1");
67
+ expect(rows[0].result?.id).toBe("3");
68
+ expect(rows[1].use.id).toBe("2");
69
+ expect(rows[1].result?.id).toBe("4");
70
+ }
71
+ });
72
+
73
+ it("leaves an in-flight tool_use with null result so UI can show pending", () => {
74
+ const rows = pairToolMessages([
75
+ msg({ id: "1", role: "tool_use", content: { id: "t1", name: "Bash" } }),
76
+ ]);
77
+ expect(rows).toHaveLength(1);
78
+ expect(rows[0].kind).toBe("tool_pair");
79
+ if (rows[0].kind === "tool_pair") {
80
+ expect(rows[0].result).toBeNull();
81
+ }
82
+ });
83
+
84
+ it("falls back to standalone when a tool_result has no matching use", () => {
85
+ const rows = pairToolMessages([
86
+ msg({ id: "1", role: "tool_result", content: { tool_use_id: "ghost" } }),
87
+ ]);
88
+ expect(rows).toHaveLength(1);
89
+ expect(rows[0].kind).toBe("message");
90
+ });
91
+
92
+ it("doesn't lose tool calls in long sessions", () => {
93
+ const msgs: Message[] = [];
94
+ for (let i = 0; i < 44; i++) {
95
+ msgs.push(
96
+ msg({
97
+ id: `${i * 2 + 1}`,
98
+ role: "tool_use",
99
+ content: { id: `t${i}`, name: "Bash" },
100
+ }),
101
+ );
102
+ if (i < 42) {
103
+ msgs.push(
104
+ msg({
105
+ id: `${i * 2 + 2}`,
106
+ role: "tool_result",
107
+ content: { tool_use_id: `t${i}` },
108
+ }),
109
+ );
110
+ }
111
+ }
112
+ const rows = pairToolMessages(msgs);
113
+ expect(rows).toHaveLength(44);
114
+ expect(rows.filter((r) => r.kind === "tool_pair")).toHaveLength(44);
115
+ // First 42 paired, last 2 pending.
116
+ const pending = rows.filter(
117
+ (r) => r.kind === "tool_pair" && r.result === null,
118
+ );
119
+ expect(pending).toHaveLength(2);
120
+ });
121
+
122
+ // The case this feature exists for: two tool calls in flight at once (Claude
123
+ // does this routinely). Their tool_start events land back-to-back, both
124
+ // still open, before either tool_end arrives — a flat order-based pairing
125
+ // would have no way to tell which result belongs to which call. The
126
+ // results also arrive OUT OF ORDER (second call finishes first), which
127
+ // order-based pairing would get flatly wrong.
128
+ it("pairs overlapping (parallel) tool calls by id, including out-of-order results", () => {
129
+ const rows = pairToolMessages([
130
+ msg({
131
+ id: "1", role: "tool_use",
132
+ content: { id: "call-a", name: "Bash", input: { command: "sleep 10" } },
133
+ }),
134
+ msg({
135
+ id: "2", role: "tool_use",
136
+ content: { id: "call-b", name: "Read", input: { file_path: "/x" } },
137
+ }),
138
+ // call-b finishes first even though call-a started first.
139
+ msg({ id: "3", role: "tool_result", content: { tool_use_id: "call-b" }, plaintext: "file contents" }),
140
+ msg({ id: "4", role: "tool_result", content: { tool_use_id: "call-a" }, plaintext: "done sleeping" }),
141
+ ]);
142
+ expect(rows).toHaveLength(2);
143
+ expect(rows[0].kind).toBe("tool_pair");
144
+ expect(rows[1].kind).toBe("tool_pair");
145
+ if (rows[0].kind === "tool_pair" && rows[1].kind === "tool_pair") {
146
+ expect(rows[0].use.id).toBe("1");
147
+ expect(rows[0].result?.id).toBe("4"); // call-a's result, not the earlier-arriving one
148
+ expect(rows[1].use.id).toBe("2");
149
+ expect(rows[1].result?.id).toBe("3"); // call-b's result
150
+ }
151
+ });
152
+
153
+ // Two overlapping calls to the SAME tool name — order/name matching alone
154
+ // is ambiguous here (that's the whole point of the id); only tool_use_id
155
+ // disambiguates which result goes with which call.
156
+ it("pairs two overlapping calls with the same tool name by id", () => {
157
+ const rows = pairToolMessages([
158
+ msg({
159
+ id: "1", role: "tool_use",
160
+ content: { id: "call-1", name: "Bash", input: { command: "echo one" } },
161
+ }),
162
+ msg({
163
+ id: "2", role: "tool_use",
164
+ content: { id: "call-2", name: "Bash", input: { command: "echo two" } },
165
+ }),
166
+ msg({ id: "3", role: "tool_result", content: { tool_use_id: "call-2" }, plaintext: "two" }),
167
+ msg({ id: "4", role: "tool_result", content: { tool_use_id: "call-1" }, plaintext: "one" }),
168
+ ]);
169
+ expect(rows).toHaveLength(2);
170
+ if (rows[0].kind === "tool_pair" && rows[1].kind === "tool_pair") {
171
+ expect(rows[0].use.id).toBe("1");
172
+ expect(rows[0].result?.id).toBe("4");
173
+ expect(rows[0].result?.plaintext).toBe("one");
174
+ expect(rows[1].use.id).toBe("2");
175
+ expect(rows[1].result?.id).toBe("3");
176
+ expect(rows[1].result?.plaintext).toBe("two");
177
+ }
178
+ });
179
+
180
+ // Backward compatibility: events with no correlation id at all (older
181
+ // ledger rows, or a runner that hasn't been updated yet) must still pair —
182
+ // falling back to the pre-id FIFO order heuristic.
183
+ it("falls back to FIFO order pairing when neither side carries an id", () => {
184
+ const rows = pairToolMessages([
185
+ msg({ id: "1", role: "tool_use", content: { name: "Bash" } }),
186
+ msg({ id: "2", role: "tool_use", content: { name: "Read" } }),
187
+ msg({ id: "3", role: "tool_result", plaintext: "first result" }),
188
+ msg({ id: "4", role: "tool_result", plaintext: "second result" }),
189
+ ]);
190
+ expect(rows).toHaveLength(2);
191
+ if (rows[0].kind === "tool_pair" && rows[1].kind === "tool_pair") {
192
+ expect(rows[0].use.id).toBe("1");
193
+ expect(rows[0].result?.id).toBe("3");
194
+ expect(rows[1].use.id).toBe("2");
195
+ expect(rows[1].result?.id).toBe("4");
196
+ }
197
+ });
198
+
199
+ // The real production shape: a session whose earlier turns predate the
200
+ // tool_use_id field (no ids) followed by a later turn from an updated
201
+ // producer (has ids) — both must pair correctly, and the id-less fallback
202
+ // queue must not accidentally absorb an id-tagged result or vice versa.
203
+ it("pairs correctly across a mixed stream (legacy no-id turn followed by an id-tagged turn)", () => {
204
+ const rows = pairToolMessages([
205
+ // Legacy turn: no ids at all.
206
+ msg({ id: "1", role: "tool_use", content: { name: "Bash" } }),
207
+ msg({ id: "2", role: "tool_result", plaintext: "legacy result" }),
208
+ // Newer turn: two parallel calls, correlated by id.
209
+ msg({ id: "3", role: "tool_use", content: { id: "new-a", name: "Bash" } }),
210
+ msg({ id: "4", role: "tool_use", content: { id: "new-b", name: "Bash" } }),
211
+ msg({ id: "5", role: "tool_result", content: { tool_use_id: "new-b" }, plaintext: "b done" }),
212
+ msg({ id: "6", role: "tool_result", content: { tool_use_id: "new-a" }, plaintext: "a done" }),
213
+ ]);
214
+ expect(rows).toHaveLength(3);
215
+ expect(rows.every((r) => r.kind === "tool_pair")).toBe(true);
216
+ if (rows[0].kind === "tool_pair" && rows[1].kind === "tool_pair" && rows[2].kind === "tool_pair") {
217
+ expect(rows[0].use.id).toBe("1");
218
+ expect(rows[0].result?.id).toBe("2");
219
+ expect(rows[1].use.id).toBe("3");
220
+ expect(rows[1].result?.id).toBe("6"); // new-a's result
221
+ expect(rows[2].use.id).toBe("4");
222
+ expect(rows[2].result?.id).toBe("5"); // new-b's result
223
+ }
224
+ });
225
+ });
226
+
227
+ describe("deriveToolStatus", () => {
228
+ it("returns pending when result is missing", () => {
229
+ const use = msg({ id: "1", role: "tool_use" });
230
+ expect(deriveToolStatus(use, null).kind).toBe("pending");
231
+ });
232
+
233
+ it("returns error when result is in error status", () => {
234
+ const use = msg({ id: "1", role: "tool_use" });
235
+ const result = msg({ id: "2", role: "tool_result", status: "error" });
236
+ expect(deriveToolStatus(use, result).kind).toBe("error");
237
+ });
238
+
239
+ it("sniffs error-like result content", () => {
240
+ const use = msg({ id: "1", role: "tool_use" });
241
+ const result = msg({
242
+ id: "2",
243
+ role: "tool_result",
244
+ plaintext: "Error: file not found",
245
+ });
246
+ expect(deriveToolStatus(use, result).kind).toBe("error");
247
+ });
248
+
249
+ it("returns success on a clean result", () => {
250
+ const use = msg({ id: "1", role: "tool_use" });
251
+ const result = msg({ id: "2", role: "tool_result", plaintext: "ok" });
252
+ expect(deriveToolStatus(use, result).kind).toBe("success");
253
+ });
254
+ });
255
+
256
+ describe("toolPreview / toolDisplayName", () => {
257
+ it("shows the first line of a Bash command", () => {
258
+ const use = msg({
259
+ id: "1",
260
+ role: "tool_use",
261
+ content: {
262
+ name: "Bash",
263
+ input: { command: "ls -la /tmp\n# more after newline" },
264
+ },
265
+ });
266
+ expect(toolPreview(use, null)).toBe("ls -la /tmp");
267
+ });
268
+
269
+ it("shows file_path for Read/Write/Edit", () => {
270
+ for (const name of ["Read", "Write", "Edit"]) {
271
+ const use = msg({
272
+ id: "1",
273
+ role: "tool_use",
274
+ content: { name, input: { file_path: "/a/b.txt" } },
275
+ });
276
+ expect(toolPreview(use, null)).toBe("/a/b.txt");
277
+ }
278
+ });
279
+
280
+ it("shows the todo count for TodoWrite", () => {
281
+ const use = msg({
282
+ id: "1",
283
+ role: "tool_use",
284
+ content: {
285
+ name: "TodoWrite",
286
+ input: { todos: [{}, {}, {}] },
287
+ },
288
+ });
289
+ expect(toolPreview(use, null)).toBe("3 todos");
290
+ });
291
+
292
+ it("shows the skill name for Skill", () => {
293
+ const use = msg({
294
+ id: "1",
295
+ role: "tool_use",
296
+ content: { name: "Skill", input: { skill: "ace:run" } },
297
+ });
298
+ expect(toolPreview(use, null)).toBe("ace:run");
299
+ });
300
+
301
+ it("strips the mcp prefix for MCP tools", () => {
302
+ const use = msg({
303
+ id: "1",
304
+ role: "tool_use",
305
+ content: {
306
+ name: "mcp__plugin_ace_ace-gdrive__drive_create_file",
307
+ input: {},
308
+ },
309
+ });
310
+ expect(toolDisplayName(use)).toBe("drive_create_file");
311
+ });
312
+ });
313
+
314
+ describe("stale pending rows", () => {
315
+ const pendingUse = (id: string): Message => ({
316
+ id: `live-${id}`, turn_index: -1, role: "tool_use",
317
+ content: { id, name: "Bash", status: "pending" }, plaintext: "",
318
+ status: "complete", error_detail: null, started_at: null, completed_at: null,
319
+ created_at: "",
320
+ })
321
+ const text = (id: string): Message => ({
322
+ id, turn_index: 10, role: "assistant", content: {}, plaintext: "done",
323
+ status: "complete", error_detail: null, started_at: null, completed_at: null,
324
+ created_at: "",
325
+ })
326
+ const resultFor = (id: string): Message => ({
327
+ id: `r-${id}`, turn_index: 20, role: "tool_result",
328
+ content: { tool_use_id: id }, plaintext: "out",
329
+ status: "complete", error_detail: null, started_at: null, completed_at: null,
330
+ created_at: "",
331
+ })
332
+
333
+ it("keeps a pending row while it is the newest thing — it IS running", () => {
334
+ const rows = pairToolMessages([text("a"), pendingUse("t1")])
335
+ expect(rows).toHaveLength(2)
336
+ expect(rows[1]).toMatchObject({ kind: "tool_pair", result: null })
337
+ })
338
+
339
+ it("drops a pending row once later activity proves it is not in flight", () => {
340
+ // The live bug: PreToolUse fired, its PostToolUse never arrived, and the row
341
+ // rendered "running…" forever — while a reload showed nothing, because the
342
+ // durable transcript never had it.
343
+ const rows = pairToolMessages([pendingUse("t1"), text("a")])
344
+ expect(rows).toHaveLength(1)
345
+ expect(rows[0].kind).toBe("message")
346
+ })
347
+
348
+ it("keeps a pending row whose result DID arrive, so the pair renders", () => {
349
+ const rows = pairToolMessages([pendingUse("t1"), resultFor("t1"), text("a")])
350
+ const pair = rows.find((r) => r.kind === "tool_pair")
351
+ expect(pair).toBeDefined()
352
+ expect((pair as { result: Message | null }).result).not.toBeNull()
353
+ })
354
+
355
+ it("leaves ordinary completed tool rows untouched", () => {
356
+ const done: Message = { ...pendingUse("t9"), content: { id: "t9", name: "Bash" } }
357
+ const rows = pairToolMessages([done, resultFor("t9"), text("a")])
358
+ expect(rows.filter((r) => r.kind === "tool_pair")).toHaveLength(1)
359
+ })
360
+ })