@grackle-ai/web-components 0.107.2

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 (279) hide show
  1. package/.rush/temp/3ae72563f781afd72723475938136f113846603e.untar.log +10 -0
  2. package/.rush/temp/bc1d5bf9201ce71abeaeaddd096deb9b0805d703.untar.log +10 -0
  3. package/.rush/temp/operation/_phase_build/all.log +18 -0
  4. package/.rush/temp/operation/_phase_build/log-chunks.jsonl +18 -0
  5. package/.rush/temp/operation/_phase_build/state.json +3 -0
  6. package/.rush/temp/operation/_phase_test/all.log +121 -0
  7. package/.rush/temp/operation/_phase_test/log-chunks.jsonl +121 -0
  8. package/.rush/temp/operation/_phase_test/state.json +3 -0
  9. package/.rush/temp/shrinkwrap-deps.json +938 -0
  10. package/.storybook/main.ts +22 -0
  11. package/.storybook/preview.tsx +30 -0
  12. package/config/rig.json +4 -0
  13. package/config/rush-project.json +12 -0
  14. package/dist/index.css +1 -0
  15. package/dist/index.js +39221 -0
  16. package/eslint.config.cjs +5 -0
  17. package/package.json +83 -0
  18. package/rush-logs/web-components._phase_build.cache.log +4 -0
  19. package/rush-logs/web-components._phase_test.cache.log +4 -0
  20. package/src/components/chat/ChatInput.module.scss +81 -0
  21. package/src/components/chat/ChatInput.stories.tsx +91 -0
  22. package/src/components/chat/ChatInput.tsx +168 -0
  23. package/src/components/chat/index.ts +6 -0
  24. package/src/components/dag/DagView.module.scss +149 -0
  25. package/src/components/dag/DagView.stories.tsx +125 -0
  26. package/src/components/dag/DagView.tsx +109 -0
  27. package/src/components/dag/TaskNode.stories.tsx +133 -0
  28. package/src/components/dag/TaskNode.tsx +40 -0
  29. package/src/components/dag/useDagLayout.ts +139 -0
  30. package/src/components/display/Breadcrumbs.module.scss +71 -0
  31. package/src/components/display/Breadcrumbs.stories.tsx +80 -0
  32. package/src/components/display/Breadcrumbs.tsx +46 -0
  33. package/src/components/display/Button.module.scss +110 -0
  34. package/src/components/display/Button.stories.tsx +88 -0
  35. package/src/components/display/Button.tsx +40 -0
  36. package/src/components/display/ConfirmDialog.module.scss +67 -0
  37. package/src/components/display/ConfirmDialog.stories.tsx +81 -0
  38. package/src/components/display/ConfirmDialog.tsx +88 -0
  39. package/src/components/display/CopyButton.module.scss +41 -0
  40. package/src/components/display/CopyButton.stories.tsx +78 -0
  41. package/src/components/display/CopyButton.tsx +64 -0
  42. package/src/components/display/DemoBanner.module.scss +37 -0
  43. package/src/components/display/DemoBanner.stories.tsx +40 -0
  44. package/src/components/display/DemoBanner.tsx +23 -0
  45. package/src/components/display/EventHoverRow.module.scss +102 -0
  46. package/src/components/display/EventHoverRow.stories.tsx +99 -0
  47. package/src/components/display/EventHoverRow.tsx +154 -0
  48. package/src/components/display/EventRenderer.module.scss +272 -0
  49. package/src/components/display/EventRenderer.stories.tsx +186 -0
  50. package/src/components/display/EventRenderer.tsx +271 -0
  51. package/src/components/display/EventStream.module.scss +93 -0
  52. package/src/components/display/EventStream.stories.tsx +249 -0
  53. package/src/components/display/EventStream.tsx +369 -0
  54. package/src/components/display/FloatingActionBar.module.scss +107 -0
  55. package/src/components/display/FloatingActionBar.stories.tsx +122 -0
  56. package/src/components/display/FloatingActionBar.tsx +119 -0
  57. package/src/components/display/SessionAttemptSelector.module.scss +50 -0
  58. package/src/components/display/SessionAttemptSelector.stories.tsx +78 -0
  59. package/src/components/display/SessionAttemptSelector.tsx +49 -0
  60. package/src/components/display/SessionPicker.module.scss +200 -0
  61. package/src/components/display/SessionPicker.stories.tsx +169 -0
  62. package/src/components/display/SessionPicker.tsx +214 -0
  63. package/src/components/display/Skeleton.module.scss +58 -0
  64. package/src/components/display/Skeleton.stories.tsx +94 -0
  65. package/src/components/display/Skeleton.tsx +127 -0
  66. package/src/components/display/Spinner.module.scss +41 -0
  67. package/src/components/display/Spinner.stories.tsx +66 -0
  68. package/src/components/display/Spinner.tsx +32 -0
  69. package/src/components/display/SplashScreen.module.scss +20 -0
  70. package/src/components/display/SplashScreen.stories.tsx +26 -0
  71. package/src/components/display/SplashScreen.tsx +16 -0
  72. package/src/components/display/SplitButton.module.scss +166 -0
  73. package/src/components/display/SplitButton.stories.tsx +95 -0
  74. package/src/components/display/SplitButton.tsx +128 -0
  75. package/src/components/display/Tooltip.module.scss +84 -0
  76. package/src/components/display/Tooltip.stories.tsx +240 -0
  77. package/src/components/display/Tooltip.tsx +184 -0
  78. package/src/components/display/extractText.test.tsx +48 -0
  79. package/src/components/display/index.ts +20 -0
  80. package/src/components/editable/EditableCheckbox.stories.tsx +54 -0
  81. package/src/components/editable/EditableCheckbox.tsx +39 -0
  82. package/src/components/editable/EditableField.module.scss +135 -0
  83. package/src/components/editable/EditableSelect.tsx +164 -0
  84. package/src/components/editable/EditableTextArea.stories.tsx +50 -0
  85. package/src/components/editable/EditableTextArea.tsx +148 -0
  86. package/src/components/editable/EditableTextField.stories.tsx +62 -0
  87. package/src/components/editable/EditableTextField.tsx +153 -0
  88. package/src/components/editable/EnvironmentSelect.module.scss +17 -0
  89. package/src/components/editable/EnvironmentSelect.stories.tsx +61 -0
  90. package/src/components/editable/EnvironmentSelect.tsx +87 -0
  91. package/src/components/editable/index.ts +13 -0
  92. package/src/components/editable/useEditableField.test.tsx +233 -0
  93. package/src/components/editable/useEditableField.ts +173 -0
  94. package/src/components/index.ts +20 -0
  95. package/src/components/knowledge/KnowledgeDetailPanel.module.scss +162 -0
  96. package/src/components/knowledge/KnowledgeDetailPanel.stories.tsx +208 -0
  97. package/src/components/knowledge/KnowledgeDetailPanel.tsx +122 -0
  98. package/src/components/knowledge/KnowledgeGraph.module.scss +110 -0
  99. package/src/components/knowledge/KnowledgeGraph.stories.tsx +180 -0
  100. package/src/components/knowledge/KnowledgeGraph.tsx +455 -0
  101. package/src/components/knowledge/KnowledgeNav.module.scss +130 -0
  102. package/src/components/knowledge/KnowledgeNav.stories.tsx +108 -0
  103. package/src/components/knowledge/KnowledgeNav.tsx +138 -0
  104. package/src/components/knowledge/index.ts +3 -0
  105. package/src/components/layout/AppNav.module.scss +82 -0
  106. package/src/components/layout/AppNav.stories.tsx +115 -0
  107. package/src/components/layout/AppNav.tsx +133 -0
  108. package/src/components/layout/BottomStatusBar.module.scss +58 -0
  109. package/src/components/layout/BottomStatusBar.stories.tsx +35 -0
  110. package/src/components/layout/BottomStatusBar.tsx +206 -0
  111. package/src/components/layout/Sidebar.module.scss +60 -0
  112. package/src/components/layout/Sidebar.stories.tsx +46 -0
  113. package/src/components/layout/Sidebar.tsx +84 -0
  114. package/src/components/layout/StatusBar.module.scss +108 -0
  115. package/src/components/layout/StatusBar.stories.tsx +119 -0
  116. package/src/components/layout/StatusBar.tsx +70 -0
  117. package/src/components/layout/index.ts +9 -0
  118. package/src/components/lists/EnvironmentNav.module.scss +118 -0
  119. package/src/components/lists/EnvironmentNav.stories.tsx +121 -0
  120. package/src/components/lists/EnvironmentNav.tsx +133 -0
  121. package/src/components/lists/FindingsNav.module.scss +126 -0
  122. package/src/components/lists/FindingsNav.tsx +146 -0
  123. package/src/components/lists/TaskList.module.scss +206 -0
  124. package/src/components/lists/TaskList.stories.tsx +401 -0
  125. package/src/components/lists/TaskList.tsx +509 -0
  126. package/src/components/lists/index.ts +6 -0
  127. package/src/components/lists/listHelpers.tsx +130 -0
  128. package/src/components/notifications/Callout.module.scss +83 -0
  129. package/src/components/notifications/Callout.stories.tsx +81 -0
  130. package/src/components/notifications/Callout.tsx +64 -0
  131. package/src/components/notifications/Toast.module.scss +86 -0
  132. package/src/components/notifications/Toast.stories.tsx +71 -0
  133. package/src/components/notifications/Toast.tsx +51 -0
  134. package/src/components/notifications/ToastContainer.module.scss +23 -0
  135. package/src/components/notifications/ToastContainer.stories.tsx +66 -0
  136. package/src/components/notifications/ToastContainer.tsx +29 -0
  137. package/src/components/notifications/UpdateBanner.stories.tsx +77 -0
  138. package/src/components/notifications/UpdateBanner.test.tsx +64 -0
  139. package/src/components/notifications/UpdateBanner.tsx +44 -0
  140. package/src/components/notifications/index.ts +8 -0
  141. package/src/components/panels/AboutPanel.stories.tsx +70 -0
  142. package/src/components/panels/AboutPanel.tsx +66 -0
  143. package/src/components/panels/AppearancePanel.stories.tsx +45 -0
  144. package/src/components/panels/AppearancePanel.tsx +97 -0
  145. package/src/components/panels/CredentialProvidersPanel.stories.tsx +62 -0
  146. package/src/components/panels/CredentialProvidersPanel.tsx +111 -0
  147. package/src/components/panels/EnvironmentEditPanel.module.scss +170 -0
  148. package/src/components/panels/EnvironmentEditPanel.stories.tsx +206 -0
  149. package/src/components/panels/EnvironmentEditPanel.tsx +785 -0
  150. package/src/components/panels/FindingsPanel.module.scss +94 -0
  151. package/src/components/panels/FindingsPanel.stories.tsx +109 -0
  152. package/src/components/panels/FindingsPanel.tsx +76 -0
  153. package/src/components/panels/KeyboardShortcutsPanel.module.scss +65 -0
  154. package/src/components/panels/KeyboardShortcutsPanel.stories.tsx +40 -0
  155. package/src/components/panels/KeyboardShortcutsPanel.tsx +104 -0
  156. package/src/components/panels/PluginsPanel.tsx +77 -0
  157. package/src/components/panels/SettingsPanel.module.scss +336 -0
  158. package/src/components/panels/TaskActionButtons.module.scss +22 -0
  159. package/src/components/panels/TaskActionButtons.stories.tsx +125 -0
  160. package/src/components/panels/TaskActionButtons.tsx +87 -0
  161. package/src/components/panels/TaskEditPanel.module.scss +202 -0
  162. package/src/components/panels/TaskEditPanel.stories.tsx +75 -0
  163. package/src/components/panels/TaskEditPanel.tsx +328 -0
  164. package/src/components/panels/TaskOverviewPanel.module.scss +236 -0
  165. package/src/components/panels/TaskOverviewPanel.stories.tsx +219 -0
  166. package/src/components/panels/TaskOverviewPanel.tsx +270 -0
  167. package/src/components/panels/TokensPanel.stories.tsx +131 -0
  168. package/src/components/panels/TokensPanel.tsx +143 -0
  169. package/src/components/panels/WorkpadPanel.module.scss +39 -0
  170. package/src/components/panels/WorkpadPanel.stories.tsx +56 -0
  171. package/src/components/panels/WorkpadPanel.tsx +63 -0
  172. package/src/components/panels/index.ts +13 -0
  173. package/src/components/personas/McpToolSelector.module.scss +109 -0
  174. package/src/components/personas/McpToolSelector.stories.tsx +129 -0
  175. package/src/components/personas/McpToolSelector.tsx +180 -0
  176. package/src/components/personas/PersonaManager.module.scss +233 -0
  177. package/src/components/personas/PersonaManager.stories.tsx +139 -0
  178. package/src/components/personas/PersonaManager.tsx +122 -0
  179. package/src/components/schedules/ScheduleManager.module.scss +98 -0
  180. package/src/components/schedules/ScheduleManager.stories.tsx +78 -0
  181. package/src/components/schedules/ScheduleManager.tsx +160 -0
  182. package/src/components/settings/SettingsNav.module.scss +82 -0
  183. package/src/components/settings/SettingsNav.stories.tsx +83 -0
  184. package/src/components/settings/SettingsNav.tsx +104 -0
  185. package/src/components/streams/StreamDetailPanel.module.scss +206 -0
  186. package/src/components/streams/StreamDetailPanel.stories.tsx +132 -0
  187. package/src/components/streams/StreamDetailPanel.tsx +119 -0
  188. package/src/components/streams/StreamList.module.scss +92 -0
  189. package/src/components/streams/StreamList.stories.tsx +99 -0
  190. package/src/components/streams/StreamList.tsx +114 -0
  191. package/src/components/streams/index.ts +10 -0
  192. package/src/components/tools/AgentToolCard.module.scss +118 -0
  193. package/src/components/tools/AgentToolCard.stories.tsx +304 -0
  194. package/src/components/tools/AgentToolCard.tsx +247 -0
  195. package/src/components/tools/FileEditCard.stories.tsx +138 -0
  196. package/src/components/tools/FileEditCard.tsx +160 -0
  197. package/src/components/tools/FileReadCard.stories.tsx +120 -0
  198. package/src/components/tools/FileReadCard.tsx +106 -0
  199. package/src/components/tools/FindingCard.stories.tsx +124 -0
  200. package/src/components/tools/FindingCard.tsx +178 -0
  201. package/src/components/tools/GenericToolCard.stories.tsx +80 -0
  202. package/src/components/tools/GenericToolCard.tsx +111 -0
  203. package/src/components/tools/IpcCard.stories.tsx +129 -0
  204. package/src/components/tools/IpcCard.tsx +178 -0
  205. package/src/components/tools/KnowledgeCard.stories.tsx +112 -0
  206. package/src/components/tools/KnowledgeCard.tsx +165 -0
  207. package/src/components/tools/MetadataCard.stories.tsx +32 -0
  208. package/src/components/tools/MetadataCard.tsx +39 -0
  209. package/src/components/tools/SearchCard.stories.tsx +74 -0
  210. package/src/components/tools/SearchCard.tsx +86 -0
  211. package/src/components/tools/ShellCard.stories.tsx +112 -0
  212. package/src/components/tools/ShellCard.tsx +106 -0
  213. package/src/components/tools/TaskCard.stories.tsx +123 -0
  214. package/src/components/tools/TaskCard.tsx +203 -0
  215. package/src/components/tools/TodoCard.module.scss +131 -0
  216. package/src/components/tools/TodoCard.stories.tsx +202 -0
  217. package/src/components/tools/TodoCard.tsx +200 -0
  218. package/src/components/tools/ToolCard.stories.tsx +177 -0
  219. package/src/components/tools/ToolCard.tsx +60 -0
  220. package/src/components/tools/ToolCardProps.ts +20 -0
  221. package/src/components/tools/ToolSearchCard.stories.tsx +81 -0
  222. package/src/components/tools/ToolSearchCard.tsx +86 -0
  223. package/src/components/tools/WorkpadCard.stories.tsx +106 -0
  224. package/src/components/tools/WorkpadCard.tsx +125 -0
  225. package/src/components/tools/classifyTool.test.ts +44 -0
  226. package/src/components/tools/classifyTool.ts +134 -0
  227. package/src/components/tools/parseDiff.ts +95 -0
  228. package/src/components/tools/parseShellOutput.ts +28 -0
  229. package/src/components/tools/toolCardHelpers.test.ts +53 -0
  230. package/src/components/tools/toolCards.module.scss +234 -0
  231. package/src/components/workspace/WorkspaceBoard.module.scss +238 -0
  232. package/src/components/workspace/WorkspaceBoard.stories.tsx +240 -0
  233. package/src/components/workspace/WorkspaceBoard.tsx +232 -0
  234. package/src/components/workspace/WorkspaceFormFields.module.scss +79 -0
  235. package/src/components/workspace/WorkspaceFormFields.stories.tsx +133 -0
  236. package/src/components/workspace/WorkspaceFormFields.tsx +185 -0
  237. package/src/context/GrackleContext.ts +28 -0
  238. package/src/context/GrackleContextTypes.ts +64 -0
  239. package/src/context/SidebarContext.tsx +53 -0
  240. package/src/context/ThemeContext.tsx +21 -0
  241. package/src/context/ToastContext.tsx +56 -0
  242. package/src/hooks/types.ts +864 -0
  243. package/src/hooks/useEventSelection.test.ts +204 -0
  244. package/src/hooks/useEventSelection.ts +158 -0
  245. package/src/hooks/useSmartScroll.ts +151 -0
  246. package/src/hooks/useTheme.ts +228 -0
  247. package/src/index.ts +210 -0
  248. package/src/mocks/MockGrackleProvider.tsx +1397 -0
  249. package/src/mocks/mockData.ts +1966 -0
  250. package/src/mocks/mockKnowledgeData.ts +294 -0
  251. package/src/scss.d.ts +12 -0
  252. package/src/styles/global.scss +244 -0
  253. package/src/styles/mixins.scss +278 -0
  254. package/src/styles/prism-theme.scss +148 -0
  255. package/src/styles/theme.scss +1102 -0
  256. package/src/test-utils/storybook-decorators.tsx +50 -0
  257. package/src/test-utils/storybook-helpers.ts +262 -0
  258. package/src/themes.ts +142 -0
  259. package/src/utils/boardColumns.ts +141 -0
  260. package/src/utils/breadcrumbs.test.ts +285 -0
  261. package/src/utils/breadcrumbs.ts +222 -0
  262. package/src/utils/dashboard.test.ts +156 -0
  263. package/src/utils/dashboard.ts +195 -0
  264. package/src/utils/eventContent.test.ts +353 -0
  265. package/src/utils/eventContent.ts +209 -0
  266. package/src/utils/findingCategory.ts +33 -0
  267. package/src/utils/format.ts +27 -0
  268. package/src/utils/iconSize.ts +18 -0
  269. package/src/utils/navigation.ts +205 -0
  270. package/src/utils/route-config.test.ts +128 -0
  271. package/src/utils/scrollUtils.test.ts +65 -0
  272. package/src/utils/scrollUtils.ts +49 -0
  273. package/src/utils/sessionEvents.test.ts +302 -0
  274. package/src/utils/sessionEvents.ts +233 -0
  275. package/src/utils/taskStatus.tsx +137 -0
  276. package/src/utils/time.ts +92 -0
  277. package/tsconfig.json +8 -0
  278. package/vite.config.ts +20 -0
  279. package/vitest.config.ts +10 -0
