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
71 changes: 69 additions & 2 deletions frontend/src/v2/__tests__/v2-layout-invariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2063,12 +2063,34 @@ describe('v2 layout invariants (CSS rule presence)', () => {
expect(thread).toContain('noAgentsHint={noAgentsInPodLive ? noAgentsHint : null}');
expect(threadMessages).toContain('className="v2-chat__no-agents"');
expect(threadMessages).toContain('podChat.noAgentsInPod.action');
expect(ruleBody(v2, '.v2-chat__no-agents')).toContain('margin: -4px 0 8px 50px');
// The box lives in the ONE shared side-row slot rule (with the delivery
// hint); the derivation from `.v2-msg`'s grid is pinned by the next test.
const slot = v2.slice(
v2.indexOf('.v2-chat__delivery-hint,\n.v2-chat__no-agents {'),
v2.indexOf('}', v2.indexOf('.v2-chat__delivery-hint,\n.v2-chat__no-agents {')),
);
expect(slot).toContain('margin: -4px 0 8px 36px');
expect(slot).toContain('width: calc(100% - 36px)');
// Their box is declared exactly once in the sheet — the shared slot — so
// there is no second copy left for the cascade to pick between. The
// margin pair is unique to this slot; `width: calc(100% - 36px)` is not
// asserted globally because `.v2-thread-block` carries a complement of
// its own one indent over.
expect(v2.match(/margin: -4px 0 8px 36px;/g) || []).toHaveLength(1);
// And neither row's own block restates a box. `ruleBody` cannot answer
// this: it takes the FIRST line-start match, and the shared rule's second
// selector line is itself a line-start `.v2-chat__no-agents {`, so it
// reports the shared rule as the row's own — the TASK-140 trap, one file
// over. lastIndexOf reaches the standalone block.
for (const selector of ['\n.v2-chat__delivery-hint {', '\n.v2-chat__no-agents {']) {
const from = v2.lastIndexOf(selector);
expect(from).toBeGreaterThan(-1);
expect(v2.slice(from, v2.indexOf('}', from))).not.toMatch(/(^|\s)(margin|width):/);
}
// `.v2-chat__messages > *` sets width: 100%, so the 50px indent must come
// OUT of the width. Without this the row overruns the pane by 50px and the
// full-width phone act and the body line are cut (ux-lead's 390 shot).
expect(ruleBody(v2, '.v2-chat__messages > *')).toContain('width: 100%');
expect(ruleBody(v2, '.v2-chat__no-agents')).toContain('width: calc(100% - 50px)');
expect(ruleBody(v2, '.v2-chat__no-agents-kicker')).toContain('font-size: 11px');
expect(ruleBody(v2, '.v2-chat__no-agents-kicker')).toContain('text-transform: lowercase');
expect(ruleBody(v2, '.v2-chat__no-agents-body')).toContain('color: var(--v2-text-secondary)');
Expand All @@ -2081,6 +2103,51 @@ describe('v2 layout invariants (CSS rule presence)', () => {
expect(phone).not.toMatch(/[^-]height: 44px/);
});

test('both chat side-rows share one slot whose indent is the message text column', () => {
// ux-lead's TASK-150 ruling: the delivery cue and the no-agent row must
// land on the message text column — derived from the grid those rows'
// own messages actually use, not a number by assumption. This guard reads
// the avatar track and the gap out of the rule in play and compares; if
// that column moves and the slot rule stays put, the suite fails instead
// of the two edges quietly parting.
//
// The grid in play is the THREAD override, not the base `.v2-msg` rule:
// these rows only ever render as siblings of a message inside
// `.v2-thread`, where `.v2-thread .v2-msg` (0,3,0) replaces the base grid
// (0,1,0). Deriving from the base rule is the mistake this rule shipped
// with — it read 38 + 12 = 50 and the gate measured both rows 14px right
// of `.v2-msg__content` at 390, 720 and 1440; 36px put them on it
// (ux-lead verified by injection on their fixture, right edge and
// scrollWidth unchanged).
const at = v2.indexOf('.v2-chat__delivery-hint,\n.v2-chat__no-agents {');
expect(at).toBeGreaterThan(-1);
const slot = v2.slice(v2.indexOf('{', at) + 1, v2.indexOf('}', at));
const threadGrid = ruleBody(v2, '.v2-thread .v2-msg');
const lead = /grid-template-columns:\s*(\d+)px/.exec(threadGrid);
const gap = /(?:^|\s)gap:\s*(\d+)px/.exec(threadGrid);
expect(lead?.[1]).toBe('28');
expect(gap?.[1]).toBe('8');
const indent = `${Number(lead?.[1]) + Number(gap?.[1])}px`;
expect(indent).toBe('36px');
// The base grid is deliberately NOT the target. Without this line a
// future reader finds 38 + 12 = 50 unreferenced and "corrects" the slot
// back to it, which is exactly how this shipped wrong the first time.
expect(ruleBody(v2, '.v2-msg')).toContain('grid-template-columns: 38px');
expect(slot).toContain(`margin: -4px 0 8px ${indent};`);
expect(slot).toContain(`width: calc(100% - ${indent});`);
// Neither row restates its own box any more. A second declaration is
// exactly how the two drifted apart, and last-in-sheet-wins would pick
// between them silently — the delivery cue is the one that never had the
// width complement at all.
expect(ruleBody(v2, '.v2-chat__delivery-hint')).not.toMatch(/(^|\s)(margin|width):/);
// The phone block that carries the transcript must not restate the slot:
// the ≤760 act override is a BUTTON rule, and a row-level geometry rule in
// there would outrank the shared rule exactly where it matters (390).
const phone = mediaBlockContaining(v2, '.v2-chat__messages');
expect(phone).toContain('padding-inline: 14px');
expect(phone).not.toMatch(/\.v2-chat__(?:delivery-hint|no-agents)/);
});

test('pod focus keeps one bounded panel above the existing board and stays usable on phones', () => {
expect(podBoard).toContain('className="v2-board__focus"');
expect(podBoard).toContain('v2-board__focus-editor');
Expand Down
46 changes: 37 additions & 9 deletions frontend/src/v2/v2.css
Original file line number Diff line number Diff line change
Expand Up @@ -1818,9 +1818,10 @@ body.modern-ui.v2-canvas {

/* Sender-only teaching cue shown once per pod/session when a message reaches
a pod with agents but addresses none of them. Aligns with message text,
stays quiet/secondary, and disappears naturally with transcript scroll. */
stays quiet/secondary, and disappears naturally with transcript scroll.
Box geometry is NOT here: it belongs to the side-row slot rule below, which
this row shares with the no-agent row. */
.v2-chat__delivery-hint {
margin: -4px 0 8px 50px;
color: var(--v2-text-tertiary);
font-size: 12px;
line-height: 1.4;
Expand All @@ -1831,6 +1832,38 @@ body.modern-ui.v2-canvas {
font-weight: 600;
}

/* The transcript's two side-rows — the sender-only delivery cue above and the
no-agent row below — occupy ONE slot, and that slot IS the message text
column of the transcript they sit in. Both are siblings of `.v2-msg` inside
`.v2-chat__messages`, so the indent is the avatar track plus the gap of the
rule that governs those rows HERE, which is the thread override
`.v2-thread .v2-msg { grid-template-columns: 28px minmax(0, 1fr); gap: 8px }`
— 28 + 8 = 36, NOT the base `.v2-msg` grid (38 + 12 = 50), which that
override replaces (0,3,0 beats 0,1,0). The first cut of this rule derived
from the base grid and was 14px out; ux-lead's #1860 gate measured it at
390, 720 and 1440 — 50px put both rows 14px right of `.v2-msg__content`,
36px put them on it (injection on their fixture, right edge and scrollWidth
unchanged). Both placements of these rows share their message's container
(transcript level, or inside a `.v2-thread-block` for a thread root), so the
same relative indent holds in both.

The width subtracts the same indent: `.v2-chat__messages > *` sets
width: 100%, so an indent without its complement overruns the pane by exactly
the indent. That was the #1846 defect — at 390 the full-width act ran 64→426
and the body line was cut (ux-lead's shot, 2026-09-23) — and Sam hit the same
class one indent over on `.v2-thread-block` (2026-08-24): indent + width must
sum to 100%.

One rule for both rows, so they cannot drift apart again: this is the only
place either row's box is declared, and v2-layout-invariants.test.ts derives
the expected indent from `.v2-thread .v2-msg`'s own grid, so moving that
column fails the suite instead of silently misaligning these rows. */
.v2-chat__delivery-hint,
.v2-chat__no-agents {
width: calc(100% - 36px);
margin: -4px 0 8px 36px;
}

/* The delivery hint above requires an agent to name (`agentsInPod > 0`), which
leaves out the pod where nothing can answer at all — a team pod with no agent
installed. This row sits in the same slot under the sent message, says why
Expand All @@ -1841,13 +1874,8 @@ body.modern-ui.v2-canvas {
flex-direction: column;
align-items: flex-start;
gap: 6px;
/* `.v2-chat__messages > *` sets width: 100%, so the 50px indent below has to
be subtracted or the row overruns the pane by exactly 50px: at 390 the
full-width act ran 64→426 and the body line was cut (ux-lead's shot,
2026-09-23). Same rule as `.v2-thread-block` below — indent + width must
sum to 100% (Sam, 2026-08-24, the same bug one indent over). */
width: calc(100% - 50px);
margin: -4px 0 8px 50px;
/* Box geometry lives in the shared side-row slot rule above, next to the
delivery cue it shares the slot with. */
}

.v2-chat__no-agents-kicker {
Expand Down
Loading