@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grackle-ai/web-components",
3
- "version": "0.113.0",
3
+ "version": "0.114.0",
4
4
  "description": "Presentational React component library for the Grackle web UI",
5
5
  "license": "MIT",
6
6
  "sideEffects": [
@@ -46,7 +46,7 @@
46
46
  "remark-gfm": "^4.0.0",
47
47
  "lucide-react": "~0.474.0",
48
48
  "react-router": "^7.0.0",
49
- "@grackle-ai/common": "0.113.0"
49
+ "@grackle-ai/common": "0.114.0"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@rushstack/heft": "1.2.7",
@@ -1,4 +1,4 @@
1
1
  Build cache hit.
2
- Cache key: 05ec67b10f932bdbe295aab3f4465cf0d26cb485
2
+ Cache key: b1a36bcc314d65fbd843fcff6d3127ebdf827e06
3
3
  Clearing cached folders: dist, storybook-static, .rush/temp/operation/_phase_build
4
4
  Successfully restored output from the build cache.
@@ -7,13 +7,13 @@ Invoking: heft run --only build -- --clean
7
7
  [build:lint] Using ESLint version 9.39.4
8
8
  vite v6.4.2 building for production...
9
9
  transforming...
10
- 2716 modules transformed.
10
+ 2718 modules transformed.
11
11
  rendering chunks...
12
12
  computing gzip size...
13
- dist/index.css 158.47 kB │ gzip: 20.60 kB
13
+ dist/index.css 159.65 kB │ gzip: 20.78 kB
14
14
  dist/McpAppWidget-CSX2W2Vb.js 205.28 kB │ gzip: 47.19 kB
15
- dist/index.js 1,385.24 kB │ gzip: 354.56 kB
16
- ✓ built in 5.92s
15
+ dist/index.js 1,392.78 kB │ gzip: 356.09 kB
16
+ ✓ built in 5.94s
17
17
  [build:vite-build] Vite build completed.
18
- ---- build finished (77.344s) ----
19
- -------------------- Finished (77.347s) --------------------
18
+ ---- build finished (75.104s) ----
19
+ -------------------- Finished (75.107s) --------------------
@@ -1,4 +1,4 @@
1
1
  Build cache hit.
2
- Cache key: b47d67cd3e2d79d0da7f9aef2eb425725d6d2f61
2
+ Cache key: 989dfd9dfc66be6288052dccbf2952ddad9066c6
3
3
  Clearing cached folders: .rush/temp/operation/_phase_test
4
4
  Successfully restored output from the build cache.
@@ -5,29 +5,31 @@ The provided list of phases does not contain all phase dependencies. You may nee
5
5
 
6
6
  RUN v3.2.4 /home/runner/work/grackle/grackle/packages/web-components
7
7
 
8
- ✓ src/utils/sessionEvents.test.ts (14 tests) 36ms
9
- ✓ src/utils/eventContent.test.ts (38 tests) 110ms
10
- ✓ src/utils/dashboard.test.ts (4 tests) 7ms
11
- ✓ src/utils/route-config.test.ts (23 tests) 21ms
12
- ✓ src/utils/scrollUtils.test.ts (11 tests) 49ms
13
- ✓ src/utils/breadcrumbs.test.ts (18 tests) 88ms
14
- ✓ src/components/tools/classifyTool.test.ts (6 tests) 17ms
15
- ✓ src/utils/assetUrl.test.ts (3 tests) 33ms
16
- ✓ src/components/tools/toolCardHelpers.test.ts (10 tests) 29ms
17
- ✓ src/components/display/extractText.test.tsx (8 tests) 31ms
18
- ✓ src/components/editable/useEditableField.test.tsx (17 tests) 273ms
19
- ✓ src/hooks/useEventSelection.test.ts (13 tests) 121ms
20
- ✓ src/components/notifications/UpdateBanner.test.tsx (4 tests) 155ms
21
- ✓ src/utils/grackleHostStyleVariables.test.ts (2 tests) 306ms
22
- ✓ src/components/display/McpAppWidget.test.tsx (3 tests) 383ms
23
-
24
- Test Files 15 passed (15)
25
- Tests 174 passed (174)
26
- Start at 03:06:05
27
- Duration 16.86s (transform 3.49s, setup 0ms, collect 18.02s, tests 1.66s, environment 13.18s, prepare 5.11s)
8
+ ✓ src/utils/sessionEvents.test.ts (14 tests) 67ms
9
+ ✓ src/utils/eventContent.test.ts (38 tests) 187ms
10
+ ✓ src/utils/dashboard.test.ts (4 tests) 38ms
11
+ ✓ src/utils/route-config.test.ts (23 tests) 90ms
12
+ ✓ src/utils/streamCoordination.test.ts (10 tests) 29ms
13
+ ✓ src/utils/breadcrumbs.test.ts (18 tests) 41ms
14
+ ✓ src/utils/scrollUtils.test.ts (11 tests) 41ms
15
+ ✓ src/components/tools/classifyTool.test.ts (6 tests) 19ms
16
+ ✓ src/components/tools/toolCardHelpers.test.ts (10 tests) 14ms
17
+ ✓ src/utils/assetUrl.test.ts (3 tests) 11ms
18
+ ✓ src/components/display/extractText.test.tsx (8 tests) 12ms
19
+ ✓ src/hooks/useEventSelection.test.ts (13 tests) 244ms
20
+ ✓ src/components/editable/useEditableField.test.tsx (17 tests) 231ms
21
+ ✓ src/components/notifications/UpdateBanner.test.tsx (4 tests) 169ms
22
+ ✓ src/components/display/McpAppWidget.test.tsx (3 tests) 234ms
23
+ ✓ src/utils/grackleHostStyleVariables.test.ts (2 tests) 477ms
24
+ grackleHostStyleVariables > always returns the MCP-standard fallback variables 467ms
25
+
26
+ Test Files 16 passed (16)
27
+ Tests 184 passed (184)
28
+ Start at 08:12:34
29
+ Duration 15.79s (transform 2.74s, setup 0ms, collect 15.85s, tests 1.90s, environment 10.61s, prepare 6.00s)
28
30
 
29
31
  [test:vitest] Vitest completed.
30
- [test:storybook-test] Starting Storybook static server on port 33461...
32
+ [test:storybook-test] Starting Storybook static server on port 45781...
31
33
  [test:storybook-test] Storybook server ready. Running interaction tests...
32
34
  jest-haste-map: duplicate manual mock found: adapter-manager
33
35
  The following files share their name; please delete one of them:
@@ -120,5 +122,5 @@ jest-haste-map: duplicate manual mock found: utils/network
120
122
  * <rootDir>/packages/server/src/__mocks__/utils/network.ts
121
123
 
122
124
  [test:storybook-test] Storybook interaction tests completed.
123
- ---- test finished (118.259s) ----
124
- -------------------- Finished (118.269s) --------------------
125
+ ---- test finished (111.929s) ----
126
+ -------------------- Finished (111.943s) --------------------
@@ -17,7 +17,7 @@ type Story = StoryObj<typeof meta>;
17
17
  export const AllTabsRendered: Story = {
18
18
  play: async ({ canvas }) => {
19
19
  await expect(canvas.getByRole("tab", { name: /Dashboard/ })).toBeInTheDocument();
20
- await expect(canvas.getByRole("tab", { name: /Sessions/ })).toBeInTheDocument();
20
+ await expect(canvas.getByRole("tab", { name: /Root/ })).toBeInTheDocument();
21
21
  await expect(canvas.getByRole("tab", { name: /Tasks/ })).toBeInTheDocument();
22
22
  await expect(canvas.getByRole("tab", { name: /Environments/ })).toBeInTheDocument();
23
23
  await expect(canvas.getByRole("tab", { name: /Knowledge/ })).toBeInTheDocument();
@@ -30,14 +30,14 @@ export const CoreOnlyTabs: Story = {
30
30
  args: {
31
31
  tabs: [
32
32
  { view: "dashboard", label: "Dashboard", icon: <Home size={ICON_LG} />, route: HOME_URL, testId: "sidebar-tab-dashboard" },
33
- { view: "chat", label: "Sessions", icon: <MessageSquare size={ICON_LG} />, route: CHAT_URL, testId: "sidebar-tab-chat" },
33
+ { view: "chat", label: "Root", icon: <MessageSquare size={ICON_LG} />, route: CHAT_URL, testId: "sidebar-tab-chat" },
34
34
  { view: "environments", label: "Environments", icon: <Monitor size={ICON_LG} />, route: ENVIRONMENTS_URL, testId: "sidebar-tab-environments" },
35
35
  { view: "settings", label: "Settings", icon: <Settings size={ICON_LG} />, route: SETTINGS_CREDENTIALS_URL, testId: "sidebar-tab-settings" },
36
36
  ],
37
37
  },
38
38
  play: async ({ canvas }) => {
39
39
  await expect(canvas.getByRole("tab", { name: /Dashboard/ })).toBeInTheDocument();
40
- await expect(canvas.getByRole("tab", { name: /Sessions/ })).toBeInTheDocument();
40
+ await expect(canvas.getByRole("tab", { name: /Root/ })).toBeInTheDocument();
41
41
  await expect(canvas.getByRole("tab", { name: /Environments/ })).toBeInTheDocument();
42
42
  await expect(canvas.getByRole("tab", { name: /Settings/ })).toBeInTheDocument();
43
43
  await expect(canvas.queryByRole("tab", { name: /Tasks/ })).not.toBeInTheDocument();
@@ -51,7 +51,7 @@ export const AllTabsExplicit: Story = {
51
51
  args: {
52
52
  tabs: [
53
53
  { view: "dashboard", label: "Dashboard", icon: <Home size={ICON_LG} />, route: HOME_URL, testId: "sidebar-tab-dashboard" },
54
- { view: "chat", label: "Sessions", icon: <MessageSquare size={ICON_LG} />, route: CHAT_URL, testId: "sidebar-tab-chat" },
54
+ { view: "chat", label: "Root", icon: <MessageSquare size={ICON_LG} />, route: CHAT_URL, testId: "sidebar-tab-chat" },
55
55
  { view: "tasks", label: "Tasks", icon: <ClipboardList size={ICON_LG} />, route: TASKS_URL, testId: "sidebar-tab-tasks" },
56
56
  { view: "environments", label: "Environments", icon: <Monitor size={ICON_LG} />, route: ENVIRONMENTS_URL, testId: "sidebar-tab-environments" },
57
57
  { view: "knowledge", label: "Knowledge", icon: <Brain size={ICON_LG} />, route: KNOWLEDGE_URL, testId: "sidebar-tab-knowledge" },
@@ -78,7 +78,7 @@ export const SettingsPinnedRight: Story = {
78
78
  args: {
79
79
  tabs: [
80
80
  { view: "dashboard", label: "Dashboard", icon: <Home size={ICON_LG} />, route: HOME_URL, testId: "sidebar-tab-dashboard" },
81
- { view: "chat", label: "Sessions", icon: <MessageSquare size={ICON_LG} />, route: CHAT_URL, testId: "sidebar-tab-chat" },
81
+ { view: "chat", label: "Root", icon: <MessageSquare size={ICON_LG} />, route: CHAT_URL, testId: "sidebar-tab-chat" },
82
82
  { view: "environments", label: "Environments", icon: <Monitor size={ICON_LG} />, route: ENVIRONMENTS_URL, testId: "sidebar-tab-environments" },
83
83
  { view: "settings", label: "Settings", icon: <Settings size={ICON_LG} />, route: SETTINGS_CREDENTIALS_URL, testId: "sidebar-tab-settings", align: "end" },
84
84
  { view: "tasks", label: "Tasks", icon: <ClipboardList size={ICON_LG} />, route: TASKS_URL, testId: "sidebar-tab-tasks" },
@@ -1,13 +1,13 @@
1
1
  import { useCallback, useMemo, useRef, type JSX, type KeyboardEvent, type ReactNode } from "react";
2
2
  import { useLocation } from "react-router";
3
- import { Brain, ClipboardList, Home, MessageSquare, Monitor, Search, Settings } from "lucide-react";
4
- import { CHAT_URL, ENVIRONMENTS_URL, FINDINGS_URL, HOME_URL, KNOWLEDGE_URL, SETTINGS_URL, SETTINGS_CREDENTIALS_URL, TASKS_URL, useAppNavigate } from "../../utils/navigation.js";
3
+ import { Brain, ClipboardList, Home, MessageSquare, Monitor, Network, Search, Settings } from "lucide-react";
4
+ import { CHAT_URL, COORDINATION_URL, ENVIRONMENTS_URL, FINDINGS_URL, HOME_URL, KNOWLEDGE_URL, SETTINGS_URL, SETTINGS_CREDENTIALS_URL, TASKS_URL, useAppNavigate } from "../../utils/navigation.js";
5
5
  import { ICON_LG } from "../../utils/iconSize.js";
6
6
  import { Tooltip } from "../display/Tooltip.js";
7
7
  import styles from "./AppNav.module.scss";
8
8
 
9
9
  /** Application view identifiers. */
10
- export type AppView = "dashboard" | "chat" | "tasks" | "environments" | "knowledge" | "findings" | "settings";
10
+ export type AppView = "dashboard" | "chat" | "tasks" | "environments" | "knowledge" | "findings" | "coordination" | "settings";
11
11
 
12
12
  /** Tab definition for the application navigation bar. */
13
13
  export interface AppTab {
@@ -36,9 +36,10 @@ export const TABS: AppTab[] = [
36
36
  { view: "dashboard", label: "Dashboard", icon: <Home size={ICON_LG} />, route: HOME_URL, testId: "sidebar-tab-dashboard", order: 0 },
37
37
  { view: "tasks", label: "Tasks", icon: <ClipboardList size={ICON_LG} />, route: TASKS_URL, testId: "sidebar-tab-tasks", order: 1 },
38
38
  { view: "environments", label: "Environments", icon: <Monitor size={ICON_LG} />, route: ENVIRONMENTS_URL, testId: "sidebar-tab-environments", order: 2 },
39
- { view: "chat", label: "Sessions", icon: <MessageSquare size={ICON_LG} />, route: CHAT_URL, testId: "sidebar-tab-chat", order: 3 },
39
+ { view: "chat", label: "Root", icon: <MessageSquare size={ICON_LG} />, route: CHAT_URL, testId: "sidebar-tab-chat", order: 3 },
40
40
  { view: "findings", label: "Findings", icon: <Search size={ICON_LG} />, route: FINDINGS_URL, testId: "sidebar-tab-findings", order: 4 },
41
41
  { view: "knowledge", label: "Knowledge", icon: <Brain size={ICON_LG} />, route: KNOWLEDGE_URL, testId: "sidebar-tab-knowledge", order: 5 },
42
+ { view: "coordination", label: "Coordination", icon: <Network size={ICON_LG} />, route: COORDINATION_URL, testId: "sidebar-tab-coordination", order: 6 },
42
43
  { view: "settings", label: "Settings", icon: <Settings size={ICON_LG} />, route: SETTINGS_CREDENTIALS_URL, testId: "sidebar-tab-settings", align: "end" },
43
44
  ];
44
45
 
@@ -47,6 +48,9 @@ export function getActiveView(pathname: string): AppView {
47
48
  if (pathname === HOME_URL || pathname === "/") {
48
49
  return "dashboard";
49
50
  }
51
+ if (pathname.startsWith(COORDINATION_URL)) {
52
+ return "coordination";
53
+ }
50
54
  if (pathname.startsWith("/chat") || pathname.startsWith("/sessions")) {
51
55
  return "chat";
52
56
  }
@@ -25,7 +25,7 @@ export const AllCategoriesRendered: Story = {
25
25
  await expect(canvas.getByText("Workspace Page")).toBeInTheDocument();
26
26
  await expect(canvas.getByText("Navigation Lists")).toBeInTheDocument();
27
27
  await expect(canvas.getByText("Editing")).toBeInTheDocument();
28
- await expect(canvas.getByText("Sessions")).toBeInTheDocument();
28
+ await expect(canvas.getByText("Root")).toBeInTheDocument();
29
29
  },
30
30
  };
31
31
 
@@ -66,7 +66,7 @@ const SHORTCUT_GROUPS: ShortcutGroup[] = [
66
66
  ],
67
67
  },
68
68
  {
69
- title: "Sessions",
69
+ title: "Root",
70
70
  shortcuts: [
71
71
  { keys: ["Ctrl/Cmd", "Enter"], description: "Send message (when the composer is focused)" },
72
72
  { keys: ["Enter"], description: "Insert a new line in the message composer" },
@@ -0,0 +1,137 @@
1
+ @use '../../styles/mixins' as *;
2
+
3
+ // =============================================================================
4
+ // CoordinationList — read-only IPC stream inventory (Coordination tab)
5
+ // =============================================================================
6
+
7
+ .container {
8
+ flex: 1;
9
+ overflow-y: auto;
10
+ padding: var(--space-sm) 0;
11
+ }
12
+
13
+ .header {
14
+ display: flex;
15
+ align-items: center;
16
+ justify-content: space-between;
17
+ padding: var(--space-sm) var(--space-md);
18
+ border-bottom: 1px solid var(--border-subtle);
19
+ }
20
+
21
+ .title {
22
+ font-size: var(--font-size-md);
23
+ font-weight: var(--font-weight-bold);
24
+ color: var(--text-primary);
25
+ }
26
+
27
+ .headerActions {
28
+ display: flex;
29
+ align-items: center;
30
+ gap: var(--space-md);
31
+ }
32
+
33
+ .internalsToggle {
34
+ display: inline-flex;
35
+ align-items: center;
36
+ gap: var(--space-xs);
37
+ font-size: var(--font-size-sm);
38
+ color: var(--text-secondary);
39
+ cursor: pointer;
40
+ user-select: none;
41
+ }
42
+
43
+ .refreshButton {
44
+ background: none;
45
+ border: none;
46
+ color: var(--text-secondary);
47
+ cursor: pointer;
48
+ padding: 2px;
49
+ display: flex;
50
+ align-items: center;
51
+ border-radius: var(--radius-sm);
52
+
53
+ &:hover {
54
+ color: var(--text-primary);
55
+ background: var(--bg-overlay);
56
+ }
57
+ }
58
+
59
+ .state {
60
+ padding: var(--space-lg) var(--space-md);
61
+ font-size: var(--font-size-sm);
62
+ color: var(--text-disabled);
63
+ text-align: center;
64
+ }
65
+
66
+ .group {
67
+ margin-top: var(--space-sm);
68
+ }
69
+
70
+ .groupHeader {
71
+ @include section-label;
72
+ padding: var(--space-xs) var(--space-md);
73
+ }
74
+
75
+ .row {
76
+ @include hover-accent;
77
+ display: flex;
78
+ align-items: center;
79
+ gap: var(--space-sm);
80
+ width: 100%;
81
+ padding: var(--space-xs) var(--space-md);
82
+ background: none;
83
+ border: none;
84
+ text-align: left;
85
+ cursor: pointer;
86
+ min-height: 34px;
87
+
88
+ &.selected {
89
+ background: var(--bg-overlay);
90
+ }
91
+ }
92
+
93
+ .kindBadge {
94
+ display: inline-flex;
95
+ align-items: center;
96
+ gap: 4px;
97
+ flex-shrink: 0;
98
+ font-size: 11px;
99
+ font-weight: var(--font-weight-medium);
100
+ padding: 1px 6px;
101
+ border-radius: var(--radius-full);
102
+ border: 1px solid var(--border-subtle);
103
+ }
104
+
105
+ .kindChatroom {
106
+ color: var(--accent-green);
107
+ background: var(--accent-green-dim);
108
+ border-color: var(--accent-green);
109
+ }
110
+
111
+ .kindPipe {
112
+ color: var(--accent-yellow);
113
+ background: var(--bg-inset);
114
+ border-color: var(--accent-yellow);
115
+ }
116
+
117
+ .kindChannel {
118
+ color: var(--accent-blue);
119
+ background: var(--accent-blue-dim);
120
+ border-color: var(--accent-blue);
121
+ }
122
+
123
+ .streamName {
124
+ flex: 1;
125
+ font-size: 13px;
126
+ color: var(--text-primary);
127
+ overflow: hidden;
128
+ text-overflow: ellipsis;
129
+ white-space: nowrap;
130
+ }
131
+
132
+ .meta {
133
+ flex-shrink: 0;
134
+ font-size: 11px;
135
+ color: var(--text-tertiary);
136
+ white-space: nowrap;
137
+ }
@@ -0,0 +1,95 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { expect, fn, userEvent } from "@storybook/test";
3
+ import type { Session, StreamData } from "../../hooks/types.js";
4
+ import { CoordinationList } from "./CoordinationList.js";
5
+
6
+ function sub(sessionId: string): StreamData["subscribers"][number] {
7
+ return { subscriptionId: `sub-${sessionId}`, sessionId, fd: 3, permission: "rw", deliveryMode: "async", createdBySpawn: false };
8
+ }
9
+ function stream(over: Partial<StreamData> & { id: string; name: string }): StreamData {
10
+ return { subscriberCount: over.subscribers?.length ?? 1, messageBufferDepth: 0, selfEcho: false, subscribers: over.subscribers ?? [sub("s1")], ...over };
11
+ }
12
+ function session(id: string, taskId?: string): Session {
13
+ return { id, environmentId: "env-1", runtime: "claude-code", status: "running", prompt: "", startedAt: "2026-01-01T00:00:00Z", taskId };
14
+ }
15
+
16
+ const sessions: Session[] = [session("s1", "task-1"), session("s2", "task-1"), session("s3")];
17
+ const tasks: { id: string; title: string }[] = [{ id: "task-1", title: "Implement JWT auth" }];
18
+
19
+ const mixedStreams: StreamData[] = [
20
+ stream({ id: "room", name: "agent-chat", selfEcho: true, subscribers: [sub("s1"), sub("s2")], subscriberCount: 2 }),
21
+ stream({ id: "chan", name: "telemetry", subscribers: [sub("s2")] }),
22
+ stream({ id: "orphan", name: "cli-inspector", subscribers: [sub("s3")] }),
23
+ ];
24
+
25
+ const meta: Meta<typeof CoordinationList> = {
26
+ title: "Grackle/Streams/CoordinationList",
27
+ component: CoordinationList,
28
+ args: {
29
+ streams: mixedStreams,
30
+ sessions,
31
+ tasks,
32
+ loading: false,
33
+ loadedOnce: true,
34
+ showInternals: false,
35
+ onToggleInternals: fn(),
36
+ onSelectStream: fn(),
37
+ onRefresh: fn(),
38
+ },
39
+ };
40
+ export default meta;
41
+ type Story = StoryObj<typeof meta>;
42
+
43
+ /** Streams grouped by owning task, with a trailing unattached/external bucket. */
44
+ export const GroupedByTask: Story = {
45
+ play: async ({ canvas }) => {
46
+ await expect(canvas.getByTestId("coordination-list")).toBeInTheDocument();
47
+ await expect(canvas.getByText("Implement JWT auth")).toBeInTheDocument();
48
+ await expect(canvas.getByText(/Unattached/)).toBeInTheDocument();
49
+ await expect(canvas.getByTestId("coordination-row-room")).toBeInTheDocument();
50
+ await expect(canvas.getByTestId("coordination-row-orphan")).toBeInTheDocument();
51
+ },
52
+ };
53
+
54
+ /** Each stream is tagged with its kind badge. */
55
+ export const KindBadges: Story = {
56
+ play: async ({ canvas }) => {
57
+ // mixedStreams has one chatroom and two channels.
58
+ await expect(canvas.getAllByText("Chatroom")).toHaveLength(1);
59
+ await expect(canvas.getAllByText("Channel")).toHaveLength(2);
60
+ },
61
+ };
62
+
63
+ /** Toggling "Show internals" calls back to re-fetch. */
64
+ export const InternalsToggle: Story = {
65
+ play: async ({ canvas, args }) => {
66
+ const toggle = canvas.getByTestId("coordination-show-internals");
67
+ await expect(toggle).not.toBeChecked();
68
+ await userEvent.click(toggle);
69
+ await expect(args.onToggleInternals).toHaveBeenCalledWith(true);
70
+ },
71
+ };
72
+
73
+ /** Clicking a row selects the stream. */
74
+ export const SelectStream: Story = {
75
+ play: async ({ canvas, args }) => {
76
+ await userEvent.click(canvas.getByTestId("coordination-row-chan"));
77
+ await expect(args.onSelectStream).toHaveBeenCalledWith("chan");
78
+ },
79
+ };
80
+
81
+ /** Empty state when there are no streams. */
82
+ export const EmptyState: Story = {
83
+ args: { streams: [] },
84
+ play: async ({ canvas }) => {
85
+ await expect(canvas.getByTestId("coordination-empty")).toBeInTheDocument();
86
+ },
87
+ };
88
+
89
+ /** Error state when the load failed. */
90
+ export const ErrorState: Story = {
91
+ args: { streams: [], loadError: true },
92
+ play: async ({ canvas }) => {
93
+ await expect(canvas.getByTestId("coordination-error")).toBeInTheDocument();
94
+ },
95
+ };
@@ -0,0 +1,153 @@
1
+ /**
2
+ * CoordinationList — read-only inventory of IPC streams for the Coordination tab.
3
+ *
4
+ * Groups streams by the task that owns their subscribers (with a trailing
5
+ * unattached/external bucket), tags each by kind, and offers a "Show internals"
6
+ * toggle to reveal internal IPC plumbing (lifecycle/pipe/stdin).
7
+ *
8
+ * Pure presentational component — data and callbacks come from the page.
9
+ *
10
+ * @module
11
+ */
12
+
13
+ import { type JSX } from "react";
14
+ import { GitBranch, Hash, MessagesSquare, RefreshCw } from "lucide-react";
15
+ import type { Session, StreamData, TaskData } from "../../hooks/types.js";
16
+ import { groupStreamsByTask, streamKind, type StreamKind } from "../../utils/streamCoordination.js";
17
+ import { ICON_SM } from "../../utils/iconSize.js";
18
+ import styles from "./CoordinationList.module.scss";
19
+
20
+ /** Human-readable label per stream kind. */
21
+ const KIND_LABEL: Record<StreamKind, string> = {
22
+ chatroom: "Chatroom",
23
+ pipe: "Pipe",
24
+ channel: "Channel",
25
+ };
26
+
27
+ /** Icon per stream kind. */
28
+ function KindIcon({ kind }: { kind: StreamKind }): JSX.Element {
29
+ if (kind === "chatroom") {
30
+ return <MessagesSquare size={ICON_SM} aria-hidden="true" />;
31
+ }
32
+ if (kind === "pipe") {
33
+ return <GitBranch size={ICON_SM} aria-hidden="true" />;
34
+ }
35
+ return <Hash size={ICON_SM} aria-hidden="true" />;
36
+ }
37
+
38
+ /** Props for the CoordinationList component. */
39
+ export interface CoordinationListProps {
40
+ /** Streams to display (already filtered server-side by the internals toggle). */
41
+ streams: StreamData[];
42
+ /** All known sessions, used to attribute streams to their owning task. */
43
+ sessions: Session[];
44
+ /** Known tasks (only `id` + `title` are used), to render group headers. */
45
+ tasks: readonly Pick<TaskData, "id" | "title">[];
46
+ /** Whether streams are currently loading. */
47
+ loading: boolean;
48
+ /** True if the most recent load attempt failed. */
49
+ loadError?: boolean;
50
+ /** True after at least one load attempt has completed. */
51
+ loadedOnce?: boolean;
52
+ /** Whether internal IPC plumbing is currently shown. */
53
+ showInternals: boolean;
54
+ /** Called when the "Show internals" toggle changes. */
55
+ onToggleInternals: (value: boolean) => void;
56
+ /** Currently selected stream id (for highlight). */
57
+ selectedStreamId?: string;
58
+ /** Called when a stream row is clicked. */
59
+ onSelectStream: (streamId: string) => void;
60
+ /** Optional refresh callback. */
61
+ onRefresh?: () => void;
62
+ }
63
+
64
+ /** Read-only, task-grouped inventory of IPC streams. */
65
+ export function CoordinationList({
66
+ streams,
67
+ sessions,
68
+ tasks,
69
+ loading,
70
+ loadError = false,
71
+ loadedOnce = true,
72
+ showInternals,
73
+ onToggleInternals,
74
+ selectedStreamId,
75
+ onSelectStream,
76
+ onRefresh,
77
+ }: CoordinationListProps): JSX.Element {
78
+ const groups = groupStreamsByTask(streams, sessions);
79
+ const kindClass: Record<StreamKind, string> = {
80
+ chatroom: styles.kindChatroom,
81
+ pipe: styles.kindPipe,
82
+ channel: styles.kindChannel,
83
+ };
84
+ const taskTitle = (taskId: string): string => tasks.find((t) => t.id === taskId)?.title ?? taskId;
85
+
86
+ return (
87
+ <div className={styles.container} data-testid="coordination-list">
88
+ <div className={styles.header}>
89
+ <span className={styles.title}>Coordination</span>
90
+ <div className={styles.headerActions}>
91
+ <label className={styles.internalsToggle}>
92
+ <input
93
+ type="checkbox"
94
+ checked={showInternals}
95
+ onChange={(e) => onToggleInternals(e.target.checked)}
96
+ data-testid="coordination-show-internals"
97
+ />
98
+ Show internals
99
+ </label>
100
+ {onRefresh && (
101
+ <button
102
+ type="button"
103
+ className={styles.refreshButton}
104
+ onClick={onRefresh}
105
+ aria-label="Refresh streams"
106
+ data-testid="coordination-refresh"
107
+ >
108
+ <RefreshCw size={ICON_SM} aria-hidden="true" />
109
+ </button>
110
+ )}
111
+ </div>
112
+ </div>
113
+
114
+ {loading && streams.length === 0 && <div className={styles.state}>Loading{"…"}</div>}
115
+ {!loading && loadError && (
116
+ <div className={styles.state} data-testid="coordination-error">Unable to load streams</div>
117
+ )}
118
+ {!loading && !loadError && loadedOnce && streams.length === 0 && (
119
+ <div className={styles.state} data-testid="coordination-empty">No active streams</div>
120
+ )}
121
+
122
+ {groups.map((group) => (
123
+ <div key={group.taskId ?? "__orphans__"} className={styles.group}>
124
+ <div className={styles.groupHeader}>
125
+ {group.taskId ? taskTitle(group.taskId) : "Unattached / external (CLI · MCP)"}
126
+ </div>
127
+ {group.streams.map((stream) => {
128
+ const kind = streamKind(stream);
129
+ const isSelected = stream.id === selectedStreamId;
130
+ return (
131
+ <button
132
+ key={stream.id}
133
+ type="button"
134
+ className={`${styles.row}${isSelected ? ` ${styles.selected}` : ""}`}
135
+ onClick={() => onSelectStream(stream.id)}
136
+ data-testid={`coordination-row-${stream.id}`}
137
+ aria-current={isSelected ? "page" : undefined}
138
+ >
139
+ <span className={`${styles.kindBadge} ${kindClass[kind]}`}>
140
+ <KindIcon kind={kind} /> {KIND_LABEL[kind]}
141
+ </span>
142
+ <span className={styles.streamName}>{stream.name}</span>
143
+ <span className={styles.meta}>
144
+ {stream.subscriberCount} {stream.subscriberCount === 1 ? "sub" : "subs"} {"·"} {stream.messageBufferDepth} buffered
145
+ </span>
146
+ </button>
147
+ );
148
+ })}
149
+ </div>
150
+ ))}
151
+ </div>
152
+ );
153
+ }
@@ -204,3 +204,33 @@
204
204
  color: var(--text-disabled, #666);
205
205
  font-style: italic;
206
206
  }
207
+
208
+ .metaValueMono {
209
+ composes: metaValue;
210
+ word-break: break-all;
211
+ white-space: normal;
212
+ }
213
+
214
+ .placeholder {
215
+ font-size: 13px;
216
+ color: var(--text-disabled, #666);
217
+ font-style: italic;
218
+ padding: 10px 12px;
219
+ border: 1px dashed var(--border-default, #333);
220
+ border-radius: 6px;
221
+ }
222
+
223
+ .advanced {
224
+ margin-top: 8px;
225
+ }
226
+
227
+ .advancedSummary {
228
+ font-size: 11px;
229
+ font-weight: 600;
230
+ text-transform: uppercase;
231
+ letter-spacing: 0.05em;
232
+ color: var(--text-disabled, #666);
233
+ cursor: pointer;
234
+ margin-bottom: 8px;
235
+ user-select: none;
236
+ }
@@ -13,6 +13,7 @@ const streamWithSubscribers: StreamData = {
13
13
  name: "agent-chat",
14
14
  subscriberCount: 2,
15
15
  messageBufferDepth: 5,
16
+ selfEcho: true,
16
17
  subscribers: [
17
18
  {
18
19
  subscriptionId: "sub-001",
@@ -38,6 +39,7 @@ const streamNoSubscribers: StreamData = {
38
39
  name: "telemetry-feed",
39
40
  subscriberCount: 0,
40
41
  messageBufferDepth: 0,
42
+ selfEcho: false,
41
43
  subscribers: [],
42
44
  };
43
45
 
@@ -46,6 +48,7 @@ const streamAllModes: StreamData = {
46
48
  name: "mixed-modes",
47
49
  subscriberCount: 3,
48
50
  messageBufferDepth: 0,
51
+ selfEcho: false,
49
52
  subscribers: [
50
53
  {
51
54
  subscriptionId: "sub-rw-async",