@grackle-ai/web-components 0.108.3 → 0.110.1

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 (30) hide show
  1. package/.rush/temp/3629cc83b3804ac8bcea27905e83162210fb5dd6.tar.log +240 -0
  2. package/.rush/temp/{28d8c029b5d0c4a740412c3ca7a7aa456ad48c1b.untar.log → 3629cc83b3804ac8bcea27905e83162210fb5dd6.untar.log} +2 -2
  3. package/.rush/temp/chunked-rush-logs/web-components._phase_build.chunks.jsonl +18 -0
  4. package/.rush/temp/chunked-rush-logs/web-components._phase_test.chunks.jsonl +121 -0
  5. package/.rush/temp/f2b8b611fc00c7b912256986db4cc966d6560387.tar.log +12 -0
  6. package/.rush/temp/{9f397c070a4229568e12d273f46cf0cda84b9bd2.untar.log → f2b8b611fc00c7b912256986db4cc966d6560387.untar.log} +2 -2
  7. package/.rush/temp/operation/_phase_build/all.log +5 -5
  8. package/.rush/temp/operation/_phase_build/log-chunks.jsonl +5 -5
  9. package/.rush/temp/operation/_phase_build/state.json +1 -1
  10. package/.rush/temp/operation/_phase_test/all.log +21 -21
  11. package/.rush/temp/operation/_phase_test/log-chunks.jsonl +21 -21
  12. package/.rush/temp/operation/_phase_test/state.json +1 -1
  13. package/README.md +95 -0
  14. package/dist/index.css +1 -1
  15. package/dist/index.js +5826 -5732
  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 +18 -0
  19. package/rush-logs/web-components._phase_test.cache.log +1 -1
  20. package/rush-logs/web-components._phase_test.log +121 -0
  21. package/src/components/layout/AppNav.module.scss +6 -0
  22. package/src/components/layout/AppNav.stories.tsx +47 -0
  23. package/src/components/layout/AppNav.tsx +24 -10
  24. package/src/components/panels/EnvironmentEditPanel.stories.tsx +87 -1
  25. package/src/components/panels/EnvironmentEditPanel.tsx +143 -34
  26. package/src/context/GrackleContextTypes.ts +3 -1
  27. package/src/hooks/types.ts +21 -0
  28. package/src/index.ts +2 -2
  29. package/src/mocks/MockGrackleProvider.tsx +7 -0
  30. package/temp/build/lint/_eslint-5eVG3S6w.json +810 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grackle-ai/web-components",
3
- "version": "0.108.3",
3
+ "version": "0.110.1",
4
4
  "description": "Presentational React component library for the Grackle web UI",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -40,7 +40,7 @@
40
40
  "remark-gfm": "^4.0.0",
41
41
  "lucide-react": "~0.474.0",
42
42
  "react-router": "^7.0.0",
43
- "@grackle-ai/common": "0.108.3"
43
+ "@grackle-ai/common": "0.110.1"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@rushstack/heft": "1.2.7",
@@ -1,4 +1,4 @@
1
1
  Build cache hit.
2
- Cache key: 9f397c070a4229568e12d273f46cf0cda84b9bd2
2
+ Cache key: 3629cc83b3804ac8bcea27905e83162210fb5dd6
3
3
  Clearing cached folders: dist, storybook-static, .rush/temp/operation/_phase_build
4
4
  Successfully restored output from the build cache.
@@ -0,0 +1,18 @@
1
+ Invoking: heft run --only build -- --clean
2
+ ---- build started ----
3
+ [build:typescript] Using TypeScript version 5.7.3
4
+ [build:storybook-build] Building Storybook...
5
+ [build:storybook-build] Storybook build completed.
6
+ [build:vite-build] Starting Vite build...
7
+ [build:lint] Using ESLint version 9.39.4
8
+ vite v6.4.2 building for production...
9
+ transforming...
10
+ ✓ 2574 modules transformed.
11
+ rendering chunks...
12
+ computing gzip size...
13
+ dist/index.css 156.09 kB │ gzip: 20.37 kB
14
+ dist/index.js 1,377.68 kB │ gzip: 352.31 kB
15
+ ✓ built in 5.19s
16
+ [build:vite-build] Vite build completed.
17
+ ---- build finished (73.056s) ----
18
+ -------------------- Finished (73.061s) --------------------
@@ -1,4 +1,4 @@
1
1
  Build cache hit.
