@stigmer/react 0.2.2 → 0.2.3

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 (70) hide show
  1. package/composer/SessionComposer.d.ts.map +1 -1
  2. package/composer/SessionComposer.js +22 -32
  3. package/composer/SessionComposer.js.map +1 -1
  4. package/github/index.d.ts +1 -1
  5. package/github/index.d.ts.map +1 -1
  6. package/github/index.js.map +1 -1
  7. package/github/useGitHubConnection.d.ts +70 -1
  8. package/github/useGitHubConnection.d.ts.map +1 -1
  9. package/github/useGitHubConnection.js +99 -20
  10. package/github/useGitHubConnection.js.map +1 -1
  11. package/identity-provider/IdentityProviderWizard.d.ts.map +1 -1
  12. package/identity-provider/IdentityProviderWizard.js +19 -3
  13. package/identity-provider/IdentityProviderWizard.js.map +1 -1
  14. package/index.d.ts +1 -1
  15. package/index.d.ts.map +1 -1
  16. package/index.js.map +1 -1
  17. package/organization/OrgProfilePanel.d.ts.map +1 -1
  18. package/organization/OrgProfilePanel.js +23 -2
  19. package/organization/OrgProfilePanel.js.map +1 -1
  20. package/package.json +4 -4
  21. package/runner/RunnerFileBrowser.d.ts +11 -1
  22. package/runner/RunnerFileBrowser.d.ts.map +1 -1
  23. package/runner/RunnerFileBrowser.js +70 -7
  24. package/runner/RunnerFileBrowser.js.map +1 -1
  25. package/runner/WorkspaceRunnerSelector.d.ts +36 -0
  26. package/runner/WorkspaceRunnerSelector.d.ts.map +1 -0
  27. package/runner/WorkspaceRunnerSelector.js +63 -0
  28. package/runner/WorkspaceRunnerSelector.js.map +1 -0
  29. package/runner/index.d.ts +2 -0
  30. package/runner/index.d.ts.map +1 -1
  31. package/runner/index.js +1 -0
  32. package/runner/index.js.map +1 -1
  33. package/runner/useRunnerFileBrowser.d.ts.map +1 -1
  34. package/runner/useRunnerFileBrowser.js +26 -2
  35. package/runner/useRunnerFileBrowser.js.map +1 -1
  36. package/settings/MembersSection.d.ts.map +1 -1
  37. package/settings/MembersSection.js +7 -2
  38. package/settings/MembersSection.js.map +1 -1
  39. package/src/composer/SessionComposer.tsx +46 -43
  40. package/src/github/index.ts +1 -0
  41. package/src/github/useGitHubConnection.ts +162 -22
  42. package/src/identity-provider/IdentityProviderWizard.tsx +112 -3
  43. package/src/index.ts +1 -0
  44. package/src/organization/OrgProfilePanel.tsx +98 -0
  45. package/src/runner/RunnerFileBrowser.tsx +227 -8
  46. package/src/runner/WorkspaceRunnerSelector.tsx +180 -0
  47. package/src/runner/index.ts +3 -0
  48. package/src/runner/useRunnerFileBrowser.ts +39 -3
  49. package/src/settings/MembersSection.tsx +23 -1
  50. package/src/workspace/WorkspaceEditor.tsx +176 -126
  51. package/src/workspace/index.ts +5 -0
  52. package/src/workspace/useRecentWorkspaces.ts +162 -0
  53. package/src/workspace/useWorkspaceEntries.ts +13 -0
  54. package/styles.css +1 -1
  55. package/workspace/WorkspaceEditor.d.ts +25 -22
  56. package/workspace/WorkspaceEditor.d.ts.map +1 -1
  57. package/workspace/WorkspaceEditor.js +64 -43
  58. package/workspace/WorkspaceEditor.js.map +1 -1
  59. package/workspace/index.d.ts +2 -0
  60. package/workspace/index.d.ts.map +1 -1
  61. package/workspace/index.js +1 -0
  62. package/workspace/index.js.map +1 -1
  63. package/workspace/useRecentWorkspaces.d.ts +31 -0
  64. package/workspace/useRecentWorkspaces.d.ts.map +1 -0
  65. package/workspace/useRecentWorkspaces.js +117 -0
  66. package/workspace/useRecentWorkspaces.js.map +1 -0
  67. package/workspace/useWorkspaceEntries.d.ts +8 -0
  68. package/workspace/useWorkspaceEntries.d.ts.map +1 -1
  69. package/workspace/useWorkspaceEntries.js +4 -0
  70. package/workspace/useWorkspaceEntries.js.map +1 -1
