Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .claude-plugin/hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@
"command": "smem-hook-session-start",
"description": "Inject recent memories as context at the start of a new session",
"timeout": 10
},
{
"event": "UserPromptSubmit",
"command": "smem-hook-user-prompt-submit",
"description": "Inject learned reasoning strategies for the active model (opt-in)",
"timeout": 10
}
]
}
5 changes: 5 additions & 0 deletions dashboard/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,8 @@ dist-ssr
*.njsproj
*.sln
*.sw?

# Playwright transient outputs (not baselines)
test-results
playwright-report
e2e/__screenshots__
180 changes: 180 additions & 0 deletions dashboard/e2e/reasoning.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { test, expect, type Page } from "@playwright/test"

/**
* Browser QA for the U8 "Reasoning Training" dashboard page
* (features/reasoning/ReasoningPage.tsx, route /reasoning, nav key `nav.reasoningTraining`).
*
* No backend runs on :8000 here, so every API call is intercepted and fulfilled with static
* JSON. main.tsx mounts <BrowserRouter basename="/ui">, so navigations target /ui/<route>.
*/

const CATEGORIES = [
"debugging",
"planning",
"implementation",
"refactoring",
"research",
"verification",
"architecture",
"data-analysis",
]

const STATUS_BODY = {
config: {
mining_enabled: true,
injection_enabled: false,
mining_models: [],
injection_map: {},
categories: CATEGORIES,
min_trace_chars: 200,
max_trace_chars: 20000,
max_traces_per_scan: 500,
scan_lookback_days: 30,
retention_days: 90,
max_traces_total: 20000,
min_cluster_support: 3,
max_patterns_per_run: 10,
min_confidence: 0.2,
min_patterns_per_category: 3,
injection_max_patterns: 5,
injection_max_chars: 4000,
distill_use_llm: false,
redact_secrets: true,
},
detected_models: ["claude-fable-5"],
per_model: [
{
model: "claude-fable-5",
trace_count: 5,
unprocessed: 2,
pattern_count: 3,
has_thinking_text: true,
last_trace_at: "2026-07-17T10:00:00",
coverage_percent: 12.5,
},
],
coverage_by_model: {
"claude-fable-5": CATEGORIES.map((c) => ({
category: c,
pattern_count: c === "debugging" ? 3 : 0,
covered: c === "debugging",
})),
},
total_traces: 5,
unprocessed_traces: 2,
total_patterns: 3,
mining: {
running: false,
started_at: null,
finished_at: null,
traces_ingested: 0,
patterns_learned: 0,
dry_run: false,
error: null,
},
}

const PATTERNS_BODY = {
patterns: [
{
id: "p1",
source_model: "claude-fable-5",
category: "debugging",
title: "restate then verify",
confidence: 1.0,
frequency: 3,
signature: "sig1",
},
],
total: 1,
limit: 20,
offset: 0,
}

const HEALTH_REPORT = {
grade: "A",
purity_score: 0.9,
connectivity: 0.8,
diversity: 0.7,
freshness: 0.85,
consolidation_ratio: 0.5,
orphan_rate: 0.1,
activation_efficiency: 0.6,
recall_confidence: 0.75,
neuron_count: 100,
synapse_count: 200,
fiber_count: 50,
contradiction_count: 3,
conflict_rate: 0.12,
warnings: [],
recommendations: [],
top_penalties: [],
}

function json(body: unknown) {
return { status: 200, contentType: "application/json", body: JSON.stringify(body) }
}

async function installMocks(page: Page) {
await page.route("**/api/dashboard/**", (route) => route.fulfill(json({})))
await page.route("**/api/dashboard/stats", (route) =>
route.fulfill(json({ active_brain: "test-brain" })),
)
await page.route("**/api/dashboard/brains", (route) => route.fulfill(json([])))
await page.route("**/api/dashboard/fibers", (route) => route.fulfill(json({ fibers: [] })))
await page.route("**/api/dashboard/health", (route) => route.fulfill(json(HEALTH_REPORT)))
await page.route(
(url) => url.pathname === "/health",
(route) => route.fulfill(json({ status: "ok", version: "test" })),
)
// Patterns must be registered before status so the more specific glob wins
// (Playwright checks handlers last-registered-first).
await page.route("**/api/dashboard/reasoning/patterns**", (route) =>
route.fulfill(json(PATTERNS_BODY)),
)
await page.route("**/api/dashboard/reasoning/status", (route) =>
route.fulfill(json(STATUS_BODY)),
)
}

