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();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (message: string) => {
|
mutationFn: async (message: string) => {
|
||||||
@@ -287,7 +301,7 @@ export function useChat(workspaceId: string, peerId: string) {
|
|||||||
"/v3/workspaces/{workspace_id}/peers/{peer_id}/chat",
|
"/v3/workspaces/{workspace_id}/peers/{peer_id}/chat",
|
||||||
{
|
{
|
||||||
params: { path: { workspace_id: workspaceId, peer_id: peerId } },
|
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);
|
return data ?? err(error);
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import { useNavigate, useParams } from "@tanstack/react-router";
|
import { useNavigate, useParams } from "@tanstack/react-router";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
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 { useState } from "react";
|
||||||
import {
|
import {
|
||||||
usePeer,
|
usePeer,
|
||||||
@@ -98,19 +108,35 @@ export function PeerDetail() {
|
|||||||
</div>
|
</div>
|
||||||
<Body className="leading-none">Peer identity & memory</Body>
|
<Body className="leading-none">Peer identity & memory</Body>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
variant="primary"
|
<Button
|
||||||
onClick={() =>
|
variant="surface"
|
||||||
navigate({
|
onClick={() =>
|
||||||
to: "/workspaces/$workspaceId/peers/$peerId/chat",
|
navigate({
|
||||||
params: { workspaceId, peerId } as never,
|
to: "/workspaces/$workspaceId/peers/$peerId/playground",
|
||||||
})
|
params: { workspaceId, peerId } as never,
|
||||||
}
|
})
|
||||||
className="shrink-0 rounded-xl"
|
}
|
||||||
>
|
className="rounded-xl"
|
||||||
<MessageCircle className="w-4 h-4" strokeWidth={1.5} />
|
title="Compare reasoning levels side-by-side"
|
||||||
Chat
|
>
|
||||||
</Button>
|
<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>
|
</div>
|
||||||
</motion.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 WorkspacesWorkspaceIdConclusionsRouteImport } from './routes/workspaces_.$workspaceId_.conclusions'
|
||||||
import { Route as WorkspacesWorkspaceIdSessionsSessionIdRouteImport } from './routes/workspaces_.$workspaceId_.sessions_.$sessionId'
|
import { Route as WorkspacesWorkspaceIdSessionsSessionIdRouteImport } from './routes/workspaces_.$workspaceId_.sessions_.$sessionId'
|
||||||
import { Route as WorkspacesWorkspaceIdPeersPeerIdRouteImport } from './routes/workspaces_.$workspaceId_.peers_.$peerId'
|
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'
|
import { Route as WorkspacesWorkspaceIdPeersPeerIdChatRouteImport } from './routes/workspaces_.$workspaceId_.peers_.$peerId_.chat'
|
||||||
|
|
||||||
const WorkspacesRoute = WorkspacesRouteImport.update({
|
const WorkspacesRoute = WorkspacesRouteImport.update({
|
||||||
@@ -109,6 +110,12 @@ const WorkspacesWorkspaceIdPeersPeerIdRoute =
|
|||||||
path: '/workspaces/$workspaceId/peers/$peerId',
|
path: '/workspaces/$workspaceId/peers/$peerId',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const WorkspacesWorkspaceIdPeersPeerIdPlaygroundRoute =
|
||||||
|
WorkspacesWorkspaceIdPeersPeerIdPlaygroundRouteImport.update({
|
||||||
|
id: '/workspaces_/$workspaceId_/peers_/$peerId_/playground',
|
||||||
|
path: '/workspaces/$workspaceId/peers/$peerId/playground',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const WorkspacesWorkspaceIdPeersPeerIdChatRoute =
|
const WorkspacesWorkspaceIdPeersPeerIdChatRoute =
|
||||||
WorkspacesWorkspaceIdPeersPeerIdChatRouteImport.update({
|
WorkspacesWorkspaceIdPeersPeerIdChatRouteImport.update({
|
||||||
id: '/workspaces_/$workspaceId_/peers_/$peerId_/chat',
|
id: '/workspaces_/$workspaceId_/peers_/$peerId_/chat',
|
||||||
@@ -133,6 +140,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/workspaces/$workspaceId/peers/$peerId': typeof WorkspacesWorkspaceIdPeersPeerIdRoute
|
'/workspaces/$workspaceId/peers/$peerId': typeof WorkspacesWorkspaceIdPeersPeerIdRoute
|
||||||
'/workspaces/$workspaceId/sessions/$sessionId': typeof WorkspacesWorkspaceIdSessionsSessionIdRoute
|
'/workspaces/$workspaceId/sessions/$sessionId': typeof WorkspacesWorkspaceIdSessionsSessionIdRoute
|
||||||
'/workspaces/$workspaceId/peers/$peerId/chat': typeof WorkspacesWorkspaceIdPeersPeerIdChatRoute
|
'/workspaces/$workspaceId/peers/$peerId/chat': typeof WorkspacesWorkspaceIdPeersPeerIdChatRoute
|
||||||
|
'/workspaces/$workspaceId/peers/$peerId/playground': typeof WorkspacesWorkspaceIdPeersPeerIdPlaygroundRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
@@ -151,6 +159,7 @@ export interface FileRoutesByTo {
|
|||||||
'/workspaces/$workspaceId/peers/$peerId': typeof WorkspacesWorkspaceIdPeersPeerIdRoute
|
'/workspaces/$workspaceId/peers/$peerId': typeof WorkspacesWorkspaceIdPeersPeerIdRoute
|
||||||
'/workspaces/$workspaceId/sessions/$sessionId': typeof WorkspacesWorkspaceIdSessionsSessionIdRoute
|
'/workspaces/$workspaceId/sessions/$sessionId': typeof WorkspacesWorkspaceIdSessionsSessionIdRoute
|
||||||
'/workspaces/$workspaceId/peers/$peerId/chat': typeof WorkspacesWorkspaceIdPeersPeerIdChatRoute
|
'/workspaces/$workspaceId/peers/$peerId/chat': typeof WorkspacesWorkspaceIdPeersPeerIdChatRoute
|
||||||
|
'/workspaces/$workspaceId/peers/$peerId/playground': typeof WorkspacesWorkspaceIdPeersPeerIdPlaygroundRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
@@ -170,6 +179,7 @@ export interface FileRoutesById {
|
|||||||
'/workspaces_/$workspaceId_/peers_/$peerId': typeof WorkspacesWorkspaceIdPeersPeerIdRoute
|
'/workspaces_/$workspaceId_/peers_/$peerId': typeof WorkspacesWorkspaceIdPeersPeerIdRoute
|
||||||
'/workspaces_/$workspaceId_/sessions_/$sessionId': typeof WorkspacesWorkspaceIdSessionsSessionIdRoute
|
'/workspaces_/$workspaceId_/sessions_/$sessionId': typeof WorkspacesWorkspaceIdSessionsSessionIdRoute
|
||||||
'/workspaces_/$workspaceId_/peers_/$peerId_/chat': typeof WorkspacesWorkspaceIdPeersPeerIdChatRoute
|
'/workspaces_/$workspaceId_/peers_/$peerId_/chat': typeof WorkspacesWorkspaceIdPeersPeerIdChatRoute
|
||||||
|
'/workspaces_/$workspaceId_/peers_/$peerId_/playground': typeof WorkspacesWorkspaceIdPeersPeerIdPlaygroundRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
@@ -190,6 +200,7 @@ export interface FileRouteTypes {
|
|||||||
| '/workspaces/$workspaceId/peers/$peerId'
|
| '/workspaces/$workspaceId/peers/$peerId'
|
||||||
| '/workspaces/$workspaceId/sessions/$sessionId'
|
| '/workspaces/$workspaceId/sessions/$sessionId'
|
||||||
| '/workspaces/$workspaceId/peers/$peerId/chat'
|
| '/workspaces/$workspaceId/peers/$peerId/chat'
|
||||||
|
| '/workspaces/$workspaceId/peers/$peerId/playground'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/'
|
| '/'
|
||||||
@@ -208,6 +219,7 @@ export interface FileRouteTypes {
|
|||||||
| '/workspaces/$workspaceId/peers/$peerId'
|
| '/workspaces/$workspaceId/peers/$peerId'
|
||||||
| '/workspaces/$workspaceId/sessions/$sessionId'
|
| '/workspaces/$workspaceId/sessions/$sessionId'
|
||||||
| '/workspaces/$workspaceId/peers/$peerId/chat'
|
| '/workspaces/$workspaceId/peers/$peerId/chat'
|
||||||
|
| '/workspaces/$workspaceId/peers/$peerId/playground'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
@@ -226,6 +238,7 @@ export interface FileRouteTypes {
|
|||||||
| '/workspaces_/$workspaceId_/peers_/$peerId'
|
| '/workspaces_/$workspaceId_/peers_/$peerId'
|
||||||
| '/workspaces_/$workspaceId_/sessions_/$sessionId'
|
| '/workspaces_/$workspaceId_/sessions_/$sessionId'
|
||||||
| '/workspaces_/$workspaceId_/peers_/$peerId_/chat'
|
| '/workspaces_/$workspaceId_/peers_/$peerId_/chat'
|
||||||
|
| '/workspaces_/$workspaceId_/peers_/$peerId_/playground'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
@@ -245,6 +258,7 @@ export interface RootRouteChildren {
|
|||||||
WorkspacesWorkspaceIdPeersPeerIdRoute: typeof WorkspacesWorkspaceIdPeersPeerIdRoute
|
WorkspacesWorkspaceIdPeersPeerIdRoute: typeof WorkspacesWorkspaceIdPeersPeerIdRoute
|
||||||
WorkspacesWorkspaceIdSessionsSessionIdRoute: typeof WorkspacesWorkspaceIdSessionsSessionIdRoute
|
WorkspacesWorkspaceIdSessionsSessionIdRoute: typeof WorkspacesWorkspaceIdSessionsSessionIdRoute
|
||||||
WorkspacesWorkspaceIdPeersPeerIdChatRoute: typeof WorkspacesWorkspaceIdPeersPeerIdChatRoute
|
WorkspacesWorkspaceIdPeersPeerIdChatRoute: typeof WorkspacesWorkspaceIdPeersPeerIdChatRoute
|
||||||
|
WorkspacesWorkspaceIdPeersPeerIdPlaygroundRoute: typeof WorkspacesWorkspaceIdPeersPeerIdPlaygroundRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
@@ -354,6 +368,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof WorkspacesWorkspaceIdPeersPeerIdRouteImport
|
preLoaderRoute: typeof WorkspacesWorkspaceIdPeersPeerIdRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
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': {
|
'/workspaces_/$workspaceId_/peers_/$peerId_/chat': {
|
||||||
id: '/workspaces_/$workspaceId_/peers_/$peerId_/chat'
|
id: '/workspaces_/$workspaceId_/peers_/$peerId_/chat'
|
||||||
path: '/workspaces/$workspaceId/peers/$peerId/chat'
|
path: '/workspaces/$workspaceId/peers/$peerId/chat'
|
||||||
@@ -383,6 +404,8 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
WorkspacesWorkspaceIdSessionsSessionIdRoute,
|
WorkspacesWorkspaceIdSessionsSessionIdRoute,
|
||||||
WorkspacesWorkspaceIdPeersPeerIdChatRoute:
|
WorkspacesWorkspaceIdPeersPeerIdChatRoute:
|
||||||
WorkspacesWorkspaceIdPeersPeerIdChatRoute,
|
WorkspacesWorkspaceIdPeersPeerIdChatRoute,
|
||||||
|
WorkspacesWorkspaceIdPeersPeerIdPlaygroundRoute:
|
||||||
|
WorkspacesWorkspaceIdPeersPeerIdPlaygroundRoute,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
._addFileChildren(rootRouteChildren)
|
._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