@@ -18,58 +18,60 @@ export interface WorkspaceEditorProps {
18
18
  readonly disabled?: boolean;
19
19
  /** GitHub connection state. When provided, enables the GitHub repo picker. */
20
20
  readonly gitHubConnection?: UseGitHubConnectionReturn;
21
- /** Show the GitHub Repo tab. Default: true. */
21
+ /** Enable the "Connect GitHub" action. Default: true. */
22
22
  readonly enableGitHub?: boolean;
23
23
  /**
24
- * Show the Local Folder tab. Default: false.
24
+ * Enable the "Browse Folder" action.
25
25
  *
26
- * The tab is only rendered when `runnerId` is also provided, since the
27
- * file browser requires a connected runner to query.
26
+ * The action is only functional when `runnerId` is also provided,
27
+ * since the file browser requires a connected runner to query.
28
+ * When `runnerId` is null (Auto selected), the action is disabled.
28
29
  */
29
30
  readonly enableLocal?: boolean;
30
31
  /**
31
32
  * ID of the runner to use for filesystem browsing.
32
33
  *
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.
34
+ * When provided together with `enableLocal`, the "Browse Folder"
35
+ * action drills into a {@link RunnerFileBrowser} that queries the
36
+ * runner's filesystem via the `ListDirectory` command.
36
37
  */
37
38
  readonly runnerId?: string | null;
38
39
  /**
39
40
  * Native folder picker callback for desktop environments.
40
41
  *
41
- * @deprecated Prefer passing `runnerId` to enable the integrated
42
- * {@link RunnerFileBrowser} which provides a consistent experience
43
- * across web and desktop.
42
+ * When provided alongside `runnerId`, renders an "Open system dialog"
43
+ * button in the Browse Folder drill-in view. Desktop-only enhancement.
44
44
  */
45
45
  readonly onBrowseLocalFolder?: () => Promise<string | null>;
46
46
  /**
47
47
  * Display name of the currently selected runner.
48
- *
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.
48
+ * Passed through to {@link RunnerFileBrowser} for the context header.
52
49
  */
53
50
  readonly runnerName?: string;
51
+ /**
52
+ * Hostname of the runner's machine (e.g. "Alice's MacBook Pro").
53
+ * Passed through to {@link RunnerFileBrowser} for the context header.
54
+ */
55
+ readonly runnerHostname?: string;
54
56
  }
55
57
 
56
- type ActiveTab = "local" | "github";
58
+ type ActivePanel = "browse" | "github" | null;
57
59
 
58
60
  const TYPE_LABELS: Record<string, string> = {
59
61
  git: "GitHub",
60
- local: "Local",
61
62
  };
62
63
 
