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>
193 lines
6.6 KiB
TypeScript
193 lines
6.6 KiB
TypeScript
import { describe, expect, it } from 'bun:test'
|
|
import type { Session } from './schemas'
|
|
import {
|
|
PENDING_REQUEST_SUMMARY_CAP,
|
|
getPendingRequestKinds,
|
|
getPendingRequests,
|
|
toSessionSummary
|
|
} from './sessionSummary'
|
|
|
|
function makeSession(overrides: Partial<Session> = {}): Session {
|
|
return {
|
|
id: 'session-1',
|
|
namespace: 'default',
|
|
active: true,
|
|
activeAt: 1000,
|
|
updatedAt: 2000,
|
|
metadata: { path: '/proj', host: 'local' },
|
|
metadataVersion: 1,
|
|
agentState: null,
|
|
agentStateVersion: 0,
|
|
thinking: false,
|
|
thinkingAt: 0,
|
|
model: null,
|
|
modelReasoningEffort: null,
|
|
effort: null,
|
|
serviceTier: null,
|
|
...overrides
|
|
}
|
|
}
|
|
|
|
describe('getPendingRequestKinds', () => {
|
|
it('classifies ask-user tools as input', () => {
|
|
const kinds = getPendingRequestKinds(makeSession({
|
|
agentState: {
|
|
requests: {
|
|
req1: { tool: 'AskUserQuestion', arguments: {} }
|
|
}
|
|
}
|
|
}))
|
|
expect(kinds).toEqual(['input'])
|
|
})
|
|
|
|
it('classifies other pending tools as permission', () => {
|
|
const kinds = getPendingRequestKinds(makeSession({
|
|
agentState: {
|
|
requests: {
|
|
req1: { tool: 'Bash', arguments: {} }
|
|
}
|
|
}
|
|
}))
|
|
expect(kinds).toEqual(['permission'])
|
|
})
|
|
|
|
it('returns both kinds when mixed requests are pending', () => {
|
|
const kinds = getPendingRequestKinds(makeSession({
|
|
agentState: {
|
|
requests: {
|
|
req1: { tool: 'Bash', arguments: {} },
|
|
req2: { tool: 'ask_user_question', arguments: {} }
|
|
}
|
|
}
|
|
}))
|
|
expect(kinds).toEqual(['permission', 'input'])
|
|
})
|
|
})
|
|
|
|
describe('toSessionSummary', () => {
|
|
it('includes pending request kinds and background task count', () => {
|
|
const summary = toSessionSummary(makeSession({
|
|
backgroundTaskCount: 2,
|
|
agentState: {
|
|
requests: {
|
|
req1: { tool: 'ExitPlanMode', arguments: {} }
|
|
}
|
|
}
|
|
}))
|
|
|
|
expect(summary.pendingRequestKinds).toEqual(['input'])
|
|
expect(summary.pendingRequestsCount).toBe(1)
|
|
expect(summary.backgroundTaskCount).toBe(2)
|
|
expect(summary.futureScheduledMessageCount).toBe(0)
|
|
})
|
|
|
|
it('includes lifecycleState in summary metadata', () => {
|
|
const summary = toSessionSummary(makeSession({
|
|
metadata: {
|
|
path: '/proj',
|
|
host: 'local',
|
|
lifecycleState: 'archived'
|
|
}
|
|
}))
|
|
|
|
expect(summary.metadata?.lifecycleState).toBe('archived')
|
|
})
|
|
|
|
it('includes structured pendingRequests for hover-tooltip copy', () => {
|
|
const summary = toSessionSummary(makeSession({
|
|
updatedAt: 5000,
|
|
agentState: {
|
|
requests: {
|
|
req1: { tool: 'Bash', arguments: {}, createdAt: 100 },
|
|
req2: { tool: 'AskUserQuestion', arguments: {}, createdAt: 50 },
|
|
req3: { tool: 'Edit', arguments: {} }
|
|
}
|
|
}
|
|
}))
|
|
|
|
expect(summary.pendingRequestsCount).toBe(3)
|
|
expect(summary.pendingRequestKinds).toEqual(['permission', 'input'])
|
|
expect(summary.pendingRequests).toHaveLength(3)
|
|
expect(summary.pendingRequests[0]).toEqual({
|
|
id: 'req2',
|
|
kind: 'input',
|
|
tool: 'AskUserQuestion',
|
|
since: 50
|
|
})
|
|
expect(summary.pendingRequests[1]).toEqual({
|
|
id: 'req1',
|
|
kind: 'permission',
|
|
tool: 'Bash',
|
|
since: 100
|
|
})
|
|
expect(summary.pendingRequests[2]).toEqual({
|
|
id: 'req3',
|
|
kind: 'permission',
|
|
tool: 'Edit',
|
|
since: 5000
|
|
})
|
|
})
|
|
|
|
it('returns empty pendingRequests when agentState has no requests', () => {
|
|
const summary = toSessionSummary(makeSession({ agentState: null }))
|
|
expect(summary.pendingRequests).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('getPendingRequests', () => {
|
|
it('caps the array length while leaving pendingRequestsCount untouched', () => {
|
|
const requests: Record<string, { tool: string; arguments: unknown; createdAt: number }> = {}
|
|
for (let i = 0; i < PENDING_REQUEST_SUMMARY_CAP + 3; i += 1) {
|
|
requests[`req-${i.toString().padStart(2, '0')}`] = {
|
|
tool: 'Bash',
|
|
arguments: {},
|
|
createdAt: i
|
|
}
|
|
}
|
|
const session = makeSession({ agentState: { requests } })
|
|
|
|
const slice = getPendingRequests(session)
|
|
expect(slice).toHaveLength(PENDING_REQUEST_SUMMARY_CAP)
|
|
// Oldest-first → the first `cap` items by createdAt should win.
|
|
expect(slice.map(r => r.id)).toEqual(
|
|
Array.from({ length: PENDING_REQUEST_SUMMARY_CAP }, (_, i) => `req-${i.toString().padStart(2, '0')}`)
|
|
)
|
|
|
|
const summary = toSessionSummary(session)
|
|
expect(summary.pendingRequestsCount).toBe(PENDING_REQUEST_SUMMARY_CAP + 3)
|
|
expect(summary.pendingRequests).toHaveLength(PENDING_REQUEST_SUMMARY_CAP)
|
|
})
|
|
|
|
it('breaks ties on createdAt by id (stable across hub restarts)', () => {
|
|
const session = makeSession({
|
|
agentState: {
|
|
requests: {
|
|
'req-b': { tool: 'Bash', arguments: {}, createdAt: 100 },
|
|
'req-a': { tool: 'Edit', arguments: {}, createdAt: 100 }
|
|
}
|
|
}
|
|
})
|
|
const slice = getPendingRequests(session)
|
|
expect(slice.map(r => r.id)).toEqual(['req-a', 'req-b'])
|
|
})
|
|
})
|
|
|
|
describe('getPendingRequestKinds', () => {
|
|
it('reads from the FULL request set (not the capped pendingRequests slice)', () => {
|
|
const requests: Record<string, { tool: string; arguments: unknown; createdAt: number }> = {}
|
|
// First CAP requests are all permission, last one is input — must still
|
|
// surface 'input' even though it would fall outside the capped slice.
|
|
for (let i = 0; i < PENDING_REQUEST_SUMMARY_CAP; i += 1) {
|
|
requests[`perm-${i}`] = { tool: 'Bash', arguments: {}, createdAt: i }
|
|
}
|
|
requests['ask'] = {
|
|
tool: 'AskUserQuestion',
|
|
arguments: {},
|
|
createdAt: PENDING_REQUEST_SUMMARY_CAP + 100
|
|
}
|
|
|
|
const kinds = getPendingRequestKinds(makeSession({ agentState: { requests } }))
|
|
expect(kinds).toEqual(['permission', 'input'])
|
|
})
|
|
})
|