@schlessera/brain-ui-react 0.4.0 → 0.5.1

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 (45) hide show
  1. package/dist/components/chat/chat-page.d.ts.map +1 -1
  2. package/dist/components/chat/chat-page.js +10 -5
  3. package/dist/components/chat/chat-page.js.map +1 -1
  4. package/dist/components/layout/mobile-tab-bar.d.ts.map +1 -1
  5. package/dist/components/layout/mobile-tab-bar.js +41 -4
  6. package/dist/components/layout/mobile-tab-bar.js.map +1 -1
  7. package/dist/components/layout/side-rail.js +3 -3
  8. package/dist/components/layout/side-rail.js.map +1 -1
  9. package/dist/components/settings/models-tab.d.ts +11 -0
  10. package/dist/components/settings/models-tab.d.ts.map +1 -0
  11. package/dist/components/settings/models-tab.js +116 -0
  12. package/dist/components/settings/models-tab.js.map +1 -0
  13. package/dist/components/settings/passkey-tab.d.ts +13 -0
  14. package/dist/components/settings/passkey-tab.d.ts.map +1 -0
  15. package/dist/components/settings/{passkey-panel.js → passkey-tab.js} +14 -11
  16. package/dist/components/settings/passkey-tab.js.map +1 -0
  17. package/dist/components/settings/settings-panel.d.ts +10 -0
  18. package/dist/components/settings/settings-panel.d.ts.map +1 -0
  19. package/dist/components/settings/settings-panel.js +24 -0
  20. package/dist/components/settings/settings-panel.js.map +1 -0
  21. package/dist/lib/api-client.d.ts +7 -1
  22. package/dist/lib/api-client.d.ts.map +1 -1
  23. package/dist/lib/api-client.js +9 -0
  24. package/dist/lib/api-client.js.map +1 -1
  25. package/dist/stores/provider-store.d.ts.map +1 -1
  26. package/dist/stores/provider-store.js +10 -1
  27. package/dist/stores/provider-store.js.map +1 -1
  28. package/dist/stores/ui-store.d.ts +9 -3
  29. package/dist/stores/ui-store.d.ts.map +1 -1
  30. package/dist/stores/ui-store.js +6 -3
  31. package/dist/stores/ui-store.js.map +1 -1
  32. package/dist/styles.css +1 -1
  33. package/package.json +2 -2
  34. package/src/components/chat/chat-page.tsx +13 -7
  35. package/src/components/layout/mobile-tab-bar.tsx +95 -8
  36. package/src/components/layout/side-rail.tsx +6 -6
  37. package/src/components/settings/models-tab.tsx +214 -0
  38. package/src/components/settings/{passkey-panel.tsx → passkey-tab.tsx} +74 -79
  39. package/src/components/settings/settings-panel.tsx +61 -0
  40. package/src/lib/api-client.ts +15 -0
  41. package/src/stores/provider-store.ts +12 -1
  42. package/src/stores/ui-store.ts +18 -7
  43. package/dist/components/settings/passkey-panel.d.ts +0 -10
  44. package/dist/components/settings/passkey-panel.d.ts.map +0 -1
  45. package/dist/components/settings/passkey-panel.js.map +0 -1
@@ -1,29 +1,94 @@
1
+ import { useEffect, useRef, useState } from "react";
1
2
  import {
2
3
  Brain,
3
- Newspaper,
4
4
  RefreshCw,
5
5
  History,
6
- KeyRound,
6
+ SlidersHorizontal,
7
7
  FolderTree,
8
+ SquarePen,
9
+ MoreHorizontal,
8
10
  } from "lucide-react";
9
11
  import { useUIStore } from "../../stores/ui-store.js";
12
+ import { useChatStore } from "../../stores/chat-store.js";
10
13
  import { cn } from "../../lib/utils.js";
11
14
 
