@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.
- package/.rush/temp/{b47d67cd3e2d79d0da7f9aef2eb425725d6d2f61.tar.log → 989dfd9dfc66be6288052dccbf2952ddad9066c6.tar.log} +2 -2
- package/.rush/temp/{05ec67b10f932bdbe295aab3f4465cf0d26cb485.untar.log → 989dfd9dfc66be6288052dccbf2952ddad9066c6.untar.log} +2 -2
- package/.rush/temp/{05ec67b10f932bdbe295aab3f4465cf0d26cb485.tar.log → b1a36bcc314d65fbd843fcff6d3127ebdf827e06.tar.log} +76 -78
- package/.rush/temp/{b47d67cd3e2d79d0da7f9aef2eb425725d6d2f61.untar.log → b1a36bcc314d65fbd843fcff6d3127ebdf827e06.untar.log} +2 -2
- package/.rush/temp/chunked-rush-logs/web-components._phase_build.chunks.jsonl +6 -6
- package/.rush/temp/chunked-rush-logs/web-components._phase_test.chunks.jsonl +25 -23
- package/.rush/temp/operation/_phase_build/all.log +6 -6
- package/.rush/temp/operation/_phase_build/log-chunks.jsonl +6 -6
- package/.rush/temp/operation/_phase_build/state.json +1 -1
- package/.rush/temp/operation/_phase_test/all.log +25 -23
- package/.rush/temp/operation/_phase_test/log-chunks.jsonl +25 -23
- package/.rush/temp/operation/_phase_test/state.json +1 -1
- package/README.md +1 -1
- package/dist/index.css +1 -1
- package/dist/index.js +7577 -7373
- package/package.json +2 -2
- package/rush-logs/web-components._phase_build.cache.log +1 -1
- package/rush-logs/web-components._phase_build.log +6 -6
- package/rush-logs/web-components._phase_test.cache.log +1 -1
- package/rush-logs/web-components._phase_test.log +25 -23
- package/src/components/layout/AppNav.stories.tsx +5 -5
- package/src/components/layout/AppNav.tsx +8 -4
- package/src/components/panels/KeyboardShortcutsPanel.stories.tsx +1 -1
- package/src/components/panels/KeyboardShortcutsPanel.tsx +1 -1
- package/src/components/streams/CoordinationList.module.scss +137 -0
- package/src/components/streams/CoordinationList.stories.tsx +95 -0
- package/src/components/streams/CoordinationList.tsx +153 -0
- package/src/components/streams/StreamDetailPanel.module.scss +30 -0
- package/src/components/streams/StreamDetailPanel.stories.tsx +3 -0
- package/src/components/streams/StreamDetailPanel.tsx +58 -24
- package/src/components/streams/index.ts +3 -3
- package/src/hooks/types.ts +9 -2
- package/src/index.ts +4 -4
- package/src/mocks/MockGrackleProvider.tsx +15 -3
- package/src/mocks/mockData.ts +4 -0
- package/src/mocks/mockStreamsData.ts +80 -0
- package/src/utils/navigation.ts +3 -5
- package/src/utils/streamCoordination.test.ts +88 -0
- package/src/utils/streamCoordination.ts +108 -0
- package/temp/build/lint/_eslint-5eVG3S6w.json +30 -18
- package/src/components/streams/StreamList.module.scss +0 -92
- package/src/components/streams/StreamList.stories.tsx +0 -99
- 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,
|
|
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
|
×
|
|
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}>
|
|
75
|
-
<span className={styles.metaValue}>{stream
|
|
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}>
|
|
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
|
-
{/*
|
|
100
|
+
{/* Participants */}
|
|
88
101
|
<div className={styles.section}>
|
|
89
|
-
<div className={styles.sectionLabel}>
|
|
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
|
-
<
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
*
|
|
2
|
+
* IPC stream inventory and detail panel components (Coordination tab).
|
|
3
3
|
*
|
|
4
4
|
* @module streams
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
export {
|
|
8
|
-
export type {
|
|
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";
|
package/src/hooks/types.ts
CHANGED
|
@@ -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
|
-
/**
|
|
657
|
-
|
|
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 {
|
|
91
|
-
export type {
|
|
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,
|
|
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,
|
package/src/mocks/mockData.ts
CHANGED
|
@@ -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
|
+
];
|
package/src/utils/navigation.ts
CHANGED
|
@@ -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
|
-
/**
|
|
166
|
-
export
|
|
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
|
+
}
|