@grackle-ai/web-components 0.112.2 → 0.114.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 (48) hide show
  1. package/.rush/temp/{a0341c0f1c835c664217d8a879aa38d780e62122.tar.log → 989dfd9dfc66be6288052dccbf2952ddad9066c6.tar.log} +2 -2
  2. package/.rush/temp/{1421806d07f6b0c455deca4bf89a6412726ffd8b.untar.log → 989dfd9dfc66be6288052dccbf2952ddad9066c6.untar.log} +2 -2
  3. package/.rush/temp/{1421806d07f6b0c455deca4bf89a6412726ffd8b.tar.log → b1a36bcc314d65fbd843fcff6d3127ebdf827e06.tar.log} +81 -81
  4. package/.rush/temp/{a0341c0f1c835c664217d8a879aa38d780e62122.untar.log → b1a36bcc314d65fbd843fcff6d3127ebdf827e06.untar.log} +2 -2
  5. package/.rush/temp/chunked-rush-logs/web-components._phase_build.chunks.jsonl +7 -6
  6. package/.rush/temp/chunked-rush-logs/web-components._phase_test.chunks.jsonl +25 -24
  7. package/.rush/temp/operation/_phase_build/all.log +7 -6
  8. package/.rush/temp/operation/_phase_build/log-chunks.jsonl +7 -6
  9. package/.rush/temp/operation/_phase_build/state.json +1 -1
  10. package/.rush/temp/operation/_phase_test/all.log +25 -24
  11. package/.rush/temp/operation/_phase_test/log-chunks.jsonl +25 -24
  12. package/.rush/temp/operation/_phase_test/state.json +1 -1
  13. package/README.md +2 -1
  14. package/dist/McpAppWidget-CSX2W2Vb.js +5774 -0
  15. package/dist/index.css +1 -1
  16. package/dist/index.js +12221 -17764
  17. package/package.json +2 -2
  18. package/rush-logs/web-components._phase_build.cache.log +1 -1
  19. package/rush-logs/web-components._phase_build.log +7 -6
  20. package/rush-logs/web-components._phase_test.cache.log +1 -1
  21. package/rush-logs/web-components._phase_test.log +25 -24
  22. package/src/components/display/EventRenderer.stories.tsx +22 -0
  23. package/src/components/display/EventRenderer.tsx +44 -2
  24. package/src/components/display/EventStream.tsx +4 -1
  25. package/src/components/display/index.ts +3 -1
  26. package/src/components/layout/AppNav.stories.tsx +5 -5
  27. package/src/components/layout/AppNav.tsx +8 -4
  28. package/src/components/panels/KeyboardShortcutsPanel.stories.tsx +1 -1
  29. package/src/components/panels/KeyboardShortcutsPanel.tsx +1 -1
  30. package/src/components/streams/CoordinationList.module.scss +137 -0
  31. package/src/components/streams/CoordinationList.stories.tsx +95 -0
  32. package/src/components/streams/CoordinationList.tsx +153 -0
  33. package/src/components/streams/StreamDetailPanel.module.scss +30 -0
  34. package/src/components/streams/StreamDetailPanel.stories.tsx +3 -0
  35. package/src/components/streams/StreamDetailPanel.tsx +58 -24
  36. package/src/components/streams/index.ts +3 -3
  37. package/src/hooks/types.ts +9 -2
  38. package/src/index.ts +5 -5
  39. package/src/mocks/MockGrackleProvider.tsx +15 -3
  40. package/src/mocks/mockData.ts +4 -0
  41. package/src/mocks/mockStreamsData.ts +80 -0
  42. package/src/utils/navigation.ts +3 -5
  43. package/src/utils/streamCoordination.test.ts +88 -0
  44. package/src/utils/streamCoordination.ts +108 -0
  45. package/temp/build/lint/_eslint-5eVG3S6w.json +42 -30
  46. package/src/components/streams/StreamList.module.scss +0 -92
  47. package/src/components/streams/StreamList.stories.tsx +0 -99
  48. package/src/components/streams/StreamList.tsx +0 -114
