@stigmer/react 0.2.0 → 0.2.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 (36) hide show
  1. package/composer/SessionComposer.d.ts.map +1 -1
  2. package/composer/SessionComposer.js +14 -5
  3. package/composer/SessionComposer.js.map +1 -1
  4. package/index.d.ts +2 -2
  5. package/index.d.ts.map +1 -1
  6. package/index.js +1 -1
  7. package/index.js.map +1 -1
  8. package/package.json +4 -4
  9. package/runner/RunnerFileBrowser.d.ts +33 -0
  10. package/runner/RunnerFileBrowser.d.ts.map +1 -0
  11. package/runner/RunnerFileBrowser.js +86 -0
  12. package/runner/RunnerFileBrowser.js.map +1 -0
  13. package/runner/__tests__/useRunnerFileBrowser.test.d.ts +2 -0
  14. package/runner/__tests__/useRunnerFileBrowser.test.d.ts.map +1 -0
  15. package/runner/__tests__/useRunnerFileBrowser.test.js +179 -0
  16. package/runner/__tests__/useRunnerFileBrowser.test.js.map +1 -0
  17. package/runner/index.d.ts +4 -0
  18. package/runner/index.d.ts.map +1 -1
  19. package/runner/index.js +2 -0
  20. package/runner/index.js.map +1 -1
  21. package/runner/useRunnerFileBrowser.d.ts +78 -0
  22. package/runner/useRunnerFileBrowser.d.ts.map +1 -0
  23. package/runner/useRunnerFileBrowser.js +191 -0
  24. package/runner/useRunnerFileBrowser.js.map +1 -0
  25. package/src/composer/SessionComposer.tsx +17 -5
  26. package/src/index.ts +5 -0
  27. package/src/runner/RunnerFileBrowser.tsx +384 -0
  28. package/src/runner/__tests__/useRunnerFileBrowser.test.tsx +256 -0
  29. package/src/runner/index.ts +9 -0
  30. package/src/runner/useRunnerFileBrowser.ts +308 -0
  31. package/src/workspace/WorkspaceEditor.tsx +86 -138
  32. package/styles.css +1 -1
  33. package/workspace/WorkspaceEditor.d.ts +30 -35
  34. package/workspace/WorkspaceEditor.d.ts.map +1 -1
  35. package/workspace/WorkspaceEditor.js +39 -48
  36. package/workspace/WorkspaceEditor.js.map +1 -1
