diff --git a/ui-tui/src/__tests__/appChromeBlockedTimers.test.tsx b/ui-tui/src/__tests__/appChromeBlockedTimers.test.tsx index 70b6b40c80..028a42f7ac 100644 --- a/ui-tui/src/__tests__/appChromeBlockedTimers.test.tsx +++ b/ui-tui/src/__tests__/appChromeBlockedTimers.test.tsx @@ -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]> = [ + ['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]> = [ + ['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 diff --git a/ui-tui/src/__tests__/appChromeStatusRule.test.tsx b/ui-tui/src/__tests__/appChromeStatusRule.test.tsx index 8029c246ec..31eaf8555f 100644 --- a/ui-tui/src/__tests__/appChromeStatusRule.test.tsx +++ b/ui-tui/src/__tests__/appChromeStatusRule.test.tsx @@ -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 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,