@@ -0,0 +1,88 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import type { Session, StreamData, StreamSubscriberData } from "../hooks/types.js";
3
+ import { attributeStream, groupStreamsByTask, isInternalStream, streamKind } from "./streamCoordination.js";
4
+
5
+ function makeSub(sessionId: string): StreamSubscriberData {
6
+ return { subscriptionId: `sub-${sessionId}`, sessionId, fd: 3, permission: "rw", deliveryMode: "async", createdBySpawn: false };
7
+ }
8
+
9
+ function makeStream(over: Partial<StreamData> & { id: string; name: string }): StreamData {
10
+ return {
11
+ subscriberCount: over.subscribers?.length ?? 0,
12
+ messageBufferDepth: 0,
13
+ selfEcho: false,
14
+ subscribers: [],
15
+ ...over,
16
+ };
17
+ }
18
+
19
+ function makeSession(id: string, taskId?: string): Session {
20
+ return { id, environmentId: "env-1", runtime: "claude-code", status: "running", prompt: "", startedAt: "2026-01-01T00:00:00Z", taskId };
21
+ }
22
+
23
+ describe("streamKind", () => {
24
+ it("classifies self-echo streams as chatroom", () => {
25
+ expect(streamKind(makeStream({ id: "1", name: "room", selfEcho: true }))).toBe("chatroom");
26
+ });
27
+ it("classifies pipe: streams as pipe", () => {
28
+ expect(streamKind(makeStream({ id: "2", name: "pipe:abc" }))).toBe("pipe");
29
+ });
30
+ it("classifies other named streams as channel", () => {
31
+ expect(streamKind(makeStream({ id: "3", name: "telemetry" }))).toBe("channel");
32
+ });
33
+ });
34
+
35
+ describe("isInternalStream", () => {
36
+ it("flags reserved prefixes", () => {
37
+ expect(isInternalStream(makeStream({ id: "1", name: "lifecycle:x" }))).toBe(true);
38
+ expect(isInternalStream(makeStream({ id: "2", name: "pipe:x" }))).toBe(true);
39
+ expect(isInternalStream(makeStream({ id: "3", name: "stdin:x" }))).toBe(true);
40
+ });
41
+ it("does not flag normal names", () => {
42
+ expect(isInternalStream(makeStream({ id: "4", name: "agent-chat" }))).toBe(false);
43
+ });
44
+ });
45
+
46
+ describe("attributeStream", () => {
47
+ const sessions = [makeSession("s-task", "task-1"), makeSession("s-orphan")];
48
+
49
+ it("attributes to a task when a subscriber session has a taskId", () => {
50
+ const stream = makeStream({ id: "1", name: "x", subscribers: [makeSub("s-task")] });
51
+ expect(attributeStream(stream, sessions)).toEqual({ kind: "task", taskId: "task-1" });
52
+ });
53
+ it("returns unattached when the session is known but task-less", () => {
54
+ const stream = makeStream({ id: "2", name: "x", subscribers: [makeSub("s-orphan")] });
55
+ expect(attributeStream(stream, sessions)).toEqual({ kind: "unattached" });
56
+ });
57
+ it("returns external when no subscriber session is known", () => {
58
+ const stream = makeStream({ id: "3", name: "x", subscribers: [makeSub("s-unknown")] });
59
+ expect(attributeStream(stream, sessions)).toEqual({ kind: "external" });
60
+ });
61
+ });
62
+
63
+ describe("groupStreamsByTask", () => {
64
+ it("groups streams by owning task with a trailing orphan bucket", () => {
65
+ const sessions = [makeSession("s1", "task-1"), makeSession("s2", "task-2"), makeSession("s3")];
66
+ const streams = [
67
+ makeStream({ id: "a", name: "a", subscribers: [makeSub("s1")] }),
68
+ makeStream({ id: "b", name: "b", subscribers: [makeSub("s2")] }),
69
+ makeStream({ id: "c", name: "c", subscribers: [makeSub("s1")] }),
70
+ makeStream({ id: "d", name: "d", subscribers: [makeSub("s3")] }), // unattached
71
+ makeStream({ id: "e", name: "e", subscribers: [makeSub("s-unknown")] }), // external
72
+ ];
73
+ const groups = groupStreamsByTask(streams, sessions);
74
+
75
+ expect(groups.map((g) => g.taskId)).toEqual(["task-1", "task-2", undefined]);
76
+ expect(groups[0].streams.map((s) => s.id)).toEqual(["a", "c"]);
77
+ expect(groups[1].streams.map((s) => s.id)).toEqual(["b"]);
78
+ expect(groups[2].streams.map((s) => s.id)).toEqual(["d", "e"]);
79
+ });
80
+
81
+ it("omits the orphan bucket when every stream is attributed", () => {
82
+ const sessions = [makeSession("s1", "task-1")];
83
+ const streams = [makeStream({ id: "a", name: "a", subscribers: [makeSub("s1")] })];
84
+ const groups = groupStreamsByTask(streams, sessions);
85
+ expect(groups).toHaveLength(1);
86
+ expect(groups[0].taskId).toBe("task-1");
87
+ });
88
+ });
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Pure helpers for the Coordination surface: classifying IPC streams by kind
3
+ * and attributing them to the task that owns their subscribers.
4
+ *
5
+ * No React or DOM dependencies — safe to unit test in isolation.
6
+ *
7
+ * @module
8
+ */
9
+
10
+ import type { Session, StreamData } from "../hooks/types.js";
11
+
12
+ /** Internal IPC plumbing prefixes (mirrors the server's RESERVED_PREFIXES). */
13
+ export const INTERNAL_STREAM_PREFIXES: readonly string[] = ["lifecycle:", "pipe:", "stdin:"];
14
+
15
+ /** Display kind of a stream, derived from its shape. */
16
+ export type StreamKind = "chatroom" | "pipe" | "channel";
17
+
18
+ /** Ownership classification of a stream, derived from its subscribers' sessions. */
19
+ export type StreamOwnership =
20
+ | { kind: "task"; taskId: string }
21
+ | { kind: "unattached" }
22
+ | { kind: "external" };
23
+
24
+ /**
25
+ * Classify a stream's kind:
26
+ * - `chatroom` — self-echo streams (N-party rooms where senders see their own messages).
27
+ * - `pipe` — internal point-to-point pipes (`pipe:` prefix).
28
+ * - `channel` — any other named stream.
29
+ */
30
+ export function streamKind(stream: StreamData): StreamKind {
31
+ if (stream.selfEcho) {
32
+ return "chatroom";
33
+ }
34
+ if (stream.name.startsWith("pipe:")) {
35
+ return "pipe";
36
+ }
37
+ return "channel";
38
+ }
39
+
40
+ /** Returns true when a stream is internal IPC plumbing (reserved prefix). */
41
+ export function isInternalStream(stream: StreamData): boolean {
42
+ return INTERNAL_STREAM_PREFIXES.some((prefix) => stream.name.startsWith(prefix));
43
+ }
44
+
45
+ /**
46
+ * Attribute a stream to the task that owns it, by resolving its subscribers'
47
+ * sessions:
48
+ * - the first subscriber whose session has a `taskId` wins → `{ kind: "task" }`;
49
+ * - else if any subscriber's session is known (but task-less) → `unattached`;
50
+ * - else (no subscriber session resolvable — e.g. CLI/MCP-created) → `external`.
51
+ */
52
+ export function attributeStream(stream: StreamData, sessions: readonly Session[]): StreamOwnership {
53
+ return attributeWithMap(stream, new Map(sessions.map((s) => [s.id, s])));
54
+ }
55
+
56
+ /** Attribution against a precomputed session map — avoids rebuilding it per stream. */
57
+ function attributeWithMap(stream: StreamData, sessionsById: ReadonlyMap<string, Session>): StreamOwnership {
58
+ let sawKnownSession = false;
59
+ for (const sub of stream.subscribers) {
60
+ const session = sessionsById.get(sub.sessionId);
61
+ if (session) {
62
+ sawKnownSession = true;
63
+ if (session.taskId) {
64
+ return { kind: "task", taskId: session.taskId };
65
+ }
66
+ }
67
+ }
68
+ return sawKnownSession ? { kind: "unattached" } : { kind: "external" };
69
+ }
70
+
71
+ /** A group of streams sharing an owning task (or the unattached/external bucket). */
72
+ export interface StreamGroup {
73
+ /** Owning task id, or `undefined` for the combined unattached/external bucket. */
74
+ taskId?: string;
75
+ /** Streams in this group, in their incoming order. */
76
+ streams: StreamData[];
77
+ }
78
+
79
+ /**
80
+ * Group streams by owning task, preserving first-seen order of tasks. Streams
81
+ * that are unattached or external are collected into a single trailing bucket
82
+ * with `taskId === undefined`.
83
+ */
84
+ export function groupStreamsByTask(streams: readonly StreamData[], sessions: readonly Session[]): StreamGroup[] {
85
+ const sessionsById = new Map(sessions.map((s) => [s.id, s]));
86
+ const taskGroups = new Map<string, StreamData[]>();
87
+ const orphans: StreamData[] = [];
88
+
89
+ for (const stream of streams) {
90
+ const ownership = attributeWithMap(stream, sessionsById);
91
+ if (ownership.kind === "task") {
92
+ const existing = taskGroups.get(ownership.taskId);
93
+ if (existing) {
94
+ existing.push(stream);
95
+ } else {
96
+ taskGroups.set(ownership.taskId, [stream]);
97
+ }
98
+ } else {
99
+ orphans.push(stream);
100
+ }
101
+ }
102
+
103
+ const groups: StreamGroup[] = Array.from(taskGroups, ([taskId, groupStreams]) => ({ taskId, streams: groupStreams }));
104
+ if (orphans.length > 0) {
105
+ groups.push({ taskId: undefined, streams: orphans });
106
+ }
107
+ return groups;
108
+ }
@@ -7,7 +7,7 @@
7
7
  ],
8
8
  [
9
9
  "hooks/types.ts",
10
- "Q3IfPXTzOZH/42LKa4mjM52/+X0=_7W3TvvwZxl0TGST6/dyQa8DKDDc="
10
+ "6V+IhDV8stiaMzUcKy7VtyKchXU=_7W3TvvwZxl0TGST6/dyQa8DKDDc="
11
11
  ],
12
12
  [
13
13
  "components/chat/ChatInput.tsx",
@@ -35,7 +35,7 @@
35
35
  ],
36
36
  [
37
37
  "utils/navigation.ts",
38
- "VYMJ/hZ95uKS4IXcv2tomXVUoCk=_7W3TvvwZxl0TGST6/dyQa8DKDDc="
38
+ "Jp9ePlbyKrjkctBaIHNfJR9ed74=_7W3TvvwZxl0TGST6/dyQa8DKDDc="
39
39
  ],
40
40
  [
41
41
  "components/dag/DagView.tsx",
@@ -145,9 +145,17 @@
145
145
  "components/tools/ToolCard.tsx",
146
146
  "daOT8+qSG8CTNk+//LXtKNBcFfE=_7W3TvvwZxl0TGST6/dyQa8DKDDc="
147
147
  ],
148
+ [
149
+ "utils/grackleHostStyleVariables.ts",
150
+ "4GAn+eMbEHJoOb1z01LPtk6nJ7U=_7W3TvvwZxl0TGST6/dyQa8DKDDc="
151
+ ],
152
+ [
153
+ "components/display/McpAppWidget.tsx",
154
+ "FHPzMKgR6sTY4eFNQv5BPGbIhBI=_7W3TvvwZxl0TGST6/dyQa8DKDDc="
155
+ ],
148
156
  [
149
157
  "components/display/EventRenderer.tsx",
150
- "baXzKtFkiMslyFhdBCwrXQHmfZ8=_7W3TvvwZxl0TGST6/dyQa8DKDDc="
158
+ "PHybhj9WzoyCcHMrbT8OHMiXuQI=_7W3TvvwZxl0TGST6/dyQa8DKDDc="
151
159
  ],
152
160
  [
153
161
  "components/display/ConfirmDialog.tsx",
@@ -177,17 +185,9 @@
177
185
  "components/display/SessionAttemptSelector.tsx",
178
186
  "3P6Dn2a9NBqHlUcL32V4cbFlQnA=_7W3TvvwZxl0TGST6/dyQa8DKDDc="
179
187
  ],
180
- [
181
- "utils/grackleHostStyleVariables.ts",
182
- "4GAn+eMbEHJoOb1z01LPtk6nJ7U=_7W3TvvwZxl0TGST6/dyQa8DKDDc="
183
- ],
184
- [
185
- "components/display/McpAppWidget.tsx",
186
- "FHPzMKgR6sTY4eFNQv5BPGbIhBI=_7W3TvvwZxl0TGST6/dyQa8DKDDc="
187
- ],
188
188
  [
189
189
  "components/display/index.ts",
190
- "RDb8Fh+7051CrG4TyqRFZAV2xes=_7W3TvvwZxl0TGST6/dyQa8DKDDc="
190
+ "u8VYHmtVaFCzY50HZB9BaToo/Uk=_7W3TvvwZxl0TGST6/dyQa8DKDDc="
191
191
  ],
192
192
  [
193
193
  "components/display/EventHoverRow.tsx",
@@ -223,7 +223,7 @@
223
223
  ],
224
224
  [
225
225
  "components/display/EventStream.tsx",
226
- "nUUukNWjhiFGA12f1MdOlE8Y3No=_7W3TvvwZxl0TGST6/dyQa8DKDDc="
226
+ "zU7SmOEHQotfmTvaRF0f7x3kIzo=_7W3TvvwZxl0TGST6/dyQa8DKDDc="
227
227
  ],
228
228
  [
229
229
  "components/editable/useEditableField.ts",
@@ -275,7 +275,7 @@
275
275
  ],
276
276
  [
277
277
  "components/layout/AppNav.tsx",
278
- "m+cH8YKux2EB/NGH9BzaikTuEw0=_7W3TvvwZxl0TGST6/dyQa8DKDDc="
278
+ "tBAiCk8HnPO62b+sMkZudZfg/2A=_7W3TvvwZxl0TGST6/dyQa8DKDDc="
279
279
  ],
280
280
  [
281
281
  "components/layout/Sidebar.tsx",
@@ -391,7 +391,7 @@
391
391
  ],
392
392
  [
393
393
  "components/panels/KeyboardShortcutsPanel.tsx",
394
- "0yOtcA8wE4p4ctgFYThvwW0x8Pk=_7W3TvvwZxl0TGST6/dyQa8DKDDc="
394
+ "3edi7irlbtgRuyn2M7Xd0UxNLlM=_7W3TvvwZxl0TGST6/dyQa8DKDDc="
395
395
  ],
396
396
  [
397
397
  "components/panels/CredentialProvidersPanel.tsx",
@@ -414,16 +414,20 @@
414
414
  "jRRfxaaHfygxZpdluJRjfEfHpHw=_7W3TvvwZxl0TGST6/dyQa8DKDDc="
415
415
  ],
416
416
  [
417
- "components/streams/StreamList.tsx",
418
- "LVEmA8wVrbyO2Xt+GtPMhSGC6B8=_7W3TvvwZxl0TGST6/dyQa8DKDDc="
417
+ "utils/streamCoordination.ts",
418
+ "EptFZSARRzENP6IxRt/AyZXBJt8=_7W3TvvwZxl0TGST6/dyQa8DKDDc="
419
+ ],
420
+ [
421
+ "components/streams/CoordinationList.tsx",
422
+ "xDBNKE1Nr/O3BrdHcyQlPea6MtM=_7W3TvvwZxl0TGST6/dyQa8DKDDc="
419
423
  ],
420
424
  [
421
425
  "components/streams/StreamDetailPanel.tsx",
422
- "RGLslyiZm41CPlqP2QpSDWm5uYw=_7W3TvvwZxl0TGST6/dyQa8DKDDc="
426
+ "OnOHSENhCoxiHgkVDZM3a06WeO4=_7W3TvvwZxl0TGST6/dyQa8DKDDc="
423
427
  ],
424
428
  [
425
429
  "components/streams/index.ts",
426
- "wjnffmfMguFqP1a4hC2+XReu4Ss=_7W3TvvwZxl0TGST6/dyQa8DKDDc="
430
+ "6ayws8aPUPY31xaNAyWGY2MkjFY=_7W3TvvwZxl0TGST6/dyQa8DKDDc="
427
431
  ],
428
432
  [
429
433
  "utils/boardColumns.ts",
@@ -465,13 +469,17 @@
465
469
  "mocks/mockKnowledgeData.ts",
466
470
  "K09GMmZqdvBWTt38ZYY1HsIjlLQ=_7W3TvvwZxl0TGST6/dyQa8DKDDc="
467
471
  ],
472
+ [
473
+ "mocks/mockStreamsData.ts",
474
+ "sV8iV8PeiQ+2LXWFB7zKll/xSIs=_7W3TvvwZxl0TGST6/dyQa8DKDDc="
475
+ ],
468
476
  [
469
477
  "mocks/mockData.ts",
470
- "8t66aL3PKQq/G0CwV/82K12tURc=_7W3TvvwZxl0TGST6/dyQa8DKDDc="
478
+ "rwiDVF97QNZ5XtbVvMH8/1e4gBI=_7W3TvvwZxl0TGST6/dyQa8DKDDc="
471
479
  ],
472
480
  [
473
481
  "mocks/MockGrackleProvider.tsx",
474
- "Zrw0hhCq5Bbv4P33XGGTpJjLm/8=_7W3TvvwZxl0TGST6/dyQa8DKDDc="
482
+ "B09K0oXwGXuyVoPp09f6MYqwzBI=_7W3TvvwZxl0TGST6/dyQa8DKDDc="
475
483
  ],
476
484
  [
477
485
  "test-utils/storybook-decorators.tsx",
@@ -483,7 +491,7 @@
483
491
  ],
484
492
  [
485
493
  "index.ts",
486
- "Lbo23h9QnwqCWZmf6x/aafniLGY=_7W3TvvwZxl0TGST6/dyQa8DKDDc="
494
+ "GBCOSyIvbIWfMykHVFwaig0ddkA=_7W3TvvwZxl0TGST6/dyQa8DKDDc="
487
495
  ],
488
496
  [
489
497
  "components/index.ts",
@@ -527,7 +535,7 @@
527
535
  ],
528
536
  [
529
537
  "components/display/EventRenderer.stories.tsx",
530
- "TJiKZNxPldFn9X6rDkmjlIjwp1k=_7W3TvvwZxl0TGST6/dyQa8DKDDc="
538
+ "vwvW6L580d9RGMBXR6UIB78YLAg=_7W3TvvwZxl0TGST6/dyQa8DKDDc="
531
539
  ],
