feat(web): show machine + last-active in session detail header (#1244)

* feat(web): show machine and last-active in session header

Multi-machine estates lose the machine signal after leaving list filter
chips; surface machine label + relative age in SessionHeader meta row.
Fixes #1241.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): keep session-header age fresh under keep-alive

Treat detail-cache activeAt keep-alives as render-relevant now that the
header reads them, and tick relative age every minute so labels advance
without a session prop change.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): skip sub-minute activeAt keep-alives in detail cache

Relative age only changes at 60s boundaries; accepting every ~10s
heartbeat replaced the Session object and re-rendered the chat tree
for no visible header change.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore: retrigger Codex PR review after stream disconnect

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
HeavyGee
2026-07-30 23:18:15 +08:00
committed by GitHub
co-authored by Cursor
parent 36eedc8701
commit f8934d81ee
6 changed files with 195 additions and 44 deletions
+108 -37
View File
@@ -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> = {}): 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(
<QueryClientProvider client={new QueryClient()}>
<ToastProvider>
<I18nProvider>
<SessionHeader
session={session}
serviceTier="priority"
onBack={vi.fn()}
api={null}
/>
</I18nProvider>
</ToastProvider>
</QueryClientProvider>
)
function renderHeader(session: Session, extra?: { serviceTier?: string | null }) {
return render(
<QueryClientProvider client={new QueryClient()}>
<ToastProvider>
<I18nProvider>
<SessionHeader
session={session}
serviceTier={extra?.serviceTier}
onBack={vi.fn()}
api={null}
/>
</I18nProvider>
</ToastProvider>
</QueryClientProvider>
)
}
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()
}
})
})
+54 -1
View File
@@ -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, string>
): 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: {
<AgentFlavorIcon flavor={session.metadata?.flavor} className="h-3.5 w-3.5 shrink-0 -translate-y-px" />
{session.metadata?.flavor?.trim() || 'unknown'}
</span>
{machineLabel ? (
<span data-testid="session-header-machine" className="truncate max-w-[12rem]" title={machineLabel}>
{t('session.item.machine')}: {machineLabel}
</span>
) : null}
{ageLabel ? (
<span data-testid="session-header-age" title={ageAbsolute ?? undefined}>
{ageLabel}
</span>
) : null}
{modelLabel ? (
<span>
{t(modelLabel.key)}: {modelLabel.value}
+17 -1
View File
@@ -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,
+14 -5
View File
@@ -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<string, unknown>
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) {
+1
View File
@@ -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',
+1
View File
@@ -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': '思考中',