Merge pull request #95 from razzant/codex/live-card-breakpoint
Some checks failed
CI / full-test (ubuntu-latest) (push) Has been cancelled
CI / quick-test (push) Has been cancelled
CI / full-test (macos-latest) (push) Has been cancelled
CI / full-test (windows-latest) (push) Has been cancelled
CI / integration-test (push) Has been cancelled
CI / skill-smoke (macos-latest) (push) Has been cancelled
CI / skill-smoke (ubuntu-latest) (push) Has been cancelled
CI / skill-smoke (windows-latest) (push) Has been cancelled
CI / marker-guards (push) Has been cancelled
CI / ui-smoke (push) Has been cancelled
CI / docker-ui-smoke (push) Has been cancelled
CI / docker-portable-test (push) Has been cancelled
CI / build (zip, windows-latest, windows-x64, syft_1.50.0_windows_amd64.zip, syft.exe, 815ee6973ec5dff6a671d7f41b0e78835a8c45b91d5a39f4743ea1cee833d3be) (push) Has been cancelled
CI / release (push) Has been cancelled
CI / build (dmg, macos-latest, macos-arm64, syft_1.50.0_darwin_arm64.tar.gz, syft, e32fdb9d47823fa633748a1efca2528fd77c37469ea93c9e40ab835da44e4cce) (push) Has been cancelled
CI / build (tar.gz, ubuntu-latest, linux-x86_64, syft_1.50.0_linux_amd64.tar.gz, syft, bf7b29ff57f06da30918266a0e1c2885a8f99784798d1bdb1628886aa015d788) (push) Has been cancelled
CI / release-preflight (push) Has been cancelled

[codex] Delay live card compact layout
This commit is contained in:
Anton Razzhigaev 2026-08-03 07:36:59 +03:00 committed by GitHub
commit fc9c1523aa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 39 additions and 26 deletions

View file

