diff --git a/AGENTS.md b/AGENTS.md index 46baf6ae1..a570a9ff1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -245,11 +245,23 @@ The daemon is the single long-running Bun server used by normal plan/review/anno | `/daemon/projects/prs` | GET | List open PRs for a project (`?cwd=`) | | `/daemon/projects/prs/detailed` | GET | List PRs with review metadata for dashboard (`?cwd=`) | | `/daemon/fs/list` | GET | List directory contents (`?path=`) | -| `/daemon/ws` | WebSocket | Multiplex daemon lifecycle events, session-scoped external annotation events, agent job events, and correlated session actions | +| `/daemon/ws` | WebSocket | Multiplex daemon lifecycle events, session-scoped external annotation events, agent job events, session revision events, and correlated session actions | | `/s/:id` | GET | Serve the browser HTML for a session | | `/s/:id/api/...` | Any | Route browser API requests to that session's plan/review/annotate handler | -Runtime live updates for daemon lifecycle events, external annotations, and agent jobs are delivered through `/daemon/ws`. Session-scoped updates subscribe by `{ family, sessionId }`. HTTP endpoints below remain for snapshots, mutations, uploads, and large payloads. AI query token streaming remains on `/api/ai/query`. +Runtime live updates for daemon lifecycle events, external annotations, agent jobs, and session revisions are delivered through `/daemon/ws`. Session-scoped updates subscribe by `{ family, sessionId }`. HTTP endpoints below remain for snapshots, mutations, uploads, and large payloads. AI query token streaming remains on `/api/ai/query`. + +### Session Persistence and Resubmission + +When a user denies a plan (or sends feedback on a review/annotation), the session enters `awaiting-resubmission` status instead of completing. The session's HTTP handler stays alive. When the agent replans and submits again via `POST /daemon/sessions`, the daemon matches the new submission to the existing session by a match key (`plan:project:slug` for plans, `review:project:branch` for reviews, `annotate:project:filePath` for single-file annotations). The session reactivates in place — the frontend receives a `session-revision` event via WebSocket with the updated content. + +**Sessions never die.** No session type calls `store.complete()` from its decision handler. All sessions survive feedback, approve, and exit — the HTTP handler stays alive and the tab keeps working. `registerPersistentDecision` always calls `store.suspend()`. `registerReviewDecision` always calls `store.idle()`. Non-terminal sessions have no expiry timer. + +**Session statuses (plan/annotate):** `active` → `awaiting-resubmission` (on any decision) → `active` (on resubmit) → `awaiting-resubmission` ... repeating. + +**Session statuses (code review):** `active` → `idle` (on any decision) → `active` (on agent resubmit) → `idle` ... repeating. + +**Event families:** `daemon`, `external-annotations`, `agent-jobs`, `session-revision`. ### Plan Server (`packages/server/index.ts`) @@ -320,7 +332,9 @@ Runtime live updates for daemon lifecycle events, external annotations, and agen | Endpoint | Method | Purpose | | --------------------- | ------ | ------------------------------------------ | -| `/api/plan` | GET | Returns `{ plan, origin, mode: "annotate", filePath, sourceInfo?, gate, renderAs?, rawHtml? }` | +| `/api/plan` | GET | Returns `{ plan, origin, mode: "annotate", filePath, sourceInfo?, gate, renderAs?, rawHtml?, previousPlan, versionInfo }` | +| `/api/plan/version` | GET | Fetch specific version (`?v=N`) — single-file annotate only | +| `/api/plan/versions` | GET | List all versions — single-file annotate only | | `/api/feedback` | POST | Submit annotations (body: feedback, annotations) | | `/api/approve` | POST | Approve without feedback (review-gate UX, `--gate`) | | `/api/exit` | POST | Close session without feedback | diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index b189d1202..adf400f6a 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -689,7 +689,7 @@ async function runDaemonSessionRequest(request: PluginRequest, options: { plugin await cancelCreatedSession(); fail(completed.error.code, completed.error.message); } - if (completed.session.status !== "completed") { + if (completed.session.status !== "completed" && completed.session.status !== "awaiting-resubmission" && completed.session.status !== "idle") { fail( completed.session.status, completed.session.error ?? `Plannotator session ${completed.session.id} ended with status ${completed.session.status}.`, diff --git a/goals/frontend-session-lifecycle/backlog.md b/goals/frontend-session-lifecycle/backlog.md index 3ba79516e..b16442f91 100644 --- a/goals/frontend-session-lifecycle/backlog.md +++ b/goals/frontend-session-lifecycle/backlog.md @@ -26,34 +26,15 @@ Some users will prefer each session opening in a new browser tab with auto-close --- -## 3. Live plan updates across deny/replan cycles +## ~~3. Live plan updates across deny/replan cycles~~ DONE -**Priority:** High — most-requested feature -**Size:** Large - -When the agent resubmits a plan after denial, the existing session should reactivate in-place rather than spawning a new session. - -**Desired behavior:** -- User denies plan, sends feedback, agent replans -- Agent calls ExitPlanMode again — daemon matches it to the existing session -- Session status flips from "completed" back to "active" -- Frontend receives a push notification via WebSocket -- Plan diff system shows what changed between versions -- The plan→deny→replan→approve cycle happens in one persistent session - -**Open questions:** -- How does the daemon match a new plan submission to an existing session? By project + plan slug? By a correlation ID from the agent? -- Does the session status reset to "active" on resubmission, or show "updated"? -- How does this interact with the version history system already in `~/.plannotator/history/`? +Implemented in `feat/session-persistence`. Sessions enter `awaiting-resubmission` status on deny. Agent resubmission is matched by `plan:project:slug` and the session reactivates in place. Frontend receives `session-revision` WebSocket event with updated content. --- -## 4. Session persistence after completion - -**Priority:** High — current behavior is broken -**Size:** Medium, tied to #3 +## ~~4. Session persistence after completion~~ DONE -**Current bug:** When a session is approved/denied, the daemon disposes the session handler. The session disappears from the sidebar even though the route still resolves. API calls fail, so the plan content is gone. +Implemented in `feat/session-persistence`. Denied sessions stay alive (handler not disposed) in `awaiting-resubmission` state with no expiry. Sessions persist until daemon restart. **Required behavior:** - Completed sessions stay in the sidebar with a status badge (approved/denied) diff --git a/goals/session-persistence/decisions.md b/goals/session-persistence/decisions.md new file mode 100644 index 000000000..e1c21b45a --- /dev/null +++ b/goals/session-persistence/decisions.md @@ -0,0 +1,147 @@ +# Session Persistence — Design Decisions + +Tracking decisions made during PR #770 review and triage (2026-05-23). + +--- + +## Product Facts + +### Annotate Mode + +- A user can annotate a document. +- A user can annotate a URL. +- A user can annotate a folder. +- A user can annotate any of the above asynchronously across multiple docs/agents. +- A user can submit/flush annotations through to the agent. +- An agent can, but may not, create new versions of the document. +- If an agent does, a user should be notified. +- Agent revisions may change the state of a document. If it does, a user is notified. +- The user should be able to see those new document versions. Diff mode should allow them to see previous. +- Annotation mode has a gating process, by default it is not used. If it is used, we should assume the agent will iterate with the user until an approval. +- The gating process is similar to the planning flow. +- If an agent gates a document the user already has open, the gate buttons would appear. +- Otherwise, the normal button set appears. + +### Code Review Mode + +- A code review session can be associated with a project (local dir) or GitHub PR or GitLab MR. +- In local mode, there is the possibility of the diffs changing under the session. +- When I review code, I can make annotations. +- I want to send/flush the annotations to the agent, or I can publish to GitHub/GitLab comments (if in PR mode). +- Code review no longer needs to end. +- If an agent session initiates a new code review session from same directory, ideally it would open in the existing session. +- But I would need to be notified of this. +- I would need to be notified if diffs change. +- In legacy tab mode, code review should show the full-screen completion overlay (countdown + close tab) after sending feedback, same as plan review. The inline banner is for embedded mode only. +- When a new diff arrives, files I've already viewed should stay hidden — unless the file actually changed in the new diff. Only show it again if the content is different. + +### Cross-Cutting + +- Every annotate session lives forever once created — single file, folder, last message, URL. No one-shot sessions. The tab stays open and interactive after feedback is sent. There are no exceptions. +- Folder annotate sessions are reusable. If the user annotates the same folder twice, the daemon should find the existing session and reactivate it — not create a new one. The match key is the folder path. There's no content to update (it's a file browser), but the session is reused as-is. +- Annotate-last is not reusable — "last message" has no stable identity across invocations. Each annotate-last creates a new session. This is fine; the command is rarely run twice in a row. +- Legacy tab mode is the only case where the tab closes after feedback — that's the full-screen overlay with countdown, and it's the expected legacy behavior. +- Sessions do not time out. A session, once created, lives until the daemon restarts. We do not kill sessions on a countdown. If a user submits feedback and the agent never comes back, that's for the user to see and decide — not for us to silently clean up. +- We should collect the right data (timestamps, feedback-sent-at, last-agent-contact) so we can eventually show the user: "you submitted feedback but it never came back." But that's a future UI concern, not a reason to expire sessions. +- When a revision arrives (plan, annotate, or review), any external annotations (lint results, agent comments) from the previous version must be cleared. They reference old content with wrong positions. +- `waitForResult` must return immediately if the result is already available — for both `idle` and `awaiting-resubmission` sessions. No consistency gaps. +- Plan/annotate actions (Approve, Deny, Send Feedback) must be disabled while awaiting resubmission. The agent already has the feedback — submitting again against stale content is wrong. Code review already handles this (buttons hidden when idle). +- Late WebSocket subscribers (tab refresh during awaiting) should receive the current state. The snapshot provider for `session-revision` must return the latest content, not null. +- HTML and markdown annotation should go through the same functional pipeline. The `--render-html` path diverges from markdown in a way that `updateContent` can't reach — `updateContent` must also update `rawHtml` for HTML sessions. +- PR review sessions that get reactivated need updated PR metadata (head SHA, etc.). The current implementation serves the correct diff but posts platform actions against the stale commit. Needs a bigger fix to make PR metadata updatable inside the session closure. +- Annotate history slug is computed once from the initial heading and doesn't update if the heading changes. Acceptable — versions stay intact, just filed under the old name on disk. +- The decision listener must stay alive after every user action — approve, deny, exit, send feedback. If the listener shuts down after approve/exit, the session looks alive but can't respond to future resubmissions. The agent hangs forever. +- Session collisions across worktrees of the same repo are not a real concern. This is a local app — one daemon per machine. + +--- + +## Decisions + +### Decision 1: Code review sessions are long-lived + +**Status:** Implemented + +Code review sessions use a new `"idle"` daemon status. The flow: + +``` +agent → plannotator review (CLI opens, blocks) → session active +session → user annotates → sends feedback → submit (CLI closes) +session → idle (user can browse and annotate, but no submit buttons — nobody is listening) +agent → plannotator review again (CLI opens) → reactivates the idle session +(repeats indefinitely) +``` + +Key behaviors: +- After feedback: session transitions to `idle` via `store.idle()`. The HTTP handler stays alive, resources stay alive. The user can browse the diff and make annotations, but Send Feedback / Approve buttons are hidden (no agent to receive them). +- On reactivation: agent triggers `plannotator review` from the same directory/branch. The daemon finds the idle session by matchKey, pushes the new diff via `updateContent`, and calls `store.reactivate()`. The frontend receives a `session-revision` WebSocket event, updates the diff, and re-shows the submit buttons. +- Infinite cycle: this repeats as many times as needed. No counter, no limit. +- Cleanup: idle sessions have no expiry. They live until daemon restart. + +**Resolved questions:** +- Notification when diffs change: agent-triggered via `session-revision` event. No file watcher (user can manually switch diff type to refresh). +- Subsequent feedback without agent: not possible — submit buttons are hidden while idle. +- Cleanup: sessions persist until daemon restart (no TTL on non-terminal sessions). + +### Decision 2: All annotate sessions are persistent + +**Status:** Implemented + +Every annotate session lives forever — single file, folder, URL, last message. No one-shot sessions. All annotate types use `registerPersistentDecision`, which never calls `store.complete()`. The session always suspends and the loop continues. + +Single-file annotate is revisable: it has a matchKey, updateContent, and version history. The frontend shows "Waiting for agent to revise..." after feedback. + +Folder, annotate-last, and URL annotate are non-revisable: no matchKey, no updateContent. The frontend shows "Feedback sent" after feedback. The session stays interactive — the user can keep browsing and send more feedback. + +### Decision 3: "Feedback sent" state should be calm, not loading + +**Status:** Implemented (code review), pending (plan/annotate) + +**Code review:** After sending feedback, the `CompletionBanner` shows a green checkmark with "Feedback sent / Your annotations were delivered to the agent." The banner persists until the agent reactivates (no auto-dismiss). Submit buttons disappear. The session stays browsable. + +**Plan/annotate:** Still uses the amber spinner "Waiting for agent to revise..." variant. This should eventually be made calmer, but it's lower priority because plan/annotate persistence works correctly (agent WILL resubmit). + +**What this means for the current code:** +- Code review uses `'feedback-sent'` CompletionBanner variant (green checkmark, not spinner) +- Plan/annotate still uses `'awaiting'` variant (amber spinner) — acceptable for now +- For plan/annotate: actions should be disabled until the revision arrives (the agent already has the feedback and is working — re-submitting before the revision arrives doesn't make sense) +- For code review: different model, TBD based on Decision 1 + +### Decision 4: Hot loop prevention for non-agent origins + +**Status:** Resolved + +The `registerDecisionLoop` spin guard uses promise identity (`currentPromise === lastPromise`) to detect when no new cycle was started. When a non-agent origin calls `resolveAndCycle`, it resolves without calling `startNew()`, so the loop sees the same promise and exits cleanly. No hot loop. + +### Decision 5: Clear external annotations on revision + +**Status:** Implemented + +All three `handleUpdateContent` functions (plan, annotate, review) call `externalAnnotations.clearAll()` before publishing the `session-revision` event. + +### Decision 6: Session expiry + +**Status:** Resolved — sessions don't expire + +Non-terminal sessions (`awaiting-resubmission`, `idle`, `active` after first decision) have `expiresAt` deleted. `cleanupExpired()` skips them. Sessions live until daemon restart or explicit cancellation. + +--- + +## Open Items + +| Item | Severity | Status | +|------|----------|--------| +| External annotations not cleared on revision (all surfaces) | P2 | Fixed | +| Plan/annotate actions not disabled during awaiting | P2 | Fixed | +| `waitForResult` missing `awaiting-resubmission` short-circuit | P2 | Fixed | +| `session-revision` snapshot provider returns null | P2 | Fixed | +| `--render-html` resubmission shows stale HTML | P2 | Fixed — `handleUpdateContent` now accepts and updates `rawHtml` | +| PR reviews keep stale metadata on reuse | P1 | Deferred — needs PR metadata updatable in session closure | +| Gate flag not updated on resubmission | P2 | Deferred — if session was created ungated and agent resubmits with `--gate`, Approve button won't appear (user still sees Send Annotations + Close). Reverse also true: gated session stays gated even if agent resubmits without `--gate`. Fix: `updateContent` should accept and update the `gate` flag. | +| Provenance data for stale sessions | P3 | Deferred — collect timestamps (feedback-sent-at, last-agent-contact) so we can show "you submitted feedback but it never came back." Future UI concern. | +| `onCancel` never wired on awaiting banner | nit | Deferred | +| Session collisions across same-repo worktrees | nit | Accepted — local app, one daemon per machine | +| Annotate slug doesn't update on heading change | nit | Accepted — cosmetic, versions work correctly | +| VS Code editor annotations not cleared on revision | P2 | Fixed — `editorAnnotations.clearAll()` added to `handleUpdateContent` in plan and review servers | +| PR diff scope/baseline not reset on reuse | P2 | Deferred — part of the broader PR metadata staleness issue. `originalPRPatch`, `currentPRDiffScope` not updated in `handleUpdateContent`. | +| Remote share link stale on session reuse | P2 | Fixed — all three reuse paths regenerate `remoteShare` before returning the record | +| `sessionRefs` lazy cleanup | nit | Accepted — negligible memory | diff --git a/goals/session-persistence/overview.md b/goals/session-persistence/overview.md new file mode 100644 index 000000000..0bdfb7a8b --- /dev/null +++ b/goals/session-persistence/overview.md @@ -0,0 +1,162 @@ +# Session Persistence — What You Need To Know + +## The User Experience + +### Before + +You review a plan. You leave annotations. You click Deny. Your feedback gets sent to the agent. The session dies. Completion screen. Done. + +The agent reads your feedback, revises the plan, and submits again. A completely new session appears — new tab or new sidebar entry. No connection to the one you just closed. No awareness that this is a revision of the same plan. You start from scratch every time. + +Same story for code review and annotate. Send feedback, session dies, agent makes changes, new session. Every deny-resubmit cycle is a fresh start. + +### After + +You deny a plan. Instead of the completion screen, you see: **"Feedback sent — waiting for agent to revise..."** The session stays alive. Your browser tab stays open. The sidebar shows a pulsing amber indicator. + +The agent revises the plan and submits again. The **same session** updates in place. You see the new plan with a diff showing what changed. Your annotations are cleared (the agent already has them). You review the fresh version. Approve or deny again. Repeat until you're satisfied. + +This works for all three session types: +- **Plan review** — agent revises the plan, session updates with plan diff +- **Code review** — agent makes code changes, session refreshes with new diff +- **Annotate** — agent edits the file, session refreshes with updated content + +### What stays the same + +- Approve works exactly as before +- Exit works exactly as before +- Auto-close works exactly as before +- Sessions opened without an agent (standalone, demo) behave exactly as before — deny is still final +- Sessions do not expire — they persist until daemon restart + +--- + +## Why This Was Needed + +Users and community members repeatedly asked for this. The linear "deny → wait → new session" flow was friction-heavy. Every cycle required the user to re-orient: find the new session, remember what they asked for, compare mentally against the previous version. The plan diff system already existed but couldn't show diffs across sessions — only within a session's version history. + +The deny-resubmit cycle is the core feedback loop of plan-driven development. Making it seamless makes the entire product more useful. + +--- + +## Technical Overview + +### New Session Status: `awaiting-resubmission` + +A non-terminal status in the daemon session lifecycle. The session stays alive — its HTTP handler keeps serving requests, the WebSocket connection stays open, and the frontend connection persists. Sessions do not expire; they persist until daemon restart. + +``` +active → awaiting-resubmission → active → awaiting-resubmission → ... +``` + +### Decision Cycle Model + +Each server (plan, annotate, review) previously used a one-shot promise for the user's decision. Now they use a **cycle model**: each deny resolves the current cycle and starts a new one. The approve/exit path resolves the cycle as final. + +Shared helper in `packages/server/session-handler.ts`: +- `createDecisionCycle()` — creates a resolvable cycle with `promise()`, `resolve()`, `startNew()` +- `resolveAndCycle(cycle, result, origin)` — resolves current cycle, starts new one if agent-originated, returns `{ awaitingResubmission: true }` flag + +### Session Matching + +When the agent resubmits, the daemon matches the new request to the existing suspended session using a **match key**: + +| Session Type | Match Key | Example | +|-------------|-----------|---------| +| Plan | `plan:${project}:${slug}` | `plan:plannotator:implementation-plan-2026-05-22` | +| Code Review | `review:${project}:${branch}` or `review:${prUrl}` | `review:plannotator:feat/session-persistence` | +| Annotate | `annotate:${filePath}` | `annotate:/path/to/README.md` | + +If a match is found: the session's `updateContent` method pushes new content, the store reactivates the session, and a `session-revision` WebSocket event notifies the frontend. + +If no match (different slug, different branch, different file): a new session is created as before. + +### Content Update + +Each server exposes a `handleUpdateContent` function that: +- Replaces the content in the server's closure (plan text, diff patch, markdown) +- Resets draft state +- Publishes a `session-revision` event to the frontend + +### Frontend + +All three surfaces (plan review, code review, annotate) handle the `awaitingResubmission` response from their feedback endpoints. When received: +- Show the "Feedback sent — waiting for agent to revise..." banner +- Subscribe to `session-revision` WebSocket events +- On revision: refresh content, clear annotations, reset awaiting state + +### CLI + +The CLI binary accepts `awaiting-resubmission` as a valid non-error status. It outputs the denial feedback and exits with code 0 — the agent reads the feedback and replans, same as always. The matching happens server-side; the agent doesn't know about session persistence. + +--- + +## Files Changed + +| File | What changed | +|------|-------------| +| `packages/shared/daemon-protocol.ts` | New `awaiting-resubmission` status, `session-revision` event family, protocol v2 | +| `packages/server/session-handler.ts` | `createDecisionCycle()` and `resolveAndCycle()` shared helpers | +| `packages/server/daemon/session-store.ts` | `suspend()`, `reactivate()` methods, `matchKey` field | +| `packages/server/daemon/session-factory.ts` | `createDecisionScope`, `registerPersistentDecision`, `findAwaitingSession`, matching + reactivation for all three types | +| `packages/server/daemon/server.ts` | Skip deletion timer for awaiting-resubmission sessions | +| `packages/server/index.ts` | Cycle model, `handleUpdateContent`, slug/getSnapshot on session | +| `packages/server/annotate.ts` | Cycle model, `handleUpdateContent` for file-based modes | +| `packages/server/review.ts` | Cycle model, `handleUpdateContent(rawPatch, gitRef)` | +| `apps/hook/server/index.ts` | Accept `awaiting-resubmission` status (exit 0, not error) | +| `packages/plannotator-plan-review/App.tsx` | `awaitingResubmission` state, deny handler check, `session-revision` subscription | +| `packages/plannotator-code-review/App.tsx` | Same as plan review, adapted for diff refresh | +| `packages/ui/components/CompletionBanner.tsx` | `awaiting` variant with spinner and cancel button | +| `AGENTS.md` | Documentation for new status, event family, resubmission flow | + +--- + +## What Does NOT Persist + +- **URL-based annotations** — the source URL might change, can't refresh +- **"Annotate last message" sessions** — ephemeral, no persistent source +- **Archive sessions** — read-only, no feedback cycle +- **Goal setup sessions** — one-shot Q&A, not a review cycle +- **Standalone/demo sessions** — no agent to resubmit + +--- + +## Recap + +1. Denied sessions stay alive instead of dying +2. The agent resubmits → same session updates in place +3. Works for plan, code review, and file-based annotate +4. Matching is by project+slug (plan), project+branch (review), or filepath (annotate) +5. Sessions persist until daemon restart — no timeout +6. Agent doesn't need to know — matching is server-side +7. Shared `createDecisionCycle` helper eliminates duplication across three servers +8. Frontend shows amber "waiting" banner with cancel option +9. No changes to approve, exit, or standalone flows + +--- + +## Quiz + +**1.** What happens to a denied session's HTTP handler? +> It stays alive. `suspend()` does NOT call `disposeResources()` or clear `handleRequest`. + +**2.** How does the daemon know a new plan submission is a revision of an existing session? +> It computes a match key (`plan:${project}:${slug}`) and searches for an `awaiting-resubmission` session with the same key. + +**3.** What happens if the agent changes the plan's heading when resubmitting? +> Different heading → different slug → no match → new session. The old session persists until daemon restart. + +**4.** Does the agent need to track session IDs or know about persistence? +> No. The CLI binary runs fresh each time. Matching is entirely server-side. + +**5.** What's the difference between `suspend()` and `complete()`? +> `complete()` sets terminal status, disposes resources, clears the HTTP handler. `suspend()` sets `awaiting-resubmission`, resolves waiters (so the CLI gets feedback), but keeps everything alive. + +**6.** How does the frontend know the content changed? +> A `session-revision` WebSocket event carrying the new content. The frontend subscribes when `awaitingResubmission` is true. + +**7.** What happens to a URL annotation session when denied? +> It completes normally (no persistence). URL sources can't be refreshed, so no match key is set. + +**8.** How long does the session wait for the agent to resubmit? +> Indefinitely. Sessions persist until daemon restart — no timeout. diff --git a/packages/plannotator-code-review/App.tsx b/packages/plannotator-code-review/App.tsx index d0902e8f9..413b9ac8f 100644 --- a/packages/plannotator-code-review/App.tsx +++ b/packages/plannotator-code-review/App.tsx @@ -36,6 +36,7 @@ import { isTypingTarget, useReviewSearch, type ReviewSearchMatch } from './hooks import { useEditorAnnotations } from '@plannotator/ui/hooks/useEditorAnnotations'; import { useExternalAnnotations } from '@plannotator/ui/hooks/useExternalAnnotations'; import { useAgentJobs } from '@plannotator/ui/hooks/useAgentJobs'; +import { subscribeToDaemonSessionFamily } from '@plannotator/ui/utils/daemonHub'; import { exportEditorAnnotations } from '@plannotator/ui/utils/parser'; import { ResizeHandle } from '@plannotator/ui/components/ResizeHandle'; import { DockviewReact, type DockviewReadyEvent, type DockviewApi } from 'dockview-react'; @@ -73,6 +74,7 @@ import { REVIEW_CODE_NAV_PANEL_ID, } from './dock/reviewPanelTypes'; import type { DiffFile } from './types'; +import { retainUnchangedViewedFiles } from './utils/diffFiles'; import type { DiffOption, WorktreeInfo, GitContext } from '@plannotator/shared/types'; import type { PRMetadata } from '@plannotator/shared/pr-types'; import type { PRDiffScope, PRDiffScopeOption, PRStackInfo, PRStackTree } from '@plannotator/shared/pr-stack'; @@ -238,6 +240,7 @@ const ReviewApp: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode; const [isApproving, setIsApproving] = useState(false); const [isExiting, setIsExiting] = useState(false); const [submitted, setSubmitted] = useState<'approved' | 'feedback' | 'exited' | false>(false); + const [feedbackSent, setFeedbackSent] = useState(false); const [showApproveWarning, setShowApproveWarning] = useState(false); const [showExitWarning, setShowExitWarning] = useState(false); const [sharingEnabled, setSharingEnabled] = useState(true); @@ -308,6 +311,36 @@ const ReviewApp: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode; const { externalAnnotations, updateExternalAnnotation, deleteExternalAnnotation } = useExternalAnnotations({ enabled: !!origin }); const agentJobs = useAgentJobs({ enabled: !!origin }); + // Listen for session-revision events (agent pushed a new diff) + useEffect(() => { + if (!origin) return; + const unsubscribe = subscribeToDaemonSessionFamily("session-revision", (msg) => { + if (!msg.payload) return; + const revision = msg.payload as { rawPatch?: string; gitRef?: string }; + if (revision.rawPatch !== undefined) { + const oldFiles = storeApi.getState().files; + const newFiles = parseDiffToFiles(revision.rawPatch); + const contentChanged = newFiles.length !== oldFiles.length || + newFiles.some((f, i) => f.patch !== oldFiles[i]?.patch); + if (contentChanged) { + setDiffData(prev => prev ? { ...prev, rawPatch: revision.rawPatch!, gitRef: revision.gitRef ?? prev.gitRef } : prev); + storeApi.getState().setFiles(newFiles); + storeApi.getState().setFocusedFile(0); + storeApi.getState().setLocalAnnotations([]); + storeApi.getState().selectAnnotation(null); + storeApi.getState().setPendingSelection(null); + setViewedFiles(prev => retainUnchangedViewedFiles(oldFiles, newFiles, prev)); + } + if (contentChanged || msg.type === "event") { + setFeedbackSent(false); + setSubmitted(false); + setIsSendingFeedback(false); + } + } + }); + return unsubscribe; + }, [origin, storeApi]); + // Tour dialog state — opens as an overlay instead of a dock panel const [tourDialogJobId, setTourDialogJobId] = useState(null); @@ -803,6 +836,7 @@ const ReviewApp: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode; error?: string; isWSL?: boolean; serverConfig?: { displayName?: string; gitUser?: string }; + lastDecision?: 'approved' | 'feedback' | 'exited' | null; }) => { configStore.init(data.serverConfig); setGitUser(data.serverConfig?.gitUser); @@ -850,6 +884,11 @@ const ReviewApp: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode; if (data.diffType && !data.prMetadata && data.gitContext?.vcsType !== 'p4' && data.gitContext?.vcsType !== 'jj' && needsDiffTypeSetup()) { setDiffTypeSetupPending(true); } + if (data.lastDecision) { + if (data.lastDecision === 'approved') setSubmitted('approved'); + else if (data.lastDecision === 'feedback') setFeedbackSent(true); + else if (data.lastDecision === 'exited') setSubmitted('exited'); + } }) .catch(() => { // Not in API mode - use demo content @@ -1528,7 +1567,16 @@ const ReviewApp: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode; }), }); if (res.ok) { - setSubmitted('feedback'); + const data = await res.json().catch(() => ({})); + if (data.feedbackDelivered) { + setFeedbackSent(true); + setIsSendingFeedback(false); + storeApi.getState().setLocalAnnotations([]); + storeApi.getState().selectAnnotation(null); + storeApi.getState().setPendingSelection(null); + } else { + setSubmitted('feedback'); + } } else { throw new Error('Failed to send'); } @@ -1538,7 +1586,7 @@ const ReviewApp: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode; setTimeout(() => setCopyFeedback(null), 2000); setIsSendingFeedback(false); } - }, [totalAnnotationCount, feedbackMarkdown, allAnnotations]); + }, [totalAnnotationCount, feedbackMarkdown, allAnnotations, storeApi]); // Exit review session without sending any feedback const handleExit = useCallback(async () => { @@ -1736,7 +1784,7 @@ const ReviewApp: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode; // If the platform post dialog is open, Cmd+Enter submits it if (platformCommentDialog) { - if (submitted || isPlatformActioning) return; + if (submitted || feedbackSent || isPlatformActioning) return; const isApproveAction = platformCommentDialog.action === 'approve'; const hasTargets = platformCommentDialog.plan.targets.length > 0; const canSubmit = isApproveAction || hasTargets || platformGeneralComment.trim(); @@ -1749,7 +1797,7 @@ const ReviewApp: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode; const tag = (e.target as HTMLElement)?.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA') return; if (showExportModal || showNoAnnotationsDialog || showApproveWarning || showExitWarning) return; - if (submitted || isSendingFeedback || isApproving || isExiting || isPlatformActioning) return; + if (submitted || feedbackSent || isSendingFeedback || isApproving || isExiting || isPlatformActioning) return; if (!origin) return; // Demo mode e.preventDefault(); @@ -1777,7 +1825,7 @@ const ReviewApp: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode; }, [ showExportModal, showNoAnnotationsDialog, showApproveWarning, showExitWarning, platformCommentDialog, platformGeneralComment, - submitted, isSendingFeedback, isApproving, isExiting, isPlatformActioning, + submitted, feedbackSent, isSendingFeedback, isApproving, isExiting, isPlatformActioning, origin, platformMode, platformLabel, platformUser, prMetadata, totalAnnotationCount, openPlatformDialog, handleApprove, handleSendFeedback, handlePlatformAction ]); @@ -1923,7 +1971,7 @@ const ReviewApp: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode; - {origin && !submitted ? ( + {origin && !submitted && !feedbackSent ? ( <> {/* Destination dropdown (PR mode only) */} {prMetadata && ( @@ -2153,7 +2201,13 @@ const ReviewApp: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode; {/* Embedded completion banner — inline, non-blocking */} - {__embedded && !legacyTabMode && } + {__embedded && !legacyTabMode && ( + + )} {/* Main content */}
@@ -2463,9 +2517,9 @@ const ReviewApp: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode; {/* Full-screen overlay: standalone mode, or legacy tab mode even when embedded */} {(!__embedded || legacyTabMode) && ( )} diff --git a/packages/plannotator-code-review/utils/diffFiles.ts b/packages/plannotator-code-review/utils/diffFiles.ts new file mode 100644 index 000000000..039df59ae --- /dev/null +++ b/packages/plannotator-code-review/utils/diffFiles.ts @@ -0,0 +1,21 @@ +import type { DiffFile } from '../types'; + +/** + * Keep files marked "viewed" only if their patch is identical in the new diff. + * Files that changed or were added get removed from the set so they reappear. + */ +export function retainUnchangedViewedFiles( + oldFiles: DiffFile[], + newFiles: DiffFile[], + viewedFiles: Set, +): Set { + if (viewedFiles.size === 0) return viewedFiles; + const oldPatches = new Map(oldFiles.map(f => [f.path, f.patch])); + const retained = new Set(); + for (const file of newFiles) { + if (viewedFiles.has(file.path) && oldPatches.get(file.path) === file.patch) { + retained.add(file.path); + } + } + return retained; +} diff --git a/packages/plannotator-plan-review/App.tsx b/packages/plannotator-plan-review/App.tsx index 55992b33c..25c448998 100644 --- a/packages/plannotator-plan-review/App.tsx +++ b/packages/plannotator-plan-review/App.tsx @@ -59,6 +59,7 @@ import { useArchive } from '@plannotator/ui/hooks/useArchive'; import { useEditorAnnotations } from '@plannotator/ui/hooks/useEditorAnnotations'; import { useExternalAnnotations } from '@plannotator/ui/hooks/useExternalAnnotations'; import { useExternalAnnotationHighlights } from '@plannotator/ui/hooks/useExternalAnnotationHighlights'; +import { subscribeToDaemonSessionFamily } from '@plannotator/ui/utils/daemonHub'; import { buildPlanAgentInstructions } from '@plannotator/ui/utils/planAgentInstructions'; import { useFileBrowser } from '@plannotator/ui/hooks/useFileBrowser'; import { isVaultBrowserEnabled } from '@plannotator/ui/utils/obsidian'; @@ -123,6 +124,8 @@ const App: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode; onOpen return getComputedStyle(rootRef.current).visibility !== 'hidden'; }, []); const [markdown, setMarkdown] = useState(DEMO_PLAN_CONTENT); + const markdownRef = useRef(markdown); + markdownRef.current = markdown; const [annotations, setAnnotations] = useState([]); const [codeAnnotations, setCodeAnnotations] = useState([]); const [selectedAnnotationId, setSelectedAnnotationId] = useState(null); @@ -184,6 +187,8 @@ const App: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode; onOpen const [isSubmitting, setIsSubmitting] = useState(false); const [isExiting, setIsExiting] = useState(false); const [submitted, setSubmitted] = useState<'approved' | 'denied' | 'exited' | null>(null); + const [awaitingResubmission, setAwaitingResubmission] = useState(false); + const [feedbackSent, setFeedbackSent] = useState(false); const [pendingPasteImage, setPendingPasteImage] = useState<{ file: File; blobUrl: string; initialName: string } | null>(null); const [showPermissionModeSetup, setShowPermissionModeSetup] = useState(false); const [permissionMode, setPermissionMode] = useState('bypassPermissions'); @@ -588,6 +593,39 @@ const App: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode; onOpen const { editorAnnotations, deleteEditorAnnotation } = useEditorAnnotations(); const { externalAnnotations, updateExternalAnnotation, deleteExternalAnnotation } = useExternalAnnotations({ enabled: isApiMode && !goalSetupMode }); + // Listen for session-revision events (plan/annotate resubmission or reactivation) + useEffect(() => { + if (!isApiMode) return; + const unsubscribe = subscribeToDaemonSessionFamily("session-revision", (msg) => { + if (!msg.payload) return; + const revision = msg.payload as { plan?: string; previousPlan?: string | null; versionInfo?: { version: number; totalVersions: number; project: string }; rawHtml?: string }; + if (revision.plan === undefined) return; + const contentChanged = revision.plan !== markdownRef.current; + if (contentChanged) { + if (revision.rawHtml !== undefined) { + setRawHtml(revision.rawHtml); + setRenderAs('html'); + } + setMarkdown(revision.plan); + if (revision.previousPlan !== undefined) setPreviousPlan(revision.previousPlan); + if (revision.versionInfo) setVersionInfo(revision.versionInfo); + setAnnotations([]); + setCodeAnnotations([]); + setGlobalAttachments([]); + setSelectedAnnotationId(null); + setSelectedCodeAnnotationId(null); + linkedDocHook.clearCache(); + } + if (contentChanged || msg.type === "event") { + setAwaitingResubmission(false); + setFeedbackSent(false); + setSubmitted(null); + setIsSubmitting(false); + } + }); + return unsubscribe; + }, [isApiMode]); + // Drive DOM highlights for SSE-delivered external annotations. Disabled // while a linked doc overlay is open (Viewer DOM is hidden) and while the // plan diff view is active (diff view has its own annotation surface). @@ -764,7 +802,7 @@ const App: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode; onOpen if (!res.ok) throw new Error('Not in API mode'); return res.json(); }) - .then((data: { plan: string; origin?: Origin; mode?: 'annotate' | 'annotate-last' | 'annotate-folder' | 'archive' | 'goal-setup'; goalSetup?: GoalSetupBundle; filePath?: string; sourceInfo?: string; sourceConverted?: boolean; gate?: boolean; renderAs?: 'html' | 'markdown'; rawHtml?: string; sharingEnabled?: boolean; shareBaseUrl?: string; pasteApiUrl?: string; repoInfo?: { display: string; branch?: string; host?: string }; previousPlan?: string | null; versionInfo?: { version: number; totalVersions: number; project: string }; archivePlans?: ArchivedPlan[]; projectRoot?: string; isWSL?: boolean; serverConfig?: { displayName?: string; gitUser?: string } }) => { + .then((data: { plan: string; origin?: Origin; mode?: 'annotate' | 'annotate-last' | 'annotate-folder' | 'archive' | 'goal-setup'; goalSetup?: GoalSetupBundle; filePath?: string; sourceInfo?: string; sourceConverted?: boolean; gate?: boolean; renderAs?: 'html' | 'markdown'; rawHtml?: string; sharingEnabled?: boolean; shareBaseUrl?: string; pasteApiUrl?: string; repoInfo?: { display: string; branch?: string; host?: string }; previousPlan?: string | null; versionInfo?: { version: number; totalVersions: number; project: string }; archivePlans?: ArchivedPlan[]; projectRoot?: string; isWSL?: boolean; serverConfig?: { displayName?: string; gitUser?: string }; lastDecision?: 'approved' | 'denied' | 'exited' | 'feedback' | null }) => { // Initialize config store with server-provided values (config file > cookie > default) configStore.init(data.serverConfig); setGitUser(data.serverConfig?.gitUser); @@ -843,6 +881,23 @@ const App: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode; onOpen if (data.isWSL) { setIsWSL(true); } + if (data.lastDecision) { + const isAnnotate = data.mode === 'annotate' || data.mode === 'annotate-last' || data.mode === 'annotate-folder'; + if (data.lastDecision === 'approved') { + setSubmitted('approved'); + } else if (data.lastDecision === 'denied') { + setAwaitingResubmission(true); + } else if (data.lastDecision === 'exited') { + setSubmitted('exited'); + } else if (data.lastDecision === 'feedback') { + const isFileBased = data.mode === 'annotate'; + if (isAnnotate && !isFileBased) { + setFeedbackSent(true); + } else { + setAwaitingResubmission(true); + } + } + } }) .catch(() => { // Not in API mode - use default content @@ -1077,7 +1132,7 @@ const App: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode; onOpen setIsSubmitting(true); try { const planSaveSettings = getPlanSaveSettings(); - await fetch('/api/deny', { + const response = await fetch('/api/deny', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -1088,7 +1143,13 @@ const App: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode; onOpen }, }) }); - setSubmitted('denied'); + const data = await response.json().catch(() => ({})); + if (data.awaitingResubmission) { + setAwaitingResubmission(true); + setIsSubmitting(false); + } else { + setSubmitted('denied'); + } } catch { setIsSubmitting(false); } @@ -1098,7 +1159,7 @@ const App: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode; onOpen const handleAnnotateFeedback = async () => { setIsSubmitting(true); try { - await fetch('/api/feedback', { + const response = await fetch('/api/feedback', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -1107,7 +1168,16 @@ const App: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode; onOpen codeAnnotations, }), }); - setSubmitted('denied'); // reuse 'denied' state for "feedback sent" overlay + const data = await response.json().catch(() => ({})); + if (data.awaitingResubmission) { + setAwaitingResubmission(true); + setIsSubmitting(false); + } else if (data.feedbackSent) { + setFeedbackSent(true); + setIsSubmitting(false); + } else { + setSubmitted('denied'); + } } catch { setIsSubmitting(false); } @@ -1175,8 +1245,8 @@ const App: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode; onOpen if (showExport || showImport || showFeedbackPrompt || showClaudeCodeWarning || showExitWarning || showAgentWarning || showPermissionModeSetup || pendingPasteImage) return; - // Don't intercept if already submitted, submitting, or exiting - if (submitted || isSubmitting || isExiting || goalSetupAction.isSubmitting) return; + // Don't intercept if already submitted, submitting, exiting, or awaiting resubmission + if (submitted || isSubmitting || isExiting || awaitingResubmission || feedbackSent || goalSetupAction.isSubmitting) return; // Don't intercept in demo/share mode (no API) if (!isApiMode) return; @@ -1754,7 +1824,7 @@ const App: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode; onOpen gate={gate} isSharedSession={isSharedSession} origin={origin} - submitted={!!submitted} + submitted={!!submitted || awaitingResubmission || feedbackSent} isSubmitting={isSubmitting} isExiting={isExiting} isPanelOpen={isPanelOpen} @@ -1803,7 +1873,13 @@ const App: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode; onOpen /> {/* Embedded completion banner — inline, non-blocking (skipped in legacy tab mode) */} - {__embedded && !legacyTabMode && } + {__embedded && !legacyTabMode && ( + + )} {/* Linked document error banner */} {linkedDocHook.error && ( @@ -2245,9 +2321,9 @@ const App: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode; onOpen {/* Full-screen overlay: standalone mode, or legacy tab mode even when embedded */} {(!__embedded || legacyTabMode) && ( )} diff --git a/packages/server/annotate.ts b/packages/server/annotate.ts index aaa6155cc..f420f0314 100644 --- a/packages/server/annotate.ts +++ b/packages/server/annotate.ts @@ -20,8 +20,11 @@ import { warmFileListCache } from "@plannotator/shared/resolve-file"; import { contentHash, deleteDraft } from "./draft"; import { createExternalAnnotationHandler } from "./external-annotations"; import { saveConfig, detectGitUser, getServerConfig } from "./config"; +import { generateSlug, saveToHistory, getPlanVersion, getVersionCount, listVersions } from "./storage"; +import { detectProjectName } from "./project"; import { dirname, resolve as resolvePath } from "path"; import { isWSL } from "./browser"; +import { createDecisionCycle, resolveAndCycle } from "./session-handler"; import type { SessionEventBridge, SessionRequestHandler } from "./session-handler"; // Re-export utilities @@ -92,6 +95,8 @@ export interface AnnotateSession { handleRequest: SessionRequestHandler; waitForDecision: AnnotateServerResult["waitForDecision"]; dispose: () => void; + updateContent: (newMarkdown: string, newRawHtml?: string) => void; + getSnapshot?: () => unknown; } // --- Server Implementation --- @@ -104,7 +109,7 @@ export async function createAnnotateSession( ): Promise { const { cwd = process.cwd(), - markdown, + markdown: initialMarkdown, filePath, htmlContent, origin, @@ -116,9 +121,11 @@ export async function createAnnotateSession( shareBaseUrl, pasteApiUrl, gate = false, - rawHtml, + rawHtml: initialRawHtml, renderHtml = false, } = options; + let markdown = initialMarkdown; + let rawHtml = initialRawHtml; // Side-channel pre-warm so /api/doc/exists POSTs land on warm cache. void warmFileListCache(cwd, "code"); @@ -129,31 +136,42 @@ export async function createAnnotateSession( mode === "annotate-folder" && folderPath ? `folder:${resolvePath(folderPath)}` : renderHtml && rawHtml ? rawHtml : markdown; - const draftKey = contentHash(draftSource); + let draftKey = contentHash(draftSource); const externalAnnotations = createExternalAnnotationHandler("plan", { publishEvent: (event) => options.sessionEvents?.publishEvent("external-annotations", event), registerSnapshotProvider: (provider) => options.sessionEvents?.registerSnapshotProvider("external-annotations", provider), }); + options.sessionEvents?.registerSnapshotProvider("session-revision", () => ({ + plan: markdown, previousPlan, versionInfo, + ...(rawHtml !== undefined && { rawHtml }), + })); // Detect repo info (cached for this session) const repoInfo = await getRepoInfo(cwd); - // Decision promise - let resolveDecision: (result: { - feedback: string; - annotations: unknown[]; - exit?: boolean; - approved?: boolean; - }) => void; - const decisionPromise = new Promise<{ - feedback: string; - annotations: unknown[]; - exit?: boolean; - approved?: boolean; - }>((resolve) => { - resolveDecision = resolve; - }); + // Version history (single-file annotate only — folders have no single document to track) + const isFileBased = mode === "annotate"; + const project = isFileBased ? ((await detectProjectName(cwd)) ?? "_unknown") : ""; + const slug = isFileBased ? generateSlug(markdown) : ""; + let previousPlan: string | null = null; + let versionInfo: { version: number; totalVersions: number; project: string } | null = null; + + if (isFileBased && markdown.trim()) { + const historyResult = saveToHistory(project, slug, markdown); + previousPlan = historyResult.version > 1 + ? getPlanVersion(project, slug, historyResult.version - 1) + : null; + versionInfo = { + version: historyResult.version, + totalVersions: getVersionCount(project, slug), + project, + }; + } + + type AnnotateDecisionResult = { feedback: string; annotations: unknown[]; exit?: boolean; approved?: boolean }; + const decisionCycle = createDecisionCycle(); + let lastDecision: 'approved' | 'exited' | 'feedback' | null = null; const handleRequest: SessionRequestHandler = async (req, url, context) => { @@ -173,12 +191,31 @@ export async function createAnnotateSession( shareBaseUrl, pasteApiUrl, repoInfo, + previousPlan, + versionInfo, projectRoot: folderPath || cwd, isWSL: wslFlag, serverConfig: getServerConfig(gitUser), + lastDecision, }); } + // API: Get a specific version from history + if (url.pathname === "/api/plan/version" && isFileBased) { + const vParam = url.searchParams.get("v"); + if (!vParam) return new Response("Missing v parameter", { status: 400 }); + const v = parseInt(vParam, 10); + if (isNaN(v) || v < 1) return new Response("Invalid version number", { status: 400 }); + const content = getPlanVersion(project, slug, v); + if (content === null) return Response.json({ error: "Version not found" }, { status: 404 }); + return Response.json({ plan: content, version: v }); + } + + // API: List all versions + if (url.pathname === "/api/plan/versions" && isFileBased) { + return Response.json({ project, slug, versions: listVersions(project, slug) }); + } + // API: Update user config (write-back to ~/.plannotator/config.json) if (url.pathname === "/api/config" && req.method === "POST") { try { @@ -258,14 +295,16 @@ export async function createAnnotateSession( // API: Exit annotation session without feedback if (url.pathname === "/api/exit" && req.method === "POST") { deleteDraft(draftKey); - resolveDecision({ feedback: "", annotations: [], exit: true }); + lastDecision = 'exited'; + resolveAndCycle(decisionCycle, { feedback: "", annotations: [], exit: true }, origin); return Response.json({ ok: true }); } // API: Approve the annotation session (review-gate UX, #570) if (url.pathname === "/api/approve" && req.method === "POST") { deleteDraft(draftKey); - resolveDecision({ feedback: "", annotations: [], approved: true }); + lastDecision = 'approved'; + resolveAndCycle(decisionCycle, { feedback: "", annotations: [], approved: true }, origin); return Response.json({ ok: true }); } @@ -278,12 +317,16 @@ export async function createAnnotateSession( }; deleteDraft(draftKey); - resolveDecision({ + lastDecision = 'feedback'; + const resubmit = resolveAndCycle(decisionCycle, { feedback: body.feedback || "", annotations: body.annotations || [], - }); + }, origin); - return Response.json({ ok: true }); + if (resubmit.awaitingResubmission && !isFileBased) { + return Response.json({ ok: true, feedbackSent: true }); + } + return Response.json({ ok: true, ...resubmit }); } catch (err) { const message = err instanceof Error @@ -302,13 +345,39 @@ export async function createAnnotateSession( }); }; + function handleUpdateContent(newMarkdown: string, newRawHtml?: string) { + markdown = newMarkdown; + lastDecision = null; + rawHtml = newRawHtml; + if (isFileBased && newMarkdown.trim()) { + const historyResult = saveToHistory(project, slug, newMarkdown); + previousPlan = historyResult.version > 1 + ? getPlanVersion(project, slug, historyResult.version - 1) + : null; + versionInfo = { + version: historyResult.version, + totalVersions: getVersionCount(project, slug), + project, + }; + } + externalAnnotations.clearAll(); + deleteDraft(draftKey); + draftKey = contentHash(renderHtml && rawHtml ? rawHtml : newMarkdown); + options.sessionEvents?.publishEvent("session-revision", { + plan: newMarkdown, previousPlan, versionInfo, + ...(rawHtml !== undefined && { rawHtml }), + }); + } + return { htmlContent, handleRequest, - waitForDecision: () => decisionPromise, + waitForDecision: () => decisionCycle.promise(), dispose: () => { externalAnnotations.dispose(); }, + getSnapshot: isFileBased ? () => ({ plan: markdown, filePath, mode, sourceInfo }) : undefined, + updateContent: handleUpdateContent, }; } diff --git a/packages/server/daemon/server.ts b/packages/server/daemon/server.ts index 944d22caa..21829db82 100644 --- a/packages/server/daemon/server.ts +++ b/packages/server/daemon/server.ts @@ -444,8 +444,10 @@ export function createDaemonFetchHandler(options: DaemonServerOptions): DaemonFe requestContext?.disableIdleTimeout?.(); const completed = await store.waitForResult(id); const response = json({ ok: true, session: store.summary(completed), result: completed.result ?? null }); - const timer = setTimeout(() => void store.delete(id), RESULT_DELETE_GRACE_MS); - timer.unref?.(); + if (completed.status !== "awaiting-resubmission" && completed.status !== "idle") { + const timer = setTimeout(() => void store.delete(id), RESULT_DELETE_GRACE_MS); + timer.unref?.(); + } return response; } diff --git a/packages/server/daemon/session-factory.test.ts b/packages/server/daemon/session-factory.test.ts index 1a0f02ec8..723d59e8a 100644 --- a/packages/server/daemon/session-factory.test.ts +++ b/packages/server/daemon/session-factory.test.ts @@ -100,7 +100,7 @@ describe("createDaemonSessionFactory", () => { ); const completed = await store.waitForResult<{ approved: boolean }>(record.id); - expect(completed.status).toBe("completed"); + expect(completed.status).toBe("awaiting-resubmission"); expect(completed.result?.approved).toBe(true); }); diff --git a/packages/server/daemon/session-factory.ts b/packages/server/daemon/session-factory.ts index eca9b02ec..0f75634fa 100644 --- a/packages/server/daemon/session-factory.ts +++ b/packages/server/daemon/session-factory.ts @@ -25,6 +25,7 @@ import type { } from "@plannotator/shared/plugin-protocol"; import { normalizeGoalSetupBundle } from "@plannotator/shared/goal-setup"; import { createPlannotatorSession } from "../index"; +import { generateSlug } from "../storage"; import { createAnnotateSession } from "../annotate"; import { createGoalSetupSession } from "../goal-setup"; import { createReviewSession } from "../review"; @@ -96,6 +97,26 @@ function createSessionEventBridge(context: DaemonFetchContext, id: string): Sess }; } +interface SessionDecisionResult { + approved?: boolean; + feedback?: string; + [key: string]: unknown; +} + +function createDecisionScope(dispose: () => void | Promise) { + let releaseDecisionWait: (() => void) | undefined; + const disposed = new Promise((_, reject) => { + releaseDecisionWait = () => reject(new Error("Session disposed.")); + }); + disposed.catch(() => {}); + const cleanup = () => { + releaseDecisionWait?.(); + releaseDecisionWait = undefined; + return dispose(); + }; + return { disposed, cleanup }; +} + function registerSessionDecision( context: DaemonFetchContext, id: string, @@ -103,10 +124,7 @@ function registerSessionDecision( dispose: () => void | Promise, mapResult: (result: TResult) => TStored = (result) => result as unknown as TStored, ): () => void | Promise { - let releaseDecisionWait: (() => void) | undefined; - const disposed = new Promise((_, reject) => { - releaseDecisionWait = () => reject(new Error("Session disposed.")); - }); + const { disposed, cleanup } = createDecisionScope(dispose); void Promise.race([waitForDecision(), disposed]) .then((result) => context.store.complete(id, mapResult(result))) @@ -116,11 +134,64 @@ function registerSessionDecision( } }); - return () => { - releaseDecisionWait?.(); - releaseDecisionWait = undefined; - return dispose(); + return cleanup; +} + +function registerDecisionLoop( + context: DaemonFetchContext, + id: string, + session: { waitForDecision: () => Promise; dispose: () => void | Promise }, + onResult: (result: SessionDecisionResult) => "continue" | "done", + activeStatuses: Set, +): () => void | Promise { + const { disposed, cleanup } = createDecisionScope(session.dispose); + + const loop = async () => { + let lastPromise: Promise | null = null; + while (true) { + const currentPromise = session.waitForDecision(); + if (currentPromise === lastPromise) return; + lastPromise = currentPromise; + const result = await Promise.race([currentPromise, disposed]); + if (onResult(result) === "done") return; + } }; + + void loop().catch((err) => { + const record = context.store.get(id); + if (record && activeStatuses.has(record.status)) { + context.store.fail(id, err instanceof Error ? err.message : String(err)); + } + }); + + return cleanup; +} + +const PERSISTENT_ACTIVE = new Set(["active", "awaiting-resubmission"]); +const REVIEW_ACTIVE = new Set(["active", "idle"]); + +function registerPersistentDecision( + context: DaemonFetchContext, + id: string, + session: { waitForDecision: () => Promise; dispose: () => void | Promise }, +) { + return registerDecisionLoop(context, id, session, (result) => { + context.store.suspend(id, result); + return "continue"; + }, PERSISTENT_ACTIVE); +} + +// Review sessions stay alive (idle) after every decision — including approve/exit. +// Sessions persist until daemon restart. +function registerReviewDecision( + context: DaemonFetchContext, + id: string, + session: { waitForDecision: () => Promise; dispose: () => void | Promise }, +) { + return registerDecisionLoop(context, id, session, (result) => { + context.store.idle(id, result); + return "continue"; + }, REVIEW_ACTIVE); } function resolvePlanFilePath(planFilePath: string, cwd: string): string { @@ -482,6 +553,31 @@ async function prepareReviewInput(request: PluginReviewRequest, cwd: string) { } export function createDaemonSessionFactory(options: DaemonSessionFactoryOptions) { + interface PersistableSession { + waitForDecision: () => Promise; + dispose: () => void | Promise; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + updateContent?: (...args: any[]) => void | Promise; + } + const sessionRefs = new Map(); + + const RESUBMIT_STATUSES = new Set(["awaiting-resubmission"]); + const RESUBMIT_OR_IDLE_STATUSES = new Set(["awaiting-resubmission", "idle"]); + + function findMatchingSession(store: DaemonFetchContext["store"], matchKey: string, matchStatuses = RESUBMIT_STATUSES) { + for (const [sessionId, ref] of sessionRefs) { + const record = store.get(sessionId); + if (!record || record.status === "completed" || record.status === "expired" || record.status === "failed" || record.status === "cancelled") { + sessionRefs.delete(sessionId); + continue; + } + if (!matchStatuses.has(record.status)) continue; + if (record.matchKey !== matchKey) continue; + return { record, session: ref.session }; + } + return null; + } + return async function createSession( createRequest: DaemonCreateSessionRequest, context: DaemonFetchContext, @@ -513,6 +609,18 @@ export function createDaemonSessionFactory(options: DaemonSessionFactoryOptions) if (request.action === "plan") { const plan = await readPlanRequest(request, cwd); + const matchKey = `plan:${project}:${generateSlug(plan)}`; + + const existing = findMatchingSession(context.store, matchKey); + if (existing && existing.session.updateContent) { + existing.session.updateContent(plan); + if (context.endpoint.isRemote && sharingEnabled) { + existing.record.remoteShare = await createRemoteShareNotice(plan, shareBaseUrl, "review the plan", "plan only").catch(() => undefined); + } + context.store.reactivate(existing.record.id); + return existing.record; + } + const remoteShare = context.endpoint.isRemote && sharingEnabled ? await createRemoteShareNotice(plan, shareBaseUrl, "review the plan", "plan only").catch(() => undefined) : undefined; @@ -538,12 +646,14 @@ export function createDaemonSessionFactory(options: DaemonSessionFactoryOptions) cwd, label: branch ? `plugin-plan-${request.origin}-${project}-${branch}` : `plugin-plan-${request.origin}-${project}`, origin: request.origin, + matchKey, ttlMs, handleRequest: session.handleRequest, - dispose: registerSessionDecision(context, id, () => session.waitForDecision(), () => session.dispose()), + dispose: registerPersistentDecision(context, id, session), remoteShare, - snapshot: () => ({ plan, origin: request.origin }), + snapshot: session.getSnapshot ?? (() => ({ plan, origin: request.origin })), }); + sessionRefs.set(id, { matchKey, session }); return record; } @@ -582,6 +692,28 @@ export function createDaemonSessionFactory(options: DaemonSessionFactoryOptions) if (request.action === "annotate" || request.action === "annotate-last") { const input = await resolveAnnotateInput(request, cwd, request.action); + const isSingleFile = input.mode === "annotate"; + const isFolder = input.mode === "annotate-folder"; + const matchKey = isSingleFile + ? `annotate:${project}:${input.filePath}` + : isFolder && input.folderPath + ? `annotate:${project}:folder:${input.folderPath}` + : undefined; + + if (matchKey) { + const existing = findMatchingSession(context.store, matchKey); + if (existing) { + if (existing.session.updateContent) { + existing.session.updateContent(input.markdown, input.rawHtml); + } + if (context.endpoint.isRemote && sharingEnabled && input.markdown) { + existing.record.remoteShare = await createRemoteShareNotice(input.markdown, shareBaseUrl, "annotate", "document only").catch(() => undefined); + } + context.store.reactivate(existing.record.id); + return existing.record; + } + } + const remoteShare = context.endpoint.isRemote && sharingEnabled && input.markdown ? await createRemoteShareNotice(input.markdown, shareBaseUrl, "annotate", "document only").catch(() => undefined) : undefined; @@ -605,21 +737,37 @@ export function createDaemonSessionFactory(options: DaemonSessionFactoryOptions) ? `plugin-annotate-${request.origin}-${basename(input.folderPath)}${branch ? `-${branch}` : ""}` : `plugin-annotate-${request.origin}-${input.mode === "annotate-last" ? "last" : basename(input.filePath)}${branch ? `-${branch}` : ""}`, origin: request.origin, + matchKey, ttlMs, handleRequest: session.handleRequest, - dispose: registerSessionDecision(context, id, () => session.waitForDecision(), () => session.dispose(), (result) => ({ - ...result, - filePath: input.filePath, - mode: input.mode, - })), + dispose: registerPersistentDecision(context, id, session), remoteShare, - snapshot: () => ({ plan: input.markdown, filePath: input.filePath, mode: input.mode, sourceInfo: input.sourceInfo }), + snapshot: session.getSnapshot ?? (() => ({ plan: input.markdown, filePath: input.filePath, mode: input.mode, sourceInfo: input.sourceInfo })), }); + if (matchKey) sessionRefs.set(id, { matchKey, session }); return record; } if (request.action === "review") { const input = await prepareReviewInput(request, cwd); + const reviewMatchKey = input.prMetadata + ? `review:${input.prMetadata.url}` + : branch ? `review:${project}:${branch}` : `review:${project}`; + + const existingReview = findMatchingSession(context.store, reviewMatchKey, RESUBMIT_OR_IDLE_STATUSES); + if (existingReview && existingReview.session.updateContent) { + await Promise.resolve(input.onCleanup?.()).catch(() => {}); + await existingReview.session.updateContent( + input.prMetadata ? input.rawPatch : undefined, + input.prMetadata ? input.gitRef : undefined, + ); + if (context.endpoint.isRemote && sharingEnabled) { + existingReview.record.remoteShare = await createRemoteShareNotice(input.rawPatch, shareBaseUrl, "review changes", "diff only").catch(() => undefined); + } + context.store.reactivate(existingReview.record.id); + return existingReview.record; + } + const sessionError = [input.error, input.localWarning].filter(Boolean).join("\n\n") || undefined; const remoteShare = context.endpoint.isRemote && sharingEnabled ? await createRemoteShareNotice(input.rawPatch, shareBaseUrl, "review changes", "diff only").catch(() => undefined) @@ -662,18 +810,20 @@ export function createDaemonSessionFactory(options: DaemonSessionFactoryOptions) ? `plugin-${getMRLabel(input.prMetadata).toLowerCase()}-review-${getDisplayRepo(input.prMetadata)}${getMRNumberLabel(input.prMetadata)}` : branch ? `plugin-review-${request.origin}-${project}-${branch}` : `plugin-review-${request.origin}-${project}`, origin: request.origin, + matchKey: reviewMatchKey, ttlMs, handleRequest: session.handleRequest, - dispose: registerSessionDecision(context, id, () => session.waitForDecision(), () => session.dispose()), + dispose: registerReviewDecision(context, id, session), remoteShare, - snapshot: () => ({ + snapshot: session.getSnapshot ?? (() => ({ rawPatch: input.rawPatch, gitRef: input.gitRef, origin: request.origin, diffType: input.gitContext ? (input.diffType ?? "unstaged") : undefined, gitContext: input.gitContext ? { currentBranch: input.gitContext.currentBranch, base: input.base } : undefined, - }), + })), }); + sessionRefs.set(id, { matchKey: reviewMatchKey, session }); return record; } diff --git a/packages/server/daemon/session-store.ts b/packages/server/daemon/session-store.ts index 50b1c90f7..b5ea0cdcf 100644 --- a/packages/server/daemon/session-store.ts +++ b/packages/server/daemon/session-store.ts @@ -19,6 +19,8 @@ export interface DaemonSessionRecord { cwd?: string; label: string; origin?: string; + matchKey?: string; + ttlMs?: number; createdAt: string; updatedAt: string; expiresAt?: string; @@ -40,6 +42,7 @@ export interface CreateDaemonSessionInput { cwd?: string; label: string; origin?: string; + matchKey?: string; ttlMs?: number; now?: number; htmlContent?: string; @@ -170,6 +173,8 @@ export class DaemonSessionStore { label: input.label, ...(input.cwd && { cwd: input.cwd }), ...(input.origin && { origin: input.origin }), + ...(input.matchKey && { matchKey: input.matchKey }), + ...(input.ttlMs !== undefined && { ttlMs: input.ttlMs }), createdAt: iso(now), updatedAt: iso(now), ...(input.ttlMs !== undefined && { expiresAt: iso(now + input.ttlMs) }), @@ -253,6 +258,44 @@ export class DaemonSessionStore { return record; } + suspend(id: string, result: TResult): DaemonSessionRecord | undefined { + const record = this.sessions.get(id) as DaemonSessionRecord | undefined; + if (!record || record.status !== "active") return record; + record.status = "awaiting-resubmission"; + record.result = result; + const now = this.now(); + record.updatedAt = iso(now); + delete record.expiresAt; + this.resolveWaiters(record); + this.emit("session-updated", record); + return record; + } + + idle(id: string, result?: TResult): DaemonSessionRecord | undefined { + const record = this.sessions.get(id) as DaemonSessionRecord | undefined; + if (!record || TERMINAL_STATUSES.has(record.status) || record.status === "idle") return undefined; + record.status = "idle"; + if (result !== undefined) record.result = result as TResult; + const now = this.now(); + record.updatedAt = iso(now); + delete record.expiresAt; + this.resolveWaiters(record); + this.emit("session-updated", record); + return record; + } + + reactivate(id: string): DaemonSessionRecord | undefined { + const record = this.sessions.get(id); + if (!record || (record.status !== "awaiting-resubmission" && record.status !== "idle")) return record; + record.status = "active"; + record.result = undefined; + const now = this.now(); + record.updatedAt = iso(now); + delete record.expiresAt; + this.emit("session-updated", record); + return record; + } + async cancel(id: string, reason = "Session cancelled."): Promise { const record = this.sessions.get(id); if (!record || TERMINAL_STATUSES.has(record.status)) return record; @@ -270,7 +313,8 @@ export class DaemonSessionStore { waitForResult(id: string): Promise> { const record = this.sessions.get(id) as DaemonSessionRecord | undefined; if (!record) return Promise.reject(new Error(`Session not found: ${id}`)); - if (TERMINAL_STATUSES.has(record.status)) return Promise.resolve(record); + const hasIntermediateResult = (record.status === "idle" || record.status === "awaiting-resubmission") && record.result !== undefined; + if (TERMINAL_STATUSES.has(record.status) || hasIntermediateResult) return Promise.resolve(record); return new Promise((resolve, reject) => { const waiters = this.waiters.get(id) ?? []; waiters.push({ resolve: resolve as (record: DaemonSessionRecord) => void, reject }); diff --git a/packages/server/editor-annotations.ts b/packages/server/editor-annotations.ts index cd01f2fcc..97d74bd9d 100644 --- a/packages/server/editor-annotations.ts +++ b/packages/server/editor-annotations.ts @@ -12,12 +12,14 @@ export type { EditorAnnotation }; export interface EditorAnnotationHandler { handle: (req: Request, url: URL) => Promise; + clearAll: () => void; } export function createEditorAnnotationHandler(): EditorAnnotationHandler { const annotations: EditorAnnotation[] = []; return { + clearAll() { annotations.length = 0; }, async handle(req: Request, url: URL): Promise { // GET /api/editor-annotations — return all if (url.pathname === "/api/editor-annotations" && req.method === "GET") { diff --git a/packages/server/external-annotations.ts b/packages/server/external-annotations.ts index b2c511700..8879d6478 100644 --- a/packages/server/external-annotations.ts +++ b/packages/server/external-annotations.ts @@ -32,6 +32,8 @@ export interface ExternalAnnotationHandler { ) => Promise; /** Push annotations directly into the store (bypasses HTTP, reuses same validation). */ addAnnotations: (body: unknown) => { ids: string[] } | { error: string }; + /** Remove all annotations from the store. */ + clearAll: () => void; dispose: () => void; } @@ -222,6 +224,10 @@ export function createExternalAnnotationHandler( return null; }, + clearAll(): void { + store.clearAll(); + }, + dispose(): void { disposed = true; for (const cleanup of Array.from(streamCleanups)) cleanup(); diff --git a/packages/server/index.ts b/packages/server/index.ts index 82728769d..90288fac4 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -50,6 +50,7 @@ import { resolveUserPath, warmFileListCache } from "@plannotator/shared/resolve- import { createEditorAnnotationHandler } from "./editor-annotations"; import { createExternalAnnotationHandler } from "./external-annotations"; import { isWSL } from "./browser"; +import { createDecisionCycle, resolveAndCycle } from "./session-handler"; import type { SessionEventBridge, SessionRequestHandler } from "./session-handler"; // Re-export utilities @@ -118,6 +119,9 @@ export interface PlannotatorSession { waitForDecision: ServerResult["waitForDecision"]; waitForDone?: () => Promise; dispose: () => void; + slug?: string; + updateContent?: (newPlan: string) => void; + getSnapshot?: () => unknown; } // --- Server Implementation --- @@ -137,7 +141,8 @@ const RETRY_DELAY_MS = 500; export async function createPlannotatorSession( options: ServerOptions ): Promise { - const { cwd = process.cwd(), plan, origin, htmlContent, permissionMode, sharingEnabled = true, shareBaseUrl, pasteApiUrl, mode, customPlanPath } = options; + const { cwd = process.cwd(), plan: initialPlan, origin, htmlContent, permissionMode, sharingEnabled = true, shareBaseUrl, pasteApiUrl, mode, customPlanPath } = options; + let plan = initialPlan; const resolvePlanStoragePath = (customPath?: string | null): string | undefined => { if (!customPath?.trim()) return undefined; return resolveUserPath(customPath, cwd); @@ -166,13 +171,14 @@ export async function createPlannotatorSession( } // --- Plan review mode setup (skip in archive mode) --- - const draftKey = mode !== "archive" ? contentHash(plan) : ""; + let draftKey = mode !== "archive" ? contentHash(plan) : ""; const editorAnnotations = mode !== "archive" ? createEditorAnnotationHandler() : null; const externalAnnotations = mode !== "archive" ? createExternalAnnotationHandler("plan", { publishEvent: (event) => options.sessionEvents?.publishEvent("external-annotations", event), registerSnapshotProvider: (provider) => options.sessionEvents?.registerSnapshotProvider("external-annotations", provider), }) : null; + if (mode !== "archive") options.sessionEvents?.registerSnapshotProvider("session-revision", () => ({ plan, previousPlan, versionInfo })); const slug = mode !== "archive" ? generateSlug(plan) : ""; // Lazy cache for in-session archive browsing (plan review sidebar tab) @@ -185,20 +191,15 @@ export async function createPlannotatorSession( let previousPlan: string | null = null; let versionInfo = { version: 0, totalVersions: 0, project: "" }; - let resolveDecision: (result: { + type DecisionResult = { approved: boolean; feedback?: string; savedPath?: string; agentSwitch?: string; permissionMode?: string; - }) => void; - let decisionPromise: Promise<{ - approved: boolean; - feedback?: string; - savedPath?: string; - agentSwitch?: string; - permissionMode?: string; - }>; + }; + const decisionCycle = createDecisionCycle(); + let lastDecision: 'approved' | 'denied' | null = null; if (mode !== "archive") { repoInfo = await getRepoInfo(cwd); @@ -215,12 +216,8 @@ export async function createPlannotatorSession( project, }; - decisionPromise = new Promise((resolve) => { - resolveDecision = resolve; - }); } else { - // Never-resolving promise — archive mode uses waitForDone instead - decisionPromise = new Promise(() => {}); + // Archive mode: decision cycle exists but is never resolved (uses waitForDone instead) } const handleRequest: SessionRequestHandler = async (req, url, context) => { @@ -293,7 +290,7 @@ export async function createPlannotatorSession( serverConfig: getServerConfig(gitUser), }); } - return Response.json({ plan, origin, permissionMode, sharingEnabled, shareBaseUrl, pasteApiUrl, repoInfo, previousPlan, versionInfo, projectRoot: cwd, isWSL: wslFlag, serverConfig: getServerConfig(gitUser) }); + return Response.json({ plan, origin, permissionMode, sharingEnabled, shareBaseUrl, pasteApiUrl, repoInfo, previousPlan, versionInfo, projectRoot: cwd, isWSL: wslFlag, serverConfig: getServerConfig(gitUser), lastDecision }); } // API: Serve a linked markdown document @@ -541,7 +538,8 @@ export async function createPlannotatorSession( // Use permission mode from client request if provided, otherwise fall back to hook input const effectivePermissionMode = requestedPermissionMode || permissionMode; - resolveDecision({ approved: true, feedback, savedPath, agentSwitch, permissionMode: effectivePermissionMode }); + lastDecision = 'approved'; + resolveAndCycle(decisionCycle, { approved: true, feedback, savedPath, agentSwitch, permissionMode: effectivePermissionMode }, origin); return Response.json({ ok: true, savedPath }); } @@ -578,8 +576,9 @@ export async function createPlannotatorSession( } deleteDraft(draftKey); - resolveDecision({ approved: false, feedback, savedPath }); - return Response.json({ ok: true, savedPath }); + lastDecision = 'denied'; + const resubmit = resolveAndCycle(decisionCycle, { approved: false, feedback, savedPath }, origin); + return Response.json({ ok: true, savedPath, ...resubmit }); } // Favicon @@ -591,14 +590,37 @@ export async function createPlannotatorSession( }); }; + function handleUpdateContent(newPlan: string) { + plan = newPlan; + lastDecision = null; + const historyResult = saveToHistory(project, slug, newPlan); + currentPlanPath = historyResult.path; + previousPlan = historyResult.version > 1 + ? getPlanVersion(project, slug, historyResult.version - 1) + : null; + versionInfo = { + version: historyResult.version, + totalVersions: getVersionCount(project, slug), + project, + }; + externalAnnotations?.clearAll(); + editorAnnotations?.clearAll(); + deleteDraft(draftKey); + draftKey = contentHash(newPlan); + options.sessionEvents?.publishEvent("session-revision", { plan: newPlan, previousPlan, versionInfo }); + } + return { htmlContent, handleRequest, - waitForDecision: () => decisionPromise, + waitForDecision: () => decisionCycle.promise(), ...(donePromise && { waitForDone: () => donePromise }), dispose: () => { externalAnnotations?.dispose(); }, + slug: mode !== "archive" ? slug : undefined, + getSnapshot: mode !== "archive" ? () => ({ plan, origin }) : undefined, + updateContent: mode !== "archive" ? handleUpdateContent : undefined, }; } diff --git a/packages/server/review.ts b/packages/server/review.ts index b26b5e5e6..632b4e4a2 100644 --- a/packages/server/review.ts +++ b/packages/server/review.ts @@ -49,6 +49,7 @@ import { type PRMetadata, type PRReviewFileComment, type PRStackTree, type PRLis import { createAIEndpoints, ProviderRegistry, SessionManager, createProvider, type AIEndpoints, type PiSDKConfig } from "@plannotator/ai"; import { isWSL } from "./browser"; import { handleCodeNavResolve, extractChangedFiles } from "./code-nav"; +import { createDecisionCycle, resolveAndCycle } from "./session-handler"; import type { SessionEventBridge, SessionRequestHandler } from "./session-handler"; // Re-export utilities @@ -130,6 +131,8 @@ export interface ReviewSession { waitForDecision: ReviewServerResult["waitForDecision"]; setServerUrl: (url: string) => void; dispose: () => void; + updateContent?: (precomputedPatch?: string, precomputedGitRef?: string) => Promise; + getSnapshot?: () => unknown; } export interface ResolveReviewScopedAgentCwdOptions { @@ -171,6 +174,7 @@ export async function createReviewSession( registerSnapshotProvider: (provider) => options.sessionEvents?.registerSnapshotProvider("external-annotations", provider), }); + options.sessionEvents?.registerSnapshotProvider("session-revision", () => ({ rawPatch: currentPatch, gitRef: currentGitRef })); const tour = createTourSession(); @@ -503,22 +507,9 @@ export async function createReviewSession( } // Decision promise - let resolveDecision: (result: { - approved: boolean; - feedback: string; - annotations: unknown[]; - agentSwitch?: string; - exit?: boolean; - }) => void; - const decisionPromise = new Promise<{ - approved: boolean; - feedback: string; - annotations: unknown[]; - agentSwitch?: string; - exit?: boolean; - }>((resolve) => { - resolveDecision = resolve; - }); + type ReviewDecisionResult = { approved: boolean; feedback: string; annotations: unknown[]; agentSwitch?: string; exit?: boolean }; + const decisionCycle = createDecisionCycle(); + let lastDecision: 'approved' | 'feedback' | 'exited' | null = null; const handleRequest: SessionRequestHandler = async (req, url, context) => { @@ -572,6 +563,7 @@ export async function createReviewSession( ...(isPRMode && initialViewedFiles.length > 0 && { viewedFiles: initialViewedFiles }), ...(currentError && { error: currentError }), serverConfig: getServerConfig(gitUser), + lastDecision, }); } @@ -1050,7 +1042,8 @@ export async function createReviewSession( // API: Exit review session without feedback if (url.pathname === "/api/exit" && req.method === "POST") { deleteDraft(draftKey); - resolveDecision({ approved: false, feedback: "", annotations: [], exit: true }); + lastDecision = 'exited'; + resolveAndCycle(decisionCycle, { approved: false, feedback: "", annotations: [], exit: true }, origin); return Response.json({ ok: true }); } @@ -1065,14 +1058,12 @@ export async function createReviewSession( }; deleteDraft(draftKey); - resolveDecision({ - approved: body.approved ?? false, - feedback: body.feedback || "", - annotations: body.annotations || [], - agentSwitch: body.agentSwitch, - }); + const isApproved = body.approved ?? false; + lastDecision = isApproved ? 'approved' : 'feedback'; + const result = { approved: isApproved, feedback: body.feedback || "", annotations: body.annotations || [], agentSwitch: body.agentSwitch }; + const resubmit = resolveAndCycle(decisionCycle, result, origin); - return Response.json({ ok: true }); + return Response.json({ ok: true, feedbackDelivered: resubmit.awaitingResubmission || undefined }); } catch (err) { const message = err instanceof Error ? err.message : "Failed to process feedback"; @@ -1181,10 +1172,35 @@ export async function createReviewSession( const exitHandler = () => agentJobs.killAll(); process.once("exit", exitHandler); + async function handleUpdateContent(precomputedPatch?: string, precomputedGitRef?: string) { + let patch: string; + let label: string; + if (precomputedPatch !== undefined) { + patch = precomputedPatch; + label = precomputedGitRef ?? currentGitRef; + currentError = undefined; + } else { + const result = await runVcsDiff(currentDiffType, currentBase, gitContext?.cwd, { + hideWhitespace: currentHideWhitespace, + }); + patch = result.patch; + label = result.label; + currentError = result.error; + } + currentPatch = patch; + currentGitRef = label; + lastDecision = null; + externalAnnotations.clearAll(); + editorAnnotations.clearAll(); + deleteDraft(draftKey); + draftKey = contentHash(patch); + options.sessionEvents?.publishEvent("session-revision", { rawPatch: patch, gitRef: label }); + } + return { htmlContent, handleRequest, - waitForDecision: () => decisionPromise, + waitForDecision: () => decisionCycle.promise(), setServerUrl: (url) => { serverUrl = url; }, @@ -1194,7 +1210,6 @@ export async function createReviewSession( agentJobs.dispose(); aiSessionManager.disposeAll(); aiRegistry.disposeAll(); - // Invoke cleanup callback (e.g., remove temp worktree) if (options.onCleanup) { try { const result = options.onCleanup(); @@ -1202,6 +1217,14 @@ export async function createReviewSession( } catch { /* best effort */ } } }, + getSnapshot: () => ({ + rawPatch: currentPatch, + gitRef: currentGitRef, + origin, + diffType: currentDiffType, + gitContext: gitContext ? { currentBranch: gitContext.currentBranch, base: currentBase } : undefined, + }), + updateContent: handleUpdateContent, }; } diff --git a/packages/server/session-handler.ts b/packages/server/session-handler.ts index f8bfcd578..19e07d7a9 100644 --- a/packages/server/session-handler.ts +++ b/packages/server/session-handler.ts @@ -3,7 +3,7 @@ export interface SessionRequestContext { upgradeWebSocket?: (data: unknown) => Response | undefined; } -export type SessionEventFamily = "external-annotations" | "agent-jobs"; +export type SessionEventFamily = "external-annotations" | "agent-jobs" | "session-revision"; export type SessionEventPublisher = ( family: SessionEventFamily, @@ -27,3 +27,45 @@ export type SessionRequestHandler = ( url: URL, context?: SessionRequestContext, ) => Response | Promise; + +/** + * Manages a resolvable decision cycle for session servers. + * Each deny/feedback starts a new cycle; approve/exit is final. + * + * Invariant: promise() returns a new Promise object after startNew(). + * The decision loop in session-factory uses reference identity to detect + * when no new cycle was started (same promise === loop should exit). + */ +export function createDecisionCycle() { + let current: { promise: Promise; resolve: (result: T) => void }; + function start() { + let resolve: (result: T) => void; + const promise = new Promise((r) => { resolve = r; }); + current = { promise, resolve: resolve! }; + } + start(); + return { + promise: () => current.promise, + resolve: (result: T) => current.resolve(result), + startNew: () => start(), + }; +} + +/** + * Resolve the current decision cycle. If the session has an agent origin, + * start a new cycle and include `awaitingResubmission: true` in the response. + */ +const NON_AGENT_ORIGINS = new Set(["plannotator-frontend"]); + +export function resolveAndCycle( + cycle: ReturnType>, + result: T, + origin: string | undefined, +): { awaitingResubmission?: true } { + cycle.resolve(result); + if (origin && !NON_AGENT_ORIGINS.has(origin)) { + cycle.startNew(); + return { awaitingResubmission: true }; + } + return {}; +} diff --git a/packages/shared/daemon-protocol.test.ts b/packages/shared/daemon-protocol.test.ts index 2b5c59d53..18be196c2 100644 --- a/packages/shared/daemon-protocol.test.ts +++ b/packages/shared/daemon-protocol.test.ts @@ -35,6 +35,7 @@ describe("daemon protocol", () => { "daemon", "external-annotations", "agent-jobs", + "session-revision", ]); expect(PLANNOTATOR_DAEMON_SESSION_VIEWS).toEqual([ "plan", diff --git a/packages/shared/daemon-protocol.ts b/packages/shared/daemon-protocol.ts index d7caf7909..531979e12 100644 --- a/packages/shared/daemon-protocol.ts +++ b/packages/shared/daemon-protocol.ts @@ -1,7 +1,7 @@ import type { PluginRequest, PluginSessionMode } from "./plugin-protocol"; export const PLANNOTATOR_DAEMON_PROTOCOL = "plannotator-daemon"; -export const PLANNOTATOR_DAEMON_PROTOCOL_VERSION = 1; +export const PLANNOTATOR_DAEMON_PROTOCOL_VERSION = 2; export const PLANNOTATOR_DAEMON_MIN_CLIENT_VERSION = 1; export const PLANNOTATOR_DAEMON_FEATURES = [ @@ -24,6 +24,7 @@ export const PLANNOTATOR_DAEMON_EVENT_FAMILIES = [ "daemon", "external-annotations", "agent-jobs", + "session-revision", ] as const; export const PLANNOTATOR_DAEMON_SESSION_VIEWS = [ @@ -40,6 +41,8 @@ export type DaemonSessionMode = PluginSessionMode; export type DaemonSessionView = (typeof PLANNOTATOR_DAEMON_SESSION_VIEWS)[number]; export type DaemonSessionStatus = | "active" + | "idle" + | "awaiting-resubmission" | "completed" | "cancelled" | "expired" diff --git a/packages/ui/components/CompletionBanner.tsx b/packages/ui/components/CompletionBanner.tsx index 6f9c68b95..c80c613ee 100644 --- a/packages/ui/components/CompletionBanner.tsx +++ b/packages/ui/components/CompletionBanner.tsx @@ -1,37 +1,57 @@ interface CompletionBannerProps { - submitted: 'approved' | 'denied' | 'feedback' | 'exited' | null | false; + submitted: 'approved' | 'denied' | 'feedback' | 'exited' | 'awaiting' | 'feedback-sent' | null | false; title: string; subtitle: string; + onCancel?: () => void; } -export function CompletionBanner({ submitted, title, subtitle }: CompletionBannerProps) { +export function CompletionBanner({ submitted, title, subtitle, onCancel }: CompletionBannerProps) { if (!submitted) return null; const isApproved = submitted === 'approved'; + const isAwaiting = submitted === 'awaiting'; + const isFeedbackSent = submitted === 'feedback-sent'; return (
- - {isApproved ? ( - - ) : ( - - )} - -
+ {isAwaiting ? ( + + + + ) : ( + + {isApproved || isFeedbackSent ? ( + + ) : ( + + )} + + )} +
{title} {subtitle}
+ {isAwaiting && onCancel && ( + + )}
); } diff --git a/packages/ui/components/CompletionOverlay.tsx b/packages/ui/components/CompletionOverlay.tsx index 218680969..517082507 100644 --- a/packages/ui/components/CompletionOverlay.tsx +++ b/packages/ui/components/CompletionOverlay.tsx @@ -25,7 +25,7 @@ const ChatBubbleIcon = () => ( // --------------------------------------------------------------------------- interface CompletionOverlayProps { - submitted: 'approved' | 'denied' | 'feedback' | 'exited' | null | false; + submitted: 'approved' | 'denied' | 'feedback' | 'feedback-sent' | 'exited' | null | false; title: string; subtitle: string; agentLabel: string; @@ -36,17 +36,17 @@ export function CompletionOverlay({ submitted, title, subtitle, agentLabel }: Co if (!submitted) return null; - const isApproved = submitted === 'approved'; + const isSuccess = submitted === 'approved' || submitted === 'feedback-sent'; return (
- {isApproved ? : } + {isSuccess ? : }
diff --git a/packages/ui/hooks/useLinkedDoc.ts b/packages/ui/hooks/useLinkedDoc.ts index 9993f35b8..008ec3c8f 100644 --- a/packages/ui/hooks/useLinkedDoc.ts +++ b/packages/ui/hooks/useLinkedDoc.ts @@ -64,6 +64,8 @@ export interface UseLinkedDocReturn { getDocAnnotations: () => Map; /** Reactive count of annotations on non-active documents (updates on open() and back()) */ docAnnotationCount: number; + /** Clear all cached linked-doc annotations (used on session revision) */ + clearCache: () => void; } const HIGHLIGHT_REAPPLY_DELAY = 100; @@ -297,5 +299,6 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn { dismissError, getDocAnnotations, docAnnotationCount, + clearCache: () => { docCache.current.clear(); setDocAnnotationCount(0); }, }; } diff --git a/packages/ui/hooks/usePlanDiff.ts b/packages/ui/hooks/usePlanDiff.ts index 735df927c..13e0c51f9 100644 --- a/packages/ui/hooks/usePlanDiff.ts +++ b/packages/ui/hooks/usePlanDiff.ts @@ -66,16 +66,15 @@ export function usePlanDiff( const [isSelectingVersion, setIsSelectingVersion] = useState(false); const [fetchingVersion, setFetchingVersion] = useState(null); - // Sync diffBasePlan when initialPreviousPlan arrives after mount (API response) + // Sync diff base when previousPlan or versionInfo changes (initial load + resubmissions) useEffect(() => { - if (initialPreviousPlan && !diffBasePlan) { + if (initialPreviousPlan) { setDiffBasePlan(initialPreviousPlan); } }, [initialPreviousPlan]); - // Sync diffBaseVersion when versionInfo arrives after mount useEffect(() => { - if (versionInfo && versionInfo.version > 1 && diffBaseVersion === null) { + if (versionInfo && versionInfo.version > 1) { setDiffBaseVersion(versionInfo.version - 1); } }, [versionInfo]);