Files
hapi/web/src/routes/settings/display.tsx
T
99f4ca471d feat(web): make pinned In progress section optional (default off) (#1350)
Restores directory glanceability by default after #1315. Settings → Display
adds a toggle next to Active sessions only that re-enables the pinned section.

Fixes #1347

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 08:05:12 +08:00

200 lines
12 KiB
TypeScript

import { useEffect, useState } from 'react'
import { useTranslation } from '@/lib/use-translation'
import { getAppearanceOptions, useAppearance } from '@/hooks/useTheme'
import { getColorThemeOptions, getColorThemePreview, useColorTheme, type ColorThemePreset } from '@/hooks/useColorTheme'
import { getFontScaleOptions, useFontScale } from '@/hooks/useFontScale'
import { getTerminalFontSizeOptions, useTerminalFontSize } from '@/hooks/useTerminalFontSize'
import { getSessionListStatusModeOptions, useSessionListStatusMode } from '@/hooks/useSessionListStatusMode'
import { useShowActiveSessionsOnly } from '@/hooks/useShowActiveSessionsOnly'
import { usePinInProgressSessions } from '@/hooks/usePinInProgressSessions'
import { MAX_SESSION_PREVIEW_LIMIT, MIN_SESSION_PREVIEW_LIMIT, normalizeSessionPreviewLimit, useSessionPreviewLimit } from '@/hooks/useSessionPreviewLimit'
import { useThemeColors, type ThemeColorKeyId } from '@/hooks/useThemeColors'
import { useSessionHeaderMetadata, type SessionHeaderMetadataKey } from '@/hooks/useSessionHeaderMetadata'
import { SettingsChoiceGroup, SettingsFieldLabel, SettingsPageContent, SettingsRow, SettingsSection, SettingsSwitch } from '@/components/settings/SettingsPrimitives'
function MinusIcon() {
return <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="h-4 w-4" aria-hidden="true"><path d="M5 12h14" /></svg>
}
function PlusIcon() {
return <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="h-4 w-4" aria-hidden="true"><path d="M12 5v14M5 12h14" /></svg>
}
function ColorThemePicker() {
const { t } = useTranslation()
const { colorTheme, setColorTheme } = useColorTheme()
return (
<div className="px-3 py-3">
<SettingsFieldLabel>{t('settings.display.colorTheme')}</SettingsFieldLabel>
<div role="radiogroup" aria-label={t('settings.display.colorTheme')} className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{getColorThemeOptions().map((option) => (
<ColorThemeOption
key={option.value}
theme={option.value}
label={t(option.labelKey)}
selected={colorTheme === option.value}
onSelect={setColorTheme}
/>
))}
</div>
</div>
)
}
function ColorThemeOption(props: { theme: ColorThemePreset; label: string; selected: boolean; onSelect: (theme: ColorThemePreset) => void }) {
const preview = getColorThemePreview(props.theme)
return (
<label
className={`flex min-w-0 items-center gap-2 rounded-lg border px-2 py-2 text-left text-sm transition-colors ${props.selected
? 'border-[var(--app-link)] bg-[var(--app-subtle-bg)] text-[var(--app-link)]'
: 'border-[var(--app-border)] text-[var(--app-fg)] hover:bg-[var(--app-subtle-bg)]'}`}
>
<input type="radio" name="color-theme" value={props.theme} checked={props.selected} onChange={() => props.onSelect(props.theme)} className="sr-only" />
<span className="relative h-7 w-7 shrink-0 overflow-hidden rounded-md border border-[var(--app-border)]" style={{ backgroundColor: preview.light }} aria-hidden="true">
<span className="absolute inset-y-0 right-0 w-1/2" style={{ backgroundColor: preview.dark }} />
<span className="absolute inset-x-1 bottom-1 h-1 rounded-full" style={{ backgroundColor: preview.accent }} />
</span>
<span className="min-w-0 truncate font-medium">{props.label}</span>
</label>
)
}
function SessionPreviewLimitControl() {
const { t } = useTranslation()
const { sessionPreviewLimit, setSessionPreviewLimit } = useSessionPreviewLimit()
const [draft, setDraft] = useState(String(sessionPreviewLimit))
useEffect(() => setDraft(String(sessionPreviewLimit)), [sessionPreviewLimit])
const commit = () => {
const parsed = draft.trim() === '' ? sessionPreviewLimit : Number(draft)
const next = normalizeSessionPreviewLimit(parsed)
setSessionPreviewLimit(next)
setDraft(String(next))
}
const step = (delta: number) => setSessionPreviewLimit(normalizeSessionPreviewLimit(sessionPreviewLimit + delta))
return (
<SettingsRow label={t('settings.display.sessionPreviewLimit')} trailing={
<div className="flex h-9 items-center rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)]">
<button type="button" onClick={() => step(-1)} disabled={sessionPreviewLimit <= MIN_SESSION_PREVIEW_LIMIT} aria-label={t('settings.display.sessionPreviewLimit.decrease')} className="flex h-8 w-8 items-center justify-center disabled:opacity-40"><MinusIcon /></button>
<input
aria-label={t('settings.display.sessionPreviewLimit')}
type="number"
inputMode="numeric"
min={MIN_SESSION_PREVIEW_LIMIT}
max={MAX_SESSION_PREVIEW_LIMIT}
value={draft}
onChange={(event) => setDraft(event.target.value)}
onBlur={commit}
onKeyDown={(event) => {
if (event.key === 'Enter') { commit(); event.currentTarget.blur() }
if (event.key === 'Escape') { setDraft(String(sessionPreviewLimit)); event.currentTarget.blur() }
}}
className="h-8 w-14 border-x border-[var(--app-border)] bg-transparent text-center text-sm text-[var(--app-fg)] outline-none"
/>
<button type="button" onClick={() => step(1)} disabled={sessionPreviewLimit >= MAX_SESSION_PREVIEW_LIMIT} aria-label={t('settings.display.sessionPreviewLimit.increase')} className="flex h-8 w-8 items-center justify-center disabled:opacity-40"><PlusIcon /></button>
</div>
} />
)
}
function ThemeColorControls() {
const { t } = useTranslation()
const { keys, getPickerValue, isCustomized, hasAnyCustom, setColor, resetColor, resetAll } = useThemeColors()
return (
<details open={hasAnyCustom} className="group">
<summary className="flex cursor-pointer list-none items-center justify-between px-3 py-3 text-[var(--app-fg)] hover:bg-[var(--app-subtle-bg)]">
<span>
<span className="block text-sm font-medium">{t('settings.display.themeColors.title')}</span>
<span className="mt-0.5 block text-xs text-[var(--app-hint)]">{t('settings.display.themeColors.description')}</span>
</span>
<span className="ml-3 text-sm text-[var(--app-hint)]">{hasAnyCustom ? t('settings.voice.advanced.customizedBadge') : t('settings.display.themeColors.expand')}</span>
</summary>
<div className="border-t border-[var(--app-divider)] px-3 py-3">
{hasAnyCustom ? <div className="mb-3 text-right"><button type="button" onClick={resetAll} className="text-sm text-[var(--app-link)] hover:underline">{t('settings.display.themeColors.resetAll')}</button></div> : null}
<div className="grid gap-3 sm:grid-cols-2">
{keys.map((key) => (
<div key={key.id} className="flex items-center justify-between gap-2 rounded-lg border border-[var(--app-border)] px-2.5 py-2">
<span className="min-w-0 truncate text-sm text-[var(--app-fg)]">{t(key.labelKey)}</span>
<span className="flex shrink-0 items-center gap-2">
{isCustomized(key.id) ? <button type="button" onClick={(event) => { event.preventDefault(); resetColor(key.id as ThemeColorKeyId) }} className="text-xs text-[var(--app-hint)] hover:text-[var(--app-link)]">{t('settings.display.themeColors.reset')}</button> : null}
<input type="color" aria-label={t(key.labelKey)} value={getPickerValue(key.id)} onChange={(event) => setColor(key.id as ThemeColorKeyId, event.target.value)} className="h-8 w-10 cursor-pointer border-0 bg-transparent p-0" />
</span>
</div>
))}
</div>
</div>
</details>
)
}
export default function SettingsDisplayPage() {
const { t } = useTranslation()
const { appearance, setAppearance } = useAppearance()
const { fontScale, setFontScale } = useFontScale()
const { terminalFontSize, setTerminalFontSize } = useTerminalFontSize()
const { sessionListStatusMode, setSessionListStatusMode } = useSessionListStatusMode()
const { showActiveSessionsOnly, setShowActiveSessionsOnly } = useShowActiveSessionsOnly()
const { pinInProgressSessions, setPinInProgressSessions } = usePinInProgressSessions()
const { preferences: sessionHeaderMetadata, setPreference: setSessionHeaderMetadata } = useSessionHeaderMetadata()
const sessionHeaderOptions: ReadonlyArray<{ key: SessionHeaderMetadataKey; labelKey: string }> = [
{ key: 'showLabels', labelKey: 'settings.display.sessionHeader.showLabels' },
{ key: 'agent', labelKey: 'settings.display.sessionHeader.agent' },
{ key: 'machine', labelKey: 'settings.display.sessionHeader.machine' },
{ key: 'lastActive', labelKey: 'settings.display.sessionHeader.lastActive' },
{ key: 'model', labelKey: 'settings.display.sessionHeader.model' },
{ key: 'reasoning', labelKey: 'settings.display.sessionHeader.reasoning' },
{ key: 'fastMode', labelKey: 'settings.display.sessionHeader.fastMode' },
{ key: 'createdAt', labelKey: 'settings.display.sessionHeader.createdAt' },
{ key: 'updatedAt', labelKey: 'settings.display.sessionHeader.updatedAt' },
{ key: 'worktree', labelKey: 'settings.display.sessionHeader.worktree' },
]
return (
<SettingsPageContent description={t('settings.display.description')}>
<SettingsSection title={t('settings.display.appearance')}>
<SettingsChoiceGroup
label={t('settings.display.appearanceMode')}
value={appearance}
columns={4}
options={getAppearanceOptions().map((option) => ({ value: option.value, label: t(option.labelKey) }))}
onChange={setAppearance}
/>
<ColorThemePicker />
<ThemeColorControls />
</SettingsSection>
<SettingsSection title={t('settings.display.typography')}>
<SettingsChoiceGroup label={t('settings.display.fontSize')} value={fontScale} columns={5} options={getFontScaleOptions()} onChange={setFontScale} />
<SettingsChoiceGroup label={t('settings.display.terminalFontSize')} value={terminalFontSize} columns={5} options={getTerminalFontSizeOptions()} onChange={setTerminalFontSize} />
</SettingsSection>
<SettingsSection title={t('settings.display.sessions')}>
<SessionPreviewLimitControl />
<SettingsSwitch label={t('settings.display.activeSessionsOnly')} description={t('settings.display.activeSessionsOnly.desc')} checked={showActiveSessionsOnly} onChange={setShowActiveSessionsOnly} />
<SettingsSwitch label={t('settings.display.pinInProgressSessions')} description={t('settings.display.pinInProgressSessions.desc')} checked={pinInProgressSessions} onChange={setPinInProgressSessions} />
<SettingsChoiceGroup
label={t('settings.display.sessionListStatus')}
description={t('settings.display.sessionListStatus.detailedDescription')}
value={sessionListStatusMode}
options={getSessionListStatusModeOptions().map((option) => ({ value: option.value, label: t(option.labelKey) }))}
onChange={setSessionListStatusMode}
/>
</SettingsSection>
<SettingsSection title={t('settings.display.sessionHeader')} description={t('settings.display.sessionHeader.description')}>
{sessionHeaderOptions.map((option) => (
<SettingsSwitch
key={option.key}
label={t(option.labelKey)}
checked={sessionHeaderMetadata[option.key]}
onChange={(checked) => setSessionHeaderMetadata(option.key, checked)}
/>
))}
</SettingsSection>
</SettingsPageContent>
)
}