63
64
  /**
64
- * Styled component that renders add/remove UI for workspace entries.
65
+ * Styled component for managing workspace entries with a flat-list
66
+ * layout and drill-in sub-views.
65
67
  *
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.
68
+ * The default view shows:
69
+ * 1. Current workspace entries (with remove buttons)
70
+ * 2. Action items: "Browse Folder" and "Connect GitHub"
70
71
  *
71
- * When only one source is available, the tab bar is hidden and the
72
- * content renders directly.
72
+ * Each action drills into a sub-view (file browser or GitHub picker)
73
+ * with a back button to return to the list. This follows the same
74
+ * progressive-disclosure pattern as the Configure menu.
73
75
  *
74
76
  * All visual properties flow through `--stgm-*` tokens.
75
77
  *
@@ -101,19 +103,15 @@ export function WorkspaceEditor({
101
103
  runnerId,
102
104
  onBrowseLocalFolder,
103
105
  runnerName,
106
+ runnerHostname,
104
107
  }: WorkspaceEditorProps) {
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",
111
- );
112
-
108
+ const [activePanel, setActivePanel] = useState<ActivePanel>(null);
113
109
  const [manualUrl, setManualUrl] = useState("");
114
110
  const [manualBranch, setManualBranch] = useState("");
115
111
  const entryList = useScrollShadows();
116
112
 
113
+ const canBrowse = enableLocal && !!runnerId;
114
+
117
115
  const handleGitHubSelect = useCallback(
118
116
  (repoUrl: string, branch: string) => {
119
117
  workspace.addGitRepo(repoUrl, branch);
@@ -139,11 +137,87 @@ export function WorkspaceEditor({
139
137
  [],
140
138
  );
141
139
 
142
- const effectiveTab: ActiveTab = hasLocal && activeTab === "local"
143
- ? "local"
144
- : hasGitHub
145
- ? "github"
146
- : "local";
140
+ const goBack = useCallback(() => setActivePanel(null), []);
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // Drill-in: Browse Folder
144
+ // ---------------------------------------------------------------------------
145
+
146
+ if (activePanel === "browse" && canBrowse) {
147
+ return (
148
+ <div className={["space-y-2", className].filter(Boolean).join(" ")}>
149
+ <button
150
+ type="button"
151
+ onClick={goBack}
152
+ className="inline-flex items-center gap-1 text-[0.65rem] text-muted-foreground hover:text-foreground transition-colors"
153
+ >
154
+ <ChevronLeftIcon />
155
+ Back
156
+ </button>
157
+ <div className="space-y-2">
158
+ <RunnerFileBrowser
159
+ runnerId={runnerId!}
160
+ onSelect={(path) => {
161
+ workspace.addLocalPath(path);
162
+ setActivePanel(null);
163
+ }}
164
+ onCancel={goBack}
165
+ runnerName={runnerName}
166
+ runnerHostname={runnerHostname}
167
+ />
168
+ </div>
169
+ </div>
170
+ );
171
+ }
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // Drill-in: Connect GitHub
175
+ // ---------------------------------------------------------------------------
176
+
177
+ if (activePanel === "github" && enableGitHub) {
178
+ return (
179
+ <div className={["space-y-2", className].filter(Boolean).join(" ")}>
180
+ <button
181
+ type="button"
182
+ onClick={goBack}
183
+ className="inline-flex items-center gap-1 text-[0.65rem] text-muted-foreground hover:text-foreground transition-colors"
184
+ >
185
+ <ChevronLeftIcon />
186
+ Back
187
+ </button>
188
+ {gitHubConnection ? (
189
+ <GitHubPanel
190
+ connection={gitHubConnection}
191
+ onSelect={(url, branch) => {
192
+ handleGitHubSelect(url, branch);
193
+ setActivePanel(null);
194
+ }}
195
+ onClose={goBack}
196
+ />
197
+ ) : (
198
+ <ManualGitPanel
199
+ url={manualUrl}
200
+ branch={manualBranch}
201
+ onUrlChange={setManualUrl}
202
+ onBranchChange={setManualBranch}
203
+ onAdd={() => {
204
+ handleManualAdd();
205
+ setActivePanel(null);
206
+ }}
207
+ onCancel={goBack}
208
+ onKeyDown={handleKeyDown(() => {
209
+ handleManualAdd();
210
+ setActivePanel(null);
211
+ })}
212
+ />
213
+ )}
214
+ </div>
215
+ );
216
+ }
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // Default view: flat list (entries + actions)
220
+ // ---------------------------------------------------------------------------
147
221
 
148
222
  return (
149
223
  <div className={["space-y-2", className].filter(Boolean).join(" ")}>
@@ -152,15 +226,21 @@ export function WorkspaceEditor({
152
226
  <div className="relative">
153
227
  {entryList.canScrollUp && <ScrollFade position="top" />}
154
228
 
155
- <div ref={entryList.scrollRef} className="max-h-28 space-y-2 overflow-y-auto">
229
+ <div ref={entryList.scrollRef} className="max-h-28 space-y-1 overflow-y-auto">
156
230
  {workspace.entries.map((entry) => (
157
231
  <div
158
232
  key={entry.id}
159
233
  className="flex items-center gap-2 rounded-md border border-border bg-muted-faint px-2.5 py-1.5 text-xs"
160
234
  >
161
- <span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[0.65rem] text-muted-foreground">
162
- {TYPE_LABELS[entry.type] ?? entry.type}
163
- </span>
235
+ {TYPE_LABELS[entry.type] ? (
236
+ <span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[0.65rem] text-muted-foreground">
237
+ {TYPE_LABELS[entry.type]}
238
+ </span>
239
+ ) : (
240
+ <span className="shrink-0 text-muted-foreground">
241
+ <FolderIcon />
242
+ </span>
243
+ )}
164
244
  <span
165
245
  className={[
166
246
  "min-w-0 flex-1 truncate text-foreground",
@@ -187,81 +267,48 @@ export function WorkspaceEditor({
187
267
  </div>
188
268
  )}
189
269
 
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">
270
+ {/* Action items */}
271
+ <div className="space-y-0.5">
272
+ {enableLocal && (
193
273
  <button
194
274
  type="button"
195
- onClick={() => setActiveTab("local")}
196
- disabled={disabled}
197
- className={[
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",
202
- ].join(" ")}
275
+ onClick={
276
+ onBrowseLocalFolder
277
+ ? async () => {
278
+ const path = await onBrowseLocalFolder();
279
+ if (path) workspace.addLocalPath(path);
280
+ }
281
+ : () => setActivePanel("browse")
282
+ }
283
+ disabled={disabled || !runnerId}
284
+ className="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-xs text-foreground transition-colors hover:bg-accent-hover disabled:pointer-events-none disabled:opacity-40"
203
285
  >
204
286
  <FolderIcon />
205
- Local Folder
287
+ <span className="flex-1 text-left">Browse Folder</span>
288
+ {!onBrowseLocalFolder && <ChevronRightIcon />}
206
289
  </button>
290
+ )}
291
+ {enableGitHub && (
207
292
  <button
208
293
  type="button"
209
- onClick={() => setActiveTab("github")}
294
+ onClick={() => setActivePanel("github")}
210
295
  disabled={disabled}
211
- className={[
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",
216
- ].join(" ")}
296
+ className="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-xs text-foreground transition-colors hover:bg-accent-hover disabled:pointer-events-none disabled:opacity-40"
217
297
  >
218
298
  <GitHubIcon />
219
- GitHub Repo
299
+ <span className="flex-1 text-left">Connect GitHub</span>
300
+ <ChevronRightIcon />
220
301
  </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
- />
234
- )}
235
-
236
- {effectiveTab === "github" && hasGitHub && (
237
- gitHubConnection ? (
238
- <GitHubPanel
239
- connection={gitHubConnection}
240
- onSelect={handleGitHubSelect}
241
- onClose={() => {
242
- if (hasLocal) setActiveTab("local");
243
- }}
244
- />
245
- ) : (
246
- <ManualGitPanel
247
- url={manualUrl}
248
- branch={manualBranch}
249
- onUrlChange={setManualUrl}
250
- onBranchChange={setManualBranch}
251
- onAdd={handleManualAdd}
252
- onCancel={() => {
253
- if (hasLocal) setActiveTab("local");
254
- }}
255
- onKeyDown={handleKeyDown(handleManualAdd)}
256
- />
257
- )
258
302
  )}
259
303
  </div>
260
304
  </div>
261
305
  );
262
306
  }
263
307
 
264
- /** GitHub panel with progressive disclosure: connect prompt or repo picker. */
308
+ // ---------------------------------------------------------------------------
309
+ // GitHub panel (progressive disclosure: connect prompt or repo picker)
310
+ // ---------------------------------------------------------------------------
311
+
265
312
  function GitHubPanel({
266
313
  connection,
267
314
  onSelect,
@@ -295,16 +342,6 @@ function GitHubPanel({
295
342
 
296
343
  return (
297
344
  <div className="space-y-3 text-center">
298
- <div className="flex justify-end">
299
- <button
300
- type="button"
301
- onClick={onClose}
302
- className="rounded p-0.5 text-muted-foreground hover:text-foreground transition-colors"
303
- aria-label="Close"
304
- >
305
- <XIcon />
306
- </button>
307
- </div>
308
345
  <div className="space-y-1">
309
346
  <p className="text-xs font-medium text-foreground">
310
347
  Choose a GitHub repo to add to workspace
@@ -365,23 +402,13 @@ function GitHubPanel({
365
402
  {connection.user?.login ?? "Connected"}
366
403
  </span>
367
404
  </div>
368
- <div className="flex items-center gap-2">
369
- <button
370
- type="button"
371
- onClick={connection.disconnect}
372
- className="text-[0.6rem] text-muted-foreground hover:text-destructive transition-colors"
373
- >
374
- Disconnect
375
- </button>
376
- <button
377
- type="button"
378
- onClick={onClose}
379
- className="rounded p-0.5 text-muted-foreground hover:text-foreground transition-colors"
380
- aria-label="Close"
381
- >
382
- <XIcon />
383
- </button>
384
- </div>
405
+ <button
406
+ type="button"
407
+ onClick={connection.disconnect}
408
+ className="text-[0.6rem] text-muted-foreground hover:text-destructive transition-colors"
409
+ >
410
+ Disconnect
411
+ </button>
385
412
  </div>
386
413
  <GitHubRepoPicker
387
414
  token={connection.token!}
@@ -392,7 +419,10 @@ function GitHubPanel({
392
419
  );
393
420
  }
394
421
 
395
- /** Fallback manual git URL input for platform builders without GitHub connection. */
422
+ // ---------------------------------------------------------------------------
423
+ // Manual git URL input (fallback for platform builders without GitHub OAuth)
424
+ // ---------------------------------------------------------------------------
425
+
396
426
  function ManualGitPanel({
397
427
  url,
398
428
  branch,
@@ -450,6 +480,26 @@ function ManualGitPanel({
450
480
  );
451
481
  }
452
482
 
483
+ // ---------------------------------------------------------------------------
484
+ // Icons
485
+ // ---------------------------------------------------------------------------
486
+
487
+ function ChevronLeftIcon() {
488
+ return (
489
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
490
+ <path d="M7.5 2.5L4 6L7.5 9.5" />
491
+ </svg>
492
+ );
493
+ }
494
+
495
+ function ChevronRightIcon() {
496
+ return (
497
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="text-muted-foreground">
498
+ <path d="M4.5 2.5L8 6L4.5 9.5" />
499
+ </svg>
500
+ );
501
+ }
502
+
453
503
  function XIcon() {
454
504
  return (
455
505
  <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
@@ -7,3 +7,8 @@ export { WorkspaceEditor } from "./WorkspaceEditor";
7
7
  export type { WorkspaceEditorProps } from "./WorkspaceEditor";
8
8
  export { WorkspaceSummary } from "./WorkspaceSummary";
9
9
  export type { WorkspaceSummaryProps } from "./WorkspaceSummary";
10
+ export { useRecentWorkspaces } from "./useRecentWorkspaces";
11
+ export type {
12
+ RecentWorkspace,
13
+ UseRecentWorkspacesReturn,
14
+ } from "./useRecentWorkspaces";
@@ -0,0 +1,162 @@
1
+ "use client";
2
+
3
+ import { useCallback, useSyncExternalStore } from "react";
4
+
5
+ const STORAGE_KEY_PREFIX = "stigmer:recent-workspaces:";
6
+ const MAX_RECENT = 8;
7
+
8
+ /** A recently used or favorited workspace path for a specific runner. */
9
+ export interface RecentWorkspace {
10
+ /** Absolute path on the runner's filesystem. */
11
+ readonly path: string;
12
+ /** Whether the user pinned this path as a favorite. */
13
+ readonly pinned: boolean;
14
+ /** Epoch ms when this path was last selected. */
15
+ readonly lastUsed: number;
16
+ }
17
+
18
+ /** Return value of {@link useRecentWorkspaces}. */
19
+ export interface UseRecentWorkspacesReturn {
20
+ /** Recent paths sorted: pinned first, then by recency. */
21
+ readonly entries: readonly RecentWorkspace[];
22
+ /** Record a path selection (adds or bumps it in the list). */
23
+ readonly recordSelection: (path: string) => void;
24
+ /** Toggle the pinned state of a path. */
25
+ readonly togglePin: (path: string) => void;
26
+ /** Remove a path from the recent list. */
27
+ readonly remove: (path: string) => void;
28
+ }
29
+
30
+ function storageKey(runnerId: string): string {
31
+ return `${STORAGE_KEY_PREFIX}${runnerId}`;
32
+ }
33
+
34
+ function readEntries(runnerId: string): RecentWorkspace[] {
35
+ try {
36
+ const raw = localStorage.getItem(storageKey(runnerId));
37
+ if (!raw) return [];
38
+ return JSON.parse(raw) as RecentWorkspace[];
39
+ } catch {
40
+ return [];
41
+ }
42
+ }
43
+
44
+ function writeEntries(runnerId: string, entries: RecentWorkspace[]): void {
45
+ try {
46
+ localStorage.setItem(storageKey(runnerId), JSON.stringify(entries));
47
+ } catch {
48
+ // localStorage full or unavailable — silently degrade.
49
+ }
50
+ }
51
+
52
+ function sortEntries(entries: RecentWorkspace[]): RecentWorkspace[] {
53
+ return [...entries].sort((a, b) => {
54
+ if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
55
+ return b.lastUsed - a.lastUsed;
56
+ });
57
+ }
58
+
59
+ const listeners = new Set<() => void>();
60
+ let snapshotVersion = 0;
61
+
62
+ function notifyListeners(): void {
63
+ snapshotVersion++;
64
+ for (const fn of listeners) fn();
65
+ }
66
+
67
+ function subscribe(callback: () => void): () => void {
68
+ listeners.add(callback);
69
+ return () => listeners.delete(callback);
70
+ }
71
+
72
+ const EMPTY: readonly RecentWorkspace[] = [];
73
+
74
+ let cachedRunnerId: string | null = null;
75
+ let cachedVersion = -1;
76
+ let cachedResult: readonly RecentWorkspace[] = EMPTY;
77
+
78
+ function getSnapshot(runnerId: string | null): readonly RecentWorkspace[] {
79
+ if (!runnerId) return EMPTY;
80
+ if (runnerId === cachedRunnerId && snapshotVersion === cachedVersion) {
81
+ return cachedResult;
82
+ }
83
+ cachedRunnerId = runnerId;
84
+ cachedVersion = snapshotVersion;
85
+ cachedResult = sortEntries(readEntries(runnerId));
86
+ return cachedResult;
87
+ }
88
+
89
+ /**
90
+ * Manages recently used workspace paths for a specific runner,
91
+ * persisted in `localStorage`.
92
+ *
93
+ * Entries are keyed by `runner_id` so each runner maintains its own
94
+ * history. Pinned paths appear first, then by most-recently-used.
95
+ *
96
+ * @param runnerId - Runner to scope the history to. When `null`, returns empty.
97
+ */
98
+ export function useRecentWorkspaces(
99
+ runnerId: string | null,
100
+ ): UseRecentWorkspacesReturn {
101
+ const entries = useSyncExternalStore(
102
+ subscribe,
103
+ () => getSnapshot(runnerId),
104
+ () => EMPTY,
105
+ );
106
+
107
+ const recordSelection = useCallback(
108
+ (path: string) => {
109
+ if (!runnerId) return;
110
+ const existing = readEntries(runnerId);
111
+ const idx = existing.findIndex((e) => e.path === path);
112
+ const now = Date.now();
113
+
114
+ let updated: RecentWorkspace[];
115
+ if (idx >= 0) {
116
+ updated = [...existing];
117
+ updated[idx] = { ...updated[idx], lastUsed: now };
118
+ } else {
119
+ updated = [{ path, pinned: false, lastUsed: now }, ...existing];
120
+ }
121
+
122
+ if (updated.length > MAX_RECENT) {
123
+ const unpinned = updated.filter((e) => !e.pinned);
124
+ if (unpinned.length > 0) {
125
+ unpinned.sort((a, b) => a.lastUsed - b.lastUsed);
126
+ const oldest = unpinned[0];
127
+ updated = updated.filter((e) => e !== oldest);
128
+ }
129
+ }
130
+
131
+ writeEntries(runnerId, updated);
132
+ notifyListeners();
133
+ },
134
+ [runnerId],
135
+ );
136
+
137
+ const togglePin = useCallback(
138
+ (path: string) => {
139
+ if (!runnerId) return;
140
+ const existing = readEntries(runnerId);
141
+ const idx = existing.findIndex((e) => e.path === path);
142
+ if (idx < 0) return;
143
+ const updated = [...existing];
144
+ updated[idx] = { ...updated[idx], pinned: !updated[idx].pinned };
145
+ writeEntries(runnerId, updated);
146
+ notifyListeners();
147
+ },
148
+ [runnerId],
149
+ );
150
+
151
+ const remove = useCallback(
152
+ (path: string) => {
153
+ if (!runnerId) return;
154
+ const updated = readEntries(runnerId).filter((e) => e.path !== path);
155
+ writeEntries(runnerId, updated);
156
+ notifyListeners();
157
+ },
158
+ [runnerId],
159
+ );
160
+
161
+ return { entries, recordSelection, togglePin, remove };
162
+ }
@@ -49,6 +49,14 @@ export interface UseWorkspaceEntriesReturn {
49
49
  readonly remove: (id: string) => void;
50
50
  /** Remove all entries. */
51
51
  readonly clear: () => void;
52
+ /**
53
+ * Remove all local folder entries, keeping git entries intact.
54
+ *
55
+ * Used when the user switches runners — local paths from the previous
56
+ * runner are invalid on the new runner, but git repos are
57
+ * runner-independent and can stay.
58
+ */
59
+ readonly clearLocal: () => void;
52
60
  /** Convert entries to the `WorkspaceEntryInput[]` shape required by the SDK. */
53
61
  readonly toInput: () => WorkspaceEntryInput[];
54
62
  /** `true` when at least one entry exists. */
@@ -133,6 +141,10 @@ export function useWorkspaceEntries(): UseWorkspaceEntriesReturn {
133
141
  setEntries([]);
134
142
  }, []);
135
143
 
144
+ const clearLocal = useCallback(() => {
145
+ setEntries((prev) => prev.filter((e) => e.type !== "local"));
146
+ }, []);
147
+
136
148
  const toInput = useCallback((): WorkspaceEntryInput[] => {
137
149
  return entries.map((entry): WorkspaceEntryInput => {
138
150
  const source: WorkspaceSourceInput =
@@ -150,6 +162,7 @@ export function useWorkspaceEntries(): UseWorkspaceEntriesReturn {
150
162
  addLocalPath,
151
163
  remove,
152
164
  clear,
165
+ clearLocal,
153
166
  toInput,
154
167
  hasEntries: entries.length > 0,
155
168
  };