feat: plan checkpointing with rewind UI (#694)

* feat(backend): plan revision history with checkpoint/revert

Persist every plan edit as an immutable TaskPlanRevision with author
attribution (agent vs user) and expose WS actions to list, fetch, and
revert to past revisions. Emits task_plan.revision.created and
task_plan.reverted events so the UI can react in real time.

* feat(web): plan revision history UI with rewind to past versions

Add a rewind button to the plan panel that opens a revisions list
showing each checkpoint with author (agent name or user) and timestamp,
and lets the user restore any past revision as the new head. Plan
state, hooks, and WS handlers are extended to load, cache, and react
to revision-created/reverted events.

* test(web): e2e coverage for plan checkpointing and rewind

Cover the full revision flow: plan edit creates a checkpoint,
the revisions panel lists authors and timestamps, and reverting
restores the selected revision as the new head. Extends session
page object and backend fixtures with revision-related helpers.

* fix(web): stabilize plan revisions state and avoid re-render loop

Zustand selector returned a fresh [] for missing revisions, fanning out
re-renders that tripped React's infinite-loop guard when the plan panel
mounted. Swap to a frozen empty-array sentinel, auto-load revisions on
mount so WS events fired before connect aren't lost, and gate the
popover's on-open fetch so reopening doesn't re-sort rows mid-click.

* test(web): make plan checkpointing e2e resilient to agent auto-layout

Agent-authored plan writes auto-open the plan panel and can push the
chat tab inactive, so the chat idle placeholder isn't always visible
once the agent finishes. Accept either chat or plan panel as the idle
signal, make openPlanPanel and openRewind idempotent, reopen the
popover after a revert cancel (the confirm dialog closes it), and drop
the toast assertion since no Toaster is mounted in the app.

* fix(backend): atomic plan revision write + review polish

- Add `WritePlanRevision(ctx, head, rev, coalesceLatestID)` repository method
  that wraps HEAD upsert + MAX+1 compute + insert/merge in a single write tx,
  closing the TOCTOU race on `revision_number` and partial-failure divergence
  between HEAD and history. `upsertPlan` and `RevertPlan` both route through it.
- Add `ErrRevisionIDRequired`; return it for empty `TargetRevisionID` (was
  previously conflated with `ErrRevisionNotFound`). Handler maps to validation.
- Backfill fallback `author_kind` changed `"user"` → `"agent"` to match
  `CreateTaskPlan` and the column default.
- Extract shared `revisionSelectCols` constant; rename `max` local (shadowed
  Go builtin) and `dto` local (shadowed package name).

* fix(web): address plan-revisions review feedback

- `mcpWrite` uses `JSON.stringify(content).slice(1, -1)` to escape backslashes,
  quotes, and control chars in one pass — closes the CodeQL finding.
- `clearTaskPlan` now evicts `revisionContentCache` entries for the task's
  revisions before dropping the maps, preventing orphaned cache growth.
- `RevertConfirmDialog` wraps `onRevert` in try/catch/finally so rejections
  surface a toast and the dialog always dismisses.
- Hoist `TaskPlanRevision` import in `task-plan-panel.tsx`.
- `RevisionRow` derives relative time from props every render with a 30s tick,
  so prop changes refresh immediately.
- Strengthen author-switch e2e with `.not.toBeEmpty()` to also reject empty or
  truncated renders.

* fix(web): evict revision content cache on coalesced update

Coalesced writes update an existing revision's content on the backend, but
the WS `upsertPlanRevision` payload carries only metadata. Without eviction,
a cached preview of the revision stays stale after the agent re-writes the
plan within the coalesce window.

* fix(web): plan history UI polish from local testing

- Drop the redundant "Plan" title in the panel header bar; the dockview/mobile
  tab already labels the panel.
- Author display: show "You" with the user icon for user-authored revisions,
  and the agent profile name with the AgentLogo (resolved from the active
  session's `agent_profile_snapshot.agent_name`) for agent revisions, matching
  the icon used elsewhere in the app.
- Replace ScrollArea with a plain `max-h-96 overflow-y-auto` div so the
  popover stays bounded when revision counts grow past ~7 rows.
- Drop the em dash from the revert dialog description.

* feat(web): add lineDiff helper for plan revision compare

Tiny wrapper around the `diff` package that classifies each line as add /
remove / context and strips the trailing-newline phantom entry. Also exposes
`diffSummary` for the dialog header counts. Pure functions; backed by Vitest.

* feat(web): plan revision preview + compare state

Adds two task-scoped store keys for the new dialogs:

- `previewRevisionIdByTaskId` — at most one open preview per task; cleared on
  `clearTaskPlan`.
- `comparePairByTaskId` — ordered 2-slot pair. `toggleComparePair` reuses a
  `nextPair` helper that handles unselect (already-picked id), fill (open
  slot), and FIFO eviction (full pair). `clearTaskPlan` resets it too.

* feat(web): plan revision preview + diff dialogs

- `useTaskPlan` exposes `previewRevisionId`, `setPreviewRevision`,
  `comparePair`, `toggleCompareSelection`, `clearComparePair` (extracted into
  `usePreviewCompareState` to keep the surrounding hook within the size cap).
- New `PlanRevisionPreviewDialog`: read-only modal for a single revision with
  Restore + "Compare with current" CTAs. Restore routes through the existing
  RevertConfirmDialog so the confirmation copy stays consistent.
- New `PlanRevisionDiffDialog`: line-level unified diff between two revisions
  with adds/removes highlighted; orders the pair by revision_number so the
  older one is always "before"; Restore targets the older side.
- Popover row split into a clickable body (opens preview), a Compare toggle
  (icon button per row), and the existing Revert button. Compare chips appear
  at the top of the popover with a Diff CTA when both slots are filled.
- Both dialogs are remounted via key on revision/pair change so loader state
  resets without ESLint-flagged setState-in-effect resets.

* test(web): e2e coverage for plan preview and compare-diff dialogs

Extends the SessionPage POM with locators for the new preview and diff
dialogs (chips, compare toggle, restore buttons, body + summary). Adds two
scenarios in plan-checkpointing.spec.ts:

- Click a revision row body → preview shows the right content; the Restore
  CTA opens the existing confirm dialog and the revert lands.
- Toggle compare on two revisions → diff opens with v1 → v2 ordering and
  shows at least one add and one remove line; the Restore CTA targets the
  older side.

* feat(web): render markdown in plan preview + diff dialogs

Adds a shared `PlanReadOnlyMarkdown` Tiptap renderer that reuses the same
StarterKit + tiptap-markdown + tables/tasks/code-block extensions as the live
plan editor (minus interactive bits — slash menu, drag handles, comment
marks). Wired into both dialogs:

- Preview replaces the raw `<pre>` block with the rendered Tiptap view.
- Diff dialog now stacks two rendered sides (Before / After) with red/green
  tinted borders, kept above the existing line-level breakdown so the user
  gets both rendered comparison and an exact textual change list.

Tiptap doesn't ship a free diff/track-changes extension (it's part of Tiptap
Pro), so the side-by-side rendering plus the line-level list together stand
in for that.

* fix(web): plan diff dialog — text-only with unified/split modes

Following local feedback:

- Drop the rendered-markdown side-by-side panes from the diff dialog. Diff
  view is text-only again, like a code review.
- Add a Unified / Split mode toggle. Split mode aligns each remove with the
  adjacent add on the same row (single-line edits stay paired) and stripes
  empty cells where one side has nothing.
- Fix the highlight-truncation bug: long lines used to lose their tinted
  background past the viewport on horizontal scroll because the row width
  was capped to the visible area. Rows now use `min-w-full w-max` so the
  background extends through the full scroll width.

Preview dialog keeps the rendered Tiptap markdown — only the diff reverted
to plain text.

E2E covers both modes and the cell kinds.

* feat(web): formatPreciseTime util for minute-level timestamps

Returns 'just now', 'Xs ago', 'Xm ago' for the first hour, then 'Today, HH:MM',
'Yesterday, HH:MM', or '{Mon} {DD}[, YYYY], HH:MM' for older entries — keeps
exact minute-of-day visible instead of decaying to 'yesterday'/'2d ago'.

* fix(web): render markdown via setContent in plan read-only renderer

The tiptap-markdown extension's parser only fires on `setContent` calls, not
on the initial `content` option. Passing `content: value` to `useEditor` was
treating the markdown source as raw text, so the preview dialog rendered
unparsed markdown.

Drop the `content` option and instead call
`editor.commands.setContent(content, { emitUpdate: false })` from a useEffect
once the editor is created — which is what the live plan editor effectively
relies on through tiptap-markdown's setContent override.

* fix(web): plan history UX — bigger modals, split layout, simpler compare

- Bump dialog widths: preview now `max-w-5xl w-[92vw]`, diff
  `max-w-6xl w-[95vw]`, both with taller max-height. Plenty of room for
  rendered markdown and side-by-side diffs.
- Replace the table-based split layout with a CSS grid (`grid-cols-2 w-max`).
  The table-fixed cells were overflowing into each other when content was
  wider than half the dialog; the grid lets the natural width drive a single
  horizontal scrollbar on the parent and keeps the two columns properly
  separated.
- Switch row timestamps to `formatPreciseTime` so users see minute-of-day
  ("Today, 14:32", "Yesterday, 09:14", "Apr 21, 11:08") instead of decaying
  to "yesterday".
- Simplify the compare flow: drop the per-row Compare toggle, the chip strip
  at the top of the popover, and the standalone "Diff" button. Compare is
  now a side effect of preview — open any version, then click "Compare with
  previous" or "Compare with current". E2E test updated to use the new path.

* test(web): update plan checkpointing e2e for the new compare flow

Drops the locators for the row Compare toggle, chips, and standalone Diff
button (those UI bits are gone). Adds a locator for the new
"Compare with previous" preview button. The compare scenario now opens the
diff via preview → "Compare with current" instead of the old chip flow.

* fix(web): preview renderer reuses live editor's markdown styles

`globals.css` keeps heading sizes / list spacing / code-block padding under
.markdown-body and .tiptap-plan-wrapper, and the live editor opts in by
applying both classes (plus a conditional .dark for theming). The read-only
renderer was using `prose prose-sm` instead, which doesn't actually take
effect on the .ProseMirror node — leaving everything the same size.

Switch the read-only renderer to the same class set, follow the live
editor's `useTheme` -> `dark` pattern, override `!h-auto` so the renderer
sizes to its content, and trim the editor's default 0.75rem padding inside
the dialog body.

* fix(web): split diff has fixed width with synced horizontal scroll

The previous split layout grew the whole dialog wider when content overflowed
which felt wrong. Now:

- The dialog stays at its fixed width.
- Each pane has its own `overflow-x-auto` so each side is capped at half the
  dialog and gets its own horizontal scrollbar.
- A scroll listener keeps both panes' `scrollLeft` in lockstep (with a
  re-entrancy guard so the bounce-back doesn't form a loop) — corresponding
  rows stay aligned visually no matter which side the user scrolls from.
- The diff body's overflow-x is hidden in split mode (the panes own it) and
  only overflow-y stays on the body so vertical scroll covers both panes
  at once.

* feat(backend): resolve agent profile name from session for plan revisions

The MCP-side handlers (handleCreateTaskPlan / handleUpdateTaskPlan) don't
carry the agent's display name in the request, so without a lookup every
agent-authored revision was rendering as the literal "Agent" fallback in
the history UI.

PlanService now looks up the task's session and reads the agent profile
display name from `agent_profile_snapshot` (preferring "label", then
"name", then "agent_display_name") when the request is an agent write
without an explicit author_name. Active session is tried first; falls
back to the most recent session by started_at so plans written between
turns still get the right author name. If no usable session/snapshot
exists, the existing "Agent" fallback applies.

The repo dependency type is widened from `repository.PlanRepository` to a
small private `planRepo` interface that adds the two session lookups —
the SQLite repository (`*sqliterepo.Repository`) already implements both,
so no caller wiring changes.

* fix(web): plan history popover row + restore button polish

- Popover content's default `gap-4` between flex children (from
  @kandev/ui's PopoverContent) was rendering as visible space above the
  top-most row. Override with `gap-0` so the list sits flush against the
  header.
- Switch row layout from `items-start` to `items-center` so the
  Restore button stays vertically centered against the row body even
  when the body grows taller (timestamp + restored-from marker on revert
  rows).
- Rename the per-row "Revert" button to "Restore" and prefix it with
  IconRestore so it matches the dialog wording and the restored-from
  marker icon. The data-testid stays `plan-revision-revert-button` for
  e2e selector stability.

* fix(backend): plan revision review feedback + lint

- `wsGetTaskPlanRevision` now returns a 400 validation error when
  `revision_id` is empty, matching `wsRevertTaskPlan`'s
  `ErrRevisionIDRequired` branch (was silently mapped to 404 via the
  service's lookup miss).
- `agentDisplayNameFromSnapshot` tries `name` first, then `label`, then
  `agent_display_name`. The orchestrator's canonical key wins over any
  stale older `label` that may still be in a session snapshot.
- Add `idx_task_plan_revisions_task_number` on
  `(task_id, revision_number DESC)`. `GetLatestTaskPlanRevision` runs on
  every plan write (coalesce check); the existing `(task_id, created_at)`
  index doesn't help since these queries order by `revision_number`.
- Drop `Deprecated:` from `NextTaskPlanRevisionNumber`'s doc comment so
  staticcheck stops flagging the existing test usage. The TOCTOU caveat
  stays in the prose.

* fix(web): plan revision review feedback, prettier, e2e resilience

- `loadRevisionContent` reads `revisionContentCache` lazily through the
  store API inside the callback, so its identity stays stable. Selecting
  the cache map at the hook level was recreating the callback whenever
  any task's content was cached, retriggering downstream content-fetch
  effects.
- `task.plan.reverted` WS event becomes a no-op handler.
  `task.plan.revision.created` fires for the same row first and already
  upserts; the duplicate handler was double-evicting the
  `revisionContentCache` entry and triggering an extra Zustand update.
- e2e: agent author assertion now uses `not.toHaveText("You")` +
  `not.toBeEmpty()` instead of the literal `"Agent"` (which only
  matched the legacy fallback — agent rows now resolve to the profile
  display name).
- e2e: `openPlanPanel` retries the toggle a few times after a page
  reload — the panel toggle can race hydration and silently no-op,
  which broke the persistence test.
- Apply prettier to the files flagged by the formatting check.

* fix(plan): toast on silent revert failure + revision ownership check

- task-plan-revisions: when `onRevert` (the hook's `revertTo`) swallows
  the error and returns null, the dialog used to close silently with no
  feedback. Add an `else` branch that fires `toast.error("Failed to
  restore plan")`.
- wsGetTaskPlanRevision: accept an optional `task_id` and reject with a
  400 "Revision does not belong to task" when the loaded revision's
  task doesn't match. Mirrors `wsRevertTaskPlan`'s ownership check so a
  WS client can't read content for an arbitrary revision UUID outside
  the tasks it already holds a reference to.
- plan-api `getPlanRevision` takes optional `taskId`; `loadRevisionContent`
  threads the active task id so the check is exercised on the happy path.
This commit is contained in:
José Almeida
2026-04-27 10:22:58 +01:00
committed by GitHub
parent 8948ebdb25
commit 12bd5b2cf3
34 changed files with 4010 additions and 265 deletions
+5 -3
View File
@@ -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
@@ -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)
@@ -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)
+43
View File
@@ -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
@@ -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.
@@ -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))
}
@@ -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"`
@@ -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
}
@@ -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)
}
}
@@ -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 {
@@ -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
}
+365 -112
View File
@@ -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
}
@@ -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)
}
}
+17 -12
View File
@@ -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"
@@ -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 (
<EditorContent
editor={editor}
data-testid={testId}
className={cn(
"tiptap-plan-wrapper markdown-body !h-auto",
resolvedTheme === "dark" && "dark",
"[&_.ProseMirror]:outline-none [&_.ProseMirror]:focus:outline-none",
// Trim the editor's vertical padding for the more compact preview body.
"[&_.tiptap]:!p-0",
className,
)}
/>
);
}
@@ -0,0 +1,447 @@
"use client";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { IconLoader2 } from "@tabler/icons-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@kandev/ui/dialog";
import { Button } from "@kandev/ui/button";
import { ToggleGroup, ToggleGroupItem } from "@kandev/ui/toggle-group";
import { cn } from "@/lib/utils";
import type { TaskPlanRevision } from "@/lib/types/http";
import { lineDiff, diffSummary, type DiffLine, type DiffLineKind } from "./task-plan-diff";
type Props = {
/** Revision pair in arbitrary user-pick order; the dialog re-orders them by
* revision_number so the older one is always the "before" side. */
pair: [TaskPlanRevision | null, TaskPlanRevision | null];
loadContent: (revisionId: string) => Promise<string>;
onClose: () => void;
/** Restore the older revision (the "before" side) — null if the pair isn't
* fully populated. */
onRestoreOlder: (revisionId: string) => void;
};
type DiffMode = "unified" | "split";
const KIND_CLASS: Record<DiffLineKind, string> = {
add: "bg-emerald-500/10 border-l-2 border-emerald-500/60",
remove: "bg-rose-500/10 border-l-2 border-rose-500/60",
context: "border-l-2 border-transparent",
};
const KIND_PREFIX: Record<DiffLineKind, string> = {
add: "+",
remove: "-",
context: " ",
};
export function PlanRevisionDiffDialog({
pair,
loadContent,
onClose,
onRestoreOlder,
}: Props): ReactNode {
const [before, after] = useMemo(() => orderPair(pair), [pair]);
const [mode, setMode] = useState<DiffMode>("unified");
const open = before !== null && after !== null;
const sameRevision = before !== null && after !== null && before.id === after.id;
const title =
before && after ? `Compare v${before.revision_number} → v${after.revision_number}` : "Compare";
return (
<Dialog
open={open}
onOpenChange={(v) => {
if (!v) onClose();
}}
>
<DialogContent
className="!max-w-6xl w-[95vw] max-h-[90vh] flex flex-col"
data-testid="plan-revision-diff-dialog"
>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription className="text-xs">
Line-level diff between the two selected versions.
</DialogDescription>
</DialogHeader>
<DiffBody
before={before}
after={after}
loadContent={loadContent}
mode={mode}
setMode={setMode}
/>
<DialogFooter>
<Button
variant="outline"
onClick={onClose}
className="cursor-pointer"
data-testid="plan-revision-diff-close"
>
Close
</Button>
{before && !sameRevision && (
<Button
onClick={() => onRestoreOlder(before.id)}
className="cursor-pointer"
data-testid="plan-revision-diff-restore"
>
Restore v{before.revision_number}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function DiffBody({
before,
after,
loadContent,
mode,
setMode,
}: {
before: TaskPlanRevision | null;
after: TaskPlanRevision | null;
loadContent: (revisionId: string) => Promise<string>;
mode: DiffMode;
setMode: (m: DiffMode) => void;
}) {
const { beforeContent, afterContent, lines, error } = useDiffContent(before, after, loadContent);
const summary = lines ? diffSummary(lines) : null;
const sameRevision = before !== null && after !== null && before.id === after.id;
return (
<div className="flex flex-col flex-1 min-h-0 gap-2">
<div className="flex items-center justify-between">
<div className="text-[11px] text-muted-foreground" data-testid="plan-revision-diff-summary">
{summary ? `${summary.added} added · ${summary.removed} removed` : "Loading…"}
</div>
<ToggleGroup
type="single"
size="sm"
value={mode}
onValueChange={(v) => {
if (v === "unified" || v === "split") setMode(v);
}}
data-testid="plan-revision-diff-mode-toggle"
>
<ToggleGroupItem
value="unified"
className="text-xs px-2 cursor-pointer"
data-testid="plan-revision-diff-mode-unified"
>
Unified
</ToggleGroupItem>
<ToggleGroupItem
value="split"
className="text-xs px-2 cursor-pointer"
data-testid="plan-revision-diff-mode-split"
>
Split
</ToggleGroupItem>
</ToggleGroup>
</div>
<div
className={cn(
"flex-1 min-h-0 rounded border border-border bg-muted/20 font-mono text-xs",
// Unified scrolls in both axes; split mode owns its own panes' scroll
// and only needs vertical here so its synced horizontal scrollbars
// sit inside the panes (one per side, scroll-linked).
mode === "split" ? "overflow-y-auto overflow-x-hidden" : "overflow-auto",
)}
data-testid="plan-revision-diff-body"
>
<DiffBodyInner
mode={mode}
lines={lines}
beforeContent={beforeContent}
afterContent={afterContent}
error={error}
sameRevision={sameRevision}
summary={summary}
/>
</div>
</div>
);
}
function DiffBodyInner({
mode,
lines,
beforeContent,
afterContent,
error,
sameRevision,
summary,
}: {
mode: DiffMode;
lines: DiffLine[] | null;
beforeContent: string | null;
afterContent: string | null;
error: string | null;
sameRevision: boolean;
summary: { added: number; removed: number } | null;
}) {
if (error) return <div className="p-3 text-destructive">{error}</div>;
if (lines === null) return <DiffLoading />;
if (sameRevision)
return <DiffMessage>These are the same version; nothing to compare.</DiffMessage>;
if (lines.length === 0) return <DiffMessage>(both versions are empty)</DiffMessage>;
if (summary && summary.added === 0 && summary.removed === 0) {
return <DiffMessage>No textual changes between these versions.</DiffMessage>;
}
if (mode === "split") {
return <SplitDiff beforeContent={beforeContent ?? ""} afterContent={afterContent ?? ""} />;
}
return <UnifiedDiff lines={lines} />;
}
function UnifiedDiff({ lines }: { lines: DiffLine[] }) {
return (
// inline-block + min-w-full lets the row backgrounds extend past the
// viewport so highlights stay solid when the user scrolls horizontally.
<ul className="inline-block min-w-full" data-testid="plan-revision-diff-unified">
{lines.map((line, i) => (
<DiffLineRow key={i} line={line} />
))}
</ul>
);
}
function DiffLineRow({ line }: { line: DiffLine }) {
return (
<li
className={cn(
"flex gap-2 px-3 py-0.5 leading-relaxed whitespace-pre min-w-full w-max",
KIND_CLASS[line.kind],
)}
data-testid="plan-revision-diff-line"
data-line-kind={line.kind}
>
<span className="select-none text-muted-foreground w-3 shrink-0">
{KIND_PREFIX[line.kind]}
</span>
<span className="flex-1 break-words">{line.text || " "}</span>
</li>
);
}
/** Side-by-side diff with two equal-width panes that scroll horizontally
* together. The dialog stays at its fixed width; each pane shows up to half
* of it and gets its own horizontal scrollbar for long lines. A scroll
* listener keeps both panes' `scrollLeft` in lockstep so corresponding rows
* stay aligned visually. */
function SplitDiff({
beforeContent,
afterContent,
}: {
beforeContent: string;
afterContent: string;
}) {
const rows = useMemo(
() => alignSplit(beforeContent, afterContent),
[beforeContent, afterContent],
);
const beforeRef = useRef<HTMLDivElement>(null);
const afterRef = useRef<HTMLDivElement>(null);
// Re-entrancy guard: when we set the partner pane's scrollLeft, that fires
// the partner's onScroll, which would bounce back here without this flag.
const syncingRef = useRef(false);
useEffect(() => {
const before = beforeRef.current;
const after = afterRef.current;
if (!before || !after) return;
const sync = (source: HTMLDivElement, target: HTMLDivElement) => () => {
if (syncingRef.current) return;
syncingRef.current = true;
target.scrollLeft = source.scrollLeft;
// Release on the next frame so the bounced onScroll has fired and
// returned early, but we don't permanently disable syncing.
requestAnimationFrame(() => {
syncingRef.current = false;
});
};
const onBefore = sync(before, after);
const onAfter = sync(after, before);
before.addEventListener("scroll", onBefore, { passive: true });
after.addEventListener("scroll", onAfter, { passive: true });
return () => {
before.removeEventListener("scroll", onBefore);
after.removeEventListener("scroll", onAfter);
};
}, [rows]);
return (
<div
className="grid grid-cols-2 divide-x divide-border min-w-0"
data-testid="plan-revision-diff-split"
>
<SplitPane ref={beforeRef} rows={rows} side="before" testIdSuffix="before" />
<SplitPane ref={afterRef} rows={rows} side="after" testIdSuffix="after" />
</div>
);
}
const SplitPane = ({
ref,
rows,
side,
testIdSuffix,
}: {
ref: React.RefObject<HTMLDivElement | null>;
rows: SplitRow[];
side: "before" | "after";
testIdSuffix: string;
}) => (
<div
ref={ref}
className="min-w-0 overflow-x-auto"
data-testid={`plan-revision-diff-split-pane-${testIdSuffix}`}
>
{/* w-max lets each pane's intrinsic width be the longest line on that
* side; combined with the parent grid-cols-2, the visible portion is
* still capped at 50% of the dialog while horizontal scroll reveals
* the rest. */}
<div className="w-max min-w-full">
{rows.map((row, i) => (
<SplitCell key={i} side={side} line={side === "before" ? row.before : row.after} />
))}
</div>
</div>
);
function SplitCell({ side, line }: { side: "before" | "after"; line: DiffLine | null }) {
// Empty cells (a removal on the after side, or an addition on the before
// side) get a subtle striped background so the row alignment is visible.
if (!line) {
return (
<div
className="px-3 py-0.5 leading-relaxed bg-muted/40 border-l-2 border-transparent min-h-[1.25rem]"
data-testid="plan-revision-diff-split-cell"
data-line-kind="empty"
data-side={side}
/>
);
}
return (
<div
className={cn("flex gap-2 px-3 py-0.5 leading-relaxed whitespace-pre", KIND_CLASS[line.kind])}
data-testid="plan-revision-diff-split-cell"
data-line-kind={line.kind}
data-side={side}
>
<span className="select-none text-muted-foreground w-3 shrink-0">
{KIND_PREFIX[line.kind]}
</span>
<span className="flex-1">{line.text || " "}</span>
</div>
);
}
function DiffLoading() {
return (
<div className="flex items-center gap-2 p-3 text-muted-foreground">
<IconLoader2 className="h-3.5 w-3.5 animate-spin" />
Loading…
</div>
);
}
function DiffMessage({ children }: { children: ReactNode }) {
return <div className="p-3 text-muted-foreground italic">{children}</div>;
}
/** Sort a 2-slot pair by revision_number ascending so the older revision is
* always the "before" side, regardless of pick order. */
function orderPair(
pair: [TaskPlanRevision | null, TaskPlanRevision | null],
): [TaskPlanRevision | null, TaskPlanRevision | null] {
const [a, b] = pair;
if (!a || !b) return pair;
return a.revision_number <= b.revision_number ? [a, b] : [b, a];
}
type SplitRow = { before: DiffLine | null; after: DiffLine | null };
/** Align the line-diff into side-by-side rows: each row contains at most one
* before-line and one after-line. Removals render on the left only; adds on
* the right only; context lines align across both sides. Adjacent
* remove/add chunks are zipped together so a single-line edit shows up on the
* same row. */
function alignSplit(beforeContent: string, afterContent: string): SplitRow[] {
const lines = lineDiff(beforeContent, afterContent);
const rows: SplitRow[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (line.kind === "context") {
rows.push({ before: line, after: line });
i++;
continue;
}
// Collect a contiguous remove block, then any adjacent add block.
const removes: DiffLine[] = [];
while (i < lines.length && lines[i].kind === "remove") {
removes.push(lines[i]);
i++;
}
const adds: DiffLine[] = [];
while (i < lines.length && lines[i].kind === "add") {
adds.push(lines[i]);
i++;
}
const max = Math.max(removes.length, adds.length);
for (let k = 0; k < max; k++) {
rows.push({ before: removes[k] ?? null, after: adds[k] ?? null });
}
}
return rows;
}
function useDiffContent(
before: TaskPlanRevision | null,
after: TaskPlanRevision | null,
loadContent: (revisionId: string) => Promise<string>,
): {
beforeContent: string | null;
afterContent: string | null;
lines: DiffLine[] | null;
error: string | null;
} {
const [beforeContent, setBeforeContent] = useState<string | null>(null);
const [afterContent, setAfterContent] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!before || !after) return;
let cancelled = false;
Promise.all([loadContent(before.id), loadContent(after.id)])
.then(([b, a]) => {
if (cancelled) return;
setBeforeContent(b);
setAfterContent(a);
})
.catch((err) => {
if (cancelled) return;
setError(err instanceof Error ? err.message : "Failed to load revision content");
});
return () => {
cancelled = true;
};
}, [before, after, loadContent]);
const lines = useMemo(() => {
if (beforeContent === null || afterContent === null) return null;
return lineDiff(beforeContent, afterContent);
}, [beforeContent, afterContent]);
return { beforeContent, afterContent, lines, error };
}
@@ -0,0 +1,69 @@
import { describe, it, expect } from "vitest";
import { lineDiff, diffSummary } from "./task-plan-diff";
describe("lineDiff", () => {
it("returns all-context for identical strings", () => {
const result = lineDiff("a\nb\nc", "a\nb\nc");
expect(result.map((l) => l.kind)).toEqual(["context", "context", "context"]);
expect(result.map((l) => l.text)).toEqual(["a", "b", "c"]);
});
it("marks added lines", () => {
const result = lineDiff("a\n", "a\nb\n");
expect(result).toEqual([
{ kind: "context", text: "a" },
{ kind: "add", text: "b" },
]);
});
it("marks removed lines", () => {
const result = lineDiff("a\nb\n", "a\n");
expect(result).toEqual([
{ kind: "context", text: "a" },
{ kind: "remove", text: "b" },
]);
});
it("handles a mixed diff", () => {
const result = lineDiff("a\nb\nc\n", "a\nB\nc\n");
expect(result).toEqual([
{ kind: "context", text: "a" },
{ kind: "remove", text: "b" },
{ kind: "add", text: "B" },
{ kind: "context", text: "c" },
]);
});
it("empty before", () => {
const result = lineDiff("", "x\ny\n");
expect(result.map((l) => l.kind)).toEqual(["add", "add"]);
expect(result.map((l) => l.text)).toEqual(["x", "y"]);
});
it("empty after", () => {
const result = lineDiff("x\ny\n", "");
expect(result.map((l) => l.kind)).toEqual(["remove", "remove"]);
});
it("does not emit a trailing empty line", () => {
const result = lineDiff("a\n", "a\n");
expect(result).toHaveLength(1);
expect(result[0]).toEqual({ kind: "context", text: "a" });
});
});
describe("diffSummary", () => {
it("counts added and removed", () => {
const result = diffSummary([
{ kind: "context", text: "a" },
{ kind: "add", text: "b" },
{ kind: "add", text: "c" },
{ kind: "remove", text: "d" },
]);
expect(result).toEqual({ added: 2, removed: 1 });
});
it("zero on no changes", () => {
expect(diffSummary([{ kind: "context", text: "a" }])).toEqual({ added: 0, removed: 0 });
});
});
@@ -0,0 +1,51 @@
import { diffLines } from "diff";
export type DiffLineKind = "add" | "remove" | "context";
export type DiffLine = {
kind: DiffLineKind;
/** Single line of text without the trailing newline. */
text: string;
};
/**
* Line-level diff of two strings in unified order. Each input line maps to one
* `DiffLine`; identical regions are emitted as `context`, lines present only in
* `before` as `remove`, and lines present only in `after` as `add`.
*
* Trailing newlines are normalized away so a final blank line doesn't show up
* as a phantom diff entry.
*/
export function lineDiff(before: string, after: string): DiffLine[] {
const out: DiffLine[] = [];
for (const part of diffLines(before, after)) {
const kind = partKind(part);
// Each part.value can contain multiple lines; split and drop a trailing
// empty string from the inevitable final "\n".
const lines = part.value.split("\n");
if (lines.length > 0 && lines[lines.length - 1] === "") {
lines.pop();
}
for (const text of lines) {
out.push({ kind, text });
}
}
return out;
}
function partKind(part: { added?: boolean; removed?: boolean }): DiffLineKind {
if (part.added) return "add";
if (part.removed) return "remove";
return "context";
}
/** Counts of added and removed lines in a diff sequence. */
export function diffSummary(lines: DiffLine[]): { added: number; removed: number } {
let added = 0;
let removed = 0;
for (const l of lines) {
if (l.kind === "add") added++;
else if (l.kind === "remove") removed++;
}
return { added, removed };
}
+140 -111
View File
@@ -1,7 +1,8 @@
"use client";
import { memo, useState, useCallback, useEffect, useRef, useMemo } from "react";
import { PanelRoot, PanelBody } from "./panel-primitives";
import { PanelRoot, PanelBody, PanelHeaderBarSplit } from "./panel-primitives";
import { TaskPlanRevisions } from "./task-plan-revisions";
import dynamic from "next/dynamic";
import { IconLoader2, IconFileText, IconRobot, IconMessage, IconClick } from "@tabler/icons-react";
import { cn } from "@/lib/utils";
@@ -15,10 +16,10 @@ import type {
TextSelection,
CommentForEditor,
} from "@/components/editors/tiptap/tiptap-plan-editor";
import type { TaskPlanRevision } from "@/lib/types/http";
import type { Editor } from "@tiptap/core";
import { PanelSearchBar } from "@/components/search/panel-search-bar";
import { usePanelSearch } from "@/hooks/use-panel-search";
import { planSearchPluginKey } from "@/components/editors/tiptap/search-highlight-extension";
import { usePlanFindShortcut } from "./use-plan-find-shortcut";
// Dynamic import to avoid SSR issues with TipTap
const PlanEditor = dynamic(
@@ -43,7 +44,22 @@ type TaskPlanPanelProps = {
};
function useTaskPlanPanelState(taskId: string | null, visible: boolean) {
const { plan, isLoading, isSaving, savePlan } = useTaskPlan(taskId, { visible });
const {
plan,
isLoading,
isSaving,
savePlan,
revisions,
isLoadingRevisions,
loadRevisions,
loadRevisionContent,
revertTo,
previewRevisionId,
setPreviewRevision,
comparePair,
toggleCompareSelection,
clearComparePair,
} = useTaskPlan(taskId, { visible });
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
const activeSession = useAppStore((state) =>
activeSessionId ? (state.taskSessions.items[activeSessionId] ?? null) : null,
@@ -114,6 +130,16 @@ function useTaskPlanPanelState(taskId: string | null, visible: boolean) {
editorWrapperRef,
editorInstanceRef,
editorInstance,
revisions,
isLoadingRevisions,
loadRevisions,
loadRevisionContent,
revertTo,
previewRevisionId,
setPreviewRevision,
comparePair,
toggleCompareSelection,
clearComparePair,
};
}
@@ -122,37 +148,16 @@ export const TaskPlanPanel = memo(function TaskPlanPanel({
visible = true,
}: TaskPlanPanelProps) {
const state = useTaskPlanPanelState(taskId, visible);
const {
plan,
isLoading,
isSaving,
savePlan,
activeSessionId,
draftContent,
setDraftContent,
editorKey,
isEditorFocused,
handleEmptyStateClick,
hasUnsavedChanges,
commentState,
selectionState,
handleEditorReady,
handleCommentDeleted,
commentHighlights,
isAgentBusy,
isAgentCreatingPlan,
editorWrapperRef,
editorInstanceRef,
editorInstance,
} = state;
// Ctrl+S to save immediately
useSaveShortcut(hasUnsavedChanges, isSaving, savePlan, draftContent, plan?.title);
useSaveShortcut(
state.hasUnsavedChanges,
state.isSaving,
state.savePlan,
state.draftContent,
state.plan?.title,
);
// Ctrl+F in-document find
const planSearch = usePlanFindShortcut(editorWrapperRef, editorInstance);
if (isLoading) {
if (state.isLoading) {
return (
<div className="flex h-full items-center justify-center text-muted-foreground">
<IconLoader2 className="h-5 w-5 animate-spin mr-2" />
@@ -169,35 +174,64 @@ export const TaskPlanPanel = memo(function TaskPlanPanel({
);
}
const { textSelection, setTextSelection } = selectionState;
return <PlanPanelContent taskId={taskId} state={state} />;
});
function PlanPanelContent({
taskId,
state,
}: {
taskId: string;
state: ReturnType<typeof useTaskPlanPanelState>;
}) {
const { editorWrapperRef, editorInstanceRef, editorInstance, selectionState } = state;
const { textSelection, setTextSelection } = selectionState;
// Ctrl+F in-document find (registers a keydown listener on the editor wrapper)
const planSearch = usePlanFindShortcut(editorWrapperRef, editorInstance);
return (
<PanelRoot data-testid="plan-panel">
<PlanPanelHeader
taskId={taskId}
revisions={state.revisions}
isLoadingRevisions={state.isLoadingRevisions}
isSaving={state.isSaving}
onOpenRevisions={state.loadRevisions}
onRevert={state.revertTo}
loadRevisionContent={state.loadRevisionContent}
previewRevisionId={state.previewRevisionId}
setPreviewRevision={state.setPreviewRevision}
comparePair={state.comparePair}
toggleCompareSelection={state.toggleCompareSelection}
clearComparePair={state.clearComparePair}
/>
<PanelBody
padding={false}
scroll={false}
className={cn("relative transition-colors cursor-text", isAgentBusy && "bg-background")}
className={cn(
"relative transition-colors cursor-text",
state.isAgentBusy && "bg-background",
)}
ref={editorWrapperRef}
onClick={handleEmptyStateClick}
onClick={state.handleEmptyStateClick}
data-panel-kind="plan"
>
<PlanEditor
key={`${taskId}-${editorKey}`}
value={draftContent}
onChange={setDraftContent}
key={`${taskId}-${state.editorKey}`}
value={state.draftContent}
onChange={state.setDraftContent}
placeholder="Start typing your plan..."
onSelectionChange={activeSessionId ? setTextSelection : undefined}
comments={commentHighlights}
onSelectionChange={state.activeSessionId ? setTextSelection : undefined}
comments={state.commentHighlights}
onCommentClick={selectionState.handleCommentHighlightClick}
onCommentDeleted={handleCommentDeleted}
onEditorReady={handleEditorReady}
onCommentDeleted={state.handleCommentDeleted}
onEditorReady={state.handleEditorReady}
/>
<PlanEmptyState
isLoading={isLoading}
draftContent={draftContent}
isEditorFocused={isEditorFocused}
isAgentCreatingPlan={isAgentCreatingPlan}
onClick={handleEmptyStateClick}
isLoading={state.isLoading}
draftContent={state.draftContent}
isEditorFocused={state.isEditorFocused}
isAgentCreatingPlan={state.isAgentCreatingPlan}
onClick={state.handleEmptyStateClick}
/>
{planSearch.isOpen && (
<PanelSearchBar
@@ -213,15 +247,15 @@ export const TaskPlanPanel = memo(function TaskPlanPanel({
<PlanSelectionPopoverWrapper
textSelection={textSelection}
activeSessionId={activeSessionId}
activeSessionId={state.activeSessionId}
taskId={taskId}
commentState={commentState}
commentState={state.commentState}
editorRef={editorInstanceRef}
onClose={selectionState.handleSelectionClose}
/>
</PanelRoot>
);
});
}
function removeCommentMark(editor: Editor | null, commentId: string) {
if (!editor) return;
@@ -441,66 +475,6 @@ function usePlanSelection(
return { textSelection, setTextSelection, handleCommentHighlightClick, handleSelectionClose };
}
/** Ctrl+F find-in-plan shortcut + editor wiring */
function usePlanFindShortcut(
wrapperRef: React.RefObject<HTMLDivElement | null>,
editor: Editor | null,
) {
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState("");
const [matchInfo, setMatchInfo] = useState({ current: 0, total: 0 });
const readMatchInfo = useCallback(() => {
if (!editor) return;
const s = planSearchPluginKey.getState(editor.state);
if (!s) return;
const next = {
current: s.matches.length ? s.current + 1 : 0,
total: s.matches.length,
};
// Bail out when nothing changed — TipTap fires `transaction` on every cursor
// move, and allocating a new object identity would re-render this hook on
// every keystroke / click in the editor (and can cascade into update loops).
setMatchInfo((prev) =>
prev.current === next.current && prev.total === next.total ? prev : next,
);
}, [editor]);
useEffect(() => {
if (!editor) return;
const handler = () => readMatchInfo();
editor.on("transaction", handler);
return () => {
editor.off("transaction", handler);
};
}, [editor, readMatchInfo]);
useEffect(() => {
if (!editor) return;
if (!isOpen) return;
editor.commands.setPlanSearchQuery(query);
}, [query, isOpen, editor]);
useEffect(() => {
if (isOpen) return;
if (!editor) return;
editor.commands.clearPlanSearch();
}, [isOpen, editor]);
const open = useCallback(() => setIsOpen(true), []);
const close = useCallback(() => setIsOpen(false), []);
const findNext = useCallback(() => {
editor?.commands.planSearchNext();
}, [editor]);
const findPrev = useCallback(() => {
editor?.commands.planSearchPrev();
}, [editor]);
usePanelSearch({ containerRef: wrapperRef, isOpen, onOpen: open, onClose: close });
return { isOpen, open, close, query, setQuery, findNext, findPrev, matchInfo };
}
/** Ctrl+S save shortcut */
function useSaveShortcut(
hasUnsavedChanges: boolean,
@@ -578,3 +552,58 @@ function PlanEmptyState({
</div>
);
}
type PlanPanelHeaderProps = {
taskId: string;
revisions: TaskPlanRevision[];
isLoadingRevisions: boolean;
isSaving: boolean;
onOpenRevisions: () => void;
onRevert: (id: string) => Promise<TaskPlanRevision | null>;
loadRevisionContent: (revisionId: string) => Promise<string>;
previewRevisionId: string | null;
setPreviewRevision: (revisionId: string | null) => void;
comparePair: [string | null, string | null];
toggleCompareSelection: (revisionId: string) => void;
clearComparePair: () => void;
};
// Header bar lives only to host the rewind button. The plan's title (default
// "Plan") is already shown by the dockview/mobile tab above the panel, so we
// don't repeat it here.
function PlanPanelHeader({
taskId,
revisions,
isLoadingRevisions,
isSaving,
onOpenRevisions,
onRevert,
loadRevisionContent,
previewRevisionId,
setPreviewRevision,
comparePair,
toggleCompareSelection,
clearComparePair,
}: PlanPanelHeaderProps) {
return (
<PanelHeaderBarSplit
left={null}
right={
<TaskPlanRevisions
taskId={taskId}
revisions={revisions}
isLoading={isLoadingRevisions}
isSaving={isSaving}
onOpen={onOpenRevisions}
onRevert={onRevert}
loadRevisionContent={loadRevisionContent}
previewRevisionId={previewRevisionId}
setPreviewRevision={setPreviewRevision}
comparePair={comparePair}
toggleCompareSelection={toggleCompareSelection}
clearComparePair={clearComparePair}
/>
}
/>
);
}
@@ -0,0 +1,188 @@
"use client";
import dynamic from "next/dynamic";
import { useEffect, useState, type ReactNode } from "react";
import { IconLoader2, IconRestore } from "@tabler/icons-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@kandev/ui/dialog";
import { Button } from "@kandev/ui/button";
import { Badge } from "@kandev/ui/badge";
import type { TaskPlanRevision } from "@/lib/types/http";
import { formatPreciseTime } from "@/lib/utils";
const PlanReadOnlyMarkdown = dynamic(
() =>
import("@/components/editors/tiptap/tiptap-plan-readonly").then(
(mod) => mod.PlanReadOnlyMarkdown,
),
{ ssr: false },
);
type Props = {
revision: TaskPlanRevision | null;
authorLabel: string;
loadContent: (revisionId: string) => Promise<string>;
onClose: () => void;
onRestore: () => void;
/** Open the diff dialog comparing the previewed revision with the previous
* revision (vN-1). Disabled if the previewed one is the oldest. */
onCompareWithPrevious: (() => void) | null;
/** Open the diff dialog comparing the previewed revision with current HEAD. */
onCompareWithCurrent: () => void;
/** True when the revision is the current HEAD — Restore + Compare-with-current
* CTAs hide. */
isCurrent: boolean;
};
/** Read-only modal preview of a single plan revision. The parent should give
* this component a `key` derived from the revision id so it remounts (and
* resets its loader state) when the user opens a different revision. */
export function PlanRevisionPreviewDialog({
revision,
authorLabel,
loadContent,
onClose,
onRestore,
onCompareWithPrevious,
onCompareWithCurrent,
isCurrent,
}: Props): ReactNode {
const open = revision !== null;
return (
<Dialog
open={open}
onOpenChange={(v) => {
if (!v) onClose();
}}
>
<DialogContent
className="!max-w-5xl w-[92vw] max-h-[88vh] flex flex-col"
data-testid="plan-revision-preview-dialog"
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<span>Version {revision?.revision_number}</span>
{isCurrent && (
<Badge variant="secondary" className="h-4 text-[10px] px-1.5">
current
</Badge>
)}
</DialogTitle>
<DialogDescription className="flex items-center gap-2 text-xs">
<span>{authorLabel}</span>
<span>·</span>
<span>{revision ? formatPreciseTime(revision.updated_at) : ""}</span>
{revision?.revert_of_revision_id && (
<span className="flex items-center gap-1 text-muted-foreground">
<IconRestore className="h-3 w-3" /> restored from earlier version
</span>
)}
</DialogDescription>
</DialogHeader>
<PreviewBody revision={revision} loadContent={loadContent} />
<DialogFooter>
<Button
variant="outline"
onClick={onClose}
className="cursor-pointer"
data-testid="plan-revision-preview-close"
>
Close
</Button>
{!isCurrent && revision && (
<>
{onCompareWithPrevious && (
<Button
variant="outline"
onClick={onCompareWithPrevious}
className="cursor-pointer"
data-testid="plan-revision-preview-compare-with-previous"
>
Compare with previous
</Button>
)}
<Button
variant="outline"
onClick={onCompareWithCurrent}
className="cursor-pointer"
data-testid="plan-revision-preview-compare-with-current"
>
Compare with current
</Button>
<Button
onClick={onRestore}
className="cursor-pointer"
data-testid="plan-revision-preview-restore"
>
Restore this version
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function PreviewBody({
revision,
loadContent,
}: {
revision: TaskPlanRevision | null;
loadContent: (revisionId: string) => Promise<string>;
}) {
const [content, setContent] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!revision) return;
let cancelled = false;
loadContent(revision.id)
.then((c) => {
if (!cancelled) setContent(c);
})
.catch((err) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : "Failed to load revision");
}
});
return () => {
cancelled = true;
};
}, [revision, loadContent]);
return (
<div
className="flex-1 min-h-0 overflow-y-auto rounded border border-border bg-card p-4"
data-testid="plan-revision-preview-body"
>
<PreviewBodyInner content={content} error={error} />
</div>
);
}
function PreviewBodyInner({ content, error }: { content: string | null; error: string | null }) {
if (error) {
return <div className="text-destructive text-xs">{error}</div>;
}
if (content === null) {
return (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<IconLoader2 className="h-3.5 w-3.5 animate-spin" />
Loading…
</div>
);
}
if (content.trim() === "") {
return <div className="text-xs text-muted-foreground italic">(empty plan)</div>;
}
return <PlanReadOnlyMarkdown content={content} />;
}
@@ -0,0 +1,522 @@
"use client";
import { useState, useCallback, useEffect, useMemo, useRef, type ReactNode } from "react";
import { IconHistory, IconUser, IconRestore, IconLoader2 } from "@tabler/icons-react";
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
import { Button } from "@kandev/ui/button";
import { Badge } from "@kandev/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@kandev/ui/dialog";
import { toast } from "sonner";
import type { TaskPlanRevision } from "@/lib/types/http";
import { formatPreciseTime } from "@/lib/utils";
import { AgentLogo } from "@/components/agent-logo";
import { useAppStore } from "@/components/state-provider";
import { PlanRevisionPreviewDialog } from "./task-plan-preview-dialog";
import { PlanRevisionDiffDialog } from "./task-plan-diff-dialog";
type ComparePair = [string | null, string | null];
type TaskPlanRevisionsProps = {
taskId: string;
revisions: TaskPlanRevision[];
isLoading: boolean;
isSaving: boolean;
onOpen: () => void;
onRevert: (revisionId: string) => Promise<TaskPlanRevision | null>;
loadRevisionContent: (revisionId: string) => Promise<string>;
previewRevisionId: string | null;
setPreviewRevision: (revisionId: string | null) => void;
/** comparePair / toggleCompareSelection / clearComparePair are still threaded
* through so the diff dialog can carry an arbitrary pair, but the popover
* UI no longer surfaces a selection mode — pairs are seeded from inside the
* preview dialog ("Compare with previous" / "Compare with current"). */
comparePair: ComparePair;
toggleCompareSelection: (revisionId: string) => void;
clearComparePair: () => void;
disabled?: boolean;
};
/** Label rendered in the UI for any user-authored revision. Display-only —
* the backend still stamps its own author_name on the row. */
const USER_DISPLAY_NAME = "You";
export function TaskPlanRevisions(props: TaskPlanRevisionsProps) {
const [open, setOpen] = useState(false);
const [confirmTarget, setConfirmTarget] = useState<TaskPlanRevision | null>(null);
const [diffOpen, setDiffOpen] = useState(false);
const agentName = useActiveAgentBackendName();
const handleOpenChange = useTriggerOnFirstOpen(setOpen, props.onOpen);
const previewRevision = useMemo(
() => props.revisions.find((r) => r.id === props.previewRevisionId) ?? null,
[props.revisions, props.previewRevisionId],
);
const compareRevisions = useMemo<[TaskPlanRevision | null, TaskPlanRevision | null]>(
() => [
props.revisions.find((r) => r.id === props.comparePair[0]) ?? null,
props.revisions.find((r) => r.id === props.comparePair[1]) ?? null,
],
[props.revisions, props.comparePair],
);
const headRevision = props.revisions[0] ?? null;
return (
<>
<RevisionsPopover
open={open}
onOpenChange={handleOpenChange}
agentName={agentName}
confirmTargetSetter={setConfirmTarget}
{...props}
/>
<RevisionsDialogStack
revisions={props.revisions}
previewRevision={previewRevision}
compareRevisions={compareRevisions}
diffOpen={diffOpen}
confirmTarget={confirmTarget}
isSaving={props.isSaving}
loadRevisionContent={props.loadRevisionContent}
headRevision={headRevision}
onRevert={props.onRevert}
setConfirmTarget={setConfirmTarget}
setDiffOpen={setDiffOpen}
setPreviewRevision={props.setPreviewRevision}
clearComparePair={props.clearComparePair}
toggleCompareSelection={props.toggleCompareSelection}
closePopover={() => setOpen(false)}
/>
</>
);
}
/** Lazy-load revisions on the first popover open; subsequent opens reuse the
* cached list. Wraps the open setter so callers don't need a ref of their own. */
function useTriggerOnFirstOpen(setOpen: (v: boolean) => void, onOpen: () => void) {
const loadedRef = useRef(false);
return useCallback(
(next: boolean) => {
setOpen(next);
if (next && !loadedRef.current) {
loadedRef.current = true;
onOpen();
}
},
[setOpen, onOpen],
);
}
function RevisionsPopover({
open,
onOpenChange,
agentName,
confirmTargetSetter,
revisions,
isLoading,
setPreviewRevision,
disabled = false,
}: TaskPlanRevisionsProps & {
open: boolean;
onOpenChange: (next: boolean) => void;
agentName: string | null;
confirmTargetSetter: (rev: TaskPlanRevision | null) => void;
}) {
const hasRevisions = revisions.length > 0;
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 cursor-pointer"
disabled={disabled || !hasRevisions}
data-testid="plan-rewind-button"
title="View plan history"
>
<IconHistory className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent
align="end"
// Override the popover's default `gap-4` between flex children — it
// was rendering as visible empty space above the top-most row.
className="w-96 p-0 gap-0"
data-testid="plan-revisions-popover"
>
<div className="flex items-center justify-between px-3 py-2 border-b">
<span className="text-sm font-medium">Plan history</span>
{isLoading && <IconLoader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />}
</div>
{/* Plain overflow-y-auto is more reliable than ScrollArea inside a Popover
* — the popover doesn't constrain its child by default, so a fixed
* max-height with native scroll keeps the list bounded. */}
<div className="max-h-96 overflow-y-auto">
<RevisionList
revisions={revisions}
isLoading={isLoading}
agentName={agentName}
onRevertClick={confirmTargetSetter}
onRowClick={(rev) => setPreviewRevision(rev.id)}
/>
</div>
</PopoverContent>
</Popover>
);
}
type DialogStackProps = {
revisions: TaskPlanRevision[];
previewRevision: TaskPlanRevision | null;
compareRevisions: [TaskPlanRevision | null, TaskPlanRevision | null];
diffOpen: boolean;
confirmTarget: TaskPlanRevision | null;
isSaving: boolean;
loadRevisionContent: (revisionId: string) => Promise<string>;
headRevision: TaskPlanRevision | null;
onRevert: (revisionId: string) => Promise<TaskPlanRevision | null>;
setConfirmTarget: (rev: TaskPlanRevision | null) => void;
setDiffOpen: (v: boolean) => void;
setPreviewRevision: (id: string | null) => void;
clearComparePair: () => void;
toggleCompareSelection: (id: string) => void;
closePopover: () => void;
};
function RevisionsDialogStack({
revisions,
previewRevision,
compareRevisions,
diffOpen,
confirmTarget,
isSaving,
loadRevisionContent,
headRevision,
onRevert,
setConfirmTarget,
setDiffOpen,
setPreviewRevision,
clearComparePair,
toggleCompareSelection,
closePopover,
}: DialogStackProps) {
const isPreviewCurrent = previewRevision !== null && previewRevision.id === headRevision?.id;
const previousRevision = useMemo(() => {
if (!previewRevision) return null;
// revisions are sorted newest-first by revision_number; previous = the
// first entry whose revision_number is strictly less than the previewed one.
return revisions.find((r) => r.revision_number < previewRevision.revision_number) ?? null;
}, [revisions, previewRevision]);
const handleRevert = useCallback(
async (revision: TaskPlanRevision) => {
try {
const result = await onRevert(revision.id);
if (result) {
toast.success(`Plan restored to v${revision.revision_number}`);
closePopover();
setPreviewRevision(null);
setDiffOpen(false);
} else {
// `revertTo` (the hook impl) swallows errors and returns null, so
// we surface the failure here too — without this branch the dialog
// closes silently on failure and the user has no feedback.
toast.error("Failed to restore plan");
}
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to restore plan");
}
},
[onRevert, setPreviewRevision, setDiffOpen, closePopover],
);
const seedComparePair = useCallback(
(a: TaskPlanRevision, b: TaskPlanRevision) => {
// Replace any existing pair with [a, b] so the diff opens immediately.
clearComparePair();
toggleCompareSelection(a.id);
if (a.id !== b.id) toggleCompareSelection(b.id);
setPreviewRevision(null);
setDiffOpen(true);
},
[clearComparePair, toggleCompareSelection, setPreviewRevision, setDiffOpen],
);
const onCompareWithCurrent = useCallback(() => {
if (!previewRevision || !headRevision) return;
seedComparePair(previewRevision, headRevision);
}, [previewRevision, headRevision, seedComparePair]);
const onCompareWithPrevious = useMemo(() => {
if (!previewRevision || !previousRevision) return null;
return () => seedComparePair(previousRevision, previewRevision);
}, [previewRevision, previousRevision, seedComparePair]);
return (
<>
<PlanRevisionPreviewDialog
// Remount on revision change so loader state resets without setState-in-effect.
key={`preview-${previewRevision?.id ?? "none"}`}
revision={previewRevision}
authorLabel={previewRevision ? authorLabel(previewRevision) : ""}
loadContent={loadRevisionContent}
onClose={() => setPreviewRevision(null)}
onRestore={() => {
if (previewRevision) setConfirmTarget(previewRevision);
}}
onCompareWithPrevious={onCompareWithPrevious}
onCompareWithCurrent={onCompareWithCurrent}
isCurrent={isPreviewCurrent}
/>
<PlanRevisionDiffDialog
// Remount on pair change so the diff loader resets cleanly.
key={`diff-${compareRevisions[0]?.id ?? ""}-${compareRevisions[1]?.id ?? ""}`}
pair={diffOpen ? compareRevisions : [null, null]}
loadContent={loadRevisionContent}
onClose={() => setDiffOpen(false)}
onRestoreOlder={(revisionId) => {
const rev = revisions.find((r) => r.id === revisionId);
if (rev) setConfirmTarget(rev);
}}
/>
<RevertConfirmDialog
target={confirmTarget}
isSaving={isSaving}
onCancel={() => setConfirmTarget(null)}
onConfirm={async () => {
if (!confirmTarget) return;
try {
await handleRevert(confirmTarget);
} finally {
setConfirmTarget(null);
}
}}
/>
</>
);
}
/** Display label for an author. User revisions always render as "You";
* agent revisions use the stored author_name (the agent profile display name). */
function authorLabel(revision: TaskPlanRevision): string {
if (revision.author_kind === "user") return USER_DISPLAY_NAME;
return revision.author_name || "Agent";
}
/** Resolve the active session's agent backend name (e.g. "claude", "codex") so
* agent revisions can render the same logo we use elsewhere. Returns null when
* there is no active session or its snapshot doesn't carry an agent_name. */
function useActiveAgentBackendName(): string | null {
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
const snapshot = useAppStore((state) =>
activeSessionId ? state.taskSessions.items[activeSessionId]?.agent_profile_snapshot : null,
);
return useMemo(() => {
const name = (snapshot as { agent_name?: unknown } | null | undefined)?.agent_name;
return typeof name === "string" && name.length > 0 ? name : null;
}, [snapshot]);
}
function RevisionList({
revisions,
isLoading,
agentName,
onRevertClick,
onRowClick,
}: {
revisions: TaskPlanRevision[];
isLoading: boolean;
agentName: string | null;
onRevertClick: (rev: TaskPlanRevision) => void;
onRowClick: (rev: TaskPlanRevision) => void;
}) {
if (revisions.length === 0 && !isLoading) {
return (
<div className="px-3 py-6 text-xs text-muted-foreground text-center">
No revisions yet. Edits will appear here.
</div>
);
}
return (
<ul className="divide-y">
{revisions.map((rev, i) => (
<RevisionRow
key={rev.id}
revision={rev}
isCurrent={i === 0}
agentName={agentName}
onRevertClick={onRevertClick}
onRowClick={onRowClick}
/>
))}
</ul>
);
}
function RevisionAuthor({
revision,
agentName,
}: {
revision: TaskPlanRevision;
agentName: string | null;
}) {
if (revision.author_kind === "agent") {
return (
<>
{agentName ? <AgentLogo agentName={agentName} size={14} className="shrink-0" /> : null}
<span className="text-xs text-foreground truncate" data-testid="plan-revision-author">
{revision.author_name}
</span>
</>
);
}
return (
<>
<IconUser className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="text-xs text-foreground truncate" data-testid="plan-revision-author">
{USER_DISPLAY_NAME}
</span>
</>
);
}
function RevisionRow({
revision,
isCurrent,
agentName,
onRevertClick,
onRowClick,
}: {
revision: TaskPlanRevision;
isCurrent: boolean;
agentName: string | null;
onRevertClick: (rev: TaskPlanRevision) => void;
onRowClick: (rev: TaskPlanRevision) => void;
}) {
// Force re-render every 30s so the precise timestamp ("5m ago", "Today,
// 14:32", …) refreshes as the revision ages — `formatPreciseTime` derives
// from `revision.updated_at` on every render.
const [, setTick] = useState(0);
useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), 30_000);
return () => clearInterval(id);
}, []);
const timestamp = formatPreciseTime(revision.updated_at);
return (
<li
// items-center keeps the Restore button vertically centered against the
// row body, even when the body grows taller (timestamp + restored-from
// marker on revert rows).
className="px-3 py-2.5 flex items-center gap-3 hover:bg-accent/30"
data-testid="plan-revision-row"
data-revision-id={revision.id}
data-revision-number={revision.revision_number}
>
<button
type="button"
onClick={() => onRowClick(revision)}
className="flex-1 min-w-0 text-left cursor-pointer"
data-testid="plan-revision-row-body"
>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-semibold">v{revision.revision_number}</span>
<RevisionAuthor revision={revision} agentName={agentName} />
{isCurrent && (
<Badge
variant="secondary"
className="h-4 text-[10px] px-1.5"
data-testid="plan-revision-current-badge"
>
current
</Badge>
)}
</div>
<div className="text-[11px] text-muted-foreground mt-1" data-testid="plan-revision-time">
{timestamp}
</div>
{revision.revert_of_revision_id && (
<div
className="text-[11px] text-muted-foreground mt-1 flex items-center gap-1"
data-testid="plan-revision-revert-marker"
>
<IconRestore className="h-3 w-3" />
restored from earlier version
</div>
)}
</button>
{!isCurrent && (
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-xs cursor-pointer shrink-0 gap-1"
onClick={(e) => {
e.stopPropagation();
onRevertClick(revision);
}}
data-testid="plan-revision-revert-button"
>
<IconRestore className="h-3.5 w-3.5" />
Restore
</Button>
)}
</li>
);
}
function RevertConfirmDialog({
target,
isSaving,
onCancel,
onConfirm,
}: {
target: TaskPlanRevision | null;
isSaving: boolean;
onCancel: () => void;
onConfirm: () => void | Promise<void>;
}): ReactNode {
const open = target !== null;
return (
<Dialog
open={open}
onOpenChange={(v) => {
if (!v) onCancel();
}}
>
<DialogContent data-testid="plan-revert-confirm-dialog">
<DialogHeader>
<DialogTitle>Restore to version {target?.revision_number}?</DialogTitle>
<DialogDescription>
This creates a new version with v{target?.revision_number}&#39;s content. Nothing is
lost; the current version stays in history.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={onCancel}
disabled={isSaving}
className="cursor-pointer"
data-testid="plan-revert-confirm-cancel"
>
Cancel
</Button>
<Button
onClick={onConfirm}
disabled={isSaving}
className="cursor-pointer"
data-testid="plan-revert-confirm-ok"
>
{isSaving ? "Restoring..." : "Restore"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,67 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import type { Editor } from "@tiptap/core";
import { usePanelSearch } from "@/hooks/use-panel-search";
import { planSearchPluginKey } from "@/components/editors/tiptap/search-highlight-extension";
/** Ctrl+F find-in-plan shortcut + editor wiring. Returns the search-bar state
* the panel renders into `<PanelSearchBar>`. */
export function usePlanFindShortcut(
wrapperRef: React.RefObject<HTMLDivElement | null>,
editor: Editor | null,
) {
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState("");
const [matchInfo, setMatchInfo] = useState({ current: 0, total: 0 });
const readMatchInfo = useCallback(() => {
if (!editor) return;
const s = planSearchPluginKey.getState(editor.state);
if (!s) return;
const next = {
current: s.matches.length ? s.current + 1 : 0,
total: s.matches.length,
};
// Bail out when nothing changed — TipTap fires `transaction` on every cursor
// move, and allocating a new object identity would re-render this hook on
// every keystroke / click in the editor (and can cascade into update loops).
setMatchInfo((prev) =>
prev.current === next.current && prev.total === next.total ? prev : next,
);
}, [editor]);
useEffect(() => {
if (!editor) return;
const handler = () => readMatchInfo();
editor.on("transaction", handler);
return () => {
editor.off("transaction", handler);
};
}, [editor, readMatchInfo]);
useEffect(() => {
if (!editor) return;
if (!isOpen) return;
editor.commands.setPlanSearchQuery(query);
}, [query, isOpen, editor]);
useEffect(() => {
if (isOpen) return;
if (!editor) return;
editor.commands.clearPlanSearch();
}, [isOpen, editor]);
const open = useCallback(() => setIsOpen(true), []);
const close = useCallback(() => setIsOpen(false), []);
const findNext = useCallback(() => {
editor?.commands.planSearchNext();
}, [editor]);
const findPrev = useCallback(() => {
editor?.commands.planSearchPrev();
}, [editor]);
usePanelSearch({ containerRef: wrapperRef, isOpen, onOpen: open, onClose: close });
return { isOpen, open, close, query, setQuery, findNext, findPrev, matchInfo };
}
+2
View File
@@ -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",
+130
View File
@@ -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<void> {
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<void> {
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<void> {
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. */
@@ -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);
});
});
+142 -2
View File
@@ -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<string> => {
// 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<TaskPlanRevision | null> => {
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,
};
}
+55 -1
View File
@@ -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<void> {
}
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<TaskPlanRevision[]> {
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<TaskPlanRevision> {
const client = getWebSocketClient();
if (!client) {
throw new Error(WS_CLIENT_UNAVAILABLE);
}
const payload: Record<string, string> = { 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<TaskPlanRevision> {
const client = getWebSocketClient();
if (!client) {
throw new Error(WS_CLIENT_UNAVAILABLE);
}
const payload: Record<string, string> = {
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;
}
@@ -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<SessionSlice["setPlanRevisions"]>[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<SessionSlice["upsertPlanRevision"]>[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]],
+23 -1
View File
@@ -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<string, Message[]>;
@@ -62,11 +62,24 @@ export type ActiveModelState = {
bySessionId: Record<string, string>;
};
/** 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<string, TaskPlan | null>;
loadingByTaskId: Record<string, boolean>;
loadedByTaskId: Record<string, boolean>;
savingByTaskId: Record<string, boolean>;
revisionsByTaskId: Record<string, TaskPlanRevision[]>;
revisionsLoadingByTaskId: Record<string, boolean>;
revisionsLoadedByTaskId: Record<string, boolean>;
revisionContentCache: Record<string, string>; // revisionId -> content
// Phase 6: preview + compare state
previewRevisionIdByTaskId: Record<string, string | null>;
comparePairByTaskId: Record<string, ComparePair>;
// 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<string, string>;
};
@@ -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;
+15
View File
@@ -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;
+20
View File
@@ -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",
+15
View File
@@ -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;
+40
View File
@@ -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.
+28
View File
@@ -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<AppState>, message: PlanMessage) {
const { task_id, id, title, content, created_by, created_at, updated_at } = message.payload;
@@ -28,6 +32,22 @@ function handlePlanUpsert(store: StoreApi<AppState>, message: PlanMessage) {
}
}
function handleRevisionPush(store: StoreApi<AppState>, 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<AppState>): WsHandlers {
return {
"task.plan.created": (message) => handlePlanUpsert(store, message),
@@ -41,5 +61,13 @@ export function registerTaskPlansHandlers(store: StoreApi<AppState>): 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": () => {},
};
}