From a50f92bc43da2d4a8a00f9ab0759fdcb3156aed3 Mon Sep 17 00:00:00 2001 From: Karn Date: Wed, 19 Aug 2026 23:58:24 +0530 Subject: [PATCH] feat(web): hold the connecting pill behind a brief skeleton Switching tabs remounts the pane, and every remount flashed the worded Connecting panel for the blink a local attach takes. A young connect now shows a wordless chip-shaped shimmer instead; the pill with its hint appears only when the connect drags past two seconds. Reconnecting still speaks immediately, since an outage is news. Co-Authored-By: Claude Fable 5 --- web/src/components/terminal.test.tsx | 25 +++++++++++++++++++++++- web/src/components/terminal.tsx | 29 +++++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/web/src/components/terminal.test.tsx b/web/src/components/terminal.test.tsx index 30ec8b6..a9739d5 100644 --- a/web/src/components/terminal.test.tsx +++ b/web/src/components/terminal.test.tsx @@ -348,6 +348,24 @@ 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) => ) @@ -1409,9 +1427,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 3fb1a1d..18fdc8c 100644 --- a/web/src/components/terminal.tsx +++ b/web/src/components/terminal.tsx @@ -137,6 +137,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`) @@ -239,6 +242,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 @@ -1366,7 +1383,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