diff --git a/web/src/components/SessionHeader.test.tsx b/web/src/components/SessionHeader.test.tsx index 452019b8..80460dae 100644 --- a/web/src/components/SessionHeader.test.tsx +++ b/web/src/components/SessionHeader.test.tsx @@ -1,50 +1,121 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { cleanup, render, screen } from '@testing-library/react' +import { act, cleanup, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import type { Session } from '@/types/api' import { I18nProvider } from '@/lib/i18n-context' import { ToastProvider } from '@/lib/toast-context' -import { SessionHeader } from './SessionHeader' +import { resolveSessionHeaderMachineLabel, SessionHeader } from './SessionHeader' afterEach(() => cleanup()) -describe('SessionHeader', () => { - it('shows an inherited catalog-default Fast tier', () => { - const session: Session = { - id: 'session-1', - namespace: 'default', - seq: 0, - createdAt: 0, - updatedAt: 0, - active: true, - activeAt: 0, - metadata: { flavor: 'codex', path: '/repo', host: 'machine' }, - metadataVersion: 0, - agentState: null, - agentStateVersion: 0, - thinking: false, - thinkingAt: 0, - model: null, - modelReasoningEffort: null, - effort: null, - serviceTier: null - } +function baseSession(overrides: Partial = {}): Session { + return { + id: 'session-1', + namespace: 'default', + seq: 0, + createdAt: 0, + updatedAt: 0, + active: true, + activeAt: 0, + metadata: { flavor: 'codex', path: '/repo', host: 'machine' }, + metadataVersion: 0, + agentState: null, + agentStateVersion: 0, + thinking: false, + thinkingAt: 0, + model: null, + modelReasoningEffort: null, + effort: null, + serviceTier: null, + ...overrides + } +} - render( - - - - - - - - ) +function renderHeader(session: Session, extra?: { serviceTier?: string | null }) { + return render( + + + + + + + + ) +} +describe('resolveSessionHeaderMachineLabel', () => { + it('prefers cached/display labels, then host, then short machine id', () => { + expect(resolveSessionHeaderMachineLabel( + baseSession({ metadata: { flavor: 'cursor', path: '/r', host: 'host.local', machineId: 'abc123456789' } }), + { abc123456789: 'Workstation' } + )).toBe('Workstation') + + expect(resolveSessionHeaderMachineLabel( + baseSession({ metadata: { flavor: 'cursor', path: '/r', host: 'host.local', machineId: 'abc123456789' } }), + {} + )).toBe('host.local') + + expect(resolveSessionHeaderMachineLabel( + baseSession({ metadata: { flavor: 'cursor', path: '/r', host: '', machineId: 'abc123456789' } }), + {} + )).toBe('abc12345') + + expect(resolveSessionHeaderMachineLabel( + baseSession({ metadata: { flavor: 'cursor', path: '/r', host: '' } }), + {} + )).toBeNull() + }) +}) + +describe('SessionHeader', () => { + it('shows an inherited catalog-default Fast tier', () => { + renderHeader(baseSession(), { serviceTier: 'priority' }) expect(screen.getByText('fast')).toBeInTheDocument() + }) + + it('shows machine label and relative last-active age in the meta row', () => { + const fiveMinutesAgo = Date.now() - 5 * 60_000 + renderHeader(baseSession({ + activeAt: fiveMinutesAgo, + updatedAt: fiveMinutesAgo, + metadata: { + flavor: 'cursor', + path: '/home/heavygee/coding/hapi', + host: 'oos-linux', + machineId: 'machine-deadbeef' + } + })) + + expect(screen.getByTestId('session-header-machine')).toHaveTextContent(/oos-linux/) + expect(screen.getByTestId('session-header-age')).toHaveTextContent(/5m ago|5分钟前/) + }) + + it('advances relative age on the minute tick without a session prop change', () => { + vi.useFakeTimers() + const now = new Date('2026-07-29T16:00:00.000Z') + vi.setSystemTime(now) + + try { + renderHeader(baseSession({ + activeAt: now.getTime() - 30_000, + updatedAt: now.getTime() - 30_000, + metadata: { flavor: 'cursor', path: '/r', host: 'host.local' } + })) + + expect(screen.getByTestId('session-header-age')).toHaveTextContent(/just now|刚刚/) + + act(() => { + vi.advanceTimersByTime(60_000) + }) + + expect(screen.getByTestId('session-header-age')).toHaveTextContent(/1m ago|1分钟前/) + } finally { + vi.useRealTimers() + } }) }) diff --git a/web/src/components/SessionHeader.tsx b/web/src/components/SessionHeader.tsx index 453e958d..7f5adb10 100644 --- a/web/src/components/SessionHeader.tsx +++ b/web/src/components/SessionHeader.tsx @@ -1,4 +1,4 @@ -import { useId, useMemo, useRef, useState } from 'react' +import { useEffect, useId, useMemo, useRef, useState } from 'react' import { useQueryClient } from '@tanstack/react-query' import type { Session } from '@/types/api' import type { ApiClient } from '@/api/client' @@ -19,6 +19,28 @@ import { getSessionTitle } from '@/lib/sessionTitle' import { useToast } from '@/lib/toast-context' import { queryKeys } from '@/lib/query-keys' import { markCodexSessionsImported } from '@/lib/codexImportedSessions' +import { useMachines } from '@/hooks/queries/useMachines' +import { useMachineLabels } from '@/hooks/useMachineLabels' +import { formatAbsoluteDateTime, formatRelativeTime } from '@/lib/relativeTime' + +/** Same preference order as session-list chips: display label → host → short id. */ +export function resolveSessionHeaderMachineLabel( + session: Session, + labelsById: Record +): string | null { + const machineId = session.metadata?.machineId?.trim() || null + if (machineId && labelsById[machineId]) { + return labelsById[machineId] + } + const host = session.metadata?.host?.trim() + if (host) { + return host + } + if (machineId) { + return machineId.slice(0, 8) + } + return null +} function FilesIcon(props: { className?: string }) { return ( @@ -119,6 +141,27 @@ export function SessionHeader(props: { const codexSessionId = session.metadata?.flavor === 'codex' ? session.metadata.codexSessionId?.trim() || null : null + const { machines } = useMachines(api, Boolean(api)) + const machineLabelsById = useMachineLabels(machines) + const machineLabel = useMemo( + () => resolveSessionHeaderMachineLabel(session, machineLabelsById), + [session, machineLabelsById] + ) + const lastActiveAt = session.activeAt || session.updatedAt || session.createdAt + // Relative labels cross minute/hour boundaries without new patches; tick + // once a minute so "just now" does not freeze forever on inactive sessions. + const [relativeTimeTick, setRelativeTimeTick] = useState(0) + useEffect(() => { + const timer = window.setInterval(() => { + setRelativeTimeTick((tick) => tick + 1) + }, 60_000) + return () => window.clearInterval(timer) + }, []) + const ageLabel = useMemo( + () => (lastActiveAt > 0 ? formatRelativeTime(lastActiveAt, t) : null), + [lastActiveAt, t, relativeTimeTick] + ) + const ageAbsolute = lastActiveAt > 0 ? formatAbsoluteDateTime(lastActiveAt) : null const [menuOpen, setMenuOpen] = useState(false) const [menuAnchorPoint, setMenuAnchorPoint] = useState<{ x: number; y: number }>({ x: 0, y: 0 }) @@ -248,6 +291,16 @@ export function SessionHeader(props: { {session.metadata?.flavor?.trim() || 'unknown'} + {machineLabel ? ( + + {t('session.item.machine')}: {machineLabel} + + ) : null} + {ageLabel ? ( + + {ageLabel} + + ) : null} {modelLabel ? ( {t(modelLabel.key)}: {modelLabel.value} diff --git a/web/src/hooks/useSSE.test.ts b/web/src/hooks/useSSE.test.ts index 80f44660..f2cf4fcb 100644 --- a/web/src/hooks/useSSE.test.ts +++ b/web/src/hooks/useSSE.test.ts @@ -85,7 +85,9 @@ describe('isRenderIrrelevantSessionPatch', () => { serviceTier: null } as unknown as Session - it('treats a keep-alive that only moves activeAt as irrelevant', () => { + it('treats a sub-minute activeAt keep-alive as irrelevant', () => { + // Relative age stays in the `just now` bucket until 60s; accepting + // every ~10s heartbeat would thrash the full chat tree for no visible change. expect(isRenderIrrelevantSessionPatch(session, { active: true, thinking: false, @@ -97,6 +99,20 @@ describe('isRenderIrrelevantSessionPatch', () => { })).toBe(true) }) + it('treats an activeAt move of at least one minute as render-relevant', () => { + // Live sessions need the cached stamp to advance so the header does + // not flip from `just now` to `1m ago` while keep-alives continue. + expect(isRenderIrrelevantSessionPatch(session, { + active: true, + thinking: false, + activeAt: 1_000 + 60_000, + model: 'opus', + effort: null, + permissionMode: 'default', + serviceTier: null + })).toBe(false) + }) + it('reports a changed field as relevant even alongside a new activeAt', () => { expect(isRenderIrrelevantSessionPatch(session, { thinking: true, diff --git a/web/src/hooks/useSSE.ts b/web/src/hooks/useSSE.ts index b86eb22f..82e662ae 100644 --- a/web/src/hooks/useSSE.ts +++ b/web/src/hooks/useSSE.ts @@ -58,15 +58,24 @@ function sortSessionSummaries(left: SessionSummary, right: SessionSummary): numb /** * True when applying `patch` to `session` would change nothing that renders. * - * Same reasoning as {@link isRenderIrrelevantPatch}, for the session-detail - * cache: the keep-alive patch repeats every field it knows about, so compare - * each one against the value already stored and ignore `activeAt`, which has - * no reader. + * Keep-alive patches re-send fields about every ~10s. SessionHeader reads + * `activeAt` for relative age, but `formatRelativeTime` only changes at + * minute boundaries (`just now` while delta < 60s). Sub-minute `activeAt` + * moves are therefore skipped here so the detail cache does not replace the + * Session object (and re-render SessionChat / HappyThread) six times a + * minute for an invisible label change. A delta of ≥60s is still + * render-relevant so the header stays on `just now` for live sessions. + * The session-list path still uses {@link isRenderIrrelevantPatch}, which + * ignores `activeAt` entirely. */ export function isRenderIrrelevantSessionPatch(session: Session, patch: SessionPatch): boolean { const current = session as unknown as Record for (const [key, value] of Object.entries(patch)) { - if (key === 'activeAt') { + if ( + key === 'activeAt' + && typeof value === 'number' + && Math.abs(value - session.activeAt) < 60_000 + ) { continue } if (current[key] !== value) { diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index e8fcdccf..a8a5f224 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -128,6 +128,7 @@ export default { 'session.item.path': 'path', 'session.item.agent': 'agent', 'session.item.model': 'model', + 'session.item.machine': 'machine', 'session.item.worktree': 'worktree', 'session.item.pending': 'pending', 'session.item.thinking': 'thinking', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 3ca558ef..2b4e28a7 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -128,6 +128,7 @@ export default { 'session.item.path': '路径', 'session.item.agent': '代理', 'session.item.model': '模型', + 'session.item.machine': '机器', 'session.item.worktree': '工作树', 'session.item.pending': '待处理', 'session.item.thinking': '思考中',