@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.
- package/dist/components/chat/chat-page.d.ts.map +1 -1
- package/dist/components/chat/chat-page.js +10 -5
- package/dist/components/chat/chat-page.js.map +1 -1
- package/dist/components/layout/mobile-tab-bar.d.ts.map +1 -1
- package/dist/components/layout/mobile-tab-bar.js +41 -4
- package/dist/components/layout/mobile-tab-bar.js.map +1 -1
- package/dist/components/layout/side-rail.js +3 -3
- package/dist/components/layout/side-rail.js.map +1 -1
- package/dist/components/settings/models-tab.d.ts +11 -0
- package/dist/components/settings/models-tab.d.ts.map +1 -0
- package/dist/components/settings/models-tab.js +116 -0
- package/dist/components/settings/models-tab.js.map +1 -0
- package/dist/components/settings/passkey-tab.d.ts +13 -0
- package/dist/components/settings/passkey-tab.d.ts.map +1 -0
- package/dist/components/settings/{passkey-panel.js → passkey-tab.js} +14 -11
- package/dist/components/settings/passkey-tab.js.map +1 -0
- package/dist/components/settings/settings-panel.d.ts +10 -0
- package/dist/components/settings/settings-panel.d.ts.map +1 -0
- package/dist/components/settings/settings-panel.js +24 -0
- package/dist/components/settings/settings-panel.js.map +1 -0
- package/dist/lib/api-client.d.ts +7 -1
- package/dist/lib/api-client.d.ts.map +1 -1
- package/dist/lib/api-client.js +9 -0
- package/dist/lib/api-client.js.map +1 -1
- package/dist/stores/provider-store.d.ts.map +1 -1
- package/dist/stores/provider-store.js +10 -1
- package/dist/stores/provider-store.js.map +1 -1
- package/dist/stores/ui-store.d.ts +9 -3
- package/dist/stores/ui-store.d.ts.map +1 -1
- package/dist/stores/ui-store.js +6 -3
- package/dist/stores/ui-store.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +2 -2
- package/src/components/chat/chat-page.tsx +13 -7
- package/src/components/layout/mobile-tab-bar.tsx +95 -8
- package/src/components/layout/side-rail.tsx +6 -6
- package/src/components/settings/models-tab.tsx +214 -0
- package/src/components/settings/{passkey-panel.tsx → passkey-tab.tsx} +74 -79
- package/src/components/settings/settings-panel.tsx +61 -0
- package/src/lib/api-client.ts +15 -0
- package/src/stores/provider-store.ts +12 -1
- package/src/stores/ui-store.ts +18 -7
- package/dist/components/settings/passkey-panel.d.ts +0 -10
- package/dist/components/settings/passkey-panel.d.ts.map +0 -1
- 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
|
-
|
|
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
|
|
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
|
|
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
|
-
<
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
{/*
|
|
74
|
+
{/* Settings (models, passkeys, sign out) */}
|
|
75
75
|
<RailButton
|
|
76
|
-
icon={
|
|
77
|
-
label="
|
|
78
|
-
onClick={
|
|
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
|
|
21
|
-
* (routes are behind the auth guard);
|
|
22
|
-
* registered on another hostname are
|
|
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
|
|
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 (
|
|
52
|
+
if (active) {
|
|
56
53
|
setError(null);
|
|
57
54
|
void refresh();
|
|
58
55
|
}
|
|
59
|
-
}, [
|
|
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
|
-
<
|
|
110
|
-
<div className="flex
|
|
111
|
-
<
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
{
|
|
171
|
-
<div className="border-t border-border p-4">
|
|
144
|
+
{passkeyMode && webAuthnSupported ? (
|
|
172
145
|
<button
|
|
173
|
-
onClick={
|
|
174
|
-
|
|
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
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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
|
-
</
|
|
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
|
+
}
|
package/src/lib/api-client.ts
CHANGED
|
@@ -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,
|