mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
98113182b5
* feat(guide): reviewer-supplied extra instructions for Guided Review (#1265) Adds a quiet, collapsed-by-default Custom instructions affordance to the guide launch page. The text is APPENDED to the built-in organizer methodology as a clearly delimited section (composeGuideMethodology) and never replaces it; absent or blank instructions produce byte-identical prompts to before. Persisted in a dedicated cookie (plannotator-guide-instructions) so a standing team preference survives sessions without bloating the plannotator.agents blob past the browser's per-cookie limit. Server side, the launch body gains an optional guide-only instructions field (both the Bun and Pi node:http agent-jobs handlers accept and thread it); prompt composition lives in the shared guide-review.ts that vendor.sh already vendors to Pi, so both runtimes compose identically. Text is capped at GUIDE_EXTRA_INSTRUCTIONS_MAX_CHARS (2000) server-side and mirrored by the textarea maxLength. Repair launches deliberately ignore instructions: a repair is a mechanical JSON fix, not a rewrite. Tests pin the regression contract (empty input keeps prior prompt bytes), appended-not-replacing composition, the length cap, repair isolation, and the cookie round-trip via the storage backend seam. * refactor(guide): store standing instructions server-side, not in a cookie Review findings on the cookie approach (silent write failure past the encoded 4KB per-cookie limit for multi-byte text) pointed at the real design problem: the instructions are consumed by the SERVER at launch time, so they belong in the data dir like review-skills.json, where no size ceiling or encoding inflation exists and the preference follows the machine instead of one browser profile. New GET/PUT /api/agents/guide-instructions in both runtimes backed by shared guide-instructions-store (vendored to Pi). Guide launches apply the stored text when the body carries none; the launch page still sends its live textarea value (explicit wins), so a just-typed preference can never race the debounced save. The sidebar surface sends nothing and inherits the stored text server-side. All cookie machinery removed. Also folds in the review fixes: marker-tag-shaped strings in instructions are defanged so first-match nonce recovery cannot be hijacked by pasted examples.
601 lines
26 KiB
TypeScript
601 lines
26 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||
import type { AgentCapabilities } from '@plannotator/ui/types';
|
||
import { GUIDE_EXTRA_INSTRUCTIONS_MAX_CHARS } from '@plannotator/shared/guide';
|
||
import type { SavedGuideListEntry } from '@plannotator/shared/guide';
|
||
import type { AgentLaunchParams } from '@plannotator/ui/hooks/useAgentJobs';
|
||
import type { ReviewEngine } from '@plannotator/ui/hooks/useAgentSettings';
|
||
// Same catalogs AgentsTab's launch panel uses — one source of truth for both
|
||
// guide launch surfaces (this page and the sidebar's Guided Review mode).
|
||
import {
|
||
TOUR_CLAUDE_MODELS,
|
||
CLAUDE_EFFORT,
|
||
CODEX_MODELS,
|
||
codexReasoningOptions,
|
||
PI_THINKING,
|
||
REVIEW_ENGINE_LABEL,
|
||
} from '@plannotator/ui/components/AgentsTab';
|
||
import { groupModelOptions, labelWithinGroup, SEARCHABLE_THRESHOLD } from '@plannotator/ui/components/AgentControls';
|
||
import { useGuideLaunch } from '../../hooks/guide/useGuideLaunch';
|
||
|
||
type Option = { value: string; label: string };
|
||
|
||
/** "2d ago" style age for the previous-guides rows (local convention — same
|
||
* shape as PRCommentsTab's formatRelativeTime, taking epoch ms instead). */
|
||
function formatSavedAge(savedAt: number): string {
|
||
if (!savedAt) return '';
|
||
const diff = Date.now() - savedAt;
|
||
const minutes = Math.floor(diff / 60000);
|
||
const hours = Math.floor(minutes / 60);
|
||
const days = Math.floor(hours / 24);
|
||
if (minutes < 1) return 'just now';
|
||
if (minutes < 60) return `${minutes}m ago`;
|
||
if (hours < 24) return `${hours}h ago`;
|
||
if (days < 30) return `${days}d ago`;
|
||
return new Date(savedAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||
}
|
||
|
||
/** Inline "Label: Value ▾" picker — the wireframe's compact select pill.
|
||
* Long catalogs (marker-engine model lists can run to hundreds) automatically
|
||
* gain a type-to-filter input, a wider popover, and provider group headers. */
|
||
function InlinePicker({
|
||
label,
|
||
value,
|
||
options,
|
||
onChange,
|
||
}: {
|
||
label: string;
|
||
value: string;
|
||
options: Option[];
|
||
onChange: (v: string) => void;
|
||
}) {
|
||
const [open, setOpen] = useState(false);
|
||
const [query, setQuery] = useState('');
|
||
const current = options.find((o) => o.value === value);
|
||
const searchable = options.length > SEARCHABLE_THRESHOLD;
|
||
|
||
const q = query.trim().toLowerCase();
|
||
const filtered = q
|
||
? options.filter((o) => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q))
|
||
: options;
|
||
const grouped = groupModelOptions(filtered);
|
||
|
||
const close = () => {
|
||
setOpen(false);
|
||
setQuery('');
|
||
};
|
||
const select = (v: string) => {
|
||
onChange(v);
|
||
close();
|
||
};
|
||
|
||
return (
|
||
<div className="relative">
|
||
<button
|
||
type="button"
|
||
onClick={() => (open ? close() : setOpen(true))}
|
||
className="flex items-center gap-2 rounded-md border border-border/50 bg-background px-2.5 py-1.5 text-xs transition-colors hover:border-border"
|
||
>
|
||
<span className="text-muted-foreground">{label}:</span>
|
||
<span className="max-w-[260px] truncate text-foreground">{current?.label ?? value}</span>
|
||
<ChevronDown className={`text-muted-foreground/40 transition-transform ${open ? 'rotate-180' : ''}`} size={11} />
|
||
</button>
|
||
{open && (
|
||
<>
|
||
<div className="fixed inset-0 z-10" onClick={close} />
|
||
<div
|
||
className={`absolute left-0 top-full z-20 mt-1 rounded-lg border border-border/50 bg-popover shadow-xl ${
|
||
searchable ? 'w-[340px]' : 'min-w-[140px]'
|
||
}`}
|
||
>
|
||
{searchable && (
|
||
<div className="p-1 pb-0">
|
||
<input
|
||
autoFocus
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
e.stopPropagation();
|
||
if (e.key === 'Escape') close();
|
||
if (e.key === 'Enter' && filtered.length > 0) select(filtered[0].value);
|
||
}}
|
||
placeholder="Type to filter…"
|
||
className="w-full rounded-md border border-border/40 bg-background px-2.5 py-1.5 text-xs text-foreground outline-none placeholder:text-muted-foreground/40 focus:border-border"
|
||
/>
|
||
</div>
|
||
)}
|
||
<div className={`overflow-y-auto p-1 ${searchable ? 'max-h-80' : 'max-h-56'}`}>
|
||
{filtered.length === 0 && (
|
||
<div className="px-2.5 py-2 text-xs text-muted-foreground/50">No matches</div>
|
||
)}
|
||
{grouped.map((group) => (
|
||
<React.Fragment key={group.label ?? '__flat'}>
|
||
{group.label && (
|
||
<div className="px-2.5 pb-0.5 pt-1.5 font-mono text-[9px] uppercase tracking-wider text-muted-foreground/40">
|
||
{group.label}
|
||
</div>
|
||
)}
|
||
{group.options.map((o) => (
|
||
<button
|
||
key={o.value}
|
||
type="button"
|
||
onClick={() => select(o.value)}
|
||
className={`flex w-full items-center rounded-md px-2.5 py-1.5 text-left text-xs transition-colors ${
|
||
value === o.value ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'
|
||
}`}
|
||
>
|
||
<span className="min-w-0 flex-1 truncate">{labelWithinGroup(o.label, group.label)}</span>
|
||
</button>
|
||
))}
|
||
</React.Fragment>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface GuideFailure {
|
||
jobId: string;
|
||
engine?: string;
|
||
error: string;
|
||
}
|
||
|
||
interface GuideEmptyStateProps {
|
||
capabilities: AgentCapabilities | null;
|
||
launchJob: (params: AgentLaunchParams) => Promise<unknown>;
|
||
/** Return to the normal diff workspace (closes the takeover). */
|
||
onBack: () => void;
|
||
/** Set when the most recent guide job failed (or was killed) rather than
|
||
* never having been launched — rendered as a recovery panel above the
|
||
* launch controls: the failure reason, an optional "Fix output" repair
|
||
* launch (offered once a captured payload is confirmed via the output
|
||
* probe below), and an editable "Show output" disclosure for manually
|
||
* correcting and resubmitting that payload. */
|
||
failure?: GuideFailure;
|
||
/** Navigate to a guide by job id once a manually-fixed output is accepted by
|
||
* the server (POST /api/guide/:jobId/submit → 200). */
|
||
onOpenFixedGuide?: (jobId: string) => void;
|
||
/** Open a persisted guide from the previous-guides list — receives the
|
||
* `saved:{id}` pseudo job id understood by the guide endpoints (#1112). */
|
||
onOpenSavedGuide?: (jobId: string) => void;
|
||
}
|
||
|
||
/**
|
||
* Guided Review's landing page — shown when there is no completed or running
|
||
* guide job yet. A clean, roomy, Notion-like page: heading, one paragraph, a
|
||
* quiet "Model defaults" card with inline Engine / Model / Effort pickers
|
||
* (the same persisted guide settings AgentsTab's launch panel edits), and a
|
||
* primary Generate button.
|
||
*/
|
||
export const GuideEmptyState: React.FC<GuideEmptyStateProps> = ({ capabilities, launchJob, onBack, failure, onOpenFixedGuide, onOpenSavedGuide }) => {
|
||
// Engine/model resolution + launch-param shapes are shared with GuideView's
|
||
// "Regenerate" hint via useGuideLaunch — this component keeps only the
|
||
// picker UI on top of it.
|
||
const launch = useGuideLaunch(capabilities);
|
||
const {
|
||
guideClaudeModel,
|
||
guideClaudeEffort,
|
||
guideCodexModel,
|
||
guideCodexReasoning,
|
||
guidePiThinking,
|
||
setGuideEngine,
|
||
setGuideClaudeModel,
|
||
setGuideClaudeEffort,
|
||
setGuideCodexModel,
|
||
setGuideCodexReasoning,
|
||
setGuideCursorModel,
|
||
setGuideOpencodeModel,
|
||
setGuidePiModel,
|
||
setGuidePiThinking,
|
||
setGuideCopilotModel,
|
||
} = launch.settings;
|
||
|
||
const [launching, setLaunching] = useState(false);
|
||
const [launchError, setLaunchError] = useState<string | null>(null);
|
||
|
||
// Extra instructions (#1265): a quiet, collapsed-by-default disclosure below
|
||
// the Model defaults card. Persisted via its own cookie (write-through on
|
||
// every edit) so a standing team preference never needs retyping; launches
|
||
// read the persisted value fresh (useGuideLaunch.buildParams), so this local
|
||
// state is only the textarea's view of it.
|
||
const [instructions, setInstructions] = useState('');
|
||
const [showInstructions, setShowInstructions] = useState(false);
|
||
const hasInstructions = instructions.trim().length > 0;
|
||
// Server-stored (#1265, GET/PUT /api/agents/guide-instructions): the text
|
||
// is consumed by the server at launch time, and a data-dir file has none
|
||
// of a cookie's size ceiling. Saves are debounced; launches carry the LIVE
|
||
// textarea value (explicit wins server-side), so a just-typed preference
|
||
// can never race a pending save.
|
||
const instructionsRef = React.useRef('');
|
||
const instructionsSaveTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
const putInstructions = (value: string) => {
|
||
void fetch('/api/agents/guide-instructions', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ instructions: value }),
|
||
}).catch(() => {});
|
||
};
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
fetch('/api/agents/guide-instructions')
|
||
.then((r) => (r.ok ? r.json() : null))
|
||
.then((data: { instructions?: unknown } | null) => {
|
||
if (!cancelled && data && typeof data.instructions === 'string') {
|
||
setInstructions(data.instructions);
|
||
instructionsRef.current = data.instructions;
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
return () => {
|
||
cancelled = true;
|
||
// Flush a pending debounced save so the last keystrokes before
|
||
// navigating away still persist for the next session.
|
||
if (instructionsSaveTimer.current) {
|
||
clearTimeout(instructionsSaveTimer.current);
|
||
instructionsSaveTimer.current = null;
|
||
putInstructions(instructionsRef.current);
|
||
}
|
||
};
|
||
}, []);
|
||
const handleInstructionsChange = (value: string) => {
|
||
setInstructions(value);
|
||
instructionsRef.current = value;
|
||
if (instructionsSaveTimer.current) clearTimeout(instructionsSaveTimer.current);
|
||
instructionsSaveTimer.current = setTimeout(() => {
|
||
instructionsSaveTimer.current = null;
|
||
putInstructions(value);
|
||
}, 400);
|
||
};
|
||
|
||
// Previous guides persisted for this repo (#1112). Null until the list
|
||
// resolves; an empty list hides the section entirely. Standalone/demo mode
|
||
// has no backend — the fetch failure leaves the section hidden.
|
||
const [savedGuides, setSavedGuides] = useState<SavedGuideListEntry[] | null>(null);
|
||
useEffect(() => {
|
||
let alive = true;
|
||
fetch('/api/guides')
|
||
.then((res) => (res.ok ? res.json() : null))
|
||
.then((data: SavedGuideListEntry[] | null) => {
|
||
if (alive && Array.isArray(data)) setSavedGuides(data);
|
||
})
|
||
.catch(() => {});
|
||
return () => {
|
||
alive = false;
|
||
};
|
||
}, []);
|
||
|
||
const handleDeleteSaved = async (id: string) => {
|
||
// Optimistic removal — a failed delete just resurfaces on the next open.
|
||
setSavedGuides((prev) => (prev ? prev.filter((g) => g.id !== id) : prev));
|
||
try {
|
||
await fetch(`/api/guides/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||
} catch {
|
||
// Best-effort.
|
||
}
|
||
};
|
||
|
||
// Failure-recovery panel state (only relevant when `failure` is set — see
|
||
// GuideScreen, which remounts this component fresh, keyed on jobId, for
|
||
// each failed guide job so this state never leaks across failures).
|
||
//
|
||
// `capturedPayload` doubles as the probe result: null means "not yet probed,
|
||
// or the server has nothing captured for this job" (404) — either way,
|
||
// "Fix output" and the output editor stay hidden until it resolves to a
|
||
// string.
|
||
const [capturedPayload, setCapturedPayload] = useState<string | null>(null);
|
||
const [editedPayload, setEditedPayload] = useState('');
|
||
const [showOutput, setShowOutput] = useState(false);
|
||
const [repairing, setRepairing] = useState(false);
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (!failure) return;
|
||
let alive = true;
|
||
fetch(`/api/guide/${encodeURIComponent(failure.jobId)}/output`)
|
||
.then(async (res) => {
|
||
if (!alive || !res.ok) return;
|
||
const data = await res.json().catch(() => null);
|
||
if (data && typeof data.payload === 'string') {
|
||
setCapturedPayload(data.payload);
|
||
setEditedPayload(data.payload);
|
||
}
|
||
})
|
||
.catch(() => {
|
||
// 404/network error ⇒ no captured output to offer — leave hidden.
|
||
});
|
||
return () => {
|
||
alive = false;
|
||
};
|
||
}, [failure?.jobId]);
|
||
|
||
const handleFixOutput = async () => {
|
||
if (!failure || repairing) return;
|
||
setRepairing(true);
|
||
setLaunchError(null);
|
||
try {
|
||
await launchJob({
|
||
provider: 'guide',
|
||
label: 'Guide Repair',
|
||
repairOf: failure.jobId,
|
||
...(failure.engine ? { engine: failure.engine } : {}),
|
||
});
|
||
// A successful launch lands as a new running guide job — GuideScreen's
|
||
// existing running-job branch takes over from here automatically.
|
||
} catch (err) {
|
||
setLaunchError(err instanceof Error ? err.message : 'Could not start the repair.');
|
||
} finally {
|
||
setRepairing(false);
|
||
}
|
||
};
|
||
|
||
const handleSubmitFixedOutput = async () => {
|
||
if (!failure || submitting) return;
|
||
setSubmitting(true);
|
||
setSubmitError(null);
|
||
try {
|
||
const res = await fetch(`/api/guide/${encodeURIComponent(failure.jobId)}/submit`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ payload: editedPayload }),
|
||
});
|
||
if (res.ok) {
|
||
onOpenFixedGuide?.(failure.jobId);
|
||
return;
|
||
}
|
||
const data = await res.json().catch(() => ({}));
|
||
setSubmitError(typeof data.error === 'string' ? data.error : 'Could not submit the fixed output.');
|
||
} catch (err) {
|
||
setSubmitError(err instanceof Error ? err.message : 'Could not submit the fixed output.');
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const {
|
||
guideAvailable,
|
||
availableEngines,
|
||
engine,
|
||
cursorOptions,
|
||
opencodeOptions,
|
||
piOptions,
|
||
copilotOptions,
|
||
effectiveCursorModel,
|
||
effectiveOpencodeModel,
|
||
effectivePiModel,
|
||
effectiveCopilotModel,
|
||
} = launch;
|
||
|
||
const modelPicker: { value: string; options: Option[]; onChange: (v: string) => void } =
|
||
engine === 'claude'
|
||
? { value: guideClaudeModel, options: TOUR_CLAUDE_MODELS, onChange: setGuideClaudeModel }
|
||
: engine === 'codex'
|
||
? { value: guideCodexModel, options: CODEX_MODELS, onChange: setGuideCodexModel }
|
||
: engine === 'cursor'
|
||
? { value: effectiveCursorModel, options: cursorOptions, onChange: setGuideCursorModel }
|
||
: engine === 'opencode'
|
||
? { value: effectiveOpencodeModel, options: opencodeOptions, onChange: setGuideOpencodeModel }
|
||
: engine === 'copilot'
|
||
? { value: effectiveCopilotModel, options: copilotOptions, onChange: setGuideCopilotModel }
|
||
: { value: effectivePiModel, options: piOptions, onChange: setGuidePiModel };
|
||
|
||
const canLaunch = launch.canLaunch && !launching;
|
||
|
||
const handleGenerate = async () => {
|
||
if (!canLaunch) return;
|
||
setLaunching(true);
|
||
setLaunchError(null);
|
||
// Launch-param shapes live in useGuideLaunch (shared with GuideView's
|
||
// Regenerate hint) and mirror AgentsTab's buildGuideLaunch exactly.
|
||
const params: AgentLaunchParams = launch.buildParams(instructions);
|
||
try {
|
||
await launchJob(params);
|
||
} catch (err) {
|
||
setLaunchError(err instanceof Error ? err.message : 'Could not start the guide.');
|
||
} finally {
|
||
setLaunching(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
// Full-width page: this content sits in the header zone (where the guide
|
||
// title lands once generated) rather than a centered island.
|
||
<div className="w-full px-10 py-8">
|
||
<h1 className="text-[22px] font-semibold tracking-tight text-foreground">Start a guided review?</h1>
|
||
<p className="mt-2 max-w-[72ch] text-[13px] leading-relaxed text-muted-foreground">
|
||
Large changesets are hard to even start reading. A guide orders this one by
|
||
importance — the core of the change first, glue and low-signal edits last — so
|
||
you can understand it in one sitting. Each chapter tells you what changed, why
|
||
it exists, and what it implies, right next to the diffs, ready to annotate.
|
||
</p>
|
||
|
||
{failure && (
|
||
<div className="mt-6 max-w-[820px]">
|
||
<div className="w-fit max-w-full rounded-md border border-destructive/20 bg-destructive/10 px-3 py-2 text-xs leading-snug text-destructive">
|
||
{failure.error}
|
||
</div>
|
||
|
||
{capturedPayload !== null && (
|
||
<div className="mt-2.5 flex items-center gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={handleFixOutput}
|
||
disabled={repairing}
|
||
className="rounded-md border border-border/50 px-2.5 py-1.5 text-[11.5px] font-medium text-foreground transition-colors hover:bg-muted/40 disabled:cursor-not-allowed disabled:opacity-50"
|
||
>
|
||
{repairing ? 'Starting repair…' : 'Fix output'}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowOutput((v) => !v)}
|
||
aria-expanded={showOutput}
|
||
className="flex items-center gap-1 rounded-md px-2 py-1.5 text-[11.5px] text-muted-foreground/70 transition-colors hover:text-foreground"
|
||
>
|
||
<ChevronRight className={`transition-transform ${showOutput ? 'rotate-90' : ''}`} size={12} />
|
||
{showOutput ? 'Hide output' : 'Show output'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{showOutput && capturedPayload !== null && (
|
||
<div className="mt-2.5 w-full">
|
||
<textarea
|
||
value={editedPayload}
|
||
onChange={(e) => setEditedPayload(e.target.value)}
|
||
spellCheck={false}
|
||
className="h-[320px] w-full resize-none overflow-y-auto rounded-md border border-border/50 bg-background p-2.5 font-mono text-[11px] leading-relaxed text-foreground outline-none focus:border-border"
|
||
/>
|
||
<div className="mt-2 flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={handleSubmitFixedOutput}
|
||
disabled={submitting}
|
||
className="rounded-md bg-primary px-3 py-1.5 text-[11.5px] font-semibold text-primary-foreground transition-colors hover:bg-primary/90 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-50"
|
||
>
|
||
{submitting ? 'Submitting…' : 'Submit fixed output'}
|
||
</button>
|
||
</div>
|
||
{submitError && <p className="mt-1.5 text-[11px] leading-snug text-destructive/80">{submitError}</p>}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{!guideAvailable || availableEngines.length === 0 ? (
|
||
<p className="mt-8 text-xs text-muted-foreground/70">
|
||
Guided review needs an agent CLI (Claude, Codex, Cursor, OpenCode, Pi, or Copilot) available on this machine.
|
||
</p>
|
||
) : (
|
||
<>
|
||
<div className="mt-6 w-fit max-w-full rounded-lg border border-border/50 bg-card/50 px-4 py-3.5">
|
||
<div className="mb-2.5 font-mono text-[10px] uppercase tracking-widest text-muted-foreground/60">
|
||
Model defaults
|
||
</div>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<InlinePicker
|
||
label="Engine"
|
||
value={engine}
|
||
options={availableEngines.map((e) => ({ value: e, label: REVIEW_ENGINE_LABEL[e] }))}
|
||
onChange={(v) => setGuideEngine(v as ReviewEngine)}
|
||
/>
|
||
<InlinePicker label="Model" {...modelPicker} />
|
||
{engine === 'claude' && (
|
||
<InlinePicker label="Effort" value={guideClaudeEffort} options={CLAUDE_EFFORT} onChange={setGuideClaudeEffort} />
|
||
)}
|
||
{engine === 'codex' && (
|
||
<InlinePicker label="Reasoning" value={guideCodexReasoning} options={codexReasoningOptions(guideCodexModel)} onChange={setGuideCodexReasoning} />
|
||
)}
|
||
{engine === 'pi' && (
|
||
<InlinePicker label="Thinking" value={guidePiThinking} options={PI_THINKING} onChange={setGuidePiThinking} />
|
||
)}
|
||
</div>
|
||
<p className="mt-2.5 text-[11px] leading-snug text-muted-foreground/60">
|
||
Newer models with lower effort are recommended — guides generate quicker.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="mt-3">
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowInstructions((v) => !v)}
|
||
aria-expanded={showInstructions}
|
||
className="flex items-center gap-1 rounded-md px-1 py-1 text-[11.5px] text-muted-foreground/70 transition-colors hover:text-foreground"
|
||
>
|
||
<ChevronRight className={`transition-transform ${showInstructions ? 'rotate-90' : ''}`} size={12} />
|
||
Custom instructions
|
||
{hasInstructions && !showInstructions && (
|
||
<span className="ml-1 rounded border border-border/50 bg-muted/40 px-1 py-px font-mono text-[9px] uppercase tracking-wider text-muted-foreground/60">
|
||
on
|
||
</span>
|
||
)}
|
||
</button>
|
||
{showInstructions && (
|
||
<div className="mt-1.5 max-w-[560px]">
|
||
<textarea
|
||
value={instructions}
|
||
onChange={(e) => handleInstructionsChange(e.target.value)}
|
||
maxLength={GUIDE_EXTRA_INSTRUCTIONS_MAX_CHARS}
|
||
rows={3}
|
||
spellCheck={false}
|
||
placeholder={'Standing preferences for guide generation, e.g. "prefer product vocabulary over internal codenames".'}
|
||
className="w-full resize-y rounded-md border border-border/50 bg-background p-2.5 text-xs leading-relaxed text-foreground outline-none placeholder:text-muted-foreground/40 focus:border-border"
|
||
/>
|
||
<p className="mt-1 text-[11px] leading-snug text-muted-foreground/60">
|
||
Added on top of the built-in guide methodology for every guide you generate. Saved for next time.
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="mt-4 flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={handleGenerate}
|
||
disabled={!canLaunch}
|
||
className="rounded-md bg-primary px-4 py-2 text-[12.5px] font-semibold text-primary-foreground transition-colors hover:bg-primary/90 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-50"
|
||
>
|
||
{launching ? 'Starting…' : failure ? 'Regenerate guide' : 'Generate guide'}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={onBack}
|
||
className="rounded-md border border-border/50 px-3 py-2 text-[12.5px] text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground"
|
||
>
|
||
Back to diff
|
||
</button>
|
||
</div>
|
||
{launchError && <p className="mt-2 text-xs leading-snug text-destructive/80">{launchError}</p>}
|
||
</>
|
||
)}
|
||
|
||
{savedGuides && savedGuides.length > 0 && (
|
||
<div className="mt-10 max-w-[820px]">
|
||
<div className="mb-2.5 font-mono text-[10px] uppercase tracking-widest text-muted-foreground/60">
|
||
Previous guides
|
||
</div>
|
||
<div className="divide-y divide-border/30 overflow-hidden rounded-lg border border-border/50 bg-card/50">
|
||
{savedGuides.map((entry) => (
|
||
<div key={entry.id} className="group flex items-center gap-3 px-3.5 py-2.5">
|
||
<button
|
||
type="button"
|
||
onClick={() => onOpenSavedGuide?.(`saved:${entry.id}`)}
|
||
className="flex min-w-0 flex-1 items-center gap-3 text-left"
|
||
>
|
||
<span className="shrink-0 rounded border border-border/50 bg-muted/40 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
||
{entry.label}
|
||
</span>
|
||
<span className="min-w-0 flex-1 truncate text-[12.5px] text-foreground transition-colors group-hover:text-primary">
|
||
{entry.title}
|
||
</span>
|
||
<span className="shrink-0 font-mono text-[10.5px] text-muted-foreground/60">
|
||
{formatSavedAge(entry.savedAt)}
|
||
</span>
|
||
<span className="shrink-0 font-mono text-[10.5px] text-muted-foreground/60">
|
||
{entry.progress.reviewed}/{entry.progress.total}
|
||
</span>
|
||
{entry.moved && (
|
||
<span className="shrink-0 font-mono text-[10px] text-amber-600/80 dark:text-amber-500/70" title="The diff has changed since this guide was generated">
|
||
diff changed
|
||
</span>
|
||
)}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => handleDeleteSaved(entry.id)}
|
||
aria-label={`Delete saved guide "${entry.title}"`}
|
||
className="shrink-0 rounded px-1 text-sm leading-none text-muted-foreground/40 transition-colors hover:text-destructive"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|