mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
be2c81fa3b
* 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>
110 lines
3.5 KiB
TypeScript
110 lines
3.5 KiB
TypeScript
/**
|
|
* 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>
|
|
);
|
|
};
|