@@ -0,0 +1,785 @@
1
+ import { useState, useCallback, type JSX } from "react";
2
+ import type { ToastVariant } from "../../context/ToastContext.js";
3
+ import type { Environment, Codespace } from "../../hooks/types.js";
4
+ import { ENVIRONMENTS_URL, environmentUrl, useAppNavigate } from "../../utils/navigation.js";
5
+ import { EditableTextField } from "../editable/EditableTextField.js";
6
+ import styles from "./EnvironmentEditPanel.module.scss";
7
+
8
+ /** Minimum valid network port. */
9
+ const MIN_PORT: number = 1;
10
+ /** Maximum valid network port. */
11
+ const MAX_PORT: number = 65535;
12
+
13
+ /** Props for the EnvironmentEditPanel component. */
14
+ interface Props {
15
+ mode: "new" | "edit";
16
+ /** Environment ID — required in edit mode. */
17
+ environmentId?: string;
18
+ /** All environments (for lookup in edit mode). */
19
+ environments: Environment[];
20
+ /** Callback to add a new environment. */
21
+ onAddEnvironment: (displayName: string, adapterType: string, adapterConfig?: Record<string, unknown>) => void;
22
+ /** Callback to update an existing environment. */
23
+ onUpdateEnvironment: (environmentId: string, fields: { displayName?: string; adapterConfig?: Record<string, unknown> }) => void;
24
+ /** Callback to list available codespaces. */
25
+ onListCodespaces: () => void;
26
+ /** Available codespaces. */
27
+ codespaces: Codespace[];
28
+ /** Error from codespace operations. */
29
+ codespaceError: string;
30
+ /** Error from listing codespaces. */
31
+ codespaceListError: string;
32
+ /** Whether a codespace is being created. */
33
+ codespaceCreating: boolean;
34
+ /** Callback to create a new codespace. */
35
+ onCreateCodespace: (repo: string, machine?: string) => void;
36
+ /** Display a toast notification. */
37
+ onShowToast?: (message: string, variant?: ToastVariant) => void;
38
+ }
39
+
40
+ /** Returns true if portStr is empty (optional) or a valid integer in [1, 65535]. */
41
+ function isPortValid(portStr: string): boolean {
42
+ if (!portStr.trim()) {
43
+ return true;
44
+ }
45
+ const n = Number(portStr);
46
+ return Number.isInteger(n) && n >= MIN_PORT && n <= MAX_PORT;
47
+ }
48
+
49
+ /** Parse adapter config JSON string into a record, defaulting to empty object. */
50
+ function parseAdapterConfig(raw: string): Record<string, unknown> {
51
+ try {
52
+ const parsed: unknown = JSON.parse(raw);
53
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
54
+ return parsed as Record<string, unknown>;
55
+ }
56
+ } catch {
57
+ // fall through
58
+ }
59
+ return {};
60
+ }
61
+
62
+ // ─── Codespace Picker ─────────────────────────────────────────────────────────
63
+
64
+ interface CodespacePickerProps {
65
+ codespaceName: string;
66
+ onCodespaceNameChange: (name: string) => void;
67
+ envName: string;
68
+ onEnvNameChange: (name: string) => void;
69
+ /** Available codespaces. */
70
+ codespaces: Codespace[];
71
+ /** Error from codespace operations. */
72
+ codespaceError: string;
73
+ /** Error from listing codespaces. */
74
+ codespaceListError: string;
75
+ /** Whether a codespace is being created. */
76
+ codespaceCreating: boolean;
77
+ /** Callback to create a new codespace. */
78
+ onCreateCodespace: (repo: string, machine?: string) => void;
79
+ }
80
+
81
+ /** Codespace picker subcomponent — pick an existing or create a new codespace. */
82
+ function CodespacePicker({ codespaceName, onCodespaceNameChange, envName, onEnvNameChange, codespaces, codespaceError, codespaceListError, codespaceCreating, onCreateCodespace }: CodespacePickerProps): JSX.Element {
83
+
84
+ const [mode, setMode] = useState<"pick" | "create">("pick");
85
+ const [createRepo, setCreateRepo] = useState("");
86
+ const [createMachine, setCreateMachine] = useState("");
87
+
88
+ if (mode === "create") {
89
+ return (
90
+ <div className={styles.codespaceSection}>
91
+ <div className={styles.section}>
92
+ <label className={styles.label}>Repository</label>
93
+ <input
94
+ type="text"
95
+ value={createRepo}
96
+ onChange={(e) => setCreateRepo(e.target.value)}
97
+ placeholder="owner/repo"
98
+ className={styles.fieldInput}
99
+ data-testid="env-codespace-repo"
100
+ />
101
+ </div>
102
+ <div className={styles.section}>
103
+ <label className={styles.label}>Machine Type</label>
104
+ <input
105
+ type="text"
106
+ value={createMachine}
107
+ onChange={(e) => setCreateMachine(e.target.value)}
108
+ placeholder="Machine type (optional)..."
109
+ className={styles.fieldInput}
110
+ data-testid="env-codespace-machine"
111
+ />
112
+ </div>
113
+ <div className={styles.codespaceActions}>
114
+ <button
115
+ onClick={() => {
116
+ if (createRepo.trim()) {
117
+ onCreateCodespace(createRepo.trim(), createMachine.trim() || undefined);
118
+ setMode("pick");
119
+ setCreateRepo("");
120
+ setCreateMachine("");
121
+ }
122
+ }}
123
+ disabled={!createRepo.trim()}
124
+ className={styles.btnPrimary}
125
+ >
126
+ Create
127
+ </button>
128
+ <button
129
+ onClick={() => { setMode("pick"); setCreateRepo(""); setCreateMachine(""); }}
130
+ className={styles.btnGhost}
131
+ >
132
+ Cancel
133
+ </button>
134
+ </div>
135
+ </div>
136
+ );
137
+ }
138
+
139
+ return (
140
+ <div className={styles.codespaceSection}>
141
+ <div className={styles.section}>
142
+ <label className={styles.label}>Codespace</label>
143
+ {!codespaceListError && (
144
+ <select
145
+ value={codespaceName}
146
+ onChange={(e) => {
147
+ if (e.target.value === "__create__") {
148
+ setMode("create");
149
+ onCodespaceNameChange("");
150
+ } else {
151
+ onCodespaceNameChange(e.target.value);
152
+ if (e.target.value && !envName.trim()) {
153
+ onEnvNameChange(e.target.value);
154
+ }
155
+ }
156
+ }}
157
+ disabled={codespaceCreating}
158
+ className={styles.adapterSelect}
159
+ data-testid="env-codespace-select"
160
+ >
161
+ <option value="">Select a codespace...</option>
162
+ {codespaces.map((cs) => (
163
+ <option key={cs.name} value={cs.name}>
164
+ {cs.name} ({cs.repository}) — {cs.state}
165
+ </option>
166
+ ))}
167
+ <option value="__create__">Create new from repo...</option>
168
+ </select>
169
+ )}
170
+ {codespaceCreating && (
171
+ <span className={styles.creatingHint}>Creating codespace...</span>
172
+ )}
173
+ {codespaceListError && (
174
+ <>
175
+ <span className={styles.errorHint}>{codespaceListError}</span>
176
+ <input
177
+ type="text"
178
+ value={codespaceName}
179
+ onChange={(e) => onCodespaceNameChange(e.target.value)}
180
+ placeholder="Or enter codespace name manually..."
181
+ className={styles.fieldInput}
182
+ data-testid="env-codespace-manual"
183
+ />
184
+ </>
185
+ )}
186
+ {codespaceError && !codespaceListError && (
187
+ <span className={styles.errorHint}>{codespaceError}</span>
188
+ )}
189
+ </div>
190
+ </div>
191
+ );
192
+ }
193
+
194
+ // ─── Main component ───────────────────────────────────────────────────────────
195
+
196
+ /**
197
+ * Full-panel create/edit form for environments.
198
+ *
199
+ * - new: blank form; calls addEnvironment on save, then navigates to settings.
200
+ * - edit: pre-populated form; uses click-to-edit fields that auto-save via
201
+ * updateEnvironment.
202
+ */
203
+ export function EnvironmentEditPanel({ mode, environmentId, environments, onAddEnvironment, onUpdateEnvironment, onListCodespaces, codespaces, codespaceError, codespaceListError, codespaceCreating, onCreateCodespace, onShowToast }: Props): JSX.Element {
204
+ const navigate = useAppNavigate();
205
+
206
+ const isEdit = mode === "edit";
207
+ const existingEnv = isEdit && environmentId
208
+ ? environments.find((e) => e.id === environmentId)
209
+ : undefined;
210
+
211
+ // ─── Create mode state ─────────────────────────────
212
+
213
+ const [envName, setEnvName] = useState("");
214
+ const [adapterType, setAdapterType] = useState("local");
215
+ const [host, setHost] = useState("");
216
+ const [port, setPort] = useState("");
217
+ const [user, setUser] = useState("");
218
+ const [identityFile, setIdentityFile] = useState("");
219
+ const [image, setImage] = useState("");
220
+ const [repo, setRepo] = useState("");
221
+ const [codespaceName, setCodespaceName] = useState("");
222
+
223
+ // ─── Edit mode state ───────────────────────────────
224
+
225
+ const [activeFieldId, setActiveFieldId] = useState<string | null>(null);
226
+
227
+ // ─── Helpers ───────────────────────────────────────
228
+
229
+ /** Build adapter config object from create-mode form state. */
230
+ const buildCreateConfig = useCallback((): Record<string, unknown> => {
231
+ const config: Record<string, unknown> = {};
232
+ if (adapterType === "local") {
233
+ if (host.trim()) {
234
+ config.host = host.trim();
235
+ }
236
+ if (port.trim()) {
237
+ const n = Number(port);
238
+ if (Number.isInteger(n)) {
239
+ config.port = n;
240
+ }
241
+ }
242
+ } else if (adapterType === "ssh") {
243
+ config.host = host.trim();
244
+ if (user.trim()) {
245
+ config.user = user.trim();
246
+ }
247
+ if (port.trim()) {
248
+ const n = Number(port);
249
+ if (Number.isInteger(n)) {
250
+ config.sshPort = n;
251
+ }
252
+ }
253
+ if (identityFile.trim()) {
254
+ config.identityFile = identityFile.trim();
255
+ }
256
+ } else if (adapterType === "docker") {
257
+ if (image.trim()) {
258
+ config.image = image.trim();
259
+ }
260
+ if (repo.trim()) {
261
+ config.repo = repo.trim();
262
+ }
263
+ } else if (adapterType === "codespace") {
264
+ config.codespaceName = codespaceName.trim();
265
+ }
266
+ return config;
267
+ }, [adapterType, host, port, user, identityFile, image, repo, codespaceName]);
268
+
269
+ const isCreateValid = (): boolean => {
270
+ if (!envName.trim()) {
271
+ return false;
272
+ }
273
+ if (adapterType === "ssh" && !host.trim()) {
274
+ return false;
275
+ }
276
+ if (adapterType === "codespace" && !codespaceName.trim()) {
277
+ return false;
278
+ }
279
+ if ((adapterType === "local" || adapterType === "ssh") && !isPortValid(port)) {
280
+ return false;
281
+ }
282
+ return true;
283
+ };
284
+
285
+ const handleCreate = (): void => {
286
+ if (!isCreateValid()) {
287
+ return;
288
+ }
289
+ onAddEnvironment(envName.trim(), adapterType, buildCreateConfig());
290
+ onShowToast?.("Environment added successfully", "success");
291
+ navigate(ENVIRONMENTS_URL, { replace: true });
292
+ };
293
+
294
+ const handleCancel = (): void => {
295
+ if (environmentId) {
296
+ navigate(environmentUrl(environmentId));
297
+ } else {
298
+ navigate(ENVIRONMENTS_URL);
299
+ }
300
+ };
301
+
302
+ /** Save a single config field in edit mode by merging into existing adapterConfig. */
303
+ const saveConfigField = useCallback(
304
+ (fieldName: string, value: string) => {
305
+ if (!existingEnv || !environmentId) {
306
+ return;
307
+ }
308
+ const current = parseAdapterConfig(existingEnv.adapterConfig);
309
+ const trimmed = value.trim();
310
+ if (trimmed) {
311
+ current[fieldName] = trimmed;
312
+ } else {
313
+ delete current[fieldName];
314
+ }
315
+ onUpdateEnvironment(environmentId, { adapterConfig: current });
316
+ },
317
+ [existingEnv, environmentId, onUpdateEnvironment],
318
+ );
319
+
320
+ /** Save a numeric config field in edit mode. */
321
+ const saveConfigNumberField = useCallback(
322
+ (fieldName: string, value: string) => {
323
+ if (!existingEnv || !environmentId) {
324
+ return;
325
+ }
326
+ const current = parseAdapterConfig(existingEnv.adapterConfig);
327
+ if (value.trim()) {
328
+ const n = Number(value);
329
+ if (Number.isInteger(n) && n >= MIN_PORT && n <= MAX_PORT) {
330
+ current[fieldName] = n;
331
+ }
332
+ } else {
333
+ delete current[fieldName];
334
+ }
335
+ onUpdateEnvironment(environmentId, { adapterConfig: current });
336
+ },
337
+ [existingEnv, environmentId, onUpdateEnvironment],
338
+ );
339
+
340
+ // ─── Edit mode ─────────────────────────────────────
341
+
342
+ if (isEdit) {
343
+ if (!existingEnv) {
344
+ return (
345
+ <div className={styles.container}>
346
+ <div className={styles.header}>
347
+ <div className={styles.headerTitle}>
348
+ <span className={styles.badge}>edit environment</span>
349
+ </div>
350
+ <div className={styles.headerActions}>
351
+ <button onClick={handleCancel} className={styles.btnGhost}>Back</button>
352
+ </div>
353
+ </div>
354
+ <div className={styles.body}>
355
+ <div className={styles.formContent}>
356
+ <span className={styles.readOnlyValue}>Environment not found</span>
357
+ </div>
358
+ </div>
359
+ </div>
360
+ );
361
+ }
362
+
363
+ const config = parseAdapterConfig(existingEnv.adapterConfig);
364
+
365
+ return (
366
+ <div className={styles.container} data-testid="env-edit-panel">
367
+ {/* Header */}
368
+ <div className={styles.header}>
369
+ <div className={styles.headerTitle}>
370
+ <span className={styles.badge}>edit environment</span>
371
+ </div>
372
+ <div className={styles.headerActions}>
373
+ <button onClick={handleCancel} className={styles.btnGhost} data-testid="env-edit-back">Back</button>
374
+ </div>
375
+ </div>
376
+
377
+ {/* Form body */}
378
+ <div className={styles.body}>
379
+ <div className={styles.formContent}>
380
+ {/* Name */}
381
+ <div className={styles.section}>
382
+ <label className={styles.label}>Name</label>
383
+ <EditableTextField
384
+ value={existingEnv.displayName}
385
+ onSave={(value) => {
386
+ if (environmentId) {
387
+ onUpdateEnvironment(environmentId, { displayName: value });
388
+ }
389
+ }}
390
+ validate={(v) => v.trim() === "" ? "Name cannot be empty" : undefined}
391
+ mode="edit"
392
+ fieldId="name"
393
+ activeFieldId={activeFieldId}
394
+ onActivate={setActiveFieldId}
395
+ placeholder="Environment name"
396
+ ariaLabel="Environment name"
397
+ data-testid="env-edit-name"
398
+ />
399
+ </div>
400
+
401
+ {/* Adapter Type (read-only) */}
402
+ <div className={styles.section}>
403
+ <label className={styles.label}>Adapter Type</label>
404
+ <span className={styles.readOnlyValue} data-testid="env-edit-adapter-type">
405
+ {existingEnv.adapterType}
406
+ </span>
407
+ </div>
408
+
409
+ {/* Adapter-specific editable fields */}
410
+ {existingEnv.adapterType === "local" && (
411
+ <>
412
+ <div className={styles.section}>
413
+ <label className={styles.label}>Host</label>
414
+ <EditableTextField
415
+ value={String(config.host ?? "")}
416
+ onSave={(v) => saveConfigField("host", v)}
417
+ mode="edit"
418
+ fieldId="host"
419
+ activeFieldId={activeFieldId}
420
+ onActivate={setActiveFieldId}
421
+ placeholder="(default)"
422
+ ariaLabel="Host"
423
+ data-testid="env-edit-host"
424
+ />
425
+ </div>
426
+ <div className={styles.section}>
427
+ <label className={styles.label}>Port</label>
428
+ <EditableTextField
429
+ value={String(config.port ?? "")}
430
+ onSave={(v) => saveConfigNumberField("port", v)}
431
+ validate={(v) => !isPortValid(v) ? "Port must be 1-65535" : undefined}
432
+ mode="edit"
433
+ fieldId="port"
434
+ activeFieldId={activeFieldId}
435
+ onActivate={setActiveFieldId}
436
+ placeholder="(default)"
437
+ ariaLabel="Port"
438
+ data-testid="env-edit-port"
439
+ />
440
+ </div>
441
+ </>
442
+ )}
443
+
444
+ {existingEnv.adapterType === "ssh" && (
445
+ <>
446
+ <div className={styles.section}>
447
+ <label className={styles.label}>Host</label>
448
+ <EditableTextField
449
+ value={String(config.host ?? "")}
450
+ onSave={(v) => saveConfigField("host", v)}
451
+ validate={(v) => v.trim() === "" ? "Host is required" : undefined}
452
+ mode="edit"
453
+ fieldId="host"
454
+ activeFieldId={activeFieldId}
455
+ onActivate={setActiveFieldId}
456
+ placeholder="hostname or IP"
457
+ ariaLabel="Host"
458
+ data-testid="env-edit-host"
459
+ />
460
+ </div>
461
+ <div className={styles.section}>
462
+ <label className={styles.label}>User</label>
463
+ <EditableTextField
464
+ value={String(config.user ?? "")}
465
+ onSave={(v) => saveConfigField("user", v)}
466
+ mode="edit"
467
+ fieldId="user"
468
+ activeFieldId={activeFieldId}
469
+ onActivate={setActiveFieldId}
470
+ placeholder="(default)"
471
+ ariaLabel="User"
472
+ data-testid="env-edit-user"
473
+ />
474
+ </div>
475
+ <div className={styles.section}>
476
+ <label className={styles.label}>SSH Port</label>
477
+ <EditableTextField
478
+ value={String(config.sshPort ?? "")}
479
+ onSave={(v) => saveConfigNumberField("sshPort", v)}
480
+ validate={(v) => !isPortValid(v) ? "Port must be 1-65535" : undefined}
481
+ mode="edit"
482
+ fieldId="sshPort"
483
+ activeFieldId={activeFieldId}
484
+ onActivate={setActiveFieldId}
485
+ placeholder="22"
486
+ ariaLabel="SSH Port"
487
+ data-testid="env-edit-ssh-port"
488
+ />
489
+ </div>
490
+ <div className={styles.section}>
491
+ <label className={styles.label}>Identity File</label>
492
+ <EditableTextField
493
+ value={String(config.identityFile ?? "")}
494
+ onSave={(v) => saveConfigField("identityFile", v)}
495
+ mode="edit"
496
+ fieldId="identityFile"
497
+ activeFieldId={activeFieldId}
498
+ onActivate={setActiveFieldId}
499
+ placeholder="~/.ssh/id_rsa"
500
+ ariaLabel="Identity File"
501
+ data-testid="env-edit-identity-file"
502
+ />
503
+ </div>
504
+ </>
505
+ )}
506
+
507
+ {existingEnv.adapterType === "docker" && (
508
+ <>
509
+ <div className={styles.section}>
510
+ <label className={styles.label}>Image</label>
511
+ <EditableTextField
512
+ value={String(config.image ?? "")}
513
+ onSave={(v) => saveConfigField("image", v)}
514
+ mode="edit"
515
+ fieldId="image"
516
+ activeFieldId={activeFieldId}
517
+ onActivate={setActiveFieldId}
518
+ placeholder="(default)"
519
+ ariaLabel="Image"
520
+ data-testid="env-edit-image"
521
+ />
522
+ </div>
523
+ <div className={styles.section}>
524
+ <label className={styles.label}>Repo</label>
525
+ <EditableTextField
526
+ value={String(config.repo ?? "")}
527
+ onSave={(v) => saveConfigField("repo", v)}
528
+ mode="edit"
529
+ fieldId="repo"
530
+ activeFieldId={activeFieldId}
531
+ onActivate={setActiveFieldId}
532
+ placeholder="(none)"
533
+ ariaLabel="Repo"
534
+ data-testid="env-edit-repo"
535
+ />
536
+ </div>
537
+ </>
538
+ )}
539
+
540
+ {existingEnv.adapterType === "codespace" && (
541
+ <div className={styles.section}>
542
+ <label className={styles.label}>Codespace Name</label>
543
+ <EditableTextField
544
+ value={String(config.codespaceName ?? "")}
545
+ onSave={(v) => saveConfigField("codespaceName", v)}
546
+ validate={(v) => v.trim() === "" ? "Codespace name is required" : undefined}
547
+ mode="edit"
548
+ fieldId="codespaceName"
549
+ activeFieldId={activeFieldId}
550
+ onActivate={setActiveFieldId}
551
+ placeholder="codespace-name"
552
+ ariaLabel="Codespace Name"
553
+ data-testid="env-edit-codespace-name"
554
+ />
555
+ </div>
556
+ )}
557
+ </div>
558
+ </div>
559
+ </div>
560
+ );
561
+ }
562
+
563
+ // ─── Create mode ───────────────────────────────────
564
+
565
+ return (
566
+ <div className={styles.container} data-testid="env-create-panel">
567
+ {/* Header */}
568
+ <div className={styles.header}>
569
+ <div className={styles.headerTitle}>
570
+ <span className={styles.badge}>new environment</span>
571
+ </div>
572
+ <div className={styles.headerActions}>
573
+ <button
574
+ onClick={handleCreate}
575
+ disabled={!isCreateValid()}
576
+ className={styles.btnPrimary}
577
+ data-testid="env-create-submit"
578
+ >
579
+ Create
580
+ </button>
581
+ <button onClick={handleCancel} className={styles.btnGhost}>
582
+ Cancel
583
+ </button>
584
+ </div>
585
+ </div>
586
+
587
+ {/* Form body */}
588
+ <div className={styles.body}>
589
+ <div className={styles.formContent}>
590
+ {/* Name */}
591
+ <div className={styles.section}>
592
+ <label className={styles.label} htmlFor="env-create-name">
593
+ Name
594
+ </label>
595
+ <input
596
+ id="env-create-name"
597
+ type="text"
598
+ value={envName}
599
+ onChange={(e) => setEnvName(e.target.value)}
600
+ placeholder="Environment name..."
601
+ autoFocus
602
+ className={styles.nameInput}
603
+ data-testid="env-create-name"
604
+ onKeyDown={(e) => {
605
+ if (e.key === "Enter" && isCreateValid()) {
606
+ handleCreate();
607
+ }
608
+ }}
609
+ />
610
+ </div>
611
+
612
+ {/* Adapter Type */}
613
+ <div className={styles.section}>
614
+ <label className={styles.label} htmlFor="env-create-adapter">
615
+ Adapter Type
616
+ </label>
617
+ <select
618
+ id="env-create-adapter"
619
+ value={adapterType}
620
+ onChange={(e) => {
621
+ setAdapterType(e.target.value);
622
+ if (e.target.value === "codespace") {
623
+ onListCodespaces();
624
+ }
625
+ }}
626
+ className={styles.adapterSelect}
627
+ data-testid="env-create-adapter"
628
+ >
629
+ <option value="local">local</option>
630
+ <option value="ssh">ssh</option>
631
+ <option value="docker">docker</option>
632
+ <option value="codespace">codespace</option>
633
+ </select>
634
+ </div>
635
+
636
+ {/* Adapter-specific fields */}
637
+ {adapterType === "local" && (
638
+ <>
639
+ <div className={styles.section}>
640
+ <label className={styles.label} htmlFor="env-create-host">
641
+ Host
642
+ </label>
643
+ <input
644
+ id="env-create-host"
645
+ type="text"
646
+ value={host}
647
+ onChange={(e) => setHost(e.target.value)}
648
+ placeholder="Host (optional)..."
649
+ className={styles.fieldInput}
650
+ data-testid="env-create-host"
651
+ />
652
+ </div>
653
+ <div className={styles.section}>
654
+ <label className={styles.label} htmlFor="env-create-port">
655
+ Port
656
+ </label>
657
+ <input
658
+ id="env-create-port"
659
+ type="number"
660
+ min={MIN_PORT}
661
+ max={MAX_PORT}
662
+ value={port}
663
+ onChange={(e) => setPort(e.target.value)}
664
+ placeholder="Port (optional)..."
665
+ className={styles.fieldInput}
666
+ data-testid="env-create-port"
667
+ />
668
+ </div>
669
+ </>
670
+ )}
671
+
672
+ {adapterType === "ssh" && (
673
+ <>
674
+ <div className={styles.section}>
675
+ <label className={styles.label} htmlFor="env-create-host">
676
+ Host
677
+ </label>
678
+ <input
679
+ id="env-create-host"
680
+ type="text"
681
+ value={host}
682
+ onChange={(e) => setHost(e.target.value)}
683
+ placeholder="Host (required)..."
684
+ className={styles.fieldInput}
685
+ data-testid="env-create-host"
686
+ />
687
+ </div>
688
+ <div className={styles.section}>
689
+ <label className={styles.label} htmlFor="env-create-user">
690
+ User
691
+ </label>
692
+ <input
693
+ id="env-create-user"
694
+ type="text"
695
+ value={user}
696
+ onChange={(e) => setUser(e.target.value)}
697
+ placeholder="User (optional)..."
698
+ className={styles.fieldInput}
699
+ data-testid="env-create-user"
700
+ />
701
+ </div>
702
+ <div className={styles.section}>
703
+ <label className={styles.label} htmlFor="env-create-port">
704
+ SSH Port
705
+ </label>
706
+ <input
707
+ id="env-create-port"
708
+ type="number"
709
+ min={MIN_PORT}
710
+ max={MAX_PORT}
711
+ value={port}
712
+ onChange={(e) => setPort(e.target.value)}
713
+ placeholder="SSH port (optional)..."
714
+ className={styles.fieldInput}
715
+ data-testid="env-create-port"
716
+ />
717
+ </div>
718
+ <div className={styles.section}>
719
+ <label className={styles.label} htmlFor="env-create-identity">
720
+ Identity File
721
+ </label>
722
+ <input
723
+ id="env-create-identity"
724
+ type="text"
725
+ value={identityFile}
726
+ onChange={(e) => setIdentityFile(e.target.value)}
727
+ placeholder="Identity file (optional)..."
728
+ className={styles.fieldInput}
729
+ data-testid="env-create-identity"
730
+ />
731
+ </div>
732
+ </>
733
+ )}
734
+
735
+ {adapterType === "docker" && (
736
+ <>
737
+ <div className={styles.section}>
738
+ <label className={styles.label} htmlFor="env-create-image">
739
+ Image
740
+ </label>
741
+ <input
742
+ id="env-create-image"
743
+ type="text"
744
+ value={image}
745
+ onChange={(e) => setImage(e.target.value)}
746
+ placeholder="Image (optional)..."
747
+ className={styles.fieldInput}
748
+ data-testid="env-create-image"
749
+ />
750
+ </div>
751
+ <div className={styles.section}>
752
+ <label className={styles.label} htmlFor="env-create-repo">
753
+ Repo
754
+ </label>
755
+ <input
756
+ id="env-create-repo"
757
+ type="text"
758
+ value={repo}
759
+ onChange={(e) => setRepo(e.target.value)}
760
+ placeholder="Repo (optional)..."
761
+ className={styles.fieldInput}
762
+ data-testid="env-create-repo"
763
+ />
764
+ </div>
765
+ </>
766
+ )}
767
+
768
+ {adapterType === "codespace" && (
769
+ <CodespacePicker
770
+ codespaceName={codespaceName}
771
+ onCodespaceNameChange={setCodespaceName}
772
+ envName={envName}
773
+ onEnvNameChange={setEnvName}
774
+ codespaces={codespaces}
775
+ codespaceError={codespaceError}
776
+ codespaceListError={codespaceListError}
777
+ codespaceCreating={codespaceCreating}
778
+ onCreateCodespace={onCreateCodespace}
779
+ />
780
+ )}
781
+ </div>
782
+ </div>
783
+ </div>
784
+ );
785
+ }