feat(web,hub): rich hover tooltips on session-list attention indicators (#941)

* 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>
This commit is contained in:
HeavyGee
2026-06-18 10:15:22 +08:00
committed by GitHub
co-authored by Cursor
parent 5f27abddd4
commit ce67823fc3
25 changed files with 902 additions and 43 deletions
+5
View File
@@ -15,6 +15,7 @@ import {
getImmediateQueuedLocalMessages,
countFutureScheduledBySessionIds,
countFutureScheduledLocalMessages,
minFutureScheduledAtBySessionIds,
countMessages,
markMessagesInvoked,
mergeSessionMessages,
@@ -83,6 +84,10 @@ export class MessageStore {
return countFutureScheduledBySessionIds(this.db, sessionIds, now)
}
minFutureScheduledAtBySessionIds(sessionIds: string[], now: number = Date.now()): Map<string, number> {
return minFutureScheduledAtBySessionIds(this.db, sessionIds, now)
}
countMessages(sessionId: string): number {
return countMessages(this.db, sessionId)
}
+4
View File
@@ -342,5 +342,9 @@ describe('countFutureScheduledLocalMessages', () => {
const counts = store.messages.countFutureScheduledBySessionIds([sessionA.id, sessionB.id], now)
expect(counts.get(sessionA.id)).toBe(2)
expect(counts.get(sessionB.id)).toBeUndefined()
const nextAt = store.messages.minFutureScheduledAtBySessionIds([sessionA.id, sessionB.id], now)
expect(nextAt.get(sessionA.id)).toBe(now + 60_000)
expect(nextAt.get(sessionB.id)).toBeUndefined()
})
})
+29
View File
@@ -361,6 +361,35 @@ export function countFutureScheduledBySessionIds(
return counts
}
/** Earliest future scheduled_at per session (session-list clock tooltip). */
export function minFutureScheduledAtBySessionIds(
db: Database,
sessionIds: string[],
now: number
): Map<string, number> {
const nextAt = new Map<string, number>()
if (sessionIds.length === 0) {
return nextAt
}
const placeholders = sessionIds.map(() => '?').join(',')
const rows = db.prepare(`
SELECT session_id, MIN(scheduled_at) AS next_at
FROM messages
WHERE session_id IN (${placeholders})
AND invoked_at IS NULL
AND local_id IS NOT NULL
AND scheduled_at IS NOT NULL
AND scheduled_at > ?
GROUP BY session_id
`).all(...sessionIds, now) as { session_id: string; next_at: number }[]
for (const row of rows) {
nextAt.set(row.session_id, row.next_at)
}
return nextAt
}
export function getMaxSeq(db: Database, sessionId: string): number {
const row = db.prepare(
'SELECT COALESCE(MAX(seq), 0) AS maxSeq FROM messages WHERE session_id = ?'
+4
View File
@@ -192,6 +192,10 @@ export class SyncEngine {
return this.store.messages.countFutureScheduledBySessionIds(sessionIds, now)
}
getNextScheduledAtBySessionIds(sessionIds: string[], now: number = Date.now()): Map<string, number> {
return this.store.messages.minFutureScheduledAtBySessionIds(sessionIds, now)
}
getSession(sessionId: string): Session | undefined {
return this.sessionCache.getSession(sessionId) ?? this.sessionCache.refreshSession(sessionId) ?? undefined
}
+3 -1
View File
@@ -84,11 +84,13 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho
return b.updatedAt - a.updatedAt
})
const scheduledCounts = engine.getFutureScheduledMessageCounts(sessionRecords.map((session) => session.id))
const nextScheduledAt = engine.getNextScheduledAtBySessionIds(sessionRecords.map((session) => session.id))
const sessions = sessionRecords.map((session) => {
const summary = toSessionSummary(session)
return {
...summary,
futureScheduledMessageCount: scheduledCounts.get(session.id) ?? 0
futureScheduledMessageCount: scheduledCounts.get(session.id) ?? 0,
nextScheduledAt: nextScheduledAt.get(session.id) ?? null
}
})
+103 -1
View File
@@ -1,6 +1,11 @@
import { describe, expect, it } from 'bun:test'
import type { Session } from './schemas'
import { getPendingRequestKinds, toSessionSummary } from './sessionSummary'
import {
PENDING_REQUEST_SUMMARY_CAP,
getPendingRequestKinds,
getPendingRequests,
toSessionSummary
} from './sessionSummary'
function makeSession(overrides: Partial<Session> = {}): Session {
return {
@@ -87,4 +92,101 @@ describe('toSessionSummary', () => {
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'])
})
})
+55 -1
View File
@@ -10,6 +10,25 @@ const INPUT_REQUEST_TOOLS = new Set([
'request_user_input'
])
/** Cap on `pendingRequests` carried in `SessionSummary`. The list is meant for
* per-row hover copy ("Approve `Bash`, `Edit` (+1 more)"); deep inspection
* should use `Session.agentState.requests`. The `pendingRequestsCount` field
* is the authoritative total `pendingRequests.length` may be smaller. */
export const PENDING_REQUEST_SUMMARY_CAP = 5
export type PendingRequest = {
id: string
kind: PendingRequestKind
tool: string
/** Epoch ms when the request was raised; falls back to `session.updatedAt`
* for older requests that were stored without `createdAt`. */
since: number
}
function classifyKind(tool: string): PendingRequestKind {
return INPUT_REQUEST_TOOLS.has(tool) ? 'input' : 'permission'
}
export type SessionSummaryMetadata = {
name?: string
path: string
@@ -31,12 +50,45 @@ export type SessionSummary = {
todoProgress: { completed: number; total: number } | null
pendingRequestsCount: number
pendingRequestKinds: PendingRequestKind[]
/** Capped, oldest-first slice of pending tool requests. Use this for tooltip
* / per-row UX. The full count (which may exceed the cap) is in
* `pendingRequestsCount`. */
pendingRequests: PendingRequest[]
backgroundTaskCount: number
futureScheduledMessageCount: number
/** Epoch ms of the soonest uninvoked future scheduled message, or null. */
nextScheduledAt: number | null
model: string | null
effort: string | null
}
export function getPendingRequests(
session: Session,
cap: number = PENDING_REQUEST_SUMMARY_CAP
): PendingRequest[] {
const requests = session.agentState?.requests
if (!requests) {
return []
}
const items: PendingRequest[] = []
for (const [id, request] of Object.entries(requests)) {
items.push({
id,
kind: classifyKind(request.tool),
tool: request.tool,
since: typeof request.createdAt === 'number' ? request.createdAt : session.updatedAt
})
}
items.sort((a, b) => {
if (a.since !== b.since) return a.since - b.since
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
})
return cap >= items.length ? items : items.slice(0, cap)
}
export function getPendingRequestKinds(session: Session): PendingRequestKind[] {
const requests = session.agentState?.requests
if (!requests) {
@@ -45,7 +97,7 @@ export function getPendingRequestKinds(session: Session): PendingRequestKind[] {
const kinds = new Set<PendingRequestKind>()
for (const request of Object.values(requests)) {
kinds.add(INPUT_REQUEST_TOOLS.has(request.tool) ? 'input' : 'permission')
kinds.add(classifyKind(request.tool))
}
return kinds.has('permission') && kinds.has('input')
@@ -88,8 +140,10 @@ export function toSessionSummary(session: Session): SessionSummary {
todoProgress,
pendingRequestsCount,
pendingRequestKinds: getPendingRequestKinds(session),
pendingRequests: getPendingRequests(session),
backgroundTaskCount: session.backgroundTaskCount ?? 0,
futureScheduledMessageCount: 0,
nextScheduledAt: null,
model: session.model,
effort: session.effort
}
+2 -1
View File
@@ -24,7 +24,8 @@ export type {
WorktreeMetadata
} from './schemas'
export type { SessionSummary, SessionSummaryMetadata, PendingRequestKind } from './sessionSummary'
export type { SessionSummary, SessionSummaryMetadata, PendingRequest, PendingRequestKind } from './sessionSummary'
export { PENDING_REQUEST_SUMMARY_CAP } from './sessionSummary'
export { AGENT_MESSAGE_PAYLOAD_TYPE } from './modes'
export type {
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import type { DecryptedMessage } from '@/types/api'
import { computeCanCancel, computeEditPendingSchedule, formatScheduledTime, getQueuedMessageEditText, getQueuedMessagePreview, sortQueuedMessages } from './QueuedMessagesBar'
import { computeCanCancel, computeEditPendingSchedule, getQueuedMessageEditText, getQueuedMessagePreview, sortQueuedMessages } from './QueuedMessagesBar'
import { formatScheduledTime } from '@/lib/scheduledTime'
/**
* Unit tests for computeCanCancel the race guard that prevents sending
@@ -10,6 +10,7 @@ import { useCancelQueuedMessage } from '@/hooks/mutations/useCancelQueuedMessage
import { useTranslation } from '@/lib/use-translation'
import { useToast } from '@/lib/toast-context'
import type { PendingSchedule } from '@/components/AssistantChat/ScheduleTimePicker'
import { formatScheduledTime } from '@/lib/scheduledTime'
function ClockIcon() {
return (
@@ -147,22 +148,6 @@ export function computeCanCancel({
* Edit = client-side cancel + prefill composer with message text (Codex dialect).
* Cancel = DELETE /sessions/:id/messages/:messageId with optimistic removal.
*/
/** @internal Exported for unit testing. */
export function formatScheduledTime(scheduledAt: number): string {
const date = new Date(scheduledAt)
const now = new Date()
const opts: Intl.DateTimeFormatOptions = {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}
if (date.getFullYear() !== now.getFullYear()) {
opts.year = 'numeric'
}
return date.toLocaleString(undefined, opts)
}
export function QueuedMessagesBar({
sessionId,
api,
+60
View File
@@ -0,0 +1,60 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import {
HoverTooltip,
SESSION_ROW_TOOLTIP_FOCUS_CLASS,
useSessionRowTooltipIds
} from './HoverTooltip'
afterEach(() => cleanup())
describe('HoverTooltip keyboard wiring', () => {
it('applies parent row focus-visible reveal classes', () => {
render(
<HoverTooltip
id="sched-tooltip"
target={<span data-testid="target">icon</span>}
revealOnParentFocusClass={SESSION_ROW_TOOLTIP_FOCUS_CLASS}
>
Scheduled copy
</HoverTooltip>
)
const tooltip = screen.getByRole('tooltip', { hidden: true })
expect(tooltip.id).toBe('sched-tooltip')
expect(tooltip.className).toContain('group-focus-visible/session-row:visible')
expect(tooltip.className).not.toContain('group-focus-within')
})
})
describe('useSessionRowTooltipIds', () => {
function Probe(props: { hasAttention: boolean; hasSchedule: boolean }) {
const { attentionId, scheduleId, describedBy } = useSessionRowTooltipIds(
props.hasAttention,
props.hasSchedule
)
return (
<div
data-testid="probe"
data-attention={attentionId ?? ''}
data-schedule={scheduleId ?? ''}
data-describedby={describedBy ?? ''}
/>
)
}
it('returns both ids and a combined describedBy when both indicators are present', () => {
render(<Probe hasAttention hasSchedule />)
const probe = screen.getByTestId('probe')
const attention = probe.getAttribute('data-attention')
const schedule = probe.getAttribute('data-schedule')
expect(attention).toBeTruthy()
expect(schedule).toBeTruthy()
expect(probe.getAttribute('data-describedby')).toBe(`${attention} ${schedule}`)
})
it('returns undefined describedBy when neither indicator is present', () => {
render(<Probe hasAttention={false} hasSchedule={false} />)
expect(screen.getByTestId('probe').getAttribute('data-describedby')).toBe('')
})
})
+79
View File
@@ -0,0 +1,79 @@
import { useId, type ReactNode } from 'react'
import { cn } from '@/lib/utils'
/** Tailwind classes that reveal the bubble when a named parent row has :focus-visible. */
export const SESSION_ROW_TOOLTIP_FOCUS_CLASS =
'group-focus-visible/session-row:opacity-100 group-focus-visible/session-row:visible'
/**
* Lightweight CSS-driven tooltip used by the session list to surface "why is
* this indicator showing?" copy on hover/focus. Pure CSS reveal (no portal,
* no positioning JS) keeps the component cheap and avoids z-index surprises
* inside the session-row `<button>`.
*
* Keyboard: the session-row `<button>` owns `aria-describedby` pointing at
* this tooltip's `id`. Pass `revealOnParentFocusClass` (see
* `SESSION_ROW_TOOLTIP_FOCUS_CLASS`) so the bubble is visible when the row
* receives keyboard focus an inner non-focusable wrapper cannot use
* `:focus-within` for that.
*
* Mouse: local `group-hover` on this wrapper still reveals the bubble when
* the pointer is over the dot/icon.
*
* Touch: no visible bubble the row is tap-to-open.
*/
export function HoverTooltip(props: {
/** Stable id for `aria-describedby` on the session-row button. */
id: string
/** Visible target element (the dot, the icon, etc.). */
target: ReactNode
/** Rich tooltip content. Plain text or a small fragment with headings/lists. */
children: ReactNode
side?: 'top' | 'bottom'
align?: 'start' | 'center' | 'end'
className?: string
/** Parent-focus reveal classes (e.g. SESSION_ROW_TOOLTIP_FOCUS_CLASS). */
revealOnParentFocusClass?: string
}) {
const side = props.side ?? 'bottom'
const align = props.align ?? 'center'
const alignClasses =
align === 'start' ? 'left-0'
: align === 'end' ? 'right-0'
: 'left-1/2 -translate-x-1/2'
return (
<span className={cn('relative inline-flex group', props.className)}>
<span className="inline-flex">
{props.target}
</span>
<span
role="tooltip"
id={props.id}
className={cn(
'pointer-events-none absolute z-30 max-w-[14rem] whitespace-normal',
'rounded-md border border-[var(--app-border)] bg-[var(--app-secondary-bg)]',
'px-2 py-1 text-xs leading-snug text-[var(--app-fg)] shadow-lg',
side === 'top' ? 'bottom-full mb-1' : 'top-full mt-1',
alignClasses,
'opacity-0 invisible',
'group-hover:opacity-100 group-hover:visible',
props.revealOnParentFocusClass,
'transition-opacity duration-100'
)}
>
{props.children}
</span>
</span>
)
}
/** Convenience hook: `${base}-attention` / `${base}-schedule` ids for a row. */
export function useSessionRowTooltipIds(hasAttention: boolean, hasSchedule: boolean) {
const base = useId()
const attentionId = hasAttention ? `${base}-attention` : undefined
const scheduleId = hasSchedule ? `${base}-schedule` : undefined
const describedBy = [attentionId, scheduleId].filter(Boolean).join(' ') || undefined
return { attentionId, scheduleId, describedBy }
}
@@ -0,0 +1,211 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import type { ReactNode } from 'react'
import type { PendingRequest, SessionSummary } from '@/types/api'
import type { SessionAttention } from '@/lib/sessionAttention'
import { I18nProvider } from '@/lib/i18n-context'
import { SessionAttentionIndicator } from './SessionAttentionIndicator'
afterEach(() => cleanup())
function renderWithI18n(children: ReactNode) {
return render(<I18nProvider>{children}</I18nProvider>)
}
function makeSummary(overrides: Partial<SessionSummary> & { id: string }): SessionSummary {
return {
active: true,
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
}
}
function makeRequest(overrides: Partial<PendingRequest> & { id: string; kind: PendingRequest['kind']; tool: string }): PendingRequest {
return { since: 0, ...overrides }
}
describe('SessionAttentionIndicator tooltip', () => {
it('renders permission tooltip body listing each pending tool', () => {
const summary = makeSummary({
id: 's1',
pendingRequestsCount: 2,
pendingRequestKinds: ['permission'],
pendingRequests: [
makeRequest({ id: 'r1', kind: 'permission', tool: 'Bash' }),
makeRequest({ id: 'r2', kind: 'permission', tool: 'Edit' })
]
})
const attention: SessionAttention = { kind: 'permission' }
renderWithI18n(
<SessionAttentionIndicator
attention={attention}
summary={summary}
label="Permission required"
tooltipId="tooltip-permission"
/>
)
const tooltip = screen.getByRole('tooltip', { hidden: true })
expect(tooltip.textContent).toContain('Permission required')
expect(tooltip.textContent).toContain('Approve:')
expect(tooltip.textContent).toContain('Bash')
expect(tooltip.textContent).toContain('Edit')
expect(tooltip.textContent).not.toContain('+1 more')
})
it('shows "+N more" when pendingRequestsCount exceeds the rendered slice', () => {
const summary = makeSummary({
id: 's1',
pendingRequestsCount: 7,
pendingRequestKinds: ['permission'],
pendingRequests: [
makeRequest({ id: 'r1', kind: 'permission', tool: 'Bash' }),
makeRequest({ id: 'r2', kind: 'permission', tool: 'Edit' }),
makeRequest({ id: 'r3', kind: 'permission', tool: 'Read' }),
makeRequest({ id: 'r4', kind: 'permission', tool: 'Write' }),
makeRequest({ id: 'r5', kind: 'permission', tool: 'Glob' })
]
})
renderWithI18n(
<SessionAttentionIndicator
attention={{ kind: 'permission' }}
summary={summary}
label="Permission required"
tooltipId="tooltip-permission-overflow"
/>
)
const tooltip = screen.getByRole('tooltip', { hidden: true })
expect(tooltip.textContent).toContain('+2 more')
})
it('renders only the requested kind even when both kinds are pending', () => {
const summary = makeSummary({
id: 's1',
pendingRequestsCount: 2,
pendingRequestKinds: ['permission', 'input'],
pendingRequests: [
makeRequest({ id: 'r1', kind: 'permission', tool: 'Bash' }),
makeRequest({ id: 'r2', kind: 'input', tool: 'AskUserQuestion' })
]
})
renderWithI18n(
<SessionAttentionIndicator
attention={{ kind: 'input' }}
summary={summary}
label="Needs input"
tooltipId="tooltip-input"
/>
)
const tooltip = screen.getByRole('tooltip', { hidden: true })
expect(tooltip.textContent).toContain('Needs input')
expect(tooltip.textContent).toContain('Reply to:')
expect(tooltip.textContent).toContain('AskUserQuestion')
expect(tooltip.textContent).not.toContain('Bash')
})
it('suppresses the "+N more" hint when both kinds are pending and the slice is capped', () => {
// 5 mixed requests in the slice + 2 more we don't see. The total count
// mixes kinds so we cannot honestly report a per-kind overflow.
const summary = makeSummary({
id: 's1',
pendingRequestsCount: 7,
pendingRequestKinds: ['permission', 'input'],
pendingRequests: [
makeRequest({ id: 'r1', kind: 'permission', tool: 'Bash' }),
makeRequest({ id: 'r2', kind: 'permission', tool: 'Edit' }),
makeRequest({ id: 'r3', kind: 'permission', tool: 'Read' }),
makeRequest({ id: 'r4', kind: 'input', tool: 'AskUserQuestion' }),
makeRequest({ id: 'r5', kind: 'input', tool: 'request_user_input' })
]
})
renderWithI18n(
<SessionAttentionIndicator
attention={{ kind: 'permission' }}
summary={summary}
label="Permission required"
tooltipId="tooltip-permission-mixed"
/>
)
const tooltip = screen.getByRole('tooltip', { hidden: true })
expect(tooltip.textContent).toContain('Bash')
expect(tooltip.textContent).toContain('Edit')
expect(tooltip.textContent).toContain('Read')
expect(tooltip.textContent).not.toMatch(/\+\d+ more/)
})
it('renders background task count', () => {
const summary = makeSummary({
id: 's1',
backgroundTaskCount: 3
})
renderWithI18n(
<SessionAttentionIndicator
attention={{ kind: 'background' }}
summary={summary}
label="Background tasks running"
tooltipId="tooltip-background"
/>
)
const tooltip = screen.getByRole('tooltip', { hidden: true })
expect(tooltip.textContent).toContain('Background tasks running')
expect(tooltip.textContent).toContain('3 tasks running')
})
it('renders only the title for unread attention (relative time is already on the row)', () => {
const updatedAt = Date.now() - 5 * 60_000
const summary = makeSummary({
id: 's1',
updatedAt
})
renderWithI18n(
<SessionAttentionIndicator
attention={{ kind: 'unread' }}
summary={summary}
label="New activity"
tooltipId="tooltip-unread"
/>
)
const tooltip = screen.getByRole('tooltip', { hidden: true })
expect(tooltip.textContent).toContain('New activity')
// The "Nm ago" pill in the session row already shows this; do not duplicate.
expect(tooltip.textContent).not.toMatch(/Updated /)
})
it('exposes a stable tooltip id for row aria-describedby wiring', () => {
const summary = makeSummary({ id: 's1' })
renderWithI18n(
<SessionAttentionIndicator
attention={{ kind: 'unread' }}
summary={summary}
label="New activity"
tooltipId="row-tooltip-unread"
/>
)
expect(document.getElementById('row-tooltip-unread')).toBeTruthy()
})
})
@@ -1,5 +1,8 @@
import type { PendingRequest, SessionSummary } from '@/types/api'
import type { SessionAttention } from '@/lib/sessionAttention'
import { getSessionAttentionLabelKey } from '@/lib/sessionAttention'
import { useTranslation } from '@/lib/use-translation'
import { HoverTooltip, SESSION_ROW_TOOLTIP_FOCUS_CLASS } from '@/components/HoverTooltip'
const ATTENTION_DOT_CLASS: Record<SessionAttention['kind'], string> = {
permission: 'bg-amber-500 animate-pulse',
@@ -8,17 +11,121 @@ const ATTENTION_DOT_CLASS: Record<SessionAttention['kind'], string> = {
unread: 'bg-[var(--app-link)]'
}
/**
* Visible attention dot + hover tooltip explaining the indicator.
*
* The tooltip body composes from `summary.pendingRequests` (capped oldest-first;
* see `PENDING_REQUEST_SUMMARY_CAP` in `@hapi/protocol`) for permission / input
* attention; from counts and timestamps for background / unread.
*/
export function SessionAttentionIndicator(props: {
attention: SessionAttention
summary: SessionSummary
label: string
tooltipId: string
}) {
return (
const { t } = useTranslation()
const dot = (
<span
className={`inline-flex h-2 w-2 shrink-0 rounded-full ${ATTENTION_DOT_CLASS[props.attention.kind]}`}
title={props.label}
aria-label={props.label}
/>
)
return (
<HoverTooltip
id={props.tooltipId}
target={dot}
side="bottom"
align="start"
className="shrink-0"
revealOnParentFocusClass={SESSION_ROW_TOOLTIP_FOCUS_CLASS}
>
<AttentionTooltipBody
attention={props.attention}
summary={props.summary}
label={props.label}
t={t}
/>
</HoverTooltip>
)
}
function AttentionTooltipBody(props: {
attention: SessionAttention
summary: SessionSummary
label: string
t: (key: string, params?: Record<string, string | number>) => string
}) {
const { attention, summary, label, t } = props
return (
<span className="block">
<span className="block font-medium">{label}</span>
<AttentionTooltipDetail attention={attention} summary={summary} t={t} />
</span>
)
}
function AttentionTooltipDetail(props: {
attention: SessionAttention
summary: SessionSummary
t: (key: string, params?: Record<string, string | number>) => string
}) {
const { attention, summary, t } = props
if (attention.kind === 'permission' || attention.kind === 'input') {
const wantedKind = attention.kind
const items = (summary.pendingRequests ?? [])
.filter((req): req is PendingRequest => req.kind === wantedKind)
if (items.length === 0) {
return null
}
// Overflow is only knowable per-kind when all pending requests in the
// session share that kind — otherwise `pendingRequestsCount` mixes the
// counts of both kinds. Suppress the "+N more" hint in the mixed case
// rather than report a wrong number.
const kinds = summary.pendingRequestKinds ?? []
const onlyThisKind = kinds.length === 1 && kinds[0] === wantedKind
const overflow = onlyThisKind
? Math.max(0, (summary.pendingRequestsCount ?? items.length) - items.length)
: 0
const bodyKey = wantedKind === 'permission'
? 'session.tooltip.permission.body'
: 'session.tooltip.input.body'
return (
<span className="block mt-1">
<span className="block text-[var(--app-hint)]">{t(bodyKey)}</span>
<ul className="mt-0.5 list-disc pl-4">
{items.map(req => (
<li key={req.id} className="font-mono text-[11px] break-all">
{req.tool}
</li>
))}
</ul>
{overflow > 0 ? (
<span className="mt-0.5 block text-[var(--app-hint)]">
{t('session.tooltip.moreCount', { count: overflow })}
</span>
) : null}
</span>
)
}
if (attention.kind === 'background') {
const count = summary.backgroundTaskCount ?? 0
if (count <= 0) return null
const key = count === 1
? 'session.tooltip.background.count.one'
: 'session.tooltip.background.count.other'
return (
<span className="mt-1 block text-[var(--app-hint)]">
{t(key, { count })}
</span>
)
}
// 'unread' deliberately has no body: the relative-time pill is already
// rendered in the session row, so a tooltip body would just duplicate it.
return null
}
export function getAttentionLabel(
@@ -18,8 +18,10 @@ function makeSession(overrides: Partial<SessionSummary> & { id: string }): Sessi
todoProgress: null,
pendingRequestsCount: 0,
pendingRequestKinds: [],
pendingRequests: [],
backgroundTaskCount: 0,
futureScheduledMessageCount: 0,
nextScheduledAt: null,
model: null,
effort: null,
...overrides
+2
View File
@@ -22,8 +22,10 @@ function makeSession(overrides: Partial<SessionSummary> & { id: string }): Sessi
todoProgress: null,
pendingRequestsCount: 0,
pendingRequestKinds: [],
pendingRequests: [],
backgroundTaskCount: 0,
futureScheduledMessageCount: 0,
nextScheduledAt: null,
model: null,
effort: null,
...overrides
+28 -18
View File
@@ -16,6 +16,9 @@ import { useSessionListStatusMode } from '@/hooks/useSessionListStatusMode'
import { classifySessionAttention } from '@/lib/sessionAttention'
import { getSessionLastSeenAt } from '@/lib/sessionLastSeen'
import { getAttentionLabel, SessionAttentionIndicator } from '@/components/SessionAttentionIndicator'
import { HoverTooltip, SESSION_ROW_TOOLTIP_FOCUS_CLASS, useSessionRowTooltipIds } from '@/components/HoverTooltip'
import { formatRelativeTime } from '@/lib/relativeTime'
import { formatScheduledTooltipDetail } from '@/lib/scheduledTime'
import { getCodexImportedAt, subscribeCodexImportedSessions } from '@/lib/codexImportedSessions'
import { formatReopenError } from '@/lib/reopenError'
@@ -545,19 +548,6 @@ function MachineIcon(props: { className?: string }) {
)
}
function formatRelativeTime(value: number, t: (key: string, params?: Record<string, string | number>) => string): string | null {
const ms = value < 1_000_000_000_000 ? value * 1000 : value
if (!Number.isFinite(ms)) return null
const delta = Date.now() - ms
if (delta < 60_000) return t('session.time.justNow')
const minutes = Math.floor(delta / 60_000)
if (minutes < 60) return t('session.time.minutesAgo', { n: minutes })
const hours = Math.floor(minutes / 60)
if (hours < 24) return t('session.time.hoursAgo', { n: hours })
const days = Math.floor(hours / 24)
if (days < 7) return t('session.time.daysAgo', { n: days })
return new Date(ms).toLocaleDateString()
}
function formatCodexImportedRelativeTime(value: number, t: (key: string, params?: Record<string, string | number>) => string): string | null {
const ms = value < 1_000_000_000_000 ? value * 1000 : value
@@ -654,14 +644,20 @@ function SessionItem(props: {
const scheduledLabel = s.futureScheduledMessageCount > 1
? t('session.item.scheduledMessages', { count: s.futureScheduledMessageCount })
: t('session.item.scheduledMessage')
const hasScheduleTooltip = showDetailedStatus && s.futureScheduledMessageCount > 0
const { attentionId, scheduleId, describedBy } = useSessionRowTooltipIds(
Boolean(attention),
hasScheduleTooltip
)
return (
<>
<button
type="button"
{...longPressHandlers}
className={`session-list-item flex w-full flex-col gap-1 px-2.5 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)] select-none rounded-lg ${selected ? 'bg-[var(--app-secondary-bg)]' : ''}`}
className={`session-list-item group/session-row flex w-full flex-col gap-1 px-2.5 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)] select-none rounded-lg ${selected ? 'bg-[var(--app-secondary-bg)]' : ''}`}
style={{ WebkitTouchCallout: 'none' }}
aria-current={selected ? 'page' : undefined}
aria-describedby={describedBy}
>
<div className={`flex items-center justify-between gap-3 ${!s.active ? 'opacity-50' : ''}`}>
<div className="flex items-center gap-2 min-w-0">
@@ -674,13 +670,27 @@ function SessionItem(props: {
) : attention ? (
<SessionAttentionIndicator
attention={attention}
summary={s}
label={attentionLabel ?? ''}
tooltipId={attentionId!}
/>
) : null}
{showDetailedStatus && s.futureScheduledMessageCount > 0 ? (
<span title={scheduledLabel} aria-label={scheduledLabel} className="inline-flex shrink-0">
<ScheduleIcon className="h-3.5 w-3.5 text-[var(--app-hint)]" />
</span>
{hasScheduleTooltip ? (
<HoverTooltip
id={scheduleId!}
target={<ScheduleIcon className="h-3.5 w-3.5 text-[var(--app-hint)]" />}
side="bottom"
align="start"
className="shrink-0"
revealOnParentFocusClass={SESSION_ROW_TOOLTIP_FOCUS_CLASS}
>
<span className="block">
<span className="block font-medium">{scheduledLabel}</span>
<span className="mt-1 block text-[var(--app-hint)]">
{formatScheduledTooltipDetail(s, t)}
</span>
</span>
</HoverTooltip>
) : null}
</div>
<div className="flex items-center gap-2 shrink-0 text-xs">
+2 -1
View File
@@ -304,7 +304,8 @@ export function useSSE(options: {
const existing = existingIndex >= 0 ? previous.sessions[existingIndex] : undefined
const summary = {
...toSessionSummary(session),
futureScheduledMessageCount: existing?.futureScheduledMessageCount ?? 0
futureScheduledMessageCount: existing?.futureScheduledMessageCount ?? 0,
nextScheduledAt: existing?.nextScheduledAt ?? null
}
const nextSessions = previous.sessions.slice()
if (existingIndex >= 0) {
+13
View File
@@ -110,10 +110,23 @@ export default {
'session.item.newActivity': 'New activity',
'session.item.scheduledMessage': 'Scheduled message pending',
'session.item.scheduledMessages': '{count} scheduled messages pending',
'session.tooltip.permission.body': 'Approve:',
'session.tooltip.input.body': 'Reply to:',
'session.tooltip.background.count.one': '1 task running',
'session.tooltip.background.count.other': '{count} tasks running',
'session.tooltip.scheduled.body': 'Will fire when due.',
'session.tooltip.scheduled.fires': 'Fires {when}',
'session.tooltip.scheduled.next': 'Next {when} · +{more} more',
'session.tooltip.moreCount': '+{count} more',
'session.time.justNow': 'just now',
'session.time.minutesAgo': '{n}m ago',
'session.time.hoursAgo': '{n}h ago',
'session.time.daysAgo': '{n}d ago',
'session.time.inLessThanMinute': 'in <1m',
'session.time.inMinutes': 'in {n}m',
'session.time.inHours': 'in {n}h',
'session.time.inDays': 'in {n}d',
'session.time.soon': 'soon',
'session.time.importedFromCodex.justNow': 'just imported from Codex',
'session.time.importedFromCodex.minutesAgo': 'imported from Codex {n}m ago',
'session.time.importedFromCodex.hoursAgo': 'imported from Codex {n}h ago',
+13
View File
@@ -110,10 +110,23 @@ export default {
'session.item.newActivity': '有新活动',
'session.item.scheduledMessage': '有待发送的定时消息',
'session.item.scheduledMessages': '{count} 条定时消息待发送',
'session.tooltip.permission.body': '批准:',
'session.tooltip.input.body': '回复:',
'session.tooltip.background.count.one': '1 个任务运行中',
'session.tooltip.background.count.other': '{count} 个任务运行中',
'session.tooltip.scheduled.body': '到时会自动发送。',
'session.tooltip.scheduled.fires': '{when} 发送',
'session.tooltip.scheduled.next': '下次 {when} · 另有 {more} 条',
'session.tooltip.moreCount': '另有 {count} 条',
'session.time.justNow': '刚刚',
'session.time.minutesAgo': '{n} 分钟前',
'session.time.hoursAgo': '{n} 小时前',
'session.time.daysAgo': '{n} 天前',
'session.time.inLessThanMinute': '不到 1 分钟',
'session.time.inMinutes': '{n} 分钟后',
'session.time.inHours': '{n} 小时后',
'session.time.inDays': '{n} 天后',
'session.time.soon': '即将',
'session.time.importedFromCodex.justNow': '刚刚从codex客户端导入',
'session.time.importedFromCodex.minutesAgo': '{n} 分钟前从codex客户端导入',
'session.time.importedFromCodex.hoursAgo': '{n} 小时前从codex客户端导入',
+22
View File
@@ -0,0 +1,22 @@
/**
* Formats an epoch ms / s value as a localised "Nm ago" / "Nh ago" / date label.
* Accepts both ms and seconds; values smaller than 1e12 are treated as seconds.
*
* Returns `null` when the input is not finite.
*/
export function formatRelativeTime(
value: number,
t: (key: string, params?: Record<string, string | number>) => string
): string | null {
const ms = value < 1_000_000_000_000 ? value * 1000 : value
if (!Number.isFinite(ms)) return null
const delta = Date.now() - ms
if (delta < 60_000) return t('session.time.justNow')
const minutes = Math.floor(delta / 60_000)
if (minutes < 60) return t('session.time.minutesAgo', { n: minutes })
const hours = Math.floor(minutes / 60)
if (hours < 24) return t('session.time.hoursAgo', { n: hours })
const days = Math.floor(hours / 24)
if (days < 7) return t('session.time.daysAgo', { n: days })
return new Date(ms).toLocaleDateString()
}
+77
View File
@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest'
import {
formatFutureRelativeTime,
formatScheduledFireLabel,
formatScheduledTime,
formatScheduledTooltipDetail
} from './scheduledTime'
const t = (key: string, params?: Record<string, string | number>) => {
const table: Record<string, string> = {
'session.time.soon': 'soon',
'session.time.inLessThanMinute': 'in <1m',
'session.time.inMinutes': 'in {n}m',
'session.time.inHours': 'in {n}h',
'session.time.inDays': 'in {n}d',
'session.tooltip.scheduled.body': 'Will fire when due.',
'session.tooltip.scheduled.fires': 'Fires {when}',
'session.tooltip.scheduled.next': 'Next {when} · +{more} more',
}
let out = table[key] ?? key
if (params) {
for (const [k, v] of Object.entries(params)) {
out = out.replace(`{${k}}`, String(v))
}
}
return out
}
describe('formatFutureRelativeTime', () => {
it('returns in-minutes countdown for near-future timestamps', () => {
const inFive = Date.now() + 5 * 60_000
expect(formatFutureRelativeTime(inFive, t)).toBe('in 5m')
})
it('returns soon for past-due timestamps', () => {
expect(formatFutureRelativeTime(Date.now() - 1_000, t)).toBe('soon')
})
})
describe('formatScheduledFireLabel', () => {
it('combines relative and absolute labels', () => {
const at = Date.now() + 5 * 60_000
const label = formatScheduledFireLabel(at, t)
expect(label).toContain('in 5m')
expect(label).toContain('·')
expect(label).toContain(formatScheduledTime(at))
})
})
describe('formatScheduledTooltipDetail', () => {
it('shows single scheduled fire time', () => {
const at = Date.now() + 5 * 60_000
const body = formatScheduledTooltipDetail({
futureScheduledMessageCount: 1,
nextScheduledAt: at
}, t)
expect(body).toMatch(/^Fires /)
expect(body).toContain('in 5m')
})
it('shows next + overflow for multiple scheduled messages', () => {
const at = Date.now() + 5 * 60_000
const body = formatScheduledTooltipDetail({
futureScheduledMessageCount: 3,
nextScheduledAt: at
}, t)
expect(body).toContain('Next ')
expect(body).toContain('+2 more')
})
it('falls back when nextScheduledAt is missing', () => {
expect(formatScheduledTooltipDetail({
futureScheduledMessageCount: 1,
nextScheduledAt: null
}, t)).toBe('Will fire when due.')
})
})
+71
View File
@@ -0,0 +1,71 @@
/**
* Formatting helpers for scheduled-send UX (queued bar + session-list clock tooltip).
*/
/** Locale-aware absolute fire time, e.g. "Jun 16, 1:45 PM". */
export function formatScheduledTime(scheduledAt: number): string {
const date = new Date(scheduledAt)
const now = new Date()
const opts: Intl.DateTimeFormatOptions = {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}
if (date.getFullYear() !== now.getFullYear()) {
opts.year = 'numeric'
}
return date.toLocaleString(undefined, opts)
}
/** Relative countdown until a future epoch-ms, e.g. "in 5m". Returns null when invalid. */
export function formatFutureRelativeTime(
value: number,
t: (key: string, params?: Record<string, string | number>) => string
): string | null {
const ms = value < 1_000_000_000_000 ? value * 1000 : value
if (!Number.isFinite(ms)) return null
const delta = ms - Date.now()
if (delta <= 0) return t('session.time.soon')
if (delta < 60_000) return t('session.time.inLessThanMinute')
const minutes = Math.ceil(delta / 60_000)
if (minutes < 60) return t('session.time.inMinutes', { n: minutes })
const hours = Math.ceil(minutes / 60)
if (hours < 24) return t('session.time.inHours', { n: hours })
const days = Math.ceil(hours / 24)
if (days < 7) return t('session.time.inDays', { n: days })
return formatScheduledTime(ms)
}
/** "in 5m · Jun 16, 1:45 PM" for tooltip / queued copy. */
export function formatScheduledFireLabel(
scheduledAt: number,
t: (key: string, params?: Record<string, string | number>) => string
): string | null {
const relative = formatFutureRelativeTime(scheduledAt, t)
if (!relative) return null
const absolute = formatScheduledTime(scheduledAt)
// When the countdown is already an absolute date (>7d), don't duplicate.
if (relative === absolute) return relative
return `${relative} · ${absolute}`
}
/** Session-list clock tooltip body from summary fields. */
export function formatScheduledTooltipDetail(
summary: { futureScheduledMessageCount: number; nextScheduledAt: number | null },
t: (key: string, params?: Record<string, string | number>) => string
): string {
if (summary.nextScheduledAt != null) {
const when = formatScheduledFireLabel(summary.nextScheduledAt, t)
if (when) {
if (summary.futureScheduledMessageCount > 1) {
return t('session.tooltip.scheduled.next', {
when,
more: summary.futureScheduledMessageCount - 1
})
}
return t('session.tooltip.scheduled.fires', { when })
}
}
return t('session.tooltip.scheduled.body')
}
+2
View File
@@ -12,8 +12,10 @@ function makeSummary(overrides: Partial<SessionSummary> & { id: string }): Sessi
todoProgress: null,
pendingRequestsCount: 0,
pendingRequestKinds: [],
pendingRequests: [],
backgroundTaskCount: 0,
futureScheduledMessageCount: 0,
nextScheduledAt: null,
model: null,
effort: null,
...overrides
+2
View File
@@ -46,6 +46,8 @@ export type {
Metadata,
PermissionMode,
Machine,
PendingRequest,
PendingRequestKind,
RunnerState,
Session,
SessionPatch,