@marimo-team/islands 0.15.3 → 0.15.5

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 (88) hide show
  1. package/dist/{ConnectedDataExplorerComponent-DfvW3rBn.js → ConnectedDataExplorerComponent-CBeIYi8p.js} +2 -2
  2. package/dist/{ImageComparisonComponent-XaJshw7d.js → ImageComparisonComponent-Bk0a0xBq.js} +1 -1
  3. package/dist/{_baseUniq-dN9WKF9m.js → _baseUniq-utU5_Vu-.js} +1 -1
  4. package/dist/{any-language-editor-CpFniVi-.js → any-language-editor-PrUUh2lr.js} +1 -1
  5. package/dist/{architectureDiagram-W76B3OCA-Bpg85ZKv.js → architectureDiagram-W76B3OCA-D-vOp0UU.js} +4 -4
  6. package/dist/assets/{worker-Y-Q4G-N2.js → worker-BcG8m3h5.js} +3 -3
  7. package/dist/{blockDiagram-QIGZ2CNN-DS1kOHlW.js → blockDiagram-QIGZ2CNN-IG-z8q8A.js} +5 -5
  8. package/dist/{c4Diagram-FPNF74CW-CyRVKssw.js → c4Diagram-FPNF74CW-5AEXIX3t.js} +2 -2
  9. package/dist/{channel-BilGXox7.js → channel-ECVsTGGL.js} +1 -1
  10. package/dist/{chunk-4BX2VUAB-CZR39zCO.js → chunk-4BX2VUAB-DfJcd9e-.js} +1 -1
  11. package/dist/{chunk-55IACEB6-BIH-MYov.js → chunk-55IACEB6-BwT8MejR.js} +1 -1
  12. package/dist/{chunk-FMBD7UC4-4PZXFZE8.js → chunk-FMBD7UC4-DW7uxNR6.js} +1 -1
  13. package/dist/{chunk-K7UQS3LO-CEvWKznk.js → chunk-K7UQS3LO-BGn2ZPDQ.js} +4 -4
  14. package/dist/{chunk-QN33PNHL-D5LO5Jq_.js → chunk-QN33PNHL-BcIbOumv.js} +1 -1
  15. package/dist/{chunk-QZHKN3VN-6gwUonWI.js → chunk-QZHKN3VN-CMSnhk6x.js} +1 -1
  16. package/dist/{chunk-TVAH2DTR-3gm06QdU.js → chunk-TVAH2DTR-CZF2JRya.js} +3 -3
  17. package/dist/{chunk-TZMSLE5B-Cm8Iy9bO.js → chunk-TZMSLE5B-BHzN_BY6.js} +1 -1
  18. package/dist/{classDiagram-v2-RKCZMP56-DC529O_z.js → classDiagram-KNZD7YFC-2H7MseyB.js} +2 -2
  19. package/dist/{classDiagram-KNZD7YFC-DC529O_z.js → classDiagram-v2-RKCZMP56-2H7MseyB.js} +2 -2
  20. package/dist/{clone-CLoRX3j6.js → clone-DKQcSK7N.js} +1 -1
  21. package/dist/{cose-bilkent-S5V4N54A-qf5DlS6Y.js → cose-bilkent-S5V4N54A-CgvKFxTr.js} +2 -2
  22. package/dist/{dagre-5GWH7T2D-Ceocls0m.js → dagre-5GWH7T2D-VNFIipzt.js} +6 -6
  23. package/dist/{data-grid-overlay-editor-AqDS_UKe.js → data-grid-overlay-editor-XdqkKCVx.js} +2 -2
  24. package/dist/{diagram-N5W7TBWH-CP66oSiv.js → diagram-N5W7TBWH-D1s8h-eH.js} +5 -5
  25. package/dist/{diagram-QEK2KX5R-_YD4kxxi.js → diagram-QEK2KX5R-DOa-AstT.js} +3 -3
  26. package/dist/{diagram-S2PKOQOG-Cnj8T-OP.js → diagram-S2PKOQOG-CFZ-Y2zi.js} +3 -3
  27. package/dist/{dockerfile-Cm8cRYCN.js → dockerfile-zE-2DWBS.js} +1 -1
  28. package/dist/{erDiagram-AWTI2OKA-CGnvoHx6.js → erDiagram-AWTI2OKA-WxUYJfbS.js} +4 -4
  29. package/dist/{flowDiagram-PVAE7QVJ-DG-pr9R9.js → flowDiagram-PVAE7QVJ-dDZH2O1W.js} +5 -5
  30. package/dist/{ganttDiagram-OWAHRB6G-JmChtxvn.js → ganttDiagram-OWAHRB6G-D3CCqPQq.js} +4 -4
  31. package/dist/{gitGraphDiagram-NY62KEGX-D8wLfOPd.js → gitGraphDiagram-NY62KEGX-BHFylEwc.js} +4 -4
  32. package/dist/{glide-data-editor-9nC3iCIZ.js → glide-data-editor-D0aJSGV_.js} +3 -3
  33. package/dist/{graph-CoRe7vAN.js → graph-BPGEu6c8.js} +3 -3
  34. package/dist/{index-6qYeHHjQ.js → index-Bx2b23rX.js} +3 -3
  35. package/dist/{index-BthgsgYX.js → index-DotQhzoN.js} +1 -1
  36. package/dist/{index-jkm77Jrz.js → index-HtOEKQ3O.js} +1 -1
  37. package/dist/{index-BpzLh4Qe.js → index-eDB61tLS.js} +1 -1
  38. package/dist/{infoDiagram-STP46IZ2-BlXxvOrR.js → infoDiagram-STP46IZ2-DWhhqGPi.js} +2 -2
  39. package/dist/{journeyDiagram-BIP6EPQ6-CNRYs_Fc.js → journeyDiagram-BIP6EPQ6-CU8FpryL.js} +3 -3
  40. package/dist/{kanban-definition-6OIFK2YF-B9HeMAuP.js → kanban-definition-6OIFK2YF-CWhF_a4g.js} +2 -2
  41. package/dist/{layout-m2vOUiW1.js → layout-DGonEvAZ.js} +4 -4
  42. package/dist/{linear-DU6Q5CX3.js → linear-Cww2a6nQ.js} +1 -1
  43. package/dist/{main-BD2KGFpU.js → main-Bc0LY9fB.js} +20636 -20608
  44. package/dist/main.js +1 -1
  45. package/dist/{mermaid-HVCtvbyx.js → mermaid-DpJuOhRr.js} +30 -30
  46. package/dist/{min-DcGMA4e_.js → min-CFQjsG4L.js} +2 -2
  47. package/dist/{mindmap-definition-Q6HEUPPD-BW8UmIDQ.js → mindmap-definition-Q6HEUPPD-K513Ef1t.js} +3 -3
  48. package/dist/{number-overlay-editor-D8Hl0Syo.js → number-overlay-editor-DuSchUfE.js} +2 -2
  49. package/dist/{pieDiagram-ADFJNKIX-Bg-3zg5U.js → pieDiagram-ADFJNKIX-DAIIUJJO.js} +3 -3
  50. package/dist/{quadrantDiagram-LMRXKWRM-BO4IG6Yz.js → quadrantDiagram-LMRXKWRM-yuf-j7Os.js} +2 -2
  51. package/dist/{react-plotly-dkvHVuRb.js → react-plotly-B378DZ9U.js} +1 -1
  52. package/dist/{requirementDiagram-4UW4RH46-5sdTguSM.js → requirementDiagram-4UW4RH46-BBWvEl6q.js} +3 -3
  53. package/dist/{sankeyDiagram-GR3RE2ED-Buhlv9OI.js → sankeyDiagram-GR3RE2ED-B_TwV-dS.js} +1 -1
  54. package/dist/{sequenceDiagram-C3RYC4MD-C3qsM2UP.js → sequenceDiagram-C3RYC4MD-BVC6lltp.js} +3 -3
  55. package/dist/{slides-component-D209A0-s.js → slides-component-CPX3S0Y9.js} +1 -1
  56. package/dist/{stateDiagram-KXAO66HF-CopJ7G6P.js → stateDiagram-KXAO66HF-BCU1tYTD.js} +4 -4
  57. package/dist/{stateDiagram-v2-UMBNRL4Z-CejL8AKf.js → stateDiagram-v2-UMBNRL4Z-BdvN6wTu.js} +2 -2
  58. package/dist/style.css +1 -1
  59. package/dist/{time-BwSBitlN.js → time-CSIip6fV.js} +2 -2
  60. package/dist/{timeline-definition-XQNQX7LJ-DzMNTX-C.js → timeline-definition-XQNQX7LJ-CCxCPNQI.js} +1 -1
  61. package/dist/{treemap-75Q7IDZK-zeJG07dk.js → treemap-75Q7IDZK-Du6v0BzD.js} +5 -5
  62. package/dist/{vega-component-CUkiTayd.js → vega-component-Da93sTnp.js} +2 -2
  63. package/dist/{xychartDiagram-6GGTOJPD-DiENNXMS.js → xychartDiagram-6GGTOJPD-Oq6xaZKR.js} +2 -2
  64. package/package.json +6 -3
  65. package/src/components/ai/ai-provider-icon.tsx +5 -1
  66. package/src/components/chat/acp/__tests__/__snapshots__/prompt.test.ts.snap +304 -0
  67. package/src/components/chat/acp/__tests__/atoms.test.ts +56 -0
  68. package/src/components/chat/acp/__tests__/prompt.test.ts +12 -0
  69. package/src/components/chat/acp/__tests__/state.test.ts +621 -0
  70. package/src/components/chat/acp/agent-docs.tsx +78 -0
  71. package/src/components/chat/acp/agent-panel.css +23 -0
  72. package/src/components/chat/acp/agent-panel.tsx +715 -0
  73. package/src/components/chat/acp/agent-selector.tsx +138 -0
  74. package/src/components/chat/acp/blocks.tsx +664 -0
  75. package/src/components/chat/acp/common.tsx +198 -0
  76. package/src/components/chat/acp/prompt.ts +284 -0
  77. package/src/components/chat/acp/scroll-to-bottom-button.tsx +50 -0
  78. package/src/components/chat/acp/session-tabs.tsx +138 -0
  79. package/src/components/chat/acp/state.ts +263 -0
  80. package/src/components/chat/acp/thread.tsx +121 -0
  81. package/src/components/chat/acp/types.ts +63 -0
  82. package/src/components/chat/acp/utils.ts +45 -0
  83. package/src/components/chat/tool-call-accordion.tsx +1 -1
  84. package/src/components/editor/chrome/types.ts +10 -0
  85. package/src/components/editor/chrome/wrapper/app-chrome.tsx +17 -3
  86. package/src/core/config/feature-flag.tsx +2 -0
  87. package/src/plugins/impl/vega/vega.css +121 -0
  88. package/src/utils/Logger.ts +5 -6