12
15
  export function MobileTabBar() {
13
16
  const toggleSessionPanel = useUIStore((s) => s.toggleSessionPanel);
14
17
  const toggleSyncPanel = useUIStore((s) => s.toggleSyncPanel);
15
- const toggleWhatsupPanel = useUIStore((s) => s.toggleWhatsupPanel);
16
18
  const toggleFilePanel = useUIStore((s) => s.toggleFilePanel);
17
- const toggleSecurityPanel = useUIStore((s) => s.toggleSecurityPanel);
19
+ const toggleSettingsPanel = useUIStore((s) => s.toggleSettingsPanel);
20
+ const clearMessages = useChatStore((s) => s.clearMessages);
21
+
22
+ const [moreOpen, setMoreOpen] = useState(false);
23
+ const moreRef = useRef<HTMLDivElement>(null);
24
+
25
+ useEffect(() => {
26
+ if (!moreOpen) return;
27
+ function onDocClick(e: MouseEvent) {
28
+ if (!moreRef.current?.contains(e.target as Node)) setMoreOpen(false);
29
+ }
30
+ function onKey(e: KeyboardEvent) {
31
+ if (e.key === "Escape") setMoreOpen(false);
32
+ }
33
+ document.addEventListener("mousedown", onDocClick);
34
+ document.addEventListener("keydown", onKey);
35
+ return () => {
36
+ document.removeEventListener("mousedown", onDocClick);
37
+ document.removeEventListener("keydown", onKey);
38
+ };
39
+ }, [moreOpen]);
18
40
 
19
41
  return (
20
42
  <nav className="md:hidden fixed bottom-0 inset-x-0 z-30 flex h-14 items-center justify-around border-t border-border bg-surface px-1 pb-[env(safe-area-inset-bottom)]">
21
43
  <TabIcon icon={Brain} label="Chat" active />
22
- <TabIcon icon={Newspaper} label="Brief" onClick={toggleWhatsupPanel} />
44
+ <TabIcon
45
+ icon={SquarePen}
46
+ label="New chat"
47
+ onClick={() => {
48
+ setMoreOpen(false);
49
+ clearMessages();
50
+ }}
51
+ />
23
52
  <TabIcon icon={FolderTree} label="Files" onClick={toggleFilePanel} />
24
- <TabIcon icon={RefreshCw} label="Sync" onClick={toggleSyncPanel} />
25
- <TabIcon icon={History} label="History" onClick={toggleSessionPanel} />
26
- <TabIcon icon={KeyRound} label="Security" onClick={toggleSecurityPanel} />
53
+ <div ref={moreRef} className="relative">
54
+ <TabIcon
55
+ icon={MoreHorizontal}
56
+ label="More"
57
+ active={moreOpen}
58
+ onClick={() => setMoreOpen((v) => !v)}
59
+ />
60
+ {moreOpen && (
61
+ <div
62
+ role="menu"
63
+ className="absolute bottom-full right-0 z-50 mb-2 min-w-[10rem] overflow-hidden rounded-xl border border-border bg-surface-overlay py-1 shadow-2xl"
64
+ >
65
+ <MoreItem
66
+ icon={RefreshCw}
67
+ label="Sync"
68
+ onClick={() => {
69
+ setMoreOpen(false);
70
+ toggleSyncPanel();
71
+ }}
72
+ />
73
+ <MoreItem
74
+ icon={History}
75
+ label="History"
76
+ onClick={() => {
77
+ setMoreOpen(false);
78
+ toggleSessionPanel();
79
+ }}
80
+ />
81
+ <MoreItem
82
+ icon={SlidersHorizontal}
83
+ label="Settings"
84
+ onClick={() => {
85
+ setMoreOpen(false);
86
+ toggleSettingsPanel();
87
+ }}
88
+ />
89
+ </div>
90
+ )}
91
+ </div>
27
92
  </nav>
28
93
  );
29
94
  }
@@ -52,3 +117,25 @@ function TabIcon({
52
117
  </button>
53
118
  );
54
119
  }
120
+
121
+ function MoreItem({
122
+ icon: Icon,
123
+ label,
124
+ onClick,
125
+ }: {
126
+ icon: typeof Brain;
127
+ label: string;
128
+ onClick: () => void;
129
+ }) {
130
+ return (
131
+ <button
132
+ role="menuitem"
133
+ type="button"
134
+ onClick={onClick}
135
+ className="flex w-full items-center gap-2.5 px-3 py-2.5 text-left text-xs text-foreground transition-colors hover:bg-surface-raised"
136
+ >
137
+ <Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
138
+ {label}
139
+ </button>
140
+ );
141
+ }
@@ -3,7 +3,7 @@ import {
3
3
  RefreshCw,
4
4
  Newspaper,
5
5
  History,
6
- KeyRound,
6
+ SlidersHorizontal,
7
7
  SquarePen,
8
8
  FolderTree,
9
9
  } from "lucide-react";
