{
+ return {
+ runtime_config_snapshot: {
+ config_baseline: {
+ mode: "default",
+ model: "anthropic/claude-sonnet-5",
+ thinking: "auto",
+ },
+ config_options: Array.from({ length: 40 }, (_, i) => ({
+ id: `opt_${i}`,
+ name: `Option ${i}`,
+ value: `v${i}`,
+ value_name: `Value ${i}`,
+ })),
+ },
+ prompt_usage: { input_tokens: 1234, output_tokens: 5678 },
+ agent_id: "agent-123",
+ agent_type: "task",
+ };
+}
diff --git a/apps/web/e2e/tests/chat/message-metadata-overflow.spec.ts b/apps/web/e2e/tests/chat/message-metadata-overflow.spec.ts
new file mode 100644
index 0000000000..bd66233588
--- /dev/null
+++ b/apps/web/e2e/tests/chat/message-metadata-overflow.spec.ts
@@ -0,0 +1,128 @@
+// Regression guard: the chat message "Message Metadata" dialog must scroll
+// its entries when they exceed the dialog's height cap instead of clipping
+// the tail. The reported bug: `turn_metadata` (the last of ten debug fields,
+// carrying a large `runtime_config_snapshot`) rendered below the dialog's
+// visible area with no scrollbar anywhere (see message-actions.tsx).
+import { test, expect, type Locator, type Page } from "../../fixtures/test-base";
+import type { ApiClient } from "../../helpers/api-client";
+import type { SeedData } from "../../fixtures/test-base";
+import { SEEDED_MESSAGE, largeTurnMetadata } from "../../helpers/message-metadata-fixtures";
+import { SessionPage } from "../../pages/session-page";
+
+/** Seeds a task, session, and overflow-sized turn metadata message. */
+async function seedOverflowMessage(
+ apiClient: ApiClient,
+ seedData: SeedData,
+): Promise<{ taskId: string; sessionId: string }> {
+ const task = await apiClient.createTask(seedData.workspaceId, "Metadata Overflow", {
+ description: "seeded message metadata overflow fixture",
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ });
+ const { session_id: sessionId } = await apiClient.seedTaskSession(task.id, {
+ state: "IDLE",
+ });
+ await apiClient.seedSessionMessage(sessionId, {
+ type: "message",
+ content: SEEDED_MESSAGE,
+ turnMetadata: largeTurnMetadata(),
+ });
+ return { taskId: task.id, sessionId };
+}
+
+/** Opens the metadata dialog for the seeded message and returns its parts. */
+async function openMetadataDialog(
+ page: Page,
+ taskId: string,
+): Promise<{
+ dialog: Locator;
+ entries: Locator;
+ turnLabel: Locator;
+ title: Locator;
+}> {
+ await page.goto(`/t/${taskId}`);
+ const session = new SessionPage(page);
+ await session.waitForLoad();
+
+ const chat = session.activeChat();
+ await expect(chat.getByText(SEEDED_MESSAGE)).toBeVisible({ timeout: 15_000 });
+
+ // Scope to the seeded message's own action row (mirrors the favorite spec).
+ const messageBody = chat
+ .locator("[data-agent-message-body][data-message-id]")
+ .filter({ hasText: SEEDED_MESSAGE });
+ const actionsRow = messageBody.locator("xpath=..");
+
+ await actionsRow.getByRole("button", { name: "Show message metadata" }).click();
+
+ const dialog = page.locator('[data-slot="dialog-content"]');
+ await expect(dialog).toBeVisible();
+ // The entries container is the dialog's only direct `div.grid` child (the
+ // header carries data-slot="dialog-header"). Strict locator: Playwright
+ // fails the test if the selector ever resolves to more than one container.
+ const entries = dialog.locator("> div.grid");
+ await expect(entries).toBeVisible();
+ return {
+ dialog,
+ entries,
+ turnLabel: dialog.getByText("turn_metadata", { exact: true }),
+ title: dialog.getByText("Message Metadata", { exact: true }),
+ };
+}
+
+test.describe("Chat message metadata dialog overflow", () => {
+ test("scrolls the entries area and keeps turn_metadata reachable", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ const { taskId } = await seedOverflowMessage(apiClient, seedData);
+ const { dialog, entries, turnLabel, title } = await openMetadataDialog(testPage, taskId);
+
+ // RED assertion: the entries area must actually be scrollable. The bug
+ // made scrollHeight == clientHeight (grid auto-rows grow to content), so
+ // the tail was clipped by the dialog's overflow-hidden with no scroller.
+ const scrollable = await entries.evaluate((el) => {
+ const container = el as HTMLElement;
+ return { scrollHeight: container.scrollHeight, clientHeight: container.clientHeight };
+ });
+ expect(scrollable.scrollHeight).toBeGreaterThan(scrollable.clientHeight);
+
+ // Scroll the entries area to the bottom and prove the last field
+ // (turn_metadata) is fully inside the dialog viewport.
+ await entries.evaluate((el) => {
+ (el as HTMLElement).scrollTop = (el as HTMLElement).scrollHeight;
+ });
+
+ const dialogBox = await dialog.boundingBox();
+ const turnBox = await turnLabel.boundingBox();
+ expect(dialogBox).not.toBeNull();
+ expect(turnBox).not.toBeNull();
+ expect(turnBox!.y).toBeGreaterThanOrEqual(dialogBox!.y);
+ expect(turnBox!.y + turnBox!.height).toBeLessThanOrEqual(dialogBox!.y + dialogBox!.height);
+
+ // The dialog title stays visible while the entries area scrolls.
+ const titleBox = await title.boundingBox();
+ expect(titleBox).not.toBeNull();
+ expect(titleBox!.y).toBeGreaterThanOrEqual(dialogBox!.y);
+ expect(titleBox!.y + titleBox!.height).toBeLessThanOrEqual(dialogBox!.y + dialogBox!.height);
+
+ // The close control is an absolute sibling outside the entries scroller;
+ // it must stay fully inside the dialog while the entries are scrolled to
+ // the bottom, and it must still close the dialog.
+ const close = dialog.locator('[data-slot="dialog-close"]');
+ await expect(close).toBeVisible();
+ const closeBox = await close.boundingBox();
+ expect(closeBox).not.toBeNull();
+ expect(closeBox!.y).toBeGreaterThanOrEqual(dialogBox!.y);
+ expect(closeBox!.y + closeBox!.height).toBeLessThanOrEqual(dialogBox!.y + dialogBox!.height);
+
+ // The entries scroll region must be keyboard-reachable so keyboard-only
+ // users can scroll to the overflowed turn_metadata field.
+ await entries.focus();
+ await expect(entries).toBeFocused();
+
+ await close.click();
+ await expect(dialog).toBeHidden();
+ });
+});
diff --git a/apps/web/e2e/tests/chat/mobile-message-metadata-overflow.spec.ts b/apps/web/e2e/tests/chat/mobile-message-metadata-overflow.spec.ts
new file mode 100644
index 0000000000..d9e571efb2
--- /dev/null
+++ b/apps/web/e2e/tests/chat/mobile-message-metadata-overflow.spec.ts
@@ -0,0 +1,110 @@
+// Mobile companion to message-metadata-overflow.spec.ts. The metadata dialog
+// is a centered modal on every viewport; on a phone the same large
+// `turn_metadata` must stay reachable through the dialog's internal entries
+// scroll, with the document itself never overflowing horizontally.
+import { test, expect, type Locator, type Page } from "../../fixtures/test-base";
+import type { ApiClient } from "../../helpers/api-client";
+import type { SeedData } from "../../fixtures/test-base";
+import { SEEDED_MESSAGE, largeTurnMetadata } from "../../helpers/message-metadata-fixtures";
+import { SessionPage } from "../../pages/session-page";
+
+/** Seeds the overflow fixture and opens the metadata dialog on this page. */
+async function openMetadataDialog(
+ page: Page,
+ apiClient: ApiClient,
+ seedData: SeedData,
+): Promise<{
+ dialog: Locator;
+ entries: Locator;
+ turnLabel: Locator;
+}> {
+ const task = await apiClient.createTask(seedData.workspaceId, "Mobile Metadata Overflow", {
+ description: "seeded mobile message metadata overflow fixture",
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ });
+ const { session_id: sessionId } = await apiClient.seedTaskSession(task.id, {
+ state: "IDLE",
+ });
+ await apiClient.seedSessionMessage(sessionId, {
+ type: "message",
+ content: SEEDED_MESSAGE,
+ turnMetadata: largeTurnMetadata(),
+ });
+
+ await page.goto(`/t/${task.id}`);
+ const session = new SessionPage(page);
+ await session.waitForLoad();
+
+ const chat = session.activeChat();
+ await expect(chat.getByText(SEEDED_MESSAGE)).toBeVisible({ timeout: 15_000 });
+
+ const messageBody = chat
+ .locator("[data-agent-message-body][data-message-id]")
+ .filter({ hasText: SEEDED_MESSAGE });
+ const actionsRow = messageBody.locator("xpath=..");
+
+ await actionsRow.getByRole("button", { name: "Show message metadata" }).tap();
+
+ const dialog = page.locator('[data-slot="dialog-content"]');
+ await expect(dialog).toBeVisible();
+ // Strict locator: Playwright fails the test if the selector ever resolves
+ // to more than one entries container (the header is the only other direct
+ // child and carries data-slot="dialog-header").
+ const entries = dialog.locator("> div.grid");
+ await expect(entries).toBeVisible();
+ return { dialog, entries, turnLabel: dialog.getByText("turn_metadata", { exact: true }) };
+}
+
+test.describe("Chat message metadata dialog overflow (mobile)", () => {
+ test("scrolls entries internally with no document horizontal overflow", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ const { dialog, entries, turnLabel } = await openMetadataDialog(testPage, apiClient, seedData);
+
+ // The entries area must be scrollable on the phone viewport too.
+ const scrollable = await entries.evaluate((el) => {
+ const container = el as HTMLElement;
+ return { scrollHeight: container.scrollHeight, clientHeight: container.clientHeight };
+ });
+ expect(scrollable.scrollHeight).toBeGreaterThan(scrollable.clientHeight);
+
+ await entries.evaluate((el) => {
+ (el as HTMLElement).scrollTop = (el as HTMLElement).scrollHeight;
+ });
+
+ const dialogBox = await dialog.boundingBox();
+ const turnBox = await turnLabel.boundingBox();
+ expect(dialogBox).not.toBeNull();
+ expect(turnBox).not.toBeNull();
+ expect(turnBox!.y).toBeGreaterThanOrEqual(dialogBox!.y);
+ expect(turnBox!.y + turnBox!.height).toBeLessThanOrEqual(dialogBox!.y + dialogBox!.height);
+
+ // The close control is an absolute sibling outside the entries scroller;
+ // it must stay fully inside the dialog while the entries are scrolled to
+ // the bottom, and it must still close the dialog.
+ const close = dialog.locator('[data-slot="dialog-close"]');
+ await expect(close).toBeVisible();
+ const closeBox = await close.boundingBox();
+ expect(closeBox).not.toBeNull();
+ expect(closeBox!.y).toBeGreaterThanOrEqual(dialogBox!.y);
+ expect(closeBox!.y + closeBox!.height).toBeLessThanOrEqual(dialogBox!.y + dialogBox!.height);
+
+ // The entries scroll region must be keyboard-reachable so keyboard-only
+ // users can scroll to the overflowed turn_metadata field.
+ await entries.focus();
+ await expect(entries).toBeFocused();
+
+ await close.tap();
+ await expect(dialog).toBeHidden();
+
+ // The dialog's internal scroll must never spill into document overflow.
+ const documentOverflow = await testPage.evaluate(() => ({
+ scrollWidth: document.documentElement.scrollWidth,
+ clientWidth: document.documentElement.clientWidth,
+ }));
+ expect(documentOverflow.scrollWidth).toBeLessThanOrEqual(documentOverflow.clientWidth);
+ });
+});
diff --git a/apps/web/src/locales/en/task.json b/apps/web/src/locales/en/task.json
index 69acd45ca8..2cad240210 100644
--- a/apps/web/src/locales/en/task.json
+++ b/apps/web/src/locales/en/task.json
@@ -725,6 +725,7 @@
"messageCount_one": "{{count}} message",
"messageCount_other": "{{count}} messages",
"messageMetadata": "Message metadata",
+ "messageMetadataEntries": "Message metadata entries",
"messageMetadataTitle": "Message Metadata",
"messageNotSent": "Message not sent",
"messageSendStatusUnknown": "Message send status unknown",
diff --git a/apps/web/src/locales/pseudo/task.json b/apps/web/src/locales/pseudo/task.json
index ed817e2b47..9a8641d0e9 100644
--- a/apps/web/src/locales/pseudo/task.json
+++ b/apps/web/src/locales/pseudo/task.json
@@ -725,6 +725,7 @@
"messageCount_one": "{{count}} ḿēśśàĝē",
"messageCount_other": "{{count}} ḿēśśàĝēś",
"messageMetadata": "Ḿēśśàĝē ḿēţàďàţà",
+ "messageMetadataEntries": "Ḿēśśàĝē ḿēţàďàţà ēńţŕĩēś",
"messageMetadataTitle": "Ḿēśśàĝē Ḿēţàďàţà",
"messageNotSent": "Ḿēśśàĝē ńōţ śēńţ",
"messageSendStatusUnknown": "Ḿēśśàĝē śēńď śţàţũś ũńķńōŵń",
diff --git a/docs/plans/message-metadata-overflow/plan.md b/docs/plans/message-metadata-overflow/plan.md
new file mode 100644
index 0000000000..be7728318a
--- /dev/null
+++ b/docs/plans/message-metadata-overflow/plan.md
@@ -0,0 +1,190 @@
+---
+spec: docs/specs/ui/message-metadata-overflow.md
+created: 2026-08-14
+status: done
+---
+
+# Implementation Plan: Message metadata dialog scroll containment
+
+## Overview
+
+The chat message "Message Metadata" dialog clips its entries at
+`max-h-[85vh]` with no scrollbar when the debug fields exceed the dialog
+height. `turn_metadata` is the last of ten fields and carries large JSON
+(`runtime_config_snapshot` with a `config_options` array), so it lands below
+the visible area and becomes unreachable. The fix adopts the repo's
+established scrollable-dialog pattern so the entries area scrolls while the
+title and close button stay visible.
+
+## Root cause
+
+`apps/web/components/task/chat/messages/message-actions.tsx`
+(`MessageDebugDialog`, ~L192-232):
+
+- `DialogContent className="max-h-[85vh] overflow-hidden sm:max-w-2xl"` — the
+ base `DialogContent` (`@kandev/ui/dialog`) is `display: grid`.
+- The entries container `div.grid.gap-3.overflow-auto.pr-1` is a grid item of
+ that grid with **no height constraint**. CSS grid auto-rows grow to content
+ height, so the container's `clientHeight == scrollHeight` and its
+ `overflow-auto` never produces a scrollbar.
+- When the combined entries exceed 85vh, `DialogContent`'s `overflow-hidden`
+ clips the tail. Browser-verified with the real compiled Tailwind CSS and
+ the real merged class strings (`twMerge`): dialog
+ `clientHeight 508 / scrollHeight 1061`, entries container
+ `clientHeight 993 == scrollHeight 993`, and the `turn_metadata` ``
+ at `rectTop 803` — entirely below the visible 45–555 region, unreachable.
+
+Why `twMerge` matters: `cn()` in `@kandev/ui` is `twMerge(clsx(...))`, which
+drops the base `grid` class when a caller passes `flex`. The fix relies on
+that (as `github-app-policy-dialog` already does), so the merged output is
+`flex ... flex-col` with no `grid`.
+
+## Frontend change
+
+`apps/web/components/task/chat/messages/message-actions.tsx`
+(`MessageDebugDialog`):
+
+- `DialogContent`: `"max-h-[85vh] overflow-hidden sm:max-w-2xl"` →
+ `"flex max-h-[85vh] flex-col overflow-hidden sm:max-w-2xl"` (twMerge drops
+ the base `grid`; the dialog becomes a flex column).
+- `DialogHeader`: add `className="shrink-0"` so the title never collapses.
+- Entries container: `"grid gap-3 overflow-auto pr-1"` →
+ `"grid min-h-0 flex-1 gap-3 overflow-auto pr-1"` (height-constrained flex
+ child; its existing `overflow-auto` now engages). Keep `grid` on the
+ container itself — the entries stay stacked as they are today.
+
+No other production file changes. The per-field `MetadataValue` `` keeps
+`max-h-[48vh] overflow-auto` unchanged.
+
+### Mobile design contract
+
+- **Desktop outcome:** the metadata dialog's entries area scrolls when
+ content exceeds the 85vh cap; title and close button stay visible.
+- **Mobile entry point:** the same info-button trigger in the chat message
+ action row; the metadata dialog is a centered `Dialog` on every viewport
+ (no drawer variant exists for it).
+- **Nearest shipped mobile exemplar:** the dialog itself before the fix; the
+ change is a pure scroll-containment fix inside the existing centered modal,
+ not a composition change.
+- **Presentation choice:** unchanged centered dialog; no drawer or navigation
+ change.
+- **Scroll owner:** the entries container is the single internal scroll
+ region; the document never scrolls horizontally or vertically for this
+ dialog.
+- **Touch targets:** unchanged (no controls move).
+- **Parity proof:** `mobile-message-metadata-overflow.spec.ts` seeds the same
+ fixture, opens the dialog on the Pixel-5 viewport, and asserts the entries
+ area scrolls and the dialog stays within the viewport.
+
+## Tests
+
+### E2E regression (RED first)
+
+The bug is CSS layout: jsdom cannot compute layout, so the regression must be
+a real-browser Playwright test.
+
+Test-support prerequisite: the e2e harness currently seeds message metadata
+but never turn metadata (`ensureSeededTurn` creates turns without metadata),
+so the exact reported field cannot be exercised. Extend the harness minimally:
+
+- `apps/backend/internal/office/testharness/routes.go`:
+ `seedMessageRequest` gains `turn_metadata` (optional
+ `map[string]interface{}`); `seedMessageHandler` applies it to the ensured
+ turn via `repo.GetTurn` + `repo.UpdateTurn` when present.
+- `apps/web/e2e/helpers/api-client.ts`: `seedSessionMessage` gains an
+ optional `turnMetadata` field forwarded to the harness body.
+
+New specs:
+
+- `apps/web/e2e/tests/chat/message-metadata-overflow.spec.ts` (chromium
+ project): seed a task, session, and a message whose `turn_metadata` carries
+ a `runtime_config_snapshot` with ~40 `config_options` (the reported shape);
+ open `/t/:taskId`, open the metadata dialog via the info trigger
+ (`aria-label` "Show message metadata"), then assert:
+ - the entries container's `scrollHeight > clientHeight` (scrollable), and
+ - after scrolling the entries container to the bottom, the `turn_metadata`
+ field is fully inside the dialog viewport and the dialog title is still
+ visible.
+ This fails on the current layout (container `scrollHeight == clientHeight`;
+ `turn_metadata` below the fold) and passes after the fix.
+- `apps/web/e2e/tests/chat/mobile-message-metadata-overflow.spec.ts`
+ (mobile-chrome project): same fixture and assertions on the Pixel-5
+ viewport, plus no document-level horizontal overflow.
+
+### Component tests
+
+Existing `message-actions.test.tsx` covers trigger rendering and stays green;
+no new jsdom test is added because the regression is layout-only and Playwright
+owns it (repo convention: pure UI behavior is proven in the browser).
+
+## Verification
+
+```bash
+(cd apps && pnpm install --frozen-lockfile)
+(cd apps/web && pnpm run typecheck)
+(cd apps/web && pnpm exec eslint \
+ components/task/chat/messages/message-actions.tsx \
+ e2e/tests/chat/message-metadata-overflow.spec.ts \
+ e2e/tests/chat/mobile-message-metadata-overflow.spec.ts \
+ e2e/helpers/api-client.ts)
+(cd apps && pnpm --filter @kandev/web test -- \
+ components/task/chat/messages/message-actions.test.tsx \
+ components/task/chat/messages/message-debug-metadata.test.ts)
+(cd apps/web && pnpm e2e:run --host -- tests/chat/message-metadata-overflow.spec.ts)
+(cd apps/web && pnpm e2e:run --host --project mobile-chrome -- \
+ tests/chat/mobile-message-metadata-overflow.spec.ts)
+(cd apps/backend && make test ./internal/office/testharness/...)
+git diff --check
+```
+
+## Implementation waves
+
+Single sequential wave; the harness extension, E2E specs, and the layout fix
+share files and one RED-GREEN cycle. `parallel-safe`: none.
+
+- [x] [Task 01: Seed turn metadata and prove the overflow in E2E](task-01-seed-turn-metadata-and-prove-overflow.md)
+- [x] [Task 02: Make the metadata dialog entries area scroll](task-02-scrollable-metadata-dialog.md)
+
+## Risks
+
+- `twMerge` is load-bearing: if the merged class list ever keeps `grid`
+ alongside `flex`, `display: grid` wins (verified: `.grid` sorts after
+ `.flex` in the compiled CSS) and clipping returns. The E2E scroll assertion
+ is the gate.
+- Dropping `min-h-0` from the entries container makes the flex child refuse
+ to shrink below content (`min-height: auto`), restoring the clip. The E2E
+ catches it.
+- The harness turn-metadata extension is test-support only and gated behind
+ `KANDEV_E2E_MOCK`; it does not change production behavior.
+
+## Open questions
+
+None.
+
+## Verification results
+
+- `go test ./internal/office/testharness/` — passed (incl. new
+ `TestSeedMessagePersistsTurnMetadata`).
+- `gofmt -l internal/office/testharness/` — clean; `golangci-lint run
+ ./internal/office/testharness/...` — 0 issues.
+- RED: `message-metadata-overflow.spec.ts` failed on the current layout with
+ `scrollHeight 970 == clientHeight 970` (entries container not scrollable,
+ `turn_metadata` below the fold).
+- GREEN: `pnpm e2e:run --host -- tests/chat/message-metadata-overflow.spec.ts`
+ — 1 passed (7.6s). `pnpm e2e:run --host --project mobile-chrome --
+ tests/chat/mobile-message-metadata-overflow.spec.ts` — 1 passed (8.1s).
+- `pnpm --filter @kandev/web test -- components/task/chat/messages/message-actions.test.tsx components/task/chat/messages/message-debug-metadata.test.ts` — 2 files, 10 tests passed.
+- `pnpm run typecheck` — passed.
+- `pnpm exec eslint` on the changed frontend files — passed, 0 errors.
+- `pnpm run i18n:ratchet` — clean (0 added + 1 modified file).
+- `pnpm exec prettier --check` on the changed frontend files — passed after
+ `--write` on the mobile spec.
+- `git diff --check` — passed.
+
+Merged class strings after `twMerge` (base + fixed `DialogContent` className):
+`grid` is dropped and `flex`/`flex-col` are kept, so the dialog becomes a flex
+column; `min-h-0 flex-1` on the entries container makes its `overflow-auto`
+engage. Before the fix `turn_metadata` `` sat at `rectTop 803` with the
+dialog's visible area ending at 555 (browser repro); after the fix the E2E
+scrolls the entries container and finds the `turn_metadata` label fully inside
+the dialog, with the title and close button still on screen.
diff --git a/docs/plans/message-metadata-overflow/task-01-seed-turn-metadata-and-prove-overflow.md b/docs/plans/message-metadata-overflow/task-01-seed-turn-metadata-and-prove-overflow.md
new file mode 100644
index 0000000000..5c7f400b25
--- /dev/null
+++ b/docs/plans/message-metadata-overflow/task-01-seed-turn-metadata-and-prove-overflow.md
@@ -0,0 +1,73 @@
+---
+id: "01-seed-turn-metadata-and-prove-overflow"
+title: "Seed turn metadata and prove the overflow in E2E"
+status: done
+wave: 1
+depends_on: []
+plan: "plan.md"
+spec: "../../specs/ui/message-metadata-overflow.md"
+---
+
+# Task 01: Seed Turn Metadata and Prove the Overflow in E2E
+
+## Acceptance
+
+- The e2e mock harness accepts an optional `turn_metadata` on the
+ `POST /api/v1/_test/messages` request and persists it on the ensured turn,
+ so the reported field (`turn_metadata`) can be exercised end-to-end.
+- `ApiClient.seedSessionMessage` forwards an optional `turnMetadata` object.
+- `message-metadata-overflow.spec.ts` fails on the current layout: the
+ entries container reports `scrollHeight == clientHeight` (no scrollbar) and
+ the `turn_metadata` field sits below the dialog's visible area.
+- `mobile-message-metadata-overflow.spec.ts` reproduces the same failure on
+ the Pixel-5 viewport.
+
+## TDD sequence
+
+1. RED: extend `seedMessageRequest`/`seedMessageHandler` in
+ `apps/backend/internal/office/testharness/routes.go` and
+ `seedSessionMessage` in `apps/web/e2e/helpers/api-client.ts`.
+2. RED: add the two E2E specs; run them and confirm the scroll assertions
+ fail for the expected reason (no scrollbar, field below the fold).
+3. These tests stay red until Task 02 applies the layout fix; do not change
+ production code in this task.
+
+## Verification
+
+```bash
+(cd apps/backend && make test ./internal/office/testharness/...)
+(cd apps && pnpm install --frozen-lockfile)
+(cd apps/web && pnpm e2e:run --host -- tests/chat/message-metadata-overflow.spec.ts)
+(cd apps/web && pnpm e2e:run --host --project mobile-chrome -- \
+ tests/chat/mobile-message-metadata-overflow.spec.ts)
+```
+
+Expected RED: both specs fail on the scroll/reachability assertions.
+
+## Files likely touched
+
+- `apps/backend/internal/office/testharness/routes.go`
+- `apps/web/e2e/helpers/api-client.ts`
+- `apps/web/e2e/tests/chat/message-metadata-overflow.spec.ts`
+- `apps/web/e2e/tests/chat/mobile-message-metadata-overflow.spec.ts`
+
+## Dependencies
+
+None (harness change is additive and test-support only).
+
+## Parallelism
+
+`sequential`. The harness change and the E2E specs are one RED cycle and must
+land together with Task 02's layout fix.
+
+## Inputs
+
+- `docs/specs/ui/message-metadata-overflow.md`
+- `apps/web/e2e/tests/chat/mobile-message-timestamp-tooltip.spec.ts` (fixture
+ pattern)
+- `apps/web/e2e/fixtures/test-base.ts` (seed fixture)
+
+## Output contract
+
+Record the RED failure output of both specs (assertion + measured
+scrollHeight/clientHeight) for the plan's verification results.
diff --git a/docs/plans/message-metadata-overflow/task-02-scrollable-metadata-dialog.md b/docs/plans/message-metadata-overflow/task-02-scrollable-metadata-dialog.md
new file mode 100644
index 0000000000..e8debbd728
--- /dev/null
+++ b/docs/plans/message-metadata-overflow/task-02-scrollable-metadata-dialog.md
@@ -0,0 +1,89 @@
+---
+id: "02-scrollable-metadata-dialog"
+title: "Make the metadata dialog entries area scroll"
+status: done
+wave: 1
+depends_on: ["01-seed-turn-metadata-and-prove-overflow"]
+plan: "plan.md"
+spec: "../../specs/ui/message-metadata-overflow.md"
+---
+
+# Task 02: Make the Metadata Dialog Entries Area Scroll
+
+## Acceptance
+
+- The Message Metadata dialog entries area scrolls when the combined debug
+ fields exceed the dialog's `max-h-[85vh]`; `turn_metadata` (the last field)
+ is reachable by scrolling.
+- The dialog title (`DialogHeader`) and the close button remain visible while
+ the entries area scrolls.
+- Short metadata sets render exactly as before (no scrollbar when content
+ fits).
+- `message-metadata-overflow.spec.ts` and
+ `mobile-message-metadata-overflow.spec.ts` (from Task 01) pass.
+
+## Root cause (recap)
+
+`DialogContent className="max-h-[85vh] overflow-hidden sm:max-w-2xl"` keeps
+the base `display: grid`; the entries container is a grid item with no height
+constraint, so its `overflow-auto` never engages and `overflow-hidden` on the
+dialog clips the tail. Browser-verified: entries container
+`scrollHeight == clientHeight`, `turn_metadata` `` entirely below the
+visible area.
+
+## TDD sequence
+
+1. RED: Task 01's E2E specs already fail against the current layout.
+2. GREEN: in `apps/web/components/task/chat/messages/message-actions.tsx`
+ (`MessageDebugDialog`):
+ - `DialogContent`: `"max-h-[85vh] overflow-hidden sm:max-w-2xl"` →
+ `"flex max-h-[85vh] flex-col overflow-hidden sm:max-w-2xl"`.
+ `twMerge` drops the base `grid`, so the dialog becomes a flex column.
+ - `DialogHeader`: add `className="shrink-0"`.
+ - Entries container: `"grid gap-3 overflow-auto pr-1"` →
+ `"grid min-h-0 flex-1 gap-3 overflow-auto pr-1"`.
+3. GREEN: run both E2E specs; confirm the scroll assertions pass.
+4. REFACTOR: keep `grid` on the entries container (stacking unchanged); do
+ not touch `MetadataValue`'s per-field `max-h-[48vh]` cap, entry ordering,
+ or any i18n string.
+
+## Verification
+
+```bash
+(cd apps && pnpm install --frozen-lockfile)
+(cd apps/web && pnpm run typecheck)
+(cd apps/web && pnpm exec eslint \
+ components/task/chat/messages/message-actions.tsx)
+(cd apps && pnpm --filter @kandev/web test -- \
+ components/task/chat/messages/message-actions.test.tsx \
+ components/task/chat/messages/message-debug-metadata.test.ts)
+(cd apps/web && pnpm e2e:run --host -- tests/chat/message-metadata-overflow.spec.ts)
+(cd apps/web && pnpm e2e:run --host --project mobile-chrome -- \
+ tests/chat/mobile-message-metadata-overflow.spec.ts)
+git diff --check
+```
+
+## Files likely touched
+
+- `apps/web/components/task/chat/messages/message-actions.tsx`
+
+## Dependencies
+
+- Task 01 (harness seeding + RED E2E specs).
+
+## Parallelism
+
+`sequential`. One class-string change, one RED-GREEN cycle.
+
+## Inputs
+
+- `docs/specs/ui/message-metadata-overflow.md`
+- `apps/web/components/github/github-app-policy-dialog.tsx` (established
+ scrollable-dialog pattern: `flex ... flex-col`, `shrink-0` header,
+ `min-h-0 flex-1` scroll body)
+
+## Output contract
+
+Record the merged class strings after `twMerge`, the measured
+scrollHeight/clientHeight before and after, and both E2E spec results for the
+plan's verification results.
diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md
index 84d3ad6b18..b48a501485 100644
--- a/docs/specs/INDEX.md
+++ b/docs/specs/INDEX.md
@@ -196,6 +196,7 @@ Subscription quota tracking and per-agent cheap-model profile routing.
| [sidebar-queued-prompt-count](ui/sidebar-queued-prompt-count.md) | shipped |
| [session-tab-delete-feedback](ui/session-tab-delete-feedback.md) | shipped |
| [message-favorite-star-mobile-size](ui/message-favorite-star-mobile-size.md) | shipped |
+| [message-metadata-overflow](ui/message-metadata-overflow.md) | shipped |
| [slash-command-composer](ui/slash-command-composer.md) | shipped |
| [subagent-observability](ui/subagent-observability.md) | building |
| [entity-reference-composer](ui/entity-reference-composer.md) | draft |
diff --git a/docs/specs/ui/message-metadata-overflow.md b/docs/specs/ui/message-metadata-overflow.md
new file mode 100644
index 0000000000..dd22a0b6f5
--- /dev/null
+++ b/docs/specs/ui/message-metadata-overflow.md
@@ -0,0 +1,76 @@
+---
+status: shipped
+created: 2026-08-14
+owner: kandev
+---
+
+# Message metadata dialog scroll containment
+
+## Why
+
+The chat message "Message Metadata" dialog (`MessageDebugDialog` in
+`apps/web/components/task/chat/messages/message-actions.tsx`) renders up to
+ten debug fields, the last of which is `turn_metadata`. Turns persist a large
+`runtime_config_snapshot` (baseline plus a `config_options` array), so the
+combined entries can easily exceed the dialog's `max-h-[85vh]` cap.
+
+The dialog clips instead of scrolling. `DialogContent` gets
+`max-h-[85vh] overflow-hidden`, and its entries container
+(`div.grid.gap-3.overflow-auto.pr-1`) is a grid item of the dialog's base
+`grid` layout with no height constraint: CSS grid auto-rows grow to content
+height, so the container's own height equals its content height and its
+`overflow-auto` never engages. `turn_metadata`, being the last field, is
+pushed entirely below the visible area. The result is JSON clipped mid-content
+with no scrollbar anywhere, exactly as reported.
+
+## What
+
+- The message metadata dialog SHALL keep every entry reachable: when the
+ entries exceed the dialog's available height, the entries area scrolls.
+- The dialog SHALL use the repo's established scrollable-dialog pattern
+ (`github-app-policy-dialog`, `jira-ticket-dialog`): `DialogContent` becomes
+ a flex column (`flex max-h-[85vh] flex-col overflow-hidden`), the
+ `DialogHeader` is `shrink-0`, and the entries container becomes a
+ height-constrained flex child (`min-h-0 flex-1`) so its existing
+ `overflow-auto` engages.
+- The title and the dialog's close button SHALL remain visible while the
+ entries area scrolls.
+- Individual JSON value boxes keep their existing
+ `max-h-[48vh] overflow-auto` internal scrolling; that per-field cap is
+ unchanged.
+- No change to entry content, ordering, keys, or data.
+
+## Scenarios
+
+- **GIVEN** a message whose debug entries (including a large `turn_metadata`)
+ exceed the dialog's `max-h-[85vh]`, **WHEN** the user opens the Message
+ Metadata dialog, **THEN** the entries area scrolls and `turn_metadata` is
+ reachable.
+- **GIVEN** the entries area is scrolled to the bottom, **WHEN** the user
+ reads the last field, **THEN** `turn_metadata` is fully visible inside the
+ dialog and the dialog's title and close button remain on screen.
+- **GIVEN** a short metadata set that fits inside the dialog, **WHEN** the
+ user opens the dialog, **THEN** no scrollbar appears and the layout is
+ unchanged from today.
+- **GIVEN** the dialog is open, **WHEN** the user presses Escape or clicks the
+ close button, **THEN** the dialog closes as before.
+
+## Out of scope
+
+- Changing what metadata is captured, how it is ordered, or the per-field
+ `max-h-[48vh]` cap.
+- Restyling the scrollbar or changing other dialogs' scroll behavior.
+- Altering the dialog's trigger, i18n strings, or accessible names.
+
+## Failure modes
+
+- If the dialog container keeps `display: grid` semantics (e.g. the merged
+ class list regresses to the base `grid`), the entries container cannot be
+ height-constrained and the clipping returns; the E2E regression asserts the
+ entries area actually scrolls, so it fails on that regression.
+- If `min-h-0` is dropped from the entries container, the flex child's
+ `min-height: auto` prevents it from shrinking below content and clipping
+ returns; the E2E scroll assertion catches this too.
+- A viewport shorter than the smallest single field set must still show the
+ header and at least the top entries; the flex-column layout preserves the
+ header regardless of remaining space.