diff --git a/apps/backend/internal/office/testharness/routes.go b/apps/backend/internal/office/testharness/routes.go index 3dc06453c9..082fac74cd 100644 --- a/apps/backend/internal/office/testharness/routes.go +++ b/apps/backend/internal/office/testharness/routes.go @@ -579,8 +579,15 @@ type seedMessageRequest struct { Type string `json:"type"` Content string `json:"content,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"` + // TurnMetadata is persisted on the ensured turn (the same turn the seeded + // message is attached to), so e2e specs can exercise the message metadata + // dialog's turn_metadata field end to end. + TurnMetadata map[string]interface{} `json:"turn_metadata,omitempty"` } +// seedMessageHandler inserts a synthetic agent message, ensuring an active +// turn exists for the session and applying optional turn_metadata to it so +// e2e specs can exercise the message metadata dialog end to end. func seedMessageHandler(repo *sqliterepo.Repository, eventBus bus.EventBus, log *logger.Logger) gin.HandlerFunc { return func(c *gin.Context) { var req seedMessageRequest @@ -610,6 +617,21 @@ func seedMessageHandler(repo *sqliterepo.Repository, eventBus bus.EventBus, log return } + if req.TurnMetadata != nil { + turn, err := repo.GetTurn(ctx, turnID) + if err != nil { + log.Error("test harness: get turn failed", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + turn.Metadata = req.TurnMetadata + if err := repo.UpdateTurn(ctx, turn); err != nil { + log.Error("test harness: update turn metadata failed", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } + meta := req.Metadata if meta == nil { meta = make(map[string]interface{}) diff --git a/apps/backend/internal/office/testharness/routes_test.go b/apps/backend/internal/office/testharness/routes_test.go index 9b3098c55b..bd5073fdba 100644 --- a/apps/backend/internal/office/testharness/routes_test.go +++ b/apps/backend/internal/office/testharness/routes_test.go @@ -442,6 +442,126 @@ func TestSeedMessageRejectsUnknownSession(t *testing.T) { } } +// TestSeedMessagePersistsTurnMetadata covers the harness turn-metadata +// contract: a later seed updates the pre-existing active turn's metadata, and +// a seed without turn_metadata leaves existing metadata untouched. +func TestSeedMessagePersistsTurnMetadata(t *testing.T) { + repo, sqlxDB := newTestRepo(t) + taskID := uuid.New().String() + seedTask(t, sqlxDB, taskID) + r := newRouter(t, repo, nil) + + sessBody := mustJSON(t, map[string]interface{}{ + "task_id": taskID, + "state": "RUNNING", + }) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/_test/task-sessions", bytes.NewReader(sessBody)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("session seed: expected 200, got %d body=%s", w.Code, w.Body.String()) + } + var sessResp seedTaskSessionResponse + _ = json.Unmarshal(w.Body.Bytes(), &sessResp) + + // Seed a message first WITHOUT turn metadata: the harness creates the + // active turn, and the later seed must update that same turn. + firstBody := mustJSON(t, map[string]interface{}{ + "session_id": sessResp.SessionID, + "type": "message", + "content": "first message", + }) + wFirst := httptest.NewRecorder() + reqFirst := httptest.NewRequest(http.MethodPost, "/api/v1/_test/messages", bytes.NewReader(firstBody)) + reqFirst.Header.Set("Content-Type", "application/json") + r.ServeHTTP(wFirst, reqFirst) + if wFirst.Code != http.StatusOK { + t.Fatalf("first seed: expected 200, got %d body=%s", wFirst.Code, wFirst.Body.String()) + } + + turnMeta := map[string]interface{}{ + "runtime_config_snapshot": map[string]interface{}{ + "config_baseline": map[string]interface{}{"mode": "default", "model": "anthropic/claude-sonnet-5"}, + "config_options": []interface{}{ + map[string]interface{}{"id": "mode", "name": "Mode", "value": "default", "value_name": "Default"}, + }, + }, + } + msgBody := mustJSON(t, map[string]interface{}{ + "session_id": sessResp.SessionID, + "type": "message", + "content": "hello world", + "turn_metadata": turnMeta, + }) + w2 := httptest.NewRecorder() + req2 := httptest.NewRequest(http.MethodPost, "/api/v1/_test/messages", bytes.NewReader(msgBody)) + req2.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w2, req2) + if w2.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", w2.Code, w2.Body.String()) + } + + // Exactly one active turn exists, both seeded messages share its id, and + // the second seed updated the metadata on the pre-existing turn. + turns, err := repo.ListTurnsBySession(context.Background(), sessResp.SessionID) + if err != nil { + t.Fatalf("list turns: %v", err) + } + if len(turns) != 1 { + t.Fatalf("expected exactly 1 turn, got %d", len(turns)) + } + turn := turns[0] + msgs, err := repo.ListMessages(context.Background(), sessResp.SessionID) + if err != nil { + t.Fatalf("list messages: %v", err) + } + if len(msgs) != 2 { + t.Fatalf("expected 2 messages, got %d", len(msgs)) + } + for _, m := range msgs { + if m.TurnID != turn.ID { + t.Fatalf("message %q on turn %q, expected shared turn %q", m.ID, m.TurnID, turn.ID) + } + } + if turn.Metadata == nil { + t.Fatal("expected turn metadata to be persisted") + } + snapshot, ok := turn.Metadata["runtime_config_snapshot"].(map[string]interface{}) + if !ok { + t.Fatalf("expected runtime_config_snapshot object, got %#v", turn.Metadata["runtime_config_snapshot"]) + } + if snapshot["config_baseline"] == nil { + t.Fatal("expected config_baseline inside the snapshot") + } + + // Seeding another message WITHOUT turn_metadata must leave the existing + // metadata untouched (nil must not clear it). + thirdBody := mustJSON(t, map[string]interface{}{ + "session_id": sessResp.SessionID, + "type": "message", + "content": "third message", + }) + w3 := httptest.NewRecorder() + req3 := httptest.NewRequest(http.MethodPost, "/api/v1/_test/messages", bytes.NewReader(thirdBody)) + req3.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w3, req3) + if w3.Code != http.StatusOK { + t.Fatalf("third seed: expected 200, got %d body=%s", w3.Code, w3.Body.String()) + } + turnAfter, err := repo.GetTurn(context.Background(), turn.ID) + if err != nil { + t.Fatalf("get turn after third seed: %v", err) + } + afterSnapshot, ok := turnAfter.Metadata["runtime_config_snapshot"].(map[string]interface{}) + if !ok { + t.Fatalf("expected runtime_config_snapshot to survive a nil turn_metadata seed, got %#v", turnAfter.Metadata) + } + if afterSnapshot["config_baseline"] == nil { + t.Fatal("expected config_baseline to survive a nil turn_metadata seed") + } +} + // The depth-1 guard lives in the task SERVICE; the harness writes through the // repository so e2e tests can build arbitrary parent_id chains (depth >= 2), // which is the only way to exercise the sidebar's multi-level rendering. diff --git a/apps/web/components/task/chat/messages/message-actions.tsx b/apps/web/components/task/chat/messages/message-actions.tsx index a2bd12b174..287f1e7517 100644 --- a/apps/web/components/task/chat/messages/message-actions.tsx +++ b/apps/web/components/task/chat/messages/message-actions.tsx @@ -189,6 +189,11 @@ function MetadataValue({ value }: { value: unknown }) { ); } +/** + * Debug dialog exposing a message's persisted and turn-derived metadata. The + * entries area is a keyboard-focusable scroll region so long fields (e.g. + * `turn_metadata`) stay reachable on every input modality. + */ function MessageDebugDialog({ message, turn, @@ -214,11 +219,16 @@ function MessageDebugDialog({ - - + + {t("task:messageMetadataTitle")} -
+
{Object.entries(entries).map(([key, value]) => (
{key}
diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index 33e4eb3e22..465805c8b0 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -1163,17 +1163,24 @@ export class ApiClient { return this.request("POST", "/api/v1/_test/task-sessions", body); } + /** + * Seeds an agent message via the e2e harness. `metadata` lands on the + * message row; `turnMetadata` is persisted on the ensured turn so specs can + * exercise the metadata dialog's `turn_metadata` field. + */ async seedSessionMessage( sessionId: string, opts: { type: string; content?: string; metadata?: Record; + turnMetadata?: Record; }, ): Promise { const body: Record = { session_id: sessionId, type: opts.type }; if (opts.content !== undefined) body.content = opts.content; if (opts.metadata !== undefined) body.metadata = opts.metadata; + if (opts.turnMetadata !== undefined) body.turn_metadata = opts.turnMetadata; await this.request("POST", "/api/v1/_test/messages", body); } diff --git a/apps/web/e2e/helpers/message-metadata-fixtures.ts b/apps/web/e2e/helpers/message-metadata-fixtures.ts new file mode 100644 index 0000000000..3d026072d1 --- /dev/null +++ b/apps/web/e2e/helpers/message-metadata-fixtures.ts @@ -0,0 +1,34 @@ +/** + * Shared fixtures for the message-metadata overflow E2E specs. Keeping the + * overflow shape in one place prevents the desktop and mobile specs from + * silently drifting apart when the fixture data changes. + */ + +/** The seeded message body both overflow specs render. */ +export const SEEDED_MESSAGE = "Message metadata overflow fixture"; + +/** + * Builds a `turn_metadata` payload large enough to overflow the metadata + * dialog's height cap: a 40-option `runtime_config_snapshot` plus usage and + * agent fields, mirroring what real turns persist. + */ +export function largeTurnMetadata(): Record { + 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.