2
- Cache key: 28d8c029b5d0c4a740412c3ca7a7aa456ad48c1b
2
+ Cache key: f2b8b611fc00c7b912256986db4cc966d6560387
3
3
  Clearing cached folders: .rush/temp/operation/_phase_test
4
4
  Successfully restored output from the build cache.
@@ -0,0 +1,121 @@
1
+ Invoking: heft run --only test
2
+ The provided list of phases does not contain all phase dependencies. You may need to run the excluded phases manually.
3
+ ---- test started ----
4
+ [test:vitest] Running vitest...
5
+
6
+ RUN v3.2.4 /home/runner/work/grackle/grackle/packages/web-components
7
+
8
+ ✓ src/utils/sessionEvents.test.ts (14 tests) 55ms
9
+ ✓ src/utils/eventContent.test.ts (38 tests) 103ms
10
+ ✓ src/utils/dashboard.test.ts (4 tests) 25ms
11
+ ✓ src/utils/route-config.test.ts (23 tests) 72ms
12
+ ✓ src/utils/breadcrumbs.test.ts (18 tests) 24ms
13
+ ✓ src/utils/scrollUtils.test.ts (11 tests) 24ms
14
+ ✓ src/components/tools/classifyTool.test.ts (6 tests) 37ms
15
+ ✓ src/components/tools/toolCardHelpers.test.ts (10 tests) 17ms
16
+ ✓ src/components/display/extractText.test.tsx (8 tests) 12ms
17
+ ✓ src/components/editable/useEditableField.test.tsx (17 tests) 181ms
18
+ ✓ src/hooks/useEventSelection.test.ts (13 tests) 182ms
19
+ ✓ src/components/notifications/UpdateBanner.test.tsx (4 tests) 125ms
20
+
21
+ Test Files 12 passed (12)
22
+ Tests 166 passed (166)
23
+ Start at 20:39:06
24
+ Duration 13.92s (transform 2.79s, setup 0ms, collect 15.06s, tests 856ms, environment 6.97s, prepare 5.20s)
25
+
26
+ [test:vitest] Vitest completed.
27
+ [test:storybook-test] Starting Storybook static server on port 43017...
28
+ [test:storybook-test] Storybook server ready. Running interaction tests...
29
+ jest-haste-map: duplicate manual mock found: adapter-manager
30
+ The following files share their name; please delete one of them:
31
+ * <rootDir>/packages/server/dist/__mocks__/adapter-manager.js
32
+ * <rootDir>/packages/server/src/__mocks__/adapter-manager.ts
33
+
34
+ jest-haste-map: duplicate manual mock found: auto-reconnect
35
+ The following files share their name; please delete one of them:
36
+ * <rootDir>/packages/server/dist/__mocks__/auto-reconnect.js
37
+ * <rootDir>/packages/server/src/__mocks__/auto-reconnect.ts
38
+
39
+ jest-haste-map: duplicate manual mock found: event-bus
40
+ The following files share their name; please delete one of them:
41
+ * <rootDir>/packages/server/dist/__mocks__/event-bus.js
42
+ * <rootDir>/packages/server/src/__mocks__/event-bus.ts
43
+
44
+ jest-haste-map: duplicate manual mock found: event-processor
45
+ The following files share their name; please delete one of them:
46
+ * <rootDir>/packages/server/dist/__mocks__/event-processor.js
47
+ * <rootDir>/packages/server/src/__mocks__/event-processor.ts
48
+
49
+ jest-haste-map: duplicate manual mock found: github-import
50
+ The following files share their name; please delete one of them:
51
+ * <rootDir>/packages/server/dist/__mocks__/github-import.js
52
+ * <rootDir>/packages/server/src/__mocks__/github-import.ts
53
+
54
+ jest-haste-map: duplicate manual mock found: lifecycle
55
+ The following files share their name; please delete one of them:
56
+ * <rootDir>/packages/server/dist/__mocks__/lifecycle.js
57
+ * <rootDir>/packages/server/src/__mocks__/lifecycle.ts
58
+
59
+ jest-haste-map: duplicate manual mock found: log-writer
60
+ The following files share their name; please delete one of them:
61
+ * <rootDir>/packages/server/dist/__mocks__/log-writer.js
62
+ * <rootDir>/packages/server/src/__mocks__/log-writer.ts
63
+
64
+ jest-haste-map: duplicate manual mock found: logger
65
+ The following files share their name; please delete one of them:
66
+ * <rootDir>/packages/server/dist/__mocks__/logger.js
67
+ * <rootDir>/packages/server/src/__mocks__/logger.ts
68
+
69
+ jest-haste-map: duplicate manual mock found: pipe-delivery
70
+ The following files share their name; please delete one of them:
71
+ * <rootDir>/packages/server/dist/__mocks__/pipe-delivery.js
72
+ * <rootDir>/packages/server/src/__mocks__/pipe-delivery.ts
73
+
74
+ jest-haste-map: duplicate manual mock found: processor-registry
75
+ The following files share their name; please delete one of them:
76
+ * <rootDir>/packages/server/dist/__mocks__/processor-registry.js
77
+ * <rootDir>/packages/server/src/__mocks__/processor-registry.ts
78
+
79
+ jest-haste-map: duplicate manual mock found: reanimate-agent
80
+ The following files share their name; please delete one of them:
81
+ * <rootDir>/packages/server/dist/__mocks__/reanimate-agent.js
82
+ * <rootDir>/packages/server/src/__mocks__/reanimate-agent.ts
83
+
84
+ jest-haste-map: duplicate manual mock found: session-recovery
85
+ The following files share their name; please delete one of them:
86
+ * <rootDir>/packages/server/dist/__mocks__/session-recovery.js
87
+ * <rootDir>/packages/server/src/__mocks__/session-recovery.ts
88
+
89
+ jest-haste-map: duplicate manual mock found: stream-hub
90
+ The following files share their name; please delete one of them:
91
+ * <rootDir>/packages/server/dist/__mocks__/stream-hub.js
92
+ * <rootDir>/packages/server/src/__mocks__/stream-hub.ts
93
+
94
+ jest-haste-map: duplicate manual mock found: stream-registry
95
+ The following files share their name; please delete one of them:
96
+ * <rootDir>/packages/server/dist/__mocks__/stream-registry.js
97
+ * <rootDir>/packages/server/src/__mocks__/stream-registry.ts
98
+
99
+ jest-haste-map: duplicate manual mock found: token-push
100
+ The following files share their name; please delete one of them:
101
+ * <rootDir>/packages/server/dist/__mocks__/token-push.js
102
+ * <rootDir>/packages/server/src/__mocks__/token-push.ts
103
+
104
+ jest-haste-map: duplicate manual mock found: utils/exec
105
+ The following files share their name; please delete one of them:
106
+ * <rootDir>/packages/server/dist/__mocks__/utils/exec.js
107
+ * <rootDir>/packages/server/src/__mocks__/utils/exec.ts
108
+
109
+ jest-haste-map: duplicate manual mock found: utils/format-gh-error
110
+ The following files share their name; please delete one of them:
111
+ * <rootDir>/packages/server/dist/__mocks__/utils/format-gh-error.js
112
+ * <rootDir>/packages/server/src/__mocks__/utils/format-gh-error.ts
113
+
114
+ jest-haste-map: duplicate manual mock found: utils/network
115
+ The following files share their name; please delete one of them:
116
+ * <rootDir>/packages/server/dist/__mocks__/utils/network.js
117
+ * <rootDir>/packages/server/src/__mocks__/utils/network.ts
118
+
119
+ [test:storybook-test] Storybook interaction tests completed.
120
+ ---- test finished (112.648s) ----
121
+ -------------------- Finished (112.652s) --------------------
@@ -80,3 +80,9 @@
80
80
  color: var(--text-primary);
