@grackle-ai/web-components 0.113.0 → 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 (43) hide show
  1. package/.rush/temp/{b47d67cd3e2d79d0da7f9aef2eb425725d6d2f61.tar.log → 989dfd9dfc66be6288052dccbf2952ddad9066c6.tar.log} +2 -2
  2. package/.rush/temp/{05ec67b10f932bdbe295aab3f4465cf0d26cb485.untar.log → 989dfd9dfc66be6288052dccbf2952ddad9066c6.untar.log} +2 -2
  3. package/.rush/temp/{05ec67b10f932bdbe295aab3f4465cf0d26cb485.tar.log → b1a36bcc314d65fbd843fcff6d3127ebdf827e06.tar.log} +76 -78
  4. package/.rush/temp/{b47d67cd3e2d79d0da7f9aef2eb425725d6d2f61.untar.log → b1a36bcc314d65fbd843fcff6d3127ebdf827e06.untar.log} +2 -2
  5. package/.rush/temp/chunked-rush-logs/web-components._phase_build.chunks.jsonl +6 -6
  6. package/.rush/temp/chunked-rush-logs/web-components._phase_test.chunks.jsonl +25 -23
  7. package/.rush/temp/operation/_phase_build/all.log +6 -6
  8. package/.rush/temp/operation/_phase_build/log-chunks.jsonl +6 -6
  9. package/.rush/temp/operation/_phase_build/state.json +1 -1
  10. package/.rush/temp/operation/_phase_test/all.log +25 -23
  11. package/.rush/temp/operation/_phase_test/log-chunks.jsonl +25 -23
  12. package/.rush/temp/operation/_phase_test/state.json +1 -1
  13. package/README.md +1 -1
  14. package/dist/index.css +1 -1
  15. package/dist/index.js +7577 -7373
  16. package/package.json +2 -2
  17. package/rush-logs/web-components._phase_build.cache.log +1 -1
  18. package/rush-logs/web-components._phase_build.log +6 -6
  19. package/rush-logs/web-components._phase_test.cache.log +1 -1
  20. package/rush-logs/web-components._phase_test.log +25 -23
  21. package/src/components/layout/AppNav.stories.tsx +5 -5
  22. package/src/components/layout/AppNav.tsx +8 -4
  23. package/src/components/panels/KeyboardShortcutsPanel.stories.tsx +1 -1
  24. package/src/components/panels/KeyboardShortcutsPanel.tsx +1 -1
  25. package/src/components/streams/CoordinationList.module.scss +137 -0
  26. package/src/components/streams/CoordinationList.stories.tsx +95 -0
  27. package/src/components/streams/CoordinationList.tsx +153 -0
  28. package/src/components/streams/StreamDetailPanel.module.scss +30 -0
  29. package/src/components/streams/StreamDetailPanel.stories.tsx +3 -0
  30. package/src/components/streams/StreamDetailPanel.tsx +58 -24
  31. package/src/components/streams/index.ts +3 -3
  32. package/src/hooks/types.ts +9 -2
  33. package/src/index.ts +4 -4
  34. package/src/mocks/MockGrackleProvider.tsx +15 -3
  35. package/src/mocks/mockData.ts +4 -0
  36. package/src/mocks/mockStreamsData.ts +80 -0
  37. package/src/utils/navigation.ts +3 -5
  38. package/src/utils/streamCoordination.test.ts +88 -0
  39. package/src/utils/streamCoordination.ts +108 -0
  40. package/temp/build/lint/_eslint-5eVG3S6w.json +30 -18
  41. package/src/components/streams/StreamList.module.scss +0 -92
  42. package/src/components/streams/StreamList.stories.tsx +0 -99
  43. package/src/components/streams/StreamList.tsx +0 -114
@@ -4,12 +4,17 @@
4
4
  * Renders as an absolutely-positioned overlay anchored to the right of its
5
5
  * containing block (which must have `position: relative`).
6
6
  *
