@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
@@ -81,6 +81,26 @@ export interface BackendInfo {
81
81
  };
82
82
  }
83
83
 
84
+ /** One active browser/device or delegated agent credential. */
85
+ export interface PrincipalSummary {
86
+ id: string;
87
+ kind: "owner" | "agent" | "ambient" | "system";
88
+ auth_method: "password" | "passkey" | "delegated" | "ambient";
89
+ label: string;
90
+ created_at: number;
91
+ expires_at: number;
92
+ last_seen_at: number | null;
93
+ is_own: boolean;
94
+ }
95
+
96
+ /** The response to minting an agent. `cookie` is intentionally returned once. */
97
+ export interface MintedAgent {
98
+ id: string;
99
+ label: string;
100
+ expiresAt: number;
101
+ cookie: string;
102
+ }
103
+
84
104
  /** Auth status of one configured pi provider (mirror of the server view). */
85
105
  export interface PiAuthProviderStatus {
86
106
  providerId: string;
@@ -242,13 +262,18 @@ export const api = {
242
262
  }),
243
263
 
244
264
  brainAdd: (content: string, opts?: { type?: string; title?: string; tags?: string[] }) =>
245
- fetchJson<{ success: boolean }>("/brain/add", {
265
+ fetchJson<{ success: boolean; path?: string; indexed?: boolean; indexError?: string }>("/brain/add", {
246
266
  method: "POST",
247
267
  body: JSON.stringify({ content, ...opts }),
248
268
  }),
249
269
 
270
+ brainIndex: () => fetchJson<{ success: boolean }>("/brain/index", {
271
+ method: "POST", body: "{}",
272
+ }),
273
+
250
274
  sessions: () =>
251
275
  fetchJson<{
276
+ unavailableBackends?: string[];
252
277
  sessions: Array<{
253
278
  id: string;
254
279
  title: string | null;
@@ -448,6 +473,20 @@ export const api = {
448
473
  logout: () =>
449
474
  fetchJson<{ ok: true }>("/auth/logout", { method: "POST" }),
450
475
 
476
+ principals: () =>
477
+ fetchJson<{ principals: PrincipalSummary[] }>("/auth/principals"),
478
+
479
+ principalMint: (label: string, ttlDays: number) =>
480
+ fetchJson<MintedAgent>("/auth/principals", {
481
+ method: "POST",
482
+ body: JSON.stringify({ label, ttlDays }),
483
+ }),
484
+
485
+ principalRevoke: (id: string) =>
486
+ fetchJson<{ ok: true }>(`/auth/principals/${encodeURIComponent(id)}`, {
487
+ method: "DELETE",
488
+ }),
489
+
451
490
  /** Which login methods the server offers (public; drives the login screen). */
452
491
  authMethods: () =>
453
492
  fetchJson<{ password: boolean; passkey: boolean }>("/auth/methods"),
@@ -0,0 +1,20 @@
1
+ /** Relative timestamp for list rows ("3m ago"); dates beyond a week. */
2
+ export function formatRelativeTime(ms: number): string {
3
+ const diff = Date.now() - ms;
4
+ const future = diff < 0;
5
+ const distance = Math.abs(diff);
6
+ if (distance < 60_000) return future ? "in less than a minute" : "just now";
7
+ const minutes = future
8
+ ? Math.ceil(distance / 60_000)
9
+ : Math.floor(distance / 60_000);
10
+ if (minutes < 60) return future ? `in ${minutes}m` : `${minutes}m ago`;
11
+ const hours = future
12
+ ? Math.ceil(distance / 3_600_000)
13
+ : Math.floor(distance / 3_600_000);
14
+ if (hours < 24) return future ? `in ${hours}h` : `${hours}h ago`;
15
+ const days = future
16
+ ? Math.ceil(distance / 86_400_000)
17
+ : Math.floor(distance / 86_400_000);
18
+ if (days < 7) return future ? `in ${days}d` : `${days}d ago`;
19
+ return new Date(ms).toLocaleDateString();
20
+ }
@@ -0,0 +1,29 @@
1
+ import { api } from "./api-client.js";
2
+
3
+ /**
4
+ * Re-assert an existing browser subscription after authentication changes.
5
+ * This does not request permission or create a subscription: it only binds an
6
+ * already-enabled endpoint to the newly issued server principal.
7
+ */
8
+ export async function rebindPushSubscriptionAfterLogin(): Promise<void> {
9
+ if (
10
+ typeof Notification === "undefined" ||
11
+ Notification.permission !== "granted" ||
12
+ typeof navigator === "undefined" ||
13
+ !("serviceWorker" in navigator)
14
+ ) {
15
+ return;
16
+ }
17
+
18
+ // Capture before awaiting: the page may begin navigating immediately after
19
+ // login while the service-worker readiness promise settles.
20
+ const serviceWorker = navigator.serviceWorker;
21
+ const label =
22
+ typeof navigator.userAgent === "string"
23
+ ? navigator.userAgent.slice(0, 100)
24
+ : undefined;
25
+ const registration = await serviceWorker.ready;
26
+ const subscription = await registration.pushManager.getSubscription();
27
+ if (!subscription) return;
28
+ await api.pushSubscribe(subscription.toJSON(), label);
29
+ }
@@ -86,8 +86,8 @@ async function fetchTree(path: string): Promise<FileEntry[]> {
86
86
  return data.entries;
87
87
  }
88
88
 
89
- async function fetchContent(path: string): Promise<FileContentResponse> {
90
- const res = await fetch(`${apiBase()}/files/content?path=${encodeURIComponent(path)}`);
89
+ async function fetchContent(path: string, signal: AbortSignal): Promise<FileContentResponse> {
90
+ const res = await fetch(`${apiBase()}/files/content?path=${encodeURIComponent(path)}`, { signal });
91
91
  if (!res.ok) {
92
92
  const body = await res.json().catch(() => ({ error: res.statusText }));
93
93
  const err = new Error(body.error || `HTTP ${res.status}`);
@@ -98,8 +98,8 @@ async function fetchContent(path: string): Promise<FileContentResponse> {
98
98
  return res.json();
99
99
  }
100
100
 
101
- async function fetchResolve(path: string): Promise<FileResolveResponse> {
102
- const res = await fetch(`${apiBase()}/files/resolve?path=${encodeURIComponent(path)}`);
101
+ async function fetchResolve(path: string, signal: AbortSignal): Promise<FileResolveResponse> {
102
+ const res = await fetch(`${apiBase()}/files/resolve?path=${encodeURIComponent(path)}`, { signal });
103
103
  if (!res.ok) {
104
104
  const body = await res.json().catch(() => ({ error: res.statusText }));
105
105
  throw new Error(body.error || `HTTP ${res.status}`);
@@ -116,6 +116,10 @@ async function fetchWikilinks(): Promise<WikilinkMapResponse> {
116
116
  return res.json();
117
117
  }
118
118
 
119
+ let fileRequest: AbortController | null = null;
120
+ let directoryRequest: AbortController | null = null;
121
+ let cacheEpoch = 0;
122
+
119
123
  export const useFileStore = create<FileState>((set, get) => ({
120
124
  dirCache: {},
121
125
  loadingDirs: new Set(),
@@ -135,6 +139,7 @@ export const useFileStore = create<FileState>((set, get) => ({
135
139
  wikilinkLoading: false,
136
140
 
137
141
  loadDir: async (path) => {
142
+ const epoch = cacheEpoch;
138
143
  const { dirCache, loadingDirs } = get();
139
144
  if (dirCache[path] || loadingDirs.has(path)) return;
140
145
  const next = new Set(loadingDirs);
@@ -142,6 +147,7 @@ export const useFileStore = create<FileState>((set, get) => ({
142
147
  set({ loadingDirs: next });
143
148
  try {
144
149
  const entries = await fetchTree(path);
150
+ if (epoch !== cacheEpoch) return;
145
151
  set((s) => {
146
152
  const loading = new Set(s.loadingDirs);
147
153
  loading.delete(path);
@@ -154,6 +160,7 @@ export const useFileStore = create<FileState>((set, get) => ({
154
160
  };
155
161
  });
156
162
  } catch (err) {
163
+ if (epoch !== cacheEpoch) return;
157
164
  set((s) => {
158
165
  const loading = new Set(s.loadingDirs);
159
166
  loading.delete(path);
@@ -189,13 +196,19 @@ export const useFileStore = create<FileState>((set, get) => ({
189
196
  },
190
197
 
191
198
  openFile: async (path) => {
199
+ fileRequest?.abort();
200
+ directoryRequest?.abort();
201
+ const request = new AbortController();
202
+ fileRequest = request;
203
+ const ownsRequest = () => fileRequest === request && !request.signal.aborted;
192
204
  const normalized = path.replace(/^\/+/, "").replace(/\\/g, "/");
193
205
  set({ currentPath: normalized, contentLoading: true, contentError: null, currentContent: null, treeExpanded: false });
194
206
 
195
207
  // Fire-and-await: load ancestor chain then content in parallel
196
208
  const ancestorsPromise = (async () => {
197
209
  try {
198
- const r = await fetchResolve(normalized);
210
+ const r = await fetchResolve(normalized, request.signal);
211
+ if (!ownsRequest()) return;
199
212
  // Expand each ancestor and root, load their contents
200
213
  const dirsToLoad = ["", ...r.ancestors];
201
214
  set((s) => {
@@ -210,12 +223,12 @@ export const useFileStore = create<FileState>((set, get) => ({
210
223
  })();
211
224
 
212
225
  try {
213
- const content = await fetchContent(normalized);
226
+ const content = await fetchContent(normalized, request.signal);
214
227
  // Drop a response the user has already navigated away from. Two rapid
215
228
  // clicks race, and without this the SLOWER fetch wins: the viewer showed
216
229
  // the newer file's path with the older file's content, which reads as
217
230
  // corruption rather than as a stale load.
218
- if (get().currentPath !== normalized) return;
231
+ if (!ownsRequest()) return;
219
232
  set({ currentContent: content, contentLoading: false });
220
233
  // Default mode: prefer preview when available; persist otherwise
221
234
  const { viewMode } = get();
@@ -233,7 +246,7 @@ export const useFileStore = create<FileState>((set, get) => ({
233
246
  else if (e.message === "invalid_path") msg = "Invalid path.";
234
247
  // Same guard: a failure for a file the user already left must not
235
248
  // replace the file they are now looking at with an error.
236
- if (get().currentPath !== normalized) return;
249
+ if (!ownsRequest()) return;
237
250
  set({ contentError: msg, contentLoading: false });
238
251
  } finally {
239
252
  await ancestorsPromise;
@@ -241,10 +254,15 @@ export const useFileStore = create<FileState>((set, get) => ({
241
254
  },
242
255
 
243
256
  closeFile: () => {
244
- set({ currentPath: null, currentContent: null, contentError: null, treeExpanded: true });
257
+ fileRequest?.abort();
258
+ fileRequest = null;
259
+ set({ currentPath: null, currentContent: null, contentError: null, contentLoading: false, treeExpanded: true });
245
260
  },
246
261
 
247
262
  openDir: async (path) => {
263
+ directoryRequest?.abort();
264
+ const request = new AbortController();
265
+ directoryRequest = request;
248
266
  const normalized = path
249
267
  .replace(/^\/+/, "")
250
268
  .replace(/\\/g, "/")
@@ -258,7 +276,8 @@ export const useFileStore = create<FileState>((set, get) => ({
258
276
  set({ treeExpanded: true, highlightedDir: normalized });
259
277
 
260
278
  try {
261
- const r = await fetchResolve(normalized);
279
+ const r = await fetchResolve(normalized, request.signal);
280
+ if (directoryRequest !== request || request.signal.aborted) return;
262
281
  const dirs = ["", ...r.ancestors, normalized];
263
282
  set((s) => {
264
283
  const next = new Set(s.expandedDirs);
@@ -278,11 +297,13 @@ export const useFileStore = create<FileState>((set, get) => ({
278
297
  setHighlightedDir: (path) => set({ highlightedDir: path }),
279
298
 
280
299
  ensureWikilinks: async () => {
300
+ const epoch = cacheEpoch;
281
301
  const { wikilinkLoaded, wikilinkLoading } = get();
282
302
  if (wikilinkLoaded || wikilinkLoading) return;
283
303
  set({ wikilinkLoading: true });
284
304
  try {
285
305
  const data = await fetchWikilinks();
306
+ if (epoch !== cacheEpoch) return;
286
307
  set({
287
308
  wikilinkMap: data.slugs,
288
309
  wikilinkLoaded: true,
@@ -290,6 +311,7 @@ export const useFileStore = create<FileState>((set, get) => ({
290
311
  });
291
312
  } catch {
292
313
  // Soft-fail — leave the map empty; wikilinks will render as plain text
314
+ if (epoch !== cacheEpoch) return;
293
315
  set({ wikilinkLoading: false });
294
316
  }
295
317
  },
@@ -304,15 +326,23 @@ export const useFileStore = create<FileState>((set, get) => ({
304
326
  return { frontmatterCollapsed: next };
305
327
  }),
306
328
 
307
- reset: () =>
329
+ reset: () => {
330
+ fileRequest?.abort();
331
+ directoryRequest?.abort();
332
+ fileRequest = null;
333
+ directoryRequest = null;
334
+ cacheEpoch++;
308
335
  set({
336
+ dirCache: {}, loadingDirs: new Set(), dirErrors: {}, expandedDirs: new Set([""]),
337
+ highlightedDir: null, wikilinkMap: {}, wikilinkLoaded: false, wikilinkLoading: false,
309
338
  currentPath: null,
310
339
  currentContent: null,
311
340
  contentLoading: false,
312
341
  contentError: null,
313
342
  viewMode: "preview",
314
343
  treeExpanded: true,
315
- }),
344
+ });
345
+ },
316
346
  }));
317
347
 
318
348
  /**
@@ -0,0 +1,38 @@
1
+ import { create } from "zustand";
2
+ import { api, type MintedAgent } from "../lib/api-client.js";
3
+
4
+ interface PrincipalState {
5
+ mintPending: boolean;
6
+ mintError: string | null;
7
+ oneTimeCredential: MintedAgent | null;
8
+ mintAgent: (label: string, ttlDays: number) => Promise<void>;
9
+ acknowledgeAgentCredential: () => void;
10
+ }
11
+
12
+ /**
13
+ * Mint state lives above Settings because the response contains an
14
+ * unrecoverable, one-time value. Closing a panel or changing views must not
15
+ * discard either the request in flight or the value it eventually returns.
16
+ */
17
+ export const usePrincipalStore = create<PrincipalState>((set, get) => ({
18
+ mintPending: false,
19
+ mintError: null,
20
+ oneTimeCredential: null,
21
+
22
+ mintAgent: async (label, ttlDays) => {
23
+ if (get().mintPending || get().oneTimeCredential) return;
24
+ set({ mintPending: true, mintError: null });
25
+ try {
26
+ const oneTimeCredential = await api.principalMint(label, ttlDays);
27
+ set({ mintPending: false, oneTimeCredential });
28
+ } catch (error) {
29
+ set({
30
+ mintPending: false,
31
+ mintError:
32
+ error instanceof Error ? error.message : "Could not create agent access",
33
+ });
34
+ }
35
+ },
36
+
37
+ acknowledgeAgentCredential: () => set({ oneTimeCredential: null }),
38
+ }));
@@ -1,7 +1,7 @@
1
1
  import { create } from "zustand";
2
2
 
3
3
  /** Which tab the settings panel opens on. */
4
- export type SettingsTab = "models" | "skills" | "security";
4
+ export type SettingsTab = "models" | "skills" | "security" | "devices";
5
5
 
6
6
  /** Full-screen surface currently shown inside the AppShell. */
7
7
  export type ActiveView = "chat" | "graph" | "activity";