fix(web): scroll message metadata dialog entries instead of clipping (#2683)

This commit is contained in:
Clément Fiere
2026-08-15 18:05:59 +02:00
committed by GitHub
parent 31e07fab34
commit 39e9bc77f5
14 changed files with 865 additions and 3 deletions
@@ -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{})
@@ -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.
@@ -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({
<IconInfoCircle className="h-full w-full" />
</button>
</DialogTrigger>
<DialogContent className="max-h-[85vh] overflow-hidden sm:max-w-2xl">
<DialogHeader>
<DialogContent className="flex max-h-[85vh] flex-col overflow-hidden sm:max-w-2xl">
<DialogHeader className="shrink-0">
<DialogTitle>{t("task:messageMetadataTitle")}</DialogTitle>
</DialogHeader>
<div className="grid gap-3 overflow-auto pr-1">
<div
role="region"
aria-label={t("task:messageMetadataEntries")}
tabIndex={0}
className="grid min-h-0 flex-1 gap-3 overflow-auto pr-1 outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
>
{Object.entries(entries).map(([key, value]) => (
<div key={key} className="grid gap-1">
<div className="font-mono text-[10px] uppercase text-muted-foreground">{key}</div>
+7
View File
@@ -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<string, unknown>;
turnMetadata?: Record<string, unknown>;
},
): Promise<void> {
const body: Record<string, unknown> = { 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);
}
@@ -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<string, unknown> {
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",
};
}
@@ -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();
});
});
@@ -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);
});
});
+1
View File
@@ -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",
+1
View File
@@ -725,6 +725,7 @@
"messageCount_one": "{{count}} ḿēśśàĝē",
"messageCount_other": "{{count}} ḿēśśàĝēś",
"messageMetadata": "Ḿēśśàĝē ḿēţàďàţà",
"messageMetadataEntries": "Ḿēśśàĝē ḿēţàďàţà ēńţŕĩēś",
"messageMetadataTitle": "Ḿēśśàĝē Ḿēţàďàţà",
"messageNotSent": "Ḿēśśàĝē ńōţ śēńţ",
"messageSendStatusUnknown": "Ḿēśśàĝē śēńď śţàţũś ũńķńōŵń",
@@ -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` `<pre>`
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` `<pre>` 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` `<pre>` 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.
@@ -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.
@@ -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` `<pre>` 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.
+1
View File
@@ -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 |
@@ -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.