test.describe("U8 Reasoning Training page", () => {
test("renders populated reasoning content", async ({ page }) => {
await installMocks(page)
await page.goto("/ui/reasoning")

await expect(
page.getByRole("heading", { level: 1, name: "Reasoning Training" }),
).toBeVisible({ timeout: 20_000 })

// KPI cards.
await expect(page.getByText("Total traces")).toBeVisible()
await expect(page.getByText("Learned patterns")).toBeVisible()

// Coverage + config cards show the detected model.
await expect(page.getByText("claude-fable-5").first()).toBeVisible()
await expect(page.getByText("12.5%")).toBeVisible()

// Patterns table row.
await expect(page.getByText("restate then verify")).toBeVisible()

// No raw untranslated i18n keys should leak into the DOM.
await expect(page.getByText(/reasoning\.[a-zA-Z]/)).toHaveCount(0)

await page.screenshot({ path: "e2e/__screenshots__/reasoning.png", fullPage: true })
})

test("sidebar Reasoning Training nav link navigates from another route", async ({ page }) => {
await installMocks(page)
await page.goto("/ui/health")
await expect(page.getByRole("navigation")).toBeVisible({ timeout: 20_000 })

const link = page.getByRole("navigation").getByRole("link", { name: "Reasoning Training" })
await expect(link).toBeVisible()
await link.click()

await expect(page).toHaveURL(/\/ui\/reasoning$/)
await expect(
page.getByRole("heading", { level: 1, name: "Reasoning Training" }),
).toBeVisible()
})
})
9 changes: 9 additions & 0 deletions dashboard/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const OraclePage = lazy(() => import("@/features/oracle/OraclePage"))
const ToolStatsPage = lazy(() => import("@/features/tool-stats/ToolStatsPage"))
const VisualizePage = lazy(() => import("@/features/visualize/VisualizePage"))
const StoragePage = lazy(() => import("@/features/storage/StoragePage"))
const ReasoningPage = lazy(() => import("@/features/reasoning/ReasoningPage"))

export default function App() {
return (
Expand Down Expand Up @@ -125,6 +126,14 @@ export default function App() {
</Suspense>
}
/>
<Route
path="reasoning"
element={
<Suspense fallback={<PageSkeleton />}>
<ReasoningPage />
</Suspense>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
Expand Down
18 changes: 18 additions & 0 deletions dashboard/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,22 @@ export const api = {
request<T>(path, { ...options, method: "DELETE" }),
}

/**
* Extract a human-readable message from an ApiError. FastAPI returns
* `{"detail": "..."}` bodies, which `request()` stores verbatim as the error
* message — parse it out so toasts show the message, not raw JSON.
*/
export function apiErrorMessage(err: unknown, fallback: string): string {
if (err instanceof ApiError) {
try {
const parsed = JSON.parse(err.message)
if (parsed && typeof parsed.detail === "string") return parsed.detail
} catch {
/* body was not JSON — fall through to the raw text */
}
return err.message || fallback
}
return fallback
}

export { ApiError }
114 changes: 114 additions & 0 deletions dashboard/src/api/hooks/useReasoning.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { api } from "@/api/client"
import type {
MineRequest,
MineResponse,
PatternsListResponse,
ReasoningConfig,
ReasoningConfigUpdate,
ReasoningConfigUpdateResponse,
ReasoningDeleteResponse,
ReasoningStatusResponse,
} from "@/api/types"

const keys = {
status: ["reasoning", "status"] as const,
patterns: ["reasoning", "patterns"] as const,
patternList: (model: string, category: string, limit: number, offset: number) =>
["reasoning", "patterns", model, category, limit, offset] as const,
}

export function useReasoningStatus() {
return useQuery({
queryKey: keys.status,
queryFn: () => api.get<ReasoningStatusResponse>("/api/dashboard/reasoning/status"),
// Poll while a mining job is running so counts/coverage update live.
refetchInterval: (query) => (query.state.data?.mining.running ? 3000 : false),
})
}

export function useUpdateReasoningConfig() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (body: ReasoningConfigUpdate) =>
api.put<ReasoningConfigUpdateResponse>("/api/dashboard/reasoning/config", body),
// Optimistically patch the cached status so toggles/checkboxes don't briefly
// revert to the old value in the window before the refetch lands.
onMutate: async (body) => {
await queryClient.cancelQueries({ queryKey: keys.status })
const prev = queryClient.getQueryData<ReasoningStatusResponse>(keys.status)
if (prev) {
queryClient.setQueryData<ReasoningStatusResponse>(keys.status, {
...prev,
config: { ...prev.config, ...body } as ReasoningConfig,
})
}
return { prev }
},
onError: (_err, _body, context) => {
if (context?.prev) queryClient.setQueryData(keys.status, context.prev)
},
onSettled: () => queryClient.invalidateQueries({ queryKey: keys.status }),
})
}

export function useTriggerMining() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (body: MineRequest) =>
api.post<MineResponse>("/api/dashboard/reasoning/mine", body),
onSuccess: () => queryClient.invalidateQueries({ queryKey: keys.status }),
})
}

export function useReasoningPatterns(model = "", category = "", limit = 50, offset = 0) {
const params = new URLSearchParams()
if (model) params.set("model", model)
if (category) params.set("category", category)
params.set("limit", String(limit))
params.set("offset", String(offset))
return useQuery({
queryKey: keys.patternList(model, category, limit, offset),
queryFn: () =>
api.get<PatternsListResponse>(`/api/dashboard/reasoning/patterns?${params.toString()}`),
})
}

export function useDeleteReasoningPattern() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
api.delete<ReasoningDeleteResponse>(
`/api/dashboard/reasoning/patterns/${encodeURIComponent(id)}`,
),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: keys.status })
queryClient.invalidateQueries({ queryKey: keys.patterns })
},
})
}

export function useDeletePatternsByModel() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (model: string) =>
api.delete<ReasoningDeleteResponse>(
`/api/dashboard/reasoning/patterns?model=${encodeURIComponent(model)}`,
),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: keys.status })
queryClient.invalidateQueries({ queryKey: keys.patterns })
},
})
}

export function useWipeTraces() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (model: string) =>
api.delete<ReasoningDeleteResponse>(
`/api/dashboard/reasoning/traces?model=${encodeURIComponent(model)}`,
),
onSuccess: () => queryClient.invalidateQueries({ queryKey: keys.status }),
})
}
Loading
Loading