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/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/messageService.test.ts b/hub/src/sync/messageService.test.ts index 7c5a378ec4..4e4f21d67d 100644 --- a/hub/src/sync/messageService.test.ts +++ b/hub/src/sync/messageService.test.ts @@ -851,6 +851,59 @@ 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 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 + 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 c1898474da..33322807f0 100644 --- a/hub/src/sync/messageService.ts +++ b/hub/src/sync/messageService.ts @@ -28,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, @@ -36,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) @@ -196,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, @@ -224,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, @@ -233,6 +244,7 @@ export class MessageService { return recheck } // Row is gone (absent) — clean cancel. + this.forgetScheduledMatureNotified([localId]) this.publisher.emit({ type: 'message-cancelled', sessionId, @@ -257,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 @@ -282,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, @@ -455,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 } } @@ -476,7 +491,13 @@ 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) { + const localId = msg.localId + if (typeof localId === 'string' && !this.scheduledMatureNotifiedLocalIds.has(localId)) { + this.scheduledMatureNotifiedLocalIds.add(localId) + maturedSessionIds.add(msg.sessionId) + } const update = { id: msg.id, seq: msg.seq, @@ -497,5 +518,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/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/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" } 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/shared/src/sessionSummary.test.ts b/shared/src/sessionSummary.test.ts new file mode 100644 index 0000000000..a82b8b7749 --- /dev/null +++ b/shared/src/sessionSummary.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'bun:test' +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) + expect(summary.futureScheduledMessageCount).toBe(0) + }) +}) diff --git a/shared/src/sessionSummary.ts b/shared/src/sessionSummary.ts index 13e580fc8b..97e45ee3f0 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,29 @@ export type SessionSummary = { metadata: SessionSummaryMetadata | null todoProgress: { completed: number; total: number } | null pendingRequestsCount: number + pendingRequestKinds: PendingRequestKind[] + backgroundTaskCount: number + futureScheduledMessageCount: 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 +85,9 @@ export function toSessionSummary(session: Session): SessionSummary { metadata, todoProgress, pendingRequestsCount, + pendingRequestKinds: getPendingRequestKinds(session), + backgroundTaskCount: session.backgroundTaskCount ?? 0, + futureScheduledMessageCount: 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/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 && ( = { + 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..6708916f6a 100644 --- a/web/src/components/SessionList.directory-action.test.tsx +++ b/web/src/components/SessionList.directory-action.test.tsx @@ -17,6 +17,9 @@ function makeSession(overrides: Partial & { id: string }): Sessi metadata: null, todoProgress: null, 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 aa6402824b..23a4deecb2 100644 --- a/web/src/components/SessionList.test.ts +++ b/web/src/components/SessionList.test.ts @@ -11,6 +11,9 @@ function makeSession(overrides: Partial & { id: string }): Sessi metadata: null, todoProgress: null, 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 ebf51a9d4c..40ffc81d65 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -7,11 +7,15 @@ 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' import { AgentFlavorIcon } from '@/components/AgentFlavorIcon' +import { useSessionListStatusMode } from '@/hooks/useSessionListStatusMode' +import { classifySessionAttention } from '@/lib/sessionAttention' +import { getSessionLastSeenAt } from '@/lib/sessionLastSeen' +import { getAttentionLabel, SessionAttentionIndicator } from '@/components/SessionAttentionIndicator' type SessionGroup = { key: string @@ -523,9 +527,10 @@ function SessionItem(props: { showPath?: boolean api: ApiClient | null selected?: boolean + showDetailedStatus?: boolean }) { const { t } = useTranslation() - const { session: s, onSelect, showPath = true, api, selected = false } = props + const { session: s, onSelect, showPath = true, api, selected = false, showDetailedStatus = false } = props const { haptic } = usePlatform() const [menuOpen, setMenuOpen] = useState(false) const [menuAnchorPoint, setMenuAnchorPoint] = useState<{ x: number; y: number }>({ x: 0, y: 0 }) @@ -555,6 +560,19 @@ function SessionItem(props: { const sessionName = getSessionTitle(s) const todoProgress = getTodoProgress(s) + const attention = useMemo( + () => showDetailedStatus + ? classifySessionAttention(s, { + selected, + lastSeenAt: getSessionLastSeenAt(s.id) + }) + : null, + [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 ( <> + + {isSessionListStatusOpen && ( +
+ {sessionListStatusModeOptions.map((opt) => { + const isSelected = sessionListStatusMode === opt.value + return ( + + ) + })} +
+ )} + + {sessionListStatusMode === 'detailed' ? ( +
+ {t('settings.display.sessionListStatus.detailedDescription')} +
+ ) : null} {/* Chat section */}