feat: add unit tests, Docker polish, and workbench UX improvements

Unit tests: Consumer class (7), recursive-splitter (10), parseJsonResponse (11) — 28 total.
Docker: add 5 commented LLM provider services, dev compose override, .env.example.
Workbench: chat persistence, error boundary, disconnect banner, prompts error handling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
elpresidank 2026-04-07 03:51:29 -05:00
parent c7eefee607
commit 72870a7e2e
17 changed files with 718 additions and 33 deletions

View file

@ -1,5 +1,6 @@
import { BrowserRouter, Routes, Route, Navigate } from "react-router";
import { RootLayout } from "@/components/layout/root-layout";
import { ErrorBoundary } from "@/components/error-boundary";
import ChatPage from "@/pages/chat";
import LibraryPage from "@/pages/library";
import GraphPage from "@/pages/graph";
@ -16,14 +17,14 @@ export default function App() {
<Routes>
<Route element={<RootLayout />}>
<Route index element={<Navigate to="/chat" replace />} />
<Route path="/chat" element={<ChatPage />} />
<Route path="/library" element={<LibraryPage />} />
<Route path="/graph" element={<GraphPage />} />
<Route path="/prompts" element={<PromptsPage />} />
<Route path="/token-cost" element={<TokenCostPage />} />
<Route path="/knowledge-cores" element={<KnowledgeCoresPage />} />
<Route path="/flows" element={<FlowsPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/chat" element={<ErrorBoundary><ChatPage /></ErrorBoundary>} />
<Route path="/library" element={<ErrorBoundary><LibraryPage /></ErrorBoundary>} />
<Route path="/graph" element={<ErrorBoundary><GraphPage /></ErrorBoundary>} />
<Route path="/prompts" element={<ErrorBoundary><PromptsPage /></ErrorBoundary>} />
<Route path="/token-cost" element={<ErrorBoundary><TokenCostPage /></ErrorBoundary>} />
<Route path="/knowledge-cores" element={<ErrorBoundary><KnowledgeCoresPage /></ErrorBoundary>} />
<Route path="/flows" element={<ErrorBoundary><FlowsPage /></ErrorBoundary>} />
<Route path="/settings" element={<ErrorBoundary><SettingsPage /></ErrorBoundary>} />
</Route>
</Routes>

View file

@ -0,0 +1,61 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
import { AlertTriangle, RefreshCw } from "lucide-react";
interface Props {
children: ReactNode;
/** Optional fallback -- if omitted, a default card is shown */
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error("[ErrorBoundary]", error, info.componentStack);
}
handleReset = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
if (this.props.fallback) return this.props.fallback;
return (
<div className="flex h-full items-center justify-center p-8">
<div className="max-w-md rounded-lg border border-error/30 bg-error/5 p-6 text-center">
<AlertTriangle className="mx-auto mb-3 h-8 w-8 text-error" />
<h2 className="mb-2 text-lg font-semibold text-fg">
Something went wrong
</h2>
<p className="mb-4 text-sm text-fg-muted">
{this.state.error?.message || "An unexpected error occurred."}
</p>
<button
onClick={this.handleReset}
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"
>
<RefreshCw className="h-3.5 w-3.5" />
Try Again
</button>
</div>
</div>
);
}
return this.props.children;
}
}

View file

@ -1,7 +1,9 @@
import { Outlet } from "react-router";
import { WifiOff } from "lucide-react";
import { Sidebar } from "./sidebar";
import { FlowSelector } from "./flow-selector";
import { useProgressStore } from "@/hooks/use-progress-store";
import { useConnectionState } from "@/providers/socket-provider";
/**
* Top loading bar -- shown when any global activity is in progress.
@ -22,6 +24,11 @@ function LoadingBar() {
* Root layout: fixed sidebar + scrollable main content area with a top bar.
*/
export function RootLayout() {
const connectionState = useConnectionState();
const isDisconnected =
connectionState.status === "failed" ||
connectionState.status === "reconnecting";
return (
<div className="relative flex h-screen w-full overflow-hidden bg-surface-0">
{/* Global loading bar */}
@ -35,6 +42,14 @@ export function RootLayout() {
<FlowSelector />
</header>
{/* Connection lost banner */}
{isDisconnected && (
<div className="flex items-center gap-2 border-b border-amber-500/30 bg-amber-500/10 px-4 py-2 text-xs text-amber-400">
<WifiOff className="h-3.5 w-3.5" />
<span>Connection lost. Attempting to reconnect...</span>
</div>
)}
{/* Page content */}
<main className="flex-1 overflow-y-auto p-6">
<Outlet />

View file

@ -1,4 +1,5 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
// ---------------------------------------------------------------------------
// Types
@ -66,26 +67,38 @@ export function nextMessageId(): string {
return `msg-${++_nextMsgId}-${Date.now()}`;
}
export const useConversation = create<ConversationState>()((set) => ({
messages: [],
input: "",
chatMode: "graph-rag",
export const useConversation = create<ConversationState>()(
persist(
(set) => ({
messages: [],
input: "",
chatMode: "graph-rag",
setInput: (value) => set({ input: value }),
setChatMode: (mode) => set({ chatMode: mode }),
setInput: (value) => set({ input: value }),
setChatMode: (mode) => set({ chatMode: mode }),
addMessage: (message) =>
set((state) => ({ messages: [...state.messages, message] })),
addMessage: (message) =>
set((state) => ({ messages: [...state.messages, message] })),
updateLastMessage: (updater) =>
set((state) => {
if (state.messages.length === 0) return state;
const last = state.messages[state.messages.length - 1]!;
const updated = updater(last);
return {
messages: [...state.messages.slice(0, -1), updated],
};
updateLastMessage: (updater) =>
set((state) => {
if (state.messages.length === 0) return state;
const last = state.messages[state.messages.length - 1]!;
const updated = updater(last);
return {
messages: [...state.messages.slice(0, -1), updated],
};
}),
clearMessages: () => set({ messages: [] }),
}),
clearMessages: () => set({ messages: [] }),
}));
{
name: "tg-conversation",
// Only persist messages and chatMode, not input or transient state
partialize: (state) => ({
messages: state.messages.filter((m) => !m.isStreaming),
chatMode: state.chatMode,
}),
},
),
);

View file

@ -8,13 +8,17 @@ export function usePrompts() {
const [prompts, setPrompts] = useState<Array<{ id: string; name?: string; description?: string }>>([]);
const [systemPrompt, setSystemPrompt] = useState<string>("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadPrompts = useCallback(async () => {
try {
setLoading(true);
setError(null);
const list = await socket.config().getPrompts();
setPrompts(Array.isArray(list) ? list : []);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
console.error("Failed to load prompts:", err);
} finally {
setLoading(false);
@ -46,5 +50,5 @@ export function usePrompts() {
}
}, [connectionState.status, loadPrompts, loadSystemPrompt]);
return { prompts, systemPrompt, loading, loadPrompts, loadSystemPrompt, getPrompt };
return { prompts, systemPrompt, loading, error, loadPrompts, loadSystemPrompt, getPrompt };
}

View file

@ -18,7 +18,7 @@ import { usePrompts } from "@/hooks/use-prompts";
type Tab = "templates" | "system";
export default function PromptsPage() {
const { prompts, systemPrompt, loading, loadPrompts, loadSystemPrompt, getPrompt } = usePrompts();
const { prompts, systemPrompt, loading, error, loadPrompts, loadSystemPrompt, getPrompt } = usePrompts();
const [activeTab, setActiveTab] = useState<Tab>("templates");
const [selectedPromptId, setSelectedPromptId] = useState<string | null>(null);
@ -96,6 +96,13 @@ export default function PromptsPage() {
</button>
</div>
{/* Error display */}
{error && (
<p className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">
{error}
</p>
)}
{/* Templates tab */}
{activeTab === "templates" && (
<div className="flex flex-1 flex-col gap-4 overflow-hidden">