mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 = ?'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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客户端导入',
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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.')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -46,6 +46,8 @@ export type {
|
||||
Metadata,
|
||||
PermissionMode,
|
||||
Machine,
|
||||
PendingRequest,
|
||||
PendingRequestKind,
|
||||
RunnerState,
|
||||
Session,
|
||||
SessionPatch,
|
||||
|
||||
Reference in New Issue
Block a user