12bd5b2cf3
* 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.
610 lines
19 KiB
TypeScript
610 lines
19 KiB
TypeScript
"use client";
|
|
|
|
import { memo, useState, useCallback, useEffect, useRef, useMemo } from "react";
|
|
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";
|
|
import { useTaskPlan } from "@/hooks/domains/session/use-task-plan";
|
|
import { useAppStore } from "@/components/state-provider";
|
|
import { PlanSelectionPopover } from "./plan-selection-popover";
|
|
import { usePlanComments } from "@/hooks/domains/comments/use-plan-comments";
|
|
import { useRunComment } from "@/hooks/domains/comments/use-run-comment";
|
|
import type { PlanComment } from "@/lib/state/slices/comments";
|
|
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 { usePlanFindShortcut } from "./use-plan-find-shortcut";
|
|
|
|
// Dynamic import to avoid SSR issues with TipTap
|
|
const PlanEditor = dynamic(
|
|
() =>
|
|
import("@/components/editors/tiptap/tiptap-plan-editor").then((mod) => mod.TipTapPlanEditor),
|
|
{
|
|
ssr: false,
|
|
loading: () => (
|
|
<div className="flex h-full items-center justify-center text-muted-foreground text-sm">
|
|
Loading editor...
|
|
</div>
|
|
),
|
|
},
|
|
);
|
|
|
|
/** Debounce delay for auto-saving plan content (ms) */
|
|
const AUTO_SAVE_DELAY = 1500;
|
|
|
|
type TaskPlanPanelProps = {
|
|
taskId: string | null;
|
|
visible?: boolean;
|
|
};
|
|
|
|
function useTaskPlanPanelState(taskId: string | null, visible: boolean) {
|
|
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,
|
|
);
|
|
const sessionState = activeSession?.state;
|
|
const isAgentBusy = sessionState === "STARTING" || sessionState === "RUNNING";
|
|
|
|
const editorWrapperRef = useRef<HTMLDivElement>(null);
|
|
const editorInstanceRef = useRef<Editor | null>(null);
|
|
const [editorInstance, setEditorInstance] = useState<Editor | null>(null);
|
|
const {
|
|
draftContent,
|
|
setDraftContent,
|
|
editorKey,
|
|
isEditorFocused,
|
|
handleEmptyStateClick,
|
|
hasUnsavedChanges,
|
|
} = usePlanDraft(plan, isSaving, savePlan, editorWrapperRef);
|
|
const commentState = usePlanComments(activeSessionId);
|
|
const selectionState = usePlanSelection(activeSessionId, commentState);
|
|
|
|
const handleEditorReady = useCallback((editor: Editor) => {
|
|
editorInstanceRef.current = editor;
|
|
setEditorInstance(editor);
|
|
}, []);
|
|
|
|
const handleCommentDeleted = useCallback(
|
|
(ids: string[]) => {
|
|
for (const id of ids) {
|
|
commentState.handleDeleteComment(id);
|
|
}
|
|
},
|
|
[commentState],
|
|
);
|
|
|
|
const commentHighlights: CommentForEditor[] = useMemo(
|
|
() =>
|
|
commentState.comments.map((c) => ({
|
|
id: c.id,
|
|
selectedText: c.selectedText,
|
|
from: c.from,
|
|
to: c.to,
|
|
})),
|
|
[commentState.comments],
|
|
);
|
|
|
|
const isAgentCreatingPlan = isAgentBusy && !plan && draftContent.trim() === "";
|
|
|
|
return {
|
|
plan,
|
|
isLoading,
|
|
isSaving,
|
|
savePlan,
|
|
activeSessionId,
|
|
draftContent,
|
|
setDraftContent,
|
|
editorKey,
|
|
isEditorFocused,
|
|
handleEmptyStateClick,
|
|
hasUnsavedChanges,
|
|
commentState,
|
|
selectionState,
|
|
handleEditorReady,
|
|
handleCommentDeleted,
|
|
commentHighlights,
|
|
isAgentBusy,
|
|
isAgentCreatingPlan,
|
|
editorWrapperRef,
|
|
editorInstanceRef,
|
|
editorInstance,
|
|
revisions,
|
|
isLoadingRevisions,
|
|
loadRevisions,
|
|
loadRevisionContent,
|
|
revertTo,
|
|
previewRevisionId,
|
|
setPreviewRevision,
|
|
comparePair,
|
|
toggleCompareSelection,
|
|
clearComparePair,
|
|
};
|
|
}
|
|
|
|
export const TaskPlanPanel = memo(function TaskPlanPanel({
|
|
taskId,
|
|
visible = true,
|
|
}: TaskPlanPanelProps) {
|
|
const state = useTaskPlanPanelState(taskId, visible);
|
|
// Ctrl+S to save immediately
|
|
useSaveShortcut(
|
|
state.hasUnsavedChanges,
|
|
state.isSaving,
|
|
state.savePlan,
|
|
state.draftContent,
|
|
state.plan?.title,
|
|
);
|
|
|
|
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" />
|
|
<span className="text-sm">Loading plan...</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!taskId) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center text-muted-foreground">
|
|
<span className="text-sm">No task selected</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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",
|
|
state.isAgentBusy && "bg-background",
|
|
)}
|
|
ref={editorWrapperRef}
|
|
onClick={state.handleEmptyStateClick}
|
|
data-panel-kind="plan"
|
|
>
|
|
<PlanEditor
|
|
key={`${taskId}-${state.editorKey}`}
|
|
value={state.draftContent}
|
|
onChange={state.setDraftContent}
|
|
placeholder="Start typing your plan..."
|
|
onSelectionChange={state.activeSessionId ? setTextSelection : undefined}
|
|
comments={state.commentHighlights}
|
|
onCommentClick={selectionState.handleCommentHighlightClick}
|
|
onCommentDeleted={state.handleCommentDeleted}
|
|
onEditorReady={state.handleEditorReady}
|
|
/>
|
|
<PlanEmptyState
|
|
isLoading={state.isLoading}
|
|
draftContent={state.draftContent}
|
|
isEditorFocused={state.isEditorFocused}
|
|
isAgentCreatingPlan={state.isAgentCreatingPlan}
|
|
onClick={state.handleEmptyStateClick}
|
|
/>
|
|
{planSearch.isOpen && (
|
|
<PanelSearchBar
|
|
value={planSearch.query}
|
|
onChange={planSearch.setQuery}
|
|
onNext={planSearch.findNext}
|
|
onPrev={planSearch.findPrev}
|
|
onClose={planSearch.close}
|
|
matchInfo={planSearch.matchInfo}
|
|
/>
|
|
)}
|
|
</PanelBody>
|
|
|
|
<PlanSelectionPopoverWrapper
|
|
textSelection={textSelection}
|
|
activeSessionId={state.activeSessionId}
|
|
taskId={taskId}
|
|
commentState={state.commentState}
|
|
editorRef={editorInstanceRef}
|
|
onClose={selectionState.handleSelectionClose}
|
|
/>
|
|
</PanelRoot>
|
|
);
|
|
}
|
|
|
|
function removeCommentMark(editor: Editor | null, commentId: string) {
|
|
if (!editor) return;
|
|
const markType = editor.state.schema.marks.commentMark;
|
|
if (!markType) return;
|
|
const { tr } = editor.state;
|
|
tr.removeMark(0, editor.state.doc.content.size, markType.create({ commentId }));
|
|
editor.view.dispatch(tr);
|
|
}
|
|
|
|
/** Conditional selection popover for adding/editing comments */
|
|
function PlanSelectionPopoverWrapper({
|
|
textSelection,
|
|
activeSessionId,
|
|
taskId,
|
|
commentState,
|
|
editorRef,
|
|
onClose,
|
|
}: {
|
|
textSelection: TextSelection | null;
|
|
activeSessionId: string | null | undefined;
|
|
taskId: string | null;
|
|
commentState: ReturnType<typeof usePlanComments>;
|
|
editorRef: React.RefObject<Editor | null>;
|
|
onClose: () => void;
|
|
}) {
|
|
const { runComment } = useRunComment({
|
|
sessionId: activeSessionId ?? null,
|
|
taskId,
|
|
});
|
|
|
|
const addCommentAndApplyMark = useCallback(
|
|
(comment: string, selectedText: string) => {
|
|
const from = textSelection?.from;
|
|
const to = textSelection?.to;
|
|
const id = commentState.handleAddComment(comment, selectedText, from, to);
|
|
const editor = editorRef.current;
|
|
if (id && editor && from != null && to != null) {
|
|
editor
|
|
.chain()
|
|
.setTextSelection({ from, to })
|
|
.setMark("commentMark", { commentId: id })
|
|
.run();
|
|
}
|
|
return id;
|
|
},
|
|
[commentState, textSelection, editorRef],
|
|
);
|
|
|
|
const handleAdd = useCallback(
|
|
(comment: string, selectedText: string) => {
|
|
addCommentAndApplyMark(comment, selectedText);
|
|
},
|
|
[addCommentAndApplyMark],
|
|
);
|
|
|
|
const handleAddAndRun = useCallback(
|
|
(comment: string, selectedText: string) => {
|
|
const id = addCommentAndApplyMark(comment, selectedText);
|
|
if (!id || !activeSessionId) return;
|
|
const newComment: PlanComment = {
|
|
id,
|
|
sessionId: activeSessionId,
|
|
source: "plan",
|
|
text: comment,
|
|
selectedText,
|
|
from: textSelection?.from,
|
|
to: textSelection?.to,
|
|
createdAt: new Date().toISOString(),
|
|
status: "pending",
|
|
};
|
|
runComment(newComment).catch((err) => console.error("Failed to run plan comment:", err));
|
|
},
|
|
[addCommentAndApplyMark, activeSessionId, runComment, textSelection],
|
|
);
|
|
|
|
if (!textSelection || !activeSessionId) return null;
|
|
const editingComment = commentState.editingCommentId
|
|
? commentState.comments.find((c) => c.id === commentState.editingCommentId)?.text
|
|
: undefined;
|
|
const onDelete = commentState.editingCommentId
|
|
? () => {
|
|
const id = commentState.editingCommentId!;
|
|
removeCommentMark(editorRef.current, id);
|
|
commentState.handleDeleteComment(id);
|
|
}
|
|
: undefined;
|
|
return (
|
|
<PlanSelectionPopover
|
|
selectedText={textSelection.text}
|
|
position={textSelection.position}
|
|
onAdd={handleAdd}
|
|
onAddAndRun={editingComment ? undefined : handleAddAndRun}
|
|
onClose={onClose}
|
|
editingComment={editingComment}
|
|
onDelete={onDelete}
|
|
/>
|
|
);
|
|
}
|
|
|
|
/** Draft content, editor key, focus tracking, and auto-save */
|
|
function usePlanDraft(
|
|
plan: { content?: string; title?: string } | null | undefined,
|
|
isSaving: boolean,
|
|
savePlan: (content: string, title?: string) => Promise<unknown>,
|
|
editorWrapperRef: React.RefObject<HTMLDivElement | null>,
|
|
) {
|
|
const [draftContent, setDraftContent] = useState(plan?.content ?? "");
|
|
const draftContentRef = useRef(draftContent);
|
|
const [editorKey, setEditorKey] = useState(0);
|
|
const lastPlanContentRef = useRef<string | undefined>(undefined);
|
|
const isExternalUpdateRef = useRef(false);
|
|
const [isEditorFocused, setIsEditorFocused] = useState(false);
|
|
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
const handleEmptyStateClick = useCallback(() => {
|
|
const el = editorWrapperRef.current?.querySelector(".ProseMirror");
|
|
if (el) (el as HTMLElement).focus();
|
|
}, [editorWrapperRef]);
|
|
|
|
// Track focus state
|
|
useEffect(() => {
|
|
const checkFocus = () => {
|
|
const wrapper = editorWrapperRef.current;
|
|
if (!wrapper) return;
|
|
setIsEditorFocused(wrapper.contains(document.activeElement));
|
|
};
|
|
document.addEventListener("focusin", checkFocus);
|
|
document.addEventListener("focusout", checkFocus);
|
|
checkFocus();
|
|
return () => {
|
|
document.removeEventListener("focusin", checkFocus);
|
|
document.removeEventListener("focusout", checkFocus);
|
|
};
|
|
}, [editorWrapperRef]);
|
|
|
|
useEffect(() => {
|
|
draftContentRef.current = draftContent;
|
|
}, [draftContent]);
|
|
|
|
// Sync from external plan updates
|
|
useEffect(() => {
|
|
const prevContent = lastPlanContentRef.current;
|
|
const newContent = plan?.content;
|
|
lastPlanContentRef.current = newContent;
|
|
if (newContent !== prevContent) {
|
|
const resolved = newContent ?? "";
|
|
if (resolved === draftContentRef.current) return;
|
|
isExternalUpdateRef.current = true;
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- syncing external plan data to local editor state
|
|
setDraftContent(resolved);
|
|
setEditorKey((k) => k + 1);
|
|
}
|
|
}, [plan?.content]);
|
|
|
|
// Auto-save with debounce
|
|
useEffect(() => {
|
|
if (isExternalUpdateRef.current) {
|
|
isExternalUpdateRef.current = false;
|
|
return;
|
|
}
|
|
const hasChanges = plan ? draftContent !== plan.content : draftContent.length > 0;
|
|
if (!hasChanges || isSaving) return;
|
|
if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current);
|
|
autoSaveTimerRef.current = setTimeout(() => {
|
|
autoSaveTimerRef.current = null;
|
|
savePlan(draftContent, plan?.title);
|
|
}, AUTO_SAVE_DELAY);
|
|
return () => {
|
|
if (autoSaveTimerRef.current) {
|
|
clearTimeout(autoSaveTimerRef.current);
|
|
autoSaveTimerRef.current = null;
|
|
}
|
|
};
|
|
}, [draftContent, plan, isSaving, savePlan]);
|
|
|
|
const hasUnsavedChanges = plan ? draftContent !== plan.content : draftContent.length > 0;
|
|
return {
|
|
draftContent,
|
|
setDraftContent,
|
|
editorKey,
|
|
isEditorFocused,
|
|
handleEmptyStateClick,
|
|
hasUnsavedChanges,
|
|
};
|
|
}
|
|
|
|
/** Text selection state for comment popover */
|
|
function usePlanSelection(
|
|
activeSessionId: string | null | undefined,
|
|
commentState: ReturnType<typeof usePlanComments>,
|
|
) {
|
|
const [textSelection, setTextSelection] = useState<TextSelection | null>(null);
|
|
|
|
const handleCommentHighlightClick = useCallback(
|
|
(id: string, position: { x: number; y: number }) => {
|
|
const comment = commentState.comments.find((c) => c.id === id);
|
|
if (comment) {
|
|
commentState.setEditingCommentId(id);
|
|
setTextSelection({
|
|
text: comment.selectedText,
|
|
from: comment.from,
|
|
to: comment.to,
|
|
position,
|
|
});
|
|
}
|
|
},
|
|
[commentState],
|
|
);
|
|
|
|
const handleSelectionClose = useCallback(() => {
|
|
setTextSelection(null);
|
|
commentState.setEditingCommentId(null);
|
|
window.getSelection()?.removeAllRanges();
|
|
}, [commentState]);
|
|
|
|
return { textSelection, setTextSelection, handleCommentHighlightClick, handleSelectionClose };
|
|
}
|
|
|
|
/** Ctrl+S save shortcut */
|
|
function useSaveShortcut(
|
|
hasUnsavedChanges: boolean,
|
|
isSaving: boolean,
|
|
savePlan: (content: string, title?: string) => Promise<unknown>,
|
|
draftContent: string,
|
|
title?: string,
|
|
) {
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
|
e.preventDefault();
|
|
if (hasUnsavedChanges && !isSaving) savePlan(draftContent, title);
|
|
}
|
|
};
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
}, [hasUnsavedChanges, isSaving, savePlan, draftContent, title]);
|
|
}
|
|
|
|
/** Rich empty state - shows when no content and editor not focused */
|
|
function PlanEmptyState({
|
|
isLoading,
|
|
draftContent,
|
|
isEditorFocused,
|
|
isAgentCreatingPlan,
|
|
onClick,
|
|
}: {
|
|
isLoading: boolean;
|
|
draftContent: string;
|
|
isEditorFocused: boolean;
|
|
isAgentCreatingPlan: boolean;
|
|
onClick: () => void;
|
|
}) {
|
|
if (isLoading || draftContent.trim() !== "" || isEditorFocused || isAgentCreatingPlan)
|
|
return null;
|
|
return (
|
|
<div
|
|
className="absolute inset-0 flex items-center justify-center pointer-events-none"
|
|
onClick={onClick}
|
|
>
|
|
<div className="flex flex-col items-center gap-6 max-w-md px-6">
|
|
<div className="flex items-center justify-center w-12 h-12 rounded-xl bg-muted/50">
|
|
<IconFileText className="h-6 w-6 text-muted-foreground" />
|
|
</div>
|
|
<div className="text-center">
|
|
<h3 className="text-sm font-medium text-foreground mb-1">Plan your implementation</h3>
|
|
<p className="text-xs text-muted-foreground">
|
|
A shared document for you and the agent to collaborate on the approach
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-col gap-3 w-full">
|
|
<div className="flex items-start gap-3">
|
|
<IconRobot className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
|
|
<p className="text-xs text-muted-foreground">
|
|
The agent can write and update the plan as it works
|
|
</p>
|
|
</div>
|
|
<div className="flex items-start gap-3">
|
|
<IconMessage className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
|
|
<p className="text-xs text-muted-foreground">
|
|
Select text and press{" "}
|
|
<kbd className="px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-mono text-[10px]">
|
|
⌘⇧C
|
|
</kbd>{" "}
|
|
to comment
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground/70">
|
|
<IconClick className="h-3.5 w-3.5" />
|
|
<span>Click anywhere to start writing</span>
|
|
</div>
|
|
</div>
|
|
</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}
|
|
/>
|
|
}
|
|
/>
|
|
);
|
|
}
|