@grackle-ai/web-components 0.108.4 → 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.
- package/.rush/temp/{116390ecbdd70f01703a1a6dbb1dd805d1a6f702.tar.log → 3629cc83b3804ac8bcea27905e83162210fb5dd6.tar.log} +40 -40
- package/.rush/temp/{116390ecbdd70f01703a1a6dbb1dd805d1a6f702.untar.log → 3629cc83b3804ac8bcea27905e83162210fb5dd6.untar.log} +2 -2
- package/.rush/temp/chunked-rush-logs/web-components._phase_build.chunks.jsonl +5 -5
- package/.rush/temp/chunked-rush-logs/web-components._phase_test.chunks.jsonl +16 -16
- package/.rush/temp/{ddb88821de1bbf09dbde855d6172b545d74acf2a.tar.log → f2b8b611fc00c7b912256986db4cc966d6560387.tar.log} +2 -2
- package/.rush/temp/{ddb88821de1bbf09dbde855d6172b545d74acf2a.untar.log → f2b8b611fc00c7b912256986db4cc966d6560387.untar.log} +2 -2
- package/.rush/temp/operation/_phase_build/all.log +5 -5
- package/.rush/temp/operation/_phase_build/log-chunks.jsonl +5 -5
- package/.rush/temp/operation/_phase_build/state.json +1 -1
- package/.rush/temp/operation/_phase_test/all.log +16 -16
- package/.rush/temp/operation/_phase_test/log-chunks.jsonl +16 -16
- package/.rush/temp/operation/_phase_test/state.json +1 -1
- package/README.md +95 -0
- package/dist/index.css +1 -1
- package/dist/index.js +5826 -5732
- package/package.json +2 -2
- package/rush-logs/web-components._phase_build.cache.log +1 -1
- package/rush-logs/web-components._phase_build.log +5 -5
- package/rush-logs/web-components._phase_test.cache.log +1 -1
- package/rush-logs/web-components._phase_test.log +16 -16
- package/src/components/layout/AppNav.module.scss +6 -0
- package/src/components/layout/AppNav.stories.tsx +47 -0
- package/src/components/layout/AppNav.tsx +24 -10
- package/src/components/panels/EnvironmentEditPanel.stories.tsx +87 -1
- package/src/components/panels/EnvironmentEditPanel.tsx +143 -34
- package/src/context/GrackleContextTypes.ts +3 -1
- package/src/hooks/types.ts +21 -0
- package/src/index.ts +2 -2
- package/src/mocks/MockGrackleProvider.tsx +7 -0
- package/temp/build/lint/_eslint-5eVG3S6w.json +8 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@grackle-ai/web-components",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
43
|
+
"@grackle-ai/common": "0.110.1"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@rushstack/heft": "1.2.7",
|
|
@@ -10,9 +10,9 @@ transforming...
|
|
|
10
10
|
✓ 2574 modules transformed.
|
|
11
11
|
rendering chunks...
|
|
12
12
|
computing gzip size...
|
|
13
|
-
dist/index.css 156.
|
|
14
|
-
dist/index.js 1,
|
|
15
|
-
✓ built in
|
|
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
16
|
[build:vite-build] Vite build completed.
|
|
17
|
-
---- build finished (
|
|
18
|
-
-------------------- Finished (
|
|
17
|
+
---- build finished (73.056s) ----
|
|
18
|
+
-------------------- Finished (73.061s) --------------------
|
|
@@ -5,26 +5,26 @@ 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/
|
|
14
|
-
✓ src/components/tools/classifyTool.test.ts (6 tests)
|
|
15
|
-
✓ src/components/tools/toolCardHelpers.test.ts (10 tests)
|
|
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
16
|
✓ src/components/display/extractText.test.tsx (8 tests) 12ms
|
|
17
|
-
✓ src/components/editable/useEditableField.test.tsx (17 tests)
|
|
18
|
-
✓ src/hooks/useEventSelection.test.ts (13 tests)
|
|
19
|
-
✓ src/components/notifications/UpdateBanner.test.tsx (4 tests)
|
|
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
20
|
|
|
21
21
|
Test Files 12 passed (12)
|
|
22
22
|
Tests 166 passed (166)
|
|
23
|
-
Start at
|
|
24
|
-
Duration 13.
|
|
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
25
|
|
|
26
26
|
[test:vitest] Vitest completed.
|
|
27
|
-
[test:storybook-test] Starting Storybook static server on port
|
|
27
|
+
[test:storybook-test] Starting Storybook static server on port 43017...
|
|
28
28
|
[test:storybook-test] Storybook server ready. Running interaction tests...
|
|
29
29
|
jest-haste-map: duplicate manual mock found: adapter-manager
|
|
30
30
|
The following files share their name; please delete one of them:
|
|
@@ -117,5 +117,5 @@ jest-haste-map: duplicate manual mock found: utils/network
|
|
|
117
117
|
* <rootDir>/packages/server/src/__mocks__/utils/network.ts
|
|
118
118
|
|
|
119
119
|
[test:storybook-test] Storybook interaction tests completed.
|
|
120
|
-
---- test finished (112.
|
|
121
|
-
-------------------- Finished (112.
|
|
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 :
|
|
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) %
|
|
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 +
|
|
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 =
|
|
105
|
+
nextIndex = orderedTabs.length - 1;
|
|
93
106
|
} else {
|
|
94
107
|
return;
|
|
95
108
|
}
|
|
96
109
|
|
|
97
|
-
navigate(
|
|
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,
|
|
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
|
-
{
|
|
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: {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useState, useCallback, type JSX } from "react";
|
|
2
2
|
import type { ToastVariant } from "../../context/ToastContext.js";
|
|
3
|
-
import type { Environment, Codespace, GitHubAccountData } from "../../hooks/types.js";
|
|
3
|
+
import type { Environment, Codespace, DockerContainer, GitHubAccountData } from "../../hooks/types.js";
|
|
4
4
|
import { ENVIRONMENTS_URL, environmentUrl, useAppNavigate } from "../../utils/navigation.js";
|
|
5
5
|
import { EditableTextField } from "../editable/EditableTextField.js";
|
|
6
6
|
import styles from "./EnvironmentEditPanel.module.scss";
|
|
@@ -35,6 +35,12 @@ interface Props {
|
|
|
35
35
|
codespaceCreating: boolean;
|
|
36
36
|
/** Callback to create a new codespace. */
|
|
37
37
|
onCreateCodespace: (repo: string, machine?: string) => void;
|
|
38
|
+
/** Callback to list running Docker containers available to attach to. */
|
|
39
|
+
onListDockerContainers: () => void;
|
|
40
|
+
/** Running Docker containers available to attach to (docker attach mode). */
|
|
41
|
+
dockerContainers: DockerContainer[];
|
|
42
|
+
/** Non-fatal error from listing Docker containers (e.g. docker CLI unavailable). */
|
|
43
|
+
dockerContainersError: string;
|
|
38
44
|
/** Display a toast notification. */
|
|
39
45
|
onShowToast?: (message: string, variant?: ToastVariant) => void;
|
|
40
46
|
}
|
|
@@ -202,7 +208,7 @@ function CodespacePicker({ codespaceName, onCodespaceNameChange, envName, onEnvN
|
|
|
202
208
|
* - edit: pre-populated form; uses click-to-edit fields that auto-save via
|
|
203
209
|
* updateEnvironment.
|
|
204
210
|
*/
|
|
205
|
-
export function EnvironmentEditPanel({ mode, environmentId, environments, githubAccounts, onAddEnvironment, onUpdateEnvironment, onListCodespaces, codespaces, codespaceError, codespaceListError, codespaceCreating, onCreateCodespace, onShowToast }: Props): JSX.Element {
|
|
211
|
+
export function EnvironmentEditPanel({ mode, environmentId, environments, githubAccounts, onAddEnvironment, onUpdateEnvironment, onListCodespaces, codespaces, codespaceError, codespaceListError, codespaceCreating, onCreateCodespace, onListDockerContainers, dockerContainers, dockerContainersError, onShowToast }: Props): JSX.Element {
|
|
206
212
|
const navigate = useAppNavigate();
|
|
207
213
|
|
|
208
214
|
const isEdit = mode === "edit";
|
|
@@ -222,6 +228,9 @@ export function EnvironmentEditPanel({ mode, environmentId, environments, github
|
|
|
222
228
|
const [repo, setRepo] = useState("");
|
|
223
229
|
const [codespaceName, setCodespaceName] = useState("");
|
|
224
230
|
const [githubAccountId, setGithubAccountId] = useState("");
|
|
231
|
+
// Docker: "create" a new container vs "attach" to an existing one (issue #1223).
|
|
232
|
+
const [dockerMode, setDockerMode] = useState<"create" | "attach">("create");
|
|
233
|
+
const [attachContainer, setAttachContainer] = useState("");
|
|
225
234
|
|
|
226
235
|
// ─── Edit mode state ───────────────────────────────
|
|
227
236
|
|
|
@@ -257,17 +266,24 @@ export function EnvironmentEditPanel({ mode, environmentId, environments, github
|
|
|
257
266
|
config.identityFile = identityFile.trim();
|
|
258
267
|
}
|
|
259
268
|
} else if (adapterType === "docker") {
|
|
260
|
-
if (
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
269
|
+
if (dockerMode === "attach") {
|
|
270
|
+
// Attach mode: target an existing container; image/repo are ignored.
|
|
271
|
+
if (attachContainer.trim()) {
|
|
272
|
+
config.attach = attachContainer.trim();
|
|
273
|
+
}
|
|
274
|
+
} else {
|
|
275
|
+
if (image.trim()) {
|
|
276
|
+
config.image = image.trim();
|
|
277
|
+
}
|
|
278
|
+
if (repo.trim()) {
|
|
279
|
+
config.repo = repo.trim();
|
|
280
|
+
}
|
|
265
281
|
}
|
|
266
282
|
} else if (adapterType === "codespace") {
|
|
267
283
|
config.codespaceName = codespaceName.trim();
|
|
268
284
|
}
|
|
269
285
|
return config;
|
|
270
|
-
}, [adapterType, host, port, user, identityFile, image, repo, codespaceName]);
|
|
286
|
+
}, [adapterType, host, port, user, identityFile, image, repo, codespaceName, dockerMode, attachContainer]);
|
|
271
287
|
|
|
272
288
|
const isCreateValid = (): boolean => {
|
|
273
289
|
if (!envName.trim()) {
|
|
@@ -279,6 +295,9 @@ export function EnvironmentEditPanel({ mode, environmentId, environments, github
|
|
|
279
295
|
if (adapterType === "codespace" && !codespaceName.trim()) {
|
|
280
296
|
return false;
|
|
281
297
|
}
|
|
298
|
+
if (adapterType === "docker" && dockerMode === "attach" && !attachContainer.trim()) {
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
282
301
|
if ((adapterType === "local" || adapterType === "ssh") && !isPortValid(port)) {
|
|
283
302
|
return false;
|
|
284
303
|
}
|
|
@@ -533,7 +552,25 @@ export function EnvironmentEditPanel({ mode, environmentId, environments, github
|
|
|
533
552
|
</>
|
|
534
553
|
)}
|
|
535
554
|
|
|
536
|
-
{existingEnv.adapterType === "docker" && (
|
|
555
|
+
{existingEnv.adapterType === "docker" && config.attach !== undefined && (
|
|
556
|
+
<div className={styles.section}>
|
|
557
|
+
<label className={styles.label}>Attach (container)</label>
|
|
558
|
+
<EditableTextField
|
|
559
|
+
value={String(config.attach ?? "")}
|
|
560
|
+
onSave={(v) => saveConfigField("attach", v)}
|
|
561
|
+
validate={(v) => v.trim() === "" ? "Container name is required" : undefined}
|
|
562
|
+
mode="edit"
|
|
563
|
+
fieldId="attach"
|
|
564
|
+
activeFieldId={activeFieldId}
|
|
565
|
+
onActivate={setActiveFieldId}
|
|
566
|
+
placeholder="container name or ID"
|
|
567
|
+
ariaLabel="Attach container"
|
|
568
|
+
data-testid="env-edit-attach"
|
|
569
|
+
/>
|
|
570
|
+
</div>
|
|
571
|
+
)}
|
|
572
|
+
|
|
573
|
+
{existingEnv.adapterType === "docker" && config.attach === undefined && (
|
|
537
574
|
<>
|
|
538
575
|
<div className={styles.section}>
|
|
539
576
|
<label className={styles.label}>Image</label>
|
|
@@ -792,33 +829,105 @@ export function EnvironmentEditPanel({ mode, environmentId, environments, github
|
|
|
792
829
|
{adapterType === "docker" && (
|
|
793
830
|
<>
|
|
794
831
|
<div className={styles.section}>
|
|
795
|
-
<label className={styles.label} htmlFor="env-
|
|
796
|
-
|
|
832
|
+
<label className={styles.label} htmlFor="env-docker-mode">
|
|
833
|
+
Source
|
|
797
834
|
</label>
|
|
798
|
-
<
|
|
799
|
-
id="env-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
type="text"
|
|
815
|
-
value={repo}
|
|
816
|
-
onChange={(e) => setRepo(e.target.value)}
|
|
817
|
-
placeholder="Repo (optional)..."
|
|
818
|
-
className={styles.fieldInput}
|
|
819
|
-
data-testid="env-create-repo"
|
|
820
|
-
/>
|
|
835
|
+
<select
|
|
836
|
+
id="env-docker-mode"
|
|
837
|
+
value={dockerMode}
|
|
838
|
+
onChange={(e) => {
|
|
839
|
+
const next = e.target.value as "create" | "attach";
|
|
840
|
+
setDockerMode(next);
|
|
841
|
+
if (next === "attach") {
|
|
842
|
+
onListDockerContainers();
|
|
843
|
+
}
|
|
844
|
+
}}
|
|
845
|
+
className={styles.adapterSelect}
|
|
846
|
+
data-testid="env-docker-mode"
|
|
847
|
+
>
|
|
848
|
+
<option value="create">Create new container</option>
|
|
849
|
+
<option value="attach">Attach to existing container</option>
|
|
850
|
+
</select>
|
|
821
851
|
</div>
|
|
852
|
+
|
|
853
|
+
{dockerMode === "create" ? (
|
|
854
|
+
<>
|
|
855
|
+
<div className={styles.section}>
|
|
856
|
+
<label className={styles.label} htmlFor="env-create-image">
|
|
857
|
+
Image
|
|
858
|
+
</label>
|
|
859
|
+
<input
|
|
860
|
+
id="env-create-image"
|
|
861
|
+
type="text"
|
|
862
|
+
value={image}
|
|
863
|
+
onChange={(e) => setImage(e.target.value)}
|
|
864
|
+
placeholder="Image (optional)..."
|
|
865
|
+
className={styles.fieldInput}
|
|
866
|
+
data-testid="env-create-image"
|
|
867
|
+
/>
|
|
868
|
+
</div>
|
|
869
|
+
<div className={styles.section}>
|
|
870
|
+
<label className={styles.label} htmlFor="env-create-repo">
|
|
871
|
+
Repo
|
|
872
|
+
</label>
|
|
873
|
+
<input
|
|
874
|
+
id="env-create-repo"
|
|
875
|
+
type="text"
|
|
876
|
+
value={repo}
|
|
877
|
+
onChange={(e) => setRepo(e.target.value)}
|
|
878
|
+
placeholder="Repo (optional)..."
|
|
879
|
+
className={styles.fieldInput}
|
|
880
|
+
data-testid="env-create-repo"
|
|
881
|
+
/>
|
|
882
|
+
</div>
|
|
883
|
+
</>
|
|
884
|
+
) : (
|
|
885
|
+
<div className={styles.section}>
|
|
886
|
+
<label className={styles.label}>Container</label>
|
|
887
|
+
{!dockerContainersError && dockerContainers.length > 0 && (
|
|
888
|
+
<select
|
|
889
|
+
value={attachContainer}
|
|
890
|
+
onChange={(e) => {
|
|
891
|
+
setAttachContainer(e.target.value);
|
|
892
|
+
if (e.target.value && !envName.trim()) {
|
|
893
|
+
setEnvName(e.target.value);
|
|
894
|
+
}
|
|
895
|
+
}}
|
|
896
|
+
className={styles.adapterSelect}
|
|
897
|
+
data-testid="env-docker-container-select"
|
|
898
|
+
>
|
|
899
|
+
<option value="">Select a container...</option>
|
|
900
|
+
{dockerContainers.map((c) => (
|
|
901
|
+
<option key={c.id} value={c.name}>
|
|
902
|
+
{c.name} ({c.image}) {c.status}
|
|
903
|
+
</option>
|
|
904
|
+
))}
|
|
905
|
+
</select>
|
|
906
|
+
)}
|
|
907
|
+
{/* Manual entry fallback: shown on listing error OR when no running
|
|
908
|
+
containers were found, so the user is never stuck with an empty picker. */}
|
|
909
|
+
{(dockerContainersError || dockerContainers.length === 0) && (
|
|
910
|
+
<>
|
|
911
|
+
{dockerContainersError
|
|
912
|
+
? <span className={styles.errorHint}>{dockerContainersError}</span>
|
|
913
|
+
: <span className={styles.creatingHint}>No running containers found.</span>}
|
|
914
|
+
<input
|
|
915
|
+
type="text"
|
|
916
|
+
value={attachContainer}
|
|
917
|
+
onChange={(e) => {
|
|
918
|
+
setAttachContainer(e.target.value);
|
|
919
|
+
if (e.target.value && !envName.trim()) {
|
|
920
|
+
setEnvName(e.target.value);
|
|
921
|
+
}
|
|
922
|
+
}}
|
|
923
|
+
placeholder="Enter container name/ID..."
|
|
924
|
+
className={styles.fieldInput}
|
|
925
|
+
data-testid="env-docker-container-manual"
|
|
926
|
+
/>
|
|
927
|
+
</>
|
|
928
|
+
)}
|
|
929
|
+
</div>
|
|
930
|
+
)}
|
|
822
931
|
</>
|
|
823
932
|
)}
|
|
824
933
|
|
|
@@ -10,7 +10,7 @@ import type {
|
|
|
10
10
|
UsageStats, UseKnowledgeResult,
|
|
11
11
|
UseEnvironmentsResult, UseSessionsResult, UseWorkspacesResult,
|
|
12
12
|
UseTasksResult, UseFindingsResult, UseTokensResult,
|
|
13
|
-
UseCredentialsResult, UseCodespacesResult, UsePersonasResult,
|
|
13
|
+
UseCredentialsResult, UseCodespacesResult, UseDockerContainersResult, UsePersonasResult,
|
|
14
14
|
UsePluginsResult,
|
|
15
15
|
UseSchedulesResult,
|
|
16
16
|
UseStreamsResult,
|
|
@@ -38,6 +38,8 @@ export interface UseGrackleSocketResult {
|
|
|
38
38
|
credentials: Omit<UseCredentialsResult, "handleEvent" | "loadCredentials">;
|
|
39
39
|
/** GitHub Codespace state and actions. */
|
|
40
40
|
codespaces: UseCodespacesResult;
|
|
41
|
+
/** Docker container discovery (attach mode) state and actions. */
|
|
42
|
+
dockerContainers: UseDockerContainersResult;
|
|
41
43
|
/** Persona state and actions. */
|
|
42
44
|
personas: Omit<UsePersonasResult, "handleEvent" | "loadPersonas">;
|
|
43
45
|
/** Schedule state and actions. */
|
package/src/hooks/types.ts
CHANGED
|
@@ -162,6 +162,15 @@ export interface Codespace {
|
|
|
162
162
|
gitStatus: string;
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
/** A running Docker container an environment can attach to (`docker ps`). */
|
|
166
|
+
export interface DockerContainer {
|
|
167
|
+
id: string;
|
|
168
|
+
name: string;
|
|
169
|
+
image: string;
|
|
170
|
+
state: string;
|
|
171
|
+
status: string;
|
|
172
|
+
}
|
|
173
|
+
|
|
165
174
|
/** An agent or script persona configuration. */
|
|
166
175
|
export interface PersonaData {
|
|
167
176
|
id: string;
|
|
@@ -496,6 +505,18 @@ export interface UseCodespacesResult {
|
|
|
496
505
|
domainHook: DomainHook;
|
|
497
506
|
}
|
|
498
507
|
|
|
508
|
+
/** Values returned by the docker-containers domain hook. */
|
|
509
|
+
export interface UseDockerContainersResult {
|
|
510
|
+
/** Running Docker containers available to attach to. */
|
|
511
|
+
dockerContainers: DockerContainer[];
|
|
512
|
+
/** Error message from the most recent list attempt, or empty string. */
|
|
513
|
+
dockerContainersError: string;
|
|
514
|
+
/** Request the current running-container list from the server. */
|
|
515
|
+
listDockerContainers: () => Promise<void>;
|
|
516
|
+
/** Lifecycle hook for connect/disconnect/event routing. */
|
|
517
|
+
domainHook: DomainHook;
|
|
518
|
+
}
|
|
519
|
+
|
|
499
520
|
/** Values returned by the personas domain hook. */
|
|
500
521
|
export interface UsePersonasResult {
|
|
501
522
|
/** All known personas. */
|