mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
* feat(web): rich hover tooltips on session-list attention indicators
The session-row attention dots and the future-scheduled clock icon used
plain `title=""` attributes which gave only a one-word label ("Permission
required"). Replace those with hover/focus-revealed tooltips that name
*which* tools are blocking, count background tasks, surface the
"updated Nm ago" timestamp, and explain the pending schedule.
To make per-tool copy possible without an extra round trip,
`SessionSummary` now carries a structured slice of the pending tool
requests, capped at `PENDING_REQUEST_SUMMARY_CAP = 5` oldest-first:
pendingRequests: Array<{ id; kind; tool; since }>
`pendingRequestsCount` remains the authoritative total;
`pendingRequestKinds` is still derived from the FULL request set so a
single `'input'` request beyond the cap still surfaces its kind on the
session row.
The tooltip primitive (`HoverTooltip`) is a CSS-driven reveal — no
portal, no positioning JS — so it composes cheaply inside the existing
session-row `<button>` and stays out of the way on touch devices, which
keep getting the same `aria-label` the old `title=""` attribute provided
to screen readers.
Test coverage: shared derivation + cap + tie-break + full-set kind
behaviour; web tooltip render across all four attention kinds plus
mixed-kind overflow suppression and aria-label exposure.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(web): opaque tooltip surface; drop redundant 'updated Nm ago' body
Two operator-feedback fixes on the new session-list HoverTooltip:
1. Tooltip background was bg-[var(--app-bg)] - the same variable as the
session row underneath - so the tooltip looked translucent and the row
text bled through. Switch to bg-[var(--app-secondary-bg)] (#2C2C2E
dark / #f3f4f6 light, both opaque) and bump shadow-md -> shadow-lg.
Telegram-themed clients still pick up tg-theme-secondary-bg-color so
the tooltip stays on-theme.
2. The 'unread' attention dot tooltip rendered 'New activity / Updated 5m
ago', but the relative-time pill ('5m ago') is already on the right
edge of the same session row. The tooltip body just duplicated info.
Render only the title for the unread case; drop the
session.tooltip.unread.body i18n key from en + zh-CN.
The other tooltip kinds (permission/input list tools, background lists
task count) keep their bodies - those facts are not visible elsewhere on
the row.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(web,hub): show scheduled fire time in session-list clock tooltip
The schedule clock tooltip previously said only "Will fire when due."
while the row already showed a relative updated-at pill. Extend the
session-list API with nextScheduledAt (MIN future scheduled_at per
session, same filter as futureScheduledMessageCount) and render:
- single scheduled: "Fires in 5m · Jun 16, 1:45 PM"
- multiple: "Next in 5m · Jun 16, 1:45 PM · +2 more"
Extract formatScheduledTime from QueuedMessagesBar into web/lib/
scheduledTime.ts alongside formatFutureRelativeTime and the tooltip
composer. SSE upsert preserves nextScheduledAt until the list refetch
that already runs on schedule-related events.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): wire session-row keyboard focus to HoverTooltip a11y
Address PR #941 Major review: aria-describedby and tooltip visibility
were on a non-focusable inner span, so keyboard users tabbing the session
row button never received the rich tooltip description and
group-focus-within never matched.
- Session row button owns aria-describedby (attention + schedule ids)
- Add group/session-row + SESSION_ROW_TOOLTIP_FOCUS_CLASS reveal on
:focus-visible
- HoverTooltip takes required id; drop inner aria-label/describedby
- useSessionRowTooltipIds helper composes stable row tooltip ids
- Tests for id wiring and parent-focus reveal classes
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
330 lines
12 KiB
TypeScript
330 lines
12 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import type { SessionSummary } from '@/types/api'
|
|
import {
|
|
deduplicateSessionsByAgentId,
|
|
expandSelectedSessionCollapseOverrides,
|
|
getSessionDedupKey,
|
|
getVisibleSessionPreview,
|
|
isSidebarEmptySessionStub,
|
|
normalizeSearch,
|
|
prepareSidebarSessions,
|
|
sessionMatchesQuery,
|
|
shouldShowSessionInSidebar
|
|
} from './SessionList'
|
|
|
|
function makeSession(overrides: Partial<SessionSummary> & { id: string }): SessionSummary {
|
|
return {
|
|
active: false,
|
|
thinking: false,
|
|
activeAt: 0,
|
|
updatedAt: 0,
|
|
metadata: null,
|
|
todoProgress: null,
|
|
pendingRequestsCount: 0,
|
|
pendingRequestKinds: [],
|
|
pendingRequests: [],
|
|
backgroundTaskCount: 0,
|
|
futureScheduledMessageCount: 0,
|
|
nextScheduledAt: null,
|
|
model: null,
|
|
effort: null,
|
|
...overrides
|
|
}
|
|
}
|
|
|
|
describe('deduplicateSessionsByAgentId', () => {
|
|
it('deduplicates sessions with the same agentSessionId', () => {
|
|
const sessions = [
|
|
makeSession({ id: 'a', metadata: { path: '/p', agentSessionId: 'thread-1' }, updatedAt: 100 }),
|
|
makeSession({ id: 'b', metadata: { path: '/p', agentSessionId: 'thread-1' }, updatedAt: 200 })
|
|
]
|
|
const result = deduplicateSessionsByAgentId(sessions)
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].id).toBe('b') // more recent wins
|
|
})
|
|
|
|
it('keeps active session over inactive duplicate', () => {
|
|
const sessions = [
|
|
makeSession({ id: 'a', active: true, metadata: { path: '/p', agentSessionId: 'thread-1' }, updatedAt: 100 }),
|
|
makeSession({ id: 'b', metadata: { path: '/p', agentSessionId: 'thread-1' }, updatedAt: 200 })
|
|
]
|
|
const result = deduplicateSessionsByAgentId(sessions)
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].id).toBe('a') // active wins despite older updatedAt
|
|
})
|
|
|
|
it('prefers selected session among inactive duplicates', () => {
|
|
const sessions = [
|
|
makeSession({ id: 'a', metadata: { path: '/p', agentSessionId: 'thread-1' }, updatedAt: 100 }),
|
|
makeSession({ id: 'b', metadata: { path: '/p', agentSessionId: 'thread-1' }, updatedAt: 200 })
|
|
]
|
|
const result = deduplicateSessionsByAgentId(sessions, 'a')
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].id).toBe('a') // selected wins despite older updatedAt
|
|
})
|
|
|
|
it('active always wins over selected inactive', () => {
|
|
const sessions = [
|
|
makeSession({ id: 'a', metadata: { path: '/p', agentSessionId: 'thread-1' }, updatedAt: 200 }),
|
|
makeSession({ id: 'b', active: true, metadata: { path: '/p', agentSessionId: 'thread-1' }, updatedAt: 100 })
|
|
]
|
|
const result = deduplicateSessionsByAgentId(sessions, 'a')
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].id).toBe('b') // active wins over selected
|
|
})
|
|
|
|
it('passes through sessions without agentSessionId', () => {
|
|
const sessions = [
|
|
makeSession({ id: 'a', metadata: { path: '/p' } }),
|
|
makeSession({ id: 'b', metadata: { path: '/p', agentSessionId: 'thread-1' } }),
|
|
makeSession({ id: 'c', metadata: null })
|
|
]
|
|
const result = deduplicateSessionsByAgentId(sessions)
|
|
expect(result).toHaveLength(3)
|
|
})
|
|
|
|
it('deduplicates cursor sessions by summary agentSessionId', () => {
|
|
const sessions = [
|
|
makeSession({
|
|
id: 'a',
|
|
active: true,
|
|
metadata: { path: '/p', flavor: 'cursor', agentSessionId: 'acp-thread-1' },
|
|
updatedAt: 100
|
|
}),
|
|
makeSession({
|
|
id: 'b',
|
|
metadata: { path: '/p', flavor: 'cursor', agentSessionId: 'acp-thread-1' },
|
|
updatedAt: 200
|
|
})
|
|
]
|
|
const result = deduplicateSessionsByAgentId(sessions)
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].id).toBe('a')
|
|
})
|
|
|
|
it('deduplicates independently across different agentSessionIds', () => {
|
|
const sessions = [
|
|
makeSession({ id: 'a', metadata: { path: '/p', agentSessionId: 'thread-1' }, updatedAt: 100 }),
|
|
makeSession({ id: 'b', metadata: { path: '/p', agentSessionId: 'thread-1' }, updatedAt: 200 }),
|
|
makeSession({ id: 'c', metadata: { path: '/p', agentSessionId: 'thread-2' }, updatedAt: 100 }),
|
|
makeSession({ id: 'd', metadata: { path: '/p', agentSessionId: 'thread-2' }, updatedAt: 200 })
|
|
]
|
|
const result = deduplicateSessionsByAgentId(sessions)
|
|
expect(result).toHaveLength(2)
|
|
expect(result.map(s => s.id).sort()).toEqual(['b', 'd'])
|
|
})
|
|
|
|
it('does not dedupe across flavors sharing the same flattened agentSessionId', () => {
|
|
const sessions = [
|
|
makeSession({
|
|
id: 'codex',
|
|
metadata: { path: '/p', flavor: 'codex', agentSessionId: 'stale-shared-id' },
|
|
updatedAt: 100
|
|
}),
|
|
makeSession({
|
|
id: 'cursor',
|
|
metadata: { path: '/p', flavor: 'cursor', agentSessionId: 'stale-shared-id' },
|
|
updatedAt: 200
|
|
})
|
|
]
|
|
|
|
expect(getSessionDedupKey(sessions[0])).toBe('codex:stale-shared-id')
|
|
expect(getSessionDedupKey(sessions[1])).toBe('cursor:stale-shared-id')
|
|
expect(deduplicateSessionsByAgentId(sessions).map(session => session.id).sort()).toEqual(['codex', 'cursor'])
|
|
expect(prepareSidebarSessions(sessions).map(session => session.id).sort()).toEqual(['codex', 'cursor'])
|
|
})
|
|
})
|
|
|
|
|
|
describe('isSidebarEmptySessionStub', () => {
|
|
it('treats inactive sessions without agent id or title as stubs', () => {
|
|
expect(isSidebarEmptySessionStub(makeSession({
|
|
id: 'stub',
|
|
metadata: { path: '/work/hapi' }
|
|
}))).toBe(true)
|
|
})
|
|
|
|
it('does not treat active sessions as stubs', () => {
|
|
expect(isSidebarEmptySessionStub(makeSession({
|
|
id: 'live',
|
|
active: true,
|
|
metadata: { path: '/work/hapi' }
|
|
}))).toBe(false)
|
|
})
|
|
|
|
it('does not treat sessions with agentSessionId as stubs', () => {
|
|
expect(isSidebarEmptySessionStub(makeSession({
|
|
id: 'resume',
|
|
metadata: { path: '/work/hapi', agentSessionId: 'thread-1' }
|
|
}))).toBe(false)
|
|
})
|
|
|
|
it('does not treat sessions with summary text as stubs', () => {
|
|
expect(isSidebarEmptySessionStub(makeSession({
|
|
id: 'titled',
|
|
metadata: { path: '/work/hapi', summary: { text: 'Fix sidebar' } }
|
|
}))).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('prepareSidebarSessions', () => {
|
|
it('hides inactive empty stubs but keeps real sessions', () => {
|
|
const sessions = [
|
|
makeSession({ id: 'stub', metadata: { path: '/work/hapi' } }),
|
|
makeSession({
|
|
id: 'real',
|
|
metadata: { path: '/work/hapi', agentSessionId: 'thread-1', summary: { text: 'Real chat' } }
|
|
})
|
|
]
|
|
|
|
const result = prepareSidebarSessions(sessions)
|
|
expect(result.map(session => session.id)).toEqual(['real'])
|
|
})
|
|
|
|
it('keeps the selected inactive stub visible', () => {
|
|
const sessions = [
|
|
makeSession({ id: 'stub', metadata: { path: '/work/hapi' } }),
|
|
makeSession({
|
|
id: 'real',
|
|
metadata: { path: '/work/hapi', agentSessionId: 'thread-1' }
|
|
})
|
|
]
|
|
|
|
const result = prepareSidebarSessions(sessions, 'stub')
|
|
expect(result.map(session => session.id).sort()).toEqual(['real', 'stub'])
|
|
})
|
|
|
|
it('deduplicates before filtering stubs', () => {
|
|
const sessions = [
|
|
makeSession({ id: 'stub', metadata: { path: '/work/hapi' } }),
|
|
makeSession({
|
|
id: 'older',
|
|
metadata: { path: '/work/hapi', agentSessionId: 'thread-1' },
|
|
updatedAt: 100
|
|
}),
|
|
makeSession({
|
|
id: 'newer',
|
|
metadata: { path: '/work/hapi', agentSessionId: 'thread-1' },
|
|
updatedAt: 200
|
|
})
|
|
]
|
|
|
|
const result = prepareSidebarSessions(sessions)
|
|
expect(result.map(session => session.id)).toEqual(['newer'])
|
|
})
|
|
})
|
|
|
|
describe('shouldShowSessionInSidebar', () => {
|
|
it('always shows active and selected sessions', () => {
|
|
const stub = makeSession({ id: 'stub', metadata: { path: '/work/hapi' } })
|
|
expect(shouldShowSessionInSidebar(stub)).toBe(false)
|
|
expect(shouldShowSessionInSidebar(stub, 'stub')).toBe(true)
|
|
expect(shouldShowSessionInSidebar({ ...stub, active: true })).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('session list search helpers', () => {
|
|
it('normalizes whitespace and case before filtering', () => {
|
|
const session = makeSession({
|
|
id: 'session-1',
|
|
metadata: {
|
|
path: '/work/hapi',
|
|
name: 'Fix Bot Review',
|
|
flavor: 'codex',
|
|
machineId: 'machine-1'
|
|
}
|
|
})
|
|
|
|
expect(normalizeSearch(' BOT ')).toBe('bot')
|
|
expect(sessionMatchesQuery(session, normalizeSearch('bot review'), 'desktop')).toBe(true)
|
|
expect(sessionMatchesQuery(session, normalizeSearch('desktop'), 'desktop')).toBe(true)
|
|
expect(sessionMatchesQuery(session, normalizeSearch('missing'), 'desktop')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('getVisibleSessionPreview', () => {
|
|
it('keeps selected and pending sessions inside the collapsed preview without promoting them', () => {
|
|
const sessions = Array.from({ length: 6 }, (_, index) => makeSession({
|
|
id: `s-${index + 1}`,
|
|
pendingRequestsCount: index === 4 ? 1 : 0,
|
|
metadata: { path: '/work/hapi' },
|
|
updatedAt: 100 - index
|
|
}))
|
|
|
|
const preview = getVisibleSessionPreview(sessions, {
|
|
selectedSessionId: 's-6',
|
|
limit: 3
|
|
})
|
|
|
|
expect(preview.map(session => session.id)).toEqual(['s-1', 's-5', 's-6'])
|
|
})
|
|
|
|
it('does not exceed the limit just because many sessions are active', () => {
|
|
const sessions = Array.from({ length: 6 }, (_, index) => makeSession({
|
|
id: `s-${index + 1}`,
|
|
active: true,
|
|
metadata: { path: '/work/hapi' },
|
|
updatedAt: 100 - index
|
|
}))
|
|
|
|
const preview = getVisibleSessionPreview(sessions, { limit: 4 })
|
|
|
|
expect(preview.map(session => session.id)).toEqual(['s-1', 's-2', 's-3', 's-4'])
|
|
})
|
|
|
|
it('does not move an already-visible selected session to the top', () => {
|
|
const sessions = Array.from({ length: 6 }, (_, index) => makeSession({
|
|
id: `s-${index + 1}`,
|
|
metadata: { path: '/work/hapi' },
|
|
updatedAt: 100 - index
|
|
}))
|
|
|
|
const preview = getVisibleSessionPreview(sessions, {
|
|
selectedSessionId: 's-3',
|
|
limit: 4
|
|
})
|
|
|
|
expect(preview.map(session => session.id)).toEqual(['s-1', 's-2', 's-3', 's-4'])
|
|
})
|
|
|
|
it('returns all sessions when expanded', () => {
|
|
const sessions = Array.from({ length: 4 }, (_, index) => makeSession({
|
|
id: `s-${index + 1}`,
|
|
metadata: { path: '/work/hapi' }
|
|
}))
|
|
|
|
expect(getVisibleSessionPreview(sessions, { expanded: true, limit: 2 })).toHaveLength(4)
|
|
})
|
|
})
|
|
|
|
|
|
describe('expandSelectedSessionCollapseOverrides', () => {
|
|
it('expands collapsed project and machine, but preserves session preview folding', () => {
|
|
const overrides = new Map<string, boolean>([
|
|
['machine-1::/work/hapi', true],
|
|
['sessions::machine-1::/work/hapi', true],
|
|
['machine::machine-1', true]
|
|
])
|
|
|
|
const result = expandSelectedSessionCollapseOverrides(overrides, {
|
|
key: 'machine-1::/work/hapi',
|
|
machineId: 'machine-1'
|
|
})
|
|
|
|
expect(result.has('machine-1::/work/hapi')).toBe(false)
|
|
expect(result.get('sessions::machine-1::/work/hapi')).toBe(true)
|
|
expect(result.has('machine::machine-1')).toBe(false)
|
|
})
|
|
|
|
it('leaves missing session preview override unset', () => {
|
|
const overrides = new Map<string, boolean>()
|
|
|
|
const result = expandSelectedSessionCollapseOverrides(overrides, {
|
|
key: 'machine-1::/work/hapi',
|
|
machineId: 'machine-1'
|
|
})
|
|
|
|
expect(result.has('sessions::machine-1::/work/hapi')).toBe(false)
|
|
})
|
|
})
|