@@ -0,0 +1,621 @@
1
+ /* Copyright 2025 Marimo. All rights reserved. */
2
+
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import {
5
+ type AgentSession,
6
+ type AgentSessionState,
7
+ addSession,
8
+ type ExternalAgentId,
9
+ getAgentConnectionCommand,
10
+ getAgentDisplayName,
11
+ getAllAgentIds,
12
+ getSessionsByAgent,
13
+ removeSession,
14
+ type TabId,
15
+ truncateTitle,
16
+ updateSessionExternalAgentSessionId,
17
+ updateSessionLastUsed,
18
+ updateSessionTitle,
19
+ } from "../state";
20
+ import type { ExternalAgentSessionId } from "../types";
21
+
22
+ describe("state utility functions", () => {
23
+ beforeEach(() => {
24
+ vi.clearAllMocks();
25
+ // Mock Date.now for consistent testing
26
+ vi.useFakeTimers();
27
+ vi.setSystemTime(new Date("2025-01-01T00:00:00Z"));
28
+ });
29
+
30
+ afterEach(() => {
31
+ vi.useRealTimers();
32
+ });
33
+
34
+ describe("truncateTitle", () => {
35
+ it("should not truncate short titles", () => {
36
+ expect(truncateTitle("Hello")).toBe("Hello");
37
+ expect(truncateTitle("Test message")).toBe("Test message");
38
+ });
39
+
40
+ it("should truncate long titles to default 20 characters", () => {
41
+ const longTitle = "This is a very long title that should be truncated";
42
+ const result = truncateTitle(longTitle);
43
+ expect(result).toBe("This is a very lo...");
44
+ expect(result.length).toBe(20);
45
+ });
46
+
47
+ it("should truncate to custom max length", () => {
48
+ const longTitle = "This is a long title";
49
+ const result = truncateTitle(longTitle, 10);
50
+ expect(result).toBe("This is...");
51
+ expect(result.length).toBe(10);
52
+ });
53
+
54
+ it("should handle empty strings", () => {
55
+ expect(truncateTitle("")).toBe("");
56
+ });
57
+
58
+ it("should handle titles exactly at max length", () => {
59
+ const exactTitle = "Exactly twenty chars";
60
+ expect(exactTitle.length).toBe(20);
61
+ expect(truncateTitle(exactTitle)).toBe(exactTitle);
62
+ });
63
+ });
64
+
65
+ describe("addSession", () => {
66
+ it("should add session to empty state", () => {
67
+ const initialState: AgentSessionState = {
68
+ sessions: [],
69
+ activeTabId: null,
70
+ };
71
+
72
+ const session = { agentId: "claude" as ExternalAgentId };
73
+ const newState = addSession(initialState, session);
74
+
75
+ // Remove the dynamic tabId for snapshot comparison
76
+ const { tabId, ...sessionWithoutId } = newState.sessions[0];
77
+ expect({
78
+ ...newState,
79
+ sessions: [sessionWithoutId],
80
+ activeTabId: "[DYNAMIC_TAB_ID]",
81
+ }).toMatchInlineSnapshot(`
82
+ {
83
+ "activeTabId": "[DYNAMIC_TAB_ID]",
84
+ "sessions": [
85
+ {
86
+ "agentId": "claude",
87
+ "createdAt": 1735689600000,
88
+ "externalAgentSessionId": null,
89
+ "lastUsedAt": 1735689600000,
90
+ "title": "New claude session",
91
+ },
92
+ ],
93
+ }
94
+ `);
95
+ expect(newState.activeTabId).toBe(newState.sessions[0].tabId);
96
+ });
97
+
98
+ it("should add session when no existing session for different agent", () => {
99
+ const existingSession: AgentSession = {
100
+ agentId: "gemini",
101
+ tabId: "tab_existing" as TabId,
102
+ title: "Existing gemini session",
103
+ createdAt: 1735689600000,
104
+ lastUsedAt: 1735689600000,
105
+ externalAgentSessionId: null,
106
+ };
107
+ const initialState: AgentSessionState = {
108
+ sessions: [existingSession],
109
+ activeTabId: existingSession.tabId,
110
+ };
111
+
112
+ const newSession = { agentId: "claude" as ExternalAgentId };
113
+ const newState = addSession(initialState, newSession);
114
+
115
+ // Remove dynamic tabId for snapshot
116
+ const { tabId, ...sessionWithoutId } = newState.sessions[0];
117
+ expect({
118
+ ...newState,
119
+ sessions: [sessionWithoutId],
120
+ activeTabId: "[DYNAMIC_TAB_ID]",
121
+ }).toMatchInlineSnapshot(`
122
+ {
123
+ "activeTabId": "[DYNAMIC_TAB_ID]",
124
+ "sessions": [
125
+ {
126
+ "agentId": "claude",
127
+ "createdAt": 1735689600000,
128
+ "externalAgentSessionId": null,
129
+ "lastUsedAt": 1735689600000,
130
+ "title": "New claude session",
131
+ },
132
+ ],
133
+ }
134
+ `);
135
+ });
136
+
137
+ it("should replace existing session for same agent (single session support)", () => {
138
+ const existingSession: AgentSession = {
139
+ agentId: "claude",
140
+ tabId: "tab_existing" as TabId,
141
+ title: "Existing claude session",
142
+ createdAt: 1735689600000,
143
+ lastUsedAt: 1735689600000,
144
+ externalAgentSessionId: null,
145
+ };
146
+ const initialState: AgentSessionState = {
147
+ sessions: [existingSession],
148
+ activeTabId: existingSession.tabId,
149
+ };
150
+
151
+ const newSession = {
152
+ agentId: "claude" as ExternalAgentId,
153
+ firstMessage: "Hello",
154
+ };
155
+ const newState = addSession(initialState, newSession);
156
+
157
+ expect(newState).toMatchInlineSnapshot(`
158
+ {
159
+ "activeTabId": "tab_existing",
160
+ "sessions": [
161
+ {
162
+ "agentId": "claude",
163
+ "createdAt": 1735689600000,
164
+ "externalAgentSessionId": null,
165
+ "lastUsedAt": 1735689600000,
166
+ "tabId": "tab_existing",
167
+ "title": "Hello",
168
+ },
169
+ ],
170
+ }
171
+ `);
172
+ });
173
+
174
+ it("should not mutate original state", () => {
175
+ const initialState: AgentSessionState = {
176
+ sessions: [],
177
+ activeTabId: null,
178
+ };
179
+
180
+ const newState = addSession(initialState, { agentId: "claude" });
181
+
182
+ expect(initialState.sessions).toHaveLength(0);
183
+ expect(newState.sessions).toHaveLength(1);
184
+ });
185
+ });
186
+
187
+ describe("removeSession", () => {
188
+ let sessions: AgentSession[];
189
+ let state: AgentSessionState;
190
+
191
+ beforeEach(() => {
192
+ sessions = [
193
+ {
194
+ agentId: "claude",
195
+ tabId: "tab_1" as TabId,
196
+ title: "Claude session 1",
197
+ createdAt: 1735689600000,
198
+ lastUsedAt: 1735689600000,
199
+ externalAgentSessionId: null,
200
+ },
201
+ {
202
+ agentId: "gemini",
203
+ tabId: "tab_2" as TabId,
204
+ title: "Gemini session",
205
+ createdAt: 1735689600000,
206
+ lastUsedAt: 1735689600000,
207
+ externalAgentSessionId: null,
208
+ },
209
+ {
210
+ agentId: "claude",
211
+ tabId: "tab_3" as TabId,
212
+ title: "Claude session 2",
213
+ createdAt: 1735689600000,
214
+ lastUsedAt: 1735689600000,
215
+ externalAgentSessionId: null,
216
+ },
217
+ ];
218
+ state = {
219
+ sessions,
220
+ activeTabId: sessions[1].tabId, // middle session is active
221
+ };
222
+ });
223
+
224
+ it("should remove specified session", () => {
225
+ const newState = removeSession(state, sessions[1].tabId);
226
+
227
+ expect(newState).toMatchInlineSnapshot(`
228
+ {
229
+ "activeTabId": "tab_3",
230
+ "sessions": [
231
+ {
232
+ "agentId": "claude",
233
+ "createdAt": 1735689600000,
234
+ "externalAgentSessionId": null,
235
+ "lastUsedAt": 1735689600000,
236
+ "tabId": "tab_1",
237
+ "title": "Claude session 1",
238
+ },
239
+ {
240
+ "agentId": "claude",
241
+ "createdAt": 1735689600000,
242
+ "externalAgentSessionId": null,
243
+ "lastUsedAt": 1735689600000,
244
+ "tabId": "tab_3",
245
+ "title": "Claude session 2",
246
+ },
247
+ ],
248
+ }
249
+ `);
250
+ });
251
+
252
+ it("should keep active session if not the one being removed", () => {
253
+ const newState = removeSession(state, sessions[1].tabId);
254
+ expect(newState.activeTabId).toMatchInlineSnapshot(`"tab_3"`);
255
+ });
256
+
257
+ it("should set active session to last session when removing active session", () => {
258
+ const newState = removeSession(state, sessions[1].tabId);
259
+ expect(newState.activeTabId).toMatchInlineSnapshot(`"tab_3"`);
260
+ });
261
+
262
+ it("should set active session to null when removing last session", () => {
263
+ const singleSession: AgentSession = {
264
+ agentId: "claude",
265
+ tabId: "tab_single" as TabId,
266
+ title: "Single session",
267
+ createdAt: 1735689600000,
268
+ lastUsedAt: 1735689600000,
269
+ externalAgentSessionId: null,
270
+ };
271
+ const singleSessionState: AgentSessionState = {
272
+ sessions: [singleSession],
273
+ activeTabId: singleSession.tabId,
274
+ };
275
+
276
+ const newState = removeSession(singleSessionState, singleSession.tabId);
277
+ expect(newState.sessions).toHaveLength(0);
278
+ expect(newState.activeTabId).toBe(null);
279
+ });
280
+
281
+ it("should handle removing non-existent session", () => {
282
+ const fakeId = "fake_session_id" as TabId;
283
+ const newState = removeSession(state, fakeId);
284
+
285
+ expect(newState.sessions).toHaveLength(3);
286
+ expect(newState.activeTabId).toBe(sessions[1].tabId);
287
+ });
288
+ });
289
+
290
+ describe("updateSessionTitle", () => {
291
+ let sessions: AgentSession[];
292
+ let state: AgentSessionState;
293
+
294
+ beforeEach(() => {
295
+ sessions = [
296
+ {
297
+ agentId: "claude",
298
+ tabId: "tab_1" as TabId,
299
+ title: "Original title",
300
+ createdAt: 1735689600000,
301
+ lastUsedAt: 1735689600000,
302
+ externalAgentSessionId: null,
303
+ },
304
+ {
305
+ agentId: "gemini",
306
+ tabId: "tab_2" as TabId,
307
+ title: "Another title",
308
+ createdAt: 1735689600000,
309
+ lastUsedAt: 1735689600000,
310
+ externalAgentSessionId: null,
311
+ },
312
+ ];
313
+ state = {
314
+ sessions,
315
+ activeTabId: sessions[0].tabId,
316
+ };
317
+ });
318
+
319
+ it("should update title of specified session", () => {
320
+ const newTitle = "Updated title for session";
321
+ const newState = updateSessionTitle(state, newTitle);
322
+
323
+ expect(newState.sessions.map((s) => s.title)).toMatchInlineSnapshot(`
324
+ [
325
+ "Updated title for...",
326
+ "Another title",
327
+ ]
328
+ `);
329
+ });
330
+
331
+ it("should truncate long titles", () => {
332
+ const longTitle = "This is a very long title that needs to be truncated";
333
+ const newState = updateSessionTitle(state, longTitle);
334
+
335
+ expect({
336
+ title: newState.sessions[0].title,
337
+ length: newState.sessions[0].title.length,
338
+ }).toMatchInlineSnapshot(`
339
+ {
340
+ "length": 20,
341
+ "title": "This is a very lo...",
342
+ }
343
+ `);
344
+ });
345
+
346
+ it("should not mutate original state", () => {
347
+ const originalTitle = sessions[0].title;
348
+ updateSessionTitle(state, "New title");
349
+
350
+ expect(sessions[0].title).toBe(originalTitle);
351
+ });
352
+ });
353
+
354
+ describe("updateSessionLastUsed", () => {
355
+ let sessions: AgentSession[];
356
+ let state: AgentSessionState;
357
+
358
+ beforeEach(() => {
359
+ sessions = [
360
+ {
361
+ agentId: "claude",
362
+ tabId: "tab_1" as TabId,
363
+ title: "Claude session",
364
+ createdAt: 1735689600000,
365
+ lastUsedAt: 1735689600000,
366
+ externalAgentSessionId: null,
367
+ },
368
+ {
369
+ agentId: "gemini",
370
+ tabId: "tab_2" as TabId,
371
+ title: "Gemini session",
372
+ createdAt: 1735689600000,
373
+ lastUsedAt: 1735689600000,
374
+ externalAgentSessionId: null,
375
+ },
376
+ ];
377
+ state = {
378
+ sessions,
379
+ activeTabId: sessions[0].tabId,
380
+ };
381
+ });
382
+
383
+ it("should update lastUsedAt timestamp", () => {
384
+ const originalTimestamp = sessions[0].lastUsedAt;
385
+
386
+ // Advance time by 1 hour
387
+ vi.advanceTimersByTime(3600000);
388
+
389
+ const newState = updateSessionLastUsed(state, sessions[0].tabId);
390
+
391
+ expect({
392
+ updatedTimestamp: newState.sessions[0].lastUsedAt,
393
+ unchangedTimestamp: newState.sessions[1].lastUsedAt,
394
+ timestampChanged: newState.sessions[0].lastUsedAt !== originalTimestamp,
395
+ }).toMatchInlineSnapshot(`
396
+ {
397
+ "timestampChanged": true,
398
+ "unchangedTimestamp": 1735689600000,
399
+ "updatedTimestamp": 1735693200000,
400
+ }
401
+ `);
402
+ });
403
+
404
+ it("should handle non-existent session ID", () => {
405
+ const fakeId = "fake_session_id" as TabId;
406
+ const originalTimestamp = sessions[0].lastUsedAt;
407
+
408
+ vi.advanceTimersByTime(3600000);
409
+
410
+ const newState = updateSessionLastUsed(state, fakeId);
411
+
412
+ expect(newState.sessions[0].lastUsedAt).toBe(originalTimestamp);
413
+ expect(newState.sessions[1].lastUsedAt).toBe(originalTimestamp);
414
+ });
415
+ });
416
+
417
+ describe("updateSessionExternalAgentSessionId", () => {
418
+ let sessions: AgentSession[];
419
+ let state: AgentSessionState;
420
+
421
+ beforeEach(() => {
422
+ sessions = [
423
+ {
424
+ agentId: "claude",
425
+ tabId: "tab_1" as TabId,
426
+ title: "Claude session",
427
+ createdAt: 1735689600000,
428
+ lastUsedAt: 1735689600000,
429
+ externalAgentSessionId: null,
430
+ },
431
+ {
432
+ agentId: "gemini",
433
+ tabId: "tab_2" as TabId,
434
+ title: "Gemini session",
435
+ createdAt: 1735689600000,
436
+ lastUsedAt: 1735689600000,
437
+ externalAgentSessionId: null,
438
+ },
439
+ ];
440
+ state = {
441
+ sessions,
442
+ activeTabId: sessions[0].tabId,
443
+ };
444
+ });
445
+
446
+ it("should update externalAgentSessionId and lastUsedAt", () => {
447
+ const originalTimestamp = sessions[0].lastUsedAt;
448
+ const agentSessionId = "agent_session_123" as ExternalAgentSessionId;
449
+
450
+ // Advance time by 1 hour
451
+ vi.advanceTimersByTime(3600000);
452
+
453
+ const newState = updateSessionExternalAgentSessionId(
454
+ state,
455
+ agentSessionId,
456
+ );
457
+
458
+ expect({
459
+ updatedSession: {
460
+ externalAgentSessionId: newState.sessions[0].externalAgentSessionId,
461
+ lastUsedAt: newState.sessions[0].lastUsedAt,
462
+ },
463
+ unchangedSession: {
464
+ externalAgentSessionId: newState.sessions[1].externalAgentSessionId,
465
+ },
466
+ timestampChanged: newState.sessions[0].lastUsedAt !== originalTimestamp,
467
+ }).toMatchInlineSnapshot(`
468
+ {
469
+ "timestampChanged": true,
470
+ "unchangedSession": {
471
+ "externalAgentSessionId": null,
472
+ },
473
+ "updatedSession": {
474
+ "externalAgentSessionId": "agent_session_123",
475
+ "lastUsedAt": 1735693200000,
476
+ },
477
+ }
478
+ `);
479
+ });
480
+
481
+ it("should not mutate original state", () => {
482
+ const originalSession = sessions[0];
483
+ const agentSessionId = "agent_session_123" as ExternalAgentSessionId;
484
+
485
+ updateSessionExternalAgentSessionId(state, agentSessionId);
486
+
487
+ expect(originalSession.externalAgentSessionId).toBe(null);
488
+ });
489
+ });
490
+
491
+ describe("getSessionsByAgent", () => {
492
+ let sessions: AgentSession[];
493
+
494
+ beforeEach(() => {
495
+ // Create sessions with different timestamps for sorting test
496
+ sessions = [
497
+ {
498
+ agentId: "claude",
499
+ tabId: "tab_1" as TabId,
500
+ title: "First claude",
501
+ createdAt: 1735689600000, // 2025-01-01T00:00:00Z
502
+ lastUsedAt: 1735689600000,
503
+ externalAgentSessionId: null,
504
+ },
505
+ {
506
+ agentId: "gemini",
507
+ tabId: "tab_2" as TabId,
508
+ title: "First gemini",
509
+ createdAt: 1735693200000, // 2025-01-01T01:00:00Z
510
+ lastUsedAt: 1735693200000,
511
+ externalAgentSessionId: null,
512
+ },
513
+ {
514
+ agentId: "claude",
515
+ tabId: "tab_3" as TabId,
516
+ title: "Second claude",
517
+ createdAt: 1735696800000, // 2025-01-01T02:00:00Z
518
+ lastUsedAt: 1735696800000,
519
+ externalAgentSessionId: null,
520
+ },
521
+ {
522
+ agentId: "claude",
523
+ tabId: "tab_4" as TabId,
524
+ title: "Third claude",
525
+ createdAt: 1735700400000, // 2025-01-01T03:00:00Z
526
+ lastUsedAt: 1735700400000,
527
+ externalAgentSessionId: null,
528
+ },
529
+ ];
530
+ });
531
+
532
+ it("should filter sessions by agent", () => {
533
+ const claudeSessions = getSessionsByAgent(sessions, "claude");
534
+
535
+ expect({
536
+ length: claudeSessions.length,
537
+ allClaude: claudeSessions.every((s) => s.agentId === "claude"),
538
+ agentIds: claudeSessions.map((s) => s.agentId),
539
+ }).toMatchInlineSnapshot(`
540
+ {
541
+ "agentIds": [
542
+ "claude",
543
+ "claude",
544
+ "claude",
545
+ ],
546
+ "allClaude": true,
547
+ "length": 3,
548
+ }
549
+ `);
550
+ });
551
+
552
+ it("should sort sessions by lastUsedAt in descending order", () => {
553
+ const claudeSessions = getSessionsByAgent(sessions, "claude");
554
+
555
+ expect(claudeSessions.map((s) => s.title)).toMatchInlineSnapshot(`
556
+ [
557
+ "Third claude",
558
+ "Second claude",
559
+ "First claude",
560
+ ]
561
+ `);
562
+ });
563
+
564
+ it("should return empty array for non-existent agent", () => {
565
+ const nonExistentSessions = getSessionsByAgent(
566
+ sessions,
567
+ "nonexistent" as ExternalAgentId,
568
+ );
569
+ expect(nonExistentSessions).toMatchInlineSnapshot("[]");
570
+ });
571
+
572
+ it("should return empty array for empty sessions list", () => {
573
+ const result = getSessionsByAgent([], "claude");
574
+ expect(result).toMatchInlineSnapshot("[]");
575
+ });
576
+ });
577
+
578
+ describe("getAllAgentIds", () => {
579
+ it("should return all available agent IDs", () => {
580
+ const agentIds = getAllAgentIds();
581
+ expect(agentIds).toMatchInlineSnapshot(`
582
+ [
583
+ "claude",
584
+ "gemini",
585
+ ]
586
+ `);
587
+ });
588
+ });
589
+
590
+ describe("getAgentDisplayName", () => {
591
+ it("should capitalize agent names", () => {
592
+ expect({
593
+ claude: getAgentDisplayName("claude"),
594
+ gemini: getAgentDisplayName("gemini"),
595
+ }).toMatchInlineSnapshot(`
596
+ {
597
+ "claude": "Claude",
598
+ "gemini": "Gemini",
599
+ }
600
+ `);
601
+ });
602
+ });
603
+
604
+ describe("getAgentConnectionCommand", () => {
605
+ it("should return correct command for claude", () => {
606
+ expect(getAgentConnectionCommand("claude")).toMatchInlineSnapshot(`
607
+ "npx supergateway --stdio\\
608
+ "npx @zed-industries/claude-code-acp" \\
609
+ --outputTransport ws --port 3017 "
610
+ `);
611
+ });
612
+
613
+ it("should return correct command for gemini", () => {
614
+ expect(getAgentConnectionCommand("gemini")).toMatchInlineSnapshot(`
615
+ "npx supergateway --stdio\\
616
+ "npx @google/gemini-cli --experimental-acp" \\
617
+ --outputTransport ws --port 3019 "
618
+ `);
619
+ });
620
+ });
621
+ });
@@ -0,0 +1,78 @@
1
+ /* Copyright 2024 Marimo. All rights reserved. */
2
+
3
+ import { TerminalIcon } from "lucide-react";
4
+ import { memo } from "react";
5
+ import { AiProviderIcon } from "@/components/ai/ai-provider-icon";
6
+ import { CopyClipboardIcon } from "@/components/icons/copy-icon";
7
+ import { cn } from "@/utils/cn";
8
+ import {
9
+ type ExternalAgentId,
10
+ getAgentConnectionCommand,
11
+ getAgentDisplayName,
12
+ getAllAgentIds,
13
+ } from "./state";
14
+
15
+ interface AgentDocItemProps {
16
+ agentId: ExternalAgentId;
17
+ showCopy?: boolean;
18
+ className?: string;
19
+ }
20
+
21
+ const AgentDocItem = memo<AgentDocItemProps>(
22
+ ({ agentId, showCopy = true, className }) => {
23
+ const command = getAgentConnectionCommand(agentId);
24
+ const displayName = getAgentDisplayName(agentId);
25
+
26
+ return (
27
+ <div className={cn("space-y-2", className)}>
28
+ <div className="flex items-center gap-2">
29
+ <AiProviderIcon provider={agentId} className="h-4 w-4" />
30
+ <span className="font-medium text-sm">{displayName}</span>
31
+ </div>
32
+ <div className="bg-muted/50 rounded-md p-2 border">
33
+ <div className="flex items-start gap-2 text-xs">
34
+ <TerminalIcon className="h-4 w-4 mt-0.5 text-muted-foreground flex-shrink-0" />
35
+ <code className="text-xs font-mono break-all flex-1 whitespace-pre-wrap">
36
+ {command}
37
+ </code>
38
+ {showCopy && (
39
+ <CopyClipboardIcon value={command} className="h-3 w-3" />
40
+ )}
41
+ </div>
42
+ </div>
43
+ </div>
44
+ );
45
+ },
46
+ );
47
+ AgentDocItem.displayName = "AgentDocItem";
48
+
49
+ interface AgentDocsProps {
50
+ title?: string;
51
+ description?: string;
52
+ agents?: ExternalAgentId[];
53
+ showCopy?: boolean;
54
+ className?: string;
55
+ }
56
+
57
+ export const AgentDocs = memo<AgentDocsProps>(
58
+ ({
59
+ title,
60
+ description,
61
+ agents = getAllAgentIds(),
62
+ showCopy = true,
63
+ className,
64
+ }) => (
65
+ <div className={cn("space-y-4", className)}>
66
+ <div className="space-y-2">
67
+ <h3 className="font-medium text-sm">{title}</h3>
68
+ <p className="text-xs text-muted-foreground">{description}</p>
69
+ </div>
70
+ <div className="space-y-3">
71
+ {agents.map((agentId) => (
72
+ <AgentDocItem key={agentId} agentId={agentId} showCopy={showCopy} />
73
+ ))}
74
+ </div>
75
+ </div>
76
+ ),
77
+ );
78
+ AgentDocs.displayName = "AgentDocs";