532
540
  [
533
541
  "components/display/EventStream.stories.tsx",
@@ -611,7 +619,7 @@
611
619
  ],
612
620
  [
613
621
  "components/layout/AppNav.stories.tsx",
614
- "oYykVWEa7SEp4YtAOIe+xpB1D3k=_7W3TvvwZxl0TGST6/dyQa8DKDDc="
622
+ "Bb31+Z83wts8nmmIRRCzUDj0/q8=_7W3TvvwZxl0TGST6/dyQa8DKDDc="
615
623
  ],
616
624
  [
617
625
  "components/layout/BottomStatusBar.stories.tsx",
@@ -675,7 +683,7 @@
675
683
  ],
676
684
  [
677
685
  "components/panels/KeyboardShortcutsPanel.stories.tsx",
678
- "gZ9hP3WGlzy58xTJYrj2eHoEpYQ=_7W3TvvwZxl0TGST6/dyQa8DKDDc="
686
+ "V+OspyHv+PfL/AcNDfxj2yloCzE=_7W3TvvwZxl0TGST6/dyQa8DKDDc="
679
687
  ],
680
688
  [
681
689
  "components/panels/TaskActionButtons.stories.tsx",
@@ -714,12 +722,12 @@
714
722
  "/43ghyGqzqkxu1CWV60htj1ImpU=_7W3TvvwZxl0TGST6/dyQa8DKDDc="
715
723
  ],
716
724
  [
717
- "components/streams/StreamDetailPanel.stories.tsx",
718
- "Q8zOB3LMfxtVSD/0uHCjEIs3+ys=_7W3TvvwZxl0TGST6/dyQa8DKDDc="
725
+ "components/streams/CoordinationList.stories.tsx",
726
+ "BlZXoGe7ntz+nxDQbdI+MC4hj/g=_7W3TvvwZxl0TGST6/dyQa8DKDDc="
719
727
  ],
720
728
  [
721
- "components/streams/StreamList.stories.tsx",
722
- "nBRv4JGi+SHcQWldHrRA0y9cyF0=_7W3TvvwZxl0TGST6/dyQa8DKDDc="
729
+ "components/streams/StreamDetailPanel.stories.tsx",
730
+ "wXCDWxvXnuORr2+5yl/rwfioFFE=_7W3TvvwZxl0TGST6/dyQa8DKDDc="
723
731
  ],
724
732
  [
725
733
  "components/tools/AgentToolCard.stories.tsx",
@@ -832,7 +840,11 @@
832
840
  [
833
841
  "utils/sessionEvents.test.ts",
834
842
  "hL9OyAvsoDAPSh4+TPUEgQrdYMI=_orHdc0vDZqoYfD6TIDl1Za3EAL4="
843
+ ],
844
+ [
845
+ "utils/streamCoordination.test.ts",
846
+ "tcFLEiIFvYYLO/1jT41tcJ4CIX4=_orHdc0vDZqoYfD6TIDl1Za3EAL4="
835
847
  ]
836
848
  ],
837
- "filesHash": "wzmDK7nkrsOam3n0gh_j3g"
849
+ "filesHash": "350HaaMGlV7A3LVA-PY0sQ"
838
850
  }
