fix: compact desktop terminal chrome

The terminal page used a header row, a status row, and an inner host-shell chrome, which reduced usable terminal space and made wheel scrolling fall into terminal scrollback while users were navigating Settings.

This collapses the terminal metadata and actions into one toolbar, removes the nested host-shell title bar, and forwards wheel input to the surrounding scroll container until the terminal is focused.

Constraint: TerminalSettings is shared by Settings, terminal tabs, and docked session terminals.
Rejected: Increase only the panel height | leaves duplicate chrome and scroll handoff unchanged
Confidence: high
Scope-risk: narrow
Directive: Keep terminal process lifetime separate from visual chrome; this change is layout and wheel routing only.
Tested: cd desktop && bun run test -- src/pages/TerminalSettings.test.tsx src/pages/ActiveSession.test.tsx src/components/layout/ContentRouter.test.tsx
Tested: cd desktop && bun run lint
Tested: bun run check:desktop
Not-tested: Manual Tauri window PTY smoke; browser screenshot unavailable in current tool environment
This commit is contained in:
程序员阿江(Relakkes) 2026-05-25 23:20:28 +08:00
parent 76a4ca5d30
commit 9a6b0c4e72
2 changed files with 95 additions and 35 deletions

View File

@ -108,7 +108,7 @@ describe('TerminalSettings', () => {
it('shows a desktop-runtime empty state outside Tauri', () => { it('shows a desktop-runtime empty state outside Tauri', () => {
render(<TerminalSettings />) render(<TerminalSettings />)
expect(screen.getByText(/claude-haha/)).toBeInTheDocument() expect(screen.getByTestId('settings-terminal-toolbar')).toHaveTextContent('Terminal')
expect(screen.getByText('Desktop runtime required')).toBeInTheDocument() expect(screen.getByText('Desktop runtime required')).toBeInTheDocument()
expect(terminalMocks.spawn).not.toHaveBeenCalled() expect(terminalMocks.spawn).not.toHaveBeenCalled()
}) })
@ -127,6 +127,44 @@ describe('TerminalSettings', () => {
expect(terminalMocks.fitInstance.fit).toHaveBeenCalled() expect(terminalMocks.fitInstance.fit).toHaveBeenCalled()
}) })
it('uses one compact toolbar instead of a nested terminal title bar', async () => {
terminalMocks.available = true
render(<TerminalSettings />)
await waitFor(() => expect(terminalMocks.spawn).toHaveBeenCalled())
expect(screen.getByTestId('settings-terminal-toolbar')).toHaveTextContent('/bin/zsh')
expect(screen.getByTestId('settings-terminal-frame')).toBeInTheDocument()
expect(screen.queryByText('Host shell')).not.toBeInTheDocument()
})
it('lets the settings page keep scrolling when the terminal is not focused', async () => {
terminalMocks.available = true
const container = document.createElement('div')
container.style.overflowY = 'auto'
let scrollTop = 0
Object.defineProperty(container, 'clientHeight', { configurable: true, value: 100 })
Object.defineProperty(container, 'scrollHeight', { configurable: true, value: 300 })
Object.defineProperty(container, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value) => { scrollTop = value },
})
const scrollBy = vi.fn(({ top }: ScrollToOptions) => {
scrollTop += Number(top ?? 0)
})
Object.defineProperty(container, 'scrollBy', { configurable: true, value: scrollBy })
document.body.appendChild(container)
render(<TerminalSettings />, { container })
await waitFor(() => expect(terminalMocks.spawn).toHaveBeenCalled())
fireEvent.wheel(screen.getByTestId('settings-terminal-frame'), { deltaY: 48 })
expect(scrollBy).toHaveBeenCalledWith({ top: 48, left: 0 })
expect(scrollTop).toBe(48)
})
it('starts in the provided cwd when embedded in a project session', async () => { it('starts in the provided cwd when embedded in a project session', async () => {
terminalMocks.available = true terminalMocks.available = true

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState, type WheelEvent } from 'react'
import { useTranslation, type TranslationKey } from '../i18n' import { useTranslation, type TranslationKey } from '../i18n'
import { terminalApi } from '../api/terminal' import { terminalApi } from '../api/terminal'
import { useSettingsStore } from '../stores/settingsStore' import { useSettingsStore } from '../stores/settingsStore'
@ -26,6 +26,21 @@ const STATUS_LABEL_KEYS: Record<TerminalStatus, TranslationKey> = {
unavailable: 'settings.terminal.status.unavailable', unavailable: 'settings.terminal.status.unavailable',
} }
function findScrollableAncestor(element: HTMLElement, deltaY: number): HTMLElement | null {
let parent = element.parentElement
while (parent) {
const style = window.getComputedStyle(parent)
const canScrollY = style.overflowY === 'auto' || style.overflowY === 'scroll'
if (canScrollY && parent.scrollHeight > parent.clientHeight) {
const maxScrollTop = parent.scrollHeight - parent.clientHeight
const canMove = deltaY < 0 ? parent.scrollTop > 0 : parent.scrollTop < maxScrollTop
if (canMove) return parent
}
parent = parent.parentElement
}
return null
}
type TerminalSettingsProps = { type TerminalSettingsProps = {
active?: boolean active?: boolean
cwd?: string cwd?: string
@ -283,6 +298,18 @@ export function TerminalSettings({
runtime.terminal?.clear() runtime.terminal?.clear()
} }
const handleTerminalWheelCapture = useCallback((event: WheelEvent<HTMLDivElement>) => {
const host = hostRef.current
if (!host || host.contains(document.activeElement)) return
const scroller = findScrollableAncestor(event.currentTarget, event.deltaY)
if (!scroller) return
event.preventDefault()
event.stopPropagation()
scroller.scrollBy({ top: event.deltaY, left: event.deltaX })
}, [])
const savePreferences = async () => { const savePreferences = async () => {
setPreferencesError(null) setPreferencesError(null)
setPreferencesSaved(false) setPreferencesSaved(false)
@ -316,23 +343,33 @@ export function TerminalSettings({
return ( return (
<div className={`flex h-full flex-col overflow-hidden ${ <div className={`flex h-full flex-col overflow-hidden ${
docked docked
? 'min-h-0 bg-[var(--color-surface-container-lowest)] px-3 py-2' ? 'min-h-0 bg-[var(--color-surface-container-lowest)] px-3 py-1.5'
: workspace : workspace
? 'min-h-0 bg-[var(--color-surface)] px-5 py-4' ? 'min-h-0 bg-[var(--color-surface)] px-5 py-4'
: 'min-h-[620px]' : 'min-h-[min(720px,calc(100vh-8rem))]'
}`}> }`}>
<div className={`${docked ? 'mb-2' : 'mb-3'} flex flex-wrap items-start justify-between gap-3`}> <div
<div className="min-w-0"> data-testid="settings-terminal-toolbar"
<h2 className={`${docked ? 'text-sm' : 'text-base'} font-semibold text-[var(--color-text-primary)]`}> className={`${docked ? 'mb-1.5 min-h-8' : 'mb-2 min-h-9'} flex min-w-0 flex-wrap items-center gap-2`}
>
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="h-2.5 w-2.5 shrink-0 rounded-full bg-[var(--color-terminal-danger)]" aria-hidden="true" />
<span className="h-2.5 w-2.5 shrink-0 rounded-full bg-[var(--color-terminal-warning)]" aria-hidden="true" />
<span className="h-2.5 w-2.5 shrink-0 rounded-full bg-[var(--color-terminal-accent)]" aria-hidden="true" />
<h2 className={`${docked ? 'text-[13px]' : 'text-sm'} shrink-0 font-semibold text-[var(--color-text-primary)]`}>
{t('settings.terminal.title')} {t('settings.terminal.title')}
</h2> </h2>
{!docked && ( <StatusPill status={status} label={t(STATUS_LABEL_KEYS[status])} compact={docked} />
<p className="mt-0.5 max-w-2xl text-sm text-[var(--color-text-tertiary)]"> {shellInfo && (
{t('settings.terminal.description')} <div className="flex min-w-0 items-center gap-1.5 text-xs text-[var(--color-text-tertiary)]">
</p> <span className="shrink-0 font-mono">{shellInfo.shell}</span>
<span className="shrink-0 text-[var(--color-border)]">/</span>
<span className="min-w-0 truncate font-mono">{shellInfo.cwd}</span>
</div>
)} )}
</div> </div>
<div className="flex items-center gap-2">
<div className="flex shrink-0 items-center gap-1.5">
{onOpenInTab && ( {onOpenInTab && (
<button <button
type="button" type="button"
@ -383,17 +420,6 @@ export function TerminalSettings({
</div> </div>
</div> </div>
<div className={`${docked ? 'mb-2' : 'mb-3'} flex flex-wrap items-center gap-2 text-xs text-[var(--color-text-tertiary)]`}>
<StatusPill status={status} label={t(STATUS_LABEL_KEYS[status])} />
{shellInfo && (
<>
<span className="font-mono">{shellInfo.shell}</span>
<span className="text-[var(--color-border)]">/</span>
<span className="min-w-0 max-w-full truncate font-mono">{shellInfo.cwd}</span>
</>
)}
</div>
{error && ( {error && (
<div className="mb-3 rounded-[var(--radius-md)] border border-[var(--color-error)]/20 bg-[var(--color-error)]/10 px-3 py-2 text-sm text-[var(--color-error)]"> <div className="mb-3 rounded-[var(--radius-md)] border border-[var(--color-error)]/20 bg-[var(--color-error)]/10 px-3 py-2 text-sm text-[var(--color-error)]">
{error} {error}
@ -492,19 +518,15 @@ export function TerminalSettings({
</div> </div>
</div> </div>
) : ( ) : (
<div className="min-h-0 flex-1 overflow-hidden rounded-[var(--radius-md)] border border-[var(--color-terminal-border)] bg-[var(--color-terminal-bg)] shadow-[var(--shadow-dropdown)]"> <div
<div className="flex h-8 items-center gap-2 border-b border-[var(--color-terminal-border)] bg-[var(--color-terminal-header)] px-3"> data-testid="settings-terminal-frame"
<span className="h-2.5 w-2.5 rounded-full bg-[var(--color-terminal-danger)]" /> onWheelCapture={handleTerminalWheelCapture}
<span className="h-2.5 w-2.5 rounded-full bg-[var(--color-terminal-warning)]" /> className="min-h-0 flex-1 overflow-hidden rounded-[var(--radius-sm)] border border-[var(--color-terminal-border)] bg-[var(--color-terminal-bg)] shadow-[var(--shadow-dropdown)]"
<span className="h-2.5 w-2.5 rounded-full bg-[var(--color-terminal-accent)]" /> >
<span className="ml-2 truncate font-mono text-[11px] text-[var(--color-terminal-muted)]">
{t('settings.terminal.windowTitle')}
</span>
</div>
<div <div
ref={hostRef} ref={hostRef}
data-testid={testId} data-testid={testId}
className="settings-terminal-host h-[calc(100%-2rem)] w-full overflow-hidden p-2" className="settings-terminal-host h-full w-full overflow-hidden px-2 pb-2 pt-1.5"
/> />
</div> </div>
)} )}
@ -512,7 +534,7 @@ export function TerminalSettings({
) )
} }
function StatusPill({ status, label }: { status: TerminalStatus; label: string }) { function StatusPill({ status, label, compact = false }: { status: TerminalStatus; label: string; compact?: boolean }) {
const color = const color =
status === 'running' status === 'running'
? 'bg-[var(--color-success)]' ? 'bg-[var(--color-success)]'
@ -523,7 +545,7 @@ function StatusPill({ status, label }: { status: TerminalStatus; label: string }
: 'bg-[var(--color-text-tertiary)]' : 'bg-[var(--color-text-tertiary)]'
return ( return (
<span className="inline-flex h-6 items-center gap-1.5 rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-2.5 text-[11px] font-medium text-[var(--color-text-secondary)]"> <span className={`inline-flex ${compact ? 'h-5 px-2 text-[10px]' : 'h-6 px-2.5 text-[11px]'} shrink-0 items-center gap-1.5 rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-low)] font-medium text-[var(--color-text-secondary)]`}>
<span className={`h-1.5 w-1.5 rounded-full ${color}`} /> <span className={`h-1.5 w-1.5 rounded-full ${color}`} />
{label} {label}
</span> </span>