Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions src/frontend/e2e/dashboard-list-view.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -332,3 +332,103 @@ test.describe('dashboard list column alignment + identity (#2358)', () => {
}
})
})

/**
* #3465 — the row layout follows the LIST's width. At a 768 window the systems
* rail (224px open, 48px collapsed) decides whether the list is ~496px or
* ~672px wide. The tablet row needs about 610px, so behind the open rail its
* success cell collapsed to 0 (the percentage painted over the capacity meter)
* and the task counts ran 50-67px past the row. Below 40rem of list
* (`list-mid:`) the compact row renders instead.
*
* The cascade itself is unit-tested (tests/unit/agentListLayoutSwitch.spec.js);
* this is the half only a layout engine can see: that whichever row renders,
* every cell stays inside it and no two cells of a line overlap. It is written
* against whatever agents the stack has, so it holds on a system-agent-only CI
* stack and bites hardest on a fleet with execution stats.
*/
test.describe('dashboard list rows fit a narrow list (#3465)', () => {
/** Geometry violations across every rendered row; [] when the layout holds. */
async function rowViolations(page) {
return page.evaluate(() => {
const out = []
const shown = (el) => getComputedStyle(el).display !== 'none'
for (const row of document.querySelectorAll('[data-agent]')) {
const slug = row.getAttribute('data-agent')
const rowBox = row.getBoundingClientRect()
if (rowBox.right > window.innerWidth + 1) out.push(`${slug}: row leaves the window`)
// The half-out avatar is `absolute` and overhangs the LEFT edge by
// design; the layout blocks are the in-flow children.
const blocks = [...row.children].filter(
(el) => getComputedStyle(el).position !== 'absolute' && shown(el)
)
for (const block of blocks) {
for (const line of block.children) {
if (!shown(line)) continue
const lineBox = line.getBoundingClientRect()
if (lineBox.right > rowBox.right + 1) {
out.push(`${slug}: a line ends ${Math.round(lineBox.right - rowBox.right)}px past the row`)
}
// The secondary / meta lines clip their own overflow by contract
// (a long slug plus tags never opens another row), so their cells
// may legitimately extend past the box; the line itself may not.
if (getComputedStyle(line).overflowX !== 'visible') continue
const cells = [...line.children].filter(shown)
let prevRight = -Infinity
for (const cell of cells) {
const box = cell.getBoundingClientRect()
const what = `${slug}: <${cell.tagName.toLowerCase()} class="${cell.className}">`
if (box.right > rowBox.right + 1) {
out.push(`${what} ends ${Math.round(box.right - rowBox.right)}px past the row`)
}
if (box.left < prevRight - 1) {
out.push(`${what} overlaps the cell before it by ${Math.round(prevRight - box.left)}px`)
}
prevRight = box.right
// A cell squeezed below its content paints that content over its
// neighbours without moving its own box — the success cell at 0px.
const cs = getComputedStyle(cell)
if (
!cs.display.startsWith('inline') &&
cs.overflowX === 'visible' &&
cell.scrollWidth > cell.clientWidth + 1
) {
out.push(`${what} content is ${cell.scrollWidth - cell.clientWidth}px wider than its box`)
}
}
}
}
}
return out
})
}

for (const [rail, collapsed, tabletRow] of [
['open', 'false', false],
['collapsed', 'true', true],
]) {
test(`768 window, rail ${rail}: no cell overlaps or leaves its row`, async ({ page }) => {
await page.addInitScript(
(value) => localStorage.setItem('trinity-sidebar-collapsed', value),
collapsed
)
await page.setViewportSize({ width: 768, height: 1024 })
await gotoList(page)
const row = page.locator('[data-agent]').first()
await expect(row).toBeVisible({ timeout: 15000 })

// Which row renders is the fix: the tablet row only where it fits.
const secondaryMd = row.locator('[data-testid="row-secondary-md"]')
if (tabletRow) await expect(secondaryMd).toBeVisible()
else await expect(secondaryMd).toBeHidden()

expect(await rowViolations(page)).toEqual([])
// Wide content never scrolls the page (contract, principle 7).
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth
)
).toBe(true)
})
}
})
16 changes: 12 additions & 4 deletions src/frontend/src/components/AgentListPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -552,8 +552,14 @@
desktop grid needs). `md:list-wide:hidden`, not `list-wide:hidden`:
Tailwind 3 emits screen variants after custom ones, so a bare
`list-wide:hidden` loses to `md:flex`, and this block then sits in
the row subgrid as an extra item that widens track 1. -->
<div class="hidden md:flex md:flex-col md:list-wide:hidden pl-8 pr-4 py-3 gap-2">
the row subgrid as an extra item that widens track 1.
It also needs the LIST to be at least 40rem wide (`list-mid:`,
#3465): its second line holds three reserved toggles, the success
bar, the meter and the task counts, about 610px. Behind the open
systems rail a 768 window leaves the list 496px — the success cell
collapsed to 0 and the task counts ran past the row — so there the
compact layout below renders instead. -->
<div class="hidden md:list-mid:flex md:list-mid:flex-col md:list-wide:hidden pl-8 pr-4 py-3 gap-2">
<div class="flex items-center gap-3">
<input
type="checkbox"
Expand Down Expand Up @@ -737,8 +743,10 @@
</div>
</div>

<!-- Mobile layout (< md) -->
<div class="flex flex-col md:hidden pl-8 pr-4 py-3 gap-2">
<!-- Compact layout: a window below md, or a list narrower than the
tablet row needs (`md:list-mid:hidden` is the exact complement of
the tablet block's `md:list-mid:flex`, #3465). -->
<div class="flex flex-col md:list-mid:hidden pl-8 pr-4 py-3 gap-2">
<div class="flex items-center gap-3">
<input
type="checkbox"
Expand Down
11 changes: 11 additions & 0 deletions src/frontend/tailwind.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,18 @@ export default {
// not 72: a 1440 window with the rail open leaves 1152px, less a classic
// scrollbar on Linux, and must still get the grid. The container is the
// wrapper around the grid.
//
// `list-mid:` — the same container, one step down (#3465). The tablet row
// keeps three reserved toggles, the success bar, the meter and the task
// counts on one line and needs about 610px; a viewport `md:` alone gave it
// 496px at a 768 window with the rail open, where the success cell
// collapsed to 0 and the task counts ran past the row. Below 40rem the
// compact (phone) row renders instead. 40, not more: a 768 window with the
// rail collapsed leaves 664px, less a classic scrollbar, and keeps the
// tablet row. Registered BEFORE `list-wide` so `md:list-wide:hidden` is
// emitted after `md:list-mid:flex` and wins where both hold.
function ({ addVariant }) {
addVariant('list-mid', '@container agent-list (min-width: 40rem)')
addVariant('list-wide', '@container agent-list (min-width: 68rem)')
},
],
Expand Down
174 changes: 174 additions & 0 deletions src/frontend/tests/unit/agentListLayoutSwitch.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
// @vitest-environment jsdom
/**
* #3465 — which of the Dashboard list's three row layouts renders is decided
* by the LIST's width, not only the window's.
*
* The defect: the tablet layout switched on a viewport `md:` alone. At a 768
* window with the systems rail open the list is about 496px wide, and the
* tablet row's second line needs about 610px (three reserved toggles, the
* success bar, the meter, the task counts). The `flex-1 min-w-0` success cell
* collapsed to 0 with its text painting over the meter, and the no-wrap task
* counts ran 50-67px past the row's right edge.
*
* jsdom has no layout engine and evaluates no media or container query, so a
* mount alone cannot see which layout is displayed. This spec therefore does
* the two halves it CAN do for real, and nothing by regex:
*
* 1. mounts `AgentListPanel` and takes the class lists off the three
* rendered layout blocks of a row, and
* 2. compiles exactly those classes through the real `tailwind.config.js`
* and resolves the `display` cascade for a simulated (window, list)
* width pair — source order, enclosing `@media` / `@container` and all.
*
* That catches what a class-string read cannot: a variant that is misspelled
* or unregistered (it emits nothing), and two rules whose winner depends on
* the order Tailwind emits them in (design-system-contract.md, #2662).
*
* What it cannot prove is that the tablet row FITS at the threshold — that is
* a browser measurement (e2e/dashboard-list-view.spec.js, #3465 case).
*/
import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import postcss from 'postcss'
import tailwindcss from 'tailwindcss'
import loadConfig from 'tailwindcss/loadConfig.js'

vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('axios', () => {
const inst = {
get: vi.fn(() => Promise.resolve({ data: {} })), post: vi.fn(), put: vi.fn(), delete: vi.fn(),
interceptors: { request: { use: vi.fn() }, response: { use: vi.fn() } },
defaults: { headers: { common: {} } },
}
return { default: Object.assign(inst, { create: () => inst }) }
})

import AgentListPanel from '../../src/components/AgentListPanel.vue'

const HERE = dirname(fileURLToPath(import.meta.url))
const REM = 16

const STUBS = {
AgentAvatar: true, RuntimeBadge: true, RunningStateToggle: true, AutonomyToggle: true,
ReadOnlyToggle: true, CapacityMeter: true, RouterLink: true,
}

/** The three layout blocks of the first rendered row, as class lists. */
function mountedLayouts() {
const wrapper = mount(AgentListPanel, {
props: {
agents: [{ name: 'alpha', status: 'running', runtime: 'claude-code', tags: [], is_owner: true }],
},
global: { stubs: STUBS },
})
const row = wrapper.find('[data-agent="alpha"]').element
const lg = wrapper.find('[data-testid="row-secondary-lg"]').element.parentElement
const md = wrapper.find('[data-testid="row-secondary-md"]').element.parentElement
// The avatar and the lg / md blocks precede it; the base block is last.
const base = row.lastElementChild
expect(new Set([lg, md, base]).size, 'three distinct layout blocks').toBe(3)
for (const el of [lg, md, base]) expect(el.parentElement).toBe(row)
return {
html: wrapper.html(),
lg: [...lg.classList],
md: [...md.classList],
base: [...base.classList],
}
}

async function compile(html) {
const config = loadConfig(resolve(HERE, '../../tailwind.config.js'))
const result = await postcss([
tailwindcss({ ...config, content: [{ raw: html }], corePlugins: { preflight: false } }),
]).process('@tailwind utilities;', { from: undefined })
return result.root
}

/** Does this at-rule hold for the simulated widths? Unknown shapes throw. */
function atRuleHolds(node, { viewport, list }) {
if (node.name === 'media') {
const m = node.params.match(/^\(min-width:\s*([\d.]+)px\)$/)
if (!m) throw new Error(`unmodelled @media ${node.params}`)
return viewport >= Number(m[1])
}
if (node.name === 'container') {
const m = node.params.match(/^agent-list\s+\(min-width:\s*([\d.]+)rem\)$/)
if (!m) throw new Error(`unmodelled @container ${node.params}`)
return list >= Number(m[1]) * REM
}
throw new Error(`unmodelled at-rule @${node.name}`)
}

/**
* The `display` an element with these classes computes to. Every rule here is
* a single class selector (equal specificity), so the last one in source order
* whose enclosing conditions hold is the winner — the same answer the browser
* gives.
*/
function displayOf(root, classes, widths) {
let winner = 'block' // a <div> with no display utility
root.walkRules((rule) => {
const cls = rule.selector.replace(/^\./, '').replace(/\\/g, '')
if (!classes.includes(cls)) return
let display = null
rule.walkDecls('display', (d) => { display = d.value })
if (display === null) return
for (let p = rule.parent; p && p.type === 'atrule'; p = p.parent) {
if (!atRuleHolds(p, widths)) return
}
winner = display
})
return winner
}

describe('the list row layout follows the list width (#3465)', () => {
let layouts
let css

beforeEach(() => setActivePinia(createPinia()))
beforeAll(async () => {
setActivePinia(createPinia())
layouts = mountedLayouts()
css = await compile(layouts.html)
})

const shown = (widths) =>
['lg', 'md', 'base'].filter((k) => displayOf(css, layouts[k], widths) !== 'none')

it('the reported case: a 768 window with the rail open gets the compact row', () => {
// 768 - 224 (rail) - 48 (panel padding) = 496. The tablet row's fixed
// parts alone are wider than that.
expect(shown({ viewport: 768, list: 496 })).toEqual(['base'])
})

it('a 768 window with the rail collapsed keeps the tablet row', () => {
expect(shown({ viewport: 768, list: 664 })).toEqual(['md'])
})

it('renders exactly one layout at every width, switching at 40rem and 68rem', () => {
const cases = [
// [window, list, layout]
[390, 342, 'base'],
// Below `md` the window still decides: unchanged by #3465.
[700, 652, 'base'],
[768, 639, 'base'],
[768, 640, 'md'],
[1024, 752, 'md'],
[1280, 992, 'md'],
[1440, 1087, 'md'],
[1440, 1088, 'lg'],
[1440, 1152, 'lg'],
[1920, 1632, 'lg'],
]
for (const [viewport, list, layout] of cases) {
expect(shown({ viewport, list }), `window ${viewport}px, list ${list}px`).toEqual([layout])
}
})

it('the desktop row is a set of grid items, never a box', () => {
expect(displayOf(css, layouts.lg, { viewport: 1440, list: 1152 })).toBe('contents')
})
})
4 changes: 2 additions & 2 deletions src/frontend/tests/unit/agentName.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -530,10 +530,10 @@ describe('structural: the desktop grid switches on the list width, not the windo

it('hides the tablet layout with the stacked `md:list-wide:hidden`', () => {
// Tailwind 3 emits screen variants after custom ones, so a bare
// `list-wide:hidden` loses to `md:flex`. The tablet block then renders
// `list-wide:hidden` loses to the `md:` show rule. The tablet block then renders
// inside the desktop row as an extra subgrid item and widens track 1 by
// hundreds of pixels, which is what the first cut of this change did.
const tablet = classAttrsContaining(PANEL, 'md:flex md:flex-col')
const tablet = classAttrsContaining(PANEL, 'md:list-mid:flex md:list-mid:flex-col')
expect(tablet).toHaveLength(1)
expect(tablet[0]).toContain('md:list-wide:hidden')
expect(tablet[0]).not.toMatch(/(?<!md:)list-wide:hidden/)
Expand Down
Loading