@@ -0,0 +1,308 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useReducer, useRef } from "react";
4
+ import { create } from "@bufbuild/protobuf";
5
+ import {
6
+ RunnerSendCommandInputSchema,
7
+ ListDirectoryRequestSchema,
8
+ type DirectoryEntry,
9
+ } from "@stigmer/protos/ai/stigmer/agentic/runner/v1/io_pb";
10
+ import { useStigmer } from "../hooks";
11
+ import { toError } from "../internal/toError";
12
+
13
+ /** A single segment of the breadcrumb path bar. */
14
+ export interface PathSegment {
15
+ /** Display name (directory name, or "/" for root). */
16
+ readonly name: string;
17
+ /** Absolute path up to and including this segment. */
18
+ readonly path: string;
19
+ }
20
+
21
+ /** Return value of {@link useRunnerFileBrowser}. */
22
+ export interface UseRunnerFileBrowserReturn {
23
+ /** Current resolved absolute path. */
24
+ readonly currentPath: string;
25
+ /** Directory entries for the current path. */
26
+ readonly entries: readonly DirectoryEntry[];
27
+ /** Breadcrumb segments for the current path. */
28
+ readonly segments: readonly PathSegment[];
29
+ /** Runner's home directory (enables Home shortcut). */
30
+ readonly homeDirectory: string;
31
+ /** Runner process's current working directory (enables CWD shortcut). */
32
+ readonly currentDirectory: string;
33
+ /** Whether hidden files are shown. */
34
+ readonly showHidden: boolean;
35
+ /** Toggle hidden file visibility. */
36
+ readonly toggleHidden: () => void;
37
+ /** True while a directory listing is in flight. */
38
+ readonly isLoading: boolean;
39
+ /** Error from the last navigation attempt. */
40
+ readonly error: Error | null;
41
+ /** Navigate into a child directory by name. */
42
+ readonly navigateTo: (name: string) => void;
43
+ /** Navigate to an absolute path. */
44
+ readonly navigateToPath: (path: string) => void;
45
+ /** Navigate to the parent directory. */
46
+ readonly navigateUp: () => void;
47
+ /** Navigate to the runner's home directory. */
48
+ readonly navigateHome: () => void;
49
+ /** Navigate to the runner's current working directory. */
50
+ readonly navigateCwd: () => void;
51
+ /** Retry the last failed navigation. */
52
+ readonly retry: () => void;
53
+ /** True when at the filesystem root (no parent). */
54
+ readonly isAtRoot: boolean;
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Reducer
59
+ // ---------------------------------------------------------------------------
60
+
61
+ interface State {
62
+ currentPath: string;
63
+ entries: readonly DirectoryEntry[];
64
+ homeDirectory: string;
65
+ currentDirectory: string;
66
+ showHidden: boolean;
67
+ isLoading: boolean;
68
+ error: Error | null;
69
+ /** The path we last requested — used for retry. */
70
+ requestedPath: string;
71
+ }
72
+
73
+ type Action =
74
+ | { type: "NAVIGATE"; path: string }
75
+ | { type: "SUCCESS"; resolvedPath: string; entries: DirectoryEntry[]; homeDirectory: string; currentDirectory: string }
76
+ | { type: "FAILURE"; error: Error }
77
+ | { type: "TOGGLE_HIDDEN" };
78
+
79
+ function reducer(state: State, action: Action): State {
80
+ switch (action.type) {
81
+ case "NAVIGATE":
82
+ return {
83
+ ...state,
84
+ requestedPath: action.path,
85
+ isLoading: true,
86
+ error: null,
87
+ };
88
+ case "SUCCESS":
89
+ return {
90
+ ...state,
91
+ currentPath: action.resolvedPath,
92
+ entries: action.entries,
93
+ homeDirectory: action.homeDirectory || state.homeDirectory,
94
+ currentDirectory: action.currentDirectory || state.currentDirectory,
95
+ isLoading: false,
96
+ error: null,
97
+ };
98
+ case "FAILURE":
99
+ return { ...state, isLoading: false, error: action.error };
100
+ case "TOGGLE_HIDDEN":
101
+ return { ...state, showHidden: !state.showHidden };
102
+ }
103
+ }
104
+
105
+ const INITIAL_STATE: State = {
106
+ currentPath: "",
107
+ entries: [],
108
+ homeDirectory: "",
109
+ currentDirectory: "",
110
+ showHidden: false,
111
+ isLoading: false,
112
+ error: null,
113
+ requestedPath: "",
114
+ };
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Path utilities
118
+ // ---------------------------------------------------------------------------
119
+
120
+ function buildSegments(path: string): PathSegment[] {
121
+ if (!path) return [];
122
+
123
+ const segments: PathSegment[] = [{ name: "/", path: "/" }];
124
+ const parts = path.split("/").filter(Boolean);
125
+ let accumulated = "";
126
+
127
+ for (const part of parts) {
128
+ accumulated += `/${part}`;
129
+ segments.push({ name: part, path: accumulated });
130
+ }
131
+
132
+ return segments;
133
+ }
134
+
135
+ function parentPath(path: string): string {
136
+ if (!path || path === "/") return "/";
137
+ const idx = path.lastIndexOf("/");
138
+ return idx <= 0 ? "/" : path.slice(0, idx);
139
+ }
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // Hook
143
+ // ---------------------------------------------------------------------------
144
+
145
+ /**
146
+ * Behavior hook that drives a filesystem browser against a connected runner.
147
+ *
148
+ * Sends `ListDirectory` commands via the runner's bidi stream (through the
149
+ * `sendCommand` unary RPC) and manages navigation state, breadcrumbs,
150
+ * loading/error handling, and hidden file filtering.
151
+ *
152
+ * Designed for composition with {@link RunnerFileBrowser} but usable
153
+ * standalone by platform builders who want custom rendering.
154
+ *
155
+ * @param runnerId - ID of the runner to browse. When `null`, the hook
156
+ * is inert (no requests are made, entries are empty).
157
+ *
158
+ * @example
159
+ * ```tsx
160
+ * function MyFilePicker({ runnerId }: { runnerId: string }) {
161
+ * const browser = useRunnerFileBrowser(runnerId);
162
+ *
163
+ * return (
164
+ * <div>
165
+ * <p>Path: {browser.currentPath}</p>
166
+ * {browser.entries
167
+ * .filter(e => e.isDirectory)
168
+ * .map(e => (
169
+ * <button key={e.name} onClick={() => browser.navigateTo(e.name)}>
170
+ * {e.name}
171
+ * </button>
172
+ * ))}
173
+ * </div>
174
+ * );
175
+ * }
176
+ * ```
177
+ */
178
+ export function useRunnerFileBrowser(
179
+ runnerId: string | null,
180
+ ): UseRunnerFileBrowserReturn {
181
+ const stigmer = useStigmer();
182
+ const [state, dispatch] = useReducer(reducer, INITIAL_STATE);
183
+ const requestIdRef = useRef(0);
184
+
185
+ const fetchDirectory = useCallback(
186
+ async (path: string) => {
187
+ if (!runnerId) return;
188
+
189
+ const id = ++requestIdRef.current;
190
+ dispatch({ type: "NAVIGATE", path });
191
+
192
+ try {
193
+ const response = await stigmer.runner.sendCommand(
194
+ create(RunnerSendCommandInputSchema, {
195
+ runnerId,
196
+ command: {
197
+ case: "listDirectory",
198
+ value: create(ListDirectoryRequestSchema, { path }),
199
+ },
200
+ }),
201
+ );
202
+
203
+ // Stale response guard — a newer navigation has started.
204
+ if (id !== requestIdRef.current) return;
205
+
206
+ if (response.result.case === "error") {
207
+ dispatch({
208
+ type: "FAILURE",
209
+ error: new Error(response.result.value.message),
210
+ });
211
+ return;
212
+ }
213
+
214
+ if (response.result.case === "listDirectory") {
215
+ const listing = response.result.value;
216
+ dispatch({
217
+ type: "SUCCESS",
218
+ resolvedPath: listing.resolvedPath,
219
+ entries: listing.entries,
220
+ homeDirectory: listing.homeDirectory,
221
+ currentDirectory: listing.currentDirectory,
222
+ });
223
+ }
224
+ } catch (err) {
225
+ if (id !== requestIdRef.current) return;
226
+ dispatch({ type: "FAILURE", error: toError(err) });
227
+ }
228
+ },
229
+ [runnerId, stigmer],
230
+ );
231
+
232
+ // Initial load: fetch home directory when runnerId becomes available.
233
+ const initializedForRef = useRef<string | null>(null);
234
+
235
+ useEffect(() => {
236
+ if (!runnerId) {
237
+ initializedForRef.current = null;
238
+ return;
239
+ }
240
+
241
+ if (initializedForRef.current !== runnerId) {
242
+ initializedForRef.current = runnerId;
243
+ fetchDirectory("");
244
+ }
245
+ }, [runnerId, fetchDirectory]);
246
+
247
+ const navigateTo = useCallback(
248
+ (name: string) => {
249
+ const next =
250
+ state.currentPath === "/"
251
+ ? `/${name}`
252
+ : `${state.currentPath}/${name}`;
253
+ fetchDirectory(next);
254
+ },
255
+ [state.currentPath, fetchDirectory],
256
+ );
257
+
258
+ const navigateToPath = useCallback(
259
+ (path: string) => fetchDirectory(path),
260
+ [fetchDirectory],
261
+ );
262
+
263
+ const navigateUp = useCallback(
264
+ () => fetchDirectory(parentPath(state.currentPath)),
265
+ [state.currentPath, fetchDirectory],
266
+ );
267
+
268
+ const navigateHome = useCallback(
269
+ () => fetchDirectory(state.homeDirectory || "~"),
270
+ [state.homeDirectory, fetchDirectory],
271
+ );
272
+
273
+ const navigateCwd = useCallback(
274
+ () => {
275
+ if (state.currentDirectory) fetchDirectory(state.currentDirectory);
276
+ },
277
+ [state.currentDirectory, fetchDirectory],
278
+ );
279
+
280
+ const retry = useCallback(
281
+ () => fetchDirectory(state.requestedPath),
282
+ [state.requestedPath, fetchDirectory],
283
+ );
284
+
285
+ const toggleHidden = useCallback(() => dispatch({ type: "TOGGLE_HIDDEN" }), []);
286
+
287
+ const segments = buildSegments(state.currentPath);
288
+ const isAtRoot = state.currentPath === "/";
289
+
290
+ return {
291
+ currentPath: state.currentPath,
292
+ entries: state.entries,
293
+ segments,
294
+ homeDirectory: state.homeDirectory,
295
+ currentDirectory: state.currentDirectory,
296
+ showHidden: state.showHidden,
297
+ toggleHidden,
298
+ isLoading: state.isLoading,
299
+ error: state.error,
300
+ navigateTo,
301
+ navigateToPath,
302
+ navigateUp,
303
+ navigateHome,
304
+ navigateCwd,
305
+ retry,
306
+ isAtRoot,
307
+ };
308
+ }
@@ -4,6 +4,7 @@ import { useState, useCallback, type KeyboardEvent } from "react";
4
4
  import type { UseWorkspaceEntriesReturn } from "./useWorkspaceEntries";
5
5
  import type { UseGitHubConnectionReturn } from "../github/useGitHubConnection";
6
6
  import { GitHubRepoPicker } from "../github/GitHubRepoPicker";
7
+ import { RunnerFileBrowser } from "../runner/RunnerFileBrowser";
7
8
  import { useScrollShadows } from "../internal/useScrollShadows";
8
9
  import { ScrollFade } from "../internal/ScrollFade";
9
10
 
@@ -17,48 +18,42 @@ export interface WorkspaceEditorProps {
17
18
  readonly disabled?: boolean;
18
19
  /** GitHub connection state. When provided, enables the GitHub repo picker. */
19
20
  readonly gitHubConnection?: UseGitHubConnectionReturn;
20
- /** Show the GitHub Repo source button. Default: true. */
21
+ /** Show the GitHub Repo tab. Default: true. */
21
22
  readonly enableGitHub?: boolean;
22
- /** Show the Local Folder source button. Default: false (set by Console based on deployment mode). */
23
- readonly enableLocal?: boolean;
24
23
  /**
25
- * Native folder picker callback for desktop environments.
24
+ * Show the Local Folder tab. Default: false.
26
25
  *
27
- * When provided and `enableLocal` is `true`, clicking the "Local Folder"
28
- * button invokes this callback instead of showing the manual path input.
29
- * Should resolve to an absolute folder path, or `null` if the user
30
- * cancelled the dialog.
31
- *
32
- * When not provided, falls back to the inline text input for manual
33
- * path entry (backward compatible with web environments).
26
+ * The tab is only rendered when `runnerId` is also provided, since the
27
+ * file browser requires a connected runner to query.
28
+ */
29
+ readonly enableLocal?: boolean;
30
+ /**
31
+ * ID of the runner to use for filesystem browsing.
34
32
  *
35
- * @example
36
- * ```tsx
37
- * // Tauri desktop integration
38
- * const browseFolder = useCallback(async () => {
39
- * const { open } = await import("@tauri-apps/plugin-dialog");
40
- * return open({ directory: true }) as Promise<string | null>;
41
- * }, []);
33
+ * When provided together with `enableLocal`, the Local Folder tab
34
+ * renders a {@link RunnerFileBrowser} that queries the runner's
35
+ * filesystem via the `ListDirectory` command.
36
+ */
37
+ readonly runnerId?: string | null;
38
+ /**
39
+ * Native folder picker callback for desktop environments.
42
40
  *
43
- * <WorkspaceEditor
44
- * workspace={workspace}
45
- * enableLocal
46
- * onBrowseLocalFolder={browseFolder}
47
- * />
48
- * ```
41
+ * @deprecated Prefer passing `runnerId` to enable the integrated
42
+ * {@link RunnerFileBrowser} which provides a consistent experience
43
+ * across web and desktop.
49
44
  */
50
45
  readonly onBrowseLocalFolder?: () => Promise<string | null>;
51
46
  /**
52
47
  * Display name of the currently selected runner.
53
48
  *
54
- * When provided (i.e. a specific runner is selected instead of "Auto"),
55
- * a contextual hint is shown above the local folder input indicating
56
- * that local paths are relative to this runner's filesystem.
49
+ * When provided, a contextual hint is shown above the manual local
50
+ * path input (fallback when `runnerId` is not set) indicating that
51
+ * paths are relative to this runner's filesystem.
57
52
  */
58
53
  readonly runnerName?: string;
59
54
  }
60
55
 
61
- type ActivePanel = "none" | "github" | "local";
56
+ type ActiveTab = "local" | "github";
62
57
 
63
58
  const TYPE_LABELS: Record<string, string> = {
64
59
  git: "GitHub",
@@ -68,13 +63,13 @@ const TYPE_LABELS: Record<string, string> = {
68
63
  /**
69
64
  * Styled component that renders add/remove UI for workspace entries.
70
65
  *
71
- * Redesigned with two source buttons (GitHub Repo, Local Folder) that
72
- * open inline popovers instead of the old tab-based form.
66
+ * Uses a tabbed layout when multiple workspace sources are available:
67
+ * - **Local Folder** (default when a runner is connected): Shows an
68
+ * interactive file browser via {@link RunnerFileBrowser}.
69
+ * - **GitHub Repo**: Shows the repo picker or connect prompt.
73
70
  *
74
- * When `gitHubConnection` is provided and the user is connected, the
75
- * GitHub button opens a repo picker. When not connected, it shows a
76
- * connect prompt. When `gitHubConnection` is not provided, falls back
77
- * to the manual URL input.
71
+ * When only one source is available, the tab bar is hidden and the
72
+ * content renders directly.
78
73
  *
79
74
  * All visual properties flow through `--stgm-*` tokens.
80
75
  *
@@ -89,7 +84,8 @@ const TYPE_LABELS: Record<string, string> = {
89
84
  * workspace={workspace}
90
85
  * gitHubConnection={gh}
91
86
  * enableGitHub
92
- * enableLocal={false}
87
+ * enableLocal
88
+ * runnerId={browseRunnerId}
93
89
  * />
94
90
  * );
95
91
  * }
@@ -102,22 +98,25 @@ export function WorkspaceEditor({
102
98
  gitHubConnection,
103
99
  enableGitHub = true,
104
100
  enableLocal = false,
101
+ runnerId,
105
102
  onBrowseLocalFolder,
106
103
  runnerName,
107
104
  }: WorkspaceEditorProps) {
108
- const [activePanel, setActivePanel] = useState<ActivePanel>(
109
- enableGitHub ? "github" : "none",
105
+ const hasLocal = enableLocal && !!runnerId;
106
+ const hasGitHub = enableGitHub;
107
+ const hasBothTabs = hasLocal && hasGitHub;
108
+
109
+ const [activeTab, setActiveTab] = useState<ActiveTab>(
110
+ hasLocal ? "local" : "github",
110
111
  );
112
+
111
113
  const [manualUrl, setManualUrl] = useState("");
112
114
  const [manualBranch, setManualBranch] = useState("");
113
115
  const entryList = useScrollShadows();
114
- const [localPath, setLocalPath] = useState("");
115
- const [isBrowsing, setIsBrowsing] = useState(false);
116
116
 
117
117
  const handleGitHubSelect = useCallback(
118
118
  (repoUrl: string, branch: string) => {
119
119
  workspace.addGitRepo(repoUrl, branch);
120
- setActivePanel("none");
121
120
  },
122
121
  [workspace],
123
122
  );
@@ -127,31 +126,9 @@ export function WorkspaceEditor({
127
126
  workspace.addGitRepo(manualUrl.trim(), manualBranch.trim() || undefined);
128
127
  setManualUrl("");
129
128
  setManualBranch("");
130
- setActivePanel("none");
131
129
  }
132
130
  }, [manualUrl, manualBranch, workspace]);
133
131
 
134
- const handleLocalAdd = useCallback(() => {
135
- if (localPath.trim()) {
136
- workspace.addLocalPath(localPath.trim());
137
- setLocalPath("");
138
- setActivePanel("none");
139
- }
140
- }, [localPath, workspace]);
141
-
142
- const handleBrowseLocalFolder = useCallback(async () => {
143
- if (!onBrowseLocalFolder || isBrowsing) return;
144
- setIsBrowsing(true);
145
- try {
146
- const path = await onBrowseLocalFolder();
147
- if (path) {
148
- workspace.addLocalPath(path);
149
- }
150
- } finally {
151
- setIsBrowsing(false);
152
- }
153
- }, [onBrowseLocalFolder, isBrowsing, workspace]);
154
-
155
132
  const handleKeyDown = useCallback(
156
133
  (handler: () => void) => (e: KeyboardEvent<HTMLInputElement>) => {
157
134
  if (e.key === "Enter") {
@@ -162,12 +139,11 @@ export function WorkspaceEditor({
162
139
  [],
163
140
  );
164
141
 
165
- const togglePanel = useCallback(
166
- (panel: ActivePanel) => {
167
- setActivePanel((prev) => (prev === panel ? "none" : panel));
168
- },
169
- [],
170
- );
142
+ const effectiveTab: ActiveTab = hasLocal && activeTab === "local"
143
+ ? "local"
144
+ : hasGitHub
145
+ ? "github"
146
+ : "local";
171
147
 
172
148
  return (
173
149
  <div className={["space-y-2", className].filter(Boolean).join(" ")}>
@@ -211,51 +187,60 @@ export function WorkspaceEditor({
211
187
  </div>
212
188
  )}
213
189
 
214
- {/* Source buttons */}
215
- <div className="flex items-center gap-2">
216
- {enableGitHub && (
190
+ {/* Tab bar (only when both sources are available) */}
191
+ {hasBothTabs && (
192
+ <div className="flex rounded-md border border-border bg-muted-faint p-0.5">
217
193
  <button
218
194
  type="button"
219
- onClick={() => togglePanel("github")}
195
+ onClick={() => setActiveTab("local")}
220
196
  disabled={disabled}
221
197
  className={[
222
- "flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs transition-colors disabled:pointer-events-none disabled:opacity-50",
223
- activePanel === "github"
224
- ? "bg-accent text-foreground"
225
- : "text-muted-foreground hover:text-foreground hover:bg-accent-hover",
198
+ "flex flex-1 items-center justify-center gap-1.5 rounded px-2 py-1 text-[0.65rem] font-medium transition-colors disabled:pointer-events-none disabled:opacity-50",
199
+ effectiveTab === "local"
200
+ ? "bg-background text-foreground shadow-sm"
201
+ : "text-muted-foreground hover:text-foreground",
226
202
  ].join(" ")}
227
203
  >
228
- <GitHubIcon />
229
- <span>GitHub Repo</span>
204
+ <FolderIcon />
205
+ Local Folder
230
206
  </button>
231
- )}
232
-
233
- {enableLocal && (
234
207
  <button
235
208
  type="button"
236
- onClick={onBrowseLocalFolder ? handleBrowseLocalFolder : () => togglePanel("local")}
237
- disabled={disabled || isBrowsing}
209
+ onClick={() => setActiveTab("github")}
210
+ disabled={disabled}
238
211
  className={[
239
- "flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs transition-colors disabled:pointer-events-none disabled:opacity-50",
240
- activePanel === "local"
241
- ? "bg-accent text-foreground"
242
- : "text-muted-foreground hover:text-foreground hover:bg-accent-hover",
212
+ "flex flex-1 items-center justify-center gap-1.5 rounded px-2 py-1 text-[0.65rem] font-medium transition-colors disabled:pointer-events-none disabled:opacity-50",
213
+ effectiveTab === "github"
214
+ ? "bg-background text-foreground shadow-sm"
215
+ : "text-muted-foreground hover:text-foreground",
243
216
  ].join(" ")}
244
217
  >
245
- <FolderIcon />
246
- <span>{isBrowsing ? "Opening\u2026" : "Local Folder"}</span>
218
+ <GitHubIcon />
219
+ GitHub Repo
247
220
  </button>
221
+ </div>
222
+ )}
223
+
224
+ {/* Tab content */}
225
+ <div className="rounded-md border border-border bg-card p-3">
226
+ {effectiveTab === "local" && hasLocal && (
227
+ <RunnerFileBrowser
228
+ runnerId={runnerId!}
229
+ onSelect={(path) => workspace.addLocalPath(path)}
230
+ onCancel={() => {
231
+ if (hasGitHub) setActiveTab("github");
232
+ }}
233
+ />
248
234
  )}
249
- </div>
250
235
 
251
- {/* GitHub panel */}
252
- {activePanel === "github" && (
253
- <div className="rounded-md border border-border bg-card p-3">
254
- {gitHubConnection ? (
236
+ {effectiveTab === "github" && hasGitHub && (
237
+ gitHubConnection ? (
255
238
  <GitHubPanel
256
239
  connection={gitHubConnection}
257
240
  onSelect={handleGitHubSelect}
258
- onClose={() => setActivePanel("none")}
241
+ onClose={() => {
242
+ if (hasLocal) setActiveTab("local");
243
+ }}
259
244
  />
260
245
  ) : (
261
246
  <ManualGitPanel
@@ -264,51 +249,14 @@ export function WorkspaceEditor({
264
249
  onUrlChange={setManualUrl}
265
250
  onBranchChange={setManualBranch}
266
251
  onAdd={handleManualAdd}
267
- onCancel={() => setActivePanel("none")}
252
+ onCancel={() => {
253
+ if (hasLocal) setActiveTab("local");
254
+ }}
268
255
  onKeyDown={handleKeyDown(handleManualAdd)}
269
256
  />
270
- )}
271
- </div>
272
- )}
273
-
274
- {/* Local folder panel */}
275
- {activePanel === "local" && (
276
- <div className="rounded-md border border-border bg-card p-3">
277
- <div className="space-y-2">
278
- {runnerName && (
279
- <p className="text-[0.65rem] text-muted-foreground">
280
- Paths relative to <span className="font-medium text-foreground">{runnerName}</span>
281
- </p>
282
- )}
283
- <input
284
- type="text"
285
- placeholder="/path/to/project"
286
- value={localPath}
287
- onChange={(e) => setLocalPath(e.target.value)}
288
- onKeyDown={handleKeyDown(handleLocalAdd)}
289
- className="w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-xs text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
290
- autoFocus
291
- />
292
- <div className="flex justify-end gap-2">
293
- <button
294
- type="button"
295
- onClick={() => setActivePanel("none")}
296
- className="rounded-md px-2.5 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
297
- >
298
- Cancel
299
- </button>
300
- <button
301
- type="button"
302
- onClick={handleLocalAdd}
303
- disabled={!localPath.trim()}
304
- className="rounded-md bg-primary px-2.5 py-1 text-xs text-primary-foreground hover:bg-primary-hover transition-colors disabled:opacity-40"
305
- >
306
- Add
307
- </button>
308
- </div>
309
- </div>
310
- </div>
311
- )}
257
+ )
258
+ )}
259
+ </div>
312
260
  </div>
313
261
  );
314
262
  }