@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grackle-ai/web-components",
3
- "version": "0.112.2",
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.112.2"
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: 1421806d07f6b0c455deca4bf89a6412726ffd8b
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,12 +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
14
- dist/index.js 1,591.31 kB │ gzip: 401.72 kB
15
- built in 5.69s
13
+ dist/index.css 159.65 kB │ gzip: 20.78 kB
14
+ dist/McpAppWidget-CSX2W2Vb.js 205.28 kB │ gzip: 47.19 kB
15
+ dist/index.js 1,392.78 kB gzip: 356.09 kB
16
+ ✓ built in 5.94s
16
17
  [build:vite-build] Vite build completed.
17
- ---- build finished (72.731s) ----
18
- -------------------- Finished (72.734s) --------------------
18
+ ---- build finished (75.104s) ----
19
+ -------------------- Finished (75.107s) --------------------
@@ -1,4 +1,4 @@
1
1
  Build cache hit.
2
- Cache key: a0341c0f1c835c664217d8a879aa38d780e62122
2
+ Cache key: 989dfd9dfc66be6288052dccbf2952ddad9066c6
3
3
  Clearing cached folders: .rush/temp/operation/_phase_test
4
4
  Successfully restored output from the build cache.
@@ -5,30 +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) 60ms
9
- ✓ src/utils/eventContent.test.ts (38 tests) 155ms
10
- ✓ src/utils/dashboard.test.ts (4 tests) 37ms
11
- ✓ src/utils/route-config.test.ts (23 tests) 50ms
12
- ✓ src/utils/scrollUtils.test.ts (11 tests) 20ms
13
- ✓ src/utils/breadcrumbs.test.ts (18 tests) 65ms
14
- ✓ src/components/tools/classifyTool.test.ts (6 tests) 22ms
15
- ✓ src/components/tools/toolCardHelpers.test.ts (10 tests) 28ms
16
- ✓ src/utils/assetUrl.test.ts (3 tests) 18ms
17
- ✓ src/components/display/extractText.test.tsx (8 tests) 17ms
18
- ✓ src/components/editable/useEditableField.test.tsx (17 tests) 206ms
19
- ✓ src/hooks/useEventSelection.test.ts (13 tests) 146ms
20
- ✓ src/components/notifications/UpdateBanner.test.tsx (4 tests) 199ms
21
- ✓ src/utils/grackleHostStyleVariables.test.ts (2 tests) 445ms
22
- grackleHostStyleVariables > always returns the MCP-standard fallback variables 416ms
23
- ✓ src/components/display/McpAppWidget.test.tsx (3 tests) 258ms
24
-
25
- Test Files 15 passed (15)
26
- Tests 174 passed (174)
27
- Start at 23:48:14
28
- Duration 16.41s (transform 2.75s, setup 0ms, collect 18.15s, tests 1.73s, environment 14.38s, prepare 5.42s)
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)
29
30
 
30
31
  [test:vitest] Vitest completed.
31
- [test:storybook-test] Starting Storybook static server on port 36655...
32
+ [test:storybook-test] Starting Storybook static server on port 45781...
32
33
  [test:storybook-test] Storybook server ready. Running interaction tests...
33
34
  jest-haste-map: duplicate manual mock found: adapter-manager
34
35
  The following files share their name; please delete one of them:
@@ -121,5 +122,5 @@ jest-haste-map: duplicate manual mock found: utils/network
121
122
  * <rootDir>/packages/server/src/__mocks__/utils/network.ts
122
123
 
123
124
  [test:storybook-test] Storybook interaction tests completed.
124
- ---- test finished (113.186s) ----
125
- -------------------- Finished (113.19s) --------------------
125
+ ---- test finished (111.929s) ----
126
+ -------------------- Finished (111.943s) --------------------
@@ -170,6 +170,28 @@ export const MarkdownParagraphWrapping: Story = {
170
170
  },
171
171
  };
