Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions hub/src/store/messageStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
getImmediateQueuedLocalMessages,
countFutureScheduledBySessionIds,
countFutureScheduledLocalMessages,
minFutureScheduledAtBySessionIds,
countMessages,
markMessagesInvoked,
mergeSessionMessages,
Expand Down Expand Up @@ -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)
}
Expand Down
4 changes: 4 additions & 0 deletions hub/src/store/messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,5 +342,9 @@ describe('countFutureScheduledLocalMessages', () => {
const counts = store.messages.countFutureScheduledBySessionIds([sessionA.id, sessionB.id], now)
expect(counts.get(sessionA.id)).toBe(2)
expect(counts.get(sessionB.id)).toBeUndefined()

const nextAt = store.messages.minFutureScheduledAtBySessionIds([sessionA.id, sessionB.id], now)
expect(nextAt.get(sessionA.id)).toBe(now + 60_000)
expect(nextAt.get(sessionB.id)).toBeUndefined()
})
})
29 changes: 29 additions & 0 deletions hub/src/store/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ?'
Expand Down
4 changes: 4 additions & 0 deletions hub/src/sync/syncEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,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
}
Expand Down
4 changes: 3 additions & 1 deletion hub/src/web/routes/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
})

Expand Down
104 changes: 103 additions & 1 deletion shared/src/sessionSummary.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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'])
})
})
56 changes: 55 additions & 1 deletion shared/src/sessionSummary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand All @@ -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')
Expand Down Expand Up @@ -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
}
Expand Down
3 changes: 2 additions & 1 deletion shared/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion web/src/components/AssistantChat/QueuedMessagesBar.test.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down
17 changes: 1 addition & 16 deletions web/src/components/AssistantChat/QueuedMessagesBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading