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', () => {
render(<TerminalSettings />)
expect(screen.getByText(/claude-haha/)).toBeInTheDocument()
expect(screen.getByTestId('settings-terminal-toolbar')).toHaveTextContent('Terminal')
expect(screen.getByText('Desktop runtime required')).toBeInTheDocument()
expect(terminalMocks.spawn).not.toHaveBeenCalled()
})
@ -127,6 +127,44 @@ describe('TerminalSettings', () => {
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 () => {
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 { terminalApi } from '../api/terminal'
import { useSettingsStore } from '../stores/settingsStore'
@ -26,6 +26,21 @@ const STATUS_LABEL_KEYS: Record<TerminalStatus, TranslationKey> = {
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 = {
active?: boolean
cwd?: string
@ -283,6 +298,18 @@ export function TerminalSettings({
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 () => {
setPreferencesError(null)
setPreferencesSaved(false)
@ -316,23 +343,33 @@ export function TerminalSettings({
return (
<div className={`flex h-full flex-col overflow-hidden ${
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
? '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 className="min-w-0">
<h2 className={`${docked ? 'text-sm' : 'text-base'} font-semibold text-[var(--color-text-primary)]`}>
<div
data-testid="settings-terminal-toolbar"
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')}
</h2>
{!docked && (
<p className="mt-0.5 max-w-2xl text-sm text-[var(--color-text-tertiary)]">
{t('settings.terminal.description')}
</p>
<StatusPill status={status} label={t(STATUS_LABEL_KEYS[status])} compact={docked} />
{shellInfo && (
<div className="flex min-w-0 items-center gap-1.5 text-xs text-[var(--color-text-tertiary)]">
<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 className="flex items-center gap-2">
<div className="flex shrink-0 items-center gap-1.5">
{onOpenInTab && (
<button
type="button"
@ -383,17 +420,6 @@ export function TerminalSettings({
</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 && (
<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}
@ -492,19 +518,15 @@ export function TerminalSettings({
</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 className="flex h-8 items-center gap-2 border-b border-[var(--color-terminal-border)] bg-[var(--color-terminal-header)] px-3">
<span className="h-2.5 w-2.5 rounded-full bg-[var(--color-terminal-danger)]" />
<span className="h-2.5 w-2.5 rounded-full bg-[var(--color-terminal-warning)]" />
<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
data-testid="settings-terminal-frame"
onWheelCapture={handleTerminalWheelCapture}
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)]"
>
<div
ref={hostRef}
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>
)}
@ -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 =
status === 'running'
? 'bg-[var(--color-success)]'
@ -523,7 +545,7 @@ function StatusPill({ status, label }: { status: TerminalStatus; label: string }
: 'bg-[var(--color-text-tertiary)]'
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}`} />
{label}
</span>