diff --git a/CHANGELOG.md b/CHANGELOG.md
index fef642f8..a114f3d2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,21 @@ date, grouped by `Added` / `Changed` / `Fixed` / `Removed`.
## [Unreleased]
+## [2026-07-13] — v0.10.0 · Rich issue rendering & delivery-safe goals
+
+### Added
+
+- **Issue descriptions, comments, and Focus now render rich content consistently.** Direct images and browser-playable videos get bounded previews; supported YouTube, GitHub, Loom, and Figma links get controlled provider cards; every preview can be collapsed, hidden, restored, or opened directly with accessible controls and safe fallbacks.
+- **Plan steps now have an obvious issue-like conversation path.** Each step exposes Comment and Ask @agent actions, with the same mention autocomplete used in issue threads. Mentioning an agent wakes canonical issue-backed work with the Goal, Plan, Step, dependency, and operator-comment context.
+
+### Changed
+
+- **Finished execution no longer certifies its own delivery.** When every required step passes, the Plan becomes Completed but the Goal remains Active in outcome review. A signed-in operator must record a delivered-outcome summary and at least one PR, commit, deployment, test, artifact, or other reference before accepting the Goal as Achieved.
+
+### Fixed
+
+- **Rich-rendering work stranded in an isolated runtime clone is now part of the real release history.** The six reviewed commits were recovered onto current `main`, preserving Forge's newer URL-safety policy, and the delivery lane now has an explicit reopen path for prematurely accepted Goals.
+
## [2026-07-13] — v0.9.0 · Context-aware plans & issue archive
### Added
diff --git a/DEVLOG.md b/DEVLOG.md
index 92c250d5..752c6782 100644
--- a/DEVLOG.md
+++ b/DEVLOG.md
@@ -2,6 +2,59 @@
> Append-only session log. Read at session start. Update at session end.
+## 2026-07-13 — Rich-rendering recovery + human delivery acceptance
+
+Recovered the six AXI-95–99 rich-rendering commits from the stale Codex bridge
+clone onto current production `main`, resolving conflicts against the newer
+issue context and URL-safety work. Issue descriptions, comments, and Focus now
+share the rich renderer; direct media and allowlisted provider links get
+bounded, accessible preview controls with inert fallbacks for unsafe schemes.
+
+Closed the lifecycle defect that let successful agent reviews certify their
+own delivery. All-DONE steps now complete the Plan while the Goal remains
+ACTIVE with `OUTCOME_REVIEW` health. A signed-in operator must explicitly
+accept a non-empty outcome summary plus durable delivery evidence; premature
+Goals can be reopened with an audited reason. Added migration 0099 for the
+structured evidence record. Agents intentionally cannot accept/reopen Goals
+over MCP.
+
+Made Plan collaboration visible instead of burying a generic composer under
+each step. Step rows now expose **Ask @agent**, prefill the assigned agent,
+focus the shared mention-aware composer, and retain ordinary comments for
+context that should not dispatch work. Goal outcome review links directly to
+the final step discussion.
+
+Verification for the v0.10.0 release: `pnpm lint` (existing repository
+warnings only), `pnpm typecheck`, the full Vitest suite (**1,159 passed; 1
+skipped**), `git diff --check`, a fresh Next.js production build, and the full
+Playwright suite (**37 passed**).
+
+## 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.
+`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
@@ -12281,3 +12334,70 @@ 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.
+
+---
+
+## 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.
+
+---
+
+## 2026-07-12 — AXI-99 rich rendering accessibility/security review
+
+Reviewed the rich issue content renderer for XSS safety, keyboard/screen-reader
+accessibility, mobile preview behavior, visual token usage, and many-preview
+performance. Fixed two review blockers before approval: markdown links with
+scriptable/non-approved schemes now render as plain text instead of anchors, and
+preview action dropdown controls now expose explicit screen-reader labels plus
+Escape handling.
+
+Added regression coverage for scriptable markdown link handling and preview
+action labels alongside the existing rich-rendering behavior tests.
+
+Verification: `pnpm test tests/unit/rich-rendering.test.ts` pass (9);
+`pnpm lint` clean; `pnpm typecheck` pass; `git diff --check` clean.
+
+Follow-up review pass: tightened provider preview polish by adding explicit
+screen-reader labels to YouTube, GitHub, Loom, and Figma open controls, and
+made the compact GitHub preview metadata wrap cleanly on narrow layouts.
+
+Added regression coverage for provider preview action labels and GitHub card
+responsive constraints.
+
+Verification: `pnpm test tests/unit/rich-rendering.test.ts` pass (10);
+`pnpm lint` clean; `pnpm typecheck` pass; `git diff --check` clean.
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/concepts/orchestration.md b/docs/concepts/orchestration.md
index e6469a58..9815a745 100644
--- a/docs/concepts/orchestration.md
+++ b/docs/concepts/orchestration.md
@@ -30,10 +30,11 @@ DAG, AgentCrew, ContextSet, ReviewGate, ActionRequest). It adds the
## How a goal runs
-A **goal** is an automated loop that runs to completion — not a static
-checklist you tick off by hand. You state the objective, approve the plan,
-and the crew drives it through to "achieved" on its own, within the budget
-and time caps you set.
+A **goal** is an automated execution loop with a human delivery boundary — not
+a static checklist you tick off by hand. You state the objective, approve the
+plan, and the crew drives every step through review within the budget and time
+caps you set. Forge completes the Plan automatically; an operator accepts the
+delivered outcome before the Goal becomes achieved.
1. **Objective** — You state a high-level goal and (optionally) assign a
crew to run it.
@@ -45,8 +46,11 @@ and time caps you set.
them in parallel.
5. **Review** — A reviewer judges each finished step. A pass advances the
plan; a fail sends it back with feedback to retry.
-6. **Achieved** — When every step passes, the goal completes. Budget and
- time caps stop runaway loops automatically.
+6. **Accept outcome** — When every step passes, the Plan completes and the Goal
+ remains active in `OUTCOME_REVIEW`. An operator records what shipped plus at
+ least one durable proof (PR, commit, deployment, test, artifact, or other
+ reference). Only that explicit acceptance marks the Goal achieved. Budget
+ and time caps stop runaway loops automatically.
### Use cases
@@ -103,6 +107,12 @@ evidence. An operator's explicit `DONE` or `CANCELED` Issue transition is
authoritative in the other direction: Forge updates the one unambiguous linked
step, cascading dependents on Done or blocking the plan on Cancel.
+Every Plan step also has an issue-like discussion thread. Use **Ask @agent**
+or type `@profileKey` in the step composer to add context, ask a question, or
+request follow-up without changing the step status. The mention materializes
+issue-backed work when needed and wakes the named agent with the Goal, Plan,
+Step, dependency, and operator-comment context attached.
+
## The loop
```
@@ -148,10 +158,15 @@ step, cascading dependents on Done or blocking the plan on Cancel.
│ │
└────────────────────────────────────────────────────┘
│
- │ (every step DONE)
+ │ (every step DONE: execution complete)
▼
+ ┌────────────────┐ plan → COMPLETED; goal stays ACTIVE
+ │ OUTCOME_REVIEW │ operator records summary + delivery evidence
+ └───────┬────────┘
+ │ goal.acceptOutcome (UI-only human acceptance)
+ ▼
┌──────────┐
- │ ACHIEVED │ plan → COMPLETED, goal → ACHIEVED, achievedAt
+ │ ACHIEVED │ goal → ACHIEVED, achievedAt
└──────────┘
```
@@ -272,7 +287,8 @@ plans.recordVerdict({ stepId, verdict: "PASS", feedback: "meets contract", score
# PASS → step DONE → cascade → dependents become READY → dispatch.
# FAIL → retry (READY + retryCount++ + lastFeedback) or BLOCKED + gate.
-# When every step is DONE, the plan COMPLETEs and the goal is ACHIEVED.
+# When every step is DONE, the plan completes and the goal waits for human
+# outcome acceptance with a summary and delivery evidence.
```
Crew management (admin-scoped):
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,
diff --git a/docs/reference/mcp.md b/docs/reference/mcp.md
index d9ceaccd..ec32a9fd 100644
--- a/docs/reference/mcp.md
+++ b/docs/reference/mcp.md
@@ -625,6 +625,13 @@ Scope: `READ_ISSUES` for reads, `WRITE_ISSUES` for the loop mutations,
| `goals.create` | `{ title, description?, issueId?, crewId?, maxTotalCostUsd?, maxWallTimeMinutes? }` → `{ id }`. Emits `GOAL_CREATED`. |
| `goals.abandon` | `{ id, reason? }` → terminal `ABANDONED`; cancels any active plan attempt. |
+When every required step reaches `DONE`, Forge marks the active Plan
+`COMPLETED` but keeps the Goal `ACTIVE` with operating health
+`OUTCOME_REVIEW`. Agents can report results through comments, artifacts, and
+`goals.update`, but cannot accept their own delivery. A signed-in operator must
+record a non-empty outcome summary plus at least one durable evidence reference
+in the Goal UI before it becomes `ACHIEVED`.
+
```json
// goals.create
{ "title": "Ship the importer", "crewId": "crew_…", "maxTotalCostUsd": 5 }
@@ -735,6 +742,9 @@ These surfaces are admin/UI only and not exposed to agents:
- **Dispatch analytics** — `analytics.dispatch.summary`,
`analytics.dispatch.timeseries`. Aggregate views over many agents'
activity, intentionally not part of an agent's self-service surface.
+- **Goal outcome acceptance/reopen** — completing execution is not permission
+ to certify delivery. `goal.acceptOutcome` and `goal.reopen` are human UI/tRPC
+ actions and are intentionally absent from MCP.
If you have a use case that wants one of these on MCP, open an issue — the
gate is intentional but not absolute.
diff --git a/package.json b/package.json
index 5187902b..f377cc0a 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "forge",
- "version": "0.9.0",
+ "version": "0.10.0",
"private": true,
"description": "Forge — a fast, minimalist, keyboard-driven project management platform with pluggable agents.",
"license": "MIT",
diff --git a/prisma/migrations/0099_goal_outcome_evidence/migration.sql b/prisma/migrations/0099_goal_outcome_evidence/migration.sql
new file mode 100644
index 00000000..dbe6dc0b
--- /dev/null
+++ b/prisma/migrations/0099_goal_outcome_evidence/migration.sql
@@ -0,0 +1 @@
+ALTER TABLE "Goal" ADD COLUMN "outcomeEvidence" JSONB;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index f8b594a5..d1edbd76 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -3078,6 +3078,11 @@ model Goal {
successCriteria String? @db.Text
/// Operator-facing closeout summary populated during or after delivery.
outcomeSummary String? @db.Text
+ /// Operator-accepted proof that the outcome was actually delivered. Shape:
+ /// array of `{ kind, label, url?, ref? }` entries (PR, commit, deployment,
+ /// test, artifact, or other). A completed plan remains ACTIVE until a human
+ /// accepts a non-empty summary and at least one evidence entry.
+ outcomeEvidence Json?
/// Optional outcome horizon; plans retain their own execution budgets.
targetDate DateTime?
status GoalStatus @default(OPEN)
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.
)}
diff --git a/src/app/(app)/w/[slug]/goals/[goalId]/page.tsx b/src/app/(app)/w/[slug]/goals/[goalId]/page.tsx
index 30ac8b8f..1be3ca1b 100644
--- a/src/app/(app)/w/[slug]/goals/[goalId]/page.tsx
+++ b/src/app/(app)/w/[slug]/goals/[goalId]/page.tsx
@@ -5,6 +5,7 @@ import { useParams, useRouter } from "next/navigation";
import {
AlertTriangle,
Ban,
+ CircleCheckBig,
ChevronLeft,
Clock3,
ExternalLink,
@@ -173,6 +174,23 @@ export default function GoalDetailPage() {
const [confirmAbandon, setConfirmAbandon] = useState(false);
const [editingGoal, setEditingGoal] = useState(false);
+ const [closeoutOpen, setCloseoutOpen] = useState(false);
+ const [reopenOpen, setReopenOpen] = useState(false);
+
+ const acceptOutcomeM = goalRouter?.acceptOutcome?.useMutation({
+ onSuccess: () => {
+ toast.success("Goal outcome accepted");
+ invalidateGoal();
+ setCloseoutOpen(false);
+ },
+ });
+ const reopenM = goalRouter?.reopen?.useMutation({
+ onSuccess: () => {
+ toast.success("Goal reopened for delivery correction");
+ invalidateGoal();
+ setReopenOpen(false);
+ },
+ });
if (!available) {
return (
@@ -254,6 +272,9 @@ export default function GoalDetailPage() {
)
: null;
const activePlanEmpty = !!activePlan && activePlan.status === "DRAFT" && activeStepCount === 0;
+ const awaitingOutcomeAcceptance =
+ goal.status !== "ACHIEVED" && activePlan?.status === "COMPLETED";
+ const discussionStep = activePlan?.steps?.at(-1) ?? null;
// Offer the planner actions whenever there's nothing to run yet: no active
// plan, or an active plan still sitting as an empty DRAFT (the bug the user
// hit). A plan with steps hides the panel.
@@ -358,6 +379,36 @@ export default function GoalDetailPage() {
activeAgents={activeByAgent.size}
latestActivity={latestGoalActivity ?? null}
/>
+ {awaitingOutcomeAcceptance ? (
+
+
+ The crew finished every plan step. The Goal remains active until you record what
+ shipped and attach durable proof such as a PR, commit, deployment, test, or
+ artifact.
+
Open
diff --git a/src/components/embeds/github-embed.tsx b/src/components/embeds/github-embed.tsx
index f2272ccc..c2908b4f 100644
--- a/src/components/embeds/github-embed.tsx
+++ b/src/components/embeds/github-embed.tsx
@@ -48,7 +48,7 @@ export function GithubEmbed({
return (
-
+
-
-
+
+
{owner}/{repo}
@@ -77,7 +77,7 @@ export function GithubEmbed({
)}
-
+
{data?.commentsCount != null ? (
Open
@@ -122,11 +123,11 @@ function chooseLabel(type: "pull" | "issue", state: string | null): string {
function chooseTone(state: string | null): string {
switch (state) {
case "open":
- return "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400";
+ return "bg-success/15 text-success";
case "merged":
- return "bg-purple-500/15 text-purple-600 dark:text-purple-400";
+ return "bg-ember/15 text-ember";
case "closed":
- return "bg-rose-500/15 text-rose-600 dark:text-rose-400";
+ return "bg-danger/15 text-danger";
case "draft":
return "bg-subtle text-muted-foreground";
default:
@@ -137,15 +138,14 @@ function chooseTone(state: string | null): string {
function chooseBadge(state: string | null): string {
switch (state) {
case "open":
- return "border-emerald-500/30 text-emerald-700 dark:text-emerald-400";
+ return "border-success/30 text-success";
case "merged":
- return "border-purple-500/30 text-purple-700 dark:text-purple-400";
+ return "border-ember/30 text-ember";
case "closed":
- return "border-rose-500/30 text-rose-700 dark:text-rose-400";
+ return "border-danger/30 text-danger";
case "draft":
return "border-border text-muted-foreground";
default:
return "border-border text-muted-foreground";
}
}
-
diff --git a/src/components/embeds/loom-embed.tsx b/src/components/embeds/loom-embed.tsx
index 62e8753a..e4853fbe 100644
--- a/src/components/embeds/loom-embed.tsx
+++ b/src/components/embeds/loom-embed.tsx
@@ -81,6 +81,7 @@ export function LoomEmbed({
target="_blank"
rel="noopener noreferrer"
className="focus-ring inline-flex items-center gap-1 rounded border border-border bg-background/80 px-1.5 py-0.5 font-mono text-[0.6875rem] text-muted-foreground hover:bg-background hover:text-foreground"
+ aria-label="Open Loom recording in a new tab"
title="Open on Loom"
>
Open
diff --git a/src/components/embeds/youtube-embed.tsx b/src/components/embeds/youtube-embed.tsx
index 6840954b..88c33ba4 100644
--- a/src/components/embeds/youtube-embed.tsx
+++ b/src/components/embeds/youtube-embed.tsx
@@ -87,6 +87,7 @@ export function YouTubeEmbed({
target="_blank"
rel="noopener noreferrer"
className="focus-ring inline-flex items-center gap-1 rounded border border-border bg-background/80 px-1.5 py-0.5 font-mono text-[0.6875rem] text-muted-foreground hover:bg-background hover:text-foreground"
+ aria-label="Open YouTube video in a new tab"
title="Open on YouTube"
>
Open
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..7e181647 100644
--- a/src/components/markdown/attachment-renderer.tsx
+++ b/src/components/markdown/attachment-renderer.tsx
@@ -1,12 +1,16 @@
"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,
FileText,
File as FileIcon,
+ Bot,
+ Maximize2,
+ Minimize2,
+ MoreHorizontal,
Paperclip,
AlertTriangle,
- Bot,
ExternalLink,
} from "lucide-react";
import { trpc } from "@/lib/trpc";
@@ -18,10 +22,7 @@ import {
import { LinkFavicon } from "@/components/attachments/attachment-chip";
import { IssueHoverPreview } from "@/components/issue-hover-preview";
import { AgentHoverPreview } from "@/components/agent-hover-preview";
-import {
- tryParseDataBlock,
- type DataBlock,
-} from "@/components/data-blocks/data-block-parser";
+import { tryParseDataBlock, type DataBlock } from "@/components/data-blocks/data-block-parser";
import { DataBlockTable } from "@/components/data-blocks/data-block-table";
import { DataBlockList } from "@/components/data-blocks/data-block-list";
import { DataBlockKV } from "@/components/data-blocks/data-block-kv";
@@ -70,13 +71,11 @@ import { isSafeExternalUrl, toRenderableHref } from "@/lib/url-safety";
// Forge primitive tokenization
// ---------------------------------------------------------------------------
-const FORGE_ATTACHMENT_RE =
- /(!?)\[([^\]]*)\]\(forge-attachment:([a-z0-9]{20,})\)/gi;
+const FORGE_ATTACHMENT_RE = /(!?)\[([^\]]*)\]\(forge-attachment:([a-z0-9]{20,})\)/gi;
const FORGE_LINK_RE = /\[([^\]]*)\]\(forge-link:(https?:\/\/[^\s)]+)\)/gi;
// Issue refs (KEY-NN) and agent mentions (@profileKey). The lookbehind on
// `@` keeps `email@host` from accidentally matching as a mention.
-const REF_RE =
- /(\b[A-Z][A-Z0-9]{1,9}-\d+\b)|(?<=^|[\s(,.;:!?])@([a-z0-9][a-z0-9_-]*)/gi;
+const REF_RE = /(\b[A-Z][A-Z0-9]{1,9}-\d+\b)|(?<=^|[\s(,.;:!?])@([a-z0-9][a-z0-9_-]*)/gi;
// Standard markdown link (used after forge-attachment / forge-link).
// Excludes the `forge-` schemes so they always win their own pass.
const MD_LINK_RE = /\[([^\]]+)\]\((?!forge-)([^)\s]+(?:\s+"[^"]*")?)\)/g;
@@ -192,8 +191,7 @@ function tokenizeInline(text: string): InlineSegment[] {
const idx = m.index ?? 0;
if (idx > lo) passD.push({ type: "text", value: t.slice(lo, idx) });
if (m[1]) passD.push({ type: "issueRef", key: m[1].toUpperCase() });
- else if (m[2])
- passD.push({ type: "mention", profileKey: m[2].toLowerCase() });
+ else if (m[2]) passD.push({ type: "mention", profileKey: m[2].toLowerCase() });
lo = idx + m[0].length;
}
if (lo < t.length) passD.push({ type: "text", value: t.slice(lo) });
@@ -247,12 +245,9 @@ function parseEmphasis(text: string): InlineSegment[] {
const codeRe = /`([^`\n]+)`/;
const codeMatch = text.match(codeRe);
if (codeMatch && codeMatch.index !== undefined) {
- if (codeMatch.index > 0)
- out.push(...parseEmphasis(text.slice(0, codeMatch.index)));
+ if (codeMatch.index > 0) out.push(...parseEmphasis(text.slice(0, codeMatch.index)));
out.push({ type: "code", value: codeMatch[1] });
- out.push(
- ...parseEmphasis(text.slice(codeMatch.index + codeMatch[0].length)),
- );
+ out.push(...parseEmphasis(text.slice(codeMatch.index + codeMatch[0].length)));
return out;
}
@@ -260,13 +255,10 @@ function parseEmphasis(text: string): InlineSegment[] {
const boldRe = /\*\*([^*\n]+?)\*\*|__([^_\n]+?)__/;
const boldMatch = text.match(boldRe);
if (boldMatch && boldMatch.index !== undefined) {
- if (boldMatch.index > 0)
- out.push(...parseEmphasis(text.slice(0, boldMatch.index)));
+ if (boldMatch.index > 0) out.push(...parseEmphasis(text.slice(0, boldMatch.index)));
const inner = boldMatch[1] ?? boldMatch[2] ?? "";
out.push({ type: "bold", children: parseEmphasis(inner) });
- out.push(
- ...parseEmphasis(text.slice(boldMatch.index + boldMatch[0].length)),
- );
+ out.push(...parseEmphasis(text.slice(boldMatch.index + boldMatch[0].length)));
return out;
}
@@ -274,15 +266,12 @@ function parseEmphasis(text: string): InlineSegment[] {
const strikeRe = /~~([^~\n]+?)~~/;
const strikeMatch = text.match(strikeRe);
if (strikeMatch && strikeMatch.index !== undefined) {
- if (strikeMatch.index > 0)
- out.push(...parseEmphasis(text.slice(0, strikeMatch.index)));
+ if (strikeMatch.index > 0) out.push(...parseEmphasis(text.slice(0, strikeMatch.index)));
out.push({
type: "strike",
children: parseEmphasis(strikeMatch[1] ?? ""),
});
- out.push(
- ...parseEmphasis(text.slice(strikeMatch.index + strikeMatch[0].length)),
- );
+ out.push(...parseEmphasis(text.slice(strikeMatch.index + strikeMatch[0].length)));
return out;
}
@@ -292,13 +281,10 @@ function parseEmphasis(text: string): InlineSegment[] {
const italicRe = /(? 0)
- out.push(...parseEmphasis(text.slice(0, italicMatch.index)));
+ if (italicMatch.index > 0) out.push(...parseEmphasis(text.slice(0, italicMatch.index)));
const inner = italicMatch[1] ?? italicMatch[2] ?? "";
out.push({ type: "italic", children: parseEmphasis(inner) });
- out.push(
- ...parseEmphasis(text.slice(italicMatch.index + italicMatch[0].length)),
- );
+ out.push(...parseEmphasis(text.slice(italicMatch.index + italicMatch[0].length)));
return out;
}
@@ -333,9 +319,82 @@ 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";
+
+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"]);
+
+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 +504,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 });
@@ -511,11 +576,7 @@ function parseBlocks(body: string): Block[] {
while (align.length < header.length) align.push(null);
const rows: string[][] = [];
let j = i + 2;
- while (
- j < lines.length &&
- lines[j].includes("|") &&
- lines[j].trim() !== ""
- ) {
+ while (j < lines.length && lines[j].includes("|") && lines[j].trim() !== "") {
const row = splitTableRow(lines[j]);
// Pad / truncate to header width so cell layout stays stable
while (row.length < header.length) row.push("");
@@ -611,11 +672,7 @@ function collectInlineAttachments(
out.push({ id: seg.attachmentId, label: seg.alt || "image", isImage: true });
else if (seg.type === "attachmentLink")
out.push({ id: seg.attachmentId, label: seg.label, isImage: false });
- else if (
- seg.type === "bold" ||
- seg.type === "italic" ||
- seg.type === "strike"
- )
+ else if (seg.type === "bold" || seg.type === "italic" || seg.type === "strike")
for (const c of seg.children) walkSeg(c);
}
for (const b of blocks) {
@@ -732,7 +789,7 @@ function renderInlineSegs(
);
if (s.type === "strike")
return (
-
+
{renderInlineSegs(s.children, inlineList, key)}
);
@@ -749,11 +806,7 @@ function renderInlineSegs(
});
}
-function renderInline(
- text: string,
- inlineList: InlineList,
- keyBase: string,
-): ReactNode[] {
+function renderInline(text: string, inlineList: InlineList, keyBase: string): ReactNode[] {
return renderInlineSegs(tokenizeInline(text), inlineList, keyBase);
}
@@ -763,13 +816,7 @@ function renderInline(
* block children with consistent vertical rhythm; no `whitespace-pre-wrap`
* because the block parser handles spacing structurally now.
*/
-export function MarkdownWithAttachments({
- body,
- className,
-}: {
- body: string;
- className?: string;
-}) {
+export function MarkdownWithAttachments({ body, className }: { body: string; className?: string }) {
const blocks = useMemo(() => parseBlocks(body ?? ""), [body]);
const inlineList = useMemo(() => collectInlineAttachments(blocks), [blocks]);
@@ -784,11 +831,9 @@ export function MarkdownWithAttachments({
);
}
-function renderBlock(
- block: Block,
- idx: number,
- inlineList: InlineList,
-): ReactNode {
+export const RichContentRenderer = MarkdownWithAttachments;
+
+function renderBlock(block: Block, idx: number, inlineList: InlineList): ReactNode {
const k = `b-${idx}`;
if (block.type === "heading") {
const sizeClass =
@@ -805,17 +850,41 @@ function renderBlock(
// dynamic-string-to-IntrinsicElements widening in strict mode.
switch (block.level) {
case 1:
- return
{inner}
;
+ return (
+
+ {inner}
+
+ );
case 2:
- return
{inner}
;
+ return (
+
+ {inner}
+
+ );
case 3:
- return
{inner}
;
+ return (
+
+ {inner}
+
+ );
case 4:
- return
{inner}
;
+ return (
+
+ {inner}
+
+ );
case 5:
- return
{inner}
;
+ return (
+
+ {inner}
+
+ );
default:
- return
{inner}
;
+ return (
+
+ {inner}
+
+ );
}
}
if (block.type === "hr") {
@@ -839,7 +908,10 @@ function renderBlock(
if (block.type === "list") {
if (block.ordered) {
return (
-
+
{block.items.map((it, j) => (
{renderInline(it.text, inlineList, `${k}-${j}`)}
@@ -860,9 +932,7 @@ function renderBlock(
{it.checked ? "✓" : ""}
@@ -878,10 +948,8 @@ function renderBlock(
}
if (block.type === "dataBlock") {
const db = block.block;
- if (db.kind === "table")
- return ;
- if (db.kind === "list")
- return ;
+ if (db.kind === "table") return ;
+ if (db.kind === "list") return ;
return ;
}
if (block.type === "dataBlockInvalid") {
@@ -905,33 +973,46 @@ 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") {
return (
-