From 3ac8995fb5550b8906ecf71046fc0e9eb3e8f3ac Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 11 Jul 2026 21:39:16 +0000 Subject: [PATCH 1/7] docs: define rich preview rendering contract --- DEVLOG.md | 12 ++++++ docs/agents/chat.md | 68 ++++++++++++++++++++++++++++++ docs/guide/time-and-attachments.md | 5 +++ 3 files changed, 85 insertions(+) diff --git a/DEVLOG.md b/DEVLOG.md index 92c250d5..47950754 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,6 +2,18 @@ > Append-only session log. Read at session start. Update at session end. +## 2026-07-11 — AXI-95 rich preview rendering contract + +Defined the shared rich preview contract for chat/message markdown surfaces. +`docs/agents/chat.md` now specifies inline image, video, file, LINK attachment, +`forge-link`, normal URL, and allowlisted provider embed behavior; max preview +sizing; collapsed/expanded/hidden states; per-preview actions; and safe +fallback/error behavior. `docs/guide/time-and-attachments.md` now links +attachment readers back to that shared contract. + +Verification: documentation diff reviewed. `corepack pnpm lint` and +`corepack pnpm typecheck` were run in the bridge container. + ## 2026-07-13 — Plan context integrity + reversible issue archive Closed the execution-context gaps that let materialized plan issues look like diff --git a/docs/agents/chat.md b/docs/agents/chat.md index f06af4d1..50ca6bb5 100644 --- a/docs/agents/chat.md +++ b/docs/agents/chat.md @@ -101,6 +101,74 @@ bold/italic, inline code, fenced code blocks (with copy buttons), blockquotes, unordered lists, attachments, tool-call cards, and local SYSTEM bubbles from slash commands. +### Rich previews contract + +Chat, comments, descriptions, notes, and artifact bodies use one rendering +contract for inline media and links. The renderer is intentionally allowlist- +driven: Forge embeds only known-safe media surfaces and never executes remote +HTML or scripts from a message body. + +| Input | Default rendering | Expanded rendering | Plain-link fallback | +| --- | --- | --- | --- | +| Uploaded image attachment (`image/png`, `image/jpeg`, `image/gif`, `image/webp`, `image/svg+xml`) | Inline thumbnail when rendered from an image token or attachment list. | Attachment lightbox with fit-to-viewport preview. | File chip if the presigned URL, image decode, or storage lookup fails. | +| Uploaded video attachment (`video/mp4`, `video/webm`, `video/quicktime`) | Compact file chip in dense message bodies; canvas/attachment preview surfaces may show an inline player. | Attachment lightbox or preview card with native controls and no autoplay. | File chip with open/download actions if playback or presign fails. | +| Uploaded non-media file | File chip with MIME-aware icon, filename, and size. | Attachment lightbox preview when Forge has a safe renderer; otherwise open/download. | File chip remains the canonical view. | +| LINK attachment | Link chip with favicon or external-link icon, title/hostname, and external indicator. | Opens the canonical URL in a new tab. | Hostname chip if title or favicon lookup fails. | +| `forge-link:https://...` markdown token | Inline link chip matching LINK attachment styling. | Opens in a new tab. | Plain text if the scheme is not `http` or `https`. | +| Normal markdown link or bare `http(s)` URL | Standard text link. | Opens in a new tab. | Standard text link; no rich card is fetched automatically. | +| Allowlisted product embed URL | Provider embed card for known safe providers only, currently Forge artifacts plus YouTube, GitHub, Loom, and Figma. | Embedded card/player in the body, constrained to the preview size rules below. | Standard text link when provider detection fails or the provider cannot render safely. | + +Preview sizing is capped by surface: + +- Inline image thumbnails are square, approximately 80 px, object-fit cover. +- Message/body embed cards use the available body width and should cap at + 640 px wide and 360 px tall, preserving media aspect ratio. +- Attachment lightbox previews fit within the modal viewport and never force + page scroll outside the modal. +- Canvas preview cards use the node bounds; new preview cards default to a + larger card height so the preview is useful, then resize with the canvas + node. +- No preview should expand a chat bubble, comment, or note beyond the readable + column width. Long filenames, titles, and URLs truncate in the middle or end + instead of reflowing controls. + +Collapsed and hidden states: + +- Dense message bodies default to compact chips for non-image files and links. +- Each preview-capable attachment or embed has a collapsed state: chip/card + summary with filename or title, host/MIME/size metadata, and an expand action. +- Expanded previews show the media/embed in place when the surface supports + inline expansion; otherwise they open the attachment lightbox. +- Users can collapse an expanded inline preview back to the chip/card summary. +- A dismissed preview hides only that preview instance for the current surface; + it does not delete the attachment, mutate message markdown, or hide the same + attachment everywhere else. + +Per-preview actions: + +- File previews: open in new tab, download, copy short-lived presigned URL, + and delete when the current user has permission for that target. +- LINK and `forge-link` previews: open in new tab and copy canonical URL. + They do not expose download because Forge has no bytes for them. +- Embed previews: open source in new tab, copy URL, collapse, and hide preview. +- Destructive actions use the existing confirmation flow and update the + surrounding attachment list after success. + +Fallback and error behavior: + +- Presign, fetch, decode, provider-detection, favicon, and playback failures + must degrade to a chip or standard link; they must not leave broken image, + iframe, or video chrome in the message. +- Unsupported MIME types should already be rejected at upload. If older data or + bad metadata reaches the renderer, render a generic file chip. +- External HTML is never injected into the DOM. Provider embeds are implemented + with first-party components or sandboxed, allowlisted iframe URLs; unknown + URLs remain normal links. +- Remote media does not autoplay. Video controls are user-initiated, and audio + stays muted/off until the user starts playback. +- Copying a presigned file URL should label the result as short-lived because + Forge download URLs expire. + ## Status and diagnostics The right-hand Chat status rail is the operator-facing source of truth for a diff --git a/docs/guide/time-and-attachments.md b/docs/guide/time-and-attachments.md index 22b32863..8d2a9799 100644 --- a/docs/guide/time-and-attachments.md +++ b/docs/guide/time-and-attachments.md @@ -226,6 +226,11 @@ favicon (origin + `/favicon.ico`, with a Lucide `ExternalLink` fallback on image-load error) and the hostname inline. Clicking opens the URL in a new tab. +Preview behavior for images, videos, link chips, provider embeds, +collapsed states, per-preview actions, and safe fallbacks is defined in +[Chat rich previews](/agents/chat.html#rich-previews-contract). The same +contract applies anywhere Forge renders attachment or link previews. + ### `forge-link` markdown token Inside any rendered markdown body (issue descriptions, comments, From c8063b9238eba205c7a3ddc532e2dc19da489fb6 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 11 Jul 2026 21:52:58 +0000 Subject: [PATCH 2/7] Build rich content renderer previews --- DEVLOG.md | 21 ++ src/components/issue-detail/issue-main.tsx | 15 +- .../markdown/attachment-renderer.tsx | 293 +++++++++++++++++- 3 files changed, 308 insertions(+), 21 deletions(-) diff --git a/DEVLOG.md b/DEVLOG.md index 47950754..5a088233 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -12293,3 +12293,24 @@ Verification: lint and typecheck passed; 1,126 unit/integration tests passed (one live-only test skipped); and a forced fresh production build plus all 37 Playwright tests passed. This change is local only; production was inspected read-only and was not deployed or mutated. +Verification: `corepack pnpm lint` clean; `corepack pnpm typecheck` pass; +`git diff --check` clean. DB-backed tests were not run because this +codex-bridge container has no Postgres/Redis service stack. + +--- + +## 2026-07-11 — AXI-96 rich issue content renderer + +Extended the shared safe markdown renderer used by issue descriptions and +comments into an explicit `RichContentRenderer` export while keeping the +existing attachment-aware renderer API compatible for other surfaces. + +The renderer now detects direct image and browser-playable video URLs in issue +content, keeps the original URL clickable/readable, and renders bounded inline +previews. Generated media/provider previews have a compact Slack-style action +menu for collapse, open, and hide/show controls, using existing tokens and +density-aware text utilities. + +Verification: `corepack pnpm lint` clean; `corepack pnpm typecheck` pass. +DB-backed tests were not run because this codex-bridge container has no +Postgres/Redis service stack. diff --git a/src/components/issue-detail/issue-main.tsx b/src/components/issue-detail/issue-main.tsx index 0d80a9cb..ee328191 100644 --- a/src/components/issue-detail/issue-main.tsx +++ b/src/components/issue-detail/issue-main.tsx @@ -9,7 +9,10 @@ import { Avatar } from "@/components/ui/avatar"; import { AgentAvatar, type AgentAvatarIdentity } from "@/components/agents/agent-avatar"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { MarkdownWithAttachments } from "@/components/markdown/attachment-renderer"; +import { + MarkdownWithAttachments, + RichContentRenderer, +} from "@/components/markdown/attachment-renderer"; import { DescriptionAiAssist } from "@/components/issue-detail/description-ai-assist"; import { AgentRunStrip } from "@/components/issue-detail/agent-run-strip"; import { @@ -645,7 +648,7 @@ function DescriptionBlock({ title="Click to edit" > {description ? ( - + ) : ( No description. Click to add. )} @@ -1232,7 +1235,7 @@ function TimelineCommentCard({ return (
- @@ -1532,9 +1535,9 @@ function CommentBodyWithTools({ className?: string; }) { // Fast path for the common case — a body with no tool directives — - // avoids wrapping a single MarkdownWithAttachments in an extra div. + // avoids wrapping a single RichContentRenderer in an extra div. if (segments.length === 1 && segments[0].kind === "md") { - return ; + return ; } return (
@@ -1543,7 +1546,7 @@ function CommentBodyWithTools({ return ; } if (!seg.text.trim()) return null; - return ; + return ; })}
); diff --git a/src/components/markdown/attachment-renderer.tsx b/src/components/markdown/attachment-renderer.tsx index b5b860de..64333985 100644 --- a/src/components/markdown/attachment-renderer.tsx +++ b/src/components/markdown/attachment-renderer.tsx @@ -2,11 +2,15 @@ import { Fragment, useId, useMemo, useState, type ReactNode } from "react"; import Link from "next/link"; import { + EyeOff, FileText, File as FileIcon, + Bot, + Maximize2, + Minimize2, + MoreHorizontal, Paperclip, AlertTriangle, - Bot, ExternalLink, } from "lucide-react"; import { trpc } from "@/lib/trpc"; @@ -333,9 +337,45 @@ type Block = | { type: "dataBlock"; block: DataBlock } | { type: "dataBlockInvalid"; raw: string; reason: string } | { type: "artifactEmbed"; artifactId: string } + | { type: "mediaPreview"; url: string; media: DirectMediaKind } | { type: "urlEmbed"; match: EmbedMatch }; type Align = "left" | "center" | "right" | null; +type DirectMediaKind = "image" | "video"; + +const IMAGE_EXTENSIONS = new Set(["avif", "gif", "jpg", "jpeg", "png", "webp"]); +const VIDEO_EXTENSIONS = new Set(["mp4", "ogg", "ogv", "webm"]); + +function directMediaKind(url: string): DirectMediaKind | null { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return null; + } + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null; + const last = parsed.pathname.split("/").pop() ?? ""; + const ext = last.includes(".") ? last.split(".").pop()?.toLowerCase() : null; + if (!ext) return null; + if (IMAGE_EXTENSIONS.has(ext)) return "image"; + if (VIDEO_EXTENSIONS.has(ext)) return "video"; + return null; +} + +function directMediaUrlsInText(text: string): Array<{ url: string; media: DirectMediaKind }> { + const seen = new Set(); + const out: Array<{ url: string; media: DirectMediaKind }> = []; + URL_RE.lastIndex = 0; + for (const m of text.matchAll(URL_RE)) { + const url = m[0]; + if (seen.has(url)) continue; + const media = directMediaKind(url); + if (!media) continue; + seen.add(url); + out.push({ url, media }); + } + return out; +} function isHr(line: string): boolean { const t = line.trim(); @@ -445,6 +485,12 @@ function parseBlocks(body: string): Block[] { { const t = ln.trim(); if (/^https?:\/\//i.test(t) && !/\s/.test(t)) { + const media = directMediaKind(t); + if (media) { + out.push({ type: "mediaPreview", url: t, media }); + i++; + continue; + } const m = matchEmbedUrl(t); if (m) { out.push({ type: "urlEmbed", match: m }); @@ -784,6 +830,8 @@ export function MarkdownWithAttachments({ ); } +export const RichContentRenderer = MarkdownWithAttachments; + function renderBlock( block: Block, idx: number, @@ -905,25 +953,47 @@ function renderBlock( if (block.type === "artifactEmbed") { return ; } + if (block.type === "mediaPreview") { + return ( + + ); + } if (block.type === "urlEmbed") { const m = block.match; if (m.kind === "youtube") - return ; + return ( + + + + ); if (m.kind === "github") return ( - + + + ); if (m.kind === "loom") - return ; + return ( + + + + ); if (m.kind === "figma") - return ; + return ( + + + + ); return null; } if (block.type === "table") { @@ -968,10 +1038,27 @@ function renderBlock( } // Paragraph — preserve hard newlines inside paragraphs (a real // newline in the source between paragraph lines stays as a
). + const mediaUrls = directMediaUrlsInText(block.text); + if (mediaUrls.length === 0) { + return ( +

+ {renderInline(block.text, inlineList, k)} +

+ ); + } return ( -

- {renderInline(block.text, inlineList, k)} -

+ +

+ {renderInline(block.text, inlineList, k)} +

+ {mediaUrls.map((it, mediaIdx) => ( + + ))} +
); } @@ -1020,6 +1107,182 @@ function CodeBlock({ code, lang }: { code: string; lang: string }) { ); } +function hostLabel(url: string): string { + try { + return new URL(url).hostname.replace(/^www\./i, ""); + } catch { + return "media"; + } +} + +function PreviewControls({ + url, + label, + children, +}: { + url: string; + label: string; + children: ReactNode; +}) { + const [hidden, setHidden] = useState(false); + const [collapsed, setCollapsed] = useState(false); + const [menuOpen, setMenuOpen] = useState(false); + const host = hostLabel(url); + + if (hidden) { + return ( +
+ + {url} + + +
+ ); + } + + return ( +
+
+ + {label} · {host} + +
+ + {menuOpen && ( +
+ + + + Open link + + +
+ )} +
+
+ {collapsed ? ( + + ) : ( +
+ {children} +
+ )} +
+ ); +} + +function DirectMediaPreview({ + url, + media, +}: { + url: string; + media: DirectMediaKind; +}) { + return ( + + {media === "image" ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( + + )} + + ); +} + // --------------------------------------------------------------------------- // Inline forge primitive components // (Same behaviour as before — these are the leaves the inline renderer From c7441c295a99e44f32ee13f0788bbb5d39decc66 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 12 Jul 2026 02:20:14 +0000 Subject: [PATCH 3/7] Render focus issue descriptions with rich content --- DEVLOG.md | 14 ++++++++++++++ src/app/(app)/w/[slug]/focus/[id]/page.tsx | 7 +++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/DEVLOG.md b/DEVLOG.md index 5a088233..0564b5ff 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,6 +2,20 @@ > Append-only session log. Read at session start. Update at session end. +## 2026-07-12 — AXI-97 rich issue rendering integration + +Integrated the shared rich body renderer into the remaining issue-adjacent +description surface. The main issue detail description and comment timeline +already render through `RichContentRenderer`; the fullscreen focus issue view +now uses the same renderer instead of raw `whitespace-pre-wrap` text, so image +URLs, video URLs, normal links, provider embeds, and Forge attachment/link +tokens preview consistently without changing stored issue description data or +the existing edit/save flows. + +Verification: `corepack pnpm lint` passed and `corepack pnpm typecheck` passed. +DB-backed tests were not run in this bridge container because it has no +Postgres/Redis/MinIO/Docker services. + ## 2026-07-11 — AXI-95 rich preview rendering contract Defined the shared rich preview contract for chat/message markdown surfaces. diff --git a/src/app/(app)/w/[slug]/focus/[id]/page.tsx b/src/app/(app)/w/[slug]/focus/[id]/page.tsx index 3130e7b4..cb7b2e40 100644 --- a/src/app/(app)/w/[slug]/focus/[id]/page.tsx +++ b/src/app/(app)/w/[slug]/focus/[id]/page.tsx @@ -6,6 +6,7 @@ import { ArrowLeft, Check, Pause, Play, RotateCcw } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { RichContentRenderer } from "@/components/markdown/attachment-renderer"; import { trpc } from "@/lib/trpc"; import { formatIssueId } from "@/lib/utils"; import { useWorkspace } from "@/hooks/use-workspace"; @@ -105,8 +106,10 @@ export default function FocusPage({ params }: { params: Promise<{ id: string }>

{issue.title}

-
- {issue.description || ( +
+ {issue.description ? ( + + ) : ( No description. )}
From d07c9eb9ad8bd268a8acfb3c0a7e8c1de6ffa6c2 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 12 Jul 2026 02:27:31 +0000 Subject: [PATCH 4/7] test rich rendering behavior --- DEVLOG.md | 19 +++ .../markdown/attachment-renderer.tsx | 60 ++++++-- tests/unit/rich-rendering.test.ts | 139 ++++++++++++++++++ 3 files changed, 206 insertions(+), 12 deletions(-) create mode 100644 tests/unit/rich-rendering.test.ts diff --git a/DEVLOG.md b/DEVLOG.md index 0564b5ff..89d215d9 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -12328,3 +12328,22 @@ density-aware text utilities. Verification: `corepack pnpm lint` clean; `corepack pnpm typecheck` pass. DB-backed tests were not run because this codex-bridge container has no Postgres/Redis service stack. + +--- + +## 2026-07-12 — AXI-98 rich rendering behavior tests + +Added focused rich-rendering regression coverage for plain text, multiple +inline links, direct image/video previews, YouTube embed promotion, unsupported +links, malformed URLs, preview hide/collapse state transitions, and issue-detail +usage of `RichContentRenderer`. + +Extracted the preview action state into a tiny reducer used by the existing +preview controls so hide/collapse/show behavior can be tested without adding a +browser-only test dependency. + +Verification: `corepack pnpm lint` clean; `corepack pnpm typecheck` pass; +`node_modules/.bin/vitest run tests/unit/rich-rendering.test.ts` pass (7). +`corepack pnpm test` was attempted and reached the normal command, but this +codex-bridge container has no `DATABASE_URL`/Postgres service, so Prisma-backed +router/service tests failed during fixture setup. diff --git a/src/components/markdown/attachment-renderer.tsx b/src/components/markdown/attachment-renderer.tsx index 64333985..e7f41536 100644 --- a/src/components/markdown/attachment-renderer.tsx +++ b/src/components/markdown/attachment-renderer.tsx @@ -1,5 +1,5 @@ "use client"; -import { Fragment, useId, useMemo, useState, type ReactNode } from "react"; +import { Fragment, useId, useMemo, useReducer, useState, type ReactNode } from "react"; import Link from "next/link"; import { EyeOff, @@ -343,6 +343,43 @@ type Block = type Align = "left" | "center" | "right" | null; type DirectMediaKind = "image" | "video"; +export type PreviewControlState = { + hidden: boolean; + collapsed: boolean; + menuOpen: boolean; +}; + +type PreviewControlAction = + | { type: "toggleMenu" } + | { type: "closeMenu" } + | { type: "toggleCollapsed" } + | { type: "hide" } + | { type: "show" }; + +const INITIAL_PREVIEW_CONTROL_STATE: PreviewControlState = { + hidden: false, + collapsed: false, + menuOpen: false, +}; + +export function reducePreviewControlState( + state: PreviewControlState, + action: PreviewControlAction, +): PreviewControlState { + switch (action.type) { + case "toggleMenu": + return { ...state, menuOpen: !state.menuOpen }; + case "closeMenu": + return { ...state, menuOpen: false }; + case "toggleCollapsed": + return { ...state, collapsed: !state.collapsed, menuOpen: false }; + case "hide": + return { ...state, hidden: true, menuOpen: false }; + case "show": + return { hidden: false, collapsed: false, menuOpen: false }; + } +} + const IMAGE_EXTENSIONS = new Set(["avif", "gif", "jpg", "jpeg", "png", "webp"]); const VIDEO_EXTENSIONS = new Set(["mp4", "ogg", "ogv", "webm"]); @@ -1124,9 +1161,11 @@ function PreviewControls({ label: string; children: ReactNode; }) { - const [hidden, setHidden] = useState(false); - const [collapsed, setCollapsed] = useState(false); - const [menuOpen, setMenuOpen] = useState(false); + const [state, dispatch] = useReducer( + reducePreviewControlState, + INITIAL_PREVIEW_CONTROL_STATE, + ); + const { hidden, collapsed, menuOpen } = state; const host = hostLabel(url); if (hidden) { @@ -1143,8 +1182,7 @@ function PreviewControls({