@schlessera/brain-ui-react 0.34.1 → 0.35.0

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 (82) hide show
  1. package/README.md +7 -0
  2. package/dist/components/chat/session-drawer.d.ts.map +1 -1
  3. package/dist/components/chat/session-drawer.js +25 -10
  4. package/dist/components/chat/session-drawer.js.map +1 -1
  5. package/dist/components/chat/tool-views.d.ts +2 -2
  6. package/dist/components/chat/tool-views.d.ts.map +1 -1
  7. package/dist/components/chat/tool-views.js +2 -16
  8. package/dist/components/chat/tool-views.js.map +1 -1
  9. package/dist/components/connectivity/connection-gate.d.ts.map +1 -1
  10. package/dist/components/connectivity/connection-gate.js +53 -1
  11. package/dist/components/connectivity/connection-gate.js.map +1 -1
  12. package/dist/components/layout/app-shell.d.ts.map +1 -1
  13. package/dist/components/layout/app-shell.js +2 -1
  14. package/dist/components/layout/app-shell.js.map +1 -1
  15. package/dist/components/quick-actions/add-modal.d.ts.map +1 -1
  16. package/dist/components/quick-actions/add-modal.js +47 -4
  17. package/dist/components/quick-actions/add-modal.js.map +1 -1
  18. package/dist/components/quick-actions/search-modal.d.ts.map +1 -1
  19. package/dist/components/quick-actions/search-modal.js +46 -18
  20. package/dist/components/quick-actions/search-modal.js.map +1 -1
  21. package/dist/components/quick-actions/streaming-modal.d.ts.map +1 -1
  22. package/dist/components/quick-actions/streaming-modal.js +4 -1
  23. package/dist/components/quick-actions/streaming-modal.js.map +1 -1
  24. package/dist/components/settings/devices-agents-tab.d.ts +5 -0
  25. package/dist/components/settings/devices-agents-tab.d.ts.map +1 -0
  26. package/dist/components/settings/devices-agents-tab.js +96 -0
  27. package/dist/components/settings/devices-agents-tab.js.map +1 -0
  28. package/dist/components/settings/one-time-agent-credential.d.ts +3 -0
  29. package/dist/components/settings/one-time-agent-credential.d.ts.map +1 -0
  30. package/dist/components/settings/one-time-agent-credential.js +55 -0
  31. package/dist/components/settings/one-time-agent-credential.js.map +1 -0
  32. package/dist/components/settings/settings-panel.d.ts.map +1 -1
  33. package/dist/components/settings/settings-panel.js +16 -3
  34. package/dist/components/settings/settings-panel.js.map +1 -1
  35. package/dist/hooks/use-vpn-status.d.ts +1 -1
  36. package/dist/hooks/use-vpn-status.d.ts.map +1 -1
  37. package/dist/hooks/use-vpn-status.js +6 -1
  38. package/dist/hooks/use-vpn-status.js.map +1 -1
  39. package/dist/hooks/use-websocket.d.ts.map +1 -1
  40. package/dist/hooks/use-websocket.js +4 -2
  41. package/dist/hooks/use-websocket.js.map +1 -1
  42. package/dist/lib/api-client.d.ts +32 -0
  43. package/dist/lib/api-client.d.ts.map +1 -1
  44. package/dist/lib/api-client.js +11 -0
  45. package/dist/lib/api-client.js.map +1 -1
  46. package/dist/lib/format-time.d.ts +3 -0
  47. package/dist/lib/format-time.d.ts.map +1 -0
  48. package/dist/lib/format-time.js +25 -0
  49. package/dist/lib/format-time.js.map +1 -0
  50. package/dist/lib/push-registration.d.ts +7 -0
  51. package/dist/lib/push-registration.d.ts.map +1 -0
  52. package/dist/lib/push-registration.js +26 -0
  53. package/dist/lib/push-registration.js.map +1 -0
  54. package/dist/stores/file-store.d.ts.map +1 -1
  55. package/dist/stores/file-store.js +54 -18
  56. package/dist/stores/file-store.js.map +1 -1
  57. package/dist/stores/principal-store.d.ts +16 -0
  58. package/dist/stores/principal-store.d.ts.map +1 -0
  59. package/dist/stores/principal-store.js +29 -0
  60. package/dist/stores/principal-store.js.map +1 -0
  61. package/dist/stores/ui-store.d.ts +1 -1
  62. package/dist/stores/ui-store.d.ts.map +1 -1
  63. package/dist/styles.css +1 -1
  64. package/package.json +2 -2
  65. package/src/components/chat/session-drawer.tsx +29 -10
  66. package/src/components/chat/tool-views.tsx +2 -13
  67. package/src/components/connectivity/connection-gate.tsx +64 -1
  68. package/src/components/layout/app-shell.tsx +2 -0
  69. package/src/components/quick-actions/add-modal.tsx +49 -4
  70. package/src/components/quick-actions/search-modal.tsx +46 -18
  71. package/src/components/quick-actions/streaming-modal.tsx +3 -1
  72. package/src/components/settings/devices-agents-tab.tsx +231 -0
  73. package/src/components/settings/one-time-agent-credential.tsx +131 -0
  74. package/src/components/settings/settings-panel.tsx +20 -5
  75. package/src/hooks/use-vpn-status.ts +8 -1
  76. package/src/hooks/use-websocket.ts +4 -2
  77. package/src/lib/api-client.ts +40 -1
  78. package/src/lib/format-time.ts +20 -0
  79. package/src/lib/push-registration.ts +29 -0
  80. package/src/stores/file-store.ts +42 -12
  81. package/src/stores/principal-store.ts +38 -0
  82. package/src/stores/ui-store.ts +1 -1
