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()
})