@ -938,7 +938,7 @@ Rationale: frontend work should not require understanding supervisor, worker, ma
### Chat
`web/modules/chat.js` owns the message timeline, input, attachment staging, input recall, budget pill, runtime controls, and live task cards. It loads persisted history from `/api/chat/history`, merges echoed local messages by `client_message_id`, and collapses task/progress/tool chatter into expandable cards rather than transcript spam. Every top-level bubble, media item, and task-card root carries numeric epoch `data-ts` derived from the raw source timestamp before display formatting. `insertMessageNode` inserts before the first sibling with a strictly greater timestamp, preserves arrival order for equal timestamps, leaves timestamp-free nodes on the append path, and always keeps the typing indicator last; an insertion above a scrolled-up viewport compensates `scrollTop` by the inserted `scrollHeight` delta, while a reconnect rebuild restores the first visible timestamped DOM anchor (so simultaneous older rows above and new rows below cannot move the reader), and near-bottom insertion retains normal autoscroll. Synchronous mounted live-card height mutations share one re-entrant `withStableViewport` boundary: an owner following the bottom stays pinned, while a scrolled-up reader keeps the measured offset of the nearest visible nested card boundary/timeline line (with card/message identity fallbacks when that node is rebuilt or hidden). A task card anchors its earliest raw timestamp on its DOM root, so ordinary card recreation naturally starts a fresh anchor without separate reset state. A logical chat message has one canonical `chat.jsonl` row; Project conversion stores a source ref and the history endpoint projects that row into the Project lens rather than creating a mirror bubble, duplicate unread, or duplicate cost. Owner-visible routing status is a compact `chat_annotations.jsonl` sidecar keyed by `client_message_id`; it is presentation-only. Chat attachments are staged client-side from paperclip, paste, and chat-wide file drag/drop, capped at 10 files, 50 MB per file, and 100 MB total per message; upload happens only immediately before send, attachment messages bypass the offline WebSocket queue, and partial upload/send failures best-effort DELETE already uploaded temporary files while preserving the staged batch for retry. The composer uses a responsive glass layout: desktop keeps Swarm, Low/Max, and Send inside the frosted text-entry surface; mobile lifts Swarm and Low/Max into a compact control row above the textarea while Send stays inside the field. Subagent progress uses separate child cards keyed by `subagent_task_id`/`task_id`; parent cards receive lineage references (`parent_task_id`, `root_task_id`, child id, role) without duplicating child bubbles on reload/reconnect. Ordinary nested child cards stay visible but collapsed by default with role-first headings so deep trees remain scannable; a child carrying review evidence expands by default so actor/model/verdict provenance is disclosed immediately. Each card is its own inline-size container: narrow summaries and timeline controls wrap against the card's actual width, and nesting indentation flattens after the first level so deep trees retain a usable text column. Mobile keyboard handling lives in `web/app.js` + CSS `keyboard-open` classes. Keyboard mode requires a focused editable plus a visual-viewport shrink from the last stable application viewport; opening the drawer blurs that editable and clears the keyboard state before navigation is shown, so sidebar state, backdrop, and rendered visibility cannot diverge.
`web/modules/chat.js` owns the message timeline, input, attachment staging, input recall, budget pill, runtime controls, and live task cards. It loads persisted history from `/api/chat/history`, merges echoed local messages by `client_message_id`, and collapses task/progress/tool chatter into expandable cards rather than transcript spam. Every top-level bubble, media item, and task-card root carries numeric epoch `data-ts` derived from the raw source timestamp before display formatting. `insertMessageNode` inserts before the first sibling with a strictly greater timestamp, preserves arrival order for equal timestamps, leaves timestamp-free nodes on the append path, and always keeps the typing indicator last; an insertion above a scrolled-up viewport compensates `scrollTop` by the inserted `scrollHeight` delta, while a reconnect rebuild restores the first visible timestamped DOM anchor (so simultaneous older rows above and new rows below cannot move the reader), and near-bottom insertion retains normal autoscroll. Synchronous mounted live-card height mutations share one re-entrant `withStableViewport` boundary: an owner following the bottom stays pinned, while a scrolled-up reader keeps the measured offset of the nearest visible nested card boundary/timeline line (with card/message identity fallbacks when that node is rebuilt or hidden). A task card anchors its earliest raw timestamp on its DOM root, so ordinary card recreation naturally starts a fresh anchor without separate reset state. A logical chat message has one canonical `chat.jsonl` row; Project conversion stores a source ref and the history endpoint projects that row into the Project lens rather than creating a mirror bubble, duplicate unread, or duplicate cost. Owner-visible routing status is a compact `chat_annotations.jsonl` sidecar keyed by `client_message_id`; it is presentation-only. Chat attachments are staged client-side from paperclip, paste, and chat-wide file drag/drop, capped at 10 files, 50 MB per file, and 100 MB total per message; upload happens only immediately before send, attachment messages bypass the offline WebSocket queue, and partial upload/send failures best-effort DELETE already uploaded temporary files while preserving the staged batch for retry. The composer uses a responsive glass layout: desktop keeps Swarm, Low/Max, and Send inside the frosted text-entry surface; mobile lifts Swarm and Low/Max into a compact control row above the textarea while Send stays inside the field. Subagent progress uses separate child cards keyed by `subagent_task_id`/`task_id`; parent cards receive lineage references (`parent_task_id`, `root_task_id`, child id, role) without duplicating child bubbles on reload/reconnect. Ordinary nested child cards stay visible but collapsed by default with role-first headings so deep trees remain scannable; a child carrying review evidence expands by default so actor/model/verdict provenance is disclosed immediately. Each card is its own inline-size container: internal summary/timeline compaction starts below the separate chat-column gutter breakpoint so the launcher-default Main root and first child retain regular geometry; materially narrower deep, Project, and mobile cards wrap against their actual width, then flatten further nesting so the text column remains usable. Mobile keyboard handling lives in `web/app.js` + CSS `keyboard-open` classes. Keyboard mode requires a focused editable plus a visual-viewport shrink from the last stable application viewport; opening the drawer blurs that editable and clears the keyboard state before navigation is shown, so sidebar state, backdrop, and rendered visibility cannot diverge.
A collapsed live card carries a dedicated activity line (`[data-live-activity]`, decided by the pure `projectCollapsedActivity`): the title keeps identity — the coined project name on root cards, role·model·id on subagent cards — while the line shows the latest meaningful action (the active headline for named root cards, the routed progress body for children). The activity projection is whitespace-normalized and explicitly bounded at 240 characters; collapsed title and activity are visually clamped to two lines with no reserved empty row. The complete headline/body/review/result/trace remains in the existing expanded timeline disclosure, never only in a native tooltip. `summarizeChatLiveEvent` is the child-card presentation projector for lifecycle, progress, final and replay routes; worker `subagent_event=progress` narration uses the stable progress row rather than being overwritten by the lifecycle row. Unnamed root cards suppress the line because their title already shows the activity, and a finished card keeps its last activity. Card cost is a sticky per-record projection (`taskCostProjection`/`mergeStickyCostMeta`, `{meta, ts, final}`): only frames carrying task-scope accounting evidence (`cost_accounting_status`/`cost_final`/subtree/reserve fields — never a bare `llm_round_finished` per-round `cost_usd` delta) may update it; rank is unavailable &lt; pending &lt; final (an honest reading always outranks an unknown; a settled value outranks both), the newer raw source timestamp wins among equals, and costless frames re-render the stored value instead of erasing it. Reload replays the same truth: the flat cost fields (`TASK_COST_META_FIELDS`, `ouroboros/task_results.py`) are written onto `task_summary` chat rows from the pre-synthesis usage snapshot in `agent_task_pipeline`, passed through history's `_copy_task_summary_metadata`, and overridden by the persisted `task_results/<id>.json` values in `_annotate_terminal_task_truth`.