@@ -1,92 +0,0 @@
1
- @use '../../styles/mixins' as *;
2
-
3
- // =============================================================================
4
- // StreamList — IPC stream sidebar list
5
- // =============================================================================
6
-
7
- .container {
8
- padding: var(--space-sm) 0;
9
- }
10
-
11
- .header {
12
- @include section-label;
13
- padding: var(--space-xs) var(--space-md);
14
- display: flex;
15
- align-items: center;
16
- justify-content: space-between;
17
- }
18
-
19
- .refreshButton {
20
- background: none;
21
- border: none;
22
- color: var(--text-secondary);
23
- cursor: pointer;
24
- padding: 2px;
25
- display: flex;
26
- align-items: center;
27
- border-radius: var(--radius-sm);
28
-
29
- &:hover {
30
- color: var(--text-primary);
31
- background: var(--bg-overlay);
32
- }
33
- }
34
-
35
- .streamRow {
36
- @include hover-accent;
37
- display: flex;
38
- align-items: center;
39
- gap: var(--space-sm);
40
- padding: var(--space-xs) var(--space-md);
41
- cursor: pointer;
42
- min-height: 32px;
43
- user-select: none;
44
-
45
- &.selected {
46
- background: var(--bg-overlay);
47
- }
48
- }
49
-
50
- .systemRow {
51
- composes: streamRow;
52
- border-bottom: 1px solid var(--border-subtle);
53
- margin-bottom: var(--space-xs);
54
- font-weight: 500;
55
- }
56
-
57
- .streamIcon {
58
- flex-shrink: 0;
59
- color: var(--text-secondary);
60
- }
61
-
62
- .streamName {
63
- flex: 1;
64
- font-size: 13px;
65
- color: var(--text-primary);
66
- overflow: hidden;
67
- text-overflow: ellipsis;
68
- white-space: nowrap;
69
- }
70
-
71
- .subscriberBadge {
72
- @include surface-inset;
73
- font-size: 11px;
74
- padding: 1px 6px;
75
- border-radius: var(--radius-sm);
76
- color: var(--text-secondary);
77
- flex-shrink: 0;
78
- }
79
-
80
- .emptyState {
81
- padding: var(--space-md);
82
- font-size: 12px;
83
- color: var(--text-disabled);
84
- text-align: center;
85
- }
86
-
87
- .loading {
88
- padding: var(--space-md);
89
- font-size: 12px;
90
- color: var(--text-disabled);
91
- text-align: center;
92
- }
@@ -1,99 +0,0 @@
1
- import type { Meta, StoryObj } from "@storybook/react";
2
- import { expect, fn } from "@storybook/test";
3
- import type { StreamData } from "../../hooks/types.js";
4
- import { withMockGrackleRoute } from "../../test-utils/storybook-helpers.js";
5
- import { StreamList } from "./StreamList.js";
6
-
7
- // ---------------------------------------------------------------------------
8
- // Mock data
9
- // ---------------------------------------------------------------------------
10
-
11
- const mockStreams: StreamData[] = [
12
- {
13
- id: "stream-001",
14
- name: "agent-chat",
15
- subscriberCount: 2,
16
- messageBufferDepth: 0,
17
- subscribers: [],
18
- },
19
- {
20
- id: "stream-002",
21
- name: "coordinator-bus",
22
- subscriberCount: 1,
23
- messageBufferDepth: 3,
24
- subscribers: [],
25
- },
26
- {
27
- id: "stream-003",
28
- name: "telemetry-feed",
29
- subscriberCount: 0,
30
- messageBufferDepth: 0,
31
- subscribers: [],
32
- },
33
- ];
34
-
35
- // ---------------------------------------------------------------------------
36
- // Story meta
37
- // ---------------------------------------------------------------------------
38
-
39
- const meta: Meta<typeof StreamList> = {
40
- title: "Grackle/Streams/StreamList",
41
- component: StreamList,
42
- parameters: { skipRouter: true },
43
- args: {
44
- streams: mockStreams,
45
- loading: false,
46
- onRefresh: fn(),
47
- },
48
- };
49
-
50
- export default meta;
51
- type Story = StoryObj<typeof StreamList>;
52
-
53
- // ---------------------------------------------------------------------------
54
- // Stories
55
- // ---------------------------------------------------------------------------
56
-
57
- /** Default: shows System pinned row and a list of named streams. */
58
- export const Default: Story = {
59
- decorators: [withMockGrackleRoute(["/chat"], "/chat")],
60
- };
61
-
62
- /** Empty state: no named streams, only the System row. */
63
- export const Empty: Story = {
64
- decorators: [withMockGrackleRoute(["/chat"], "/chat")],
65
- args: {
66
- streams: [],
67
- },
68
- };
69
-
70
- /** Loading state while streams are being fetched. */
71
- export const Loading: Story = {
72
- decorators: [withMockGrackleRoute(["/chat"], "/chat")],
73
- args: {
74
- streams: [],
75
- loading: true,
76
- },
77
- };
78
-
79
- /** System row is visually selected when on the /chat route. */
80
- export const SystemSelected: Story = {
81
- decorators: [withMockGrackleRoute(["/chat"], "/chat")],
82
- play: async ({ canvas }) => {
83
- const systemRow = canvas.getByTestId("stream-list-system-row");
84
- await expect(systemRow).toBeInTheDocument();
85
- await expect(systemRow).toHaveAttribute("aria-current", "page");
86
- },
87
- };
88
-
89
- /** A named stream is selected when on its /chat/:streamId route. */
90
- export const StreamSelected: Story = {
91
- decorators: [withMockGrackleRoute(["/chat/stream-001"], "/chat/:streamId")],
92
- play: async ({ canvas }) => {
93
- const streamRow = canvas.getByTestId("stream-list-row-stream-001");
94
- await expect(streamRow).toBeInTheDocument();
95
- await expect(streamRow).toHaveAttribute("aria-current", "page");
96
- const systemRow = canvas.getByTestId("stream-list-system-row");
97
- await expect(systemRow).not.toHaveAttribute("aria-current");
98
- },
99
- };
@@ -1,114 +0,0 @@
1
- /**
2
- * StreamList — sidebar list of IPC streams with a pinned "System" entry.
3
- *
4
- * @module
5
- */
6
-
7
- import { useCallback, type JSX } from "react";
8
- import { useLocation, useMatch } from "react-router";
9
- import { MessageSquare, Radio, RefreshCw } from "lucide-react";
10
- import type { StreamData } from "../../hooks/types.js";
11
- import { useAppNavigate, chatStreamUrl, CHAT_URL } from "../../utils/navigation.js";
12
- import styles from "./StreamList.module.scss";
13
-
14
- /** Size for row icons. */
15
- const ICON_SIZE: number = 14;
16
-
17
- /** Props for the StreamList sidebar component. */
18
- export interface StreamListProps {
19
- /** All known IPC streams. */
20
- streams: StreamData[];
21
- /** Whether streams are currently loading. */
22
- loading: boolean;
23
- /** True if the most recent load attempt failed. */
24
- streamsLoadError?: boolean;
25
- /** True after at least one load attempt has completed. */
26
- streamsLoadedOnce?: boolean;
27
- /** Optional callback to trigger a stream list refresh. */
28
- onRefresh?: () => void;
29
- }
30
-
31
- /**
32
- * Sidebar list showing IPC streams.
33
- *
34
- * The "System" row is always pinned at the top and links to `/chat`.
35
- * Named streams are listed below, sorted alphabetically.
36
- */
37
- export function StreamList({ streams, loading, streamsLoadError = false, streamsLoadedOnce = true, onRefresh }: StreamListProps): JSX.Element {
38
- const navigate = useAppNavigate();
39
- const location = useLocation();
40
- const streamMatch = useMatch("/chat/:streamId");
41
-
42
- const selectedStreamId = streamMatch?.params.streamId;
43
- const isSystemSelected = !selectedStreamId && location.pathname === CHAT_URL;
44
-
45
- const sortedStreams = [...streams].sort((a, b) => a.name.localeCompare(b.name));
46
-
47
- const handleSystemClick = useCallback(() => {
48
- navigate(CHAT_URL);
49
- }, [navigate]);
50
-
51
- const handleStreamClick = useCallback((streamId: string) => {
52
- navigate(chatStreamUrl(streamId));
53
- }, [navigate]);
54
-
55
- return (
56
- <div className={styles.container} data-testid="stream-list">
57
- <div className={styles.header}>
58
- <span>Streams</span>
59
- {onRefresh && (
60
- <button
61
- className={styles.refreshButton}
62
- onClick={onRefresh}
63
- aria-label="Refresh streams"
64
- data-testid="stream-list-refresh"
65
- >
66
- <RefreshCw size={12} />
67
- </button>
68
- )}
69
- </div>
70
-
71
- {/* Pinned System row */}
72
- <button
73
- type="button"
74
- className={`${styles.systemRow}${isSystemSelected ? ` ${styles.selected}` : ""}`}
75
- onClick={handleSystemClick}
76
- data-testid="stream-list-system-row"
77
- aria-current={isSystemSelected ? "page" : undefined}
78
- >
79
- <MessageSquare size={ICON_SIZE} className={styles.streamIcon} />
80
- <span className={styles.streamName}>System</span>
81
- </button>
82
-
83
- {/* Named streams */}
84
- {loading && sortedStreams.length === 0 && (
85
- <div className={styles.loading}>Loading...</div>
86
- )}
87
- {!loading && streamsLoadError && (
88
- <div className={styles.emptyState} data-testid="stream-list-error">Unable to load streams</div>
89
- )}
90
- {!loading && !streamsLoadError && streamsLoadedOnce && sortedStreams.length === 0 && (
91
- <div className={styles.emptyState}>No streams</div>
92
- )}
93
- {sortedStreams.map((stream) => {
94
- const isSelected = selectedStreamId === stream.id;
95
- return (
96
- <button
97
- key={stream.id}
98
- type="button"
99
- className={`${styles.streamRow}${isSelected ? ` ${styles.selected}` : ""}`}
100
- onClick={() => handleStreamClick(stream.id)}
101
- data-testid={`stream-list-row-${stream.id}`}
102
- aria-current={isSelected ? "page" : undefined}
103
- >
104
- <Radio size={ICON_SIZE} className={styles.streamIcon} />
105
- <span className={styles.streamName}>{stream.name}</span>
106
- {stream.subscriberCount > 0 && (
107
- <span className={styles.subscriberBadge}>{stream.subscriberCount}</span>
108
- )}
109
- </button>
110
- );
111
- })}
112
- </div>
113
- );
114
- }