fix: QA regression pass — graph sizing, focus trap, contrast, accessibility

- Fix graph canvas using window dimensions instead of container by using
  ResizeObserver ref callback (attaches when conditionally-rendered
  container mounts)
- Fix dialog focus trap escaping to background — filter hidden/disabled
  elements from focusable selector
- Fix sidebar connection status and disconnection banner contrast — use
  semantic text-warning/bg-warning instead of amber-400 (1.65:1 → 4.5:1+)
- Add aria-label to chat textarea, htmlFor/id pairs to flows dialog inputs
- Add ARIA tab pattern to prompts page (role=tablist/tab/tabpanel,
  aria-selected, aria-controls)
- Fix prompts heading hierarchy (H1→H2 instead of H1→H3)
- Add flex-wrap to flows page header, fix badge contrast across pages
- Fix service-call race condition with early returns instead of console.log

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
elpresidank 2026-04-10 07:48:01 -05:00
parent b854b56558
commit 77a5fa5044
12 changed files with 214 additions and 70 deletions

View file

@ -76,9 +76,8 @@ export class ServiceCall {
* @param resp - The response object received from the server
*/
onReceived(resp: object) {
// Defensive check - this shouldn't happen but log if it does
if (this.complete == true)
console.log(this.mid, "should not happen, request is already complete");
// Guard: ignore duplicate responses after completion
if (this.complete) return;
// Mark as complete to prevent duplicate processing
this.complete = true;
@ -151,12 +150,8 @@ export class ServiceCall {
* Triggers another attempt if retries are available
*/
onTimeout() {
// Defensive check - this shouldn't happen but log if it does
if (this.complete == true)
console.log(
this.mid,
"timeout should not happen, request is already complete",
);
// Guard: ignore timeout after completion
if (this.complete) return;
console.log("Request", this.mid, "timed out");
@ -184,12 +179,8 @@ export class ServiceCall {
* Handles retries and waits for BaseApi to handle reconnection
*/
attempt() {
// Defensive check - this shouldn't be called on completed requests
if (this.complete == true)
console.log(
this.mid,
"attempt should not be called, request is already complete",
);
// Guard: don't retry completed requests
if (this.complete) return;
// Decrement retry counter
this.retries--;

View file

@ -31,6 +31,14 @@ export function RootLayout() {
return (
<div className="relative flex h-screen w-full overflow-hidden bg-surface-0">
{/* Skip to main content link (visible on focus for keyboard users) */}
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-50 focus:rounded-lg focus:bg-brand-600 focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:text-white focus:shadow-lg"
>
Skip to content
</a>
{/* Global loading bar */}
<LoadingBar />
@ -44,14 +52,14 @@ export function RootLayout() {
{/* Connection lost banner */}
{isDisconnected && (
<div className="flex items-center gap-2 border-b border-amber-500/30 bg-amber-500/10 px-4 py-2 text-xs text-amber-400">
<div className="flex items-center gap-2 border-b border-warning/30 bg-warning/10 px-4 py-2 text-xs text-warning">
<WifiOff className="h-3.5 w-3.5" />
<span>Connection lost. Attempting to reconnect...</span>
</div>
)}
{/* Page content */}
<main className="flex-1 overflow-y-auto p-6">
<main id="main-content" className="flex-1 overflow-y-auto p-6">
<Outlet />
</main>
</div>

View file

@ -69,7 +69,7 @@ function ConnectionBadge() {
className={cn(
"flex items-center gap-2 rounded-lg px-3 py-2 text-xs font-medium",
isWarning
? "text-amber-400"
? "text-warning"
: isConnected
? "text-success"
: "text-fg-subtle",
@ -79,7 +79,7 @@ function ConnectionBadge() {
className={cn(
"h-2 w-2 shrink-0 rounded-full",
isWarning
? "bg-amber-400 animate-pulse"
? "bg-warning animate-pulse"
: isConnected
? "bg-success animate-pulse"
: "bg-fg-subtle",
@ -111,12 +111,13 @@ function FlowSelectorDropdown() {
<div className="space-y-2 px-3">
{/* Flow selector */}
<div className="space-y-1">
<label className="flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wider text-fg-subtle">
<label htmlFor="sidebar-flow-select" className="flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wider text-fg-subtle">
<Workflow className="h-3 w-3" />
Flow
</label>
<div className="relative">
<select
id="sidebar-flow-select"
value={flowId}
onChange={(e) => setFlowId(e.target.value)}
className="w-full appearance-none rounded-md border border-border bg-surface-100 py-1.5 pl-2.5 pr-7 text-xs text-fg focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"

View file

@ -3,6 +3,8 @@ import {
type MouseEvent,
useCallback,
useEffect,
useId,
useRef,
} from "react";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
@ -20,6 +22,7 @@ interface DialogProps {
/**
* Simple modal dialog built with Tailwind.
* Renders a backdrop overlay + centered content panel.
* Includes focus trap, auto-focus, and Escape to close.
*/
export function Dialog({
open,
@ -29,6 +32,9 @@ export function Dialog({
footer,
className,
}: DialogProps) {
const titleId = useId();
const dialogRef = useRef<HTMLDivElement>(null);
// Close on Escape key
useEffect(() => {
if (!open) return;
@ -39,6 +45,63 @@ export function Dialog({
return () => window.removeEventListener("keydown", handler);
}, [open, onClose]);
// Auto-focus first focusable element when dialog opens
useEffect(() => {
if (!open || !dialogRef.current) return;
const focusable = Array.from(
dialogRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
),
).filter(
(el) =>
!el.hidden &&
!(el as HTMLButtonElement).disabled &&
el.offsetParent !== null &&
window.getComputedStyle(el).display !== "none",
);
// Focus the first input/textarea if available, otherwise the close button
const firstInput = focusable.find(
(el) => el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT",
);
(firstInput ?? focusable[0])?.focus();
}, [open]);
// Focus trap — keep Tab within the dialog
useEffect(() => {
if (!open || !dialogRef.current) return;
const dialog = dialogRef.current;
const handleTab = (e: KeyboardEvent) => {
if (e.key !== "Tab") return;
const focusable = Array.from(
dialog.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
),
).filter(
(el) =>
!el.hidden &&
!(el as HTMLButtonElement).disabled &&
el.offsetParent !== null &&
window.getComputedStyle(el).display !== "none",
);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
window.addEventListener("keydown", handleTab);
return () => window.removeEventListener("keydown", handleTab);
}, [open]);
const handleBackdrop = useCallback(
(e: MouseEvent) => {
if (e.target === e.currentTarget) onClose();
@ -54,9 +117,10 @@ export function Dialog({
onClick={handleBackdrop}
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
aria-labelledby={titleId}
className={cn(
"relative w-full max-w-lg rounded-xl border border-border bg-surface-100 shadow-2xl",
className,
@ -64,9 +128,10 @@ export function Dialog({
>
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-6 py-4">
<h2 id="dialog-title" className="text-lg font-semibold text-fg">{title}</h2>
<h2 id={titleId} className="text-lg font-semibold text-fg">{title}</h2>
<button
onClick={onClose}
aria-label="Close dialog"
className="rounded-md p-1 text-fg-subtle hover:bg-surface-200 hover:text-fg"
>
<X className="h-4 w-4" />

View file

@ -16,6 +16,7 @@ import {
ChevronRight,
Loader2,
X,
AlertTriangle,
} from "lucide-react";
import Markdown from "react-markdown";
import { cn } from "@/lib/utils";
@ -113,6 +114,7 @@ function AgentPhaseBlock({
function MessageBubble({ msg }: { msg: ChatMessage }) {
const isUser = msg.role === "user";
const hasAgentPhases = msg.agentPhases != null;
const isError = !isUser && msg.content.startsWith("Error:");
return (
<div
@ -120,7 +122,9 @@ function MessageBubble({ msg }: { msg: ChatMessage }) {
"rounded-lg px-4 py-3 text-sm leading-relaxed",
isUser
? "ml-auto max-w-[80%] bg-brand-700/30 text-fg"
: "mr-auto max-w-[80%] bg-surface-100 text-fg",
: isError
? "mr-auto max-w-[80%] border border-error/30 bg-error/10 text-error"
: "mr-auto max-w-[80%] bg-surface-100 text-fg",
)}
>
{/* Agent phase blocks (only for agent messages) */}
@ -152,6 +156,11 @@ function MessageBubble({ msg }: { msg: ChatMessage }) {
{/* Main content (markdown for assistant, plain for user) */}
{isUser ? (
<p className="whitespace-pre-wrap">{msg.content}</p>
) : isError ? (
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<p className="whitespace-pre-wrap">{msg.content}</p>
</div>
) : (
<div className="prose prose-sm max-w-none text-fg prose-headings:text-fg prose-strong:text-fg prose-p:my-1 prose-a:text-brand-400 prose-pre:bg-surface-200 prose-pre:text-fg prose-code:text-brand-300">
<Markdown>{msg.content || (msg.isStreaming ? "" : "(empty)")}</Markdown>
@ -235,7 +244,7 @@ export default function ChatPage() {
<div className="flex items-center gap-3">
<MessageSquareText className="h-6 w-6 text-brand-400" />
<h1 className="text-2xl font-bold text-fg">Chat</h1>
<span className="ml-2 rounded bg-surface-200 px-2 py-0.5 text-xs text-fg-subtle">
<span className="ml-2 rounded bg-surface-200 px-2 py-0.5 text-xs text-fg-muted">
{collection}
</span>
</div>
@ -310,6 +319,7 @@ export default function ChatPage() {
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type your message... (Enter to send, Shift+Enter for new line)"
aria-label="Chat message"
maxRows={6}
/>
<button

View file

@ -135,10 +135,11 @@ function StartFlowDialog({
>
{/* Flow ID */}
<div className="mb-3 space-y-1.5">
<label className="block text-sm font-medium text-fg-muted">
<label htmlFor="flow-id" className="block text-sm font-medium text-fg-muted">
Flow ID <span className="text-error">*</span>
</label>
<input
id="flow-id"
type="text"
value={id}
onChange={(e) => setId(e.target.value)}
@ -152,7 +153,7 @@ function StartFlowDialog({
{/* Blueprint name */}
<div className="mb-3 space-y-1.5">
<label className="block text-sm font-medium text-fg-muted">
<label htmlFor="flow-blueprint" className="block text-sm font-medium text-fg-muted">
Blueprint <span className="text-error">*</span>
</label>
{loadingBlueprints ? (
@ -161,6 +162,7 @@ function StartFlowDialog({
</div>
) : (
<select
id="flow-blueprint"
value={blueprint}
onChange={(e) => setBlueprint(e.target.value)}
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
@ -182,10 +184,11 @@ function StartFlowDialog({
{/* Description */}
<div className="mb-3 space-y-1.5">
<label className="block text-sm font-medium text-fg-muted">
<label htmlFor="flow-description" className="block text-sm font-medium text-fg-muted">
Description <span className="text-error">*</span>
</label>
<input
id="flow-description"
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
@ -199,10 +202,11 @@ function StartFlowDialog({
{/* Parameters (JSON) */}
<div className="space-y-1.5">
<label className="block text-sm font-medium text-fg-muted">
<label htmlFor="flow-params" className="block text-sm font-medium text-fg-muted">
Parameters (JSON)
</label>
<textarea
id="flow-params"
value={paramsJson}
onChange={(e) => {
setParamsJson(e.target.value);
@ -410,11 +414,11 @@ export default function FlowsPage() {
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="mb-6 flex items-center justify-between">
<div className="mb-6 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-3">
<Workflow className="h-6 w-6 text-brand-400" />
<h1 className="text-2xl font-bold text-fg">Flows</h1>
<span className="ml-2 rounded bg-surface-200 px-2 py-0.5 text-xs text-fg-subtle">
<span className="ml-2 rounded bg-surface-200 px-2 py-0.5 text-xs text-fg-muted">
{flows.length} active
</span>
</div>

View file

@ -316,6 +316,30 @@ export default function GraphPage() {
const fgRef = useRef<ForceGraphMethods<GraphNode, GraphLink> | undefined>(
undefined,
);
const [containerSize, setContainerSize] = useState<{
width: number;
height: number;
} | null>(null);
const roRef = useRef<ResizeObserver | null>(null);
// Ref callback — attaches ResizeObserver when the container mounts
const containerRef = useCallback((el: HTMLDivElement | null) => {
// Disconnect previous observer
if (roRef.current) {
roRef.current.disconnect();
roRef.current = null;
}
if (!el) return;
const ro = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
const { width, height } = entry.contentRect;
setContainerSize({ width: Math.floor(width), height: Math.floor(height) });
}
});
ro.observe(el);
roRef.current = ro;
}, []);
// Fetch triples
const fetchTriples = useCallback(async () => {
@ -371,6 +395,17 @@ export default function GraphPage() {
? labelMap.get(selectedNode) ?? localName(selectedNode)
: "";
// Auto-fit graph to view once data loads
const hasAutoFit = useRef(false);
useEffect(() => {
if (graphData.nodes.length > 0 && fgRef.current && !hasAutoFit.current) {
hasAutoFit.current = true;
// Wait for force simulation to settle briefly before fitting
const timer = setTimeout(() => fgRef.current?.zoomToFit(400, 40), 500);
return () => clearTimeout(timer);
}
}, [graphData.nodes.length]);
// Zoom helpers
const zoomIn = () => fgRef.current?.zoom(2, 300);
const zoomOut = () => fgRef.current?.zoom(0.5, 300);
@ -447,16 +482,16 @@ export default function GraphPage() {
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="mb-4 flex items-center justify-between">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-3">
<Rotate3d className="h-6 w-6 text-brand-400" />
<h1 className="text-2xl font-bold text-fg">Graph</h1>
<span className="ml-2 rounded bg-surface-200 px-2 py-0.5 text-xs text-fg-subtle">
<span className="ml-2 rounded bg-surface-200 px-2 py-0.5 text-xs text-fg-muted">
{graphData.nodes.length} nodes, {graphData.links.length} edges
</span>
</div>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
{/* Search */}
<div className="relative">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-fg-subtle" />
@ -548,9 +583,9 @@ export default function GraphPage() {
)}
{graphData.nodes.length > 0 && (
<div className="flex flex-1 overflow-hidden rounded-lg border border-border">
<div className="relative flex flex-1 overflow-hidden rounded-lg border border-border">
{/* Graph canvas */}
<div className="relative flex-1 bg-surface-0">
<div ref={containerRef} className="relative min-w-0 flex-1 bg-surface-0">
<Suspense fallback={<div className="flex h-full items-center justify-center"><Loader2 className="h-5 w-5 animate-spin text-fg-subtle" /></div>}>
<ForceGraph2D
ref={fgRef}
@ -575,8 +610,8 @@ export default function GraphPage() {
}}
onBackgroundClick={() => setSelectedNode(null)}
backgroundColor="transparent"
width={undefined}
height={undefined}
width={containerSize?.width}
height={containerSize?.height}
/>
</Suspense>
@ -590,15 +625,17 @@ export default function GraphPage() {
)}
</div>
{/* Detail panel */}
{/* Detail panel -- positioned absolutely so it overlays the graph */}
{selectedNode && (
<NodeDetailPanel
nodeId={selectedNode}
label={selectedLabel}
triples={triples}
labelMap={labelMap}
onClose={() => setSelectedNode(null)}
/>
<div className="absolute inset-y-0 right-0 z-10">
<NodeDetailPanel
nodeId={selectedNode}
label={selectedLabel}
triples={triples}
labelMap={labelMap}
onClose={() => setSelectedNode(null)}
/>
</div>
)}
</div>
)}

View file

@ -155,7 +155,7 @@ export default function KnowledgeCoresPage() {
<BrainCircuit className="h-6 w-6 text-brand-400" />
<h1 className="text-2xl font-bold text-fg">Knowledge Cores</h1>
{!loading && (
<span className="ml-2 rounded bg-surface-200 px-2 py-0.5 text-xs text-fg-subtle">
<span className="ml-2 rounded bg-surface-200 px-2 py-0.5 text-xs text-fg-muted">
{cores.length} core{cores.length !== 1 ? "s" : ""}
</span>
)}

View file

@ -55,18 +55,20 @@ function UploadDialog({
setUploading(false);
};
const handleFile = (f: File) => {
const titleRef = useRef(title);
titleRef.current = title;
const handleFile = useCallback((f: File) => {
setFile(f);
if (!title) setTitle(f.name.replace(/\.[^/.]+$/, ""));
};
if (!titleRef.current) setTitle(f.name.replace(/\.[^/.]+$/, ""));
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
const f = e.dataTransfer.files[0];
if (f) handleFile(f);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [handleFile]);
const handleSubmit = async () => {
if (!file) return;
@ -121,6 +123,8 @@ function UploadDialog({
>
{/* Drop zone */}
<div
role="button"
tabIndex={0}
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
@ -128,6 +132,13 @@ function UploadDialog({
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
inputRef.current?.click();
}
}}
aria-label="Drop a file here or press Enter to browse"
className={cn(
"mb-4 flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed px-6 py-8 transition-colors",
dragOver
@ -172,9 +183,11 @@ function UploadDialog({
{/* Title */}
<div className="mb-3 space-y-1.5">
<label className="block text-sm font-medium text-fg-muted">Title</label>
<label htmlFor="upload-title" className="block text-sm font-medium text-fg-muted">Title</label>
<input
id="upload-title"
type="text"
required
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Document title"
@ -184,8 +197,9 @@ function UploadDialog({
{/* Comments */}
<div className="mb-3 space-y-1.5">
<label className="block text-sm font-medium text-fg-muted">Comments</label>
<label htmlFor="upload-comments" className="block text-sm font-medium text-fg-muted">Comments</label>
<input
id="upload-comments"
type="text"
value={comments}
onChange={(e) => setComments(e.target.value)}
@ -196,8 +210,9 @@ function UploadDialog({
{/* Tags */}
<div className="space-y-1.5">
<label className="block text-sm font-medium text-fg-muted">Tags</label>
<label htmlFor="upload-tags" className="block text-sm font-medium text-fg-muted">Tags</label>
<input
id="upload-tags"
type="text"
value={tags}
onChange={(e) => setTags(e.target.value)}
@ -334,7 +349,7 @@ export default function LibraryPage() {
<div className="flex items-center gap-3">
<LibraryBig className="h-6 w-6 text-brand-400" />
<h1 className="text-2xl font-bold text-fg">Library</h1>
<span className="ml-2 rounded bg-surface-200 px-2 py-0.5 text-xs text-fg-subtle">
<span className="ml-2 rounded bg-surface-200 px-2 py-0.5 text-xs text-fg-muted">
{collection}
</span>
</div>

View file

@ -69,8 +69,11 @@ export default function PromptsPage() {
</div>
{/* Tabs */}
<div className="mb-4 flex gap-1 rounded-lg bg-surface-100 p-1">
<div role="tablist" aria-label="Prompt sections" className="mb-4 flex gap-1 rounded-lg bg-surface-100 p-1">
<button
role="tab"
aria-selected={activeTab === "templates"}
aria-controls="panel-templates"
onClick={() => setActiveTab("templates")}
className={cn(
"flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-colors",
@ -83,6 +86,9 @@ export default function PromptsPage() {
Templates
</button>
<button
role="tab"
aria-selected={activeTab === "system"}
aria-controls="panel-system"
onClick={() => setActiveTab("system")}
className={cn(
"flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-colors",
@ -105,7 +111,7 @@ export default function PromptsPage() {
{/* Templates tab */}
{activeTab === "templates" && (
<div className="flex flex-1 flex-col gap-4 overflow-hidden">
<div id="panel-templates" role="tabpanel" className="flex flex-1 flex-col gap-4 overflow-hidden">
{loading && prompts.length === 0 && (
<div className="flex items-center justify-center py-12">
<Loader2 className="mr-2 h-5 w-5 animate-spin text-fg-subtle" />
@ -125,9 +131,9 @@ export default function PromptsPage() {
{/* Prompt list */}
<div className="w-80 shrink-0 overflow-y-auto rounded-lg border border-border">
<div className="border-b border-border bg-surface-100 px-4 py-3">
<h3 className="text-xs font-medium uppercase tracking-wider text-fg-muted">
<h2 className="text-xs font-medium uppercase tracking-wider text-fg-muted">
Templates ({prompts.length})
</h3>
</h2>
</div>
<div className="divide-y divide-border">
{prompts.map((p) => {
@ -156,9 +162,9 @@ export default function PromptsPage() {
{selectedPromptId ? (
<>
<div className="flex items-center justify-between border-b border-border bg-surface-100 px-4 py-3">
<h3 className="text-sm font-medium text-fg">
<h2 className="text-sm font-medium text-fg">
<span className="font-mono">{selectedPromptId}</span>
</h3>
</h2>
<button
onClick={() => {
setSelectedPromptId(null);
@ -195,11 +201,11 @@ export default function PromptsPage() {
{/* System Prompt tab */}
{activeTab === "system" && (
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border border-border">
<div id="panel-system" role="tabpanel" className="flex flex-1 flex-col overflow-hidden rounded-lg border border-border">
<div className="border-b border-border bg-surface-100 px-4 py-3">
<h3 className="text-xs font-medium uppercase tracking-wider text-fg-muted">
<h2 className="text-xs font-medium uppercase tracking-wider text-fg-muted">
System Prompt
</h3>
</h2>
</div>
<div className="flex-1 overflow-y-auto p-4">
{loading ? (

View file

@ -171,10 +171,11 @@ export default function SettingsPage() {
</div>
<div className="space-y-1.5">
<label className="block text-sm font-medium text-fg-muted">
<label htmlFor="settings-gateway-url" className="block text-sm font-medium text-fg-muted">
Gateway URL
</label>
<input
id="settings-gateway-url"
type="text"
value={settings.gatewayUrl}
onChange={(e) => updateSetting("gatewayUrl", e.target.value)}
@ -187,10 +188,11 @@ export default function SettingsPage() {
</div>
<div className="space-y-1.5">
<label className="block text-sm font-medium text-fg-muted">
<label htmlFor="settings-user-id" className="block text-sm font-medium text-fg-muted">
User ID
</label>
<input
id="settings-user-id"
type="text"
value={settings.user}
onChange={(e) => updateSetting("user", e.target.value)}
@ -205,11 +207,12 @@ export default function SettingsPage() {
icon={<Key className="h-4 w-4 text-fg-subtle" />}
>
<div className="space-y-1.5">
<label className="block text-sm font-medium text-fg-muted">
<label htmlFor="settings-api-key" className="block text-sm font-medium text-fg-muted">
API Key
</label>
<div className="relative">
<input
id="settings-api-key"
type={showApiKey ? "text" : "password"}
value={settings.apiKey}
onChange={(e) => updateSetting("apiKey", e.target.value)}
@ -241,7 +244,7 @@ export default function SettingsPage() {
icon={<Database className="h-4 w-4 text-fg-subtle" />}
>
<div className="space-y-1.5">
<label className="block text-sm font-medium text-fg-muted">
<label htmlFor="settings-collection" className="block text-sm font-medium text-fg-muted">
Active Collection
</label>
{loadingCollections ? (
@ -251,6 +254,7 @@ export default function SettingsPage() {
</div>
) : collections.length > 0 ? (
<select
id="settings-collection"
value={settings.collection}
onChange={(e) => updateSetting("collection", e.target.value)}
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
@ -266,6 +270,7 @@ export default function SettingsPage() {
</select>
) : (
<input
id="settings-collection"
type="text"
value={settings.collection}
onChange={(e) => updateSetting("collection", e.target.value)}
@ -281,11 +286,12 @@ export default function SettingsPage() {
icon={<Workflow className="h-4 w-4 text-fg-subtle" />}
>
<div className="space-y-1.5">
<label className="block text-sm font-medium text-fg-muted">
<label htmlFor="settings-flow" className="block text-sm font-medium text-fg-muted">
Flow
</label>
{flows.length > 0 ? (
<select
id="settings-flow"
value={flowId}
onChange={(e) => setFlowId(e.target.value)}
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
@ -300,6 +306,7 @@ export default function SettingsPage() {
</select>
) : (
<input
id="settings-flow"
type="text"
value={flowId}
onChange={(e) => setFlowId(e.target.value)}

View file

@ -72,7 +72,7 @@ export default function TokenCostPage() {
<Coins className="h-6 w-6 text-brand-400" />
<h1 className="text-2xl font-bold text-fg">Token Cost</h1>
{!loading && (
<span className="ml-2 rounded bg-surface-200 px-2 py-0.5 text-xs text-fg-subtle">
<span className="ml-2 rounded bg-surface-200 px-2 py-0.5 text-xs text-fg-muted">
{costs.length} model{costs.length !== 1 ? "s" : ""}
</span>
)}