@@ -18,7 +18,7 @@ export function SideRail() {
18
18
  const toggleSyncPanel = useUIStore((s) => s.toggleSyncPanel);
19
19
  const toggleWhatsupPanel = useUIStore((s) => s.toggleWhatsupPanel);
20
20
  const toggleFilePanel = useUIStore((s) => s.toggleFilePanel);
21
- const toggleSecurityPanel = useUIStore((s) => s.toggleSecurityPanel);
21
+ const toggleSettingsPanel = useUIStore((s) => s.toggleSettingsPanel);
22
22
  const clearMessages = useChatStore((s) => s.clearMessages);
23
23
  const hasMessages = useChatStore((s) => activeChat(s).messages.length > 0);
24
24
  const isStreaming = useChatStore((s) => activeChat(s).isStreaming);
@@ -71,11 +71,11 @@ export function SideRail() {
71
71
  onClick={toggleSessionPanel}
72
72
  />
73
73
 
74
- {/* Security (passkeys, sign out) */}
74
+ {/* Settings (models, passkeys, sign out) */}
75
75
  <RailButton
76
- icon={KeyRound}
77
- label="Security"
78
- onClick={toggleSecurityPanel}
76
+ icon={SlidersHorizontal}
77
+ label="Settings"
78
+ onClick={toggleSettingsPanel}
79
79
  />
80
80
 
81
81
  {/* Connection status */}
@@ -0,0 +1,214 @@
1
+ import { useEffect, useState } from "react";
2
+ import { Eye, EyeOff, Loader2, RefreshCw } from "lucide-react";
3
+ import type {
4
+ ModelCatalogEntry,
5
+ ModelCatalogResponse,
6
+ } from "@schlessera/brain-ui-sdk/protocol";
7
+ import { api } from "../../lib/api-client.js";
8
+ import { useProviderStore } from "../../stores/provider-store.js";
9
+ import { cn } from "../../lib/utils.js";
10
+
11
+ /**
12
+ * The model picker's contents, and which of them to show.
13
+ *
14
+ * The roster comes from the server (Anthropic model discovery plus any
15
+ * host-declared profiles), so this tab never hardcodes model names. Hiding is
16
+ * presentation only — a session already pinned to a hidden model keeps running.
17
+ */
18
+ export function ModelsTab({ active }: { active: boolean }) {
19
+ const [catalog, setCatalog] = useState<ModelCatalogResponse | null>(null);
20
+ const [loading, setLoading] = useState(false);
21
+ const [refreshing, setRefreshing] = useState(false);
22
+ const [error, setError] = useState<string | null>(null);
23
+ const loadProviders = useProviderStore((s) => s.loadProviders);
24
+
25
+ useEffect(() => {
26
+ if (!active) return;
27
+ let cancelled = false;
28
+ setLoading(true);
29
+ setError(null);
30
+ api
31
+ .models()
32
+ .then((data) => {
33
+ if (!cancelled) setCatalog(data);
34
+ })
35
+ .catch((err: unknown) => {
36
+ if (!cancelled) {
37
+ setError(err instanceof Error ? err.message : "Could not load models");
38
+ }
39
+ })
40
+ .finally(() => {
41
+ if (!cancelled) setLoading(false);
42
+ });
43
+ return () => {
44
+ cancelled = true;
45
+ };
46
+ }, [active]);
47
+
48
+ async function toggleHidden(entry: ModelCatalogEntry) {
49
+ if (!catalog) return;
50
+ const previous = catalog;
51
+ const hidden = catalog.models
52
+ .filter((model) =>
53
+ model.id === entry.id ? !entry.hidden : model.hidden
54
+ )
55
+ .map((model) => model.id);
56
+
57
+ // Optimistic: the list is the user's own click, so reflect it immediately
58
+ // and roll back if the write fails.
59
+ setCatalog({
60
+ ...catalog,
61
+ models: catalog.models.map((model) =>
62
+ model.id === entry.id ? { ...model, hidden: !model.hidden } : model
63
+ ),
64
+ });
65
+ setError(null);
66
+ try {
67
+ setCatalog(await api.setHiddenModels(hidden));
68
+ // The composer's picker holds its own copy of the roster, fetched once on
69
+ // mount — without this it keeps offering a model the user just hid until
70
+ // the page is reloaded.
71
+ void loadProviders();
72
+ } catch (err) {
73
+ setCatalog(previous);
74
+ setError(err instanceof Error ? err.message : "Could not save");
75
+ }
76
+ }
77
+
78
+ async function onRefresh() {
79
+ if (refreshing) return;
80
+ setRefreshing(true);
81
+ setError(null);
82
+ try {
83
+ setCatalog(await api.refreshModels());
84
+ // A refresh can surface newly released models — put them in the picker
85
+ // now, not on next load.
86
+ void loadProviders();
87
+ } catch (err) {
88
+ setError(err instanceof Error ? err.message : "Refresh failed");
89
+ } finally {
90
+ setRefreshing(false);
91
+ }
92
+ }
93
+
94
+ const visibleCount = catalog?.models.filter((m) => !m.hidden).length ?? 0;
95
+
96
+ return (
97
+ <div className="flex h-full flex-col">
98
+ <div className="flex-1 overflow-y-auto p-4">
99
+ <h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
100
+ Models
101
+ </h3>
102
+ <p className="mt-1 text-xs text-muted-foreground">
103
+ The list is read from the provider and refreshes on its own. Hide the
104
+ ones you never pick — running sessions are unaffected.
105
+ </p>
106
+
107
+ {loading ? (
108
+ <div className="mt-6 flex justify-center">
109
+ <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
110
+ </div>
111
+ ) : (
112
+ <ul className="mt-4 flex flex-col gap-2">
113
+ {catalog?.models.map((entry) => (
114
+ <ModelRow
115
+ key={entry.id}
116
+ entry={entry}
117
+ onToggle={() => toggleHidden(entry)}
118
+ />
119
+ ))}
120
+ {catalog?.models.length === 0 && (
121
+ <li className="rounded-lg border border-dashed border-border-subtle p-4 text-center text-xs text-muted-foreground">
122
+ No models available.
123
+ </li>
124
+ )}
125
+ </ul>
126
+ )}
127
+
128
+ {error && (
129
+ <p role="alert" className="mt-3 text-xs text-destructive">
130
+ {error}
131
+ </p>
132
+ )}
133
+
134
+ {catalog?.discovery.error && (
135
+ <p className="mt-3 text-xs text-muted-foreground">
136
+ Last refresh failed ({catalog.discovery.error}). Showing the last
137
+ known list.
138
+ </p>
139
+ )}
140
+ {catalog && !catalog.discovery.enabled && (
141
+ <p className="mt-3 text-xs text-muted-foreground">
142
+ Discovery is disabled (BRAIN_UI_MODEL_DISCOVERY). Only configured
143
+ profiles are listed.
144
+ </p>
145
+ )}
146
+ </div>
147
+
148
+ <div className="flex items-center justify-between gap-3 border-t border-border p-4">
149
+ <span className="text-[11px] text-muted-foreground">
150
+ {visibleCount} in picker · {formatRefreshed(catalog?.refreshedAt ?? null)}
151
+ </span>
152
+ <button
153
+ onClick={onRefresh}
154
+ disabled={refreshing}
155
+ className="flex items-center gap-2 rounded-lg border border-border-subtle bg-surface px-3 py-2 text-xs font-medium text-foreground transition-colors hover:border-primary hover:text-primary disabled:opacity-50"
156
+ >
157
+ <RefreshCw className={cn("h-3.5 w-3.5", refreshing && "animate-spin")} />
158
+ Refresh
159
+ </button>
160
+ </div>
161
+ </div>
162
+ );
163
+ }
164
+
165
+ function ModelRow({
166
+ entry,
167
+ onToggle,
168
+ }: {
169
+ entry: ModelCatalogEntry;
170
+ onToggle: () => void;
171
+ }) {
172
+ const Icon = entry.hidden ? EyeOff : Eye;
173
+ return (
174
+ <li
175
+ className={cn(
176
+ "flex items-center gap-3 rounded-lg border border-border-subtle bg-surface p-3",
177
+ entry.hidden && "opacity-50"
178
+ )}
179
+ >
180
+ <div className="min-w-0 flex-1">
181
+ <p className="truncate text-sm text-foreground">{entry.label}</p>
182
+ <p className="truncate text-[11px] text-muted-foreground">
183
+ {entry.id}
184
+ {entry.contextWindow ? ` · ${formatTokens(entry.contextWindow)}` : ""}
185
+ {entry.source === "declared" ? " · configured" : ""}
186
+ </p>
187
+ </div>
188
+ <button
189
+ onClick={onToggle}
190
+ title={entry.hidden ? "Show in picker" : "Hide from picker"}
191
+ aria-pressed={!entry.hidden}
192
+ className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-surface-raised hover:text-foreground"
193
+ >
194
+ <Icon className="h-4 w-4" />
195
+ </button>
196
+ </li>
197
+ );
198
+ }
199
+
200
+ function formatTokens(tokens: number): string {
201
+ if (tokens >= 1_000_000) return `${tokens / 1_000_000}M context`;
202
+ if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}K context`;
203
+ return `${tokens} context`;
204
+ }
205
+
206
+ function formatRefreshed(refreshedAt: number | null): string {
207
+ if (refreshedAt === null) return "never refreshed";
208
+ const minutes = Math.round((Date.now() - refreshedAt) / 60_000);
209
+ if (minutes < 1) return "refreshed just now";
210
+ if (minutes < 60) return `refreshed ${minutes}m ago`;
211
+ const hours = Math.round(minutes / 60);
212
+ if (hours < 24) return `refreshed ${hours}h ago`;
213
+ return `refreshed ${Math.round(hours / 24)}d ago`;
214
+ }
@@ -13,21 +13,18 @@ import {
13
13
  import type { PasskeySummary } from "@schlessera/brain-ui-sdk/protocol";
14
14
  import { api } from "../../lib/api-client.js";
15
15
  import { isUserCancel, registerPasskey, supportsPasskeys } from "../../lib/passkeys.js";
16
- import { SlidePanel } from "../layout/slide-panel.js";
17
16
  import { cn } from "../../lib/utils.js";
18
17
 
19
18
  /**
20
- * Passkey management + sign out. Registration requires the current session
21
- * (routes are behind the auth guard); credentials are RP-scoped, so entries
22
- * registered on another hostname are badged and only useful there.
19
+ * Passkey management + sign out — the Security tab of the settings panel.
20
+ * Registration requires the current session (routes are behind the auth guard);
21
+ * credentials are RP-scoped, so entries registered on another hostname are
22
+ * badged and only useful there.
23
+ *
24
+ * `active` is "this tab is on screen": the list reloads when it becomes visible
25
+ * rather than on mount, so switching tabs picks up changes made elsewhere.
23
26
  */
24
- export function PasskeyPanel({
25
- open,
26
- onClose,
27
- }: {
28
- open: boolean;
29
- onClose: () => void;
30
- }) {
27
+ export function PasskeyTab({ active }: { active: boolean }) {
31
28
  const [credentials, setCredentials] = useState<PasskeySummary[]>([]);
32
29
  const [loading, setLoading] = useState(false);
33
30
  const [busy, setBusy] = useState(false);
@@ -52,11 +49,11 @@ export function PasskeyPanel({
52
49
  }
53
50
 
54
51
  useEffect(() => {
55
- if (open) {
52
+ if (active) {
56
53
  setError(null);
57
54
  void refresh();
58
55
  }
59
- }, [open]);
56
+ }, [active]);
60
57
 
61
58
  async function onAdd() {
62
59
  if (busy) return;
@@ -106,79 +103,77 @@ export function PasskeyPanel({
106
103
  }
107
104
 
108
105
  return (
109
- <SlidePanel open={open} onClose={onClose} title="Security">
110
- <div className="flex h-full flex-col">
111
- <div className="flex-1 overflow-y-auto p-4">
112
- <h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
113
- Passkeys
114
- </h3>
115
- <p className="mt-1 text-xs text-muted-foreground">
116
- Sign in with Face ID, fingerprint, or a security key instead of the
117
- password. Passkeys only work on the site they were added on.
118
- </p>
106
+ <div className="flex h-full flex-col">
107
+ <div className="flex-1 overflow-y-auto p-4">
108
+ <h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
109
+ Passkeys
110
+ </h3>
111
+ <p className="mt-1 text-xs text-muted-foreground">
112
+ Sign in with Face ID, fingerprint, or a security key instead of the
113
+ password. Passkeys only work on the site they were added on.
114
+ </p>
119
115
 
120
- {loading ? (
121
- <div className="mt-6 flex justify-center">
122
- <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
123
- </div>
124
- ) : (
125
- <ul className="mt-4 flex flex-col gap-2">
126
- {credentials.map((credential) => (
127
- <PasskeyRow
128
- key={credential.id}
129
- credential={credential}
130
- onDelete={() => onDelete(credential.id)}
131
- onRename={(label) => onRename(credential.id, label)}
132
- />
133
- ))}
134
- {credentials.length === 0 && passkeyMode && (
135
- <li className="rounded-lg border border-dashed border-border-subtle p-4 text-center text-xs text-muted-foreground">
136
- No passkeys yet.
137
- </li>
138
- )}
139
- </ul>
140
- )}
141
-
142
- {error && (
143
- <p role="alert" className="mt-3 text-xs text-destructive">
144
- {error}
145
- </p>
146
- )}
116
+ {loading ? (
117
+ <div className="mt-6 flex justify-center">
118
+ <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
119
+ </div>
120
+ ) : (
121
+ <ul className="mt-4 flex flex-col gap-2">
122
+ {credentials.map((credential) => (
123
+ <PasskeyRow
124
+ key={credential.id}
125
+ credential={credential}
126
+ onDelete={() => onDelete(credential.id)}
127
+ onRename={(label) => onRename(credential.id, label)}
128
+ />
129
+ ))}
130
+ {credentials.length === 0 && passkeyMode && (
131
+ <li className="rounded-lg border border-dashed border-border-subtle p-4 text-center text-xs text-muted-foreground">
132
+ No passkeys yet.
133
+ </li>
134
+ )}
135
+ </ul>
136
+ )}
147
137
 
148
- {passkeyMode && webAuthnSupported ? (
149
- <button
150
- onClick={onAdd}
151
- disabled={busy}
152
- 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"
153
- >
154
- {busy ? (
155
- <Loader2 className="h-4 w-4 animate-spin" />
156
- ) : (
157
- <Plus className="h-4 w-4" />
158
- )}
159
- Add a passkey
160
- </button>
161
- ) : (
162
- <p className="mt-4 text-xs text-muted-foreground">
163
- {passkeyMode
164
- ? "This browser does not support passkeys."
165
- : "Passkeys need password auth mode on the server."}
166
- </p>
167
- )}
168
- </div>
138
+ {error && (
139
+ <p role="alert" className="mt-3 text-xs text-destructive">
140
+ {error}
141
+ </p>
142
+ )}
169
143
 
170
- {/* Sign out */}
171
- <div className="border-t border-border p-4">
144
+ {passkeyMode && webAuthnSupported ? (
172
145
  <button
173
- onClick={onSignOut}
174
- className="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-destructive hover:text-destructive"
146
+ onClick={onAdd}
147
+ disabled={busy}
148
+ 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"
175
149
  >
176
- <LogOut className="h-4 w-4" />
177
- Sign out
150
+ {busy ? (
151
+ <Loader2 className="h-4 w-4 animate-spin" />
152
+ ) : (
153
+ <Plus className="h-4 w-4" />
154
+ )}
155
+ Add a passkey
178
156
  </button>
179
- </div>
157
+ ) : (
158
+ <p className="mt-4 text-xs text-muted-foreground">
159
+ {passkeyMode
160
+ ? "This browser does not support passkeys."
161
+ : "Passkeys need password auth mode on the server."}
162
+ </p>
163
+ )}
164
+ </div>
165
+
166
+ {/* Sign out */}
167
+ <div className="border-t border-border p-4">
168
+ <button
169
+ onClick={onSignOut}
170
+ className="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-destructive hover:text-destructive"
171
+ >
172
+ <LogOut className="h-4 w-4" />
173
+ Sign out
174
+ </button>
180
175
  </div>
181
- </SlidePanel>
176
+ </div>
182
177
  );
183
178
  }
184
179
 
@@ -0,0 +1,61 @@
1
+ import { KeyRound, SlidersHorizontal } from "lucide-react";
2
+ import { useUIStore, type SettingsTab } from "../../stores/ui-store.js";
3
+ import { SlidePanel } from "../layout/slide-panel.js";
4
+ import { cn } from "../../lib/utils.js";
5
+ import { ModelsTab } from "./models-tab.js";
6
+ import { PasskeyTab } from "./passkey-tab.js";
7
+
8
+ const TABS: Array<{ id: SettingsTab; label: string; icon: typeof KeyRound }> = [
9
+ { id: "models", label: "Models", icon: SlidersHorizontal },
10
+ { id: "security", label: "Security", icon: KeyRound },
11
+ ];
12
+
13
+ /**
14
+ * The settings surface: one panel, one entry point in the menus, a tab per
15
+ * area. Only the selected tab is mounted — each fetches its own data on becoming
16
+ * active, so switching tabs (or reopening the panel) always shows current state.
17
+ */
18
+ export function SettingsPanel({
19
+ open,
20
+ onClose,
21
+ }: {
22
+ open: boolean;
23
+ onClose: () => void;
24
+ }) {
25
+ const tab = useUIStore((s) => s.settingsTab);
26
+ const setTab = useUIStore((s) => s.setSettingsTab);
27
+
28
+ return (
29
+ <SlidePanel open={open} onClose={onClose} title="Settings">
30
+ <div className="flex h-full flex-col">
31
+ <div className="flex shrink-0 gap-1 border-b border-border px-2 pt-2">
32
+ {TABS.map(({ id, label, icon: Icon }) => (
33
+ <button
34
+ key={id}
35
+ onClick={() => setTab(id)}
36
+ aria-selected={tab === id}
37
+ role="tab"
38
+ className={cn(
39
+ "flex items-center gap-1.5 rounded-t-lg px-3 py-2 text-xs font-medium transition-colors",
40
+ tab === id
41
+ ? "border-b-2 border-primary text-foreground"
42
+ : "text-muted-foreground hover:text-foreground"
43
+ )}
44
+ >
45
+ <Icon className="h-3.5 w-3.5" />
46
+ {label}
47
+ </button>
48
+ ))}
49
+ </div>
50
+
51
+ <div className="min-h-0 flex-1">
52
+ {tab === "models" ? (
53
+ <ModelsTab active={open && tab === "models"} />
54
+ ) : (
55
+ <PasskeyTab active={open && tab === "security"} />
56
+ )}
57
+ </div>
58
+ </div>
59
+ </SlidePanel>
60
+ );
61
+ }
@@ -6,6 +6,7 @@ import type {
6
6
  PronunciationOverride,
7
7
  ProviderInfo,
8
8
  PasskeySummary,
9
+ ModelCatalogResponse,
9
10
  } from "@schlessera/brain-ui-sdk/protocol";
10
11
  import type {
11
12
  AuthenticationResponseJSON,
@@ -119,6 +120,20 @@ export const api = {
119
120
  backends?: Record<string, BackendInfo>;
120
121
  }>("/providers"),
121
122
 
123
+ /** Full model catalog for the settings screen — hidden entries included. */
124
+ models: () => fetchJson<ModelCatalogResponse>("/models"),
125
+
126
+ /** Replace the hidden set (full list, not a delta); returns the new catalog. */
127
+ setHiddenModels: (hidden: string[]) =>
128
+ fetchJson<ModelCatalogResponse>("/models/hidden", {
129
+ method: "PUT",
130
+ body: JSON.stringify({ hidden }),
131
+ }),
132
+
133
+ /** Force a discovery refresh, bypassing the TTL. */
134
+ refreshModels: () =>
135
+ fetchJson<ModelCatalogResponse>("/models/refresh", { method: "POST" }),
136
+
122
137
  /** Password-mode login. Resolves on success; throws the server error otherwise. */
123
138
  login: (password: string) =>
124
139
  fetchJson<{ ok: true }>("/auth/login", {
@@ -51,12 +51,23 @@ export const useProviderStore = create<ProviderState>((set, get) => ({
51
51
  const { providers, backends } = await api.providers();
52
52
  set((s) => {
53
53
  // Keep the persisted choice if it's still on offer; otherwise fall
54
- // back to the first available combo.
54
+ // back to the first available combo. This runs on every reload of the
55
+ // roster, so hiding the selected model in settings moves the selection
56
+ // instead of leaving the composer pointing at a profile the picker no
57
+ // longer lists.
55
58
  const stillValid = providers.some((p) => p.id === s.selectedId);
56
59
  const selectedId =
57
60
  stillValid && s.selectedId
58
61
  ? s.selectedId
59
62
  : providers[0]?.id ?? "";
63
+ // Persist the fallback too — otherwise the stale id sits in
64
+ // localStorage and has to be re-resolved on every boot.
65
+ if (
66
+ selectedId !== s.selectedId &&
67
+ typeof localStorage !== "undefined"
68
+ ) {
69
+ localStorage.setItem(PROVIDER_ID_KEY, selectedId);
70
+ }
60
71
  return {
61
72
  available: providers,
62
73
  selectedId,