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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added docs/screenshots/playground-idle.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/playground-results.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/playground-running.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
131 changes: 131 additions & 0 deletions packages/web/e2e/playground.screenshots.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
18 changes: 16 additions & 2 deletions packages/web/src/api/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,15 +263,29 @@ 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) => {
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: message, stream: false, reasoning_level: "low" },
body: { query: message, stream: false, reasoning_level: reasoningLevel },
},
);
return data ?? err(error);
Expand Down
54 changes: 40 additions & 14 deletions packages/web/src/components/peers/PeerDetail.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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>

Expand Down
Loading
Loading