81
81
  font-weight: var(--font-weight-medium);
82
82
  }
83
+
84
+ // Pins this tab (and any after it) to the right edge by absorbing the free
85
+ // space to its left. Inert on mobile, where the nav scrolls horizontally.
86
+ .tabEnd {
87
+ margin-left: auto;
88
+ }
@@ -66,6 +66,53 @@ export const AllTabsExplicit: Story = {
66
66
  },
67
67
  };
68
68
 
69
+ /**
70
+ * Settings is pinned to the right edge even when the incoming tab list places it
71
+ * mid-list (as `buildTabs` does: core tabs, including Settings, come before
72
+ * orchestration and knowledge tabs). The component reorders end-aligned tabs last.
73
+ */
74
+ export const SettingsPinnedRight: Story = {
75
+ // Fullscreen so the nav fills the viewport and the auto margin has free space
76
+ // to absorb (otherwise margin-left: auto would resolve to 0).
77
+ parameters: { layout: "fullscreen" },
78
+ args: {
79
+ tabs: [
80
+ { view: "dashboard", label: "Dashboard", icon: <Home size={ICON_LG} />, route: HOME_URL, testId: "sidebar-tab-dashboard" },
81
+ { view: "chat", label: "Chat", icon: <MessageSquare size={ICON_LG} />, route: CHAT_URL, testId: "sidebar-tab-chat" },
82
+ { view: "environments", label: "Environments", icon: <Monitor size={ICON_LG} />, route: ENVIRONMENTS_URL, testId: "sidebar-tab-environments" },
83
+ { view: "settings", label: "Settings", icon: <Settings size={ICON_LG} />, route: SETTINGS_CREDENTIALS_URL, testId: "sidebar-tab-settings", align: "end" },
84
+ { view: "tasks", label: "Tasks", icon: <ClipboardList size={ICON_LG} />, route: TASKS_URL, testId: "sidebar-tab-tasks" },
85
+ { view: "findings", label: "Findings", icon: <Search size={ICON_LG} />, route: FINDINGS_URL, testId: "sidebar-tab-findings" },
86
+ { view: "knowledge", label: "Knowledge", icon: <Brain size={ICON_LG} />, route: KNOWLEDGE_URL, testId: "sidebar-tab-knowledge" },
87
+ ],
88
+ },
89
+ play: async ({ canvas }) => {
90
+ const renderedTabs = canvas.getAllByRole("tab");
91
+ // Despite Settings being 4th in the input, it must render last (rightmost).
92
+ const settingsTab = renderedTabs[renderedTabs.length - 1];
93
+ await expect(settingsTab).toHaveAccessibleName(/Settings/);
94
+
95
+ // The end-alignment lives on the flex item (the Tooltip wrapper around the
96
+ // button), not the button itself. Verify margin-left: auto resolves to a
97
+ // positive used value there, so Settings is actually pushed to the right edge.
98
+ const settingsFlexItem = settingsTab.parentElement;
99
+ if (!settingsFlexItem) {
100
+ throw new Error("expected the Settings tab to have a flex-item wrapper");
101
+ }
102
+ const settingsMarginLeft = Number.parseFloat(globalThis.getComputedStyle(settingsFlexItem).marginLeft);
103
+ await expect(settingsMarginLeft).toBeGreaterThan(0);
104
+
105
+ // The neighbor immediately before it (Knowledge) must NOT have an auto
106
+ // margin, confirming the spacer is applied only to the pinned tab.
107
+ const neighborFlexItem = renderedTabs[renderedTabs.length - 2].parentElement;
108
+ if (!neighborFlexItem) {
109
+ throw new Error("expected the neighbor tab to have a flex-item wrapper");
110
+ }
111
+ const neighborMarginLeft = Number.parseFloat(globalThis.getComputedStyle(neighborFlexItem).marginLeft);
112
+ await expect(neighborMarginLeft).toBe(0);
113
+ },
114
+ };
115
+
69
116
  /** Arrow keys navigate between tabs horizontally. */