View file

@ -2508,8 +2508,9 @@ def test_ui_smoke_live_cards_keep_usable_geometry_at_depth_and_in_project_panel(
A real replayed task tree reaches the configured hard depth of ten. The
narrow checks use geometry instead of CSS declarations, then reload to
cover replay. Wide Main and a narrow Project panel prove the card-local
container responds to its actual consumer width rather than the viewport.
cover replay. Launcher-default Main and a narrow Project panel prove the
card-local container responds to its actual consumer width rather than the
viewport.
"""
pytest.importorskip("playwright.sync_api", reason="Playwright is not installed")
from playwright.sync_api import Error as PlaywrightError
@ -2676,7 +2677,7 @@ def test_ui_smoke_live_cards_keep_usable_geometry_at_depth_and_in_project_panel(
assert_mobile_geometry(mobile)
mobile_context.close()
wide = browser.new_page(viewport={"width": 1280, "height": 900})
wide = browser.new_page(viewport={"width": 1100, "height": 750})
wide.goto(url, wait_until="domcontentloaded", timeout=30_000)
wide.wait_for_selector(
'#page-chat .chat-live-card[data-task-id="layout-root"]',
@ -2690,25 +2691,35 @@ def test_ui_smoke_live_cards_keep_usable_geometry_at_depth_and_in_project_panel(
assert len(rendered_ids) == 11, rendered_ids
wide_facts = wide.evaluate(
"""() => {
const root = document.querySelector('#page-chat #chat-messages > .chat-live-card[data-task-id="layout-root"]');
const summary = root.querySelector(':scope > .chat-live-summary-button .chat-live-summary');
const main = summary.querySelector('.chat-live-summary-main').getBoundingClientRect();
const side = summary.querySelector('.chat-live-summary-side').getBoundingClientRect();
return {
wrap: getComputedStyle(summary).flexWrap,
mainTop: main.top,
mainBottom: main.bottom,
sideTop: side.top,
sideBottom: side.bottom,
rootClient: root.clientWidth,
rootScroll: root.scrollWidth,
};
const ids = ['layout-root', 'layout-child-01', 'layout-child-02'];
return ids.map((id) => {
const card = document.querySelector(`#page-chat .chat-live-card[data-task-id="${id}"]`);
const summary = card.querySelector(':scope > .chat-live-summary-button .chat-live-summary');
const main = summary.querySelector('.chat-live-summary-main').getBoundingClientRect();
const side = summary.querySelector('.chat-live-summary-side').getBoundingClientRect();
const rect = card.getBoundingClientRect();
return {
id,
left: rect.left,
width: rect.width,
wrap: getComputedStyle(summary).flexWrap,
mainTop: main.top,
mainBottom: main.bottom,
sideTop: side.top,
sideBottom: side.bottom,
client: card.clientWidth,
scroll: card.scrollWidth,
};
});
}"""
)
assert wide_facts["wrap"] == "nowrap", wide_facts
assert min(wide_facts["mainBottom"], wide_facts["sideBottom"]) \
> max(wide_facts["mainTop"], wide_facts["sideTop"]), wide_facts
assert wide_facts["rootScroll"] <= wide_facts["rootClient"] + 1, wide_facts
assert [card["wrap"] for card in wide_facts] == ["nowrap", "nowrap", "wrap"], wide_facts
assert wide_facts[1]["left"] - wide_facts[0]["left"] >= 30, wide_facts
assert wide_facts[2]["left"] - wide_facts[1]["left"] >= 30, wide_facts
assert all(card["scroll"] <= card["client"] + 1 for card in wide_facts), wide_facts
for card in wide_facts[:2]:
assert min(card["mainBottom"], card["sideBottom"]) \
> max(card["mainTop"], card["sideTop"]), wide_facts
with (logs_dir / "progress.jsonl").open("a", encoding="utf-8") as handle:
handle.write(json.dumps(panel_row) + "\n")
@ -2740,7 +2751,7 @@ def test_ui_smoke_live_cards_keep_usable_geometry_at_depth_and_in_project_panel(
};
}"""
)
assert panel_facts["panelWidth"] <= 620, panel_facts
assert panel_facts["panelWidth"] <= 560, panel_facts
assert panel_facts["cardWidth"] >= panel_facts["panelWidth"] * 0.9, panel_facts
assert panel_facts["cardScroll"] <= panel_facts["cardClient"] + 1, panel_facts
assert panel_facts["titleWidth"] >= 180, panel_facts

View file

@ -1861,7 +1861,7 @@ button.chat-file:hover,
it only when a coined name holds the title (JS leaves it empty otherwise);
subagent cards always feed it from their routed progress. Empty hidden, so
it reserves no space. Anywhere-wrap at ALL widths keeps the narrow-container
layout (chatcol query below) safe for long URLs/paths. */
layout (card-local query below) safe for long URLs/paths. */
.chat-live-activity {
color: var(--text-muted);
font-size: 12px;
@ -1966,9 +1966,11 @@ button.chat-file:hover,
/* Internal card geometry is owned by the CARD'S actual width. This catches a
deeply nested child even when the outer chat column is wide. The main row
takes a real 100% flex basis; flex-wrap alone leaves flex:1 at a zero basis
and recreates the one-letter-wide title. Nested indentation is paid once,
then flattened so depth cannot consume the text column. */
@container livecard (max-width: 620px) {
and recreates the one-letter-wide title. This component threshold stays
below the separate chat-column gutter breakpoint so the launcher-default
root and first child keep regular geometry. Nested indentation is paid once,
then flattened so deeper cards cannot consume the text column. */
@container livecard (max-width: 560px) {
.chat-live-summary {
flex-wrap: wrap;
}