From 0537ddf84a8ecfb3def7935ce67d803a1cb9d0c2 Mon Sep 17 00:00:00 2001 From: Ananovo Date: Sat, 1 Aug 2026 23:26:17 +0800 Subject: [PATCH] feat(web): make session header metadata configurable (#1267) * feat(web): make session header metadata configurable * fix(web): align mobile header metadata priority --- .../components/AssistantChat/StatusBar.tsx | 4 +- web/src/components/SessionHeader.test.tsx | 1 + web/src/components/SessionHeader.tsx | 85 +++++++++++---- .../hooks/useSessionHeaderMetadata.test.ts | 36 ++++++ web/src/hooks/useSessionHeaderMetadata.ts | 103 ++++++++++++++++++ web/src/lib/codexStatusLabels.test.ts | 5 + web/src/lib/codexStatusLabels.ts | 5 +- web/src/lib/locales/en.ts | 14 +++ web/src/lib/locales/zh-CN.ts | 14 +++ .../lib/sessionHeaderMobileMetadata.test.ts | 21 ++++ web/src/lib/sessionHeaderMobileMetadata.ts | 26 +++++ web/src/lib/sessionHeaderTimestamp.test.ts | 15 +++ web/src/lib/sessionHeaderTimestamp.ts | 13 +++ web/src/routes/settings/display.tsx | 25 +++++ web/src/routes/settings/index.test.tsx | 24 ++++ 15 files changed, 366 insertions(+), 25 deletions(-) create mode 100644 web/src/hooks/useSessionHeaderMetadata.test.ts create mode 100644 web/src/hooks/useSessionHeaderMetadata.ts create mode 100644 web/src/lib/sessionHeaderMobileMetadata.test.ts create mode 100644 web/src/lib/sessionHeaderMobileMetadata.ts create mode 100644 web/src/lib/sessionHeaderTimestamp.test.ts create mode 100644 web/src/lib/sessionHeaderTimestamp.ts diff --git a/web/src/components/AssistantChat/StatusBar.tsx b/web/src/components/AssistantChat/StatusBar.tsx index ebd51e1b..790db496 100644 --- a/web/src/components/AssistantChat/StatusBar.tsx +++ b/web/src/components/AssistantChat/StatusBar.tsx @@ -18,6 +18,7 @@ import { } from '@/lib/codexStatusLabels' import { isFastServiceTier } from './codexFastMode' import { useTranslation } from '@/lib/use-translation' +import { useSessionHeaderMetadata } from '@/hooks/useSessionHeaderMetadata' // Vibing messages for thinking state const VIBING_MESSAGES = [ @@ -205,6 +206,7 @@ export function StatusBar(props: { voiceStatus?: ConversationStatus }) { const { t } = useTranslation() + const { preferences: headerMetadata } = useSessionHeaderMetadata() const connectionStatus = useMemo( () => getConnectionStatus(props.active, props.thinking, props.agentState, props.voiceStatus, props.backgroundTaskCount ?? 0, t), [props.active, props.thinking, props.agentState, props.voiceStatus, props.backgroundTaskCount, t] @@ -254,7 +256,7 @@ export function StatusBar(props: { : null const displaysCodexReasoning = shouldShowCodexReasoningLabel(props.agentFlavor) const codexReasoningLabel = displaysCodexReasoning - ? formatCodexReasoningLabel(props.modelReasoningEffort) + ? formatCodexReasoningLabel(props.modelReasoningEffort, headerMetadata.showLabels) : null const compactCodexReasoningLabel = displaysCodexReasoning ? formatCompactCodexReasoningLabel(props.modelReasoningEffort) diff --git a/web/src/components/SessionHeader.test.tsx b/web/src/components/SessionHeader.test.tsx index 80460dae..4a57735d 100644 --- a/web/src/components/SessionHeader.test.tsx +++ b/web/src/components/SessionHeader.test.tsx @@ -76,6 +76,7 @@ describe('SessionHeader', () => { it('shows an inherited catalog-default Fast tier', () => { renderHeader(baseSession(), { serviceTier: 'priority' }) expect(screen.getByText('fast')).toBeInTheDocument() + expect(screen.queryByText('reasoning default')).not.toBeInTheDocument() }) it('shows machine label and relative last-active age in the meta row', () => { diff --git a/web/src/components/SessionHeader.tsx b/web/src/components/SessionHeader.tsx index c1276291..8584e20d 100644 --- a/web/src/components/SessionHeader.tsx +++ b/web/src/components/SessionHeader.tsx @@ -22,6 +22,9 @@ import { markCodexSessionsImported } from '@/lib/codexImportedSessions' import { useMachines } from '@/hooks/queries/useMachines' import { useMachineLabels } from '@/hooks/useMachineLabels' import { formatAbsoluteDateTime, formatRelativeTime } from '@/lib/relativeTime' +import { useSessionHeaderMetadata } from '@/hooks/useSessionHeaderMetadata' +import { formatSessionHeaderTimestamp } from '@/lib/sessionHeaderTimestamp' +import { selectMobileSessionHeaderSecondary } from '@/lib/sessionHeaderMobileMetadata' /** Same preference order as session-list chips: display label → host → short id. */ export function resolveSessionHeaderMachineLabel( @@ -125,19 +128,24 @@ export function SessionHeader(props: { onSessionDeleted?: () => void onSessionReopened?: (newSessionId: string) => void }) { - const { t } = useTranslation() + const { t, locale } = useTranslation() const queryClient = useQueryClient() const { addToast } = useToast() const { session, api, onSessionDeleted, onSessionReopened } = props const title = useMemo(() => getSessionTitle(session), [session]) - const worktreeBranch = session.metadata?.worktree?.branch + const worktreeBranch = session.metadata?.worktree?.branch?.trim() || null + const { preferences: headerMetadata } = useSessionHeaderMetadata() const modelLabel = getSessionModelLabel(session) const agentFlavor = session.metadata?.flavor ?? null - const reasoningLabel = shouldShowCodexReasoningLabel(agentFlavor) - ? formatCodexReasoningLabel(session.modelReasoningEffort) + const agentLabel = agentFlavor?.trim() || null + const reasoningEffort = session.modelReasoningEffort?.trim() || null + const reasoningLabel = reasoningEffort && shouldShowCodexReasoningLabel(agentFlavor) + ? formatCodexReasoningLabel(reasoningEffort, headerMetadata.showLabels) : null // Match expected Fast badge semantics (#1004): only explicit service tier, no effort/model heuristics. const showFastBadge = agentFlavor === 'codex' && isFastServiceTier(props.serviceTier ?? session.serviceTier) + const createdAtLabel = headerMetadata.createdAt ? formatSessionHeaderTimestamp(session.createdAt, locale) : null + const updatedAtLabel = headerMetadata.updatedAt ? formatSessionHeaderTimestamp(session.updatedAt, locale) : null const codexSessionId = session.metadata?.flavor === 'codex' ? session.metadata.codexSessionId?.trim() || null : null @@ -158,10 +166,21 @@ export function SessionHeader(props: { return () => window.clearInterval(timer) }, []) const ageLabel = useMemo( - () => (lastActiveAt > 0 ? formatRelativeTime(lastActiveAt, t) : null), - [lastActiveAt, t, relativeTimeTick] + () => (headerMetadata.lastActive && lastActiveAt > 0 ? formatRelativeTime(lastActiveAt, t) : null), + [headerMetadata.lastActive, lastActiveAt, t, relativeTimeTick] ) - const ageAbsolute = lastActiveAt > 0 ? formatAbsoluteDateTime(lastActiveAt) : null + const ageAbsolute = ageLabel ? formatAbsoluteDateTime(lastActiveAt) : null + const mobileSecondary = selectMobileSessionHeaderSecondary({ + model: headerMetadata.model && modelLabel !== null, + reasoning: headerMetadata.reasoning && reasoningLabel !== null, + machine: headerMetadata.machine && machineLabel !== null, + lastActive: ageLabel !== null, + updatedAt: updatedAtLabel !== null, + createdAt: createdAtLabel !== null, + worktree: headerMetadata.worktree && Boolean(worktreeBranch), + fastMode: headerMetadata.fastMode && showFastBadge, + }) + const showMobileMetadata = (headerMetadata.agent && agentLabel !== null) || mobileSecondary !== null const [menuOpen, setMenuOpen] = useState(false) const [menuAnchorPoint, setMenuAnchorPoint] = useState<{ x: number; y: number }>({ x: 0, y: 0 }) @@ -286,14 +305,34 @@ export function SessionHeader(props: {
{title}
-
- - - {session.metadata?.flavor?.trim() || 'unknown'} - - {machineLabel ? ( - - {t('session.item.machine')}: {machineLabel} + {showMobileMetadata ? ( +
+ {headerMetadata.agent && agentLabel ? ( + + + {agentLabel} + + ) : null} + {mobileSecondary === 'model' && modelLabel ? {headerMetadata.showLabels ? `${t(modelLabel.key)}: ` : ''}{modelLabel.value} : null} + {mobileSecondary === 'reasoning' && reasoningLabel ? {reasoningLabel} : null} + {mobileSecondary === 'machine' && machineLabel ? {headerMetadata.showLabels ? `${t('session.item.machine')}: ` : ''}{machineLabel} : null} + {mobileSecondary === 'lastActive' && ageLabel ? {ageLabel} : null} + {mobileSecondary === 'updatedAt' && updatedAtLabel ? {headerMetadata.showLabels ? `${t('session.header.updatedAt')}: ` : ''}{updatedAtLabel} : null} + {mobileSecondary === 'createdAt' && createdAtLabel ? {headerMetadata.showLabels ? `${t('session.header.createdAt')}: ` : ''}{createdAtLabel} : null} + {mobileSecondary === 'worktree' && worktreeBranch ? {headerMetadata.showLabels ? `${t('session.item.worktree')}: ` : ''}{worktreeBranch} : null} + {mobileSecondary === 'fastMode' ? fast : null} +
+ ) : null} +
+ {headerMetadata.agent && agentLabel ? ( + + + {agentLabel} + + ) : null} + {headerMetadata.machine && machineLabel ? ( + + {headerMetadata.showLabels ? `${t('session.item.machine')}: ` : ''}{machineLabel} ) : null} {ageLabel ? ( @@ -301,23 +340,25 @@ export function SessionHeader(props: { {ageLabel} ) : null} - {modelLabel ? ( + {headerMetadata.model && modelLabel ? ( - {t(modelLabel.key)}: {modelLabel.value} + {headerMetadata.showLabels ? `${t(modelLabel.key)}: ` : ''}{modelLabel.value} ) : null} - {reasoningLabel ? ( - + {headerMetadata.reasoning && reasoningLabel ? ( + {reasoningLabel} ) : null} - {showFastBadge ? ( + {headerMetadata.fastMode && showFastBadge ? ( fast ) : null} - {worktreeBranch ? ( - {t('session.item.worktree')}: {worktreeBranch} + {createdAtLabel ? {headerMetadata.showLabels ? `${t('session.header.createdAt')}: ` : ''}{createdAtLabel} : null} + {updatedAtLabel ? {headerMetadata.showLabels ? `${t('session.header.updatedAt')}: ` : ''}{updatedAtLabel} : null} + {headerMetadata.worktree && worktreeBranch ? ( + {headerMetadata.showLabels ? `${t('session.item.worktree')}: ` : ''}{worktreeBranch} ) : null}
diff --git a/web/src/hooks/useSessionHeaderMetadata.test.ts b/web/src/hooks/useSessionHeaderMetadata.test.ts new file mode 100644 index 00000000..7265c1c6 --- /dev/null +++ b/web/src/hooks/useSessionHeaderMetadata.test.ts @@ -0,0 +1,36 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { DEFAULT_SESSION_HEADER_METADATA, parseSessionHeaderMetadata } from './useSessionHeaderMetadata' + +describe('parseSessionHeaderMetadata', () => { + beforeEach(() => localStorage.clear()) + + it('preserves the existing header display by default', () => { + expect(parseSessionHeaderMetadata(null)).toEqual(DEFAULT_SESSION_HEADER_METADATA) + expect(DEFAULT_SESSION_HEADER_METADATA).toMatchObject({ + showLabels: true, + agent: true, + model: true, + reasoning: true, + fastMode: true, + machine: true, + lastActive: true, + createdAt: false, + updatedAt: false, + worktree: true, + }) + }) + + it('merges stored booleans with defaults for forward compatibility', () => { + expect(parseSessionHeaderMetadata(JSON.stringify({ showLabels: false, reasoning: false, createdAt: true, model: 'nope' }))).toEqual({ + ...DEFAULT_SESSION_HEADER_METADATA, + showLabels: false, + reasoning: false, + createdAt: true, + }) + }) + + it('ignores invalid stored values', () => { + expect(parseSessionHeaderMetadata('{')).toEqual(DEFAULT_SESSION_HEADER_METADATA) + expect(parseSessionHeaderMetadata('[]')).toEqual(DEFAULT_SESSION_HEADER_METADATA) + }) +}) diff --git a/web/src/hooks/useSessionHeaderMetadata.ts b/web/src/hooks/useSessionHeaderMetadata.ts new file mode 100644 index 00000000..15b8a47e --- /dev/null +++ b/web/src/hooks/useSessionHeaderMetadata.ts @@ -0,0 +1,103 @@ +import { useCallback, useEffect, useState } from 'react' + +export type SessionHeaderMetadataKey = + | 'showLabels' + | 'agent' + | 'model' + | 'reasoning' + | 'fastMode' + | 'machine' + | 'lastActive' + | 'createdAt' + | 'updatedAt' + | 'worktree' + +export type SessionHeaderMetadataPreferences = Record + +export const DEFAULT_SESSION_HEADER_METADATA: SessionHeaderMetadataPreferences = { + showLabels: true, + agent: true, + model: true, + reasoning: true, + fastMode: true, + machine: true, + lastActive: true, + createdAt: false, + updatedAt: false, + worktree: true, +} + +const STORAGE_KEY = 'hapi-session-header-metadata' + +function isBrowser(): boolean { + return typeof window !== 'undefined' && typeof document !== 'undefined' +} + +export function parseSessionHeaderMetadata(raw: string | null): SessionHeaderMetadataPreferences { + if (!raw) return DEFAULT_SESSION_HEADER_METADATA + + try { + const parsed: unknown = JSON.parse(raw) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return DEFAULT_SESSION_HEADER_METADATA + } + + const record = parsed as Record + return Object.fromEntries( + Object.entries(DEFAULT_SESSION_HEADER_METADATA).map(([key, fallback]) => [ + key, + typeof record[key] === 'boolean' ? record[key] : fallback, + ]) + ) as SessionHeaderMetadataPreferences + } catch { + return DEFAULT_SESSION_HEADER_METADATA + } +} + +function readPreferences(): SessionHeaderMetadataPreferences { + if (!isBrowser()) return DEFAULT_SESSION_HEADER_METADATA + try { + return parseSessionHeaderMetadata(localStorage.getItem(STORAGE_KEY)) + } catch { + return DEFAULT_SESSION_HEADER_METADATA + } +} + +function writePreferences(preferences: SessionHeaderMetadataPreferences): void { + if (!isBrowser()) return + try { + if (Object.entries(DEFAULT_SESSION_HEADER_METADATA).every(([key, value]) => preferences[key as SessionHeaderMetadataKey] === value)) { + localStorage.removeItem(STORAGE_KEY) + } else { + localStorage.setItem(STORAGE_KEY, JSON.stringify(preferences)) + } + } catch { + // Ignore storage errors. + } +} + +export function useSessionHeaderMetadata(): { + preferences: SessionHeaderMetadataPreferences + setPreference: (key: SessionHeaderMetadataKey, value: boolean) => void +} { + const [preferences, setPreferences] = useState(readPreferences) + + useEffect(() => { + if (!isBrowser()) return + const onStorage = (event: StorageEvent) => { + if (event.key === STORAGE_KEY) setPreferences(parseSessionHeaderMetadata(event.newValue)) + } + window.addEventListener('storage', onStorage) + return () => window.removeEventListener('storage', onStorage) + }, []) + + const setPreference = useCallback((key: SessionHeaderMetadataKey, value: boolean) => { + setPreferences((current) => { + const next = { ...current, [key]: value } + writePreferences(next) + return next + }) + }, []) + + return { preferences, setPreference } +} diff --git a/web/src/lib/codexStatusLabels.test.ts b/web/src/lib/codexStatusLabels.test.ts index c6d69f62..4358bdcf 100644 --- a/web/src/lib/codexStatusLabels.test.ts +++ b/web/src/lib/codexStatusLabels.test.ts @@ -18,6 +18,11 @@ describe('codexStatusLabels', () => { expect(formatCodexReasoningLabel('Ultra')).toBe('reasoning ultra') }) + it('can omit the reasoning field label', () => { + expect(formatCodexReasoningLabel('xhigh', false)).toBe('xhigh') + expect(formatCodexReasoningLabel(null, false)).toBe('default') + }) + it('formats compact effort-only labels', () => { expect(formatCompactCodexReasoningLabel(null)).toBe('default') expect(formatCompactCodexReasoningLabel('default')).toBe('default') diff --git a/web/src/lib/codexStatusLabels.ts b/web/src/lib/codexStatusLabels.ts index 02324289..4702cc89 100644 --- a/web/src/lib/codexStatusLabels.ts +++ b/web/src/lib/codexStatusLabels.ts @@ -6,8 +6,9 @@ export function formatCompactCodexReasoningLabel(effort?: string | null): string return normalized } -export function formatCodexReasoningLabel(effort?: string | null): string { - return `reasoning ${formatCompactCodexReasoningLabel(effort)}` +export function formatCodexReasoningLabel(effort?: string | null, showLabel = true): string { + const value = formatCompactCodexReasoningLabel(effort) + return showLabel ? `reasoning ${value}` : value } export function shouldShowCodexReasoningLabel(agentFlavor: string | null | undefined): boolean { diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 03b99c23..63afa0f2 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -689,6 +689,20 @@ export default { 'settings.display.sessionListStatus.standard': 'Standard', 'settings.display.sessionListStatus.detailed': 'Detailed', 'settings.display.sessionListStatus.detailedDescription': 'Shows why a session stopped: permission, input, background work, new activity, or a scheduled message (clock icon).', + 'settings.display.sessionHeader': 'Session header', + 'settings.display.sessionHeader.description': 'Choose which details appear below the session title. Mobile shows the agent plus the highest-priority available detail.', + 'settings.display.sessionHeader.showLabels': 'Show field labels', + 'settings.display.sessionHeader.agent': 'Agent', + 'settings.display.sessionHeader.model': 'Model', + 'settings.display.sessionHeader.reasoning': 'Reasoning effort', + 'settings.display.sessionHeader.fastMode': 'Fast mode', + 'settings.display.sessionHeader.machine': 'Machine', + 'settings.display.sessionHeader.lastActive': 'Active time', + 'settings.display.sessionHeader.createdAt': 'Created time', + 'settings.display.sessionHeader.updatedAt': 'Updated time', + 'settings.display.sessionHeader.worktree': 'Worktree branch', + 'session.header.createdAt': 'Created', + 'session.header.updatedAt': 'Updated', 'settings.chat.title': 'Chat', 'settings.chat.description': 'Message input, tool cards, and conversation colors.', 'settings.chat.input': 'Input', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 94a598b3..a3f1aa08 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -693,6 +693,20 @@ export default { 'settings.display.sessionListStatus.standard': '标准', 'settings.display.sessionListStatus.detailed': '详细', 'settings.display.sessionListStatus.detailedDescription': '显示会话停止的原因:权限、输入、后台任务、新活动或定时消息(时钟图标)。', + 'settings.display.sessionHeader': '会话顶部信息', + 'settings.display.sessionHeader.description': '选择在会话标题下方显示的信息。移动端显示 Agent 类型及优先级最高的一个可用信息。', + 'settings.display.sessionHeader.showLabels': '显示字段标题', + 'settings.display.sessionHeader.agent': 'Agent 类型', + 'settings.display.sessionHeader.model': '模型', + 'settings.display.sessionHeader.reasoning': '推理强度', + 'settings.display.sessionHeader.fastMode': '快速模式', + 'settings.display.sessionHeader.machine': '机器', + 'settings.display.sessionHeader.lastActive': '活跃时间', + 'settings.display.sessionHeader.createdAt': '创建时间', + 'settings.display.sessionHeader.updatedAt': '更新时间', + 'settings.display.sessionHeader.worktree': '工作树分支', + 'session.header.createdAt': '创建', + 'session.header.updatedAt': '更新', 'settings.chat.title': '聊天', 'settings.chat.description': '消息输入、工具卡片和对话颜色。', 'settings.chat.input': '输入', diff --git a/web/src/lib/sessionHeaderMobileMetadata.test.ts b/web/src/lib/sessionHeaderMobileMetadata.test.ts new file mode 100644 index 00000000..115bc3c2 --- /dev/null +++ b/web/src/lib/sessionHeaderMobileMetadata.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { selectMobileSessionHeaderSecondary } from './sessionHeaderMobileMetadata' + +describe('selectMobileSessionHeaderSecondary', () => { + it('follows the upstream-first display order', () => { + expect(selectMobileSessionHeaderSecondary({ machine: true, model: true, updatedAt: true })).toBe('machine') + expect(selectMobileSessionHeaderSecondary({ model: true, updatedAt: true, worktree: true })).toBe('model') + }) + + it('uses the next enabled and available detail when the model is absent', () => { + expect(selectMobileSessionHeaderSecondary({ machine: true, lastActive: true, updatedAt: true })).toBe('machine') + expect(selectMobileSessionHeaderSecondary({ lastActive: true, updatedAt: true })).toBe('lastActive') + expect(selectMobileSessionHeaderSecondary({ fastMode: true, updatedAt: true, createdAt: true, worktree: true })).toBe('fastMode') + expect(selectMobileSessionHeaderSecondary({ updatedAt: true, createdAt: true, worktree: true })).toBe('createdAt') + expect(selectMobileSessionHeaderSecondary({ worktree: true, fastMode: true })).toBe('fastMode') + }) + + it('returns no secondary detail when none are available', () => { + expect(selectMobileSessionHeaderSecondary({})).toBeNull() + }) +}) diff --git a/web/src/lib/sessionHeaderMobileMetadata.ts b/web/src/lib/sessionHeaderMobileMetadata.ts new file mode 100644 index 00000000..fa3ed553 --- /dev/null +++ b/web/src/lib/sessionHeaderMobileMetadata.ts @@ -0,0 +1,26 @@ +export type SessionHeaderSecondaryMetadataKey = + | 'model' + | 'reasoning' + | 'machine' + | 'lastActive' + | 'updatedAt' + | 'createdAt' + | 'worktree' + | 'fastMode' + +const MOBILE_SECONDARY_PRIORITY: ReadonlyArray = [ + 'machine', + 'lastActive', + 'model', + 'reasoning', + 'fastMode', + 'createdAt', + 'updatedAt', + 'worktree', +] + +export function selectMobileSessionHeaderSecondary( + available: Partial> +): SessionHeaderSecondaryMetadataKey | null { + return MOBILE_SECONDARY_PRIORITY.find((key) => available[key] === true) ?? null +} diff --git a/web/src/lib/sessionHeaderTimestamp.test.ts b/web/src/lib/sessionHeaderTimestamp.test.ts new file mode 100644 index 00000000..633a48a8 --- /dev/null +++ b/web/src/lib/sessionHeaderTimestamp.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { formatSessionHeaderTimestamp } from './sessionHeaderTimestamp' + +describe('formatSessionHeaderTimestamp', () => { + it('formats a session timestamp in the selected locale', () => { + const value = new Date(2026, 6, 26, 8, 53).getTime() + expect(formatSessionHeaderTimestamp(value, 'en-US')).toMatch(/Jul 26, 2026.*08:53 AM/) + expect(formatSessionHeaderTimestamp(value, 'zh-CN')).toContain('2026年7月26日') + }) + + it('rejects missing and invalid timestamps', () => { + expect(formatSessionHeaderTimestamp(0)).toBeNull() + expect(formatSessionHeaderTimestamp(Number.NaN)).toBeNull() + }) +}) diff --git a/web/src/lib/sessionHeaderTimestamp.ts b/web/src/lib/sessionHeaderTimestamp.ts new file mode 100644 index 00000000..23ffe226 --- /dev/null +++ b/web/src/lib/sessionHeaderTimestamp.ts @@ -0,0 +1,13 @@ +export function formatSessionHeaderTimestamp(value: number, locale?: string): string | null { + if (!Number.isFinite(value) || value <= 0) return null + const date = new Date(value) + if (Number.isNaN(date.getTime())) return null + + return new Intl.DateTimeFormat(locale, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }).format(date) +} diff --git a/web/src/routes/settings/display.tsx b/web/src/routes/settings/display.tsx index 1d91da35..c56d76e1 100644 --- a/web/src/routes/settings/display.tsx +++ b/web/src/routes/settings/display.tsx @@ -8,6 +8,7 @@ import { getSessionListStatusModeOptions, useSessionListStatusMode } from '@/hoo import { useShowActiveSessionsOnly } from '@/hooks/useShowActiveSessionsOnly' 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() { @@ -135,6 +136,19 @@ export default function SettingsDisplayPage() { const { terminalFontSize, setTerminalFontSize } = useTerminalFontSize() const { sessionListStatusMode, setSessionListStatusMode } = useSessionListStatusMode() const { showActiveSessionsOnly, setShowActiveSessionsOnly } = useShowActiveSessionsOnly() + 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 ( @@ -166,6 +180,17 @@ export default function SettingsDisplayPage() { onChange={setSessionListStatusMode} /> + + + {sessionHeaderOptions.map((option) => ( + setSessionHeaderMetadata(option.key, checked)} + /> + ))} + ) } diff --git a/web/src/routes/settings/index.test.tsx b/web/src/routes/settings/index.test.tsx index 40707100..9f867fed 100644 --- a/web/src/routes/settings/index.test.tsx +++ b/web/src/routes/settings/index.test.tsx @@ -76,6 +76,24 @@ vi.mock('@/hooks/useShowActiveSessionsOnly', () => ({ useShowActiveSessionsOnly: () => ({ showActiveSessionsOnly: false, setShowActiveSessionsOnly: vi.fn() }), })) +vi.mock('@/hooks/useSessionHeaderMetadata', () => ({ + useSessionHeaderMetadata: () => ({ + preferences: { + showLabels: true, + agent: true, + model: true, + reasoning: true, + fastMode: true, + machine: true, + lastActive: true, + createdAt: false, + updatedAt: false, + worktree: true, + }, + setPreference: vi.fn(), + }), +})) + vi.mock('@/hooks/useSessionPreviewLimit', () => ({ MIN_SESSION_PREVIEW_LIMIT: 1, MAX_SESSION_PREVIEW_LIMIT: 99, @@ -210,6 +228,12 @@ describe('responsive settings pages', () => { expect(setColorTheme).toHaveBeenCalledWith('nord') expect(screen.getByRole('radio', { name: '120%' })).toBeInTheDocument() expect(screen.getByRole('spinbutton', { name: 'Sessions Before Folding' })).toHaveValue(8) + expect(screen.getByRole('checkbox', { name: 'Show field labels' })).toBeChecked() + expect(screen.getByRole('checkbox', { name: 'Reasoning effort' })).toBeChecked() + expect(screen.getByRole('checkbox', { name: 'Machine' })).toBeChecked() + expect(screen.getByRole('checkbox', { name: 'Active time' })).toBeChecked() + expect(screen.getByRole('checkbox', { name: 'Created time' })).not.toBeChecked() + expect(screen.getByRole('checkbox', { name: 'Updated time' })).not.toBeChecked() expect(screen.queryByRole('listbox')).not.toBeInTheDocument() })