fix(web): scroll message metadata dialog entries instead of clipping (#2683)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user