From 2a08b756ce3d4faf719fb41313ab15b6b3a9ee17 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Tue, 26 May 2026 11:27:59 +0100 Subject: [PATCH 01/13] feat(web): show session list attention indicators for blocked and unread Extend SessionSummary with pending request kinds and background task counts, classify sidebar attention state (permission, input, background, unread), and track last-seen timestamps client-side when opening sessions. Fixes tiann/hapi#698 Co-authored-by: Cursor --- shared/src/sessionSummary.test.ts | 76 +++++++++++++++++++ shared/src/sessionSummary.ts | 30 ++++++++ shared/src/types.ts | 2 +- .../components/SessionAttentionIndicator.tsx | 29 +++++++ .../SessionList.directory-action.test.tsx | 2 + web/src/components/SessionList.test.ts | 2 + web/src/components/SessionList.tsx | 18 ++++- web/src/hooks/useSSE.ts | 3 + web/src/lib/locales/en.ts | 4 + web/src/lib/locales/zh-CN.ts | 4 + web/src/lib/sessionAttention.test.ts | 59 ++++++++++++++ web/src/lib/sessionAttention.ts | 47 ++++++++++++ web/src/lib/sessionLastSeen.ts | 43 +++++++++++ web/src/router.tsx | 6 ++ 14 files changed, 323 insertions(+), 2 deletions(-) create mode 100644 shared/src/sessionSummary.test.ts create mode 100644 web/src/components/SessionAttentionIndicator.tsx create mode 100644 web/src/lib/sessionAttention.test.ts create mode 100644 web/src/lib/sessionAttention.ts create mode 100644 web/src/lib/sessionLastSeen.ts diff --git a/shared/src/sessionSummary.test.ts b/shared/src/sessionSummary.test.ts new file mode 100644 index 0000000000..5b288d2f58 --- /dev/null +++ b/shared/src/sessionSummary.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import type { Session } from './schemas' +import { getPendingRequestKinds, toSessionSummary } from './sessionSummary' + +function makeSession(overrides: Partial = {}): Session { + return { + id: 'session-1', + namespace: 'default', + active: true, + activeAt: 1000, + updatedAt: 2000, + metadata: { path: '/proj', host: 'local' }, + metadataVersion: 1, + agentState: null, + agentStateVersion: 0, + thinking: false, + thinkingAt: 0, + model: null, + modelReasoningEffort: null, + effort: null, + ...overrides + } +} + +describe('getPendingRequestKinds', () => { + it('classifies ask-user tools as input', () => { + const kinds = getPendingRequestKinds(makeSession({ + agentState: { + requests: { + req1: { tool: 'AskUserQuestion', arguments: {} } + } + } + })) + expect(kinds).toEqual(['input']) + }) + + it('classifies other pending tools as permission', () => { + const kinds = getPendingRequestKinds(makeSession({ + agentState: { + requests: { + req1: { tool: 'Bash', arguments: {} } + } + } + })) + expect(kinds).toEqual(['permission']) + }) + + it('returns both kinds when mixed requests are pending', () => { + const kinds = getPendingRequestKinds(makeSession({ + agentState: { + requests: { + req1: { tool: 'Bash', arguments: {} }, + req2: { tool: 'ask_user_question', arguments: {} } + } + } + })) + expect(kinds).toEqual(['permission', 'input']) + }) +}) + +describe('toSessionSummary', () => { + it('includes pending request kinds and background task count', () => { + const summary = toSessionSummary(makeSession({ + backgroundTaskCount: 2, + agentState: { + requests: { + req1: { tool: 'ExitPlanMode', arguments: {} } + } + } + })) + + expect(summary.pendingRequestKinds).toEqual(['input']) + expect(summary.pendingRequestsCount).toBe(1) + expect(summary.backgroundTaskCount).toBe(2) + }) +}) diff --git a/shared/src/sessionSummary.ts b/shared/src/sessionSummary.ts index 13e580fc8b..20336dd6ac 100644 --- a/shared/src/sessionSummary.ts +++ b/shared/src/sessionSummary.ts @@ -1,5 +1,15 @@ import type { Session, WorktreeMetadata } from './schemas' +export type PendingRequestKind = 'permission' | 'input' + +const INPUT_REQUEST_TOOLS = new Set([ + 'AskUserQuestion', + 'ask_user_question', + 'ExitPlanMode', + 'exit_plan_mode', + 'request_user_input' +]) + export type SessionSummaryMetadata = { name?: string path: string @@ -19,10 +29,28 @@ export type SessionSummary = { metadata: SessionSummaryMetadata | null todoProgress: { completed: number; total: number } | null pendingRequestsCount: number + pendingRequestKinds: PendingRequestKind[] + backgroundTaskCount: number model: string | null effort: string | null } +export function getPendingRequestKinds(session: Session): PendingRequestKind[] { + const requests = session.agentState?.requests + if (!requests) { + return [] + } + + const kinds = new Set() + for (const request of Object.values(requests)) { + kinds.add(INPUT_REQUEST_TOOLS.has(request.tool) ? 'input' : 'permission') + } + + return kinds.has('permission') && kinds.has('input') + ? ['permission', 'input'] + : Array.from(kinds) +} + export function toSessionSummary(session: Session): SessionSummary { const pendingRequestsCount = session.agentState?.requests ? Object.keys(session.agentState.requests).length : 0 @@ -56,6 +84,8 @@ export function toSessionSummary(session: Session): SessionSummary { metadata, todoProgress, pendingRequestsCount, + pendingRequestKinds: getPendingRequestKinds(session), + backgroundTaskCount: session.backgroundTaskCount ?? 0, model: session.model, effort: session.effort } diff --git a/shared/src/types.ts b/shared/src/types.ts index aaeab1b144..b10060a40e 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -24,7 +24,7 @@ export type { WorktreeMetadata } from './schemas' -export type { SessionSummary, SessionSummaryMetadata } from './sessionSummary' +export type { SessionSummary, SessionSummaryMetadata, PendingRequestKind } from './sessionSummary' export { AGENT_MESSAGE_PAYLOAD_TYPE } from './modes' export type { diff --git a/web/src/components/SessionAttentionIndicator.tsx b/web/src/components/SessionAttentionIndicator.tsx new file mode 100644 index 0000000000..e5bb8a8a12 --- /dev/null +++ b/web/src/components/SessionAttentionIndicator.tsx @@ -0,0 +1,29 @@ +import type { SessionAttention } from '@/lib/sessionAttention' +import { getSessionAttentionLabelKey } from '@/lib/sessionAttention' + +const ATTENTION_DOT_CLASS: Record = { + permission: 'bg-amber-500 animate-pulse', + input: 'bg-blue-500', + background: 'bg-blue-400', + unread: 'bg-[var(--app-link)]' +} + +export function SessionAttentionIndicator(props: { + attention: SessionAttention + label: string +}) { + return ( + + ) +} + +export function getAttentionLabel( + attention: SessionAttention, + t: (key: string) => string +): string { + return t(getSessionAttentionLabelKey(attention)) +} diff --git a/web/src/components/SessionList.directory-action.test.tsx b/web/src/components/SessionList.directory-action.test.tsx index f5243d266f..f979c5b412 100644 --- a/web/src/components/SessionList.directory-action.test.tsx +++ b/web/src/components/SessionList.directory-action.test.tsx @@ -17,6 +17,8 @@ function makeSession(overrides: Partial & { id: string }): Sessi metadata: null, todoProgress: null, pendingRequestsCount: 0, + pendingRequestKinds: [], + backgroundTaskCount: 0, model: null, effort: null, ...overrides diff --git a/web/src/components/SessionList.test.ts b/web/src/components/SessionList.test.ts index aa6402824b..3a7fcbc9fa 100644 --- a/web/src/components/SessionList.test.ts +++ b/web/src/components/SessionList.test.ts @@ -11,6 +11,8 @@ function makeSession(overrides: Partial & { id: string }): Sessi metadata: null, todoProgress: null, pendingRequestsCount: 0, + pendingRequestKinds: [], + backgroundTaskCount: 0, model: null, effort: null, ...overrides diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index ebf51a9d4c..8f75cf2b03 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -12,6 +12,9 @@ import { cn } from '@/lib/utils' import { useTranslation } from '@/lib/use-translation' import { DEFAULT_SESSION_PREVIEW_LIMIT, useSessionPreviewLimit } from '@/hooks/useSessionPreviewLimit' import { AgentFlavorIcon } from '@/components/AgentFlavorIcon' +import { classifySessionAttention } from '@/lib/sessionAttention' +import { getSessionLastSeenAt } from '@/lib/sessionLastSeen' +import { getAttentionLabel, SessionAttentionIndicator } from '@/components/SessionAttentionIndicator' type SessionGroup = { key: string @@ -555,6 +558,14 @@ function SessionItem(props: { const sessionName = getSessionTitle(s) const todoProgress = getTodoProgress(s) + const attention = useMemo( + () => classifySessionAttention(s, { + selected, + lastSeenAt: getSessionLastSeenAt(s.id) + }), + [s, selected] + ) + const attentionLabel = attention ? getAttentionLabel(attention, t) : null return ( <> + + {isSessionListStatusOpen && ( +
+ {sessionListStatusModeOptions.map((opt) => { + const isSelected = sessionListStatusMode === opt.value + return ( + + ) + })} +
+ )} + + {sessionListStatusMode === 'detailed' ? ( +
+ {t('settings.display.sessionListStatus.detailedDescription')} +
+ ) : null} {/* Chat section */} From b0347ba32e6d67bbd91a88dc90b6767dd7ff5e44 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Tue, 26 May 2026 16:09:13 +0100 Subject: [PATCH 05/13] fix(web): always record session last-seen watermark Track selected session updatedAt in standard and detailed modes so enabling detailed status does not flood unread dots. Ignore localStorage write failures on the write path. Co-authored-by: Cursor --- web/src/lib/sessionLastSeen.test.ts | 12 +++++++++++- web/src/lib/sessionLastSeen.ts | 6 +++++- web/src/router.tsx | 6 ++---- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/web/src/lib/sessionLastSeen.test.ts b/web/src/lib/sessionLastSeen.test.ts index 74aadf9b1b..556c7e15ea 100644 --- a/web/src/lib/sessionLastSeen.test.ts +++ b/web/src/lib/sessionLastSeen.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, beforeEach } from 'vitest' +import { describe, expect, it, beforeEach, vi } from 'vitest' import { getSessionLastSeenAt, markSessionSeen } from './sessionLastSeen' describe('sessionLastSeen', () => { @@ -17,4 +17,14 @@ describe('sessionLastSeen', () => { markSessionSeen('session-a', 2000) expect(getSessionLastSeenAt('session-a')).toBe(5000) }) + + it('ignores localStorage write failures', () => { + const setItem = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('quota exceeded') + }) + + expect(() => markSessionSeen('session-a', 1000)).not.toThrow() + + setItem.mockRestore() + }) }) diff --git a/web/src/lib/sessionLastSeen.ts b/web/src/lib/sessionLastSeen.ts index e7c335a632..0f56f2bd39 100644 --- a/web/src/lib/sessionLastSeen.ts +++ b/web/src/lib/sessionLastSeen.ts @@ -26,7 +26,11 @@ function writeStore(store: LastSeenStore): void { if (typeof localStorage === 'undefined') { return } - localStorage.setItem(STORAGE_KEY, JSON.stringify(store)) + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(store)) + } catch { + // Ignore storage errors + } } export function getSessionLastSeenAt(sessionId: string): number { diff --git a/web/src/router.tsx b/web/src/router.tsx index 16bf32cb07..2b9fc75395 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -35,7 +35,6 @@ import { useTranslation } from '@/lib/use-translation' import { fetchLatestMessages, seedMessageWindowFromSession } from '@/lib/message-window-store' import { clearDraftsAfterSend } from '@/lib/clearDraftsAfterSend' import { markSessionSeen } from '@/lib/sessionLastSeen' -import { useSessionListStatusMode } from '@/hooks/useSessionListStatusMode' import type { Machine } from '@/types/api' import FilesPage from '@/routes/sessions/files' import FilePage from '@/routes/sessions/file' @@ -155,13 +154,12 @@ function SessionsPage() { () => sessions.find((session) => session.id === selectedSessionId) ?? null, [sessions, selectedSessionId] ) - const { sessionListStatusMode } = useSessionListStatusMode() useEffect(() => { - if (sessionListStatusMode !== 'detailed' || !selectedSessionId || !selectedSession) { + if (!selectedSessionId || !selectedSession) { return } markSessionSeen(selectedSessionId, selectedSession.updatedAt) - }, [sessionListStatusMode, selectedSessionId, selectedSession?.updatedAt]) + }, [selectedSessionId, selectedSession?.updatedAt]) const isSessionsIndex = pathname === '/sessions' || pathname === '/sessions/' const sidebar = useSidebarResize() const handleNewSessionInDirectory = useCallback((args: { machineId: string | null; directory: string }) => { From bf212cf80d9a4a8480833011628a80af3b750e0b Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Tue, 26 May 2026 16:15:09 +0100 Subject: [PATCH 06/13] fix(web): guard localStorage getter in session last-seen store Access window.localStorage inside try/catch so embedded browsers that throw on the property getter degrade safely. Co-authored-by: Cursor --- web/src/lib/sessionLastSeen.test.ts | 17 +++++++++++++++++ web/src/lib/sessionLastSeen.ts | 21 +++++++++++++++++---- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/web/src/lib/sessionLastSeen.test.ts b/web/src/lib/sessionLastSeen.test.ts index 556c7e15ea..2ce40c200b 100644 --- a/web/src/lib/sessionLastSeen.test.ts +++ b/web/src/lib/sessionLastSeen.test.ts @@ -27,4 +27,21 @@ describe('sessionLastSeen', () => { setItem.mockRestore() }) + + it('returns zero when localStorage getter throws', () => { + const localStorageDescriptor = Object.getOwnPropertyDescriptor(window, 'localStorage') + Object.defineProperty(window, 'localStorage', { + configurable: true, + get() { + throw new Error('storage denied') + }, + }) + + expect(getSessionLastSeenAt('session-a')).toBe(0) + expect(() => markSessionSeen('session-a', 1000)).not.toThrow() + + if (localStorageDescriptor) { + Object.defineProperty(window, 'localStorage', localStorageDescriptor) + } + }) }) diff --git a/web/src/lib/sessionLastSeen.ts b/web/src/lib/sessionLastSeen.ts index 0f56f2bd39..ccdcbed930 100644 --- a/web/src/lib/sessionLastSeen.ts +++ b/web/src/lib/sessionLastSeen.ts @@ -2,13 +2,25 @@ const STORAGE_KEY = 'hapi.sessionLastSeen.v1' type LastSeenStore = Record +function getLocalStorage(): Storage | null { + if (typeof window === 'undefined') { + return null + } + try { + return window.localStorage + } catch { + return null + } +} + function readStore(): LastSeenStore { - if (typeof localStorage === 'undefined') { + const storage = getLocalStorage() + if (!storage) { return {} } try { - const raw = localStorage.getItem(STORAGE_KEY) + const raw = storage.getItem(STORAGE_KEY) if (!raw) { return {} } @@ -23,11 +35,12 @@ function readStore(): LastSeenStore { } function writeStore(store: LastSeenStore): void { - if (typeof localStorage === 'undefined') { + const storage = getLocalStorage() + if (!storage) { return } try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(store)) + storage.setItem(STORAGE_KEY, JSON.stringify(store)) } catch { // Ignore storage errors } From f9f96bee11de2bcebfb47fe78ddc569ffc3d7635 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Tue, 26 May 2026 16:20:31 +0100 Subject: [PATCH 07/13] chore(test): run shared workspace tests in root test script Wire shared tests into bun run test so sessionSummary coverage runs in CI. Co-authored-by: Cursor --- package.json | 3 ++- shared/package.json | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 3bf9766b0e..3d57f453e6 100644 --- a/package.json +++ b/package.json @@ -18,10 +18,11 @@ "typecheck:cli": "cd cli && bun run typecheck", "typecheck:hub": "cd hub && bun run typecheck", "typecheck:web": "cd web && bun run typecheck", - "test": "bun run test:cli && bun run test:hub && bun run test:web", + "test": "bun run test:cli && bun run test:hub && bun run test:web && bun run test:shared", "test:cli": "cd cli && bun run test", "test:hub": "cd hub && bun run test", "test:web": "cd web && bun run test", + "test:shared": "cd shared && bun run test", "clean-session": "bun run hub/scripts/cleanup-sessions.ts", "release-all": "cd cli && bun run release-all" }, diff --git a/shared/package.json b/shared/package.json index d81bf2cbd8..f19d0bc08c 100644 --- a/shared/package.json +++ b/shared/package.json @@ -18,6 +18,9 @@ "./voice": "./src/voice.ts" }, "sideEffects": false, + "scripts": { + "test": "bun test" + }, "dependencies": { "zod": "^4.2.1" } From efe90f08f2d56b21a642b7a0ac72df49647071df Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Tue, 26 May 2026 16:27:01 +0100 Subject: [PATCH 08/13] fix(test): use bun:test in shared sessionSummary tests Match the shared package test runner so root bun run test succeeds. Co-authored-by: Cursor --- shared/src/sessionSummary.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/src/sessionSummary.test.ts b/shared/src/sessionSummary.test.ts index 5b288d2f58..af8cc27e4c 100644 --- a/shared/src/sessionSummary.test.ts +++ b/shared/src/sessionSummary.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it } from 'bun:test' import type { Session } from './schemas' import { getPendingRequestKinds, toSessionSummary } from './sessionSummary' From 8995d8ddb99b3a77b335fde74ed32f6541170326 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Tue, 26 May 2026 18:57:52 +0100 Subject: [PATCH 09/13] 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 --- hub/src/store/messageStore.ts | 10 +++- hub/src/store/messages.test.ts | 57 +++++++++++++++++++ hub/src/store/messages.ts | 47 +++++++++++++++ hub/src/sync/syncEngine.ts | 4 ++ hub/src/web/routes/sessions.ts | 11 +++- shared/src/sessionSummary.test.ts | 1 + shared/src/sessionSummary.ts | 2 + .../AssistantChat/ComposerButtons.tsx | 22 +------ .../SessionList.directory-action.test.tsx | 1 + web/src/components/SessionList.test.ts | 1 + web/src/components/SessionList.tsx | 10 +++- web/src/components/icons.tsx | 12 ++++ web/src/hooks/useSSE.ts | 8 ++- web/src/lib/locales/en.ts | 4 +- web/src/lib/locales/zh-CN.ts | 4 +- web/src/lib/sessionAttention.test.ts | 1 + 16 files changed, 167 insertions(+), 28 deletions(-) diff --git a/hub/src/store/messageStore.ts b/hub/src/store/messageStore.ts index 90fe0b162b..fbd99e42c1 100644 --- a/hub/src/store/messageStore.ts +++ b/hub/src/store/messageStore.ts @@ -1,7 +1,7 @@ import type { Database } from 'bun:sqlite' import type { StoredMessage } from './types' -import { addMessage, cancelQueuedMessage, deleteQueuedMessageById, lookupQueuedMessage, getMessages, getFirstMessages, getDeliverableMessagesAfter, getMessagesByPosition, getUninvokedLocalMessages, getMatureScheduledMessages, getImmediateQueuedLocalMessages, markMessagesInvoked, mergeSessionMessages, type CancelQueuedMessageResult, type LookupQueuedMessageResult } from './messages' +import { addMessage, cancelQueuedMessage, deleteQueuedMessageById, lookupQueuedMessage, getMessages, getFirstMessages, getDeliverableMessagesAfter, getMessagesByPosition, getUninvokedLocalMessages, getMatureScheduledMessages, getImmediateQueuedLocalMessages, countFutureScheduledBySessionIds, countFutureScheduledLocalMessages, markMessagesInvoked, mergeSessionMessages, type CancelQueuedMessageResult, type LookupQueuedMessageResult } from './messages' export class MessageStore { private readonly db: Database @@ -42,6 +42,14 @@ export class MessageStore { return getImmediateQueuedLocalMessages(this.db, sessionId) } + countFutureScheduledLocalMessages(sessionId: string, now: number = Date.now()): number { + return countFutureScheduledLocalMessages(this.db, sessionId, now) + } + + countFutureScheduledBySessionIds(sessionIds: string[], now: number = Date.now()): Map { + return countFutureScheduledBySessionIds(this.db, sessionIds, now) + } + cancelQueuedMessage(sessionId: string, messageId: string): CancelQueuedMessageResult { return cancelQueuedMessage(this.db, sessionId, messageId) } diff --git a/hub/src/store/messages.test.ts b/hub/src/store/messages.test.ts index f6ad990cdb..e569473962 100644 --- a/hub/src/store/messages.test.ts +++ b/hub/src/store/messages.test.ts @@ -287,3 +287,60 @@ describe('getDeliverableMessagesAfter: CLI backfill excludes future-scheduled ro expect(empty).toHaveLength(0) }) }) + +describe('countFutureScheduledLocalMessages', () => { + it('counts only future scheduled uninvoked local messages', () => { + const store = makeStore() + const session = makeSession(store, 'sched-count') + const now = Date.now() + + store.messages.addMessage( + session.id, + { role: 'user', content: { type: 'text', text: 'immediate queued' } }, + 'local-immediate' + ) + store.messages.addMessage( + session.id, + { role: 'user', content: { type: 'text', text: 'future scheduled' } }, + 'local-future', + now + 60_000 + ) + store.messages.addMessage( + session.id, + { role: 'user', content: { type: 'text', text: 'mature scheduled' } }, + 'local-mature', + now - 1 + ) + + expect(store.messages.countFutureScheduledLocalMessages(session.id, now)).toBe(1) + }) + + it('batch query returns counts keyed by session id', () => { + const store = makeStore() + const sessionA = makeSession(store, 'sched-batch-a') + const sessionB = makeSession(store, 'sched-batch-b') + const now = Date.now() + + store.messages.addMessage( + sessionA.id, + { role: 'user', content: { type: 'text', text: 'a1' } }, + 'a-1', + now + 60_000 + ) + store.messages.addMessage( + sessionA.id, + { role: 'user', content: { type: 'text', text: 'a2' } }, + 'a-2', + now + 120_000 + ) + store.messages.addMessage( + sessionB.id, + { role: 'user', content: { type: 'text', text: 'immediate' } }, + 'b-1' + ) + + const counts = store.messages.countFutureScheduledBySessionIds([sessionA.id, sessionB.id], now) + expect(counts.get(sessionA.id)).toBe(2) + expect(counts.get(sessionB.id)).toBeUndefined() + }) +}) diff --git a/hub/src/store/messages.ts b/hub/src/store/messages.ts index 019c403fdd..2c32cded35 100644 --- a/hub/src/store/messages.ts +++ b/hub/src/store/messages.ts @@ -233,6 +233,53 @@ export function getImmediateQueuedLocalMessages( return rows.map(toStoredMessage) } +/** Count uninvoked local messages scheduled for a future time (session list indicator). */ +export function countFutureScheduledLocalMessages( + db: Database, + sessionId: string, + now: number +): number { + const row = db.prepare(` + SELECT COUNT(*) AS count + FROM messages + WHERE session_id = ? + AND invoked_at IS NULL + AND local_id IS NOT NULL + AND scheduled_at IS NOT NULL + AND scheduled_at > ? + `).get(sessionId, now) as { count: number } | undefined + return row?.count ?? 0 +} + +/** Batch variant for GET /sessions — one query for all session IDs in a namespace. */ +export function countFutureScheduledBySessionIds( + db: Database, + sessionIds: string[], + now: number +): Map { + const counts = new Map() + if (sessionIds.length === 0) { + return counts + } + + const placeholders = sessionIds.map(() => '?').join(',') + const rows = db.prepare(` + SELECT session_id, COUNT(*) AS count + 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; count: number }[] + + for (const row of rows) { + counts.set(row.session_id, row.count) + } + return counts +} + 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 9d6ebd07eb..401f4d4426 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -176,6 +176,10 @@ export class SyncEngine { return this.sessionCache.getSessionsByNamespace(namespace) } + getFutureScheduledMessageCounts(sessionIds: string[], now: number = Date.now()): Map { + return this.store.messages.countFutureScheduledBySessionIds(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 f0555e9dc3..34c8547378 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -64,7 +64,7 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho const getPendingCount = (s: Session) => s.agentState?.requests ? Object.keys(s.agentState.requests).length : 0 const namespace = c.get('namespace') - const sessions = engine.getSessionsByNamespace(namespace) + const sessionRecords = engine.getSessionsByNamespace(namespace) .sort((a, b) => { // Active sessions first if (a.active !== b.active) { @@ -79,7 +79,14 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho // Then by updatedAt return b.updatedAt - a.updatedAt }) - .map(toSessionSummary) + const scheduledCounts = engine.getFutureScheduledMessageCounts(sessionRecords.map((session) => session.id)) + const sessions = sessionRecords.map((session) => { + const summary = toSessionSummary(session) + return { + ...summary, + futureScheduledMessageCount: scheduledCounts.get(session.id) ?? 0 + } + }) return c.json({ sessions }) }) diff --git a/shared/src/sessionSummary.test.ts b/shared/src/sessionSummary.test.ts index af8cc27e4c..a82b8b7749 100644 --- a/shared/src/sessionSummary.test.ts +++ b/shared/src/sessionSummary.test.ts @@ -72,5 +72,6 @@ describe('toSessionSummary', () => { expect(summary.pendingRequestKinds).toEqual(['input']) expect(summary.pendingRequestsCount).toBe(1) expect(summary.backgroundTaskCount).toBe(2) + expect(summary.futureScheduledMessageCount).toBe(0) }) }) diff --git a/shared/src/sessionSummary.ts b/shared/src/sessionSummary.ts index 20336dd6ac..97e45ee3f0 100644 --- a/shared/src/sessionSummary.ts +++ b/shared/src/sessionSummary.ts @@ -31,6 +31,7 @@ export type SessionSummary = { pendingRequestsCount: number pendingRequestKinds: PendingRequestKind[] backgroundTaskCount: number + futureScheduledMessageCount: number model: string | null effort: string | null } @@ -86,6 +87,7 @@ export function toSessionSummary(session: Session): SessionSummary { pendingRequestsCount, pendingRequestKinds: getPendingRequestKinds(session), backgroundTaskCount: session.backgroundTaskCount ?? 0, + futureScheduledMessageCount: 0, model: session.model, effort: session.effort } diff --git a/web/src/components/AssistantChat/ComposerButtons.tsx b/web/src/components/AssistantChat/ComposerButtons.tsx index cff59782f8..28d1fcc88a 100644 --- a/web/src/components/AssistantChat/ComposerButtons.tsx +++ b/web/src/components/AssistantChat/ComposerButtons.tsx @@ -1,29 +1,11 @@ import { ComposerPrimitive } from '@assistant-ui/react' import type { ConversationStatus } from '@/realtime/types' import { useTranslation } from '@/lib/use-translation' +import { ScheduleIcon } from '@/components/icons' import { ScheduleTimePicker } from './ScheduleTimePicker' import type { PendingSchedule } from './ScheduleTimePicker' import { useRef, useState } from 'react' -function ScheduleIcon() { - return ( - - - - - ) -} - function VoiceAssistantIcon() { return ( - + {showSchedulePicker && ( & { id: string }): Sessi pendingRequestsCount: 0, pendingRequestKinds: [], backgroundTaskCount: 0, + futureScheduledMessageCount: 0, model: null, effort: null, ...overrides diff --git a/web/src/components/SessionList.test.ts b/web/src/components/SessionList.test.ts index 3a7fcbc9fa..23a4deecb2 100644 --- a/web/src/components/SessionList.test.ts +++ b/web/src/components/SessionList.test.ts @@ -13,6 +13,7 @@ function makeSession(overrides: Partial & { id: string }): Sessi pendingRequestsCount: 0, pendingRequestKinds: [], backgroundTaskCount: 0, + futureScheduledMessageCount: 0, model: null, effort: null, ...overrides diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index ee3bc9e0d7..40ffc81d65 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -7,7 +7,7 @@ import { useSessionActions } from '@/hooks/mutations/useSessionActions' import { SessionActionMenu } from '@/components/SessionActionMenu' import { RenameSessionDialog } from '@/components/RenameSessionDialog' import { ConfirmDialog } from '@/components/ui/ConfirmDialog' -import { CopyIcon, CheckIcon } from '@/components/icons' +import { CopyIcon, CheckIcon, ScheduleIcon } from '@/components/icons' import { cn } from '@/lib/utils' import { useTranslation } from '@/lib/use-translation' import { DEFAULT_SESSION_PREVIEW_LIMIT, useSessionPreviewLimit } from '@/hooks/useSessionPreviewLimit' @@ -570,6 +570,9 @@ function SessionItem(props: { [s, selected, showDetailedStatus] ) const attentionLabel = attention ? getAttentionLabel(attention, t) : null + const scheduledLabel = s.futureScheduledMessageCount > 1 + ? t('session.item.scheduledMessages', { count: s.futureScheduledMessageCount }) + : t('session.item.scheduledMessage') return ( <> ) : null} + {showDetailedStatus && s.futureScheduledMessageCount > 0 ? ( + + + + ) : null}
{todoProgress ? ( diff --git a/web/src/components/icons.tsx b/web/src/components/icons.tsx index 5200e05fa6..5e58714463 100644 --- a/web/src/components/icons.tsx +++ b/web/src/components/icons.tsx @@ -60,3 +60,15 @@ export function CheckIcon(props: IconProps) { 2 ) } + +/** Composer schedule-send clock — circle + hands (matches ComposerButtons). */ +export function ScheduleIcon(props: IconProps) { + return createIcon( + <> + + + , + props, + 2 + ) +} diff --git a/web/src/hooks/useSSE.ts b/web/src/hooks/useSSE.ts index ce92ce9e95..ce02a05b08 100644 --- a/web/src/hooks/useSSE.ts +++ b/web/src/hooks/useSSE.ts @@ -299,9 +299,13 @@ export function useSSE(options: { return previous } - const summary = toSessionSummary(session) + const existingIndex = previous.sessions.findIndex((item) => item.id === session.id) + const existing = existingIndex >= 0 ? previous.sessions[existingIndex] : undefined + const summary = { + ...toSessionSummary(session), + futureScheduledMessageCount: existing?.futureScheduledMessageCount ?? 0 + } const nextSessions = previous.sessions.slice() - const existingIndex = nextSessions.findIndex((item) => item.id === session.id) if (existingIndex >= 0) { nextSessions[existingIndex] = summary } else { diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index db801f44fa..8f6ebf7c1f 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -65,6 +65,8 @@ export default { 'session.item.needsInput': 'Needs input', 'session.item.background': 'Background tasks running', 'session.item.newActivity': 'New activity', + 'session.item.scheduledMessage': 'Scheduled message pending', + 'session.item.scheduledMessages': '{count} scheduled messages pending', 'session.time.justNow': 'just now', 'session.time.minutesAgo': '{n}m ago', 'session.time.hoursAgo': '{n}h ago', @@ -393,7 +395,7 @@ export default { 'settings.display.sessionListStatus': 'Session list status', 'settings.display.sessionListStatus.standard': 'Standard', 'settings.display.sessionListStatus.detailed': 'Detailed', - 'settings.display.sessionListStatus.detailedDescription': 'Shows why a session stopped: permission, input, background work, or new activity.', + 'settings.display.sessionListStatus.detailedDescription': 'Shows why a session stopped: permission, input, background work, new activity, or a scheduled message (clock icon).', 'settings.chat.title': 'Chat', 'settings.chat.enterBehavior': 'Enter Key', 'settings.chat.enterBehavior.send': 'Send message', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 2d4608f19e..763170ab29 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -65,6 +65,8 @@ export default { 'session.item.needsInput': '需要输入', 'session.item.background': '后台任务运行中', 'session.item.newActivity': '有新活动', + 'session.item.scheduledMessage': '有待发送的定时消息', + 'session.item.scheduledMessages': '{count} 条定时消息待发送', 'session.time.justNow': '刚刚', 'session.time.minutesAgo': '{n} 分钟前', 'session.time.hoursAgo': '{n} 小时前', @@ -395,7 +397,7 @@ export default { 'settings.display.sessionListStatus': '会话列表状态', 'settings.display.sessionListStatus.standard': '标准', 'settings.display.sessionListStatus.detailed': '详细', - 'settings.display.sessionListStatus.detailedDescription': '显示会话停止的原因:权限、输入、后台任务或新活动。', + 'settings.display.sessionListStatus.detailedDescription': '显示会话停止的原因:权限、输入、后台任务、新活动或定时消息(时钟图标)。', 'settings.chat.title': '聊天', 'settings.chat.enterBehavior': '回车键行为', 'settings.chat.enterBehavior.send': '发送消息', diff --git a/web/src/lib/sessionAttention.test.ts b/web/src/lib/sessionAttention.test.ts index 28162c580a..919d6c631a 100644 --- a/web/src/lib/sessionAttention.test.ts +++ b/web/src/lib/sessionAttention.test.ts @@ -13,6 +13,7 @@ function makeSummary(overrides: Partial & { id: string }): Sessi pendingRequestsCount: 0, pendingRequestKinds: [], backgroundTaskCount: 0, + futureScheduledMessageCount: 0, model: null, effort: null, ...overrides From 57334d64057ee128e37924101b95f92eb78fe40f Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Wed, 27 May 2026 03:08:26 +0100 Subject: [PATCH 10/13] fix(web): refresh scheduled counts on global SSE message events Invalidate session list when scheduled messages are received, cancelled, or consumed so futureScheduledMessageCount stays accurate in Detailed mode. Co-authored-by: Cursor --- web/src/hooks/useSSE.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/web/src/hooks/useSSE.ts b/web/src/hooks/useSSE.ts index ce02a05b08..5300a639d3 100644 --- a/web/src/hooks/useSSE.ts +++ b/web/src/hooks/useSSE.ts @@ -447,7 +447,10 @@ export function useSSE(options: { } if (scope === 'global' && MESSAGE_STREAM_EVENT_TYPES.has(event.type)) { - if (event.type === 'message-received') { + if (event.type === 'message-received' && event.message.scheduledAt != null) { + queueSessionListInvalidation() + } + if (event.type === 'message-cancelled' || event.type === 'messages-consumed') { queueSessionListInvalidation() } onEventRef.current(event) From 5901260ccbada529e48cdc79e02a66aba1d27a43 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Thu, 28 May 2026 23:10:21 +0100 Subject: [PATCH 11/13] fix(hub): emit scheduled-matured SSE for session list refresh When the mature-scan releases scheduled messages to the CLI, notify web clients so futureScheduledMessageCount drops without waiting for consume. Co-authored-by: Cursor --- hub/src/sse/sseManager.ts | 2 +- hub/src/sync/messageService.test.ts | 18 ++++++++++++++++++ hub/src/sync/messageService.ts | 5 +++++ shared/src/schemas.ts | 3 +++ web/src/hooks/useSSE.test.ts | 1 + web/src/hooks/useSSE.ts | 9 +++++++-- 6 files changed, 35 insertions(+), 3 deletions(-) diff --git a/hub/src/sse/sseManager.ts b/hub/src/sse/sseManager.ts index 8461c558d5..e4fec1ad89 100644 --- a/hub/src/sse/sseManager.ts +++ b/hub/src/sse/sseManager.ts @@ -155,7 +155,7 @@ export class SSEManager { } } - if (event.type === 'message-received') { + if (event.type === 'message-received' || event.type === 'scheduled-matured') { return connection.all || connection.sessionId === event.sessionId } diff --git a/hub/src/sync/messageService.test.ts b/hub/src/sync/messageService.test.ts index 7c5a378ec4..5437a06e26 100644 --- a/hub/src/sync/messageService.test.ts +++ b/hub/src/sync/messageService.test.ts @@ -851,6 +851,24 @@ describe('MessageService.releaseMatureScheduledMessages', () => { expect(cliEmitted).toHaveLength(1) }) + it('emits scheduled-matured once per session for web session-list refresh', async () => { + const store = makeStore() + const session = makeSession(store, 'release-sse') + const publisher = makePublisher() + const { io } = makeTrackingIo() + + const now = Date.now() + const past = now - 1000 + store.messages.addMessage(session.id, { role: 'user', content: { type: 'text', text: 'one' } }, 'local-a', past) + store.messages.addMessage(session.id, { role: 'user', content: { type: 'text', text: 'two' } }, 'local-b', past) + + const service = new MessageService(store, io, publisher as any) + service.releaseMatureScheduledMessages(now) + + const matured = publisher.events.filter((event) => event.type === 'scheduled-matured') + expect(matured).toEqual([{ type: 'scheduled-matured', sessionId: session.id }]) + }) + it('does NOT call markMessagesInvoked (pitfall #2 guard): message is re-emitted on next tick', async () => { const store = makeStore() const session = makeSession(store, 'release-no-mark') diff --git a/hub/src/sync/messageService.ts b/hub/src/sync/messageService.ts index c1898474da..e4a2c5fa3c 100644 --- a/hub/src/sync/messageService.ts +++ b/hub/src/sync/messageService.ts @@ -476,7 +476,9 @@ export class MessageService { * expected behaviour. */ releaseMatureScheduledMessages(now: number): void { const mature = this.store.messages.getMatureScheduledMessages(now) + const maturedSessionIds = new Set() for (const msg of mature) { + maturedSessionIds.add(msg.sessionId) const update = { id: msg.id, seq: msg.seq, @@ -497,5 +499,8 @@ export class MessageService { // NOTE: do NOT call markMessagesInvoked here (pitfall #2). // CLI ack (messages-consumed) will handle invoked_at stamping. } + for (const sessionId of maturedSessionIds) { + this.publisher.emit({ type: 'scheduled-matured', sessionId }) + } } } diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 43c7bfdf48..53267a18c6 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -320,6 +320,9 @@ export const SyncEventSchema = z.discriminatedUnion('type', [ SessionChangedSchema.extend({ type: z.literal('messages-invalidated') }), + SessionChangedSchema.extend({ + type: z.literal('scheduled-matured') + }), SessionChangedSchema.extend({ type: z.literal('session-ended'), reason: SessionEndReasonSchema.optional() diff --git a/web/src/hooks/useSSE.test.ts b/web/src/hooks/useSSE.test.ts index 3942c34f5f..e098f9ef08 100644 --- a/web/src/hooks/useSSE.test.ts +++ b/web/src/hooks/useSSE.test.ts @@ -6,6 +6,7 @@ describe('useSSE scope handling', () => { expect(isGlobalScopedMessageStreamEvent('global', 'message-received')).toBe(true) expect(isGlobalScopedMessageStreamEvent('global', 'messages-consumed')).toBe(true) expect(isGlobalScopedMessageStreamEvent('global', 'message-cancelled')).toBe(true) + expect(isGlobalScopedMessageStreamEvent('global', 'scheduled-matured')).toBe(true) }) it('does not skip session lifecycle events on the global connection', () => { diff --git a/web/src/hooks/useSSE.ts b/web/src/hooks/useSSE.ts index 5300a639d3..24286f31ab 100644 --- a/web/src/hooks/useSSE.ts +++ b/web/src/hooks/useSSE.ts @@ -26,7 +26,8 @@ export type SSEScope = 'global' | 'full' const MESSAGE_STREAM_EVENT_TYPES = new Set([ 'message-received', 'messages-consumed', - 'message-cancelled' + 'message-cancelled', + 'scheduled-matured' ]) export function isGlobalScopedMessageStreamEvent(scope: SSEScope, eventType: SyncEvent['type']): boolean { @@ -450,7 +451,11 @@ export function useSSE(options: { if (event.type === 'message-received' && event.message.scheduledAt != null) { queueSessionListInvalidation() } - if (event.type === 'message-cancelled' || event.type === 'messages-consumed') { + if ( + event.type === 'message-cancelled' + || event.type === 'messages-consumed' + || event.type === 'scheduled-matured' + ) { queueSessionListInvalidation() } onEventRef.current(event) From e3dc65d4f42c21de5d0a15674d4c9044de81f97f Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Thu, 28 May 2026 23:28:52 +0100 Subject: [PATCH 12/13] fix(hub): emit scheduled-matured only on newly matured window Avoid refetching the session list every 5s tick while CLI ack is pending. Aligns SSE with the inactivity scan cadence (scheduledAt > now - 5s). Co-authored-by: Cursor --- hub/src/sync/messageService.test.ts | 20 +++++++++++++++++++- hub/src/sync/messageService.ts | 11 ++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/hub/src/sync/messageService.test.ts b/hub/src/sync/messageService.test.ts index 5437a06e26..77add841b6 100644 --- a/hub/src/sync/messageService.test.ts +++ b/hub/src/sync/messageService.test.ts @@ -11,7 +11,7 @@ import { describe, expect, it } from 'bun:test' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { MessageService } from './messageService' +import { MessageService, MATURE_SCHEDULED_TICK_MS } from './messageService' import { Store } from '../store' import type { Server } from 'socket.io' import type { SyncEvent } from '@hapi/protocol/types' @@ -869,6 +869,24 @@ describe('MessageService.releaseMatureScheduledMessages', () => { expect(matured).toEqual([{ type: 'scheduled-matured', sessionId: session.id }]) }) + it('does NOT re-emit scheduled-matured on later ticks while CLI ack is pending', async () => { + const store = makeStore() + const session = makeSession(store, 'release-sse-no-repeat') + const publisher = makePublisher() + const { io } = makeTrackingIo() + + const now = Date.now() + const past = now - 1000 + store.messages.addMessage(session.id, { role: 'user', content: { type: 'text', text: 'hi' } }, 'local-repeat', past) + + const service = new MessageService(store, io, publisher as any) + service.releaseMatureScheduledMessages(now) + service.releaseMatureScheduledMessages(now + MATURE_SCHEDULED_TICK_MS) + + const matured = publisher.events.filter((event) => event.type === 'scheduled-matured') + expect(matured).toHaveLength(1) + }) + it('does NOT call markMessagesInvoked (pitfall #2 guard): message is re-emitted on next tick', async () => { const store = makeStore() const session = makeSession(store, 'release-no-mark') diff --git a/hub/src/sync/messageService.ts b/hub/src/sync/messageService.ts index e4a2c5fa3c..9fa9848ccc 100644 --- a/hub/src/sync/messageService.ts +++ b/hub/src/sync/messageService.ts @@ -7,6 +7,13 @@ import { EventPublisher } from './eventPublisher' type StoredMessageForDelivery = ReturnType[number] +/** Matches syncEngine.expireInactive cadence (releaseMatureScheduledMessages piggyback). */ +export const MATURE_SCHEDULED_TICK_MS = 5_000 + +function isScheduledNewlyMature(scheduledAt: number | null, now: number): boolean { + return scheduledAt !== null && scheduledAt > now - MATURE_SCHEDULED_TICK_MS +} + function isWebVisibleStoredMessage(message: StoredMessageForDelivery): boolean { return !isRedundantGoalStatusEventContent(message.content) } @@ -478,7 +485,9 @@ export class MessageService { const mature = this.store.messages.getMatureScheduledMessages(now) const maturedSessionIds = new Set() for (const msg of mature) { - maturedSessionIds.add(msg.sessionId) + if (isScheduledNewlyMature(msg.scheduledAt, now)) { + maturedSessionIds.add(msg.sessionId) + } const update = { id: msg.id, seq: msg.seq, From 353c34dd6b0cb37a8c72263098fb72aa012170d1 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Fri, 29 May 2026 04:14:43 +0100 Subject: [PATCH 13/13] fix(hub): dedupe scheduled-matured by localId not time window Per-localId notify set fixes missed refresh after late hub scan and stops refetching the session list every mature tick until CLI ack. Co-authored-by: Cursor --- hub/src/sync/messageService.test.ts | 21 +++++++++++++++++++-- hub/src/sync/messageService.ts | 26 ++++++++++++++++++-------- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/hub/src/sync/messageService.test.ts b/hub/src/sync/messageService.test.ts index 77add841b6..4e4f21d67d 100644 --- a/hub/src/sync/messageService.test.ts +++ b/hub/src/sync/messageService.test.ts @@ -11,7 +11,7 @@ import { describe, expect, it } from 'bun:test' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { MessageService, MATURE_SCHEDULED_TICK_MS } from './messageService' +import { MessageService } from './messageService' import { Store } from '../store' import type { Server } from 'socket.io' import type { SyncEvent } from '@hapi/protocol/types' @@ -881,12 +881,29 @@ describe('MessageService.releaseMatureScheduledMessages', () => { const service = new MessageService(store, io, publisher as any) service.releaseMatureScheduledMessages(now) - service.releaseMatureScheduledMessages(now + MATURE_SCHEDULED_TICK_MS) + service.releaseMatureScheduledMessages(now + 60_000) const matured = publisher.events.filter((event) => event.type === 'scheduled-matured') expect(matured).toHaveLength(1) }) + it('emits scheduled-matured when first scan is long after scheduled_at', async () => { + const store = makeStore() + const session = makeSession(store, 'release-sse-late-scan') + const publisher = makePublisher() + const { io } = makeTrackingIo() + + const now = Date.now() + const past = now - 60_000 + store.messages.addMessage(session.id, { role: 'user', content: { type: 'text', text: 'hi' } }, 'local-late', past) + + const service = new MessageService(store, io, publisher as any) + service.releaseMatureScheduledMessages(now) + + const matured = publisher.events.filter((event) => event.type === 'scheduled-matured') + expect(matured).toEqual([{ type: 'scheduled-matured', sessionId: session.id }]) + }) + it('does NOT call markMessagesInvoked (pitfall #2 guard): message is re-emitted on next tick', async () => { const store = makeStore() const session = makeSession(store, 'release-no-mark') diff --git a/hub/src/sync/messageService.ts b/hub/src/sync/messageService.ts index 9fa9848ccc..33322807f0 100644 --- a/hub/src/sync/messageService.ts +++ b/hub/src/sync/messageService.ts @@ -7,13 +7,6 @@ import { EventPublisher } from './eventPublisher' type StoredMessageForDelivery = ReturnType[number] -/** Matches syncEngine.expireInactive cadence (releaseMatureScheduledMessages piggyback). */ -export const MATURE_SCHEDULED_TICK_MS = 5_000 - -function isScheduledNewlyMature(scheduledAt: number | null, now: number): boolean { - return scheduledAt !== null && scheduledAt > now - MATURE_SCHEDULED_TICK_MS -} - function isWebVisibleStoredMessage(message: StoredMessageForDelivery): boolean { return !isRedundantGoalStatusEventContent(message.content) } @@ -35,6 +28,9 @@ function toVisibleDecryptedMessages(messages: StoredMessageForDelivery[]): Decry } export class MessageService { + /** One scheduled-matured SSE per localId per hub process (cleared on cancel/consume paths here). */ + private readonly scheduledMatureNotifiedLocalIds = new Set() + constructor( private readonly store: Store, private readonly io: Server, @@ -43,6 +39,12 @@ export class MessageService { ) { } + private forgetScheduledMatureNotified(localIds: Iterable): void { + for (const localId of localIds) { + this.scheduledMatureNotifiedLocalIds.delete(localId) + } + } + getMessages(sessionId: string, limit: number = 200): DecryptedMessage[] { const stored = this.store.messages.getMessages(sessionId, limit) return toVisibleDecryptedMessages(stored) @@ -203,6 +205,7 @@ export class MessageService { const now = Date.now() if (scheduledAt !== null && scheduledAt > now) { this.store.messages.deleteQueuedMessageById(sessionId, resolvedId) + this.forgetScheduledMatureNotified([localId]) this.publisher.emit({ type: 'message-cancelled', sessionId, @@ -231,6 +234,7 @@ export class MessageService { const recheck = this.store.messages.lookupQueuedMessage(sessionId, resolvedId) if (recheck.status === 'invoked') { // CLI beat us — treat identically to Race-B (ack returned not-found). + this.forgetScheduledMatureNotified([localId]) this.publisher.emit({ type: 'messages-consumed', sessionId, @@ -240,6 +244,7 @@ export class MessageService { return recheck } // Row is gone (absent) — clean cancel. + this.forgetScheduledMatureNotified([localId]) this.publisher.emit({ type: 'message-cancelled', sessionId, @@ -264,6 +269,7 @@ export class MessageService { // DB write failed — let the HTTP 500 surface to the caller. throw err } + this.forgetScheduledMatureNotified([localId]) // Notify all SSE subscribers (other open tabs) that this queued row is now // invoked so they remove it from the floating bar. Without this emit, only // the tab that sent the DELETE request learns about the status change via the @@ -289,6 +295,7 @@ export class MessageService { // Phase 3: CLI confirmed removal. Now DELETE the DB row and broadcast SSE. this.store.messages.deleteQueuedMessageById(sessionId, resolvedId) + this.forgetScheduledMatureNotified([localId]) this.publisher.emit({ type: 'message-cancelled', sessionId, @@ -462,6 +469,7 @@ export class MessageService { .filter((id): id is string => typeof id === 'string') if (localIds.length === 0) return null this.store.messages.markMessagesInvoked(sessionId, localIds, invokedAt) + this.forgetScheduledMatureNotified(localIds) this.publisher.emit({ type: 'messages-consumed', sessionId, localIds, invokedAt }) return { localIds, invokedAt } } @@ -485,7 +493,9 @@ export class MessageService { const mature = this.store.messages.getMatureScheduledMessages(now) const maturedSessionIds = new Set() for (const msg of mature) { - if (isScheduledNewlyMature(msg.scheduledAt, now)) { + const localId = msg.localId + if (typeof localId === 'string' && !this.scheduledMatureNotifiedLocalIds.has(localId)) { + this.scheduledMatureNotifiedLocalIds.add(localId) maturedSessionIds.add(msg.sessionId) } const update = {