172
172
 
173
+ /** Widget event renders the lazy-loaded MCP App host iframe. */
174
+ export const WidgetEvent: Story = {
175
+ args: {
176
+ event: makeEvent({
177
+ eventType: "widget",
178
+ content: JSON.stringify({
179
+ resourceUri: "ui://grackle/hello-widget",
180
+ toolName: "show_hello_widget",
181
+ html: "<!doctype html><html><body><div class=\"card\">widget</div></body></html>",
182
+ toolInput: { message: "hi" },
183
+ toolResult: { content: [{ type: "text", text: "ok" }] },
184
+ }),
185
+ }),
186
+ // Different origin than Storybook (6006) so McpAppWidget's same-origin guard passes.
187
+ sandboxProxyUrl: "http://localhost:6007/sandbox.html",
188
+ },
189
+ play: async ({ canvas }) => {
190
+ // McpAppWidget is lazy-loaded behind Suspense; findByTestId waits for the chunk.
191
+ await expect(await canvas.findByTestId("mcp-app-widget")).toBeInTheDocument();
192
+ },
193
+ };
194
+
173
195
  /** User input events render as markdown (bold, lists, inline code) inside the bubble. */
174
196
  export const UserMessageMarkdown: Story = {
175
197
  args: {
@@ -1,4 +1,4 @@
1
- import { type ReactNode, useState, type JSX } from "react";
1
+ import { type ReactNode, useState, lazy, Suspense, type LazyExoticComponent, type ComponentType, type JSX } from "react";
2
2
  import { ChevronDown, ChevronRight } from "lucide-react";
3
3
  import Markdown from "react-markdown";
4
4
  import rehypePrismPlus from "rehype-prism-plus/common";
@@ -7,9 +7,19 @@ import type { SessionEvent } from "../../hooks/types.js";
7
7
  import { formatTokens, formatCost } from "../../utils/format.js";
8
8
  import { ICON_SM } from "../../utils/iconSize.js";
9
9
  import { ToolCard } from "../tools/ToolCard.js";
10
+ import type { McpAppWidgetProps } from "./McpAppWidget.js";
11
+ import type { McpUiResourceCsp } from "@modelcontextprotocol/ext-apps/app-bridge";
12
+ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
10
13
  import { CopyButton } from "./CopyButton.js";
11
14
  import styles from "./EventRenderer.module.scss";
12
15
 
16
+ // Lazy-loaded (and intentionally NOT re-exported from the package barrel) so the
17
+ // heavy ext-apps AppBridge is code-split into an async chunk loaded only when a
18
+ // widget actually renders — keeps the main chat bundle under the chunk-size cap.
19
+ const McpAppWidget: LazyExoticComponent<ComponentType<McpAppWidgetProps>> = lazy(() =>
20
+ import("./McpAppWidget.js").then((m) => ({ default: m.McpAppWidget })),
21
+ );
22
+
13
23
  /** Props for the EventRenderer component. */
14
24
  interface Props {
15
25
  event: SessionEvent;
@@ -17,6 +27,8 @@ interface Props {
17
27
  toolUseCtx?: { tool: string; args: unknown; detailedResult?: string };
18
28
  /** True when a tool_use completed but has no tool_result (e.g. Claude Code text-result pattern). */
19
29
  settled?: boolean;
30
+ /** Sandbox proxy origin URL for rendering MCP Apps widget events (different origin than the app). */
31
+ sandboxProxyUrl?: string;
20
32
  }
21
33
 
22
34
  // --- Individual event type renderers ---
@@ -201,10 +213,40 @@ function DefaultEvent({ content }: { content: string }): JSX.Element {
201
213
  // --- Main component ---
202
214
 
203
215
  /** Renders a single session event, dispatching to the appropriate type-specific renderer. */
204
- export function EventRenderer({ event, toolUseCtx, settled }: Props): JSX.Element {
216
+ export function EventRenderer({ event, toolUseCtx, settled, sandboxProxyUrl }: Props): JSX.Element {
205
217
  const time = new Date(event.timestamp).toLocaleTimeString();
206
218
 
207
219
  switch (event.eventType) {
220
+ case "widget": {
221
+ // MCP Apps widget event (pushed by the broker). Self-contained: HTML +
222
+ // tool input/result. Renders in the cross-origin sandbox via McpAppWidget.
223
+ if (!sandboxProxyUrl) {
224
+ return <DefaultEvent content={event.content} />;
225
+ }
226
+ let payload: {
227
+ html?: string;
228
+ csp?: McpUiResourceCsp;
229
+ toolInput?: Record<string, unknown>;
230
+ toolResult?: CallToolResult;
231
+ } = {};
232
+ try {
233
+ payload = JSON.parse(event.content) as typeof payload;
234
+ } catch { /* malformed widget payload — fall back */ }
235
+ if (!payload.html) {
236
+ return <DefaultEvent content={event.content} />;
237
+ }
238
+ return (
239
+ <Suspense fallback={<DefaultEvent content="Loading widget..." />}>
240
+ <McpAppWidget
241
+ widgetHtml={payload.html}
242
+ sandboxProxyUrl={sandboxProxyUrl}
243
+ csp={payload.csp}
244
+ toolInput={payload.toolInput}
245
+ toolResult={payload.toolResult}
246
+ />
247
+ </Suspense>
248
+ );
249
+ }
208
250
  case "system": {
209
251
  // Detect system context events via the raw metadata marker
210
252
  if (event.raw) {
@@ -88,6 +88,8 @@ interface EventStreamProps {
88
88
  * Receives the target session ID and the formatted envelope text.
89
89
  */
90
90
  onForward?: (sessionId: string, text: string) => Promise<void>;
91
+ /** Sandbox proxy origin URL for rendering MCP Apps widget events. */
92
+ sandboxProxyUrl?: string;
91
93
  }
92
94
 
93
95
  /**
@@ -104,6 +106,7 @@ export function EventStream({
104
106
  environments,
105
107
  personas,
106
108
  onForward,
109
+ sandboxProxyUrl,
107
110
  }: EventStreamProps): JSX.Element {
108
111
  const scrollRef = useRef<HTMLDivElement>(null);
109
112
  const [isReversed, setIsReversed] = useState(readStoredDirection);
@@ -303,7 +306,7 @@ export function EventStream({
303
306
  onToggle={(shiftKey) => { selection.toggleEvent(originalIndex, shiftKey); }}
304
307
  onCopied={() => { onShowToast?.("Copied to clipboard", "success"); }}
305
308
  >
306
- <EventRenderer event={event} toolUseCtx={event.toolUseCtx} settled={event.settled} />
309
+ <EventRenderer event={event} toolUseCtx={event.toolUseCtx} settled={event.settled} sandboxProxyUrl={sandboxProxyUrl} />
307
310
  </EventHoverRow>
308
311
  </motion.div>
309
312
  );
@@ -14,7 +14,9 @@ export { Spinner } from "./Spinner.js";
14
14
  export { SplashScreen } from "./SplashScreen.js";
15
15
  export { Tooltip } from "./Tooltip.js";
16
16
  export { SessionAttemptSelector } from "./SessionAttemptSelector.js";
17
- export { McpAppWidget } from "./McpAppWidget.js";
17
+ // NOTE: McpAppWidget is intentionally NOT re-exported as a value — EventRenderer
18
+ // lazy-imports it directly so the heavy ext-apps AppBridge stays code-split out of
19
+ // the main chat bundle. Its prop types remain exported below for consumers.
18
20
 
19
21
  export type { ButtonProps, ButtonVariant, ButtonSize } from "./Button.js";
20
22
  export type { TooltipProps, TooltipPlacement } from "./Tooltip.js";
@@ -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
+ };