Merge pull request #28 from offendingcommit/feat/dialectic-playground

feat(web): add dialectic reasoning playground
This commit is contained in:
Offending Commit
2026-05-28 14:26:39 -05:00
committed by GitHub
10 changed files with 644 additions and 16 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

View File

@@ -0,0 +1,131 @@
/**
* One-off screenshot script for the dialectic playground.
* Run with: pnpm exec playwright test packages/web/e2e/playground.screenshots.ts
* Outputs are written to docs/screenshots/.
*/
import { mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { test } from "@playwright/test";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const OUT_DIR = resolve(__dirname, "../../../docs/screenshots");
const STORE_KEY = "openconcho:instances";
const STORE_VALUE = JSON.stringify({
instances: [
{
id: "demo-inst",
name: "Demo Honcho",
baseUrl: "http://localhost:8001",
token: "",
},
],
activeId: "demo-inst",
});
const WORKSPACE = "demo-workspace";
const PEER = "alice@example.com";
// Per-level mocked latency (ms) and answer.
const FIXTURES: Record<string, { delayMs: number; content: string }> = {
minimal: {
delayMs: 140,
content:
"Quick gist: Alice prefers async standups, dislikes meetings on Mondays, and tracks priorities in Linear.",
},
low: {
delayMs: 410,
content:
"Alice runs the platform team. She prefers async standups, batches code review in the afternoons, and pushes back on meetings before 10am. Linear is her source of truth for priorities.",
},
medium: {
delayMs: 1180,
content:
"Alice leads the platform team and operates on async-by-default. Three recurring patterns:\n\n• Async over sync — she explicitly skips standups in favor of written status posts on Wednesdays.\n• Deep-work mornings — meetings before 10am are pushed back; she protects 911am for coding.\n• Single-source-of-truth in Linear — anything not tracked there is treated as not happening.",
},
high: {
delayMs: 2410,
content:
"Alice's working model has stayed remarkably stable over the last three months. She leads platform, treats async writing as the default communication mode, and resists synchronous coordination unless a decision is actively blocked. Three concrete patterns recur:\n\n1. Async-first standups — Wednesday written status, no daily sync.\n2. Morning deep work — calendar protected 911am, meetings pushed past 10.\n3. Linear as system-of-record — verbal commitments she hasn't written into Linear are treated as not real.\n\nShe also pushes back hard on cross-team meetings without a clear decision owner.",
},
max: {
delayMs: 3920,
content:
"Across her recent sessions Alice consistently surfaces three reinforcing patterns and one tension worth flagging.\n\nPatterns:\n1. Async-first communication — explicit preference for written status (Wednesday Linear updates) over standups; she's said \"if it's not in Linear it isn't real\" in three separate threads.\n2. Protected morning deep-work — calendar is blocked 911am every weekday; she'll move meetings rather than break the block.\n3. Decision-owner gating — she refuses cross-team meetings without a named decision owner; this has come up six times since March.\n\nTension to flag: Alice's async-default occasionally collides with newer hires who prefer synchronous onboarding. She's aware of this — last month she experimented with a weekly 30-min office hour — but the data is too thin to call it resolved.",
},
};
// Default baseURL comes from playwright.config.ts (localhost:5173); override
// with PLAYWRIGHT_BASE_URL=http://localhost:5184 if regenerating screenshots
// against a worktree dev server on a different port.
const BASE_URL = process.env.PLAYWRIGHT_BASE_URL;
test.use({
viewport: { width: 1600, height: 1000 },
...(BASE_URL ? { baseURL: BASE_URL } : {}),
});
test("playground screenshots", async ({ page }) => {
mkdirSync(OUT_DIR, { recursive: true });
await page.addInitScript(
([key, value]) => {
window.localStorage.setItem(key, value);
},
[STORE_KEY, STORE_VALUE],
);
// Mock the Honcho health probe so the SPA doesn't show a disconnected banner.
await page.route("**/v3/health*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ status: "ok" }),
}),
);
// Mock the chat POST with per-level fixtures.
await page.route("**/v3/workspaces/*/peers/*/chat", async (route) => {
const body = JSON.parse(route.request().postData() ?? "{}") as {
reasoning_level?: keyof typeof FIXTURES;
};
const level = body.reasoning_level ?? "low";
const fx = FIXTURES[level];
await new Promise((r) => setTimeout(r, fx.delayMs));
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ content: fx.content }),
});
});
// 1. Idle: empty playground.
await page.goto(`/workspaces/${WORKSPACE}/peers/${encodeURIComponent(PEER)}/playground`);
await page.waitForSelector('[data-testid="column-minimal"]');
await page.screenshot({
path: `${OUT_DIR}/playground-idle.png`,
fullPage: false,
});
// 2. Mid-flight: type a query, fire, capture while columns are still pending.
await page.getByLabel("Query").fill("What patterns does Alice show across her recent sessions?");
await page.getByLabel("Run selected levels").click();
await page.waitForSelector('[data-testid="column-minimal"][data-status="success"]');
// minimal returns at ~140ms; capture now so medium/high/max are still pending.
await page.screenshot({
path: `${OUT_DIR}/playground-running.png`,
fullPage: false,
});
// 3. Settled: wait for max to finish.
await page.waitForSelector('[data-testid="column-max"][data-status="success"]', {
timeout: 10_000,
});
await page.screenshot({
path: `${OUT_DIR}/playground-results.png`,
fullPage: false,
});
});

