feat: Code Tour — guided PR walkthrough as a third agent provider (#569)

* feat(server): add Code Tour agent as third review provider

Adds a "tour" provider alongside "claude" and "codex" that generates a
guided walkthrough of a changeset from a product-minded colleague's
perspective. Reuses the entire agent-jobs infrastructure (process
lifecycle, SSE broadcasting, live logs, kill support, capability
detection) so the only new server plumbing is the prompt/schema module,
the GET endpoint for the result, and checklist persistence.

- tour-review.ts: prompt, JSON schema, Claude (stream-json) and Codex
  (file output) command builders, and parsers for both. Prompt frames
  the agent as a colleague giving a casual tour, orders stops by reading
  flow (not impact), chunks by logical change (not per-file), and writes
  QA questions a human can answer by reading code or using the product.
- review.ts: tour buildCommand with per-engine model defaults (fixes a
  bug where Codex was defaulting to "sonnet", a Claude model), an
  onJobComplete handler that parses and stores the tour, plus GET
  /api/tour/:jobId and PUT /api/tour/:jobId/checklist endpoints.
- agent-jobs.ts: tour capability detection and config threading from the
  POST body through to the provider's buildCommand.
- shared/agent-jobs.ts: engine/model fields on AgentJobInfo so the UI
  can render tour jobs with their chosen provider + model.

For provenance purposes, this commit was AI assisted.

* feat(review-editor): Code Tour dialog with animated walkthrough

Adds the tour overlay surface: a full-screen dialog with three animated
pages (Overview, Walkthrough, Checklist) that renders the CodeTourOutput
from the server-side agent. Opens automatically when a tour job completes
and can be dismissed with Escape or backdrop click.

- components/tour/TourDialog.tsx: three-page animated dialog. Intro page
  uses a composition cascade (greeting, Intent/Before/After cards with
  one-shot color-dot pulse, key takeaways table) with a pinned Start
  Tour button and a bottom fade so content scrolls cleanly under it.
  Page slides between Overview/Walkthrough/Checklist are ease-out
  springs with direction auto-derived from tab index.
- components/tour/TourStopCard.tsx: per-stop accordion using motion's
  AnimatePresence for height + opacity springs with staggered children
  for detail text and anchor blocks. Anchors lazy-mount DiffHunkPreview
  on first open to keep mount costs low. Callout labels (Important,
  Warning, Note) are deterministic typography, no emoji.
- components/tour/QAChecklist.tsx: always-open list of verification
  questions with per-item spring entrance and persistent checkbox state
  that PUTs to the server.
- hooks/useTourData.ts: fetch + checklist persistence with a dev-mode
  short-circuit when jobId === DEMO_TOUR_ID so UI iteration doesn't
  require a live agent run.
- demoTour.ts: realistic demo data (multi-stop, multiple takeaways,
  real diff hunks) for dev iteration.
- App.tsx: tour dialog state, auto-open on job completion, and a
  dev-only "Demo tour" floating button + Cmd/Ctrl+Shift+T shortcut
  guarded by import.meta.env.DEV.
- index.css: all tour animations (dialog enter/exit, page slides, stop
  reveal cascade, intro exit) with reduced-motion fallbacks. Dark-mode
  contrast tuning across structural surfaces (borders, surface tints,
  callout backgrounds) so the design works in both themes.
- package.json: adds motion@12.38.0 for spring-driven accordion physics
  and the intro composition cascade.

For provenance purposes, this commit was AI assisted.

* feat(review-editor): wire Code Tour into agent jobs UI + polish DiffHunkPreview

Connects the tour provider to the existing agent jobs sidebar and detail
panel so the user can launch a tour the same way they launch Claude or
Codex reviews. Also tightens DiffHunkPreview (used inside tour anchors)
to render correctly on first mount and handle every diff hunk shape the
agent might produce.

- ui/components/AgentsTab.tsx: tour engine (Claude/Codex) + model select
  when launching a tour job. Resets model to blank when falling back to
  Codex-only so Codex uses its own default instead of inheriting
  "sonnet" (a Claude model).
- ui/hooks/useAgentJobs.ts: forward engine/model config from the UI
  through to the POST /api/agents/jobs body so the server's tour
  provider can pick the right command shape.
- dock/panels/ReviewAgentJobDetailPanel.tsx: tour-aware detail panel.
  Replaces the "Findings" tab with a "Status" card for tour jobs and
  surfaces an "Open Tour" button that opens the dialog overlay when
  the tab is already in the dock.
- components/DiffHunkPreview.tsx: synchronous pierre theme init via a
  useState lazy initializer so the first render (inside a tooltip) is
  already themed; robust hunk parser that handles bare @@ hunks, file-
  level --- hunks, and full git diffs; "Diff not available" fallback
  for broken hunks.
- components/ReviewSidebar.tsx, dock/ReviewStateContext.tsx: small
  wiring changes to expose openTourPanel from the review state context.

Also removes two dead components (TourHeader, DiffAnchorChip) that were
superseded by the inline title bar in TourDialog and the inline
AnchorBlock in TourStopCard.

For provenance purposes, this commit was AI assisted.

* refactor(server): extract Code Tour lifecycle into shared createTourSession factory + Pi parity

The route-parity test suite requires the Pi server (apps/pi-extension/)
to expose the same routes as the Bun server (packages/server/). After
Code Tour shipped in the prior 3 commits, Pi was missing /api/tour/:jobId
(GET) and /api/tour/:jobId/checklist (PUT).

A naive mirror would duplicate ~100 lines of provider-branch logic
(buildCommand, onJobComplete, in-memory maps) into Pi's serverReview.ts,
perpetuating the existing claude/codex duplication problem. Instead,
extract the pure runtime-agnostic tour lifecycle into a
createTourSession() factory that both servers consume. Route handlers
stay per-server (different HTTP primitives) but are ~5 lines each.

Net effect: Pi port is ~25 lines instead of ~100. Future providers that
adopt the same pattern cost ~15 lines per server.

- tour-review.ts: new createTourSession() at the bottom of the module.
  Encapsulates tourResults + tourChecklists maps, buildCommand (with
  the Claude-vs-Codex model-default fix baked in), onJobComplete
  (parse/store/summarize), plus getTour/saveChecklist lookup helpers
  for route handlers.
- review.ts (Bun): tour branch in buildCommand, tour branch in
  onJobComplete, and both route handlers collapse to one-line calls
  into the factory. Drops ~70 lines.
- vendor.sh: add tour-review to the review-agent loop so Pi regenerates
  generated/tour-review.ts on every build:pi.
- serverReview.ts (Pi): import createTourSession from
  ../generated/tour-review.js; add tour branch to buildCommand (one
  line), tour branch to onJobComplete (three lines), and GET/PUT route
  handlers using Pi's json() helper. ~25 lines added.
- agent-jobs.ts (Pi): extend buildCommand interface to accept config
  and return engine/model; thread config from POST body; extend
  spawnJob to persist engine/model on AgentJobInfo; add tour to
  capability list.

Claude and Codex branches are intentionally left in the old pattern;
they can migrate to the factory approach when next touched to keep
this change's blast radius contained.

Tests: 518/518 passing (previous 3 route-parity failures resolved,
plus 2 extra assertions passing since tour is now in both servers'
route tables).

For provenance purposes, this commit was AI assisted.

* refactor(tour): self-review cleanup — Pi route match parity + remove setOpen shim

Two small cleanups surfaced by a self-review pass:

- Pi's GET /api/tour/:jobId route used `endsWith("/checklist")` to block
  the checklist sub-route, while Bun uses `includes("/", ...)`. The two
  are not equivalent: a URL like /api/tour/abc/extra would be accepted
  by Pi (jobId becomes "abc/extra") but correctly rejected by Bun.
  Align Pi to Bun's pattern.
- TourStopCard had a leftover `setOpen` shim from when open state was
  local to the card. State is now lifted to TourDialog, so the shim
  just aliases onToggle and ignores its argument. Replace with a
  direct `onClick={onToggle}` on the trigger button.

520/0 tests still pass.

For provenance purposes, this commit was AI assisted.

* feat(review): per-provider model + effort controls across all review agents

Exposes model, effort/reasoning, and fast-mode (Codex) as per-job knobs
for Claude, Codex, and Code Tour via the Agents tab. Threads the config
from the UI through the POST body into each provider's buildCommand,
which emits the corresponding CLI flags (--model/--effort for Claude;
-m / -c model_reasoning_effort / -c service_tier for Codex). Bun and Pi
servers stay in parity.

UI: option catalogs (CLAUDE_MODELS, CLAUDE_EFFORT, CODEX_MODELS,
CODEX_REASONING, TOUR_CLAUDE_MODELS) are defined once and mapped into
every dropdown so there's a single source of truth for the choices.
Tour's Claude/Codex settings are kept separate from the standalone
provider's state so toggling the tour engine no longer overwrites the
provider's last choice.

Job badge now surfaces the model/reasoning/fast selection for both Tour
and the standalone Codex provider.

For provenance purposes, this commit was AI assisted.

* docs: add Prompts reference page

Documents the three-layer structure every review call travels through
(CLI system prompt, our user message of review-prompt + user-prompt,
output schema flag), names each constant + its file, and calls out
that the Claude/Codex review prompts are the upstream ones from those
projects (only Code Tour's prompt is original to Plannotator).

Linked from the Code Review command page.

For provenance purposes, this commit was AI assisted.

* feat(review): persist per-agent, per-model settings in a single cookie

Drops the hidden "Default" options from the agent dropdowns, locks in explicit
sensible defaults (Opus 4.7/High for Claude review, gpt-5.3-codex/High for Codex,
Sonnet/Medium for Tour Claude, gpt-5.3-codex/Medium for Tour Codex), and
remembers the last-used effort/reasoning/fast-mode per (agent job × model) so
switching models reveals the choices you made last time.

Backed by a single `plannotator.agents` cookie holding the whole settings tree —
one read on mount, one mirror write per change, all mutations funnel through a
single React state owner to avoid stale-read or lost-write races across rapid
successive updates.

For provenance purposes, this commit was AI assisted.

* fix(tour): unblock reduced-motion nav + flush pending checklist save on close

Two P2 issues surfaced in review:

- Under prefers-reduced-motion, tour page animations are suppressed, so
  onAnimationEnd never fires and exitingPage stuck on 'intro' kept the
  walkthrough/checklist gated out. navigate() now swaps pages directly when
  reduced motion is on, mirroring the pattern already used in the wrapper.

- Checklist toggles are debounced 500ms before the PUT, but unmount only
  cleared the timer — checking an item and closing within the window dropped
  the save. Cleanup now flushes the pending payload with keepalive: true.

For provenance purposes, this commit was AI assisted.

* feat(review): surface Claude model + effort in job badge, trim redundant labels

Claude effort was never persisted on AgentJobInfo, so the job badge had nothing
to render beyond "Claude". Plumbs `effort` through the shared type, both
server build-command pipelines (Bun + Pi), and spawnJob, then teaches the
ProviderBadge to display Claude and Tour Claude model + effort with the same
shape Codex already had. Labels resolve via the dropdown catalogs so the
badge shows "Opus 4.7" / "High" instead of raw ids.

Server labels for both Claude and Codex reviews collapse to plain "Code Review"
(matching tour's "Code Tour"), since the badge now carries the provider +
model + settings — the title only needs to name the action.

For provenance purposes, this commit was AI assisted.

* polish(review): prefix agent dropdown options with the action name

The Run launcher listed "Claude Code", "Codex CLI", and "Code Tour" side by
side — the CLI's detection name was doing double duty as the action label.
Prefixes the review entries with "Code Review · " so scanning three options
surfaces two reviews + one tour instead of three raw provider names.

For provenance purposes, this commit was AI assisted.

* fix(review): drop invalid codex reasoning option + fail tour on empty output

Two P2 issues from PR review:

- Tour jobs exited 0 but returned null output would still be marked "done",
  and the auto-open watcher would greet the user with a 404 "Tour not found"
  dialog. onJobComplete now flips the job to failed with an error message
  when nothing was stored, so the card reflects the real state.

- The codex reasoning dropdown offered "None", but codex-rs only accepts
  minimal/low/medium/high/xhigh. Picking None sent `-c model_reasoning_effort=none`
  and launched a broken job. Removed the option; added a one-shot cookie
  migration so users who already saved "none" don't keep shipping it.

For provenance purposes, this commit was AI assisted.

* fix(tour): format Claude-engine logs + allow reading linked issues

Two P2/P3 issues from PR review:

- The stdout formatter keyed only on provider === "claude", so Code Tour
  jobs running on the Claude engine streamed raw JSONL to the log panel
  while Claude review jobs got formatted text. Widened the check to also
  catch spawnOptions.engine === "claude" and mirrored the fix into Pi.

- The tour Claude allowlist permitted PR/MR commands but not issue reads,
  yet the prompt explicitly asks the agent to open `Fixes #123` / `Closes
  owner/repo#456` targets for deeper context. Claude was being denied
  those commands mid-tour. Added gh issue view + gh api issues + glab
  issue view to the allowlist.

For provenance purposes, this commit was AI assisted.

* cleanup(review): e2e punchlist — Pi parity + dedup + tests

- Pi now logs Claude parse failures with the same diagnostic as Bun
- Tour route match uses a regex instead of an includes-offset trick
- Drop `any` cast in Pi's Codex blocking-findings predicate
- Extract `patchClaude` twin of `patchCodex` in useAgentSettings
- Factor `resolveAgentCwd` helper in Bun review to match Pi
- AgentsTab launch payload becomes a per-provider dispatch table
- Wrap TourDialog in MotionConfig reducedMotion="user" so motion.*
  children honor prefers-reduced-motion alongside the CSS keyframes
- Tests for parseTourStreamOutput/parseTourFileOutput and the Codex
  perModel sanitizer

For provenance purposes, this commit was AI assisted.

* test(tour-review): use real TourStop shape in fixtures

The parsers don't validate stop fields so the original made-up shape
passed fine, but future readers would be misled. Use the real
CodeTourOutput / TourStop / TourDiffAnchor shapes from tour-review.ts.

For provenance purposes, this commit was AI assisted.

* cleanup(tour): hoist shared types + subfolder server module + dedup

- Hoist tour output types to packages/shared/tour.ts so server and UI
  share one source of truth (prevents silent drift when schema changes).
- Move tour-review into packages/server/tour/ subfolder alongside the
  existing packages/review-editor/components/tour/ convention.
- Extend tour.buildCommand to accept tour context directly (patch,
  diffType, options, prMetadata), so Bun and Pi no longer duplicate the
  buildTourUserMessage call site. Export TOUR_EMPTY_OUTPUT_ERROR from
  the tour module to prevent string drift between servers.
- Early-return the tour branch in buildCommand so the review userMessage
  is built once on the shared non-tour path instead of unconditionally
  (dead work on tour launches) or twice (codex+claude dup).
- Regex capture group for tour checklist jobId in both servers —
  replaces a second url.pathname.split("/")[3] fragile fallback.
- Drop existsSync guard in parseTourFileOutput (TOCTOU + duplicate
  syscall); the outer try/catch already handles ENOENT.
- Dedupe two matchMedia reads in TourDialog behind a prefersReducedMotion
  helper; memoize intro-page card array so unrelated state churn doesn't
  recompute it.
- Comment hygiene on tour files — drop WHAT/change-referencing comments
  added by this PR; keep non-obvious WHY.

For provenance purposes, this commit was AI assisted.

* fix(tour): hoist introCards useMemo above early returns + subfolder hook

The memoized intro-card array was placed after the loading/error early
returns in TourDialogContent, so the first successful render called one
more hook than the initial loading render — React threw "Rendered more
hooks than during the previous render" (error #310) the moment a tour
finished loading. Move the useMemo above the early returns and use
optional chaining on `tour` so it's safe to run during loading.

Also move useTourData.ts into a hooks/tour/ subfolder to start a
standard for feature-scoped hooks (was flat among siblings).

For provenance purposes, this commit was AI assisted.
This commit is contained in:
Michael Ramos
2026-04-20 20:57:57 -07:00
committed by GitHub
parent 3a7f1156cd
commit 53d2246b16
31 changed files with 3334 additions and 165 deletions
@@ -106,6 +106,10 @@ When multiple providers are available, set your default in **Settings → AI**.
If only one provider is installed, it's used automatically with no configuration needed.
## How review agents prompt the CLI
The review agents (Claude, Codex, Code Tour) shell out to external CLIs. Plannotator controls the user message and output schema; the CLI's own harness owns the system prompt. See the [Prompts reference](/docs/reference/prompts/) for the full breakdown of what each provider sends, how the pieces join, and which knobs you can tune per job.
## Submitting feedback
- **Send Feedback** formats your annotations and sends them to the agent
@@ -0,0 +1,69 @@
---
title: "Prompts"
description: "How Plannotator's review agents structure their prompts, what we control, what the CLI harness owns, and how the pieces fit together."
sidebar:
order: 33
section: "Reference"
---
Plannotator's review agents (Claude, Codex, and Code Tour) all shell out to an external CLI. This page maps what those CLIs receive on every invocation: which parts Plannotator controls, and which parts are owned by the CLI's own agent harness.
Importantly, **we don't invent our own review prompts**. The Claude review prompt is derived from Claude Code's published open-source review prompt, and the Codex review prompt is copied verbatim from [`codex-rs/core/review_prompt.md`](https://github.com/openai/codex). You get the same review behavior those tools ship with. Code Tour is the one exception: it's a Plannotator-original workflow, so its prompt is ours.
## The three layers
Every review call is shaped by three layers:
1. **System prompt.** Owned by the CLI (Claude Code or codex-rs). Plannotator never sets or touches this.
2. **User message.** What Plannotator sends. Always a single concatenated string of two parts: a static **review prompt** plus a dynamic **user prompt**.
3. **Output schema.** A JSON schema passed to the CLI as a flag, forcing the final assistant message to match a known shape.
## What's in the user message
The user message Plannotator sends is always:
```
<review prompt>
---
<user prompt>
```
**Review prompt** is a long, static review instruction that lives in the repo as a TypeScript constant. It's distinct per provider.
**User prompt** is a short, dynamic line built per call from the diff type (`uncommitted`, `staged`, `last-commit`, `branch`, PR URL, and so on). The same builder is used for all providers.
## Matrix
| | Claude review | Codex review | Code Tour (Claude or Codex) |
|---|---|---|---|
| **System prompt** | Owned by `claude` CLI. We don't touch it. | Owned by `codex` CLI. We don't touch it. | Same as whichever engine runs. |
| **Review prompt (static, ours)** | `CLAUDE_REVIEW_PROMPT` in `packages/server/claude-review.ts` | `CODEX_REVIEW_SYSTEM_PROMPT` in `packages/server/codex-review.ts` (misnamed; it's user content) | `TOUR_REVIEW_PROMPT` in `packages/server/tour-review.ts` |
| **User prompt (dynamic, ours)** | `buildCodexReviewUserMessage(patch, diffType, …)` | same function | same function |
| **Full user message** | `review prompt + "\n\n---\n\n" + user prompt` | same | same |
| **Delivered via** | stdin | last positional argv | stdin (Claude engine) or positional argv (Codex engine) |
| **Output schema flag** | `--json-schema <inline JSON>` | `--output-schema <file path>` | same as engine |
| **Schema shape** | severity findings (`important`, `nit`, `pre_existing`) | priority findings (P0 through P3) | stops plus QA checklist |
## Why the schema matters
The schema flag is a terminal constraint, not a per-turn one. The agent reasons freely across N turns, reading files, grepping, running tests, and only the final assistant message is forced to deserialize against the schema. Everything upstream is unconstrained exploration.
That's why this pattern works for review. You get agentic exploration (the whole point of using Claude Code or Codex over a raw LLM call), plus a machine-readable payload the UI can render without any scraping.
## What you can tune per job
From the **Agents** tab in the code-review UI, each provider exposes these settings:
| Setting | Claude | Codex | Tour |
|---|---|---|---|
| Model | yes (`--model`) | yes (`-m`) | yes (per engine) |
| Reasoning effort | yes (`--effort`) | yes (`-c model_reasoning_effort=…`) | yes (per engine) |
| Fast mode | no | yes (`-c service_tier=fast`) | Codex engine only |
None of these change the review prompt or user prompt. They only change how the underlying CLI executes the same user message.
## Relationship to code review
See [Code Review](/docs/commands/code-review/) for the end-to-end flow this feeds into.
+44 -5
View File
@@ -54,8 +54,8 @@ export interface AgentJobHandlerOptions {
mode: "plan" | "review" | "annotate";
getServerUrl: () => string;
getCwd: () => string;
/** Server-side command builder for known providers (codex, claude). */
buildCommand?: (provider: string) => Promise<{
/** Server-side command builder for known providers (codex, claude, tour). */
buildCommand?: (provider: string, config?: Record<string, unknown>) => Promise<{
command: string[];
outputPath?: string;
captureStdout?: boolean;
@@ -63,6 +63,16 @@ export interface AgentJobHandlerOptions {
cwd?: string;
prompt?: string;
label?: string;
/** Underlying engine used (e.g., "claude" or "codex"). Stored on AgentJobInfo for UI display. */
engine?: string;
/** Model used (e.g., "sonnet", "opus"). Stored on AgentJobInfo for UI display. */
model?: string;
/** Claude --effort level. */
effort?: string;
/** Codex reasoning effort level. */
reasoningEffort?: string;
/** Whether Codex fast mode was enabled. */
fastMode?: boolean;
} | null>;
/** Called when a job completes successfully — parse results and push annotations. */
onJobComplete?: (job: AgentJobInfo, meta: { outputPath?: string; stdout?: string; cwd?: string }) => void | Promise<void>;
@@ -81,6 +91,7 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) {
const capabilities: AgentCapability[] = [
{ id: "claude", name: "Claude Code", available: whichCmd("claude") },
{ id: "codex", name: "Codex CLI", available: whichCmd("codex") },
{ id: "tour", name: "Code Tour", available: whichCmd("claude") || whichCmd("codex") },
];
const capabilitiesResponse: AgentCapabilities = {
mode,
@@ -107,7 +118,7 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) {
command: string[],
label: string,
outputPath?: string,
spawnOptions?: { captureStdout?: boolean; stdinPrompt?: string; cwd?: string; prompt?: string },
spawnOptions?: { captureStdout?: boolean; stdinPrompt?: string; cwd?: string; prompt?: string; engine?: string; model?: string; effort?: string; reasoningEffort?: string; fastMode?: boolean },
): AgentJobInfo {
const id = crypto.randomUUID();
const source = jobSource(id);
@@ -121,6 +132,11 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) {
startedAt: Date.now(),
command,
cwd: getCwd(),
...(spawnOptions?.engine && { engine: spawnOptions.engine }),
...(spawnOptions?.model && { model: spawnOptions.model }),
...(spawnOptions?.effort && { effort: spawnOptions.effort }),
...(spawnOptions?.reasoningEffort && { reasoningEffort: spawnOptions.reasoningEffort }),
...(spawnOptions?.fastMode && { fastMode: spawnOptions.fastMode }),
};
let proc: ChildProcess | null = null;
@@ -169,7 +185,8 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) {
const lines = text.split('\n');
for (const line of lines) {
if (!line.trim()) continue;
if (provider === "claude") {
// Tour jobs with the Claude engine also stream Claude JSONL.
if (provider === "claude" || spawnOptions?.engine === "claude") {
const formatted = formatClaudeLogEvent(line);
if (formatted !== null) {
broadcast({ type: "job:log", jobId: id, delta: formatted + '\n' });
@@ -397,8 +414,20 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) {
let stdinPrompt: string | undefined;
let spawnCwd: string | undefined;
let promptText: string | undefined;
let jobEngine: string | undefined;
let jobModel: string | undefined;
let jobEffort: string | undefined;
let jobReasoningEffort: string | undefined;
let jobFastMode: boolean | undefined;
if (options.buildCommand) {
const built = await options.buildCommand(provider);
// Thread config from POST body to buildCommand
const config: Record<string, unknown> = {};
if (typeof body.engine === "string") config.engine = body.engine;
if (typeof body.model === "string") config.model = body.model;
if (typeof body.reasoningEffort === "string") config.reasoningEffort = body.reasoningEffort;
if (typeof body.effort === "string") config.effort = body.effort;
if (body.fastMode === true) config.fastMode = true;
const built = await options.buildCommand(provider, Object.keys(config).length > 0 ? config : undefined);
if (built) {
command = built.command;
outputPath = built.outputPath;
@@ -407,6 +436,11 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) {
spawnCwd = built.cwd;
promptText = built.prompt;
if (built.label) label = built.label;
jobEngine = built.engine;
jobModel = built.model;
jobEffort = built.effort;
jobReasoningEffort = built.reasoningEffort;
jobFastMode = built.fastMode;
}
}
@@ -420,6 +454,11 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) {
stdinPrompt,
cwd: spawnCwd,
prompt: promptText,
engine: jobEngine,
model: jobModel,
effort: jobEffort,
reasoningEffort: jobReasoningEffort,
fastMode: jobFastMode,
});
json(res, { job }, 201);
} catch {
+72 -14
View File
@@ -71,6 +71,7 @@ import {
parseClaudeStreamOutput,
transformClaudeFindings,
} from "../generated/claude-review.js";
import { createTourSession, TOUR_EMPTY_OUTPUT_ERROR } from "../generated/tour-review.js";
/** Detect if running inside WSL (Windows Subsystem for Linux) */
function detectWSL(): boolean {
@@ -194,7 +195,6 @@ export async function startReviewServer(options: {
// Agent jobs — background process manager (late-binds serverUrl via getter)
let serverUrl = "";
// Worktree-aware cwd resolver — shared by getCwd, buildCommand, and onJobComplete
function resolveAgentCwd(): string {
if (options.agentCwd) return options.agentCwd;
if (currentDiffType.startsWith("worktree:")) {
@@ -203,32 +203,47 @@ export async function startReviewServer(options: {
}
return options.gitContext?.cwd ?? process.cwd();
}
const tour = createTourSession();
const agentJobs = createAgentJobHandler({
mode: "review",
getServerUrl: () => serverUrl,
getCwd: resolveAgentCwd,
async buildCommand(provider) {
async buildCommand(provider, config) {
const cwd = resolveAgentCwd();
const hasAgentLocalAccess = !!options.agentCwd || !!options.gitContext;
const userMessage = buildCodexReviewUserMessage(
currentPatch,
currentDiffType,
{ defaultBranch: options.gitContext?.defaultBranch, hasLocalAccess: hasAgentLocalAccess },
options.prMetadata,
);
const userMessageOptions = { defaultBranch: options.gitContext?.defaultBranch, hasLocalAccess: hasAgentLocalAccess };
if (provider === "tour") {
return tour.buildCommand({
cwd,
patch: currentPatch,
diffType: currentDiffType,
options: userMessageOptions,
prMetadata: options.prMetadata,
config,
});
}
const userMessage = buildCodexReviewUserMessage(currentPatch, currentDiffType, userMessageOptions, options.prMetadata);
if (provider === "codex") {
const model = typeof config?.model === "string" && config.model ? config.model : undefined;
const reasoningEffort = typeof config?.reasoningEffort === "string" && config.reasoningEffort ? config.reasoningEffort : undefined;
const fastMode = config?.fastMode === true;
const outputPath = generateOutputPath();
const prompt = CODEX_REVIEW_SYSTEM_PROMPT + "\n\n---\n\n" + userMessage;
const command = await buildCodexCommand({ cwd, outputPath, prompt });
return { command, outputPath, prompt, label: "Codex Review" };
const command = await buildCodexCommand({ cwd, outputPath, prompt, model, reasoningEffort, fastMode });
return { command, outputPath, prompt, label: "Code Review", model, reasoningEffort, fastMode: fastMode || undefined };
}
if (provider === "claude") {
const model = typeof config?.model === "string" && config.model ? config.model : undefined;
const effort = typeof config?.effort === "string" && config.effort ? config.effort : undefined;
const prompt = CLAUDE_REVIEW_PROMPT + "\n\n---\n\n" + userMessage;
const { command, stdinPrompt } = buildClaudeCommand(prompt);
return { command, stdinPrompt, prompt, cwd, label: "Claude Code Review", captureStdout: true };
const { command, stdinPrompt } = buildClaudeCommand(prompt, model, effort);
return { command, stdinPrompt, prompt, cwd, label: "Code Review", captureStdout: true, model, effort };
}
return null;
@@ -243,7 +258,7 @@ export async function startReviewServer(options: {
// Override verdict if there are blocking findings (P0/P1) — Codex's
// freeform correctness string can say "mostly correct" with real bugs.
const hasBlockingFindings = output.findings.some((f: any) => f.priority !== null && f.priority <= 1);
const hasBlockingFindings = output.findings.some(f => f.priority !== null && f.priority <= 1);
job.summary = {
correctness: hasBlockingFindings ? "Issues Found" : output.overall_correctness,
explanation: output.overall_explanation,
@@ -260,7 +275,10 @@ export async function startReviewServer(options: {
if (job.provider === "claude" && meta.stdout) {
const output = parseClaudeStreamOutput(meta.stdout);
if (!output) return;
if (!output) {
console.error(`[claude-review] Failed to parse output (${meta.stdout.length} bytes, last 200: ${meta.stdout.slice(-200)})`);
return;
}
const total = output.summary.important + output.summary.nit + output.summary.pre_existing;
job.summary = {
@@ -276,6 +294,20 @@ export async function startReviewServer(options: {
}
return;
}
if (job.provider === "tour") {
const { summary } = await tour.onJobComplete({ job, meta });
if (summary) {
job.summary = summary;
} else {
// The process exited 0 but the model returned empty or malformed output
// and nothing was stored. Flip status so the client doesn't auto-open
// a successful-looking card that 404s on /api/tour/:id.
job.status = "failed";
job.error = TOUR_EMPTY_OUTPUT_ERROR;
}
return;
}
},
});
const sharingEnabled =
@@ -415,6 +447,32 @@ export async function startReviewServer(options: {
const server = createServer(async (req, res) => {
const url = requestUrl(req);
// API: Get tour result
if (url.pathname.match(/^\/api\/tour\/[^/]+$/) && req.method === "GET") {
const jobId = url.pathname.slice("/api/tour/".length);
const result = tour.getTour(jobId);
if (!result) {
json(res, { error: "Tour not found" }, 404);
return;
}
json(res, result);
return;
}
// API: Save tour checklist state
const checklistMatch = url.pathname.match(/^\/api\/tour\/([^/]+)\/checklist$/);
if (checklistMatch && req.method === "PUT") {
const jobId = checklistMatch[1];
try {
const body = await parseBody(req) as { checked: boolean[] };
if (Array.isArray(body.checked)) tour.saveChecklist(jobId, body.checked);
json(res, { ok: true });
} catch {
json(res, { error: "Invalid JSON" }, 400);
}
return;
}
if (url.pathname === "/api/diff" && req.method === "GET") {
json(res, {
rawPatch: currentPatch,
+12 -1
View File
@@ -6,7 +6,7 @@ cd "$(dirname "$0")"
mkdir -p generated generated/ai/providers
for f in feedback-templates review-core storage draft project pr-provider pr-github pr-gitlab checklist integrations-common repo reference-common favicon resolve-file config external-annotation agent-jobs worktree html-to-markdown url-to-markdown; do
for f in feedback-templates review-core storage draft project pr-provider pr-github pr-gitlab checklist integrations-common repo reference-common favicon resolve-file config external-annotation agent-jobs worktree html-to-markdown url-to-markdown tour; do
src="../../packages/shared/$f.ts"
printf '// @generated — DO NOT EDIT. Source: packages/shared/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts"
done
@@ -21,6 +21,17 @@ for f in codex-review claude-review path-utils; do
> "generated/$f.ts"
done
# tour-review lives in packages/server/tour/ — parent-relative imports and the
# shared tour types package each map to the flat generated/ layout.
for f in tour-review; do
src="../../packages/server/tour/$f.ts"
printf '// @generated — DO NOT EDIT. Source: packages/server/tour/%s.ts\n' "$f" | cat - "$src" \
| sed 's|from "\.\./vcs"|from "./review-core.js"|' \
| sed 's|from "\.\./pr"|from "./pr-provider.js"|' \
| sed 's|from "@plannotator/shared/tour"|from "./tour.js"|' \
> "generated/$f.ts"
done
for f in index types provider session-manager endpoints context base-session; do
src="../../packages/ai/$f.ts"
printf '// @generated — DO NOT EDIT. Source: packages/ai/%s.ts\n' "$f" | cat - "$src" > "generated/ai/$f.ts"
+21 -3
View File
@@ -64,7 +64,7 @@
},
"apps/opencode-plugin": {
"name": "@plannotator/opencode",
"version": "0.17.10",
"version": "0.18.0",
"dependencies": {
"@opencode-ai/plugin": "^1.1.10",
},
@@ -86,7 +86,7 @@
},
"apps/pi-extension": {
"name": "@plannotator/pi-extension",
"version": "0.17.10",
"version": "0.18.0",
"dependencies": {
"@joplin/turndown-plugin-gfm": "^1.0.64",
"turndown": "^7.2.4",
@@ -174,7 +174,11 @@
"@pierre/diffs": "^1.1.12",
"@plannotator/shared": "workspace:*",
"@plannotator/ui": "workspace:*",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-tooltip": "^1.2.8",
"highlight.js": "^11.11.1",
"motion": "^12.38.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"tailwindcss": "^4.1.18",
@@ -182,7 +186,7 @@
},
"packages/server": {
"name": "@plannotator/server",
"version": "0.17.10",
"version": "0.18.0",
"dependencies": {
"@plannotator/ai": "workspace:*",
"@plannotator/shared": "workspace:*",
@@ -702,6 +706,10 @@
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
"@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="],
"@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="],
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
@@ -732,6 +740,8 @@
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="],
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
@@ -1574,6 +1584,8 @@
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"framer-motion": ["framer-motion@12.38.0", "", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="],
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
"fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
@@ -1990,6 +2002,12 @@
"mlly": ["mlly@1.8.0", "", { "dependencies": { "acorn": "^8.15.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.1" } }, "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g=="],
"motion": ["motion@12.38.0", "", { "dependencies": { "framer-motion": "^12.38.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w=="],
"motion-dom": ["motion-dom@12.38.0", "", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA=="],
"motion-utils": ["motion-utils@12.36.0", "", {}, "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="],
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+56
View File
@@ -59,6 +59,8 @@ import type { DiffFile } from './types';
import type { DiffOption, WorktreeInfo, GitContext } from '@plannotator/shared/types';
import type { PRMetadata } from '@plannotator/shared/pr-provider';
import { altKey } from '@plannotator/ui/utils/platform';
import { TourDialog } from './components/tour/TourDialog';
import { DEMO_TOUR_ID } from './demoTour';
declare const __APP_VERSION__: string;
@@ -224,6 +226,9 @@ const ReviewApp: React.FC = () => {
const { externalAnnotations, updateExternalAnnotation, deleteExternalAnnotation } = useExternalAnnotations<CodeAnnotation>({ enabled: !!origin });
const agentJobs = useAgentJobs({ enabled: !!origin });
// Tour dialog state — opens as an overlay instead of a dock panel
const [tourDialogJobId, setTourDialogJobId] = useState<string | null>(null);
// Dockview center panel API for the review workspace.
const [dockApi, setDockApi] = useState<DockviewApi | null>(null);
const filesRef = useRef(files);
@@ -547,6 +552,39 @@ const ReviewApp: React.FC = () => {
});
}, [dockApi, agentJobs.jobs]);
// Open tour as a dialog overlay
const handleOpenTour = useCallback((jobId: string) => {
setTourDialogJobId(jobId);
}, []);
// Dev-only: Cmd/Ctrl+Shift+T toggles the demo tour for fast UI iteration.
useEffect(() => {
if (!import.meta.env.DEV) return;
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.shiftKey && (e.key === 'T' || e.key === 't')) {
e.preventDefault();
setTourDialogJobId(prev => (prev === DEMO_TOUR_ID ? null : DEMO_TOUR_ID));
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []);
// Auto-open tour dialog when a tour job completes
const tourAutoOpenRef = useRef(new Set<string>());
useEffect(() => {
for (const job of agentJobs.jobs) {
if (
job.provider === 'tour' &&
job.status === 'done' &&
!tourAutoOpenRef.current.has(job.id)
) {
tourAutoOpenRef.current.add(job.id);
setTourDialogJobId(job.id);
}
}
}, [agentJobs.jobs]);
// Open PR panel as center dock panel
const handleOpenPRPanel = useCallback((type: 'summary' | 'comments' | 'checks') => {
const api = dockApi;
@@ -1016,6 +1054,7 @@ const ReviewApp: React.FC = () => {
fetchPRContext,
platformUser,
openDiffFile,
openTourPanel: handleOpenTour,
}), [
files, activeFileIndex, diffStyle, diffOverflow, diffIndicators,
diffLineDiffType, diffShowLineNumbers, diffShowBackground,
@@ -1030,6 +1069,7 @@ const ReviewApp: React.FC = () => {
handleAskAI, handleViewAIResponse, handleClickAIMarker,
aiHistoryForSelection, agentJobs.jobs, prMetadata, prContext,
isPRContextLoading, prContextError, fetchPRContext, platformUser, openDiffFile,
handleOpenTour,
]);
// Separate context for high-frequency job logs — prevents re-rendering all panels on every SSE event
@@ -2020,6 +2060,22 @@ const ReviewApp: React.FC = () => {
</div>
)}
</div>
{/* Tour dialog overlay */}
<TourDialog jobId={tourDialogJobId} onClose={() => setTourDialogJobId(null)} />
{/* Dev-only: open a fully-formed demo tour without running the agent.
Stripped from production builds via import.meta.env.DEV. */}
{import.meta.env.DEV && (
<button
onClick={() => setTourDialogJobId(tourDialogJobId === DEMO_TOUR_ID ? null : DEMO_TOUR_ID)}
title="Open the demo tour (dev only). Cmd+Shift+T also works."
className="fixed bottom-3 right-3 z-[60] px-2.5 py-1 rounded-md bg-foreground/80 text-background text-[10px] font-mono uppercase tracking-wider shadow-lg hover:bg-foreground transition-colors"
>
{tourDialogJobId === DEMO_TOUR_ID ? 'Close tour' : 'Demo tour'}
</button>
)}
</JobLogsProvider>
</ReviewStateProvider>
</ThemeProvider>
@@ -12,6 +12,43 @@ interface DiffHunkPreviewProps {
className?: string;
}
/**
* Build the unsafeCSS string for @pierre/diffs by reading computed CSS variables.
* Called synchronously so the first render is already themed (no flash on tooltip open).
*/
function buildPierreCSS(mode: 'dark' | 'light', fontFamily: string, fontSize: string): string {
try {
const styles = getComputedStyle(document.documentElement);
const bg = styles.getPropertyValue('--background').trim();
const fg = styles.getPropertyValue('--foreground').trim();
if (!bg || !fg) return '';
const fontCSS = (fontFamily || fontSize) ? `
pre, code, [data-line-content], [data-column-number] {
${fontFamily ? `font-family: '${fontFamily}', monospace !important;` : ''}
${fontSize ? `font-size: ${fontSize} !important; line-height: 1.5 !important;` : ''}
}` : '';
return `
:host, [data-diff], [data-file], [data-diffs-header], [data-error-wrapper], [data-virtualizer-buffer] {
--diffs-bg: ${bg} !important;
--diffs-fg: ${fg} !important;
--diffs-dark-bg: ${bg};
--diffs-light-bg: ${bg};
--diffs-dark: ${fg};
--diffs-light: ${fg};
}
pre, code { background-color: ${bg} !important; }
[data-column-number] { background-color: ${bg} !important; }
[data-file-info] { display: none !important; }
[data-diffs-header] { display: none !important; }
${fontCSS}
`;
} catch {
return '';
}
}
/**
* Renders a small inline diff hunk using @pierre/diffs.
* Compact, read-only, no file header. Shares theme + font settings
@@ -29,60 +66,46 @@ export const DiffHunkPreview: React.FC<DiffHunkPreviewProps> = ({
const fileDiff = useMemo(() => {
if (!hunk) return undefined;
try {
const needsHeaders = !hunk.startsWith('diff --git') && !hunk.startsWith('--- ');
const patch = needsHeaders
? `diff --git a/file b/file\n--- a/file\n+++ b/file\n${hunk}`
: hunk;
// Robustly handle all three hunk formats the tour agent might produce:
// 1. Full git diff: starts with "diff --git" — use as-is
// 2. File-level diff: starts with "--- " — prepend "diff --git" line only
// 3. Bare hunk: starts with "@@ " — prepend full synthetic headers
const patch = hunk.startsWith('diff --git')
? hunk
: hunk.startsWith('--- ')
? `diff --git a/file b/file\n${hunk}`
: `diff --git a/file b/file\n--- a/file\n+++ b/file\n${hunk}`;
return getSingularPatch(patch);
} catch {
return undefined;
}
}, [hunk]);
// Theme injection — same pattern as DiffViewer (reads computed CSS vars, injects via unsafeCSS)
const [pierreTheme, setPierreTheme] = useState<{ type: 'dark' | 'light'; css: string }>({
type: resolvedMode,
css: '',
});
// Initialize synchronously so the very first render (inside a tooltip) is already themed.
// The lazy initializer reads computed CSS variables from the document root.
const [pierreTheme, setPierreTheme] = useState<{ type: 'dark' | 'light'; css: string }>(() => ({
type: resolvedMode ?? 'dark',
css: buildPierreCSS(resolvedMode ?? 'dark', state.fontFamily, state.fontSize),
}));
// Re-compute on theme / font changes
useEffect(() => {
const rafId = requestAnimationFrame(() => {
const styles = getComputedStyle(document.documentElement);
const bg = styles.getPropertyValue('--background').trim();
const fg = styles.getPropertyValue('--foreground').trim();
if (!bg || !fg) return;
const fontFamily = state.fontFamily;
const fontSize = state.fontSize;
const fontCSS = fontFamily || fontSize ? `
pre, code, [data-line-content], [data-column-number] {
${fontFamily ? `font-family: '${fontFamily}', monospace !important;` : ''}
${fontSize ? `font-size: ${fontSize} !important; line-height: 1.5 !important;` : ''}
}` : '';
setPierreTheme({
type: resolvedMode,
css: `
:host, [data-diff], [data-file], [data-diffs-header], [data-error-wrapper], [data-virtualizer-buffer] {
--diffs-bg: ${bg} !important;
--diffs-fg: ${fg} !important;
--diffs-dark-bg: ${bg};
--diffs-light-bg: ${bg};
--diffs-dark: ${fg};
--diffs-light: ${fg};
}
pre, code { background-color: ${bg} !important; }
[data-column-number] { background-color: ${bg} !important; }
[data-file-info] { display: none !important; }
[data-diffs-header] { display: none !important; }
${fontCSS}
`,
type: resolvedMode ?? 'dark',
css: buildPierreCSS(resolvedMode ?? 'dark', state.fontFamily, state.fontSize),
});
});
return () => cancelAnimationFrame(rafId);
}, [resolvedMode, state.fontFamily, state.fontSize]);
if (!fileDiff) return null;
if (!fileDiff) {
return (
<div className="px-3 py-2 text-[11px] text-muted-foreground/30 italic">
Diff not available
</div>
);
}
return (
<div className={`rounded overflow-hidden border border-border/20 ${className ?? ''}`}>
@@ -97,8 +120,6 @@ export const DiffHunkPreview: React.FC<DiffHunkPreviewProps> = ({
unsafeCSS: pierreTheme.css,
diffStyle: 'unified',
disableLineNumbers: true,
disableFileHeader: true,
disableBackground: true,
overflow: 'wrap',
}}
/>
@@ -55,7 +55,7 @@ interface ReviewSidebarProps {
// Agent props
agentJobs?: AgentJobInfo[];
agentCapabilities?: AgentCapabilities | null;
onAgentLaunch?: (params: { provider?: string; command?: string[]; label?: string }) => void;
onAgentLaunch?: (params: { provider?: string; command?: string[]; label?: string; engine?: string; model?: string; reasoningEffort?: string; effort?: string; fastMode?: boolean }) => void;
onAgentKillJob?: (id: string) => void;
onAgentKillAll?: () => void;
externalAnnotations?: Array<{ source?: string }>;
@@ -0,0 +1,89 @@
import React from 'react';
import { motion } from 'motion/react';
import * as Checkbox from '@radix-ui/react-checkbox';
import type { TourQAItem, TourStop } from '../../hooks/tour/useTourData';
interface QAChecklistProps {
items: TourQAItem[];
stops: TourStop[];
checked: boolean[];
onToggle: (index: number) => void;
onScrollToStop?: (index: number) => void;
}
export const QAChecklist: React.FC<QAChecklistProps> = ({
items,
checked,
onToggle,
onScrollToStop,
}) => {
return (
<motion.div
className="space-y-0.5"
initial="hidden"
animate="visible"
variants={{
hidden: {},
visible: { transition: { staggerChildren: 0.04, delayChildren: 0.05 } },
}}
>
{items.map((item, i) => (
<motion.label
key={i}
className="flex items-start gap-3 py-2 px-1 rounded-md hover:bg-muted/20 transition-colors cursor-pointer"
variants={{
hidden: { opacity: 0, y: 4 },
visible: { opacity: 1, y: 0, transition: { type: 'spring', stiffness: 220, damping: 24 } },
}}
>
<Checkbox.Root
checked={checked[i] ?? false}
onCheckedChange={() => onToggle(i)}
className="tour-checkbox mt-0.5 w-[18px] h-[18px] rounded-[4px] flex-shrink-0 border border-foreground/25 dark:border-border/50 bg-muted/40 dark:bg-background shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)] dark:shadow-none data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=checked]:shadow-none flex items-center justify-center"
>
<Checkbox.Indicator>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path
d="M2.5 6L5 8.5L9.5 3.5"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</Checkbox.Indicator>
</Checkbox.Root>
<div className="flex-1 min-w-0">
<span
className={`text-sm leading-relaxed transition-[opacity,text-decoration] duration-150 ${
checked[i] ? 'line-through opacity-50 text-foreground/50' : 'text-foreground'
}`}
>
{item.question}
</span>
{item.stop_indices.length > 0 && (
<span className="ml-2 text-[10px] text-muted-foreground/50">
{item.stop_indices.map((si, j) => (
<React.Fragment key={si}>
{j > 0 && ', '}
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onScrollToStop?.(si);
}}
className="hover:text-primary transition-colors"
>
Stop {si + 1}
</button>
</React.Fragment>
))}
</span>
)}
</div>
</motion.label>
))}
</motion.div>
);
};
@@ -0,0 +1,561 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { motion, MotionConfig, type Variants } from 'motion/react';
import { OverlayScrollArea } from '@plannotator/ui/components/OverlayScrollArea';
import { useTourData } from '../../hooks/tour/useTourData';
import { useReviewState } from '../../dock/ReviewStateContext';
import { TourStopCard } from './TourStopCard';
import { QAChecklist } from './QAChecklist';
import type { TourKeyTakeaway } from '../../hooks/tour/useTourData';
const prefersReducedMotion = () =>
typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
// ---------------------------------------------------------------------------
// Intro composition cascade — each piece springs into place after the dialog
// card lands, so the landing page composes itself rather than appearing all
// at once. Spring physics give natural settle, no cartoony bounce.
// ---------------------------------------------------------------------------
const introContainerVariants: Variants = {
hidden: {},
visible: { transition: { staggerChildren: 0.06, delayChildren: 0.12 } },
};
const introItemVariants: Variants = {
hidden: { opacity: 0, y: 8 },
visible: {
opacity: 1,
y: 0,
transition: { type: 'spring', stiffness: 220, damping: 24 },
},
};
const takeawayListVariants: Variants = {
hidden: {},
visible: { transition: { staggerChildren: 0.04, delayChildren: 0.05 } },
};
const takeawayRowVariants: Variants = {
hidden: { opacity: 0, y: 4 },
visible: {
opacity: 1,
y: 0,
transition: { type: 'spring', stiffness: 240, damping: 26 },
},
};
const startButtonVariants: Variants = {
hidden: { opacity: 0, scale: 0.96, y: 6 },
visible: {
opacity: 1,
scale: 1,
y: 0,
transition: { type: 'spring', stiffness: 260, damping: 18 },
},
};
// One-shot "spark of life" — color dot pulses once when its card lands.
const dotVariants: Variants = {
hidden: { scale: 1 },
visible: {
scale: [1, 1.35, 1],
transition: { duration: 0.45, times: [0, 0.5, 1], ease: 'easeOut' },
},
};
// ---------------------------------------------------------------------------
// Key takeaways table
// ---------------------------------------------------------------------------
const severityLabel: Record<TourKeyTakeaway['severity'], string> = {
info: 'Info',
important: 'Important',
warning: 'Warning',
};
const severityLabelClass: Record<TourKeyTakeaway['severity'], string> = {
info: 'text-muted-foreground/70',
important: 'text-primary/80 dark:text-primary',
warning: 'text-warning/80 dark:text-warning',
};
const severityRowClass: Record<TourKeyTakeaway['severity'], string> = {
info: '',
important: 'bg-primary/[0.03] dark:bg-primary/[0.10]',
warning: 'bg-warning/[0.03] dark:bg-warning/[0.10]',
};
function TakeawaysTable({ takeaways }: { takeaways: TourKeyTakeaway[] }) {
if (takeaways.length === 0) return null;
return (
<motion.div
className="mb-6 border border-border/20 dark:border-border/40 rounded-lg overflow-hidden text-[13px]"
variants={takeawayListVariants}
>
<div className="px-4 py-2 border-b border-border/15 dark:border-border/30 bg-muted/10 dark:bg-muted/30">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/50">
Key takeaways
</span>
</div>
{takeaways.map((t, i) => (
<motion.div
key={i}
variants={takeawayRowVariants}
className={`flex items-start gap-4 px-4 py-2.5 ${severityRowClass[t.severity]} ${
i > 0 ? 'border-t border-border/10 dark:border-border/30' : ''
}`}
>
<span className={`flex-shrink-0 w-24 text-[10px] font-semibold uppercase tracking-wider pt-1 ${severityLabelClass[t.severity]}`}>
{severityLabel[t.severity]}
</span>
<span className="text-foreground leading-relaxed">{t.text}</span>
</motion.div>
))}
</motion.div>
);
}
// ---------------------------------------------------------------------------
// Page types + animation helpers
// ---------------------------------------------------------------------------
type TourPage = 'intro' | 'stops' | 'checklist';
const PAGE_ORDER: TourPage[] = ['intro', 'stops', 'checklist'];
const PAGE_LABELS: Record<TourPage, string> = {
intro: 'Overview',
stops: 'Walkthrough',
checklist: 'Checklist',
};
function getPageAnimClass(
thisPage: TourPage,
currentPage: TourPage,
exitingPage: TourPage | null,
slideDir: 'fwd' | 'bwd',
): string {
if (exitingPage === thisPage) {
if (thisPage === 'intro') return 'tour-intro-exit';
return slideDir === 'fwd' ? 'tour-page-exit-fwd' : 'tour-page-exit-bwd';
}
if (currentPage === thisPage && exitingPage !== null) {
if (thisPage === 'stops' && exitingPage === 'intro') return '';
return slideDir === 'fwd' ? 'tour-page-enter-fwd' : 'tour-page-enter-bwd';
}
return '';
}
// ---------------------------------------------------------------------------
// Dialog wrapper
// ---------------------------------------------------------------------------
interface TourDialogProps {
jobId: string | null;
onClose: () => void;
}
export const TourDialog: React.FC<TourDialogProps> = ({ jobId, onClose }) => {
// Keep the last known jobId mounted while we play the exit animation
const [renderedJobId, setRenderedJobId] = useState<string | null>(jobId);
const [closing, setClosing] = useState(false);
useEffect(() => {
if (jobId) {
setRenderedJobId(jobId);
setClosing(false);
} else if (renderedJobId) {
// If reduced motion is on, the exit animation is suppressed and
// onAnimationEnd will never fire — unmount immediately instead.
if (prefersReducedMotion()) {
setRenderedJobId(null);
setClosing(false);
} else {
setClosing(true);
}
}
}, [jobId, renderedJobId]);
const requestClose = useCallback(() => { onClose(); }, [onClose]);
// Escape to close
useEffect(() => {
if (!renderedJobId || closing) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') { e.stopPropagation(); requestClose(); }
};
window.addEventListener('keydown', handler, true);
return () => window.removeEventListener('keydown', handler, true);
}, [renderedJobId, closing, requestClose]);
const handleExitEnd = useCallback((e: React.AnimationEvent<HTMLDivElement>) => {
if (e.target !== e.currentTarget) return;
setRenderedJobId(null);
setClosing(false);
}, []);
if (!renderedJobId) return null;
return (
<MotionConfig reducedMotion="user">
<div className="fixed inset-0 z-50 flex items-center justify-center px-4 py-2 md:px-8 md:py-4" onClick={requestClose}>
{/* Backdrop */}
<div className={`absolute inset-0 bg-black/50 backdrop-blur-[2px] ${closing ? 'tour-dialog-overlay-closing' : 'tour-dialog-overlay'}`} />
{/* Dialog card */}
<div
className={`relative z-10 w-full max-w-6xl h-full max-h-[96vh] bg-background rounded-2xl shadow-2xl border border-border/15 overflow-hidden flex flex-col ${closing ? 'tour-dialog-content-closing' : 'tour-dialog-content'}`}
onClick={(e) => e.stopPropagation()}
onAnimationEnd={closing ? handleExitEnd : undefined}
>
<TourDialogContent jobId={renderedJobId} onClose={requestClose} />
</div>
</div>
</MotionConfig>
);
};
// ---------------------------------------------------------------------------
// Dialog content — 3 navigable pages
// ---------------------------------------------------------------------------
function TourDialogContent({ jobId, onClose }: { jobId: string; onClose: () => void }) {
const state = useReviewState();
const { tour, loading, error, checked, toggleChecked, retry } = useTourData(jobId);
const stopsRef = useRef<(HTMLDivElement | null)[]>([]);
const stopsSeenRef = useRef(false);
const walkthroughAutoOpenTriggered = useRef(false);
const [openStops, setOpenStops] = useState<Set<number>>(() => new Set());
// Single-open accordion: opening a new stop closes any other that was open.
// Clicking the currently-open stop closes it.
const toggleStop = useCallback((i: number) => {
setOpenStops((prev) => {
if (prev.has(i)) return new Set();
return new Set([i]);
});
}, []);
const [page, setPage] = useState<TourPage>('intro');
const [exitingPage, setExitingPage] = useState<TourPage | null>(null);
const [slideDir, setSlideDir] = useState<'fwd' | 'bwd'>('fwd');
const navigate = useCallback((next: TourPage) => {
if (exitingPage || next === page) return;
// Reduced motion suppresses the page animations, so onAnimationEnd
// never fires to clear exitingPage — swap immediately instead.
if (prefersReducedMotion()) {
setPage(next);
return;
}
const fromIdx = PAGE_ORDER.indexOf(page);
const toIdx = PAGE_ORDER.indexOf(next);
setExitingPage(page);
setSlideDir(toIdx > fromIdx ? 'fwd' : 'bwd');
setPage(next);
}, [page, exitingPage]);
const handleSlideEnd = useCallback((exiting: TourPage) => {
setExitingPage((prev) => (prev === exiting ? null : prev));
}, []);
const handleAnchorClick = useCallback((filePath: string) => {
state.openDiffFile(filePath);
onClose();
}, [state.openDiffFile, onClose]);
const handleScrollToStop = useCallback((index: number) => {
const el = stopsRef.current[index];
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, []);
// Mark each page as "seen" after first mount. Gated on `tour` so it doesn't
// flip the refs during the loading render (which would strip the first-time
// animation classes before the page ever rendered).
useEffect(() => {
if (!tour) return;
if (page === 'stops' || exitingPage === 'stops') stopsSeenRef.current = true;
}, [page, exitingPage, tour]);
// First-visit auto-open: when the user lands on the walkthrough page for the
// first time, wait for the stops cascade to finish + a brief breath, then
// pop the first accordion. The card itself plays its motion accordion
// animation in response, so this slots into the natural reveal beat.
useEffect(() => {
if (!tour) return;
if (page !== 'stops') return;
if (walkthroughAutoOpenTriggered.current) return;
walkthroughAutoOpenTriggered.current = true;
const stopRevealMs = 280; // .tour-stop-reveal duration
const stopStaggerMs = 60; // delay between stops
const lastStopFinishesAt = stopRevealMs + (tour.stops.length - 1) * stopStaggerMs;
const breath = 500;
const t = setTimeout(() => setOpenStops(new Set([0])), lastStopFinishesAt + breath);
return () => clearTimeout(t);
}, [page, tour]);
// All hooks must run before the early returns below (rules of hooks).
// `tour` may still be null during loading, so access it safely — the
// filter strips the empty values out anyway.
const introCards = useMemo(
() =>
[
{ label: 'Intent', value: tour?.intent, dot: 'bg-primary/70' },
{ label: 'Before', value: tour?.before, dot: 'bg-warning/70' },
{ label: 'After', value: tour?.after, dot: 'bg-success/70' },
].filter((card) => card.value && card.value.trim()),
[tour?.intent, tour?.before, tour?.after],
);
// Loading
if (loading) {
return (
<>
<div className="flex-shrink-0 flex items-center gap-3 px-6 py-4 border-b border-border/20">
<div className="flex-1">
<div className="h-4 w-56 bg-muted/30 animate-pulse rounded" />
<div className="h-3 w-20 bg-muted/20 animate-pulse rounded mt-1.5" />
</div>
</div>
<div className="flex-1 px-8 py-6 space-y-4">
<div className="h-4 w-72 bg-muted/20 animate-pulse rounded" />
<div className="grid grid-cols-2 gap-3">
<div className="h-16 bg-muted/15 animate-pulse rounded-lg" />
<div className="h-16 bg-muted/15 animate-pulse rounded-lg" />
</div>
<div className="h-24 bg-muted/15 animate-pulse rounded-lg" />
</div>
</>
);
}
// Error
if (error || !tour) {
return (
<div className="flex-1 flex items-center justify-center">
<div className="text-center px-8">
<p className="text-sm text-muted-foreground">{error ?? 'Tour not found'}</p>
<button onClick={retry} className="mt-3 text-xs text-primary hover:text-primary/80 transition-colors">Retry</button>
</div>
</div>
);
}
const verifiedCount = checked.filter(Boolean).length;
const checklistCount = tour.qa_checklist.length;
const isActive = (p: TourPage) => page === p && !exitingPage;
// Mark pages as "seen" the first time we mount them so we don't re-animate
// the section heading + staggered cards on every back-and-forth navigation.
const stopsAlreadySeen = stopsSeenRef.current;
return (
<>
{/* Title bar + close */}
<div className="flex-shrink-0 flex items-center gap-3 px-5 py-1.5 border-b border-border/20 dark:border-border/40">
<h2 className="text-sm font-semibold tracking-tight text-foreground truncate flex-1 min-w-0">
{tour.title}
</h2>
<button
onClick={onClose}
className="flex-shrink-0 w-6 h-6 flex items-center justify-center rounded-md text-muted-foreground/50 hover:text-foreground/70 hover:bg-muted/20 transition-colors"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M3 3l8 8M11 3l-8 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</button>
</div>
{/* Page nav tabs */}
<div className="flex-shrink-0 flex items-center gap-1 px-5 py-1.5 border-b border-border/10 dark:border-border/30">
{PAGE_ORDER.map((p) => (
<button
key={p}
onClick={() => navigate(p)}
disabled={isActive(p)}
className={`text-[11px] font-medium px-3 py-1.5 rounded-md transition-colors ${
isActive(p)
? 'text-foreground bg-muted/30 dark:bg-muted/60 cursor-default'
: 'text-muted-foreground/60 dark:text-muted-foreground/70 hover:text-foreground/80 hover:bg-muted/10 dark:hover:bg-muted/30'
}`}
>
{PAGE_LABELS[p]}
{p === 'checklist' && checklistCount > 0 && (
<span className="ml-1.5 text-[9px] font-mono text-muted-foreground/50">
{verifiedCount}/{checklistCount}
</span>
)}
</button>
))}
</div>
{/* Page container */}
<div className="flex-1 relative overflow-hidden">
{/* ── OVERVIEW ── */}
{(page === 'intro' || exitingPage === 'intro') && (
<div
className={`absolute inset-0 z-10 ${getPageAnimClass('intro', page, exitingPage, slideDir)}`}
onAnimationEnd={exitingPage === 'intro'
? (e: React.AnimationEvent) => { if (e.target === e.currentTarget) handleSlideEnd('intro'); }
: undefined}
>
<motion.div
className="h-full flex flex-col"
initial="hidden"
animate="visible"
variants={introContainerVariants}
>
{/* Scrollable content area with a bottom fade so content disappears
cleanly under the pinned Start Tour button. */}
<div className="relative flex-1 min-h-0">
<OverlayScrollArea className="h-full">
<div className="px-8 py-6 pb-12 max-w-4xl mx-auto">
{tour.greeting && (
<motion.p
variants={introItemVariants}
className="text-sm text-foreground leading-relaxed mb-5"
>
{tour.greeting}
</motion.p>
)}
{/* Intent · Before · After trilogy — each card lands in turn,
its color dot doing a single "spark of life" pulse on land. */}
<motion.div
variants={introContainerVariants}
className="grid grid-cols-3 gap-3 mb-5 text-[13px]"
>
{introCards.map((card) => (
<motion.div
key={card.label}
variants={introItemVariants}
className="rounded-md bg-muted/30 dark:bg-muted/60 border border-border/60 px-4 py-3 shadow-[0_1px_2px_rgba(0,0,0,0.04),0_1px_3px_rgba(0,0,0,0.03)] dark:shadow-none"
>
<div className="flex items-center gap-1.5 mb-1.5">
<motion.span
variants={dotVariants}
className={`w-1.5 h-1.5 rounded-full ${card.dot}`}
/>
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/70">
{card.label}
</span>
</div>
<span className="text-foreground leading-relaxed">{card.value}</span>
</motion.div>
))}
</motion.div>
<TakeawaysTable takeaways={tour.key_takeaways} />
</div>
</OverlayScrollArea>
{/* Bottom fade — content gracefully disappears under the pinned button */}
<div
className="pointer-events-none absolute bottom-0 left-0 right-0 h-12 bg-background backdrop-blur-xl"
style={{
WebkitMaskImage: 'linear-gradient(to top, white, transparent)',
maskImage: 'linear-gradient(to top, white, transparent)',
}}
/>
</div>
{/* Pinned Start Tour button — always visible in the viewport */}
{page === 'intro' && (
<div className="flex-shrink-0 px-8 pb-5 pt-1 max-w-4xl mx-auto w-full">
<motion.button
variants={startButtonVariants}
onClick={() => navigate('stops')}
className="group w-full flex items-center justify-center gap-2 px-6 py-3.5 rounded-lg bg-primary/85 text-primary-foreground hover:bg-primary text-sm font-medium shadow-sm hover:shadow-md active:scale-[0.98] transition-[background-color,box-shadow,transform] duration-150 ease-out"
>
Start Tour
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"
className="transition-transform duration-150 group-hover:translate-x-0.5">
<path d="M3 8h10M9 4l4 4-4 4" stroke="currentColor" strokeWidth="1.5"
strokeLinecap="round" strokeLinejoin="round" />
</svg>
</motion.button>
</div>
)}
</motion.div>
</div>
)}
{/* ── WALKTHROUGH — deferred until intro exit completes so staggered reveals don't flash behind it ── */}
{(page === 'stops' || exitingPage === 'stops') && exitingPage !== 'intro' && (
<div
className={`absolute inset-0 ${getPageAnimClass('stops', page, exitingPage, slideDir)}`}
onAnimationEnd={exitingPage === 'stops'
? (e: React.AnimationEvent) => { if (e.target === e.currentTarget) handleSlideEnd('stops'); }
: undefined}
>
<OverlayScrollArea className="h-full">
<div className="px-8 py-6 pb-16 max-w-4xl mx-auto">
<div className="relative border-l border-border/15 dark:border-border/35 ml-2.5">
{tour.stops.map((stop, i) => (
<div
key={i}
ref={(el) => { stopsRef.current[i] = el; }}
className={stopsAlreadySeen ? '' : 'tour-stop-reveal'}
style={stopsAlreadySeen ? undefined : { animationDelay: `${i * 60}ms` }}
>
<TourStopCard
stop={stop}
index={i}
total={tour.stops.length}
onAnchorClick={handleAnchorClick}
open={openStops.has(i)}
onToggle={() => toggleStop(i)}
dimmed={openStops.size > 0 && !openStops.has(i)}
/>
</div>
))}
</div>
<div className="mt-8 pt-4 border-t border-border/15 dark:border-border/35 text-[10px] text-muted-foreground/50 flex items-center justify-between">
<span>{tour.stops.length} stop{tour.stops.length !== 1 ? 's' : ''}</span>
{checklistCount > 0 && (
<button onClick={() => navigate('checklist')}
className="text-muted-foreground/50 hover:text-primary/60 transition-colors">
Checklist →
</button>
)}
</div>
</div>
</OverlayScrollArea>
</div>
)}
{/* ── CHECKLIST — also deferred past intro exit ── */}
{(page === 'checklist' || exitingPage === 'checklist') && exitingPage !== 'intro' && (
<div
className={`absolute inset-0 ${getPageAnimClass('checklist', page, exitingPage, slideDir)}`}
onAnimationEnd={exitingPage === 'checklist'
? (e: React.AnimationEvent) => { if (e.target === e.currentTarget) handleSlideEnd('checklist'); }
: undefined}
>
<OverlayScrollArea className="h-full">
<div className="px-8 py-6 pb-16 max-w-4xl mx-auto">
{checklistCount > 0 ? (
<QAChecklist
items={tour.qa_checklist}
stops={tour.stops}
checked={checked}
onToggle={toggleChecked}
onScrollToStop={(i) => {
navigate('stops');
setTimeout(() => handleScrollToStop(i), 280);
}}
/>
) : (
<p className="text-sm text-muted-foreground/50">No checklist items for this tour.</p>
)}
</div>
</OverlayScrollArea>
</div>
)}
</div>
</>
);
}
@@ -0,0 +1,291 @@
import React, { useRef } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import type { TourStop, TourDiffAnchor } from '../../hooks/tour/useTourData';
import { DiffHunkPreview } from '../DiffHunkPreview';
import { renderInlineMarkdown } from '../../utils/renderInlineMarkdown';
function renderDetail(text: string): React.ReactNode[] {
if (!text) return [];
const nodes: React.ReactNode[] = [];
const lines = text.split('\n');
let i = 0;
let key = 0;
while (i < lines.length) {
const line = lines[i];
if (!line.trim()) {
i++;
continue;
}
// Callout block: > [!IMPORTANT] / > [!NOTE] / > [!WARNING]
if (line.match(/^>\s*\[!(IMPORTANT|NOTE|WARNING)\]/i)) {
const type = line.match(/WARNING/i) ? 'warning' : line.match(/IMPORTANT/i) ? 'important' : 'note';
const calloutLines: string[] = [];
let j = i + 1;
while (j < lines.length && lines[j].startsWith('>')) {
calloutLines.push(lines[j].replace(/^>\s?/, ''));
j++;
}
const calloutStyles = {
important: 'bg-primary/[0.05] dark:bg-primary/[0.12] text-foreground',
warning: 'bg-warning/[0.05] dark:bg-warning/[0.12] text-foreground',
note: 'bg-muted/20 dark:bg-muted/40 text-foreground',
};
const calloutLabel = { important: 'Important', warning: 'Warning', note: 'Note' };
nodes.push(
<div key={key++} className={`my-2 px-3 py-2 rounded text-[12px] ${calloutStyles[type]}`}>
<span className="font-semibold text-[10px] uppercase tracking-wider block mb-0.5 opacity-70">
{calloutLabel[type]}
</span>
<span className="leading-relaxed">{renderInlineMarkdown(calloutLines.join(' '))}</span>
</div>
);
i = j;
continue;
}
// Heading h3
if (line.startsWith('### ')) {
nodes.push(
<h3 key={key++} className="text-[12px] font-semibold text-foreground mt-3 mb-1">
{line.slice(4)}
</h3>
);
i++;
continue;
}
// Bullet list
if (line.match(/^[-*] /)) {
const bullets: string[] = [line.slice(2)];
let j = i + 1;
while (j < lines.length && lines[j].match(/^[-*] /)) {
bullets.push(lines[j].slice(2));
j++;
}
nodes.push(
<ul key={key++} className="my-1.5 space-y-0.5 pl-4">
{bullets.map((b, bi) => (
<li key={bi} className="text-[13px] text-foreground list-disc leading-relaxed">
{renderInlineMarkdown(b)}
</li>
))}
</ul>
);
i = j;
continue;
}
// Paragraph — collect until blank line or block element
const paraLines: string[] = [line];
let j = i + 1;
while (
j < lines.length &&
lines[j].trim() &&
!lines[j].startsWith('### ') &&
!lines[j].match(/^[-*] /) &&
!lines[j].match(/^>\s*\[!/)
) {
paraLines.push(lines[j]);
j++;
}
nodes.push(
<p key={key++} className="text-[13px] text-foreground leading-relaxed">
{renderInlineMarkdown(paraLines.join(' '))}
</p>
);
i = j;
}
return nodes;
}
// ---------------------------------------------------------------------------
// Inline anchor block
// ---------------------------------------------------------------------------
function AnchorBlock({
anchor,
onClick,
parentOpen,
}: {
anchor: TourDiffAnchor;
onClick: () => void;
/** Parent accordion state — DiffHunkPreview is deferred until first open. */
parentOpen: boolean;
}) {
// Lazy-mount: only create the heavy FileDiff web component on first expand.
// The ref flips synchronously during render so the grid sees the correct height.
const mountedRef = useRef(false);
if (parentOpen) mountedRef.current = true;
return (
<div className="border border-border/20 dark:border-border/40 rounded-lg overflow-hidden">
{/* File header */}
<div className="flex items-center gap-2 px-3 py-2 bg-muted/20 dark:bg-muted/40 border-b border-border/15 dark:border-border/30">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" className="text-muted-foreground/50 flex-shrink-0">
<path d="M3 1.5h6.5L13 5v9.5a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-13a1 1 0 0 1 1-1Z" stroke="currentColor" strokeWidth="1.2" />
<path d="M9.5 1.5V5H13" stroke="currentColor" strokeWidth="1.2" />
</svg>
<span className="text-[11px] font-mono text-muted-foreground truncate flex-1">
{anchor.file}
</span>
<span className="text-[10px] text-muted-foreground/50 whitespace-nowrap flex-shrink-0">
L{anchor.line}–{anchor.end_line}
</span>
<button
onClick={(e) => { e.stopPropagation(); onClick(); }}
className="flex-shrink-0 text-[10px] text-primary/60 hover:text-primary transition-colors ml-1 font-medium"
>
Open ↗
</button>
</div>
{/* Label */}
{anchor.label && (
<div className="px-3 py-2 text-[12px] text-foreground border-b border-border/10 dark:border-border/30 bg-muted/5 dark:bg-muted/20">
{anchor.label}
</div>
)}
{/* Diff preview — deferred until parent accordion first opens to avoid
mounting all FileDiff web components on page load (causes jank) */}
{mountedRef.current && (
<DiffHunkPreview hunk={anchor.hunk} maxHeight={220} className="!border-0 !rounded-none" />
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Stop card
// ---------------------------------------------------------------------------
interface TourStopCardProps {
stop: TourStop;
index: number;
total: number;
onAnchorClick: (filePath: string) => void;
open: boolean;
onToggle: () => void;
/** True when another stop is open and this one isn't — fades to defocus. */
dimmed?: boolean;
}
export const TourStopCard: React.FC<TourStopCardProps> = ({
stop,
index,
total,
onAnchorClick,
open,
onToggle,
dimmed = false,
}) => {
const isLast = index === total - 1;
return (
<div
className={`relative pl-8 pb-6 transition-opacity duration-200 ease-out ${
dimmed ? 'opacity-55' : 'opacity-100'
}`}
>
{/* Timeline node */}
<div className={`absolute left-0 top-1 -translate-x-1/2 w-5 h-5 rounded-full flex items-center justify-center border-2 border-background z-10 text-[9px] font-mono font-semibold transition-colors duration-200 ${
open
? 'bg-primary text-primary-foreground'
: 'bg-primary/10 dark:bg-primary/25 text-primary/70 dark:text-primary'
}`}>
{index + 1}
</div>
{/* Trigger */}
<button className="w-full text-left group cursor-pointer" onClick={onToggle}>
<div className="flex items-start gap-2">
<div className="flex-1 min-w-0">
<span className="text-sm font-semibold text-foreground group-hover:text-primary transition-colors duration-150">
{stop.title}
</span>
<p className="text-[13px] text-foreground leading-relaxed mt-0.5">
{stop.gist}
</p>
</div>
<svg
width="14"
height="14"
viewBox="0 0 14 14"
className={`flex-shrink-0 mt-1 text-muted-foreground/50 transition-transform duration-[250ms] ease-[cubic-bezier(0.16,1,0.3,1)] ${open ? 'rotate-180' : ''}`}
>
<path d="M3.5 5.5L7 9L10.5 5.5" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</div>
</button>
{/* Spring-animated accordion: height + opacity, with staggered child entrance */}
<AnimatePresence initial={false}>
{open && (
<motion.div
key="content"
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{
height: { type: 'spring', stiffness: 180, damping: 24, mass: 0.7 },
opacity: { duration: 0.18, ease: 'easeOut' },
}}
style={{ overflow: 'hidden' }}
>
<motion.div
className="pt-3 space-y-2"
initial="hidden"
animate="visible"
exit="hidden"
variants={{
hidden: { transition: { staggerChildren: 0.04, staggerDirection: -1 } },
visible: { transition: { staggerChildren: 0.06, delayChildren: 0.05 } },
}}
>
<motion.div
className="space-y-2"
variants={{
hidden: { opacity: 0, y: -4 },
visible: { opacity: 1, y: 0, transition: { type: 'spring', stiffness: 200, damping: 22 } },
}}
>
{renderDetail(stop.detail)}
</motion.div>
{stop.anchors.length > 0 && (
<motion.div className="space-y-3 mt-3" variants={{ hidden: {}, visible: {} }}>
{stop.anchors.map((anchor, i) => (
<motion.div
key={`${anchor.file}:${anchor.line}-${i}`}
variants={{
hidden: { opacity: 0, y: 8 },
visible: { opacity: 1, y: 0, transition: { type: 'spring', stiffness: 200, damping: 22 } },
}}
>
<AnchorBlock
anchor={anchor}
onClick={() => onAnchorClick(anchor.file)}
parentOpen={open}
/>
</motion.div>
))}
</motion.div>
)}
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* Transition phrase */}
{!isLast && stop.transition && (
<p className="text-[11px] text-muted-foreground/50 italic mt-4">
{stop.transition}
</p>
)}
</div>
);
};
+198
View File
@@ -0,0 +1,198 @@
/**
* Demo tour data for development mode.
*
* Loaded by useTourData when jobId === DEMO_TOUR_ID, so the dialog renders
* with realistic content without needing an agent run. Toggle via the dev-only
* floating button in App.tsx (only shown when import.meta.env.DEV is true).
*/
import type { CodeTourData } from './hooks/tour/useTourData';
export const DEMO_TOUR_ID = 'demo-tour';
const buttonHunk = `@@ -1,15 +1,22 @@
-import React from 'react';
+import React, { useCallback } from 'react';
interface ButtonProps {
label: string;
onClick: () => void;
+ disabled?: boolean;
+ variant?: 'primary' | 'secondary';
}
-export const Button = ({ label, onClick }: ButtonProps) => {
+export const Button = ({ label, onClick, disabled, variant = 'primary' }: ButtonProps) => {
+ const handleClick = useCallback(() => {
+ if (!disabled) onClick();
+ }, [disabled, onClick]);
+
return (
- <button onClick={onClick}>
+ <button onClick={handleClick} disabled={disabled} className={variant}>
{label}
</button>
);
};`;
const authHunk = `@@ -42,7 +42,7 @@ export class AuthService {
async createSession(userId: string): Promise<Session> {
const token = await generateToken(userId);
- const expiresAt = Date.now() + 7 * 24 * 60 * 60 * 1000; // 7 days
+ const expiresAt = Date.now() + 24 * 60 * 60 * 1000; // 24 hours
return this.store.create({ userId, token, expiresAt });
}`;
const retryHunk = `@@ -1,5 +1,18 @@
export async function fetchWithRetry(url: string, opts?: RequestInit) {
- return fetch(url, opts);
+ let attempt = 0;
+ let lastErr: unknown;
+ while (attempt < 3) {
+ try {
+ const res = await fetch(url, opts);
+ if (res.ok || res.status < 500) return res;
+ lastErr = new Error(\`HTTP \${res.status}\`);
+ } catch (err) {
+ lastErr = err;
+ }
+ await new Promise(r => setTimeout(r, 200 * 2 ** attempt));
+ attempt++;
+ }
+ throw lastErr;
}`;
export const DEMO_TOUR: CodeTourData = {
title: 'Tighten auth + harden network calls',
greeting:
"Hey — okay, so this PR does three related things: it tightens the auth session lifetime from a week down to 24 hours, gives every network call a retry budget with exponential backoff, and finally fixes that Button component that's been silently ignoring its disabled prop for the last six months. Grab a coffee — I'll walk you through it in the order I'd actually want to read it.",
intent:
'Closes SEC-412, the overly-permissive session TTL flagged by the security team during the Q1 audit. Also lays groundwork for the offline-first work shipping next sprint, which depends on the network layer being resilient to transient failures. The Button fix is opportunistic — once we were touching the auth refresh flow, we noticed the Button on the login page had been masking its disabled state, which is how we ended up with the duplicate-submit bug from #412.',
before:
'Sessions lasted 7 days with no refresh contract, network calls failed hard on the first 5xx, and the Button component would happily fire onClick even when disabled was true at the prop level.',
after:
'Sessions expire in 24h with a clean refresh path, network calls retry up to 3 times with exponential backoff (200ms, 400ms, 800ms), and Button now respects disabled at both the prop and the underlying DOM element so the browser also blocks the click.',
key_takeaways: [
{
text: 'Session TTL dropped from 7 days to 24 hours — every active session pre-deploy will be invalidated. Coordinate the deploy with a maintenance window or expect a flood of re-auth events at deploy time.',
severity: 'warning',
},
{
text: 'Mobile clients that polled session/refresh every 6 hours need to drop to every 15 minutes. The mobile team has a separate PR (#418) that ships at the same time. Do NOT merge this without that one.',
severity: 'warning',
},
{
text: 'New retry logic uses exponential backoff (200ms, 400ms, 800ms). Worst-case latency for a fully-failing endpoint is now ~1.4s before throwing instead of immediate failure — make sure no UI is blocking on these calls without a loading state.',
severity: 'important',
},
{
text: 'Retries happen on 5xx and thrown network errors only. 4xx responses still return immediately because they represent client errors, not transient failures.',
severity: 'important',
},
{
text: 'Button now memoizes its click handler with useCallback and respects disabled at the DOM level. Existing callsites are source-compatible — no migration needed.',
severity: 'info',
},
{
text: 'The auth.ts changes touch the same lines as the in-flight refactor on PR #401. Expect a merge conflict; the resolution is mechanical (both branches just renamed the constant).',
severity: 'info',
},
{
text: 'No new dependencies. No schema changes. No new feature flags. Pure behavior + API tightening.',
severity: 'info',
},
],
stops: [
{
title: 'Auth session lifetime cut from 7 days to 24 hours',
gist: 'Single line change in AuthService.createSession — but every active session gets invalidated on deploy.',
detail: `The session TTL is now 86,400,000 ms (24h) instead of 604,800,000 ms (7 days). This is the line the security audit flagged.
> [!IMPORTANT]
> Every existing session token in the database has a stored expiresAt based on the old 7-day window. New sessions issued after deploy will use the 24h window. We are NOT retroactively shortening existing tokens — we're letting them expire naturally.
### What to verify
- The session refresh flow handles the shorter window gracefully (users on the app continuously for >24h need a silent re-auth)
- Mobile clients should already poll \`/api/session/refresh\` every 15 min; confirm that's still the case`,
transition: 'With shorter sessions, clients refresh more often — which makes the network layer the next thing to harden.',
anchors: [
{
file: 'src/services/auth.ts',
line: 42,
end_line: 48,
hunk: authHunk,
label: 'TTL constant change in createSession',
},
],
},
{
title: 'Network calls now retry on 5xx and network errors',
gist: 'fetchWithRetry wraps fetch with up to 3 attempts and exponential backoff (200ms, 400ms, 800ms).',
detail: `Before, any 5xx or thrown error bubbled up immediately. Now we retry up to 3 times with exponential backoff before giving up.
### Behavior matrix
- 2xx / 4xx → return immediately (no retry on client errors)
- 5xx → retry up to 3x
- Thrown errors (network down) → retry up to 3x
- All retries exhausted → throw the last error
### Worst case
A consistently failing endpoint takes 1.4s before throwing (200 + 400 + 800 = 1400ms of backoff). Make sure no UI is blocking on these calls without a loading state.`,
transition: 'The retry budget makes the auth refresh more reliable — closing the loop on the shorter session window.',
anchors: [
{
file: 'src/lib/fetchWithRetry.ts',
line: 1,
end_line: 18,
hunk: retryHunk,
label: 'Retry loop with exponential backoff',
},
],
},
{
title: 'Button component: disabled, variant, memoized handler',
gist: 'Pure additive API change — existing callsites unchanged, but Button now respects disabled at both prop and DOM level.',
detail: `Three things happen here:
- New optional \`disabled\` prop, applied to both the underlying \`<button>\` element and gated in the click handler
- New optional \`variant\` prop (\`'primary' | 'secondary'\`, defaults to \`'primary'\`)
- Click handler wrapped in useCallback with proper dependency array
> [!NOTE]
> The double-gating on disabled (both DOM attribute AND handler check) is intentional — it prevents a click that lands during the brief window between disabled becoming true and React flushing the DOM update.`,
transition: '',
anchors: [
{
file: 'src/components/Button.tsx',
line: 1,
end_line: 22,
hunk: buttonHunk,
label: 'Button props + memoized handler',
},
],
},
],
qa_checklist: [
{
question: 'Does the session refresh flow handle the new 24h window without forcing users to re-login mid-session?',
stop_indices: [0],
},
{
question: 'Are all network call sites resilient to the new 1.4s worst-case latency (no UI blocking without loading state)?',
stop_indices: [1],
},
{
question: 'Is the deploy coordinated with a maintenance window or comms to active users?',
stop_indices: [0],
},
{
question: 'Does Button still render correctly at every existing callsite (no breaking prop changes)?',
stop_indices: [2],
},
{
question: 'Are there metrics/alerts in place to detect if the retry budget is being exhausted in production?',
stop_indices: [1],
},
],
checklist: [],
};
@@ -78,6 +78,9 @@ export interface ReviewState {
// Diff navigation
openDiffFile: (filePath: string) => void;
// Tour
openTourPanel: (jobId: string) => void;
}
const ReviewStateContext = createContext<ReviewState | null>(null);
@@ -27,7 +27,8 @@ export const ReviewAgentJobDetailPanel: React.FC<IDockviewPanelProps> = (props)
);
const terminal = job ? isTerminalStatus(job.status) : false;
const [activeTab, setActiveTab] = useState<DetailTab>('findings');
const isTour = job?.provider === 'tour';
const [activeTab, setActiveTab] = useState<DetailTab>(isTour ? 'logs' : 'findings');
const { fullCommand, userMessage, systemPrompt } = useMemo(() => {
const cmd = job?.command ?? [];
@@ -108,7 +109,7 @@ export const ReviewAgentJobDetailPanel: React.FC<IDockviewPanelProps> = (props)
<div className="flex-shrink-0 px-8 py-3 border-b border-border/40">
<div className="flex items-center gap-2">
<StatusDot status={job.status} />
<ProviderPill provider={job.provider} />
<ProviderPill provider={job.provider} engine={job.engine} model={job.model} />
<span className="text-sm font-medium text-foreground truncate">{job.label}</span>
<span className="ml-auto text-[10px] font-mono text-muted-foreground">
{terminal && job.endedAt ? formatDuration(job.endedAt - job.startedAt) : <ElapsedTime startedAt={job.startedAt} />}
@@ -132,7 +133,7 @@ export const ReviewAgentJobDetailPanel: React.FC<IDockviewPanelProps> = (props)
<pre className="text-xs font-mono text-foreground/80 whitespace-pre-wrap break-words max-h-48 overflow-y-auto">{userMessage}</pre>
</Disclosure>
{systemPrompt && (
<Disclosure title="Review Prompt" copyText={systemPrompt} nested>
<Disclosure title={isTour ? "Tour Prompt" : "Review Prompt"} copyText={systemPrompt} nested>
<pre className="text-xs font-mono text-foreground/80 whitespace-pre-wrap break-words max-h-48 overflow-y-auto">{systemPrompt}</pre>
</Disclosure>
)}
@@ -142,9 +143,16 @@ export const ReviewAgentJobDetailPanel: React.FC<IDockviewPanelProps> = (props)
{/* ── Tabs ── */}
<div className="flex-shrink-0 px-8 flex gap-0.5 border-b border-border/40">
<TabButton active={activeTab === 'findings'} onClick={() => setActiveTab('findings')}>
Findings{activeAnnotations.length > 0 && ` (${activeAnnotations.length})`}
</TabButton>
{!isTour && (
<TabButton active={activeTab === 'findings'} onClick={() => setActiveTab('findings')}>
Findings{activeAnnotations.length > 0 && ` (${activeAnnotations.length})`}
</TabButton>
)}
{isTour && (
<TabButton active={activeTab === 'findings'} onClick={() => setActiveTab('findings')}>
Status
</TabButton>
)}
<TabButton active={activeTab === 'logs'} onClick={() => setActiveTab('logs')}>
Logs
{!terminal && <span className="ml-1.5 w-1.5 h-1.5 rounded-full bg-primary animate-pulse inline-block" />}
@@ -153,43 +161,51 @@ export const ReviewAgentJobDetailPanel: React.FC<IDockviewPanelProps> = (props)
{/* ── Content ── */}
{activeTab === 'findings' ? (
<ScrollFade>
<div className="px-8 py-3 space-y-4 max-w-2xl">
{/* Verdict — scrolls with content */}
<VerdictCard summary={job.summary} isCorrect={isCorrect} terminal={terminal} />
{/* Findings list */}
{displayAnnotations.length > 0 && (
<>
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">
{activeAnnotations.length} finding{activeAnnotations.length !== 1 ? 's' : ''}
{dismissedCount > 0 && ` · ${dismissedCount} dismissed`}
</span>
{copyAllText && <CopyButton text={copyAllText} variant="inline" label="Copy All" />}
</div>
{activeAnnotations.some(a => a.severity) && (
<div className="flex items-center gap-3 text-[10px] text-muted-foreground/60">
<span className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-destructive" /> Important</span>
<span className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-amber-500" /> Nit</span>
<span className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-muted-foreground" /> Pre-existing</span>
</div>
)}
</>
)}
{displayAnnotations.length === 0 ? (
<EmptyState terminal={terminal} />
) : (
<div className="space-y-2">
{displayAnnotations.map(({ annotation: ann, dismissed }) => (
<AnnotationRow key={ann.id} annotation={ann} dismissed={dismissed} onClick={handleAnnotationClick} />
))}
isTour ? (
/* Tour status view — no findings, just status + Open Tour button */
<ScrollFade>
<div className="px-8 py-3 space-y-4 max-w-2xl">
<TourStatusCard summary={job.summary} terminal={terminal} jobId={jobId} />
</div>
)}
</ScrollFade>
) : (
/* Review findings view */
<ScrollFade>
<div className="px-8 py-3 space-y-4 max-w-2xl">
<VerdictCard summary={job.summary} isCorrect={isCorrect} terminal={terminal} />
</div>
</ScrollFade>
{displayAnnotations.length > 0 && (
<>
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">
{activeAnnotations.length} finding{activeAnnotations.length !== 1 ? 's' : ''}
{dismissedCount > 0 && ` · ${dismissedCount} dismissed`}
</span>
{copyAllText && <CopyButton text={copyAllText} variant="inline" label="Copy All" />}
</div>
{activeAnnotations.some(a => a.severity) && (
<div className="flex items-center gap-3 text-[10px] text-muted-foreground/60">
<span className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-destructive" /> Important</span>
<span className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-amber-500" /> Nit</span>
<span className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-muted-foreground" /> Pre-existing</span>
</div>
)}
</>
)}
{displayAnnotations.length === 0 ? (
<EmptyState terminal={terminal} />
) : (
<div className="space-y-2">
{displayAnnotations.map(({ annotation: ann, dismissed }) => (
<AnnotationRow key={ann.id} annotation={ann} dismissed={dismissed} onClick={handleAnnotationClick} />
))}
</div>
)}
</div>
</ScrollFade>
)
) : (
<div className="flex-1 flex flex-col min-h-0 px-8 py-3">
<LiveLogViewer content={logContent} isLive={!terminal} />
@@ -254,6 +270,54 @@ function VerdictCard({ summary, isCorrect, terminal }: {
);
}
function TourStatusCard({ summary, terminal, jobId }: {
summary: AgentJobInfo['summary'];
terminal: boolean;
jobId: string;
}) {
const state = useReviewState();
if (summary) {
return (
<div className="space-y-3">
<div className="rounded px-3 py-2.5 bg-success/5">
<div className="flex items-baseline gap-2">
<span className="text-xs font-semibold text-success">Tour Generated</span>
</div>
<p className="text-xs text-foreground/80 leading-relaxed mt-1.5">{summary.explanation}</p>
</div>
<button
onClick={() => state.openTourPanel(jobId)}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors text-xs font-medium active:scale-[0.98]"
>
Open Tour
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M3 7h8M8 3.5L11 7l-3 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
);
}
return (
<div className="rounded bg-muted/10 px-3 py-2.5">
<div className="flex items-center gap-2">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">
Tour Status
</span>
{!terminal && (
<span className="text-[10px] text-muted-foreground/40 animate-pulse">Generating...</span>
)}
</div>
{terminal ? (
<p className="text-xs text-muted-foreground/50 mt-1">Tour generation failed.</p>
) : (
<p className="text-xs text-muted-foreground/50 mt-1">The tour will be ready when the agent finishes.</p>
)}
</div>
);
}
function StatusDot({ status }: { status: AgentJobInfo['status'] }) {
if (status === 'starting' || status === 'running') {
return (
@@ -267,9 +331,21 @@ function StatusDot({ status }: { status: AgentJobInfo['status'] }) {
return <span className={`inline-flex rounded-full h-2 w-2 flex-shrink-0 ${c[status] ?? c.killed}`} />;
}
function ProviderPill({ provider }: { provider: string }) {
const label = provider === 'claude' ? 'Claude' : provider === 'codex' ? 'Codex' : 'Shell';
return <span className="text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{label}</span>;
function ProviderPill({ provider, engine, model }: { provider: string; engine?: string; model?: string }) {
let label: string;
if (provider === 'tour') {
const engineLabel = engine === 'codex' ? 'Codex' : 'Claude';
label = model && engine !== 'codex' ? `Tour · ${engineLabel} ${model.charAt(0).toUpperCase() + model.slice(1)}` : `Tour · ${engineLabel}`;
} else {
label = provider === 'claude' ? 'Claude' : provider === 'codex' ? 'Codex' : 'Shell';
}
return (
<span className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${
provider === 'tour' ? 'bg-accent/10 text-accent' : 'bg-muted text-muted-foreground'
}`}>
{label}
</span>
);
}
function TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
@@ -0,0 +1,106 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { DEMO_TOUR, DEMO_TOUR_ID } from '../../demoTour';
import type { CodeTourData } from '@plannotator/shared/tour';
export type { TourDiffAnchor, TourKeyTakeaway, TourStop, TourQAItem, CodeTourData } from '@plannotator/shared/tour';
export interface UseTourDataReturn {
tour: CodeTourData | null;
loading: boolean;
error: string | null;
checked: boolean[];
toggleChecked: (index: number) => void;
retry: () => void;
}
export function useTourData(jobId: string): UseTourDataReturn {
const [tour, setTour] = useState<CodeTourData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [checked, setChecked] = useState<boolean[]>([]);
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingChecklistRef = useRef<boolean[] | null>(null);
const fetchTour = useCallback(() => {
if (!jobId) return;
setLoading(true);
setError(null);
// Dev short-circuit: render the demo tour without a backend.
if (jobId === DEMO_TOUR_ID) {
setTour(DEMO_TOUR);
setChecked(new Array(DEMO_TOUR.qa_checklist.length).fill(false));
setLoading(false);
return;
}
fetch(`/api/tour/${jobId}`)
.then((res) => {
if (!res.ok) throw new Error(res.status === 404 ? 'Tour not found' : `HTTP ${res.status}`);
return res.json();
})
.then((data: CodeTourData) => {
setTour(data);
setChecked(data.checklist?.length > 0 ? data.checklist : new Array(data.qa_checklist.length).fill(false));
setLoading(false);
})
.catch((err) => {
setError(err instanceof Error ? err.message : String(err));
setLoading(false);
});
}, [jobId]);
useEffect(() => {
fetchTour();
}, [fetchTour]);
const saveChecklist = useCallback(
(next: boolean[]) => {
if (jobId === DEMO_TOUR_ID) return;
pendingChecklistRef.current = next;
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(() => {
const payload = pendingChecklistRef.current;
pendingChecklistRef.current = null;
saveTimerRef.current = null;
if (!payload) return;
fetch(`/api/tour/${jobId}/checklist`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ checked: payload }),
}).catch(() => {});
}, 500);
},
[jobId],
);
const toggleChecked = useCallback(
(index: number) => {
setChecked((prev) => {
const next = [...prev];
next[index] = !next[index];
saveChecklist(next);
return next;
});
},
[saveChecklist],
);
useEffect(() => {
return () => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
const payload = pendingChecklistRef.current;
pendingChecklistRef.current = null;
if (!payload || jobId === DEMO_TOUR_ID) return;
// keepalive lets the request survive if this unmount is part of a tab close.
fetch(`/api/tour/${jobId}/checklist`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ checked: payload }),
keepalive: true,
}).catch(() => {});
};
}, [jobId]);
return { tour, loading, error, checked, toggleChecked, retry: fetchTour };
}
+147
View File
@@ -1081,3 +1081,150 @@ diffs-container {
:root[style*="--diff-font-size-override"] .ai-markdown code {
font-size: var(--diff-font-size-override) !important;
}
/* =====================================================
Code Tour
===================================================== */
/* --- Dialog overlay + card entrance / exit --- */
@keyframes tour-dialog-overlay-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes tour-dialog-overlay-out {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes tour-dialog-content-in {
from { opacity: 0; transform: scale(0.97); }
to { opacity: 1; transform: scale(1); }
}
@keyframes tour-dialog-content-out {
from { opacity: 1; transform: scale(1); }
to { opacity: 0; transform: scale(0.98); }
}
.tour-dialog-overlay {
animation: tour-dialog-overlay-in 150ms cubic-bezier(0.33, 1, 0.68, 1) both;
}
.tour-dialog-content {
animation: tour-dialog-content-in 250ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.tour-dialog-overlay-closing {
animation: tour-dialog-overlay-out 160ms cubic-bezier(0.33, 1, 0.68, 1) forwards;
}
.tour-dialog-content-closing {
animation: tour-dialog-content-out 180ms cubic-bezier(0.33, 1, 0.68, 1) forwards;
pointer-events: none;
}
/* --- Stop card reveal (applied by parent wrapper, staggered via animation-delay) --- */
@keyframes tour-stop-reveal {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.tour-stop-reveal {
animation: tour-stop-reveal 280ms cubic-bezier(0.16, 1, 0.3, 1) both;
will-change: transform, opacity;
}
/* --- Intro page exit: lifts up and fades out, then stops page reveals --- */
@keyframes tour-intro-exit {
0% { opacity: 1; transform: translateY(0) scale(1); }
30% { opacity: 0.8; transform: translateY(-4px) scale(0.99); }
100% { opacity: 0; transform: translateY(-20px) scale(0.97); }
}
.tour-intro-exit {
animation: tour-intro-exit 320ms cubic-bezier(0.33, 1, 0.68, 1) forwards;
pointer-events: none;
}
/* --- Stops section label entrance (after intro exits) --- */
@keyframes tour-section-enter {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.tour-section-enter {
animation: tour-section-enter 280ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
/* --- Page slide transitions (stops ↔ checklist) --- */
@keyframes tour-slide-out-left {
from { opacity: 1; transform: translateX(0); }
to { opacity: 0; transform: translateX(-48px); }
}
@keyframes tour-slide-in-right {
from { opacity: 0; transform: translateX(48px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes tour-slide-out-right {
from { opacity: 1; transform: translateX(0); }
to { opacity: 0; transform: translateX(48px); }
}
@keyframes tour-slide-in-left {
from { opacity: 0; transform: translateX(-48px); }
to { opacity: 1; transform: translateX(0); }
}
.tour-page-exit-fwd {
animation: tour-slide-out-left 220ms cubic-bezier(0.33, 1, 0.68, 1) forwards;
pointer-events: none;
}
.tour-page-enter-fwd {
animation: tour-slide-in-right 260ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.tour-page-exit-bwd {
animation: tour-slide-out-right 220ms cubic-bezier(0.33, 1, 0.68, 1) forwards;
pointer-events: none;
}
.tour-page-enter-bwd {
animation: tour-slide-in-left 260ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
/* --- Checkbox transition (ease for color changes — elegant, per the easing blueprint) --- */
.tour-checkbox {
transition: background-color 150ms ease, border-color 150ms ease;
}
/* --- Reduced motion: disable all tour animations --- */
@media (prefers-reduced-motion: reduce) {
.tour-dialog-overlay,
.tour-dialog-overlay-closing,
.tour-dialog-content,
.tour-dialog-content-closing,
.tour-stop-reveal,
.tour-intro-exit,
.tour-section-enter,
.tour-page-exit-fwd,
.tour-page-enter-fwd,
.tour-page-exit-bwd,
.tour-page-enter-bwd {
animation: none !important;
}
.tour-checkbox {
transition: none !important;
}
}
+5 -1
View File
@@ -7,10 +7,14 @@
"./styles": "./index.css"
},
"dependencies": {
"@pierre/diffs": "^1.1.12",
"@plannotator/shared": "workspace:*",
"@plannotator/ui": "workspace:*",
"@pierre/diffs": "^1.1.12",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-tooltip": "^1.2.8",
"highlight.js": "^11.11.1",
"motion": "^12.38.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"tailwindcss": "^4.1.18"
+44 -5
View File
@@ -62,7 +62,7 @@ export interface AgentJobHandlerOptions {
* Return an object with the command to spawn (and optional output path for result ingestion).
* Return null to reject or fall through to frontend-supplied command.
*/
buildCommand?: (provider: string) => Promise<{
buildCommand?: (provider: string, config?: Record<string, unknown>) => Promise<{
command: string[];
outputPath?: string;
captureStdout?: boolean;
@@ -71,6 +71,16 @@ export interface AgentJobHandlerOptions {
label?: string;
/** The full prompt text for display in the detail panel. */
prompt?: string;
/** Underlying engine used (e.g., "claude" or "codex"). Stored on AgentJobInfo for UI display. */
engine?: string;
/** Model used (e.g., "sonnet", "opus"). Stored on AgentJobInfo for UI display. */
model?: string;
/** Claude --effort level. */
effort?: string;
/** Codex reasoning effort level. */
reasoningEffort?: string;
/** Whether Codex fast mode was enabled. */
fastMode?: boolean;
} | null>;
/**
* Called after a job process exits with exit code 0.
@@ -93,6 +103,7 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions): AgentJob
const capabilities: AgentCapability[] = [
{ id: "claude", name: "Claude Code", available: !!Bun.which("claude") },
{ id: "codex", name: "Codex CLI", available: !!Bun.which("codex") },
{ id: "tour", name: "Code Tour", available: !!Bun.which("claude") || !!Bun.which("codex") },
];
const capabilitiesResponse: AgentCapabilities = {
mode,
@@ -119,7 +130,7 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions): AgentJob
command: string[],
label: string,
outputPath?: string,
spawnOptions?: { captureStdout?: boolean; stdinPrompt?: string; cwd?: string; prompt?: string },
spawnOptions?: { captureStdout?: boolean; stdinPrompt?: string; cwd?: string; prompt?: string; engine?: string; model?: string; effort?: string; reasoningEffort?: string; fastMode?: boolean },
): AgentJobInfo {
const id = crypto.randomUUID();
const source = jobSource(id);
@@ -133,6 +144,11 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions): AgentJob
startedAt: Date.now(),
command,
cwd: getCwd(),
...(spawnOptions?.engine && { engine: spawnOptions.engine }),
...(spawnOptions?.model && { model: spawnOptions.model }),
...(spawnOptions?.effort && { effort: spawnOptions.effort }),
...(spawnOptions?.reasoningEffort && { reasoningEffort: spawnOptions.reasoningEffort }),
...(spawnOptions?.fastMode && { fastMode: spawnOptions.fastMode }),
};
let proc: ReturnType<typeof Bun.spawn> | null = null;
@@ -220,8 +236,9 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions): AgentJob
const lines = text.split('\n');
for (const line of lines) {
if (!line.trim()) continue;
// Claude: format JSONL into readable text
if (provider === "claude") {
// Claude: format JSONL into readable text. Tour jobs with the
// Claude engine also stream Claude JSONL, so key off engine too.
if (provider === "claude" || spawnOptions?.engine === "claude") {
const formatted = formatClaudeLogEvent(line);
if (formatted !== null) {
broadcast({ type: "job:log", jobId: id, delta: formatted + '\n' });
@@ -423,8 +440,20 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions): AgentJob
let stdinPrompt: string | undefined;
let spawnCwd: string | undefined;
let promptText: string | undefined;
let jobEngine: string | undefined;
let jobModel: string | undefined;
let jobEffort: string | undefined;
let jobReasoningEffort: string | undefined;
let jobFastMode: boolean | undefined;
if (options.buildCommand) {
const built = await options.buildCommand(provider);
// Thread config from POST body to buildCommand
const config: Record<string, unknown> = {};
if (typeof body.engine === "string") config.engine = body.engine;
if (typeof body.model === "string") config.model = body.model;
if (typeof body.reasoningEffort === "string") config.reasoningEffort = body.reasoningEffort;
if (typeof body.effort === "string") config.effort = body.effort;
if (body.fastMode === true) config.fastMode = true;
const built = await options.buildCommand(provider, Object.keys(config).length > 0 ? config : undefined);
if (built) {
command = built.command;
outputPath = built.outputPath;
@@ -433,6 +462,11 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions): AgentJob
spawnCwd = built.cwd;
promptText = built.prompt;
if (built.label) label = built.label;
jobEngine = built.engine;
jobModel = built.model;
jobEffort = built.effort;
jobReasoningEffort = built.reasoningEffort;
jobFastMode = built.fastMode;
}
}
@@ -448,6 +482,11 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions): AgentJob
stdinPrompt,
cwd: spawnCwd,
prompt: promptText,
engine: jobEngine,
model: jobModel,
effort: jobEffort,
reasoningEffort: jobReasoningEffort,
fastMode: jobFastMode,
});
return Response.json({ job }, { status: 201 });
} catch {
+3 -2
View File
@@ -189,7 +189,7 @@ export interface ClaudeCommandResult {
* Build the `claude -p` command. Prompt is passed via stdin, not as a
* positional arg — avoids quoting issues, argv limits, and variadic flag conflicts.
*/
export function buildClaudeCommand(prompt: string): ClaudeCommandResult {
export function buildClaudeCommand(prompt: string, model: string = "claude-opus-4-7", effort?: string): ClaudeCommandResult {
const allowedTools = [
"Agent", "Read", "Glob", "Grep",
// GitHub CLI
@@ -224,7 +224,8 @@ export function buildClaudeCommand(prompt: string): ClaudeCommandResult {
"--verbose",
"--json-schema", CLAUDE_REVIEW_SCHEMA_JSON,
"--no-session-persistence",
"--model", "sonnet",
"--model", model,
...(effort ? ["--effort", effort] : []),
"--tools", "Agent,Bash,Read,Glob,Grep",
"--allowedTools", allowedTools,
"--disallowedTools", disallowedTools,
+7 -1
View File
@@ -226,15 +226,21 @@ export interface CodexCommandOptions {
cwd: string;
outputPath: string;
prompt: string;
model?: string;
reasoningEffort?: string;
fastMode?: boolean;
}
/** Build the `codex exec` argv array. Materializes the schema file on first call. */
export async function buildCodexCommand(options: CodexCommandOptions): Promise<string[]> {
const { cwd, outputPath, prompt } = options;
const { cwd, outputPath, prompt, model, reasoningEffort, fastMode } = options;
const schemaPath = await ensureSchemaFile();
const command = [
"codex",
...(model ? ["-m", model] : []),
...(reasoningEffort ? ["-c", `model_reasoning_effort=${reasoningEffort}`] : []),
...(fastMode ? ["-c", "service_tier=fast"] : []),
"exec",
"--output-schema", schemaPath,
"-o", outputPath,
+68 -17
View File
@@ -32,6 +32,7 @@ import {
parseClaudeStreamOutput,
transformClaudeFindings,
} from "./claude-review";
import { createTourSession, TOUR_EMPTY_OUTPUT_ERROR } from "./tour/tour-review";
import { saveConfig, detectGitUser, getServerConfig } from "./config";
import { type PRMetadata, type PRReviewFileComment, fetchPRFileContent, fetchPRContext, submitPRReview, fetchPRViewedFiles, markPRFilesViewed, getPRUser, prRefFromMetadata, getDisplayRepo, getMRLabel, getMRNumberLabel } from "./pr";
import { createAIEndpoints, ProviderRegistry, SessionManager, createProvider, type AIEndpoints, type PiSDKConfig } from "@plannotator/ai";
@@ -120,6 +121,8 @@ export async function startReviewServer(
const editorAnnotations = createEditorAnnotationHandler();
const externalAnnotations = createExternalAnnotationHandler("review");
const tour = createTourSession();
// Mutable state for diff switching
let currentPatch = options.rawPatch;
let currentGitRef = options.gitRef;
@@ -128,42 +131,54 @@ export async function startReviewServer(
// Agent jobs — background process manager (late-binds serverUrl via getter)
let serverUrl = "";
const resolveAgentCwd = (): string =>
options.agentCwd ?? resolveVcsCwd(currentDiffType, gitContext?.cwd) ?? process.cwd();
const agentJobs = createAgentJobHandler({
mode: "review",
getServerUrl: () => serverUrl,
getCwd: () => {
if (options.agentCwd) return options.agentCwd;
return resolveVcsCwd(currentDiffType, gitContext?.cwd) ?? process.cwd();
},
getCwd: resolveAgentCwd,
async buildCommand(provider) {
const cwd = options.agentCwd ?? resolveVcsCwd(currentDiffType, gitContext?.cwd) ?? process.cwd();
async buildCommand(provider, config) {
const cwd = resolveAgentCwd();
const hasAgentLocalAccess = !!options.agentCwd || !!gitContext;
const userMessage = buildCodexReviewUserMessage(
currentPatch,
currentDiffType,
{ defaultBranch: gitContext?.defaultBranch, hasLocalAccess: hasAgentLocalAccess },
prMetadata,
);
const userMessageOptions = { defaultBranch: gitContext?.defaultBranch, hasLocalAccess: hasAgentLocalAccess };
if (provider === "tour") {
return tour.buildCommand({
cwd,
patch: currentPatch,
diffType: currentDiffType,
options: userMessageOptions,
prMetadata,
config,
});
}
const userMessage = buildCodexReviewUserMessage(currentPatch, currentDiffType, userMessageOptions, prMetadata);
if (provider === "codex") {
const model = typeof config?.model === "string" && config.model ? config.model : undefined;
const reasoningEffort = typeof config?.reasoningEffort === "string" && config.reasoningEffort ? config.reasoningEffort : undefined;
const fastMode = config?.fastMode === true;
const outputPath = generateOutputPath();
const prompt = CODEX_REVIEW_SYSTEM_PROMPT + "\n\n---\n\n" + userMessage;
const command = await buildCodexCommand({ cwd, outputPath, prompt });
return { command, outputPath, prompt, label: "Codex Review" };
const command = await buildCodexCommand({ cwd, outputPath, prompt, model, reasoningEffort, fastMode });
return { command, outputPath, prompt, label: "Code Review", model, reasoningEffort, fastMode: fastMode || undefined };
}
if (provider === "claude") {
const model = typeof config?.model === "string" && config.model ? config.model : undefined;
const effort = typeof config?.effort === "string" && config.effort ? config.effort : undefined;
const prompt = CLAUDE_REVIEW_PROMPT + "\n\n---\n\n" + userMessage;
const { command, stdinPrompt } = buildClaudeCommand(prompt);
return { command, stdinPrompt, prompt, cwd, label: "Claude Code Review", captureStdout: true };
const { command, stdinPrompt } = buildClaudeCommand(prompt, model, effort);
return { command, stdinPrompt, prompt, cwd, label: "Code Review", captureStdout: true, model, effort };
}
return null;
},
async onJobComplete(job, meta) {
const cwd = options.agentCwd ?? resolveVcsCwd(currentDiffType, gitContext?.cwd) ?? process.cwd();
const cwd = resolveAgentCwd();
// --- Codex path ---
if (job.provider === "codex" && meta.outputPath) {
@@ -209,6 +224,21 @@ export async function startReviewServer(
}
return;
}
// --- Tour path ---
if (job.provider === "tour") {
const { summary } = await tour.onJobComplete({ job, meta });
if (summary) {
job.summary = summary;
} else {
// The process exited 0 but the model returned empty or malformed output
// and nothing was stored. Flip status so the client doesn't auto-open
// a successful-looking card that 404s on /api/tour/:id.
job.status = "failed";
job.error = TOUR_EMPTY_OUTPUT_ERROR;
}
return;
}
},
});
@@ -354,6 +384,27 @@ export async function startReviewServer(
async fetch(req, server) {
const url = new URL(req.url);
// API: Get tour result
if (url.pathname.match(/^\/api\/tour\/[^/]+$/) && req.method === "GET") {
const jobId = url.pathname.slice("/api/tour/".length);
const result = tour.getTour(jobId);
if (!result) return Response.json({ error: "Tour not found" }, { status: 404 });
return Response.json(result);
}
// API: Save tour checklist state
const checklistMatch = url.pathname.match(/^\/api\/tour\/([^/]+)\/checklist$/);
if (checklistMatch && req.method === "PUT") {
const jobId = checklistMatch[1];
try {
const body = await req.json() as { checked: boolean[] };
if (Array.isArray(body.checked)) tour.saveChecklist(jobId, body.checked);
return Response.json({ ok: true });
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
}
// API: Get diff content
if (url.pathname === "/api/diff" && req.method === "GET") {
return Response.json({
+98
View File
@@ -0,0 +1,98 @@
import { describe, expect, test } from "bun:test";
import { writeFile, mkdtemp } from "node:fs/promises";
import { existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { parseTourStreamOutput, parseTourFileOutput } from "./tour-review";
const stop = {
title: "Add retry",
gist: "Wrap the fetch in a retry.",
detail: "Three attempts, exponential backoff.",
transition: "",
anchors: [{ file: "src/a.ts", line: 1, end_line: 10, hunk: "", label: "retry" }],
};
const validOutput = {
title: "Retry pass",
greeting: "hi",
intent: "",
before: "",
after: "",
key_takeaways: [],
stops: [stop],
qa_checklist: [],
};
describe("parseTourStreamOutput", () => {
test("returns parsed output from terminal result event", () => {
const stdout = [
JSON.stringify({ type: "assistant" }),
JSON.stringify({ type: "result", is_error: false, structured_output: validOutput }),
].join("\n");
expect(parseTourStreamOutput(stdout)).toEqual(validOutput);
});
test("returns null when result has is_error: true", () => {
const stdout = JSON.stringify({ type: "result", is_error: true, structured_output: validOutput });
expect(parseTourStreamOutput(stdout)).toBeNull();
});
test("returns null when stops array is empty", () => {
const stdout = JSON.stringify({ type: "result", is_error: false, structured_output: { stops: [] } });
expect(parseTourStreamOutput(stdout)).toBeNull();
});
test("returns null when structured_output has no stops key", () => {
const stdout = JSON.stringify({ type: "result", is_error: false, structured_output: {} });
expect(parseTourStreamOutput(stdout)).toBeNull();
});
test("returns null on empty input", () => {
expect(parseTourStreamOutput("")).toBeNull();
expect(parseTourStreamOutput(" \n ")).toBeNull();
});
test("does not throw on truncated/malformed JSON", () => {
const stdout = '{"type":"assistant"}\n{"type":"result","is_err';
expect(parseTourStreamOutput(stdout)).toBeNull();
});
});
describe("parseTourFileOutput", () => {
test("returns null when file missing", async () => {
const missing = join(tmpdir(), "plannotator-tour-missing-" + Date.now() + ".json");
expect(await parseTourFileOutput(missing)).toBeNull();
});
test("returns parsed output and unlinks file", async () => {
const dir = await mkdtemp(join(tmpdir(), "plannotator-tour-"));
const file = join(dir, "out.json");
await writeFile(file, JSON.stringify(validOutput));
const result = await parseTourFileOutput(file);
expect(result).toEqual(validOutput);
expect(existsSync(file)).toBe(false);
});
test("returns null and unlinks file when JSON is malformed", async () => {
const dir = await mkdtemp(join(tmpdir(), "plannotator-tour-"));
const file = join(dir, "out.json");
await writeFile(file, "{not json");
const result = await parseTourFileOutput(file);
expect(result).toBeNull();
expect(existsSync(file)).toBe(false);
});
test("returns null when stops array is empty", async () => {
const dir = await mkdtemp(join(tmpdir(), "plannotator-tour-"));
const file = join(dir, "out.json");
await writeFile(file, JSON.stringify({ stops: [] }));
expect(await parseTourFileOutput(file)).toBeNull();
});
test("returns null when stops key missing", async () => {
const dir = await mkdtemp(join(tmpdir(), "plannotator-tour-"));
const file = join(dir, "out.json");
await writeFile(file, JSON.stringify({ other: 1 }));
expect(await parseTourFileOutput(file)).toBeNull();
});
});
+582
View File
@@ -0,0 +1,582 @@
import { join } from "node:path";
import { homedir, tmpdir } from "node:os";
import { mkdir, writeFile, readFile, unlink } from "node:fs/promises";
import type { DiffType } from "../vcs";
import type { PRMetadata } from "../pr";
import type {
CodeTourOutput,
TourDiffAnchor,
TourKeyTakeaway,
TourStop,
TourQAItem,
} from "@plannotator/shared/tour";
export type { CodeTourOutput, TourDiffAnchor, TourKeyTakeaway, TourStop, TourQAItem };
export const TOUR_EMPTY_OUTPUT_ERROR = "Tour generation returned empty or malformed output";
export const TOUR_SCHEMA_JSON = JSON.stringify({
type: "object",
properties: {
title: { type: "string" },
greeting: { type: "string" },
intent: { type: "string" },
before: { type: "string" },
after: { type: "string" },
key_takeaways: {
type: "array",
items: {
type: "object",
properties: {
text: { type: "string" },
severity: { type: "string", enum: ["info", "important", "warning"] },
},
required: ["text", "severity"],
additionalProperties: false,
},
},
stops: {
type: "array",
items: {
type: "object",
properties: {
title: { type: "string" },
gist: { type: "string" },
detail: { type: "string" },
transition: { type: "string" },
anchors: {
type: "array",
items: {
type: "object",
properties: {
file: { type: "string" },
line: { type: "integer" },
end_line: { type: "integer" },
hunk: { type: "string" },
label: { type: "string" },
},
required: ["file", "line", "end_line", "hunk", "label"],
additionalProperties: false,
},
},
},
required: ["title", "gist", "detail", "transition", "anchors"],
additionalProperties: false,
},
},
qa_checklist: {
type: "array",
items: {
type: "object",
properties: {
question: { type: "string" },
stop_indices: { type: "array", items: { type: "integer" } },
},
required: ["question", "stop_indices"],
additionalProperties: false,
},
},
},
required: ["title", "greeting", "intent", "before", "after", "key_takeaways", "stops", "qa_checklist"],
additionalProperties: false,
});
export const TOUR_REVIEW_PROMPT = `# Code Tour Narrator
## Identity
You are a colleague giving a casual, warm tour of work you understand well.
Think of it like sitting down next to someone and saying: "Hey Mike, here's
the PR. Let me walk you through it." The whole voice is conversational, not
documentary. You're telling the story of what changed and why.
The arguments (like "here's why we did it this way" or "we picked X instead
of Y") live INSIDE the stop details, where they belong. The framing (the
greeting, intent, before/after, transitions between stops) stays warm and
human, the way a coworker actually talks over coffee.
You are NOT finding bugs. You are NOT writing a technical report.
## Tone
- Conversational throughout. You're talking to a coworker, not writing docs.
- Use "we" and "you". "Here's what we changed." "You'll notice that..."
- A couple of sentences of context is fine, even for small PRs. If a
colleague was describing a one-line change, they wouldn't just say "I
changed a line." They'd say "Oh yeah, I bumped the TTL from 7 days to 24
hours because the audit flagged it last month." A little color is good.
- Each stop should feel like a colleague pausing to point at something:
"Okay, look at this part. Here's why it's interesting."
- **Do NOT use em-dashes (—) anywhere.** They're a dead giveaway of
AI-generated prose. Use commas, colons, semicolons, or separate sentences
instead. If you want to add an aside, use parentheses or start a new
sentence. Never an em-dash.
- No emoji anywhere. The UI handles all visual labeling deterministically.
## Output structure
### greeting
2-4 sentences welcoming the reviewer and setting the scene. Not a headline,
more like how you'd actually open a conversation. "Hey, so this PR does X
and Y. Grab a coffee; I'll walk you through it." A bit of warmth and context,
even for small changes.
Example: "Hey, so this PR tightens the auth session lifetime from a week down
to 24 hours. It's small in line count but it's the fix the security team has
been asking for since Q1. Let me walk you through it."
### intent
1-3 sentences explaining WHY this changeset exists. What problem is being
solved? What motivated the work? Keep it conversational; you're giving
context, not writing a ticket.
To determine intent:
- If a PR/MR URL was provided, read the PR description (gh pr view or
equivalent). Look for motivation, linked issues, and context the author
provided.
- If the PR body references a GitHub issue (e.g. "Fixes #123", "Closes
owner/repo#456") or GitLab issue, read that specific issue for deeper
context.
- If no PR is provided, infer intent from commit messages, branch name, and
the nature of the changes themselves.
- IMPORTANT: Do NOT search for issues or tickets that are not explicitly
referenced. Do not browse all open issues. Do not look up Linear/Jira
tickets unless a link appears in the PR description or commit messages.
Only follow what is given.
Example: "Closes SEC-412, the overly-permissive session TTL flagged by the
security team during the Q1 audit. It also lays some groundwork for the
offline-first work shipping next sprint."
### before / after
One to two sentences each. Paint the picture of the world before and after
this change. Focus on user or system behavior, not code structure.
Example before: "Sessions lasted 7 days, with no refresh contract, so a
stolen token was dangerous for a full week."
Example after: "Sessions now expire in 24 hours with a clean refresh path,
and mobile clients poll every 15 minutes to stay fresh."
### key_takeaways
3 to 5 bullet points. These are the MOST IMPORTANT things someone needs to
know at a glance about what this changeset DOES. Focus on what changes in
behavior, functionality, or developer experience. Each is ONE sentence. No
emoji, no prefix, just the text.
Severity guide (drives visual styling automatically; pick honestly, don't inflate):
- "info": neutral context, good to know.
- "important": a meaningful change in behavior, capability, or system contract.
- "warning": a behavioral shift worth watching, something that changes how
the system works in a way someone could miss. NOT code smells or style
nits. A clean changeset with no warnings is perfectly normal.
### stops
Each stop is the colleague pausing at a specific change to explain it.
#### How to ORDER stops
Order by READING FLOW, the order the colleague would walk you through the
change to make it understandable. NOT by blast radius or criticality.
Lead with the entry point: the file or function that, if understood alone,
unlocks the rest. Then walk outward:
- Definitions before consumers (types/interfaces/schemas before usage).
- Cause before effect (the change that motivated downstream changes comes first).
- Verification last (tests and migrations after the code they exercise).
#### How to CHUNK stops
A stop is a logical change, NOT a file. If three files changed for one reason,
that's ONE stop with three anchors. If one file has two unrelated changes,
that's two stops. Never "one-stop-per-file" by default; let logic decide.
#### Stop fields
- **title**: Short, friendly. "Token refresh flow", not "Changes to auth/refresh.ts".
- **gist**: ONE sentence. The headline. A reviewer who reads nothing else should
understand this stop from the gist alone.
- **detail**: This is where the colleague pauses to explain. Supports basic markdown.
- Start with 1-2 sentences describing the situation or problem this stop addresses.
- Then make the argument: WHY did we change this? WHY does the new code look the
way it does? If a non-obvious choice was made (data structure, error strategy,
sync vs async, where the logic lives), surface it. "We did X instead of Y
because Z" is exactly what the reviewer wants.
- Use ### headings (e.g. "### Why this shape") to highlight critical sub-sections.
- Use > [!IMPORTANT], > [!WARNING], or > [!NOTE] callout blocks for context
that helps the reader understand non-obvious decisions or behavioral shifts
(e.g., a new default value, a changed error path, a contract that callers
now depend on). These are not for flagging code smells.
- Use - bullet points for multi-part changes or parallel considerations.
- Keep total length reasonable, around 3-6 sentences equivalent. Don't write
an essay.
- **transition**: A short connective phrase to the next stop, in the colleague's
voice. Examples: "Building on that...", "On a related note...", "To support
that change...". Empty string for the last stop.
- **anchors**: The specific diff hunks shown inline below the detail narrative.
Each anchor MUST have a non-empty "hunk" field containing the actual unified
diff text extracted from the changeset. The hunk must include the @@ line.
Valid hunk format (REQUIRED; every anchor needs this):
@@ -42,7 +42,9 @@
function processRequest(req) {
- const result = await fetch(url);
- return result.json();
+ const result = await fetch(url, { timeout: 5000 });
+ if (!result.ok) throw new Error("HTTP " + result.status);
+ return result.json();
}
The label should be a substantive 1-sentence explanation of what this code
section does or why it matters, not a filename paraphrase.
E.g. "Adds a 5-second timeout and explicit error check to prevent silent hangs",
not "Changes to request.ts".
### qa_checklist
4 to 8 verification questions a HUMAN can actually answer. Two valid channels:
1. By READING the code (e.g., "Did we update both call sites of \`legacyAuth()\`?",
"Are all uses of the old token format migrated?", "Does the error handler
cover the new throw paths?").
2. By manually USING the product (e.g., "Sign in, restart the browser, and
confirm the session persists.", "Trigger a 503 from the API and confirm the
retry banner appears.").
NOT machine-runnable test ideas. NOT generic "smoke test" framing. The reviewer
is a person; what would THEY do to gain confidence?
Reference which stops each question relates to via stop_indices. Every question
should reference at least one stop.
## Pipeline
1. Read the full diff (git diff or inlined patch).
2. Read CLAUDE.md and README.md for project context.
3. Read commit messages (git log --oneline) and PR title/body if available.
4. Identify logical groupings of change (cross-file when appropriate). These
become stops.
5. Determine reading flow order: entry point first, then outward. Definitions
before consumers, cause before effect.
6. Write the greeting, intent, before/after, takeaways, stops, and checklist
in the voice of a coworker walking you through the work.
7. Return structured JSON matching the schema.
## Hard constraints
- Every anchor MUST have a non-empty "hunk" field. An anchor with an empty hunk
is broken; it will show "diff not available" to the reviewer. Extract the
real unified diff text from the input patch. Do not leave hunk blank.
- Never fabricate line numbers. Extract them from the diff.
- Gist must be ONE sentence. Not two. Not a run-on. One.
- Detail supports markdown. Use it when it makes the explanation clearer, not
for decoration. Plain prose is fine when the change is simple.
- Anchor labels must explain the code's purpose or the change's impact, not
just describe the filename.
- key_takeaways: 3 to 5 items, each ONE sentence.
- Stops are LOGICAL units, not files. Cross-file grouping is expected.
- Stop ORDER is reading flow: entry point first, definitions before consumers,
cause before effect, verification last.
- Combine trivial changes (renames, imports, formatting) into one "Housekeeping"
stop at the end, or omit entirely.
- QA questions must be answerable by a human, either by reading code or by
using the product. Never frame them as automated tests.
- NEVER use em-dashes (—) anywhere in the output. Use commas, colons,
semicolons, parentheses, or separate sentences. This is a hard constraint.
## Calibration: tour, not review
Your job is to EXPLAIN the changeset, not to critique it. If you genuinely
spot a real bug or a meaningful behavioral concern while reading the code,
surface it naturally in the relevant stop detail or as a warning takeaway.
That's the colleague noticing something worth mentioning. But don't hunt for
problems. Most clean changesets should have zero warnings and zero [!WARNING]
callouts. The primary question is "what does this change do and why?" not
"what's wrong with this code?"`;
function buildTourUserMessage(
patch: string,
diffType: DiffType,
options?: { defaultBranch?: string; hasLocalAccess?: boolean },
prMetadata?: PRMetadata,
): string {
if (prMetadata) {
if (options?.hasLocalAccess) {
return [
prMetadata.url,
"",
"You are in a local worktree checked out at the PR head. The code is available locally.",
`To see the PR changes, diff against the remote base branch: git diff origin/${prMetadata.baseBranch}...HEAD`,
"Do NOT diff against the local `main` branch; it may be stale. Always use origin/.",
"",
"Walk the reviewer through this changeset as a guided tour.",
].join("\n");
}
return [prMetadata.url, "", "Walk the reviewer through this PR as a guided tour."].join("\n");
}
const effectiveDiffType = diffType.startsWith("worktree:")
? diffType.split(":").pop() || "uncommitted"
: diffType;
switch (effectiveDiffType) {
case "uncommitted":
return "Walk the reviewer through the current code changes (staged, unstaged, and untracked files) as a guided tour.";
case "staged":
return "Walk the reviewer through the currently staged code changes (`git diff --staged`) as a guided tour.";
case "unstaged":
return "Walk the reviewer through the unstaged code changes (tracked modifications and untracked files) as a guided tour.";
case "last-commit":
return "Walk the reviewer through the code changes introduced in the last commit (`git diff HEAD~1..HEAD`) as a guided tour.";
case "branch": {
const base = options?.defaultBranch || "main";
return `Walk the reviewer through the code changes against the base branch '${base}' as a guided tour. Run \`git diff ${base}..HEAD\` to inspect the changes.`;
}
default:
return [
"Walk the reviewer through the following code changes as a guided tour.",
"",
"```diff",
patch,
"```",
].join("\n");
}
}
export interface TourClaudeCommandResult {
command: string[];
stdinPrompt: string;
}
export function buildTourClaudeCommand(prompt: string, model: string = "sonnet", effort?: string): TourClaudeCommandResult {
const allowedTools = [
"Agent", "Read", "Glob", "Grep",
"Bash(git status:*)", "Bash(git diff:*)", "Bash(git log:*)",
"Bash(git show:*)", "Bash(git blame:*)", "Bash(git branch:*)",
"Bash(git grep:*)", "Bash(git ls-remote:*)", "Bash(git ls-tree:*)",
"Bash(git merge-base:*)", "Bash(git remote:*)", "Bash(git rev-parse:*)",
"Bash(git show-ref:*)",
"Bash(gh pr view:*)", "Bash(gh pr diff:*)", "Bash(gh pr list:*)",
"Bash(gh api repos/*/*/pulls/*)", "Bash(gh api repos/*/*/pulls/*/files*)",
// The tour prompt follows linked issues (`Fixes #123`, `Closes owner/repo#456`),
// so the allowlist has to permit the issue-read commands.
"Bash(gh issue view:*)", "Bash(gh api repos/*/*/issues/*)",
"Bash(glab mr view:*)", "Bash(glab mr diff:*)",
"Bash(glab issue view:*)",
"Bash(wc:*)",
].join(",");
const disallowedTools = [
"Edit", "Write", "NotebookEdit", "WebFetch", "WebSearch",
"Bash(python:*)", "Bash(python3:*)", "Bash(node:*)", "Bash(npx:*)",
"Bash(bun:*)", "Bash(bunx:*)", "Bash(sh:*)", "Bash(bash:*)", "Bash(zsh:*)",
"Bash(curl:*)", "Bash(wget:*)",
].join(",");
return {
command: [
"claude", "-p",
"--permission-mode", "dontAsk",
"--output-format", "stream-json",
"--verbose",
"--json-schema", TOUR_SCHEMA_JSON,
"--no-session-persistence",
"--model", model,
...(effort ? ["--effort", effort] : []),
"--tools", "Agent,Bash,Read,Glob,Grep",
"--allowedTools", allowedTools,
"--disallowedTools", disallowedTools,
],
stdinPrompt: prompt,
};
}
const TOUR_SCHEMA_DIR = join(homedir(), ".plannotator");
const TOUR_SCHEMA_FILE = join(TOUR_SCHEMA_DIR, "tour-schema.json");
let tourSchemaMaterialized = false;
async function ensureTourSchemaFile(): Promise<string> {
if (!tourSchemaMaterialized) {
await mkdir(TOUR_SCHEMA_DIR, { recursive: true });
await writeFile(TOUR_SCHEMA_FILE, TOUR_SCHEMA_JSON);
tourSchemaMaterialized = true;
}
return TOUR_SCHEMA_FILE;
}
export function generateTourOutputPath(): string {
return join(tmpdir(), `plannotator-tour-${crypto.randomUUID()}.json`);
}
export async function buildTourCodexCommand(options: {
cwd: string;
outputPath: string;
prompt: string;
model?: string;
reasoningEffort?: string;
fastMode?: boolean;
}): Promise<string[]> {
const { cwd, outputPath, prompt, model, reasoningEffort, fastMode } = options;
const schemaPath = await ensureTourSchemaFile();
const command = [
"codex",
// Global flags must precede the "exec" subcommand for the Codex CLI.
...(model ? ["-m", model] : []),
...(reasoningEffort ? ["-c", `model_reasoning_effort=${reasoningEffort}`] : []),
...(fastMode ? ["-c", "service_tier=fast"] : []),
"exec",
"--output-schema", schemaPath,
"-o", outputPath,
"--full-auto", "--ephemeral",
"-C", cwd,
prompt,
];
return command;
}
export function parseTourStreamOutput(stdout: string): CodeTourOutput | null {
if (!stdout.trim()) return null;
const lines = stdout.trim().split('\n');
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i].trim();
if (!line) continue;
try {
const event = JSON.parse(line);
if (event.type === 'result') {
if (event.is_error) return null;
const output = event.structured_output;
// A tour with no stops isn't a tour — treat as invalid so the UI
// error state fires instead of rendering an empty walkthrough.
if (!output || !Array.isArray(output.stops) || output.stops.length === 0) return null;
return output as CodeTourOutput;
}
} catch {
// Not valid JSON — skip
}
}
return null;
}
export async function parseTourFileOutput(outputPath: string): Promise<CodeTourOutput | null> {
try {
const text = await readFile(outputPath, "utf-8");
try { await unlink(outputPath); } catch { /* ignore */ }
if (!text.trim()) return null;
const parsed = JSON.parse(text);
// A tour with no stops isn't a tour — treat as invalid so the UI
// error state fires instead of rendering an empty walkthrough.
if (!parsed || !Array.isArray(parsed.stops) || parsed.stops.length === 0) return null;
return parsed as CodeTourOutput;
} catch {
try { await unlink(outputPath); } catch { /* ignore */ }
return null;
}
}
export interface TourSessionBuildCommandOptions {
cwd: string;
patch: string;
diffType: DiffType;
options?: { defaultBranch?: string; hasLocalAccess?: boolean };
prMetadata?: PRMetadata;
config?: Record<string, unknown>;
}
export interface TourSessionBuildCommandResult {
command: string[];
outputPath?: string;
captureStdout?: boolean;
stdinPrompt?: string;
cwd?: string;
label?: string;
prompt?: string;
engine: "claude" | "codex";
model: string;
effort?: string;
reasoningEffort?: string;
fastMode?: boolean;
}
export interface TourSessionJobSummary {
correctness: string;
explanation: string;
confidence: number;
}
export interface TourSessionJobRef {
id: string;
engine?: string;
}
export interface TourSessionOnJobCompleteOptions {
job: TourSessionJobRef;
meta: { outputPath?: string; stdout?: string };
}
export interface TourSession {
tourResults: Map<string, CodeTourOutput>;
tourChecklists: Map<string, boolean[]>;
buildCommand(opts: TourSessionBuildCommandOptions): Promise<TourSessionBuildCommandResult>;
onJobComplete(opts: TourSessionOnJobCompleteOptions): Promise<{ summary: TourSessionJobSummary | null }>;
getTour(jobId: string): (CodeTourOutput & { checklist: boolean[] }) | null;
saveChecklist(jobId: string, checked: boolean[]): void;
}
export function createTourSession(): TourSession {
const tourResults = new Map<string, CodeTourOutput>();
const tourChecklists = new Map<string, boolean[]>();
return {
tourResults,
tourChecklists,
async buildCommand({ cwd, patch, diffType, options, prMetadata, config }) {
const engine = (typeof config?.engine === "string" ? config.engine : "claude") as "claude" | "codex";
const explicitModel = typeof config?.model === "string" && config.model ? config.model : null;
// "sonnet" is a Claude model, so we must NOT pass it to Codex when no model
// is explicitly selected. Leave Codex model blank and let its CLI default pick.
const model = explicitModel ?? (engine === "codex" ? "" : "sonnet");
const reasoningEffort = typeof config?.reasoningEffort === "string" && config.reasoningEffort ? config.reasoningEffort : undefined;
const effort = typeof config?.effort === "string" && config.effort ? config.effort : undefined;
const fastMode = config?.fastMode === true;
const userMessage = buildTourUserMessage(patch, diffType, options, prMetadata);
const prompt = TOUR_REVIEW_PROMPT + "\n\n---\n\n" + userMessage;
if (engine === "codex") {
const outputPath = generateTourOutputPath();
const command = await buildTourCodexCommand({ cwd, outputPath, prompt, model: model || undefined, reasoningEffort, fastMode });
return { command, outputPath, prompt, label: "Code Tour", engine: "codex", model, reasoningEffort, fastMode: fastMode || undefined };
}
const { command, stdinPrompt } = buildTourClaudeCommand(prompt, model, effort);
return { command, stdinPrompt, prompt, cwd, label: "Code Tour", captureStdout: true, engine: "claude", model, effort };
},
async onJobComplete({ job, meta }) {
let output: CodeTourOutput | null = null;
if (job.engine === "codex" && meta.outputPath) {
output = await parseTourFileOutput(meta.outputPath);
} else if (meta.stdout) {
output = parseTourStreamOutput(meta.stdout);
}
if (!output) {
console.error(`[tour] Failed to parse output for job ${job.id}`);
return { summary: null };
}
tourResults.set(job.id, output);
const summary: TourSessionJobSummary = {
correctness: "Tour Generated",
explanation: `${output.stops.length} stop${output.stops.length !== 1 ? "s" : ""}, ${output.qa_checklist.length} QA item${output.qa_checklist.length !== 1 ? "s" : ""}`,
confidence: 1.0,
};
return { summary };
},
getTour(jobId) {
const tour = tourResults.get(jobId);
if (!tour) return null;
return { ...tour, checklist: tourChecklists.get(jobId) ?? [] };
},
saveChecklist(jobId, checked) {
tourChecklists.set(jobId, checked);
},
};
}
+11 -1
View File
@@ -19,8 +19,18 @@ export interface AgentJobInfo {
id: string;
/** Source identifier for external annotations — "agent-{id prefix}". */
source: string;
/** Provider that spawned this job — "claude", "codex", "shell", etc. */
/** Provider that spawned this job — "claude", "codex", "tour", "shell", etc. */
provider: string;
/** Underlying engine used (e.g., "claude" or "codex"). Set when provider is "tour". */
engine?: string;
/** Model used (e.g., "sonnet", "opus"). Set when provider is "tour" with Claude engine. */
model?: string;
/** Claude --effort level (e.g., "low", "medium", "high", "xhigh", "max"). */
effort?: string;
/** Codex reasoning effort level (e.g., "high", "medium"). */
reasoningEffort?: string;
/** Whether Codex fast mode (service_tier=fast) was enabled. */
fastMode?: boolean;
/** Human-readable label for the job. */
label: string;
/** Current lifecycle status. */
+2 -1
View File
@@ -25,7 +25,8 @@
"./improvement-hooks": "./improvement-hooks.ts",
"./worktree": "./worktree.ts",
"./html-to-markdown": "./html-to-markdown.ts",
"./url-to-markdown": "./url-to-markdown.ts"
"./url-to-markdown": "./url-to-markdown.ts",
"./tour": "./tour.ts"
},
"dependencies": {
"@joplin/turndown-plugin-gfm": "^1.0.64",
+61
View File
@@ -0,0 +1,61 @@
export interface TourDiffAnchor {
/** Relative file path within the repo. */
file: string;
/** Start line in the new file (post-change). */
line: number;
/** End line in the new file. */
end_line: number;
/** Raw unified diff hunk for this anchor. */
hunk: string;
/** One-line chip label, e.g. "Add retry logic". */
label: string;
}
export interface TourKeyTakeaway {
/** One sentence — the takeaway. */
text: string;
/** Severity for visual styling. */
severity: "info" | "important" | "warning";
}
export interface TourStop {
/** Short chapter title, friendly tone. */
title: string;
/** ONE sentence — the headline for this stop. Scannable without expanding. */
gist: string;
/** 2-3 sentences of additional context. Only shown when expanded. */
detail: string;
/** Connective phrase to the next stop, e.g. "Building on that..." (empty for last stop). */
transition: string;
/** Diff anchors — the code locations this stop references. */
anchors: TourDiffAnchor[];
}
export interface TourQAItem {
/** Product-level verification question. */
question: string;
/** Indices into stops[] that this question relates to. */
stop_indices: number[];
}
export interface CodeTourOutput {
/** One-line title for the entire tour. */
title: string;
/** 1-2 sentence friendly greeting + summary. Conversational, not formal. */
greeting: string;
/** 1-3 sentences: why this changeset exists — the motivation/problem being solved. */
intent: string;
/** What things looked like before this changeset — one sentence. */
before: string;
/** What things look like after — one sentence. */
after: string;
/** 3-5 key takeaways — the most critical info, scannable at a glance. */
key_takeaways: TourKeyTakeaway[];
/** Ordered tour stops — the detailed walk-through. */
stops: TourStop[];
/** Product-level QA checklist. */
qa_checklist: TourQAItem[];
}
/** UI-side tour shape: server output extended with persisted checklist state. */
export type CodeTourData = CodeTourOutput & { checklist: boolean[] };
+324 -27
View File
@@ -1,12 +1,67 @@
import React, { useState, useEffect, useRef, useMemo } from 'react';
import React, { useState, useEffect, useMemo } from 'react';
import type { AgentJobInfo, AgentCapabilities } from '../types';
import { isTerminalStatus } from '@plannotator/shared/agent-jobs';
import { ReviewAgentsIcon } from './ReviewAgentsIcon';
import { useAgentSettings } from '../hooks/useAgentSettings';
// --- Agent option catalogs (shared across provider + tour-engine dropdowns) ---
const CLAUDE_MODELS: Array<{ value: string; label: string }> = [
{ value: 'claude-opus-4-7', label: 'Opus 4.7' },
{ value: 'claude-opus-4-6', label: 'Opus 4.6' },
{ value: 'claude-sonnet-4-6', label: 'Sonnet 4.6' },
];
const CLAUDE_EFFORT: Array<{ value: string; label: string }> = [
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
{ value: 'xhigh', label: 'XHigh' },
{ value: 'max', label: 'Max' },
];
const CODEX_MODELS: Array<{ value: string; label: string }> = [
{ value: 'gpt-5.4', label: 'GPT-5.4' },
{ value: 'gpt-5.3-codex', label: 'GPT-5.3 Codex' },
{ value: 'gpt-5.3-codex-spark', label: 'GPT-5.3 Codex Spark' },
{ value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex' },
{ value: 'gpt-5.2', label: 'GPT-5.2' },
{ value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' },
{ value: 'gpt-5.1-codex-mini', label: 'GPT-5.1 Codex Mini' },
{ value: 'gpt-5.4-mini', label: 'GPT-5.4 Mini' },
];
const CODEX_REASONING: Array<{ value: string; label: string }> = [
{ value: 'minimal', label: 'Minimal' },
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
{ value: 'xhigh', label: 'XHigh' },
];
// Tour Claude reuses the same effort levels but offers a different model set.
const TOUR_CLAUDE_MODELS: Array<{ value: string; label: string }> = [
{ value: 'sonnet', label: 'Sonnet (fast)' },
{ value: 'opus', label: 'Opus (thorough)' },
];
// Dropdown labels: action first, provider second. Groups visually by action —
// you scan two "Code Review" entries and one "Code Tour" instead of three raw
// CLI names.
const PROVIDER_DROPDOWN_LABEL: Record<string, string> = {
claude: 'Code Review · Claude',
codex: 'Code Review · Codex',
tour: 'Code Tour',
};
function providerDropdownLabel(id: string, fallback: string): string {
return PROVIDER_DROPDOWN_LABEL[id] ?? fallback;
}
interface AgentsTabProps {
jobs: AgentJobInfo[];
capabilities: AgentCapabilities | null;
onLaunch: (params: { provider?: string; command?: string[]; label?: string }) => void;
onLaunch: (params: { provider?: string; command?: string[]; label?: string; engine?: string; model?: string; reasoningEffort?: string; effort?: string; fastMode?: boolean }) => void;
onKillJob: (id: string) => void;
onKillAll: () => void;
externalAnnotations: Array<{ source?: string }>;
@@ -80,13 +135,53 @@ function StatusBadge({ status }: { status: AgentJobInfo['status'] }) {
// --- Provider badge ---
function ProviderBadge({ provider }: { provider: string }) {
const label =
provider === 'claude' ? 'Claude' :
provider === 'codex' ? 'Codex' :
'Shell';
// Lookup a human label from the catalogs; fall back to the raw id.
function catalogLabel(list: Array<{ value: string; label: string }>, value: string): string {
return list.find((o) => o.value === value)?.label ?? value;
}
function formatModel(provider: string, engine: string | undefined, model: string): string {
if (provider === 'codex' || engine === 'codex') return catalogLabel(CODEX_MODELS, model);
if (provider === 'tour' && engine === 'claude') return catalogLabel(TOUR_CLAUDE_MODELS, model);
return catalogLabel(CLAUDE_MODELS, model);
}
function formatEffort(value: string): string {
return catalogLabel(CLAUDE_EFFORT, value);
}
function formatReasoning(value: string): string {
return catalogLabel(CODEX_REASONING, value);
}
function ProviderBadge({ provider, engine, model, effort, reasoningEffort, fastMode }: { provider: string; engine?: string; model?: string; effort?: string; reasoningEffort?: string; fastMode?: boolean }) {
let label: string;
if (provider === 'tour') {
const engineLabel = engine === 'codex' ? 'Codex' : 'Claude';
const parts = [`Tour · ${engineLabel}`];
if (model) parts.push(formatModel(provider, engine, model));
if (engine === 'claude' && effort) parts.push(formatEffort(effort));
if (engine === 'codex' && reasoningEffort) parts.push(formatReasoning(reasoningEffort));
if (fastMode) parts.push('Fast');
label = parts.join(' · ');
} else if (provider === 'codex') {
const parts = ['Codex'];
if (model) parts.push(formatModel(provider, engine, model));
if (reasoningEffort) parts.push(formatReasoning(reasoningEffort));
if (fastMode) parts.push('Fast');
label = parts.join(' · ');
} else if (provider === 'claude') {
const parts = ['Claude'];
if (model) parts.push(formatModel(provider, engine, model));
if (effort) parts.push(formatEffort(effort));
label = parts.join(' · ');
} else {
label = 'Shell';
}
return (
<span className="text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
<span className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${
provider === 'tour' ? 'bg-accent/10 text-accent' : 'bg-muted text-muted-foreground'
}`}>
{label}
</span>
);
@@ -122,7 +217,7 @@ function JobCard({
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<ProviderBadge provider={job.provider} />
<ProviderBadge provider={job.provider} engine={job.engine} model={job.model} effort={job.effort} reasoningEffort={job.reasoningEffort} fastMode={job.fastMode} />
<span className="text-xs text-foreground/80 truncate">{job.label}</span>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
@@ -183,20 +278,49 @@ export const AgentsTab: React.FC<AgentsTabProps> = ({
externalAnnotations,
onOpenJobDetail,
}) => {
const [selectedProvider, setSelectedProvider] = useState<string>('');
const [expandedJobId, setExpandedJobId] = useState<string | null>(null);
const initializedRef = useRef(false);
const settings = useAgentSettings();
const {
selectedProvider,
tourEngine,
claudeModel,
claudeEffort,
codexModel,
codexReasoning,
codexFast,
tourClaudeModel,
tourClaudeEffort,
tourCodexModel,
tourCodexReasoning,
tourCodexFast,
setSelectedProvider,
setTourEngine,
setClaudeModel,
setClaudeEffort,
setCodexModel,
setCodexReasoning,
setCodexFast,
setTourClaudeModel,
setTourClaudeEffort,
setTourCodexModel,
setTourCodexReasoning,
setTourCodexFast,
} = settings;
// Set default provider once capabilities load
// Reconcile provider + tour engine against live capabilities. Runs when
// capabilities change or the stored selection becomes invalid.
useEffect(() => {
if (capabilities && !initializedRef.current) {
const firstAvailable = capabilities.providers.find((p) => p.available);
if (firstAvailable) {
setSelectedProvider(firstAvailable.id);
initializedRef.current = true;
}
if (!capabilities) return;
const available = capabilities.providers.filter((p) => p.available);
if (available.length === 0) return;
if (!selectedProvider || !available.some((p) => p.id === selectedProvider)) {
setSelectedProvider(available[0].id);
}
}, [capabilities]);
const hasClaude = available.some((p) => p.id === 'claude');
const hasCodex = available.some((p) => p.id === 'codex');
if (tourEngine === 'claude' && !hasClaude && hasCodex) setTourEngine('codex');
else if (tourEngine === 'codex' && !hasCodex && hasClaude) setTourEngine('claude');
}, [capabilities, selectedProvider, tourEngine, setSelectedProvider, setTourEngine]);
const availableProviders = useMemo(
() => capabilities?.providers.filter((p) => p.available) ?? [],
@@ -229,13 +353,34 @@ export const AgentsTab: React.FC<AgentsTabProps> = ({
[jobs],
);
// Detect which engines are available for tour config
const claudeAvailable = capabilities?.providers.some((p) => p.id === 'claude' && p.available) ?? false;
const codexAvailable = capabilities?.providers.some((p) => p.id === 'codex' && p.available) ?? false;
type LaunchParams = Parameters<typeof onLaunch>[0];
const buildLaunch: Record<string, () => LaunchParams> = {
claude: () => ({ provider: 'claude', label: 'Code Review', model: claudeModel, effort: claudeEffort }),
codex: () => ({
provider: 'codex',
label: 'Code Review',
model: codexModel,
reasoningEffort: codexReasoning,
...(codexFast && { fastMode: true }),
}),
tour: () => ({
provider: 'tour',
label: 'Code Tour',
engine: tourEngine,
model: tourEngine === 'claude' ? tourClaudeModel : tourCodexModel,
...(tourEngine === 'claude'
? { effort: tourClaudeEffort }
: { reasoningEffort: tourCodexReasoning, ...(tourCodexFast && { fastMode: true }) }),
}),
};
const handleLaunch = () => {
if (!selectedProvider) return;
const provider = availableProviders.find((p) => p.id === selectedProvider);
onLaunch({
provider: selectedProvider,
label: provider ? `${provider.name} Review` : selectedProvider,
});
onLaunch(buildLaunch[selectedProvider]?.() ?? { provider: selectedProvider, label: selectedProvider });
};
return (
@@ -246,19 +391,19 @@ export const AgentsTab: React.FC<AgentsTabProps> = ({
<div className="flex items-center gap-1.5">
{availableProviders.length > 1 ? (
<select
value={selectedProvider}
value={selectedProvider ?? ''}
onChange={(e) => setSelectedProvider(e.target.value)}
className="flex-1 text-xs px-2 py-1.5 rounded bg-muted/50 border border-border/50 text-foreground focus:outline-none focus:ring-1 focus:ring-primary/30"
>
{availableProviders.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
{providerDropdownLabel(p.id, p.name)}
</option>
))}
</select>
) : (
<span className="flex-1 text-xs px-2 py-1.5 text-muted-foreground">
{availableProviders[0]?.name}
{availableProviders[0] ? providerDropdownLabel(availableProviders[0].id, availableProviders[0].name) : ''}
</span>
)}
<button
@@ -269,6 +414,158 @@ export const AgentsTab: React.FC<AgentsTabProps> = ({
Run
</button>
</div>
{/* Claude model + effort config */}
{selectedProvider === 'claude' && (
<div className="mt-2 space-y-1.5">
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
<span className="font-medium w-14">Model</span>
<select
value={claudeModel}
onChange={(e) => setClaudeModel(e.target.value)}
className="flex-1 text-[10px] px-1.5 py-0.5 rounded bg-muted/50 border border-border/40 text-foreground focus:outline-none focus:ring-1 focus:ring-primary/30"
>
{CLAUDE_MODELS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
<span className="font-medium w-14">Effort</span>
<select
value={claudeEffort}
onChange={(e) => setClaudeEffort(e.target.value)}
className="flex-1 text-[10px] px-1.5 py-0.5 rounded bg-muted/50 border border-border/40 text-foreground focus:outline-none focus:ring-1 focus:ring-primary/30"
>
{CLAUDE_EFFORT.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
</div>
)}
{/* Codex model + reasoning + fast mode config */}
{selectedProvider === 'codex' && (
<div className="mt-2 space-y-1.5">
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
<span className="font-medium w-14">Model</span>
<select
value={codexModel}
onChange={(e) => setCodexModel(e.target.value)}
className="flex-1 text-[10px] px-1.5 py-0.5 rounded bg-muted/50 border border-border/40 text-foreground focus:outline-none focus:ring-1 focus:ring-primary/30"
>
{CODEX_MODELS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
<span className="font-medium w-14">Reasoning</span>
<select
value={codexReasoning}
onChange={(e) => setCodexReasoning(e.target.value)}
className="flex-1 text-[10px] px-1.5 py-0.5 rounded bg-muted/50 border border-border/40 text-foreground focus:outline-none focus:ring-1 focus:ring-primary/30"
>
{CODEX_REASONING.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
<span className="font-medium w-14">Fast</span>
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={codexFast}
onChange={(e) => setCodexFast(e.target.checked)}
className="w-3 h-3 accent-primary"
/>
<span className={codexFast ? 'text-foreground' : ''}>Fast mode</span>
</label>
</div>
</div>
)}
{/* Tour engine/model config — only shown when tour is selected */}
{selectedProvider === 'tour' && (
<div className="mt-2 space-y-1.5">
{/* Engine selector */}
{claudeAvailable && codexAvailable && (
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
<span className="font-medium w-14">Engine</span>
<label className="flex items-center gap-1 cursor-pointer">
<input
type="radio"
name="tour-engine"
checked={tourEngine === 'claude'}
onChange={() => setTourEngine('claude')}
className="w-3 h-3 accent-primary"
/>
<span className={tourEngine === 'claude' ? 'text-foreground' : ''}>Claude</span>
</label>
<label className="flex items-center gap-1 cursor-pointer">
<input
type="radio"
name="tour-engine"
checked={tourEngine === 'codex'}
onChange={() => setTourEngine('codex')}
className="w-3 h-3 accent-primary"
/>
<span className={tourEngine === 'codex' ? 'text-foreground' : ''}>Codex</span>
</label>
</div>
)}
{/* Model selector — engine-specific options */}
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
<span className="font-medium w-14">Model</span>
<select
value={tourEngine === 'claude' ? tourClaudeModel : tourCodexModel}
onChange={(e) => (tourEngine === 'claude' ? setTourClaudeModel(e.target.value) : setTourCodexModel(e.target.value))}
className="flex-1 text-[10px] px-1.5 py-0.5 rounded bg-muted/50 border border-border/40 text-foreground focus:outline-none focus:ring-1 focus:ring-primary/30"
>
{(tourEngine === 'claude' ? TOUR_CLAUDE_MODELS : CODEX_MODELS).map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
{/* Claude-only: effort level */}
{tourEngine === 'claude' && (
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
<span className="font-medium w-14">Effort</span>
<select
value={tourClaudeEffort}
onChange={(e) => setTourClaudeEffort(e.target.value)}
className="flex-1 text-[10px] px-1.5 py-0.5 rounded bg-muted/50 border border-border/40 text-foreground focus:outline-none focus:ring-1 focus:ring-primary/30"
>
{CLAUDE_EFFORT.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
)}
{/* Codex-only: reasoning effort + fast mode */}
{tourEngine === 'codex' && (
<>
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
<span className="font-medium w-14">Reasoning</span>
<select
value={tourCodexReasoning}
onChange={(e) => setTourCodexReasoning(e.target.value)}
className="flex-1 text-[10px] px-1.5 py-0.5 rounded bg-muted/50 border border-border/40 text-foreground focus:outline-none focus:ring-1 focus:ring-primary/30"
>
{CODEX_REASONING.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
<span className="font-medium w-14">Fast</span>
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={tourCodexFast}
onChange={(e) => setTourCodexFast(e.target.checked)}
className="w-3 h-3 accent-primary"
/>
<span className={tourCodexFast ? 'text-foreground' : ''}>Fast mode</span>
</label>
</div>
</>
)}
</div>
)}
</div>
)}
+6 -1
View File
@@ -22,7 +22,7 @@ interface UseAgentJobsReturn {
jobs: AgentJobInfo[];
jobLogs: Map<string, string>;
capabilities: AgentCapabilities | null;
launchJob: (params: { provider?: string; command?: string[]; label?: string }) => Promise<AgentJobInfo | null>;
launchJob: (params: { provider?: string; command?: string[]; label?: string; engine?: string; model?: string; reasoningEffort?: string; effort?: string; fastMode?: boolean }) => Promise<AgentJobInfo | null>;
killJob: (id: string) => Promise<void>;
killAll: () => Promise<void>;
}
@@ -163,6 +163,11 @@ export function useAgentJobs(
provider?: string;
command?: string[];
label?: string;
engine?: string;
model?: string;
reasoningEffort?: string;
effort?: string;
fastMode?: boolean;
}): Promise<AgentJobInfo | null> => {
try {
const res = await fetch(JOBS_URL, {
@@ -0,0 +1,44 @@
import { describe, expect, test } from "bun:test";
import { sanitizeCodexPerModel, DEFAULT_CODEX_REASONING } from "./useAgentSettings";
describe("sanitizeCodexPerModel", () => {
test("returns empty object for undefined/empty input", () => {
expect(sanitizeCodexPerModel(undefined)).toEqual({});
expect(sanitizeCodexPerModel({})).toEqual({});
});
test("drops stale reasoning: 'none' entry when fast is false", () => {
const result = sanitizeCodexPerModel({
"gpt-5.3-codex": { reasoning: "none", fast: false },
});
expect(result).toEqual({});
});
test("retains entry with reasoning: 'none' but fast: true, replacing reasoning with default", () => {
const result = sanitizeCodexPerModel({
"gpt-5.3-codex": { reasoning: "none", fast: true },
});
expect(result).toEqual({
"gpt-5.3-codex": { reasoning: DEFAULT_CODEX_REASONING, fast: true },
});
});
test("passes through valid entries unchanged", () => {
const input = {
"gpt-5.3-codex": { reasoning: "high", fast: false },
"gpt-5.3-pro": { reasoning: "medium", fast: true },
};
expect(sanitizeCodexPerModel(input)).toEqual(input);
});
test("skips non-object entries", () => {
const input = {
valid: { reasoning: "high", fast: false },
nullish: null as unknown as { reasoning: string; fast: boolean },
stringy: "bad" as unknown as { reasoning: string; fast: boolean },
};
expect(sanitizeCodexPerModel(input)).toEqual({
valid: { reasoning: "high", fast: false },
});
});
});
+223
View File
@@ -0,0 +1,223 @@
import { useCallback, useEffect, useState } from 'react';
import { getItem, setItem } from '../utils/storage';
const COOKIE_KEY = 'plannotator.agents';
export const DEFAULT_CLAUDE_MODEL = 'claude-opus-4-7';
export const DEFAULT_CLAUDE_EFFORT = 'high';
export const DEFAULT_CODEX_MODEL = 'gpt-5.3-codex';
export const DEFAULT_CODEX_REASONING = 'high';
export const DEFAULT_CODEX_FAST = false;
export const DEFAULT_TOUR_CLAUDE_MODEL = 'sonnet';
export const DEFAULT_TOUR_CLAUDE_EFFORT = 'medium';
export const DEFAULT_TOUR_CODEX_MODEL = 'gpt-5.3-codex';
export const DEFAULT_TOUR_CODEX_REASONING = 'medium';
export const DEFAULT_TOUR_CODEX_FAST = false;
interface ClaudeSection {
model: string;
perModel: Record<string, { effort: string }>;
}
interface CodexSection {
model: string;
perModel: Record<string, { reasoning: string; fast: boolean }>;
}
interface AgentSettingsState {
selectedProvider?: string;
tourEngine: 'claude' | 'codex';
claude: ClaudeSection;
codex: CodexSection;
tourClaude: ClaudeSection;
tourCodex: CodexSection;
}
const initialState: AgentSettingsState = {
tourEngine: 'claude',
claude: { model: DEFAULT_CLAUDE_MODEL, perModel: {} },
codex: { model: DEFAULT_CODEX_MODEL, perModel: {} },
tourClaude: { model: DEFAULT_TOUR_CLAUDE_MODEL, perModel: {} },
tourCodex: { model: DEFAULT_TOUR_CODEX_MODEL, perModel: {} },
};
// One-shot migration: drop any cached "none" codex reasoning entries. The
// dropdown no longer offers "None" (codex-rs rejects it as a config value);
// fall back to the default instead of shipping an invalid flag.
export function sanitizeCodexPerModel(
perModel: Record<string, { reasoning: string; fast: boolean }> | undefined,
): Record<string, { reasoning: string; fast: boolean }> {
if (!perModel) return {};
const out: Record<string, { reasoning: string; fast: boolean }> = {};
for (const [model, entry] of Object.entries(perModel)) {
if (!entry || typeof entry !== 'object') continue;
if (entry.reasoning === 'none') {
if (entry.fast) out[model] = { reasoning: DEFAULT_CODEX_REASONING, fast: true };
continue;
}
out[model] = entry;
}
return out;
}
function readCookie(): AgentSettingsState {
const raw = getItem(COOKIE_KEY);
if (!raw) return initialState;
try {
const parsed = JSON.parse(raw);
return {
selectedProvider: typeof parsed.selectedProvider === 'string' ? parsed.selectedProvider : undefined,
tourEngine: parsed.tourEngine === 'codex' ? 'codex' : 'claude',
claude: {
model: typeof parsed.claude?.model === 'string' ? parsed.claude.model : DEFAULT_CLAUDE_MODEL,
perModel: parsed.claude?.perModel ?? {},
},
codex: {
model: typeof parsed.codex?.model === 'string' ? parsed.codex.model : DEFAULT_CODEX_MODEL,
perModel: sanitizeCodexPerModel(parsed.codex?.perModel),
},
tourClaude: {
model: typeof parsed.tourClaude?.model === 'string' ? parsed.tourClaude.model : DEFAULT_TOUR_CLAUDE_MODEL,
perModel: parsed.tourClaude?.perModel ?? {},
},
tourCodex: {
model: typeof parsed.tourCodex?.model === 'string' ? parsed.tourCodex.model : DEFAULT_TOUR_CODEX_MODEL,
perModel: sanitizeCodexPerModel(parsed.tourCodex?.perModel),
},
};
} catch {
return initialState;
}
}
export function useAgentSettings() {
const [state, setState] = useState<AgentSettingsState>(readCookie);
useEffect(() => {
setItem(COOKIE_KEY, JSON.stringify(state));
}, [state]);
const setSelectedProvider = useCallback((id: string) => {
setState((s) => ({ ...s, selectedProvider: id }));
}, []);
const setTourEngine = useCallback((engine: 'claude' | 'codex') => {
setState((s) => ({ ...s, tourEngine: engine }));
}, []);
const setClaudeModel = useCallback((model: string) => {
setState((s) => ({ ...s, claude: { ...s.claude, model } }));
}, []);
const patchClaude = useCallback(
(section: 'claude' | 'tourClaude', patch: Partial<{ effort: string }>) => {
setState((s) => {
const cur = s[section];
const prev = cur.perModel[cur.model] ?? { effort: '' };
return {
...s,
[section]: {
...cur,
perModel: { ...cur.perModel, [cur.model]: { ...prev, ...patch } },
},
};
});
},
[],
);
const setClaudeEffort = useCallback(
(effort: string) => patchClaude('claude', { effort }),
[patchClaude],
);
const setCodexModel = useCallback((model: string) => {
setState((s) => ({ ...s, codex: { ...s.codex, model } }));
}, []);
const patchCodex = useCallback(
(
section: 'codex' | 'tourCodex',
patch: Partial<{ reasoning: string; fast: boolean }>,
defaults: { reasoning: string; fast: boolean },
) => {
setState((s) => {
const cur = s[section];
const prev = cur.perModel[cur.model] ?? defaults;
return {
...s,
[section]: {
...cur,
perModel: { ...cur.perModel, [cur.model]: { ...prev, ...patch } },
},
};
});
},
[],
);
const setCodexReasoning = useCallback(
(reasoning: string) => patchCodex('codex', { reasoning }, { reasoning: DEFAULT_CODEX_REASONING, fast: DEFAULT_CODEX_FAST }),
[patchCodex],
);
const setCodexFast = useCallback(
(fast: boolean) => patchCodex('codex', { fast }, { reasoning: DEFAULT_CODEX_REASONING, fast: DEFAULT_CODEX_FAST }),
[patchCodex],
);
const setTourClaudeModel = useCallback((model: string) => {
setState((s) => ({ ...s, tourClaude: { ...s.tourClaude, model } }));
}, []);
const setTourClaudeEffort = useCallback(
(effort: string) => patchClaude('tourClaude', { effort }),
[patchClaude],
);
const setTourCodexModel = useCallback((model: string) => {
setState((s) => ({ ...s, tourCodex: { ...s.tourCodex, model } }));
}, []);
const setTourCodexReasoning = useCallback(
(reasoning: string) => patchCodex('tourCodex', { reasoning }, { reasoning: DEFAULT_TOUR_CODEX_REASONING, fast: DEFAULT_TOUR_CODEX_FAST }),
[patchCodex],
);
const setTourCodexFast = useCallback(
(fast: boolean) => patchCodex('tourCodex', { fast }, { reasoning: DEFAULT_TOUR_CODEX_REASONING, fast: DEFAULT_TOUR_CODEX_FAST }),
[patchCodex],
);
const claudeEffort = state.claude.perModel[state.claude.model]?.effort ?? DEFAULT_CLAUDE_EFFORT;
const codexReasoning = state.codex.perModel[state.codex.model]?.reasoning ?? DEFAULT_CODEX_REASONING;
const codexFast = state.codex.perModel[state.codex.model]?.fast ?? DEFAULT_CODEX_FAST;
const tourClaudeEffort = state.tourClaude.perModel[state.tourClaude.model]?.effort ?? DEFAULT_TOUR_CLAUDE_EFFORT;
const tourCodexReasoning = state.tourCodex.perModel[state.tourCodex.model]?.reasoning ?? DEFAULT_TOUR_CODEX_REASONING;
const tourCodexFast = state.tourCodex.perModel[state.tourCodex.model]?.fast ?? DEFAULT_TOUR_CODEX_FAST;
return {
selectedProvider: state.selectedProvider,
tourEngine: state.tourEngine,
claudeModel: state.claude.model,
claudeEffort,
codexModel: state.codex.model,
codexReasoning,
codexFast,
tourClaudeModel: state.tourClaude.model,
tourClaudeEffort,
tourCodexModel: state.tourCodex.model,
tourCodexReasoning,
tourCodexFast,
setSelectedProvider,
setTourEngine,
setClaudeModel,
setClaudeEffort,
setCodexModel,
setCodexReasoning,
setCodexFast,
setTourClaudeModel,
setTourClaudeEffort,
setTourCodexModel,
setTourCodexReasoning,
setTourCodexFast,
};
}