mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat(web): configure session preview limit (#629)
This commit is contained in:
@@ -10,6 +10,7 @@ import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
|
||||
import { CopyIcon, CheckIcon } from '@/components/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useTranslation } from '@/lib/use-translation'
|
||||
import { DEFAULT_SESSION_PREVIEW_LIMIT, useSessionPreviewLimit } from '@/hooks/useSessionPreviewLimit'
|
||||
|
||||
type SessionGroup = {
|
||||
key: string
|
||||
@@ -91,7 +92,7 @@ function getGroupDisplayName(directory: string): string {
|
||||
}
|
||||
|
||||
export const UNKNOWN_MACHINE_ID = '__unknown__'
|
||||
export const GROUP_SESSION_PREVIEW_LIMIT = 8
|
||||
export const GROUP_SESSION_PREVIEW_LIMIT = DEFAULT_SESSION_PREVIEW_LIMIT
|
||||
|
||||
export function deduplicateSessionsByAgentId(sessions: SessionSummary[], selectedSessionId?: string | null): SessionSummary[] {
|
||||
const byAgentId = new Map<string, SessionSummary[]>()
|
||||
@@ -698,6 +699,7 @@ export function SessionList(props: {
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { renderHeader = true, api, selectedSessionId, machineLabelsById = {}, onNewSessionInDirectory } = props
|
||||
const { sessionPreviewLimit } = useSessionPreviewLimit()
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const normalizedQuery = normalizeSearch(searchQuery)
|
||||
const isSearching = normalizedQuery.length > 0
|
||||
@@ -756,7 +758,7 @@ export function SessionList(props: {
|
||||
}
|
||||
|
||||
const isSessionGroupExpanded = (group: SessionGroup): boolean => {
|
||||
if (isSearching || group.sessions.length <= GROUP_SESSION_PREVIEW_LIMIT) return true
|
||||
if (isSearching || group.sessions.length <= sessionPreviewLimit) return true
|
||||
const key = `sessions::${group.key}`
|
||||
const override = collapseOverrides.get(key)
|
||||
if (override !== undefined) return !override
|
||||
@@ -778,7 +780,8 @@ export function SessionList(props: {
|
||||
group.sessions,
|
||||
{
|
||||
expanded: isSessionGroupExpanded(group),
|
||||
selectedSessionId
|
||||
selectedSessionId,
|
||||
limit: sessionPreviewLimit
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -955,7 +958,7 @@ export function SessionList(props: {
|
||||
selected={s.id === selectedSessionId}
|
||||
/>
|
||||
))}
|
||||
{!isSearching && group.sessions.length > GROUP_SESSION_PREVIEW_LIMIT && (sessionGroupExpanded || hiddenSessionCount > 0) ? (
|
||||
{!isSearching && group.sessions.length > sessionPreviewLimit && (sessionGroupExpanded || hiddenSessionCount > 0) ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSessionGroup(group)}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
DEFAULT_SESSION_PREVIEW_LIMIT,
|
||||
MAX_SESSION_PREVIEW_LIMIT,
|
||||
MIN_SESSION_PREVIEW_LIMIT,
|
||||
getInitialSessionPreviewLimit,
|
||||
normalizeSessionPreviewLimit,
|
||||
} from './useSessionPreviewLimit'
|
||||
|
||||
describe('useSessionPreviewLimit helpers', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
it('falls back to the default for missing or invalid values', () => {
|
||||
expect(getInitialSessionPreviewLimit()).toBe(DEFAULT_SESSION_PREVIEW_LIMIT)
|
||||
|
||||
window.localStorage.setItem('hapi-session-preview-limit', 'invalid')
|
||||
expect(getInitialSessionPreviewLimit()).toBe(DEFAULT_SESSION_PREVIEW_LIMIT)
|
||||
|
||||
window.localStorage.setItem('hapi-session-preview-limit', '12.5')
|
||||
expect(getInitialSessionPreviewLimit()).toBe(DEFAULT_SESSION_PREVIEW_LIMIT)
|
||||
})
|
||||
|
||||
it('reads valid stored values', () => {
|
||||
window.localStorage.setItem('hapi-session-preview-limit', '12')
|
||||
|
||||
expect(getInitialSessionPreviewLimit()).toBe(12)
|
||||
})
|
||||
|
||||
it('clamps values to the supported range', () => {
|
||||
expect(normalizeSessionPreviewLimit(0)).toBe(MIN_SESSION_PREVIEW_LIMIT)
|
||||
expect(normalizeSessionPreviewLimit(120)).toBe(MAX_SESSION_PREVIEW_LIMIT)
|
||||
|
||||
window.localStorage.setItem('hapi-session-preview-limit', '0')
|
||||
expect(getInitialSessionPreviewLimit()).toBe(MIN_SESSION_PREVIEW_LIMIT)
|
||||
|
||||
window.localStorage.setItem('hapi-session-preview-limit', '120')
|
||||
expect(getInitialSessionPreviewLimit()).toBe(MAX_SESSION_PREVIEW_LIMIT)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
export const DEFAULT_SESSION_PREVIEW_LIMIT = 8
|
||||
export const MIN_SESSION_PREVIEW_LIMIT = 1
|
||||
export const MAX_SESSION_PREVIEW_LIMIT = 99
|
||||
const SESSION_PREVIEW_LIMIT_CHANGED_EVENT = 'hapi-session-preview-limit-changed'
|
||||
|
||||
function getSessionPreviewLimitStorageKey(): string {
|
||||
return 'hapi-session-preview-limit'
|
||||
}
|
||||
|
||||
function isBrowser(): boolean {
|
||||
return typeof window !== 'undefined' && typeof document !== 'undefined'
|
||||
}
|
||||
|
||||
function safeGetItem(key: string): string | null {
|
||||
if (!isBrowser()) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return localStorage.getItem(key)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function safeSetItem(key: string, value: string): void {
|
||||
if (!isBrowser()) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(key, value)
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
function safeRemoveItem(key: string): void {
|
||||
if (!isBrowser()) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
localStorage.removeItem(key)
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeSessionPreviewLimit(value: number): number {
|
||||
if (!Number.isInteger(value)) {
|
||||
return DEFAULT_SESSION_PREVIEW_LIMIT
|
||||
}
|
||||
return Math.min(MAX_SESSION_PREVIEW_LIMIT, Math.max(MIN_SESSION_PREVIEW_LIMIT, value))
|
||||
}
|
||||
|
||||
function parseSessionPreviewLimit(raw: string | null): number {
|
||||
if (raw === null || raw.trim() === '') {
|
||||
return DEFAULT_SESSION_PREVIEW_LIMIT
|
||||
}
|
||||
const value = Number(raw)
|
||||
return normalizeSessionPreviewLimit(value)
|
||||
}
|
||||
|
||||
export function getInitialSessionPreviewLimit(): number {
|
||||
return parseSessionPreviewLimit(safeGetItem(getSessionPreviewLimitStorageKey()))
|
||||
}
|
||||
|
||||
export function useSessionPreviewLimit(): {
|
||||
sessionPreviewLimit: number
|
||||
setSessionPreviewLimit: (limit: number) => void
|
||||
} {
|
||||
const [sessionPreviewLimit, setSessionPreviewLimitState] = useState<number>(getInitialSessionPreviewLimit)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isBrowser()) {
|
||||
return
|
||||
}
|
||||
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key !== getSessionPreviewLimitStorageKey()) {
|
||||
return
|
||||
}
|
||||
setSessionPreviewLimitState(parseSessionPreviewLimit(event.newValue))
|
||||
}
|
||||
|
||||
const onLocalChange = (event: Event) => {
|
||||
const next = event instanceof CustomEvent ? event.detail : null
|
||||
if (typeof next === 'number') {
|
||||
setSessionPreviewLimitState(normalizeSessionPreviewLimit(next))
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('storage', onStorage)
|
||||
window.addEventListener(SESSION_PREVIEW_LIMIT_CHANGED_EVENT, onLocalChange)
|
||||
return () => {
|
||||
window.removeEventListener('storage', onStorage)
|
||||
window.removeEventListener(SESSION_PREVIEW_LIMIT_CHANGED_EVENT, onLocalChange)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const setSessionPreviewLimit = useCallback((limit: number) => {
|
||||
const normalized = normalizeSessionPreviewLimit(limit)
|
||||
setSessionPreviewLimitState(normalized)
|
||||
|
||||
if (normalized === DEFAULT_SESSION_PREVIEW_LIMIT) {
|
||||
safeRemoveItem(getSessionPreviewLimitStorageKey())
|
||||
} else {
|
||||
safeSetItem(getSessionPreviewLimitStorageKey(), String(normalized))
|
||||
}
|
||||
|
||||
if (isBrowser()) {
|
||||
window.dispatchEvent(new CustomEvent(SESSION_PREVIEW_LIMIT_CHANGED_EVENT, { detail: normalized }))
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { sessionPreviewLimit, setSessionPreviewLimit }
|
||||
}
|
||||
@@ -374,6 +374,9 @@ export default {
|
||||
'settings.display.appearance.light': 'Light',
|
||||
'settings.display.fontSize': 'Font Size',
|
||||
'settings.display.terminalFontSize': 'Terminal Font Size',
|
||||
'settings.display.sessionPreviewLimit': 'Sessions Before Folding',
|
||||
'settings.display.sessionPreviewLimit.decrease': 'Show fewer sessions before folding',
|
||||
'settings.display.sessionPreviewLimit.increase': 'Show more sessions before folding',
|
||||
'settings.chat.title': 'Chat',
|
||||
'settings.chat.enterBehavior': 'Enter Key',
|
||||
'settings.chat.enterBehavior.send': 'Send message',
|
||||
|
||||
@@ -376,6 +376,9 @@ export default {
|
||||
'settings.display.appearance.light': '浅色',
|
||||
'settings.display.fontSize': '字体大小',
|
||||
'settings.display.terminalFontSize': '终端字体大小',
|
||||
'settings.display.sessionPreviewLimit': '会话折叠阈值',
|
||||
'settings.display.sessionPreviewLimit.decrease': '减少折叠前显示的会话数',
|
||||
'settings.display.sessionPreviewLimit.increase': '增加折叠前显示的会话数',
|
||||
'settings.chat.title': '聊天',
|
||||
'settings.chat.enterBehavior': '回车键行为',
|
||||
'settings.chat.enterBehavior.send': '发送消息',
|
||||
|
||||
@@ -51,6 +51,13 @@ vi.mock('@/hooks/useTerminalToolDisplayMode', () => ({
|
||||
],
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/useSessionPreviewLimit', () => ({
|
||||
MIN_SESSION_PREVIEW_LIMIT: 1,
|
||||
MAX_SESSION_PREVIEW_LIMIT: 99,
|
||||
normalizeSessionPreviewLimit: (value: number) => Number.isInteger(value) ? Math.min(99, Math.max(1, value)) : 8,
|
||||
useSessionPreviewLimit: () => ({ sessionPreviewLimit: 8, setSessionPreviewLimit: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/useChatSurfaceColors', () => ({
|
||||
useChatSurfaceColors: () => ({
|
||||
toolGroupBackground: 'default',
|
||||
@@ -170,6 +177,9 @@ describe('SettingsPage', () => {
|
||||
const calledKeys = spyT.mock.calls.map((call) => call[0])
|
||||
expect(calledKeys).toContain('settings.display.appearance')
|
||||
expect(calledKeys).toContain('settings.display.appearance.system')
|
||||
expect(calledKeys).toContain('settings.display.sessionPreviewLimit')
|
||||
expect(calledKeys).toContain('settings.display.sessionPreviewLimit.decrease')
|
||||
expect(calledKeys).toContain('settings.display.sessionPreviewLimit.increase')
|
||||
})
|
||||
|
||||
it('renders the Terminal Font Size setting', () => {
|
||||
@@ -178,6 +188,14 @@ describe('SettingsPage', () => {
|
||||
expect(screen.getAllByText('13px').length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('renders the Session Preview Limit setting', () => {
|
||||
renderWithProviders(<SettingsPage />)
|
||||
expect(screen.getAllByText('Sessions Before Folding').length).toBeGreaterThanOrEqual(1)
|
||||
expect(screen.getByLabelText('Sessions Before Folding')).toHaveValue(8)
|
||||
expect(screen.getAllByLabelText('Show fewer sessions before folding').length).toBeGreaterThanOrEqual(1)
|
||||
expect(screen.getAllByLabelText('Show more sessions before folding').length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('renders the Enter Key setting', () => {
|
||||
renderWithProviders(<SettingsPage />)
|
||||
expect(screen.getAllByText('Enter Key').length).toBeGreaterThanOrEqual(1)
|
||||
|
||||
@@ -6,6 +6,12 @@ import { getFontScaleOptions, useFontScale, type FontScale } from '@/hooks/useFo
|
||||
import { getTerminalFontSizeOptions, useTerminalFontSize, type TerminalFontSize } from '@/hooks/useTerminalFontSize'
|
||||
import { getComposerEnterBehaviorOptions, useComposerEnterBehavior, type ComposerEnterBehavior } from '@/hooks/useComposerEnterBehavior'
|
||||
import { getTerminalToolDisplayModeOptions, useTerminalToolDisplayMode, type TerminalToolDisplayMode } from '@/hooks/useTerminalToolDisplayMode'
|
||||
import {
|
||||
MAX_SESSION_PREVIEW_LIMIT,
|
||||
MIN_SESSION_PREVIEW_LIMIT,
|
||||
normalizeSessionPreviewLimit,
|
||||
useSessionPreviewLimit,
|
||||
} from '@/hooks/useSessionPreviewLimit'
|
||||
import {
|
||||
getChatSurfaceColorPickerValue,
|
||||
getChatSurfaceColorPresetOptions,
|
||||
@@ -82,6 +88,125 @@ function ChevronDownIcon(props: { className?: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function MinusIcon(props: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={props.className}
|
||||
>
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function PlusIcon(props: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={props.className}
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionPreviewLimitControl(props: {
|
||||
label: string
|
||||
value: number
|
||||
onChange: (value: number) => void
|
||||
decreaseLabel: string
|
||||
increaseLabel: string
|
||||
}) {
|
||||
const [draft, setDraft] = useState(String(props.value))
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(String(props.value))
|
||||
}, [props.value])
|
||||
|
||||
const commitDraft = () => {
|
||||
const parsed = draft.trim() === '' ? props.value : Number(draft)
|
||||
const next = normalizeSessionPreviewLimit(parsed)
|
||||
props.onChange(next)
|
||||
setDraft(String(next))
|
||||
}
|
||||
|
||||
const step = (delta: number) => {
|
||||
const next = normalizeSessionPreviewLimit(props.value + delta)
|
||||
props.onChange(next)
|
||||
setDraft(String(next))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between gap-3 px-3 py-3">
|
||||
<label htmlFor="session-preview-limit" className="text-[var(--app-fg)]">
|
||||
{props.label}
|
||||
</label>
|
||||
<div className="flex h-9 shrink-0 items-center rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] shadow-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => step(-1)}
|
||||
disabled={props.value <= MIN_SESSION_PREVIEW_LIMIT}
|
||||
aria-label={props.decreaseLabel}
|
||||
title={props.decreaseLabel}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-l-lg text-[var(--app-hint)] transition-colors hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<MinusIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<input
|
||||
id="session-preview-limit"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={MIN_SESSION_PREVIEW_LIMIT}
|
||||
max={MAX_SESSION_PREVIEW_LIMIT}
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onBlur={commitDraft}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
commitDraft()
|
||||
event.currentTarget.blur()
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
setDraft(String(props.value))
|
||||
event.currentTarget.blur()
|
||||
}
|
||||
}}
|
||||
className="h-8 w-14 border-x border-[var(--app-border)] bg-transparent text-center text-sm font-medium tabular-nums text-[var(--app-fg)] outline-none focus:bg-[var(--app-subtle-bg)]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => step(1)}
|
||||
disabled={props.value >= MAX_SESSION_PREVIEW_LIMIT}
|
||||
aria-label={props.increaseLabel}
|
||||
title={props.increaseLabel}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-r-lg text-[var(--app-hint)] transition-colors hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<PlusIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChatSurfaceColorControl(props: {
|
||||
label: string
|
||||
preference: ChatSurfaceColorPreference
|
||||
@@ -158,6 +283,7 @@ export default function SettingsPage() {
|
||||
const voiceContainerRef = useRef<HTMLDivElement>(null)
|
||||
const { fontScale, setFontScale } = useFontScale()
|
||||
const { terminalFontSize, setTerminalFontSize } = useTerminalFontSize()
|
||||
const { sessionPreviewLimit, setSessionPreviewLimit } = useSessionPreviewLimit()
|
||||
const { composerEnterBehavior, setComposerEnterBehavior } = useComposerEnterBehavior()
|
||||
const { terminalToolDisplayMode, setTerminalToolDisplayMode } = useTerminalToolDisplayMode()
|
||||
const {
|
||||
@@ -499,6 +625,13 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SessionPreviewLimitControl
|
||||
label={t('settings.display.sessionPreviewLimit')}
|
||||
value={sessionPreviewLimit}
|
||||
onChange={setSessionPreviewLimit}
|
||||
decreaseLabel={t('settings.display.sessionPreviewLimit.decrease')}
|
||||
increaseLabel={t('settings.display.sessionPreviewLimit.increase')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Chat section */}
|
||||
|
||||
Reference in New Issue
Block a user