Files
Michael Ramos 7ad4d39ed9 feat(comments): reference agent skills with / or $ in plan review and annotate comments (#1229)
* feat(comments): reference agent skills with / or $ in plan and annotate comments

Typing / or $ at the start of a word in the document-UI comment composer
opens a picker of the user's global agent skills (~/.claude/skills,
~/.codex/skills, ~/.agents skills roots), served by a new GET /api/skills
on the plan and annotate servers in both runtimes (Bun + Pi mirror).
Multiple references per comment are supported; references live in the
comment text itself and are appended to exported feedback as a
'Skills referenced' block so the acting agent knows which skills to apply.

Human-invocation-only skills (disable-model-invocation: true frontmatter)
stay listed and selectable but render dimmed with a badge, warn in the
menu and composer, and are marked in the export so the agent is never
asked to invoke something it cannot.

Discovery reuses the review-skill loader (same roots, precedence, and
skip-and-log discipline), reads only an 8KB head per SKILL.md, caps the
catalog at 500 skills, takes no client input, and is never persisted;
any failure degrades to plain typing.

* fix(comments): harden skill references per review (trigger, IME, seam, fail-closed frontmatter)

Blockers:
- B1: a trigger now requires at least one query character. A bare / or $
  no longer opens the catalog, so Enter stays a newline and Tab still
  leaves the field ("This costs $" + Enter, "cd /" + Tab, bullets).
- B2: the menu ignores keys mid-IME-composition (nativeEvent.isComposing),
  matching the 16 existing guards; Enter committing a Pinyin/Telex/Korean
  candidate can no longer insert a skill.
- B3: the catalog request is a host seam (skillCatalogTransport via
  configurePlannotatorUI), defaulting to the existing GET /api/skills.
- B4: resetSkillCatalogCache() invalidates outstanding requests
  (generation counter), and a late-resolving stale request can no longer
  overwrite a newer cached value or the export registry. The catalog
  tests reset in beforeEach, so they hold in any file order.

Also:
- F1: skillReferences={false} is fully inert — the human-only notice memo
  and the cache seed are gated on the prop.
- F4: frontmatter flag parsing no longer fails open: trailing YAML
  comments are stripped, on/1 (and TRUE/yes etc.) read as true, the head
  read is 64KB, and truncated unterminated frontmatter fails CLOSED on
  disable-model-invocation.
- F5: extraction ignores markdown link destinations ([x](/name)), shell
  redirects (cat /x > out), and /-triggered FHS root names (/run, /tmp);
  menu insertion switches / to $ for those names so inserted references
  always survive extraction.
- F6: the 500-skill cap slices after sorting, so which skills survive no
  longer depends on readdir order.
- F3: /api/skills wiring guards for the Bun and Pi plan + annotate
  servers (skills-endpoint.test.ts).
- Keyboard state machine tests against the real CommentPopover in
  happy-dom (bare trigger, insertion, composition, Escape, highlight
  bounding, opt-out inertness), added to the CI DOM step.
- The insertion path dismisses the trigger start so the menu close is
  ordering-safe against React's select-plugin re-reading a stale caret.

* feat(comments): redesign the skill reference menu (bare triggers, no preselection, highlighted tokens)

Per maintainer direction, reversing the earlier bare-trigger opt-out
deliberately: typing a bare / or $ at the start of a word now opens the
full skill catalog immediately, and the safety story moves from the
trigger to the menu itself.

No preselection (the load-bearing rule): the menu opens with NO row
active, and while nothing is active every key behaves exactly as if the
menu were closed. "This costs $" + Enter is a newline; "cd /" + Tab
leaves the field (the proven regression that must never return). A row
activates only via ArrowDown/ArrowUp (Down from none lands on the first
row, Up on the last); only then do Enter/Tab insert. Pointer hover never
activates a row, because the menu floats exactly where the mouse rests
over the composer; a click inserts directly and never arms Enter.
Continuing to type re-filters and disarms any active row. Escape clears
the active row and dismisses when the user engaged (query typed or row
active); an unengaged bare-trigger menu passes Escape through so closing
the composer still costs one press.

