@stigmer/react 0.2.1 → 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
@@ -1,8 +1,9 @@
1
1
  "use client";
2
2
 
3
- import { useMemo } from "react";
3
+ import { useCallback, useMemo, useState, useRef, type KeyboardEvent } from "react";
4
4
  import { getUserMessage } from "@stigmer/sdk";
5
5
  import { useRunnerFileBrowser } from "./useRunnerFileBrowser";
6
+ import { useRecentWorkspaces, type RecentWorkspace } from "../workspace/useRecentWorkspaces";
6
7
 
7
8
  /** Props for {@link RunnerFileBrowser}. */
8
9
  export interface RunnerFileBrowserProps {
@@ -14,6 +15,16 @@ export interface RunnerFileBrowserProps {
14
15
  readonly onCancel: () => void;
15
16
  /** Additional CSS class names for the root container. */
16
17
  readonly className?: string;
18
+ /**
19
+ * Display name of the runner (e.g. "dev-macbook").
20
+ * Shown in the context header so users know which machine they're browsing.
21
+ */
22
+ readonly runnerName?: string;
23
+ /**
24
+ * Hostname of the runner's machine (e.g. "Alice's MacBook Pro").
25
+ * Shown alongside the runner name in the context header.
26
+ */
27
+ readonly runnerHostname?: string;
17
28
  }
18
29
 
19
30
  /**
@@ -41,8 +52,54 @@ export function RunnerFileBrowser({
41
52
  onSelect,
42
53
  onCancel,
43
54
  className,
55
+ runnerName,
56
+ runnerHostname,
44
57
  }: RunnerFileBrowserProps) {
45
58
  const browser = useRunnerFileBrowser(runnerId);
59
+ const recents = useRecentWorkspaces(runnerId);
60
+
61
+ const handleSelect = useCallback(
62
+ (path: string) => {
63
+ recents.recordSelection(path);
64
+ onSelect(path);
65
+ },
66
+ [recents, onSelect],
67
+ );
68
+
69
+ const [isEditingPath, setIsEditingPath] = useState(false);
70
+ const [editPathValue, setEditPathValue] = useState("");
71
+ const pathInputRef = useRef<HTMLInputElement>(null);
72
+
73
+ const startEditingPath = useCallback(() => {
74
+ setEditPathValue(browser.currentPath);
75
+ setIsEditingPath(true);
76
+ requestAnimationFrame(() => pathInputRef.current?.select());
77
+ }, [browser.currentPath]);
78
+
79
+ const commitPath = useCallback(() => {
80
+ const trimmed = editPathValue.trim();
81
+ if (trimmed && trimmed !== browser.currentPath) {
82
+ browser.navigateToPath(trimmed);
83
+ }
84
+ setIsEditingPath(false);
85
+ }, [editPathValue, browser]);
86
+
87
+ const cancelEditing = useCallback(() => {
88
+ setIsEditingPath(false);
89
+ }, []);
90
+
91
+ const handlePathKeyDown = useCallback(
92
+ (e: KeyboardEvent<HTMLInputElement>) => {
93
+ if (e.key === "Enter") {
94
+ e.preventDefault();
95
+ commitPath();
96
+ } else if (e.key === "Escape") {
97
+ e.preventDefault();
98
+ cancelEditing();
99
+ }
100
+ },
101
+ [commitPath, cancelEditing],
102
+ );
46
103
 
47
104
  const visibleEntries = useMemo(
48
105
  () =>
@@ -77,6 +134,33 @@ export function RunnerFileBrowser({
77
134
 
78
135
  return (
79
136
  <div className={["space-y-2", className].filter(Boolean).join(" ")}>
137
+ {/* Runner context header */}
138
+ {(runnerName || runnerHostname) && (
139
+ <div className="flex items-center gap-1.5 text-[0.65rem] text-muted-foreground">
140
+ <ServerIcon />
141
+ <span className="truncate">
142
+ {runnerName ?? runnerHostname}
143
+ {runnerName && runnerHostname && (
144
+ <span className="text-border"> &middot; {runnerHostname}</span>
145
+ )}
146
+ </span>
147
+ </div>
148
+ )}
149
+
150
+ {/* Recent / favorite paths */}
151
+ {recents.entries.length > 0 && (
152
+ <RecentPathsList
153
+ entries={recents.entries}
154
+ onSelect={(path) => {
155
+ recents.recordSelection(path);
156
+ onSelect(path);
157
+ }}
158
+ onTogglePin={recents.togglePin}
159
+ onRemove={recents.remove}
160
+ onNavigate={browser.navigateToPath}
161
+ />
162
+ )}
163
+
80
164
  {/* Navigation bar: shortcuts + breadcrumb */}
