@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.
- package/.rush/temp/{a0341c0f1c835c664217d8a879aa38d780e62122.tar.log → 989dfd9dfc66be6288052dccbf2952ddad9066c6.tar.log} +2 -2
- package/.rush/temp/{1421806d07f6b0c455deca4bf89a6412726ffd8b.untar.log → 989dfd9dfc66be6288052dccbf2952ddad9066c6.untar.log} +2 -2
- package/.rush/temp/{1421806d07f6b0c455deca4bf89a6412726ffd8b.tar.log → b1a36bcc314d65fbd843fcff6d3127ebdf827e06.tar.log} +81 -81
- package/.rush/temp/{a0341c0f1c835c664217d8a879aa38d780e62122.untar.log → b1a36bcc314d65fbd843fcff6d3127ebdf827e06.untar.log} +2 -2
- package/.rush/temp/chunked-rush-logs/web-components._phase_build.chunks.jsonl +7 -6
- package/.rush/temp/chunked-rush-logs/web-components._phase_test.chunks.jsonl +25 -24
- package/.rush/temp/operation/_phase_build/all.log +7 -6
- package/.rush/temp/operation/_phase_build/log-chunks.jsonl +7 -6
- package/.rush/temp/operation/_phase_build/state.json +1 -1
- package/.rush/temp/operation/_phase_test/all.log +25 -24
- package/.rush/temp/operation/_phase_test/log-chunks.jsonl +25 -24
- package/.rush/temp/operation/_phase_test/state.json +1 -1
- package/README.md +2 -1
- package/dist/McpAppWidget-CSX2W2Vb.js +5774 -0
- package/dist/index.css +1 -1
- package/dist/index.js +12221 -17764
- package/package.json +2 -2
- package/rush-logs/web-components._phase_build.cache.log +1 -1
- package/rush-logs/web-components._phase_build.log +7 -6
- package/rush-logs/web-components._phase_test.cache.log +1 -1
- package/rush-logs/web-components._phase_test.log +25 -24
- package/src/components/display/EventRenderer.stories.tsx +22 -0
- package/src/components/display/EventRenderer.tsx +44 -2
- package/src/components/display/EventStream.tsx +4 -1
- package/src/components/display/index.ts +3 -1
- 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 +5 -5
- 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 +42 -30
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@grackle-ai/web-components",
|
|
3
|
-
"version": "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.
|
|
49
|
+
"@grackle-ai/common": "0.114.0"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@rushstack/heft": "1.2.7",
|
|
@@ -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
|
-
✓
|
|
10
|
+
✓ 2718 modules transformed.
|
|
11
11
|
rendering chunks...
|
|
12
12
|
computing gzip size...
|
|
13
|
-
dist/index.css
|
|
14
|
-
dist/
|
|
15
|
-
|
|
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 (
|
|
18
|
-
-------------------- Finished (
|
|
18
|
+
---- build finished (75.104s) ----
|
|
19
|
+
-------------------- Finished (75.107s) --------------------
|
|
@@ -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)
|
|
9
|
-
✓ src/utils/eventContent.test.ts (38 tests)
|
|
10
|
-
✓ src/utils/dashboard.test.ts (4 tests)
|
|
11
|
-
✓ src/utils/route-config.test.ts (23 tests)
|
|
12
|
-
✓ src/utils/
|
|
13
|
-
✓ src/utils/breadcrumbs.test.ts (18 tests)
|
|
14
|
-
✓ src/
|
|
15
|
-
✓ src/components/tools/
|
|
16
|
-
✓ src/
|
|
17
|
-
✓ src/
|
|
18
|
-
✓ src/components/
|
|
19
|
-
✓ src/hooks/useEventSelection.test.ts (13 tests)
|
|
20
|
-
✓ src/components/
|
|
21
|
-
✓ src/
|
|
22
|
-
|
|
23
|
-
✓ src/
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
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 (
|
|
125
|
-
-------------------- Finished (
|
|
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
|
-
|
|
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: /
|
|
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: "
|
|
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: /
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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("
|
|
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: "
|
|
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
|
+
};
|