Skip to content
Merged
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
2 changes: 1 addition & 1 deletion docs/specs/layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ The label is the `DerivedHeader` from `deriveHeader(...)`; `docs/specs/terminal-

**Must tint the copyable Surface ref as an action and confirm each successful context copy in its button** with a checkmark and “Copied” for 1.4 seconds, preserving button width and keeping the context open. Failed copies show the action error without success feedback.

**Must suppress context action hover and focus highlights while the window is unfocused**, including after opening a native explorer or system browser.
**Must suppress context action hover and focus highlights while the window is unfocused**, including after opening a native explorer or system browser. **Must also withhold hover from an in-flight action, which stays focusable and `aria-disabled` rather than `disabled`** so the innermost disclosure keeps a focused descendant for Escape and Tab.

**Must show “Opening…” with a spinner in the directory explorer button during launch and for at least 0.75 seconds**, preserving width and keyboard focus while blocking repeat clicks. Stop immediately on failure and show the action error. Respect reduced motion by keeping the spinner static.

Expand Down
11 changes: 8 additions & 3 deletions lib/src/components/design.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -379,10 +379,15 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
*/
export const UNDER_SWITCH_INDENT = 'ml-18';

/** Quiet action tint and interaction treatment, shared by switches and context actions. */
/**
* Quiet action tint and interaction treatment, shared by switches and context actions.
* Hover is gated on `not-aria-disabled` as well as `:enabled`, because a button that
* drops its clicks via `aria-disabled` (an in-flight `ContextAction`) is still `:enabled`.
* `focus-visible` is deliberately ungated: such a button keeps focus and must keep its ring.
*/
export const SUBTLE_ACTION_REST_COLOR_CLASS = 'text-[color:color-mix(in_srgb,var(--color-link)_35%,var(--color-muted))]';
export const SUBTLE_ACTION_COLOR_CLASS = `${SUBTLE_ACTION_REST_COLOR_CLASS} enabled:hover:text-link enabled:focus-visible:text-link`;
export const SUBTLE_ACTION_INTERACTION_CLASS = 'enabled:hover:bg-current/10 focus-visible:outline focus-visible:outline-focus-ring';
export const SUBTLE_ACTION_COLOR_CLASS = `${SUBTLE_ACTION_REST_COLOR_CLASS} enabled:not-aria-disabled:hover:text-link enabled:focus-visible:text-link`;
export const SUBTLE_ACTION_INTERACTION_CLASS = 'enabled:not-aria-disabled:hover:bg-current/10 focus-visible:outline focus-visible:outline-focus-ring';

/**
* The app's boolean control: compact track (off left, on right) and one state
Expand Down
43 changes: 43 additions & 0 deletions lib/src/components/wall/TerminalContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,49 @@ it('keeps a failed promotion visible and retryable', async () => {
props.onPromote = vi.fn(async () => { throw new Error('Placement failed'); }); render(); await click('Move this terminal into a new pane');
expect(container.querySelector('[role="alert"]')?.textContent).toBe('Placement failed'); expect(props.onClose).not.toHaveBeenCalled();
});
it('keeps a submitting detail button focused so the dialog keeps Escape and its Tab trap', async () => {
let reject!: (reason: Error) => void;
props.onModify = vi.fn(() => new Promise<void>((_resolve, fail) => { reject = fail; }));
props.initialDetail = 'modify'; render();
const save = button('Save default');
act(() => save.focus());
await click('Save default');
// In flight: inert via aria-disabled only, so the browser cannot blur it.
expect(save.disabled).toBe(false);
expect(save.getAttribute('aria-disabled')).toBe('true');
expect(document.activeElement).toBe(save);
await click('Save default');
expect(props.onModify).toHaveBeenCalledOnce();
await act(async () => reject(new Error('Command rejected')));
// The rejected edit keeps the dialog open, and focus never left the button.
expect(container.querySelector('[role="alert"]')?.textContent).toBe('Command rejected');
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
expect(save.hasAttribute('aria-disabled')).toBe(false);
expect(document.activeElement).toBe(save);
// So the <section>'s handlers still receive Tab and Escape from a focused descendant.
act(() => save.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true })));
// Focus has to *move* and stay in: `contains` alone passes for a no-op Tab. Which element it
// lands on is not asserted — jsdom returns a selector list grouped by selector rather than in
// document order, so the trap's wrap target differs here from a real browser.
expect(document.activeElement).not.toBe(save);
expect(container.querySelector('[role="dialog"]')!.contains(document.activeElement)).toBe(true);
Comment thread
dormouse-bot marked this conversation as resolved.
act(() => (document.activeElement as HTMLElement).dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })));
expect(container.querySelector('[role="dialog"]')).toBeNull();
expect(props.onClose).not.toHaveBeenCalled();
});
it('keeps the focus ring on an in-flight button while withholding only its hover styling', async () => {
props.onModify = vi.fn(() => new Promise<void>(() => {}));
props.initialDetail = 'modify'; render();
const save = button('Save default');
const rest = save.className;
await click('Save default');
// Hover is gated on aria-disabled; focus-visible is not, so the focused button keeps its ring.
const hovers = rest.split(' ').filter(c => c.includes('hover:'));
expect(hovers.length).toBeGreaterThan(0);
for (const hover of hovers) expect(hover).toContain('not-aria-disabled:');
for (const ring of ['focus-visible:outline', 'focus-visible:outline-focus-ring', 'enabled:focus-visible:text-link']) expect(save.className.split(' ')).toContain(ring);
expect(save.className).toBe(rest);
});
it('shows both directories in the mismatch warning', () => {
props.mismatch = true; props.helperCwd = '~/other'; render(); expect(container.querySelector('[role="alert"]')?.textContent).toContain('~/other'); expect(container.querySelector('[role="alert"]')?.textContent).toContain('~/repo');
});
Expand Down
8 changes: 5 additions & 3 deletions lib/src/components/wall/TerminalContextView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,10 @@ export function ContextAction({ children, label, onClick, disabled = false, busy
const windowFocused = useContext(WindowFocusedContext);
// Native app launches can leave :hover stale until this window regains focus.
const color = muted ? 'text-muted' : windowFocused ? SUBTLE_ACTION_COLOR_CLASS : SUBTLE_ACTION_REST_COLOR_CLASS;
// `busy` must never reach native `disabled`: the browser blurs a button the moment it is disabled,
// and this context's Escape and Tab handling both live on the <section> and need a focused descendant.
return <button type="button" title={label} aria-label={label} aria-busy={busy || undefined} aria-disabled={busy || undefined} disabled={disabled} onClick={busy ? undefined : onClick}
className={`inline-flex h-6 shrink-0 items-center justify-center gap-1.5 rounded px-1.5 ${busy ? '' : 'disabled:opacity-40'} ${windowFocused ? SUBTLE_ACTION_INTERACTION_CLASS : ''} ${color}`}>{children}</button>;
className={`inline-flex h-6 shrink-0 items-center justify-center gap-1.5 rounded px-1.5 disabled:opacity-40 ${windowFocused ? SUBTLE_ACTION_INTERACTION_CLASS : ''} ${color}`}>{children}</button>;
}

function ContextCopyAction({ children, label, onCopy }: { children: ReactNode; label: string; onCopy: () => Promise<boolean> }) {
Expand Down Expand Up @@ -178,15 +180,15 @@ export function TerminalContextView(p: TerminalContextViewProps) {
<div className="flex min-w-0 items-center gap-2 text-muted">{p.status === 'running' || p.status === 'waiting' ? <CircleNotchIcon size={13} className="shrink-0 animate-spin" /> : preserved || p.status === 'off' || p.status === 'unsupported' ? <PauseIcon size={13} className="shrink-0" /> : <CheckIcon size={13} className="shrink-0" />}<span className="truncate" title={status}>{status}</span>
{preserved || p.status === 'exited' ? <ContextAction label="Reset helper terminal" onClick={() => setDetail('reset')}><ArrowCounterClockwiseIcon size={13} />Reset…</ContextAction> : <ContextAction label="Modify autorun command" onClick={() => { setCommand(p.defaultCommand ?? p.command); setDetail('modify'); }}><SlidersHorizontalIcon size={15} />Modify</ContextAction>}
</div>
<div className="ml-auto shrink-0"><ContextAction label="Move this terminal into a new pane" disabled={busy} onClick={() => void submit(p.onPromote)}><ArrowLineUpIcon size={15} />Promote</ContextAction></div>
<div className="ml-auto shrink-0"><ContextAction label="Move this terminal into a new pane" busy={busy} onClick={() => void submit(p.onPromote)}><ArrowLineUpIcon size={15} />Promote</ContextAction></div>
</div>
{p.mismatch && <div role="alert" className="mx-3 mb-2 flex shrink-0 items-start gap-2 border-l-4 border-error bg-error/10 px-3 py-2"><WarningIcon size={18} weight="fill" className="shrink-0 text-error" /><div><div className="font-semibold">Helper directory differs from parent</div><div className="mt-1 grid grid-cols-[4rem_1fr] gap-x-2"><span className="text-muted">Helper</span><strong>{p.helperCwd}</strong><span className="text-muted">Parent</span><span>{p.cwd}</span></div></div></div>}
{(p.warning || (!detail && error)) && <div role="alert" className="mx-3 mb-2 border-l-4 border-error bg-error/10 px-3 py-2">{p.warning || error}</div>}
<div className="min-h-0 flex-1 bg-terminal-bg text-terminal-fg">{p.children}</div>
</div>
{detail && <div className="absolute inset-0 z-10 bg-app-bg/35" onClick={() => setDetail(null)}><div ref={detailRoot} role="dialog" aria-modal="true" aria-label={detail === 'title' ? 'Title sources' : detail === 'modify' ? 'Default helper autorun command' : 'Reset helper terminal'} className={`${POPUP_SURFACE_CLASS} absolute left-3 right-3 top-9 p-4`} onClick={e => e.stopPropagation()}>
<div className="mb-3 flex items-center justify-between font-semibold"><span>{detail === 'title' ? 'Why this title?' : detail === 'modify' ? 'Default helper autorun command' : 'Reset helper terminal?'}</span><ContextAction label="Close details" onClick={() => setDetail(null)} muted><XIcon size={14} /></ContextAction></div>
{detail === 'title' ? <div className="grid grid-cols-[8rem_1fr_auto] gap-x-3 gap-y-2">{p.titleSources.map((source, index) => <div className="contents" key={index}><span className="text-muted">{source.source}</span><span>{source.value}</span><span className="text-muted">{source.note}</span></div>)}</div> : detail === 'modify' ? <><input autoFocus aria-label="Default helper autorun command" value={command} onChange={e => setCommand(e.target.value)} maxLength={4096} placeholder="Leave empty to turn autorun off" className="w-full border-b border-input-border bg-input-bg px-2 py-1.5 outline-focus-ring" /><p className="mb-4 mt-2 text-muted">Global default. Applies to new and reset helpers. Leave empty to turn autorun off.</p><div className="flex justify-end gap-2"><ContextAction label="Reset helper terminal" onClick={() => setDetail('reset')}>Reset helper…</ContextAction><ContextAction label="Save default" disabled={busy} onClick={() => void submit(() => p.onModify(command))}>Save default</ContextAction></div></> : <><p>Discard this helper, including scrollback, unfinished input, and any running program? Unsaved edits will be lost.</p><p className="mb-4 mt-2 text-muted">A fresh helper starts in the parent's current directory using the global autorun default.</p><div className="flex justify-end gap-2"><ContextAction label="Keep helper" onClick={() => setDetail(null)}>Keep helper</ContextAction><ContextAction label="Discard and reset" disabled={busy} onClick={() => void submit(p.onReset)}>Discard and reset</ContextAction></div></>}
{detail === 'title' ? <div className="grid grid-cols-[8rem_1fr_auto] gap-x-3 gap-y-2">{p.titleSources.map((source, index) => <div className="contents" key={index}><span className="text-muted">{source.source}</span><span>{source.value}</span><span className="text-muted">{source.note}</span></div>)}</div> : detail === 'modify' ? <><input autoFocus aria-label="Default helper autorun command" value={command} onChange={e => setCommand(e.target.value)} maxLength={4096} placeholder="Leave empty to turn autorun off" className="w-full border-b border-input-border bg-input-bg px-2 py-1.5 outline-focus-ring" /><p className="mb-4 mt-2 text-muted">Global default. Applies to new and reset helpers. Leave empty to turn autorun off.</p><div className="flex justify-end gap-2"><ContextAction label="Reset helper terminal" onClick={() => setDetail('reset')}>Reset helper…</ContextAction><ContextAction label="Save default" busy={busy} onClick={() => void submit(() => p.onModify(command))}>Save default</ContextAction></div></> : <><p>Discard this helper, including scrollback, unfinished input, and any running program? Unsaved edits will be lost.</p><p className="mb-4 mt-2 text-muted">A fresh helper starts in the parent's current directory using the global autorun default.</p><div className="flex justify-end gap-2"><ContextAction label="Keep helper" onClick={() => setDetail(null)}>Keep helper</ContextAction><ContextAction label="Discard and reset" busy={busy} onClick={() => void submit(p.onReset)}>Discard and reset</ContextAction></div></>}
{error && <p role="alert" className="mt-2 text-error">{error}</p>}
</div></div>}
</div>
Expand Down