mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-01 09:29:38 +02:00
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:
parent
b854b56558
commit
77a5fa5044
12 changed files with 214 additions and 70 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue