Skip to content

Commit 84f4723

Browse files
heavygeecursoragent
andcommitted
feat(web): show scheduled message clock in detailed session list
Add futureScheduledMessageCount to SessionSummary (hub batch query) and render the composer ScheduleIcon in Detailed mode when a session has future scheduled messages pending. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 220ed35 commit 84f4723

16 files changed

Lines changed: 167 additions & 28 deletions

hub/src/store/messageStore.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Database } from 'bun:sqlite'
22

33
import type { StoredMessage } from './types'
4-
import { addMessage, cancelQueuedMessage, deleteQueuedMessageById, lookupQueuedMessage, getMessages, getFirstMessages, getDeliverableMessagesAfter, getMessagesByPosition, getUninvokedLocalMessages, getMatureScheduledMessages, getImmediateQueuedLocalMessages, markMessagesInvoked, mergeSessionMessages, type CancelQueuedMessageResult, type LookupQueuedMessageResult } from './messages'
4+
import { addMessage, cancelQueuedMessage, deleteQueuedMessageById, lookupQueuedMessage, getMessages, getFirstMessages, getDeliverableMessagesAfter, getMessagesByPosition, getUninvokedLocalMessages, getMatureScheduledMessages, getImmediateQueuedLocalMessages, countFutureScheduledBySessionIds, countFutureScheduledLocalMessages, markMessagesInvoked, mergeSessionMessages, type CancelQueuedMessageResult, type LookupQueuedMessageResult } from './messages'
55

66
export class MessageStore {
77
private readonly db: Database
@@ -42,6 +42,14 @@ export class MessageStore {
4242
return getImmediateQueuedLocalMessages(this.db, sessionId)
4343
}
4444

45+
countFutureScheduledLocalMessages(sessionId: string, now: number = Date.now()): number {
46+
return countFutureScheduledLocalMessages(this.db, sessionId, now)
47+
}
48+
49+
countFutureScheduledBySessionIds(sessionIds: string[], now: number = Date.now()): Map<string, number> {
50+
return countFutureScheduledBySessionIds(this.db, sessionIds, now)
51+
}
52+
4553
cancelQueuedMessage(sessionId: string, messageId: string): CancelQueuedMessageResult {
4654
return cancelQueuedMessage(this.db, sessionId, messageId)
4755
}

hub/src/store/messages.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,3 +287,60 @@ describe('getDeliverableMessagesAfter: CLI backfill excludes future-scheduled ro
287287
expect(empty).toHaveLength(0)
288288
})
289289
})
290+
291+
describe('countFutureScheduledLocalMessages', () => {
292+
it('counts only future scheduled uninvoked local messages', () => {
293+
const store = makeStore()
294+
const session = makeSession(store, 'sched-count')
295+
const now = Date.now()
296+
297+
store.messages.addMessage(
298+
session.id,
299+
{ role: 'user', content: { type: 'text', text: 'immediate queued' } },
300+
'local-immediate'
301+
)
302+
store.messages.addMessage(
303+
session.id,
304+
{ role: 'user', content: { type: 'text', text: 'future scheduled' } },
305+
'local-future',
306+
now + 60_000
307+
)
308+
store.messages.addMessage(
309+
session.id,
310+
{ role: 'user', content: { type: 'text', text: 'mature scheduled' } },
311+
'local-mature',
312+
now - 1
313+
)
314+
315+
expect(store.messages.countFutureScheduledLocalMessages(session.id, now)).toBe(1)
316+
})
317+
318+
it('batch query returns counts keyed by session id', () => {
319+
const store = makeStore()
320+
const sessionA = makeSession(store, 'sched-batch-a')
321+
const sessionB = makeSession(store, 'sched-batch-b')
322+
const now = Date.now()
323+
324+
store.messages.addMessage(
325+
sessionA.id,
326+
{ role: 'user', content: { type: 'text', text: 'a1' } },
327+
'a-1',
328+
now + 60_000
329+
)
330+
store.messages.addMessage(
331+
sessionA.id,
332+
{ role: 'user', content: { type: 'text', text: 'a2' } },
333+
'a-2',
334+
now + 120_000
335+
)
336+
store.messages.addMessage(
337+
sessionB.id,
338+
{ role: 'user', content: { type: 'text', text: 'immediate' } },
339+
'b-1'
340+
)
341+
342+
const counts = store.messages.countFutureScheduledBySessionIds([sessionA.id, sessionB.id], now)
343+
expect(counts.get(sessionA.id)).toBe(2)
344+
expect(counts.get(sessionB.id)).toBeUndefined()
345+
})
346+
})

hub/src/store/messages.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,53 @@ export function getImmediateQueuedLocalMessages(
233233
return rows.map(toStoredMessage)
234234
}
235235

236+
/** Count uninvoked local messages scheduled for a future time (session list indicator). */
237+
export function countFutureScheduledLocalMessages(
238+
db: Database,
239+
sessionId: string,
240+
now: number
241+
): number {
242+
const row = db.prepare(`
243+
SELECT COUNT(*) AS count
244+
FROM messages
245+
WHERE session_id = ?
246+
AND invoked_at IS NULL
247+
AND local_id IS NOT NULL
248+
AND scheduled_at IS NOT NULL
249+
AND scheduled_at > ?
250+
`).get(sessionId, now) as { count: number } | undefined
251+
return row?.count ?? 0
252+
}
253+
254+
/** Batch variant for GET /sessions — one query for all session IDs in a namespace. */
255+
export function countFutureScheduledBySessionIds(
256+
db: Database,
257+
sessionIds: string[],
258+
now: number
259+
): Map<string, number> {
260+
const counts = new Map<string, number>()
261+
if (sessionIds.length === 0) {
262+
return counts
263+
}
264+
265+
const placeholders = sessionIds.map(() => '?').join(',')
266+
const rows = db.prepare(`
267+
SELECT session_id, COUNT(*) AS count
268+
FROM messages
269+
WHERE session_id IN (${placeholders})
270+
AND invoked_at IS NULL
271+
AND local_id IS NOT NULL
272+
AND scheduled_at IS NOT NULL
273+
AND scheduled_at > ?
274+
GROUP BY session_id
275+
`).all(...sessionIds, now) as { session_id: string; count: number }[]
276+
277+
for (const row of rows) {
278+
counts.set(row.session_id, row.count)
279+
}
280+
return counts
281+
}
282+
236283
export function getMaxSeq(db: Database, sessionId: string): number {
237284
const row = db.prepare(
238285
'SELECT COALESCE(MAX(seq), 0) AS maxSeq FROM messages WHERE session_id = ?'

hub/src/sync/syncEngine.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,10 @@ export class SyncEngine {
176176
return this.sessionCache.getSessionsByNamespace(namespace)
177177
}
178178

179+
getFutureScheduledMessageCounts(sessionIds: string[], now: number = Date.now()): Map<string, number> {
180+
return this.store.messages.countFutureScheduledBySessionIds(sessionIds, now)
181+
}
182+
179183
getSession(sessionId: string): Session | undefined {
180184
return this.sessionCache.getSession(sessionId) ?? this.sessionCache.refreshSession(sessionId) ?? undefined
181185
}

hub/src/web/routes/sessions.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho
6464
const getPendingCount = (s: Session) => s.agentState?.requests ? Object.keys(s.agentState.requests).length : 0
6565

6666
const namespace = c.get('namespace')
67-
const sessions = engine.getSessionsByNamespace(namespace)
67+
const sessionRecords = engine.getSessionsByNamespace(namespace)
6868
.sort((a, b) => {
6969
// Active sessions first
7070
if (a.active !== b.active) {
@@ -79,7 +79,14 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho
7979
// Then by updatedAt
8080
return b.updatedAt - a.updatedAt
8181
})
82-
.map(toSessionSummary)
82+
const scheduledCounts = engine.getFutureScheduledMessageCounts(sessionRecords.map((session) => session.id))
83+
const sessions = sessionRecords.map((session) => {
84+
const summary = toSessionSummary(session)
85+
return {
86+
...summary,
87+
futureScheduledMessageCount: scheduledCounts.get(session.id) ?? 0
88+
}
89+
})
8390

8491
return c.json({ sessions })
8592
})

shared/src/sessionSummary.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,5 +72,6 @@ describe('toSessionSummary', () => {
7272
expect(summary.pendingRequestKinds).toEqual(['input'])
7373
expect(summary.pendingRequestsCount).toBe(1)
7474
expect(summary.backgroundTaskCount).toBe(2)
75+
expect(summary.futureScheduledMessageCount).toBe(0)
7576
})
7677
})

shared/src/sessionSummary.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export type SessionSummary = {
3131
pendingRequestsCount: number
3232
pendingRequestKinds: PendingRequestKind[]
3333
backgroundTaskCount: number
34+
futureScheduledMessageCount: number
3435
model: string | null
3536
effort: string | null
3637
}
@@ -86,6 +87,7 @@ export function toSessionSummary(session: Session): SessionSummary {
8687
pendingRequestsCount,
8788
pendingRequestKinds: getPendingRequestKinds(session),
8889
backgroundTaskCount: session.backgroundTaskCount ?? 0,
90+
futureScheduledMessageCount: 0,
8991
model: session.model,
9092
effort: session.effort
9193
}

web/src/components/AssistantChat/ComposerButtons.tsx

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,11 @@
11
import { ComposerPrimitive } from '@assistant-ui/react'
22
import type { ConversationStatus } from '@/realtime/types'
33
import { useTranslation } from '@/lib/use-translation'
4+
import { ScheduleIcon } from '@/components/icons'
45
import { ScheduleTimePicker } from './ScheduleTimePicker'
56
import type { PendingSchedule } from './ScheduleTimePicker'
67
import { useRef, useState } from 'react'
78

8-
function ScheduleIcon() {
9-
return (
10-
<svg
11-
xmlns="http://www.w3.org/2000/svg"
12-
width="18"
13-
height="18"
14-
viewBox="0 0 24 24"
15-
fill="none"
16-
stroke="currentColor"
17-
strokeWidth="2"
18-
strokeLinecap="round"
19-
strokeLinejoin="round"
20-
>
21-
<circle cx="12" cy="12" r="9" />
22-
<polyline points="12 7 12 12 15.5 14" />
23-
</svg>
24-
)
25-
}
26-
279
function VoiceAssistantIcon() {
2810
return (
2911
<svg
@@ -460,7 +442,7 @@ export function ComposerButtons(props: {
460442
: 'text-[var(--app-fg)]/60 hover:bg-[var(--app-bg)] hover:text-[var(--app-fg)]'
461443
}`}
462444
>
463-
<ScheduleIcon />
445+
<ScheduleIcon className="h-[18px] w-[18px]" />
464446
</button>
465447
{showSchedulePicker && (
466448
<ScheduleTimePicker

web/src/components/SessionList.directory-action.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ function makeSession(overrides: Partial<SessionSummary> & { id: string }): Sessi
1919
pendingRequestsCount: 0,
2020
pendingRequestKinds: [],
2121
backgroundTaskCount: 0,
22+
futureScheduledMessageCount: 0,
2223
model: null,
2324
effort: null,
2425
...overrides

web/src/components/SessionList.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ function makeSession(overrides: Partial<SessionSummary> & { id: string }): Sessi
1313
pendingRequestsCount: 0,
1414
pendingRequestKinds: [],
1515
backgroundTaskCount: 0,
16+
futureScheduledMessageCount: 0,
1617
model: null,
1718
effort: null,
1819
...overrides

0 commit comments

Comments
 (0)