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
@@ -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,