View File

@@ -279,7 +279,21 @@ export function useSearchPeer(workspaceId: string, peerId: string) {
});
}
export function useChat(workspaceId: string, peerId: string) {
export type ReasoningLevel = "minimal" | "low" | "medium" | "high" | "max";
export const REASONING_LEVELS: readonly ReasoningLevel[] = [
"minimal",
"low",
"medium",
"high",
"max",
] as const;
export function useChat(
workspaceId: string,
peerId: string,
reasoningLevel: ReasoningLevel = "low",
) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (message: string) => {
@@ -287,7 +301,7 @@ export function useChat(workspaceId: string, peerId: string) {
"/v3/workspaces/{workspace_id}/peers/{peer_id}/chat",
{
params: { path: { workspace_id: workspaceId, peer_id: peerId } },
body: { query: message, stream: false, reasoning_level: "low" },
body: { query: message, stream: false, reasoning_level: reasoningLevel },
},
);
return data ?? err(error);

View File

@@ -1,6 +1,16 @@
import { useNavigate, useParams } from "@tanstack/react-router";
import { AnimatePresence, motion } from "framer-motion";
import { Eye, EyeOff, MessageCircle, Save, Search, User, Users, X } from "lucide-react";
import {
Eye,
EyeOff,
FlaskConical,
MessageCircle,
Save,
Search,
User,
Users,
X,
} from "lucide-react";
import { useState } from "react";
import {
usePeer,
@@ -98,19 +108,35 @@ export function PeerDetail() {
</div>
<Body className="leading-none">Peer identity &amp; memory</Body>
</div>
<Button
variant="primary"
onClick={() =>
navigate({
to: "/workspaces/$workspaceId/peers/$peerId/chat",
params: { workspaceId, peerId } as never,
})
}
className="shrink-0 rounded-xl"
>
<MessageCircle className="w-4 h-4" strokeWidth={1.5} />
Chat
</Button>
<div className="flex items-center gap-2 shrink-0">
<Button
variant="surface"
onClick={() =>
navigate({
to: "/workspaces/$workspaceId/peers/$peerId/playground",
params: { workspaceId, peerId } as never,
})
}
className="rounded-xl"
title="Compare reasoning levels side-by-side"
>
<FlaskConical className="w-4 h-4" strokeWidth={1.5} />
Playground
</Button>
<Button
variant="primary"
onClick={() =>
navigate({
to: "/workspaces/$workspaceId/peers/$peerId/chat",
params: { workspaceId, peerId } as never,
})
}
className="rounded-xl"
>
<MessageCircle className="w-4 h-4" strokeWidth={1.5} />
Chat
</Button>
</div>
</div>
</motion.div>

View File

@@ -0,0 +1,376 @@
import { useQueryClient } from "@tanstack/react-query";
import { Link, useParams } from "@tanstack/react-router";
import { motion } from "framer-motion";
import { FlaskConical, Play } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { client } from "@/api/client";
import { REASONING_LEVELS, type ReasoningLevel } from "@/api/queries";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/input";
import { SectionHeading } from "@/components/ui/typography";
import { useDemo } from "@/hooks/useDemo";
// ─── Types ────────────────────────────────────────────────────────────────────
interface ColumnState {
status: "idle" | "pending" | "success" | "error";
content: string | null;
error: string | null;
startedAt: number | null;
endedAt: number | null;
}
type RunResult = { ok: true; content: string | null } | { ok: false; error: string };
const IDLE: ColumnState = {
status: "idle",
content: null,
error: null,
startedAt: null,
endedAt: null,
};
// ─── Pure helpers (exported for testing) ─────────────────────────────────────
export function buildInitialColumns(): Record<ReasoningLevel, ColumnState> {
return Object.fromEntries(REASONING_LEVELS.map((l) => [l, { ...IDLE }])) as Record<
ReasoningLevel,
ColumnState
>;
}
export function latencyMs(col: ColumnState): number | null {
if (col.startedAt == null || col.endedAt == null) return null;
return col.endedAt - col.startedAt;
}
// ─── Run-fanout (exported for testing) ───────────────────────────────────────
export interface FanoutDeps {
now: () => number;
runOne: (level: ReasoningLevel, query: string) => Promise<RunResult>;
onStart: (level: ReasoningLevel, startedAt: number) => void;
onEnd: (level: ReasoningLevel, endedAt: number, result: RunResult) => void;
}
/**
* Fires the same query at every selected level concurrently. Returns when all
* settle. Per-column timing is captured via onStart/onEnd so React state updates
* stay reflective of the network race, not the await order.
*/
export async function fanoutQuery(
levels: readonly ReasoningLevel[],
query: string,
deps: FanoutDeps,
): Promise<void> {
await Promise.all(
levels.map(async (level) => {
const startedAt = deps.now();
deps.onStart(level, startedAt);
try {
const result = await deps.runOne(level, query);
deps.onEnd(level, deps.now(), result);
} catch (e) {
deps.onEnd(level, deps.now(), {
ok: false,
error: e instanceof Error ? e.message : String(e),
});
}
}),
);
}
// ─── Component ───────────────────────────────────────────────────────────────
export function DialecticPlayground() {
const { mask } = useDemo();
const { workspaceId, peerId } = useParams({ strict: false }) as {
workspaceId: string;
peerId: string;
};
const qc = useQueryClient();
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<Record<ReasoningLevel, boolean>>(
() =>
Object.fromEntries(REASONING_LEVELS.map((l) => [l, true])) as Record<ReasoningLevel, boolean>,
);
const [columns, setColumns] = useState<Record<ReasoningLevel, ColumnState>>(buildInitialColumns);
const anyPending = useMemo(
() => REASONING_LEVELS.some((l) => columns[l].status === "pending"),
[columns],
);
const selectedLevels = useMemo(() => REASONING_LEVELS.filter((l) => selected[l]), [selected]);
const runOne = useCallback(
async (level: ReasoningLevel, q: string): Promise<RunResult> => {
const { data, error } = await client.current.POST(
"/v3/workspaces/{workspace_id}/peers/{peer_id}/chat",
{
params: { path: { workspace_id: workspaceId, peer_id: peerId } },
body: { query: q, stream: false, reasoning_level: level },
},
);
if (error) {
return {
ok: false,
error: typeof error === "object" ? JSON.stringify(error) : String(error),
};
}
const content = (data as { content?: string | null } | undefined)?.content ?? null;
return { ok: true, content };
},
[workspaceId, peerId],
);
const handleRun = useCallback(async () => {
const trimmed = query.trim();
if (!trimmed || anyPending || selectedLevels.length === 0) return;
setColumns((prev) => {
const next = { ...prev };
for (const l of selectedLevels) next[l] = { ...IDLE, status: "pending" };
return next;
});
await fanoutQuery(selectedLevels, trimmed, {
now: () => performance.now(),
runOne,
onStart: (level, startedAt) => {
setColumns((prev) => ({
...prev,
[level]: { ...prev[level], status: "pending", startedAt, endedAt: null },
}));
},
onEnd: (level, endedAt, result) => {
setColumns((prev) => ({
...prev,
[level]: {
...prev[level],
endedAt,
status: result.ok ? "success" : "error",
content: result.ok ? result.content : null,
error: result.ok ? null : result.error,
},
}));
},
});
qc.invalidateQueries({ queryKey: ["peer-context", workspaceId, peerId] });
}, [anyPending, peerId, qc, query, runOne, selectedLevels, workspaceId]);
function toggleLevel(level: ReasoningLevel) {
setSelected((prev) => ({ ...prev, [level]: !prev[level] }));
}
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
if ((e.key === "Enter" && (e.metaKey || e.ctrlKey)) || (e.key === "Enter" && !e.shiftKey)) {
e.preventDefault();
handleRun();
}
}
return (
<div className="flex flex-col h-screen" style={{ background: "var(--bg)" }}>
{/* Header */}
<div
className="shrink-0 px-6 py-4"
style={{ borderBottom: "1px solid var(--border)", background: "var(--bg-2)" }}
>
<div className="flex items-center gap-2 text-xs mb-1" style={{ color: "var(--text-3)" }}>
<Link
to="/workspaces/$workspaceId/peers/$peerId"
params={{ workspaceId, peerId } as never}
className="hover:underline font-mono"
>
{mask(peerId)}
</Link>
<span>/</span>
<span>Playground</span>
</div>
<div className="flex items-center gap-2">
<FlaskConical className="w-4 h-4" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
<SectionHeading as="h1" className="mb-0">
Dialectic reasoning playground
</SectionHeading>
</div>
<p className="text-xs mt-0.5" style={{ color: "var(--text-3)" }}>
Fire the same query at every reasoning level in parallel compare answers and latency.
</p>
</div>
{/* Query input */}
<div
className="shrink-0 px-4 sm:px-6 py-4"
style={{ borderBottom: "1px solid var(--border)", background: "var(--bg-2)" }}
>
<div className="flex gap-3 max-w-5xl mx-auto items-end">
<Textarea
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask once, compare five answers… (Enter to run, Shift+Enter for newline)"
rows={2}
className="flex-1 resize-none"
aria-label="Query"
/>
<Button
variant="primary"
onClick={handleRun}
disabled={!query.trim() || anyPending || selectedLevels.length === 0}
className="self-end mb-0.5"
aria-label="Run selected levels"
>
{anyPending ? (
<LoadingSpinner size="sm" />
) : (
<Play className="w-4 h-4" strokeWidth={1.5} />
)}
<span className="hidden sm:block">
{selectedLevels.length === REASONING_LEVELS.length
? "Run all levels"
: `Run ${selectedLevels.length}`}
</span>
</Button>
</div>
<div className="max-w-5xl mx-auto mt-3 flex flex-wrap gap-2 items-center">
<span className="text-xs" style={{ color: "var(--text-3)" }}>
Levels:
</span>
{REASONING_LEVELS.map((level) => (
<label
key={level}
className="inline-flex items-center gap-1.5 text-xs px-2 py-1 rounded-md cursor-pointer transition-colors"
style={{
background: selected[level] ? "var(--accent-dim)" : "var(--surface)",
color: selected[level] ? "var(--accent-text)" : "var(--text-3)",
border: `1px solid ${selected[level] ? "var(--accent-border)" : "var(--border)"}`,
}}
>
<input
type="checkbox"
checked={selected[level]}
onChange={() => toggleLevel(level)}
className="accent-current"
aria-label={`Include ${level}`}
/>
<span className="font-mono">{level}</span>
</label>
))}
</div>
</div>
{/* 5-column grid */}
<div className="flex-1 overflow-auto px-4 sm:px-6 py-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3 max-w-[1600px] mx-auto">
{REASONING_LEVELS.map((level) => (
<LevelColumn
key={level}
level={level}
state={columns[level]}
included={selected[level]}
maskFn={mask}
/>
))}
</div>
</div>
</div>
);
}
// ─── Per-column subcomponent ─────────────────────────────────────────────────
interface LevelColumnProps {
level: ReasoningLevel;
state: ColumnState;
included: boolean;
maskFn: (s: string) => string;
}
function LevelColumn({ level, state, included, maskFn }: LevelColumnProps) {
const ms = latencyMs(state);
return (
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: included ? 1 : 0.45, y: 0 }}
transition={{ duration: 0.2 }}
className="flex flex-col rounded-xl min-h-[280px]"
style={{
background: "var(--bg-2)",
border: "1px solid var(--border)",
}}
data-level={level}
data-status={state.status}
data-testid={`column-${level}`}
>
{/* Header */}
<div
className="px-3 py-2 flex items-center justify-between"
style={{ borderBottom: "1px solid var(--border)" }}
>
<div className="flex items-center gap-1.5">
<span
className="text-xs font-mono font-medium uppercase tracking-wide"
style={{ color: "var(--text-1)" }}
>
{level}
</span>
</div>
{!included && (
<span className="text-xs" style={{ color: "var(--text-4)" }}>
skipped
</span>
)}
</div>
{/* Body */}
<div className="flex-1 px-3 py-3 text-sm" style={{ color: "var(--text-2)" }}>
{state.status === "idle" && (
<p className="text-xs" style={{ color: "var(--text-4)" }}>
Awaiting query
</p>
)}
{state.status === "pending" && (
<div className="flex items-center gap-2">
<LoadingSpinner size="sm" />
<span className="text-xs" style={{ color: "var(--text-3)" }}>
Thinking
</span>
</div>
)}
{state.status === "error" && (
<p
className="text-xs whitespace-pre-wrap"
style={{ color: "var(--destructive, #f87171)" }}
data-testid={`column-${level}-error`}
>
Error: {state.error}
</p>
)}
{state.status === "success" && (
<p
className="whitespace-pre-wrap leading-relaxed"
data-testid={`column-${level}-content`}
>
{maskFn(state.content ?? "(empty response)")}
</p>
)}
</div>
{/* Footer */}
<div
className="px-3 py-2 flex items-center justify-between text-xs"
style={{ borderTop: "1px solid var(--border)", color: "var(--text-4)" }}
>
<span data-testid={`column-${level}-latency`}>
{ms != null ? `${Math.round(ms)} ms` : "—"}
</span>
<span>
{state.content != null ? `${state.content.length.toLocaleString()} chars` : "—"}
</span>
</div>
</motion.div>
);
}

View File

@@ -24,6 +24,7 @@ import { Route as WorkspacesWorkspaceIdDreamsRouteImport } from './routes/worksp
import { Route as WorkspacesWorkspaceIdConclusionsRouteImport } from './routes/workspaces_.$workspaceId_.conclusions'
import { Route as WorkspacesWorkspaceIdSessionsSessionIdRouteImport } from './routes/workspaces_.$workspaceId_.sessions_.$sessionId'
import { Route as WorkspacesWorkspaceIdPeersPeerIdRouteImport } from './routes/workspaces_.$workspaceId_.peers_.$peerId'
import { Route as WorkspacesWorkspaceIdPeersPeerIdPlaygroundRouteImport } from './routes/workspaces_.$workspaceId_.peers_.$peerId_.playground'
import { Route as WorkspacesWorkspaceIdPeersPeerIdChatRouteImport } from './routes/workspaces_.$workspaceId_.peers_.$peerId_.chat'
const WorkspacesRoute = WorkspacesRouteImport.update({
@@ -109,6 +110,12 @@ const WorkspacesWorkspaceIdPeersPeerIdRoute =
path: '/workspaces/$workspaceId/peers/$peerId',
getParentRoute: () => rootRouteImport,
} as any)
const WorkspacesWorkspaceIdPeersPeerIdPlaygroundRoute =
WorkspacesWorkspaceIdPeersPeerIdPlaygroundRouteImport.update({
id: '/workspaces_/$workspaceId_/peers_/$peerId_/playground',
path: '/workspaces/$workspaceId/peers/$peerId/playground',
getParentRoute: () => rootRouteImport,
} as any)
const WorkspacesWorkspaceIdPeersPeerIdChatRoute =
WorkspacesWorkspaceIdPeersPeerIdChatRouteImport.update({
id: '/workspaces_/$workspaceId_/peers_/$peerId_/chat',
@@ -133,6 +140,7 @@ export interface FileRoutesByFullPath {
'/workspaces/$workspaceId/peers/$peerId': typeof WorkspacesWorkspaceIdPeersPeerIdRoute
'/workspaces/$workspaceId/sessions/$sessionId': typeof WorkspacesWorkspaceIdSessionsSessionIdRoute
'/workspaces/$workspaceId/peers/$peerId/chat': typeof WorkspacesWorkspaceIdPeersPeerIdChatRoute
'/workspaces/$workspaceId/peers/$peerId/playground': typeof WorkspacesWorkspaceIdPeersPeerIdPlaygroundRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
@@ -151,6 +159,7 @@ export interface FileRoutesByTo {
'/workspaces/$workspaceId/peers/$peerId': typeof WorkspacesWorkspaceIdPeersPeerIdRoute
'/workspaces/$workspaceId/sessions/$sessionId': typeof WorkspacesWorkspaceIdSessionsSessionIdRoute
'/workspaces/$workspaceId/peers/$peerId/chat': typeof WorkspacesWorkspaceIdPeersPeerIdChatRoute
'/workspaces/$workspaceId/peers/$peerId/playground': typeof WorkspacesWorkspaceIdPeersPeerIdPlaygroundRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ -170,6 +179,7 @@ export interface FileRoutesById {
'/workspaces_/$workspaceId_/peers_/$peerId': typeof WorkspacesWorkspaceIdPeersPeerIdRoute
'/workspaces_/$workspaceId_/sessions_/$sessionId': typeof WorkspacesWorkspaceIdSessionsSessionIdRoute
'/workspaces_/$workspaceId_/peers_/$peerId_/chat': typeof WorkspacesWorkspaceIdPeersPeerIdChatRoute
'/workspaces_/$workspaceId_/peers_/$peerId_/playground': typeof WorkspacesWorkspaceIdPeersPeerIdPlaygroundRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -190,6 +200,7 @@ export interface FileRouteTypes {
| '/workspaces/$workspaceId/peers/$peerId'
| '/workspaces/$workspaceId/sessions/$sessionId'
| '/workspaces/$workspaceId/peers/$peerId/chat'
| '/workspaces/$workspaceId/peers/$peerId/playground'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
@@ -208,6 +219,7 @@ export interface FileRouteTypes {
| '/workspaces/$workspaceId/peers/$peerId'
| '/workspaces/$workspaceId/sessions/$sessionId'
| '/workspaces/$workspaceId/peers/$peerId/chat'
| '/workspaces/$workspaceId/peers/$peerId/playground'
id:
| '__root__'
| '/'
@@ -226,6 +238,7 @@ export interface FileRouteTypes {
| '/workspaces_/$workspaceId_/peers_/$peerId'
| '/workspaces_/$workspaceId_/sessions_/$sessionId'
| '/workspaces_/$workspaceId_/peers_/$peerId_/chat'
| '/workspaces_/$workspaceId_/peers_/$peerId_/playground'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -245,6 +258,7 @@ export interface RootRouteChildren {
WorkspacesWorkspaceIdPeersPeerIdRoute: typeof WorkspacesWorkspaceIdPeersPeerIdRoute
WorkspacesWorkspaceIdSessionsSessionIdRoute: typeof WorkspacesWorkspaceIdSessionsSessionIdRoute
WorkspacesWorkspaceIdPeersPeerIdChatRoute: typeof WorkspacesWorkspaceIdPeersPeerIdChatRoute
WorkspacesWorkspaceIdPeersPeerIdPlaygroundRoute: typeof WorkspacesWorkspaceIdPeersPeerIdPlaygroundRoute
}
declare module '@tanstack/react-router' {
@@ -354,6 +368,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof WorkspacesWorkspaceIdPeersPeerIdRouteImport
parentRoute: typeof rootRouteImport
}
'/workspaces_/$workspaceId_/peers_/$peerId_/playground': {
id: '/workspaces_/$workspaceId_/peers_/$peerId_/playground'
path: '/workspaces/$workspaceId/peers/$peerId/playground'
fullPath: '/workspaces/$workspaceId/peers/$peerId/playground'
preLoaderRoute: typeof WorkspacesWorkspaceIdPeersPeerIdPlaygroundRouteImport
parentRoute: typeof rootRouteImport
}
'/workspaces_/$workspaceId_/peers_/$peerId_/chat': {
id: '/workspaces_/$workspaceId_/peers_/$peerId_/chat'
path: '/workspaces/$workspaceId/peers/$peerId/chat'
@@ -383,6 +404,8 @@ const rootRouteChildren: RootRouteChildren = {
WorkspacesWorkspaceIdSessionsSessionIdRoute,
WorkspacesWorkspaceIdPeersPeerIdChatRoute:
WorkspacesWorkspaceIdPeersPeerIdChatRoute,
WorkspacesWorkspaceIdPeersPeerIdPlaygroundRoute:
WorkspacesWorkspaceIdPeersPeerIdPlaygroundRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { DialecticPlayground } from "@/components/playground/DialecticPlayground";
export const Route = createFileRoute("/workspaces_/$workspaceId_/peers_/$peerId_/playground")({
component: DialecticPlayground,
});

View File

@@ -0,0 +1,52 @@
import { describe, expect, it, vi } from "vitest";
import { REASONING_LEVELS, type ReasoningLevel } from "@/api/queries";
import {
buildInitialColumns,
fanoutQuery,
latencyMs,
} from "@/components/playground/DialecticPlayground";
describe("fanoutQuery", () => {
it("fires every selected level concurrently rather than sequentially", async () => {
const inFlight = new Set<ReasoningLevel>();
let peakConcurrency = 0;
const released: Array<() => void> = [];
const runOne = vi.fn(async (level: ReasoningLevel) => {
inFlight.add(level);
peakConcurrency = Math.max(peakConcurrency, inFlight.size);
await new Promise<void>((resolve) => released.push(resolve));
inFlight.delete(level);
return { ok: true as const, content: `answer-${level}` };
});
let nowVal = 0;
const fanout = fanoutQuery(REASONING_LEVELS, "ping", {
now: () => nowVal++,
runOne,
onStart: () => {},
onEnd: () => {},
});
await Promise.resolve();
await Promise.resolve();
expect(peakConcurrency).toBe(REASONING_LEVELS.length);
for (const release of released) release();
await fanout;
expect(runOne).toHaveBeenCalledTimes(REASONING_LEVELS.length);
});
});
describe("latencyMs", () => {
it("measures the gap between request start and response end per column", () => {
const columns = buildInitialColumns();
columns.medium.status = "success";
columns.medium.startedAt = 1000;
columns.medium.endedAt = 1842;
expect(latencyMs(columns.medium)).toBe(842);
});
});