feat: plan checkpointing with rewind UI (#694)
* feat(backend): plan revision history with checkpoint/revert
Persist every plan edit as an immutable TaskPlanRevision with author
attribution (agent vs user) and expose WS actions to list, fetch, and
revert to past revisions. Emits task_plan.revision.created and
task_plan.reverted events so the UI can react in real time.
* feat(web): plan revision history UI with rewind to past versions
Add a rewind button to the plan panel that opens a revisions list
showing each checkpoint with author (agent name or user) and timestamp,
and lets the user restore any past revision as the new head. Plan
state, hooks, and WS handlers are extended to load, cache, and react
to revision-created/reverted events.
* test(web): e2e coverage for plan checkpointing and rewind
Cover the full revision flow: plan edit creates a checkpoint,
the revisions panel lists authors and timestamps, and reverting
restores the selected revision as the new head. Extends session
page object and backend fixtures with revision-related helpers.
* fix(web): stabilize plan revisions state and avoid re-render loop
Zustand selector returned a fresh [] for missing revisions, fanning out
re-renders that tripped React's infinite-loop guard when the plan panel
mounted. Swap to a frozen empty-array sentinel, auto-load revisions on
mount so WS events fired before connect aren't lost, and gate the
popover's on-open fetch so reopening doesn't re-sort rows mid-click.
* test(web): make plan checkpointing e2e resilient to agent auto-layout
Agent-authored plan writes auto-open the plan panel and can push the
chat tab inactive, so the chat idle placeholder isn't always visible
once the agent finishes. Accept either chat or plan panel as the idle
signal, make openPlanPanel and openRewind idempotent, reopen the
popover after a revert cancel (the confirm dialog closes it), and drop
the toast assertion since no Toaster is mounted in the app.
* fix(backend): atomic plan revision write + review polish
- Add `WritePlanRevision(ctx, head, rev, coalesceLatestID)` repository method
that wraps HEAD upsert + MAX+1 compute + insert/merge in a single write tx,
closing the TOCTOU race on `revision_number` and partial-failure divergence
between HEAD and history. `upsertPlan` and `RevertPlan` both route through it.
- Add `ErrRevisionIDRequired`; return it for empty `TargetRevisionID` (was
previously conflated with `ErrRevisionNotFound`). Handler maps to validation.
- Backfill fallback `author_kind` changed `"user"` → `"agent"` to match
`CreateTaskPlan` and the column default.
- Extract shared `revisionSelectCols` constant; rename `max` local (shadowed
Go builtin) and `dto` local (shadowed package name).
* fix(web): address plan-revisions review feedback
- `mcpWrite` uses `JSON.stringify(content).slice(1, -1)` to escape backslashes,
quotes, and control chars in one pass — closes the CodeQL finding.
- `clearTaskPlan` now evicts `revisionContentCache` entries for the task's
revisions before dropping the maps, preventing orphaned cache growth.
- `RevertConfirmDialog` wraps `onRevert` in try/catch/finally so rejections
surface a toast and the dialog always dismisses.
- Hoist `TaskPlanRevision` import in `task-plan-panel.tsx`.
- `RevisionRow` derives relative time from props every render with a 30s tick,
so prop changes refresh immediately.
- Strengthen author-switch e2e with `.not.toBeEmpty()` to also reject empty or
truncated renders.
* fix(web): evict revision content cache on coalesced update
Coalesced writes update an existing revision's content on the backend, but
the WS `upsertPlanRevision` payload carries only metadata. Without eviction,
a cached preview of the revision stays stale after the agent re-writes the
plan within the coalesce window.
* fix(web): plan history UI polish from local testing
- Drop the redundant "Plan" title in the panel header bar; the dockview/mobile
tab already labels the panel.
- Author display: show "You" with the user icon for user-authored revisions,
and the agent profile name with the AgentLogo (resolved from the active
session's `agent_profile_snapshot.agent_name`) for agent revisions, matching
the icon used elsewhere in the app.
- Replace ScrollArea with a plain `max-h-96 overflow-y-auto` div so the
popover stays bounded when revision counts grow past ~7 rows.
- Drop the em dash from the revert dialog description.
* feat(web): add lineDiff helper for plan revision compare
Tiny wrapper around the `diff` package that classifies each line as add /
remove / context and strips the trailing-newline phantom entry. Also exposes
`diffSummary` for the dialog header counts. Pure functions; backed by Vitest.
* feat(web): plan revision preview + compare state
Adds two task-scoped store keys for the new dialogs:
- `previewRevisionIdByTaskId` — at most one open preview per task; cleared on
`clearTaskPlan`.
- `comparePairByTaskId` — ordered 2-slot pair. `toggleComparePair` reuses a
`nextPair` helper that handles unselect (already-picked id), fill (open
slot), and FIFO eviction (full pair). `clearTaskPlan` resets it too.
* feat(web): plan revision preview + diff dialogs
- `useTaskPlan` exposes `previewRevisionId`, `setPreviewRevision`,
`comparePair`, `toggleCompareSelection`, `clearComparePair` (extracted into
`usePreviewCompareState` to keep the surrounding hook within the size cap).
- New `PlanRevisionPreviewDialog`: read-only modal for a single revision with
Restore + "Compare with current" CTAs. Restore routes through the existing
RevertConfirmDialog so the confirmation copy stays consistent.
- New `PlanRevisionDiffDialog`: line-level unified diff between two revisions
with adds/removes highlighted; orders the pair by revision_number so the
older one is always "before"; Restore targets the older side.
- Popover row split into a clickable body (opens preview), a Compare toggle
(icon button per row), and the existing Revert button. Compare chips appear
at the top of the popover with a Diff CTA when both slots are filled.
- Both dialogs are remounted via key on revision/pair change so loader state
resets without ESLint-flagged setState-in-effect resets.
* test(web): e2e coverage for plan preview and compare-diff dialogs
Extends the SessionPage POM with locators for the new preview and diff
dialogs (chips, compare toggle, restore buttons, body + summary). Adds two
scenarios in plan-checkpointing.spec.ts:
- Click a revision row body → preview shows the right content; the Restore
CTA opens the existing confirm dialog and the revert lands.
- Toggle compare on two revisions → diff opens with v1 → v2 ordering and
shows at least one add and one remove line; the Restore CTA targets the
older side.
* feat(web): render markdown in plan preview + diff dialogs
Adds a shared `PlanReadOnlyMarkdown` Tiptap renderer that reuses the same
StarterKit + tiptap-markdown + tables/tasks/code-block extensions as the live
plan editor (minus interactive bits — slash menu, drag handles, comment
marks). Wired into both dialogs:
- Preview replaces the raw `<pre>` block with the rendered Tiptap view.
- Diff dialog now stacks two rendered sides (Before / After) with red/green
tinted borders, kept above the existing line-level breakdown so the user
gets both rendered comparison and an exact textual change list.
Tiptap doesn't ship a free diff/track-changes extension (it's part of Tiptap
Pro), so the side-by-side rendering plus the line-level list together stand
in for that.
* fix(web): plan diff dialog — text-only with unified/split modes
Following local feedback:
- Drop the rendered-markdown side-by-side panes from the diff dialog. Diff
view is text-only again, like a code review.
- Add a Unified / Split mode toggle. Split mode aligns each remove with the
adjacent add on the same row (single-line edits stay paired) and stripes
empty cells where one side has nothing.
- Fix the highlight-truncation bug: long lines used to lose their tinted
background past the viewport on horizontal scroll because the row width
was capped to the visible area. Rows now use `min-w-full w-max` so the
background extends through the full scroll width.
Preview dialog keeps the rendered Tiptap markdown — only the diff reverted
to plain text.
E2E covers both modes and the cell kinds.
* feat(web): formatPreciseTime util for minute-level timestamps
Returns 'just now', 'Xs ago', 'Xm ago' for the first hour, then 'Today, HH:MM',
'Yesterday, HH:MM', or '{Mon} {DD}[, YYYY], HH:MM' for older entries — keeps
exact minute-of-day visible instead of decaying to 'yesterday'/'2d ago'.
* fix(web): render markdown via setContent in plan read-only renderer
The tiptap-markdown extension's parser only fires on `setContent` calls, not
on the initial `content` option. Passing `content: value` to `useEditor` was
treating the markdown source as raw text, so the preview dialog rendered
unparsed markdown.
Drop the `content` option and instead call
`editor.commands.setContent(content, { emitUpdate: false })` from a useEffect
once the editor is created — which is what the live plan editor effectively
relies on through tiptap-markdown's setContent override.
* fix(web): plan history UX — bigger modals, split layout, simpler compare
- Bump dialog widths: preview now `max-w-5xl w-[92vw]`, diff
`max-w-6xl w-[95vw]`, both with taller max-height. Plenty of room for
rendered markdown and side-by-side diffs.
- Replace the table-based split layout with a CSS grid (`grid-cols-2 w-max`).
The table-fixed cells were overflowing into each other when content was
wider than half the dialog; the grid lets the natural width drive a single
horizontal scrollbar on the parent and keeps the two columns properly
separated.
- Switch row timestamps to `formatPreciseTime` so users see minute-of-day
("Today, 14:32", "Yesterday, 09:14", "Apr 21, 11:08") instead of decaying
to "yesterday".
- Simplify the compare flow: drop the per-row Compare toggle, the chip strip
at the top of the popover, and the standalone "Diff" button. Compare is
now a side effect of preview — open any version, then click "Compare with
previous" or "Compare with current". E2E test updated to use the new path.
* test(web): update plan checkpointing e2e for the new compare flow
Drops the locators for the row Compare toggle, chips, and standalone Diff
button (those UI bits are gone). Adds a locator for the new
"Compare with previous" preview button. The compare scenario now opens the
diff via preview → "Compare with current" instead of the old chip flow.
* fix(web): preview renderer reuses live editor's markdown styles
`globals.css` keeps heading sizes / list spacing / code-block padding under
.markdown-body and .tiptap-plan-wrapper, and the live editor opts in by
applying both classes (plus a conditional .dark for theming). The read-only
renderer was using `prose prose-sm` instead, which doesn't actually take
effect on the .ProseMirror node — leaving everything the same size.
Switch the read-only renderer to the same class set, follow the live
editor's `useTheme` -> `dark` pattern, override `!h-auto` so the renderer
sizes to its content, and trim the editor's default 0.75rem padding inside
the dialog body.
* fix(web): split diff has fixed width with synced horizontal scroll
The previous split layout grew the whole dialog wider when content overflowed
which felt wrong. Now:
- The dialog stays at its fixed width.
- Each pane has its own `overflow-x-auto` so each side is capped at half the
dialog and gets its own horizontal scrollbar.
- A scroll listener keeps both panes' `scrollLeft` in lockstep (with a
re-entrancy guard so the bounce-back doesn't form a loop) — corresponding
rows stay aligned visually no matter which side the user scrolls from.
- The diff body's overflow-x is hidden in split mode (the panes own it) and
only overflow-y stays on the body so vertical scroll covers both panes
at once.
* feat(backend): resolve agent profile name from session for plan revisions
The MCP-side handlers (handleCreateTaskPlan / handleUpdateTaskPlan) don't
carry the agent's display name in the request, so without a lookup every
agent-authored revision was rendering as the literal "Agent" fallback in
the history UI.
PlanService now looks up the task's session and reads the agent profile
display name from `agent_profile_snapshot` (preferring "label", then
"name", then "agent_display_name") when the request is an agent write
without an explicit author_name. Active session is tried first; falls
back to the most recent session by started_at so plans written between
turns still get the right author name. If no usable session/snapshot
exists, the existing "Agent" fallback applies.
The repo dependency type is widened from `repository.PlanRepository` to a
small private `planRepo` interface that adds the two session lookups —
the SQLite repository (`*sqliterepo.Repository`) already implements both,
so no caller wiring changes.
* fix(web): plan history popover row + restore button polish
- Popover content's default `gap-4` between flex children (from
@kandev/ui's PopoverContent) was rendering as visible space above the
top-most row. Override with `gap-0` so the list sits flush against the
header.
- Switch row layout from `items-start` to `items-center` so the
Restore button stays vertically centered against the row body even
when the body grows taller (timestamp + restored-from marker on revert
rows).
- Rename the per-row "Revert" button to "Restore" and prefix it with
IconRestore so it matches the dialog wording and the restored-from
marker icon. The data-testid stays `plan-revision-revert-button` for
e2e selector stability.
* fix(backend): plan revision review feedback + lint
- `wsGetTaskPlanRevision` now returns a 400 validation error when
`revision_id` is empty, matching `wsRevertTaskPlan`'s
`ErrRevisionIDRequired` branch (was silently mapped to 404 via the
service's lookup miss).
- `agentDisplayNameFromSnapshot` tries `name` first, then `label`, then
`agent_display_name`. The orchestrator's canonical key wins over any
stale older `label` that may still be in a session snapshot.
- Add `idx_task_plan_revisions_task_number` on
`(task_id, revision_number DESC)`. `GetLatestTaskPlanRevision` runs on
every plan write (coalesce check); the existing `(task_id, created_at)`
index doesn't help since these queries order by `revision_number`.
- Drop `Deprecated:` from `NextTaskPlanRevisionNumber`'s doc comment so
staticcheck stops flagging the existing test usage. The TOCTOU caveat
stays in the prose.
* fix(web): plan revision review feedback, prettier, e2e resilience
- `loadRevisionContent` reads `revisionContentCache` lazily through the
store API inside the callback, so its identity stays stable. Selecting
the cache map at the hook level was recreating the callback whenever
any task's content was cached, retriggering downstream content-fetch
effects.
- `task.plan.reverted` WS event becomes a no-op handler.
`task.plan.revision.created` fires for the same row first and already
upserts; the duplicate handler was double-evicting the
`revisionContentCache` entry and triggering an extra Zustand update.
- e2e: agent author assertion now uses `not.toHaveText("You")` +
`not.toBeEmpty()` instead of the literal `"Agent"` (which only
matched the legacy fallback — agent rows now resolve to the profile
display name).
- e2e: `openPlanPanel` retries the toggle a few times after a page
reload — the panel toggle can race hydration and silently no-op,
which broke the persistence test.
- Apply prettier to the files flagged by the formatting check.
* fix(plan): toast on silent revert failure + revision ownership check
- task-plan-revisions: when `onRevert` (the hook's `revertTo`) swallows
the error and returns null, the dialog used to close silently with no
feedback. Add an `else` branch that fires `toast.error("Failed to
restore plan")`.
- wsGetTaskPlanRevision: accept an optional `task_id` and reject with a
400 "Revision does not belong to task" when the loaded revision's
task doesn't match. Mirrors `wsRevertTaskPlan`'s ownership check so a
WS client can't read content for an arbitrary revision UUID outside
the tasks it already holds a reference to.
- plan-api `getPlanRevision` takes optional `taskId`; `loadRevisionContent`
threads the active task id so the check is exercised on the happy path.
This commit is contained in:
@@ -0,0 +1,447 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { IconLoader2 } from "@tabler/icons-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@kandev/ui/dialog";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@kandev/ui/toggle-group";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { TaskPlanRevision } from "@/lib/types/http";
|
||||
import { lineDiff, diffSummary, type DiffLine, type DiffLineKind } from "./task-plan-diff";
|
||||
|
||||
type Props = {
|
||||
/** Revision pair in arbitrary user-pick order; the dialog re-orders them by
|
||||
* revision_number so the older one is always the "before" side. */
|
||||
pair: [TaskPlanRevision | null, TaskPlanRevision | null];
|
||||
loadContent: (revisionId: string) => Promise<string>;
|
||||
onClose: () => void;
|
||||
/** Restore the older revision (the "before" side) — null if the pair isn't
|
||||
* fully populated. */
|
||||
onRestoreOlder: (revisionId: string) => void;
|
||||
};
|
||||
|
||||
type DiffMode = "unified" | "split";
|
||||
|
||||
const KIND_CLASS: Record<DiffLineKind, string> = {
|
||||
add: "bg-emerald-500/10 border-l-2 border-emerald-500/60",
|
||||
remove: "bg-rose-500/10 border-l-2 border-rose-500/60",
|
||||
context: "border-l-2 border-transparent",
|
||||
};
|
||||
|
||||
const KIND_PREFIX: Record<DiffLineKind, string> = {
|
||||
add: "+",
|
||||
remove: "-",
|
||||
context: " ",
|
||||
};
|
||||
|
||||
export function PlanRevisionDiffDialog({
|
||||
pair,
|
||||
loadContent,
|
||||
onClose,
|
||||
onRestoreOlder,
|
||||
}: Props): ReactNode {
|
||||
const [before, after] = useMemo(() => orderPair(pair), [pair]);
|
||||
const [mode, setMode] = useState<DiffMode>("unified");
|
||||
const open = before !== null && after !== null;
|
||||
const sameRevision = before !== null && after !== null && before.id === after.id;
|
||||
const title =
|
||||
before && after ? `Compare v${before.revision_number} → v${after.revision_number}` : "Compare";
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
if (!v) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="!max-w-6xl w-[95vw] max-h-[90vh] flex flex-col"
|
||||
data-testid="plan-revision-diff-dialog"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
Line-level diff between the two selected versions.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DiffBody
|
||||
before={before}
|
||||
after={after}
|
||||
loadContent={loadContent}
|
||||
mode={mode}
|
||||
setMode={setMode}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
className="cursor-pointer"
|
||||
data-testid="plan-revision-diff-close"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
{before && !sameRevision && (
|
||||
<Button
|
||||
onClick={() => onRestoreOlder(before.id)}
|
||||
className="cursor-pointer"
|
||||
data-testid="plan-revision-diff-restore"
|
||||
>
|
||||
Restore v{before.revision_number}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function DiffBody({
|
||||
before,
|
||||
after,
|
||||
loadContent,
|
||||
mode,
|
||||
setMode,
|
||||
}: {
|
||||
before: TaskPlanRevision | null;
|
||||
after: TaskPlanRevision | null;
|
||||
loadContent: (revisionId: string) => Promise<string>;
|
||||
mode: DiffMode;
|
||||
setMode: (m: DiffMode) => void;
|
||||
}) {
|
||||
const { beforeContent, afterContent, lines, error } = useDiffContent(before, after, loadContent);
|
||||
const summary = lines ? diffSummary(lines) : null;
|
||||
const sameRevision = before !== null && after !== null && before.id === after.id;
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0 gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-[11px] text-muted-foreground" data-testid="plan-revision-diff-summary">
|
||||
{summary ? `${summary.added} added · ${summary.removed} removed` : "Loading…"}
|
||||
</div>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
size="sm"
|
||||
value={mode}
|
||||
onValueChange={(v) => {
|
||||
if (v === "unified" || v === "split") setMode(v);
|
||||
}}
|
||||
data-testid="plan-revision-diff-mode-toggle"
|
||||
>
|
||||
<ToggleGroupItem
|
||||
value="unified"
|
||||
className="text-xs px-2 cursor-pointer"
|
||||
data-testid="plan-revision-diff-mode-unified"
|
||||
>
|
||||
Unified
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="split"
|
||||
className="text-xs px-2 cursor-pointer"
|
||||
data-testid="plan-revision-diff-mode-split"
|
||||
>
|
||||
Split
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 min-h-0 rounded border border-border bg-muted/20 font-mono text-xs",
|
||||
// Unified scrolls in both axes; split mode owns its own panes' scroll
|
||||
// and only needs vertical here so its synced horizontal scrollbars
|
||||
// sit inside the panes (one per side, scroll-linked).
|
||||
mode === "split" ? "overflow-y-auto overflow-x-hidden" : "overflow-auto",
|
||||
)}
|
||||
data-testid="plan-revision-diff-body"
|
||||
>
|
||||
<DiffBodyInner
|
||||
mode={mode}
|
||||
lines={lines}
|
||||
beforeContent={beforeContent}
|
||||
afterContent={afterContent}
|
||||
error={error}
|
||||
sameRevision={sameRevision}
|
||||
summary={summary}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DiffBodyInner({
|
||||
mode,
|
||||
lines,
|
||||
beforeContent,
|
||||
afterContent,
|
||||
error,
|
||||
sameRevision,
|
||||
summary,
|
||||
}: {
|
||||
mode: DiffMode;
|
||||
lines: DiffLine[] | null;
|
||||
beforeContent: string | null;
|
||||
afterContent: string | null;
|
||||
error: string | null;
|
||||
sameRevision: boolean;
|
||||
summary: { added: number; removed: number } | null;
|
||||
}) {
|
||||
if (error) return <div className="p-3 text-destructive">{error}</div>;
|
||||
if (lines === null) return <DiffLoading />;
|
||||
if (sameRevision)
|
||||
return <DiffMessage>These are the same version; nothing to compare.</DiffMessage>;
|
||||
if (lines.length === 0) return <DiffMessage>(both versions are empty)</DiffMessage>;
|
||||
if (summary && summary.added === 0 && summary.removed === 0) {
|
||||
return <DiffMessage>No textual changes between these versions.</DiffMessage>;
|
||||
}
|
||||
if (mode === "split") {
|
||||
return <SplitDiff beforeContent={beforeContent ?? ""} afterContent={afterContent ?? ""} />;
|
||||
}
|
||||
return <UnifiedDiff lines={lines} />;
|
||||
}
|
||||
|
||||
function UnifiedDiff({ lines }: { lines: DiffLine[] }) {
|
||||
return (
|
||||
// inline-block + min-w-full lets the row backgrounds extend past the
|
||||
// viewport so highlights stay solid when the user scrolls horizontally.
|
||||
<ul className="inline-block min-w-full" data-testid="plan-revision-diff-unified">
|
||||
{lines.map((line, i) => (
|
||||
<DiffLineRow key={i} line={line} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function DiffLineRow({ line }: { line: DiffLine }) {
|
||||
return (
|
||||
<li
|
||||
className={cn(
|
||||
"flex gap-2 px-3 py-0.5 leading-relaxed whitespace-pre min-w-full w-max",
|
||||
KIND_CLASS[line.kind],
|
||||
)}
|
||||
data-testid="plan-revision-diff-line"
|
||||
data-line-kind={line.kind}
|
||||
>
|
||||
<span className="select-none text-muted-foreground w-3 shrink-0">
|
||||
{KIND_PREFIX[line.kind]}
|
||||
</span>
|
||||
<span className="flex-1 break-words">{line.text || " "}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** Side-by-side diff with two equal-width panes that scroll horizontally
|
||||
* together. The dialog stays at its fixed width; each pane shows up to half
|
||||
* of it and gets its own horizontal scrollbar for long lines. A scroll
|
||||
* listener keeps both panes' `scrollLeft` in lockstep so corresponding rows
|
||||
* stay aligned visually. */
|
||||
function SplitDiff({
|
||||
beforeContent,
|
||||
afterContent,
|
||||
}: {
|
||||
beforeContent: string;
|
||||
afterContent: string;
|
||||
}) {
|
||||
const rows = useMemo(
|
||||
() => alignSplit(beforeContent, afterContent),
|
||||
[beforeContent, afterContent],
|
||||
);
|
||||
const beforeRef = useRef<HTMLDivElement>(null);
|
||||
const afterRef = useRef<HTMLDivElement>(null);
|
||||
// Re-entrancy guard: when we set the partner pane's scrollLeft, that fires
|
||||
// the partner's onScroll, which would bounce back here without this flag.
|
||||
const syncingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const before = beforeRef.current;
|
||||
const after = afterRef.current;
|
||||
if (!before || !after) return;
|
||||
const sync = (source: HTMLDivElement, target: HTMLDivElement) => () => {
|
||||
if (syncingRef.current) return;
|
||||
syncingRef.current = true;
|
||||
target.scrollLeft = source.scrollLeft;
|
||||
// Release on the next frame so the bounced onScroll has fired and
|
||||
// returned early, but we don't permanently disable syncing.
|
||||
requestAnimationFrame(() => {
|
||||
syncingRef.current = false;
|
||||
});
|
||||
};
|
||||
const onBefore = sync(before, after);
|
||||
const onAfter = sync(after, before);
|
||||
before.addEventListener("scroll", onBefore, { passive: true });
|
||||
after.addEventListener("scroll", onAfter, { passive: true });
|
||||
return () => {
|
||||
before.removeEventListener("scroll", onBefore);
|
||||
after.removeEventListener("scroll", onAfter);
|
||||
};
|
||||
}, [rows]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="grid grid-cols-2 divide-x divide-border min-w-0"
|
||||
data-testid="plan-revision-diff-split"
|
||||
>
|
||||
<SplitPane ref={beforeRef} rows={rows} side="before" testIdSuffix="before" />
|
||||
<SplitPane ref={afterRef} rows={rows} side="after" testIdSuffix="after" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SplitPane = ({
|
||||
ref,
|
||||
rows,
|
||||
side,
|
||||
testIdSuffix,
|
||||
}: {
|
||||
ref: React.RefObject<HTMLDivElement | null>;
|
||||
rows: SplitRow[];
|
||||
side: "before" | "after";
|
||||
testIdSuffix: string;
|
||||
}) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className="min-w-0 overflow-x-auto"
|
||||
data-testid={`plan-revision-diff-split-pane-${testIdSuffix}`}
|
||||
>
|
||||
{/* w-max lets each pane's intrinsic width be the longest line on that
|
||||
* side; combined with the parent grid-cols-2, the visible portion is
|
||||
* still capped at 50% of the dialog while horizontal scroll reveals
|
||||
* the rest. */}
|
||||
<div className="w-max min-w-full">
|
||||
{rows.map((row, i) => (
|
||||
<SplitCell key={i} side={side} line={side === "before" ? row.before : row.after} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
function SplitCell({ side, line }: { side: "before" | "after"; line: DiffLine | null }) {
|
||||
// Empty cells (a removal on the after side, or an addition on the before
|
||||
// side) get a subtle striped background so the row alignment is visible.
|
||||
if (!line) {
|
||||
return (
|
||||
<div
|
||||
className="px-3 py-0.5 leading-relaxed bg-muted/40 border-l-2 border-transparent min-h-[1.25rem]"
|
||||
data-testid="plan-revision-diff-split-cell"
|
||||
data-line-kind="empty"
|
||||
data-side={side}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={cn("flex gap-2 px-3 py-0.5 leading-relaxed whitespace-pre", KIND_CLASS[line.kind])}
|
||||
data-testid="plan-revision-diff-split-cell"
|
||||
data-line-kind={line.kind}
|
||||
data-side={side}
|
||||
>
|
||||
<span className="select-none text-muted-foreground w-3 shrink-0">
|
||||
{KIND_PREFIX[line.kind]}
|
||||
</span>
|
||||
<span className="flex-1">{line.text || " "}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DiffLoading() {
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-3 text-muted-foreground">
|
||||
<IconLoader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DiffMessage({ children }: { children: ReactNode }) {
|
||||
return <div className="p-3 text-muted-foreground italic">{children}</div>;
|
||||
}
|
||||
|
||||
/** Sort a 2-slot pair by revision_number ascending so the older revision is
|
||||
* always the "before" side, regardless of pick order. */
|
||||
function orderPair(
|
||||
pair: [TaskPlanRevision | null, TaskPlanRevision | null],
|
||||
): [TaskPlanRevision | null, TaskPlanRevision | null] {
|
||||
const [a, b] = pair;
|
||||
if (!a || !b) return pair;
|
||||
return a.revision_number <= b.revision_number ? [a, b] : [b, a];
|
||||
}
|
||||
|
||||
type SplitRow = { before: DiffLine | null; after: DiffLine | null };
|
||||
|
||||
/** Align the line-diff into side-by-side rows: each row contains at most one
|
||||
* before-line and one after-line. Removals render on the left only; adds on
|
||||
* the right only; context lines align across both sides. Adjacent
|
||||
* remove/add chunks are zipped together so a single-line edit shows up on the
|
||||
* same row. */
|
||||
function alignSplit(beforeContent: string, afterContent: string): SplitRow[] {
|
||||
const lines = lineDiff(beforeContent, afterContent);
|
||||
const rows: SplitRow[] = [];
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
if (line.kind === "context") {
|
||||
rows.push({ before: line, after: line });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
// Collect a contiguous remove block, then any adjacent add block.
|
||||
const removes: DiffLine[] = [];
|
||||
while (i < lines.length && lines[i].kind === "remove") {
|
||||
removes.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
const adds: DiffLine[] = [];
|
||||
while (i < lines.length && lines[i].kind === "add") {
|
||||
adds.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
const max = Math.max(removes.length, adds.length);
|
||||
for (let k = 0; k < max; k++) {
|
||||
rows.push({ before: removes[k] ?? null, after: adds[k] ?? null });
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function useDiffContent(
|
||||
before: TaskPlanRevision | null,
|
||||
after: TaskPlanRevision | null,
|
||||
loadContent: (revisionId: string) => Promise<string>,
|
||||
): {
|
||||
beforeContent: string | null;
|
||||
afterContent: string | null;
|
||||
lines: DiffLine[] | null;
|
||||
error: string | null;
|
||||
} {
|
||||
const [beforeContent, setBeforeContent] = useState<string | null>(null);
|
||||
const [afterContent, setAfterContent] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!before || !after) return;
|
||||
let cancelled = false;
|
||||
Promise.all([loadContent(before.id), loadContent(after.id)])
|
||||
.then(([b, a]) => {
|
||||
if (cancelled) return;
|
||||
setBeforeContent(b);
|
||||
setAfterContent(a);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
setError(err instanceof Error ? err.message : "Failed to load revision content");
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [before, after, loadContent]);
|
||||
|
||||
const lines = useMemo(() => {
|
||||
if (beforeContent === null || afterContent === null) return null;
|
||||
return lineDiff(beforeContent, afterContent);
|
||||
}, [beforeContent, afterContent]);
|
||||
return { beforeContent, afterContent, lines, error };
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { lineDiff, diffSummary } from "./task-plan-diff";
|
||||
|
||||
describe("lineDiff", () => {
|
||||
it("returns all-context for identical strings", () => {
|
||||
const result = lineDiff("a\nb\nc", "a\nb\nc");
|
||||
expect(result.map((l) => l.kind)).toEqual(["context", "context", "context"]);
|
||||
expect(result.map((l) => l.text)).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("marks added lines", () => {
|
||||
const result = lineDiff("a\n", "a\nb\n");
|
||||
expect(result).toEqual([
|
||||
{ kind: "context", text: "a" },
|
||||
{ kind: "add", text: "b" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks removed lines", () => {
|
||||
const result = lineDiff("a\nb\n", "a\n");
|
||||
expect(result).toEqual([
|
||||
{ kind: "context", text: "a" },
|
||||
{ kind: "remove", text: "b" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles a mixed diff", () => {
|
||||
const result = lineDiff("a\nb\nc\n", "a\nB\nc\n");
|
||||
expect(result).toEqual([
|
||||
{ kind: "context", text: "a" },
|
||||
{ kind: "remove", text: "b" },
|
||||
{ kind: "add", text: "B" },
|
||||
{ kind: "context", text: "c" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("empty before", () => {
|
||||
const result = lineDiff("", "x\ny\n");
|
||||
expect(result.map((l) => l.kind)).toEqual(["add", "add"]);
|
||||
expect(result.map((l) => l.text)).toEqual(["x", "y"]);
|
||||
});
|
||||
|
||||
it("empty after", () => {
|
||||
const result = lineDiff("x\ny\n", "");
|
||||
expect(result.map((l) => l.kind)).toEqual(["remove", "remove"]);
|
||||
});
|
||||
|
||||
it("does not emit a trailing empty line", () => {
|
||||
const result = lineDiff("a\n", "a\n");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({ kind: "context", text: "a" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("diffSummary", () => {
|
||||
it("counts added and removed", () => {
|
||||
const result = diffSummary([
|
||||
{ kind: "context", text: "a" },
|
||||
{ kind: "add", text: "b" },
|
||||
{ kind: "add", text: "c" },
|
||||
{ kind: "remove", text: "d" },
|
||||
]);
|
||||
expect(result).toEqual({ added: 2, removed: 1 });
|
||||
});
|
||||
|
||||
it("zero on no changes", () => {
|
||||
expect(diffSummary([{ kind: "context", text: "a" }])).toEqual({ added: 0, removed: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { diffLines } from "diff";
|
||||
|
||||
export type DiffLineKind = "add" | "remove" | "context";
|
||||
|
||||
export type DiffLine = {
|
||||
kind: DiffLineKind;
|
||||
/** Single line of text without the trailing newline. */
|
||||
text: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Line-level diff of two strings in unified order. Each input line maps to one
|
||||
* `DiffLine`; identical regions are emitted as `context`, lines present only in
|
||||
* `before` as `remove`, and lines present only in `after` as `add`.
|
||||
*
|
||||
* Trailing newlines are normalized away so a final blank line doesn't show up
|
||||
* as a phantom diff entry.
|
||||
*/
|
||||
export function lineDiff(before: string, after: string): DiffLine[] {
|
||||
const out: DiffLine[] = [];
|
||||
for (const part of diffLines(before, after)) {
|
||||
const kind = partKind(part);
|
||||
// Each part.value can contain multiple lines; split and drop a trailing
|
||||
// empty string from the inevitable final "\n".
|
||||
const lines = part.value.split("\n");
|
||||
if (lines.length > 0 && lines[lines.length - 1] === "") {
|
||||
lines.pop();
|
||||
}
|
||||
for (const text of lines) {
|
||||
out.push({ kind, text });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function partKind(part: { added?: boolean; removed?: boolean }): DiffLineKind {
|
||||
if (part.added) return "add";
|
||||
if (part.removed) return "remove";
|
||||
return "context";
|
||||
}
|
||||
|
||||
/** Counts of added and removed lines in a diff sequence. */
|
||||
export function diffSummary(lines: DiffLine[]): { added: number; removed: number } {
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
for (const l of lines) {
|
||||
if (l.kind === "add") added++;
|
||||
else if (l.kind === "remove") removed++;
|
||||
}
|
||||
return { added, removed };
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useState, useCallback, useEffect, useRef, useMemo } from "react";
|
||||
import { PanelRoot, PanelBody } from "./panel-primitives";
|
||||
import { PanelRoot, PanelBody, PanelHeaderBarSplit } from "./panel-primitives";
|
||||
import { TaskPlanRevisions } from "./task-plan-revisions";
|
||||
import dynamic from "next/dynamic";
|
||||
import { IconLoader2, IconFileText, IconRobot, IconMessage, IconClick } from "@tabler/icons-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -15,10 +16,10 @@ import type {
|
||||
TextSelection,
|
||||
CommentForEditor,
|
||||
} from "@/components/editors/tiptap/tiptap-plan-editor";
|
||||
import type { TaskPlanRevision } from "@/lib/types/http";
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import { PanelSearchBar } from "@/components/search/panel-search-bar";
|
||||
import { usePanelSearch } from "@/hooks/use-panel-search";
|
||||
import { planSearchPluginKey } from "@/components/editors/tiptap/search-highlight-extension";
|
||||
import { usePlanFindShortcut } from "./use-plan-find-shortcut";
|
||||
|
||||
// Dynamic import to avoid SSR issues with TipTap
|
||||
const PlanEditor = dynamic(
|
||||
@@ -43,7 +44,22 @@ type TaskPlanPanelProps = {
|
||||
};
|
||||
|
||||
function useTaskPlanPanelState(taskId: string | null, visible: boolean) {
|
||||
const { plan, isLoading, isSaving, savePlan } = useTaskPlan(taskId, { visible });
|
||||
const {
|
||||
plan,
|
||||
isLoading,
|
||||
isSaving,
|
||||
savePlan,
|
||||
revisions,
|
||||
isLoadingRevisions,
|
||||
loadRevisions,
|
||||
loadRevisionContent,
|
||||
revertTo,
|
||||
previewRevisionId,
|
||||
setPreviewRevision,
|
||||
comparePair,
|
||||
toggleCompareSelection,
|
||||
clearComparePair,
|
||||
} = useTaskPlan(taskId, { visible });
|
||||
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
|
||||
const activeSession = useAppStore((state) =>
|
||||
activeSessionId ? (state.taskSessions.items[activeSessionId] ?? null) : null,
|
||||
@@ -114,6 +130,16 @@ function useTaskPlanPanelState(taskId: string | null, visible: boolean) {
|
||||
editorWrapperRef,
|
||||
editorInstanceRef,
|
||||
editorInstance,
|
||||
revisions,
|
||||
isLoadingRevisions,
|
||||
loadRevisions,
|
||||
loadRevisionContent,
|
||||
revertTo,
|
||||
previewRevisionId,
|
||||
setPreviewRevision,
|
||||
comparePair,
|
||||
toggleCompareSelection,
|
||||
clearComparePair,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -122,37 +148,16 @@ export const TaskPlanPanel = memo(function TaskPlanPanel({
|
||||
visible = true,
|
||||
}: TaskPlanPanelProps) {
|
||||
const state = useTaskPlanPanelState(taskId, visible);
|
||||
const {
|
||||
plan,
|
||||
isLoading,
|
||||
isSaving,
|
||||
savePlan,
|
||||
activeSessionId,
|
||||
draftContent,
|
||||
setDraftContent,
|
||||
editorKey,
|
||||
isEditorFocused,
|
||||
handleEmptyStateClick,
|
||||
hasUnsavedChanges,
|
||||
commentState,
|
||||
selectionState,
|
||||
handleEditorReady,
|
||||
handleCommentDeleted,
|
||||
commentHighlights,
|
||||
isAgentBusy,
|
||||
isAgentCreatingPlan,
|
||||
editorWrapperRef,
|
||||
editorInstanceRef,
|
||||
editorInstance,
|
||||
} = state;
|
||||
|
||||
// Ctrl+S to save immediately
|
||||
useSaveShortcut(hasUnsavedChanges, isSaving, savePlan, draftContent, plan?.title);
|
||||
useSaveShortcut(
|
||||
state.hasUnsavedChanges,
|
||||
state.isSaving,
|
||||
state.savePlan,
|
||||
state.draftContent,
|
||||
state.plan?.title,
|
||||
);
|
||||
|
||||
// Ctrl+F in-document find
|
||||
const planSearch = usePlanFindShortcut(editorWrapperRef, editorInstance);
|
||||
|
||||
if (isLoading) {
|
||||
if (state.isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<IconLoader2 className="h-5 w-5 animate-spin mr-2" />
|
||||
@@ -169,35 +174,64 @@ export const TaskPlanPanel = memo(function TaskPlanPanel({
|
||||
);
|
||||
}
|
||||
|
||||
const { textSelection, setTextSelection } = selectionState;
|
||||
return <PlanPanelContent taskId={taskId} state={state} />;
|
||||
});
|
||||
|
||||
function PlanPanelContent({
|
||||
taskId,
|
||||
state,
|
||||
}: {
|
||||
taskId: string;
|
||||
state: ReturnType<typeof useTaskPlanPanelState>;
|
||||
}) {
|
||||
const { editorWrapperRef, editorInstanceRef, editorInstance, selectionState } = state;
|
||||
const { textSelection, setTextSelection } = selectionState;
|
||||
// Ctrl+F in-document find (registers a keydown listener on the editor wrapper)
|
||||
const planSearch = usePlanFindShortcut(editorWrapperRef, editorInstance);
|
||||
return (
|
||||
<PanelRoot data-testid="plan-panel">
|
||||
<PlanPanelHeader
|
||||
taskId={taskId}
|
||||
revisions={state.revisions}
|
||||
isLoadingRevisions={state.isLoadingRevisions}
|
||||
isSaving={state.isSaving}
|
||||
onOpenRevisions={state.loadRevisions}
|
||||
onRevert={state.revertTo}
|
||||
loadRevisionContent={state.loadRevisionContent}
|
||||
previewRevisionId={state.previewRevisionId}
|
||||
setPreviewRevision={state.setPreviewRevision}
|
||||
comparePair={state.comparePair}
|
||||
toggleCompareSelection={state.toggleCompareSelection}
|
||||
clearComparePair={state.clearComparePair}
|
||||
/>
|
||||
<PanelBody
|
||||
padding={false}
|
||||
scroll={false}
|
||||
className={cn("relative transition-colors cursor-text", isAgentBusy && "bg-background")}
|
||||
className={cn(
|
||||
"relative transition-colors cursor-text",
|
||||
state.isAgentBusy && "bg-background",
|
||||
)}
|
||||
ref={editorWrapperRef}
|
||||
onClick={handleEmptyStateClick}
|
||||
onClick={state.handleEmptyStateClick}
|
||||
data-panel-kind="plan"
|
||||
>
|
||||
<PlanEditor
|
||||
key={`${taskId}-${editorKey}`}
|
||||
value={draftContent}
|
||||
onChange={setDraftContent}
|
||||
key={`${taskId}-${state.editorKey}`}
|
||||
value={state.draftContent}
|
||||
onChange={state.setDraftContent}
|
||||
placeholder="Start typing your plan..."
|
||||
onSelectionChange={activeSessionId ? setTextSelection : undefined}
|
||||
comments={commentHighlights}
|
||||
onSelectionChange={state.activeSessionId ? setTextSelection : undefined}
|
||||
comments={state.commentHighlights}
|
||||
onCommentClick={selectionState.handleCommentHighlightClick}
|
||||
onCommentDeleted={handleCommentDeleted}
|
||||
onEditorReady={handleEditorReady}
|
||||
onCommentDeleted={state.handleCommentDeleted}
|
||||
onEditorReady={state.handleEditorReady}
|
||||
/>
|
||||
<PlanEmptyState
|
||||
isLoading={isLoading}
|
||||
draftContent={draftContent}
|
||||
isEditorFocused={isEditorFocused}
|
||||
isAgentCreatingPlan={isAgentCreatingPlan}
|
||||
onClick={handleEmptyStateClick}
|
||||
isLoading={state.isLoading}
|
||||
draftContent={state.draftContent}
|
||||
isEditorFocused={state.isEditorFocused}
|
||||
isAgentCreatingPlan={state.isAgentCreatingPlan}
|
||||
onClick={state.handleEmptyStateClick}
|
||||
/>
|
||||
{planSearch.isOpen && (
|
||||
<PanelSearchBar
|
||||
@@ -213,15 +247,15 @@ export const TaskPlanPanel = memo(function TaskPlanPanel({
|
||||
|
||||
<PlanSelectionPopoverWrapper
|
||||
textSelection={textSelection}
|
||||
activeSessionId={activeSessionId}
|
||||
activeSessionId={state.activeSessionId}
|
||||
taskId={taskId}
|
||||
commentState={commentState}
|
||||
commentState={state.commentState}
|
||||
editorRef={editorInstanceRef}
|
||||
onClose={selectionState.handleSelectionClose}
|
||||
/>
|
||||
</PanelRoot>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function removeCommentMark(editor: Editor | null, commentId: string) {
|
||||
if (!editor) return;
|
||||
@@ -441,66 +475,6 @@ function usePlanSelection(
|
||||
return { textSelection, setTextSelection, handleCommentHighlightClick, handleSelectionClose };
|
||||
}
|
||||
|
||||
/** Ctrl+F find-in-plan shortcut + editor wiring */
|
||||
function usePlanFindShortcut(
|
||||
wrapperRef: React.RefObject<HTMLDivElement | null>,
|
||||
editor: Editor | null,
|
||||
) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [matchInfo, setMatchInfo] = useState({ current: 0, total: 0 });
|
||||
|
||||
const readMatchInfo = useCallback(() => {
|
||||
if (!editor) return;
|
||||
const s = planSearchPluginKey.getState(editor.state);
|
||||
if (!s) return;
|
||||
const next = {
|
||||
current: s.matches.length ? s.current + 1 : 0,
|
||||
total: s.matches.length,
|
||||
};
|
||||
// Bail out when nothing changed — TipTap fires `transaction` on every cursor
|
||||
// move, and allocating a new object identity would re-render this hook on
|
||||
// every keystroke / click in the editor (and can cascade into update loops).
|
||||
setMatchInfo((prev) =>
|
||||
prev.current === next.current && prev.total === next.total ? prev : next,
|
||||
);
|
||||
}, [editor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
const handler = () => readMatchInfo();
|
||||
editor.on("transaction", handler);
|
||||
return () => {
|
||||
editor.off("transaction", handler);
|
||||
};
|
||||
}, [editor, readMatchInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
if (!isOpen) return;
|
||||
editor.commands.setPlanSearchQuery(query);
|
||||
}, [query, isOpen, editor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) return;
|
||||
if (!editor) return;
|
||||
editor.commands.clearPlanSearch();
|
||||
}, [isOpen, editor]);
|
||||
|
||||
const open = useCallback(() => setIsOpen(true), []);
|
||||
const close = useCallback(() => setIsOpen(false), []);
|
||||
const findNext = useCallback(() => {
|
||||
editor?.commands.planSearchNext();
|
||||
}, [editor]);
|
||||
const findPrev = useCallback(() => {
|
||||
editor?.commands.planSearchPrev();
|
||||
}, [editor]);
|
||||
|
||||
usePanelSearch({ containerRef: wrapperRef, isOpen, onOpen: open, onClose: close });
|
||||
|
||||
return { isOpen, open, close, query, setQuery, findNext, findPrev, matchInfo };
|
||||
}
|
||||
|
||||
/** Ctrl+S save shortcut */
|
||||
function useSaveShortcut(
|
||||
hasUnsavedChanges: boolean,
|
||||
@@ -578,3 +552,58 @@ function PlanEmptyState({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type PlanPanelHeaderProps = {
|
||||
taskId: string;
|
||||
revisions: TaskPlanRevision[];
|
||||
isLoadingRevisions: boolean;
|
||||
isSaving: boolean;
|
||||
onOpenRevisions: () => void;
|
||||
onRevert: (id: string) => Promise<TaskPlanRevision | null>;
|
||||
loadRevisionContent: (revisionId: string) => Promise<string>;
|
||||
previewRevisionId: string | null;
|
||||
setPreviewRevision: (revisionId: string | null) => void;
|
||||
comparePair: [string | null, string | null];
|
||||
toggleCompareSelection: (revisionId: string) => void;
|
||||
clearComparePair: () => void;
|
||||
};
|
||||
|
||||
// Header bar lives only to host the rewind button. The plan's title (default
|
||||
// "Plan") is already shown by the dockview/mobile tab above the panel, so we
|
||||
// don't repeat it here.
|
||||
function PlanPanelHeader({
|
||||
taskId,
|
||||
revisions,
|
||||
isLoadingRevisions,
|
||||
isSaving,
|
||||
onOpenRevisions,
|
||||
onRevert,
|
||||
loadRevisionContent,
|
||||
previewRevisionId,
|
||||
setPreviewRevision,
|
||||
comparePair,
|
||||
toggleCompareSelection,
|
||||
clearComparePair,
|
||||
}: PlanPanelHeaderProps) {
|
||||
return (
|
||||
<PanelHeaderBarSplit
|
||||
left={null}
|
||||
right={
|
||||
<TaskPlanRevisions
|
||||
taskId={taskId}
|
||||
revisions={revisions}
|
||||
isLoading={isLoadingRevisions}
|
||||
isSaving={isSaving}
|
||||
onOpen={onOpenRevisions}
|
||||
onRevert={onRevert}
|
||||
loadRevisionContent={loadRevisionContent}
|
||||
previewRevisionId={previewRevisionId}
|
||||
setPreviewRevision={setPreviewRevision}
|
||||
comparePair={comparePair}
|
||||
toggleCompareSelection={toggleCompareSelection}
|
||||
clearComparePair={clearComparePair}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { IconLoader2, IconRestore } from "@tabler/icons-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@kandev/ui/dialog";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
import type { TaskPlanRevision } from "@/lib/types/http";
|
||||
import { formatPreciseTime } from "@/lib/utils";
|
||||
|
||||
const PlanReadOnlyMarkdown = dynamic(
|
||||
() =>
|
||||
import("@/components/editors/tiptap/tiptap-plan-readonly").then(
|
||||
(mod) => mod.PlanReadOnlyMarkdown,
|
||||
),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
type Props = {
|
||||
revision: TaskPlanRevision | null;
|
||||
authorLabel: string;
|
||||
loadContent: (revisionId: string) => Promise<string>;
|
||||
onClose: () => void;
|
||||
onRestore: () => void;
|
||||
/** Open the diff dialog comparing the previewed revision with the previous
|
||||
* revision (vN-1). Disabled if the previewed one is the oldest. */
|
||||
onCompareWithPrevious: (() => void) | null;
|
||||
/** Open the diff dialog comparing the previewed revision with current HEAD. */
|
||||
onCompareWithCurrent: () => void;
|
||||
/** True when the revision is the current HEAD — Restore + Compare-with-current
|
||||
* CTAs hide. */
|
||||
isCurrent: boolean;
|
||||
};
|
||||
|
||||
/** Read-only modal preview of a single plan revision. The parent should give
|
||||
* this component a `key` derived from the revision id so it remounts (and
|
||||
* resets its loader state) when the user opens a different revision. */
|
||||
export function PlanRevisionPreviewDialog({
|
||||
revision,
|
||||
authorLabel,
|
||||
loadContent,
|
||||
onClose,
|
||||
onRestore,
|
||||
onCompareWithPrevious,
|
||||
onCompareWithCurrent,
|
||||
isCurrent,
|
||||
}: Props): ReactNode {
|
||||
const open = revision !== null;
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
if (!v) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="!max-w-5xl w-[92vw] max-h-[88vh] flex flex-col"
|
||||
data-testid="plan-revision-preview-dialog"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<span>Version {revision?.revision_number}</span>
|
||||
{isCurrent && (
|
||||
<Badge variant="secondary" className="h-4 text-[10px] px-1.5">
|
||||
current
|
||||
</Badge>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="flex items-center gap-2 text-xs">
|
||||
<span>{authorLabel}</span>
|
||||
<span>·</span>
|
||||
<span>{revision ? formatPreciseTime(revision.updated_at) : ""}</span>
|
||||
{revision?.revert_of_revision_id && (
|
||||
<span className="flex items-center gap-1 text-muted-foreground">
|
||||
<IconRestore className="h-3 w-3" /> restored from earlier version
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<PreviewBody revision={revision} loadContent={loadContent} />
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
className="cursor-pointer"
|
||||
data-testid="plan-revision-preview-close"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
{!isCurrent && revision && (
|
||||
<>
|
||||
{onCompareWithPrevious && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onCompareWithPrevious}
|
||||
className="cursor-pointer"
|
||||
data-testid="plan-revision-preview-compare-with-previous"
|
||||
>
|
||||
Compare with previous
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onCompareWithCurrent}
|
||||
className="cursor-pointer"
|
||||
data-testid="plan-revision-preview-compare-with-current"
|
||||
>
|
||||
Compare with current
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onRestore}
|
||||
className="cursor-pointer"
|
||||
data-testid="plan-revision-preview-restore"
|
||||
>
|
||||
Restore this version
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewBody({
|
||||
revision,
|
||||
loadContent,
|
||||
}: {
|
||||
revision: TaskPlanRevision | null;
|
||||
loadContent: (revisionId: string) => Promise<string>;
|
||||
}) {
|
||||
const [content, setContent] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!revision) return;
|
||||
let cancelled = false;
|
||||
loadContent(revision.id)
|
||||
.then((c) => {
|
||||
if (!cancelled) setContent(c);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load revision");
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [revision, loadContent]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-1 min-h-0 overflow-y-auto rounded border border-border bg-card p-4"
|
||||
data-testid="plan-revision-preview-body"
|
||||
>
|
||||
<PreviewBodyInner content={content} error={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewBodyInner({ content, error }: { content: string | null; error: string | null }) {
|
||||
if (error) {
|
||||
return <div className="text-destructive text-xs">{error}</div>;
|
||||
}
|
||||
if (content === null) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<IconLoader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (content.trim() === "") {
|
||||
return <div className="text-xs text-muted-foreground italic">(empty plan)</div>;
|
||||
}
|
||||
return <PlanReadOnlyMarkdown content={content} />;
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo, useRef, type ReactNode } from "react";
|
||||
import { IconHistory, IconUser, IconRestore, IconLoader2 } from "@tabler/icons-react";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@kandev/ui/dialog";
|
||||
import { toast } from "sonner";
|
||||
import type { TaskPlanRevision } from "@/lib/types/http";
|
||||
import { formatPreciseTime } from "@/lib/utils";
|
||||
import { AgentLogo } from "@/components/agent-logo";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { PlanRevisionPreviewDialog } from "./task-plan-preview-dialog";
|
||||
import { PlanRevisionDiffDialog } from "./task-plan-diff-dialog";
|
||||
|
||||
type ComparePair = [string | null, string | null];
|
||||
|
||||
type TaskPlanRevisionsProps = {
|
||||
taskId: string;
|
||||
revisions: TaskPlanRevision[];
|
||||
isLoading: boolean;
|
||||
isSaving: boolean;
|
||||
onOpen: () => void;
|
||||
onRevert: (revisionId: string) => Promise<TaskPlanRevision | null>;
|
||||
loadRevisionContent: (revisionId: string) => Promise<string>;
|
||||
previewRevisionId: string | null;
|
||||
setPreviewRevision: (revisionId: string | null) => void;
|
||||
/** comparePair / toggleCompareSelection / clearComparePair are still threaded
|
||||
* through so the diff dialog can carry an arbitrary pair, but the popover
|
||||
* UI no longer surfaces a selection mode — pairs are seeded from inside the
|
||||
* preview dialog ("Compare with previous" / "Compare with current"). */
|
||||
comparePair: ComparePair;
|
||||
toggleCompareSelection: (revisionId: string) => void;
|
||||
clearComparePair: () => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
/** Label rendered in the UI for any user-authored revision. Display-only —
|
||||
* the backend still stamps its own author_name on the row. */
|
||||
const USER_DISPLAY_NAME = "You";
|
||||
|
||||
export function TaskPlanRevisions(props: TaskPlanRevisionsProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [confirmTarget, setConfirmTarget] = useState<TaskPlanRevision | null>(null);
|
||||
const [diffOpen, setDiffOpen] = useState(false);
|
||||
const agentName = useActiveAgentBackendName();
|
||||
const handleOpenChange = useTriggerOnFirstOpen(setOpen, props.onOpen);
|
||||
|
||||
const previewRevision = useMemo(
|
||||
() => props.revisions.find((r) => r.id === props.previewRevisionId) ?? null,
|
||||
[props.revisions, props.previewRevisionId],
|
||||
);
|
||||
const compareRevisions = useMemo<[TaskPlanRevision | null, TaskPlanRevision | null]>(
|
||||
() => [
|
||||
props.revisions.find((r) => r.id === props.comparePair[0]) ?? null,
|
||||
props.revisions.find((r) => r.id === props.comparePair[1]) ?? null,
|
||||
],
|
||||
[props.revisions, props.comparePair],
|
||||
);
|
||||
const headRevision = props.revisions[0] ?? null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<RevisionsPopover
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
agentName={agentName}
|
||||
confirmTargetSetter={setConfirmTarget}
|
||||
{...props}
|
||||
/>
|
||||
<RevisionsDialogStack
|
||||
revisions={props.revisions}
|
||||
previewRevision={previewRevision}
|
||||
compareRevisions={compareRevisions}
|
||||
diffOpen={diffOpen}
|
||||
confirmTarget={confirmTarget}
|
||||
isSaving={props.isSaving}
|
||||
loadRevisionContent={props.loadRevisionContent}
|
||||
headRevision={headRevision}
|
||||
onRevert={props.onRevert}
|
||||
setConfirmTarget={setConfirmTarget}
|
||||
setDiffOpen={setDiffOpen}
|
||||
setPreviewRevision={props.setPreviewRevision}
|
||||
clearComparePair={props.clearComparePair}
|
||||
toggleCompareSelection={props.toggleCompareSelection}
|
||||
closePopover={() => setOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Lazy-load revisions on the first popover open; subsequent opens reuse the
|
||||
* cached list. Wraps the open setter so callers don't need a ref of their own. */
|
||||
function useTriggerOnFirstOpen(setOpen: (v: boolean) => void, onOpen: () => void) {
|
||||
const loadedRef = useRef(false);
|
||||
return useCallback(
|
||||
(next: boolean) => {
|
||||
setOpen(next);
|
||||
if (next && !loadedRef.current) {
|
||||
loadedRef.current = true;
|
||||
onOpen();
|
||||
}
|
||||
},
|
||||
[setOpen, onOpen],
|
||||
);
|
||||
}
|
||||
|
||||
function RevisionsPopover({
|
||||
open,
|
||||
onOpenChange,
|
||||
agentName,
|
||||
confirmTargetSetter,
|
||||
revisions,
|
||||
isLoading,
|
||||
setPreviewRevision,
|
||||
disabled = false,
|
||||
}: TaskPlanRevisionsProps & {
|
||||
open: boolean;
|
||||
onOpenChange: (next: boolean) => void;
|
||||
agentName: string | null;
|
||||
confirmTargetSetter: (rev: TaskPlanRevision | null) => void;
|
||||
}) {
|
||||
const hasRevisions = revisions.length > 0;
|
||||
return (
|
||||
<Popover open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 cursor-pointer"
|
||||
disabled={disabled || !hasRevisions}
|
||||
data-testid="plan-rewind-button"
|
||||
title="View plan history"
|
||||
>
|
||||
<IconHistory className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
// Override the popover's default `gap-4` between flex children — it
|
||||
// was rendering as visible empty space above the top-most row.
|
||||
className="w-96 p-0 gap-0"
|
||||
data-testid="plan-revisions-popover"
|
||||
>
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b">
|
||||
<span className="text-sm font-medium">Plan history</span>
|
||||
{isLoading && <IconLoader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
{/* Plain overflow-y-auto is more reliable than ScrollArea inside a Popover
|
||||
* — the popover doesn't constrain its child by default, so a fixed
|
||||
* max-height with native scroll keeps the list bounded. */}
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
<RevisionList
|
||||
revisions={revisions}
|
||||
isLoading={isLoading}
|
||||
agentName={agentName}
|
||||
onRevertClick={confirmTargetSetter}
|
||||
onRowClick={(rev) => setPreviewRevision(rev.id)}
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
type DialogStackProps = {
|
||||
revisions: TaskPlanRevision[];
|
||||
previewRevision: TaskPlanRevision | null;
|
||||
compareRevisions: [TaskPlanRevision | null, TaskPlanRevision | null];
|
||||
diffOpen: boolean;
|
||||
confirmTarget: TaskPlanRevision | null;
|
||||
isSaving: boolean;
|
||||
loadRevisionContent: (revisionId: string) => Promise<string>;
|
||||
headRevision: TaskPlanRevision | null;
|
||||
onRevert: (revisionId: string) => Promise<TaskPlanRevision | null>;
|
||||
setConfirmTarget: (rev: TaskPlanRevision | null) => void;
|
||||
setDiffOpen: (v: boolean) => void;
|
||||
setPreviewRevision: (id: string | null) => void;
|
||||
clearComparePair: () => void;
|
||||
toggleCompareSelection: (id: string) => void;
|
||||
closePopover: () => void;
|
||||
};
|
||||
|
||||
function RevisionsDialogStack({
|
||||
revisions,
|
||||
previewRevision,
|
||||
compareRevisions,
|
||||
diffOpen,
|
||||
confirmTarget,
|
||||
isSaving,
|
||||
loadRevisionContent,
|
||||
headRevision,
|
||||
onRevert,
|
||||
setConfirmTarget,
|
||||
setDiffOpen,
|
||||
setPreviewRevision,
|
||||
clearComparePair,
|
||||
toggleCompareSelection,
|
||||
closePopover,
|
||||
}: DialogStackProps) {
|
||||
const isPreviewCurrent = previewRevision !== null && previewRevision.id === headRevision?.id;
|
||||
const previousRevision = useMemo(() => {
|
||||
if (!previewRevision) return null;
|
||||
// revisions are sorted newest-first by revision_number; previous = the
|
||||
// first entry whose revision_number is strictly less than the previewed one.
|
||||
return revisions.find((r) => r.revision_number < previewRevision.revision_number) ?? null;
|
||||
}, [revisions, previewRevision]);
|
||||
|
||||
const handleRevert = useCallback(
|
||||
async (revision: TaskPlanRevision) => {
|
||||
try {
|
||||
const result = await onRevert(revision.id);
|
||||
if (result) {
|
||||
toast.success(`Plan restored to v${revision.revision_number}`);
|
||||
closePopover();
|
||||
setPreviewRevision(null);
|
||||
setDiffOpen(false);
|
||||
} else {
|
||||
// `revertTo` (the hook impl) swallows errors and returns null, so
|
||||
// we surface the failure here too — without this branch the dialog
|
||||
// closes silently on failure and the user has no feedback.
|
||||
toast.error("Failed to restore plan");
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to restore plan");
|
||||
}
|
||||
},
|
||||
[onRevert, setPreviewRevision, setDiffOpen, closePopover],
|
||||
);
|
||||
|
||||
const seedComparePair = useCallback(
|
||||
(a: TaskPlanRevision, b: TaskPlanRevision) => {
|
||||
// Replace any existing pair with [a, b] so the diff opens immediately.
|
||||
clearComparePair();
|
||||
toggleCompareSelection(a.id);
|
||||
if (a.id !== b.id) toggleCompareSelection(b.id);
|
||||
setPreviewRevision(null);
|
||||
setDiffOpen(true);
|
||||
},
|
||||
[clearComparePair, toggleCompareSelection, setPreviewRevision, setDiffOpen],
|
||||
);
|
||||
|
||||
const onCompareWithCurrent = useCallback(() => {
|
||||
if (!previewRevision || !headRevision) return;
|
||||
seedComparePair(previewRevision, headRevision);
|
||||
}, [previewRevision, headRevision, seedComparePair]);
|
||||
|
||||
const onCompareWithPrevious = useMemo(() => {
|
||||
if (!previewRevision || !previousRevision) return null;
|
||||
return () => seedComparePair(previousRevision, previewRevision);
|
||||
}, [previewRevision, previousRevision, seedComparePair]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PlanRevisionPreviewDialog
|
||||
// Remount on revision change so loader state resets without setState-in-effect.
|
||||
key={`preview-${previewRevision?.id ?? "none"}`}
|
||||
revision={previewRevision}
|
||||
authorLabel={previewRevision ? authorLabel(previewRevision) : ""}
|
||||
loadContent={loadRevisionContent}
|
||||
onClose={() => setPreviewRevision(null)}
|
||||
onRestore={() => {
|
||||
if (previewRevision) setConfirmTarget(previewRevision);
|
||||
}}
|
||||
onCompareWithPrevious={onCompareWithPrevious}
|
||||
onCompareWithCurrent={onCompareWithCurrent}
|
||||
isCurrent={isPreviewCurrent}
|
||||
/>
|
||||
<PlanRevisionDiffDialog
|
||||
// Remount on pair change so the diff loader resets cleanly.
|
||||
key={`diff-${compareRevisions[0]?.id ?? ""}-${compareRevisions[1]?.id ?? ""}`}
|
||||
pair={diffOpen ? compareRevisions : [null, null]}
|
||||
loadContent={loadRevisionContent}
|
||||
onClose={() => setDiffOpen(false)}
|
||||
onRestoreOlder={(revisionId) => {
|
||||
const rev = revisions.find((r) => r.id === revisionId);
|
||||
if (rev) setConfirmTarget(rev);
|
||||
}}
|
||||
/>
|
||||
<RevertConfirmDialog
|
||||
target={confirmTarget}
|
||||
isSaving={isSaving}
|
||||
onCancel={() => setConfirmTarget(null)}
|
||||
onConfirm={async () => {
|
||||
if (!confirmTarget) return;
|
||||
try {
|
||||
await handleRevert(confirmTarget);
|
||||
} finally {
|
||||
setConfirmTarget(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Display label for an author. User revisions always render as "You";
|
||||
* agent revisions use the stored author_name (the agent profile display name). */
|
||||
function authorLabel(revision: TaskPlanRevision): string {
|
||||
if (revision.author_kind === "user") return USER_DISPLAY_NAME;
|
||||
return revision.author_name || "Agent";
|
||||
}
|
||||
|
||||
/** Resolve the active session's agent backend name (e.g. "claude", "codex") so
|
||||
* agent revisions can render the same logo we use elsewhere. Returns null when
|
||||
* there is no active session or its snapshot doesn't carry an agent_name. */
|
||||
function useActiveAgentBackendName(): string | null {
|
||||
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
|
||||
const snapshot = useAppStore((state) =>
|
||||
activeSessionId ? state.taskSessions.items[activeSessionId]?.agent_profile_snapshot : null,
|
||||
);
|
||||
return useMemo(() => {
|
||||
const name = (snapshot as { agent_name?: unknown } | null | undefined)?.agent_name;
|
||||
return typeof name === "string" && name.length > 0 ? name : null;
|
||||
}, [snapshot]);
|
||||
}
|
||||
|
||||
function RevisionList({
|
||||
revisions,
|
||||
isLoading,
|
||||
agentName,
|
||||
onRevertClick,
|
||||
onRowClick,
|
||||
}: {
|
||||
revisions: TaskPlanRevision[];
|
||||
isLoading: boolean;
|
||||
agentName: string | null;
|
||||
onRevertClick: (rev: TaskPlanRevision) => void;
|
||||
onRowClick: (rev: TaskPlanRevision) => void;
|
||||
}) {
|
||||
if (revisions.length === 0 && !isLoading) {
|
||||
return (
|
||||
<div className="px-3 py-6 text-xs text-muted-foreground text-center">
|
||||
No revisions yet. Edits will appear here.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ul className="divide-y">
|
||||
{revisions.map((rev, i) => (
|
||||
<RevisionRow
|
||||
key={rev.id}
|
||||
revision={rev}
|
||||
isCurrent={i === 0}
|
||||
agentName={agentName}
|
||||
onRevertClick={onRevertClick}
|
||||
onRowClick={onRowClick}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function RevisionAuthor({
|
||||
revision,
|
||||
agentName,
|
||||
}: {
|
||||
revision: TaskPlanRevision;
|
||||
agentName: string | null;
|
||||
}) {
|
||||
if (revision.author_kind === "agent") {
|
||||
return (
|
||||
<>
|
||||
{agentName ? <AgentLogo agentName={agentName} size={14} className="shrink-0" /> : null}
|
||||
<span className="text-xs text-foreground truncate" data-testid="plan-revision-author">
|
||||
{revision.author_name}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<IconUser className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="text-xs text-foreground truncate" data-testid="plan-revision-author">
|
||||
{USER_DISPLAY_NAME}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RevisionRow({
|
||||
revision,
|
||||
isCurrent,
|
||||
agentName,
|
||||
onRevertClick,
|
||||
onRowClick,
|
||||
}: {
|
||||
revision: TaskPlanRevision;
|
||||
isCurrent: boolean;
|
||||
agentName: string | null;
|
||||
onRevertClick: (rev: TaskPlanRevision) => void;
|
||||
onRowClick: (rev: TaskPlanRevision) => void;
|
||||
}) {
|
||||
// Force re-render every 30s so the precise timestamp ("5m ago", "Today,
|
||||
// 14:32", …) refreshes as the revision ages — `formatPreciseTime` derives
|
||||
// from `revision.updated_at` on every render.
|
||||
const [, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTick((t) => t + 1), 30_000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
const timestamp = formatPreciseTime(revision.updated_at);
|
||||
|
||||
return (
|
||||
<li
|
||||
// items-center keeps the Restore button vertically centered against the
|
||||
// row body, even when the body grows taller (timestamp + restored-from
|
||||
// marker on revert rows).
|
||||
className="px-3 py-2.5 flex items-center gap-3 hover:bg-accent/30"
|
||||
data-testid="plan-revision-row"
|
||||
data-revision-id={revision.id}
|
||||
data-revision-number={revision.revision_number}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRowClick(revision)}
|
||||
className="flex-1 min-w-0 text-left cursor-pointer"
|
||||
data-testid="plan-revision-row-body"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-semibold">v{revision.revision_number}</span>
|
||||
<RevisionAuthor revision={revision} agentName={agentName} />
|
||||
{isCurrent && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-4 text-[10px] px-1.5"
|
||||
data-testid="plan-revision-current-badge"
|
||||
>
|
||||
current
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground mt-1" data-testid="plan-revision-time">
|
||||
{timestamp}
|
||||
</div>
|
||||
{revision.revert_of_revision_id && (
|
||||
<div
|
||||
className="text-[11px] text-muted-foreground mt-1 flex items-center gap-1"
|
||||
data-testid="plan-revision-revert-marker"
|
||||
>
|
||||
<IconRestore className="h-3 w-3" />
|
||||
restored from earlier version
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
{!isCurrent && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2 text-xs cursor-pointer shrink-0 gap-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRevertClick(revision);
|
||||
}}
|
||||
data-testid="plan-revision-revert-button"
|
||||
>
|
||||
<IconRestore className="h-3.5 w-3.5" />
|
||||
Restore
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function RevertConfirmDialog({
|
||||
target,
|
||||
isSaving,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
target: TaskPlanRevision | null;
|
||||
isSaving: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
}): ReactNode {
|
||||
const open = target !== null;
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
if (!v) onCancel();
|
||||
}}
|
||||
>
|
||||
<DialogContent data-testid="plan-revert-confirm-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Restore to version {target?.revision_number}?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This creates a new version with v{target?.revision_number}'s content. Nothing is
|
||||
lost; the current version stays in history.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={isSaving}
|
||||
className="cursor-pointer"
|
||||
data-testid="plan-revert-confirm-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
disabled={isSaving}
|
||||
className="cursor-pointer"
|
||||
data-testid="plan-revert-confirm-ok"
|
||||
>
|
||||
{isSaving ? "Restoring..." : "Restore"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import { usePanelSearch } from "@/hooks/use-panel-search";
|
||||
import { planSearchPluginKey } from "@/components/editors/tiptap/search-highlight-extension";
|
||||
|
||||
/** Ctrl+F find-in-plan shortcut + editor wiring. Returns the search-bar state
|
||||
* the panel renders into `<PanelSearchBar>`. */
|
||||
export function usePlanFindShortcut(
|
||||
wrapperRef: React.RefObject<HTMLDivElement | null>,
|
||||
editor: Editor | null,
|
||||
) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [matchInfo, setMatchInfo] = useState({ current: 0, total: 0 });
|
||||
|
||||
const readMatchInfo = useCallback(() => {
|
||||
if (!editor) return;
|
||||
const s = planSearchPluginKey.getState(editor.state);
|
||||
if (!s) return;
|
||||
const next = {
|
||||
current: s.matches.length ? s.current + 1 : 0,
|
||||
total: s.matches.length,
|
||||
};
|
||||
// Bail out when nothing changed — TipTap fires `transaction` on every cursor
|
||||
// move, and allocating a new object identity would re-render this hook on
|
||||
// every keystroke / click in the editor (and can cascade into update loops).
|
||||
setMatchInfo((prev) =>
|
||||
prev.current === next.current && prev.total === next.total ? prev : next,
|
||||
);
|
||||
}, [editor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
const handler = () => readMatchInfo();
|
||||
editor.on("transaction", handler);
|
||||
return () => {
|
||||
editor.off("transaction", handler);
|
||||
};
|
||||
}, [editor, readMatchInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
if (!isOpen) return;
|
||||
editor.commands.setPlanSearchQuery(query);
|
||||
}, [query, isOpen, editor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) return;
|
||||
if (!editor) return;
|
||||
editor.commands.clearPlanSearch();
|
||||
}, [isOpen, editor]);
|
||||
|
||||
const open = useCallback(() => setIsOpen(true), []);
|
||||
const close = useCallback(() => setIsOpen(false), []);
|
||||
const findNext = useCallback(() => {
|
||||
editor?.commands.planSearchNext();
|
||||
}, [editor]);
|
||||
const findPrev = useCallback(() => {
|
||||
editor?.commands.planSearchPrev();
|
||||
}, [editor]);
|
||||
|
||||
usePanelSearch({ containerRef: wrapperRef, isOpen, onOpen: open, onClose: close });
|
||||
|
||||
return { isOpen, open, close, query, setQuery, findNext, findPrev, matchInfo };
|
||||
}
|
||||
Reference in New Issue
Block a user