diff --git a/.agents/evidence/pr/mobile-first-ui/README.md b/.agents/evidence/pr/mobile-first-ui/README.md new file mode 100644 index 00000000..ec019570 --- /dev/null +++ b/.agents/evidence/pr/mobile-first-ui/README.md @@ -0,0 +1,62 @@ +Mobile-first UI evidence captured from a real Claude Code trace. + +Source trace data: + +- Local trace database: `.traces/mobile-first-ui/traces.sqlite3` +- Session id: `600fd026-dbab-4d44-b19f-8df27536398d` +- Session model: `claude-sonnet-4-6` +- Session prompt: `Reply exactly: mobile-layout-real-trace-ok` +- API records: 2 + +Trace capture: + +```bash +CLOUDTAP_DB=.traces/mobile-first-ui/traces.sqlite3 \ + timeout 45s uv run python -m claude_tap \ + --tap-no-live \ + --tap-no-open \ + --tap-no-update-check \ + --tap-output-dir .traces/mobile-first-ui/out \ + -- -p --tools '' --no-session-persistence --max-budget-usd 0.03 \ + 'Reply exactly: mobile-layout-real-trace-ok' +``` + +Local dashboard server: + +```bash +CLOUDTAP_DB=.traces/mobile-first-ui/traces.sqlite3 \ + uv run claude-tap dashboard \ + --tap-output-dir .traces/mobile-first-ui/out \ + --tap-live-port 33117 \ + --tap-no-open +``` + +Screenshots: + +- `dashboard-mobile-list-320.png`: dashboard session cards at 320px. +- `dashboard-mobile-actions-320.png`: dashboard mobile card action row at 320px. +- `dashboard-mobile-list-375.png`: dashboard session cards at 375px. +- `dashboard-tablet-list-768.png`: tablet-width dashboard session cards at 768px. +- `dashboard-desktop-table-1440.png`: desktop dashboard table at 1440px. +- `viewer-mobile-list-320.png`: standalone viewer list state at 320px. +- `viewer-mobile-list-375.png`: standalone viewer list state at 375px. +- `viewer-mobile-detail-320.png`: standalone viewer detail state at 320px. +- `viewer-mobile-detail-375.png`: standalone viewer detail state at 375px. +- `viewer-desktop-detail-1440.png`: standalone viewer detail state at 1440px. + +Capture assertions: + +- `/api/sessions` returned exactly the generated session. +- Every captured viewport reported page-level `overflowX <= 2`. +- Dashboard mobile refresh control renders as an icon with `aria-label="Refresh"`, not a literal `R`. +- Viewer mobile detail action buttons stay inside the action bar, including `Diff with Prev`. +- Viewer mobile detail action bar stays below the sticky detail inspector tabs after scrolling. +- Empty embedded traces keep the sidebar hidden at mobile width. +- Viewer detail evidence keeps long `System Prompt` and `Messages` sections collapsed to avoid exposing local paths or private prompt context in public PR screenshots. + +Manual screenshot review: + +- No mobile screenshot shows clipped primary actions. +- No mobile detail screenshot shows action buttons hidden under the detail inspector tabs. +- No screenshot shows synthetic or mock trace data. +- No screenshot exposes secrets, tokens, local email addresses, or expanded long prompt context. diff --git a/.agents/evidence/pr/mobile-first-ui/dashboard-desktop-table-1440.png b/.agents/evidence/pr/mobile-first-ui/dashboard-desktop-table-1440.png new file mode 100644 index 00000000..b9917b5b Binary files /dev/null and b/.agents/evidence/pr/mobile-first-ui/dashboard-desktop-table-1440.png differ diff --git a/.agents/evidence/pr/mobile-first-ui/dashboard-mobile-actions-320.png b/.agents/evidence/pr/mobile-first-ui/dashboard-mobile-actions-320.png new file mode 100644 index 00000000..487d8eab Binary files /dev/null and b/.agents/evidence/pr/mobile-first-ui/dashboard-mobile-actions-320.png differ diff --git a/.agents/evidence/pr/mobile-first-ui/dashboard-mobile-list-320.png b/.agents/evidence/pr/mobile-first-ui/dashboard-mobile-list-320.png new file mode 100644 index 00000000..d54a10fe Binary files /dev/null and b/.agents/evidence/pr/mobile-first-ui/dashboard-mobile-list-320.png differ diff --git a/.agents/evidence/pr/mobile-first-ui/dashboard-mobile-list-375.png b/.agents/evidence/pr/mobile-first-ui/dashboard-mobile-list-375.png new file mode 100644 index 00000000..76429d74 Binary files /dev/null and b/.agents/evidence/pr/mobile-first-ui/dashboard-mobile-list-375.png differ diff --git a/.agents/evidence/pr/mobile-first-ui/dashboard-tablet-list-768.png b/.agents/evidence/pr/mobile-first-ui/dashboard-tablet-list-768.png new file mode 100644 index 00000000..842ec6df Binary files /dev/null and b/.agents/evidence/pr/mobile-first-ui/dashboard-tablet-list-768.png differ diff --git a/.agents/evidence/pr/mobile-first-ui/viewer-desktop-detail-1440.png b/.agents/evidence/pr/mobile-first-ui/viewer-desktop-detail-1440.png new file mode 100644 index 00000000..81de48db Binary files /dev/null and b/.agents/evidence/pr/mobile-first-ui/viewer-desktop-detail-1440.png differ diff --git a/.agents/evidence/pr/mobile-first-ui/viewer-mobile-detail-320.png b/.agents/evidence/pr/mobile-first-ui/viewer-mobile-detail-320.png new file mode 100644 index 00000000..6b1bcda7 Binary files /dev/null and b/.agents/evidence/pr/mobile-first-ui/viewer-mobile-detail-320.png differ diff --git a/.agents/evidence/pr/mobile-first-ui/viewer-mobile-detail-375.png b/.agents/evidence/pr/mobile-first-ui/viewer-mobile-detail-375.png new file mode 100644 index 00000000..0ca501e5 Binary files /dev/null and b/.agents/evidence/pr/mobile-first-ui/viewer-mobile-detail-375.png differ diff --git a/.agents/evidence/pr/mobile-first-ui/viewer-mobile-list-320.png b/.agents/evidence/pr/mobile-first-ui/viewer-mobile-list-320.png new file mode 100644 index 00000000..49f956e6 Binary files /dev/null and b/.agents/evidence/pr/mobile-first-ui/viewer-mobile-list-320.png differ diff --git a/.agents/evidence/pr/mobile-first-ui/viewer-mobile-list-375.png b/.agents/evidence/pr/mobile-first-ui/viewer-mobile-list-375.png new file mode 100644 index 00000000..6af6f369 Binary files /dev/null and b/.agents/evidence/pr/mobile-first-ui/viewer-mobile-list-375.png differ diff --git a/claude_tap/dashboard.html b/claude_tap/dashboard.html index c021d376..6e4c352c 100644 --- a/claude_tap/dashboard.html +++ b/claude_tap/dashboard.html @@ -74,11 +74,16 @@ } *,*::before,*::after { margin: 0; padding: 0; box-sizing: border-box; } +html { + width: 100%; + overflow-x: clip; +} body { font-family: var(--sans); background: var(--bg); color: var(--text); height: 100vh; + width: 100%; display: flex; flex-direction: column; overflow: hidden; @@ -849,22 +854,374 @@ } @media (max-width: 900px) { - body { overflow: auto; height: auto; min-height: 100vh; } - .header { flex-wrap: wrap; padding: 10px 12px; } - .stats { order: 4; width: 100%; flex-wrap: wrap; margin-left: 0; } - .trace-path-bar { display: none; } - .main { overflow: visible; padding: 12px; } - .dashboard-title-row { flex-wrap: wrap; } - .dashboard-clock { width: 100%; margin-left: 0; } - .control-panel { grid-template-columns: 1fr; } - .overview-card { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; } - .table-scroll { max-height: none; } - .detail-page-head { align-items: flex-start; } - .viewer-frame-wrap { height: 78vh; min-height: 520px; } + body { + height: auto; + min-height: 100dvh; + overflow-y: auto; + overflow-x: clip; + } + .header { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 8px; + align-items: center; + padding: 8px 10px; + } + .header .logo { + grid-column: 1; + grid-row: 1; + min-width: 0; + } + .header .logo span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .header .divider { + display: none; + } + .theme-toggle, + .lang-select { + grid-row: 1; + min-height: 36px; + } + .theme-toggle { + grid-column: 2; + justify-self: end; + justify-content: center; + width: 38px; + min-width: 38px; + padding-inline: 0; + } + .lang-select { + grid-column: 3; + justify-self: end; + min-width: 92px; + max-width: 112px; + } + .path-filter { + grid-column: 1 / -1; + grid-row: 2; + min-width: 0; + max-width: 100%; + overflow-x: auto; + flex-wrap: nowrap; + padding-bottom: 2px; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + } + .path-filter::-webkit-scrollbar { + display: none; + } + .filter-chip { + flex: 0 0 auto; + min-height: 30px; + } + .stats { + grid-column: 1 / -1; + grid-row: 3; + width: 100%; + margin-left: 0; + gap: 6px; + overflow-x: auto; + flex-wrap: nowrap; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + } + .stats::-webkit-scrollbar { + display: none; + } + .stats .stat-group, + .live-status { + flex: 0 0 auto; + min-height: 28px; + padding: 4px 8px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--bg); + } + .trace-path-bar { + display: none; + } + .main { + overflow: visible; + padding: 10px; + } + body.detail-route .main { + padding-top: 10px; + } + .dashboard-title-row { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 4px; + margin-bottom: 10px; + } + .dashboard-title { + font-size: 13px; + } + .dashboard-clock { + width: 100%; + margin-left: 0; + font-size: 11px; + letter-spacing: .08em; + } + .control-panel { + grid-template-columns: repeat(2, minmax(0, 1fr)) auto; + gap: 8px; + } + .control-field { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 4px; + align-items: start; + } + .control-label { + font-size: 11px; + } + .session-search { + grid-column: 1 / 3; + } + .history-select, + .session-search input { + min-height: 38px; + } + .history-refresh { + grid-column: 3; + min-width: 42px; + min-height: 38px; + padding: 0 10px; + } + .overview-card { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + min-height: 0; + padding: 12px; + margin-bottom: 10px; + border-radius: 8px; + } + .metric { + min-width: 0; + } + .metric-label { + font-size: 10px; + letter-spacing: .04em; + } + .metric-value { + margin-top: 6px; + font-size: 18px; + } + .table-panel { + overflow: visible; + border: 0; + background: transparent; + box-shadow: none; + } + .table-scroll { + max-height: none; + overflow: visible; + } + .session-table { + display: block; + min-width: 0; + } + .session-table thead { + display: none; + } + .session-table tbody { + display: grid; + gap: 10px; + } + .session-table tr.session-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 9px 12px; + min-width: 0; + padding: 12px; + border: 1px solid #cfd8e7; + border-radius: 8px; + background: var(--bg-card); + box-shadow: var(--shadow-sm); + } + [data-theme="dark"] .session-table tr.session-row { + border-color: var(--border); + } + .session-table tr.session-row.active { + box-shadow: inset 3px 0 0 var(--blue), var(--shadow-sm); + } + .session-row.active td:first-child { + box-shadow: none; + } + .session-table td { + min-width: 0; + padding: 0; + border-bottom: 0; + } + .session-table td[data-label]::before { + content: attr(data-label); + display: block; + margin-bottom: 4px; + color: var(--text-tertiary); + font-family: var(--mono); + font-size: 10px; + font-weight: 700; + letter-spacing: .05em; + text-transform: uppercase; + } + .session-start-cell { + grid-column: 1 / 2; + grid-row: 1; + } + .session-status-cell { + grid-column: 2 / 3; + grid-row: 1; + justify-self: end; + } + .session-status-cell::before { + display: none !important; + } + .session-first-cell { + grid-column: 1 / -1; + } + .session-actions-cell { + grid-column: 1 / -1; + } + .first-message { + display: -webkit-box; + max-width: none; + overflow: hidden; + white-space: normal; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + } + .model-pill, + .agent-pill, + .token-pill, + .status-pill { + max-width: 100%; + min-height: 28px; + } + .model-pill { + justify-content: flex-start; + } + .row-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + width: 100%; + } + .row-action { + min-height: 42px; + width: 100%; + } + .loading-cell, + .table-empty { + display: block; + padding: 22px 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-card); + } + .detail-page-head { + position: sticky; + top: 0; + z-index: 5; + align-items: flex-start; + gap: 8px; + margin: -10px -10px 10px; + padding: 8px 10px; + border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--bg) 92%, transparent); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + } + .back-btn { + min-height: 38px; + flex: 0 0 auto; + } + .detail-subtitle { + white-space: normal; + overflow-wrap: anywhere; + } + .viewer-frame-wrap { + height: calc(100dvh - 112px); + min-height: 460px; + border-radius: 8px; + } + .detail-inspector-bar { + gap: 6px; + padding: 6px; + } + .detail-tabs { + width: 100%; + flex-wrap: nowrap; + overflow-x: auto; + scrollbar-width: none; + } + .detail-tabs::-webkit-scrollbar { + display: none; + } + .detail-tab { + flex: 0 0 auto; + min-height: 36px; + } + .action-bar { + width: 100%; + margin-left: 0; + justify-content: flex-start; + } + .act-btn { + min-height: 36px; + } .action-bar .export-menu-list { left: 0; right: auto; } + .modal-backdrop { + align-items: flex-end; + padding: 10px; + } + .confirm-dialog { + width: 100%; + max-height: calc(100dvh - 20px); + overflow: auto; + padding: 16px; + } + .confirm-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .confirm-btn { + min-height: 42px; + } +} + +@media (max-width: 420px) { + .header { + grid-template-columns: minmax(0, 1fr) 38px 78px; + } + .theme-toggle { + min-width: 38px; + padding-inline: 0; + } + .lang-select { + min-width: 78px; + padding-inline: 6px; + } + .overview-card { + grid-template-columns: repeat(2, minmax(0, 1fr)); + padding: 10px; + } + .metric-value { + font-size: 16px; + } + .session-table tr.session-row { + grid-template-columns: minmax(0, 1fr); + } + .session-status-cell { + grid-column: 1 / -1; + grid-row: auto; + justify-self: start; + } } @@ -919,7 +1276,7 @@

