Skip to content

Commit c79288f

Browse files
heavygeecursoragent
andcommitted
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>
1 parent c311afd commit c79288f

14 files changed

Lines changed: 599 additions & 22 deletions

shared/src/sessionSummary.test.ts

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { describe, expect, it } from 'bun:test'
22
import type { Session } from './schemas'
3-
import { getPendingRequestKinds, toSessionSummary } from './sessionSummary'
3+
import {
4+
PENDING_REQUEST_SUMMARY_CAP,
5+
getPendingRequestKinds,
6+
getPendingRequests,
7+
toSessionSummary
8+
} from './sessionSummary'
49

510
function makeSession(overrides: Partial<Session> = {}): Session {
611
return {
@@ -87,4 +92,101 @@ describe('toSessionSummary', () => {
8792

8893
expect(summary.metadata?.lifecycleState).toBe('archived')
8994
})
95+
96+
it('includes structured pendingRequests for hover-tooltip copy', () => {
97+
const summary = toSessionSummary(makeSession({
98+
updatedAt: 5000,
99+
agentState: {
100+
requests: {
101+
req1: { tool: 'Bash', arguments: {}, createdAt: 100 },
102+
req2: { tool: 'AskUserQuestion', arguments: {}, createdAt: 50 },
103+
req3: { tool: 'Edit', arguments: {} }
104+
}
105+
}
106+
}))
107+
108+
expect(summary.pendingRequestsCount).toBe(3)
109+
expect(summary.pendingRequestKinds).toEqual(['permission', 'input'])
110+
expect(summary.pendingRequests).toHaveLength(3)
111+
expect(summary.pendingRequests[0]).toEqual({
112+
id: 'req2',
113+
kind: 'input',
114+
tool: 'AskUserQuestion',
115+
since: 50
116+
})
117+
expect(summary.pendingRequests[1]).toEqual({
118+
id: 'req1',
119+
kind: 'permission',
120+
tool: 'Bash',
121+
since: 100
122+
})
123+
expect(summary.pendingRequests[2]).toEqual({
124+
id: 'req3',
125+
kind: 'permission',
126+
tool: 'Edit',
127+
since: 5000
128+
})
129+
})
130+
131+
it('returns empty pendingRequests when agentState has no requests', () => {
132+
const summary = toSessionSummary(makeSession({ agentState: null }))
133+
expect(summary.pendingRequests).toEqual([])
134+
})
135+
})
136+
137+
describe('getPendingRequests', () => {
138+
it('caps the array length while leaving pendingRequestsCount untouched', () => {
139+
const requests: Record<string, { tool: string; arguments: unknown; createdAt: number }> = {}
140+
for (let i = 0; i < PENDING_REQUEST_SUMMARY_CAP + 3; i += 1) {
141+
requests[`req-${i.toString().padStart(2, '0')}`] = {
142+
tool: 'Bash',
143+
arguments: {},
144+
createdAt: i
145+
}
146+
}
147+
const session = makeSession({ agentState: { requests } })
148+
149+
const slice = getPendingRequests(session)
150+
expect(slice).toHaveLength(PENDING_REQUEST_SUMMARY_CAP)
151+
// Oldest-first → the first `cap` items by createdAt should win.
152+
expect(slice.map(r => r.id)).toEqual(
153+
Array.from({ length: PENDING_REQUEST_SUMMARY_CAP }, (_, i) => `req-${i.toString().padStart(2, '0')}`)
154+
)
155+
156+
const summary = toSessionSummary(session)
157+
expect(summary.pendingRequestsCount).toBe(PENDING_REQUEST_SUMMARY_CAP + 3)
158+
expect(summary.pendingRequests).toHaveLength(PENDING_REQUEST_SUMMARY_CAP)
159+
})
160+
161+
it('breaks ties on createdAt by id (stable across hub restarts)', () => {
162+
const session = makeSession({
163+
agentState: {
164+
requests: {
165+
'req-b': { tool: 'Bash', arguments: {}, createdAt: 100 },
166+
'req-a': { tool: 'Edit', arguments: {}, createdAt: 100 }
167+
}
168+
}
169+
})
170+
const slice = getPendingRequests(session)
171+
expect(slice.map(r => r.id)).toEqual(['req-a', 'req-b'])
172+
})
173+
})
174+
175+
describe('getPendingRequestKinds', () => {
176+
it('reads from the FULL request set (not the capped pendingRequests slice)', () => {
177+
const requests: Record<string, { tool: string; arguments: unknown; createdAt: number }> = {}
178+
// First CAP requests are all permission, last one is input — must still
179+
// surface 'input' even though it would fall outside the capped slice.
180+
for (let i = 0; i < PENDING_REQUEST_SUMMARY_CAP; i += 1) {
181+
requests[`perm-${i}`] = { tool: 'Bash', arguments: {}, createdAt: i }
182+
}
183+
requests['ask'] = {
184+
tool: 'AskUserQuestion',
185+
arguments: {},
186+
createdAt: PENDING_REQUEST_SUMMARY_CAP + 100
187+
}
188+
189+
const kinds = getPendingRequestKinds(makeSession({ agentState: { requests } }))
190+
expect(kinds).toEqual(['permission', 'input'])
191+
})
90192
})

