diff --git a/apps/hook/server/codex-session.ts b/apps/hook/server/codex-session.ts index 585e5f2d..8d45d2c1 100644 --- a/apps/hook/server/codex-session.ts +++ b/apps/hook/server/codex-session.ts @@ -341,6 +341,53 @@ export function getLastCodexMessage( return null; } +/** + * Extract up to `limit` of the most recent assistant messages from a Codex + * rollout file. Returned newest-first. + * + * Used by the picker UI to let users choose among recent messages rather + * than always defaulting to the newest transcript entry — which is incorrect + * after a /rewind. + */ +export interface CodexRecentMessage { + messageId: string; + text: string; + timestamp?: string; +} + +export function getRecentCodexMessages( + rolloutPath: string, + limit: number, + options: GetLastCodexMessageOptions = {} +): CodexRecentMessage[] { + if (limit <= 0) return []; + const entries = parseRolloutEntries(rolloutPath); + const activeTurnStart = options.beforeActiveTurn + ? findActiveTurnStartIndex(entries) + : -1; + const endIndex = activeTurnStart === -1 ? entries.length - 1 : activeTurnStart - 1; + + const messages: CodexRecentMessage[] = []; + for (let i = endIndex; i >= 0; i--) { + if (messages.length >= limit) break; + const entry = entries[i]; + if (entry.type !== "response_item") continue; + if (entry.payload?.type !== "message") continue; + if (entry.payload?.role !== "assistant") continue; + + const text = getMessageText(entry, ["output_text"]); + if (!text) continue; + // Codex doesn't expose a stable message id in the rollout format we read, + // so fall back to an index-based id. Stable within a single rollout read. + messages.push({ + messageId: `codex-msg-${i}`, + text, + timestamp: entry.timestamp, + }); + } + return messages; +} + /** * Extract the latest Codex plan from a rollout file. * diff --git a/apps/hook/server/copilot-session.ts b/apps/hook/server/copilot-session.ts index 78ff9203..8934d171 100644 --- a/apps/hook/server/copilot-session.ts +++ b/apps/hook/server/copilot-session.ts @@ -1,7 +1,7 @@ /** * Copilot CLI Session Parser * - * Extracts the last rendered assistant message from a Copilot CLI session. + * Extracts recent assistant messages and plan content from a Copilot CLI session. * Copilot CLI stores sessions at ~/.copilot/session-state// * * Detection: The COPILOT_CLI=1 environment variable is set in Copilot CLI sessions. @@ -153,23 +153,21 @@ export function findCopilotPlanContent(sessionId?: string): string | null { // --- Message Extraction --- /** - * Extract the last assistant message from a Copilot CLI session. - * - * Walks backward through events.jsonl, finds the last `assistant.message` - * event with non-empty content. Accumulates content from multiple events - * in the same turn (identified by interactionId) if needed. + * Walk backward through events.jsonl, returning up to `limit` recent + * `assistant.message` events with non-empty content (newest first). */ -export function getLastCopilotMessage( +export function getRecentCopilotMessages( sessionDir: string, -): { text: string } | null { + limit: number, +): { messageId: string; text: string; timestamp?: string }[] { const eventsPath = join(sessionDir, "events.jsonl"); - if (!existsSync(eventsPath)) return null; + if (!existsSync(eventsPath)) return []; const content = readFileSync(eventsPath, "utf-8"); const lines = content.trim().split("\n"); - // Walk backward to find the last assistant message with content - for (let i = lines.length - 1; i >= 0; i--) { + const out: { messageId: string; text: string; timestamp?: string }[] = []; + for (let i = lines.length - 1; i >= 0 && out.length < limit; i--) { let event: CopilotEvent; try { event = JSON.parse(lines[i]); @@ -180,8 +178,11 @@ export function getLastCopilotMessage( if (event.type !== "assistant.message") continue; if (!event.data.content?.trim()) continue; - return { text: event.data.content }; + out.push({ + messageId: event.data.messageId || event.id, + text: event.data.content, + timestamp: event.timestamp, + }); } - - return null; + return out; } diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 174ed289..88e56dfd 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -109,14 +109,14 @@ import { findDroidSessionLogsForCwd, findSessionLogsByAncestorWalk, findSessionLogsForCwd, - getLastRenderedMessage, + getRecentRenderedMessages, resolveDroidSessionLogForCwd, resolveSessionLogByAncestorPids, resolveSessionLogByCwdScan, type RenderedMessage, } from "./session-log"; -import { findCodexRolloutByThreadId, getLastCodexMessage, getLatestCodexPlan } from "./codex-session"; -import { findCopilotPlanContent, findCopilotSessionForCwd, getLastCopilotMessage } from "./copilot-session"; +import { findCodexRolloutByThreadId, getLatestCodexPlan, getRecentCodexMessages } from "./codex-session"; +import { findCopilotPlanContent, findCopilotSessionForCwd, getRecentCopilotMessages } from "./copilot-session"; import { formatInteractiveNoArgClarification, formatTopLevelHelp, @@ -152,10 +152,10 @@ const noJinaIdx = args.indexOf("--no-jina"); const cliNoJina = noJinaIdx !== -1; if (cliNoJina) args.splice(noJinaIdx, 1); -// Annotate review-gate flags (#570): --gate adds an Approve button, -// --json switches stdout to structured decision output, --hook emits -// hook-native JSON that works directly with Claude Code and Codex -// PostToolUse/Stop hook protocols. +// Annotate review-gate flags: --gate adds an Approve button, --json +// switches stdout to structured decision output, --hook emits hook-native +// JSON that works directly with Claude Code and Codex PostToolUse/Stop +// hook protocols. const gateIdx = args.indexOf("--gate"); let gateFlag = gateIdx !== -1; if (gateFlag) args.splice(gateIdx, 1); @@ -170,7 +170,7 @@ const renderHtmlIdx = args.indexOf("--render-html"); const renderHtmlFlag = renderHtmlIdx !== -1; if (renderHtmlFlag) args.splice(renderHtmlIdx, 1); -// Stdout matrix for annotate / annotate-last / copilot annotate-last (#570). +// Stdout matrix for annotate / annotate-last / copilot annotate-last. // // --hook (recommended for hooks): // Approve/Close → empty stdout (hook passes, agent proceeds). @@ -853,7 +853,15 @@ if (args[0] === "sessions") { const isCodex = !!codexThreadId; const isDroid = detectedOrigin === "droid"; + // Collect up to N recent assistant messages so the user can pick the right + // one — defaults to the same selection as the legacy "last message" + // behavior (index 0). Necessary because the newest transcript entry isn't + // always the message the user intended to annotate (e.g., after /rewind). + // 25 covers long conversations worth of rewinds without flooding the + // picker; the list scrolls past this if more are shown. + const RECENT_MESSAGES_LIMIT = 25; let lastMessage: RenderedMessage | null = null; + let recentMessages: RenderedMessage[] = []; if (stdinFlag) { const text = (await Bun.stdin.text()).trim(); @@ -870,10 +878,9 @@ if (args[0] === "sessions") { if (process.env.PLANNOTATOR_DEBUG) { console.error(`[DEBUG] Rollout: ${rolloutPath}`); } - const msg = getLastCodexMessage(rolloutPath, { beforeActiveTurn: true }); - if (msg) { - lastMessage = { messageId: codexThreadId, text: msg.text, lineNumbers: [] }; - } + recentMessages = getRecentCodexMessages(rolloutPath, RECENT_MESSAGES_LIMIT, { beforeActiveTurn: true }) + .map((m) => ({ messageId: m.messageId, text: m.text, lineNumbers: [], timestamp: m.timestamp })); + lastMessage = recentMessages[0] ?? null; } } else if (isDroid) { // Droid/Factory path: resolve the current repo's session log from @@ -903,7 +910,8 @@ if (args[0] === "sessions") { console.error(`[DEBUG] Droid selected log: ${droidLog ?? "(none)"}`); } if (droidLog) { - lastMessage = getLastRenderedMessage(droidLog); + recentMessages = getRecentRenderedMessages(droidLog, RECENT_MESSAGES_LIMIT); + lastMessage = recentMessages[0] ?? null; } } else { // Claude Code path: resolve session log @@ -935,8 +943,12 @@ if (args[0] === "sessions") { console.error(`[DEBUG] ${label}: ${paths.length ? paths.join(", ") : "(none)"}`); } for (const logPath of paths) { - lastMessage = getLastRenderedMessage(logPath); - if (lastMessage) return; + const recent = getRecentRenderedMessages(logPath, RECENT_MESSAGES_LIMIT); + if (recent.length > 0) { + recentMessages = recent; + lastMessage = recent[0]; + return; + } } } @@ -969,6 +981,12 @@ if (args[0] === "sessions") { const annotatedMessage = lastMessage; const annotateProject = (await detectProjectName()) ?? "_unknown"; + // Only ship the picker list when there's a choice to make. The client uses + // its presence (length > 1) as the signal to render the picker UI. + const pickerMessages = recentMessages.length > 1 + ? recentMessages.map((m) => ({ messageId: m.messageId, text: m.text, timestamp: m.timestamp })) + : undefined; + const server = await startAnnotateServer({ markdown: annotatedMessage.text, filePath: "last-message", @@ -979,6 +997,7 @@ if (args[0] === "sessions") { pasteApiUrl, gate: gateFlag, htmlContent: planHtmlContent, + recentMessages: pickerMessages, onReady: async (url, isRemote, port) => { handleAnnotateServerReady(url, isRemote, port); @@ -1147,7 +1166,8 @@ if (args[0] === "sessions") { console.error(`[DEBUG] Session dir: ${sessionDir}`); } - const msg = getLastCopilotMessage(sessionDir); + const recent = getRecentCopilotMessages(sessionDir, 25); + const msg = recent[0] ?? null; if (!msg) { console.error("No assistant message found in Copilot CLI session."); process.exit(1); @@ -1158,12 +1178,14 @@ if (args[0] === "sessions") { } const annotateProject = (await detectProjectName()) ?? "_unknown"; + const pickerMessages = recent.length > 1 ? recent : undefined; const server = await startAnnotateServer({ markdown: msg.text, filePath: "last-message", origin: "copilot-cli", mode: "annotate-last", + recentMessages: pickerMessages, sharingEnabled, shareBaseUrl, gate: gateFlag, diff --git a/apps/hook/server/session-log.test.ts b/apps/hook/server/session-log.test.ts index 28c6cb3e..07537d6e 100644 --- a/apps/hook/server/session-log.test.ts +++ b/apps/hook/server/session-log.test.ts @@ -13,6 +13,7 @@ import { isHumanPrompt, findAnchorIndex, extractLastRenderedMessage, + extractRecentRenderedMessages, findDroidSessionLogsForCwd, resolveDroidSessionLogForCwd, projectSlugFromCwd, @@ -660,6 +661,84 @@ describe("extractLastRenderedMessage — edge cases", () => { }); }); +describe("extractRecentRenderedMessages (picker)", () => { + test("returns newest-first across turn boundaries", () => { + // Multiple turns — picker must surface assistant bubbles from all of them, + // not stop at the most recent turn boundary like extractLastRenderedMessage does. + const log = buildLog( + userPrompt("first"), + assistantText("msg_1", "First response"), + userPrompt("second"), + assistantText("msg_2", "Second response"), + userPrompt("third"), + assistantText("msg_3", "Third response"), + ); + const entries = parseSessionLog(log); + const result = extractRecentRenderedMessages(entries, entries.length, 5); + expect(result.map((m) => m.text)).toEqual([ + "Third response", + "Second response", + "First response", + ]); + expect(result.map((m) => m.messageId)).toEqual(["msg_3", "msg_2", "msg_1"]); + }); + + test("honors limit", () => { + const log = buildLog( + userPrompt("first"), + assistantText("a", "A"), + userPrompt("second"), + assistantText("b", "B"), + userPrompt("third"), + assistantText("c", "C"), + ); + const entries = parseSessionLog(log); + const result = extractRecentRenderedMessages(entries, entries.length, 2); + expect(result).toHaveLength(2); + expect(result.map((m) => m.text)).toEqual(["C", "B"]); + }); + + test("first entry matches extractLastRenderedMessage default", () => { + // The picker's default selection (index 0) must match today's annotate-last + // behavior — otherwise the no-interaction case would silently change. + const log = buildLog( + userPrompt("first"), + assistantText("msg_1", "Old"), + userPrompt("second"), + assistantText("msg_2", "New"), + ); + const entries = parseSessionLog(log); + const last = extractLastRenderedMessage(entries, entries.length)!; + const recent = extractRecentRenderedMessages(entries, entries.length, 5); + expect(recent[0].messageId).toBe(last.messageId); + expect(recent[0].text).toBe(last.text); + }); + + test("empty log → empty list", () => { + const result = extractRecentRenderedMessages([], 0, 5); + expect(result).toEqual([]); + }); + + test("limit of zero → empty list", () => { + const log = buildLog(userPrompt("first"), assistantText("a", "A")); + const entries = parseSessionLog(log); + expect(extractRecentRenderedMessages(entries, entries.length, 0)).toEqual([]); + }); + + test("skips assistant entries with no text (tool_use only)", () => { + const log = buildLog( + userPrompt("first"), + assistantText("msg_1", "Hello"), + assistantToolUse("msg_tool", "Bash"), + userPrompt("second"), + assistantText("msg_2", "World"), + ); + const entries = parseSessionLog(log); + const result = extractRecentRenderedMessages(entries, entries.length, 5); + expect(result.map((m) => m.text)).toEqual(["World", "Hello"]); + }); +}); + describe("parseSessionLog", () => { test("skips malformed lines", () => { const log = '{"type":"user"}\nnot json\n{"type":"assistant"}'; diff --git a/apps/hook/server/session-log.ts b/apps/hook/server/session-log.ts index 6f926e82..d052e490 100644 --- a/apps/hook/server/session-log.ts +++ b/apps/hook/server/session-log.ts @@ -68,6 +68,8 @@ export interface RenderedMessage { text: string; /** Line numbers in the JSONL where this message appeared */ lineNumbers: number[]; + /** Timestamp from the entry (ISO 8601), if available */ + timestamp?: string; } // --- Session File Discovery --- @@ -711,3 +713,81 @@ export function getLastRenderedMessage( return null; } } + +/** + * Extract up to `limit` of the most recent rendered assistant messages. + * + * Returned newest-first. Unlike `extractLastRenderedMessage`, this does not + * stop at turn boundaries (human prompts) — picker UIs want a flat list of + * recent assistant bubbles. Necessary for the rewind case: after `/rewind`, + * the message at the bottom of the terminal isn't the newest transcript + * entry, so the user needs to pick from a list. + * + * Chunks of a single API message (same message.id) are concatenated. + */ +export function extractRecentRenderedMessages( + entries: SessionLogEntry[], + beforeIndex: number, + limit: number, +): RenderedMessage[] { + if (limit <= 0) return []; + + // Map preserves insertion order — we walk backward, so first key inserted is + // newest. Each bucket collects the chunks of one API message (same message.id). + const buckets = new Map< + string, + { chunks: { texts: string[]; lineNum: number }[]; timestamp?: string } + >(); + + for (let i = beforeIndex - 1; i >= 0; i--) { + const entry = entries[i]; + if (!entry) continue; + + if (entry.type === "progress" || entry.type === "system") continue; + if (entry.type === "file-history-snapshot") continue; + if (entry.type === "queue-operation") continue; + if (getEntryRole(entry) !== "assistant") continue; + if (isHiddenTranscriptEntry(entry)) continue; + + const texts = extractTextBlocks(entry); + if (texts.length === 0) continue; + const msgId = getEntryMessageId(entry); + if (!msgId) continue; + + let bucket = buckets.get(msgId); + if (!bucket) { + if (buckets.size >= limit) continue; + const ts = typeof entry.timestamp === "string" ? entry.timestamp : undefined; + bucket = { chunks: [], timestamp: ts }; + buckets.set(msgId, bucket); + } + bucket.chunks.push({ texts, lineNum: i + 1 }); + } + + return Array.from(buckets, ([messageId, b]) => { + // Walked backward, so reverse to restore chronological order within a message + const chrono = b.chunks.slice().reverse(); + return { + messageId, + text: chrono.flatMap((e) => e.texts).join("\n"), + lineNumbers: chrono.map((e) => e.lineNum), + timestamp: b.timestamp, + }; + }); +} + +/** + * High-level: read up to `limit` recent assistant messages from a session log. + */ +export function getRecentRenderedMessages( + logPath: string, + limit: number, +): RenderedMessage[] { + try { + const content = readFileSync(logPath, "utf-8"); + const entries = parseSessionLog(content); + return extractRecentRenderedMessages(entries, entries.length, limit); + } catch { + return []; + } +} diff --git a/apps/opencode-plugin/commands.ts b/apps/opencode-plugin/commands.ts index 7a50c75a..7e62b6d3 100644 --- a/apps/opencode-plugin/commands.ts +++ b/apps/opencode-plugin/commands.ts @@ -172,7 +172,7 @@ export async function handleAnnotateCommand( // @ts-ignore - Event properties contain arguments const rawArgs = event.properties?.arguments || event.arguments || ""; - // #570: split --gate / --json out of the args; rest is the file path. + // Split --gate / --json out of the args; rest is the file path. // --json is accepted silently (OpenCode writes to session, not stdout). // parseAnnotateArgs strips leading @ on filePath (reference-mode convention). // `rawFilePath` preserves it for the scoped-package markdown fallback. @@ -349,7 +349,7 @@ export async function handleAnnotateLastCommand( // @ts-ignore - Event properties contain arguments const rawArgs = event.properties?.arguments || event.arguments || ""; - // #570: support --gate on /plannotator-last (Stop-hook review-gate pattern). + // Support --gate on /plannotator-last (Stop-hook review-gate pattern). const { gate } = parseAnnotateArgs(rawArgs); // @ts-ignore - Event properties contain sessionID @@ -365,23 +365,25 @@ export async function handleAnnotateLastCommand( }); const messages = messagesResponse.data; - // Walk backward, find last assistant message with text - let lastText: string | null = null; + const RECENT_LIMIT = 25; + const recentMessages: { messageId: string; text: string; timestamp?: string }[] = []; if (messages) { - for (let i = messages.length - 1; i >= 0; i--) { + for (let i = messages.length - 1; i >= 0 && recentMessages.length < RECENT_LIMIT; i--) { const msg = messages[i]; - if (msg.info.role === "assistant") { - const textParts = msg.parts - .filter((p: any) => p.type === "text" && p.text?.trim()) - .map((p: any) => p.text); - if (textParts.length > 0) { - lastText = textParts.join("\n"); - break; - } - } + if (msg.info.role !== "assistant") continue; + const textParts = msg.parts + .filter((p: any) => p.type === "text" && p.text?.trim()) + .map((p: any) => p.text); + if (textParts.length === 0) continue; + recentMessages.push({ + messageId: msg.info.id ?? `opencode-${i}`, + text: textParts.join("\n"), + timestamp: msg.info.time?.created ? new Date(msg.info.time.created).toISOString() : undefined, + }); } } + const lastText = recentMessages[0]?.text ?? null; if (!lastText) { client.app.log({ level: "error", message: "No assistant message found in session." }); return null; @@ -389,11 +391,14 @@ export async function handleAnnotateLastCommand( client.app.log({ level: "info", message: "Opening annotation UI for last message..." }); + const pickerMessages = recentMessages.length > 1 ? recentMessages : undefined; + const server = await startAnnotateServer({ markdown: lastText, filePath: "last-message", origin: "opencode", mode: "annotate-last", + recentMessages: pickerMessages, sharingEnabled: await getSharingEnabled(), shareBaseUrl: getShareBaseUrl(), pasteApiUrl: getPasteApiUrl(), diff --git a/apps/opencode-plugin/index.ts b/apps/opencode-plugin/index.ts index d33fd58e..aebeeef8 100644 --- a/apps/opencode-plugin/index.ts +++ b/apps/opencode-plugin/index.ts @@ -506,7 +506,7 @@ Do NOT proceed with implementation until your plan is approved.`); directory: ctx.directory, }; // input.arguments is the raw tail string from OpenCode's command dispatcher — - // needed so --gate / --json reach the handlers' parseAnnotateArgs (#570). + // needed so --gate / --json reach the handlers' parseAnnotateArgs. const event = { properties: { sessionID: input.sessionID, arguments: input.arguments }, }; diff --git a/apps/pi-extension/assistant-message.ts b/apps/pi-extension/assistant-message.ts index 46a9d4d0..5f3682cd 100644 --- a/apps/pi-extension/assistant-message.ts +++ b/apps/pi-extension/assistant-message.ts @@ -10,6 +10,7 @@ type AssistantMessageLike = { type SessionEntryLike = { id: string; type: string; + timestamp?: unknown; message?: AssistantMessageLike; }; @@ -18,6 +19,30 @@ export type LastAssistantMessageSnapshot = { text: string; }; +export type RecentAssistantMessage = { + messageId: string; + text: string; + timestamp?: string; +}; + +// Pi's SDK currently types `SessionEntryBase.timestamp` as `string`, but the +// picker contract everywhere else is ISO and we don't want a silent drift if +// that ever changes. Accept string/number(ms)/Date; drop anything else. +function normalizeTimestamp(value: unknown): string | undefined { + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? undefined : value.toISOString(); + } + if (typeof value === "number" && Number.isFinite(value)) { + const d = new Date(value); + return Number.isNaN(d.getTime()) ? undefined : d.toISOString(); + } + if (typeof value === "string" && value.trim()) { + const d = new Date(value); + return Number.isNaN(d.getTime()) ? undefined : d.toISOString(); + } + return undefined; +} + function isAssistantMessage(message: AssistantMessageLike): message is { role: "assistant"; content: AssistantTextBlock[] } { return message.role === "assistant" && Array.isArray(message.content); } @@ -63,6 +88,35 @@ export function getLastAssistantMessageText(ctx: ExtensionContext): string | nul return getLastAssistantMessageSnapshot(ctx)?.text ?? null; } +export function findAssistantMessageByEntryId( + ctx: ExtensionContext, + entryId: string, +): LastAssistantMessageSnapshot | null { + const branch = getCurrentBranch(ctx); + for (const entry of branch) { + if (entry.id !== entryId || entry.type !== "message" || !entry.message) continue; + const text = getAssistantMessageText(entry.message); + if (text) return { entryId: entry.id, text }; + } + return null; +} + +export function getRecentAssistantMessages( + ctx: ExtensionContext, + limit: number, +): RecentAssistantMessage[] { + const branch = getCurrentBranch(ctx); + const out: RecentAssistantMessage[] = []; + for (let i = branch.length - 1; i >= 0 && out.length < limit; i--) { + const entry = branch[i]; + if (entry.type !== "message" || !entry.message) continue; + const text = getAssistantMessageText(entry.message); + if (!text) continue; + out.push({ messageId: entry.id, text, timestamp: normalizeTimestamp(entry.timestamp) }); + } + return out; +} + export function hasSessionMovedPastEntry(ctx: ExtensionContext, entryId: string): boolean { if (!ctx.isIdle()) return true; diff --git a/apps/pi-extension/index.ts b/apps/pi-extension/index.ts index d5f2ff44..34786573 100644 --- a/apps/pi-extension/index.ts +++ b/apps/pi-extension/index.ts @@ -65,8 +65,10 @@ import { registerPlannotatorEventListeners, } from "./plannotator-events.js"; import { + findAssistantMessageByEntryId, getAssistantMessageText, getLastAssistantMessageSnapshot, + getRecentAssistantMessages, hasSessionMovedPastEntry, } from "./assistant-message.js"; import { @@ -489,7 +491,7 @@ export default function plannotator(pi: ExtensionAPI): void { pi.registerCommand("plannotator-annotate", { description: "Open markdown file or folder in annotation UI", handler: async (args, ctx) => { - // #570: split --gate / --json from the path. --json is silently + // Split --gate / --json from the path. --json is silently // accepted (Pi writes back via sendUserMessage, not stdout). // `rawFilePath` keeps any leading `@` for the literal-@ fallback // (scoped-package-style names). @@ -649,7 +651,7 @@ export default function plannotator(pi: ExtensionAPI): void { pi.registerCommand("plannotator-last", { description: "Annotate the last assistant message", handler: async (args, ctx) => { - // #570: support --gate on /plannotator-last for Stop-hook review gate. + // Support --gate on /plannotator-last for the Stop-hook review gate. const { gate } = parseAnnotateArgs(args ?? ""); if (!hasPlanBrowserHtml()) { @@ -669,10 +671,13 @@ export default function plannotator(pi: ExtensionAPI): void { return; } + const recent = getRecentAssistantMessages(ctx, 25); + const pickerMessages = recent.length > 1 ? recent : undefined; + ctx.ui.notify("Opening annotation UI for last message...", "info"); try { - const session = await startLastMessageAnnotationSession(ctx, snapshot.text, gate); + const session = await startLastMessageAnnotationSession(ctx, snapshot.text, gate, pickerMessages); ctx.ui.notify("Last-message annotation opened. You can keep chatting while it runs.", "info"); void session .waitForDecision() @@ -690,9 +695,14 @@ export default function plannotator(pi: ExtensionAPI): void { safeNotify(ctx, "Annotation closed (no feedback).", "info", origin); return; } - const feedback = shouldAnchorLastMessageFeedback(ctx, snapshot.entryId, origin) - ? anchorMessageFeedback(result.feedback, snapshot.text) - : result.feedback; + // Picker may have changed which message the feedback targets; if so, + // look that one up in the current branch so the anchor quote matches. + const target = result.selectedMessageId && result.selectedMessageId !== snapshot.entryId + ? findAssistantMessageByEntryId(ctx, result.selectedMessageId) ?? snapshot + : snapshot; + const feedback = result.feedbackScope !== "messages" && shouldAnchorLastMessageFeedback(ctx, target.entryId, origin) + ? anchorMessageFeedback(result.feedback, target.text) + : result.feedback; sendUserMessageWithCurrentSessionFallback( pi, getAnnotateMessageFeedbackPrompt("pi", loadConfig(), { diff --git a/apps/pi-extension/plannotator-browser.ts b/apps/pi-extension/plannotator-browser.ts index 6cc23ff6..f0c898ff 100644 --- a/apps/pi-extension/plannotator-browser.ts +++ b/apps/pi-extension/plannotator-browser.ts @@ -448,7 +448,7 @@ export async function openMarkdownAnnotation( sourceInfo?: string, sourceConverted?: boolean, gate?: boolean, -): Promise<{ feedback: string; exit?: boolean; approved?: boolean }> { +): Promise<{ feedback: string; exit?: boolean; approved?: boolean; selectedMessageId?: string; feedbackScope?: "message" | "messages" }> { const session = await startMarkdownAnnotationSession( ctx, filePath, @@ -473,7 +473,8 @@ export async function startMarkdownAnnotationSession( gate?: boolean, rawHtml?: string, renderHtml?: boolean, -): Promise> { + recentMessages?: { messageId: string; text: string; timestamp?: string }[], +): Promise> { if (!ctx.hasUI || !planHtmlContent) { throw new Error("Plannotator annotation browser is unavailable in this session."); } @@ -496,6 +497,7 @@ export async function startMarkdownAnnotationSession( origin: "pi", mode, folderPath, + recentMessages, sourceInfo, sourceConverted, gate, @@ -514,15 +516,18 @@ export async function openLastMessageAnnotation( ctx: ExtensionContext, lastText: string, gate?: boolean, -): Promise<{ feedback: string; exit?: boolean; approved?: boolean }> { - return openMarkdownAnnotation(ctx, "last-message", lastText, "annotate-last", undefined, undefined, undefined, gate); + recentMessages?: { messageId: string; text: string; timestamp?: string }[], +): Promise<{ feedback: string; exit?: boolean; approved?: boolean; selectedMessageId?: string; feedbackScope?: "message" | "messages" }> { + const session = await startLastMessageAnnotationSession(ctx, lastText, gate, recentMessages); + return session.waitForDecision(); } export async function startLastMessageAnnotationSession( ctx: ExtensionContext, lastText: string, gate?: boolean, -): Promise> { + recentMessages?: { messageId: string; text: string; timestamp?: string }[], +): Promise> { return startMarkdownAnnotationSession( ctx, "last-message", @@ -532,6 +537,9 @@ export async function startLastMessageAnnotationSession( undefined, undefined, gate, + undefined, + undefined, + recentMessages, ); } diff --git a/apps/pi-extension/plannotator-events.ts b/apps/pi-extension/plannotator-events.ts index 84d09d74..0b8edf22 100644 --- a/apps/pi-extension/plannotator-events.ts +++ b/apps/pi-extension/plannotator-events.ts @@ -3,6 +3,7 @@ import { homedir } from "node:os"; import { dirname, join } from "node:path"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import type { DiffType, VcsSelection } from "./server.js"; +import { getRecentAssistantMessages } from "./assistant-message.js"; import { getLastAssistantMessageText, getStartupErrorMessage, @@ -105,7 +106,7 @@ export interface PlannotatorAnnotatePayload { markdown?: string; mode?: "annotate" | "annotate-folder" | "annotate-last"; folderPath?: string; - /** Enable review-gate UX (Approve / Annotate / Close), #570 */ + /** Enable review-gate UX (Approve / Annotate / Close). */ gate?: boolean; } @@ -113,7 +114,7 @@ export interface PlannotatorAnnotationResult { feedback: string; /** True when the reviewer closed the session without providing feedback. */ exit?: boolean; - /** True when the reviewer clicked Approve in review-gate mode, #570 */ + /** True when the reviewer clicked Approve in review-gate mode. */ approved?: boolean; } @@ -295,12 +296,15 @@ export function registerPlannotatorEventListeners(pi: ExtensionAPI): void { } case "annotate-last": { const payload = request.payload; - const lastText = payload?.markdown?.trim() ? payload.markdown : getLastAssistantMessageText(ctx); + const usePayloadText = !!payload?.markdown?.trim(); + const lastText = usePayloadText ? payload!.markdown! : getLastAssistantMessageText(ctx); if (!lastText) { request.respond({ status: "unavailable", error: "No assistant message found in session." }); return; } - const result = await openLastMessageAnnotation(ctx, lastText, payload?.gate); + const recent = usePayloadText ? [] : getRecentAssistantMessages(ctx, 25); + const pickerMessages = recent.length > 1 ? recent : undefined; + const result = await openLastMessageAnnotation(ctx, lastText, payload?.gate, pickerMessages); request.respond({ status: "handled", result }); return; } diff --git a/apps/pi-extension/server/serverAnnotate.ts b/apps/pi-extension/server/serverAnnotate.ts index e3606c29..a8e797ba 100644 --- a/apps/pi-extension/server/serverAnnotate.ts +++ b/apps/pi-extension/server/serverAnnotate.ts @@ -31,7 +31,7 @@ export interface AnnotateServerResult { port: number; portSource: "env" | "remote-default" | "random"; url: string; - waitForDecision: () => Promise<{ feedback: string; annotations: unknown[]; exit?: boolean; approved?: boolean }>; + waitForDecision: () => Promise<{ feedback: string; annotations: unknown[]; exit?: boolean; approved?: boolean; selectedMessageId?: string; feedbackScope?: "message" | "messages" }>; stop: () => void; } @@ -42,6 +42,7 @@ export async function startAnnotateServer(options: { origin?: string; mode?: string; folderPath?: string; + recentMessages?: { messageId: string; text: string; timestamp?: string }[]; sharingEnabled?: boolean; shareBaseUrl?: string; pasteApiUrl?: string; @@ -66,12 +67,16 @@ export async function startAnnotateServer(options: { annotations: unknown[]; exit?: boolean; approved?: boolean; + selectedMessageId?: string; + feedbackScope?: "message" | "messages"; }) => void; const decisionPromise = new Promise<{ feedback: string; annotations: unknown[]; exit?: boolean; approved?: boolean; + selectedMessageId?: string; + feedbackScope?: "message" | "messages"; }>((r) => { resolveDecision = r; }); @@ -112,6 +117,7 @@ export async function startAnnotateServer(options: { repoInfo, projectRoot: options.folderPath || process.cwd(), serverConfig: getServerConfig(gitUser), + ...(options.recentMessages ? { recentMessages: options.recentMessages } : {}), }); } else if (url.pathname === "/api/config" && req.method === "POST") { try { @@ -165,6 +171,8 @@ export async function startAnnotateServer(options: { resolveDecision({ feedback: (body.feedback as string) || "", annotations: (body.annotations as unknown[]) || [], + selectedMessageId: typeof body.selectedMessageId === "string" ? body.selectedMessageId : undefined, + feedbackScope: body.feedbackScope === "messages" ? "messages" : body.feedbackScope === "message" ? "message" : undefined, }); json(res, { ok: true }); } catch (err) { diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 0f37b669..ae8280cc 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from 'react'; import { toast, Toaster } from 'sonner'; import { type Origin, getAgentName } from '@plannotator/shared/agents'; -import { parseMarkdownToBlocks, exportAnnotations, exportLinkedDocAnnotations, exportEditorAnnotations, exportCodeFileAnnotations, extractFrontmatter, wrapFeedbackForAgent, Frontmatter, type LinkedDocAnnotationEntry } from '@plannotator/ui/utils/parser'; +import { parseMarkdownToBlocks, exportAnnotations, exportLinkedDocAnnotations, exportEditorAnnotations, exportCodeFileAnnotations, exportMessageAnnotations, extractFrontmatter, wrapFeedbackForAgent, Frontmatter, type LinkedDocAnnotationEntry, type MessageAnnotationEntry } from '@plannotator/ui/utils/parser'; import { Viewer, ViewerHandle } from '@plannotator/ui/components/Viewer'; import { HtmlViewer } from '@plannotator/ui/components/html-viewer'; import { AnnotationPanel } from '@plannotator/ui/components/AnnotationPanel'; @@ -61,7 +61,7 @@ import { ImageAnnotator } from '@plannotator/ui/components/ImageAnnotator'; import { deriveImageName } from '@plannotator/ui/components/AttachmentsButton'; import { useSidebar, type SidebarTab } from '@plannotator/ui/hooks/useSidebar'; import { usePlanDiff, type VersionInfo } from '@plannotator/ui/hooks/usePlanDiff'; -import { useLinkedDoc } from '@plannotator/ui/hooks/useLinkedDoc'; +import { useLinkedDoc, type LinkedDocSessionState } from '@plannotator/ui/hooks/useLinkedDoc'; import { useCodeFilePopout } from '@plannotator/ui/hooks/useCodeFilePopout'; import { useAnnotationDraft } from '@plannotator/ui/hooks/useAnnotationDraft'; import { useArchive } from '@plannotator/ui/hooks/useArchive'; @@ -76,6 +76,7 @@ import { generateId } from '@plannotator/ui/utils/generateId'; import { SidebarTabs } from '@plannotator/ui/components/sidebar/SidebarTabs'; import { SidebarContainer } from '@plannotator/ui/components/sidebar/SidebarContainer'; import type { ArchivedPlan } from '@plannotator/ui/components/sidebar/ArchiveBrowser'; +import type { PickerMessage } from '@plannotator/ui/components/sidebar/MessagesBrowser'; import { PlanDiffViewer } from '@plannotator/ui/components/plan-diff/PlanDiffViewer'; import { CodeFilePopout, type CodeFileAnnotationInput } from '@plannotator/ui/components/CodeFilePopout'; import type { PlanDiffMode } from '@plannotator/ui/components/plan-diff/PlanDiffModeSwitcher'; @@ -109,6 +110,76 @@ type NoteAutoSaveResults = { octarine?: boolean; }; +type MessageAnnotationState = { + messageId: string; + text: string; + timestamp?: string; + linkedDocSession: LinkedDocSessionState; + codeAnnotations: CodeAnnotation[]; + selectedCodeAnnotationId: string | null; +}; + +const countLinkedDocSessionAnnotations = (session: LinkedDocSessionState): number => { + let total = + session.root.annotations.length + + session.root.globalAttachments.length; + for (const doc of session.docs.values()) { + total += doc.annotations.length + doc.globalAttachments.length; + } + return total; +}; + +const countMessageAnnotations = (state: MessageAnnotationState): number => + countLinkedDocSessionAnnotations(state.linkedDocSession) + + state.codeAnnotations.length; + +const createEmptyMessageState = (message: PickerMessage): MessageAnnotationState => ({ + messageId: message.messageId, + text: message.text, + timestamp: message.timestamp, + linkedDocSession: { + root: { + markdown: message.text, + annotations: [], + selectedAnnotationId: null, + globalAttachments: [], + }, + docs: new Map(), + }, + codeAnnotations: [], + selectedCodeAnnotationId: null, +}); + +const normalizeMessageState = ( + state: MessageAnnotationState, + message: PickerMessage, +): MessageAnnotationState => ({ + ...state, + text: message.text, + timestamp: message.timestamp, + linkedDocSession: { + root: { + ...state.linkedDocSession.root, + // The root document for a message is immutable and comes from the picker. + // Keep it as the source of truth so transient UI state cannot cache an + // empty markdown value for a message. + markdown: message.text, + }, + docs: new Map(state.linkedDocSession.docs), + }, +}); + +const buildMessageAnnotationCounts = ( + states: Map +): Map => { + const counts = new Map(); + for (const [messageId, state] of states) { + const count = countMessageAnnotations(state); + if (count > 0) counts.set(messageId, count); + } + return counts; +}; + const App: React.FC = () => { const [markdown, setMarkdown] = useState(DEMO_PLAN_CONTENT); const [annotations, setAnnotations] = useState([]); @@ -173,6 +244,10 @@ const App: React.FC = () => { const [annotateMode, setAnnotateMode] = useState(false); const [gate, setGate] = useState(false); const [annotateSource, setAnnotateSource] = useState<'file' | 'message' | 'folder' | null>(null); + const [recentMessages, setRecentMessages] = useState([]); + const [selectedMessageId, setSelectedMessageId] = useState(null); + const messageStateCacheRef = useRef>(new Map()); + const [cachedMessageAnnotationCounts, setCachedMessageAnnotationCounts] = useState>(new Map()); const [goalSetupBundle, setGoalSetupBundle] = useState(null); const goalSetupSurfaceRef = useRef(null); const [goalSetupAction, setGoalSetupAction] = useState({ @@ -497,8 +572,120 @@ const App: React.FC = () => { } }, [sidebar.activeTab, showFilesTab, fileBrowserDirs, vaultPath]); + const buildCurrentMessageState = React.useCallback((): MessageAnnotationState | null => { + if (annotateSource !== 'message' || !selectedMessageId) return null; + const msg = recentMessages.find((m) => m.messageId === selectedMessageId); + if (!msg) return null; + const snapshot = linkedDocHook.snapshotSession(); + return normalizeMessageState({ + messageId: msg.messageId, + text: msg.text, + timestamp: msg.timestamp, + linkedDocSession: snapshot, + codeAnnotations: [...codeAnnotations], + selectedCodeAnnotationId, + }, msg); + }, [ + annotateSource, + selectedMessageId, + recentMessages, + linkedDocHook.snapshotSession, + codeAnnotations, + selectedCodeAnnotationId, + ]); + + const getMessageStatesWithCurrent = React.useCallback((): Map => { + const states = new Map(messageStateCacheRef.current); + const current = buildCurrentMessageState(); + if (current) states.set(current.messageId, current); + return states; + }, [buildCurrentMessageState]); + + const saveCurrentMessageState = React.useCallback((): Map => { + const states = getMessageStatesWithCurrent(); + messageStateCacheRef.current = states; + setCachedMessageAnnotationCounts(buildMessageAnnotationCounts(states)); + return states; + }, [getMessageStatesWithCurrent]); + + const messageAnnotationEntries = React.useMemo(() => { + if (annotateSource !== 'message' || recentMessages.length === 0) return []; + const states = getMessageStatesWithCurrent(); + return recentMessages.map((msg) => { + const state = states.get(msg.messageId) ?? createEmptyMessageState(msg); + const linkedDocs: Map = new Map(); + for (const [filepath, doc] of state.linkedDocSession.docs) { + linkedDocs.set(filepath, { + ...doc, + blocks: doc.markdown ? parseMarkdownToBlocks(doc.markdown) : undefined, + }); + } + return { + messageId: msg.messageId, + text: msg.text, + timestamp: msg.timestamp, + annotations: state.linkedDocSession.root.annotations, + globalAttachments: state.linkedDocSession.root.globalAttachments, + blocks: parseMarkdownToBlocks(state.linkedDocSession.root.markdown), + linkedDocs, + codeAnnotations: state.codeAnnotations, + }; + }); + }, [annotateSource, recentMessages, getMessageStatesWithCurrent]); + + const activeMessageAnnotationCounts = React.useMemo(() => { + const counts = new Map(cachedMessageAnnotationCounts); + const current = buildCurrentMessageState(); + if (current) { + const count = countMessageAnnotations(current); + if (count > 0) counts.set(current.messageId, count); + else counts.delete(current.messageId); + } + return counts; + }, [cachedMessageAnnotationCounts, buildCurrentMessageState]); + + const messageFeedbackAnnotationCount = React.useMemo( + () => Array.from(activeMessageAnnotationCounts.values()).reduce((sum, count) => sum + count, 0), + [activeMessageAnnotationCounts] + ); + + const annotatedMessageIds = React.useMemo( + () => messageAnnotationEntries + .filter((entry) => + entry.annotations.length > 0 || + entry.globalAttachments.length > 0 || + (entry.codeAnnotations?.length ?? 0) > 0 || + (entry.linkedDocs + ? Array.from(entry.linkedDocs.values()).some((doc) => doc.annotations.length > 0 || doc.globalAttachments.length > 0) + : false) + ) + .map((entry) => entry.messageId), + [messageAnnotationEntries] + ); + // File browser file selection: open via linked doc system // For vault dirs (isVault), use the Obsidian doc endpoint; otherwise use generic /api/doc + const handleSelectMessage = React.useCallback((messageId: string) => { + const msg = recentMessages.find((m) => m.messageId === messageId); + if (!msg || messageId === selectedMessageId) return; + + const states = saveCurrentMessageState(); + const targetState = normalizeMessageState( + states.get(messageId) ?? createEmptyMessageState(msg), + msg, + ); + + setSelectedMessageId(messageId); + linkedDocHook.restoreSession(targetState.linkedDocSession); + setCodeAnnotations([...targetState.codeAnnotations]); + setSelectedCodeAnnotationId(targetState.selectedCodeAnnotationId); + }, [ + recentMessages, + selectedMessageId, + saveCurrentMessageState, + linkedDocHook.restoreSession, + ]); + const handleFileBrowserSelect = React.useCallback((absolutePath: string, dirPath: string) => { const dirState = fileBrowser.dirs.find(d => d.path === dirPath); const buildUrl = dirState?.isVault @@ -608,6 +795,16 @@ const App: React.FC = () => { : annotateSource === 'message' ? 'message' : 'plan'; + // Viewer identity must change when the rendered document changes: web-highlighter + // mutates the Viewer DOM, so reconciling new content against the old subtree throws + // removeChild errors — a changed key remounts it cleanly instead. StickyHeaderLane + // observes a node inside Viewer, so it re-anchors off the same token. + const viewerContentKey = linkedDocHook.isActive + ? `doc:${linkedDocHook.filepath}` + : annotateSource === 'message' && selectedMessageId + ? `msg:${selectedMessageId}` + : 'plan'; + // Track active section for TOC highlighting const headingCount = useMemo(() => blocks.filter(b => b.type === 'heading').length, [blocks]); const activeSection = useActiveSection(containerRef, headingCount, scrollViewport); @@ -649,20 +846,32 @@ const App: React.FC = () => { const viewerAnnotations = useMemo(() => allAnnotations.filter(a => !a.diffContext), [allAnnotations]); // Any-annotations flag used by Close/Approve/Send guards. Consolidates the // four-term check that was inlined across the annotate-mode header + keyboard paths. + const messageMultiSelectMode = annotateSource === 'message' && recentMessages.length > 1; const hasAnyAnnotations = useMemo( - () => allAnnotations.length > 0 - || codeAnnotations.length > 0 - || editorAnnotations.length > 0 - || linkedDocHook.docAnnotationCount > 0 - || globalAttachments.length > 0, - [allAnnotations.length, codeAnnotations.length, editorAnnotations.length, linkedDocHook.docAnnotationCount, globalAttachments.length], + () => messageMultiSelectMode + ? messageFeedbackAnnotationCount > 0 || editorAnnotations.length > 0 + : allAnnotations.length > 0 + || codeAnnotations.length > 0 + || editorAnnotations.length > 0 + || linkedDocHook.docAnnotationCount > 0 + || globalAttachments.length > 0, + [ + messageMultiSelectMode, + messageFeedbackAnnotationCount, + allAnnotations.length, + codeAnnotations.length, + editorAnnotations.length, + linkedDocHook.docAnnotationCount, + globalAttachments.length, + ], ); - const feedbackAnnotationCount = - allAnnotations.length + - codeAnnotations.length + - editorAnnotations.length + - linkedDocHook.docAnnotationCount + - globalAttachments.length; + const feedbackAnnotationCount = messageMultiSelectMode + ? messageFeedbackAnnotationCount + editorAnnotations.length + : allAnnotations.length + + codeAnnotations.length + + editorAnnotations.length + + linkedDocHook.docAnnotationCount + + globalAttachments.length; // Code-file comments are intentionally not serialized into share URLs in v1. // Hide share entry points once they exist so we do not silently drop feedback. const canShareCurrentSession = sharingEnabled && codeAnnotations.length === 0; @@ -793,7 +1002,7 @@ const App: React.FC = () => { 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 }; recentMessages?: PickerMessage[] }) => { // Initialize config store with server-provided values (config file > cookie > default) configStore.init(data.serverConfig); setAISessionEnabled(data.mode !== 'archive' && data.mode !== 'goal-setup'); @@ -831,6 +1040,17 @@ const App: React.FC = () => { if (data.mode === 'annotate' || data.mode === 'annotate-last' || data.mode === 'annotate-folder') { setAnnotateSource(data.mode === 'annotate-last' ? 'message' : data.mode === 'annotate-folder' ? 'folder' : 'file'); } + if (data.mode === 'annotate-last' && data.recentMessages && data.recentMessages.length > 0) { + messageStateCacheRef.current = new Map(); + setCachedMessageAnnotationCounts(new Map()); + setRecentMessages(data.recentMessages); + setSelectedMessageId(data.recentMessages[0].messageId); + } else { + messageStateCacheRef.current = new Map(); + setCachedMessageAnnotationCounts(new Map()); + setRecentMessages([]); + setSelectedMessageId(null); + } setSourceInfo(data.sourceInfo ?? undefined); setSourceConverted(!!data.sourceConverted); if (data.filePath) { @@ -1173,6 +1393,12 @@ const App: React.FC = () => { const handleAnnotateFeedback = async () => { setIsSubmitting(true); try { + if (messageMultiSelectMode) { + saveCurrentMessageState(); + } + const scopedSelectedMessageId = messageMultiSelectMode + ? annotatedMessageIds.length === 1 ? annotatedMessageIds[0] : undefined + : selectedMessageId ?? undefined; await fetch('/api/feedback', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -1180,6 +1406,8 @@ const App: React.FC = () => { feedback: annotationsOutput, annotations: allAnnotations, codeAnnotations, + ...(scopedSelectedMessageId ? { selectedMessageId: scopedSelectedMessageId } : {}), + ...(messageMultiSelectMode && annotatedMessageIds.length > 1 ? { feedbackScope: 'messages' } : {}), }), }); setSubmitted('denied'); // reuse 'denied' state for "feedback sent" overlay @@ -1188,7 +1416,7 @@ const App: React.FC = () => { } }; - // Annotate gate-mode handler — approves the artifact without feedback (#570) + // Annotate gate-mode handler — approves the artifact without feedback const handleAnnotateApprove = async () => { setIsSubmitting(true); try { @@ -1442,6 +1670,14 @@ const App: React.FC = () => { }; const annotationsOutput = useMemo(() => { + if (messageMultiSelectMode) { + let output = exportMessageAnnotations(messageAnnotationEntries); + if (editorAnnotations.length > 0) { + output += `\n\n${exportEditorAnnotations(editorAnnotations)}`; + } + return output; + } + const docAnnotations = linkedDocHook.getDocAnnotations(); const hasDocAnnotations = Array.from(docAnnotations.values()).some( (d) => d.annotations.length > 0 || d.globalAttachments.length > 0 @@ -1492,7 +1728,7 @@ const App: React.FC = () => { } return output; - }, [blocks, allAnnotations, globalAttachments, linkedDocHook.getDocAnnotations, editorAnnotations, codeAnnotations, sourceConverted, annotateSource, linkedDocHook.isActive, linkedDocHook.filepath]); + }, [messageMultiSelectMode, messageAnnotationEntries, blocks, allAnnotations, globalAttachments, linkedDocHook.getDocAnnotations, editorAnnotations, codeAnnotations, sourceConverted, annotateSource, linkedDocHook.isActive, linkedDocHook.filepath]); const aiAnnotationsContext = useMemo( () => hasAnyAnnotations ? annotationsOutput : undefined, @@ -2043,6 +2279,8 @@ const App: React.FC = () => { hasDiff={planDiff.hasPreviousVersion} showVersionsTab={versionInfo !== null && versionInfo.totalVersions > 1} showFilesTab={showFilesTab && !archive.archiveMode} + showMessagesTab={annotateSource === 'message' && recentMessages.length > 1} + hasMessageAnnotations={activeMessageAnnotationCounts.size > 0} hasFileAnnotations={hasFileAnnotations} className="hidden lg:flex absolute left-0 top-0 z-10" /> @@ -2091,6 +2329,11 @@ const App: React.FC = () => { selectedArchiveFile={archive.selectedFile} onArchiveSelect={archive.select} isLoadingArchive={archive.isLoading} + showMessagesTab={annotateSource === 'message' && recentMessages.length > 1} + messages={recentMessages} + selectedMessageId={selectedMessageId} + onSelectMessage={handleSelectMessage} + messageAnnotationCounts={activeMessageAnnotationCounts} /> @@ -2119,7 +2362,8 @@ const App: React.FC = () => { of doc; original toolstrip/badges remain the source of truth there. Hidden in plan diff or archive mode, or when sticky actions are disabled. remountToken re-anchors the - ResizeObserver when Viewer swaps content (linked docs). */} + ResizeObserver when Viewer swaps content (linked docs or + message switches). */} {!goalSetupMode && !isPlanDiffActive && !archive.archiveMode && uiPrefs.stickyActionsEnabled && ( { onPlanDiffToggle={() => setIsPlanDiffActive(!isPlanDiffActive)} archiveInfo={archive.currentInfo} maxWidth={annotateReaderMaxWidth} - remountToken={linkedDocHook.isActive ? `doc:${linkedDocHook.filepath}` : 'plan'} + remountToken={viewerContentKey} /> )} @@ -2245,7 +2489,7 @@ const App: React.FC = () => { /> ) : ( { copyLabel={annotateSource === 'message' ? 'Copy message' : annotateSource === 'file' || annotateSource === 'folder' ? 'Copy file' : undefined} archiveInfo={archive.currentInfo} sourceInfo={sourceInfo} + messagePickerInfo={ + annotateSource === 'message' && recentMessages.length > 1 + ? { + // selectedMessageId is always one of recentMessages (set on init, + // only changed via handleSelectMessage), so findIndex is >= 0. + current: recentMessages.findIndex((m) => m.messageId === selectedMessageId) + 1, + total: recentMessages.length, + onOpen: () => sidebar.open('messages'), + } + : undefined + } onToggleCheckbox={checkbox.toggle} checkboxOverrides={checkbox.overrides} actionsLabelMode={actionsLabelMode} diff --git a/packages/server/annotate.ts b/packages/server/annotate.ts index 94a7fc6c..56227415 100644 --- a/packages/server/annotate.ts +++ b/packages/server/annotate.ts @@ -45,6 +45,13 @@ export interface AnnotateServerOptions { mode?: "annotate" | "annotate-last" | "annotate-folder"; /** Folder path when annotating a directory (used as projectRoot for file browser) */ folderPath?: string; + /** + * Recent assistant messages for `annotate-last` mode (newest-first). When + * provided with more than one entry, the editor renders a picker so users + * can choose which message to annotate; index 0 is the default selection + * and matches the legacy "last message" behavior. + */ + recentMessages?: { messageId: string; text: string; timestamp?: string }[]; /** Whether URL sharing is enabled (default: true) */ sharingEnabled?: boolean; /** Custom base URL for share links */ @@ -56,7 +63,7 @@ export interface AnnotateServerOptions { /** True when `markdown` was produced by Turndown/Jina (HTML or URL) — * feedback line numbers won't match the original source. */ sourceConverted?: boolean; - /** Enable review-gate UX: adds an Approve button alongside Close/Send Annotations (#570) */ + /** Enable review-gate UX: adds an Approve button alongside Close/Send Annotations */ gate?: boolean; /** Raw HTML content for direct iframe rendering (--render-html mode) */ rawHtml?: string; @@ -79,6 +86,8 @@ export interface AnnotateServerResult { annotations: unknown[]; exit?: boolean; approved?: boolean; + selectedMessageId?: string; + feedbackScope?: "message" | "messages"; }>; /** Stop the server */ stop: () => void; @@ -110,6 +119,7 @@ export async function startAnnotateServer( origin, mode = "annotate", folderPath, + recentMessages, sourceInfo, sourceConverted, sharingEnabled = true, @@ -142,12 +152,16 @@ export async function startAnnotateServer( annotations: unknown[]; exit?: boolean; approved?: boolean; + selectedMessageId?: string; + feedbackScope?: "message" | "messages"; }) => void; const decisionPromise = new Promise<{ feedback: string; annotations: unknown[]; exit?: boolean; approved?: boolean; + selectedMessageId?: string; + feedbackScope?: "message" | "messages"; }>((resolve) => { resolveDecision = resolve; }); @@ -183,6 +197,7 @@ export async function startAnnotateServer( projectRoot: folderPath || process.cwd(), isWSL: wslFlag, serverConfig: getServerConfig(gitUser), + ...(recentMessages ? { recentMessages } : {}), }); } @@ -280,7 +295,7 @@ export async function startAnnotateServer( return Response.json({ ok: true }); } - // API: Approve the annotation session (review-gate UX, #570) + // API: Approve the annotation session (review-gate UX) if (url.pathname === "/api/approve" && req.method === "POST") { deleteDraft(draftKey); resolveDecision({ feedback: "", annotations: [], approved: true }); @@ -293,12 +308,16 @@ export async function startAnnotateServer( const body = (await req.json()) as { feedback: string; annotations: unknown[]; + selectedMessageId?: string; + feedbackScope?: "message" | "messages"; }; deleteDraft(draftKey); resolveDecision({ feedback: body.feedback || "", annotations: body.annotations || [], + selectedMessageId: body.selectedMessageId, + feedbackScope: body.feedbackScope, }); return Response.json({ ok: true }); diff --git a/packages/shared/annotate-args.ts b/packages/shared/annotate-args.ts index 158018e4..cc1fe028 100644 --- a/packages/shared/annotate-args.ts +++ b/packages/shared/annotate-args.ts @@ -1,8 +1,8 @@ /** * Parse CLI-style args arriving as a single whitespace-delimited string. * - * Extracts the `--gate`, `--json`, and `--hook` flags (issue #570) - * from the remainder, which is treated as the target path. Leading `@` is + * Extracts the `--gate`, `--json`, and `--hook` flags from the remainder, + * which is treated as the target path. Leading `@` is * stripped via the shared at-reference helper — reference-mode is primary. * Scoped-package-style literal `@` paths are handled by a fallback that the * downstream resolver opts into (see at-reference.ts). diff --git a/packages/ui/components/Viewer.tsx b/packages/ui/components/Viewer.tsx index dac5eb9c..571c6ee3 100644 --- a/packages/ui/components/Viewer.tsx +++ b/packages/ui/components/Viewer.tsx @@ -36,6 +36,7 @@ class ToolbarErrorBoundary extends React.Component< import { CommentPopover, type CommentAskAIContext } from './CommentPopover'; import { TaterSpriteSitting } from './TaterSpriteSitting'; import { AttachmentsButton } from './AttachmentsButton'; +import { MessagesIcon } from './icons/MessagesIcon'; import { GraphvizBlock } from './GraphvizBlock'; import { MermaidBlock } from './MermaidBlock'; import { getImageSrc } from './ImageThumbnail'; @@ -93,6 +94,13 @@ interface ViewerProps { archiveInfo?: { status: 'approved' | 'denied' | 'unknown'; timestamp: string; title: string } | null; /** Source attribution for HTML/URL annotations (e.g. URL or filename) */ sourceInfo?: string; + /** + * Message picker affordance — annotate-last mode only. Shown as a button in + * the sticky-top action bar so the user can switch to a different recent + * assistant message. Clicking opens the full picker in the left sidebar's + * Messages tab. + */ + messagePickerInfo?: { current: number; total: number; onOpen: () => void }; // Checkbox toggle props onToggleCheckbox?: (blockId: string, checked: boolean) => void; checkboxOverrides?: Map; @@ -169,6 +177,7 @@ export const Viewer = forwardRef(({ actionsLabelMode = 'full', archiveInfo, sourceInfo, + messagePickerInfo, onToggleCheckbox, checkboxOverrides, onAskAI, @@ -549,6 +558,22 @@ export const Viewer = forwardRef(({ {/* Header buttons - top right */}
+ {messagePickerInfo && ( + + )} + {/* Attachments button */} {onAddGlobalAttachment && onRemoveGlobalAttachment && ( = ({ className = 'w-3.5 h-3.5' }) => ( + + + +); diff --git a/packages/ui/components/sidebar/MessagesBrowser.tsx b/packages/ui/components/sidebar/MessagesBrowser.tsx new file mode 100644 index 00000000..3c0b7928 --- /dev/null +++ b/packages/ui/components/sidebar/MessagesBrowser.tsx @@ -0,0 +1,109 @@ +/** + * MessagesBrowser — Sidebar list of recent assistant messages. + * + * Used by annotate-last. Lets the user pick which assistant message to + * annotate when the newest transcript entry isn't the one they intended + * (e.g., after `/rewind`). + */ + +import React from "react"; + +export interface PickerMessage { + messageId: string; + text: string; + timestamp?: string; +} + +interface MessagesBrowserProps { + messages: PickerMessage[]; + selectedMessageId: string | null; + onSelect: (messageId: string) => void; + annotationCounts?: Map; +} + +// Hard cap for browsers where line-clamp is unavailable, and to avoid huge sidebar text nodes. +const PREVIEW_MAX_CHARS = 140; + +function previewText(text: string): string { + const normalized = text.replace(/\s+/g, " ").trim(); + return normalized.length > PREVIEW_MAX_CHARS + ? normalized.slice(0, PREVIEW_MAX_CHARS).trimEnd() + "…" + : normalized; +} + +function formatTimestamp(ts?: string): string | null { + if (!ts) return null; + const d = new Date(ts); + if (Number.isNaN(d.getTime())) return null; + return d.toLocaleString([], { + hour: "2-digit", + minute: "2-digit", + month: "short", + day: "numeric", + }); +} + +export const MessagesBrowser: React.FC = ({ + messages, + selectedMessageId, + onSelect, + annotationCounts, +}) => { + if (messages.length === 0) { + return ( +
+ No recent assistant messages found. +
+ ); + } + + return ( +
+
+ Recent messages — newest first +
+
+ {messages.map((msg, idx) => { + const isSelected = msg.messageId === selectedMessageId; + const isDefault = idx === 0; + const ts = formatTimestamp(msg.timestamp); + const annotationCount = annotationCounts?.get(msg.messageId) ?? 0; + return ( + + ); + })} +
+
+ ); +}; diff --git a/packages/ui/components/sidebar/SidebarContainer.tsx b/packages/ui/components/sidebar/SidebarContainer.tsx index 5d82b0dc..a926e040 100644 --- a/packages/ui/components/sidebar/SidebarContainer.tsx +++ b/packages/ui/components/sidebar/SidebarContainer.tsx @@ -14,6 +14,8 @@ import { TableOfContents } from "../TableOfContents"; import { VersionBrowser } from "./VersionBrowser"; import { FileBrowser } from "./FileBrowser"; import { ArchiveBrowser, type ArchivedPlan } from "./ArchiveBrowser"; +import { MessagesBrowser, type PickerMessage } from "./MessagesBrowser"; +import { MessagesIcon } from "../icons/MessagesIcon"; import { OverlayScrollArea } from "../OverlayScrollArea"; interface SidebarContainerProps { @@ -58,6 +60,11 @@ interface SidebarContainerProps { selectedArchiveFile: string | null; onArchiveSelect: (filename: string) => void; isLoadingArchive: boolean; + showMessagesTab?: boolean; + messages?: PickerMessage[]; + selectedMessageId?: string | null; + onSelectMessage?: (messageId: string) => void; + messageAnnotationCounts?: Map; } export const SidebarContainer: React.FC = ({ @@ -97,6 +104,11 @@ export const SidebarContainer: React.FC = ({ selectedArchiveFile, onArchiveSelect, isLoadingArchive, + showMessagesTab, + messages, + selectedMessageId, + onSelectMessage, + messageAnnotationCounts, }) => { return ( ); diff --git a/packages/ui/components/sidebar/SidebarTabs.tsx b/packages/ui/components/sidebar/SidebarTabs.tsx index 2c87956f..a40acff4 100644 --- a/packages/ui/components/sidebar/SidebarTabs.tsx +++ b/packages/ui/components/sidebar/SidebarTabs.tsx @@ -7,6 +7,7 @@ import React from "react"; import type { SidebarTab } from "../../hooks/useSidebar"; +import { MessagesIcon } from "../icons/MessagesIcon"; interface SidebarTabsProps { activeTab: SidebarTab; @@ -14,7 +15,9 @@ interface SidebarTabsProps { hasDiff: boolean; showVersionsTab?: boolean; showFilesTab?: boolean; + showMessagesTab?: boolean; hasFileAnnotations?: boolean; + hasMessageAnnotations?: boolean; className?: string; } @@ -24,7 +27,9 @@ export const SidebarTabs: React.FC = ({ hasDiff, showVersionsTab, showFilesTab, + showMessagesTab, hasFileAnnotations, + hasMessageAnnotations, className, }) => { return ( @@ -80,6 +85,19 @@ export const SidebarTabs: React.FC = ({ )} + {showMessagesTab && ( + + )} + {/* Files tab */} {showFilesTab && (