@schlessera/brain-ui-react 0.34.0 → 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.
- package/README.md +12 -0
- package/dist/components/activity/activity-page.d.ts.map +1 -1
- package/dist/components/activity/activity-page.js +8 -149
- package/dist/components/activity/activity-page.js.map +1 -1
- package/dist/components/activity/activity-rollups.d.ts +5 -0
- package/dist/components/activity/activity-rollups.d.ts.map +1 -0
- package/dist/components/activity/activity-rollups.js +39 -0
- package/dist/components/activity/activity-rollups.js.map +1 -0
- package/dist/components/activity/activity-run-detail.d.ts +11 -0
- package/dist/components/activity/activity-run-detail.d.ts.map +1 -0
- package/dist/components/activity/activity-run-detail.js +111 -0
- package/dist/components/activity/activity-run-detail.js.map +1 -0
- package/dist/components/activity/activity-run-list.d.ts +15 -0
- package/dist/components/activity/activity-run-list.d.ts.map +1 -0
- package/dist/components/activity/activity-run-list.js +25 -0
- package/dist/components/activity/activity-run-list.js.map +1 -0
- package/dist/components/chat/brain-markdown-code.d.ts +4 -0
- package/dist/components/chat/brain-markdown-code.d.ts.map +1 -0
- package/dist/components/chat/brain-markdown-code.js +47 -0
- package/dist/components/chat/brain-markdown-code.js.map +1 -0
- package/dist/components/chat/brain-markdown-entities.d.ts +14 -0
- package/dist/components/chat/brain-markdown-entities.d.ts.map +1 -0
- package/dist/components/chat/brain-markdown-entities.js +93 -0
- package/dist/components/chat/brain-markdown-entities.js.map +1 -0
- package/dist/components/chat/brain-markdown-links.d.ts +36 -0
- package/dist/components/chat/brain-markdown-links.d.ts.map +1 -0
- package/dist/components/chat/brain-markdown-links.js +176 -0
- package/dist/components/chat/brain-markdown-links.js.map +1 -0
- package/dist/components/chat/brain-markdown-share.d.ts +15 -0
- package/dist/components/chat/brain-markdown-share.d.ts.map +1 -0
- package/dist/components/chat/brain-markdown-share.js +40 -0
- package/dist/components/chat/brain-markdown-share.js.map +1 -0
- package/dist/components/chat/brain-markdown.d.ts +1 -34
- package/dist/components/chat/brain-markdown.d.ts.map +1 -1
- package/dist/components/chat/brain-markdown.js +9 -402
- package/dist/components/chat/brain-markdown.js.map +1 -1
- package/dist/components/chat/composer.d.ts.map +1 -1
- package/dist/components/chat/composer.js +14 -1
- package/dist/components/chat/composer.js.map +1 -1
- package/dist/components/chat/session-drawer.d.ts.map +1 -1
- package/dist/components/chat/session-drawer.js +25 -10
- package/dist/components/chat/session-drawer.js.map +1 -1
- package/dist/components/chat/tool-views.d.ts +2 -2
- package/dist/components/chat/tool-views.d.ts.map +1 -1
- package/dist/components/chat/tool-views.js +2 -16
- package/dist/components/chat/tool-views.js.map +1 -1
- package/dist/components/chat/use-markdown-highlight.d.ts +12 -0
- package/dist/components/chat/use-markdown-highlight.d.ts.map +1 -0
- package/dist/components/chat/use-markdown-highlight.js +56 -0
- package/dist/components/chat/use-markdown-highlight.js.map +1 -0
- package/dist/components/connectivity/connection-gate.d.ts.map +1 -1
- package/dist/components/connectivity/connection-gate.js +94 -8
- package/dist/components/connectivity/connection-gate.js.map +1 -1
- package/dist/components/connectivity/connection-state.d.ts +12 -0
- package/dist/components/connectivity/connection-state.d.ts.map +1 -0
- package/dist/components/connectivity/connection-state.js +23 -0
- package/dist/components/connectivity/connection-state.js.map +1 -0
- package/dist/components/graph/graph-canvas-lazy.d.ts +2 -0
- package/dist/components/graph/graph-canvas-lazy.d.ts.map +1 -0
- package/dist/components/graph/graph-canvas-lazy.js +4 -0
- package/dist/components/graph/graph-canvas-lazy.js.map +1 -0
- package/dist/components/graph/graph-controls.js +4 -1
- package/dist/components/graph/graph-controls.js.map +1 -1
- package/dist/components/graph/graph-maintenance.d.ts +8 -0
- package/dist/components/graph/graph-maintenance.d.ts.map +1 -0
- package/dist/components/graph/graph-maintenance.js +115 -0
- package/dist/components/graph/graph-maintenance.js.map +1 -0
- package/dist/components/graph/graph-page.d.ts.map +1 -1
- package/dist/components/graph/graph-page.js +11 -306
- package/dist/components/graph/graph-page.js.map +1 -1
- package/dist/components/graph/graph-scene.d.ts +8 -0
- package/dist/components/graph/graph-scene.d.ts.map +1 -0
- package/dist/components/graph/graph-scene.js +208 -0
- package/dist/components/graph/graph-scene.js.map +1 -0
- package/dist/components/graph/graph-spinner.d.ts +2 -0
- package/dist/components/graph/graph-spinner.d.ts.map +1 -0
- package/dist/components/graph/graph-spinner.js +6 -0
- package/dist/components/graph/graph-spinner.js.map +1 -0
- package/dist/components/layout/app-shell.d.ts.map +1 -1
- package/dist/components/layout/app-shell.js +2 -1
- package/dist/components/layout/app-shell.js.map +1 -1
- package/dist/components/quick-actions/add-modal.d.ts.map +1 -1
- package/dist/components/quick-actions/add-modal.js +47 -4
- package/dist/components/quick-actions/add-modal.js.map +1 -1
- package/dist/components/quick-actions/search-modal.d.ts.map +1 -1
- package/dist/components/quick-actions/search-modal.js +46 -18
- package/dist/components/quick-actions/search-modal.js.map +1 -1
- package/dist/components/quick-actions/streaming-modal.d.ts.map +1 -1
- package/dist/components/quick-actions/streaming-modal.js +4 -1
- package/dist/components/quick-actions/streaming-modal.js.map +1 -1
- package/dist/components/settings/devices-agents-tab.d.ts +5 -0
- package/dist/components/settings/devices-agents-tab.d.ts.map +1 -0
- package/dist/components/settings/devices-agents-tab.js +96 -0
- package/dist/components/settings/devices-agents-tab.js.map +1 -0
- package/dist/components/settings/one-time-agent-credential.d.ts +3 -0
- package/dist/components/settings/one-time-agent-credential.d.ts.map +1 -0
- package/dist/components/settings/one-time-agent-credential.js +55 -0
- package/dist/components/settings/one-time-agent-credential.js.map +1 -0
- package/dist/components/settings/settings-panel.d.ts.map +1 -1
- package/dist/components/settings/settings-panel.js +16 -3
- package/dist/components/settings/settings-panel.js.map +1 -1
- package/dist/hooks/use-vpn-status.d.ts +6 -1
- package/dist/hooks/use-vpn-status.d.ts.map +1 -1
- package/dist/hooks/use-vpn-status.js +67 -30
- package/dist/hooks/use-vpn-status.js.map +1 -1
- package/dist/hooks/use-websocket.d.ts +3 -2
- package/dist/hooks/use-websocket.d.ts.map +1 -1
- package/dist/hooks/use-websocket.js +65 -254
- package/dist/hooks/use-websocket.js.map +1 -1
- package/dist/hooks/websocket-handlers/activity.d.ts +5 -0
- package/dist/hooks/websocket-handlers/activity.d.ts.map +1 -0
- package/dist/hooks/websocket-handlers/activity.js +10 -0
- package/dist/hooks/websocket-handlers/activity.js.map +1 -0
- package/dist/hooks/websocket-handlers/chat.d.ts +17 -0
- package/dist/hooks/websocket-handlers/chat.d.ts.map +1 -0
- package/dist/hooks/websocket-handlers/chat.js +175 -0
- package/dist/hooks/websocket-handlers/chat.js.map +1 -0
- package/dist/hooks/websocket-handlers/connection.d.ts +6 -0
- package/dist/hooks/websocket-handlers/connection.d.ts.map +1 -0
- package/dist/hooks/websocket-handlers/connection.js +27 -0
- package/dist/hooks/websocket-handlers/connection.js.map +1 -0
- package/dist/hooks/websocket-handlers/index.d.ts +4 -0
- package/dist/hooks/websocket-handlers/index.d.ts.map +1 -0
- package/dist/hooks/websocket-handlers/index.js +19 -0
- package/dist/hooks/websocket-handlers/index.js.map +1 -0
- package/dist/hooks/websocket-handlers/mask.d.ts +4 -0
- package/dist/hooks/websocket-handlers/mask.d.ts.map +1 -0
- package/dist/hooks/websocket-handlers/mask.js +14 -0
- package/dist/hooks/websocket-handlers/mask.js.map +1 -0
- package/dist/hooks/websocket-handlers/provider.d.ts +4 -0
- package/dist/hooks/websocket-handlers/provider.d.ts.map +1 -0
- package/dist/hooks/websocket-handlers/provider.js +31 -0
- package/dist/hooks/websocket-handlers/provider.js.map +1 -0
- package/dist/hooks/websocket-handlers/types.d.ts +24 -0
- package/dist/hooks/websocket-handlers/types.d.ts.map +1 -0
- package/dist/hooks/websocket-handlers/types.js +2 -0
- package/dist/hooks/websocket-handlers/types.js.map +1 -0
- package/dist/lib/api-client.d.ts +32 -0
- package/dist/lib/api-client.d.ts.map +1 -1
- package/dist/lib/api-client.js +11 -0
- package/dist/lib/api-client.js.map +1 -1
- package/dist/lib/format-time.d.ts +3 -0
- package/dist/lib/format-time.d.ts.map +1 -0
- package/dist/lib/format-time.js +25 -0
- package/dist/lib/format-time.js.map +1 -0
- package/dist/lib/push-registration.d.ts +7 -0
- package/dist/lib/push-registration.d.ts.map +1 -0
- package/dist/lib/push-registration.js +26 -0
- package/dist/lib/push-registration.js.map +1 -0
- package/dist/stores/activity-store-helpers.d.ts +50 -0
- package/dist/stores/activity-store-helpers.d.ts.map +1 -0
- package/dist/stores/activity-store-helpers.js +143 -0
- package/dist/stores/activity-store-helpers.js.map +1 -0
- package/dist/stores/activity-store-types.d.ts +48 -0
- package/dist/stores/activity-store-types.d.ts.map +1 -0
- package/dist/stores/activity-store-types.js +2 -0
- package/dist/stores/activity-store-types.js.map +1 -0
- package/dist/stores/activity-store.d.ts +2 -78
- package/dist/stores/activity-store.d.ts.map +1 -1
- package/dist/stores/activity-store.js +22 -142
- package/dist/stores/activity-store.js.map +1 -1
- package/dist/stores/connection-store.d.ts +14 -2
- package/dist/stores/connection-store.d.ts.map +1 -1
- package/dist/stores/connection-store.js +17 -0
- package/dist/stores/connection-store.js.map +1 -1
- package/dist/stores/file-store.d.ts.map +1 -1
- package/dist/stores/file-store.js +54 -18
- package/dist/stores/file-store.js.map +1 -1
- package/dist/stores/principal-store.d.ts +16 -0
- package/dist/stores/principal-store.d.ts.map +1 -0
- package/dist/stores/principal-store.js +29 -0
- package/dist/stores/principal-store.js.map +1 -0
- package/dist/stores/ui-store.d.ts +1 -1
- package/dist/stores/ui-store.d.ts.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +2 -2
- package/src/components/activity/activity-page.tsx +6 -477
- package/src/components/activity/activity-rollups.tsx +94 -0
- package/src/components/activity/activity-run-detail.tsx +301 -0
- package/src/components/activity/activity-run-list.tsx +97 -0
- package/src/components/chat/brain-markdown-code.tsx +60 -0
- package/src/components/chat/brain-markdown-entities.tsx +116 -0
- package/src/components/chat/brain-markdown-links.tsx +247 -0
- package/src/components/chat/brain-markdown-share.ts +47 -0
- package/src/components/chat/brain-markdown.tsx +27 -535
- package/src/components/chat/composer.tsx +14 -1
- package/src/components/chat/session-drawer.tsx +29 -10
- package/src/components/chat/tool-views.tsx +2 -13
- package/src/components/chat/use-markdown-highlight.ts +63 -0
- package/src/components/connectivity/connection-gate.tsx +128 -14
- package/src/components/connectivity/connection-state.ts +37 -0
- package/src/components/graph/graph-canvas-lazy.tsx +4 -0
- package/src/components/graph/graph-controls.tsx +4 -1
- package/src/components/graph/graph-maintenance.tsx +304 -0
- package/src/components/graph/graph-page.tsx +11 -840
- package/src/components/graph/graph-scene.tsx +548 -0
- package/src/components/graph/graph-spinner.tsx +9 -0
- package/src/components/layout/app-shell.tsx +2 -0
- package/src/components/quick-actions/add-modal.tsx +49 -4
- package/src/components/quick-actions/search-modal.tsx +46 -18
- package/src/components/quick-actions/streaming-modal.tsx +3 -1
- package/src/components/settings/devices-agents-tab.tsx +231 -0
- package/src/components/settings/one-time-agent-credential.tsx +131 -0
- package/src/components/settings/settings-panel.tsx +20 -5
- package/src/hooks/use-vpn-status.ts +71 -31
- package/src/hooks/use-websocket.ts +74 -275
- package/src/hooks/websocket-handlers/activity.ts +13 -0
- package/src/hooks/websocket-handlers/chat.ts +210 -0
- package/src/hooks/websocket-handlers/connection.ts +30 -0
- package/src/hooks/websocket-handlers/index.ts +59 -0
- package/src/hooks/websocket-handlers/mask.ts +17 -0
- package/src/hooks/websocket-handlers/provider.ts +35 -0
- package/src/hooks/websocket-handlers/types.ts +27 -0
- package/src/lib/api-client.ts +40 -1
- package/src/lib/format-time.ts +20 -0
- package/src/lib/push-registration.ts +29 -0
- package/src/stores/activity-store-helpers.ts +164 -0
- package/src/stores/activity-store-types.ts +55 -0
- package/src/stores/activity-store.ts +21 -217
- package/src/stores/connection-store.ts +33 -2
- package/src/stores/file-store.ts +42 -12
- package/src/stores/principal-store.ts +38 -0
- 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
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
controllerRef.current = null;
|
|
119
|
+
cancelSearch();
|
|
94
120
|
setResults([]);
|
|
95
121
|
setWarnings([]);
|
|
96
122
|
setState("idle");
|
|
97
123
|
return;
|
|
98
124
|
}
|
|
99
|
-
|
|
100
|
-
return
|
|
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 (
|
|
141
|
+
if (currentResults.length === 0) return;
|
|
114
142
|
e.preventDefault();
|
|
115
143
|
const delta = e.key === "ArrowDown" ? 1 : -1;
|
|
116
|
-
const next = (selected + delta +
|
|
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 =
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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 & 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={
|
|
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={() =>
|
|
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>
|