From 510247ca44526528e2326de15ee49e081cc8ad07 Mon Sep 17 00:00:00 2001 From: Alejandro Perez Date: Thu, 2 Jul 2026 22:21:19 +0100 Subject: [PATCH] =?UTF-8?q?app(fe):=20agent=20voice=20=E2=80=94=20shared?= =?UTF-8?q?=20markdown,=20AgentCallout=20identity,=20in-code=20finding=20m?= =?UTF-8?q?arks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One safe-markdown renderer for everywhere the agent (or an author) speaks: agent's read, descriptions, finding rationales, conversation. AgentCallout (brand rail + tint + agent mark + mono eyebrow) gives agent-authored content one recognizable identity across the intent panel, findings band, and trajectory card — severity stays on findings. Findings now mark the code itself: gutter dots, faint line tints, minimap markers, and a reduced-motion-aware jump flash. Co-Authored-By: Claude Fable 5 --- app/src/app.css | 68 ++++++++- app/src/components/AgentCallout.test.tsx | 32 ++++ app/src/components/AgentCallout.tsx | 66 +++++++++ app/src/components/AgentPanel.tsx | 33 +++-- app/src/components/ConversationBand.tsx | 173 ++-------------------- app/src/components/DiffView.tsx | 84 +++++++++-- app/src/components/FindingsPanel.test.tsx | 24 +++ app/src/components/FindingsPanel.tsx | 14 +- app/src/components/IntentPanel.test.tsx | 24 ++- app/src/components/IntentPanel.tsx | 49 +++--- app/src/components/Markdown.test.tsx | 47 ++++++ app/src/components/Markdown.tsx | 173 ++++++++++++++++++++++ app/src/components/ReviewCard.tsx | 24 ++- app/src/lib/finding-decorations.test.ts | 84 +++++++++++ app/src/lib/finding-decorations.ts | 111 ++++++++++++++ app/src/lib/markdown-text.ts | 42 ++++++ 16 files changed, 810 insertions(+), 238 deletions(-) create mode 100644 app/src/components/AgentCallout.test.tsx create mode 100644 app/src/components/AgentCallout.tsx create mode 100644 app/src/components/Markdown.test.tsx create mode 100644 app/src/components/Markdown.tsx create mode 100644 app/src/lib/finding-decorations.test.ts create mode 100644 app/src/lib/finding-decorations.ts create mode 100644 app/src/lib/markdown-text.ts diff --git a/app/src/app.css b/app/src/app.css index e39ef47..0b1909e 100644 --- a/app/src/app.css +++ b/app/src/app.css @@ -238,14 +238,66 @@ border-left: 3px solid var(--color-state-in-review); } -/* Advisory review-finding rails in the glyph margin (B2). Dashed to read as - distinct from the solid human-comment accent, colored by severity. */ -.finding-line-info { - border-left: 3px dashed var(--color-muted-foreground); +/* Advisory finding markers in the code view (agent-voice §3). A severity-colored + dot in the glyph margin makes a finding's line discoverable without opening + its view zone. Colors mirror the status tokens — severity is state. */ +.finding-glyph-info, +.finding-glyph-warning, +.finding-glyph-critical { + display: flex; + align-items: center; + justify-content: center; +} +.finding-glyph-info::after, +.finding-glyph-warning::after, +.finding-glyph-critical::after { + content: ""; + width: 6px; + height: 6px; + border-radius: 9999px; +} +.finding-glyph-info::after { + background-color: var(--color-muted-foreground); +} +.finding-glyph-warning::after { + background-color: var(--color-warning); } -.finding-line-warning { - border-left: 3px dashed var(--color-warning); +.finding-glyph-critical::after { + background-color: var(--color-danger); } -.finding-line-critical { - border-left: 3px dashed var(--color-danger); + +/* Faint whole-line tint across a finding's range, so the flagged region reads at + a glance. Very low alpha of the severity color. */ +.finding-line-bg-info { + background-color: color-mix( + in oklch, + var(--color-muted-foreground) 6%, + transparent + ) !important; +} +.finding-line-bg-warning { + background-color: color-mix( + in oklch, + var(--color-warning) 7%, + transparent + ) !important; +} +.finding-line-bg-critical { + background-color: color-mix( + in oklch, + var(--color-danger) 7%, + transparent + ) !important; +} + +/* Transient focus flash on jump-to-line (agent-voice §3): a stronger, brief + brand tint drawing the eye to the revealed range. Applied for ~1.2s then + removed in JS, and skipped entirely under prefers-reduced-motion. Declared + last so it wins over the faint finding tint on the same line during a flash. */ +.diff-line-flash { + background-color: color-mix( + in oklch, + var(--color-brand) 22%, + transparent + ) !important; } diff --git a/app/src/components/AgentCallout.test.tsx b/app/src/components/AgentCallout.test.tsx new file mode 100644 index 0000000..615830d --- /dev/null +++ b/app/src/components/AgentCallout.test.tsx @@ -0,0 +1,32 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { AgentCallout } from "./AgentCallout"; + +describe("AgentCallout", () => { + it("renders the eyebrow, content, aside, and footer", () => { + render( + telemetry} + footer="advisory note" + > +

the agent said this

+
, + ); + expect(screen.getByText("AGENT'S READ")).toBeInTheDocument(); + expect(screen.getByText("the agent said this")).toBeInTheDocument(); + expect(screen.getByText("telemetry")).toBeInTheDocument(); + expect(screen.getByText("advisory note")).toBeInTheDocument(); + }); + + it("omits the optional slots when not provided", () => { + render( + +

content only

+
, + ); + expect(screen.getByText("LAST RUN")).toBeInTheDocument(); + expect(screen.getByText("content only")).toBeInTheDocument(); + expect(screen.queryByText("advisory note")).not.toBeInTheDocument(); + }); +}); diff --git a/app/src/components/AgentCallout.tsx b/app/src/components/AgentCallout.tsx new file mode 100644 index 0000000..12aad20 --- /dev/null +++ b/app/src/components/AgentCallout.tsx @@ -0,0 +1,66 @@ +/** + * The agent voice — one recognizable treatment for agent-authored content. + * + * Wherever the app shows something the agent wrote or derived (the PR intent's + * "agent's read", the last-run summary, and more), it wraps in this callout so + * the user learns a single visual cue: an HUD-teal left rail + the agent mark = + * "the agent said this". Restrained by design — a 2px brand rail and a very + * low-alpha brand tint, no new colors beyond the one owned accent. Severity and + * other status colors stay reserved for state and are never mixed in here + * (authorship is not a status). + */ + +import { type ReactNode } from "react"; +import { AgentMark } from "@/lib/agent-event"; +import { cn } from "@/lib/utils"; + +interface AgentCalloutProps { + /** Mono, uppercase eyebrow label (e.g. `AGENT'S READ`, `LAST RUN`). */ + readonly eyebrow: string; + /** The agent-authored content. */ + readonly children: ReactNode; + /** Optional right-aligned slot in the header row (telemetry, controls). */ + readonly aside?: ReactNode; + /** Optional provenance / advisory note, shown muted and italic beneath. */ + readonly footer?: ReactNode; + /** Extra classes for the outer container. */ + readonly className?: string; +} + +/** + * Wrap agent-authored content in the shared agent-voice treatment: a brand left + * rail, a faint brand tint, and a header row carrying the agent mark and a mono + * uppercase eyebrow. + */ +export function AgentCallout({ + eyebrow, + children, + aside, + footer, + className, +}: AgentCalloutProps) { + return ( +
+
+ + + {eyebrow} + + {aside !== undefined && ( + {aside} + )} +
+
{children}
+ {footer !== undefined && ( +
+ {footer} +
+ )} +
+ ); +} diff --git a/app/src/components/AgentPanel.tsx b/app/src/components/AgentPanel.tsx index c886529..1d4d679 100644 --- a/app/src/components/AgentPanel.tsx +++ b/app/src/components/AgentPanel.tsx @@ -31,8 +31,9 @@ import { AgentMark, type EventPresentation, } from "@/lib/agent-event"; -import { X, Trash2, Square, FileText, History, ShieldAlert } from "lucide-react"; +import { X, Trash2, Square, FileText, ShieldAlert } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { AgentCallout } from "./AgentCallout"; import { cn } from "@/lib/utils"; /** @@ -294,17 +295,12 @@ function TrajectoryCard({ summary }: { readonly summary: TrajectorySummary }) { }); return ( -
- {/* Header: last-run label + mode + telemetry. */} -
- - - Last run - - - · {summary.mode.toLowerCase()} - - + // The last-run summary is agent-authored, so it wears the shared agent voice + // (teal rail + agent mark) exactly like the intent's "agent's read". + {formatElapsed(summary.duration_ms)} @@ -316,11 +312,16 @@ function TrajectoryCard({ summary }: { readonly summary: TrajectorySummary }) { )} {endedAgo} ago -
+ } + > + {/* Mode line — a deterministic fact, kept small and muted. */} + + {summary.mode.toLowerCase()} + {/* Commands the agent ran, each ✓/✗ with the full command in the tooltip. */} {summary.commands.length > 0 && ( -
    +
      {summary.commands.map((cmd, idx) => (
    • +

      )} -
+ ); } diff --git a/app/src/components/ConversationBand.tsx b/app/src/components/ConversationBand.tsx index 0d4709a..5a49bf2 100644 --- a/app/src/components/ConversationBand.tsx +++ b/app/src/components/ConversationBand.tsx @@ -14,9 +14,7 @@ * `[bot]` account collapse to a one-line preview by default. */ -import { useEffect, useMemo, useState, type ComponentProps } from "react"; -import Markdown, { type Components } from "react-markdown"; -import remarkGfm from "remark-gfm"; +import { useEffect, useMemo, useState } from "react"; import { ChevronDown, ChevronRight, @@ -30,8 +28,14 @@ import type { ConversationItem } from "../bindings/ConversationItem"; import type { ConversationKind } from "../bindings/ConversationKind"; import { elapsedSince } from "@/lib/relative-time"; import { openExternal } from "@/lib/open"; +import { previewText, stripMarkdown } from "@/lib/markdown-text"; +import { Markdown } from "./Markdown"; import { cn } from "@/lib/utils"; +// Re-exported so existing importers (and the shared strip helper) keep a single +// stable path; the implementations now live in `@/lib/markdown-text`. +export { previewText, stripMarkdown }; + interface ConversationBandProps { /** The GitHub conversation items, in chronological order. */ readonly items: readonly ConversationItem[]; @@ -45,9 +49,6 @@ function assertNever(x: never): never { throw new Error(`unreachable: ${String(x)}`); } -/** Characters of the stripped preview shown for a collapsed (bot) comment. */ -const PREVIEW_LIMIT = 120; - /** * Whether an author is a GitHub app / bot account (login ends in `[bot]`). * Bot comments (dependabot, renovate, CI apps) are collapsed by default so the @@ -57,36 +58,6 @@ export function isBotAuthor(author: string): boolean { return /\[bot\]$/i.test(author.trim()); } -/** - * Strip common markdown / HTML syntax to plain text, for the one-line collapsed - * preview. Best-effort and pure so it can be unit-tested: it removes fences, - * inline code, images, link URLs (keeping the text), HTML tags, heading/list/ - * quote markers, and emphasis, then collapses whitespace. - */ -export function stripMarkdown(md: string): string { - return md - .replace(/```[\s\S]*?```/g, " ") // fenced code blocks - .replace(/`([^`]*)`/g, "$1") // inline code - .replace(/!\[[^\]]*\]\([^)]*\)/g, " ") // images - .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") // links -> link text - .replace(/<[^>]+>/g, " ") // raw HTML tags - .replace(/^\s{0,3}#{1,6}\s+/gm, "") // ATX headings - .replace(/^\s{0,3}>\s?/gm, "") // blockquotes - .replace(/^\s*[-*+]\s+/gm, "") // unordered list markers - .replace(/^\s*\d+\.\s+/gm, "") // ordered list markers - .replace(/[*_~]{1,3}/g, "") // emphasis / strikethrough - .replace(/\s+/g, " ") // collapse whitespace - .trim(); -} - -/** Single-line preview: markdown stripped, then truncated to {@link PREVIEW_LIMIT}. */ -export function previewText(body: string): string { - const plain = stripMarkdown(body); - return plain.length > PREVIEW_LIMIT - ? `${plain.slice(0, PREVIEW_LIMIT).trimEnd()}…` - : plain; -} - /** Presentation tone for an item's kind/state label. */ type LabelTone = "approved" | "changes" | "neutral"; @@ -174,132 +145,6 @@ function VerdictDot({ tone }: { readonly tone: LabelTone }) { return null; } -/** Open an anchor href through the system browser, never in the webview. */ -function handleExternalClick( - e: React.MouseEvent, - href: string | undefined, -): void { - e.preventDefault(); - if (href !== undefined && href !== "") void openExternal(href); -} - -/** - * Constrained renderers for the untrusted markdown bodies. Headings collapse to - * bold text (no oversized h1–h6), links open externally with rel safety, images - * degrade to an external link (no remote fetch), and code/pre use the mono - * tokens. Raw HTML never reaches here — `skipHtml` drops it upstream. - */ -const markdownComponents: Components = { - h1: ({ children }) => ( -

{children}

- ), - h2: ({ children }) => ( -

{children}

- ), - h3: ({ children }) => ( -

{children}

- ), - h4: ({ children }) => ( -

{children}

- ), - h5: ({ children }) => ( -

{children}

- ), - h6: ({ children }) => ( -

{children}

- ), - p: ({ children }) => ( -

{children}

- ), - a: ({ href, children }: ComponentProps<"a">) => ( - { - handleExternalClick(e, href); - }} - rel="noreferrer noopener" - className="text-primary hover:underline" - > - {children} - - ), - img: ({ src, alt }: ComponentProps<"img">) => { - const href = typeof src === "string" ? src : undefined; - const text = alt !== undefined && alt !== "" ? alt : "image"; - if (href === undefined) { - return {text}; - } - return ( - { - handleExternalClick(e, href); - }} - rel="noreferrer noopener" - className="text-primary underline" - title={href} - > - {text} ↗ - - ); - }, - code: ({ className, children }: ComponentProps<"code">) => ( - - {children} - - ), - pre: ({ children }) => ( -
-      {children}
-    
- ), - ul: ({ children }) => ( - - ), - ol: ({ children }) => ( -
    {children}
- ), - li: ({ children }) =>
  • {children}
  • , - blockquote: ({ children }) => ( -
    - {children} -
    - ), - hr: () =>
    , - table: ({ children }) => ( - {children}
    - ), - th: ({ children }) => ( - - {children} - - ), - td: ({ children }) => ( - {children} - ), -}; - -/** - * Render an untrusted GitHub comment body as constrained markdown. `skipHtml` - * keeps raw HTML inert (dropped, never parsed); only `remark-gfm` is enabled. - */ -function MarkdownBody({ body }: { readonly body: string }) { - return ( - - {body} - - ); -} - /** * A single conversation item row: avatar, author, label, time, body. Bot-authored * comments start collapsed to a one-line preview; human comments start expanded. @@ -383,8 +228,8 @@ function ConversationRow({ item }: { readonly item: ConversationItem }) { {hasBody && (expanded ? ( -
    - +
    +
    ) : (