Advance TS port Effect workbench

This commit is contained in:
elpresidank 2026-06-01 16:22:25 -05:00
parent 92dae8c374
commit 3515106670
116 changed files with 12286 additions and 9584 deletions

View file

@ -1,10 +1,5 @@
import {
useCallback,
useEffect,
useRef,
useState,
type KeyboardEvent,
} from "react";
import { useAtom, useAtomSet, useAtomValue } from "@effect/atom-react";
import type { KeyboardEvent } from "react";
import {
MessageSquareText,
Send,
@ -20,47 +15,49 @@ import {
} from "lucide-react";
import Markdown from "react-markdown";
import { cn } from "@/lib/utils";
import { useConversation, type ChatMessage } from "@/hooks/use-conversation";
import { useChat } from "@/hooks/use-chat";
import { useSettings } from "@/providers/settings-provider";
import { useProgressStore } from "@/hooks/use-progress-store";
import {
agentPhaseExpandedAtom,
cancelChatAtom,
clearMessagesAtom,
conversationAtom,
deleteMessageAtom,
isLoadingAtom,
regenerateLastMessageAtom,
setChatModeAtom,
setConversationInputAtom,
settingsAtom,
submitMessageAtom,
type ChatMessage,
} from "@/atoms/workbench";
import { AutoTextarea } from "@/components/ui/textarea";
import { MessageActions } from "@/components/chat/message-actions";
import { ExplainGraph } from "@/components/chat/explain-graph";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const MODES = [
{ value: "graph-rag" as const, label: "Graph RAG" },
{ value: "document-rag" as const, label: "Doc RAG" },
{ value: "agent" as const, label: "Agent" },
];
// ---------------------------------------------------------------------------
// Agent phase section (collapsible)
// ---------------------------------------------------------------------------
function AgentPhaseBlock({
messageId,
phase,
icon,
label,
content,
isActive,
}: {
messageId: string;
phase: string;
icon: React.ReactNode;
label: string;
content: string;
isActive: boolean;
}) {
const [manualToggle, setManualToggle] = useState<boolean | null>(null);
const [expandedMap, setExpandedMap] = useAtom(agentPhaseExpandedAtom);
const key = `${messageId}:${phase}`;
if (content.length === 0 && !isActive) return null;
// Auto-expand while actively streaming; user can override
const expanded = manualToggle ?? isActive;
const expanded = expandedMap[key] ?? isActive;
const phaseColors: Record<string, string> = {
think: "border-amber-500/30 bg-amber-500/5",
@ -75,40 +72,22 @@ function AgentPhaseBlock({
};
return (
<div
className={cn(
"rounded-md border",
phaseColors[phase] ?? "border-border bg-surface-100",
)}
>
<div className={cn("rounded-md border", phaseColors[phase] ?? "border-border bg-surface-100")}>
<button
onClick={() => setManualToggle((prev) => !(prev ?? isActive))}
onClick={() => setExpandedMap({ ...expandedMap, [key]: !expanded })}
aria-expanded={expanded}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-medium text-fg-muted"
>
{expanded ? (
<ChevronDown className="h-3 w-3 shrink-0" />
) : (
<ChevronRight className="h-3 w-3 shrink-0" />
)}
{expanded ? <ChevronDown className="h-3 w-3 shrink-0" /> : <ChevronRight className="h-3 w-3 shrink-0" />}
{icon}
<span
className={cn(
"rounded px-1.5 py-0.5",
badgeColors[phase] ?? "bg-surface-200 text-fg-muted",
)}
>
<span className={cn("rounded px-1.5 py-0.5", badgeColors[phase] ?? "bg-surface-200 text-fg-muted")}>
{label}
</span>
{isActive && (
<Loader2 className="ml-auto h-3 w-3 animate-spin text-fg-subtle" />
)}
{isActive && <Loader2 className="ml-auto h-3 w-3 animate-spin text-fg-subtle" />}
</button>
{expanded && (content.length > 0 || isActive) && (
<div className="border-t border-border/50 px-3 py-2 text-xs leading-relaxed text-fg-muted">
<p className="whitespace-pre-wrap">
{content.length > 0 ? content : isActive ? "..." : ""}
</p>
<p className="whitespace-pre-wrap">{content.length > 0 ? content : isActive ? "..." : ""}</p>
{isActive && content.length > 0 && (
<span className="mt-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-amber-400" />
)}
@ -118,168 +97,146 @@ function AgentPhaseBlock({
);
}
// ---------------------------------------------------------------------------
// Single message bubble
// ---------------------------------------------------------------------------
function MessageBubble({ msg, collection }: { msg: ChatMessage; collection: string }) {
function MessageBubble({
msg,
collection,
isLastAssistant,
}: {
msg: ChatMessage;
collection: string;
isLastAssistant: boolean;
}) {
const deleteMessage = useAtomSet(deleteMessageAtom);
const regenerateLastMessage = useAtomSet(regenerateLastMessageAtom);
const isUser = msg.role === "user";
const agentPhases = msg.agentPhases;
const isError = !isUser && msg.content.startsWith("Error:");
return (
<div
className={cn(
"rounded-lg px-4 py-3 text-sm leading-relaxed",
isUser
? "ml-auto max-w-[80%] bg-brand-700/30 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) */}
{agentPhases !== undefined && (
<div className="mb-2 space-y-1.5">
<AgentPhaseBlock
phase="think"
icon={<Brain className="h-3 w-3" />}
label="Thinking"
content={agentPhases.think}
isActive={msg.activePhase === "think"}
/>
<AgentPhaseBlock
phase="observe"
icon={<Eye className="h-3 w-3" />}
label="Observing"
content={agentPhases.observe}
isActive={msg.activePhase === "observe"}
/>
{agentPhases.answer.length > 0 && (
<div className="flex items-center gap-1.5 px-1 pt-1 text-xs text-emerald-400">
<CheckCircle className="h-3 w-3" />
<span className="font-medium">Answer</span>
</div>
)}
</div>
)}
<div className="group relative">
<div
className={cn(
"rounded-lg px-4 py-3 text-sm leading-relaxed",
isUser
? "ml-auto max-w-[80%] bg-brand-700/30 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",
)}
>
{agentPhases !== undefined && (
<div className="mb-2 space-y-1.5">
<AgentPhaseBlock
messageId={msg.id}
phase="think"
icon={<Brain className="h-3 w-3" />}
label="Thinking"
content={agentPhases.think}
isActive={msg.activePhase === "think"}
/>
<AgentPhaseBlock
messageId={msg.id}
phase="observe"
icon={<Eye className="h-3 w-3" />}
label="Observing"
content={agentPhases.observe}
isActive={msg.activePhase === "observe"}
/>
{agentPhases.answer.length > 0 && (
<div className="flex items-center gap-1.5 px-1 pt-1 text-xs text-emerald-400">
<CheckCircle className="h-3 w-3" />
<span className="font-medium">Answer</span>
</div>
)}
</div>
)}
{/* 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" />
{isUser ? (
<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.length > 0 ? msg.content : msg.isStreaming === true ? "" : "(empty)"}</Markdown>
</div>
)}
) : 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.length > 0 ? msg.content : msg.isStreaming === true ? "" : "(empty)"}</Markdown>
</div>
)}
{/* Streaming indicator */}
{msg.isStreaming === true && (
<span className="mt-1 inline-block h-2 w-2 animate-pulse rounded-full bg-brand-400" />
)}
{msg.isStreaming === true && (
<span className="mt-1 inline-block h-2 w-2 animate-pulse rounded-full bg-brand-400" />
)}
{/* Token metadata */}
{msg.metadata !== undefined && (
<div className="mt-2 flex items-center gap-3 text-[10px] text-fg-subtle">
{msg.metadata.model !== undefined && msg.metadata.model.length > 0 && <span>{msg.metadata.model}</span>}
{msg.metadata.inTokens != null && (
<span>in: {msg.metadata.inTokens}</span>
)}
{msg.metadata.outTokens != null && (
<span>out: {msg.metadata.outTokens}</span>
)}
</div>
)}
{msg.metadata !== undefined && (
<div className="mt-2 flex items-center gap-3 text-[10px] text-fg-subtle">
{msg.metadata.model !== undefined && msg.metadata.model.length > 0 && <span>{msg.metadata.model}</span>}
{msg.metadata.inTokens != null && <span>in: {msg.metadata.inTokens}</span>}
{msg.metadata.outTokens != null && <span>out: {msg.metadata.outTokens}</span>}
</div>
)}
{/* Explainability graph */}
{!isUser && !isError && msg.isStreaming !== true && msg.explainEvents !== undefined && msg.explainEvents.length > 0 && (
<ExplainGraph explainEvents={msg.explainEvents} collection={collection} />
{!isUser && !isError && msg.isStreaming !== true && msg.explainEvents !== undefined && msg.explainEvents.length > 0 && (
<ExplainGraph explainEvents={msg.explainEvents} collection={collection} />
)}
</div>
{!isUser && (
<MessageActions
messageId={msg.id}
content={msg.content}
isLastAssistant={isLastAssistant}
onDelete={() => deleteMessage(msg.id)}
onRegenerate={() => regenerateLastMessage()}
/>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Chat page
// ---------------------------------------------------------------------------
export default function ChatPage() {
const messages = useConversation((s) => s.messages);
const input = useConversation((s) => s.input);
const chatMode = useConversation((s) => s.chatMode);
const setInput = useConversation((s) => s.setInput);
const setChatMode = useConversation((s) => s.setChatMode);
const clearMessages = useConversation((s) => s.clearMessages);
const { submitMessage, cancelRequest, regenerateLastMessage } = useChat();
const deleteMessage = useConversation((s) => s.deleteMessage);
const collection = useSettings((s) => s.settings.collection);
const isLoading = useProgressStore((s) => s.isLoading);
const conversation = useAtomValue(conversationAtom);
const collection = useAtomValue(settingsAtom).collection;
const isLoading = useAtomValue(isLoadingAtom);
const setInput = useAtomSet(setConversationInputAtom);
const setChatMode = useAtomSet(setChatModeAtom);
const clearMessages = useAtomSet(clearMessagesAtom);
const submitMessage = useAtomSet(submitMessageAtom);
const cancelRequest = useAtomSet(cancelChatAtom);
const scrollRef = useRef<HTMLDivElement>(null);
// Elapsed time counter while loading
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
if (!isLoading) {
setElapsed(0);
return;
const handleSubmit = () => {
if (conversation.input.trim().length > 0) {
submitMessage({ input: conversation.input });
}
const interval = setInterval(() => setElapsed((e) => e + 1), 1000);
return () => clearInterval(interval);
}, [isLoading]);
};
// Auto-scroll to bottom when messages change
useEffect(() => {
scrollRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const handleSubmit = useCallback(() => {
if (input.trim().length > 0) {
submitMessage({ input });
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
handleSubmit();
}
}, [input, submitMessage]);
};
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
},
[handleSubmit],
);
const lastAssistantId = [...conversation.messages].reverse().find((message) => message.role === "assistant")?.id;
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<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-muted">
{collection}
</span>
</div>
<div className="flex flex-wrap items-center gap-2">
{/* Mode selector */}
<div role="group" aria-label="Chat mode" className="flex rounded-lg border border-border bg-surface-100 p-0.5">
<div className="flex items-center gap-2">
<div className="flex rounded-lg border border-border bg-surface-100 p-1">
{MODES.map((mode) => (
<button
type="button"
key={mode.value}
onClick={() => setChatMode(mode.value)}
aria-pressed={chatMode === mode.value}
className={cn(
"rounded-md px-3 py-1 text-xs font-medium transition-colors",
chatMode === mode.value
"rounded-md px-3 py-1.5 text-xs font-medium transition-colors",
conversation.chatMode === mode.value
? "bg-brand-600 text-white"
: "text-fg-muted hover:text-fg",
: "text-fg-muted hover:bg-surface-200 hover:text-fg",
)}
>
{mode.label}
@ -287,84 +244,68 @@ export default function ChatPage() {
))}
</div>
<button
onClick={() => { cancelRequest(); clearMessages(); }}
className="rounded-lg p-2 text-fg-subtle hover:bg-surface-200 hover:text-fg"
title="Clear messages"
aria-label="Clear messages"
>
<Trash2 className="h-4 w-4" />
</button>
{conversation.messages.length > 0 && (
<button
onClick={() => clearMessages(null)}
className="rounded-lg border border-border p-2 text-fg-subtle hover:bg-surface-200 hover:text-error"
aria-label="Clear conversation"
title="Clear conversation"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
</div>
{/* Messages */}
<div className="flex-1 space-y-4 overflow-y-auto pb-4 pt-10">
{messages.length === 0 && (
<div className="flex flex-col items-center justify-center py-20 text-fg-subtle">
<MessageSquareText className="mb-3 h-10 w-10 opacity-30" />
<p>Send a message to start a conversation.</p>
<p className="mt-1 text-xs">
Mode: <span className="text-fg-muted">{MODES.find((m) => m.value === chatMode)?.label ?? chatMode}</span>
</p>
<div className="min-h-0 flex-1 overflow-y-auto rounded-lg border border-border bg-surface-50 p-4">
{conversation.messages.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center text-center">
<MessageSquareText className="mb-3 h-10 w-10 text-fg-subtle opacity-30" />
<p className="text-sm text-fg-subtle">Start a conversation with TrustGraph.</p>
</div>
) : (
<div className="space-y-4">
{conversation.messages.map((message) => (
<MessageBubble
key={message.id}
msg={message}
collection={collection}
isLastAssistant={message.id === lastAssistantId}
/>
))}
</div>
)}
{messages.map((msg, idx) => {
const isLastAssistant =
msg.role === "assistant" &&
idx === messages.length - 1;
return (
<div key={msg.id} className="group relative">
{msg.isStreaming !== true && (
<MessageActions
content={msg.content}
isLastAssistant={isLastAssistant}
onDelete={() => deleteMessage(msg.id)}
{...(isLastAssistant ? { onRegenerate: regenerateLastMessage } : {})}
/>
)}
<MessageBubble msg={msg} collection={collection} />
</div>
);
})}
<div ref={scrollRef} />
</div>
{/* Loading indicator */}
{isLoading && (
<div className="flex items-center gap-2 pb-2 text-xs text-fg-subtle">
<Loader2 className="h-3 w-3 animate-spin" />
<span>Processing... {elapsed}s</span>
<button
onClick={cancelRequest}
className="flex items-center gap-1 rounded-lg px-3 py-1 text-xs text-red-400 hover:bg-surface-200"
>
<X className="h-3 w-3" />
Cancel
</button>
<div className="mt-4 rounded-lg border border-border bg-surface-50 p-3">
<div className="flex items-end gap-2">
<AutoTextarea
value={conversation.input}
onChange={(event) => setInput(event.target.value)}
onKeyDown={handleKeyDown}
placeholder={`Ask using ${MODES.find((mode) => mode.value === conversation.chatMode)?.label ?? "TrustGraph"}...`}
disabled={isLoading}
maxRows={8}
/>
{isLoading ? (
<button
onClick={() => cancelRequest(null)}
className="rounded-lg border border-border p-3 text-fg-muted hover:bg-error/10 hover:text-error"
aria-label="Cancel request"
>
<X className="h-4 w-4" />
</button>
) : (
<button
onClick={handleSubmit}
disabled={conversation.input.trim().length === 0}
className="rounded-lg bg-brand-600 p-3 text-white hover:bg-brand-500 disabled:opacity-40"
aria-label="Send message"
>
<Send className="h-4 w-4" />
</button>
)}
</div>
)}
{/* Input area */}
<div className="flex items-end gap-2 border-t border-border pt-4">
<AutoTextarea
value={input}
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
onClick={handleSubmit}
disabled={input.trim().length === 0 || isLoading}
aria-label="Send message"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-brand-600 text-white transition-colors hover:bg-brand-500 disabled:opacity-40"
>
<Send className="h-4 w-4" />
</button>
</div>
</div>
);

View file

@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useAtom, useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";
import {
Workflow,
Plus,
@ -7,579 +7,201 @@ import {
ChevronDown,
ChevronRight,
Loader2,
AlertTriangle,
Info,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useFlows, type FlowSummary } from "@/hooks/use-flows";
import { useSocket } from "@/providers/socket-provider";
import { useNotification } from "@/providers/notification-provider";
import {
activeActionAtom,
encodeJsonUnknownString,
flowBlueprintAtom,
flowBlueprintsAtom,
flowExpandedAtom,
flowsAtom,
flowsStartDialogOpenAtom,
parseJsonUnknown,
resultData,
resultError,
resultLoading,
startFlowAtom,
startFlowFormAtom,
stopFlowAtom,
} from "@/atoms/workbench";
import { Dialog } from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
// ---------------------------------------------------------------------------
// Start flow dialog
// ---------------------------------------------------------------------------
function StartFlowDialog() {
const [open, setOpen] = useAtom(flowsStartDialogOpenAtom);
const [form, setForm] = useAtom(startFlowFormAtom);
const startFlow = useAtomSet(startFlowAtom);
const blueprintsResult = useAtomValue(flowBlueprintsAtom);
const blueprints = resultData(blueprintsResult, []);
const blueprintDetail = resultData(useAtomValue(flowBlueprintAtom(form.blueprint)), null) as Record<string, unknown> | null;
const loadingBlueprints = resultLoading(blueprintsResult, blueprints);
const isValid = form.id.trim().length > 0 && form.blueprint.length > 0 && form.description.trim().length > 0;
function StartFlowDialog({
open,
onClose,
onStart,
}: {
open: boolean;
onClose: () => void;
onStart: (
id: string,
blueprint: string,
description: string,
params: Record<string, unknown>,
) => Promise<void>;
}) {
const socket = useSocket();
const [blueprints, setBlueprints] = useState<string[]>([]);
const [loadingBlueprints, setLoadingBlueprints] = useState(false);
const [id, setId] = useState("");
const [blueprint, setBlueprint] = useState("");
const [description, setDescription] = useState("");
const [paramsJson, setParamsJson] = useState("{}");
const [submitting, setSubmitting] = useState(false);
const [paramsError, setParamsError] = useState<string | null>(null);
const [submitted, setSubmitted] = useState(false);
const [blueprintDef, setBlueprintDef] = useState<Record<string, unknown> | null>(null);
const [loadingDef, setLoadingDef] = useState(false);
const [defExpanded, setDefExpanded] = useState(false);
// Fetch blueprints when dialog opens
useEffect(() => {
if (!open) return;
setLoadingBlueprints(true);
socket
.flows()
.getFlowBlueprints()
.then((names) => {
const list = names ?? [];
setBlueprints(list);
if (list.length > 0 && blueprint.length === 0) {
setBlueprint(list[0]!);
}
})
.catch(() => setBlueprints([]))
.finally(() => setLoadingBlueprints(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, socket]);
// Fetch blueprint definition when selection changes
useEffect(() => {
if (blueprint.length === 0) {
setBlueprintDef(null);
return;
}
let cancelled = false;
setLoadingDef(true);
setBlueprintDef(null);
socket
.flows()
.getFlowBlueprint(blueprint)
.then((def) => {
if (cancelled) return;
setBlueprintDef(def);
// Pre-populate parameters with defaults from the definition
const paramsDef =
def?.parameters ?? def?.params ?? def?.["parameters"] ?? def?.["params"];
if (paramsDef !== undefined && paramsDef !== null && typeof paramsDef === "object") {
const defaults: Record<string, unknown> = {};
const params = paramsDef as Record<string, unknown>;
for (const [key, val] of Object.entries(params)) {
if (val !== null && typeof val === "object" && "default" in (val as Record<string, unknown>)) {
defaults[key] = (val as Record<string, unknown>).default;
}
}
if (Object.keys(defaults).length > 0) {
setParamsJson(JSON.stringify(defaults, null, 2));
}
}
})
.catch(() => {
if (cancelled === false) setBlueprintDef(null);
})
.finally(() => {
if (cancelled === false) setLoadingDef(false);
});
return () => {
cancelled = true;
};
}, [blueprint, socket]);
const reset = () => {
setId("");
setBlueprint("");
setDescription("");
setParamsJson("{}");
setParamsError(null);
setSubmitting(false);
setSubmitted(false);
setBlueprintDef(null);
setLoadingDef(false);
setDefExpanded(false);
const close = () => {
setForm({
id: "",
blueprint: "",
description: "",
paramsJson: "{}",
submitting: false,
paramsError: null,
submitted: false,
definitionExpanded: false,
});
setOpen(false);
};
const handleSubmit = async () => {
setSubmitted(true);
if (!isValid) return;
let params: Record<string, unknown> = {};
try {
params = JSON.parse(paramsJson);
setParamsError(null);
} catch {
setParamsError("Invalid JSON");
return;
}
setSubmitting(true);
try {
await onStart(id, blueprint, description, params);
reset();
onClose();
} catch {
setSubmitting(false);
}
};
const isValid = id.trim().length > 0 && blueprint.length > 0 && description.trim().length > 0;
return (
<Dialog
open={open}
onClose={() => {
if (!submitting) {
reset();
onClose();
}
}}
onClose={close}
title="Start Flow"
footer={
<>
<button
onClick={() => {
reset();
onClose();
}}
disabled={submitting}
onClick={close}
disabled={form.submitting}
className="rounded-lg border border-border px-4 py-2 text-sm text-fg-muted hover:bg-surface-200 disabled:opacity-40"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={submitting}
onClick={() => {
setForm({ ...form, submitted: true });
if (!isValid) return;
const parameters = parseJsonUnknown(form.paramsJson);
if (parameters === undefined || typeof parameters !== "object" || parameters === null || Array.isArray(parameters)) {
setForm({ ...form, paramsError: "Invalid JSON", submitted: true });
return;
}
startFlow({
id: form.id.trim(),
blueprint: form.blueprint,
description: form.description.trim(),
parameters: parameters as Record<string, unknown>,
});
close();
}}
disabled={form.submitting}
className="flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-500 disabled:opacity-40"
>
{submitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{form.submitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
<Plus className="h-3.5 w-3.5" />
Start
</button>
</>
}
>
{/* Flow ID */}
<div className="mb-3 space-y-1.5">
<label htmlFor="flow-id" className="block text-sm font-medium text-fg-muted">
Flow ID <span className="text-error">*</span>
<div className="space-y-3">
<label className="block">
<span className="mb-1 block text-sm font-medium text-fg-muted">Flow ID</span>
<input
value={form.id}
onChange={(event) => setForm({ ...form, id: event.target.value })}
placeholder="my-flow-id"
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
{form.submitted && form.id.trim().length === 0 && (
<p className="mt-1 text-xs text-red-400">Flow ID is required</p>
)}
</label>
<input
id="flow-id"
type="text"
value={id}
onChange={(e) => setId(e.target.value)}
placeholder="my-flow-id"
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
{submitted && id.trim().length === 0 && (
<p className="mt-1 text-xs text-red-400">Flow ID is required</p>
)}
</div>
{/* Blueprint name */}
<div className="mb-3 space-y-1.5">
<label htmlFor="flow-blueprint" className="block text-sm font-medium text-fg-muted">
Blueprint <span className="text-error">*</span>
</label>
{loadingBlueprints ? (
<div className="flex items-center gap-2 py-2 text-xs text-fg-subtle">
<Loader2 className="h-3 w-3 animate-spin" /> Loading blueprints...
</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"
>
<option value="" disabled>
Select a blueprint
</option>
{blueprints.map((bp) => (
<option key={bp} value={bp}>
{bp}
</option>
))}
</select>
)}
{submitted && blueprint.length === 0 && (
<p className="mt-1 text-xs text-red-400">Blueprint is required</p>
)}
{/* Blueprint details info section */}
{loadingDef && (
<div className="mt-2 flex items-center gap-2 text-xs text-fg-subtle">
<Loader2 className="h-3 w-3 animate-spin" /> Loading blueprint details...
</div>
)}
{blueprintDef !== null && !loadingDef && (
<div className="mt-2 rounded-lg border border-border bg-surface-50 p-3">
<div className="flex items-center gap-1.5 text-xs font-medium text-fg-muted">
<Info className="h-3.5 w-3.5 text-brand-400" />
Blueprint Details
<label className="block">
<span className="mb-1 block text-sm font-medium text-fg-muted">Blueprint</span>
{loadingBlueprints ? (
<div className="flex items-center gap-2 py-2 text-xs text-fg-subtle">
<Loader2 className="h-3 w-3 animate-spin" /> Loading blueprints...
</div>
) : (
<select
value={form.blueprint}
onChange={(event) => setForm({ ...form, blueprint: event.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"
>
<option value="">Select a blueprint</option>
{blueprints.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
)}
{form.submitted && form.blueprint.length === 0 && (
<p className="mt-1 text-xs text-red-400">Blueprint is required</p>
)}
</label>
{/* Description from definition */}
{(blueprintDef.description !== undefined || blueprintDef.desc !== undefined) && (
<p className="mt-1.5 text-xs text-fg-muted">
{String(blueprintDef.description ?? blueprintDef.desc)}
</p>
)}
{/* Parameters schema */}
{(() => {
const paramsDef =
blueprintDef.parameters ??
blueprintDef.params ??
blueprintDef["parameters"] ??
blueprintDef["params"];
if (paramsDef === undefined || paramsDef === null || typeof paramsDef !== "object") {
return null;
}
const entries = Object.entries(paramsDef as Record<string, unknown>);
if (entries.length === 0) return null;
return (
<div className="mt-2">
<p className="text-xs font-medium text-fg-muted">Parameters</p>
<div className="mt-1 space-y-1">
{entries.map(([name, schema]) => {
const s = schema as Record<string, unknown> | null;
const type = s?.type !== undefined ? String(s.type) : undefined;
const defaultVal = s !== null && "default" in s ? s.default : undefined;
const desc = s?.description !== undefined ? String(s.description) : undefined;
return (
<div
key={name}
className="flex flex-wrap items-baseline gap-x-2 text-xs"
>
<span className="font-mono font-medium text-fg">{name}</span>
{type !== undefined && (
<span className="rounded bg-surface-200 px-1 py-0.5 text-[10px] text-fg-subtle">
{type}
</span>
)}
{defaultVal !== undefined && (
<span className="text-fg-subtle">
default: <span className="font-mono">{JSON.stringify(defaultVal)}</span>
</span>
)}
{desc !== undefined && <span className="text-fg-subtle">- {desc}</span>}
</div>
);
})}
</div>
</div>
);
})()}
{/* Raw JSON toggle */}
{blueprintDetail !== null && (
<div className="rounded-lg border border-border bg-surface-50 p-3">
<button
type="button"
onClick={() => setDefExpanded((p) => !p)}
className="mt-2 flex items-center gap-1 text-[11px] text-fg-subtle hover:text-fg-muted"
onClick={() => setForm({ ...form, definitionExpanded: !form.definitionExpanded })}
className="flex w-full items-center gap-1.5 text-left text-xs font-medium text-fg-muted"
>
{defExpanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
Raw definition
{form.definitionExpanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
<Info className="h-3.5 w-3.5 text-brand-400" />
Blueprint Details
</button>
{defExpanded && (
<pre className="mt-1 max-h-40 overflow-auto rounded border border-border bg-surface-100 p-2 font-mono text-[11px] text-fg-subtle">
{JSON.stringify(blueprintDef, null, 2)}
{form.definitionExpanded && (
<pre className="mt-2 max-h-48 overflow-auto rounded-md bg-surface-100 p-2 font-mono text-[10px] text-fg-muted">
{encodeJsonUnknownString(blueprintDetail)}
</pre>
)}
</div>
)}
</div>
{/* Description */}
<div className="mb-3 space-y-1.5">
<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)}
placeholder="Human-readable description"
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
{submitted && description.trim().length === 0 && (
<p className="mt-1 text-xs text-red-400">Description is required</p>
)}
</div>
{/* Parameters (JSON) */}
<div className="space-y-1.5">
<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);
setParamsError(null);
}}
rows={4}
className={cn(
"w-full resize-none rounded-lg border bg-surface-100 px-3 py-2 font-mono text-xs text-fg placeholder:text-fg-subtle focus:outline-none focus:ring-1",
paramsError !== null
? "border-error focus:border-error focus:ring-error"
: "border-border focus:border-brand-500 focus:ring-brand-500",
<label className="block">
<span className="mb-1 block text-sm font-medium text-fg-muted">Description</span>
<input
value={form.description}
onChange={(event) => setForm({ ...form, description: event.target.value })}
placeholder="What this flow does"
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
{form.submitted && form.description.trim().length === 0 && (
<p className="mt-1 text-xs text-red-400">Description is required</p>
)}
/>
{paramsError !== null && (
<p className="text-xs text-error">{paramsError}</p>
)}
</label>
<label className="block">
<span className="mb-1 block text-sm font-medium text-fg-muted">Parameters JSON</span>
<textarea
value={form.paramsJson}
onChange={(event) => setForm({ ...form, paramsJson: event.target.value, paramsError: null })}
rows={6}
className="w-full resize-none rounded-lg border border-border bg-surface-100 px-3 py-2 font-mono text-xs text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
{form.paramsError !== null && <p className="mt-1 text-xs text-red-400">{form.paramsError}</p>}
</label>
</div>
</Dialog>
);
}
// ---------------------------------------------------------------------------
// Stop flow confirm dialog
// ---------------------------------------------------------------------------
function StopFlowDialog({
open,
flowId,
onClose,
onConfirm,
}: {
open: boolean;
flowId: string;
onClose: () => void;
onConfirm: () => Promise<void>;
}) {
const [stopping, setStopping] = useState(false);
const handleStop = async () => {
setStopping(true);
try {
await onConfirm();
} finally {
setStopping(false);
}
};
return (
<Dialog
open={open}
onClose={() => {
if (!stopping) onClose();
}}
title="Stop Flow"
footer={
<>
<button
onClick={onClose}
disabled={stopping}
className="rounded-lg border border-border px-4 py-2 text-sm text-fg-muted hover:bg-surface-200 disabled:opacity-40"
>
Cancel
</button>
<button
onClick={handleStop}
disabled={stopping}
className="flex items-center gap-2 rounded-lg bg-error px-4 py-2 text-sm font-medium text-white hover:opacity-90 disabled:opacity-40"
>
{stopping && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
Stop
</button>
</>
}
>
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-error" />
<p className="text-sm text-fg-muted">
Are you sure you want to stop flow{" "}
<span className="font-mono font-medium text-fg">{flowId}</span>?
</p>
</div>
</Dialog>
);
}
// ---------------------------------------------------------------------------
// Flow detail row (expandable)
// ---------------------------------------------------------------------------
function FlowRow({
flow,
onStop,
}: {
flow: FlowSummary;
onStop: (id: string) => void;
}) {
const [expanded, setExpanded] = useState(false);
// Determine all the extra keys beyond id/description
const detailKeys = Object.keys(flow).filter(
(k) => k !== "id" && k !== "description",
);
return (
<>
<tr
className="cursor-pointer hover:bg-surface-100/50"
onClick={() => setExpanded((p) => !p)}
>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
{expanded ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-fg-subtle" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-fg-subtle" />
)}
<span className="font-mono text-sm text-fg">{flow.id}</span>
</div>
</td>
<td className="px-4 py-3 text-fg-muted">
{(flow.description ?? "").length > 0 ? flow.description : "--"}
</td>
<td className="px-4 py-3">
<Badge variant="success">Running</Badge>
</td>
<td className="px-4 py-3 text-right">
<button
onClick={(e) => {
e.stopPropagation();
onStop(flow.id);
}}
className="rounded p-1.5 text-fg-subtle hover:bg-error/10 hover:text-error"
title="Stop flow"
aria-label={`Stop flow ${flow.id}`}
>
<Square className="h-3.5 w-3.5" />
</button>
</td>
</tr>
{/* Detail row */}
{expanded && detailKeys.length > 0 && (
<tr>
<td colSpan={4} className="bg-surface-50 px-8 py-3">
<div className="grid grid-cols-2 gap-x-6 gap-y-2 text-xs">
{detailKeys.map((key) => (
<div key={key}>
<span className="font-medium text-fg-muted">{key}: </span>
<span className="text-fg-subtle">
{typeof flow[key] === "object"
? JSON.stringify(flow[key])
: String(flow[key] ?? "")}
</span>
</div>
))}
</div>
</td>
</tr>
)}
</>
);
}
// ---------------------------------------------------------------------------
// Flows page
// ---------------------------------------------------------------------------
export default function FlowsPage() {
const { flows, loading, error, getFlows, startFlow, stopFlow } = useFlows();
const notify = useNotification();
const [createOpen, setCreateOpen] = useState(false);
const [stopTarget, setStopTarget] = useState<string | null>(null);
// Auto-refresh every 10 seconds
useEffect(() => {
const interval = setInterval(() => {
getFlows();
}, 10_000);
return () => clearInterval(interval);
}, [getFlows]);
// Also refresh on window focus
useEffect(() => {
const handler = () => getFlows();
window.addEventListener("focus", handler);
return () => window.removeEventListener("focus", handler);
}, [getFlows]);
const handleStart = async (
id: string,
blueprint: string,
description: string,
params: Record<string, unknown>,
) => {
try {
await startFlow(id, blueprint, description, params);
notify.success("Flow started", `Flow "${id}" has been started.`);
} catch (err) {
notify.error(
"Failed to start flow",
err instanceof Error ? err.message : String(err),
);
throw err; // re-throw so dialog stays open
}
};
const handleStop = async () => {
if (stopTarget === null || stopTarget.length === 0) return;
try {
await stopFlow(stopTarget);
notify.success("Flow stopped", `Flow "${stopTarget}" has been stopped.`);
} catch (err) {
notify.error(
"Failed to stop flow",
err instanceof Error ? err.message : String(err),
);
}
setStopTarget(null);
};
const flowsResult = useAtomValue(flowsAtom);
const refreshFlows = useAtomRefresh(flowsAtom);
const [expanded, setExpanded] = useAtom(flowExpandedAtom);
const setStartOpen = useAtomSet(flowsStartDialogOpenAtom);
const stopFlow = useAtomSet(stopFlowAtom);
const actionInProgress = useAtomValue(activeActionAtom);
const flows = resultData(flowsResult, []);
const loading = resultLoading(flowsResult, flows);
const error = resultError(flowsResult);
return (
<div className="flex h-full flex-col">
{/* Header */}
<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-muted">
{flows.length} active
</span>
{!loading && <Badge>{flows.length} flow{flows.length !== 1 ? "s" : ""}</Badge>}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => getFlows()}
onClick={refreshFlows}
disabled={loading}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-sm text-fg-muted transition-colors hover:bg-surface-200 disabled:opacity-40"
>
@ -587,16 +209,15 @@ export default function FlowsPage() {
Refresh
</button>
<button
onClick={() => setCreateOpen(true)}
className="flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-brand-500"
onClick={() => setStartOpen(true)}
className="flex items-center gap-1.5 rounded-lg bg-brand-600 px-3 py-2 text-sm font-medium text-white hover:bg-brand-500"
>
<Plus className="h-4 w-4" />
<Plus className="h-3.5 w-3.5" />
Start Flow
</button>
</div>
</div>
{/* Content */}
{loading && flows.length === 0 && (
<div className="flex items-center justify-center py-12">
<Loader2 className="mr-2 h-5 w-5 animate-spin text-fg-subtle" />
@ -605,7 +226,7 @@ export default function FlowsPage() {
)}
{error !== null && (
<p role="alert" className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">
<p className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">
{error}
</p>
)}
@ -613,50 +234,51 @@ export default function FlowsPage() {
{!loading && error === null && flows.length === 0 && (
<div className="flex flex-1 flex-col items-center justify-center">
<Workflow className="mb-3 h-10 w-10 text-fg-subtle opacity-30" />
<p className="text-fg-subtle">No flows configured.</p>
<p className="mt-1 text-xs text-fg-subtle">
Click "Start Flow" to create one.
</p>
<p className="text-fg-subtle">No flows are running.</p>
</div>
)}
{flows.length > 0 && (
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-left text-sm">
<thead className="border-b border-border bg-surface-100 text-fg-muted">
<tr>
<th className="px-4 py-3 font-medium">ID</th>
<th className="px-4 py-3 font-medium">Description</th>
<th className="px-4 py-3 font-medium">Status</th>
<th className="px-4 py-3 font-medium text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{flows.map((flow) => (
<FlowRow
key={flow.id}
flow={flow}
onStop={(id) => setStopTarget(id)}
/>
))}
</tbody>
</table>
<div className="space-y-3">
{flows.map((flow) => {
const isExpanded = expanded[flow.id] === true;
return (
<div key={flow.id} className="rounded-lg border border-border bg-surface-50">
<div className="flex items-center justify-between gap-3 px-4 py-3">
<button
onClick={() => setExpanded({ ...expanded, [flow.id]: !isExpanded })}
className="flex min-w-0 flex-1 items-center gap-2 text-left"
>
{isExpanded ? <ChevronDown className="h-4 w-4 text-fg-subtle" /> : <ChevronRight className="h-4 w-4 text-fg-subtle" />}
<span className="truncate font-mono text-sm font-medium text-fg">{flow.id}</span>
{flow.description !== undefined && (
<span className="truncate text-xs text-fg-muted">{flow.description}</span>
)}
</button>
<button
onClick={() => stopFlow(flow.id)}
disabled={actionInProgress === flow.id}
aria-label={`Stop flow ${flow.id}`}
className="flex items-center gap-1.5 rounded px-2.5 py-1.5 text-xs font-medium text-error hover:bg-error/10 disabled:opacity-40"
>
{actionInProgress === flow.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Square className="h-3.5 w-3.5" />}
Stop
</button>
</div>
{isExpanded && (
<div className="border-t border-border px-4 py-3">
<pre className="max-h-96 overflow-auto rounded-md bg-surface-100 p-3 font-mono text-xs text-fg-muted">
{encodeJsonUnknownString(flow)}
</pre>
</div>
)}
</div>
);
})}
</div>
)}
{/* Dialogs */}
<StartFlowDialog
open={createOpen}
onClose={() => setCreateOpen(false)}
onStart={handleStart}
/>
<StopFlowDialog
open={stopTarget !== null}
flowId={stopTarget ?? ""}
onClose={() => setStopTarget(null)}
onConfirm={handleStop}
/>
<StartFlowDialog />
</div>
);
}

View file

@ -1,59 +1,37 @@
import {
lazy,
Suspense,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { lazy, Suspense } from "react";
import { useAtom, useAtomRefresh, useAtomValue } from "@effect/atom-react";
import {
Rotate3d,
Search,
ZoomIn,
ZoomOut,
Maximize,
Loader2,
X,
ArrowRight,
ArrowLeft,
Filter,
RefreshCw,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useSocket } from "@/providers/socket-provider";
import { useSessionStore } from "@/hooks/use-session-store";
import { useSettings } from "@/providers/settings-provider";
import { useProgressStore } from "@/hooks/use-progress-store";
import { Badge } from "@/components/ui/badge";
import type { Triple, Term } from "@trustgraph/client";
import {
termValue,
flowIdAtom,
graphTriplesAtom,
graphViewAtom,
resultData,
resultError,
resultLoading,
settingsAtom,
} from "@/atoms/workbench";
import type { Triple } from "@trustgraph/client";
import {
localName,
hashColor,
triplesToGraph,
RDFS_LABEL,
RDF_TYPE,
termValue,
type GraphNode,
type GraphLink,
} from "@/lib/graph-utils";
import type { ForceGraphProps } from "react-force-graph-2d";
import { Badge } from "@/components/ui/badge";
// ---------------------------------------------------------------------------
// Lazy-load ForceGraph2D to keep bundle size down
// ---------------------------------------------------------------------------
import type {
ForceGraphMethods,
ForceGraphProps,
} from "react-force-graph-2d";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ForceGraph2D = lazy(() => import("react-force-graph-2d")) as unknown as React.ComponentType<ForceGraphProps<any, any> & { ref?: React.Ref<any> }>;
// Graph helpers imported from @/lib/graph-utils
// ---------------------------------------------------------------------------
// Node detail panel
// ---------------------------------------------------------------------------
const ForceGraph2D = lazy(() => import("react-force-graph-2d")) as unknown as React.ComponentType<ForceGraphProps<GraphNode, GraphLink>>;
function NodeDetailPanel({
nodeId,
@ -68,672 +46,243 @@ function NodeDetailPanel({
labelMap: Map<string, string>;
onClose: () => void;
}) {
// Find triples where this node is subject or object
const related = useMemo(() => {
const outbound: { predicate: string; object: string; objectLabel: string }[] = [];
const inbound: { predicate: string; subject: string; subjectLabel: string }[] = [];
const outbound: { predicate: string; object: string; objectLabel: string }[] = [];
const inbound: { predicate: string; subject: string; subjectLabel: string }[] = [];
for (const t of triples) {
const sVal = termValue(t.s);
const pVal = termValue(t.p);
const oVal = termValue(t.o);
if (pVal === RDFS_LABEL || pVal === RDF_TYPE) continue;
if (sVal === nodeId) {
outbound.push({
predicate: labelMap.get(pVal) ?? localName(pVal),
object: oVal,
objectLabel: labelMap.get(oVal) ?? localName(oVal),
});
}
if (oVal === nodeId) {
inbound.push({
predicate: labelMap.get(pVal) ?? localName(pVal),
subject: sVal,
subjectLabel: labelMap.get(sVal) ?? localName(sVal),
});
}
for (const triple of triples) {
const subject = termValue(triple.s);
const predicate = termValue(triple.p);
const object = termValue(triple.o);
if (predicate === RDFS_LABEL || predicate === RDF_TYPE) continue;
if (subject === nodeId) {
outbound.push({
predicate: labelMap.get(predicate) ?? localName(predicate),
object,
objectLabel: labelMap.get(object) ?? localName(object),
});
}
return { outbound, inbound };
}, [nodeId, triples, labelMap]);
if (object === nodeId) {
inbound.push({
predicate: labelMap.get(predicate) ?? localName(predicate),
subject,
subjectLabel: labelMap.get(subject) ?? localName(subject),
});
}
}
return (
<div className="flex h-full w-80 shrink-0 flex-col border-l border-border bg-surface-50">
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<h3 className="truncate text-sm font-semibold text-fg">{label}</h3>
<button
onClick={onClose}
className="rounded p-1 text-fg-subtle hover:bg-surface-200 hover:text-fg"
aria-label="Close detail panel"
>
<aside className="absolute right-4 top-4 z-20 max-h-[calc(100%-2rem)] w-96 overflow-y-auto rounded-lg border border-border bg-surface-50 shadow-xl">
<div className="flex items-start justify-between gap-3 border-b border-border px-4 py-3">
<div className="min-w-0">
<h2 className="truncate text-sm font-semibold text-fg">{label}</h2>
<p className="break-all font-mono text-[10px] text-fg-subtle">{nodeId}</p>
</div>
<button onClick={onClose} aria-label="Close node details" className="rounded p-1 text-fg-subtle hover:bg-surface-200 hover:text-fg">
<X className="h-4 w-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4">
<p className="mb-3 truncate font-mono text-[10px] text-fg-subtle">
{nodeId}
</p>
{/* Outbound relationships */}
{related.outbound.length > 0 && (
<div className="mb-4">
<h4 className="mb-2 flex items-center gap-1.5 text-xs font-medium text-fg-muted">
<ArrowRight className="h-3 w-3" />
Outbound ({related.outbound.length})
</h4>
<div className="space-y-1">
{related.outbound.map((r, i) => (
<div
key={i}
className="flex items-center gap-1.5 rounded bg-surface-100 px-2 py-1.5 text-xs"
>
<Badge variant="default">{r.predicate}</Badge>
<span className="truncate text-fg-muted">{r.objectLabel}</span>
<div className="space-y-4 p-4">
{outbound.length > 0 && (
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wider text-fg-subtle">Outgoing</h3>
<div className="space-y-2">
{outbound.map((edge, index) => (
<div key={`${edge.object}-${index}`} className="rounded-md bg-surface-100 p-2 text-xs">
<p className="text-fg-muted">{edge.predicate}</p>
<p className="mt-0.5 break-all font-mono text-fg">{edge.objectLabel}</p>
</div>
))}
</div>
</div>
</section>
)}
{/* Inbound relationships */}
{related.inbound.length > 0 && (
<div>
<h4 className="mb-2 flex items-center gap-1.5 text-xs font-medium text-fg-muted">
<ArrowLeft className="h-3 w-3" />
Inbound ({related.inbound.length})
</h4>
<div className="space-y-1">
{related.inbound.map((r, i) => (
<div
key={i}
className="flex items-center gap-1.5 rounded bg-surface-100 px-2 py-1.5 text-xs"
>
<span className="truncate text-fg-muted">{r.subjectLabel}</span>
<Badge variant="default">{r.predicate}</Badge>
{inbound.length > 0 && (
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wider text-fg-subtle">Incoming</h3>
<div className="space-y-2">
{inbound.map((edge, index) => (
<div key={`${edge.subject}-${index}`} className="rounded-md bg-surface-100 p-2 text-xs">
<p className="text-fg-muted">{edge.predicate}</p>
<p className="mt-0.5 break-all font-mono text-fg">{edge.subjectLabel}</p>
</div>
))}
</div>
</div>
)}
{related.outbound.length === 0 && related.inbound.length === 0 && (
<p className="text-xs text-fg-subtle">No relationships found.</p>
</section>
)}
</div>
</div>
</aside>
);
}
// ---------------------------------------------------------------------------
// Graph page
// ---------------------------------------------------------------------------
function paintNode(showLabels: boolean) {
return (node: GraphNode, ctx: CanvasRenderingContext2D, globalScale: number) => {
const radius = Math.max(3, Math.sqrt(node.degree + 1) * 2.2);
const x = node.x ?? 0;
const y = node.y ?? 0;
ctx.beginPath();
ctx.arc(x, y, radius, 0, 2 * Math.PI);
ctx.fillStyle = node.color ?? "#5b80ff";
ctx.fill();
if (!showLabels || globalScale < 0.7) return;
const fontSize = Math.max(10 / globalScale, 2);
ctx.font = `${fontSize}px Inter, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "top";
const light = document.documentElement.classList.contains("light");
ctx.fillStyle = light ? "rgba(24,24,27,0.85)" : "rgba(250,250,250,0.85)";
ctx.fillText(node.label, x, y + radius + 1);
};
}
function paintLink(link: GraphLink, ctx: CanvasRenderingContext2D, globalScale: number) {
if (globalScale < 1.5) return;
const source = link.source as unknown as GraphNode;
const target = link.target as unknown as GraphNode;
if (source.x === undefined || source.y === undefined || target.x === undefined || target.y === undefined) return;
const midX = (source.x + target.x) / 2;
const midY = (source.y + target.y) / 2;
const fontSize = Math.max(8 / globalScale, 2);
ctx.font = `${fontSize}px Inter, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillStyle = "rgba(161,161,170,0.65)";
ctx.fillText(link.label, midX, midY);
}
export default function GraphPage() {
const socket = useSocket();
const flowId = useSessionStore((s) => s.flowId);
const collection = useSettings((s) => s.settings.collection);
const addActivity = useProgressStore((s) => s.addActivity);
const removeActivity = useProgressStore((s) => s.removeActivity);
const [triples, setTriples] = useState<Triple[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState("");
const [selectedNode, setSelectedNode] = useState<string | null>(null);
// Query filters
const [showFilters, setShowFilters] = useState(false);
const [subjectFilter, setSubjectFilter] = useState("");
const [predicateFilter, setPredicateFilter] = useState("");
const [objectFilter, setObjectFilter] = useState("");
const [tripleLimit, setTripleLimit] = useState(2000);
const [showLegend, setShowLegend] = useState(false);
const hasActiveFilters =
subjectFilter.length > 0 ||
predicateFilter.length > 0 ||
objectFilter.length > 0;
const fgRef = useRef<ForceGraphMethods<GraphNode, GraphLink> | undefined>(
undefined,
);
const [containerSize, setContainerSize] = useState<{
width: number;
height: number;
} | null>(null);
const roRef = useRef<ResizeObserver | null>(null);
// Auto-fit tracking — declared early so fetchTriples can reset it
const hasAutoFit = useRef(false);
// Ref callback — attaches ResizeObserver when the container mounts
const containerRef = useCallback((el: HTMLDivElement | null) => {
// Disconnect previous observer
if (roRef.current !== null) {
roRef.current.disconnect();
roRef.current = null;
}
if (el === null) return;
const ro = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry !== undefined) {
const { width, height } = entry.contentRect;
setContainerSize({ width: Math.floor(width), height: Math.floor(height) });
}
});
ro.observe(el);
roRef.current = ro;
}, []);
// Fetch triples with optional filters
const fetchTriples = useCallback(async () => {
const act = "Load graph";
try {
setLoading(true);
setError(null);
addActivity(act);
hasAutoFit.current = false;
const flow = socket.flow(flowId);
const s: Term | undefined = subjectFilter.length > 0 ? { t: "i", i: subjectFilter } : undefined;
const p: Term | undefined = predicateFilter.length > 0 ? { t: "i", i: predicateFilter } : undefined;
const o: Term | undefined = objectFilter.length > 0 ? { t: "i", i: objectFilter } : undefined;
const result = await flow.triplesQuery(
s,
p,
o,
tripleLimit,
collection,
);
setTriples(result);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
removeActivity(act);
}
}, [socket, flowId, collection, subjectFilter, predicateFilter, objectFilter, tripleLimit, addActivity, removeActivity]);
useEffect(() => {
fetchTriples();
}, [fetchTriples]);
// Build graph
const { data: graphData, labelMap, typeMap } = useMemo(
() => triplesToGraph(Array.isArray(triples) ? triples : []),
[triples],
);
// Unique types for legend
const uniqueTypes = useMemo(() => {
const seen = new Map<string, string>();
for (const [, typeUri] of typeMap) {
const name = localName(typeUri);
if (!seen.has(name)) {
seen.set(name, typeUri);
}
}
return Array.from(seen.entries());
}, [typeMap]);
// Search filter -- highlight matching nodes
const searchLower = searchTerm.toLowerCase();
const matchingIds = useMemo(() => {
if (searchLower.length === 0) return new Set<string>();
return new Set(
graphData.nodes
.filter(
(n) =>
n.label.toLowerCase().includes(searchLower) ||
n.id.toLowerCase().includes(searchLower),
)
.map((n) => n.id),
);
}, [graphData.nodes, searchLower]);
const selectedLabel = selectedNode !== null
? labelMap.get(selectedNode) ?? localName(selectedNode)
: "";
// Auto-fit graph to view once data loads
useEffect(() => {
if (
graphData.nodes.length > 0 &&
fgRef.current !== undefined &&
hasAutoFit.current === false
) {
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);
const zoomFit = () =>
fgRef.current?.zoomToFit(400, 40);
// Node paint callback — with glow effect
const paintNode = useCallback(
(node: GraphNode, ctx: CanvasRenderingContext2D, globalScale: number) => {
const isSelected = node.id === selectedNode;
const isMatch = matchingIds.size > 0 && matchingIds.has(node.id);
const dim = matchingIds.size > 0 && !isMatch && !isSelected;
const radius = Math.max(3, Math.sqrt(node.degree + 1) * 2.5);
const x = node.x ?? 0;
const y = node.y ?? 0;
const baseColor = dim
? "rgba(100,100,100,0.3)"
: isSelected
? "#fbbf24"
: isMatch
? "#22c55e"
: node.color ?? "#5b80ff";
// Outer glow (only when not dimmed)
if (!dim) {
ctx.save();
ctx.shadowColor = baseColor;
ctx.shadowBlur = isSelected ? 16 : 8;
ctx.beginPath();
ctx.arc(x, y, radius, 0, 2 * Math.PI);
ctx.fillStyle = baseColor;
ctx.fill();
ctx.restore();
}
// Node circle (crisp, on top of glow)
ctx.beginPath();
ctx.arc(x, y, radius, 0, 2 * Math.PI);
ctx.fillStyle = baseColor;
ctx.fill();
// Inner highlight (subtle white dot for depth)
if (!dim && radius > 3) {
ctx.beginPath();
ctx.arc(x - radius * 0.25, y - radius * 0.25, radius * 0.3, 0, 2 * Math.PI);
ctx.fillStyle = "rgba(255,255,255,0.2)";
ctx.fill();
}
if (isSelected || isMatch) {
ctx.strokeStyle = isSelected ? "#fbbf24" : "#22c55e";
ctx.lineWidth = 1.5 / globalScale;
ctx.stroke();
}
// Label
const fontSize = Math.max(10 / globalScale, 2);
ctx.font = `600 ${fontSize}px Inter, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "top";
const isLight = document.documentElement.classList.contains("light");
ctx.fillStyle = dim
? "rgba(100,100,100,0.3)"
: isLight
? "rgba(24,24,27,0.9)"
: "rgba(250,250,250,0.9)";
ctx.fillText(node.label, x, y + radius + 2);
},
[selectedNode, matchingIds],
);
// Link label painting
const paintLink = useCallback(
(link: GraphLink, ctx: CanvasRenderingContext2D, globalScale: number) => {
if (globalScale < 1.5) return; // only show labels when zoomed in enough
const src = link.source as unknown as GraphNode;
const tgt = link.target as unknown as GraphNode;
if (
src.x === undefined ||
src.y === undefined ||
tgt.x === undefined ||
tgt.y === undefined
) {
return;
}
const midX = ((src.x ?? 0) + (tgt.x ?? 0)) / 2;
const midY = ((src.y ?? 0) + (tgt.y ?? 0)) / 2;
const fontSize = Math.max(8 / globalScale, 1.5);
ctx.font = `${fontSize}px Inter, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillStyle = "rgba(161,161,170,0.7)";
ctx.fillText(link.label, midX, midY);
},
[],
);
const flowId = useAtomValue(flowIdAtom);
const collection = useAtomValue(settingsAtom).collection;
const [view, setView] = useAtom(graphViewAtom);
const triplesResult = useAtomValue(graphTriplesAtom({ flowId, collection, limit: view.nodeLimit }));
const refresh = useAtomRefresh(graphTriplesAtom({ flowId, collection, limit: view.nodeLimit }));
const triples = resultData(triplesResult, []);
const loading = resultLoading(triplesResult, triples);
const error = resultError(triplesResult);
const { data, labelMap, typeMap } = triplesToGraph(triples);
const search = view.searchTerm.trim().toLowerCase();
const graphData = search.length === 0
? data
: (() => {
const nodes = data.nodes.filter((node) => node.label.toLowerCase().includes(search) || node.id.toLowerCase().includes(search));
const nodeIds = new Set(nodes.map((node) => node.id));
return {
nodes,
links: data.links.filter((link) => {
const source = typeof link.source === "string" ? link.source : (link.source as GraphNode).id;
const target = typeof link.target === "string" ? link.target : (link.target as GraphNode).id;
return nodeIds.has(source) && nodeIds.has(target);
}),
};
})();
const selectedNode = view.selectedNodeId !== null
? data.nodes.find((node) => node.id === view.selectedNodeId)
: undefined;
const uniqueTypes = Array.from(new Set(Array.from(typeMap.values()).map(localName))).sort();
return (
<div className="flex h-full flex-col">
{/* Header */}
<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-muted">
{graphData.nodes.length} nodes, {graphData.links.length} edges
</span>
<Badge>{graphData.nodes.length} nodes</Badge>
<Badge>{graphData.links.length} edges</Badge>
</div>
<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" />
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 rounded-lg border border-border bg-surface-100 px-3 py-2">
<Search className="h-4 w-4 text-fg-subtle" />
<input
id="graph-search"
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search nodes..."
aria-label="Search nodes"
className="w-48 rounded-lg border border-border bg-surface-100 py-1.5 pl-8 pr-3 text-xs text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
value={view.searchTerm}
onChange={(event) => setView({ ...view, searchTerm: event.target.value })}
placeholder="Search graph..."
className="w-48 bg-transparent text-sm text-fg placeholder:text-fg-subtle focus:outline-none"
/>
{searchTerm.length > 0 && (
<button
onClick={() => setSearchTerm("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-fg-subtle hover:text-fg"
aria-label="Clear search"
>
<X className="h-3 w-3" />
</button>
)}
</div>
{/* Zoom controls */}
<div className="flex rounded-lg border border-border bg-surface-100">
<button
onClick={zoomIn}
className="px-2 py-1.5 text-fg-muted hover:text-fg"
title="Zoom in"
aria-label="Zoom in"
>
<ZoomIn className="h-3.5 w-3.5" />
</button>
<button
onClick={zoomOut}
className="border-l border-r border-border px-2 py-1.5 text-fg-muted hover:text-fg"
title="Zoom out"
aria-label="Zoom out"
>
<ZoomOut className="h-3.5 w-3.5" />
</button>
<button
onClick={zoomFit}
className="px-2 py-1.5 text-fg-muted hover:text-fg"
title="Fit to view"
aria-label="Fit to view"
>
<Maximize className="h-3.5 w-3.5" />
</button>
</div>
{/* Filter toggle */}
<button
onClick={() => setShowFilters((p) => !p)}
className={cn(
"flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs transition-colors",
showFilters || hasActiveFilters
? "border-brand-500/50 bg-brand-600/10 text-brand-400"
: "border-border text-fg-muted hover:bg-surface-200",
)}
title="Query filters"
aria-label="Toggle query filters"
aria-expanded={showFilters}
<select
value={view.nodeLimit}
onChange={(event) => setView({ ...view, nodeLimit: Number(event.target.value) })}
aria-label="Node limit"
className="rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg focus:border-brand-500 focus:outline-none"
>
<Filter className="h-3.5 w-3.5" />
Filters
{hasActiveFilters && !showFilters && (
<span className="ml-0.5 h-1.5 w-1.5 rounded-full bg-brand-400" />
)}
</button>
{/* Legend toggle */}
{uniqueTypes.length > 0 && (
<button
onClick={() => setShowLegend((p) => !p)}
className={cn(
"flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs transition-colors",
showLegend
? "border-brand-500/50 bg-brand-600/10 text-brand-400"
: "border-border text-fg-muted hover:bg-surface-200",
)}
title="Type legend"
aria-label="Toggle type legend"
aria-expanded={showLegend}
>
Legend
</button>
)}
{[100, 250, 500, 1000].map((limit) => <option key={limit} value={limit}>{limit}</option>)}
</select>
<button
onClick={fetchTriples}
onClick={refresh}
disabled={loading}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-xs text-fg-muted hover:bg-surface-200 disabled:opacity-40"
aria-label="Refresh graph"
className="rounded-lg border border-border px-3 py-2 text-fg-muted hover:bg-surface-200 disabled:opacity-40"
>
{loading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Rotate3d className="h-3.5 w-3.5" />
)}
Reload
<RefreshCwIcon loading={loading} />
</button>
</div>
</div>
{/* Filter panel */}
{showFilters && (
<div className="mb-4 rounded-lg border border-border bg-surface-50 p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="flex items-center gap-2 text-xs font-medium text-fg-muted">
<Filter className="h-3 w-3" />
Query Filters
</h3>
{hasActiveFilters && (
<button
onClick={() => {
setSubjectFilter("");
setPredicateFilter("");
setObjectFilter("");
}}
className="text-xs text-brand-400 hover:text-brand-300"
>
Clear all
</button>
)}
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1">
<label htmlFor="filter-subject" className="block text-[10px] font-medium uppercase tracking-wider text-fg-subtle">
Subject
</label>
<input
id="filter-subject"
type="text"
value={subjectFilter}
onChange={(e) => setSubjectFilter(e.target.value)}
placeholder="URI filter..."
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-1.5 text-xs text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
</div>
<div className="space-y-1">
<label htmlFor="filter-predicate" className="block text-[10px] font-medium uppercase tracking-wider text-fg-subtle">
Predicate
</label>
<input
id="filter-predicate"
type="text"
value={predicateFilter}
onChange={(e) => setPredicateFilter(e.target.value)}
placeholder="URI filter..."
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-1.5 text-xs text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
</div>
<div className="space-y-1">
<label htmlFor="filter-object" className="block text-[10px] font-medium uppercase tracking-wider text-fg-subtle">
Object
</label>
<input
id="filter-object"
type="text"
value={objectFilter}
onChange={(e) => setObjectFilter(e.target.value)}
placeholder="URI filter..."
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-1.5 text-xs text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
</div>
</div>
<div className="mt-3 flex items-center gap-4">
<div className="flex items-center gap-2">
<label htmlFor="filter-limit" className="text-[10px] font-medium uppercase tracking-wider text-fg-subtle">
Limit
</label>
<input
id="filter-limit"
type="range"
min={100}
max={5000}
step={100}
value={tripleLimit}
onChange={(e) => setTripleLimit(Number(e.target.value))}
className="w-24 accent-brand-500"
/>
<span className="text-xs text-fg-muted">{tripleLimit}</span>
</div>
<button
onClick={fetchTriples}
disabled={loading}
className="ml-auto flex items-center gap-1.5 rounded-lg bg-brand-600 px-4 py-1.5 text-xs font-medium text-white transition-colors hover:bg-brand-500 disabled:opacity-40"
>
Apply
</button>
</div>
</div>
)}
{/* Content */}
{error !== null && (
<p className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">
{error}
</p>
<p className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">{error}</p>
)}
{loading && triples.length === 0 && (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="mr-2 h-5 w-5 animate-spin text-fg-subtle" />
<span className="text-fg-subtle">Loading graph data...</span>
</div>
)}
<div className="mb-3 flex flex-wrap items-center gap-2">
<button
onClick={() => setView({ ...view, showLabels: !view.showLabels })}
className={cn("flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-xs", view.showLabels ? "bg-brand-600/10 text-brand-400" : "text-fg-muted")}
>
<Filter className="h-3.5 w-3.5" />
Labels
</button>
{uniqueTypes.slice(0, 8).map((type) => <Badge key={type} variant="info">{type}</Badge>)}
</div>
{!loading && graphData.nodes.length === 0 && (
<div className="flex flex-1 items-center justify-center rounded-lg border border-dashed border-border">
<div className="text-center">
<Rotate3d className="mx-auto mb-3 h-10 w-10 text-fg-subtle opacity-30" />
<p className="text-fg-subtle">No graph data in this collection.</p>
<p className="mt-1 text-xs text-fg-subtle">
Upload documents and process them to populate the knowledge graph.
</p>
<div className="relative min-h-0 flex-1 overflow-hidden rounded-lg border border-border bg-surface-50">
{loading && triples.length === 0 && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-surface-50">
<Loader2 className="mr-2 h-5 w-5 animate-spin text-fg-subtle" />
<span className="text-fg-subtle">Loading graph...</span>
</div>
</div>
)}
)}
{graphData.nodes.length > 0 && (
<div className="relative flex flex-1 overflow-hidden rounded-lg border border-border">
{/* Graph canvas */}
<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>}>
{!loading && graphData.nodes.length === 0 && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center">
<Rotate3d className="mb-3 h-10 w-10 text-fg-subtle opacity-30" />
<p className="text-fg-subtle">No graph triples available.</p>
</div>
)}
{graphData.nodes.length > 0 && (
<Suspense fallback={<div className="flex h-full items-center justify-center text-fg-subtle">Loading graph renderer...</div>}>
<ForceGraph2D
ref={fgRef}
graphData={graphData}
nodeCanvasObject={paintNode}
nodePointerAreaPaint={(node: GraphNode, color, ctx) => {
const radius = Math.max(3, Math.sqrt(node.degree + 1) * 2.5);
ctx.beginPath();
ctx.arc(node.x ?? 0, node.y ?? 0, radius + 2, 0, 2 * Math.PI);
ctx.fillStyle = color;
ctx.fill();
}}
width={900}
height={650}
backgroundColor="rgba(0,0,0,0)"
nodeCanvasObject={paintNode(view.showLabels)}
linkCanvasObjectMode={() => "after"}
linkCanvasObject={paintLink}
linkColor={() => "rgba(91,128,255,0.18)"}
linkWidth={1.5}
linkDirectionalArrowLength={5}
linkDirectionalArrowRelPos={0.85}
linkDirectionalArrowColor={() => "rgba(91,128,255,0.5)"}
linkDirectionalParticles={2}
linkDirectionalParticleWidth={2}
linkDirectionalParticleSpeed={0.004}
linkDirectionalParticleColor={() => "rgba(91,128,255,0.6)"}
linkCurvature={0.1}
onNodeClick={(node: GraphNode) => {
setSelectedNode((prev) =>
prev === node.id ? null : node.id,
);
linkColor={() => "rgba(120,120,140,0.32)"}
nodePointerAreaPaint={(node, color, ctx) => {
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(node.x ?? 0, node.y ?? 0, Math.max(6, Math.sqrt(node.degree + 1) * 3), 0, 2 * Math.PI);
ctx.fill();
}}
onBackgroundClick={() => setSelectedNode(null)}
backgroundColor="transparent"
cooldownTicks={100}
warmupTicks={30}
{...(containerSize !== null
? { width: containerSize.width, height: containerSize.height }
: {})}
onNodeClick={(node) => setView({ ...view, selectedNodeId: node.id, selectedNodeLabel: node.label })}
/>
</Suspense>
</Suspense>
)}
{/* Search results badge overlay */}
{searchTerm.length > 0 && matchingIds.size > 0 && (
<div className="absolute bottom-3 left-3">
<Badge variant="success">
{matchingIds.size} match{matchingIds.size > 1 ? "es" : ""}
</Badge>
</div>
)}
</div>
{/* Type legend overlay */}
{showLegend && uniqueTypes.length > 0 && (
<div className="absolute bottom-3 left-3 z-10 max-h-48 overflow-y-auto rounded-lg border border-border bg-surface-50/95 px-3 py-2 shadow-lg backdrop-blur-sm">
<h4 className="mb-1.5 text-[10px] font-medium uppercase tracking-wider text-fg-subtle">
Node Types
</h4>
<div className="space-y-1">
{uniqueTypes.map(([name]) => (
<div key={name} className="flex items-center gap-2 text-xs text-fg-muted">
<span
className="inline-block h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: hashColor(name) }}
/>
<span className="truncate">{name}</span>
</div>
))}
</div>
</div>
)}
{/* Detail panel -- positioned absolutely so it overlays the graph */}
{selectedNode !== 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>
)}
{selectedNode !== undefined && view.selectedNodeId !== null && (
<NodeDetailPanel
nodeId={view.selectedNodeId}
label={view.selectedNodeLabel ?? selectedNode.label}
triples={triples}
labelMap={labelMap}
onClose={() => setView({ ...view, selectedNodeId: null, selectedNodeLabel: null })}
/>
)}
</div>
</div>
);
}
function RefreshCwIcon({ loading }: { loading: boolean }) {
return loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />;
}

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useAtom, useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";
import {
BrainCircuit,
Loader2,
@ -8,30 +8,30 @@ import {
AlertTriangle,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useSocket } from "@/providers/socket-provider";
import { useConnectionState } from "@/providers/socket-provider";
import { useNotification } from "@/providers/notification-provider";
import { useSessionStore } from "@/hooks/use-session-store";
import {
activeActionAtom,
deleteKgCoreAtom,
kgCoresAtom,
knowledgeDeleteTargetAtom,
loadKgCoreAtom,
resultData,
resultError,
resultLoading,
} from "@/atoms/workbench";
import { Dialog } from "@/components/ui/dialog";
// ---------------------------------------------------------------------------
// Delete confirmation dialog
// ---------------------------------------------------------------------------
function DeleteCoreDialog({
open,
coreId,
onClose,
onConfirm,
}: {
open: boolean;
coreId: string;
coreId: string | null;
onClose: () => void;
onConfirm: () => void;
}) {
return (
<Dialog
open={open}
open={coreId !== null}
onClose={onClose}
title="Delete Knowledge Core"
footer={
@ -55,7 +55,7 @@ function DeleteCoreDialog({
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-error" />
<p className="text-sm text-fg-muted">
Are you sure you want to delete knowledge core{" "}
<span className="font-mono font-medium text-fg">{coreId}</span>?
<span className="font-mono font-medium text-fg">{coreId ?? ""}</span>?
This action cannot be undone.
</p>
</div>
@ -63,93 +63,20 @@ function DeleteCoreDialog({
);
}
// ---------------------------------------------------------------------------
// Knowledge Cores page
// ---------------------------------------------------------------------------
export default function KnowledgeCoresPage() {
const socket = useSocket();
const connectionState = useConnectionState();
const notify = useNotification();
const flowId = useSessionStore((s) => s.flowId);
const result = useAtomValue(kgCoresAtom);
const refresh = useAtomRefresh(kgCoresAtom);
const loadCore = useAtomSet(loadKgCoreAtom);
const deleteCore = useAtomSet(deleteKgCoreAtom);
const [deleteTarget, setDeleteTarget] = useAtom(knowledgeDeleteTargetAtom);
const actionInProgress = useAtomValue(activeActionAtom);
const [cores, setCores] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const [actionInProgress, setActionInProgress] = useState<string | null>(null);
const loadCores = useCallback(async () => {
try {
setLoading(true);
setError(null);
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("Request timed out")), 15000),
);
const ids = await Promise.race([
socket.knowledge().getKnowledgeCores(),
timeoutPromise,
]);
setCores(Array.isArray(ids) ? ids : []);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
console.error("Failed to load knowledge cores:", err);
} finally {
setLoading(false);
}
}, [socket]);
// Auto-load when connected
useEffect(() => {
const connected =
connectionState.status === "connected" ||
connectionState.status === "authenticated" ||
connectionState.status === "unauthenticated";
if (connected) {
loadCores();
}
}, [connectionState.status, loadCores]);
const handleLoad = useCallback(
async (id: string) => {
setActionInProgress(id);
try {
await socket.knowledge().loadKgCore(id, flowId);
notify.success("Core loaded", `Knowledge core "${id}" has been loaded.`);
} catch (err) {
notify.error(
"Failed to load core",
err instanceof Error ? err.message : String(err),
);
} finally {
setActionInProgress(null);
}
},
[socket, flowId, notify],
);
const handleDelete = useCallback(async () => {
if (deleteTarget === null || deleteTarget.length === 0) return;
setActionInProgress(deleteTarget);
try {
await socket.knowledge().deleteKgCore(deleteTarget);
notify.success("Core deleted", `Knowledge core "${deleteTarget}" has been deleted.`);
await loadCores();
} catch (err) {
notify.error(
"Failed to delete core",
err instanceof Error ? err.message : String(err),
);
} finally {
setActionInProgress(null);
setDeleteTarget(null);
}
}, [socket, deleteTarget, notify, loadCores]);
const cores = resultData(result, []);
const loading = resultLoading(result, cores);
const error = resultError(result);
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="mb-6 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-3">
<BrainCircuit className="h-6 w-6 text-brand-400" />
@ -162,7 +89,7 @@ export default function KnowledgeCoresPage() {
</div>
<button
onClick={loadCores}
onClick={refresh}
disabled={loading}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-sm text-fg-muted transition-colors hover:bg-surface-200 disabled:opacity-40"
>
@ -171,7 +98,6 @@ export default function KnowledgeCoresPage() {
</button>
</div>
{/* Content */}
{loading && cores.length === 0 && (
<div className="flex items-center justify-center py-12">
<Loader2 className="mr-2 h-5 w-5 animate-spin text-fg-subtle" />
@ -179,7 +105,7 @@ export default function KnowledgeCoresPage() {
</div>
)}
{error !== null && error.length > 0 && (
{error !== null && (
<p className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">
{error}
</p>
@ -210,7 +136,7 @@ export default function KnowledgeCoresPage() {
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1">
<button
onClick={() => handleLoad(id)}
onClick={() => loadCore(id)}
disabled={actionInProgress === id}
className="flex items-center gap-1.5 rounded px-2.5 py-1.5 text-xs font-medium text-brand-400 hover:bg-brand-600/10 disabled:opacity-40"
title="Load core"
@ -242,12 +168,13 @@ export default function KnowledgeCoresPage() {
</div>
)}
{/* Delete confirmation dialog */}
<DeleteCoreDialog
open={deleteTarget != null}
coreId={deleteTarget ?? ""}
coreId={deleteTarget}
onClose={() => setDeleteTarget(null)}
onConfirm={handleDelete}
onConfirm={() => {
if (deleteTarget !== null) deleteCore(deleteTarget);
setDeleteTarget(null);
}}
/>
</div>
);

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,4 @@
import { useCallback, useState } from "react";
import { useAtom, useAtomRefresh, useAtomValue } from "@effect/atom-react";
import {
MessageCircleCode,
Loader2,
@ -9,47 +9,40 @@ import {
Terminal,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { usePrompts } from "@/hooks/use-prompts";
// ---------------------------------------------------------------------------
// Prompts page
// ---------------------------------------------------------------------------
type Tab = "templates" | "system";
import {
promptActiveTabAtom,
promptDetailAtom,
promptsAtom,
resultData,
resultError,
resultLoading,
selectedPromptIdAtom,
systemPromptAtom,
} from "@/atoms/workbench";
export default function PromptsPage() {
const { prompts, systemPrompt, loading, error, loadPrompts, loadSystemPrompt, getPrompt } = usePrompts();
const promptsResult = useAtomValue(promptsAtom);
const systemPromptResult = useAtomValue(systemPromptAtom);
const refreshPrompts = useAtomRefresh(promptsAtom);
const refreshSystemPrompt = useAtomRefresh(systemPromptAtom);
const [activeTab, setActiveTab] = useAtom(promptActiveTabAtom);
const [selectedPromptId, setSelectedPromptId] = useAtom(selectedPromptIdAtom);
const promptDetailResult = useAtomValue(promptDetailAtom(selectedPromptId ?? ""));
const [activeTab, setActiveTab] = useState<Tab>("templates");
const [selectedPromptId, setSelectedPromptId] = useState<string | null>(null);
const [promptDetail, setPromptDetail] = useState<{ system?: string; prompt?: string } | string | null>(null);
const [loadingDetail, setLoadingDetail] = useState(false);
const prompts = resultData(promptsResult, []);
const systemPrompt = resultData(systemPromptResult, "");
const loading = resultLoading(promptsResult, prompts) || resultLoading(systemPromptResult, systemPrompt);
const error = resultError(promptsResult) ?? resultError(systemPromptResult);
const promptDetail = resultData(promptDetailResult, null) as { system?: string; prompt?: string } | string | null;
const loadingDetail = selectedPromptId !== null && resultLoading(promptDetailResult, promptDetail);
const handleSelectPrompt = useCallback(
async (id: string) => {
setSelectedPromptId(id);
setLoadingDetail(true);
try {
const detail = await getPrompt(id);
setPromptDetail(detail as typeof promptDetail);
} catch (err) {
console.error("Failed to load prompt detail:", err);
setPromptDetail("Error loading prompt.");
} finally {
setLoadingDetail(false);
}
},
[getPrompt],
);
const handleRefresh = useCallback(() => {
loadPrompts();
loadSystemPrompt();
}, [loadPrompts, loadSystemPrompt]);
const refresh = () => {
refreshPrompts();
refreshSystemPrompt();
};
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="mb-6 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-3">
<MessageCircleCode className="h-6 w-6 text-brand-400" />
@ -57,7 +50,7 @@ export default function PromptsPage() {
</div>
<button
onClick={handleRefresh}
onClick={refresh}
disabled={loading}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-sm text-fg-muted transition-colors hover:bg-surface-200 disabled:opacity-40"
>
@ -66,7 +59,6 @@ export default function PromptsPage() {
</button>
</div>
{/* Tabs */}
<div role="tablist" aria-label="Prompt sections" className="mb-4 flex gap-1 rounded-lg bg-surface-100 p-1">
<button
id="tab-templates"
@ -75,9 +67,7 @@ export default function PromptsPage() {
onClick={() => setActiveTab("templates")}
className={cn(
"flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-colors",
activeTab === "templates"
? "bg-surface-50 text-fg shadow-sm"
: "text-fg-muted hover:text-fg",
activeTab === "templates" ? "bg-surface-50 text-fg shadow-sm" : "text-fg-muted hover:text-fg",
)}
>
<FileText className="h-3.5 w-3.5" />
@ -90,9 +80,7 @@ export default function PromptsPage() {
onClick={() => setActiveTab("system")}
className={cn(
"flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-colors",
activeTab === "system"
? "bg-surface-50 text-fg shadow-sm"
: "text-fg-muted hover:text-fg",
activeTab === "system" ? "bg-surface-50 text-fg shadow-sm" : "text-fg-muted hover:text-fg",
)}
>
<Terminal className="h-3.5 w-3.5" />
@ -100,14 +88,12 @@ export default function PromptsPage() {
</button>
</div>
{/* Error display */}
{error !== null && error.length > 0 && (
{error !== null && (
<p className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">
{error}
</p>
)}
{/* Templates tab */}
{activeTab === "templates" && (
<div id="panel-templates" role="tabpanel" aria-labelledby="tab-templates" tabIndex={0} className="flex flex-1 flex-col gap-4 overflow-hidden">
{loading && prompts.length === 0 && (
@ -125,21 +111,20 @@ export default function PromptsPage() {
)}
{prompts.length > 0 && (
<div className="flex flex-1 gap-4 overflow-hidden">
{/* Prompt list */}
<div className="w-80 shrink-0 overflow-y-auto rounded-lg border border-border">
<div className="flex flex-1 flex-col gap-4 overflow-hidden lg:flex-row">
<div className="max-h-56 w-full shrink-0 overflow-y-auto rounded-lg border border-border lg:max-h-none lg:w-80">
<div className="border-b border-border bg-surface-100 px-4 py-3">
<h2 className="text-xs font-medium uppercase tracking-wider text-fg-muted">
Templates ({prompts.length})
</h2>
</div>
<div className="divide-y divide-border">
{prompts.map((p) => {
const id = p.id ?? (p as Record<string, unknown>).name ?? String(p);
{prompts.map((prompt) => {
const id = prompt.id ?? (prompt as Record<string, unknown>).name ?? String(prompt);
return (
<button
key={String(id)}
onClick={() => handleSelectPrompt(String(id))}
onClick={() => setSelectedPromptId(String(id))}
className={cn(
"flex w-full items-center justify-between px-4 py-3 text-left text-sm transition-colors",
selectedPromptId === String(id)
@ -155,8 +140,7 @@ export default function PromptsPage() {
</div>
</div>
{/* Prompt detail */}
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border border-border">
<div className="min-h-0 flex flex-1 flex-col overflow-hidden rounded-lg border border-border">
{selectedPromptId !== null && selectedPromptId.length > 0 ? (
<>
<div className="flex items-center justify-between border-b border-border bg-surface-100 px-4 py-3">
@ -164,10 +148,8 @@ export default function PromptsPage() {
<span className="font-mono">{selectedPromptId}</span>
</h2>
<button
onClick={() => {
setSelectedPromptId(null);
setPromptDetail("");
}}
onClick={() => setSelectedPromptId(null)}
aria-label="Close prompt detail"
className="rounded-md p-1 text-fg-subtle hover:bg-surface-200 hover:text-fg"
>
<X className="h-4 w-4" />
@ -220,7 +202,6 @@ export default function PromptsPage() {
</div>
)}
{/* System Prompt tab */}
{activeTab === "system" && (
<div id="panel-system" role="tabpanel" aria-labelledby="tab-system" tabIndex={0} 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">

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useAtom, useAtomSet, useAtomValue } from "@effect/atom-react";
import {
Settings as SettingsIcon,
Wifi,
@ -9,42 +9,44 @@ import {
Database,
Workflow,
Info,
Loader2,
Moon,
Sun,
Plus,
Trash2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useSettings } from "@/providers/settings-provider";
import { useSocket } from "@/providers/socket-provider";
import { useConnectionState } from "@/providers/socket-provider";
import { useFlows } from "@/hooks/use-flows";
import { useSessionStore } from "@/hooks/use-session-store";
import { useNotification } from "@/providers/notification-provider";
import type * as React from "react";
import {
collectionFormAtom,
collectionsAtom,
connectionStateAtom,
createCollectionAtom,
createCollectionDialogOpenAtom,
deleteCollectionAtom,
deleteCollectionDialogOpenAtom,
flowIdAtom,
flowsAtom,
resultData,
settingsAtom,
settingsShowApiKeyAtom,
setSettingsFieldAtom,
themeAtom,
toggleThemeAtom,
updateFeatureSwitchesAtom,
} from "@/atoms/workbench";
import { Badge } from "@/components/ui/badge";
import { Dialog } from "@/components/ui/dialog";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const ACRONYMS: Record<string, string> = { mcp: "MCP", llm: "LLM", api: "API" };
/** Convert camelCase key to display label, preserving known acronyms. */
function featureLabel(key: string): string {
return key
.replace(/([A-Z])/g, " $1")
.trim()
.split(" ")
.map((w) => ACRONYMS[w.toLowerCase()] ?? w.charAt(0).toUpperCase() + w.slice(1))
.map((word) => ACRONYMS[word.toLowerCase()] ?? word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
}
// ---------------------------------------------------------------------------
// Section wrapper
// ---------------------------------------------------------------------------
function Section({
title,
icon,
@ -65,181 +67,27 @@ function Section({
);
}
// ---------------------------------------------------------------------------
// Settings page
// ---------------------------------------------------------------------------
export default function SettingsPage() {
const { settings, updateSetting, updateFeatureSwitches } = useSettings();
const connectionState = useConnectionState();
const socket = useSocket();
const { flows } = useFlows();
const notify = useNotification();
const settings = useAtomValue(settingsAtom);
const setField = useAtomSet(setSettingsFieldAtom);
const updateFeatureSwitches = useAtomSet(updateFeatureSwitchesAtom);
const connectionState = useAtomValue(connectionStateAtom);
const flows = resultData(useAtomValue(flowsAtom), []);
const [flowId, setFlowId] = useAtom(flowIdAtom);
const [showApiKey, setShowApiKey] = useAtom(settingsShowApiKeyAtom);
const [theme] = useAtom(themeAtom);
const toggleTheme = useAtomSet(toggleThemeAtom);
const collections = resultData(useAtomValue(collectionsAtom), []);
const [createOpen, setCreateOpen] = useAtom(createCollectionDialogOpenAtom);
const [deleteOpen, setDeleteOpen] = useAtom(deleteCollectionDialogOpenAtom);
const [collectionForm, setCollectionForm] = useAtom(collectionFormAtom);
const createCollection = useAtomSet(createCollectionAtom);
const deleteCollection = useAtomSet(deleteCollectionAtom);
const flowId = useSessionStore((s) => s.flowId);
const setFlowId = useSessionStore((s) => s.setFlowId);
const [showApiKey, setShowApiKey] = useState(false);
const [collections, setCollections] = useState<
Array<{ id?: string; name?: string; [key: string]: unknown }>
>([]);
const [loadingCollections, setLoadingCollections] = useState(false);
// Create-collection dialog state
const [createOpen, setCreateOpen] = useState(false);
const [newId, setNewId] = useState("");
const [newName, setNewName] = useState("");
const [newDescription, setNewDescription] = useState("");
const [newTags, setNewTags] = useState("");
const [creating, setCreating] = useState(false);
// Delete-collection confirmation dialog state
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
// Dark mode toggle -- uses a class on <html>/<body> and persists to localStorage
const [isDark, setIsDark] = useState(() => {
if (typeof window === "undefined") return true;
const saved = localStorage.getItem("tg-theme");
if (saved !== null) return saved === "dark";
return !document.documentElement.classList.contains("light");
});
const toggleTheme = useCallback(() => {
const next = !isDark;
setIsDark(next);
if (next) {
document.documentElement.classList.remove("light");
document.body.classList.remove("light");
document.body.classList.add("dark");
localStorage.setItem("tg-theme", "dark");
} else {
document.documentElement.classList.add("light");
document.body.classList.add("light");
document.body.classList.remove("dark");
localStorage.setItem("tg-theme", "light");
}
}, [isDark]);
// Reusable function to fetch collections from the backend
const refreshCollections = useCallback(() => {
setLoadingCollections(true);
return socket
.collectionManagement()
.listCollections()
.then((cols) => {
const list = Array.isArray(cols)
? (cols as Array<{ id?: string; collection?: string; name?: string; [key: string]: unknown }>)
: [];
// Ensure "default" collection is always present
const hasDefault = list.some(
(c) => (c.collection ?? c.id ?? c.name) === "default",
);
if (!hasDefault) {
list.unshift({ id: "default", collection: "default", name: "default" });
}
setCollections(list);
return list;
})
.catch(() => {
// Fallback: at minimum show "default"
setCollections([{ id: "default", collection: "default", name: "default" }]);
})
.finally(() => {
setLoadingCollections(false);
});
}, [socket]);
// Fetch collections on mount
useEffect(() => {
let cancelled = false;
refreshCollections().then(() => {
if (cancelled) return;
});
return () => {
cancelled = true;
};
}, [refreshCollections]);
// Create a new collection
const handleCreateCollection = useCallback(async () => {
const trimmedId = newId.trim();
if (trimmedId.length === 0) return;
setCreating(true);
try {
const tags = newTags
.split(",")
.map((t) => t.trim())
.filter((tag) => tag.length > 0);
await socket
.collectionManagement()
.updateCollection(
trimmedId,
newName.trim().length > 0 ? newName.trim() : undefined,
newDescription.trim().length > 0 ? newDescription.trim() : undefined,
tags.length > 0 ? tags : undefined,
);
await refreshCollections();
updateSetting("collection", trimmedId);
notify.success("Collection created", `"${newName.trim() || trimmedId}" is now active.`);
// Reset form and close
setNewId("");
setNewName("");
setNewDescription("");
setNewTags("");
setCreateOpen(false);
} catch (err) {
notify.error(
"Failed to create collection",
err instanceof Error ? err.message : String(err),
);
} finally {
setCreating(false);
}
}, [newId, newName, newDescription, newTags, socket, refreshCollections, updateSetting, notify]);
// Delete the current collection
const handleDeleteCollection = useCallback(async () => {
const currentId = settings.collection;
if (currentId.length === 0) return;
setDeleting(true);
try {
await socket.collectionManagement().deleteCollection(currentId);
await refreshCollections();
// Switch to the first remaining collection
const remaining = collections.filter((c) => {
const id = c.id ?? String(c.name ?? c);
return id !== currentId;
});
if (remaining.length > 0) {
const firstId = remaining[0].id ?? String(remaining[0].name ?? remaining[0]);
updateSetting("collection", firstId);
}
notify.success("Collection deleted", `"${currentId}" has been removed.`);
setDeleteOpen(false);
} catch (err) {
notify.error(
"Failed to delete collection",
err instanceof Error ? err.message : String(err),
);
} finally {
setDeleting(false);
}
}, [settings.collection, socket, refreshCollections, collections, updateSetting, notify]);
// Connection status helpers
const isConnected =
connectionState.status === "connected" ||
connectionState.status === "authenticated" ||
connectionState.status === "unauthenticated";
const isWarning = connectionState.status === "unauthenticated";
const statusBadge = isConnected ? (
<Badge variant={isWarning ? "info" : "success"}>
@ -253,418 +101,235 @@ export default function SettingsPage() {
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="mb-6 flex items-center gap-3">
<SettingsIcon className="h-6 w-6 text-brand-400" />
<h1 className="text-2xl font-bold text-fg">Settings</h1>
</div>
{/* Form */}
<div className="max-w-2xl space-y-5 pb-8 overflow-y-auto">
{/* Connection */}
<Section
title="Connection"
icon={<Wifi className="h-4 w-4 text-fg-subtle" />}
>
<div className="flex items-center gap-3">
<span className="text-sm text-fg-muted">Status:</span>
<div className="grid gap-6 lg:grid-cols-2">
<Section title="Connection" icon={<Wifi className="h-4 w-4 text-brand-400" />}>
<div className="flex items-center justify-between rounded-lg border border-border bg-surface-100 px-4 py-3">
<div>
<p className="text-sm font-medium text-fg">Gateway status</p>
{connectionState.lastError !== undefined && (
<p className="mt-0.5 text-xs text-error">{connectionState.lastError}</p>
)}
</div>
{statusBadge}
</div>
<div className="space-y-1.5">
<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)}
placeholder="Leave blank to use the default proxy"
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
<p className="text-xs text-fg-subtle">
The WebSocket URL for the Beep Graph gateway.
</p>
</div>
<div className="space-y-1.5">
<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)}
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
</div>
</Section>
{/* Authentication */}
<Section
title="Authentication"
icon={<Key className="h-4 w-4 text-fg-subtle" />}
>
<div className="space-y-1.5">
<label htmlFor="settings-api-key" className="block text-sm font-medium text-fg-muted">
API Key
</label>
<label className="block">
<span className="mb-1 flex items-center gap-1.5 text-sm font-medium text-fg-muted">
<Key className="h-3.5 w-3.5" /> API Key
</span>
<div className="relative">
<input
id="settings-api-key"
type={showApiKey ? "text" : "password"}
value={settings.apiKey}
onChange={(e) => updateSetting("apiKey", e.target.value)}
placeholder="Leave blank for unauthenticated access"
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 pr-10 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
onChange={(event) => setField({ key: "apiKey", value: event.target.value })}
placeholder="Optional gateway bearer token"
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 pr-10 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
<button
type="button"
onClick={() => setShowApiKey((p) => !p)}
onClick={() => setShowApiKey(!showApiKey)}
aria-label={showApiKey ? "Hide API key" : "Show API key"}
className="absolute right-3 top-1/2 -translate-y-1/2 text-fg-subtle hover:text-fg"
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-fg-subtle hover:text-fg"
>
{showApiKey ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<p className="text-xs text-fg-subtle">
Changing the API key will reconnect the WebSocket.
</p>
</div>
</label>
<label className="block">
<span className="mb-1 block text-sm font-medium text-fg-muted">Gateway URL</span>
<input
type="text"
value={settings.gatewayUrl}
onChange={(event) => setField({ key: "gatewayUrl", value: event.target.value })}
placeholder="/api/v1/rpc"
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
</label>
<label className="block">
<span className="mb-1 block text-sm font-medium text-fg-muted">User</span>
<input
type="text"
value={settings.user}
onChange={(event) => setField({ key: "user", value: event.target.value })}
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
</label>
</Section>
{/* Collection */}
<Section
title="Collection"
icon={<Database className="h-4 w-4 text-fg-subtle" />}
>
<div className="space-y-1.5">
<label htmlFor="settings-collection" className="block text-sm font-medium text-fg-muted">
Active Collection
</label>
{loadingCollections ? (
<div className="flex items-center gap-2 py-2 text-xs text-fg-subtle">
<Loader2 className="h-3 w-3 animate-spin" /> Loading
collections...
</div>
) : collections.length > 0 ? (
<div className="flex items-center gap-2">
<select
id="settings-collection"
value={settings.collection}
onChange={(e) => updateSetting("collection", e.target.value)}
className="flex-1 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"
>
{collections.map((c) => {
const cObj = c as { collection?: string; id?: string; name?: string };
const collId = cObj.collection ?? cObj.id ?? String(cObj.name ?? c);
const label = cObj.name ?? collId;
return (
<option key={collId} value={collId}>
{label !== collId ? `${label} (${collId})` : collId}
</option>
);
})}
</select>
<button
type="button"
onClick={() => setCreateOpen(true)}
aria-label="New collection"
title="New collection"
className="rounded-lg border border-border bg-surface-100 p-2 text-fg-subtle hover:bg-surface-200 hover:text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500"
>
<Plus className="h-4 w-4" />
</button>
{collections.length > 1 && (
<button
type="button"
onClick={() => setDeleteOpen(true)}
aria-label="Delete collection"
title="Delete collection"
className="rounded-lg border border-red-500/30 bg-surface-100 p-2 text-red-400 hover:bg-red-500/10 hover:text-red-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
) : (
<div className="flex items-center gap-2">
<input
id="settings-collection"
type="text"
value={settings.collection}
onChange={(e) => updateSetting("collection", e.target.value)}
className="flex-1 rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
<button
type="button"
onClick={() => setCreateOpen(true)}
aria-label="New collection"
title="New collection"
className="rounded-lg border border-border bg-surface-100 p-2 text-fg-subtle hover:bg-surface-200 hover:text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500"
>
<Plus className="h-4 w-4" />
</button>
</div>
)}
</div>
</Section>
{/* Create Collection Dialog */}
<Dialog
open={createOpen}
onClose={() => setCreateOpen(false)}
title="New Collection"
footer={
<>
<button
type="button"
onClick={() => setCreateOpen(false)}
className="rounded-lg border border-border px-4 py-2 text-sm text-fg-muted hover:bg-surface-200"
>
Cancel
</button>
<button
type="button"
disabled={newId.trim().length === 0 || creating}
onClick={handleCreateCollection}
className="inline-flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{creating && <Loader2 className="h-3 w-3 animate-spin" />}
Create
</button>
</>
}
>
<div className="space-y-4">
<div className="space-y-1.5">
<label htmlFor="new-collection-id" className="block text-sm font-medium text-fg-muted">
Collection ID <span className="text-red-400">*</span>
</label>
<input
id="new-collection-id"
type="text"
value={newId}
onChange={(e) => setNewId(e.target.value)}
placeholder="my-collection"
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
<p className="text-xs text-fg-subtle">
A unique identifier for this collection.
</p>
</div>
<div className="space-y-1.5">
<label htmlFor="new-collection-name" className="block text-sm font-medium text-fg-muted">
Display Name
</label>
<input
id="new-collection-name"
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="My Collection"
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
</div>
<div className="space-y-1.5">
<label htmlFor="new-collection-description" className="block text-sm font-medium text-fg-muted">
Description
</label>
<textarea
id="new-collection-description"
value={newDescription}
onChange={(e) => setNewDescription(e.target.value)}
placeholder="What this collection is for..."
rows={3}
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500 resize-none"
/>
</div>
<div className="space-y-1.5">
<label htmlFor="new-collection-tags" className="block text-sm font-medium text-fg-muted">
Tags
</label>
<input
id="new-collection-tags"
type="text"
value={newTags}
onChange={(e) => setNewTags(e.target.value)}
placeholder="research, finance, internal"
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
<p className="text-xs text-fg-subtle">
Comma-separated list of tags for categorization.
</p>
</div>
</div>
</Dialog>
{/* Delete Collection Confirmation Dialog */}
<Dialog
open={deleteOpen}
onClose={() => setDeleteOpen(false)}
title="Delete Collection"
className="max-w-md"
footer={
<>
<button
type="button"
onClick={() => setDeleteOpen(false)}
className="rounded-lg border border-border px-4 py-2 text-sm text-fg-muted hover:bg-surface-200"
>
Cancel
</button>
<button
type="button"
disabled={deleting}
onClick={handleDeleteCollection}
className="inline-flex items-center gap-2 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{deleting && <Loader2 className="h-3 w-3 animate-spin" />}
Delete
</button>
</>
}
>
<p className="text-sm text-fg-muted">
Are you sure you want to delete the collection{" "}
<span className="font-semibold text-fg">"{settings.collection}"</span>?
This will remove the collection and all its data. This action cannot be undone.
</p>
</Dialog>
{/* Flow */}
<Section
title="Active Flow"
icon={<Workflow className="h-4 w-4 text-fg-subtle" />}
>
<div className="space-y-1.5">
<label htmlFor="settings-flow" className="block text-sm font-medium text-fg-muted">
Flow
</label>
{flows.length > 0 ? (
<Section title="Workspace" icon={<Database className="h-4 w-4 text-brand-400" />}>
<label className="block">
<span className="mb-1 block text-sm font-medium text-fg-muted">Collection</span>
<div className="flex gap-2">
<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"
value={settings.collection}
onChange={(event) => setField({ key: "collection", value: event.target.value })}
className="min-w-0 flex-1 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"
>
<option value="default">default</option>
{flows.map((f) => (
<option key={f.id} value={f.id}>
{f.id}
{f.description !== undefined && f.description.length > 0 ? ` -- ${f.description}` : ""}
</option>
))}
{collections.map((collection) => {
const id = String(collection.collection ?? collection.id ?? collection.name ?? "default");
return (
<option key={id} value={id}>
{id}
</option>
);
})}
</select>
) : (
<input
id="settings-flow"
type="text"
value={flowId}
onChange={(e) => setFlowId(e.target.value)}
placeholder="default"
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
)}
<p className="text-xs text-fg-subtle">
The flow ID used for chat, graph queries, and document processing.
</p>
</div>
</Section>
{/* Theme */}
<Section
title="Appearance"
icon={isDark ? <Moon className="h-4 w-4 text-fg-subtle" /> : <Sun className="h-4 w-4 text-fg-subtle" />}
>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-fg">Theme</p>
<p className="text-xs text-fg-subtle">
Toggle between dark and light mode.
</p>
<p className="text-xs text-fg-subtle">
Currently using {isDark ? "dark" : "light"} mode.
</p>
<button
type="button"
onClick={() => setCreateOpen(true)}
className="rounded-lg border border-border px-3 py-2 text-fg-muted hover:bg-surface-200 hover:text-fg"
aria-label="Create collection"
>
<Plus className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setDeleteOpen(true)}
className="rounded-lg border border-border px-3 py-2 text-error hover:bg-error/10"
aria-label="Delete collection"
disabled={settings.collection === "default"}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
<button
role="switch"
aria-checked={isDark}
aria-label="Dark mode"
onClick={toggleTheme}
className={cn(
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors",
isDark ? "bg-brand-600" : "bg-fg-subtle",
)}
</label>
<label className="block">
<span className="mb-1 flex items-center gap-1.5 text-sm font-medium text-fg-muted">
<Workflow className="h-3.5 w-3.5" /> Flow
</span>
<select
value={flowId}
onChange={(event) => setFlowId(event.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"
>
<span
className={cn(
"inline-block h-4 w-4 rounded-full bg-white transition-transform",
isDark ? "translate-x-6" : "translate-x-1",
)}
/>
</button>
</div>
<option value="default">default</option>
{flows.map((flow) => (
<option key={flow.id} value={flow.id}>
{flow.id}
</option>
))}
</select>
</label>
<button
type="button"
onClick={() => toggleTheme(null)}
className="flex w-full items-center justify-between rounded-lg border border-border bg-surface-100 px-4 py-3 text-sm text-fg hover:bg-surface-200"
>
<span className="flex items-center gap-2">
{theme === "dark" ? <Moon className="h-4 w-4" /> : <Sun className="h-4 w-4" />}
Theme
</span>
<span className="capitalize text-fg-muted">{theme}</span>
</button>
</Section>
{/* Feature Switches */}
<Section
title="Feature Switches"
icon={<SettingsIcon className="h-4 w-4 text-fg-subtle" />}
>
{Object.entries(settings.featureSwitches).map(([key, enabled]) => {
const isEnabled = enabled === true;
return (
<div key={key} className="flex items-center justify-between">
<div>
<p className="text-sm text-fg">{featureLabel(key)}</p>
</div>
<button
role="switch"
aria-checked={isEnabled}
aria-label={featureLabel(key)}
onClick={() => updateFeatureSwitches({ [key]: !isEnabled })}
className={cn(
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors",
isEnabled ? "bg-brand-600" : "bg-fg-subtle",
)}
>
<span className={cn(
"inline-block h-4 w-4 rounded-full bg-white transition-transform",
isEnabled ? "translate-x-6" : "translate-x-1",
)} />
</button>
</div>
);
})}
</Section>
{/* About */}
<Section
title="About"
icon={<Info className="h-4 w-4 text-fg-subtle" />}
>
<div className="space-y-2 text-sm text-fg-muted">
<p>
<span className="font-medium text-fg">Beep Graph</span>{" "}
v0.1.0
</p>
<p>
A web-based interface for interacting with the Beep Graph
knowledge-graph system.
</p>
<Section title="Feature Switches" icon={<Info className="h-4 w-4 text-brand-400" />}>
<div className="grid gap-2 sm:grid-cols-2">
{Object.entries(settings.featureSwitches).map(([key, value]) => (
<label
key={key}
className="flex items-center justify-between rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm"
>
<span className="text-fg-muted">{featureLabel(key)}</span>
<input
type="checkbox"
checked={value}
onChange={(event) => updateFeatureSwitches({ [key]: event.target.checked })}
className="h-4 w-4 accent-brand-500"
/>
</label>
))}
</div>
</Section>
</div>
<Dialog
open={createOpen}
onClose={() => setCreateOpen(false)}
title="Create Collection"
footer={
<>
<button
onClick={() => setCreateOpen(false)}
className="rounded-lg border border-border px-4 py-2 text-sm text-fg-muted hover:bg-surface-200"
>
Cancel
</button>
<button
onClick={() => {
if (collectionForm.id.trim().length === 0) return;
createCollection(collectionForm);
setCollectionForm({ id: "", name: "", description: "", tags: "", submitting: false });
setCreateOpen(false);
}}
disabled={collectionForm.id.trim().length === 0}
className="rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-500 disabled:opacity-40"
>
Create
</button>
</>
}
>
<div className="space-y-3">
{[
["id", "Collection ID", "research"] as const,
["name", "Display Name", "Research"] as const,
["description", "Description", "Optional description"] as const,
["tags", "Tags", "comma, separated"] as const,
].map(([key, label, placeholder]) => (
<label key={key} className="block">
<span className="mb-1 block text-sm font-medium text-fg-muted">{label}</span>
<input
value={collectionForm[key]}
onChange={(event) => setCollectionForm({ ...collectionForm, [key]: event.target.value })}
placeholder={placeholder}
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
</label>
))}
</div>
</Dialog>
<Dialog
open={deleteOpen}
onClose={() => setDeleteOpen(false)}
title="Delete Collection"
footer={
<>
<button
onClick={() => setDeleteOpen(false)}
className="rounded-lg border border-border px-4 py-2 text-sm text-fg-muted hover:bg-surface-200"
>
Cancel
</button>
<button
onClick={() => {
deleteCollection(settings.collection);
setDeleteOpen(false);
}}
className="rounded-lg bg-error px-4 py-2 text-sm font-medium text-white hover:opacity-90"
>
Delete
</button>
</>
}
>
<div className="flex items-start gap-3">
<Trash2 className="mt-0.5 h-5 w-5 shrink-0 text-error" />
<p className="text-sm text-fg-muted">
Delete <span className="font-mono text-fg">{settings.collection}</span> and its data?
</p>
</div>
</Dialog>
</div>
);
}

View file

@ -1,72 +1,22 @@
import { useCallback, useEffect, useState } from "react";
import { useAtomRefresh, useAtomValue } from "@effect/atom-react";
import { Coins, Loader2, RefreshCw } from "lucide-react";
import { cn } from "@/lib/utils";
import { useSocket } from "@/providers/socket-provider";
import { useConnectionState } from "@/providers/socket-provider";
import { resultData, resultError, resultLoading, tokenCostsAtom } from "@/atoms/workbench";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface TokenCost {
model: string;
input_price: number;
output_price: number;
function formatPrice(price: number) {
if (!Number.isFinite(price)) return "--";
return `$${price.toFixed(2)}`;
}
// ---------------------------------------------------------------------------
// Token Cost page
// ---------------------------------------------------------------------------
export default function TokenCostPage() {
const socket = useSocket();
const connectionState = useConnectionState();
const [costs, setCosts] = useState<TokenCost[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadCosts = useCallback(async () => {
try {
setLoading(true);
setError(null);
const data = await socket.config().getTokenCosts();
setCosts(
Array.isArray(data)
? data.map((d: Record<string, unknown>) => ({
model: String(d.model ?? ""),
input_price: Number(d.input_price ?? 0),
output_price: Number(d.output_price ?? 0),
}))
: [],
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
console.error("Failed to load token costs:", err);
} finally {
setLoading(false);
}
}, [socket]);
// Auto-load when connected
useEffect(() => {
const connected =
connectionState.status === "connected" ||
connectionState.status === "authenticated" ||
connectionState.status === "unauthenticated";
if (connected) {
loadCosts();
}
}, [connectionState.status, loadCosts]);
const formatPrice = (price: number) => {
if (!Number.isFinite(price)) return "--";
return `$${price.toFixed(2)}`;
};
const result = useAtomValue(tokenCostsAtom);
const refresh = useAtomRefresh(tokenCostsAtom);
const costs = resultData(result, []);
const loading = resultLoading(result, costs);
const error = resultError(result);
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="mb-6 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-3">
<Coins className="h-6 w-6 text-brand-400" />
@ -79,7 +29,7 @@ export default function TokenCostPage() {
</div>
<button
onClick={loadCosts}
onClick={refresh}
disabled={loading}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-sm text-fg-muted transition-colors hover:bg-surface-200 disabled:opacity-40"
>
@ -88,7 +38,6 @@ export default function TokenCostPage() {
</button>
</div>
{/* Content */}
{loading && costs.length === 0 && (
<div className="flex items-center justify-center py-12">
<Loader2 className="mr-2 h-5 w-5 animate-spin text-fg-subtle" />
@ -96,7 +45,7 @@ export default function TokenCostPage() {
</div>
)}
{error !== null && error.length > 0 && (
{error !== null && (
<p className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">
{error}
</p>