81
165
  <div className="space-y-1.5">
82
166
  {/* Shortcut buttons */}
@@ -121,16 +205,26 @@ export function RunnerFileBrowser({
121
205
  </div>
122
206
  </div>
123
207
 
124
- {/* Breadcrumb path bar */}
125
- {browser.segments.length > 0 && (
208
+ {/* Path bar — editable input or clickable breadcrumb */}
209
+ {isEditingPath ? (
210
+ <input
211
+ ref={pathInputRef}
212
+ type="text"
213
+ value={editPathValue}
214
+ onChange={(e) => setEditPathValue(e.target.value)}
215
+ onKeyDown={handlePathKeyDown}
216
+ onBlur={commitPath}
217
+ className="w-full rounded-md border border-input bg-background px-2 py-1 text-[0.65rem] text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
218
+ placeholder="/path/to/directory or ~/relative"
219
+ autoFocus
220
+ />
221
+ ) : browser.segments.length > 0 ? (
126
222
  <div className="flex items-center gap-0.5 overflow-x-auto text-[0.65rem] scrollbar-none">
127
223
  {browser.segments.map((seg, i) => {
128
224
  const isLast = i === browser.segments.length - 1;
129
225
  return (
130
226
  <span key={seg.path} className="flex shrink-0 items-center gap-0.5">
131
- {i > 0 && (
132
- <ChevronRightIcon />
133
- )}
227
+ {i > 0 && <ChevronRightIcon />}
134
228
  {isLast ? (
135
229
  <span className="rounded px-1 py-0.5 font-medium text-foreground">
136
230
  {seg.name}
@@ -148,8 +242,17 @@ export function RunnerFileBrowser({
148
242
  </span>
149
243
  );
150
244
  })}
245
+ <button
246
+ type="button"
247
+ onClick={startEditingPath}
248
+ className="ml-1 shrink-0 rounded p-0.5 text-muted-foreground hover:text-foreground hover:bg-accent-hover transition-colors"
249
+ title="Type a path directly"
250
+ aria-label="Edit path"
251
+ >
252
+ <EditIcon />
253
+ </button>
151
254
  </div>
152
- )}
255
+ ) : null}
153
256
  </div>
154
257
 
155
258
  {/* Inline error banner (when we already have a current path) */}
@@ -226,7 +329,7 @@ export function RunnerFileBrowser({
226
329
  </button>
227
330
  <button
228
331
  type="button"
229
- onClick={() => onSelect(browser.currentPath)}
332
+ onClick={() => handleSelect(browser.currentPath)}
230
333
  disabled={!browser.currentPath || browser.isLoading}
231
334
  className="rounded-md bg-primary px-2.5 py-1 text-xs text-primary-foreground hover:bg-primary-hover transition-colors disabled:opacity-40"
232
335
  >
@@ -281,6 +384,87 @@ function ErrorDisplay({
281
384
  );
282
385
  }
283
386
 
387
+ // ---------------------------------------------------------------------------
388
+ // Recent paths list
389
+ // ---------------------------------------------------------------------------
390
+
391
+ function RecentPathsList({
392
+ entries,
393
+ onSelect,
394
+ onTogglePin,
395
+ onRemove,
396
+ onNavigate,
397
+ }: {
398
+ entries: readonly RecentWorkspace[];
399
+ onSelect: (path: string) => void;
400
+ onTogglePin: (path: string) => void;
401
+ onRemove: (path: string) => void;
402
+ onNavigate: (path: string) => void;
403
+ }) {
404
+ return (
405
+ <div className="space-y-1">
406
+ <span className="text-[0.6rem] font-medium text-muted-foreground">
407
+ Recent
408
+ </span>
409
+ <div className="max-h-24 overflow-y-auto rounded-md border border-border">
410
+ {entries.map((entry) => {
411
+ const dirName = entry.path.split("/").filter(Boolean).pop() ?? entry.path;
412
+ return (
413
+ <div
414
+ key={entry.path}
415
+ className="group flex items-center gap-1.5 px-2 py-1 text-xs transition-colors hover:bg-accent-hover"
416
+ >
417
+ <button
418
+ type="button"
419
+ onClick={() => onTogglePin(entry.path)}
420
+ className={[
421
+ "shrink-0 transition-colors",
422
+ entry.pinned
423
+ ? "text-foreground"
424
+ : "text-transparent group-hover:text-muted-foreground",
425
+ ].join(" ")}
426
+ title={entry.pinned ? "Unpin" : "Pin"}
427
+ aria-label={entry.pinned ? `Unpin ${dirName}` : `Pin ${dirName}`}
428
+ >
429
+ <PinIcon />
430
+ </button>
431
+ <button
432
+ type="button"
433
+ onClick={() => onSelect(entry.path)}
434
+ className="flex min-w-0 flex-1 items-center gap-1.5 text-left text-foreground"
435
+ title={`Select ${entry.path}`}
436
+ >
437
+ <FolderIcon />
438
+ <span className="truncate [direction:rtl] text-left">
439
+ <bdi>{entry.path}</bdi>
440
+ </span>
441
+ </button>
442
+ <button
443
+ type="button"
444
+ onClick={() => onNavigate(entry.path)}
445
+ className="shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 hover:text-foreground"
446
+ title="Browse this directory"
447
+ aria-label={`Browse ${dirName}`}
448
+ >
449
+ <ChevronRightIcon />
450
+ </button>
451
+ <button
452
+ type="button"
453
+ onClick={() => onRemove(entry.path)}
454
+ className="shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 hover:text-destructive"
455
+ title="Remove from recent"
456
+ aria-label={`Remove ${dirName} from recent`}
457
+ >
458
+ <XSmallIcon />
459
+ </button>
460
+ </div>
461
+ );
462
+ })}
463
+ </div>
464
+ </div>
465
+ );
466
+ }
467
+
284
468
  // ---------------------------------------------------------------------------
285
469
  // Shortcut button
286
470
  // ---------------------------------------------------------------------------
@@ -334,6 +518,41 @@ function LoadingSkeleton() {
334
518
  // Icons (inline SVG, consistent with existing SDK icon patterns)
335
519
  // ---------------------------------------------------------------------------
336
520
 
521
+ function EditIcon() {
522
+ return (
523
+ <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
524
+ <path d="M8.5 1.5L10.5 3.5L4 10H2V8L8.5 1.5Z" />
525
+ </svg>
526
+ );
527
+ }
528
+
529
+ function PinIcon() {
530
+ return (
531
+ <svg width="10" height="10" viewBox="0 0 12 12" fill="currentColor" stroke="none">
532
+ <path d="M7.5 1.5L10.5 4.5L8 7L9 10L6 7L2 11L5 3L4.5 1.5L7.5 1.5Z" />
533
+ </svg>
534
+ );
535
+ }
536
+
537
+ function XSmallIcon() {
538
+ return (
539
+ <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
540
+ <path d="M2.5 2.5L7.5 7.5M7.5 2.5L2.5 7.5" />
541
+ </svg>
542
+ );
543
+ }
544
+
545
+ function ServerIcon() {
546
+ return (
547
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
548
+ <rect x="1.5" y="1.5" width="9" height="3.5" rx="0.5" />
549
+ <rect x="1.5" y="7" width="9" height="3.5" rx="0.5" />
550
+ <circle cx="3.5" cy="3.25" r="0.5" fill="currentColor" stroke="none" />
551
+ <circle cx="3.5" cy="8.75" r="0.5" fill="currentColor" stroke="none" />
552
+ </svg>
553
+ );
554
+ }
555
+
337
556
  function HomeIcon() {
338
557
  return (
339
558
  <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
@@ -0,0 +1,180 @@
1
+ "use client";
2
+
3
+ import { useMemo } from "react";
4
+ import { cn } from "@stigmer/theme";
5
+ import type { Runner } from "@stigmer/protos/ai/stigmer/agentic/runner/v1/api_pb";
6
+ import { RunnerPhase } from "@stigmer/protos/ai/stigmer/agentic/runner/v1/enum_pb";
7
+ import { useRunnerList } from "./useRunnerList";
8
+ import { isActivePhase, phaseDotColor, PHASE_SORT_ORDER } from "./phase";
9
+
10
+ /** Props for {@link WorkspaceRunnerSelector}. */
11
+ export interface WorkspaceRunnerSelectorProps {
12
+ /** Organization slug to scope the runner list. */
13
+ readonly org: string;
14
+ /** Currently selected runner ID, or `null` for "Auto". */
15
+ readonly value: string | null;
16
+ /** Called when the user selects a runner. `null` means "Auto". */
17
+ readonly onChange: (runnerId: string | null) => void;
18
+ /** Disables all interactions. */
19
+ readonly disabled?: boolean;
20
+ /**
21
+ * Set of runner IDs that are known to be running on the local machine.
22
+ *
23
+ * When provided, matching runners are labeled "This machine" instead
24
+ * of their name/hostname. This is a host-provided signal — the desktop
25
+ * app determines local runners from `~/.stigmer/runners/` state files;
26
+ * the web console does not pass this prop.
27
+ */
28
+ readonly localRunnerIds?: ReadonlySet<string>;
29
+ }
30
+
31
+ /**
32
+ * Cursor-inspired "Run On" section for the workspace popover.
33
+ *
34
+ * Renders a compact, sectioned list of available runners so users can
35
+ * pick where their session runs without leaving the workspace context.
36
+ * Designed to be composed alongside {@link WorkspaceEditor} inside the
37
+ * workspace popover — NOT as a standalone picker.
38
+ *
39
+ * Label strategy (SDK-agnostic, no platform branding):
40
+ * - "Auto" for null selection (platform assigns a runner)
41
+ * - "Cloud" for system-managed runners
42
+ * - "This machine" when the host provides `localRunnerIds`
43
+ * - Runner name / hostname for everything else
44
+ */
45
+ export function WorkspaceRunnerSelector({
46
+ org,
47
+ value,
48
+ onChange,
49
+ disabled,
50
+ localRunnerIds,
51
+ }: WorkspaceRunnerSelectorProps) {
52
+ const { runners, isLoading } = useRunnerList(org);
53
+
54
+ const active = useMemo(() => {
55
+ return runners
56
+ .filter((r) => isActivePhase(r.status?.phase ?? RunnerPhase.UNSPECIFIED))
57
+ .sort((a, b) => {
58
+ const pa = a.status?.phase ?? RunnerPhase.UNSPECIFIED;
59
+ const pb = b.status?.phase ?? RunnerPhase.UNSPECIFIED;
60
+ const po = PHASE_SORT_ORDER[pa] - PHASE_SORT_ORDER[pb];
61
+ if (po !== 0) return po;
62
+ return (a.metadata?.name ?? "").localeCompare(b.metadata?.name ?? "");
63
+ });
64
+ }, [runners]);
65
+
66
+ return (
67
+ <div className="space-y-1" role="listbox" aria-label="Run on">
68
+ <div className="px-1 text-[0.6rem] font-medium uppercase tracking-wider text-muted-foreground">
69
+ Run On
70
+ </div>
71
+
72
+ {/* Auto option */}
73
+ <RunOnOption
74
+ selected={value === null}
75
+ onClick={() => onChange(null)}
76
+ disabled={disabled}
77
+ label="Auto"
78
+ hint="platform assigns"
79
+ dotClass="bg-primary"
80
+ />
81
+
82
+ {/* Available runners */}
83
+ {active.map((r) => {
84
+ const id = r.metadata!.id;
85
+ return (
86
+ <RunOnOption
87
+ key={id}
88
+ selected={value === id}
89
+ onClick={() => onChange(id)}
90
+ disabled={disabled}
91
+ label={runnerLabel(r, localRunnerIds)}
92
+ hint={runnerHint(r, localRunnerIds)}
93
+ dotClass={phaseDotColor(r.status?.phase ?? RunnerPhase.UNSPECIFIED)}
94
+ />
95
+ );
96
+ })}
97
+
98
+ {/* Loading */}
99
+ {isLoading && active.length === 0 && (
100
+ <div className="px-1 py-2 text-[0.65rem] text-muted-foreground">
101
+ Loading runners...
102
+ </div>
103
+ )}
104
+
105
+ {/* Empty */}
106
+ {!isLoading && active.length === 0 && (
107
+ <div className="px-1 py-2 text-[0.65rem] text-muted-foreground">
108
+ No runners available
109
+ </div>
110
+ )}
111
+ </div>
112
+ );
113
+ }
114
+
115
+ function RunOnOption({
116
+ selected,
117
+ onClick,
118
+ disabled,
119
+ label,
120
+ hint,
121
+ dotClass,
122
+ }: {
123
+ selected: boolean;
124
+ onClick: () => void;
125
+ disabled?: boolean;
126
+ label: string;
127
+ hint?: string;
128
+ dotClass: string;
129
+ }) {
130
+ return (
131
+ <button
132
+ type="button"
133
+ onClick={onClick}
134
+ disabled={disabled}
135
+ className={cn(
136
+ "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors",
137
+ "disabled:pointer-events-none disabled:opacity-50",
138
+ selected
139
+ ? "bg-accent font-medium text-foreground"
140
+ : "text-foreground hover:bg-accent-hover",
141
+ )}
142
+ role="option"
143
+ aria-selected={selected}
144
+ >
145
+ <span
146
+ className={cn("inline-block h-1.5 w-1.5 shrink-0 rounded-full", dotClass)}
147
+ aria-hidden="true"
148
+ />
149
+ <span className="min-w-0 flex-1 truncate">{label}</span>
150
+ {hint && (
151
+ <span className="shrink-0 text-[0.6rem] text-muted-foreground">
152
+ {hint}
153
+ </span>
154
+ )}
155
+ </button>
156
+ );
157
+ }
158
+
159
+ function runnerLabel(
160
+ runner: Runner,
161
+ localRunnerIds?: ReadonlySet<string>,
162
+ ): string {
163
+ const id = runner.metadata?.id ?? "";
164
+ if (localRunnerIds?.has(id)) return "This machine";
165
+ return runner.metadata?.name ?? runner.status?.connectionInfo?.hostname ?? "Unnamed";
166
+ }
167
+
168
+ function runnerHint(
169
+ runner: Runner,
170
+ localRunnerIds?: ReadonlySet<string>,
171
+ ): string | undefined {
172
+ const id = runner.metadata?.id ?? "";
173
+ if (localRunnerIds?.has(id)) {
174
+ return runner.status?.connectionInfo?.hostname;
175
+ }
176
+ const name = runner.metadata?.name;
177
+ const hostname = runner.status?.connectionInfo?.hostname;
178
+ if (name && hostname && name !== hostname) return hostname;
179
+ return undefined;
180
+ }
@@ -41,6 +41,9 @@ export type {
41
41
  export { RunnerListPanel } from "./RunnerListPanel";
42
42
  export type { RunnerListPanelProps } from "./RunnerListPanel";
43
43
 
44
+ export { WorkspaceRunnerSelector } from "./WorkspaceRunnerSelector";
45
+ export type { WorkspaceRunnerSelectorProps } from "./WorkspaceRunnerSelector";
46
+
44
47
  export {
45
48
  phaseLabel,
46
49
  phaseDotColor,
@@ -58,6 +58,12 @@ export interface UseRunnerFileBrowserReturn {
58
58
  // Reducer
59
59
  // ---------------------------------------------------------------------------
60
60
 
61
+ /** Cached result of a single directory listing. */
62
+ interface CachedListing {
63
+ readonly entries: readonly DirectoryEntry[];
64
+ readonly fetchedAt: number;
65
+ }
66
+
61
67
  interface State {
62
68
  currentPath: string;
63
69
  entries: readonly DirectoryEntry[];
@@ -68,13 +74,18 @@ interface State {
68
74
  error: Error | null;
69
75
  /** The path we last requested — used for retry. */
70
76
  requestedPath: string;
77
+ /** Directory listing cache keyed by resolved absolute path. */
78
+ cache: Map<string, CachedListing>;
71
79
  }
72
80
 
73
81
  type Action =
74
82
  | { type: "NAVIGATE"; path: string }
75
83
  | { type: "SUCCESS"; resolvedPath: string; entries: DirectoryEntry[]; homeDirectory: string; currentDirectory: string }
76
84
  | { type: "FAILURE"; error: Error }
77
- | { type: "TOGGLE_HIDDEN" };
85
+ | { type: "TOGGLE_HIDDEN" }
86
+ | { type: "CACHE_HIT"; resolvedPath: string; entries: readonly DirectoryEntry[] };
87
+
88
+ const CACHE_MAX_AGE_MS = 30_000;
78
89
 
79
90
  function reducer(state: State, action: Action): State {
80
91
  switch (action.type) {
@@ -85,7 +96,12 @@ function reducer(state: State, action: Action): State {
85
96
  isLoading: true,
86
97
  error: null,
87
98
  };
88
- case "SUCCESS":
99
+ case "SUCCESS": {
100
+ const nextCache = new Map(state.cache);
101
+ nextCache.set(action.resolvedPath, {
102
+ entries: action.entries,
103
+ fetchedAt: Date.now(),
104
+ });
89
105
  return {
90
106
  ...state,
91
107
  currentPath: action.resolvedPath,
@@ -94,6 +110,17 @@ function reducer(state: State, action: Action): State {
94
110
  currentDirectory: action.currentDirectory || state.currentDirectory,
95
111
  isLoading: false,
96
112
  error: null,
113
+ cache: nextCache,
114
+ };
115
+ }
116
+ case "CACHE_HIT":
117
+ return {
118
+ ...state,
119
+ currentPath: action.resolvedPath,
120
+ entries: action.entries,
121
+ isLoading: false,
122
+ error: null,
123
+ requestedPath: action.resolvedPath,
97
124
  };
98
125
  case "FAILURE":
99
126
  return { ...state, isLoading: false, error: action.error };
@@ -111,6 +138,7 @@ const INITIAL_STATE: State = {
111
138
  isLoading: false,
112
139
  error: null,
113
140
  requestedPath: "",
141
+ cache: new Map(),
114
142
  };
115
143
 
116
144
  // ---------------------------------------------------------------------------
@@ -182,10 +210,19 @@ export function useRunnerFileBrowser(
182
210
  const [state, dispatch] = useReducer(reducer, INITIAL_STATE);
183
211
  const requestIdRef = useRef(0);
184
212
 
213
+ const cacheRef = useRef(state.cache);
214
+ cacheRef.current = state.cache;
215
+
185
216
  const fetchDirectory = useCallback(
186
217
  async (path: string) => {
187
218
  if (!runnerId) return;
188
219
 
220
+ const cached = cacheRef.current.get(path);
221
+ if (cached && Date.now() - cached.fetchedAt < CACHE_MAX_AGE_MS) {
222
+ dispatch({ type: "CACHE_HIT", resolvedPath: path, entries: cached.entries });
223
+ return;
224
+ }
225
+
189
226
  const id = ++requestIdRef.current;
190
227
  dispatch({ type: "NAVIGATE", path });
191
228
 
@@ -200,7 +237,6 @@ export function useRunnerFileBrowser(
200
237
  }),
201
238
  );
202
239
 
203
- // Stale response guard — a newer navigation has started.
204
240
  if (id !== requestIdRef.current) return;
205
241
 
206
242
  if (response.result.case === "error") {
@@ -4,12 +4,23 @@ import { OrgMembersPanel } from "../iam-policy/OrgMembersPanel";
4
4
  import { useResourceAvailable, ApiResourceKind } from "../deployment-mode";
5
5
  import { CloudFeatureNotice } from "../internal/CloudFeatureNotice";
6
6
  import { useOrg } from "../organization/OrgProvider";
7
+ import { useIdentityProviderList } from "../identity-provider/useIdentityProviderList";
7
8
 
8
9
  /** Settings section for organization membership and role management. */
9
10
  export function MembersSection() {
10
11
  const { activeOrg } = useOrg();
11
12
  const membersAvailable = useResourceAvailable(ApiResourceKind.iam_policy);
13
+ const idpAvailable = useResourceAvailable(ApiResourceKind.identity_provider);
12
14
  const orgId = activeOrg?.metadata?.id ?? "";
15
+ const orgSlug = activeOrg?.metadata?.slug ?? "";
16
+
17
+ const { identityProviders } = useIdentityProviderList(
18
+ idpAvailable && orgSlug ? orgSlug : null,
19
+ );
20
+
21
+ const hasJitProviders = identityProviders.some(
22
+ (idp) => idp.spec?.autoProvisionAccounts || idp.spec?.isSsoProvider,
23
+ );
13
24
 
14
25
  return (
15
26
  <section aria-labelledby="members-heading">
@@ -34,7 +45,18 @@ export function MembersSection() {
34
45
  Select an organization to manage members.
35
46
  </p>
36
47
  ) : (
37
- <OrgMembersPanel orgId={orgId} />
48
+ <>
49
+ {hasJitProviders && (
50
+ <div className="mb-3 rounded-md border border-border-muted bg-muted-faint px-3 py-2">
51
+ <p className="text-[0.65rem] text-muted-foreground">
52
+ This organization has identity providers with auto-provisioning
53
+ enabled. Members may appear here automatically when users
54
+ authenticate via federated identity.
55
+ </p>
56
+ </div>
57
+ )}
58
+ <OrgMembersPanel orgId={orgId} />
59
+ </>
38
60
  )}
39
61
  </section>
40
62
  );