@@ -24,6 +24,7 @@ export function SearchPanel({
24
24
  }) {
25
25
  const [query, setQuery] = useState("");
26
26
  const [state, setState] = useState<State>("idle");
27
+ const [resultQuery, setResultQuery] = useState("");
27
28
  const [results, setResults] = useState<BrainSearchHit[]>([]);
28
29
  const [warnings, setWarnings] = useState<string[]>([]);
29
30
  const [error, setError] = useState("");
@@ -31,15 +32,40 @@ export function SearchPanel({
31
32
 
32
33
  const inputRef = useRef<HTMLInputElement>(null);
33
34
  const listRef = useRef<HTMLDivElement>(null);
35
+ const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
34
36
  const controllerRef = useRef<AbortController | null>(null);
35
37
 
36
38
  const openFile = useFileStore((s) => s.openFile);
37
39
  const setFilePanelOpen = useUIStore((s) => s.setFilePanelOpen);
38
40
 
39
41
  const trimmed = query.trim();
42
+ const currentResults = resultQuery === trimmed ? results : [];
40
43
 
41
- const runSearch = useCallback(async (q: string) => {
44
+ const cancelSearch = useCallback(() => {
45
+ if (debounceRef.current !== null) clearTimeout(debounceRef.current);
46
+ debounceRef.current = null;
42
47
  controllerRef.current?.abort();
48
+ controllerRef.current = null;
49
+ }, []);
50
+
51
+ function changeQuery(value: string) {
52
+ if (value.trim() === trimmed) {
53
+ setQuery(value);
54
+ return;
55
+ }
56
+ // Invalidate before the debounce, including completions already queued.
57
+ cancelSearch();
58
+ setQuery(value);
59
+ setResultQuery("");
60
+ setResults([]);
61
+ setWarnings([]);
62
+ setError("");
63
+ setSelected(0);
64
+ setState("idle");
65
+ }
66
+
67
+ const runSearch = useCallback(async (q: string) => {
68
+ cancelSearch();
43
69
  const controller = new AbortController();
44
70
  controllerRef.current = controller;
45
71
 
@@ -50,6 +76,7 @@ export function SearchPanel({
50
76
  signal: controller.signal,
51
77
  });
52
78
  if (controller.signal.aborted) return;
79
+ setResultQuery(q);
53
80
  setResults(res.results);
54
81
  setWarnings(res.warnings ?? []);
55
82
  setSelected(0);
@@ -63,17 +90,17 @@ export function SearchPanel({
63
90
  } finally {
64
91
  if (controllerRef.current === controller) controllerRef.current = null;
65
92
  }
66
- }, []);
93
+ }, [cancelSearch]);
67
94
 
68
95
  // Fresh panel every time it opens: stale results from a previous query would
69
96
  // otherwise flash before the first search lands.
70
97
  useEffect(() => {
71
98
  if (!open) {
72
- controllerRef.current?.abort();
73
- controllerRef.current = null;
99
+ cancelSearch();
74
100
  return;
75
101
  }
76
102
  setQuery("");
103
+ setResultQuery("");
77
104
  setResults([]);
78
105
  setWarnings([]);
79
106
  setError("");
@@ -82,27 +109,28 @@ export function SearchPanel({
82
109
  // Focus after the slide-in starts; focusing mid-transform fights the
83
110
  // mobile keyboard on some browsers.
84
111
  const t = setTimeout(() => inputRef.current?.focus(), 120);
85
- return () => clearTimeout(t);
86
- }, [open]);
112
+ return () => { clearTimeout(t); cancelSearch(); };
113
+ }, [open, cancelSearch]);
87
114
 
88
115
  // Debounced search on typing.
89
116
  useEffect(() => {
90
117
  if (!open) return;
91
118
  if (trimmed.length < MIN_QUERY) {
92
- controllerRef.current?.abort();
93
- controllerRef.current = null;
119
+ cancelSearch();
94
120
  setResults([]);
95
121
  setWarnings([]);
96
122
  setState("idle");
97
123
  return;
98
124
  }
99
- const t = setTimeout(() => void runSearch(trimmed), DEBOUNCE_MS);
100
- return () => clearTimeout(t);
101
- }, [open, trimmed, runSearch]);
125
+ debounceRef.current = setTimeout(() => void runSearch(trimmed), DEBOUNCE_MS);
126
+ return cancelSearch;
127
+ }, [open, trimmed, runSearch, cancelSearch]);
102
128
 
103
129
  function handleOpenResult(hit: BrainSearchHit) {
104
130
  // The file panel takes the same right-hand slot, so hand the screen over
105
131
  // rather than stacking two sheets.
132
+ if (resultQuery !== trimmed || state !== "done") return;
133
+ cancelSearch();
106
134
  onClose();
107
135
  setFilePanelOpen(true);
108
136
  void openFile(hit.path);
@@ -110,10 +138,10 @@ export function SearchPanel({
110
138
 
111
139
  function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
112
140
  if (e.key === "ArrowDown" || e.key === "ArrowUp") {
113
- if (results.length === 0) return;
141
+ if (currentResults.length === 0) return;
114
142
  e.preventDefault();
115
143
  const delta = e.key === "ArrowDown" ? 1 : -1;
116
- const next = (selected + delta + results.length) % results.length;
144
+ const next = (selected + delta + currentResults.length) % currentResults.length;
117
145
  setSelected(next);
118
146
  listRef.current
119
147
  ?.querySelectorAll("[data-result]")
@@ -122,10 +150,10 @@ export function SearchPanel({
122
150
  }
123
151
  if (e.key === "Enter") {
124
152
  e.preventDefault();
125
- const hit = results[selected];
153
+ const hit = currentResults[selected];
126
154
  if (hit) handleOpenResult(hit);
127
155
  // Enter with nothing selected jumps the debounce.
128
- else if (trimmed.length >= MIN_QUERY) void runSearch(trimmed);
156
+ else if (trimmed.length >= MIN_QUERY && state !== "loading") void runSearch(trimmed);
129
157
  }
130
158
  }
131
159
 
@@ -139,7 +167,7 @@ export function SearchPanel({
139
167
  <input
140
168
  ref={inputRef}
141
169
  value={query}
142
- onChange={(e) => setQuery(e.target.value)}
170
+ onChange={(e) => changeQuery(e.target.value)}
143
171
  onKeyDown={handleKeyDown}
144
172
  placeholder="Search your brain..."
145
173
  className="min-w-0 flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground"
@@ -153,7 +181,7 @@ export function SearchPanel({
153
181
  {query && state !== "loading" && (
154
182
  <button
155
183
  onClick={() => {
156
- setQuery("");
184
+ changeQuery("");
157
185
  inputRef.current?.focus();
158
186
  }}
159
187
  className="shrink-0 rounded p-0.5 text-muted-foreground transition-colors hover:text-foreground"
@@ -192,7 +220,7 @@ export function SearchPanel({
192
220
  <Placeholder>No results for “{trimmed}”.</Placeholder>
193
221
  ) : (
194
222
  <div className="divide-y divide-border/40">
195
- {results.map((hit, i) => (
223
+ {currentResults.map((hit, i) => (
196
224
  <ResultRow
197
225
  key={hit.path}
198
226
  hit={hit}
@@ -41,7 +41,9 @@ export function StreamingPanel({
41
41
 
42
42
  if (!res.ok || !res.body) {
43
43
  setState("error");
44
- setLines((l) => [...l, `HTTP ${res.status}: ${res.statusText}`]);
44
+ const body = await res.json().catch(() => null);
45
+ const message = typeof body?.error === "string" ? body.error : `HTTP ${res.status}: ${res.statusText}`;
46
+ if (!controller.signal.aborted) setLines((l) => [...l, message]);
45
47
  return;
46
48
  }
47
49
 
@@ -0,0 +1,231 @@
1
+ import { useEffect, useState, type FormEvent } from "react";
2
+ import { Bot, Laptop, Loader2, Plus, Trash2 } from "lucide-react";
3
+ import { api, type PrincipalSummary } from "../../lib/api-client.js";
4
+ import { formatRelativeTime } from "../../lib/format-time.js";
5
+ import { cn } from "../../lib/utils.js";
6
+ import { usePrincipalStore } from "../../stores/principal-store.js";
7
+
8
+ const MAX_LABEL_LENGTH = 64;
9
+ const NOT_ENABLED = "Principal management is not enabled";
10
+
11
+ function displayLabel(label: string): string {
12
+ return Array.from(label).slice(0, MAX_LABEL_LENGTH).join("");
13
+ }
14
+
15
+ /** Active devices and delegated agents, with owner-only mint and revoke controls. */
16
+ export function DevicesAgentsTab({ active }: { active: boolean }) {
17
+ const [principals, setPrincipals] = useState<PrincipalSummary[]>([]);
18
+ const [loading, setLoading] = useState(false);
19
+ const [enabled, setEnabled] = useState(true);
20
+ const [loadFailed, setLoadFailed] = useState(false);
21
+ const [error, setError] = useState<string | null>(null);
22
+ const [label, setLabel] = useState("");
23
+ const [ttlDays, setTtlDays] = useState(7);
24
+ const busy = usePrincipalStore((state) => state.mintPending);
25
+ const mintError = usePrincipalStore((state) => state.mintError);
26
+ const oneTimeCredential = usePrincipalStore(
27
+ (state) => state.oneTimeCredential
28
+ );
29
+ const mintAgent = usePrincipalStore((state) => state.mintAgent);
30
+
31
+ async function refresh() {
32
+ setLoading(true);
33
+ setLoadFailed(false);
34
+ setError(null);
35
+ try {
36
+ const data = await api.principals();
37
+ setPrincipals(data.principals);
38
+ setEnabled(true);
39
+ } catch (err) {
40
+ setPrincipals([]);
41
+ if (err instanceof Error && err.message === NOT_ENABLED) {
42
+ setEnabled(false);
43
+ } else {
44
+ setEnabled(true);
45
+ setLoadFailed(true);
46
+ setError(err instanceof Error ? err.message : "Could not load devices and agents");
47
+ }
48
+ } finally {
49
+ setLoading(false);
50
+ }
51
+ }
52
+
53
+ useEffect(() => {
54
+ if (active) void refresh();
55
+ }, [active]);
56
+
57
+ useEffect(() => {
58
+ if (!oneTimeCredential) return;
59
+ setLabel("");
60
+ if (active) void refresh();
61
+ }, [active, oneTimeCredential]);
62
+
63
+ function onMint(event: FormEvent<HTMLFormElement>) {
64
+ event.preventDefault();
65
+ const trimmed = label.trim();
66
+ if (!trimmed || busy) return;
67
+ setError(null);
68
+ void mintAgent(trimmed, ttlDays);
69
+ }
70
+
71
+ async function onRevoke(principal: PrincipalSummary) {
72
+ const consequence = principal.is_own
73
+ ? "This device will be signed out immediately, and you will return to sign in."
74
+ : principal.kind === "agent"
75
+ ? "That agent will lose access immediately."
76
+ : "That device will be signed out immediately.";
77
+ if (!window.confirm(`${consequence} You can't undo this.`)) return;
78
+
79
+ setError(null);
80
+ try {
81
+ await api.principalRevoke(principal.id);
82
+ setPrincipals((rows) => rows.filter((row) => row.id !== principal.id));
83
+ if (principal.is_own) window.location.reload();
84
+ } catch (err) {
85
+ setError(err instanceof Error ? err.message : "Could not revoke access");
86
+ }
87
+ }
88
+
89
+ return (
90
+ <div className="h-full overflow-y-auto p-4">
91
+ <h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
92
+ Devices &amp; agents
93
+ </h3>
94
+ <p className="mt-1 text-xs text-muted-foreground">
95
+ See where this brain is signed in and create time-limited access for an agent.
96
+ </p>
97
+
98
+ {loading ? (
99
+ <div className="mt-6 flex justify-center">
100
+ <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
101
+ </div>
102
+ ) : !enabled ? (
103
+ <p className="mt-4 text-xs text-muted-foreground">
104
+ Devices and agents can only be managed when password authentication is enabled.
105
+ </p>
106
+ ) : loadFailed ? (
107
+ <p role="alert" className="mt-4 text-xs text-destructive">
108
+ {error}
109
+ </p>
110
+ ) : (
111
+ <>
112
+ <ul className="mt-4 flex flex-col gap-2">
113
+ {principals.map((principal) => (
114
+ <PrincipalRow
115
+ key={principal.id}
116
+ principal={principal}
117
+ onRevoke={() => void onRevoke(principal)}
118
+ />
119
+ ))}
120
+ {principals.length === 0 ? (
121
+ <li className="rounded-lg border border-dashed border-border-subtle p-4 text-center text-xs text-muted-foreground">
122
+ No active devices or agents.
123
+ </li>
124
+ ) : null}
125
+ </ul>
126
+
127
+ {error || mintError ? (
128
+ <p role="alert" className="mt-3 text-xs text-destructive">
129
+ {error ?? mintError}
130
+ </p>
131
+ ) : null}
132
+
133
+ <form onSubmit={onMint} className="mt-6 border-t border-border pt-4">
134
+ <h4 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
135
+ Agent access
136
+ </h4>
137
+ <p className="mt-1 text-xs text-muted-foreground">
138
+ Create a credential an agent can use until it expires or you revoke it.
139
+ </p>
140
+ <label className="mt-3 block text-xs font-medium text-foreground" htmlFor="agent-label">
141
+ Label
142
+ </label>
143
+ <input
144
+ id="agent-label"
145
+ value={label}
146
+ maxLength={MAX_LABEL_LENGTH}
147
+ onChange={(event) => setLabel(event.target.value)}
148
+ placeholder="Build agent"
149
+ className="mt-1 w-full rounded-lg border border-border-subtle bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary"
150
+ />
151
+ <label className="mt-3 block text-xs font-medium text-foreground" htmlFor="agent-ttl">
152
+ Expires after
153
+ </label>
154
+ <select
155
+ id="agent-ttl"
156
+ value={ttlDays}
157
+ onChange={(event) => setTtlDays(Number(event.target.value))}
158
+ className="mt-1 w-full rounded-lg border border-border-subtle bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary"
159
+ >
160
+ <option value={1}>1 day</option>
161
+ <option value={7}>7 days</option>
162
+ <option value={30}>30 days</option>
163
+ </select>
164
+ <button
165
+ type="submit"
166
+ disabled={busy || !label.trim()}
167
+ className="mt-4 flex w-full items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:brightness-110 disabled:opacity-50"
168
+ >
169
+ {busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
170
+ Create agent credential
171
+ </button>
172
+ </form>
173
+ </>
174
+ )}
175
+ </div>
176
+ );
177
+ }
178
+
179
+ function PrincipalRow({
180
+ principal,
181
+ onRevoke,
182
+ }: {
183
+ principal: PrincipalSummary;
184
+ onRevoke: () => void;
185
+ }) {
186
+ const agent = principal.kind === "agent";
187
+ const Icon = agent ? Bot : Laptop;
188
+ const kind = principal.is_own ? "This device" : agent ? "Agent" : "Device";
189
+
190
+ return (
191
+ <li
192
+ className={cn(
193
+ "rounded-lg border bg-surface p-3",
194
+ principal.is_own ? "border-primary/60" : "border-border-subtle"
195
+ )}
196
+ >
197
+ <div className="flex items-start gap-2">
198
+ <Icon className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
199
+ <div className="min-w-0 flex-1">
200
+ <div className="flex flex-wrap items-center gap-1.5">
201
+ <span className="break-words text-sm text-foreground">
202
+ {displayLabel(principal.label)}
203
+ </span>
204
+ <span className="rounded bg-surface-raised px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
205
+ {kind}
206
+ </span>
207
+ </div>
208
+ <dl className="mt-1.5 grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5 text-[11px] text-muted-foreground">
209
+ <dt>Created</dt>
210
+ <dd>{formatRelativeTime(principal.created_at)}</dd>
211
+ <dt>Last seen</dt>
212
+ <dd>
213
+ {principal.last_seen_at ? formatRelativeTime(principal.last_seen_at) : "Never"}
214
+ </dd>
215
+ <dt>Expires</dt>
216
+ <dd>{formatRelativeTime(principal.expires_at)}</dd>
217
+ </dl>
218
+ </div>
219
+ <button
220
+ type="button"
221
+ onClick={onRevoke}
222
+ aria-label={`Revoke ${displayLabel(principal.label)}`}
223
+ className="flex shrink-0 items-center gap-1 rounded px-1.5 py-1 text-xs text-muted-foreground transition-colors hover:text-destructive"
224
+ >
225
+ <Trash2 className="h-3.5 w-3.5" />
226
+ Revoke
227
+ </button>
228
+ </div>
229
+ </li>
230
+ );
231
+ }
@@ -0,0 +1,131 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import { Check, Copy } from "lucide-react";
4
+ import type { MintedAgent } from "../../lib/api-client.js";
5
+ import { usePrincipalStore } from "../../stores/principal-store.js";
6
+
7
+ /** App-level portal for the unrecoverable value returned by an agent mint. */
8
+ export function OneTimeAgentCredentialDialog() {
9
+ const credential = usePrincipalStore((state) => state.oneTimeCredential);
10
+ const acknowledge = usePrincipalStore(
11
+ (state) => state.acknowledgeAgentCredential
12
+ );
13
+
14
+ return credential ? (
15
+ <OneTimeCredential
16
+ key={credential.id}
17
+ credential={credential}
18
+ onDone={acknowledge}
19
+ />
20
+ ) : null;
21
+ }
22
+
23
+ function OneTimeCredential({
24
+ credential,
25
+ onDone,
26
+ }: {
27
+ credential: MintedAgent;
28
+ onDone: () => void;
29
+ }) {
30
+ const [copied, setCopied] = useState(false);
31
+ const [saved, setSaved] = useState(false);
32
+ const [copyError, setCopyError] = useState(false);
33
+ const dialogRef = useRef<HTMLDivElement>(null);
34
+
35
+ useEffect(() => {
36
+ const keepDialogOpen = (event: KeyboardEvent) => {
37
+ if (event.key === "Escape") {
38
+ event.preventDefault();
39
+ event.stopPropagation();
40
+ return;
41
+ }
42
+ if (event.key !== "Tab") return;
43
+ const focusable = Array.from(
44
+ dialogRef.current?.querySelectorAll<HTMLElement>(
45
+ 'button:not([disabled]), input:not([disabled])'
46
+ ) ?? []
47
+ );
48
+ if (focusable.length === 0) return;
49
+ const first = focusable[0]!;
50
+ const last = focusable[focusable.length - 1]!;
51
+ if (event.shiftKey && document.activeElement === first) {
52
+ event.preventDefault();
53
+ last.focus();
54
+ } else if (!event.shiftKey && document.activeElement === last) {
55
+ event.preventDefault();
56
+ first.focus();
57
+ }
58
+ };
59
+ window.addEventListener("keydown", keepDialogOpen, true);
60
+ return () => window.removeEventListener("keydown", keepDialogOpen, true);
61
+ }, []);
62
+
63
+ async function copyCredential() {
64
+ try {
65
+ await navigator.clipboard.writeText(credential.cookie);
66
+ setCopied(true);
67
+ setCopyError(false);
68
+ } catch {
69
+ setCopyError(true);
70
+ }
71
+ }
72
+
73
+ return createPortal(
74
+ <div
75
+ role="dialog"
76
+ aria-modal="true"
77
+ aria-labelledby="one-time-credential-title"
78
+ className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 p-4"
79
+ >
80
+ <div
81
+ ref={dialogRef}
82
+ className="w-full max-w-md rounded-xl border border-border bg-surface p-5 shadow-[0_16px_48px_rgba(0,0,0,0.5)]"
83
+ >
84
+ <h3 id="one-time-credential-title" className="text-base font-semibold text-foreground">
85
+ Save this credential now
86
+ </h3>
87
+ <p className="mt-2 text-sm font-medium text-destructive">
88
+ This is the only time you will see this value.
89
+ </p>
90
+ <p className="mt-1 text-xs text-muted-foreground">
91
+ Store it somewhere safe before continuing. It cannot be recovered later.
92
+ </p>
93
+ <div className="mt-4 rounded-lg border border-border-subtle bg-background p-3">
94
+ <code className="block break-all text-xs text-foreground">{credential.cookie}</code>
95
+ </div>
96
+ <button
97
+ autoFocus
98
+ type="button"
99
+ onClick={() => void copyCredential()}
100
+ className="mt-3 flex w-full items-center justify-center gap-2 rounded-lg border border-border-subtle bg-surface px-4 py-2 text-sm font-medium text-foreground transition-colors hover:border-primary"
101
+ >
102
+ {copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
103
+ {copied ? "Copied" : "Copy credential"}
104
+ </button>
105
+ {copyError ? (
106
+ <p role="alert" className="mt-2 text-xs text-destructive">
107
+ Copy failed. Select the value above and copy it manually.
108
+ </p>
109
+ ) : null}
110
+ <label className="mt-4 flex items-start gap-2 text-xs text-foreground">
111
+ <input
112
+ type="checkbox"
113
+ checked={saved}
114
+ onChange={(event) => setSaved(event.target.checked)}
115
+ className="mt-0.5"
116
+ />
117
+ I have saved this credential somewhere safe.
118
+ </label>
119
+ <button
120
+ type="button"
121
+ disabled={!saved}
122
+ onClick={onDone}
123
+ className="mt-3 w-full rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:brightness-110 disabled:opacity-50"
124
+ >
125
+ Done
126
+ </button>
127
+ </div>
128
+ </div>,
129
+ document.body
130
+ );
131
+ }
@@ -1,8 +1,9 @@
1
1
  import { Suspense, lazy } from "react";
2
- import { KeyRound, Puzzle, SlidersHorizontal } from "lucide-react";
2
+ import { KeyRound, Laptop, Puzzle, SlidersHorizontal } from "lucide-react";
3
3
  import { useUIStore, type SettingsTab } from "../../stores/ui-store.js";
4
4
  import { SlidePanel } from "../layout/slide-panel.js";
5
5
  import { cn } from "../../lib/utils.js";
6
+ import { usePrincipalStore } from "../../stores/principal-store.js";
6
7
 
7
8
  /**
8
9
  * Each tab is fetched the first time it is opened. Settings is the largest
@@ -13,12 +14,14 @@ import { cn } from "../../lib/utils.js";
13
14
  */
14
15
  const ModelsTab = lazy(() => import("./models-tab.js").then((m) => ({ default: m.ModelsTab })));
15
16
  const PasskeyTab = lazy(() => import("./passkey-tab.js").then((m) => ({ default: m.PasskeyTab })));
17
+ const DevicesAgentsTab = lazy(() => import("./devices-agents-tab.js").then((m) => ({ default: m.DevicesAgentsTab })));
16
18
  const SkillsTab = lazy(() => import("./skills-tab.js").then((m) => ({ default: m.SkillsTab })));
17
19
 
18
20
  const TABS: Array<{ id: SettingsTab; label: string; icon: typeof KeyRound }> = [
19
21
  { id: "models", label: "Models", icon: SlidersHorizontal },
20
22
  { id: "skills", label: "Skills", icon: Puzzle },
21
23
  { id: "security", label: "Security", icon: KeyRound },
24
+ { id: "devices", label: "Devices & agents", icon: Laptop },
22
25
  ];
23
26
 
24
27
  /**
@@ -35,19 +38,29 @@ export function SettingsPanel({
35
38
  }) {
36
39
  const tab = useUIStore((s) => s.settingsTab);
37
40
  const setTab = useUIStore((s) => s.setSettingsTab);
41
+ const mintPending = usePrincipalStore((s) => s.mintPending);
42
+ const oneTimeCredential = usePrincipalStore((s) => s.oneTimeCredential);
43
+ const credentialProtected = mintPending || oneTimeCredential !== null;
44
+
45
+ function closeIfSafe() {
46
+ if (!credentialProtected) onClose();
47
+ }
38
48
 
39
49
  return (
40
- <SlidePanel open={open} onClose={onClose} title="Settings">
50
+ <SlidePanel open={open} onClose={closeIfSafe} title="Settings" wide>
41
51
  <div className="flex h-full flex-col">
42
52
  <div className="flex shrink-0 gap-1 border-b border-border px-2 pt-2">
43
53
  {TABS.map(({ id, label, icon: Icon }) => (
44
54
  <button
45
55
  key={id}
46
- onClick={() => setTab(id)}
56
+ onClick={() => {
57
+ if (!credentialProtected) setTab(id);
58
+ }}
59
+ disabled={credentialProtected && tab !== id}
47
60
  aria-selected={tab === id}
48
61
  role="tab"
49
62
  className={cn(
50
- "flex items-center gap-1.5 rounded-t-lg px-3 py-2 text-xs font-medium transition-colors",
63
+ "flex items-center gap-1.5 rounded-t-lg px-3 py-2 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50",
51
64
  tab === id
52
65
  ? "border-b-2 border-primary text-foreground"
53
66
  : "text-muted-foreground hover:text-foreground"
@@ -65,8 +78,10 @@ export function SettingsPanel({
65
78
  <ModelsTab active={open && tab === "models"} />
66
79
  ) : tab === "skills" ? (
67
80
  <SkillsTab active={open && tab === "skills"} />
68
- ) : (
81
+ ) : tab === "security" ? (
69
82
  <PasskeyTab active={open && tab === "security"} />
83
+ ) : (
84
+ <DevicesAgentsTab active={open && tab === "devices"} />
70
85
  )}
71
86
  </Suspense>
72
87
  </div>
@@ -1,4 +1,4 @@
1
- import { useEffect } from "react";
1
+ import { useEffect, useState } from "react";
2
2
  import {
3
3
  useConnectionStore,
4
4
  type VpnStatus,
@@ -39,6 +39,8 @@ export function recheckVpnStatus(): void {
39
39
  }
40
40
 
41
41
  export function useVpnStatus() {
42
+ const [successfulProbeCount, setSuccessfulProbeCount] = useState(0);
43
+
42
44
  useEffect(() => {
43
45
  let timer: ReturnType<typeof setTimeout> | undefined;
44
46
  let inFlight = false;
@@ -64,6 +66,9 @@ export function useVpnStatus() {
64
66
  useConnectionStore.getState().socketOpens !== opensAtStart;
65
67
  if (!disposed && !(socketOpenedDuringCheck && status !== "connected")) {
66
68
  useConnectionStore.getState().setVpnStatus(status);
69
+ if (status === "connected") {
70
+ setSuccessfulProbeCount((count) => count + 1);
71
+ }
67
72
  }
68
73
  } finally {
69
74
  inFlight = false;
@@ -105,4 +110,6 @@ export function useVpnStatus() {
105
110
  document.removeEventListener("visibilitychange", onVisible);
106
111
  };
107
112
  }, []);
113
+
114
+ return successfulProbeCount;
108
115
  }
@@ -37,9 +37,11 @@ function isOurDraftAnnouncement(
37
37
  state: ReturnType<typeof useChatStore.getState>,
38
38
  msg: ServerMessage
39
39
  ): boolean {
40
- if (msg.type !== "session_info" && msg.type !== "result") return false;
40
+ // A terminal result has no draft correlation. Another client's turn may
41
+ // finish before our session_info arrives, so it can never claim our draft.
42
+ if (msg.type !== "session_info") return false;
41
43
  const pending = state.pendingDraftId;
42
- if (msg.type === "session_info" && msg.draftId) return msg.draftId === pending;
44
+ if (msg.draftId) return msg.draftId === pending;
43
45
  // No echo to compare against.
44
46
  return true;
45
47
  }