fix(tmux): collapse installer banner by default

This commit is contained in:
777genius 2026-04-14 21:52:06 +03:00
parent 51aac9b7a1
commit 80221884ed
2 changed files with 383 additions and 284 deletions

View file

@ -12,6 +12,8 @@ import {
import { useTmuxInstallerBanner } from '../hooks/useTmuxInstallerBanner'; import { useTmuxInstallerBanner } from '../hooks/useTmuxInstallerBanner';
const SUMMARY_TITLE = 'tmux is not installed';
const SourceLink = ({ const SourceLink = ({
label, label,
url, url,
@ -35,8 +37,10 @@ const SourceLink = ({
export function TmuxInstallerBannerView(): React.JSX.Element | null { export function TmuxInstallerBannerView(): React.JSX.Element | null {
const { viewModel, install, cancel, submitInput, refresh, toggleDetails, openExternal } = const { viewModel, install, cancel, submitInput, refresh, toggleDetails, openExternal } =
useTmuxInstallerBanner(); useTmuxInstallerBanner();
const [expanded, setExpanded] = React.useState(false);
const [inputValue, setInputValue] = React.useState(''); const [inputValue, setInputValue] = React.useState('');
const [manualHintsExpanded, setManualHintsExpanded] = React.useState(false); const [manualHintsExpanded, setManualHintsExpanded] = React.useState(false);
const previousPhaseRef = React.useRef(viewModel.phase);
React.useEffect(() => { React.useEffect(() => {
if (!viewModel.acceptsInput) { if (!viewModel.acceptsInput) {
@ -50,6 +54,21 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
} }
}, [viewModel.manualHintsCollapsible]); }, [viewModel.manualHintsCollapsible]);
React.useEffect(() => {
const previousPhase = previousPhaseRef.current;
const becameActive =
previousPhase === 'idle' &&
viewModel.phase !== 'idle' &&
viewModel.phase !== 'completed' &&
viewModel.phase !== 'cancelled';
if (becameActive) {
setExpanded(true);
}
previousPhaseRef.current = viewModel.phase;
}, [viewModel.phase]);
if (!viewModel.visible) { if (!viewModel.visible) {
return null; return null;
} }
@ -66,9 +85,13 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
borderColor: 'rgba(245, 158, 11, 0.2)', borderColor: 'rgba(245, 158, 11, 0.2)',
}} }}
> >
<div className="space-y-3"> <button
<div className="min-w-0 max-w-4xl"> type="button"
<div className="flex items-start gap-2 text-base font-semibold leading-6"> aria-expanded={expanded}
onClick={() => setExpanded((current) => !current)}
className="flex w-full items-center justify-between gap-3 text-left"
>
<span className="flex min-w-0 items-start gap-2 text-base font-semibold leading-6">
<span className="pt-0.5"> <span className="pt-0.5">
{viewModel.error ? ( {viewModel.error ? (
<AlertTriangle className="size-4 text-red-300" /> <AlertTriangle className="size-4 text-red-300" />
@ -76,8 +99,26 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
<Wrench className="size-4 text-amber-300" /> <Wrench className="size-4 text-amber-300" />
)} )}
</span> </span>
<span>{viewModel.title}</span> <span className="truncate">{SUMMARY_TITLE}</span>
</span>
{expanded ? (
<ChevronUp className="size-4 shrink-0" />
) : (
<ChevronDown className="size-4 shrink-0" />
)}
</button>
{expanded && (
<div className="mt-4 space-y-3">
<div className="min-w-0 max-w-4xl">
{viewModel.title !== SUMMARY_TITLE && (
<div
className="text-sm font-medium leading-6"
style={{ color: 'var(--color-text-secondary)' }}
>
{viewModel.title}
</div> </div>
)}
<p <p
className="mt-2 text-[15px] leading-7" className="mt-2 text-[15px] leading-7"
style={{ color: 'var(--color-text-secondary)' }} style={{ color: 'var(--color-text-secondary)' }}
@ -235,10 +276,9 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
</button> </button>
)} )}
</div> </div>
</div>
{viewModel.progressPercent !== null && ( {viewModel.progressPercent !== null && (
<div className="mt-3"> <div>
<div className="mb-1 flex items-center justify-between text-[11px]"> <div className="mb-1 flex items-center justify-between text-[11px]">
<span style={{ color: 'var(--color-text-muted)' }}>Installer progress</span> <span style={{ color: 'var(--color-text-muted)' }}>Installer progress</span>
<span style={{ color: 'var(--color-text-secondary)' }}> <span style={{ color: 'var(--color-text-secondary)' }}>
@ -261,7 +301,7 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
)} )}
{viewModel.acceptsInput && ( {viewModel.acceptsInput && (
<div className="mt-3 space-y-2"> <div className="space-y-2">
<form <form
className="flex flex-col gap-2 sm:flex-row sm:items-center" className="flex flex-col gap-2 sm:flex-row sm:items-center"
onSubmit={(event) => { onSubmit={(event) => {
@ -297,15 +337,15 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
</form> </form>
{viewModel.inputSecret && ( {viewModel.inputSecret && (
<div className="text-[11px]" style={{ color: 'var(--color-text-muted)' }}> <div className="text-[11px]" style={{ color: 'var(--color-text-muted)' }}>
Password input is sent directly to the installer terminal and is not added to the log Password input is sent directly to the installer terminal and is not added to
output. the log output.
</div> </div>
)} )}
</div> </div>
)} )}
{manualHintsVisible && ( {manualHintsVisible && (
<div className="mt-3 grid gap-2 lg:grid-cols-2"> <div className="grid gap-2 lg:grid-cols-2">
{viewModel.manualHints.map((hint) => ( {viewModel.manualHints.map((hint) => (
<div <div
key={`${hint.title}-${hint.command ?? hint.url ?? hint.description}`} key={`${hint.title}-${hint.command ?? hint.url ?? hint.description}`}
@ -337,7 +377,7 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
)} )}
{(viewModel.logs.length > 0 || viewModel.error) && ( {(viewModel.logs.length > 0 || viewModel.error) && (
<div className="mt-3"> <div>
<button <button
type="button" type="button"
onClick={toggleDetails} onClick={toggleDetails}
@ -361,5 +401,7 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
</div> </div>
)} )}
</div> </div>
)}
</div>
); );
} }

View file

@ -93,16 +93,32 @@ describe('TmuxInstallerBannerView', () => {
it('keeps Windows setup steps collapsed by default and expands them on demand', async () => { it('keeps Windows setup steps collapsed by default and expands them on demand', async () => {
const { host, root } = renderBanner(baseViewModel); const { host, root } = renderBanner(baseViewModel);
expect(host.textContent).toContain('Show setup steps (2)'); expect(host.textContent).toContain('tmux is not installed');
expect(host.textContent).not.toContain('WSL is available, but no Linux distribution is installed yet.');
expect(host.textContent).not.toContain('Show setup steps (2)');
expect(host.textContent).not.toContain('wsl --install --no-distribution'); expect(host.textContent).not.toContain('wsl --install --no-distribution');
const toggleButton = [...host.querySelectorAll('button')].find((button) => const summaryButton = [...host.querySelectorAll('button')].find((button) =>
button.textContent?.includes('Show setup steps') button.textContent?.includes('tmux is not installed')
); );
expect(toggleButton).toBeDefined(); expect(summaryButton).toBeDefined();
await act(async () => { await act(async () => {
toggleButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); summaryButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(host.textContent).toContain('WSL is available, but no Linux distribution is installed yet.');
expect(host.textContent).toContain('Show setup steps (2)');
expect(host.textContent).not.toContain('Hide setup steps');
const setupToggle = [...host.querySelectorAll('button')].find((button) =>
button.textContent?.includes('Show setup steps')
);
expect(setupToggle).toBeDefined();
await act(async () => {
setupToggle?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve(); await Promise.resolve();
}); });
@ -125,6 +141,15 @@ describe('TmuxInstallerBannerView', () => {
], ],
}); });
const summaryButton = [...host.querySelectorAll('button')].find((button) =>
button.textContent?.includes('tmux is not installed')
);
expect(summaryButton).toBeDefined();
act(() => {
summaryButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(host.textContent).toContain('brew install tmux'); expect(host.textContent).toContain('brew install tmux');
expect(host.textContent).not.toContain('Show setup steps'); expect(host.textContent).not.toContain('Show setup steps');
@ -132,4 +157,36 @@ describe('TmuxInstallerBannerView', () => {
root.unmount(); root.unmount();
}); });
}); });
it('auto-expands when installer flow becomes active', async () => {
const { host, root } = renderBanner(baseViewModel);
mockUseTmuxInstallerBanner.mockReturnValue({
viewModel: {
...baseViewModel,
title: 'tmux needs a restart',
body: 'Restart Windows before continuing.',
phase: 'needs_restart',
progressPercent: 96,
},
install: vi.fn(),
cancel: vi.fn(),
submitInput: vi.fn(),
refresh: vi.fn(),
toggleDetails: vi.fn(),
openExternal: vi.fn(),
});
await act(async () => {
root.render(React.createElement(TmuxInstallerBannerView));
await Promise.resolve();
});
expect(host.textContent).toContain('Restart Windows before continuing.');
expect(host.textContent).toContain('96%');
act(() => {
root.unmount();
});
});
}); });