mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '思考中',
|
||||
|
||||
Reference in New Issue
Block a user