Skip to content
Open
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
26 changes: 25 additions & 1 deletion web/src/components/terminal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => <Terminal sessionId="s1" createEmulator={e.create} />)
// 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) => <Terminal sessionId="s1" createEmulator={e.create} />)
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) => <Terminal sessionId="s1" createEmulator={e.create} />)
act(() => sock.emitControl(attached({ ref: 1, id: 's1' })))
Expand Down Expand Up @@ -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) => <Terminal sessionId="s1" createEmulator={e.create} />)
// 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 () => {
Expand Down
29 changes: 28 additions & 1 deletion web/src/components/terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand Down Expand Up @@ -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<string | null>(() => 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<KeyboardMode>('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
Expand Down Expand Up @@ -1330,7 +1347,17 @@ export function Terminal({
<ShortcutsHelp chipStyle={chipStyle} chip={!coarse} />
</>
)}
{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.
<div
role="status"
aria-label="Connecting"
className="h-7 w-24 rounded-lg bg-(--chip-bg) ring-1 ring-(--chip-ring) backdrop-blur-sm motion-safe:animate-pulse"
/>
)}
{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
Expand Down