70
117
  export const KeyboardNavigation: Story = {
71
118
  play: async ({ canvas }) => {
@@ -1,4 +1,4 @@
1
- import { useCallback, useRef, type JSX, type KeyboardEvent, type ReactNode } from "react";
1
+ import { useCallback, useMemo, useRef, type JSX, type KeyboardEvent, type ReactNode } from "react";
2
2
  import { useLocation } from "react-router";
3
3
  import { Brain, ClipboardList, Home, MessageSquare, Monitor, Search, Settings } from "lucide-react";
4
4
  import { CHAT_URL, ENVIRONMENTS_URL, FINDINGS_URL, HOME_URL, KNOWLEDGE_URL, SETTINGS_URL, SETTINGS_CREDENTIALS_URL, TASKS_URL, useAppNavigate } from "../../utils/navigation.js";
@@ -21,6 +21,8 @@ export interface AppTab {
21
21
  route: string;
22
22
  /** data-testid suffix. */
23
23
  testId: string;
24
+ /** Horizontal alignment within the nav bar. `"end"` pins the tab to the right edge. */
25
+ align?: "end";
24
26
  }
25
27
 
26
28
  /** Ordered list of all app navigation tabs. Exported for plugin registry use. */
@@ -31,7 +33,7 @@ export const TABS: AppTab[] = [
31
33
  { view: "environments", label: "Environments", icon: <Monitor size={ICON_LG} />, route: ENVIRONMENTS_URL, testId: "sidebar-tab-environments" },
32
34
  { view: "knowledge", label: "Knowledge", icon: <Brain size={ICON_LG} />, route: KNOWLEDGE_URL, testId: "sidebar-tab-knowledge" },
33
35
  { view: "findings", label: "Findings", icon: <Search size={ICON_LG} />, route: FINDINGS_URL, testId: "sidebar-tab-findings" },
34
- { view: "settings", label: "Settings", icon: <Settings size={ICON_LG} />, route: SETTINGS_CREDENTIALS_URL, testId: "sidebar-tab-settings" },
36
+ { view: "settings", label: "Settings", icon: <Settings size={ICON_LG} />, route: SETTINGS_CREDENTIALS_URL, testId: "sidebar-tab-settings", align: "end" },
35
37
  ];
36
38
 
37
39
  /** Derive the active application view from a URL pathname. */
@@ -65,6 +67,17 @@ export function AppNav({ tabs = TABS }: { tabs?: AppTab[] }): JSX.Element {
65
67
 
66
68
  const activeView = getActiveView(location.pathname);
67
69
 
70
+ // Render end-aligned tabs (e.g. Settings) last regardless of the incoming order,
71
+ // so they stay pinned to the right edge no matter which plugins contribute tabs.
72
+ const orderedTabs = useMemo(
73
+ () => [
74
+ ...tabs.filter((t) => t.align !== "end"),
75
+ ...tabs.filter((t) => t.align === "end"),
76
+ ],
77
+ [tabs],
78
+ );
79
+ const firstEndAlignedView = orderedTabs.find((t) => t.align === "end")?.view;
80
+
68
81
  const handleClick = useCallback((tab: AppTab) => {
69
82
  navigate(tab.route);
70
83
  }, [navigate]);
@@ -75,28 +88,28 @@ export function AppNav({ tabs = TABS }: { tabs?: AppTab[] }): JSX.Element {
75
88
  return;
76
89
  }
77
90
  const focusedIndex = Array.from(buttons).findIndex((b) => b === document.activeElement);
78
- const currentIndex = focusedIndex >= 0 ? focusedIndex : tabs.findIndex((t) => t.view === activeView);
91
+ const currentIndex = focusedIndex >= 0 ? focusedIndex : orderedTabs.findIndex((t) => t.view === activeView);
79
92
  let nextIndex = currentIndex;
80
93
 
81
94
  if (e.key === "ArrowRight" || e.key === "j" || e.key === "J") {
82
95
  e.preventDefault();
83
- nextIndex = (currentIndex + 1) % tabs.length;
96
+ nextIndex = (currentIndex + 1) % orderedTabs.length;
84
97
  } else if (e.key === "ArrowLeft" || e.key === "k" || e.key === "K") {
85
98
  e.preventDefault();
86
- nextIndex = (currentIndex - 1 + tabs.length) % tabs.length;
99
+ nextIndex = (currentIndex - 1 + orderedTabs.length) % orderedTabs.length;
87
100
  } else if (e.key === "Home") {
88
101
  e.preventDefault();
89
102
  nextIndex = 0;
90
103
  } else if (e.key === "End") {
91
104
  e.preventDefault();
92
- nextIndex = tabs.length - 1;
105
+ nextIndex = orderedTabs.length - 1;
93
106
  } else {
94
107
  return;
95
108
  }
96
109
 
97
- navigate(tabs[nextIndex].route);
110
+ navigate(orderedTabs[nextIndex].route);
98
111
  buttons[nextIndex]?.focus(); // eslint-disable-line @typescript-eslint/no-unnecessary-condition -- index may be out of bounds
99
- }, [activeView, navigate, tabs]);
112
+ }, [activeView, navigate, orderedTabs]);
100
113
 
