test: restore TUI status-rule clock and title guards dropped by #120071

- appChromeBlockedTimers: 'status-chrome timers track the current overlay
  model' (#12463). Each overlay state is mounted on the real StatusRule: the
  in-flow prompts (approval, billing, clarify, confirm, secret, subscription,
  sudo), agents/journey and the ambient dock keep the 1s clocks running, and
  the occluding set pauses them. Gating clarify/ambient in
  $isStatusRuleOccluded turns it red.
- appChromeBlockedTimers: the existing 're-syncs the elapsed read-outs'
  test flaked under load (a fixed 20ms flush); it now polls for the reveal frame.
- appChromeStatusRule: session title renders without a raw accent background
  fill (#82465 contrast bug).
- appChromeStatusRule: idle-since read-out is shown when idle, hidden while busy
  and before the first turn completes.
This commit is contained in:
teknium1
2026-09-23 04:26:01 -07:00
committed by Teknium
parent 36f165a162
commit 6300f73659
2 changed files with 175 additions and 1 deletions

View File

@@ -304,7 +304,9 @@ describe('status-chrome timers under an occluding overlay', () => {
nowSpy.mockReturnValue(T0 + 300_000)
rule.clear()
resetOverlayState()
await flush()
// Poll for the reveal frame instead of a fixed tick: under CI load the
// store-driven re-render can land well after one 20ms scheduler turn.
await vi.waitFor(() => expect(rule.output()).toContain('6m 0s'), { interval: 10, timeout: 5_000 })
const resumed = rule.output()
@@ -344,6 +346,65 @@ describe('status-chrome timers under an occluding overlay', () => {
})
})
// teknium1's review of #12463 called out that its test asserted on a `picker`
// overlay state that no longer exists. Pin the gate to fields the current
// OverlayState actually carries so a rename breaks this file loudly.
describe('status-chrome timers track the current overlay model', () => {
// Everything that genuinely paints over the rule: the modal widget slot,
// plus the FloatingOverlays set (with the rule at its default `top`).
const occluding: Array<[string, Partial<OverlayState>]> = [
['modelPicker', { modelPicker: true }],
['pager', { pager: { lines: ['a'], offset: 0 } }],
['petPicker', { petPicker: true }],
['pluginsHub', { pluginsHub: true }],
['sessions', { sessions: true }],
['skillsHub', { skillsHub: true }],
['widget', { widget: { appId: 'demo', state: null } }]
]
// In `$isBlocked` but NOT occluding. `agents` / `journey` unmount the whole
// ComposerPane subtree, so React's effect cleanup already stops the clocks
// and gating on them would be dead code; the rest are PromptZone states that
// render in normal flow and push the rule down without covering it.
const nonOccluding: Array<[string, Partial<OverlayState>]> = [
['agents', { agents: true }],
['approval', { approval: { command: 'ls', requestId: 'a-1' } as OverlayState['approval'] }],
['billing', { billing: { kind: 'credits' } as OverlayState['billing'] }],
['clarify', { clarify: { question: 'which?', requestId: 'c-1' } as OverlayState['clarify'] }],
['confirm', { confirm: { onConfirm: () => {}, prompt: 'sure?' } as OverlayState['confirm'] }],
['journey', { journey: true }],
['secret', { secret: { envVar: 'TOKEN', prompt: 'token?' } as OverlayState['secret'] }],
['subscription', { subscription: { kind: 'expired' } as OverlayState['subscription'] }],
['sudo', { sudo: { requestId: 'sudo-1' } as OverlayState['sudo'] }]
]
it.each(occluding)('pauses the status clocks while %s covers the rule', (_name, patch) => {
patchOverlayState(patch)
mount(idleProps)
expect(oneSecondTimers(intervalSpy)).toBe(0)
})
it.each(nonOccluding)('keeps the status clocks running while %s is open', (_name, patch) => {
patchOverlayState(patch)
mount(idleProps)
expect(oneSecondTimers(intervalSpy)).toBe(2)
})
it('keeps the clocks running for the non-occluding ambient dock', () => {
// `ambient` is a glanceable in-flow dock that reserves its own rows and
// doesn't cover the status rule, so pausing there would be a regression.
patchOverlayState({ ambient: [{ appId: 'clock', state: null }] })
mount(idleProps)
expect(oneSecondTimers(intervalSpy)).toBe(2)
})
})
// The visibility gate teknium1 asked for: mount the REAL AppLayout so the
// status rule sits in its true position relative to PromptZone (normal flow,
// above ComposerPane) and FloatingOverlays (absolute, growing upward), then

View File

@@ -54,6 +54,40 @@ const findClickableWithText = (node: ReactNodeLike, needle: string): React.React
return findClickableWithText(node.props.children, needle)
}
// Find the innermost element whose own (direct) text content includes the
// needle. Used to assert the colour the session title is rendered with.
const findElementWithText = (node: ReactNodeLike, needle: string): React.ReactElement | null => {
if (node === null || node === undefined || typeof node === 'boolean') {
return null
}
if (Array.isArray(node)) {
for (const child of node) {
const found = findElementWithText(child, needle)
if (found) {
return found
}
}
return null
}
if (!React.isValidElement(node)) {
return null
}
// Prefer the deepest matching element so we get the leaf <Text> that
// actually carries the colour, not an ancestor Box.
const deeper = findElementWithText(node.props.children, needle)
if (deeper) {
return deeper
}
return textContent(node).includes(needle) ? node : null
}
const baseProps = {
bgCount: 0,
busy: false,
@@ -113,9 +147,19 @@ describe('StatusRule session title', () => {
})
const rendered = textContent(element)
const title = findElementWithText(element, 'weekly-digest')
expect(rendered).toContain('weekly-digest')
expect(rendered).not.toContain('~/repo')
// Regression for issue #82465: a raw, full-saturation accent-hue
// background (e.g. #FFBF00 on DARK_SEEDS) paired with statusFg (a
// near-white tone never designed to sit on it) rendered at roughly a
// 1.5-2:1 contrast ratio -- unreadable. No background fill at all;
// the accent color goes on the text instead, matching the theme's
// own convention that a raw accent hue is never used as a solid
// fill elsewhere (fills are always softened, e.g. activeRow).
expect(title?.props.backgroundColor).toBeUndefined()
expect(title?.props.color).toBe(DEFAULT_THEME.color.accent)
})
})
@@ -312,6 +356,75 @@ describe('StatusRule battery indicator', () => {
})
})
describe('StatusRule idle-since read-out', () => {
// The IdleSince component uses hooks, so it can't be invoked outside a
// renderer — assert on the element tree instead (same reason the duration
// tests don't check SessionDuration's text).
const findComponentByName = (node: ReactNodeLike, name: string): React.ReactElement | null => {
if (node === null || node === undefined || typeof node === 'boolean') {
return null
}
if (Array.isArray(node)) {
for (const child of node) {
const found = findComponentByName(child, name)
if (found) {
return found
}
}
return null
}
if (!React.isValidElement(node)) {
return null
}
if (typeof node.type === 'function' && node.type.name === name) {
return node
}
return findComponentByName(node.props.children, name)
}
it('shows time since the last final agent response when idle', () => {
const endedAt = Date.now() - 42_000
const element = StatusRule({
...baseProps,
lastTurnEndedAt: endedAt,
sessionStartedAt: Date.now() - 60_000
})
const idle = findComponentByName(element, 'IdleSince')
expect(idle).not.toBeNull()
expect(idle!.props.endedAt).toBe(endedAt)
})
it('is hidden while a turn is busy', () => {
const element = StatusRule({
...baseProps,
busy: true,
lastTurnEndedAt: Date.now() - 42_000,
turnStartedAt: Date.now()
})
expect(findComponentByName(element, 'IdleSince')).toBeNull()
})
it('is hidden before the first turn completes', () => {
const element = StatusRule({
...baseProps,
lastTurnEndedAt: null,
sessionStartedAt: Date.now() - 60_000
})
expect(findComponentByName(element, 'IdleSince')).toBeNull()
})
})
describe('StatusRule perf read-outs (cache hit / latency / tps)', () => {
const perfUsage = {
...baseProps.usage,