diff --git a/web/src/components/terminal.test.tsx b/web/src/components/terminal.test.tsx index a8a9b53..042248f 100644 --- a/web/src/components/terminal.test.tsx +++ b/web/src/components/terminal.test.tsx @@ -348,6 +348,25 @@ describe('Terminal', () => { vi.useRealTimers() }) + it('holds the connecting pill behind a wordless skeleton until the connect drags', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + mountTerminal((e) => ) + // A fresh mount connects in a blink on a local daemon; words would flash. + expect(screen.getByRole('status').textContent).not.toContain('Connecting') + + await act(() => vi.advanceTimersByTimeAsync(2100)) + expect(screen.getByRole('status').textContent).toContain('Connecting') + vi.useRealTimers() + }) + + it('drops the skeleton the moment the attach lands', () => { + const { sock } = mountTerminal((e) => ) + expect(screen.getByRole('status')).toBeTruthy() + + act(() => sock.emitControl(attached({ ref: 1, id: 's1' }))) + expect(screen.queryByRole('status')).toBeNull() + }) + it('reports the process exiting, with its code, once', () => { const { sock, em } = mountTerminal((e) => ) act(() => sock.emitControl(attached({ ref: 1, id: 's1' }))) @@ -1312,9 +1331,14 @@ describe('Terminal', () => { expect(pane().getAttribute('data-flue-mode')).toBe('tab') }) - it('offers the shortcut where there is already chrome to put it on', () => { + it('offers the shortcut where there is already chrome to put it on', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) mountTerminal((e) => ) + // The hint rides the worded pill, which a young connect holds behind + // the skeleton — so it, too, waits out the grace. + await act(() => vi.advanceTimersByTimeAsync(2100)) expect(screen.getByRole('status').textContent).toContain(TERMINAL_SHORTCUT_HINT) + vi.useRealTimers() }) it('enters focus mode on the shortcut, and takes the key before the terminal sees it', async () => { diff --git a/web/src/components/terminal.tsx b/web/src/components/terminal.tsx index cfb3e48..bee1594 100644 --- a/web/src/components/terminal.tsx +++ b/web/src/components/terminal.tsx @@ -135,6 +135,9 @@ export const TERMINAL_SHORTCUT_HINT = 'Ctrl+Shift+Enter' */ const RESET = new TextEncoder().encode('\x1bc') +/** How long a connect stays a skeleton before the pill spells it out. */ +const SLOW_CONNECT_MS = 2000 + const EXIT_NOTICE = (code: number) => new TextEncoder().encode(`\r\n\x1b[90m[process exited: ${code}]\x1b[0m\r\n`) @@ -237,6 +240,20 @@ export function Terminal({ // The daemon's reason, for the pill's second line. Seeded alongside the // phase and replaced by the event so both mount orders read the same. const [revokedWhy, setRevokedWhy] = useState(() => client.revoked) + // A fresh connect resolves in a blink on a local daemon, and a worded pill + // for that blink reads as a flash on every tab switch — the group view + // remounts the pane. The pill waits this long behind a wordless skeleton; + // a connect still pending by then is genuinely slow and worth the words. + // Reconnecting is exempt: an outage is news however briefly it lasts. + const [slowConnect, setSlowConnect] = useState(false) + useEffect(() => { + if (phase !== 'connecting') { + setSlowConnect(false) + return + } + const t = setTimeout(() => setSlowConnect(true), SLOW_CONNECT_MS) + return () => clearTimeout(t) + }, [phase]) const [mode, setMode] = useState('tab') // Coarse pointer once per mount: whether this device's primary pointer is a // finger decides the key bar's existence, and a pointer does not change @@ -1330,7 +1347,17 @@ export function Terminal({ )} - {phase !== 'live' && ( + {phase === 'connecting' && !slowConnect && ( + // The young connect's stand-in: a chip-shaped shimmer, sized like + // the pill so nothing shifts when the words do arrive. See + // slowConnect for why the words wait. +
+ )} + {phase !== 'live' && !(phase === 'connecting' && !slowConnect) && ( // Dark in both themes, like the pane it floats over usually is; the // translucent ground and backdrop-blur keep it legible over whatever // the screen underneath was showing. The dot is the phase at a