From 12bd5b2cf325feccde73874ddb6a1da84bc8cfe2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Mon, 27 Apr 2026 10:22:58 +0100 Subject: [PATCH] feat: plan checkpointing with rewind UI (#694) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 `
` 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.
---
apps/backend/internal/events/types.go | 8 +-
.../gateway/websocket/task_notifications.go | 2 +
.../websocket/task_notifications_test.go | 2 +-
apps/backend/internal/task/dto/dto.go | 43 ++
.../internal/task/handlers/task_handlers.go | 3 +
.../task/handlers/task_plan_handlers.go | 132 ++++-
apps/backend/internal/task/models/models.go | 15 +
.../internal/task/repository/interface.go | 14 +-
.../task/repository/plan_revisions_test.go | 249 +++++++++
.../internal/task/repository/sqlite/base.go | 83 ++-
.../internal/task/repository/sqlite/plan.go | 255 +++++++++
.../internal/task/service/plan_service.go | 477 ++++++++++++----
.../task/service/plan_service_test.go | 256 +++++++++
apps/backend/pkg/websocket/actions.go | 29 +-
.../editors/tiptap/tiptap-plan-readonly.tsx | 87 +++
.../components/task/task-plan-diff-dialog.tsx | 447 +++++++++++++++
.../components/task/task-plan-diff.test.ts | 69 +++
apps/web/components/task/task-plan-diff.ts | 51 ++
apps/web/components/task/task-plan-panel.tsx | 251 +++++----
.../task/task-plan-preview-dialog.tsx | 188 +++++++
.../components/task/task-plan-revisions.tsx | 522 ++++++++++++++++++
.../components/task/use-plan-find-shortcut.ts | 67 +++
apps/web/e2e/fixtures/backend.ts | 2 +
apps/web/e2e/pages/session-page.ts | 130 +++++
.../e2e/tests/task/plan-checkpointing.spec.ts | 455 +++++++++++++++
.../hooks/domains/session/use-task-plan.ts | 144 ++++-
apps/web/lib/api/domains/plan-api.ts | 56 +-
.../lib/state/slices/session/session-slice.ts | 96 ++++
apps/web/lib/state/slices/session/types.ts | 24 +-
apps/web/lib/state/store.ts | 15 +
apps/web/lib/types/backend.ts | 20 +
apps/web/lib/types/http-agents.ts | 15 +
apps/web/lib/utils.ts | 40 ++
apps/web/lib/ws/handlers/task-plans.ts | 28 +
34 files changed, 4010 insertions(+), 265 deletions(-)
create mode 100644 apps/backend/internal/task/repository/plan_revisions_test.go
create mode 100644 apps/web/components/editors/tiptap/tiptap-plan-readonly.tsx
create mode 100644 apps/web/components/task/task-plan-diff-dialog.tsx
create mode 100644 apps/web/components/task/task-plan-diff.test.ts
create mode 100644 apps/web/components/task/task-plan-diff.ts
create mode 100644 apps/web/components/task/task-plan-preview-dialog.tsx
create mode 100644 apps/web/components/task/task-plan-revisions.tsx
create mode 100644 apps/web/components/task/use-plan-find-shortcut.ts
create mode 100644 apps/web/e2e/tests/task/plan-checkpointing.spec.ts
diff --git a/apps/backend/internal/events/types.go b/apps/backend/internal/events/types.go
index 06febd1272..80f5e54fd3 100644
--- a/apps/backend/internal/events/types.go
+++ b/apps/backend/internal/events/types.go
@@ -49,9 +49,11 @@ const (
// Event types for task plans
const (
- TaskPlanCreated = "task_plan.created"
- TaskPlanUpdated = "task_plan.updated"
- TaskPlanDeleted = "task_plan.deleted"
+ TaskPlanCreated = "task_plan.created"
+ TaskPlanUpdated = "task_plan.updated"
+ TaskPlanDeleted = "task_plan.deleted"
+ TaskPlanRevisionCreated = "task_plan.revision.created"
+ TaskPlanReverted = "task_plan.reverted"
)
// Event types for session turns
diff --git a/apps/backend/internal/gateway/websocket/task_notifications.go b/apps/backend/internal/gateway/websocket/task_notifications.go
index f2e12e054c..5956fb0974 100644
--- a/apps/backend/internal/gateway/websocket/task_notifications.go
+++ b/apps/backend/internal/gateway/websocket/task_notifications.go
@@ -44,6 +44,8 @@ func RegisterTaskNotifications(ctx context.Context, eventBus bus.EventBus, hub *
b.subscribe(eventBus, events.TaskPlanCreated, ws.ActionTaskPlanCreated)
b.subscribe(eventBus, events.TaskPlanUpdated, ws.ActionTaskPlanUpdated)
b.subscribe(eventBus, events.TaskPlanDeleted, ws.ActionTaskPlanDeleted)
+ b.subscribe(eventBus, events.TaskPlanRevisionCreated, ws.ActionTaskPlanRevisionCreated)
+ b.subscribe(eventBus, events.TaskPlanReverted, ws.ActionTaskPlanReverted)
b.subscribe(eventBus, events.RepositoryCreated, ws.ActionRepositoryCreated)
b.subscribe(eventBus, events.RepositoryUpdated, ws.ActionRepositoryUpdated)
b.subscribe(eventBus, events.RepositoryDeleted, ws.ActionRepositoryDeleted)
diff --git a/apps/backend/internal/gateway/websocket/task_notifications_test.go b/apps/backend/internal/gateway/websocket/task_notifications_test.go
index 27d5d4a143..3efae8079c 100644
--- a/apps/backend/internal/gateway/websocket/task_notifications_test.go
+++ b/apps/backend/internal/gateway/websocket/task_notifications_test.go
@@ -39,7 +39,7 @@ func TestTaskEventBroadcaster_NoDuplicateSubscriptions(t *testing.T) {
//
// Update this number when adding or removing event subscriptions in
// RegisterTaskNotifications — it is intentionally exact.
- const wantSubscriptions = 46
+ const wantSubscriptions = 48
if got := len(b.subscriptions); got != wantSubscriptions {
t.Errorf("RegisterTaskNotifications created %d subscriptions, want %d — "+
"did an event get subscribed twice?", got, wantSubscriptions)
diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go
index f03c2ba6dd..6af7d29386 100644
--- a/apps/backend/internal/task/dto/dto.go
+++ b/apps/backend/internal/task/dto/dto.go
@@ -697,6 +697,49 @@ func TaskPlanFromModel(plan *models.TaskPlan) *TaskPlanDTO {
}
}
+// TaskPlanRevisionDTO represents a plan revision for API responses.
+// Content is optional so list responses can omit it (fetched on demand).
+type TaskPlanRevisionDTO struct {
+ ID string `json:"id"`
+ TaskID string `json:"task_id"`
+ RevisionNumber int `json:"revision_number"`
+ Title string `json:"title"`
+ Content string `json:"content,omitempty"`
+ AuthorKind string `json:"author_kind"`
+ AuthorName string `json:"author_name"`
+ RevertOfRevisionID *string `json:"revert_of_revision_id,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+// TaskPlanRevisionFromModel converts a TaskPlanRevision model with content.
+func TaskPlanRevisionFromModel(rev *models.TaskPlanRevision) *TaskPlanRevisionDTO {
+ if rev == nil {
+ return nil
+ }
+ return &TaskPlanRevisionDTO{
+ ID: rev.ID,
+ TaskID: rev.TaskID,
+ RevisionNumber: rev.RevisionNumber,
+ Title: rev.Title,
+ Content: rev.Content,
+ AuthorKind: rev.AuthorKind,
+ AuthorName: rev.AuthorName,
+ RevertOfRevisionID: rev.RevertOfRevisionID,
+ CreatedAt: rev.CreatedAt,
+ UpdatedAt: rev.UpdatedAt,
+ }
+}
+
+// TaskPlanRevisionMetaFromModel converts without content (for list payloads/WS broadcasts).
+func TaskPlanRevisionMetaFromModel(rev *models.TaskPlanRevision) *TaskPlanRevisionDTO {
+ meta := TaskPlanRevisionFromModel(rev)
+ if meta != nil {
+ meta.Content = ""
+ }
+ return meta
+}
+
// FromTurn converts a Turn model to a TurnDTO.
func FromTurn(turn *models.Turn) TurnDTO {
var completedAt *string
diff --git a/apps/backend/internal/task/handlers/task_handlers.go b/apps/backend/internal/task/handlers/task_handlers.go
index e28e541838..b7e060a82f 100644
--- a/apps/backend/internal/task/handlers/task_handlers.go
+++ b/apps/backend/internal/task/handlers/task_handlers.go
@@ -109,6 +109,9 @@ func (h *TaskHandlers) registerWS(dispatcher *ws.Dispatcher) {
dispatcher.RegisterFunc(ws.ActionTaskPlanGet, h.wsGetTaskPlan)
dispatcher.RegisterFunc(ws.ActionTaskPlanUpdate, h.wsUpdateTaskPlan)
dispatcher.RegisterFunc(ws.ActionTaskPlanDelete, h.wsDeleteTaskPlan)
+ dispatcher.RegisterFunc(ws.ActionTaskPlanRevisionsList, h.wsListTaskPlanRevisions)
+ dispatcher.RegisterFunc(ws.ActionTaskPlanRevisionGet, h.wsGetTaskPlanRevision)
+ dispatcher.RegisterFunc(ws.ActionTaskPlanRevert, h.wsRevertTaskPlan)
}
// convertToServiceRepos converts dto.TaskRepositoryInput slice to service.TaskRepositoryInput slice.
diff --git a/apps/backend/internal/task/handlers/task_plan_handlers.go b/apps/backend/internal/task/handlers/task_plan_handlers.go
index 2a74171231..a9a3b92de0 100644
--- a/apps/backend/internal/task/handlers/task_plan_handlers.go
+++ b/apps/backend/internal/task/handlers/task_plan_handlers.go
@@ -15,26 +15,29 @@ import (
// wsCreateTaskPlan creates a new task plan
func (h *TaskHandlers) wsCreateTaskPlan(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
- TaskID string `json:"task_id"`
- Title string `json:"title"`
- Content string `json:"content"`
- CreatedBy string `json:"created_by"`
+ TaskID string `json:"task_id"`
+ Title string `json:"title"`
+ Content string `json:"content"`
+ CreatedBy string `json:"created_by"`
+ AuthorKind string `json:"author_kind"`
+ AuthorName string `json:"author_name"`
}
if err := json.Unmarshal(msg.Payload, &req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
- // Default to "user" for frontend requests
createdBy := req.CreatedBy
if createdBy == "" {
createdBy = "user"
}
plan, err := h.planService.CreatePlan(ctx, service.CreatePlanRequest{
- TaskID: req.TaskID,
- Title: req.Title,
- Content: req.Content,
- CreatedBy: createdBy,
+ TaskID: req.TaskID,
+ Title: req.Title,
+ Content: req.Content,
+ CreatedBy: createdBy,
+ AuthorKind: req.AuthorKind,
+ AuthorName: req.AuthorName,
})
if err != nil {
if errors.Is(err, service.ErrTaskIDRequired) {
@@ -72,26 +75,29 @@ func (h *TaskHandlers) wsGetTaskPlan(ctx context.Context, msg *ws.Message) (*ws.
// wsUpdateTaskPlan updates an existing task plan
func (h *TaskHandlers) wsUpdateTaskPlan(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
- TaskID string `json:"task_id"`
- Title string `json:"title"`
- Content string `json:"content"`
- CreatedBy string `json:"created_by"`
+ TaskID string `json:"task_id"`
+ Title string `json:"title"`
+ Content string `json:"content"`
+ CreatedBy string `json:"created_by"`
+ AuthorKind string `json:"author_kind"`
+ AuthorName string `json:"author_name"`
}
if err := json.Unmarshal(msg.Payload, &req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
- // Default to "user" for frontend requests
createdBy := req.CreatedBy
if createdBy == "" {
createdBy = "user"
}
plan, err := h.planService.UpdatePlan(ctx, service.UpdatePlanRequest{
- TaskID: req.TaskID,
- Title: req.Title,
- Content: req.Content,
- CreatedBy: createdBy,
+ TaskID: req.TaskID,
+ Title: req.Title,
+ Content: req.Content,
+ CreatedBy: createdBy,
+ AuthorKind: req.AuthorKind,
+ AuthorName: req.AuthorName,
})
if err != nil {
if errors.Is(err, service.ErrTaskIDRequired) {
@@ -128,3 +134,93 @@ func (h *TaskHandlers) wsDeleteTaskPlan(ctx context.Context, msg *ws.Message) (*
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{"success": true})
}
+
+// wsListTaskPlanRevisions returns revision metadata newest-first (no content).
+func (h *TaskHandlers) wsListTaskPlanRevisions(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
+ var req struct {
+ TaskID string `json:"task_id"`
+ }
+ if err := json.Unmarshal(msg.Payload, &req); err != nil {
+ return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
+ }
+
+ revs, err := h.planService.ListRevisions(ctx, req.TaskID)
+ if err != nil {
+ if errors.Is(err, service.ErrTaskIDRequired) {
+ return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil)
+ }
+ return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list revisions: "+err.Error(), nil)
+ }
+
+ out := make([]*dto.TaskPlanRevisionDTO, 0, len(revs))
+ for _, r := range revs {
+ out = append(out, dto.TaskPlanRevisionMetaFromModel(r))
+ }
+ return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{"revisions": out})
+}
+
+// wsGetTaskPlanRevision returns a single revision with content.
+func (h *TaskHandlers) wsGetTaskPlanRevision(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
+ var req struct {
+ TaskID string `json:"task_id"`
+ RevisionID string `json:"revision_id"`
+ }
+ if err := json.Unmarshal(msg.Payload, &req); err != nil {
+ return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
+ }
+ if req.RevisionID == "" {
+ // Surface missing argument as a 400, matching wsRevertTaskPlan's
+ // ErrRevisionIDRequired branch — without this guard, an empty id
+ // falls through to the service and returns 404 (lookup miss),
+ // which is misleading for the caller.
+ return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "revision_id is required", nil)
+ }
+
+ rev, err := h.planService.GetRevision(ctx, req.RevisionID)
+ if err != nil {
+ if errors.Is(err, service.ErrRevisionNotFound) {
+ return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Revision not found", nil)
+ }
+ return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to get revision: "+err.Error(), nil)
+ }
+ // Match wsRevertTaskPlan's ownership check so a caller can only read
+ // content for revisions belonging to the task they hold a reference to.
+ // Optional task_id keeps existing callers compatible while letting the
+ // frontend tighten the request shape.
+ if req.TaskID != "" && rev.TaskID != req.TaskID {
+ return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "Revision does not belong to task", nil)
+ }
+ return ws.NewResponse(msg.ID, msg.Action, dto.TaskPlanRevisionFromModel(rev))
+}
+
+// wsRevertTaskPlan reverts the plan HEAD to a target revision by appending a new revision.
+func (h *TaskHandlers) wsRevertTaskPlan(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
+ var req struct {
+ TaskID string `json:"task_id"`
+ RevisionID string `json:"revision_id"`
+ AuthorName string `json:"author_name"`
+ }
+ if err := json.Unmarshal(msg.Payload, &req); err != nil {
+ return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
+ }
+
+ rev, err := h.planService.RevertPlan(ctx, service.RevertPlanRequest{
+ TaskID: req.TaskID,
+ TargetRevisionID: req.RevisionID,
+ AuthorName: req.AuthorName,
+ })
+ if err != nil {
+ switch {
+ case errors.Is(err, service.ErrTaskIDRequired):
+ return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil)
+ case errors.Is(err, service.ErrRevisionIDRequired):
+ return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "revision_id is required", nil)
+ case errors.Is(err, service.ErrRevisionNotFound):
+ return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Revision not found", nil)
+ case errors.Is(err, service.ErrRevisionTaskMismatch):
+ return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "Revision does not belong to task", nil)
+ }
+ return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to revert plan: "+err.Error(), nil)
+ }
+ return ws.NewResponse(msg.ID, msg.Action, dto.TaskPlanRevisionFromModel(rev))
+}
diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go
index 946905d7fe..cbe087eb17 100644
--- a/apps/backend/internal/task/models/models.go
+++ b/apps/backend/internal/task/models/models.go
@@ -611,6 +611,21 @@ type TaskPlan struct {
UpdatedAt time.Time `json:"updated_at"`
}
+// TaskPlanRevision is one immutable snapshot in the revision history of a task plan.
+// Revisions are the source of truth for history; TaskPlan stores the latest revision's content as HEAD.
+type TaskPlanRevision struct {
+ ID string `json:"id"`
+ TaskID string `json:"task_id"`
+ RevisionNumber int `json:"revision_number"`
+ Title string `json:"title"`
+ Content string `json:"content"`
+ AuthorKind string `json:"author_kind"` // "agent" | "user"
+ AuthorName string `json:"author_name"` // display snapshot (agent profile name or user identifier)
+ RevertOfRevisionID *string `json:"revert_of_revision_id,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"` // bumps on coalesce merge
+}
+
// SessionFileReview tracks per-file review state within a session
type SessionFileReview struct {
ID string `json:"id"`
diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go
index ccb22c2a54..d3d04887ea 100644
--- a/apps/backend/internal/task/repository/interface.go
+++ b/apps/backend/internal/task/repository/interface.go
@@ -198,10 +198,22 @@ type ReviewRepository interface {
DeleteSessionFileReviews(ctx context.Context, sessionID string) error
}
-// PlanRepository handles task plan CRUD.
+// PlanRepository handles task plan CRUD and its revision history.
type PlanRepository interface {
CreateTaskPlan(ctx context.Context, plan *models.TaskPlan) error
GetTaskPlan(ctx context.Context, taskID string) (*models.TaskPlan, error)
UpdateTaskPlan(ctx context.Context, plan *models.TaskPlan) error
DeleteTaskPlan(ctx context.Context, taskID string) error
+
+ // Revision history
+ InsertTaskPlanRevision(ctx context.Context, rev *models.TaskPlanRevision) error
+ UpdateTaskPlanRevision(ctx context.Context, rev *models.TaskPlanRevision) error
+ GetTaskPlanRevision(ctx context.Context, id string) (*models.TaskPlanRevision, error)
+ GetLatestTaskPlanRevision(ctx context.Context, taskID string) (*models.TaskPlanRevision, error)
+ ListTaskPlanRevisions(ctx context.Context, taskID string, limit int) ([]*models.TaskPlanRevision, error)
+ NextTaskPlanRevisionNumber(ctx context.Context, taskID string) (int, error)
+ // WritePlanRevision atomically upserts the HEAD plan and writes/merges a revision in a
+ // single transaction. Pass a non-nil coalesceLatestID to merge into an existing revision;
+ // otherwise a new revision is appended with revision_number computed inside the tx.
+ WritePlanRevision(ctx context.Context, head *models.TaskPlan, rev *models.TaskPlanRevision, coalesceLatestID *string) error
}
diff --git a/apps/backend/internal/task/repository/plan_revisions_test.go b/apps/backend/internal/task/repository/plan_revisions_test.go
new file mode 100644
index 0000000000..fbe59aa833
--- /dev/null
+++ b/apps/backend/internal/task/repository/plan_revisions_test.go
@@ -0,0 +1,249 @@
+package repository
+
+import (
+ "context"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/jmoiron/sqlx"
+ "github.com/kandev/kandev/internal/db"
+ "github.com/kandev/kandev/internal/task/models"
+ "github.com/kandev/kandev/internal/task/repository/sqlite"
+ v1 "github.com/kandev/kandev/pkg/api/v1"
+)
+
+func seedPlanRevisionTask(t *testing.T, ctx context.Context, repo interface {
+ CreateWorkspace(context.Context, *models.Workspace) error
+ CreateWorkflow(context.Context, *models.Workflow) error
+ CreateTask(context.Context, *models.Task) error
+}, taskID string) {
+ t.Helper()
+ _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-rev", Name: "Rev WS"})
+ _ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-rev", WorkspaceID: "ws-rev", Name: "WF"})
+ now := time.Now().UTC()
+ _ = repo.CreateTask(ctx, &models.Task{
+ ID: taskID, WorkspaceID: "ws-rev", WorkflowID: "wf-rev",
+ Title: "T", State: v1.TaskStateCreated, CreatedAt: now, UpdatedAt: now,
+ })
+}
+
+func TestPlanRevisions_InsertAndList(t *testing.T) {
+ repo, cleanup := createTestSQLiteRepo(t)
+ defer cleanup()
+ ctx := context.Background()
+ seedPlanRevisionTask(t, ctx, repo, "task-r1")
+
+ for i := 1; i <= 3; i++ {
+ rev := &models.TaskPlanRevision{
+ TaskID: "task-r1", RevisionNumber: i,
+ Title: "Plan", Content: "c",
+ AuthorKind: "agent", AuthorName: "Agent",
+ }
+ if err := repo.InsertTaskPlanRevision(ctx, rev); err != nil {
+ t.Fatalf("insert %d: %v", i, err)
+ }
+ }
+
+ list, err := repo.ListTaskPlanRevisions(ctx, "task-r1", 0)
+ if err != nil {
+ t.Fatalf("list: %v", err)
+ }
+ if len(list) != 3 {
+ t.Fatalf("expected 3, got %d", len(list))
+ }
+ if list[0].RevisionNumber != 3 || list[2].RevisionNumber != 1 {
+ t.Errorf("expected newest-first ordering, got %d..%d", list[0].RevisionNumber, list[2].RevisionNumber)
+ }
+}
+
+func TestPlanRevisions_NextRevisionNumber(t *testing.T) {
+ repo, cleanup := createTestSQLiteRepo(t)
+ defer cleanup()
+ ctx := context.Background()
+ seedPlanRevisionTask(t, ctx, repo, "task-r2")
+
+ n, err := repo.NextTaskPlanRevisionNumber(ctx, "task-r2")
+ if err != nil {
+ t.Fatalf("next: %v", err)
+ }
+ if n != 1 {
+ t.Errorf("expected 1, got %d", n)
+ }
+
+ _ = repo.InsertTaskPlanRevision(ctx, &models.TaskPlanRevision{
+ TaskID: "task-r2", RevisionNumber: 1, AuthorKind: "agent",
+ })
+ _ = repo.InsertTaskPlanRevision(ctx, &models.TaskPlanRevision{
+ TaskID: "task-r2", RevisionNumber: 2, AuthorKind: "agent",
+ })
+
+ n, _ = repo.NextTaskPlanRevisionNumber(ctx, "task-r2")
+ if n != 3 {
+ t.Errorf("expected 3, got %d", n)
+ }
+}
+
+func TestPlanRevisions_UniqueConstraint(t *testing.T) {
+ repo, cleanup := createTestSQLiteRepo(t)
+ defer cleanup()
+ ctx := context.Background()
+ seedPlanRevisionTask(t, ctx, repo, "task-r3")
+
+ if err := repo.InsertTaskPlanRevision(ctx, &models.TaskPlanRevision{
+ TaskID: "task-r3", RevisionNumber: 1, AuthorKind: "agent",
+ }); err != nil {
+ t.Fatalf("first insert: %v", err)
+ }
+ err := repo.InsertTaskPlanRevision(ctx, &models.TaskPlanRevision{
+ TaskID: "task-r3", RevisionNumber: 1, AuthorKind: "agent",
+ })
+ if err == nil {
+ t.Fatal("expected UNIQUE violation on duplicate (task_id, revision_number)")
+ }
+}
+
+func TestPlanRevisions_GetLatest(t *testing.T) {
+ repo, cleanup := createTestSQLiteRepo(t)
+ defer cleanup()
+ ctx := context.Background()
+ seedPlanRevisionTask(t, ctx, repo, "task-r4")
+
+ latest, _ := repo.GetLatestTaskPlanRevision(ctx, "task-r4")
+ if latest != nil {
+ t.Errorf("expected nil before any insert, got %+v", latest)
+ }
+
+ _ = repo.InsertTaskPlanRevision(ctx, &models.TaskPlanRevision{TaskID: "task-r4", RevisionNumber: 1, AuthorKind: "agent", Content: "a"})
+ _ = repo.InsertTaskPlanRevision(ctx, &models.TaskPlanRevision{TaskID: "task-r4", RevisionNumber: 2, AuthorKind: "user", Content: "b"})
+
+ latest, err := repo.GetLatestTaskPlanRevision(ctx, "task-r4")
+ if err != nil {
+ t.Fatalf("latest: %v", err)
+ }
+ if latest == nil || latest.RevisionNumber != 2 || latest.Content != "b" {
+ t.Errorf("expected newest rev 2 with content 'b', got %+v", latest)
+ }
+}
+
+func TestPlanRevisions_UpdateCoalesceMerge(t *testing.T) {
+ repo, cleanup := createTestSQLiteRepo(t)
+ defer cleanup()
+ ctx := context.Background()
+ seedPlanRevisionTask(t, ctx, repo, "task-r5")
+
+ rev := &models.TaskPlanRevision{
+ TaskID: "task-r5", RevisionNumber: 1,
+ Title: "Plan", Content: "v1",
+ AuthorKind: "agent", AuthorName: "Agent",
+ }
+ _ = repo.InsertTaskPlanRevision(ctx, rev)
+ originalID := rev.ID
+
+ rev.Content = "v2"
+ if err := repo.UpdateTaskPlanRevision(ctx, rev); err != nil {
+ t.Fatalf("update: %v", err)
+ }
+
+ got, _ := repo.GetTaskPlanRevision(ctx, originalID)
+ if got.Content != "v2" {
+ t.Errorf("expected merged content 'v2', got %q", got.Content)
+ }
+ if !got.UpdatedAt.After(got.CreatedAt) && !got.UpdatedAt.Equal(got.CreatedAt) {
+ t.Errorf("updated_at should be >= created_at")
+ }
+
+ list, _ := repo.ListTaskPlanRevisions(ctx, "task-r5", 0)
+ if len(list) != 1 {
+ t.Errorf("coalesce merge should not add rows; have %d", len(list))
+ }
+}
+
+func TestPlanRevisions_RevertOfRevisionID(t *testing.T) {
+ repo, cleanup := createTestSQLiteRepo(t)
+ defer cleanup()
+ ctx := context.Background()
+ seedPlanRevisionTask(t, ctx, repo, "task-r6")
+
+ target := &models.TaskPlanRevision{
+ TaskID: "task-r6", RevisionNumber: 1,
+ Content: "original", AuthorKind: "agent", AuthorName: "Agent",
+ }
+ _ = repo.InsertTaskPlanRevision(ctx, target)
+
+ refID := target.ID
+ revertRev := &models.TaskPlanRevision{
+ TaskID: "task-r6", RevisionNumber: 2,
+ Content: "original", AuthorKind: "user", AuthorName: "User",
+ RevertOfRevisionID: &refID,
+ }
+ _ = repo.InsertTaskPlanRevision(ctx, revertRev)
+
+ got, _ := repo.GetTaskPlanRevision(ctx, revertRev.ID)
+ if got.RevertOfRevisionID == nil || *got.RevertOfRevisionID != refID {
+ t.Errorf("expected revert_of_revision_id=%q, got %v", refID, got.RevertOfRevisionID)
+ }
+}
+
+func TestPlanRevisions_BackfillsExistingPlan(t *testing.T) {
+ ctx := context.Background()
+ tmpDir := t.TempDir()
+ dbPath := filepath.Join(tmpDir, "backfill.db")
+
+ // First open: seed a task + plan, then close (simulates pre-migration state plus
+ // a subsequent reopen — backfill runs once per newRepository call).
+ firstConn, err := db.OpenSQLite(dbPath)
+ if err != nil {
+ t.Fatalf("open 1: %v", err)
+ }
+ firstDB := sqlx.NewDb(firstConn, "sqlite3")
+ firstRepo, err := sqlite.NewWithDB(firstDB, firstDB)
+ if err != nil {
+ t.Fatalf("repo 1: %v", err)
+ }
+ seedPlanRevisionTask(t, ctx, firstRepo, "task-r7")
+ _ = firstRepo.CreateTaskPlan(ctx, &models.TaskPlan{
+ TaskID: "task-r7", Title: "Legacy", Content: "legacy content", CreatedBy: "agent",
+ })
+ // Remove the revision the plan creation would have left (if any) so backfill has real work.
+ _, _ = firstDB.Exec(`DELETE FROM task_plan_revisions WHERE task_id = ?`, "task-r7")
+ if err := firstDB.Close(); err != nil {
+ t.Fatalf("close 1: %v", err)
+ }
+
+ // Second open: backfill should run during initSchema and synthesize a revision.
+ secondConn, err := db.OpenSQLite(dbPath)
+ if err != nil {
+ t.Fatalf("open 2: %v", err)
+ }
+ secondDB := sqlx.NewDb(secondConn, "sqlite3")
+ secondRepo, err := sqlite.NewWithDB(secondDB, secondDB)
+ if err != nil {
+ t.Fatalf("repo 2: %v", err)
+ }
+ defer func() {
+ if err := secondDB.Close(); err != nil {
+ t.Errorf("close 2: %v", err)
+ }
+ }()
+
+ list, err := secondRepo.ListTaskPlanRevisions(ctx, "task-r7", 0)
+ if err != nil {
+ t.Fatalf("list: %v", err)
+ }
+ if len(list) != 1 {
+ t.Fatalf("expected 1 backfilled revision, got %d", len(list))
+ }
+ if list[0].Content != "legacy content" {
+ t.Errorf("expected backfilled content 'legacy content', got %q", list[0].Content)
+ }
+ if list[0].AuthorName != "legacy" {
+ t.Errorf("expected author_name 'legacy', got %q", list[0].AuthorName)
+ }
+ if list[0].AuthorKind != "agent" {
+ t.Errorf("expected author_kind 'agent' (carried over from created_by), got %q", list[0].AuthorKind)
+ }
+ if list[0].RevisionNumber != 1 {
+ t.Errorf("expected revision_number=1, got %d", list[0].RevisionNumber)
+ }
+}
diff --git a/apps/backend/internal/task/repository/sqlite/base.go b/apps/backend/internal/task/repository/sqlite/base.go
index 0d2c59e9c1..d66f6a2ebf 100644
--- a/apps/backend/internal/task/repository/sqlite/base.go
+++ b/apps/backend/internal/task/repository/sqlite/base.go
@@ -581,7 +581,7 @@ func (r *Repository) initCoreIndexes() error {
}
func (r *Repository) initPlansSchema() error {
- _, err := r.db.Exec(`
+ if _, err := r.db.Exec(`
CREATE TABLE IF NOT EXISTS task_plans (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL UNIQUE,
@@ -593,8 +593,85 @@ func (r *Repository) initPlansSchema() error {
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_task_plans_task_id ON task_plans(task_id);
- `)
- return err
+ `); err != nil {
+ return err
+ }
+ if _, err := r.db.Exec(`
+ CREATE TABLE IF NOT EXISTS task_plan_revisions (
+ id TEXT PRIMARY KEY,
+ task_id TEXT NOT NULL,
+ revision_number INTEGER NOT NULL,
+ title TEXT NOT NULL DEFAULT 'Plan',
+ content TEXT NOT NULL DEFAULT '',
+ author_kind TEXT NOT NULL DEFAULT 'agent',
+ author_name TEXT NOT NULL DEFAULT '',
+ revert_of_revision_id TEXT,
+ created_at TIMESTAMP NOT NULL,
+ updated_at TIMESTAMP NOT NULL,
+ FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
+ UNIQUE (task_id, revision_number)
+ );
+ CREATE INDEX IF NOT EXISTS idx_task_plan_revisions_task_created
+ ON task_plan_revisions(task_id, created_at DESC);
+ -- Hot-path index: GetLatestTaskPlanRevision (called on every plan write
+ -- as part of the coalesce check), ListTaskPlanRevisions, and the
+ -- MAX(revision_number) lookup in WritePlanRevision all order/scan by
+ -- (task_id, revision_number DESC). With this index the latest-row lookup
+ -- is O(1) instead of an O(N) scan + sort per task.
+ CREATE INDEX IF NOT EXISTS idx_task_plan_revisions_task_number
+ ON task_plan_revisions(task_id, revision_number DESC);
+ `); err != nil {
+ return err
+ }
+ return r.backfillInitialPlanRevisions()
+}
+
+// backfillInitialPlanRevisions ensures every existing task_plans row has at least
+// one corresponding revision. Runs once at startup and is idempotent.
+func (r *Repository) backfillInitialPlanRevisions() error {
+ rows, err := r.db.Query(`
+ SELECT p.id, p.task_id, p.title, p.content, p.created_by, p.created_at, p.updated_at
+ FROM task_plans p
+ WHERE NOT EXISTS (
+ SELECT 1 FROM task_plan_revisions r WHERE r.task_id = p.task_id
+ )`)
+ if err != nil {
+ return fmt.Errorf("query plans missing revisions: %w", err)
+ }
+ defer func() { _ = rows.Close() }()
+
+ type row struct {
+ id, taskID, title, content, createdBy string
+ createdAt, updatedAt interface{}
+ }
+ var pending []row
+ for rows.Next() {
+ var x row
+ if err := rows.Scan(&x.id, &x.taskID, &x.title, &x.content, &x.createdBy, &x.createdAt, &x.updatedAt); err != nil {
+ return fmt.Errorf("scan plan for backfill: %w", err)
+ }
+ pending = append(pending, x)
+ }
+ if err := rows.Err(); err != nil {
+ return fmt.Errorf("iterate plans for backfill: %w", err)
+ }
+
+ for _, x := range pending {
+ authorKind := x.createdBy
+ // Match CreateTaskPlan (plan.go) and the task_plan_revisions column DEFAULT 'agent'.
+ if authorKind != "user" && authorKind != "agent" {
+ authorKind = "agent"
+ }
+ _, err := r.db.Exec(r.db.Rebind(`
+ INSERT INTO task_plan_revisions
+ (id, task_id, revision_number, title, content, author_kind, author_name, revert_of_revision_id, created_at, updated_at)
+ VALUES (?, ?, 1, ?, ?, ?, 'legacy', NULL, ?, ?)
+ `), uuid.New().String(), x.taskID, x.title, x.content, authorKind, x.createdAt, x.updatedAt)
+ if err != nil {
+ return fmt.Errorf("backfill revision for task %s: %w", x.taskID, err)
+ }
+ }
+ return nil
}
func (r *Repository) initSessionSchema() error {
diff --git a/apps/backend/internal/task/repository/sqlite/plan.go b/apps/backend/internal/task/repository/sqlite/plan.go
index c528081c4d..6e8edb43cc 100644
--- a/apps/backend/internal/task/repository/sqlite/plan.go
+++ b/apps/backend/internal/task/repository/sqlite/plan.go
@@ -7,9 +7,14 @@ import (
"time"
"github.com/google/uuid"
+ "github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/task/models"
)
+// revisionSelectCols lists the task_plan_revisions columns in the fixed order used by
+// every SELECT in this file (and by scanRevisionRow / scanRevisionRows).
+const revisionSelectCols = `id, task_id, revision_number, title, content, author_kind, author_name, revert_of_revision_id, created_at, updated_at`
+
// CreateTaskPlan creates a new task plan.
func (r *Repository) CreateTaskPlan(ctx context.Context, plan *models.TaskPlan) error {
if plan.ID == "" {
@@ -82,3 +87,253 @@ func (r *Repository) DeleteTaskPlan(ctx context.Context, taskID string) error {
}
return nil
}
+
+// Revision history
+
+// InsertTaskPlanRevision inserts a new revision row.
+func (r *Repository) InsertTaskPlanRevision(ctx context.Context, rev *models.TaskPlanRevision) error {
+ if rev.ID == "" {
+ rev.ID = uuid.New().String()
+ }
+ now := time.Now().UTC()
+ if rev.CreatedAt.IsZero() {
+ rev.CreatedAt = now
+ }
+ rev.UpdatedAt = now
+
+ _, err := r.db.ExecContext(ctx, r.db.Rebind(`
+ INSERT INTO task_plan_revisions
+ (id, task_id, revision_number, title, content, author_kind, author_name, revert_of_revision_id, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `),
+ rev.ID, rev.TaskID, rev.RevisionNumber, rev.Title, rev.Content,
+ rev.AuthorKind, rev.AuthorName, rev.RevertOfRevisionID, rev.CreatedAt, rev.UpdatedAt)
+ if err != nil {
+ return fmt.Errorf("failed to insert task plan revision: %w", err)
+ }
+ return nil
+}
+
+// UpdateTaskPlanRevision updates title/content/updated_at on an existing revision (coalesce merge).
+func (r *Repository) UpdateTaskPlanRevision(ctx context.Context, rev *models.TaskPlanRevision) error {
+ rev.UpdatedAt = time.Now().UTC()
+ result, err := r.db.ExecContext(ctx, r.db.Rebind(`
+ UPDATE task_plan_revisions
+ SET title = ?, content = ?, updated_at = ?
+ WHERE id = ?
+ `), rev.Title, rev.Content, rev.UpdatedAt, rev.ID)
+ if err != nil {
+ return fmt.Errorf("failed to update task plan revision: %w", err)
+ }
+ rows, _ := result.RowsAffected()
+ if rows == 0 {
+ return fmt.Errorf("task plan revision not found: %s", rev.ID)
+ }
+ return nil
+}
+
+// GetTaskPlanRevision fetches a single revision by ID.
+func (r *Repository) GetTaskPlanRevision(ctx context.Context, id string) (*models.TaskPlanRevision, error) {
+ return r.scanRevisionRow(r.ro.QueryRowContext(ctx, r.ro.Rebind(
+ `SELECT `+revisionSelectCols+` FROM task_plan_revisions WHERE id = ?`,
+ ), id))
+}
+
+// GetLatestTaskPlanRevision returns the newest revision for a task (by revision_number DESC).
+func (r *Repository) GetLatestTaskPlanRevision(ctx context.Context, taskID string) (*models.TaskPlanRevision, error) {
+ return r.scanRevisionRow(r.ro.QueryRowContext(ctx, r.ro.Rebind(
+ `SELECT `+revisionSelectCols+` FROM task_plan_revisions WHERE task_id = ? ORDER BY revision_number DESC LIMIT 1`,
+ ), taskID))
+}
+
+// ListTaskPlanRevisions returns revisions newest-first. limit <= 0 returns all.
+func (r *Repository) ListTaskPlanRevisions(ctx context.Context, taskID string, limit int) ([]*models.TaskPlanRevision, error) {
+ query := `SELECT ` + revisionSelectCols + ` FROM task_plan_revisions WHERE task_id = ? ORDER BY revision_number DESC`
+ args := []interface{}{taskID}
+ if limit > 0 {
+ query += ` LIMIT ?`
+ args = append(args, limit)
+ }
+ rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query), args...)
+ if err != nil {
+ return nil, fmt.Errorf("failed to list task plan revisions: %w", err)
+ }
+ defer func() { _ = rows.Close() }()
+
+ var out []*models.TaskPlanRevision
+ for rows.Next() {
+ rev := &models.TaskPlanRevision{}
+ var revertOf sql.NullString
+ if err := rows.Scan(
+ &rev.ID, &rev.TaskID, &rev.RevisionNumber, &rev.Title, &rev.Content,
+ &rev.AuthorKind, &rev.AuthorName, &revertOf, &rev.CreatedAt, &rev.UpdatedAt,
+ ); err != nil {
+ return nil, fmt.Errorf("failed to scan task plan revision: %w", err)
+ }
+ if revertOf.Valid {
+ v := revertOf.String
+ rev.RevertOfRevisionID = &v
+ }
+ out = append(out, rev)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("iterate task plan revisions: %w", err)
+ }
+ return out, nil
+}
+
+// NextTaskPlanRevisionNumber returns max(revision_number)+1 for a task, or 1 if none exist.
+//
+// Note: prefer WritePlanRevision when writing a new revision — it computes the next number
+// atomically with the insert. This helper reads via the RO replica and is only safe against
+// TOCTOU when followed by a write under a BEGIN IMMEDIATE-equivalent (e.g., within
+// WritePlanRevision). Direct callers should be confined to read-only inspection / tests.
+func (r *Repository) NextTaskPlanRevisionNumber(ctx context.Context, taskID string) (int, error) {
+ var maxNum sql.NullInt64
+ err := r.ro.QueryRowContext(ctx, r.ro.Rebind(`
+ SELECT MAX(revision_number) FROM task_plan_revisions WHERE task_id = ?
+ `), taskID).Scan(&maxNum)
+ if err != nil {
+ return 0, fmt.Errorf("failed to get next revision number: %w", err)
+ }
+ if !maxNum.Valid {
+ return 1, nil
+ }
+ return int(maxNum.Int64) + 1, nil
+}
+
+// WritePlanRevision atomically upserts HEAD (task_plans) and either appends a new revision
+// or merges into an existing one in a single write transaction keyed by task_id. This closes
+// the TOCTOU window on revision_number (MAX+1 is computed inside the tx) and prevents HEAD
+// from disagreeing with history on partial failure.
+//
+// Coalesce behavior: when coalesceLatestID is non-nil and non-empty, the revision with that
+// ID has title/content/updated_at merged in-place and its other fields (revision_number,
+// author, created_at) are preserved. When nil or empty, a new revision row is inserted with
+// revision_number = MAX(existing)+1 and populated from rev.
+//
+// On success, rev is mutated to reflect the persisted state (ID, RevisionNumber, CreatedAt,
+// UpdatedAt).
+func (r *Repository) WritePlanRevision(
+ ctx context.Context,
+ head *models.TaskPlan,
+ rev *models.TaskPlanRevision,
+ coalesceLatestID *string,
+) error {
+ tx, err := r.db.BeginTxx(ctx, nil)
+ if err != nil {
+ return fmt.Errorf("begin plan revision tx: %w", err)
+ }
+ defer func() { _ = tx.Rollback() }()
+
+ now := time.Now().UTC()
+ if err := upsertPlanHead(ctx, tx, r.db, head, now); err != nil {
+ return err
+ }
+ if coalesceLatestID != nil && *coalesceLatestID != "" {
+ if err := mergeRevisionInTx(ctx, tx, r.db, rev, *coalesceLatestID, now); err != nil {
+ return err
+ }
+ } else {
+ if err := insertNewRevisionInTx(ctx, tx, r.db, rev, now); err != nil {
+ return err
+ }
+ }
+ return tx.Commit()
+}
+
+func upsertPlanHead(ctx context.Context, tx *sqlx.Tx, db *sqlx.DB, head *models.TaskPlan, now time.Time) error {
+ if head.ID == "" {
+ head.ID = uuid.New().String()
+ }
+ if head.Title == "" {
+ head.Title = "Plan"
+ }
+ if head.CreatedBy == "" {
+ head.CreatedBy = "agent"
+ }
+ if head.CreatedAt.IsZero() {
+ head.CreatedAt = now
+ }
+ head.UpdatedAt = now
+ if _, err := tx.ExecContext(ctx, db.Rebind(`
+ INSERT INTO task_plans (id, task_id, title, content, created_by, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(task_id) DO UPDATE SET
+ title = excluded.title,
+ content = excluded.content,
+ created_by = excluded.created_by,
+ updated_at = excluded.updated_at
+ `), head.ID, head.TaskID, head.Title, head.Content, head.CreatedBy, head.CreatedAt, head.UpdatedAt); err != nil {
+ return fmt.Errorf("upsert task plan head: %w", err)
+ }
+ return nil
+}
+
+func mergeRevisionInTx(ctx context.Context, tx *sqlx.Tx, db *sqlx.DB, rev *models.TaskPlanRevision, latestID string, now time.Time) error {
+ result, err := tx.ExecContext(ctx, db.Rebind(`
+ UPDATE task_plan_revisions
+ SET title = ?, content = ?, updated_at = ?
+ WHERE id = ?
+ `), rev.Title, rev.Content, now, latestID)
+ if err != nil {
+ return fmt.Errorf("merge plan revision: %w", err)
+ }
+ rows, _ := result.RowsAffected()
+ if rows == 0 {
+ return fmt.Errorf("task plan revision not found: %s", latestID)
+ }
+ rev.ID = latestID
+ rev.UpdatedAt = now
+ return nil
+}
+
+func insertNewRevisionInTx(ctx context.Context, tx *sqlx.Tx, db *sqlx.DB, rev *models.TaskPlanRevision, now time.Time) error {
+ var maxNum sql.NullInt64
+ if err := tx.QueryRowContext(ctx, db.Rebind(`
+ SELECT MAX(revision_number) FROM task_plan_revisions WHERE task_id = ?
+ `), rev.TaskID).Scan(&maxNum); err != nil {
+ return fmt.Errorf("compute next revision number: %w", err)
+ }
+ rev.RevisionNumber = 1
+ if maxNum.Valid {
+ rev.RevisionNumber = int(maxNum.Int64) + 1
+ }
+ if rev.ID == "" {
+ rev.ID = uuid.New().String()
+ }
+ if rev.CreatedAt.IsZero() {
+ rev.CreatedAt = now
+ }
+ rev.UpdatedAt = now
+ if _, err := tx.ExecContext(ctx, db.Rebind(`
+ INSERT INTO task_plan_revisions
+ (`+revisionSelectCols+`)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `),
+ rev.ID, rev.TaskID, rev.RevisionNumber, rev.Title, rev.Content,
+ rev.AuthorKind, rev.AuthorName, rev.RevertOfRevisionID, rev.CreatedAt, rev.UpdatedAt); err != nil {
+ return fmt.Errorf("insert plan revision: %w", err)
+ }
+ return nil
+}
+
+func (r *Repository) scanRevisionRow(row *sql.Row) (*models.TaskPlanRevision, error) {
+ rev := &models.TaskPlanRevision{}
+ var revertOf sql.NullString
+ err := row.Scan(
+ &rev.ID, &rev.TaskID, &rev.RevisionNumber, &rev.Title, &rev.Content,
+ &rev.AuthorKind, &rev.AuthorName, &revertOf, &rev.CreatedAt, &rev.UpdatedAt,
+ )
+ if err == sql.ErrNoRows {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, fmt.Errorf("failed to scan task plan revision: %w", err)
+ }
+ if revertOf.Valid {
+ v := revertOf.String
+ rev.RevertOfRevisionID = &v
+ }
+ return rev, nil
+}
diff --git a/apps/backend/internal/task/service/plan_service.go b/apps/backend/internal/task/service/plan_service.go
index 5a14d9c504..6ed4933ede 100644
--- a/apps/backend/internal/task/service/plan_service.go
+++ b/apps/backend/internal/task/service/plan_service.go
@@ -3,6 +3,9 @@ package service
import (
"context"
"errors"
+ "os"
+ "strconv"
+ "time"
"go.uber.org/zap"
@@ -14,127 +17,103 @@ import (
)
var (
- ErrTaskPlanNotFound = errors.New("task plan not found")
- ErrTaskIDRequired = errors.New("task_id is required")
- ErrContentRequired = errors.New("content is required")
+ ErrTaskPlanNotFound = errors.New("task plan not found")
+ ErrTaskIDRequired = errors.New("task_id is required")
+ ErrContentRequired = errors.New("content is required")
+ ErrRevisionNotFound = errors.New("task plan revision not found")
+ ErrRevisionIDRequired = errors.New("target_revision_id is required")
+ ErrRevisionTaskMismatch = errors.New("revision does not belong to given task")
)
-// createdByAgent is the default creator for agent-created plans.
-const createdByAgent = "agent"
+const (
+ createdByAgent = "agent"
+ createdByUser = "user"
+ defaultCoalesceWindow = 5 * time.Minute
+ coalesceWindowEnvVar = "KANDEV_PLAN_COALESCE_WINDOW_MS"
+ defaultAgentAuthorFallback = "Agent"
+ defaultUserAuthorFallback = "User"
+)
+
+// planRepo is the repository surface this service depends on. It combines the
+// plan-revision storage with a tiny slice of session lookups used to resolve
+// the active session's agent profile name when the MCP path doesn't provide
+// an explicit author_name.
+type planRepo interface {
+ repository.PlanRepository
+ GetActiveTaskSessionByTaskID(ctx context.Context, taskID string) (*models.TaskSession, error)
+ GetTaskSessionByTaskID(ctx context.Context, taskID string) (*models.TaskSession, error)
+}
// PlanService provides task plan business logic.
type PlanService struct {
- repo repository.PlanRepository
- eventBus bus.EventBus
- logger *logger.Logger
+ repo planRepo
+ eventBus bus.EventBus
+ logger *logger.Logger
+ coalesceWindow time.Duration
}
-// NewPlanService creates a new task plan service.
-func NewPlanService(repo repository.PlanRepository, eventBus bus.EventBus, log *logger.Logger) *PlanService {
+// NewPlanService creates a new task plan service. The concrete repository
+// passed by callers must implement both PlanRepository and the session-lookup
+// methods on planRepo (the SQLite repository does both).
+func NewPlanService(repo planRepo, eventBus bus.EventBus, log *logger.Logger) *PlanService {
return &PlanService{
- repo: repo,
- eventBus: eventBus,
- logger: log.WithFields(zap.String("component", "plan-service")),
+ repo: repo,
+ eventBus: eventBus,
+ logger: log.WithFields(zap.String("component", "plan-service")),
+ coalesceWindow: readCoalesceWindow(),
}
}
-// CreatePlanRequest contains parameters for creating a task plan.
+func readCoalesceWindow() time.Duration {
+ raw := os.Getenv(coalesceWindowEnvVar)
+ if raw == "" {
+ return defaultCoalesceWindow
+ }
+ ms, err := strconv.Atoi(raw)
+ if err != nil || ms < 0 {
+ return defaultCoalesceWindow
+ }
+ return time.Duration(ms) * time.Millisecond
+}
+
+// CreatePlanRequest contains parameters for creating/updating a task plan.
+// AuthorKind and AuthorName are optional; when absent they are derived from CreatedBy.
type CreatePlanRequest struct {
- TaskID string
- Title string
- Content string
- CreatedBy string // "agent" or "user"
+ TaskID string
+ Title string
+ Content string
+ CreatedBy string // "agent" | "user"
+ AuthorKind string // optional explicit override
+ AuthorName string // optional; display snapshot
}
-// CreatePlan creates a new task plan, or updates the existing one if a plan
-// already exists for the given task (upsert semantics).
+// CreatePlan upserts a plan and appends or coalesces a revision.
func (s *PlanService) CreatePlan(ctx context.Context, req CreatePlanRequest) (*models.TaskPlan, error) {
- if req.TaskID == "" {
- return nil, ErrTaskIDRequired
- }
-
- title := req.Title
- if title == "" {
- title = "Plan"
- }
- createdBy := req.CreatedBy
- if createdBy == "" {
- createdBy = createdByAgent
- }
-
- // If a plan already exists for this task, update it instead of inserting.
- existing, err := s.repo.GetTaskPlan(ctx, req.TaskID)
- if err != nil {
- s.logger.Error("failed to check existing task plan", zap.String("task_id", req.TaskID), zap.Error(err))
- return nil, err
- }
- if existing != nil {
- return s.UpdatePlan(ctx, UpdatePlanRequest{
- TaskID: req.TaskID,
- Title: title,
- Content: req.Content,
- CreatedBy: createdBy,
- })
- }
-
- plan := &models.TaskPlan{
- TaskID: req.TaskID,
- Title: title,
- Content: req.Content,
- CreatedBy: createdBy,
- }
-
- if err := s.repo.CreateTaskPlan(ctx, plan); err != nil {
- s.logger.Error("failed to create task plan", zap.String("task_id", req.TaskID), zap.Error(err))
- return nil, err
- }
-
- s.logger.Info("created task plan", zap.String("task_id", req.TaskID), zap.String("plan_id", plan.ID))
- s.publishEvent(ctx, events.TaskPlanCreated, plan)
-
- return plan, nil
+ return s.upsertPlan(ctx, req)
}
-// GetPlan retrieves a task plan by task ID.
-// Returns nil, nil if no plan exists.
-func (s *PlanService) GetPlan(ctx context.Context, taskID string) (*models.TaskPlan, error) {
- if taskID == "" {
- return nil, ErrTaskIDRequired
- }
-
- plan, err := s.repo.GetTaskPlan(ctx, taskID)
- if err != nil {
- s.logger.Error("failed to get task plan", zap.String("task_id", taskID), zap.Error(err))
- return nil, err
- }
-
- return plan, nil
-}
-
-// UpdatePlanRequest contains parameters for updating a task plan.
+// UpdatePlanRequest mirrors CreatePlanRequest; kept as a distinct type for API clarity.
type UpdatePlanRequest struct {
- TaskID string
- Title string // Optional: if empty, preserves existing title
- Content string
- CreatedBy string // Optional: if empty, preserves existing or defaults to "user"
+ TaskID string
+ Title string
+ Content string
+ CreatedBy string
+ AuthorKind string
+ AuthorName string
}
-// UpdatePlan updates an existing task plan.
+// UpdatePlan updates an existing plan (errors if missing).
func (s *PlanService) UpdatePlan(ctx context.Context, req UpdatePlanRequest) (*models.TaskPlan, error) {
if req.TaskID == "" {
return nil, ErrTaskIDRequired
}
-
existing, err := s.repo.GetTaskPlan(ctx, req.TaskID)
if err != nil {
- s.logger.Error("failed to get existing task plan", zap.String("task_id", req.TaskID), zap.Error(err))
return nil, err
}
if existing == nil {
return nil, ErrTaskPlanNotFound
}
-
- // Preserve existing values if not provided
title := req.Title
if title == "" {
title = existing.Title
@@ -143,60 +122,297 @@ func (s *PlanService) UpdatePlan(ctx context.Context, req UpdatePlanRequest) (*m
if createdBy == "" {
createdBy = existing.CreatedBy
}
+ return s.upsertPlan(ctx, CreatePlanRequest{
+ TaskID: req.TaskID,
+ Title: title,
+ Content: req.Content,
+ CreatedBy: createdBy,
+ AuthorKind: req.AuthorKind,
+ AuthorName: req.AuthorName,
+ })
+}
+
+// upsertPlan is the shared write path. It upserts the task_plans HEAD row and either
+// coalesces into the latest revision (same author within window) or appends a new revision
+// — both steps run in one write transaction via WritePlanRevision so HEAD and history
+// cannot diverge under concurrent writers or partial failures.
+func (s *PlanService) upsertPlan(ctx context.Context, req CreatePlanRequest) (*models.TaskPlan, error) {
+ if req.TaskID == "" {
+ return nil, ErrTaskIDRequired
+ }
+
+ title := req.Title
+ if title == "" {
+ title = "Plan"
+ }
+ // Resolve a missing AuthorName for agent writes from the active session's
+ // profile snapshot before falling back to the literal "Agent". The MCP path
+ // (handleCreateTaskPlan / handleUpdateTaskPlan) doesn't carry the agent's
+ // display name in the request, so without this lookup every agent revision
+ // would render as "Agent" in the history UI.
+ if req.AuthorName == "" {
+ kindHint := req.AuthorKind
+ if kindHint == "" {
+ kindHint = req.CreatedBy
+ }
+ if kindHint == createdByAgent {
+ req.AuthorName = s.resolveAgentDisplayName(ctx, req.TaskID)
+ }
+ }
+ authorKind, authorName, createdBy := resolveAuthor(req)
+
+ existing, err := s.repo.GetTaskPlan(ctx, req.TaskID)
+ if err != nil {
+ s.logger.Error("get existing plan", zap.String("task_id", req.TaskID), zap.Error(err))
+ return nil, err
+ }
+ eventType := events.TaskPlanCreated
+ if existing != nil {
+ eventType = events.TaskPlanUpdated
+ }
plan := &models.TaskPlan{
- ID: existing.ID,
TaskID: req.TaskID,
Title: title,
Content: req.Content,
CreatedBy: createdBy,
- CreatedAt: existing.CreatedAt,
+ }
+ if existing != nil {
+ plan.ID = existing.ID
+ plan.CreatedAt = existing.CreatedAt
}
- if err := s.repo.UpdateTaskPlan(ctx, plan); err != nil {
- s.logger.Error("failed to update task plan", zap.String("task_id", req.TaskID), zap.Error(err))
+ latest, err := s.repo.GetLatestTaskPlanRevision(ctx, req.TaskID)
+ if err != nil {
+ return nil, err
+ }
+ now := time.Now().UTC()
+ coalesce := s.canCoalesce(latest, authorKind, authorName, now)
+
+ rev := &models.TaskPlanRevision{
+ TaskID: req.TaskID,
+ Title: title,
+ Content: req.Content,
+ AuthorKind: authorKind,
+ AuthorName: authorName,
+ }
+ var coalesceID *string
+ if coalesce {
+ coalesceID = &latest.ID
+ // Preserve the original revision's author + number on merge.
+ rev.RevisionNumber = latest.RevisionNumber
+ rev.AuthorKind = latest.AuthorKind
+ rev.AuthorName = latest.AuthorName
+ rev.CreatedAt = latest.CreatedAt
+ }
+
+ if err := s.repo.WritePlanRevision(ctx, plan, rev, coalesceID); err != nil {
+ s.logger.Error("write plan revision", zap.String("task_id", req.TaskID), zap.Error(err))
return nil, err
}
- s.logger.Info("updated task plan", zap.String("task_id", req.TaskID))
- s.publishEvent(ctx, events.TaskPlanUpdated, plan)
-
+ s.publishPlanEvent(ctx, eventType, plan)
+ s.publishRevisionEvent(ctx, rev, coalesce)
return plan, nil
}
-// DeletePlan deletes a task plan by task ID.
+// resolveAgentDisplayName returns the agent profile's display name for the
+// task's most recent session, or "" if no usable session/snapshot exists.
+// Tries the active session first (running/starting/waiting) and falls back to
+// the most recent session by started_at so plans written between turns still
+// get the right author name.
+func (s *PlanService) resolveAgentDisplayName(ctx context.Context, taskID string) string {
+ session, err := s.repo.GetActiveTaskSessionByTaskID(ctx, taskID)
+ if err != nil || session == nil {
+ session, err = s.repo.GetTaskSessionByTaskID(ctx, taskID)
+ if err != nil || session == nil {
+ return ""
+ }
+ }
+ return agentDisplayNameFromSnapshot(session.AgentProfileSnapshot)
+}
+
+// agentDisplayNameFromSnapshot picks the best available display name from a
+// session's agent_profile_snapshot. The orchestrator's canonical key is
+// "name" (the profile's display name, e.g. "Claude Sonnet 4.5"); we try it
+// first so a snapshot that carries both "name" and a stale older "label"
+// doesn't render the stale value. Falls back through "label" (older paths)
+// and "agent_display_name" (some DTO mappings) before giving up.
+func agentDisplayNameFromSnapshot(snapshot map[string]interface{}) string {
+ if snapshot == nil {
+ return ""
+ }
+ for _, key := range []string{"name", "label", "agent_display_name"} {
+ if v, ok := snapshot[key]; ok {
+ if s, ok := v.(string); ok && s != "" {
+ return s
+ }
+ }
+ }
+ return ""
+}
+
+func (s *PlanService) canCoalesce(latest *models.TaskPlanRevision, authorKind, authorName string, now time.Time) bool {
+ if latest == nil {
+ return false
+ }
+ if latest.RevertOfRevisionID != nil {
+ return false // revert markers are permanent
+ }
+ if latest.AuthorKind != authorKind || latest.AuthorName != authorName {
+ return false
+ }
+ if s.coalesceWindow <= 0 {
+ return false
+ }
+ return now.Sub(latest.UpdatedAt) < s.coalesceWindow
+}
+
+// resolveAuthor derives the authoritative (kind, name, legacyCreatedBy) tuple
+// from a write request. Callers may pass explicit AuthorKind/AuthorName; when
+// absent we fall back to CreatedBy and a literal display name.
+func resolveAuthor(req CreatePlanRequest) (kind, name, createdBy string) {
+ createdBy = req.CreatedBy
+ kind = req.AuthorKind
+ if kind == "" {
+ kind = createdBy
+ }
+ if kind != createdByAgent && kind != createdByUser {
+ kind = createdByAgent
+ }
+ if createdBy == "" {
+ createdBy = kind
+ }
+ name = req.AuthorName
+ if name == "" {
+ if kind == createdByAgent {
+ name = defaultAgentAuthorFallback
+ } else {
+ name = defaultUserAuthorFallback
+ }
+ }
+ return kind, name, createdBy
+}
+
+// GetPlan retrieves a task plan by task ID. Returns nil, nil if missing.
+func (s *PlanService) GetPlan(ctx context.Context, taskID string) (*models.TaskPlan, error) {
+ if taskID == "" {
+ return nil, ErrTaskIDRequired
+ }
+ return s.repo.GetTaskPlan(ctx, taskID)
+}
+
+// DeletePlan removes a plan and all its revisions (cascade via FK when task goes; here we delete only HEAD).
+// Historical revisions remain for audit; callers wanting a full wipe should delete the task.
func (s *PlanService) DeletePlan(ctx context.Context, taskID string) error {
if taskID == "" {
return ErrTaskIDRequired
}
-
- // Get plan before deleting for event payload
existing, err := s.repo.GetTaskPlan(ctx, taskID)
if err != nil {
- s.logger.Error("failed to get task plan for delete", zap.String("task_id", taskID), zap.Error(err))
return err
}
if existing == nil {
return ErrTaskPlanNotFound
}
-
if err := s.repo.DeleteTaskPlan(ctx, taskID); err != nil {
- s.logger.Error("failed to delete task plan", zap.String("task_id", taskID), zap.Error(err))
return err
}
-
- s.logger.Info("deleted task plan", zap.String("task_id", taskID))
- s.publishEvent(ctx, events.TaskPlanDeleted, existing)
-
+ s.publishPlanEvent(ctx, events.TaskPlanDeleted, existing)
return nil
}
-// publishEvent publishes a task plan event to the event bus.
-func (s *PlanService) publishEvent(ctx context.Context, eventType string, plan *models.TaskPlan) {
+// ListRevisions returns plan revisions newest-first without content (metadata only).
+func (s *PlanService) ListRevisions(ctx context.Context, taskID string) ([]*models.TaskPlanRevision, error) {
+ if taskID == "" {
+ return nil, ErrTaskIDRequired
+ }
+ return s.repo.ListTaskPlanRevisions(ctx, taskID, 0)
+}
+
+// GetRevision returns a single revision with content (for diff/preview).
+func (s *PlanService) GetRevision(ctx context.Context, id string) (*models.TaskPlanRevision, error) {
+ rev, err := s.repo.GetTaskPlanRevision(ctx, id)
+ if err != nil {
+ return nil, err
+ }
+ if rev == nil {
+ return nil, ErrRevisionNotFound
+ }
+ return rev, nil
+}
+
+// RevertPlanRequest parameters for a revert-to-revision operation.
+type RevertPlanRequest struct {
+ TaskID string
+ TargetRevisionID string
+ AuthorName string // user display name; "User" fallback when empty
+}
+
+// RevertPlan creates a new revision whose content mirrors the target and updates HEAD,
+// atomically via WritePlanRevision. Revert revisions are never coalesced (the "restored
+// from vK" marker is preserved).
+func (s *PlanService) RevertPlan(ctx context.Context, req RevertPlanRequest) (*models.TaskPlanRevision, error) {
+ if req.TaskID == "" {
+ return nil, ErrTaskIDRequired
+ }
+ if req.TargetRevisionID == "" {
+ return nil, ErrRevisionIDRequired
+ }
+ target, err := s.repo.GetTaskPlanRevision(ctx, req.TargetRevisionID)
+ if err != nil {
+ return nil, err
+ }
+ if target == nil {
+ return nil, ErrRevisionNotFound
+ }
+ if target.TaskID != req.TaskID {
+ return nil, ErrRevisionTaskMismatch
+ }
+
+ authorName := req.AuthorName
+ if authorName == "" {
+ authorName = defaultUserAuthorFallback
+ }
+
+ head, err := s.repo.GetTaskPlan(ctx, req.TaskID)
+ if err != nil {
+ return nil, err
+ }
+ plan := &models.TaskPlan{
+ TaskID: req.TaskID,
+ Title: target.Title,
+ Content: target.Content,
+ CreatedBy: createdByUser,
+ }
+ if head != nil {
+ plan.ID = head.ID
+ plan.CreatedAt = head.CreatedAt
+ }
+
+ targetID := target.ID
+ rev := &models.TaskPlanRevision{
+ TaskID: req.TaskID,
+ Title: target.Title,
+ Content: target.Content,
+ AuthorKind: createdByUser,
+ AuthorName: authorName,
+ RevertOfRevisionID: &targetID,
+ }
+ if err := s.repo.WritePlanRevision(ctx, plan, rev, nil); err != nil {
+ return nil, err
+ }
+
+ s.publishPlanEvent(ctx, events.TaskPlanUpdated, plan)
+ s.publishRevisionEvent(ctx, rev, false)
+ s.publishReverted(ctx, rev)
+ return rev, nil
+}
+
+func (s *PlanService) publishPlanEvent(ctx context.Context, eventType string, plan *models.TaskPlan) {
if s.eventBus == nil {
return
}
-
payload := map[string]interface{}{
"id": plan.ID,
"task_id": plan.TaskID,
@@ -206,8 +422,45 @@ func (s *PlanService) publishEvent(ctx context.Context, eventType string, plan *
"created_at": plan.CreatedAt,
"updated_at": plan.UpdatedAt,
}
-
if err := s.eventBus.Publish(ctx, eventType, bus.NewEvent(eventType, "plan-service", payload)); err != nil {
- s.logger.Error("failed to publish task plan event", zap.String("event_type", eventType), zap.Error(err))
+ s.logger.Error("publish plan event", zap.String("event_type", eventType), zap.Error(err))
}
}
+
+func (s *PlanService) publishRevisionEvent(ctx context.Context, rev *models.TaskPlanRevision, coalesced bool) {
+ if s.eventBus == nil {
+ return
+ }
+ payload := revisionPayload(rev)
+ payload["coalesced"] = coalesced
+ if err := s.eventBus.Publish(ctx, events.TaskPlanRevisionCreated, bus.NewEvent(events.TaskPlanRevisionCreated, "plan-service", payload)); err != nil {
+ s.logger.Error("publish revision event", zap.Error(err))
+ }
+}
+
+func (s *PlanService) publishReverted(ctx context.Context, rev *models.TaskPlanRevision) {
+ if s.eventBus == nil {
+ return
+ }
+ payload := revisionPayload(rev)
+ if err := s.eventBus.Publish(ctx, events.TaskPlanReverted, bus.NewEvent(events.TaskPlanReverted, "plan-service", payload)); err != nil {
+ s.logger.Error("publish reverted event", zap.Error(err))
+ }
+}
+
+func revisionPayload(rev *models.TaskPlanRevision) map[string]interface{} {
+ p := map[string]interface{}{
+ "id": rev.ID,
+ "task_id": rev.TaskID,
+ "revision_number": rev.RevisionNumber,
+ "title": rev.Title,
+ "author_kind": rev.AuthorKind,
+ "author_name": rev.AuthorName,
+ "created_at": rev.CreatedAt,
+ "updated_at": rev.UpdatedAt,
+ }
+ if rev.RevertOfRevisionID != nil {
+ p["revert_of_revision_id"] = *rev.RevertOfRevisionID
+ }
+ return p
+}
diff --git a/apps/backend/internal/task/service/plan_service_test.go b/apps/backend/internal/task/service/plan_service_test.go
index 9f4838de4f..ba81e3db57 100644
--- a/apps/backend/internal/task/service/plan_service_test.go
+++ b/apps/backend/internal/task/service/plan_service_test.go
@@ -192,3 +192,259 @@ func TestPlanService_DeletePlanNotFound(t *testing.T) {
t.Errorf("expected ErrTaskPlanNotFound, got %v", err)
}
}
+
+func TestPlanService_CreatesInitialRevision(t *testing.T) {
+ svc, _, repo := createTestPlanService(t)
+ ctx := context.Background()
+ seedTask(t, ctx, repo, "task-rev")
+
+ _, err := svc.CreatePlan(ctx, CreatePlanRequest{
+ TaskID: "task-rev", Content: "v1",
+ AuthorKind: "agent", AuthorName: "Claude",
+ })
+ if err != nil {
+ t.Fatalf("create: %v", err)
+ }
+
+ list, _ := svc.ListRevisions(ctx, "task-rev")
+ if len(list) != 1 {
+ t.Fatalf("expected 1 revision, got %d", len(list))
+ }
+ if list[0].AuthorName != "Claude" || list[0].AuthorKind != "agent" {
+ t.Errorf("unexpected author: %+v", list[0])
+ }
+ if list[0].RevisionNumber != 1 {
+ t.Errorf("expected rev #1, got %d", list[0].RevisionNumber)
+ }
+}
+
+func TestPlanService_CoalescesWithinWindow(t *testing.T) {
+ svc, _, repo := createTestPlanService(t)
+ ctx := context.Background()
+ seedTask(t, ctx, repo, "task-co")
+ svc.coalesceWindow = 10 * time.Minute // force generous window
+
+ _, _ = svc.CreatePlan(ctx, CreatePlanRequest{
+ TaskID: "task-co", Content: "v1",
+ AuthorKind: "agent", AuthorName: "Claude",
+ })
+ _, _ = svc.CreatePlan(ctx, CreatePlanRequest{
+ TaskID: "task-co", Content: "v2",
+ AuthorKind: "agent", AuthorName: "Claude",
+ })
+
+ list, _ := svc.ListRevisions(ctx, "task-co")
+ if len(list) != 1 {
+ t.Fatalf("expected coalesced to 1, got %d", len(list))
+ }
+ if list[0].Content != "v2" {
+ t.Errorf("expected merged content v2, got %q", list[0].Content)
+ }
+}
+
+func TestPlanService_AppendsWhenWindowExpired(t *testing.T) {
+ svc, _, repo := createTestPlanService(t)
+ ctx := context.Background()
+ seedTask(t, ctx, repo, "task-win")
+ svc.coalesceWindow = 0 // disable coalescing
+
+ _, _ = svc.CreatePlan(ctx, CreatePlanRequest{
+ TaskID: "task-win", Content: "v1",
+ AuthorKind: "agent", AuthorName: "Claude",
+ })
+ _, _ = svc.CreatePlan(ctx, CreatePlanRequest{
+ TaskID: "task-win", Content: "v2",
+ AuthorKind: "agent", AuthorName: "Claude",
+ })
+
+ list, _ := svc.ListRevisions(ctx, "task-win")
+ if len(list) != 2 {
+ t.Fatalf("expected 2 separate revisions, got %d", len(list))
+ }
+}
+
+func TestPlanService_AuthorSwitchBreaksCoalesce(t *testing.T) {
+ svc, _, repo := createTestPlanService(t)
+ ctx := context.Background()
+ seedTask(t, ctx, repo, "task-sw")
+ svc.coalesceWindow = 10 * time.Minute
+
+ _, _ = svc.CreatePlan(ctx, CreatePlanRequest{
+ TaskID: "task-sw", Content: "agent-wrote",
+ AuthorKind: "agent", AuthorName: "Claude",
+ })
+ _, _ = svc.CreatePlan(ctx, CreatePlanRequest{
+ TaskID: "task-sw", Content: "user-edited",
+ AuthorKind: "user", AuthorName: "Alice",
+ })
+
+ list, _ := svc.ListRevisions(ctx, "task-sw")
+ if len(list) != 2 {
+ t.Fatalf("expected 2 revisions (author switch breaks coalesce), got %d", len(list))
+ }
+ if list[0].AuthorKind != "user" || list[1].AuthorKind != "agent" {
+ t.Errorf("unexpected order: [%s, %s]", list[0].AuthorKind, list[1].AuthorKind)
+ }
+}
+
+func TestPlanService_RevertToEarlierRevision(t *testing.T) {
+ svc, _, repo := createTestPlanService(t)
+ ctx := context.Background()
+ seedTask(t, ctx, repo, "task-rv")
+ svc.coalesceWindow = 0
+
+ _, _ = svc.CreatePlan(ctx, CreatePlanRequest{TaskID: "task-rv", Content: "v1", AuthorKind: "agent", AuthorName: "Claude"})
+ _, _ = svc.CreatePlan(ctx, CreatePlanRequest{TaskID: "task-rv", Content: "v2", AuthorKind: "agent", AuthorName: "Claude"})
+ _, _ = svc.CreatePlan(ctx, CreatePlanRequest{TaskID: "task-rv", Content: "v3", AuthorKind: "agent", AuthorName: "Claude"})
+
+ list, _ := svc.ListRevisions(ctx, "task-rv")
+ if len(list) != 3 {
+ t.Fatalf("expected 3 before revert, got %d", len(list))
+ }
+ v1 := list[2]
+
+ revert, err := svc.RevertPlan(ctx, RevertPlanRequest{
+ TaskID: "task-rv", TargetRevisionID: v1.ID, AuthorName: "Alice",
+ })
+ if err != nil {
+ t.Fatalf("revert: %v", err)
+ }
+ if revert.RevertOfRevisionID == nil || *revert.RevertOfRevisionID != v1.ID {
+ t.Errorf("revert_of_revision_id mismatch: %v", revert.RevertOfRevisionID)
+ }
+ if revert.AuthorKind != "user" || revert.AuthorName != "Alice" {
+ t.Errorf("expected user/Alice, got %s/%s", revert.AuthorKind, revert.AuthorName)
+ }
+ if revert.RevisionNumber != 4 {
+ t.Errorf("expected rev #4, got %d", revert.RevisionNumber)
+ }
+
+ head, _ := svc.GetPlan(ctx, "task-rv")
+ if head.Content != "v1" {
+ t.Errorf("expected HEAD content v1, got %q", head.Content)
+ }
+}
+
+func TestPlanService_RevertNeverCoalesces(t *testing.T) {
+ svc, _, repo := createTestPlanService(t)
+ ctx := context.Background()
+ seedTask(t, ctx, repo, "task-rvc")
+ svc.coalesceWindow = 10 * time.Minute
+
+ _, _ = svc.CreatePlan(ctx, CreatePlanRequest{TaskID: "task-rvc", Content: "v1", AuthorKind: "agent", AuthorName: "Claude"})
+ list, _ := svc.ListRevisions(ctx, "task-rvc")
+ v1 := list[0]
+
+ // Two reverts by the same user in quick succession must remain separate rows.
+ _, _ = svc.RevertPlan(ctx, RevertPlanRequest{TaskID: "task-rvc", TargetRevisionID: v1.ID, AuthorName: "Alice"})
+ _, _ = svc.RevertPlan(ctx, RevertPlanRequest{TaskID: "task-rvc", TargetRevisionID: v1.ID, AuthorName: "Alice"})
+
+ list, _ = svc.ListRevisions(ctx, "task-rvc")
+ if len(list) != 3 {
+ t.Fatalf("expected 3 revisions (1 original + 2 reverts), got %d", len(list))
+ }
+}
+
+func TestPlanService_RevertRejectsWrongTask(t *testing.T) {
+ svc, _, repo := createTestPlanService(t)
+ ctx := context.Background()
+ seedTask(t, ctx, repo, "task-x")
+ _ = repo.CreateTask(ctx, &models.Task{
+ ID: "task-y", WorkspaceID: "ws-plan", WorkflowID: "wf-plan", Title: "Y",
+ State: v1.TaskStateCreated, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(),
+ })
+
+ _, _ = svc.CreatePlan(ctx, CreatePlanRequest{TaskID: "task-x", Content: "x", AuthorKind: "agent"})
+ xList, _ := svc.ListRevisions(ctx, "task-x")
+ xRev := xList[0]
+
+ _, err := svc.RevertPlan(ctx, RevertPlanRequest{
+ TaskID: "task-y", TargetRevisionID: xRev.ID, AuthorName: "Alice",
+ })
+ if err != ErrRevisionTaskMismatch {
+ t.Errorf("expected ErrRevisionTaskMismatch, got %v", err)
+ }
+}
+
+func TestPlanService_AgentAuthorNameFromSession(t *testing.T) {
+ svc, _, repo := createTestPlanService(t)
+ ctx := context.Background()
+ seedTask(t, ctx, repo, "task-an")
+
+ // Seed an active session with an agent profile snapshot. The MCP path
+ // resolves the agent's display name from this snapshot when the request
+ // doesn't carry an explicit author_name.
+ now := time.Now().UTC()
+ if err := repo.CreateTaskSession(ctx, &models.TaskSession{
+ ID: "sess-an",
+ TaskID: "task-an",
+ AgentExecutionID: "exec-an",
+ AgentProfileID: "ap-claude",
+ AgentProfileSnapshot: map[string]interface{}{
+ "id": "ap-claude",
+ "name": "Claude Sonnet 4.5",
+ "agent_id": "claude",
+ "agent_name": "claude",
+ },
+ State: models.TaskSessionState("RUNNING"),
+ StartedAt: now,
+ UpdatedAt: now,
+ }); err != nil {
+ t.Fatalf("CreateTaskSession failed: %v", err)
+ }
+
+ // MCP path: created_by=agent, no author_name provided.
+ _, err := svc.CreatePlan(ctx, CreatePlanRequest{
+ TaskID: "task-an",
+ Content: "first draft",
+ CreatedBy: "agent",
+ })
+ if err != nil {
+ t.Fatalf("CreatePlan failed: %v", err)
+ }
+
+ list, _ := svc.ListRevisions(ctx, "task-an")
+ if len(list) != 1 {
+ t.Fatalf("expected 1 revision, got %d", len(list))
+ }
+ if list[0].AuthorKind != "agent" {
+ t.Errorf("expected author_kind=agent, got %q", list[0].AuthorKind)
+ }
+ if list[0].AuthorName != "Claude Sonnet 4.5" {
+ t.Errorf("expected author_name resolved from session snapshot, got %q", list[0].AuthorName)
+ }
+}
+
+func TestPlanService_AgentAuthorNameFallsBackWhenNoSession(t *testing.T) {
+ svc, _, repo := createTestPlanService(t)
+ ctx := context.Background()
+ seedTask(t, ctx, repo, "task-an2")
+
+ // No session seeded → resolution returns "" → resolveAuthor falls back to "Agent".
+ _, err := svc.CreatePlan(ctx, CreatePlanRequest{
+ TaskID: "task-an2",
+ Content: "first draft",
+ CreatedBy: "agent",
+ })
+ if err != nil {
+ t.Fatalf("CreatePlan failed: %v", err)
+ }
+
+ list, _ := svc.ListRevisions(ctx, "task-an2")
+ if list[0].AuthorName != defaultAgentAuthorFallback {
+ t.Errorf("expected fallback %q, got %q", defaultAgentAuthorFallback, list[0].AuthorName)
+ }
+}
+
+func TestPlanService_RevertMissingRevision(t *testing.T) {
+ svc, _, repo := createTestPlanService(t)
+ ctx := context.Background()
+ seedTask(t, ctx, repo, "task-mr")
+
+ _, err := svc.RevertPlan(ctx, RevertPlanRequest{
+ TaskID: "task-mr", TargetRevisionID: "does-not-exist", AuthorName: "Alice",
+ })
+ if err != ErrRevisionNotFound {
+ t.Errorf("expected ErrRevisionNotFound, got %v", err)
+ }
+}
diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go
index 09651c48db..d17acc0c9e 100644
--- a/apps/backend/pkg/websocket/actions.go
+++ b/apps/backend/pkg/websocket/actions.go
@@ -57,18 +57,21 @@ const (
ActionEnvironmentDelete = "environment.delete"
// Task actions
- ActionTaskList = "task.list"
- ActionTaskCreate = "task.create"
- ActionTaskGet = "task.get"
- ActionTaskUpdate = "task.update"
- ActionTaskDelete = "task.delete"
- ActionTaskMove = "task.move"
- ActionTaskState = "task.state"
- ActionTaskArchive = "task.archive"
- ActionTaskPlanCreate = "task.plan.create"
- ActionTaskPlanGet = "task.plan.get"
- ActionTaskPlanUpdate = "task.plan.update"
- ActionTaskPlanDelete = "task.plan.delete"
+ ActionTaskList = "task.list"
+ ActionTaskCreate = "task.create"
+ ActionTaskGet = "task.get"
+ ActionTaskUpdate = "task.update"
+ ActionTaskDelete = "task.delete"
+ ActionTaskMove = "task.move"
+ ActionTaskState = "task.state"
+ ActionTaskArchive = "task.archive"
+ ActionTaskPlanCreate = "task.plan.create"
+ ActionTaskPlanGet = "task.plan.get"
+ ActionTaskPlanUpdate = "task.plan.update"
+ ActionTaskPlanDelete = "task.plan.delete"
+ ActionTaskPlanRevisionsList = "task.plan.revisions.list"
+ ActionTaskPlanRevisionGet = "task.plan.revision.get"
+ ActionTaskPlanRevert = "task.plan.revert"
ActionTaskSessionList = "task.session.list"
ActionTaskSessionResume = "task.session.resume"
@@ -159,6 +162,8 @@ const (
ActionTaskPlanCreated = "task.plan.created"
ActionTaskPlanUpdated = "task.plan.updated"
ActionTaskPlanDeleted = "task.plan.deleted"
+ ActionTaskPlanRevisionCreated = "task.plan.revision.created"
+ ActionTaskPlanReverted = "task.plan.reverted"
ActionAgentUpdated = "agent.updated"
ActionAgentAvailableUpdated = "agent.available.updated"
ActionWorkspaceCreated = "workspace.created"
diff --git a/apps/web/components/editors/tiptap/tiptap-plan-readonly.tsx b/apps/web/components/editors/tiptap/tiptap-plan-readonly.tsx
new file mode 100644
index 0000000000..e53f8b37cc
--- /dev/null
+++ b/apps/web/components/editors/tiptap/tiptap-plan-readonly.tsx
@@ -0,0 +1,87 @@
+"use client";
+
+import { useEffect } from "react";
+import { useTheme } from "next-themes";
+import { useEditor, EditorContent } from "@tiptap/react";
+import StarterKit from "@tiptap/starter-kit";
+import Highlight from "@tiptap/extension-highlight";
+import Underline from "@tiptap/extension-underline";
+import Link from "@tiptap/extension-link";
+import TaskList from "@tiptap/extension-task-list";
+import TaskItem from "@tiptap/extension-task-item";
+import { Table } from "@tiptap/extension-table";
+import { TableRow } from "@tiptap/extension-table-row";
+import { TableCell } from "@tiptap/extension-table-cell";
+import { TableHeader } from "@tiptap/extension-table-header";
+import { Markdown } from "tiptap-markdown";
+import { common, createLowlight } from "lowlight";
+import { createCodeBlockWithMermaid } from "./tiptap-mermaid-extension";
+import { cn } from "@/lib/utils";
+
+type Props = {
+ /** Markdown source. The editor's tiptap-markdown extension parses it via
+ * `setContent`, so headings, lists, tables, and code blocks render properly. */
+ content: string;
+ className?: string;
+ testId?: string;
+};
+
+const lowlight = createLowlight(common);
+
+/** Read-only Tiptap renderer for plan revision content.
+ *
+ * Reuses the same StarterKit + Markdown + tables/tasks/code-block extensions as
+ * the live plan editor so previewed markdown matches what the user sees while
+ * editing — minus interactive bits (slash menu, drag handles, comment marks).
+ *
+ * Initial markdown content is set via `editor.commands.setContent(content, ...)`
+ * after creation rather than the `content` option, because tiptap-markdown's
+ * setContent override is what triggers the markdown -> doc parse.
+ */
+export function PlanReadOnlyMarkdown({ content, className, testId }: Props) {
+ const { resolvedTheme } = useTheme();
+ const editor = useEditor({
+ immediatelyRender: false,
+ editable: false,
+ extensions: [
+ StarterKit.configure({ codeBlock: false }),
+ createCodeBlockWithMermaid(lowlight),
+ Markdown.configure({ html: true, transformPastedText: false, transformCopiedText: false }),
+ Link.configure({ openOnClick: false }),
+ Highlight,
+ Underline,
+ TaskList,
+ TaskItem.configure({ nested: true }),
+ Table.configure({ resizable: false }),
+ TableRow,
+ TableCell,
+ TableHeader,
+ ],
+ });
+
+ useEffect(() => {
+ if (!editor || editor.isDestroyed) return;
+ // Tiptap-markdown intercepts setContent for markdown strings; setting
+ // content this way (instead of via the `content` option) ensures the
+ // input gets parsed as markdown rather than treated as raw text/HTML.
+ editor.commands.setContent(content, { emitUpdate: false });
+ }, [editor, content]);
+
+ // Reuse the same class names as the live plan editor so the heading sizes,
+ // list spacing, code-block styling, etc. defined in `globals.css` under
+ // `.markdown-body` and `.tiptap-plan-wrapper` apply identically here.
+ return (
+
+ );
+}
diff --git a/apps/web/components/task/task-plan-diff-dialog.tsx b/apps/web/components/task/task-plan-diff-dialog.tsx
new file mode 100644
index 0000000000..58eef52ccb
--- /dev/null
+++ b/apps/web/components/task/task-plan-diff-dialog.tsx
@@ -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;
+ 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 = {
+ 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 = {
+ add: "+",
+ remove: "-",
+ context: " ",
+};
+
+export function PlanRevisionDiffDialog({
+ pair,
+ loadContent,
+ onClose,
+ onRestoreOlder,
+}: Props): ReactNode {
+ const [before, after] = useMemo(() => orderPair(pair), [pair]);
+ const [mode, setMode] = useState("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 (
+
+ );
+}
+
+function DiffBody({
+ before,
+ after,
+ loadContent,
+ mode,
+ setMode,
+}: {
+ before: TaskPlanRevision | null;
+ after: TaskPlanRevision | null;
+ loadContent: (revisionId: string) => Promise;
+ 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 (
+
+
+
+ {summary ? `${summary.added} added · ${summary.removed} removed` : "Loading…"}
+
+ {
+ if (v === "unified" || v === "split") setMode(v);
+ }}
+ data-testid="plan-revision-diff-mode-toggle"
+ >
+
+ Unified
+
+
+ Split
+
+
+
+
+
+
+
+ );
+}
+
+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 {error};
+ if (lines === null) return ;
+ if (sameRevision)
+ return These are the same version; nothing to compare. ;
+ if (lines.length === 0) return (both versions are empty) ;
+ if (summary && summary.added === 0 && summary.removed === 0) {
+ return No textual changes between these versions. ;
+ }
+ if (mode === "split") {
+ return ;
+ }
+ return ;
+}
+
+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.
+
+ {lines.map((line, i) => (
+
+ ))}
+
+ );
+}
+
+function DiffLineRow({ line }: { line: DiffLine }) {
+ return (
+
+
+ {KIND_PREFIX[line.kind]}
+
+ {line.text || " "}
+
+ );
+}
+
+/** 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(null);
+ const afterRef = useRef(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 (
+
+
+
+
+ );
+}
+
+const SplitPane = ({
+ ref,
+ rows,
+ side,
+ testIdSuffix,
+}: {
+ ref: React.RefObject;
+ rows: SplitRow[];
+ side: "before" | "after";
+ testIdSuffix: string;
+}) => (
+
+ {/* 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. */}
+
+ {rows.map((row, i) => (
+
+ ))}
+
+
+);
+
+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 (
+
+ );
+ }
+ return (
+
+
+ {KIND_PREFIX[line.kind]}
+
+ {line.text || " "}
+
+ );
+}
+
+function DiffLoading() {
+ return (
+
+
+ Loading…
+
+ );
+}
+
+function DiffMessage({ children }: { children: ReactNode }) {
+ return {children};
+}
+
+/** 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,
+): {
+ beforeContent: string | null;
+ afterContent: string | null;
+ lines: DiffLine[] | null;
+ error: string | null;
+} {
+ const [beforeContent, setBeforeContent] = useState(null);
+ const [afterContent, setAfterContent] = useState(null);
+ const [error, setError] = useState(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 };
+}
diff --git a/apps/web/components/task/task-plan-diff.test.ts b/apps/web/components/task/task-plan-diff.test.ts
new file mode 100644
index 0000000000..9b7afb8a9e
--- /dev/null
+++ b/apps/web/components/task/task-plan-diff.test.ts
@@ -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 });
+ });
+});
diff --git a/apps/web/components/task/task-plan-diff.ts b/apps/web/components/task/task-plan-diff.ts
new file mode 100644
index 0000000000..dc9c101602
--- /dev/null
+++ b/apps/web/components/task/task-plan-diff.ts
@@ -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 };
+}
diff --git a/apps/web/components/task/task-plan-panel.tsx b/apps/web/components/task/task-plan-panel.tsx
index 414cc6fa57..8fe3c55726 100644
--- a/apps/web/components/task/task-plan-panel.tsx
+++ b/apps/web/components/task/task-plan-panel.tsx
@@ -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 (
@@ -169,35 +174,64 @@ export const TaskPlanPanel = memo(function TaskPlanPanel({
);
}
- const { textSelection, setTextSelection } = selectionState;
+ return ;
+});
+function PlanPanelContent({
+ taskId,
+ state,
+}: {
+ taskId: string;
+ state: ReturnType;
+}) {
+ 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 (
+
{planSearch.isOpen && (
);
-});
+}
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,
- 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({
);
}
+
+type PlanPanelHeaderProps = {
+ taskId: string;
+ revisions: TaskPlanRevision[];
+ isLoadingRevisions: boolean;
+ isSaving: boolean;
+ onOpenRevisions: () => void;
+ onRevert: (id: string) => Promise;
+ loadRevisionContent: (revisionId: string) => Promise;
+ 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 (
+
+ }
+ />
+ );
+}
diff --git a/apps/web/components/task/task-plan-preview-dialog.tsx b/apps/web/components/task/task-plan-preview-dialog.tsx
new file mode 100644
index 0000000000..3fb5a75aed
--- /dev/null
+++ b/apps/web/components/task/task-plan-preview-dialog.tsx
@@ -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;
+ 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 (
+
+ );
+}
+
+function PreviewBody({
+ revision,
+ loadContent,
+}: {
+ revision: TaskPlanRevision | null;
+ loadContent: (revisionId: string) => Promise;
+}) {
+ const [content, setContent] = useState(null);
+ const [error, setError] = useState(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 (
+
+
+
+ );
+}
+
+function PreviewBodyInner({ content, error }: { content: string | null; error: string | null }) {
+ if (error) {
+ return {error};
+ }
+ if (content === null) {
+ return (
+
+
+ Loading…
+
+ );
+ }
+ if (content.trim() === "") {
+ return (empty plan);
+ }
+ return ;
+}
diff --git a/apps/web/components/task/task-plan-revisions.tsx b/apps/web/components/task/task-plan-revisions.tsx
new file mode 100644
index 0000000000..782d7f45c2
--- /dev/null
+++ b/apps/web/components/task/task-plan-revisions.tsx
@@ -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;
+ loadRevisionContent: (revisionId: string) => Promise;
+ 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(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 (
+ <>
+
+ 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 (
+
+
+
+
+
+
+ Plan history
+ {isLoading && }
+
+ {/* 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. */}
+
+ setPreviewRevision(rev.id)}
+ />
+
+
+
+ );
+}
+
+type DialogStackProps = {
+ revisions: TaskPlanRevision[];
+ previewRevision: TaskPlanRevision | null;
+ compareRevisions: [TaskPlanRevision | null, TaskPlanRevision | null];
+ diffOpen: boolean;
+ confirmTarget: TaskPlanRevision | null;
+ isSaving: boolean;
+ loadRevisionContent: (revisionId: string) => Promise;
+ headRevision: TaskPlanRevision | null;
+ onRevert: (revisionId: string) => Promise;
+ 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 (
+ <>
+ setPreviewRevision(null)}
+ onRestore={() => {
+ if (previewRevision) setConfirmTarget(previewRevision);
+ }}
+ onCompareWithPrevious={onCompareWithPrevious}
+ onCompareWithCurrent={onCompareWithCurrent}
+ isCurrent={isPreviewCurrent}
+ />
+ setDiffOpen(false)}
+ onRestoreOlder={(revisionId) => {
+ const rev = revisions.find((r) => r.id === revisionId);
+ if (rev) setConfirmTarget(rev);
+ }}
+ />
+ 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 (
+
+ No revisions yet. Edits will appear here.
+
+ );
+ }
+ return (
+
+ {revisions.map((rev, i) => (
+
+ ))}
+
+ );
+}
+
+function RevisionAuthor({
+ revision,
+ agentName,
+}: {
+ revision: TaskPlanRevision;
+ agentName: string | null;
+}) {
+ if (revision.author_kind === "agent") {
+ return (
+ <>
+ {agentName ? : null}
+
+ {revision.author_name}
+
+ >
+ );
+ }
+ return (
+ <>
+
+
+ {USER_DISPLAY_NAME}
+
+ >
+ );
+}
+
+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 (
+
+
+ {!isCurrent && (
+
+ )}
+
+ );
+}
+
+function RevertConfirmDialog({
+ target,
+ isSaving,
+ onCancel,
+ onConfirm,
+}: {
+ target: TaskPlanRevision | null;
+ isSaving: boolean;
+ onCancel: () => void;
+ onConfirm: () => void | Promise;
+}): ReactNode {
+ const open = target !== null;
+ return (
+
+ );
+}
diff --git a/apps/web/components/task/use-plan-find-shortcut.ts b/apps/web/components/task/use-plan-find-shortcut.ts
new file mode 100644
index 0000000000..c693ec3b6c
--- /dev/null
+++ b/apps/web/components/task/use-plan-find-shortcut.ts
@@ -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 ``. */
+export function usePlanFindShortcut(
+ wrapperRef: React.RefObject,
+ 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 };
+}
diff --git a/apps/web/e2e/fixtures/backend.ts b/apps/web/e2e/fixtures/backend.ts
index bd9f7af981..2cc2c7b56f 100644
--- a/apps/web/e2e/fixtures/backend.ts
+++ b/apps/web/e2e/fixtures/backend.ts
@@ -243,6 +243,8 @@ exec git "$@"
KANDEV_LOG_LEVEL: process.env.KANDEV_LOG_LEVEL ?? "warn",
AGENTCTL_INSTANCE_PORT_BASE: String(agentctlPortBase),
AGENTCTL_INSTANCE_PORT_MAX: String(agentctlPortMax),
+ // Short window makes coalesce-boundary tests practical without wall-clock waits.
+ KANDEV_PLAN_COALESCE_WINDOW_MS: process.env.KANDEV_PLAN_COALESCE_WINDOW_MS ?? "2000",
GIT_AUTHOR_NAME: "E2E Test",
GIT_AUTHOR_EMAIL: "e2e@test.local",
GIT_COMMITTER_NAME: "E2E Test",
diff --git a/apps/web/e2e/pages/session-page.ts b/apps/web/e2e/pages/session-page.ts
index 7bda63ed53..1f65f4594c 100644
--- a/apps/web/e2e/pages/session-page.ts
+++ b/apps/web/e2e/pages/session-page.ts
@@ -696,6 +696,136 @@ export class SessionPage {
return this.changes.getByTestId(`bulk-discard-${variant}`);
}
+ // --- Plan revisions / rewind ---
+
+ /** Rewind button in the plan panel header (opens revision history popover). */
+ rewindButton(): Locator {
+ return this.planPanel.getByTestId("plan-rewind-button");
+ }
+
+ /** Plan revisions popover (opens after clicking rewind). */
+ revisionsPopover(): Locator {
+ return this.page.getByTestId("plan-revisions-popover");
+ }
+
+ /** All revision rows inside the popover, newest-first. */
+ revisionRows(): Locator {
+ return this.revisionsPopover().getByTestId("plan-revision-row");
+ }
+
+ /** Specific revision row by number. */
+ revisionRow(n: number): Locator {
+ return this.revisionsPopover().locator(`[data-revision-number="${n}"]`);
+ }
+
+ /** Revert button scoped to a given revision row. */
+ revertButton(row: Locator): Locator {
+ return row.getByTestId("plan-revision-revert-button");
+ }
+
+ /** Revert-confirm dialog. */
+ revertConfirmDialog(): Locator {
+ return this.page.getByTestId("plan-revert-confirm-dialog");
+ }
+
+ revertConfirmOk(): Locator {
+ return this.page.getByTestId("plan-revert-confirm-ok");
+ }
+
+ revertConfirmCancel(): Locator {
+ return this.page.getByTestId("plan-revert-confirm-cancel");
+ }
+
+ /** TipTap editor inside the plan panel (for typing user edits). */
+ planEditor(): Locator {
+ return this.planPanel.locator(".ProseMirror");
+ }
+
+ /** Open the rewind popover and wait for it to render. No-op when already open. */
+ async openRewind(): Promise {
+ if (await this.revisionsPopover().isVisible()) return;
+ await this.rewindButton().click();
+ await expect(this.revisionsPopover()).toBeVisible({ timeout: 5_000 });
+ }
+
+ /** Open rewind, click revert on the row with the given revision number, and confirm. */
+ async revertToRevision(n: number): Promise {
+ await this.openRewind();
+ await this.revertButton(this.revisionRow(n)).click();
+ await expect(this.revertConfirmDialog()).toBeVisible({ timeout: 5_000 });
+ await this.revertConfirmOk().click();
+ }
+
+ // --- Plan revision preview & compare (Phase 6) ---
+
+ /** Click the row body (not the Revert/Compare buttons) to open the preview dialog. */
+ revisionRowBody(row: Locator): Locator {
+ return row.getByTestId("plan-revision-row-body");
+ }
+
+ previewDialog(): Locator {
+ return this.page.getByTestId("plan-revision-preview-dialog");
+ }
+
+ previewBody(): Locator {
+ return this.page.getByTestId("plan-revision-preview-body");
+ }
+
+ previewRestoreButton(): Locator {
+ return this.page.getByTestId("plan-revision-preview-restore");
+ }
+
+ previewCompareWithCurrentButton(): Locator {
+ return this.page.getByTestId("plan-revision-preview-compare-with-current");
+ }
+
+ previewCompareWithPreviousButton(): Locator {
+ return this.page.getByTestId("plan-revision-preview-compare-with-previous");
+ }
+
+ previewCloseButton(): Locator {
+ return this.page.getByTestId("plan-revision-preview-close");
+ }
+
+ diffDialog(): Locator {
+ return this.page.getByTestId("plan-revision-diff-dialog");
+ }
+
+ diffSummary(): Locator {
+ return this.page.getByTestId("plan-revision-diff-summary");
+ }
+
+ diffLines(kind?: "add" | "remove" | "context"): Locator {
+ const root = this.diffDialog();
+ if (!kind) return root.getByTestId("plan-revision-diff-line");
+ return root.locator(`[data-testid="plan-revision-diff-line"][data-line-kind="${kind}"]`);
+ }
+
+ diffSplitCells(kind?: "add" | "remove" | "context" | "empty"): Locator {
+ const root = this.diffDialog();
+ if (!kind) return root.getByTestId("plan-revision-diff-split-cell");
+ return root.locator(`[data-testid="plan-revision-diff-split-cell"][data-line-kind="${kind}"]`);
+ }
+
+ diffModeToggle(mode: "unified" | "split"): Locator {
+ return this.page.getByTestId(`plan-revision-diff-mode-${mode}`);
+ }
+
+ diffRestoreButton(): Locator {
+ return this.page.getByTestId("plan-revision-diff-restore");
+ }
+
+ diffCloseButton(): Locator {
+ return this.page.getByTestId("plan-revision-diff-close");
+ }
+
+ /** Open rewind and click into the row body to bring up the preview dialog. */
+ async openRevisionPreview(n: number): Promise {
+ await this.openRewind();
+ await this.revisionRowBody(this.revisionRow(n)).click();
+ await expect(this.previewDialog()).toBeVisible({ timeout: 5_000 });
+ }
+
// --- Panel search helpers (Ctrl+F feature) ---
/** Any currently-mounted panel search bar. */
diff --git a/apps/web/e2e/tests/task/plan-checkpointing.spec.ts b/apps/web/e2e/tests/task/plan-checkpointing.spec.ts
new file mode 100644
index 0000000000..f1c5759f62
--- /dev/null
+++ b/apps/web/e2e/tests/task/plan-checkpointing.spec.ts
@@ -0,0 +1,455 @@
+import { type Page } from "@playwright/test";
+import { test, expect } from "../../fixtures/test-base";
+import type { SeedData } from "../../fixtures/test-base";
+import type { ApiClient } from "../../helpers/api-client";
+import { SessionPage } from "../../pages/session-page";
+
+// ---------------------------------------------------------------------------
+// Overview
+// ---------------------------------------------------------------------------
+//
+// Covers the plan checkpointing feature (task plan revisions + rewind/revert).
+//
+// Coalesce window is set to 2000ms by the backend fixture
+// (KANDEV_PLAN_COALESCE_WINDOW_MS=2000). Tests that cross the boundary use
+// e2e:delay(2500) inside the mock-agent script; tests that stay within use
+// e2e:delay(200).
+//
+// Agent-authored revisions are seeded through the MCP tool
+// (create_task_plan_kandev). User-authored revisions are produced by typing
+// into the plan editor and waiting for the 1500ms autosave debounce.
+
+// ---------------------------------------------------------------------------
+// Shared helpers
+// ---------------------------------------------------------------------------
+
+/** Create a task with agent, navigate to session, wait for agent idle. */
+async function seedTaskAndWaitForIdle(
+ testPage: Page,
+ apiClient: ApiClient,
+ seedData: SeedData,
+ title: string,
+ description = "/e2e:simple-message",
+) {
+ const task = await apiClient.createTaskWithAgent(
+ seedData.workspaceId,
+ title,
+ seedData.agentProfileId,
+ {
+ description,
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ repository_ids: [seedData.repositoryId],
+ },
+ );
+ await testPage.goto(`/t/${task.id}`);
+
+ const session = new SessionPage(testPage);
+ await session.waitForLoad();
+ // Agent writes may auto-switch the active panel to Plan, hiding the chat
+ // input. Accept either the chat idle placeholder or the Plan panel as a
+ // signal that the agent has produced output.
+ await expect
+ .poll(
+ async () => (await session.idleInput().isVisible()) || (await session.planPanel.isVisible()),
+ { timeout: 30_000, message: "agent output not visible" },
+ )
+ .toBe(true);
+
+ return { session, taskId: task.id, sessionId: task.session_id! };
+}
+
+/** Ensure the plan panel is visible. When an agent write auto-opens it,
+ * the toggle is unnecessary (and would close it). After a page reload the
+ * panel may not auto-open, in which case togglePlanMode adds it; we poll
+ * a few times because hydration can race the click. */
+async function openPlanPanel(session: SessionPage) {
+ if (await session.planPanel.isVisible()) return;
+ for (let attempt = 0; attempt < 3; attempt++) {
+ try {
+ await session.togglePlanMode();
+ } catch {
+ // toggle button not yet ready — retry after a short wait
+ }
+ try {
+ await session.planPanel.waitFor({ state: "visible", timeout: 5_000 });
+ return;
+ } catch {
+ // Not visible yet; loop and try toggling again.
+ }
+ }
+ await expect(session.planPanel).toBeVisible({ timeout: 10_000 });
+}
+
+/** Poll for the number of revision rows in the popover. */
+async function expectRevisionCount(session: SessionPage, count: number, timeout = 10_000) {
+ await expect(session.revisionRows()).toHaveCount(count, { timeout });
+}
+
+// MCP script building blocks — each emits one agent-authored plan revision.
+function mcpWrite(content: string): string {
+ // JSON-string-escape content: handles backslashes, quotes, and control chars.
+ const escaped = JSON.stringify(content).slice(1, -1);
+ return `e2e:mcp:kandev:create_task_plan_kandev({"task_id":"{task_id}","content":"${escaped}"})`;
+}
+
+/** Build a mock-agent script that chains plan writes with inter-write delays. */
+function planWriteScript(writes: Array<{ content: string; delayMsAfter?: number }>): string {
+ const lines: string[] = ['e2e:thinking("Seeding plan revisions...")'];
+ for (const w of writes) {
+ lines.push(mcpWrite(w.content));
+ if (w.delayMsAfter) lines.push(`e2e:delay(${w.delayMsAfter})`);
+ }
+ lines.push('e2e:message("Revisions seeded.")');
+ return lines.join("\n");
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+test.describe("Plan checkpointing — rewind UI", () => {
+ test.describe.configure({ retries: 1 });
+
+ test("empty plan: rewind button is disabled when no revisions exist", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(60_000);
+
+ const { session } = await seedTaskAndWaitForIdle(
+ testPage,
+ apiClient,
+ seedData,
+ "Checkpoint empty",
+ );
+ await openPlanPanel(session);
+
+ await expect(session.rewindButton()).toBeVisible({ timeout: 5_000 });
+ // No plan yet → no revisions → button disabled.
+ await expect(session.rewindButton()).toBeDisabled();
+ });
+
+ test("single agent write: rewind exposes one revision with current badge", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(90_000);
+
+ const script = planWriteScript([{ content: "Initial plan" }]);
+ const { session } = await seedTaskAndWaitForIdle(
+ testPage,
+ apiClient,
+ seedData,
+ "Checkpoint single",
+ script,
+ );
+ await openPlanPanel(session);
+ await expect(session.planPanel.getByText("Initial plan", { exact: false })).toBeVisible({
+ timeout: 10_000,
+ });
+
+ await session.openRewind();
+ await expectRevisionCount(session, 1);
+ await expect(session.revisionRow(1).getByTestId("plan-revision-current-badge")).toBeVisible();
+ });
+
+ test("two agent writes within coalesce window: remain one revision", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(90_000);
+
+ const script = planWriteScript([
+ { content: "Draft v1", delayMsAfter: 200 },
+ { content: "Draft v2 (merged)" },
+ ]);
+ const { session } = await seedTaskAndWaitForIdle(
+ testPage,
+ apiClient,
+ seedData,
+ "Checkpoint coalesce",
+ script,
+ );
+ await openPlanPanel(session);
+ await expect(session.planPanel.getByText("Draft v2", { exact: false })).toBeVisible({
+ timeout: 10_000,
+ });
+
+ await session.openRewind();
+ await expectRevisionCount(session, 1);
+ });
+
+ test("two agent writes across coalesce window: produce two revisions", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(90_000);
+
+ const script = planWriteScript([
+ { content: "First pass", delayMsAfter: 2500 },
+ { content: "Second pass" },
+ ]);
+ const { session } = await seedTaskAndWaitForIdle(
+ testPage,
+ apiClient,
+ seedData,
+ "Checkpoint cross-window",
+ script,
+ );
+ await openPlanPanel(session);
+ await expect(session.planPanel.getByText("Second pass", { exact: false })).toBeVisible({
+ timeout: 10_000,
+ });
+
+ await session.openRewind();
+ await expectRevisionCount(session, 2);
+ // Newest first: v2 has current badge; v1 has revert button instead.
+ await expect(session.revisionRow(2).getByTestId("plan-revision-current-badge")).toBeVisible();
+ await expect(session.revisionRow(1).getByTestId("plan-revision-current-badge")).toHaveCount(0);
+ await expect(session.revisionRow(1).getByTestId("plan-revision-revert-button")).toBeVisible();
+ });
+
+ test("revert to earlier revision: HEAD updates and new revision appears", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(120_000);
+
+ const script = planWriteScript([
+ { content: "Agent draft A", delayMsAfter: 2500 },
+ { content: "Agent draft B" },
+ ]);
+ const { session } = await seedTaskAndWaitForIdle(
+ testPage,
+ apiClient,
+ seedData,
+ "Checkpoint revert",
+ script,
+ );
+ await openPlanPanel(session);
+ await expect(session.planPanel.getByText("Agent draft B", { exact: false })).toBeVisible({
+ timeout: 10_000,
+ });
+
+ await session.openRewind();
+ await expectRevisionCount(session, 2);
+
+ // Revert to v1.
+ await session.revertToRevision(1);
+
+ // HEAD content must now be the old v1 content.
+ await expect(session.planPanel.getByText("Agent draft A", { exact: false })).toBeVisible({
+ timeout: 10_000,
+ });
+
+ // Reopen popover; expect 3 revisions with restore marker on v3.
+ await session.openRewind();
+ await expectRevisionCount(session, 3);
+ await expect(session.revisionRow(3).getByTestId("plan-revision-current-badge")).toBeVisible();
+ await expect(session.revisionRow(3).getByTestId("plan-revision-revert-marker")).toBeVisible();
+ });
+
+ test("revert confirm dialog: cancel leaves revisions untouched", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(90_000);
+
+ const script = planWriteScript([
+ { content: "First", delayMsAfter: 2500 },
+ { content: "Second" },
+ ]);
+ const { session } = await seedTaskAndWaitForIdle(
+ testPage,
+ apiClient,
+ seedData,
+ "Checkpoint cancel",
+ script,
+ );
+ await openPlanPanel(session);
+
+ await session.openRewind();
+ await expectRevisionCount(session, 2);
+
+ // Click revert on v1 then cancel the dialog.
+ await session.revertButton(session.revisionRow(1)).click();
+ await expect(session.revertConfirmDialog()).toBeVisible({ timeout: 5_000 });
+ await session.revertConfirmCancel().click();
+ await expect(session.revertConfirmDialog()).toBeHidden({ timeout: 5_000 });
+
+ // HEAD is unchanged and the two revisions are still there.
+ await expect(session.planPanel.getByText("Second", { exact: false })).toBeVisible();
+ await session.openRewind();
+ await expectRevisionCount(session, 2);
+ });
+
+ test("author switch breaks coalesce: user edit creates a second revision", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(120_000);
+
+ const script = planWriteScript([{ content: "Agent wrote this" }]);
+ const { session } = await seedTaskAndWaitForIdle(
+ testPage,
+ apiClient,
+ seedData,
+ "Checkpoint author switch",
+ script,
+ );
+ await openPlanPanel(session);
+ await expect(session.planPanel.getByText("Agent wrote this", { exact: false })).toBeVisible({
+ timeout: 10_000,
+ });
+
+ // User types into the editor; autosave fires after 1500ms.
+ const editor = session.planEditor();
+ await editor.click();
+ const modifier = process.platform === "darwin" ? "Meta" : "Control";
+ await testPage.keyboard.press(`${modifier}+a`);
+ await testPage.keyboard.type("User overwrote it");
+ // Wait past autosave debounce + short settle time.
+ await testPage.waitForTimeout(2_000);
+
+ await session.openRewind();
+ await expectRevisionCount(session, 2);
+ // v2 (newest) is the user write — UI displays "You" for any user-authored
+ // revision regardless of stored author_name. v1 stays as the agent label,
+ // resolved from the active session's profile snapshot — exact value varies
+ // per E2E setup ("Claude", "mock-default", …) so we just ensure it's a
+ // non-empty agent name (i.e., not the user sentinel).
+ const v2Author = session.revisionRow(2).getByTestId("plan-revision-author");
+ const v1Author = session.revisionRow(1).getByTestId("plan-revision-author");
+ await expect(v1Author).not.toHaveText("You");
+ await expect(v1Author).not.toBeEmpty();
+ await expect(v2Author).toHaveText("You");
+ });
+
+ test("preview dialog: row click opens content, restore goes through confirm", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(120_000);
+
+ const script = planWriteScript([
+ { content: "Older draft", delayMsAfter: 2500 },
+ { content: "Newer draft" },
+ ]);
+ const { session } = await seedTaskAndWaitForIdle(
+ testPage,
+ apiClient,
+ seedData,
+ "Checkpoint preview",
+ script,
+ );
+ await openPlanPanel(session);
+ await session.openRewind();
+ await expectRevisionCount(session, 2);
+
+ // Open the older revision via row click.
+ await session.openRevisionPreview(1);
+ await expect(session.previewBody()).toContainText("Older draft", { timeout: 5_000 });
+ await expect(session.previewRestoreButton()).toBeVisible();
+
+ // Restore from preview routes through the confirm dialog.
+ await session.previewRestoreButton().click();
+ await expect(session.revertConfirmDialog()).toBeVisible({ timeout: 5_000 });
+ await session.revertConfirmOk().click();
+ await expect(session.revertConfirmDialog()).toBeHidden({ timeout: 5_000 });
+ // HEAD is now the older content again.
+ await expect(session.planPanel.getByText("Older draft", { exact: false })).toBeVisible({
+ timeout: 10_000,
+ });
+
+ await session.openRewind();
+ await expectRevisionCount(session, 3);
+ });
+
+ test("compare diff: open from preview, view unified + split, restore older", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(120_000);
+
+ const script = planWriteScript([
+ { content: "alpha\nbeta\ngamma", delayMsAfter: 2500 },
+ { content: "alpha\nBETA\ngamma\ndelta" },
+ ]);
+ const { session } = await seedTaskAndWaitForIdle(
+ testPage,
+ apiClient,
+ seedData,
+ "Checkpoint compare",
+ script,
+ );
+ await openPlanPanel(session);
+ await session.openRewind();
+ await expectRevisionCount(session, 2);
+
+ // Open the older revision in preview, then "Compare with current" — this
+ // is the only entry point now; there is no per-row compare toggle.
+ await session.openRevisionPreview(1);
+ await session.previewCompareWithCurrentButton().click();
+ await expect(session.diffDialog()).toBeVisible({ timeout: 5_000 });
+ await expect(session.diffDialog()).toContainText("Compare v1 → v2");
+
+ // Summary + unified diff lines.
+ await expect(session.diffSummary()).toContainText("added", { timeout: 5_000 });
+ await expect(session.diffSummary()).toContainText("removed");
+ await expect(session.diffLines("add").first()).toBeVisible();
+ await expect(session.diffLines("remove").first()).toBeVisible();
+
+ // Switch to split mode: side-by-side cells render with kinds.
+ await session.diffModeToggle("split").click();
+ await expect(session.diffSplitCells("remove").first()).toBeVisible({ timeout: 5_000 });
+ await expect(session.diffSplitCells("add").first()).toBeVisible();
+
+ // Back to unified.
+ await session.diffModeToggle("unified").click();
+ await expect(session.diffLines("add").first()).toBeVisible();
+
+ // Restore from diff targets the older revision (v1).
+ await session.diffRestoreButton().click();
+ await expect(session.revertConfirmDialog()).toBeVisible({ timeout: 5_000 });
+ await expect(session.revertConfirmDialog()).toContainText("version 1");
+ await session.revertConfirmCancel().click();
+ });
+
+ test("persistence across reload: revisions survive page refresh", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(120_000);
+
+ const script = planWriteScript([{ content: "One", delayMsAfter: 2500 }, { content: "Two" }]);
+ const { session, taskId } = await seedTaskAndWaitForIdle(
+ testPage,
+ apiClient,
+ seedData,
+ "Checkpoint reload",
+ script,
+ );
+ await openPlanPanel(session);
+ await session.openRewind();
+ await expectRevisionCount(session, 2);
+
+ // Reload and reopen the panel.
+ await testPage.goto(`/t/${taskId}`);
+ const freshSession = new SessionPage(testPage);
+ await freshSession.waitForLoad();
+ await openPlanPanel(freshSession);
+ await freshSession.openRewind();
+ await expectRevisionCount(freshSession, 2);
+ });
+});
diff --git a/apps/web/hooks/domains/session/use-task-plan.ts b/apps/web/hooks/domains/session/use-task-plan.ts
index b11cf7b0fc..ca0f33e78b 100644
--- a/apps/web/hooks/domains/session/use-task-plan.ts
+++ b/apps/web/hooks/domains/session/use-task-plan.ts
@@ -1,12 +1,17 @@
import { useEffect, useCallback, useState, useRef } from "react";
-import { useAppStore } from "@/components/state-provider";
+import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import {
getTaskPlan,
createTaskPlan,
updateTaskPlan,
deleteTaskPlan,
+ listPlanRevisions,
+ getPlanRevision,
+ revertPlanRevision,
} from "@/lib/api/domains/plan-api";
-import type { TaskPlan } from "@/lib/types/http";
+import type { TaskPlan, TaskPlanRevision } from "@/lib/types/http";
+
+const EMPTY_REVISIONS: readonly TaskPlanRevision[] = Object.freeze([]);
/**
* Hook to fetch and manage the plan for a task.
@@ -119,6 +124,8 @@ export function useTaskPlan(taskId: string | null, options?: { visible?: boolean
}
}, [taskId, setTaskPlan, setTaskPlanSaving]);
+ const revisionsBundle = useTaskPlanRevisions(taskId, setTaskPlanSaving, setError);
+
return {
plan: plan ?? null,
isLoading,
@@ -127,5 +134,138 @@ export function useTaskPlan(taskId: string | null, options?: { visible?: boolean
savePlan,
deletePlan: removePlan,
refetch: fetchPlan,
+ ...revisionsBundle,
+ };
+}
+
+const EMPTY_PAIR: readonly [string | null, string | null] = Object.freeze([
+ null,
+ null,
+]) as readonly [string | null, string | null];
+
+function useTaskPlanRevisions(
+ taskId: string | null,
+ setTaskPlanSaving: (taskId: string, saving: boolean) => void,
+ setError: (err: string | null) => void,
+) {
+ const revisions = useAppStore((state) =>
+ taskId ? (state.taskPlans.revisionsByTaskId[taskId] ?? EMPTY_REVISIONS) : EMPTY_REVISIONS,
+ ) as TaskPlanRevision[];
+ const isLoadingRevisions = useAppStore((state) =>
+ taskId ? (state.taskPlans.revisionsLoadingByTaskId[taskId] ?? false) : false,
+ );
+ const isRevisionsLoaded = useAppStore((state) =>
+ taskId ? (state.taskPlans.revisionsLoadedByTaskId[taskId] ?? false) : false,
+ );
+ const connectionStatus = useAppStore((state) => state.connection.status);
+ const storeApi = useAppStoreApi();
+ const setPlanRevisions = useAppStore((state) => state.setPlanRevisions);
+ const setPlanRevisionsLoading = useAppStore((state) => state.setPlanRevisionsLoading);
+ const cachePlanRevisionContent = useAppStore((state) => state.cachePlanRevisionContent);
+
+ const loadRevisions = useCallback(async () => {
+ if (!taskId) return;
+ setPlanRevisionsLoading(taskId, true);
+ try {
+ const list = await listPlanRevisions(taskId);
+ setPlanRevisions(taskId, list);
+ } catch (err) {
+ console.error("Failed to load plan revisions:", err);
+ setError(err instanceof Error ? err.message : "Failed to load revisions");
+ } finally {
+ setPlanRevisionsLoading(taskId, false);
+ }
+ }, [taskId, setPlanRevisions, setPlanRevisionsLoading, setError]);
+
+ // Load revisions once on mount — events may have fired before the WS connected.
+ useEffect(() => {
+ if (connectionStatus !== "connected") return;
+ if (!taskId || isRevisionsLoaded || isLoadingRevisions) return;
+ loadRevisions();
+ }, [taskId, connectionStatus, isRevisionsLoaded, isLoadingRevisions, loadRevisions]);
+
+ const loadRevisionContent = useCallback(
+ async (revisionId: string): Promise => {
+ // Read the cache lazily via the store API inside the callback so this
+ // function's identity stays stable across cache updates. Selecting the
+ // cache object as a hook input would re-create the callback whenever
+ // any task's content was cached, which retriggers the dialogs'
+ // content-fetch effects (cache short-circuits, but the work is wasted).
+ const cached = storeApi.getState().taskPlans.revisionContentCache[revisionId];
+ if (cached !== undefined) return cached;
+ // Pass taskId so the backend can enforce revision-belongs-to-task.
+ const rev = await getPlanRevision(revisionId, taskId ?? undefined);
+ const content = rev.content ?? "";
+ cachePlanRevisionContent(revisionId, content);
+ return content;
+ },
+ [taskId, storeApi, cachePlanRevisionContent],
+ );
+
+ const revertTo = useCallback(
+ async (revisionId: string, authorName?: string): Promise => {
+ if (!taskId) return null;
+ setTaskPlanSaving(taskId, true);
+ setError(null);
+ try {
+ return await revertPlanRevision(taskId, revisionId, authorName);
+ } catch (err) {
+ console.error("Failed to revert plan:", err);
+ setError(err instanceof Error ? err.message : "Failed to revert plan");
+ return null;
+ } finally {
+ setTaskPlanSaving(taskId, false);
+ }
+ },
+ [taskId, setTaskPlanSaving, setError],
+ );
+
+ return {
+ revisions,
+ isLoadingRevisions,
+ loadRevisions,
+ loadRevisionContent,
+ revertTo,
+ ...usePreviewCompareState(taskId),
+ };
+}
+
+/** Phase 6: preview + compare selectors and actions, scoped to the active task. */
+function usePreviewCompareState(taskId: string | null) {
+ const previewRevisionId = useAppStore((state) =>
+ taskId ? (state.taskPlans.previewRevisionIdByTaskId[taskId] ?? null) : null,
+ );
+ const comparePair = useAppStore((state) =>
+ taskId ? (state.taskPlans.comparePairByTaskId[taskId] ?? EMPTY_PAIR) : EMPTY_PAIR,
+ ) as [string | null, string | null];
+ const setPreviewRevisionStore = useAppStore((state) => state.setPreviewRevision);
+ const toggleComparePairStore = useAppStore((state) => state.toggleComparePair);
+ const clearComparePairStore = useAppStore((state) => state.clearComparePair);
+
+ const setPreviewRevision = useCallback(
+ (revisionId: string | null) => {
+ if (!taskId) return;
+ setPreviewRevisionStore(taskId, revisionId);
+ },
+ [taskId, setPreviewRevisionStore],
+ );
+ const toggleCompareSelection = useCallback(
+ (revisionId: string) => {
+ if (!taskId) return;
+ toggleComparePairStore(taskId, revisionId);
+ },
+ [taskId, toggleComparePairStore],
+ );
+ const clearComparePair = useCallback(() => {
+ if (!taskId) return;
+ clearComparePairStore(taskId);
+ }, [taskId, clearComparePairStore]);
+
+ return {
+ previewRevisionId,
+ setPreviewRevision,
+ comparePair,
+ toggleCompareSelection,
+ clearComparePair,
};
}
diff --git a/apps/web/lib/api/domains/plan-api.ts b/apps/web/lib/api/domains/plan-api.ts
index c9e5bfce53..6da9cc43f0 100644
--- a/apps/web/lib/api/domains/plan-api.ts
+++ b/apps/web/lib/api/domains/plan-api.ts
@@ -1,5 +1,5 @@
import { getWebSocketClient } from "@/lib/ws/connection";
-import type { TaskPlan } from "@/lib/types/http";
+import type { TaskPlan, TaskPlanRevision } from "@/lib/types/http";
const WS_CLIENT_UNAVAILABLE = "WebSocket client not available";
@@ -80,3 +80,57 @@ export async function deleteTaskPlan(taskId: string): Promise {
}
await client.request("task.plan.delete", { task_id: taskId });
}
+
+/**
+ * List all revisions for a plan (metadata only, newest first).
+ */
+export async function listPlanRevisions(taskId: string): Promise {
+ const client = getWebSocketClient();
+ if (!client) {
+ throw new Error(WS_CLIENT_UNAVAILABLE);
+ }
+ const response = await client.request("task.plan.revisions.list", { task_id: taskId });
+ const list = (response as { revisions?: TaskPlanRevision[] })?.revisions;
+ return list ?? [];
+}
+
+/**
+ * Fetch a single revision including its content (for diff/preview).
+ * `taskId` is optional but recommended — the backend uses it to enforce
+ * ownership (a connected client can only read revisions belonging to a
+ * task it already holds a reference to).
+ */
+export async function getPlanRevision(
+ revisionId: string,
+ taskId?: string,
+): Promise {
+ const client = getWebSocketClient();
+ if (!client) {
+ throw new Error(WS_CLIENT_UNAVAILABLE);
+ }
+ const payload: Record = { revision_id: revisionId };
+ if (taskId) payload.task_id = taskId;
+ const response = await client.request("task.plan.revision.get", payload);
+ return response as TaskPlanRevision;
+}
+
+/**
+ * Revert the plan to a previous revision. Server appends a new revision with the target's content.
+ */
+export async function revertPlanRevision(
+ taskId: string,
+ revisionId: string,
+ authorName?: string,
+): Promise {
+ const client = getWebSocketClient();
+ if (!client) {
+ throw new Error(WS_CLIENT_UNAVAILABLE);
+ }
+ const payload: Record = {
+ task_id: taskId,
+ revision_id: revisionId,
+ };
+ if (authorName) payload.author_name = authorName;
+ const response = await client.request("task.plan.revert", payload);
+ return response as TaskPlanRevision;
+}
diff --git a/apps/web/lib/state/slices/session/session-slice.ts b/apps/web/lib/state/slices/session/session-slice.ts
index 1a2a0e10e6..1ade07a095 100644
--- a/apps/web/lib/state/slices/session/session-slice.ts
+++ b/apps/web/lib/state/slices/session/session-slice.ts
@@ -79,6 +79,12 @@ export const defaultSessionState: SessionSliceState = {
loadingByTaskId: {},
loadedByTaskId: {},
savingByTaskId: {},
+ revisionsByTaskId: {},
+ revisionsLoadingByTaskId: {},
+ revisionsLoadedByTaskId: {},
+ revisionContentCache: {},
+ previewRevisionIdByTaskId: {},
+ comparePairByTaskId: {},
lastSeenUpdatedAtByTaskId: {},
},
queue: { bySessionId: {}, isLoading: {} },
@@ -178,10 +184,23 @@ function buildTaskPlanActions(set: ImmerSet) {
}),
clearTaskPlan: (taskId: string) =>
set((draft) => {
+ // revisionContentCache is keyed by revisionId, so pick the IDs for this
+ // task before deleting the revisions list and drop their cache entries.
+ const revs = draft.taskPlans.revisionsByTaskId[taskId];
+ if (revs) {
+ for (const r of revs) {
+ delete draft.taskPlans.revisionContentCache[r.id];
+ }
+ }
delete draft.taskPlans.byTaskId[taskId];
delete draft.taskPlans.loadingByTaskId[taskId];
delete draft.taskPlans.loadedByTaskId[taskId];
delete draft.taskPlans.savingByTaskId[taskId];
+ delete draft.taskPlans.revisionsByTaskId[taskId];
+ delete draft.taskPlans.revisionsLoadingByTaskId[taskId];
+ delete draft.taskPlans.revisionsLoadedByTaskId[taskId];
+ delete draft.taskPlans.previewRevisionIdByTaskId[taskId];
+ delete draft.taskPlans.comparePairByTaskId[taskId];
delete draft.taskPlans.lastSeenUpdatedAtByTaskId[taskId];
}),
markTaskPlanSeen: (taskId: string) =>
@@ -189,9 +208,86 @@ function buildTaskPlanActions(set: ImmerSet) {
const plan = draft.taskPlans.byTaskId[taskId];
draft.taskPlans.lastSeenUpdatedAtByTaskId[taskId] = plan?.updated_at ?? "";
}),
+ setPlanRevisions: (
+ taskId: string,
+ revisions: Parameters[1],
+ ) =>
+ set((draft) => {
+ draft.taskPlans.revisionsByTaskId[taskId] = [...revisions].sort(
+ (a, b) => b.revision_number - a.revision_number,
+ );
+ draft.taskPlans.revisionsLoadedByTaskId[taskId] = true;
+ draft.taskPlans.revisionsLoadingByTaskId[taskId] = false;
+ }),
+ upsertPlanRevision: (
+ taskId: string,
+ revision: Parameters[1],
+ ) =>
+ set((draft) => {
+ const list = draft.taskPlans.revisionsByTaskId[taskId] ?? [];
+ const idx = list.findIndex((r) => r.id === revision.id);
+ if (idx === -1) {
+ list.unshift(revision);
+ } else {
+ list[idx] = { ...list[idx], ...revision };
+ // Coalesced writes update an existing revision's content on the
+ // backend, but the WS payload carries metadata only — drop any
+ // cached content so the next preview refetches.
+ delete draft.taskPlans.revisionContentCache[revision.id];
+ }
+ list.sort((a, b) => b.revision_number - a.revision_number);
+ draft.taskPlans.revisionsByTaskId[taskId] = list;
+ }),
+ setPlanRevisionsLoading: (taskId: string, loading: boolean) =>
+ set((draft) => {
+ draft.taskPlans.revisionsLoadingByTaskId[taskId] = loading;
+ }),
+ cachePlanRevisionContent: (revisionId: string, content: string) =>
+ set((draft) => {
+ draft.taskPlans.revisionContentCache[revisionId] = content;
+ }),
+ ...buildPreviewCompareActions(set),
};
}
+function buildPreviewCompareActions(set: ImmerSet) {
+ return {
+ setPreviewRevision: (taskId: string, revisionId: string | null) =>
+ set((draft) => {
+ if (revisionId === null) {
+ delete draft.taskPlans.previewRevisionIdByTaskId[taskId];
+ } else {
+ draft.taskPlans.previewRevisionIdByTaskId[taskId] = revisionId;
+ }
+ }),
+ toggleComparePair: (taskId: string, revisionId: string) =>
+ set((draft) => {
+ draft.taskPlans.comparePairByTaskId[taskId] = nextPair(
+ draft.taskPlans.comparePairByTaskId[taskId] ?? [null, null],
+ revisionId,
+ );
+ }),
+ clearComparePair: (taskId: string) =>
+ set((draft) => {
+ delete draft.taskPlans.comparePairByTaskId[taskId];
+ }),
+ };
+}
+
+/** Compute the next compare-pair after a toggle. Already-selected ids unselect;
+ * empty slots fill in order (slot 0 first); a full pair drops slot 0 and shifts
+ * slot 1 → 0, putting the new pick in slot 1 (FIFO of length 2). */
+function nextPair(
+ current: readonly [string | null, string | null],
+ revisionId: string,
+): [string | null, string | null] {
+ if (current[0] === revisionId) return [current[1], null];
+ if (current[1] === revisionId) return [current[0], null];
+ if (current[0] === null) return [revisionId, current[1]];
+ if (current[1] === null) return [current[0], revisionId];
+ return [current[1], revisionId];
+}
+
export const createSessionSlice: StateCreator<
SessionSlice,
[["zustand/immer", never]],
diff --git a/apps/web/lib/state/slices/session/types.ts b/apps/web/lib/state/slices/session/types.ts
index 10718ea1b5..cde68ad071 100644
--- a/apps/web/lib/state/slices/session/types.ts
+++ b/apps/web/lib/state/slices/session/types.ts
@@ -1,4 +1,4 @@
-import type { Message, TaskSession, Turn, TaskPlan } from "@/lib/types/http";
+import type { Message, TaskSession, Turn, TaskPlan, TaskPlanRevision } from "@/lib/types/http";
export type MessagesState = {
bySession: Record;
@@ -62,11 +62,24 @@ export type ActiveModelState = {
bySessionId: Record;
};
+/** Ordered slot pair for the compare-revisions feature. Either slot may be
+ * null. Reducers enforce a 2-slot cap and reject duplicates. */
+export type ComparePair = [string | null, string | null];
+
export type TaskPlansState = {
byTaskId: Record;
loadingByTaskId: Record;
loadedByTaskId: Record;
savingByTaskId: Record;
+ revisionsByTaskId: Record;
+ revisionsLoadingByTaskId: Record;
+ revisionsLoadedByTaskId: Record;
+ revisionContentCache: Record; // revisionId -> content
+ // Phase 6: preview + compare state
+ previewRevisionIdByTaskId: Record;
+ comparePairByTaskId: Record;
+ // From main: tracks the last `updated_at` the user has seen, so the panel
+ // can flag unseen-changes after agent writes between visits.
lastSeenUpdatedAtByTaskId: Record;
};
@@ -143,6 +156,15 @@ export type SessionSliceActions = {
setTaskPlanSaving: (taskId: string, saving: boolean) => void;
clearTaskPlan: (taskId: string) => void;
markTaskPlanSeen: (taskId: string) => void;
+ // Revision actions
+ setPlanRevisions: (taskId: string, revisions: TaskPlanRevision[]) => void;
+ upsertPlanRevision: (taskId: string, revision: TaskPlanRevision) => void;
+ setPlanRevisionsLoading: (taskId: string, loading: boolean) => void;
+ cachePlanRevisionContent: (revisionId: string, content: string) => void;
+ // Phase 6: preview + compare actions
+ setPreviewRevision: (taskId: string, revisionId: string | null) => void;
+ toggleComparePair: (taskId: string, revisionId: string) => void;
+ clearComparePair: (taskId: string) => void;
// Queue actions
setQueueStatus: (sessionId: string, status: QueueStatus) => void;
setQueueLoading: (sessionId: string, loading: boolean) => void;
diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts
index cc7a75eb22..4347aae3ff 100644
--- a/apps/web/lib/state/store.ts
+++ b/apps/web/lib/state/store.ts
@@ -396,6 +396,21 @@ export type AppState = {
setTaskPlanSaving: (taskId: string, saving: boolean) => void;
clearTaskPlan: (taskId: string) => void;
markTaskPlanSeen: (taskId: string) => void;
+ // Plan revision actions
+ setPlanRevisions: (
+ taskId: string,
+ revisions: import("@/lib/types/http").TaskPlanRevision[],
+ ) => void;
+ upsertPlanRevision: (
+ taskId: string,
+ revision: import("@/lib/types/http").TaskPlanRevision,
+ ) => void;
+ setPlanRevisionsLoading: (taskId: string, loading: boolean) => void;
+ cachePlanRevisionContent: (revisionId: string, content: string) => void;
+ // Plan revision preview + compare actions
+ setPreviewRevision: (taskId: string, revisionId: string | null) => void;
+ toggleComparePair: (taskId: string, revisionId: string) => void;
+ clearComparePair: (taskId: string) => void;
// Queue actions
setQueueStatus: (sessionId: string, status: import("./slices/session/types").QueueStatus) => void;
setQueueLoading: (sessionId: string, loading: boolean) => void;
diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts
index e3f507f1dc..70f93f7cfa 100644
--- a/apps/web/lib/types/backend.ts
+++ b/apps/web/lib/types/backend.ts
@@ -7,6 +7,8 @@ export type BackendMessageType =
| "task.plan.created"
| "task.plan.updated"
| "task.plan.deleted"
+ | "task.plan.revision.created"
+ | "task.plan.reverted"
| "agent.updated"
| "agent.available.updated"
| "terminal.output"
@@ -477,6 +479,19 @@ export type TaskPlanEventPayload = {
updated_at: string;
};
+export type TaskPlanRevisionEventPayload = {
+ id: string;
+ task_id: string;
+ revision_number: number;
+ title: string;
+ author_kind: "agent" | "user";
+ author_name: string;
+ revert_of_revision_id?: string | null;
+ coalesced?: boolean;
+ created_at: string;
+ updated_at: string;
+};
+
export type QueuedMessagePayload = {
content: string;
model?: string;
@@ -501,6 +516,11 @@ export type BackendMessageMap = {
"task.plan.created": BackendMessage<"task.plan.created", TaskPlanEventPayload>;
"task.plan.updated": BackendMessage<"task.plan.updated", TaskPlanEventPayload>;
"task.plan.deleted": BackendMessage<"task.plan.deleted", TaskPlanEventPayload>;
+ "task.plan.revision.created": BackendMessage<
+ "task.plan.revision.created",
+ TaskPlanRevisionEventPayload
+ >;
+ "task.plan.reverted": BackendMessage<"task.plan.reverted", TaskPlanRevisionEventPayload>;
"agent.updated": BackendMessage<"agent.updated", AgentUpdatePayload>;
"agent.available.updated": BackendMessage<
"agent.available.updated",
diff --git a/apps/web/lib/types/http-agents.ts b/apps/web/lib/types/http-agents.ts
index 80c63e764f..8decf7efab 100644
--- a/apps/web/lib/types/http-agents.ts
+++ b/apps/web/lib/types/http-agents.ts
@@ -269,6 +269,21 @@ export type TaskPlanResponse = {
plan: TaskPlan | null;
};
+// A single revision in the task plan history. `content` is optional because
+// list responses/WS broadcasts omit it for size; fetch detail separately.
+export type TaskPlanRevision = {
+ id: string;
+ task_id: string;
+ revision_number: number;
+ title: string;
+ content?: string;
+ author_kind: "agent" | "user";
+ author_name: string;
+ revert_of_revision_id?: string | null;
+ created_at: string;
+ updated_at: string;
+};
+
// Stats types
export type TaskStatsDTO = {
task_id: string;
diff --git a/apps/web/lib/utils.ts b/apps/web/lib/utils.ts
index 16b4d0b8f9..3865ffd7ec 100644
--- a/apps/web/lib/utils.ts
+++ b/apps/web/lib/utils.ts
@@ -170,6 +170,46 @@ export function formatRelativeTime(dateString: string): string {
return date.toLocaleDateString();
}
+/**
+ * Format a date string with minute-level precision and short context.
+ * Examples: "just now", "5m ago", "Today, 14:32", "Yesterday, 14:32",
+ * "Apr 25, 14:32", "Apr 25 2025, 14:32".
+ */
+export function formatPreciseTime(dateString: string): string {
+ const date = new Date(dateString);
+ const now = new Date();
+ const diffMs = now.getTime() - date.getTime();
+ const diffSec = Math.floor(diffMs / 1000);
+ const diffMin = Math.floor(diffSec / 60);
+
+ if (diffSec < 10) return "just now";
+ if (diffSec < 60) return `${diffSec}s ago`;
+ if (diffMin < 60) return `${diffMin}m ago`;
+
+ const time = date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
+ const sameDay =
+ date.getFullYear() === now.getFullYear() &&
+ date.getMonth() === now.getMonth() &&
+ date.getDate() === now.getDate();
+ if (sameDay) return `Today, ${time}`;
+
+ const yesterday = new Date(now);
+ yesterday.setDate(now.getDate() - 1);
+ const isYesterday =
+ date.getFullYear() === yesterday.getFullYear() &&
+ date.getMonth() === yesterday.getMonth() &&
+ date.getDate() === yesterday.getDate();
+ if (isYesterday) return `Yesterday, ${time}`;
+
+ const sameYear = date.getFullYear() === now.getFullYear();
+ const dateLabel = date.toLocaleDateString(undefined, {
+ month: "short",
+ day: "numeric",
+ year: sameYear ? undefined : "numeric",
+ });
+ return `${dateLabel}, ${time}`;
+}
+
/**
* Convert an absolute file path to a relative path based on a workspace root.
* If the path is within the workspace, returns the relative portion.
diff --git a/apps/web/lib/ws/handlers/task-plans.ts b/apps/web/lib/ws/handlers/task-plans.ts
index 9973ad0c26..81b4c10945 100644
--- a/apps/web/lib/ws/handlers/task-plans.ts
+++ b/apps/web/lib/ws/handlers/task-plans.ts
@@ -2,8 +2,12 @@ import type { StoreApi } from "zustand";
import type { AppState } from "@/lib/state/store";
import type { BackendMessageMap } from "@/lib/types/backend";
import type { WsHandlers } from "@/lib/ws/handlers/types";
+import type { TaskPlanRevision } from "@/lib/types/http";
type PlanMessage = BackendMessageMap["task.plan.created"] | BackendMessageMap["task.plan.updated"];
+type RevisionMessage =
+ | BackendMessageMap["task.plan.revision.created"]
+ | BackendMessageMap["task.plan.reverted"];
function handlePlanUpsert(store: StoreApi, message: PlanMessage) {
const { task_id, id, title, content, created_by, created_at, updated_at } = message.payload;
@@ -28,6 +32,22 @@ function handlePlanUpsert(store: StoreApi, message: PlanMessage) {
}
}
+function handleRevisionPush(store: StoreApi, message: RevisionMessage) {
+ const p = message.payload;
+ const rev: TaskPlanRevision = {
+ id: p.id,
+ task_id: p.task_id,
+ revision_number: p.revision_number,
+ title: p.title,
+ author_kind: p.author_kind,
+ author_name: p.author_name,
+ revert_of_revision_id: p.revert_of_revision_id ?? null,
+ created_at: p.created_at,
+ updated_at: p.updated_at,
+ };
+ store.getState().upsertPlanRevision(p.task_id, rev);
+}
+
export function registerTaskPlansHandlers(store: StoreApi): WsHandlers {
return {
"task.plan.created": (message) => handlePlanUpsert(store, message),
@@ -41,5 +61,13 @@ export function registerTaskPlansHandlers(store: StoreApi): WsHandlers
store.getState().setTaskPlan(task_id, null);
store.getState().markTaskPlanSeen(task_id);
},
+ "task.plan.revision.created": (message) => handleRevisionPush(store, message),
+ // `task.plan.reverted` is published alongside `task.plan.revision.created`
+ // for the same row by the backend RevertPlan path. Re-running the upsert
+ // on this event would be a no-op against the list (same id, same data)
+ // but would needlessly evict the revisionContentCache entry and trigger
+ // an extra Zustand update. Treat this event as a notification-only signal
+ // — register no-op so the dispatcher doesn't warn about an unhandled type.
+ "task.plan.reverted": () => {},
};
}