shared/src/sessionSummary.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,25 @@ const INPUT_REQUEST_TOOLS = new Set([
1010
'request_user_input'
1111
])
1212

13+
/** Cap on `pendingRequests` carried in `SessionSummary`. The list is meant for
14+
* per-row hover copy ("Approve `Bash`, `Edit` (+1 more)"); deep inspection
15+
* should use `Session.agentState.requests`. The `pendingRequestsCount` field
16+
* is the authoritative total — `pendingRequests.length` may be smaller. */
17+
export const PENDING_REQUEST_SUMMARY_CAP = 5
18+
19+
export type PendingRequest = {
20+
id: string
21+
kind: PendingRequestKind
22+
tool: string
23+
/** Epoch ms when the request was raised; falls back to `session.updatedAt`
24+
* for older requests that were stored without `createdAt`. */
25+
since: number
26+
}
27+
28+
function classifyKind(tool: string): PendingRequestKind {
29+
return INPUT_REQUEST_TOOLS.has(tool) ? 'input' : 'permission'
30+
}
31+
1332
export type SessionSummaryMetadata = {
1433
name?: string
1534
path: string
@@ -31,12 +50,43 @@ export type SessionSummary = {
3150
todoProgress: { completed: number; total: number } | null
3251
pendingRequestsCount: number
3352
pendingRequestKinds: PendingRequestKind[]
53+
/** Capped, oldest-first slice of pending tool requests. Use this for tooltip
54+
* / per-row UX. The full count (which may exceed the cap) is in
55+
* `pendingRequestsCount`. */
56+
pendingRequests: PendingRequest[]
3457
backgroundTaskCount: number
3558
futureScheduledMessageCount: number
3659
model: string | null
3760
effort: string | null
3861
}
3962

63+
export function getPendingRequests(
64+
session: Session,
65+
cap: number = PENDING_REQUEST_SUMMARY_CAP
66+
): PendingRequest[] {
67+
const requests = session.agentState?.requests
68+
if (!requests) {
69+
return []
70+
}
71+
72+
const items: PendingRequest[] = []
73+
for (const [id, request] of Object.entries(requests)) {
74+
items.push({
75+
id,
76+
kind: classifyKind(request.tool),
77+
tool: request.tool,
78+
since: typeof request.createdAt === 'number' ? request.createdAt : session.updatedAt
79+
})
80+
}
81+
82+
items.sort((a, b) => {
83+
if (a.since !== b.since) return a.since - b.since
84+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
85+
})
86+
87+
return cap >= items.length ? items : items.slice(0, cap)
88+
}
89+
4090
export function getPendingRequestKinds(session: Session): PendingRequestKind[] {
4191
const requests = session.agentState?.requests
4292
if (!requests) {
@@ -45,7 +95,7 @@ export function getPendingRequestKinds(session: Session): PendingRequestKind[] {
4595

4696
const kinds = new Set<PendingRequestKind>()
4797
for (const request of Object.values(requests)) {
48-
kinds.add(INPUT_REQUEST_TOOLS.has(request.tool) ? 'input' : 'permission')
98+
kinds.add(classifyKind(request.tool))
4999
}
50100

51101
return kinds.has('permission') && kinds.has('input')
@@ -88,6 +138,7 @@ export function toSessionSummary(session: Session): SessionSummary {
88138
todoProgress,
89139
pendingRequestsCount,
90140
pendingRequestKinds: getPendingRequestKinds(session),
141+
pendingRequests: getPendingRequests(session),
91142
backgroundTaskCount: session.backgroundTaskCount ?? 0,
92143
futureScheduledMessageCount: 0,
93144
model: session.model,

shared/src/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ export type {
2424
WorktreeMetadata
2525
} from './schemas'
2626

27-
export type { SessionSummary, SessionSummaryMetadata, PendingRequestKind } from './sessionSummary'
27+
export type { SessionSummary, SessionSummaryMetadata, PendingRequest, PendingRequestKind } from './sessionSummary'
28+
export { PENDING_REQUEST_SUMMARY_CAP } from './sessionSummary'
2829
export { AGENT_MESSAGE_PAYLOAD_TYPE } from './modes'
2930

3031
export type {
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { useId, type ReactNode } from 'react'
2+
import { cn } from '@/lib/utils'
3+
4+
/**
5+
* Lightweight CSS-driven tooltip used by the session list to surface "why is
6+
* this indicator showing?" copy on hover/focus. Pure CSS reveal (no portal,
7+
* no positioning JS) keeps the component cheap and avoids z-index surprises
8+
* inside the session-row `<button>`.
9+
*
10+
* Touch devices get the `aria-label` (announced) but no visible bubble — the
11+
* row is tap-to-open, so the dot deliberately does not capture touch events.
12+
*
13+
* Layout: tooltip flips to top-anchored when `side='top'`, otherwise hangs
14+
* below. Content is wrapped to `max-w-[14rem]` so long tool names don't blow
15+
* out the sidebar.
16+
*/
17+
export function HoverTooltip(props: {
18+
/** Plain-text label used for `aria-label` and screen readers. */
19+
label: string
20+
/** Visible target element (the dot, the icon, etc.). */
21+
target: ReactNode
22+
/** Rich tooltip content. Plain text or a small fragment with headings/lists. */
23+
children: ReactNode
24+
side?: 'top' | 'bottom'
25+
align?: 'start' | 'center' | 'end'
26+
className?: string
27+
}) {
28+
const id = useId()
29+
const side = props.side ?? 'bottom'
30+
const align = props.align ?? 'center'
31+
32+
const alignClasses =
33+
align === 'start' ? 'left-0'
34+
: align === 'end' ? 'right-0'
35+
: 'left-1/2 -translate-x-1/2'
36+
37+
return (
38+
<span className={cn('relative inline-flex group', props.className)}>
39+
<span
40+
aria-describedby={id}
41+
aria-label={props.label}
42+
className="inline-flex"
43+
>
44+
{props.target}
45+
</span>
46+
<span
47+
role="tooltip"
48+
id={id}
49+
className={cn(
50+
'pointer-events-none absolute z-30 max-w-[14rem] whitespace-normal',
51+
'rounded-md border border-[var(--app-border)] bg-[var(--app-bg)]',
52+
'px-2 py-1 text-xs leading-snug text-[var(--app-fg)] shadow-md',
53+
side === 'top' ? 'bottom-full mb-1' : 'top-full mt-1',
54+
alignClasses,
55+
'opacity-0 invisible',
56+
'group-hover:opacity-100 group-hover:visible',
57+
'group-focus-within:opacity-100 group-focus-within:visible',
58+
'transition-opacity duration-100'
59+
)}
60+
>
61+
{props.children}
62+
</span>
63+
</span>
64+
)
65+
}

0 commit comments

Comments
 (0)