7
+ * Read-only: participants link to their sessions; low-level wiring (fds, full
8
+ * GUIDs, permission/delivery mode) is tucked behind an "Advanced" disclosure.
9
+ * Live conversation content arrives in V2 (#1230).
10
+ *
7
11
  * @module
8
12
  */
9
13
 
10
14
  import { useEffect, type JSX } from "react";
11
15
  import type { StreamData } from "../../hooks/types.js";
12
16
  import { useAppNavigate, sessionUrl } from "../../utils/navigation.js";
17
+ import { streamKind, type StreamKind } from "../../utils/streamCoordination.js";
13
18
  import styles from "./StreamDetailPanel.module.scss";
14
19
 
15
20
  /** Props for the StreamDetailPanel component. */
@@ -20,6 +25,13 @@ export interface StreamDetailPanelProps {
20
25
  onClose: () => void;
21
26
  }
22
27
 
28
+ /** Human-readable kind label. */
29
+ const KIND_LABEL: Record<StreamKind, string> = {
30
+ chatroom: "Chatroom",
31
+ pipe: "Pipe",
32
+ channel: "Channel",
33
+ };
34
+
23
35
  /** Render a permission badge with appropriate color. */
24
36
  function PermissionBadge({ permission }: { permission: string }): JSX.Element {
25
37
  const cls = permission === "rw"
@@ -41,7 +53,8 @@ function DeliveryModeBadge({ mode }: { mode: string }): JSX.Element {
41
53
  }
42
54
 
43
55
  /**
44
- * Pull-out right drawer showing stream metadata: overview, subscribers, fds.
56
+ * Pull-out right drawer showing stream metadata: overview, participants, and an
57
+ * Advanced disclosure with low-level wiring. Conversation content is V2.
45
58
  */
46
59
  export function StreamDetailPanel({ stream, onClose }: StreamDetailPanelProps): JSX.Element {
47
60
  const navigate = useAppNavigate();
@@ -61,7 +74,7 @@ export function StreamDetailPanel({ stream, onClose }: StreamDetailPanelProps):
61
74
  <div className={styles.panel} data-testid="stream-detail-panel">
62
75
  <div className={styles.header}>
63
76
  <h3 className={styles.title}>{stream.name}</h3>
64
- <button className={styles.closeButton} onClick={onClose} aria-label="Close stream details">
77
+ <button type="button" className={styles.closeButton} onClick={onClose} aria-label="Close stream details">
65
78
  &times;
66
79
  </button>
67
80
  </div>
@@ -71,11 +84,11 @@ export function StreamDetailPanel({ stream, onClose }: StreamDetailPanelProps):
71
84
  <div className={styles.section}>
72
85
  <div className={styles.sectionLabel}>Overview</div>
73
86
  <div className={styles.metaRow}>
74
- <span className={styles.metaKey}>Stream ID</span>
75
- <span className={styles.metaValue}>{stream.id}</span>
87
+ <span className={styles.metaKey}>Kind</span>
88
+ <span className={styles.metaValue}>{KIND_LABEL[streamKind(stream)]}</span>
76
89
  </div>
77
90
  <div className={styles.metaRow}>
78
- <span className={styles.metaKey}>Subscribers</span>
91
+ <span className={styles.metaKey}>Participants</span>
79
92
  <span className={styles.metaValue}>{stream.subscriberCount}</span>
80
93
  </div>
81
94
  <div className={styles.metaRow}>
@@ -84,35 +97,56 @@ export function StreamDetailPanel({ stream, onClose }: StreamDetailPanelProps):
84
97
  </div>
85
98
  </div>
86
99
 
87
- {/* Subscribers */}
100
+ {/* Participants */}
88
101
  <div className={styles.section}>
89
- <div className={styles.sectionLabel}>Subscribers</div>
102
+ <div className={styles.sectionLabel}>Participants</div>
90
103
  {stream.subscribers.length === 0 ? (
91
104
  <div className={styles.emptySubscribers}>No active subscribers</div>
92
105
  ) : (
93
106
  stream.subscribers.map((sub) => (
94
107
  <div key={sub.subscriptionId} className={styles.subscriberCard} data-testid={`subscriber-card-${sub.subscriptionId}`}>
95
- <div className={styles.subscriberHeader}>
96
- <span className={styles.fdNumber}>fd {sub.fd}</span>
97
- <button
98
- className={styles.sessionLink}
99
- onClick={() => { navigate(sessionUrl(sub.sessionId)); }}
100
- title={sub.sessionId}
101
- >
102
- {sub.sessionId.slice(0, 12)}…
103
- </button>
104
- </div>
105
- <div className={styles.badges}>
106
- <PermissionBadge permission={sub.permission} />
107
- <DeliveryModeBadge mode={sub.deliveryMode} />
108
- {sub.createdBySpawn && (
109
- <span className={styles.spawnTag}>spawn</span>
110
- )}
111
- </div>
108
+ <button
109
+ type="button"
110
+ className={styles.sessionLink}
111
+ onClick={() => { navigate(sessionUrl(sub.sessionId)); }}
112
+ title={sub.sessionId}
113
+ >
114
+ {sub.sessionId.slice(0, 12)}…
115
+ </button>
116
+ {sub.createdBySpawn && <span className={styles.spawnTag}>spawn</span>}
112
117
  </div>
113
118
  ))
114
119
  )}
115
120
  </div>
121
+
122
+ {/* Live conversation — V2 */}
123
+ <div className={styles.section}>
124
+ <div className={styles.sectionLabel}>Conversation</div>
125
+ <div className={styles.placeholder} data-testid="stream-conversation-placeholder">
126
+ Live conversation view — coming in V2.
127
+ </div>
128
+ </div>
129
+
130
+ {/* Advanced wiring */}
131
+ <details className={styles.advanced} data-testid="stream-advanced">
132
+ <summary className={styles.advancedSummary}>Advanced</summary>
133
+ <div className={styles.metaRow}>
134
+ <span className={styles.metaKey}>Stream ID</span>
135
+ <span className={styles.metaValueMono}>{stream.id}</span>
136
+ </div>
137
+ {stream.subscribers.map((sub) => (
138
+ <div key={sub.subscriptionId} className={styles.subscriberCard}>
139
+ <div className={styles.subscriberHeader}>
140
+ <span className={styles.fdNumber}>fd {sub.fd}</span>
141
+ <span className={styles.metaValueMono} title={sub.subscriptionId}>{sub.subscriptionId.slice(0, 12)}…</span>
142
+ </div>
143
+ <div className={styles.badges}>
144
+ <PermissionBadge permission={sub.permission} />
145
+ <DeliveryModeBadge mode={sub.deliveryMode} />
146
+ </div>
147
+ </div>
148
+ ))}
149
+ </details>
116
150
  </div>
117
151
  </div>
118
152
  );
@@ -1,10 +1,10 @@
1
1
  /**
2
- * Stream sidebar and detail panel components.
2
+ * IPC stream inventory and detail panel components (Coordination tab).
3
3
  *
4
4
  * @module streams
5
5
  */
6
6
 
7
- export { StreamList } from "./StreamList.js";
8
- export type { StreamListProps } from "./StreamList.js";
7
+ export { CoordinationList } from "./CoordinationList.js";
8
+ export type { CoordinationListProps } from "./CoordinationList.js";
9
9
  export { StreamDetailPanel } from "./StreamDetailPanel.js";
10
10
  export type { StreamDetailPanelProps } from "./StreamDetailPanel.js";
@@ -49,6 +49,8 @@ export interface Session {
49
49
  error?: string;
50
50
  endReason?: string;
51
51
  personaId?: string;
52
+ /** ID of the task this session belongs to, if any (root/orchestrated work). */
53
+ taskId?: string;
52
54
  inputTokens?: number;
53
55
  outputTokens?: number;
54
56
  costMillicents?: number;
@@ -641,6 +643,8 @@ export interface StreamData {
641
643
  messageBufferDepth: number;
642
644
  /** Full subscriber details. */
643
645
  subscribers: StreamSubscriberData[];
646
+ /** Whether publishers receive their own messages echoed back (marks a chatroom). */
647
+ selfEcho: boolean;
644
648
  }
645
649
 
646
650
  /** Values returned by the streams domain hook. */
@@ -653,8 +657,11 @@ export interface UseStreamsResult {
653
657
  streamsLoadedOnce: boolean;
654
658
  /** True if the most recent loadStreams call failed (e.g. RPC/network error). */
655
659
  streamsLoadError: boolean;
656
- /** Request the current stream list from the server. */
657
- loadStreams: () => Promise<void>;
660
+ /**
661
+ * Request the current stream list from the server. Pass `includeInternal`
662
+ * to surface internal IPC plumbing (lifecycle/pipe/stdin); defaults to false.
663
+ */
664
+ loadStreams: (includeInternal?: boolean) => Promise<void>;
658
665
  /** Handle a domain event from the event bus. Returns `true` if handled. */
659
666
  handleEvent: (event: GrackleEvent) => boolean;
660
667
  /** Lifecycle hook for connect/disconnect/event routing. */
package/src/index.ts CHANGED
@@ -86,9 +86,9 @@ export type { ScheduleManagerProps } from "./components/schedules/ScheduleManage
86
86
  // Settings
87
87
  export { SettingsNav } from "./components/settings/SettingsNav.js";
88
88
 
89
- // Streams
90
- export { StreamList, StreamDetailPanel } from "./components/streams/index.js";
91
- export type { StreamListProps, StreamDetailPanelProps } from "./components/streams/index.js";
89
+ // Streams (Coordination tab)
90
+ export { CoordinationList, StreamDetailPanel } from "./components/streams/index.js";
91
+ export type { CoordinationListProps, StreamDetailPanelProps } from "./components/streams/index.js";
92
92
 
93
93
  // Tools
94
94
  export { ToolCard } from "./components/tools/ToolCard.js";
@@ -165,7 +165,7 @@ export {
165
165
  SETTINGS_APPEARANCE_URL, SETTINGS_ABOUT_URL, SETTINGS_SHORTCUTS_URL,
166
166
  PAIR_PATH, NEW_WORKSPACE_URL, KNOWLEDGE_URL, HOME_URL,
167
167
  FINDINGS_URL, findingsUrl, findingUrl,
168
- CHAT_URL, chatStreamUrl, TASKS_URL,
168
+ CHAT_URL, COORDINATION_URL, TASKS_URL,
169
169
  } from "./utils/navigation.js";
170
170
 
171
171
  export {
@@ -44,6 +44,7 @@ const NOOP_DOMAIN_HOOK: DomainHook = {
44
44
  import {
45
45
  MOCK_ENVIRONMENTS,
46
46
  MOCK_SESSIONS,
47
+ MOCK_STREAMS,
47
48
  MOCK_EVENTS,
48
49
  MOCK_WORKSPACES,
49
50
  MOCK_TASKS,
@@ -57,7 +58,16 @@ import {
57
58
  MOCK_KNOWLEDGE_DETAILS,
58
59
  type MockStreamStep,
59
60
  } from "./mockData.js";
60
- import type { GraphNode, GraphLink, NodeDetail } from "../hooks/types.js";
61
+ import type { GraphNode, GraphLink, NodeDetail, StreamData } from "../hooks/types.js";
62
+ import { INTERNAL_STREAM_PREFIXES } from "../utils/streamCoordination.js";
63
+
64
+ /** Filter internal IPC plumbing streams unless explicitly requested. */
65
+ function filterMockStreams(includeInternal: boolean): StreamData[] {
66
+ if (includeInternal) {
67
+ return MOCK_STREAMS;
68
+ }
69
+ return MOCK_STREAMS.filter((s) => !INTERNAL_STREAM_PREFIXES.some((p) => s.name.startsWith(p)));
70
+ }
61
71
 
62
72
  // ─── Constants ──────────────────────────────────────
63
73
 
@@ -81,6 +91,7 @@ export function MockGrackleProvider({ children }: MockGrackleProviderProps): JSX
81
91
  // ── State ─────────────────────────────────────────
82
92
  const [environments, setEnvironments] = useState<Environment[]>(MOCK_ENVIRONMENTS);
83
93
  const [sessions, setSessions] = useState<Session[]>(MOCK_SESSIONS);
94
+ const [streams, setStreams] = useState<StreamData[]>(() => filterMockStreams(false));
84
95
  const [events, setEvents] = useState<SessionEvent[]>(MOCK_EVENTS);
85
96
  const [lastSpawnedId, setLastSpawnedId] = useState<string | undefined>(undefined);
86
97
  const [workspaces, setWorkspaces] = useState<Workspace[]>(MOCK_WORKSPACES);
@@ -1176,11 +1187,11 @@ export function MockGrackleProvider({ children }: MockGrackleProviderProps): JSX
1176
1187
  },
1177
1188
 
1178
1189
  streams: {
1179
- streams: [],
1190
+ streams,
1180
1191
  streamsLoading: false,
1181
1192
  streamsLoadedOnce: true,
1182
1193
  streamsLoadError: false,
1183
- loadStreams: async () => {},
1194
+ loadStreams: async (includeInternal = false) => { setStreams(filterMockStreams(includeInternal)); },
1184
1195
  domainHook: NOOP_DOMAIN_HOOK,
1185
1196
  },
1186
1197
 
@@ -1371,6 +1382,7 @@ export function MockGrackleProvider({ children }: MockGrackleProviderProps): JSX
1371
1382
  [
1372
1383
  environments,
1373
1384
  sessions,
1385
+ streams,
1374
1386
  events,
1375
1387
  lastSpawnedId,
1376
1388
  taskSessions,
@@ -17,6 +17,7 @@ import type {
17
17
  PersonaData,
18
18
  } from "../hooks/types.js";
19
19
  export { MOCK_KNOWLEDGE_NODES, MOCK_KNOWLEDGE_LINKS, MOCK_KNOWLEDGE_DETAILS } from "./mockKnowledgeData.js";
20
+ export { MOCK_STREAMS } from "./mockStreamsData.js";
20
21
 
21
22
  // ─── Environments ───────────────────────────────────
22
23
 
@@ -94,6 +95,7 @@ export const MOCK_SESSIONS: Session[] = [
94
95
  status: "running",
95
96
  prompt: "Refactor the authentication middleware to use JWT tokens",
96
97
  startedAt: "2026-02-27T08:15:00Z",
98
+ taskId: "task-001",
97
99
  inputTokens: 42_600,
98
100
  outputTokens: 8_100,
99
101
  costMillicents: 22_000,
@@ -107,6 +109,7 @@ export const MOCK_SESSIONS: Session[] = [
107
109
  prompt: "Write unit tests for the user registration endpoint",
108
110
  startedAt: "2026-02-27T07:30:00Z",
109
111
  endedAt: "2026-02-27T07:33:00Z",
112
+ taskId: "task-003",
110
113
  inputTokens: 31_400,
111
114
  outputTokens: 9_800,
112
115
  costMillicents: 18_000,
@@ -131,6 +134,7 @@ export const MOCK_SESSIONS: Session[] = [
131
134
  status: "running",
132
135
  prompt: "Implement rate limiting for the public API",
133
136
  startedAt: "2026-02-27T09:00:00Z",
137
+ taskId: "task-006c",
134
138
  inputTokens: 18_900,
135
139
  outputTokens: 4_500,
136
140
  costMillicents: 10_000,
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Static mock IPC streams for the Coordination surface in `?mock` demo mode.
3
+ *
4
+ * Subscribers reference sessions from {@link MOCK_SESSIONS}; sessions carry a
5
+ * `taskId`, so the Coordination tab attributes each stream to its owning task.
6
+ * Internal plumbing (`lifecycle:`/`pipe:`/`stdin:`) is hidden until the user
7
+ * toggles "Show internals".
8
+ *
9
+ * @module
10
+ */
11
+
12
+ import type { StreamData } from "../hooks/types.js";
13
+
14
+ /** Sample IPC streams: a chatroom + channel attributed to tasks, an unattached
15
+ * stream, and internal plumbing streams. */
16
+ export const MOCK_STREAMS: StreamData[] = [
17
+ {
18
+ id: "stream-planning",
19
+ name: "jwt-planning-room",
20
+ selfEcho: true,
21
+ subscriberCount: 2,
22
+ messageBufferDepth: 3,
23
+ subscribers: [
24
+ { subscriptionId: "sub-p1", sessionId: "sess-001", fd: 3, permission: "rw", deliveryMode: "async", createdBySpawn: false },
25
+ { subscriptionId: "sub-p2", sessionId: "sess-002", fd: 4, permission: "r", deliveryMode: "async", createdBySpawn: true },
26
+ ],
27
+ },
28
+ {
29
+ id: "stream-metrics",
30
+ name: "rate-limit-metrics",
31
+ selfEcho: false,
32
+ subscriberCount: 1,
33
+ messageBufferDepth: 0,
34
+ subscribers: [
35
+ { subscriptionId: "sub-m1", sessionId: "sess-004", fd: 3, permission: "r", deliveryMode: "sync", createdBySpawn: false },
36
+ ],
37
+ },
38
+ {
39
+ id: "stream-cli",
40
+ name: "cli-inspector",
41
+ selfEcho: false,
42
+ subscriberCount: 1,
43
+ messageBufferDepth: 1,
44
+ subscribers: [
45
+ { subscriptionId: "sub-c1", sessionId: "external-cli-session", fd: 5, permission: "rw", deliveryMode: "async", createdBySpawn: false },
46
+ ],
47
+ },
48
+ // ── Internal plumbing — hidden unless "Show internals" is on ──
49
+ {
50
+ id: "stream-lifecycle",
51
+ name: "lifecycle:sess-001-7f3a",
52
+ selfEcho: false,
53
+ subscriberCount: 1,
54
+ messageBufferDepth: 0,
55
+ subscribers: [
56
+ { subscriptionId: "sub-l1", sessionId: "sess-001", fd: 6, permission: "rw", deliveryMode: "detach", createdBySpawn: true },
57
+ ],
58
+ },
59
+ {
60
+ id: "stream-pipe",
61
+ name: "pipe:sess-001-sess-004",
62
+ selfEcho: false,
63
+ subscriberCount: 2,
64
+ messageBufferDepth: 0,
65
+ subscribers: [
66
+ { subscriptionId: "sub-pp1", sessionId: "sess-001", fd: 7, permission: "rw", deliveryMode: "async", createdBySpawn: false },
67
+ { subscriptionId: "sub-pp2", sessionId: "sess-004", fd: 8, permission: "rw", deliveryMode: "async", createdBySpawn: true },
68
+ ],
69
+ },
70
+ {
71
+ id: "stream-stdin",
72
+ name: "stdin:sess-002-9c2e",
73
+ selfEcho: false,
74
+ subscriberCount: 1,
75
+ messageBufferDepth: 0,
76
+ subscribers: [
77
+ { subscriptionId: "sub-s1", sessionId: "sess-002", fd: 9, permission: "w", deliveryMode: "detach", createdBySpawn: true },
78
+ ],
79
+ },
80
+ ];
@@ -159,13 +159,11 @@ export const SETTINGS_SHORTCUTS_URL: string = "/settings/shortcuts";
159
159
  /** URL for the device pairing page. */
160
160
  export const PAIR_PATH: string = "/pair";
161
161
 
162
- /** URL for the root-task chat page. */
162
+ /** URL for the root-task ("Root") chat page. */
163
163
  export const CHAT_URL: string = "/chat";
164
164
 
165
- /** Build URL for a specific IPC stream's chat page. */
166
- export function chatStreamUrl(streamId: string): string {
167
- return `/chat/${encodeURIComponent(streamId)}`;
168
- }
165
+ /** URL for the Coordination page (read-only IPC stream inventory). */
166
+ export const COORDINATION_URL: string = "/coordination";
169
167
 
170
168
  /** URL for the home dashboard page. */
171
169
  export const HOME_URL: string = "/";
@@ -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
+ }