annotate-last: pick which message to annotate (fixes #800) (#809)

* feat: message picker for annotate-last (#800)

When running /plannotator-last after /rewind, the newest transcript
entry is no longer the message the user intended to annotate, and there
was no affordance to pick a different one.

Adds a picker UI that surfaces the recent assistant messages so the
user can choose which one to annotate:

- A "Message N of M" button in the Viewer's sticky-top action bar
  (alongside Copy / Global comment / Attachments), so it stays
  accessible while scrolling.
- A "Messages" tab in the left sidebar with the full list
  (newest-first, preview + timestamp, default ★), mirroring the
  existing Files / Versions / Archive tab pattern.

Wired for Claude Code, Codex, and Droid (all share apps/hook/server).
OpenCode, Pi, and Copilot still get the original single-message
behavior — they don't emit recentMessages, so the picker affordances
hide cleanly.

Default selection (index 0) matches today's "last message" behavior,
so users who don't interact with the picker see no change.

* feat: extend annotate-last picker to Copilot and OpenCode

The picker UI from #800 was wired for Claude / Codex / Droid only. Pull
Copilot and OpenCode onto the same shape so users on those harnesses
also get the recent-messages picker when annotating the last assistant
message.

- Copilot: replace getLastCopilotMessage with getRecentCopilotMessages,
  walking events.jsonl newest-first up to 25 assistant.message events.
- OpenCode: rewrite the session walk to collect up to 25 messages
  (newest first) instead of bailing on the first hit; normalize the SDK
  time.created (ms epoch) to ISO to match the shared picker contract.
- Both pass recentMessages to startAnnotateServer only when length > 1,
  matching the existing Claude/Codex/Droid behavior.

Also trims a leftover narrating comment in MessagesBrowser and refreshes
the stale Copilot session-parser header.

Pi parity follows in the next commit (needs round-trip of the picker
selection through /api/feedback so its post-submit anchoring quotes the
right message).

* feat(pi): wire annotate-last picker with feedback round-trip

Extends the picker UI (#800) to Pi and fixes a Pi-specific anchoring bug
the picker would otherwise introduce.

Picker plumbing
- assistant-message: getRecentAssistantMessages walks the active branch
  newest-first, returning { messageId, text, timestamp? } in the same
  shape the other harnesses produce.
- Plumbed through plannotator-browser / plannotator-events so the Bun
  server's recentMessages option is populated when the branch has more
  than one assistant message.

Anchoring fix
- Pi quotes the targeted assistant message back to the agent because its
  UX is async — the conversation may have moved on by feedback time.
  With the picker, that target is no longer guaranteed to be the
  snapshot taken when the UI opened. The editor now sends the user's
  selectedMessageId with /api/feedback; Pi looks it up in the current
  branch via findAssistantMessageByEntryId and quotes that message
  instead. Falls back to the original snapshot if the entry is gone.
- The round-trip field is optional and only meaningful in annotate-last
  mode; other harnesses (and other modes) ignore it.

Timestamp safety
- Pi's SDK currently types SessionEntryBase.timestamp as string, but the
  picker contract everywhere else is ISO. Treat the value as unknown and
  normalize string/number(ms)/Date to ISO; drop anything else, rather
  than blind-casting and risking silent drift if the SDK changes.

* chore: strip issue-number references from comments

Comments shouldn't rely on external references — issue numbers age out
of context, link rot is a thing, and a reader shouldn't need to open
GitHub to understand why a line exists. Strip the `(#800)` and `(#570)`
parentheticals from comments and doc strings across the picker and
review-gate code; the surrounding "why" content is preserved.

* fix: prevent removeChild crash when switching annotate-last messages

Switching the picked message remounted nothing, so React reconciled new
content against DOM that web-highlighter had mutated with <mark> nodes,
throwing removeChild. Drive the Viewer key (and StickyHeaderLane's
remount token) off a shared viewerContentKey so a message switch fully
remounts the Viewer and re-anchors the sticky-header observer.

Also cap MessagesBrowser row previews via previewText() and drop the
redundant 'block' class that was overriding line-clamp-2.

* feat: persist annotate-last feedback across messages

---------

Co-authored-by: Michael Ramos <mdramos8@gmail.com>
This commit is contained in:
Graham Lipsman
2026-06-03 13:01:36 -07:00
committed by GitHub
parent 96aad592af
commit be2c81fa3b
24 changed files with 1123 additions and 121 deletions
+47
View File
@@ -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.
*
+15 -14
View File
@@ -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/<uuid>/
*
* 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;
}
+38 -16
View File
@@ -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,
+79
View File
@@ -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"}';
+80
View File
@@ -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 [];
}
}
+19 -14
View File
@@ -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(),
+1 -1
View File
@@ -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 },
};
+54
View File
@@ -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;
+16 -6
View File
@@ -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(), {
+13 -5
View File
@@ -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<BrowserDecisionSession<{ feedback: string; exit?: boolean; approved?: boolean }>> {
recentMessages?: { messageId: string; text: string; timestamp?: string }[],
): Promise<BrowserDecisionSession<{ feedback: string; exit?: boolean; approved?: boolean; selectedMessageId?: string; feedbackScope?: "message" | "messages" }>> {
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<BrowserDecisionSession<{ feedback: string; exit?: boolean; approved?: boolean }>> {
recentMessages?: { messageId: string; text: string; timestamp?: string }[],
): Promise<BrowserDecisionSession<{ feedback: string; exit?: boolean; approved?: boolean; selectedMessageId?: string; feedbackScope?: "message" | "messages" }>> {
return startMarkdownAnnotationSession(
ctx,
"last-message",
@@ -532,6 +537,9 @@ export async function startLastMessageAnnotationSession(
undefined,
undefined,
gate,
undefined,
undefined,
recentMessages,
);
}
+8 -4
View File
@@ -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;
}
+9 -1
View File
@@ -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) {
+275 -20
View File
@@ -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<string, MessageAnnotationState>
): Map<string, number> => {
const counts = new Map<string, number>();
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<Annotation[]>([]);
@@ -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<PickerMessage[]>([]);
const [selectedMessageId, setSelectedMessageId] = useState<string | null>(null);
const messageStateCacheRef = useRef<Map<string, MessageAnnotationState>>(new Map());
const [cachedMessageAnnotationCounts, setCachedMessageAnnotationCounts] = useState<Map<string, number>>(new Map());
const [goalSetupBundle, setGoalSetupBundle] = useState<GoalSetupBundle | null>(null);
const goalSetupSurfaceRef = useRef<GoalSetupSurfaceHandle>(null);
const [goalSetupAction, setGoalSetupAction] = useState<GoalSetupActionState>({
@@ -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<string, MessageAnnotationState> => {
const states = new Map(messageStateCacheRef.current);
const current = buildCurrentMessageState();
if (current) states.set(current.messageId, current);
return states;
}, [buildCurrentMessageState]);
const saveCurrentMessageState = React.useCallback((): Map<string, MessageAnnotationState> => {
const states = getMessageStatesWithCurrent();
messageStateCacheRef.current = states;
setCachedMessageAnnotationCounts(buildMessageAnnotationCounts(states));
return states;
}, [getMessageStatesWithCurrent]);
const messageAnnotationEntries = React.useMemo<MessageAnnotationEntry[]>(() => {
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<string, LinkedDocAnnotationEntry> = 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}
/>
<ResizeHandle {...tocResize.handleProps} className="hidden lg:block" side="left" />
</>
@@ -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 && (
<StickyHeaderLane
inputMethod={inputMethod}
@@ -2134,7 +2378,7 @@ const App: React.FC = () => {
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 = () => {
/>
) : (
<Viewer
key={linkedDocHook.isActive ? `doc:${linkedDocHook.filepath}` : 'plan'}
key={viewerContentKey}
ref={viewerRef}
blocks={blocks}
markdown={markdown}
@@ -2276,6 +2520,17 @@ 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}
+21 -2
View File
@@ -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 });
+2 -2
View File
@@ -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).
+25
View File
@@ -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<string, boolean>;
@@ -169,6 +177,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
actionsLabelMode = 'full',
archiveInfo,
sourceInfo,
messagePickerInfo,
onToggleCheckbox,
checkboxOverrides,
onAskAI,
@@ -549,6 +558,22 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
{/* Header buttons - top right */}
<div data-print-hide data-sticky-actions className={`${stickyActions ? 'sticky top-3' : ''} z-30 float-right flex items-start gap-1 md:gap-2 rounded-lg p-1 md:p-2 transition-colors duration-150 ${isStuck ? 'bg-card/95 backdrop-blur-sm shadow-sm' : ''} -mr-3 mt-6 md:-mr-5 md:-mt-5 lg:-mr-7 lg:-mt-7 xl:-mr-9 xl:-mt-9`}>
{messagePickerInfo && (
<button
onClick={messagePickerInfo.onOpen}
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground bg-muted/50 hover:bg-muted rounded-md transition-colors"
title="Pick a different message to annotate"
>
<MessagesIcon />
{actionsLabelMode === 'full' && (
<span>Message {messagePickerInfo.current} of {messagePickerInfo.total}</span>
)}
{actionsLabelMode === 'short' && (
<span>{messagePickerInfo.current}/{messagePickerInfo.total}</span>
)}
</button>
)}
{/* Attachments button */}
{onAddGlobalAttachment && onRemoveGlobalAttachment && (
<AttachmentsButton
@@ -0,0 +1,11 @@
import React from 'react';
export const MessagesIcon: React.FC<{ className?: string }> = ({ className = 'w-3.5 h-3.5' }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
);
@@ -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<string, number>;
}
// 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<MessagesBrowserProps> = ({
messages,
selectedMessageId,
onSelect,
annotationCounts,
}) => {
if (messages.length === 0) {
return (
<div className="p-4 text-xs text-muted-foreground text-center">
No recent assistant messages found.
</div>
);
}
return (
<div className="p-2">
<div className="px-2 pt-1 pb-2 text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
Recent messages — newest first
</div>
<div className="space-y-0.5">
{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 (
<button
key={msg.messageId}
onClick={() => onSelect(msg.messageId)}
className={`w-full text-left px-2 py-1.5 rounded text-xs transition-colors flex items-start gap-2 ${
isSelected
? "bg-primary/10 text-primary border border-primary/30"
: "text-foreground hover:bg-muted/50 border border-transparent"
}`}
>
<span className="font-mono text-[10px] text-muted-foreground pt-0.5 w-8 shrink-0 text-right">
#{idx + 1}
{isDefault ? " ★" : ""}
</span>
<span className="flex-1 min-w-0">
<span className="line-clamp-2 leading-snug">
{previewText(msg.text)}
</span>
{ts && (
<span className="block text-[10px] text-muted-foreground mt-0.5">
{ts}
</span>
)}
</span>
{annotationCount > 0 && (
<span
className="shrink-0 min-w-5 h-5 px-1 rounded-full bg-primary/10 text-primary border border-primary/30 text-[10px] font-semibold inline-flex items-center justify-center"
title={`${annotationCount} annotation${annotationCount === 1 ? "" : "s"}`}
>
{annotationCount}
</span>
)}
</button>
);
})}
</div>
</div>
);
};
@@ -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<string, number>;
}
export const SidebarContainer: React.FC<SidebarContainerProps> = ({
@@ -97,6 +104,11 @@ export const SidebarContainer: React.FC<SidebarContainerProps> = ({
selectedArchiveFile,
onArchiveSelect,
isLoadingArchive,
showMessagesTab,
messages,
selectedMessageId,
onSelectMessage,
messageAnnotationCounts,
}) => {
return (
<aside
@@ -147,6 +159,15 @@ export const SidebarContainer: React.FC<SidebarContainerProps> = ({
label="Versions"
/>
)}
{showMessagesTab && (
<TabButton
active={activeTab === "messages"}
onClick={() => onTabChange("messages")}
icon={<MessagesIcon className="w-3 h-3" />}
label="Messages"
badge={messageAnnotationCounts !== undefined && messageAnnotationCounts.size > 0}
/>
)}
{showFilesTab && (
<TabButton
active={activeTab === "files"}
@@ -266,6 +287,14 @@ export const SidebarContainer: React.FC<SidebarContainerProps> = ({
isLoading={isLoadingArchive}
/>
)}
{activeTab === "messages" && showMessagesTab && messages && onSelectMessage && (
<MessagesBrowser
messages={messages}
selectedMessageId={selectedMessageId ?? null}
onSelect={onSelectMessage}
annotationCounts={messageAnnotationCounts}
/>
)}
</OverlayScrollArea>
</aside>
);
@@ -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<SidebarTabsProps> = ({
hasDiff,
showVersionsTab,
showFilesTab,
showMessagesTab,
hasFileAnnotations,
hasMessageAnnotations,
className,
}) => {
return (
@@ -80,6 +85,19 @@ export const SidebarTabs: React.FC<SidebarTabsProps> = ({
</button>
)}
{showMessagesTab && (
<button
onClick={() => onToggleTab("messages")}
className="sidebar-tab-flag group relative flex items-center justify-center w-7 h-9 rounded-r-md border border-l-0 border-border/50 bg-card/80 backdrop-blur-sm text-muted-foreground hover:text-foreground hover:bg-card transition-colors"
title="Pick a different message"
>
<MessagesIcon />
{hasMessageAnnotations && (
<span className="absolute top-1 right-1 w-1.5 h-1.5 rounded-full bg-primary" />
)}
</button>
)}
{/* Files tab */}
{showFilesTab && (
<button
+98 -35
View File
@@ -111,6 +111,78 @@ export function useAnnotationHighlighter({
const searchOnce = (needle: string): Range | null => {
if (!needle || !containerRef.current) return null;
const rangeFromTextOffsets = (startIndex: number, endIndex: number): Range | null => {
const walker = document.createTreeWalker(
containerRef.current!,
NodeFilter.SHOW_TEXT,
null
);
let charCount = 0;
let startNode: Text | null = null;
let startOffset = 0;
let endNode: Text | null = null;
let endOffset = 0;
let node: Text | null;
while ((node = walker.nextNode() as Text | null)) {
const nodeLength = node.textContent?.length || 0;
if (!startNode && charCount + nodeLength > startIndex) {
startNode = node;
startOffset = startIndex - charCount;
}
if (startNode && charCount + nodeLength >= endIndex) {
endNode = node;
endOffset = endIndex - charCount;
break;
}
charCount += nodeLength;
}
if (startNode && endNode) {
const range = document.createRange();
range.setStart(startNode, startOffset);
range.setEnd(endNode, endOffset);
return range;
}
return null;
};
const normalizeWithMap = (text: string): { text: string; map: number[] } => {
let normalized = '';
const map: number[] = [];
let inWhitespace = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (/\s/.test(ch)) {
if (!inWhitespace) {
normalized += ' ';
map.push(i);
inWhitespace = true;
}
} else {
normalized += ch;
map.push(i);
inWhitespace = false;
}
}
let start = 0;
let end = normalized.length;
while (start < end && normalized[start] === ' ') start++;
while (end > start && normalized[end - 1] === ' ') end--;
return {
text: normalized.slice(start, end),
map: map.slice(start, end),
};
};
const walker = document.createTreeWalker(
containerRef.current,
NodeFilter.SHOW_TEXT,
@@ -131,42 +203,17 @@ export function useAnnotationHighlighter({
const fullText = containerRef.current.textContent || '';
const searchIndex = fullText.indexOf(needle);
if (searchIndex === -1) return null;
const walker2 = document.createTreeWalker(
containerRef.current,
NodeFilter.SHOW_TEXT,
null
);
let charCount = 0;
let startNode: Text | null = null;
let startOffset = 0;
let endNode: Text | null = null;
let endOffset = 0;
while ((node = walker2.nextNode() as Text | null)) {
const nodeLength = node.textContent?.length || 0;
if (!startNode && charCount + nodeLength > searchIndex) {
startNode = node;
startOffset = searchIndex - charCount;
}
if (startNode && charCount + nodeLength >= searchIndex + needle.length) {
endNode = node;
endOffset = searchIndex + needle.length - charCount;
break;
}
charCount += nodeLength;
if (searchIndex !== -1) {
return rangeFromTextOffsets(searchIndex, searchIndex + needle.length);
}
if (startNode && endNode) {
const range = document.createRange();
range.setStart(startNode, startOffset);
range.setEnd(endNode, endOffset);
return range;
const haystack = normalizeWithMap(fullText);
const normalizedNeedle = normalizeWithMap(needle).text;
const normalizedIndex = haystack.text.indexOf(normalizedNeedle);
if (normalizedNeedle && normalizedIndex !== -1) {
const originalStart = haystack.map[normalizedIndex];
const originalEnd = haystack.map[normalizedIndex + normalizedNeedle.length - 1] + 1;
return rangeFromTextOffsets(originalStart, originalEnd);
}
return null;
@@ -258,6 +305,21 @@ export function useAnnotationHighlighter({
const existingManual = containerRef.current?.querySelector(`[data-bind-id="${ann.id}"]`);
if (existingManual) return;
if (ann.startMeta && ann.endMeta) {
try {
highlighter.fromStore(ann.startMeta, ann.endMeta, ann.originalText, ann.id);
const restoredDoms = highlighter.getDoms(ann.id);
if (restoredDoms && restoredDoms.length > 0) {
if (ann.type === AnnotationType.DELETION) {
highlighter.addClass('deletion', ann.id);
} else if (ann.type === AnnotationType.COMMENT) {
highlighter.addClass('comment', ann.id);
}
return;
}
} catch {}
}
const range = findTextInDOM(ann.originalText);
if (!range) {
console.warn(`Could not find text for annotation ${ann.id}: "${ann.originalText.slice(0, 50)}..."`);
@@ -388,7 +450,8 @@ export function useAnnotationHighlighter({
highlighterRef.current = highlighter;
highlighter.on(Highlighter.event.CREATE, ({ sources }: { sources: any[] }) => {
highlighter.on(Highlighter.event.CREATE, ({ sources, type }: { sources: any[]; type?: string }) => {
if (type === 'from-store') return;
if (sources.length > 0) {
const source = sources[0];
const doms = highlighter.getDoms(source.id);
+71
View File
@@ -44,6 +44,11 @@ export interface CachedDocState {
isConverted?: boolean;
}
export interface LinkedDocSessionState {
root: SavedPlanState;
docs: Map<string, CachedDocState>;
}
export interface UseLinkedDocReturn {
/** Whether a linked doc is currently active */
isActive: boolean;
@@ -61,6 +66,10 @@ export interface UseLinkedDocReturn {
dismissError: () => void;
/** All linked doc annotations including the active doc's live state (keyed by filepath) */
getDocAnnotations: () => Map<string, CachedDocState>;
/** Snapshot the root document plus linked-doc cache for cross-document session swaps */
snapshotSession: () => LinkedDocSessionState;
/** Restore a root document plus linked-doc cache, closing any active linked document */
restoreSession: (state: LinkedDocSessionState) => void;
/** Reactive count of annotations on non-active documents (updates on open() and back()) */
docAnnotationCount: number;
}
@@ -263,6 +272,66 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn {
const dismissError = useCallback(() => setError(null), []);
const snapshotSession = useCallback((): LinkedDocSessionState => {
const docs = new Map(docCache.current);
if (linkedDoc) {
docs.set(linkedDoc.filepath, {
annotations: [...annotations],
globalAttachments: [...globalAttachments],
markdown: linkedDoc.markdown,
isConverted: linkedDoc.isConverted,
});
}
const root = savedPlanState.current
? {
markdown: savedPlanState.current.markdown,
annotations: [...savedPlanState.current.annotations],
selectedAnnotationId: savedPlanState.current.selectedAnnotationId,
globalAttachments: [...savedPlanState.current.globalAttachments],
}
: {
markdown,
annotations: [...annotations],
selectedAnnotationId,
globalAttachments: [...globalAttachments],
};
return { root, docs };
}, [linkedDoc, annotations, globalAttachments, markdown, selectedAnnotationId]);
const restoreSession = useCallback((state: LinkedDocSessionState) => {
viewerRef.current?.clearAllHighlights();
savedPlanState.current = null;
docCache.current = new Map(state.docs);
let total = 0;
for (const cached of docCache.current.values()) {
total += cached.annotations.length + cached.globalAttachments.length;
}
setDocAnnotationCount(total);
setMarkdown(state.root.markdown);
setAnnotations([...state.root.annotations]);
setGlobalAttachments([...state.root.globalAttachments]);
setSelectedAnnotationId(state.root.selectedAnnotationId);
setLinkedDoc(null);
setError(null);
if (state.root.annotations.length) {
setTimeout(() => {
viewerRef.current?.clearAllHighlights();
viewerRef.current?.applySharedAnnotations(state.root.annotations);
}, HIGHLIGHT_REAPPLY_DELAY);
}
}, [
setMarkdown,
setAnnotations,
setSelectedAnnotationId,
setGlobalAttachments,
viewerRef,
]);
const getDocAnnotations = useCallback((): Map<string, CachedDocState> => {
const result = new Map(docCache.current);
// Include stashed original-file annotations when viewing a linked doc
@@ -294,6 +363,8 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn {
back,
dismissError,
getDocAnnotations,
snapshotSession,
restoreSession,
docAnnotationCount,
};
}
+1 -1
View File
@@ -8,7 +8,7 @@
import { useState, useCallback } from "react";
export type SidebarTab = "toc" | "versions" | "files" | "archive";
export type SidebarTab = "toc" | "versions" | "files" | "archive" | "messages";
export interface UseSidebarReturn<T extends string = SidebarTab> {
isOpen: boolean;
+84
View File
@@ -725,3 +725,87 @@ export const exportCodeFileAnnotations = (annotations: CodeAnnotation[]): string
output += `---\n`;
return output;
};
export interface MessageAnnotationEntry {
messageId: string;
text: string;
timestamp?: string;
annotations: Annotation[];
globalAttachments: ImageAttachment[];
blocks?: Block[];
linkedDocs?: Map<string, LinkedDocAnnotationEntry>;
codeAnnotations?: CodeAnnotation[];
}
const MESSAGE_EXCERPT_MAX_CHARS = 1200;
const excerptMessageText = (text: string): string => {
const trimmed = text.trim();
if (trimmed.length <= MESSAGE_EXCERPT_MAX_CHARS) return trimmed;
return `${trimmed.slice(0, MESSAGE_EXCERPT_MAX_CHARS).trimEnd()}...`;
};
const fencedBlock = (text: string, language = ''): string => {
let fence = '```';
while (text.includes(fence)) fence += '`';
return `${fence}${language}\n${text}\n${fence}\n`;
};
export const exportMessageAnnotations = (entries: MessageAnnotationEntry[]): string => {
const nonEmpty = entries.filter((entry) => {
const linkedDocCount = entry.linkedDocs
? Array.from(entry.linkedDocs.values()).reduce(
(sum, doc) => sum + doc.annotations.length + doc.globalAttachments.length,
0
)
: 0;
return (
entry.annotations.length > 0 ||
entry.globalAttachments.length > 0 ||
(entry.codeAnnotations?.length ?? 0) > 0 ||
linkedDocCount > 0
);
});
if (nonEmpty.length === 0) {
return 'User reviewed the messages and has no feedback.';
}
let output = `# Message Feedback\n\nThe following feedback spans ${nonEmpty.length} assistant message${nonEmpty.length === 1 ? '' : 's'}. Each section includes an excerpt of the message it applies to.\n\n`;
nonEmpty.forEach((entry, index) => {
const label = entry.timestamp ? ` (${entry.timestamp})` : '';
output += `## Message ${index + 1}${label}\n\n`;
output += `Message excerpt:\n`;
output += fencedBlock(excerptMessageText(entry.text), 'markdown');
output += '\n';
if (entry.annotations.length > 0 || entry.globalAttachments.length > 0) {
output += exportAnnotations(
entry.blocks ?? parseMarkdownToBlocks(entry.text),
entry.annotations,
entry.globalAttachments,
`Feedback for Message ${index + 1}`,
'message',
);
output += '\n';
}
const hasLinkedDocFeedback = entry.linkedDocs
? Array.from(entry.linkedDocs.values()).some(
(doc) => doc.annotations.length > 0 || doc.globalAttachments.length > 0
)
: false;
if (entry.linkedDocs && hasLinkedDocFeedback) {
output += exportLinkedDocAnnotations(entry.linkedDocs);
output += '\n';
}
if (entry.codeAnnotations?.length) {
output += exportCodeFileAnnotations(entry.codeAnnotations);
output += '\n';
}
});
return output.trimEnd();
};