Merge pull request #28 from offendingcommit/feat/dialectic-playground
feat(web): add dialectic reasoning playground
This commit is contained in:
BIN
docs/screenshots/playground-idle.png
Normal file
BIN
docs/screenshots/playground-idle.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
BIN
docs/screenshots/playground-results.png
Normal file
BIN
docs/screenshots/playground-results.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 224 KiB |
BIN
docs/screenshots/playground-running.png
Normal file
BIN
docs/screenshots/playground-running.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
131
packages/web/e2e/playground.screenshots.spec.ts
Normal file
131
packages/web/e2e/playground.screenshots.spec.ts
Normal 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 9–11am 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 9–11am, 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 9–11am 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,
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
|
||||
376
packages/web/src/components/playground/DialecticPlayground.tsx
Normal file
376
packages/web/src/components/playground/DialecticPlayground.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
52
packages/web/src/test/playground.test.tsx
Normal file
52
packages/web/src/test/playground.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user