101
114
  return (
102
115
  <nav
@@ -108,10 +121,11 @@ export function AppNav({ tabs = TABS }: { tabs?: AppTab[] }): JSX.Element {
108
121
  onKeyDown={handleKeyDown}
109
122
  data-testid="sidebar-nav"
110
123
  >
111
- {tabs.map((tab) => {
124
+ {orderedTabs.map((tab) => {
112
125
  const isActive = tab.view === activeView;
126
+ const isFirstEndAligned = tab.view === firstEndAlignedView;
113
127
  return (
114
- <Tooltip key={tab.view} text={tab.label} placement="bottom">
128
+ <Tooltip key={tab.view} text={tab.label} placement="bottom" className={isFirstEndAligned ? styles.tabEnd : undefined}>
115
129
  <button
116
130
  role="tab"
117
131
  type="button"
@@ -18,6 +18,9 @@ const meta: Meta<typeof EnvironmentEditPanel> = {
18
18
  codespaceListError: "",
19
19
  codespaceCreating: false,
20
20
  onCreateCodespace: fn(),
21
+ onListDockerContainers: fn(),
22
+ dockerContainers: [],
23
+ dockerContainersError: "",
21
24
  onShowToast: fn(),
22
25
  },
23
26
  };
@@ -133,13 +136,96 @@ export const SwitchingAdapterShowsFields: Story = {
133
136
  await expect(canvas.getByTestId("env-create-port")).toBeInTheDocument();
134
137
  await expect(canvas.getByTestId("env-create-identity")).toBeInTheDocument();
135
138
 
136
- // Switch to Docker — shows image and repo
139
+ // Switch to Docker — shows the source toggle plus image and repo (create mode default)
137
140
  await userEvent.selectOptions(adapterSelect, "docker");
141
+ await expect(canvas.getByTestId("env-docker-mode")).toBeInTheDocument();
138
142
  await expect(canvas.getByTestId("env-create-image")).toBeInTheDocument();
139
143
  await expect(canvas.getByTestId("env-create-repo")).toBeInTheDocument();
140
144
  },
141
145
  };
142
146
 
147
+ /** Docker attach mode lists running containers and requests them when selected. */
148
+ export const DockerAttachLists: Story = {
149
+ args: {
150
+ dockerContainers: [
151
+ { id: "abc123", name: "demo-ext", image: "node:22", state: "running", status: "Up 3 minutes" },
152
+ ],
153
+ },
154
+ play: async ({ canvas, args }) => {
155
+ const adapterSelect = canvas.getByTestId("env-create-adapter");
156
+ await userEvent.selectOptions(adapterSelect, "docker");
157
+
158
+ // Switch the docker source to "attach" — the container list should be requested
159
+ const modeSelect = canvas.getByTestId("env-docker-mode");
160
+ await userEvent.selectOptions(modeSelect, "attach");
161
+ await expect(args.onListDockerContainers).toHaveBeenCalled();
162
+
163
+ // The picker replaces the image/repo fields
164
+ await expect(canvas.getByTestId("env-docker-container-select")).toBeInTheDocument();
165
+ await expect(canvas.queryByTestId("env-create-image")).not.toBeInTheDocument();
166
+ },
167
+ };
168
+
169
+ /** Selecting a container in attach mode builds an { attach } config on Create. */
170
+ export const DockerAttachCreatesConfig: Story = {
171
+ args: {
172
+ dockerContainers: [
173
+ { id: "abc123", name: "demo-ext", image: "node:22", state: "running", status: "Up 3 minutes" },
174
+ ],
175
+ },
176
+ play: async ({ canvas, args }) => {
177
+ await userEvent.selectOptions(canvas.getByTestId("env-create-adapter"), "docker");
178
+ await userEvent.selectOptions(canvas.getByTestId("env-docker-mode"), "attach");
179
+
180
+ // Pick the running container; the env name auto-fills from it
181
+ await userEvent.selectOptions(canvas.getByTestId("env-docker-container-select"), "demo-ext");
182
+
183
+ const createButton = canvas.getByTestId("env-create-submit");
184
+ await expect(createButton).toBeEnabled();
185
+ await userEvent.click(createButton);
186
+
187
+ await expect(args.onAddEnvironment).toHaveBeenCalledWith(
188
+ "demo-ext",
189
+ "docker",
190
+ { attach: "demo-ext" },
191
+ undefined,
192
+ );
193
+ },
194
+ };
195
+
196
+ /** When docker container listing fails, a manual entry input appears. */
197
+ export const DockerAttachManualEntry: Story = {
198
+ args: {
199
+ dockerContainersError: "docker: command not found",
200
+ },
201
+ play: async ({ canvas }) => {
202
+ await userEvent.selectOptions(canvas.getByTestId("env-create-adapter"), "docker");
203
+ await userEvent.selectOptions(canvas.getByTestId("env-docker-mode"), "attach");
204
+
205
+ await expect(canvas.getByTestId("env-docker-container-manual")).toBeInTheDocument();
206
+ await expect(canvas.queryByTestId("env-docker-container-select")).not.toBeInTheDocument();
207
+ },
208
+ };
209
+
210
+ /** When discovery succeeds but finds no running containers, manual entry is still offered. */
211
+ export const DockerAttachEmptyListManualEntry: Story = {
212
+ args: {
213
+ dockerContainers: [],
214
+ },
215
+ play: async ({ canvas }) => {
216
+ await userEvent.selectOptions(canvas.getByTestId("env-create-adapter"), "docker");
217
+ await userEvent.selectOptions(canvas.getByTestId("env-docker-mode"), "attach");
218
+
219
+ // No select (nothing to pick), but a manual input is available
220
+ await expect(canvas.queryByTestId("env-docker-container-select")).not.toBeInTheDocument();
221
+ const manual = canvas.getByTestId("env-docker-container-manual");
222
+ await userEvent.type(manual, "demo-ext");
223
+
224
+ const createButton = canvas.getByTestId("env-create-submit");
225
+ await expect(createButton).toBeEnabled();
226
+ },
227
+ };
228
+
143
229
  /** When codespace listing fails, a manual entry input appears and the select dropdown is hidden. */
144
230
  export const CodespaceManualEntry: Story = {
145
231
  args: {