Menu redesign to the reference look: icon, bold name, dimmed inline
description with ellipsis, right-aligned source column (Agents / Claude
/ Codex from the discovery roots), rounded generously padded rows, and a
subtle active-row background; human-only rows stay dimmed with their
badge and the warning now shows while such a row is ACTIVE.

Inserted references render highlighted in the composer via a mirrored
aria-hidden overlay behind a transparent-text textarea (identical font,
padding and wrapping metrics; scroll synced; tokens change color and
background only, drawn from the --primary theme token so every palette
works in light and dark). The caret keeps --foreground, selection uses a
translucent primary wash, and IME composition temporarily restores
native textarea text so composition underlines render normally.
skillReferences={false} still renders the plain pre-feature textarea.

Also, per review:
- extraction: dropped the over-broad shell-redirect exclusion (false
  negatives on prose like "use /animate <- this one"; the motivating
  case stays covered by the reserved-path rule)
- frontmatter: an unterminated frontmatter block now fails CLOSED on
  disable-model-invocation even in complete (untruncated) files
- the reserved-path / to $ insertion switch stays: extraction still
  reads /run as a path, and the new token highlight makes the switch
  self-explanatory (an unhighlighted insert would look broken)

The composition guard, transport seam, catalog generation counter,
enabled gating, and export rules are unchanged and re-covered by the
rewritten DOM test matrix.

* fix(comments): give the skill reference menu adaptive, viewport-clamped placement

The menu rendered bottom-full with a fixed max-h-64: always upward, up to
256px, with no viewport awareness. With the comment popover near the top of
the viewport (annotating near the top of a document), typing a trigger ran
the menu off the top of the screen with its upper rows unreachable.

Placement now mirrors the popover's own computePosition idiom: measure the
space above and below the composer wrapper against window.innerHeight,
prefer above (the shipped direction; keeps the action row and human-only
notice visible), flip below when the list fits below but not above, and when
neither side fits pick the roomier side. The list's max height is clamped to
the available space (still capped at the former 256px), so the menu never
extends past a viewport edge. Recomputes on every commit (drag moves,
popover flips, filtering changing the item count, warning-footer toggles)
plus capture-phase scroll and resize listeners, matching the popover's
tracking. Visual design of the menu and rows is unchanged.

* feat(comments): inject human-only skill instructions into exported feedback

A human-only skill (disable-model-invocation: true) referenced in a review
comment used to export as a dead name the agent could do nothing with. A
human referencing a human-only skill IS the human invocation, so the export
now injects the skill's SKILL.md body verbatim (frontmatter stripped) inside
clearly delimited BEGIN/END SKILL INSTRUCTIONS markers, with the absolute
skill directory and the resolve-relative-paths pointer so references/,
scripts/, and assets/ stay actionable. Model-invocable skills keep exporting
as names the agent can invoke itself.

Transport is lazy: a new GET /api/skills/content?name= endpoint (Bun and Pi)
serves one discovered skill's body, capped at 20k chars with an explicit
truncation notice pointing at the file; the client fetches contents only for
the human-only skills actually referenced, keyed off comment state, and the
catalog now carries each skill's absolute dir so every failure path (deleted
skill, unreadable file, race with submit) degrades to naming the skill plus
its directory. Names are matched against discovery only and never used as
paths, so traversal cannot escape the skill roots. A per-export dedupe
injects each skill once even when several comments reference it, and
GLOBAL_COMMENT annotations run through the same block.

The referenced-skills header now says the reviewer is asking for the
invocation, and the human-only menu footer and composer notice explain that
the skill's instructions will be included with the feedback instead of
warning that the reference will not work.

* polish(comments): quiet, progressive human-only skill treatment

The human-only surfaces shipped with too much emphasis: a dimmed row plus
a bordered uppercase badge, an amber warning footer, and a persistent
amber notice in the composer after insertion. Human-only is a property of
a skill, not an error state, so the treatment is now quiet and
progressively disclosed:

- Menu rows render at full strength with a small muted 'human-only' pill
  (bg-muted / muted-foreground tokens; no border, no dimming).
- The plain-language explanation (a model cannot invoke it, so its
  instructions will be included with your feedback) appears as a muted
  footer only while a human-only row is active (keyboard) or hovered
  (pointer). Hover disclosure is purely visual state local to the menu;
  it never touches activeIndex, so the no-preselection invariant and the
  hover-never-arms-Enter rule are unchanged and re-asserted by a new test.
- When not disclosed, the same sentence stays in the DOM sr-only and
  human-only rows point at it with aria-describedby, so the state reaches
  assistive tech as text rather than as a purely visual badge (this does
  not attempt the #1233 combobox semantics, and does not worsen them).
- After insertion, the highlighted token itself carries the quiet inline
  marker (a dotted primary underline; text-decoration cannot move glyphs,
  so overlay alignment is untouched) and the standing amber notice is
  replaced by a native <details> disclosure: a single muted 'Includes
  skill instructions' summary line that expands to the full accurate
  sentence, operable by pointer, keyboard, and AT alike.

No amber remains; every color is a theme token (muted, muted-foreground,
border, primary, ring), so the treatment follows every palette in light
and dark. Copy is unchanged where it was accurate. Behavior is unchanged:
human-only skills stay selectable and injection still happens.

* fix(comments): harden human-only skill injection per adversarial review

Three findings on the injection path, each with tests that fail pre-fix:

1. Marker forgery: an injected SKILL.md body containing our own
   `--- BEGIN/END SKILL INSTRUCTIONS ---` markers (or an
   `[Instructions truncated:` notice) could close the block early — making
   everything after it read as the reviewer's own words — forge a block for
   a skill nobody referenced, or forge a truncation notice pointing at an
   attacker-chosen path. Body lines matching the structural marker forms
   (leading-whitespace and case variants included) are now visibly
   neutralized before injection: kept verbatim but prefixed, never silently
   deleted (neutralizeSkillMarkerLines).

2. Forged human invocation: POST /api/external-annotations is
   unauthenticated on localhost, so any local process could submit a
   comment referencing a human-only skill and cause its instructions to be
   injected "at the reviewer's request". Annotations carrying a `source`
   now still LIST their skill references but never cause verbatim
   injection — human-only references fall back to naming the skill plus
   its directory, with an honest reason. The content-prime effect skips
   external texts for the same reason. A human referencing a human-only
   skill IS the human invocation; a tool is not.

3. Unbounded read: readReferenceSkillContent read the whole SKILL.md
   before slicing to the 20k cap, so an unauthenticated no-cors fetch loop
   could balloon RSS by file size per request (measured +64.4MB for a 64MB
   file). It now uses the same bounded readFileHead as the catalog,
   reading only frontmatter allowance + 4 bytes per capped char + slack;
   truncation detection is unchanged for any file whose frontmatter fits
   the catalog bound, and frontmatter that overflows the read falls back
   to null rather than serving raw YAML. Measured: 12 reads of a 64MB
   SKILL.md now cost +5.1MB total.

Also: the fast-fail guard no longer rejects legitimately discovered names —
`name.includes("..")` 404'd a real `v1..2` skill dir forever (and `\` is
legal in POSIX names) while defending nothing, since the name is only ever
matched against discovery output and never joined into a path. It now
rejects exactly the names that can never be a readdir entry: empty, `.`,
`..`.
2026-08-07 09:50:42 -07:00

604 lines
21 KiB
TypeScript

import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { File, type LineAnnotation } from '@pierre/diffs/react';
import type { SelectedLineRange as PierreSelectedLineRange, LineEventBaseProps } from '@pierre/diffs';
import { PopoutDialog } from './PopoutDialog';
import { useTheme } from './ThemeProvider';
import { CommentPopover } from './CommentPopover';
import { ImageThumbnail } from './ImageThumbnail';
import type { CodeAnnotation, ImageAttachment } from '../types';
import { copyTextToClipboard } from '../utils/clipboard';
export interface CodeFileAnnotationInput {
filePath: string;
lineStart: number;
lineEnd: number;
text: string;
images?: ImageAttachment[];
originalCode: string;
}
interface CodeFilePopoutProps {
open: boolean;
onClose: () => void;
filepath: string;
contents: string;
prerenderedHTML?: string;
error?: string;
requestedPath?: string;
annotations?: CodeAnnotation[];
selectedAnnotationId?: string | null;
onAddAnnotation?: (annotation: CodeFileAnnotationInput) => void;
onEditAnnotation?: (id: string, updates: Partial<CodeAnnotation>) => void;
onDeleteAnnotation?: (id: string) => void;
onSelectAnnotation?: (id: string | null) => void;
container?: HTMLElement | null;
}
interface PendingComment {
range: { start: number; end: number };
contextText: string;
originalCode: string;
anchorEl?: HTMLElement;
anchorRect?: DOMRect;
}
const gutterButtonStyle: React.CSSProperties = {
appearance: 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '1lh',
height: '1lh',
fontSize: 'var(--diffs-font-size, 13px)',
lineHeight: 'var(--diffs-line-height, 20px)',
border: 'none',
borderRadius: 4,
backgroundColor: 'var(--diffs-modified-base)',
color: 'var(--diffs-bg)',
cursor: 'pointer',
position: 'relative',
zIndex: 4,
padding: 0,
marginRight: 'calc(1ch - 1lh)',
};
function getThemeColors(): { bg: string; fg: string } {
try {
const styles = getComputedStyle(document.documentElement);
return {
bg: styles.getPropertyValue('--background').trim(),
fg: styles.getPropertyValue('--foreground').trim(),
};
} catch {
return { bg: '', fg: '' };
}
}
function buildPierreCSS(mode: 'dark' | 'light', bg: string, fg: string): string {
if (!bg || !fg) return '';
return `
:host {
color-scheme: ${mode};
height: 100% !important;
}
:host, [data-diff], [data-file], [data-diffs-header], [data-error-wrapper], [data-virtualizer-buffer] {
--diffs-bg: ${bg} !important;
--diffs-fg: ${fg} !important;
--diffs-dark-bg: ${bg};
--diffs-light-bg: ${bg};
--diffs-dark: ${fg};
--diffs-light: ${fg};
}
pre, code { background-color: ${bg} !important; }
[data-column-number] { background-color: ${bg} !important; }
[data-file] { height: 100% !important; }
[data-code] { height: 100% !important; overflow-y: auto !important; }
[data-line] { cursor: pointer; }
`;
}
function getLineSlice(contents: string, start: number, end: number): string {
return contents
.split('\n')
.slice(Math.max(0, start - 1), Math.max(0, end))
.join('\n');
}
function lineLabel(start: number, end: number): string {
return start === end ? `line ${start}` : `lines ${start}-${end}`;
}
function getLineNumberFromSelectionNode(node: Node | null): number | null {
let current: Node | null = node;
if (current?.nodeType === Node.TEXT_NODE) current = current.parentNode;
while (current) {
if (current instanceof HTMLElement) {
const line = current.closest('[data-line]')?.getAttribute('data-line');
if (line) {
const parsed = Number(line);
return Number.isFinite(parsed) ? parsed : null;
}
}
current = current.parentNode;
}
return null;
}
function getPierreSelection(root: HTMLElement | null): Selection | null {
const shadowRoot = root?.querySelector('diffs-container')?.shadowRoot;
const shadowSelection = (shadowRoot as (ShadowRoot & { getSelection?: () => Selection | null }) | null)
?.getSelection?.();
return shadowSelection && !shadowSelection.isCollapsed
? shadowSelection
: window.getSelection();
}
const CodeInlineAnnotation: React.FC<{
annotation: CodeAnnotation;
isSelected: boolean;
onSelect?: (id: string | null) => void;
onEdit?: (id: string, updates: Partial<CodeAnnotation>) => void;
onDelete?: (id: string) => void;
}> = ({ annotation, isSelected, onSelect, onEdit, onDelete }) => {
const [isEditing, setIsEditing] = useState(false);
const [editText, setEditText] = useState(annotation.text ?? '');
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (!isEditing) setEditText(annotation.text ?? '');
}, [annotation.text, isEditing]);
useEffect(() => {
if (isEditing) {
textareaRef.current?.focus();
textareaRef.current?.select();
}
}, [isEditing]);
const save = () => {
onEdit?.(annotation.id, { text: editText });
setIsEditing(false);
};
return (
<div
data-code-annotation-id={annotation.id}
onClick={() => onSelect?.(annotation.id)}
className={`group my-2 mx-3 rounded-lg border px-3 py-2 text-xs shadow-sm cursor-pointer transition-colors ${
isSelected ? 'border-primary/50' : 'border-border hover:border-border/80'
}`}
style={{
backgroundColor: isSelected ? 'color-mix(in oklab, var(--primary) 12%, var(--popover))' : 'var(--popover)',
color: 'var(--foreground)',
opacity: 1,
}}
>
<div className="flex items-center gap-2 text-[10px] uppercase tracking-wide text-muted-foreground">
<span className="font-semibold text-primary">Comment</span>
<span className="rounded bg-muted px-1.5 py-0.5 font-mono normal-case text-foreground">
{lineLabel(annotation.lineStart, annotation.lineEnd)}
</span>
{annotation.author && <span className="truncate normal-case">by {annotation.author}</span>}
<div className="ml-auto flex items-center gap-1 opacity-0 group-hover:opacity-100 [@media(hover:none)]:opacity-100">
{onEdit && !isEditing && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setIsEditing(true);
}}
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
title="Edit comment"
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
)}
{onDelete && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDelete(annotation.id);
}}
className="rounded p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
title="Delete comment"
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
{isEditing ? (
<div className="mt-2 space-y-2">
<textarea
ref={textareaRef}
value={editText}
onChange={(e) => setEditText(e.target.value)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault();
setIsEditing(false);
setEditText(annotation.text ?? '');
} else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !e.nativeEvent.isComposing) {
e.preventDefault();
save();
}
}}
rows={Math.min(editText.split('\n').length + 1, 8)}
className="w-full resize-none rounded border border-border bg-background px-2 py-1.5 text-xs text-foreground focus:outline-none focus:ring-2 focus:ring-primary/40"
/>
<div className="flex items-center gap-2">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
save();
}}
className="rounded bg-primary px-2 py-1 text-[10px] font-medium text-primary-foreground hover:opacity-90"
>
Save
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setIsEditing(false);
setEditText(annotation.text ?? '');
}}
className="rounded bg-muted px-2 py-1 text-[10px] font-medium text-muted-foreground hover:bg-muted/80"
>
Cancel
</button>
</div>
</div>
) : (
annotation.text && (
<div className="mt-1.5 whitespace-pre-wrap border-l-2 border-primary/50 pl-2 text-foreground/90">
{annotation.text}
</div>
)
)}
{annotation.images && annotation.images.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{annotation.images.map((img) => (
<div key={img.path} className="text-center">
<ImageThumbnail path={img.path} size="sm" showRemove={false} />
<div className="max-w-[3rem] truncate text-[9px] text-muted-foreground" title={img.name}>
{img.name}
</div>
</div>
))}
</div>
)}
</div>
);
};
export const CodeFilePopout: React.FC<CodeFilePopoutProps> = ({
open,
onClose,
filepath,
contents,
prerenderedHTML,
error,
requestedPath,
annotations = [],
selectedAnnotationId,
onAddAnnotation,
onEditAnnotation,
onDeleteAnnotation,
onSelectAnnotation,
container,
}) => {
const { resolvedMode } = useTheme();
const mode = resolvedMode ?? 'dark';
const colors = getThemeColors();
const [pierreTheme, setPierreTheme] = useState(() => ({
type: mode as 'dark' | 'light',
css: buildPierreCSS(mode, colors.bg, colors.fg),
}));
const [copied, setCopied] = useState(false);
const [pendingComment, setPendingComment] = useState<PendingComment | null>(null);
const fileAreaRef = useRef<HTMLDivElement>(null);
const lastPointerRectRef = useRef<DOMRect | null>(null);
const suppressLineClickUntilRef = useRef(0);
useEffect(() => {
requestAnimationFrame(() => {
const c = getThemeColors();
setPierreTheme({
type: mode,
css: buildPierreCSS(mode, c.bg, c.fg),
});
});
}, [mode]);
useEffect(() => {
setPendingComment(null);
}, [filepath]);
const displayName = filepath.split('/').pop() || filepath;
const relativePath = filepath.replace(/.*\/(?=.*\/)/, '');
const lineCount = useMemo(() => contents.split('\n').length, [contents]);
const selectedCodeAnnotation = useMemo(
() => annotations.find((ann) => ann.id === selectedAnnotationId),
[annotations, selectedAnnotationId],
);
// TODO: add token-level annotation support (charStart/charEnd) — for now only line-scope
const lineAnnotations = useMemo((): LineAnnotation<CodeAnnotation>[] => {
return annotations
.filter((ann) => (ann.scope ?? 'line') === 'line')
.map((ann) => ({
lineNumber: ann.lineEnd,
metadata: ann,
}));
}, [annotations]);
const selectedLines = useMemo((): PierreSelectedLineRange | null => {
if (pendingComment) {
return { start: pendingComment.range.start, end: pendingComment.range.end };
}
if (selectedCodeAnnotation) {
return { start: selectedCodeAnnotation.lineStart, end: selectedCodeAnnotation.lineEnd };
}
return null;
}, [pendingComment, selectedCodeAnnotation]);
const effectivePrerenderedHTML = lineAnnotations.length === 0 ? prerenderedHTML : undefined;
useEffect(() => {
if (!selectedAnnotationId || !fileAreaRef.current) return;
const timer = setTimeout(() => {
fileAreaRef.current
?.querySelector(`[data-code-annotation-id="${selectedAnnotationId}"]`)
?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 100);
return () => clearTimeout(timer);
}, [selectedAnnotationId, filepath]);
const openCommentForRange = useCallback((
range: { start: number; end: number },
anchorEl?: HTMLElement,
anchorRect?: DOMRect,
) => {
const start = Math.min(range.start, range.end);
const end = Math.max(range.start, range.end);
setPendingComment({
range: { start, end },
anchorEl,
anchorRect: anchorRect ?? anchorEl?.getBoundingClientRect() ?? lastPointerRectRef.current ?? undefined,
contextText: `${relativePath} ${lineLabel(start, end)}`,
originalCode: getLineSlice(contents, start, end),
});
}, [contents, relativePath]);
const openCommentForBrowserSelection = useCallback(() => {
if (!onAddAnnotation) return;
const selection = getPierreSelection(fileAreaRef.current);
const selectedText = selection?.toString();
if (!selection || selection.isCollapsed || !selectedText?.trim()) return;
const anchorLine = getLineNumberFromSelectionNode(selection.anchorNode);
const focusLine = getLineNumberFromSelectionNode(selection.focusNode);
if (anchorLine == null || focusLine == null) return;
openCommentForRange(
{ start: anchorLine, end: focusLine },
undefined,
lastPointerRectRef.current
?? (selection.rangeCount > 0 ? selection.getRangeAt(0).getBoundingClientRect() : undefined),
);
selection.removeAllRanges();
}, [onAddAnnotation, openCommentForRange]);
const renderAnnotation = useCallback((annotation: LineAnnotation<CodeAnnotation>) => {
if (!annotation.metadata) return null;
return (
<CodeInlineAnnotation
annotation={annotation.metadata}
isSelected={selectedAnnotationId === annotation.metadata.id}
onSelect={onSelectAnnotation}
onEdit={onEditAnnotation}
onDelete={onDeleteAnnotation}
/>
);
}, [onDeleteAnnotation, onEditAnnotation, onSelectAnnotation, selectedAnnotationId]);
const renderGutterUtility = useCallback((getHoveredLine: () => { lineNumber: number } | undefined) => {
return (
<button
type="button"
style={gutterButtonStyle}
title="Add code comment"
onMouseEnter={(e) => {
e.currentTarget.style.filter = 'brightness(1.2)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.filter = '';
}}
onClick={(e) => {
e.stopPropagation();
const line = getHoveredLine();
if (!line) return;
openCommentForRange({ start: line.lineNumber, end: line.lineNumber }, e.currentTarget);
}}
>
+
</button>
);
}, [openCommentForRange]);
const handleLineSelectionEnd = useCallback((range: PierreSelectedLineRange | null) => {
if (!onAddAnnotation) return;
if (!range) return;
if (range.start !== range.end) {
suppressLineClickUntilRef.current = Date.now() + 300;
}
openCommentForRange({ start: range.start, end: range.end }, undefined, lastPointerRectRef.current ?? undefined);
}, [onAddAnnotation, openCommentForRange]);
const handleLineClick = useCallback((props: LineEventBaseProps & { event: PointerEvent }) => {
if (!onAddAnnotation) return;
if (Date.now() < suppressLineClickUntilRef.current) return;
openCommentForRange(
{ start: props.lineNumber, end: props.lineNumber },
undefined,
props.lineElement.getBoundingClientRect(),
);
}, [onAddAnnotation, openCommentForRange]);
const handleCopy = async () => {
if (await copyTextToClipboard(contents)) {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} else {
console.error('Failed to copy');
}
};
if (error) {
// The server's error string distinguishes "File not found", "Ambiguous
// path '…'", and other failures (e.g. permission). Earlier this dialog
// hardcoded "File not found in repo" regardless of cause, which was
// misleading when an optimistic-link click hit an ambiguous response
// before validation completed.
const isNotFound = /^file not found/i.test(error);
return (
<PopoutDialog
open={open}
onClose={onClose}
title={requestedPath ?? displayName}
container={container}
className="w-[min(520px,calc(100vw-4rem))]"
>
<div className="flex flex-col gap-2 px-5 py-6 text-sm">
<div className="font-medium text-foreground">{error}</div>
<code className="text-xs font-mono text-muted-foreground break-all">
{requestedPath ?? filepath}
</code>
{isNotFound && (
<p className="text-xs text-muted-foreground mt-1">
The path was referenced in the document but no matching file was found
in this project. It may describe a planned/future file.
</p>
)}
</div>
</PopoutDialog>
);
}
return (
<PopoutDialog
open={open}
onClose={onClose}
title={displayName}
container={container}
className="w-[calc(100vw-4rem)] max-w-[min(calc(100vw-4rem),1500px)] h-[calc(100vh-4rem)]"
>
<div className="flex items-center gap-3 px-5 pt-4 pb-3 pr-12">
<div className="flex items-center gap-2 min-w-0">
<svg className="w-4 h-4 flex-shrink-0 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
<span className="text-sm font-medium text-foreground truncate" title={filepath}>
{relativePath}
</span>
</div>
<div className="ml-auto flex items-center gap-2">
<span className="text-xs text-muted-foreground tabular-nums">
{lineCount} lines
</span>
<button
onClick={handleCopy}
title={copied ? 'Copied!' : 'Copy file contents'}
className={`p-1.5 rounded-md transition-colors ${
copied ? 'text-success' : 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
{copied ? (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</button>
</div>
</div>
<div className="border-t border-border/30" />
<div
ref={fileAreaRef}
className="flex-1 min-h-0"
onPointerMove={(e) => {
lastPointerRectRef.current = new DOMRect(e.clientX, e.clientY, 0, 0);
}}
onMouseUp={() => {
requestAnimationFrame(openCommentForBrowserSelection);
}}
>
<File
key={filepath}
file={{ name: displayName, contents }}
prerenderedHTML={effectivePrerenderedHTML}
className="h-full"
lineAnnotations={lineAnnotations}
selectedLines={selectedLines}
renderAnnotation={renderAnnotation}
renderGutterUtility={renderGutterUtility}
style={{
'--diffs-dark-bg': colors.bg,
'--diffs-light-bg': colors.bg,
'--diffs-dark': colors.fg,
'--diffs-light': colors.fg,
} as React.CSSProperties}
options={{
themeType: pierreTheme.type,
unsafeCSS: pierreTheme.css,
overflow: 'scroll',
disableFileHeader: true,
enableLineSelection: true,
enableGutterUtility: !!onAddAnnotation,
lineHoverHighlight: onAddAnnotation ? 'line' : 'disabled',
onLineClick: handleLineClick,
onLineSelectionEnd: handleLineSelectionEnd,
}}
/>
</div>
{pendingComment && onAddAnnotation && (
<CommentPopover
anchorEl={pendingComment.anchorEl}
anchorRect={pendingComment.anchorRect}
contextText={pendingComment.contextText}
isGlobal={false}
skillReferences
onSubmit={(text, images) => {
onAddAnnotation({
filePath: filepath,
lineStart: pendingComment.range.start,
lineEnd: pendingComment.range.end,
text,
images,
originalCode: pendingComment.originalCode,
});
setPendingComment(null);
}}
onClose={() => setPendingComment(null)}
/>
)}
</PopoutDialog>
);
};