Conversation Log

- +
@@ -1252,6 +1609,9 @@

{ element.setAttribute("title", t(element.dataset.i18nTitle)); }); + document.querySelectorAll("[data-i18n-aria-label]").forEach(element => { + element.setAttribute("aria-label", t(element.dataset.i18nAriaLabel)); + }); document.querySelectorAll("[data-i18n-placeholder]").forEach(element => { element.setAttribute("placeholder", t(element.dataset.i18nPlaceholder)); }); @@ -1560,14 +1920,14 @@

- ${esc(fmtTableTime(session.started_at || session.updated_at))} -
${esc(firstMessage)}
- ${fmtNumber(session.record_count)} - ${fmtNumber(session.total_tokens)} - ${esc(session.model || t("unknown"))} - ${esc(session.agent || t("unknown"))} - ${esc(status.label)} - + ${esc(fmtTableTime(session.started_at || session.updated_at))} +
${esc(firstMessage)}
+ ${fmtNumber(session.record_count)} + ${fmtNumber(session.total_tokens)} + ${esc(session.model || t("unknown"))} + ${esc(session.agent || t("unknown"))} + ${esc(status.label)} +
diff --git a/claude_tap/viewer_assets/filters_search.js b/claude_tap/viewer_assets/filters_search.js index 6a7222c5..c39fce1b 100644 --- a/claude_tap/viewer_assets/filters_search.js +++ b/claude_tap/viewer_assets/filters_search.js @@ -5,6 +5,7 @@ function getPath(e) { return (e.request?.path || '/unknown').replace(/\?.*$/, '' function renderApp(preserveDetail) { $('#drop-zone').style.display = 'none'; $('#sidebar-wrap').style.display = 'flex'; + $('#sidebar-wrap').classList.add('has-trace-list'); $('#search-bar').style.display = ''; $('#sidebar-sort').style.display = ''; $('#position-indicator').style.display = ''; diff --git a/claude_tap/viewer_assets/i18n_ui.js b/claude_tap/viewer_assets/i18n_ui.js index 11f9178b..93850e08 100644 --- a/claude_tap/viewer_assets/i18n_ui.js +++ b/claude_tap/viewer_assets/i18n_ui.js @@ -167,7 +167,10 @@ function renderEmptyTraceState() { filtered = []; activeIdx = -1; $('#sidebar-wrap').style.display = 'none'; + $('#sidebar-wrap').classList.remove('has-trace-list'); + $('#sidebar-wrap').classList.remove('mobile-hidden'); $('#detail').style.display = 'none'; + $('#detail').classList.remove('mobile-fullwidth'); $('#stats').style.display = 'none'; $('#path-filter').style.display = 'none'; renderTracePathBar(); diff --git a/claude_tap/viewer_assets/viewer.css b/claude_tap/viewer_assets/viewer.css index 10b62748..1ff8292d 100644 --- a/claude_tap/viewer_assets/viewer.css +++ b/claude_tap/viewer_assets/viewer.css @@ -83,7 +83,8 @@ [data-theme="dark"] .jt-toggle:hover { color: #c9d1d9; } [data-theme="dark"] .jt-summary { color: #6e7681; } *,*::before,*::after { margin: 0; padding: 0; box-sizing: border-box; } -body { font-family: var(--sans); background: var(--bg); color: var(--text); height: 100vh; display: flex; flex-direction: column; overflow: hidden; -webkit-font-smoothing: antialiased; } +html { width: 100%; overflow-x: clip; } +body { font-family: var(--sans); background: var(--bg); color: var(--text); width: 100%; height: 100vh; display: flex; flex-direction: column; overflow: hidden; -webkit-font-smoothing: antialiased; } /* ─── Header ─── */ .header { @@ -1500,6 +1501,379 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig .sidebar-item { min-height: 44px; padding: 12px 16px; } .sidebar-group-header { min-height: 44px; } } + +@media (max-width: 768px) { + body { + height: 100dvh; + overflow: hidden; + } + .header { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: 8px; + padding: 8px 10px; + max-width: 100%; + } + .header .logo { + grid-column: 1; + grid-row: 1; + min-width: 0; + } + .header .logo span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .theme-toggle, + .lang-select { + grid-row: 1; + min-height: 38px; + } + .theme-toggle { + grid-column: 2; + justify-self: end; + justify-content: center; + min-width: 38px; + width: 38px; + padding-inline: 0; + } + .lang-select { + grid-column: 3; + justify-self: end; + max-width: 104px; + margin-left: 0; + } + .path-filter, + .stats { + grid-column: 1 / -1; + min-width: 0; + max-width: 100%; + overflow-x: auto; + flex-wrap: nowrap; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + } + .path-filter::-webkit-scrollbar, + .stats::-webkit-scrollbar { + display: none; + } + .path-filter { + grid-row: 2; + } + .filter-chip, + .stats .stat-group, + #live-status { + flex: 0 0 auto; + } + .stats { + grid-row: 3; + width: 100%; + gap: 6px; + } + .stats .stat-group, + #live-status { + min-height: 28px; + padding: 4px 8px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--bg); + } + .viewer-actions { + grid-column: 1 / -1; + grid-row: 4; + width: 100%; + justify-content: flex-start; + gap: 6px; + padding-bottom: 2px; + overflow: visible; + flex-wrap: wrap; + } + .viewer-action { + flex: 0 0 auto; + min-height: 38px; + padding: 8px 11px; + } + .viewer-actions .export-menu { + flex: 0 0 auto; + } + .viewer-actions .export-menu-list { + max-width: calc(100vw - 20px); + } + .trace-path-bar { + display: flex; + flex-wrap: nowrap; + overflow: hidden; + padding: 5px 10px; + } + .trace-path-bar .tp-val { + max-width: none; + } + .main { + flex: 1; + min-height: 0; + overflow: hidden; + } + .drop-zone { + min-height: 0; + padding: 16px; + } + .drop-zone-inner { + width: min(100%, 360px); + padding: 24px 18px; + } + #sidebar-wrap { + height: 100%; + min-height: 0 !important; + border-bottom: 0; + } + #sidebar-wrap.has-trace-list:not(.mobile-hidden) { + display: flex !important; + } + #detail:not(.mobile-fullwidth) { + display: none !important; + } + .history-bar, + .search-bar, + .sidebar-sort { + flex-shrink: 0; + } + .history-bar { + gap: 6px; + padding: 8px 10px; + } + .history-delete-btn { + min-height: 36px; + } + .search-bar { + padding: 8px 10px; + } + .search-bar input { + min-height: 38px; + } + .sidebar-sort { + align-items: center; + gap: 6px; + padding: 8px 10px; + } + .sidebar-sort-label { + flex: 0 0 auto; + } + .sidebar-sort-segment { + min-width: 0; + overflow-x: auto; + scrollbar-width: none; + } + .sidebar-sort-segment::-webkit-scrollbar { + display: none; + } + .sidebar-sort-btn { + min-height: 34px; + white-space: nowrap; + } + .position-indicator { + flex-shrink: 0; + } + .sidebar { + flex: 1; + min-height: 0; + overflow-y: auto; + } + .sidebar-item { + padding: 12px 14px; + } + .sidebar-item .si-row1 { + align-items: flex-start; + } + .sidebar-item .si-model, + .sidebar-item .si-task { + max-width: 45%; + } + .sidebar-item .si-row2 { + flex-wrap: wrap; + gap: 6px 10px; + } + #mobile-back-btn { + width: fit-content; + min-height: 38px; + margin: 8px 12px 0; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--bg-card); + box-shadow: var(--shadow-sm); + } + #mobile-nav-bar { + width: auto; + min-height: 44px; + margin: 8px 12px 0; + padding: 6px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--bg-card); + box-shadow: var(--shadow-sm); + } + #mobile-nav-bar button { + min-width: 40px; + min-height: 36px; + border-radius: 999px; + padding: 6px 12px; + } + #mobile-nav-pos { + font-family: var(--mono); + font-size: 12px; + } + #detail.mobile-fullwidth { + display: block !important; + flex: 1; + width: 100%; + min-height: 0; + overflow-y: auto; + } + .detail { + flex: 1; + min-height: 0; + padding: 10px 12px 16px; + overflow-y: auto; + } + .detail-inspector-bar { + position: sticky; + top: 0; + z-index: 8; + gap: 6px; + margin: 0 0 8px; + padding: 6px; + } + .detail-tabs { + flex: 1 1 100%; + flex-wrap: nowrap; + overflow-x: auto; + scrollbar-width: none; + } + .detail-tabs::-webkit-scrollbar { + display: none; + } + .detail-tab { + flex: 0 0 auto; + min-height: 36px; + } + .action-bar { + position: sticky; + top: 52px; + z-index: 7; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + width: 100%; + max-width: 100%; + overflow-x: visible; + padding: 5px; + border-radius: 16px; + } + .act-btn { + width: 100%; + min-width: 0; + min-height: 38px; + justify-content: center; + padding: 8px 9px; + white-space: normal; + text-align: center; + } + .act-btn:last-child:nth-child(3) { + grid-column: 1 / -1; + } + .section-header { + align-items: flex-start; + padding: 10px 11px; + } + .section-header .title, + .section-header .path, + .section-header .badge { + min-width: 0; + } + .section-header .copy-btn { + min-height: 34px; + margin-left: 0; + } + .section-body { + padding: 0 10px 12px; + } + .msg { + padding: 10px 12px; + } + .tool-block-header { + align-items: flex-start; + gap: 8px; + padding: 10px 11px; + } + .tb-name { + flex-shrink: 1; + min-width: 0; + overflow-wrap: anywhere; + } + .tb-desc { + white-space: normal; + } + .tool-input-actions { + top: 5px; + right: 5px; + } + .tool-input-view { + padding-right: 70px; + } + .global-search-overlay { + left: 8px; + right: 8px; + top: 8px; + } + .global-search-box { + display: grid; + grid-template-columns: 1fr auto auto auto; + width: 100%; + } + .global-search-box input { + grid-column: 1 / -1; + min-width: 0; + width: 100%; + } + .global-search-count { + justify-self: start; + } + .global-search-btn { + min-height: 36px; + min-width: 44px; + } +} + +@media (max-width: 420px) { + .header { + grid-template-columns: minmax(0, 1fr) 38px 86px; + } + .header .logo .logo-version { + display: none !important; + } + .lang-select { + max-width: 86px; + padding-inline: 6px; + } + .history-label, + .sidebar-sort-label { + display: none; + } + .history-delete-btn span { + display: none; + } + .sidebar-item .si-model, + .sidebar-item .si-task { + max-width: 38%; + } + .global-search-box { + grid-template-columns: 1fr 1fr 1fr; + } + .global-search-count { + grid-column: 1 / -1; + } +} body.embed-mode { overflow: hidden !important; background: var(--bg-card); diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index cdee46b8..1e12061e 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -1041,6 +1041,66 @@ async def test_dashboard_session_export_menu_is_not_clipped_on_mobile(trace_db, await server.stop() +@pytest.mark.asyncio +async def test_dashboard_mobile_session_list_uses_cards_without_horizontal_overflow(trace_db, tmp_path: Path) -> None: + playwright = pytest.importorskip("playwright.async_api") + first_trace = tmp_path / "2026-05-20" / "trace_080000.jsonl" + second_trace = tmp_path / "2026-05-20" / "trace_081500.jsonl" + _write_jsonl(first_trace, [_anthropic_record(turn=1), _anthropic_record(turn=2)]) + _write_jsonl(second_trace, [_antigravity_record()]) + _seed_legacy(tmp_path) + + server = LiveViewerServer(port=0, migrate_from=tmp_path, dashboard_mode=True) + port = await server.start() + try: + async with playwright.async_playwright() as pw: + browser = await pw.chromium.launch(headless=True) + try: + page = await browser.new_page(viewport={"width": 390, "height": 820}) + await page.goto(f"http://127.0.0.1:{port}/dashboard", wait_until="domcontentloaded") + await page.wait_for_selector(".session-row", timeout=5000) + + for width in (320, 375, 414, 768): + await page.set_viewport_size({"width": width, "height": 820}) + layout = await page.evaluate( + """() => { + const row = document.querySelector('.session-row'); + const table = document.querySelector('.session-table'); + const actions = document.querySelector('.row-actions'); + const first = document.querySelector('.first-message'); + const firstLabel = document.querySelector('.session-first-cell'); + const refresh = document.querySelector('#refresh'); + const rowBox = row.getBoundingClientRect(); + return { + overflowX: document.documentElement.scrollWidth - window.innerWidth, + rowDisplay: getComputedStyle(row).display, + rowLeft: rowBox.left, + rowRight: rowBox.right, + tableMinWidth: getComputedStyle(table).minWidth, + actionsDisplay: getComputedStyle(actions).display, + firstWhiteSpace: getComputedStyle(first).whiteSpace, + firstLabel: getComputedStyle(firstLabel, '::before').content, + refreshText: refresh.textContent.trim(), + refreshLabel: refresh.getAttribute('aria-label'), + }; + }""" + ) + assert layout["overflowX"] <= 2 + assert layout["rowDisplay"] == "grid" + assert layout["rowLeft"] >= -1 + assert layout["rowRight"] <= width + 1 + assert layout["tableMinWidth"] == "0px" + assert layout["actionsDisplay"] == "grid" + assert layout["firstWhiteSpace"] == "normal" + assert "First Message" in layout["firstLabel"] + assert layout["refreshText"] != "R" + assert layout["refreshLabel"] == "Refresh" + finally: + await browser.close() + finally: + await server.stop() + + @pytest.mark.asyncio async def test_dashboard_delete_button_keyboard_focuses_confirmation_dialog(trace_db) -> None: playwright = pytest.importorskip("playwright.async_api") diff --git a/tests/test_viewer_contracts.py b/tests/test_viewer_contracts.py index 0e2368fb..2db37a39 100644 --- a/tests/test_viewer_contracts.py +++ b/tests/test_viewer_contracts.py @@ -2001,6 +2001,7 @@ def test_viewer_empty_embedded_trace_renders_explicit_no_api_calls_state(tmp_pat page.on("pageerror", lambda exc: errors.append(f"pageerror: {exc}")) page.on("console", lambda msg: errors.append(f"console.error: {msg.text}") if msg.type == "error" else None) try: + page.set_viewport_size({"width": 320, "height": 760}) page.goto(html_path.resolve().as_uri(), timeout=10000) page.wait_for_selector(".empty-trace-state", timeout=5000) state = page.evaluate( @@ -2011,6 +2012,7 @@ def test_viewer_empty_embedded_trace_renders_explicit_no_api_calls_state(tmp_pat hint: document.querySelector('#empty-trace-hint')?.textContent || '', sidebarItemCount: document.querySelectorAll('.sidebar-item').length, sidebarDisplay: getComputedStyle(document.querySelector('#sidebar-wrap')).display, + sidebarHasListState: document.querySelector('#sidebar-wrap')?.classList.contains('has-trace-list'), detailDisplay: getComputedStyle(document.querySelector('#detail')).display, pathText: document.querySelector('#trace-path-bar')?.innerText || '', oldDropTitlePresent: Boolean(document.querySelector('#drop-title')), @@ -2027,6 +2029,7 @@ def test_viewer_empty_embedded_trace_renders_explicit_no_api_calls_state(tmp_pat assert "real empty run" in state["hint"] assert state["sidebarItemCount"] == 0 assert state["sidebarDisplay"] == "none" + assert state["sidebarHasListState"] is False assert state["detailDisplay"] == "none" assert "empty_trace.jsonl" in state["pathText"] assert "empty_trace.html" in state["pathText"] @@ -2282,7 +2285,13 @@ def snapshot(width: int, height: int, theme: str, mobile: bool = False) -> dict: page.set_viewport_size({"width": width, "height": height}) page.evaluate("theme => document.documentElement.setAttribute('data-theme', theme)", theme) if mobile: - page.evaluate("mobileShowDetail()") + page.evaluate( + """() => { + mobileShowDetail(); + const detail = document.querySelector('#detail'); + if (detail) detail.scrollTop = 180; + }""" + ) return page.evaluate( """() => { const rect = selector => { @@ -2292,6 +2301,7 @@ def snapshot(width: int, height: int, theme: str, mobile: bool = False) -> dict: return { width: r.width, height: r.height, left: r.left, right: r.right, top: r.top, bottom: r.bottom }; }; const color = selector => getComputedStyle(document.querySelector(selector)).backgroundColor; + const actionBar = document.querySelector('.action-bar'); return { overflowX: document.documentElement.scrollWidth - window.innerWidth, bodyBg: getComputedStyle(document.body).backgroundColor, @@ -2304,12 +2314,48 @@ def snapshot(width: int, height: int, theme: str, mobile: bool = False) -> dict: toolBlock: rect('.tool-block'), response: rect('.section-body.open .content-block'), sectionCount: document.querySelectorAll('#detail .section').length, + inspector: rect('.detail-inspector-bar'), + actionBar: rect('.action-bar'), + actionBarOverflow: actionBar ? actionBar.scrollWidth - actionBar.clientWidth : null, + actionButtons: actionBar ? Array.from(actionBar.querySelectorAll('.act-btn')).map(el => { + const box = el.getBoundingClientRect(); + return { + text: el.textContent.trim(), + clipped: el.scrollWidth - el.clientWidth, + left: box.left, + right: box.right, + }; + }) : [], }; }""" ) desktop_light = snapshot(1440, 1000, "light") desktop_dark = snapshot(1440, 1000, "dark") + page.set_viewport_size({"width": 320, "height": 760}) + page.evaluate("document.documentElement.setAttribute('data-theme', 'light')") + page.evaluate("mobileShowSidebar()") + mobile_list = page.evaluate( + """() => { + const rect = selector => { + const el = document.querySelector(selector); + if (!el) return null; + const r = el.getBoundingClientRect(); + return { width: r.width, height: r.height, left: r.left, right: r.right, top: r.top, bottom: r.bottom }; + }; + return { + overflowX: document.documentElement.scrollWidth - window.innerWidth, + sidebar: rect('#sidebar-wrap'), + detail: rect('#detail'), + header: rect('.header'), + search: rect('#search-bar'), + sort: rect('#sidebar-sort'), + firstItem: rect('.sidebar-item'), + sidebarHidden: document.querySelector('#sidebar-wrap')?.classList.contains('mobile-hidden'), + detailHidden: getComputedStyle(document.querySelector('#detail')).display === 'none', + }; + }""" + ) page.evaluate( "requestId => renderDetail(entries.find(entry => entry.request_id === requestId))", "req_content_block_boundary_contract", @@ -2330,6 +2376,7 @@ def snapshot(width: int, height: int, theme: str, mobile: bool = False) -> dict: "requestId => renderDetail(entries.find(entry => entry.request_id === requestId))", "req_gemini_contract" ) mobile_dark = snapshot(390, 900, "dark", mobile=True) + mobile_narrow_detail = snapshot(320, 760, "dark", mobile=True) finally: page.close() @@ -2353,9 +2400,31 @@ def snapshot(width: int, height: int, theme: str, mobile: bool = False) -> dict: assert dark_content_block["backgroundColor"] != "rgba(0, 0, 0, 0)" assert dark_content_block["borderLeftWidth"] == "1px" + assert mobile_list["overflowX"] <= 2 + assert mobile_list["sidebar"]["width"] == 320 + assert mobile_list["sidebar"]["left"] == 0 + assert mobile_list["detailHidden"] is True + assert mobile_list["sidebarHidden"] is False + assert mobile_list["header"]["width"] == 320 + assert mobile_list["search"]["width"] <= 320 + assert mobile_list["sort"]["width"] <= 320 + assert mobile_list["firstItem"]["right"] <= 320 + assert mobile_dark["sidebar"]["width"] == 0 assert mobile_dark["detail"]["width"] == 390 assert mobile_dark["detail"]["left"] == 0 + assert mobile_narrow_detail["overflowX"] <= 2 + assert mobile_narrow_detail["sidebar"]["width"] == 0 + assert mobile_narrow_detail["detail"]["width"] == 320 + assert mobile_narrow_detail["detail"]["left"] == 0 + assert mobile_narrow_detail["actionBarOverflow"] <= 2 + assert mobile_narrow_detail["actionBar"]["top"] >= mobile_narrow_detail["inspector"]["bottom"] - 1 + assert mobile_narrow_detail["actionButtons"] + assert any(button["text"] == "Diff with Prev" for button in mobile_narrow_detail["actionButtons"]) + for button in mobile_narrow_detail["actionButtons"]: + assert button["clipped"] <= 2 + assert button["left"] >= mobile_narrow_detail["actionBar"]["left"] - 1 + assert button["right"] <= mobile_narrow_detail["actionBar"]["right"] + 1 def test_viewer_session_identical_prompts_image_tags_and_early_title_generation(