diff --git a/hub/src/store/messageStore.ts b/hub/src/store/messageStore.ts index 99060bc927..394a497208 100644 --- a/hub/src/store/messageStore.ts +++ b/hub/src/store/messageStore.ts @@ -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 { + return minFutureScheduledAtBySessionIds(this.db, sessionIds, now) + } + countMessages(sessionId: string): number { return countMessages(this.db, sessionId) } diff --git a/hub/src/store/messages.test.ts b/hub/src/store/messages.test.ts index e569473962..6c164b3475 100644 --- a/hub/src/store/messages.test.ts +++ b/hub/src/store/messages.test.ts @@ -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() }) }) diff --git a/hub/src/store/messages.ts b/hub/src/store/messages.ts index bca78a0cc7..4747c81914 100644 --- a/hub/src/store/messages.ts +++ b/hub/src/store/messages.ts @@ -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 { + const nextAt = new Map() + 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 = ?' diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index a1e7368619..abbc7dcde0 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -190,6 +190,10 @@ export class SyncEngine { return this.store.messages.countFutureScheduledBySessionIds(sessionIds, now) } + getNextScheduledAtBySessionIds(sessionIds: string[], now: number = Date.now()): Map { + return this.store.messages.minFutureScheduledAtBySessionIds(sessionIds, now) + } + getSession(sessionId: string): Session | undefined { return this.sessionCache.getSession(sessionId) ?? this.sessionCache.refreshSession(sessionId) ?? undefined } diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index b6f84e6657..2b94f35224 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -82,11 +82,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 } }) diff --git a/shared/src/sessionSummary.test.ts b/shared/src/sessionSummary.test.ts index 8698896f34..c490c1bc75 100644 --- a/shared/src/sessionSummary.test.ts +++ b/shared/src/sessionSummary.test.ts @@ -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 { 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 = {} + 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 = {} + // 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']) + }) }) diff --git a/shared/src/sessionSummary.ts b/shared/src/sessionSummary.ts index 16421c4bed..32a3a48c23 100644 --- a/shared/src/sessionSummary.ts +++ b/shared/src/sessionSummary.ts @@ -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() 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 } diff --git a/shared/src/types.ts b/shared/src/types.ts index b10060a40e..9c38fda221 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -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 { diff --git a/web/src/components/AssistantChat/QueuedMessagesBar.test.tsx b/web/src/components/AssistantChat/QueuedMessagesBar.test.tsx index d8df623050..be98157241 100644 --- a/web/src/components/AssistantChat/QueuedMessagesBar.test.tsx +++ b/web/src/components/AssistantChat/QueuedMessagesBar.test.tsx @@ -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 diff --git a/web/src/components/AssistantChat/QueuedMessagesBar.tsx b/web/src/components/AssistantChat/QueuedMessagesBar.tsx index dbb6c8e1ca..371e70ce8b 100644 --- a/web/src/components/AssistantChat/QueuedMessagesBar.tsx +++ b/web/src/components/AssistantChat/QueuedMessagesBar.tsx @@ -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, diff --git a/web/src/components/HoverTooltip.test.tsx b/web/src/components/HoverTooltip.test.tsx new file mode 100644 index 0000000000..3cebecf77f --- /dev/null +++ b/web/src/components/HoverTooltip.test.tsx @@ -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( + icon} + revealOnParentFocusClass={SESSION_ROW_TOOLTIP_FOCUS_CLASS} + > + Scheduled copy + + ) + + 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 ( +
+ ) + } + + it('returns both ids and a combined describedBy when both indicators are present', () => { + render() + 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() + expect(screen.getByTestId('probe').getAttribute('data-describedby')).toBe('') + }) +}) diff --git a/web/src/components/HoverTooltip.tsx b/web/src/components/HoverTooltip.tsx new file mode 100644 index 0000000000..cc523f0620 --- /dev/null +++ b/web/src/components/HoverTooltip.tsx @@ -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 `