From 3ca893523f27464968c7393f36c62af62aac67a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 17 Jul 2026 00:43:21 +0200 Subject: [PATCH 01/13] feat(session-ingest): enable remote session attention pushes --- .../src/remote-session-notifications.test.ts | 25 +++++++++++++++++-- .../src/remote-session-notifications.ts | 3 +-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/services/session-ingest/src/remote-session-notifications.test.ts b/services/session-ingest/src/remote-session-notifications.test.ts index d06e6a7434..6fff2938dd 100644 --- a/services/session-ingest/src/remote-session-notifications.test.ts +++ b/services/session-ingest/src/remote-session-notifications.test.ts @@ -39,7 +39,7 @@ describe('buildRemoteSessionAttentionPushBody', () => { }); describe('dispatchRemoteSessionAttentionSignal', () => { - it('suppresses pushes while remote-session presence reporting is unavailable', async () => { + it('dispatches the push when enabled and the CLI reports an active session', async () => { const hasActiveCliSession = vi.fn(async () => true); const sendPush = vi.fn(async () => ({ dispatched: true })); const outcome = await dispatchRemoteSessionAttentionSignal( @@ -47,8 +47,29 @@ describe('dispatchRemoteSessionAttentionSignal', () => { { hasActiveCliSession, sendPush } ); + expect(outcome).toBe('sent'); + expect(hasActiveCliSession).toHaveBeenCalledTimes(1); + expect(sendPush).toHaveBeenCalledTimes(1); + expect(sendPush).toHaveBeenCalledWith({ + userId: 'usr_1', + cliSessionId: 'ses_1', + executionId: 'remote:msg-1', + status: 'completed', + body: 'Done', + suppressIfViewingSession: true, + }); + }); + + it('suppresses the push when the CLI has no active heartbeat, even with the flag enabled', async () => { + const hasActiveCliSession = vi.fn(async () => false); + const sendPush = vi.fn(async () => ({ dispatched: true })); + const outcome = await dispatchRemoteSessionAttentionSignal( + { kiloUserId: 'usr_1', sessionId: 'ses_1', signal: completedSignal('Done') }, + { hasActiveCliSession, sendPush } + ); + expect(outcome).toBe('suppressed'); - expect(hasActiveCliSession).not.toHaveBeenCalled(); + expect(hasActiveCliSession).toHaveBeenCalledTimes(1); expect(sendPush).not.toHaveBeenCalled(); }); }); diff --git a/services/session-ingest/src/remote-session-notifications.ts b/services/session-ingest/src/remote-session-notifications.ts index 8e611b3f21..d3355ebb82 100644 --- a/services/session-ingest/src/remote-session-notifications.ts +++ b/services/session-ingest/src/remote-session-notifications.ts @@ -16,8 +16,7 @@ export function isEligibleForRemoteSessionAttention(session: RemoteSessionInfo): const NEEDS_INPUT_BODY = 'Kilo needs your input.'; const DEFAULT_COMPLETED_BODY = 'Task completed'; -// Temporary until the CLI's session-presence reporting is released. -const REMOTE_SESSION_ATTENTION_PUSH_ENABLED = false; +const REMOTE_SESSION_ATTENTION_PUSH_ENABLED = true; export function buildRemoteSessionAttentionPushBody( signal: Pick From 32d0a34195acd57e7dd7b60703e2ff7a4e87dc6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 17 Jul 2026 00:43:27 +0200 Subject: [PATCH 02/13] feat(cloud-agent): push notification when a session needs input --- .../src/persistence/CloudAgentSession.ts | 46 +++ .../ingest-attention-classifier.test.ts | 340 ++++++++++++++++++ .../websocket/ingest-attention-classifier.ts | 153 ++++++++ .../src/websocket/ingest.test.ts | 214 +++++++++++ .../cloud-agent-next/src/websocket/ingest.ts | 16 + 5 files changed, 769 insertions(+) create mode 100644 services/cloud-agent-next/src/websocket/ingest-attention-classifier.test.ts create mode 100644 services/cloud-agent-next/src/websocket/ingest-attention-classifier.ts diff --git a/services/cloud-agent-next/src/persistence/CloudAgentSession.ts b/services/cloud-agent-next/src/persistence/CloudAgentSession.ts index f8feb88043..097c6a2834 100644 --- a/services/cloud-agent-next/src/persistence/CloudAgentSession.ts +++ b/services/cloud-agent-next/src/persistence/CloudAgentSession.ts @@ -63,6 +63,8 @@ import { type IngestHandler, type IngestDOContext, } from '../websocket/ingest.js'; +import type { AttentionEvent } from '../websocket/ingest-attention-classifier.js'; +import { dispatchCloudAgentAttentionPush } from '../websocket/ingest-attention-classifier.js'; import type { StoredEvent } from '../websocket/types.js'; import type { WrapperCommand, CloudStatusData } from '../shared/protocol.js'; import { @@ -659,6 +661,43 @@ export class CloudAgentSession extends DurableObject { return this.messageSettlementOutbox; } + /** + * Best-effort push for a root-session `question.asked`/`permission.asked` + * event. Mirrors the terminal push gates in + * `MessageSettlementOutbox.settlePushNotificationEffect`: + * - metadata present, + * - source event is from the root kilocode session (not a child), + * - the run was created on the cloud-agent-web platform, + * - no /stream clients are currently connected. + * + * Runs under `ctx.waitUntil`; any failure is logged and swallowed so the + * ingest path is never broken. Replay dedup is owned by the notifications + * service via the stable `executionId: attention:{requestId}` key. + * + * Gate + dispatch logic lives in `dispatchCloudAgentAttentionPush` so it + * is unit-testable without constructing this DO. This method is the + * thin DO-specific adapter: fetch metadata, inject stream-client + push + * dependencies, and log on dispatch failure. + */ + private async handleAttentionEvent(event: AttentionEvent): Promise { + const metadata = await this.getMetadata(); + try { + await dispatchCloudAgentAttentionPush(event, metadata, { + hasConnectedStreamClients: () => getConnectedStreamClientCount(this.ctx) > 0, + sendPush: params => this.env.NOTIFICATIONS.sendCloudAgentSessionNotification(params), + }); + } catch (error) { + logger + .withFields({ + sessionId: this.sessionId, + requestId: event.requestId, + kind: event.kind, + error: error instanceof Error ? error.message : String(error), + }) + .warn('Cloud agent attention push notification dispatch failed'); + } + } + private getAgentRuntime(): AgentRuntime { if (!this.agentRuntime) { this.agentRuntime = createAgentRuntime({ @@ -839,6 +878,13 @@ export class CloudAgentSession extends DurableObject { : params ); }, + // Best-effort attention push: dispatch under ctx.waitUntil so ingest + // remains non-blocking. Duplicate replays invoke this each time; + // dedup is the notifications service's job (executionId is stable + // per raise: `attention:{requestId}`). + onAttentionEvent: event => { + this.ctx.waitUntil(this.handleAttentionEvent(event)); + }, }; this.ingestHandler = createIngestHandler( diff --git a/services/cloud-agent-next/src/websocket/ingest-attention-classifier.test.ts b/services/cloud-agent-next/src/websocket/ingest-attention-classifier.test.ts new file mode 100644 index 0000000000..245f62ce1b --- /dev/null +++ b/services/cloud-agent-next/src/websocket/ingest-attention-classifier.test.ts @@ -0,0 +1,340 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { Mock } from 'vitest'; +import { + classifyAttentionKilocodeEvent, + dispatchCloudAgentAttentionPush, + type AttentionPushDeps, + type CloudAgentAttentionMetadata, +} from './ingest-attention-classifier.js'; +import type { AttentionEvent } from './ingest-attention-classifier.js'; + +const SOURCE_SESSION_ID = 'kilo_session_source'; + +describe('classifyAttentionKilocodeEvent', () => { + describe('raise mappings', () => { + it.each([ + ['question.asked', 'question'], + ['permission.asked', 'permission'], + ] as const)('%s with nested properties.id raises %s', (eventName, kind) => { + const result = classifyAttentionKilocodeEvent({ + event: eventName, + properties: { id: 'req_nested', sessionID: SOURCE_SESSION_ID }, + }); + expect(result).toEqual({ + requestId: 'req_nested', + kind, + sourceKiloSessionId: SOURCE_SESSION_ID, + }); + }); + + it.each([ + ['question.asked', 'question'], + ['permission.asked', 'permission'], + ] as const)('%s with direct top-level data.id fallback raises %s', (eventName, kind) => { + const result = classifyAttentionKilocodeEvent({ + event: eventName, + id: 'req_direct', + sessionID: SOURCE_SESSION_ID, + }); + expect(result).toEqual({ + requestId: 'req_direct', + kind, + sourceKiloSessionId: SOURCE_SESSION_ID, + }); + }); + + it('prefers properties.id over data.id for raise', () => { + const result = classifyAttentionKilocodeEvent({ + event: 'question.asked', + id: 'req_direct', + sessionID: 'top_session', + properties: { id: 'req_nested', sessionID: SOURCE_SESSION_ID }, + }); + expect(result).toEqual({ + requestId: 'req_nested', + kind: 'question', + sourceKiloSessionId: SOURCE_SESSION_ID, + }); + }); + + it('prefers properties.sessionID over data.sessionID', () => { + const result = classifyAttentionKilocodeEvent({ + event: 'permission.asked', + id: 'req_1', + sessionID: 'top_session', + properties: { id: 'req_1', sessionID: SOURCE_SESSION_ID }, + }); + expect(result).toEqual({ + requestId: 'req_1', + kind: 'permission', + sourceKiloSessionId: SOURCE_SESSION_ID, + }); + }); + }); + + describe('resolve events are explicitly ignored', () => { + it.each(['question.replied', 'question.rejected', 'permission.replied'])( + '%s returns null even with a valid id and source sessionID', + eventName => { + expect( + classifyAttentionKilocodeEvent({ + event: eventName, + properties: { id: 'req_x', requestID: 'req_x', sessionID: SOURCE_SESSION_ID }, + }) + ).toBeNull(); + } + ); + }); + + describe('ignored event types', () => { + it.each([ + 'session.status', + 'session.idle', + 'session.diff', + 'session.completed', + 'session.error', + 'session.network.asked', + 'session.network.restored', + 'message.part.delta', + 'message.part.updated', + 'message.updated', + 'message.part.removed', + 'session.created', + 'session.updated', + 'session.turn.close', + 'permission.ask', // partial + 'question.ask', // partial + 'retry.foo', + 'error.bar', + 'unknown', + ])('ignores %s', eventName => { + const result = classifyAttentionKilocodeEvent({ + event: eventName, + id: 'req_1', + properties: { id: 'req_1', sessionID: SOURCE_SESSION_ID }, + }); + expect(result).toBeNull(); + }); + }); + + describe('missing or invalid id or sessionID', () => { + it('ignores qualifying event with no id anywhere', () => { + const result = classifyAttentionKilocodeEvent({ + event: 'question.asked', + properties: { sessionID: SOURCE_SESSION_ID }, + }); + expect(result).toBeNull(); + }); + + it('ignores qualifying event with empty string id', () => { + const result = classifyAttentionKilocodeEvent({ + event: 'question.asked', + id: '', + sessionID: SOURCE_SESSION_ID, + properties: { id: '' }, + }); + expect(result).toBeNull(); + }); + + it('ignores qualifying event when source sessionID is missing', () => { + const result = classifyAttentionKilocodeEvent({ + event: 'question.asked', + properties: { id: 'req_present' }, + }); + expect(result).toBeNull(); + }); + + it('ignores qualifying event when source sessionID is empty', () => { + const result = classifyAttentionKilocodeEvent({ + event: 'question.asked', + properties: { id: 'req_present', sessionID: '' }, + }); + expect(result).toBeNull(); + }); + + it('ignores qualifying event with non-string id', () => { + const result = classifyAttentionKilocodeEvent({ + event: 'question.asked', + id: 123, + sessionID: SOURCE_SESSION_ID, + properties: { id: { foo: 'bar' }, sessionID: SOURCE_SESSION_ID }, + }); + expect(result).toBeNull(); + }); + + it('falls back to top-level id when properties is not an object', () => { + const result = classifyAttentionKilocodeEvent({ + event: 'question.asked', + properties: 'not-an-object', + id: 'req_direct', + sessionID: SOURCE_SESSION_ID, + }); + expect(result).toEqual({ + requestId: 'req_direct', + kind: 'question', + sourceKiloSessionId: SOURCE_SESSION_ID, + }); + }); + + it('returns null when properties is null and no top-level id', () => { + const result = classifyAttentionKilocodeEvent({ + event: 'question.asked', + properties: null, + }); + expect(result).toBeNull(); + }); + }); + + describe('non-object input', () => { + it.each([null, undefined, 'string', 42, true, []])('returns null for %s', value => { + expect(classifyAttentionKilocodeEvent(value)).toBeNull(); + }); + }); + + describe('missing event name', () => { + it('returns null when event is missing', () => { + expect(classifyAttentionKilocodeEvent({ id: 'req_1' })).toBeNull(); + }); + + it('returns null when event is not a string', () => { + expect(classifyAttentionKilocodeEvent({ event: 42, id: 'req_1' })).toBeNull(); + }); + }); +}); + +describe('dispatchCloudAgentAttentionPush', () => { + const baseMetadata: CloudAgentAttentionMetadata = { + auth: { kiloSessionId: 'kilo_root' }, + identity: { + sessionId: 'sess_1', + userId: 'user_1', + createdOnPlatform: 'cloud-agent-web', + }, + }; + + const baseEvent: AttentionEvent = { + requestId: 'req_1', + kind: 'question', + sourceKiloSessionId: 'kilo_root', + }; + + function createHarness(overrides?: { hasConnectedStreamClients?: Mock }): { + deps: AttentionPushDeps; + sendPush: Mock; + hasConnectedStreamClients: Mock; + } { + const hasConnectedStreamClients = overrides?.hasConnectedStreamClients ?? vi.fn(() => false); + const sendPush = vi.fn(() => Promise.resolve(undefined)); + return { + deps: { hasConnectedStreamClients, sendPush } satisfies AttentionPushDeps, + sendPush, + hasConnectedStreamClients, + }; + } + + it('dispatches with exact payload for question raise when all gates pass', async () => { + const { deps, sendPush } = createHarness(); + const result = await dispatchCloudAgentAttentionPush(baseEvent, baseMetadata, deps); + + expect(result).toBe('sent'); + expect(sendPush).toHaveBeenCalledTimes(1); + expect(sendPush).toHaveBeenCalledWith({ + userId: 'user_1', + cliSessionId: 'kilo_root', + executionId: 'attention:req_1', + status: 'completed', + body: 'Kilo needs your input.', + suppressIfViewingSession: true, + }); + }); + + it('dispatches with exact payload for permission raise when all gates pass', async () => { + const { deps, sendPush } = createHarness(); + const permissionEvent: AttentionEvent = { + requestId: 'req_2', + kind: 'permission', + sourceKiloSessionId: 'kilo_root', + }; + + const result = await dispatchCloudAgentAttentionPush(permissionEvent, baseMetadata, deps); + + expect(result).toBe('sent'); + expect(sendPush).toHaveBeenCalledTimes(1); + expect(sendPush).toHaveBeenCalledWith({ + userId: 'user_1', + cliSessionId: 'kilo_root', + executionId: 'attention:req_2', + status: 'completed', + body: 'Kilo needs your input.', + suppressIfViewingSession: true, + }); + }); + + it('suppresses when sourceKiloSessionId does not match metadata (non-root session)', async () => { + const { deps, sendPush } = createHarness(); + const childEvent: AttentionEvent = { + requestId: 'req_3', + kind: 'question', + sourceKiloSessionId: 'kilo_child', + }; + + const result = await dispatchCloudAgentAttentionPush(childEvent, baseMetadata, deps); + + expect(result).toBe('suppressed'); + expect(sendPush).not.toHaveBeenCalled(); + }); + + it('suppresses when metadata is null', async () => { + const { deps, sendPush } = createHarness(); + const result = await dispatchCloudAgentAttentionPush(baseEvent, null, deps); + + expect(result).toBe('suppressed'); + expect(sendPush).not.toHaveBeenCalled(); + }); + + it('suppresses when createdOnPlatform is not cloud-agent-web', async () => { + const { deps, sendPush } = createHarness(); + const cliMetadata: CloudAgentAttentionMetadata = { + auth: { kiloSessionId: 'kilo_root' }, + identity: { + sessionId: 'sess_1', + userId: 'user_1', + createdOnPlatform: 'cli', + }, + }; + + const result = await dispatchCloudAgentAttentionPush(baseEvent, cliMetadata, deps); + + expect(result).toBe('suppressed'); + expect(sendPush).not.toHaveBeenCalled(); + }); + + it('suppresses when metadata.auth.kiloSessionId is missing', async () => { + const { deps, sendPush } = createHarness(); + const noKiloSessionIdMetadata: CloudAgentAttentionMetadata = { + auth: {}, + identity: { + sessionId: 'sess_1', + userId: 'user_1', + createdOnPlatform: 'cloud-agent-web', + }, + }; + + const result = await dispatchCloudAgentAttentionPush(baseEvent, noKiloSessionIdMetadata, deps); + + expect(result).toBe('suppressed'); + expect(sendPush).not.toHaveBeenCalled(); + }); + + it('suppresses when hasConnectedStreamClients returns true', async () => { + const { deps, sendPush, hasConnectedStreamClients } = createHarness({ + hasConnectedStreamClients: vi.fn(() => true), + }); + + const result = await dispatchCloudAgentAttentionPush(baseEvent, baseMetadata, deps); + + expect(result).toBe('suppressed'); + expect(sendPush).not.toHaveBeenCalled(); + expect(hasConnectedStreamClients).toHaveBeenCalledTimes(1); + }); +}); diff --git a/services/cloud-agent-next/src/websocket/ingest-attention-classifier.ts b/services/cloud-agent-next/src/websocket/ingest-attention-classifier.ts new file mode 100644 index 0000000000..9c5df5a6d9 --- /dev/null +++ b/services/cloud-agent-next/src/websocket/ingest-attention-classifier.ts @@ -0,0 +1,153 @@ +/** + * Attention event classifier for kilocode ingest events. + * + * Cloud agent session attention is raise-only: `question.asked` and + * `permission.asked` indicate the wrapper is waiting for human input and + * should trigger a best-effort push notification. Resolves are not + * consumed (no outbox or scheduler) so they intentionally return `null`. + * + * The classifier is pure and synchronous, returning a stable requestId, + * a raise kind, and the source `kiloSessionId` so the caller can filter + * out events from child/sub-agent kilocode sessions of this Cloud Agent + * run, or null for non-attention events. + * + * Authoritative id source for raises: `properties.id`, with top-level + * `data.id` as the fallback (the wrapper's real-time shape spreads + * properties at the top level of `data`). + * + * The source `kiloSessionId` is extracted from `properties.sessionID` + * (authoritative) or top-level `data.sessionID` (fallback). A qualifying + * event without a non-empty source `sessionID` is ignored because the + * caller cannot verify it belongs to the root session. + */ + +import type { SessionMetadata } from '../persistence/session-metadata.js'; +import type { SendCloudAgentSessionNotificationParams } from '../notifications-binding.js'; + +export type AttentionEvent = { + requestId: string; + kind: 'question' | 'permission'; + sourceKiloSessionId: string; +}; + +/** + * The subset of `SessionMetadata` needed by the attention push gate. + * Kept narrow so the dispatch function can be called from the Durable + * Object (full `SessionMetadata`) and from unit tests (synthetic fixtures) + * without coupling tests to unrelated metadata fields. + */ +export type CloudAgentAttentionMetadata = Pick; + +/** + * Dependencies the dispatch function needs from its host. The Durable + * Object supplies these; unit tests inject spies. + */ +export type AttentionPushDeps = { + hasConnectedStreamClients: () => boolean; + sendPush: (params: SendCloudAgentSessionNotificationParams) => Promise; +}; + +const RAISE_KILO_EVENTS: ReadonlyMap = new Map([ + ['question.asked', 'question'], + ['permission.asked', 'permission'], +]); + +function readNonEmptyString( + record: Record | null, + key: string +): string | undefined { + if (!record) return undefined; + const value = record[key]; + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function readSessionIdFromRecord(record: Record | null): string | undefined { + if (!record) return undefined; + return readNonEmptyString(record, 'sessionID'); +} + +/** + * Classify a kilocode ingest event for the attention system. + * + * Returns a stable requestId and raise kind, or null when the event is + * not an attention raise. The wrapper's real-time shape spreads + * properties at the top level of `data`, so the id is read from the + * nested `properties` first and falls back to the top-level field. + * + * @param data - The kilocode event data (already validated as an object) + * @returns AttentionEvent with requestId, kind, and sourceKiloSessionId, or null + */ +export function classifyAttentionKilocodeEvent(data: unknown): AttentionEvent | null { + if (typeof data !== 'object' || data === null) return null; + const dataRecord = data as Record; + const eventName = typeof dataRecord.event === 'string' ? dataRecord.event : undefined; + if (!eventName) return null; + + const kind = RAISE_KILO_EVENTS.get(eventName); + if (kind === undefined) return null; + + const properties = + typeof dataRecord.properties === 'object' && dataRecord.properties !== null + ? (dataRecord.properties as Record) + : null; + + // Authoritative nested id; top-level fallback for the wrapper's + // real-time spread shape. + const requestId = readNonEmptyString(properties, 'id') ?? readNonEmptyString(dataRecord, 'id'); + if (!requestId) return null; + + const sourceKiloSessionId = + readSessionIdFromRecord(properties) ?? readSessionIdFromRecord(dataRecord); + if (!sourceKiloSessionId) return null; + + return { requestId, kind, sourceKiloSessionId }; +} + +/** + * Gate + dispatch a best-effort push notification for a root-session + * `question.asked`/`permission.asked` event. Mirrors the terminal push + * gates used by the message settlement outbox. + * + * Gate order (each step short-circuits with `'suppressed'`): + * 1. metadata must be present + * 2. event's source `kiloSessionId` must equal the run's root + * `auth.kiloSessionId` (filters child/sub-agent sessions) + * 3. `identity.createdOnPlatform` must be `'cloud-agent-web'` + * 4. `auth.kiloSessionId` must be present (defensive; subsumed by + * step 2 today but explicit for future callers) + * 5. no `/stream` clients may currently be connected + * + * On the happy path, calls `deps.sendPush` with the stable + * `executionId: 'attention:' + event.requestId` and returns `'sent'`. + * The executionId is the dedup key the notifications service uses to + * drop replays for the same raise. + * + * This function is intentionally free of try/catch: any error from + * `deps.sendPush` propagates to the caller (the Durable Object's + * `handleAttentionEvent`), which logs with DO-specific context and + * swallows so the ingest path stays non-blocking. + */ +export async function dispatchCloudAgentAttentionPush( + event: AttentionEvent, + metadata: CloudAgentAttentionMetadata | null, + deps: AttentionPushDeps +): Promise<'sent' | 'suppressed'> { + if (!metadata) return 'suppressed'; + + const cliSessionId = metadata.auth.kiloSessionId; + if (event.sourceKiloSessionId !== cliSessionId) return 'suppressed'; + if (metadata.identity.createdOnPlatform !== 'cloud-agent-web') return 'suppressed'; + if (!cliSessionId) return 'suppressed'; + if (deps.hasConnectedStreamClients()) return 'suppressed'; + + await deps.sendPush({ + userId: metadata.identity.userId, + cliSessionId, + executionId: `attention:${event.requestId}`, + status: 'completed', + body: 'Kilo needs your input.', + suppressIfViewingSession: true, + }); + + return 'sent'; +} diff --git a/services/cloud-agent-next/src/websocket/ingest.test.ts b/services/cloud-agent-next/src/websocket/ingest.test.ts index 03f64f8e55..a4a185e88c 100644 --- a/services/cloud-agent-next/src/websocket/ingest.test.ts +++ b/services/cloud-agent-next/src/websocket/ingest.test.ts @@ -1653,4 +1653,218 @@ describe('createIngestHandler', () => { expect(await response.text()).toBe('Missing wrapperRunId parameter'); }); }); + + describe('handleIngestMessage — onAttentionEvent hook', () => { + const SOURCE_SESSION_ID = 'kilo_session_source'; + + function makeKilocodeData( + eventName: string, + props: Record, + options: { spreadId?: boolean } = {} + ): Record { + return options.spreadId + ? { ...props, event: eventName, type: eventName, properties: props } + : { event: eventName, type: eventName, properties: props }; + } + + function makeKilocodeMessageFromData(data: Record): string { + return JSON.stringify({ + streamEventType: 'kilocode', + data, + timestamp: new Date().toISOString(), + }); + } + + function createAttentionContext(): { + doContext: IngestDOContext; + onAttentionEvent: ReturnType; + } { + const doContext = createFakeDOContext(); + const onAttentionEvent = vi.fn(); + return { doContext: { ...doContext, onAttentionEvent }, onAttentionEvent }; + } + + it.each([ + { eventName: 'question.asked', kind: 'question' as const }, + { eventName: 'permission.asked', kind: 'permission' as const }, + ])('invokes onAttentionEvent for qualifying $eventName', async ({ eventName, kind }) => { + const { doContext, onAttentionEvent } = createAttentionContext(); + const broadcastFn = vi.fn(); + const handler = createIngestHandler( + createFakeState(), + createFakeEventQueries(), + SESSION_ID, + broadcastFn, + doContext + ); + const ws = createFakeWebSocket(makeAttachment()); + await handler.handleIngestMessage( + ws, + makeKilocodeMessageFromData( + makeKilocodeData(eventName, { id: 'req_nested', sessionID: SOURCE_SESSION_ID }) + ) + ); + expect(onAttentionEvent).toHaveBeenCalledTimes(1); + expect(onAttentionEvent).toHaveBeenCalledWith({ + requestId: 'req_nested', + kind, + sourceKiloSessionId: SOURCE_SESSION_ID, + }); + expect(broadcastFn).toHaveBeenCalledWith( + expect.objectContaining({ id: 0, stream_event_type: 'kilocode' }) + ); + }); + + it('invokes onAttentionEvent for top-level-spread data shape', async () => { + const { doContext, onAttentionEvent } = createAttentionContext(); + const broadcastFn = vi.fn(); + const handler = createIngestHandler( + createFakeState(), + createFakeEventQueries(), + SESSION_ID, + broadcastFn, + doContext + ); + const ws = createFakeWebSocket(makeAttachment()); + await handler.handleIngestMessage( + ws, + makeKilocodeMessageFromData( + makeKilocodeData( + 'question.asked', + { id: 'req_direct', sessionID: SOURCE_SESSION_ID }, + { spreadId: true } + ) + ) + ); + expect(onAttentionEvent).toHaveBeenCalledTimes(1); + expect(onAttentionEvent).toHaveBeenCalledWith({ + requestId: 'req_direct', + kind: 'question', + sourceKiloSessionId: SOURCE_SESSION_ID, + }); + expect(broadcastFn).toHaveBeenCalledWith( + expect.objectContaining({ id: 0, stream_event_type: 'kilocode' }) + ); + }); + + it.each(['session.diff', 'message.part.delta', 'session.completed'])( + 'does not invoke onAttentionEvent for non-attention kilocode event %s', + async eventName => { + const { doContext, onAttentionEvent } = createAttentionContext(); + const broadcastFn = vi.fn(); + const handler = createIngestHandler( + createFakeState(), + createFakeEventQueries(), + SESSION_ID, + broadcastFn, + doContext + ); + const ws = createFakeWebSocket(makeAttachment()); + await handler.handleIngestMessage( + ws, + makeKilocodeMessageFromData( + makeKilocodeData(eventName, { id: 'req_1', sessionID: SOURCE_SESSION_ID }) + ) + ); + expect(onAttentionEvent).not.toHaveBeenCalled(); + expect(broadcastFn).toHaveBeenCalledWith( + expect.objectContaining({ id: 0, stream_event_type: 'kilocode' }) + ); + } + ); + + it('does not invoke onAttentionEvent for resolve events', async () => { + const { doContext, onAttentionEvent } = createAttentionContext(); + const broadcastFn = vi.fn(); + const handler = createIngestHandler( + createFakeState(), + createFakeEventQueries(), + SESSION_ID, + broadcastFn, + doContext + ); + const ws = createFakeWebSocket(makeAttachment()); + await handler.handleIngestMessage( + ws, + makeKilocodeMessageFromData( + makeKilocodeData('question.replied', { + id: 'req_x', + requestID: 'req_x', + sessionID: SOURCE_SESSION_ID, + }) + ) + ); + expect(onAttentionEvent).not.toHaveBeenCalled(); + }); + + it('does not invoke onAttentionEvent when id is missing', async () => { + const { doContext, onAttentionEvent } = createAttentionContext(); + const broadcastFn = vi.fn(); + const handler = createIngestHandler( + createFakeState(), + createFakeEventQueries(), + SESSION_ID, + broadcastFn, + doContext + ); + const ws = createFakeWebSocket(makeAttachment()); + await handler.handleIngestMessage( + ws, + makeKilocodeMessageFromData({ + event: 'question.asked', + type: 'question.asked', + properties: { sessionID: SOURCE_SESSION_ID }, + }) + ); + expect(onAttentionEvent).not.toHaveBeenCalled(); + expect(broadcastFn).toHaveBeenCalledWith( + expect.objectContaining({ id: 0, stream_event_type: 'kilocode' }) + ); + }); + + it('does not invoke onAttentionEvent when source sessionID is missing', async () => { + const { doContext, onAttentionEvent } = createAttentionContext(); + const broadcastFn = vi.fn(); + const handler = createIngestHandler( + createFakeState(), + createFakeEventQueries(), + SESSION_ID, + broadcastFn, + doContext + ); + const ws = createFakeWebSocket(makeAttachment()); + await handler.handleIngestMessage( + ws, + makeKilocodeMessageFromData(makeKilocodeData('question.asked', { id: 'req_no_session' })) + ); + expect(onAttentionEvent).not.toHaveBeenCalled(); + expect(broadcastFn).toHaveBeenCalledWith( + expect.objectContaining({ id: 0, stream_event_type: 'kilocode' }) + ); + }); + + it('does not throw when onAttentionEvent is not registered', async () => { + const doContext = createFakeDOContext(); // no onAttentionEvent + const broadcastFn = vi.fn(); + const handler = createIngestHandler( + createFakeState(), + createFakeEventQueries(), + SESSION_ID, + broadcastFn, + doContext + ); + const ws = createFakeWebSocket(makeAttachment()); + await expect( + handler.handleIngestMessage( + ws, + makeKilocodeMessageFromData( + makeKilocodeData('question.asked', { id: 'req_1', sessionID: SOURCE_SESSION_ID }) + ) + ) + ).resolves.toBeUndefined(); + expect(broadcastFn).toHaveBeenCalledWith( + expect.objectContaining({ id: 0, stream_event_type: 'kilocode' }) + ); + }); + }); }); diff --git a/services/cloud-agent-next/src/websocket/ingest.ts b/services/cloud-agent-next/src/websocket/ingest.ts index 65d953ba36..63da454c36 100644 --- a/services/cloud-agent-next/src/websocket/ingest.ts +++ b/services/cloud-agent-next/src/websocket/ingest.ts @@ -40,6 +40,10 @@ import { cloudStatusForPreparingEvent, materializePreparationEvent, } from '../session/preparation-history.js'; +import { + classifyAttentionKilocodeEvent, + type AttentionEvent, +} from './ingest-attention-classifier.js'; // --------------------------------------------------------------------------- // Ingest Attachment @@ -257,6 +261,14 @@ export type IngestDOContext = { ) => Promise; /** Persist the slash-command catalog so connecting clients can be hydrated. */ setAvailableCommands: (commands: SlashCommandInfo[]) => Promise; + /** + * Optional callback invoked for qualifying question/permission kilocode + * events. Synchronous/fire-and-forget; the DO owns any `waitUntil` for + * external notification IO. Duplicate snapshot replays call it again + * and dedup is the notification dispatch's job (idempotency key on + * `executionId`), not this hook's. + */ + onAttentionEvent?: (event: AttentionEvent) => void; }; // --------------------------------------------------------------------------- @@ -743,6 +755,10 @@ export function createIngestHandler( } ); ws.serializeAttachment(attachment); + const attention = classifyAttentionKilocodeEvent(ingestEvent.data); + if (attention) { + doContext.onAttentionEvent?.(attention); + } } else { console.warn('Invalid kilocode event payload'); } From 8f0fbf54f49f308fd92a20e5770679d9e861fb1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 17 Jul 2026 23:07:00 +0200 Subject: [PATCH 03/13] feat(mobile): session attention derivation and ack store --- apps/mobile/src/lib/session-attention.test.ts | 323 ++++++++++++++++++ apps/mobile/src/lib/session-attention.ts | 148 ++++++++ 2 files changed, 471 insertions(+) create mode 100644 apps/mobile/src/lib/session-attention.test.ts create mode 100644 apps/mobile/src/lib/session-attention.ts diff --git a/apps/mobile/src/lib/session-attention.test.ts b/apps/mobile/src/lib/session-attention.test.ts new file mode 100644 index 0000000000..6601349097 --- /dev/null +++ b/apps/mobile/src/lib/session-attention.test.ts @@ -0,0 +1,323 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + __peekSessionAttentionForTests, + __resetSessionAttentionForTests, + ackSessionAttention, + getRevisionSnapshot, + isAttentionAcked, + reconcileSessionAttention, + sessionNeedsInput, + shouldShowNeedsInput, + subscribe, +} from './session-attention'; + +beforeEach(() => { + __resetSessionAttentionForTests(); +}); + +describe('sessionNeedsInput', () => { + it('returns true for question', () => { + expect(sessionNeedsInput('question')).toBe(true); + }); + + it('returns true for permission', () => { + expect(sessionNeedsInput('permission')).toBe(true); + }); + + it('returns false for idle', () => { + expect(sessionNeedsInput('idle')).toBe(false); + }); + + it('returns false for busy', () => { + expect(sessionNeedsInput('busy')).toBe(false); + }); + + it('returns false for retry', () => { + expect(sessionNeedsInput('retry')).toBe(false); + }); + + it('returns false for null', () => { + expect(sessionNeedsInput(null)).toBe(false); + }); + + it('returns false for undefined', () => { + expect(sessionNeedsInput(undefined)).toBe(false); + }); + + it('returns false for an unknown status string', () => { + expect(sessionNeedsInput('mystery')).toBe(false); + }); +}); + +describe('shouldShowNeedsInput', () => { + it('shows when status is attention and not acked', () => { + expect(shouldShowNeedsInput({ status: 'question', raiseId: 'R1', isAcked: false })).toBe(true); + }); + + it('hides when status is attention and acked', () => { + expect(shouldShowNeedsInput({ status: 'question', raiseId: 'R1', isAcked: true })).toBe(false); + }); + + it('hides when status is non-attention even if not acked', () => { + expect(shouldShowNeedsInput({ status: 'busy', raiseId: null, isAcked: false })).toBe(false); + }); + + it('hides when status is non-attention even if acked', () => { + expect(shouldShowNeedsInput({ status: 'idle', raiseId: null, isAcked: true })).toBe(false); + }); + + it('hides when status is null', () => { + expect(shouldShowNeedsInput({ status: null, raiseId: null, isAcked: false })).toBe(false); + }); +}); + +describe('ack store state machine', () => { + it('pending ack hides the indicator immediately for any raiseId', () => { + ackSessionAttention('s1'); + expect(isAttentionAcked('s1', 'R1')).toBe(true); + expect(isAttentionAcked('s1', 'R2')).toBe(true); + expect(isAttentionAcked('s1', null)).toBe(true); + }); + + it('reconcile resolves a pending entry to the observed raise', () => { + ackSessionAttention('s1'); + reconcileSessionAttention('s1', 'question', 'R1'); + expect(isAttentionAcked('s1', 'R1')).toBe(true); + expect(isAttentionAcked('s1', 'R2')).toBe(false); + }); + + it('a resolved ack hides its own raise but not a new one', () => { + ackSessionAttention('s1'); + reconcileSessionAttention('s1', 'question', 'R1'); + expect(isAttentionAcked('s1', 'R1')).toBe(true); + // a new status_updated_at means a new raise — should show again + expect(isAttentionAcked('s1', 'R2')).toBe(false); + }); + + it('re-opening a session with a stale resolved ack re-pends and hides the new raise', () => { + ackSessionAttention('s1'); + reconcileSessionAttention('s1', 'question', 'R1'); + // new raise R2 arrives and is not acked + expect(isAttentionAcked('s1', 'R2')).toBe(false); + // user opens the session again → ack overwrites with pending + ackSessionAttention('s1'); + expect(isAttentionAcked('s1', 'R2')).toBe(true); + }); + + it('reconcile deletes the entry on a non-attention status, so the next raise shows', () => { + ackSessionAttention('s1'); + reconcileSessionAttention('s1', 'question', 'R1'); + expect(__peekSessionAttentionForTests('s1')).toEqual({ raiseId: 'R1' }); + + // status drops to busy — entry should be cleared + reconcileSessionAttention('s1', 'busy', null); + expect(__peekSessionAttentionForTests('s1')).toBeUndefined(); + + // next question raise (no timestamp — remote active-only row) + // is NOT acked and therefore visible + expect(isAttentionAcked('s1', 'question')).toBe(false); + }); + + it('timestamp-less remote question → busy → question cycle re-raises visibly', () => { + ackSessionAttention('s1'); + // resolve to status string (no statusUpdatedAt) + reconcileSessionAttention('s1', 'question', null); + expect(__peekSessionAttentionForTests('s1')).toEqual({ raiseId: 'question' }); + + // busy clears the entry + reconcileSessionAttention('s1', 'busy', null); + expect(__peekSessionAttentionForTests('s1')).toBeUndefined(); + + // fresh question raise — not acked, visible + expect(isAttentionAcked('s1', 'question')).toBe(false); + }); + + it('frozen-return sequence: pending entry resolves via reconcile, then next raise is not absorbed', () => { + // raise R1 observed, then user opens the session (ack → pending) + ackSessionAttention('s1'); + // reconcile resolves the pending entry to R1 + reconcileSessionAttention('s1', 'question', 'R1'); + expect(isAttentionAcked('s1', 'R1')).toBe(true); + + // new raise R2 — pending is already resolved to R1, so R2 is NOT acked + expect(isAttentionAcked('s1', 'R2')).toBe(false); + }); + + it('reconcile with attention status and no entry is a no-op', () => { + const before = getRevisionSnapshot(); + const listener = vi.fn<() => void>(); + const unsubscribe = subscribe(listener); + + reconcileSessionAttention('s1', 'question', 'R1'); + + expect(getRevisionSnapshot()).toBe(before); + expect(listener).not.toHaveBeenCalled(); + expect(isAttentionAcked('s1', 'R1')).toBe(false); + + unsubscribe(); + }); + + it('reconcile with non-attention status and no entry is a no-op (does not bump revision)', () => { + const before = getRevisionSnapshot(); + const listener = vi.fn<() => void>(); + const unsubscribe = subscribe(listener); + + reconcileSessionAttention('s1', 'busy', null); + + expect(getRevisionSnapshot()).toBe(before); + expect(listener).not.toHaveBeenCalled(); + + unsubscribe(); + }); + + it('reconcile with attention status and a resolved entry is a no-op (does not bump revision)', () => { + ackSessionAttention('s1'); + reconcileSessionAttention('s1', 'question', 'R1'); + // now entry.raiseId === 'R1' + + const before = getRevisionSnapshot(); + const listener = vi.fn<() => void>(); + const unsubscribe = subscribe(listener); + + // same raiseId → still resolved, no change + reconcileSessionAttention('s1', 'question', 'R1'); + expect(getRevisionSnapshot()).toBe(before); + expect(listener).not.toHaveBeenCalled(); + + // different raiseId → resolved entry blocks absorb, no change + reconcileSessionAttention('s1', 'question', 'R2'); + expect(getRevisionSnapshot()).toBe(before); + expect(listener).not.toHaveBeenCalled(); + expect(isAttentionAcked('s1', 'R1')).toBe(true); + expect(isAttentionAcked('s1', 'R2')).toBe(false); + + unsubscribe(); + }); +}); + +describe('revision snapshot and listener notification', () => { + it('bumps revision and notifies listeners on ackSessionAttention', () => { + const before = getRevisionSnapshot(); + const listener = vi.fn<() => void>(); + const unsubscribe = subscribe(listener); + + ackSessionAttention('s1'); + + expect(getRevisionSnapshot()).toBe(before + 1); + expect(listener).toHaveBeenCalledTimes(1); + + unsubscribe(); + }); + + it('does not bump revision or notify when re-acking an already-pending entry', () => { + ackSessionAttention('s1'); + + const after = getRevisionSnapshot(); + const listener = vi.fn<() => void>(); + const unsubscribe = subscribe(listener); + + // Repeated open of the same still-pending session is a no-op. + ackSessionAttention('s1'); + ackSessionAttention('s1'); + + expect(getRevisionSnapshot()).toBe(after); + expect(listener).not.toHaveBeenCalled(); + expect(isAttentionAcked('s1', 'R1')).toBe(true); + + unsubscribe(); + }); + + it('bumps revision when re-acking a resolved entry (re-pends it)', () => { + ackSessionAttention('s1'); + reconcileSessionAttention('s1', 'question', 'R1'); + // entry is now resolved to R1 + + const after = getRevisionSnapshot(); + const listener = vi.fn<() => void>(); + const unsubscribe = subscribe(listener); + + ackSessionAttention('s1'); + + expect(getRevisionSnapshot()).toBe(after + 1); + expect(listener).toHaveBeenCalledTimes(1); + // re-pended: a new raise is once again absorbed + expect(isAttentionAcked('s1', 'R2')).toBe(true); + + unsubscribe(); + }); + + it('bumps revision on mutating reconciles (resolve, delete) and stays stable on no-ops', () => { + const listener = vi.fn<() => void>(); + const unsubscribe = subscribe(listener); + + const initial = getRevisionSnapshot(); + let mutations = 0; + + // ack → mutation + ackSessionAttention('s1'); + if (getRevisionSnapshot() !== initial) { + mutations += 1; + } + + // resolve → mutation + reconcileSessionAttention('s1', 'question', 'R1'); + if (getRevisionSnapshot() !== initial + mutations) { + mutations += 1; + } + + const afterMutations = getRevisionSnapshot(); + + // no-op reconciles: no entry → no change; resolved entry → no change + reconcileSessionAttention('s2', 'busy', null); + reconcileSessionAttention('s1', 'question', 'R1'); + reconcileSessionAttention('s1', 'question', 'R2'); + reconcileSessionAttention('s1', 'question', null); + + expect(getRevisionSnapshot()).toBe(afterMutations); + expect(listener).toHaveBeenCalledTimes(mutations); + + // delete → mutation + reconcileSessionAttention('s1', 'busy', null); + expect(getRevisionSnapshot()).toBe(afterMutations + 1); + expect(listener).toHaveBeenCalledTimes(mutations + 1); + + unsubscribe(); + }); + + it('listener notification count equals revision delta', () => { + const listener = vi.fn<() => void>(); + const unsubscribe = subscribe(listener); + + const start = getRevisionSnapshot(); + ackSessionAttention('a'); + ackSessionAttention('b'); + reconcileSessionAttention('a', 'question', 'R1'); + reconcileSessionAttention('a', 'busy', null); + // no-ops: + reconcileSessionAttention('a', 'busy', null); + reconcileSessionAttention('c', 'question', 'R9'); + reconcileSessionAttention('a', 'question', 'R1'); + const end = getRevisionSnapshot(); + + expect(end - start).toBe(listener.mock.calls.length); + expect(listener).toHaveBeenCalledTimes(4); + + unsubscribe(); + }); + + it('unsubscribe stops further notifications', () => { + const listener = vi.fn<() => void>(); + const unsubscribe = subscribe(listener); + + ackSessionAttention('s1'); + expect(listener).toHaveBeenCalledTimes(1); + + unsubscribe(); + // A real mutation on a different session would notify a live listener; + // after unsubscribe it must not. + ackSessionAttention('s2'); + expect(getRevisionSnapshot()).toBeGreaterThan(0); + expect(listener).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/mobile/src/lib/session-attention.ts b/apps/mobile/src/lib/session-attention.ts new file mode 100644 index 0000000000..0ab1e17fc6 --- /dev/null +++ b/apps/mobile/src/lib/session-attention.ts @@ -0,0 +1,148 @@ +import { useEffect, useSyncExternalStore } from 'react'; + +/** + * Pure session-attention derivation + in-memory ack store for the mobile + * Agents session list "needs input" indicator. + * + * The detail screen is the only ack writer. Acks are intentionally NOT + * persisted across app restarts. Raise identity is `statusUpdatedAt ?? status` + * (stored rows carry server `status_updated_at`; remote active-only rows + * carry none so identity degrades to the status string). + * + * No backend, tRPC, or shared-package imports: this is a mobile-local + * module so the web client can keep its own copy. + */ + +const ATTENTION_STATUSES = new Set(['question', 'permission']); + +export function sessionNeedsInput(status: string | null | undefined): boolean { + return status != null && ATTENTION_STATUSES.has(status); +} + +type AckEntry = { raiseId: string | null }; + +const listeners = new Set<() => void>(); +const entries = new Map(); +let revision = 0; + +function bumpRevision(): void { + revision += 1; + for (const listener of listeners) { + listener(); + } +} + +export function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function getRevisionSnapshot(): number { + return revision; +} + +/** + * Server snapshot for `useSyncExternalStore`. Stable across calls so the + * hook is SSR / RN-safe; the real revision is read on the client. + */ +function getServerSnapshot(): number { + return 0; +} + +export function ackSessionAttention(sessionId: string): void { + // Opening a session always leaves the entry pending. If it is already + // pending, nothing changes — skip the bump so we don't fire a redundant + // global re-render (e.g. React Strict Mode's double effect invocation). + if (entries.get(sessionId)?.raiseId === null) { + return; + } + entries.set(sessionId, { raiseId: null }); + bumpRevision(); +} + +/** + * Reconcile the ack store against the latest observed status. + * + * `raiseId = statusUpdatedAt ?? status`. + * + * - non-attention status: delete the entry (if any) and notify + * - attention + existing pending entry: resolve it to the current raise + * - otherwise: no-op (does NOT bump the revision) + */ +export function reconcileSessionAttention( + sessionId: string, + status: string | null | undefined, + statusUpdatedAt: string | null | undefined +): void { + if (!sessionNeedsInput(status)) { + if (entries.delete(sessionId)) { + bumpRevision(); + } + return; + } + + const raiseId = statusUpdatedAt ?? status ?? null; + if (entries.get(sessionId)?.raiseId === null) { + entries.set(sessionId, { raiseId }); + bumpRevision(); + } +} + +export function isAttentionAcked(sessionId: string, raiseId: string | null): boolean { + const entry = entries.get(sessionId); + if (!entry) { + return false; + } + return entry.raiseId === null || entry.raiseId === raiseId; +} + +export function shouldShowNeedsInput({ + status, + raiseId: _raiseId, + isAcked, +}: { + status: string | null | undefined; + raiseId: string | null; + isAcked: boolean; +}): boolean { + return sessionNeedsInput(status) && !isAcked; +} + +/** + * Subscribe a component to the ack store's revision counter. When the + * revision changes, the component re-renders and re-evaluates + * `isAttentionAcked` for its session. + */ +export function useSessionAttentionRevision(): number { + return useSyncExternalStore(subscribe, getRevisionSnapshot, getServerSnapshot); +} + +/** + * Ack a session's attention indicator when the detail screen opens. + * Re-runs if `sessionId` changes (e.g. switching sessions). + */ +export function useAckSessionAttentionOnOpen(sessionId: string): void { + useEffect(() => { + ackSessionAttention(sessionId); + }, [sessionId]); +} + +/** + * Test-only: clear all acks and reset the revision counter so each + * test starts from a known state. Not for production use. + */ +export function __resetSessionAttentionForTests(): void { + entries.clear(); + revision = 0; +} + +/** + * Test-only: peek at the current entry for a session (or undefined if + * no entry exists). Lets tests assert on the raw store shape without + * exposing it on the production API. + */ +export function __peekSessionAttentionForTests(sessionId: string): AckEntry | undefined { + return entries.get(sessionId); +} From b43be8111a60e20526bcaa93b44e865f550dfcc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 17 Jul 2026 23:07:01 +0200 Subject: [PATCH 04/13] feat(mobile): soft pulse option for StatusDot --- apps/mobile/src/components/ui/status-dot.tsx | 68 +++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/components/ui/status-dot.tsx b/apps/mobile/src/components/ui/status-dot.tsx index 87b2cab109..b95ad90887 100644 --- a/apps/mobile/src/components/ui/status-dot.tsx +++ b/apps/mobile/src/components/ui/status-dot.tsx @@ -1,4 +1,14 @@ +import { useEffect } from 'react'; import { View } from 'react-native'; +import Animated, { + cancelAnimation, + Easing, + useAnimatedStyle, + useReducedMotion, + useSharedValue, + withRepeat, + withTiming, +} from 'react-native-reanimated'; import { cn } from '@/lib/utils'; @@ -7,6 +17,8 @@ export type StatusDotTone = 'good' | 'warn' | 'danger' | 'muted'; type StatusDotProps = { tone?: StatusDotTone; className?: string; + /** Soft opacity breathe on the dot+halo. Static when reduced motion is on. */ + pulse?: boolean; }; // Solid inner-dot and outer-halo classes per tone. The halo uses the @@ -19,12 +31,66 @@ const TONE: Record = { muted: { dot: 'bg-muted-soft', halo: 'bg-neutral-500/20' }, }; +// Soft breathe range and cadence. Mirrors the provisioning-step pulse +// pattern: 1.0 (fully visible) down to ~0.45 (faded), reversed, looping. +const PULSE_LOW = 0.45; +const PULSE_DURATION_MS = 1100; + /** * Status indicator dot with a halo (replaces CSS box-shadow). * 7px inner dot centered inside a 13px halo. + * + * When `pulse` is true, the entire dot+halo softly breathes via opacity — + * never a hard on/off blink. Respects `useReducedMotion()` and renders + * statically (fully visible) when motion is reduced. */ -export function StatusDot({ tone = 'good', className }: Readonly) { +export function StatusDot({ tone = 'good', className, pulse = false }: Readonly) { const styles = TONE[tone]; + + // Animated branch: opacity breathe on the wrapper so the inner dot and + // halo fade together. Static when reduced motion is on. + const reducedMotion = useReducedMotion(); + const opacity = useSharedValue(1); + useEffect(() => { + // Only the pulsing branch animates. Non-pulsing dots (every existing + // caller) must not start a perpetual invisible animation, and reduced + // motion keeps the dot fully visible. + if (!pulse || reducedMotion) { + cancelAnimation(opacity); + opacity.value = 1; + return undefined; + } + opacity.value = withRepeat( + withTiming(PULSE_LOW, { + duration: PULSE_DURATION_MS, + easing: Easing.inOut(Easing.ease), + }), + -1, + true + ); + return () => { + cancelAnimation(opacity); + }; + }, [opacity, pulse, reducedMotion]); + const pulseStyle = useAnimatedStyle(() => ({ + opacity: opacity.value, + })); + + if (pulse) { + return ( + + + + ); + } + return ( Date: Fri, 17 Jul 2026 23:07:02 +0200 Subject: [PATCH 05/13] feat(mobile): needs-input indicator in session list --- .../src/app/(app)/agent-chat/[session-id].tsx | 2 + .../src/components/agents/session-row.tsx | 41 +++++++++++++++++-- apps/mobile/src/components/ui/session-row.tsx | 37 +++++++++++++---- 3 files changed, 70 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx b/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx index c6fc84d4be..bad0eb3bd2 100644 --- a/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx +++ b/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx @@ -13,6 +13,7 @@ import { ScreenHeader } from '@/components/screen-header'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { useTRPC } from '@/lib/trpc'; +import { useAckSessionAttentionOnOpen } from '@/lib/session-attention'; export default function SessionDetailScreen() { const { @@ -26,6 +27,7 @@ export default function SessionDetailScreen() { }>(); const trpc = useTRPC(); const router = useRouter(); + useAckSessionAttentionOnOpen(sessionId); const sessionQuery = useQuery({ ...trpc.cliSessionsV2.get.queryOptions( { session_id: sessionId }, diff --git a/apps/mobile/src/components/agents/session-row.tsx b/apps/mobile/src/components/agents/session-row.tsx index 9938e5e4e0..aba8b31640 100644 --- a/apps/mobile/src/components/agents/session-row.tsx +++ b/apps/mobile/src/components/agents/session-row.tsx @@ -1,11 +1,17 @@ import * as Haptics from 'expo-haptics'; -import { useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { ActionSheetIOS, Alert, Modal, Platform, Pressable, TextInput, View } from 'react-native'; import { SessionRow } from '@/components/ui/session-row'; import { Text } from '@/components/ui/text'; import { type AgentSessionSortBy, getAgentSessionTimestamp } from '@/lib/agent-session-sort'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { + isAttentionAcked, + reconcileSessionAttention, + shouldShowNeedsInput, + useSessionAttentionRevision, +} from '@/lib/session-attention'; import { parseTimestamp, timeAgo } from '@/lib/utils'; type StoredSessionRowProps = { @@ -19,6 +25,7 @@ type StoredSessionRowProps = { updated_at: string; git_branch: string | null; status: string | null; + status_updated_at: string | null; }; isLive: boolean; /** @@ -115,6 +122,17 @@ export function StoredSessionRow({ const renameTextRef = useRef(title); const agentLabel = platformLabel(session.created_on_platform); + const revision = useSessionAttentionRevision(); + const raiseId = session.status_updated_at ?? session.status ?? null; + const needsInput = shouldShowNeedsInput({ + status: session.status, + raiseId, + isAcked: isAttentionAcked(session.session_id, raiseId), + }); + useEffect(() => { + reconcileSessionAttention(session.session_id, session.status, session.status_updated_at); + }, [session.session_id, session.status, session.status_updated_at, revision]); + const handleRenameConfirm = () => { const newName = renameTextRef.current.trim(); setRenameVisible(false); @@ -167,7 +185,7 @@ export function StoredSessionRow({ @@ -236,14 +255,30 @@ export function StoredSessionRow({ export function RemoteSessionRow({ session, onPress }: Readonly) { const title = session.title.length > 0 ? session.title : 'Untitled session'; + const revision = useSessionAttentionRevision(); + const raiseId = session.status; + const needsInput = shouldShowNeedsInput({ + status: session.status, + raiseId, + isAcked: isAttentionAcked(session.id, raiseId), + }); + useEffect(() => { + reconcileSessionAttention(session.id, session.status, null); + }, [session.id, session.status, revision]); + return ( - + diff --git a/apps/mobile/src/components/ui/session-row.tsx b/apps/mobile/src/components/ui/session-row.tsx index 2a0cd0d56d..fccd6ebf0e 100644 --- a/apps/mobile/src/components/ui/session-row.tsx +++ b/apps/mobile/src/components/ui/session-row.tsx @@ -1,3 +1,4 @@ +import * as React from 'react'; import { ChevronRight } from 'lucide-react-native'; import { Pressable, View } from 'react-native'; @@ -18,6 +19,11 @@ type SessionRowProps = { meta?: string; /** When true, renders a pulsing good-tone StatusDot before the meta. */ live?: boolean; + /** + * When true, replaces the live dot / meta with a pulsing warn-tone dot + * and a `NEEDS INPUT` label. Highest priority in the eyebrow row. + */ + needsInput?: boolean; onPress?: () => void; /** Suppress bottom divider on the last row of a group. */ last?: boolean; @@ -43,6 +49,7 @@ export function SessionRow({ subtitle, meta, live, + needsInput = false, onPress, last, stripMode = 'edge', @@ -50,7 +57,28 @@ export function SessionRow({ }: Readonly) { const colors = useThemeColors(); const color = agentColor(agentLabel); - const dimStrip = !live; + const dimStrip = !live && !needsInput; + + let eyebrowRight: React.ReactNode = null; + if (needsInput) { + eyebrowRight = ( + + + + NEEDS INPUT + + + ); + } else if (live) { + eyebrowRight = ; + } else if (meta) { + eyebrowRight = ( + + {meta} + + ); + } + const row = ( {agentLabel} - {live && } - {!live && meta && ( - - {meta} - - )} + {eyebrowRight} {title} From 160271954e22a85f4027717ae0089a1d3d1dd4e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 18 Jul 2026 01:49:11 +0200 Subject: [PATCH 06/13] fix(mobile): reflect needs-input clear-on-open across frozen tab Anchor the attention ack store on globalThis so the single-writer detail screen and the session-list readers share one instance across Expo Router route bundles, and remount the Agents list on attention-revision change after refocus so the freezeOnBlur'd list re-reads the ack store on return. --- .../agents/session-list-content.tsx | 25 +++++++++++ apps/mobile/src/lib/session-attention.ts | 44 ++++++++++++------- 2 files changed, 52 insertions(+), 17 deletions(-) diff --git a/apps/mobile/src/components/agents/session-list-content.tsx b/apps/mobile/src/components/agents/session-list-content.tsx index 076cd6ea61..025aaf65d9 100644 --- a/apps/mobile/src/components/agents/session-list-content.tsx +++ b/apps/mobile/src/components/agents/session-list-content.tsx @@ -1,3 +1,4 @@ +import { useFocusEffect } from 'expo-router'; import { Bot, Plus, Search, SearchX } from 'lucide-react-native'; import { useCallback, useMemo, useRef, useState } from 'react'; import { @@ -24,6 +25,7 @@ import { type AgentSessionSortBy } from '@/lib/agent-session-sort'; import { type StoredSession } from '@/lib/hooks/use-agent-sessions'; import { useSessionMutations } from '@/lib/hooks/use-session-mutations'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { useSessionAttentionRevision } from '@/lib/session-attention'; import { getTabBarOverlayHeight } from '@/lib/tab-bar-layout'; // Height of the hidden-by-default search bar (mt-3 12 + border 1 + py-1.5 12 + line-20 + border 1 + mb-14 14 = 60). @@ -189,6 +191,27 @@ export function AgentSessionListContent({ [storedSessions] ); + // The tabs navigator uses `freezeOnBlur`, so while the session detail screen + // is pushed the Agents list is frozen. react-freeze reveals the previously + // rendered (cached) cells on return WITHOUT re-running them, so the attention + // store's `useSyncExternalStore` subscription does not re-render the list and + // the detail-screen mount ack is not reflected. `useFocusEffect` fires + // reliably on refocus (after unfreeze); bump a tick so the content re-renders + // and re-reads the current attention revision. + const attentionRevision = useSessionAttentionRevision(); + const [, bumpFocusTick] = useState(0); + useFocusEffect( + useCallback(() => { + bumpFocusTick(tick => tick + 1); + }, []) + ); + // Remount the list only when the attention revision changed since it was last + // rendered — e.g. returning from a session that was just opened (acked). A + // remount is the only reliable way to force frozen cells to re-read the ack + // store; keying on the revision (re-read above on focus) leaves scroll intact + // during ordinary focus changes where no ack/reconcile occurred. + const attentionListKey = `${sortBy}:${attentionRevision}`; + const handleRefresh = useCallback(() => { void (async () => { setRefreshing(true); @@ -304,10 +327,12 @@ export function AgentSessionListContent({ return ( + key={attentionListKey} sections={sections} renderItem={renderItem} renderSectionHeader={renderSectionHeader} keyExtractor={keyExtractor} + extraData={attentionListKey} ListHeaderComponent={listHeader} ListEmptyComponent={emptyComponent} ListFooterComponent={ diff --git a/apps/mobile/src/lib/session-attention.ts b/apps/mobile/src/lib/session-attention.ts index 0ab1e17fc6..b574f14751 100644 --- a/apps/mobile/src/lib/session-attention.ts +++ b/apps/mobile/src/lib/session-attention.ts @@ -21,26 +21,36 @@ export function sessionNeedsInput(status: string | null | undefined): boolean { type AckEntry = { raiseId: string | null }; -const listeners = new Set<() => void>(); -const entries = new Map(); -let revision = 0; +type AttentionStore = { + listeners: Set<() => void>; + entries: Map; + revision: number; +}; + +const STORE_KEY = '__kiloSessionAttentionStore__'; +const globalScope = globalThis as typeof globalThis & { [STORE_KEY]?: AttentionStore }; +const store: AttentionStore = (globalScope[STORE_KEY] ??= { + listeners: new Set<() => void>(), + entries: new Map(), + revision: 0, +}); function bumpRevision(): void { - revision += 1; - for (const listener of listeners) { + store.revision += 1; + for (const listener of store.listeners) { listener(); } } export function subscribe(listener: () => void): () => void { - listeners.add(listener); + store.listeners.add(listener); return () => { - listeners.delete(listener); + store.listeners.delete(listener); }; } export function getRevisionSnapshot(): number { - return revision; + return store.revision; } /** @@ -55,10 +65,10 @@ export function ackSessionAttention(sessionId: string): void { // Opening a session always leaves the entry pending. If it is already // pending, nothing changes — skip the bump so we don't fire a redundant // global re-render (e.g. React Strict Mode's double effect invocation). - if (entries.get(sessionId)?.raiseId === null) { + if (store.entries.get(sessionId)?.raiseId === null) { return; } - entries.set(sessionId, { raiseId: null }); + store.entries.set(sessionId, { raiseId: null }); bumpRevision(); } @@ -77,21 +87,21 @@ export function reconcileSessionAttention( statusUpdatedAt: string | null | undefined ): void { if (!sessionNeedsInput(status)) { - if (entries.delete(sessionId)) { + if (store.entries.delete(sessionId)) { bumpRevision(); } return; } const raiseId = statusUpdatedAt ?? status ?? null; - if (entries.get(sessionId)?.raiseId === null) { - entries.set(sessionId, { raiseId }); + if (store.entries.get(sessionId)?.raiseId === null) { + store.entries.set(sessionId, { raiseId }); bumpRevision(); } } export function isAttentionAcked(sessionId: string, raiseId: string | null): boolean { - const entry = entries.get(sessionId); + const entry = store.entries.get(sessionId); if (!entry) { return false; } @@ -134,8 +144,8 @@ export function useAckSessionAttentionOnOpen(sessionId: string): void { * test starts from a known state. Not for production use. */ export function __resetSessionAttentionForTests(): void { - entries.clear(); - revision = 0; + store.entries.clear(); + store.revision = 0; } /** @@ -144,5 +154,5 @@ export function __resetSessionAttentionForTests(): void { * exposing it on the production API. */ export function __peekSessionAttentionForTests(sessionId: string): AckEntry | undefined { - return entries.get(sessionId); + return store.entries.get(sessionId); } From dc86ab99474c50582e35dc627df99929d3de5958 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 18 Jul 2026 02:27:52 +0200 Subject: [PATCH 07/13] fix: address review findings for needs-input feature - cloud-agent-next: fetch session metadata inside the attention-push try/catch so a metadata failure is logged and swallowed as documented, never breaking the best-effort ingest path. - mobile: key the Agents list on an attention revision snapshotted at tab focus, so it remounts to re-read the ack store only on return from a just-opened session, not on any unrelated session's attention change during browsing (preserving scroll). - mobile: isolate attention-store subscribers so one throwing listener cannot prevent the rest from being notified. --- .../agents/session-list-content.tsx | 24 +++++++++---------- apps/mobile/src/lib/session-attention.test.ts | 18 ++++++++++++++ apps/mobile/src/lib/session-attention.ts | 8 ++++++- .../src/persistence/CloudAgentSession.ts | 2 +- 4 files changed, 37 insertions(+), 15 deletions(-) diff --git a/apps/mobile/src/components/agents/session-list-content.tsx b/apps/mobile/src/components/agents/session-list-content.tsx index 025aaf65d9..a127b3dc01 100644 --- a/apps/mobile/src/components/agents/session-list-content.tsx +++ b/apps/mobile/src/components/agents/session-list-content.tsx @@ -25,7 +25,7 @@ import { type AgentSessionSortBy } from '@/lib/agent-session-sort'; import { type StoredSession } from '@/lib/hooks/use-agent-sessions'; import { useSessionMutations } from '@/lib/hooks/use-session-mutations'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; -import { useSessionAttentionRevision } from '@/lib/session-attention'; +import { getRevisionSnapshot } from '@/lib/session-attention'; import { getTabBarOverlayHeight } from '@/lib/tab-bar-layout'; // Height of the hidden-by-default search bar (mt-3 12 + border 1 + py-1.5 12 + line-20 + border 1 + mb-14 14 = 60). @@ -195,22 +195,20 @@ export function AgentSessionListContent({ // is pushed the Agents list is frozen. react-freeze reveals the previously // rendered (cached) cells on return WITHOUT re-running them, so the attention // store's `useSyncExternalStore` subscription does not re-render the list and - // the detail-screen mount ack is not reflected. `useFocusEffect` fires - // reliably on refocus (after unfreeze); bump a tick so the content re-renders - // and re-reads the current attention revision. - const attentionRevision = useSessionAttentionRevision(); - const [, bumpFocusTick] = useState(0); + // the detail-screen mount ack is not reflected. Snapshot the attention + // revision only when the tab (re)gains focus, via `useFocusEffect`, which + // fires reliably after unfreeze. Keying the list on that focus snapshot + // remounts it exactly when an ack/reconcile happened while the list was away + // (e.g. returning from a session that was just opened) so frozen cells re-read + // the ack store — while a revision bump for some unrelated session that occurs + // *during* browsing does not touch the snapshot, so scroll is preserved. + const [attentionFocusRevision, setAttentionFocusRevision] = useState(getRevisionSnapshot); useFocusEffect( useCallback(() => { - bumpFocusTick(tick => tick + 1); + setAttentionFocusRevision(getRevisionSnapshot()); }, []) ); - // Remount the list only when the attention revision changed since it was last - // rendered — e.g. returning from a session that was just opened (acked). A - // remount is the only reliable way to force frozen cells to re-read the ack - // store; keying on the revision (re-read above on focus) leaves scroll intact - // during ordinary focus changes where no ack/reconcile occurred. - const attentionListKey = `${sortBy}:${attentionRevision}`; + const attentionListKey = `${sortBy}:${attentionFocusRevision}`; const handleRefresh = useCallback(() => { void (async () => { diff --git a/apps/mobile/src/lib/session-attention.test.ts b/apps/mobile/src/lib/session-attention.test.ts index 6601349097..6940dc95c7 100644 --- a/apps/mobile/src/lib/session-attention.test.ts +++ b/apps/mobile/src/lib/session-attention.test.ts @@ -306,6 +306,24 @@ describe('revision snapshot and listener notification', () => { unsubscribe(); }); + it('isolates a throwing listener so later subscribers are still notified once', () => { + const throwing = vi.fn(() => { + throw new Error('listener boom'); + }); + const good = vi.fn<() => void>(); + const unsubThrowing = subscribe(throwing); + const unsubGood = subscribe(good); + + const before = getRevisionSnapshot(); + expect(() => ackSessionAttention('s1')).not.toThrow(); + expect(throwing).toHaveBeenCalledTimes(1); + expect(good).toHaveBeenCalledTimes(1); + expect(getRevisionSnapshot()).toBe(before + 1); + + unsubThrowing(); + unsubGood(); + }); + it('unsubscribe stops further notifications', () => { const listener = vi.fn<() => void>(); const unsubscribe = subscribe(listener); diff --git a/apps/mobile/src/lib/session-attention.ts b/apps/mobile/src/lib/session-attention.ts index b574f14751..c686aa1555 100644 --- a/apps/mobile/src/lib/session-attention.ts +++ b/apps/mobile/src/lib/session-attention.ts @@ -37,8 +37,14 @@ const store: AttentionStore = (globalScope[STORE_KEY] ??= { function bumpRevision(): void { store.revision += 1; + // Isolate subscribers: one throwing listener must not prevent the rest from + // being notified of the revision change. for (const listener of store.listeners) { - listener(); + try { + listener(); + } catch { + // A subscriber's own error must not break store notification. + } } } diff --git a/services/cloud-agent-next/src/persistence/CloudAgentSession.ts b/services/cloud-agent-next/src/persistence/CloudAgentSession.ts index 097c6a2834..da5b3f8477 100644 --- a/services/cloud-agent-next/src/persistence/CloudAgentSession.ts +++ b/services/cloud-agent-next/src/persistence/CloudAgentSession.ts @@ -680,8 +680,8 @@ export class CloudAgentSession extends DurableObject { * dependencies, and log on dispatch failure. */ private async handleAttentionEvent(event: AttentionEvent): Promise { - const metadata = await this.getMetadata(); try { + const metadata = await this.getMetadata(); await dispatchCloudAgentAttentionPush(event, metadata, { hasConnectedStreamClients: () => getConnectedStreamClientCount(this.ctx) > 0, sendPush: params => this.env.NOTIFICATIONS.sendCloudAgentSessionNotification(params), From feeed497fef5bbe9324b99e793e2030f2d193e6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 18 Jul 2026 02:40:04 +0200 Subject: [PATCH 08/13] test(mobile): use block body in listener-isolation test oxlint no-confusing-void-expression forbids returning the void expression from the arrow shorthand passed to expect(). --- apps/mobile/src/lib/session-attention.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/lib/session-attention.test.ts b/apps/mobile/src/lib/session-attention.test.ts index 6940dc95c7..1147da8acb 100644 --- a/apps/mobile/src/lib/session-attention.test.ts +++ b/apps/mobile/src/lib/session-attention.test.ts @@ -315,7 +315,9 @@ describe('revision snapshot and listener notification', () => { const unsubGood = subscribe(good); const before = getRevisionSnapshot(); - expect(() => ackSessionAttention('s1')).not.toThrow(); + expect(() => { + ackSessionAttention('s1'); + }).not.toThrow(); expect(throwing).toHaveBeenCalledTimes(1); expect(good).toHaveBeenCalledTimes(1); expect(getRevisionSnapshot()).toBe(before + 1); From a4cb7e8b3453464bf7068dd097f77f5c154cfc98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 19 Jul 2026 01:38:15 +0200 Subject: [PATCH 09/13] feat(notifications): lead session pushes with the session title Thread the heartbeat session title from UserConnectionDO through SessionIngestDO into the notifications service so attention and session-ready push notifications lead with the session title. - packages/notifications: optional title on sendSessionReadyNotificationInputSchema. - services/notifications: add a code-point-safe title sanitizer (collapse whitespace, <=80 code points incl. ellipsis, no split surrogate) used by dispatchCloudAgentSessionPush and the ready push. Ready push prefers the heartbeat title hint, then the DB title, then the fixed 'Kilo session ready'. - services/session-ingest: forward the heartbeat title from UserConnectionDO.claimSessionReadyPush into SessionIngestDO and on to sendSessionReadyNotification. - Tests cover title composition, long-title and surrogate-pair truncation, whitespace handling, hint-vs-DB precedence, and schema strip-on-parse. --- packages/notifications/src/rpc-schemas.ts | 1 + .../src/lib/cloud-agent-session-push.ts | 18 +- .../notifications-service-cloud-agent.test.ts | 226 ++++++++++++++++++ .../src/dos/SessionIngestDO.test.ts | 17 +- .../session-ingest/src/dos/SessionIngestDO.ts | 3 +- .../src/dos/UserConnectionDO.test.ts | 4 +- .../src/dos/UserConnectionDO.ts | 6 +- 7 files changed, 265 insertions(+), 10 deletions(-) diff --git a/packages/notifications/src/rpc-schemas.ts b/packages/notifications/src/rpc-schemas.ts index 5b5cdfc97a..78ec16f327 100644 --- a/packages/notifications/src/rpc-schemas.ts +++ b/packages/notifications/src/rpc-schemas.ts @@ -162,6 +162,7 @@ export type SendCloudAgentSessionNotificationResult = z.infer< export const sendSessionReadyNotificationInputSchema = z.object({ userId: z.string().min(1), cliSessionId: z.string().min(1), + title: z.string().optional(), }); export type SendSessionReadyNotificationParams = z.infer< typeof sendSessionReadyNotificationInputSchema diff --git a/services/notifications/src/lib/cloud-agent-session-push.ts b/services/notifications/src/lib/cloud-agent-session-push.ts index 20735c3d54..cb38c0aa53 100644 --- a/services/notifications/src/lib/cloud-agent-session-push.ts +++ b/services/notifications/src/lib/cloud-agent-session-push.ts @@ -15,6 +15,17 @@ type CloudAgentNotificationSession = { organizationId: string | null; }; +const TITLE_MAX_LENGTH = 80; + +function sanitizeTitle(title: string | null | undefined): string | null { + if (title == null) return null; + const collapsed = title.replace(/\s+/g, ' ').trim(); + if (collapsed === '') return null; + const codePoints = Array.from(collapsed); + if (codePoints.length <= TITLE_MAX_LENGTH) return collapsed; + return `${codePoints.slice(0, TITLE_MAX_LENGTH - 3).join('')}...`; +} + export type DispatchCloudAgentSessionPushDeps = { getSession: ( userId: string, @@ -85,7 +96,7 @@ export async function dispatchCloudAgentSessionPush( ? presenceContextForCliSession(parsed.cliSessionId) : null, idempotencyKey: `cloud-agent:${parsed.cliSessionId}:${parsed.executionId}`, - title: session.title ?? 'Agent session', + title: sanitizeTitle(session.title) ?? 'Agent session', body: parsed.body, }), deps @@ -105,10 +116,11 @@ export async function dispatchSessionReadyPush( return dispatchSessionPush( parsed.userId, parsed.cliSessionId, - () => ({ + session => ({ presenceContext: presenceContextForPlatform('app'), idempotencyKey: `cloud-agent:${parsed.cliSessionId}:session-ready`, - title: 'Kilo session ready', + title: + sanitizeTitle(parsed.title ?? null) ?? sanitizeTitle(session.title) ?? 'Kilo session ready', body: 'Your Kilo session is ready to control from your phone', }), deps diff --git a/services/notifications/src/lib/notifications-service-cloud-agent.test.ts b/services/notifications/src/lib/notifications-service-cloud-agent.test.ts index 482bb620ea..8ad427d561 100644 --- a/services/notifications/src/lib/notifications-service-cloud-agent.test.ts +++ b/services/notifications/src/lib/notifications-service-cloud-agent.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { sendCloudAgentSessionNotificationInputSchema, + sendSessionReadyNotificationInputSchema, type DispatchPushInput, type DispatchPushOutcome, } from '@kilocode/notifications'; @@ -252,6 +253,148 @@ describe('dispatchCloudAgentSessionPush', () => { ).rejects.toThrow(); expect(deps.getSession).not.toHaveBeenCalled(); }); + + it('composes the push title from the session title for an attention dispatch', async () => { + const deps = createDeps({ + session: { title: 'Refactor auth module', organizationId: null }, + }); + + const result = await dispatchCloudAgentSessionPush( + { + userId: 'user-1', + cliSessionId: 'ses_1', + executionId: 'attention:req_1', + status: 'completed', + body: 'Kilo needs your input.', + }, + deps + ); + + expect(result).toEqual({ dispatched: true }); + expect(mockDispatchPush).toHaveBeenCalledWith( + expect.objectContaining({ + push: expect.objectContaining({ + title: 'Refactor auth module', + body: 'Kilo needs your input.', + }), + }) + ); + }); + + it('falls back to the fixed title when the session title is null', async () => { + const deps = createDeps({ session: { title: null, organizationId: null } }); + + await dispatchCloudAgentSessionPush( + { + userId: 'user-1', + cliSessionId: 'ses_1', + executionId: 'exec_null', + status: 'completed', + body: 'Finished', + }, + deps + ); + + expect(mockDispatchPush).toHaveBeenCalledWith( + expect.objectContaining({ push: expect.objectContaining({ title: 'Agent session' }) }) + ); + }); + + it('falls back to the fixed title when the session title is only whitespace', async () => { + const deps = createDeps({ session: { title: ' \n \t', organizationId: null } }); + + await dispatchCloudAgentSessionPush( + { + userId: 'user-1', + cliSessionId: 'ses_1', + executionId: 'exec_ws', + status: 'completed', + body: 'Finished', + }, + deps + ); + + expect(mockDispatchPush).toHaveBeenCalledWith( + expect.objectContaining({ push: expect.objectContaining({ title: 'Agent session' }) }) + ); + }); + + it('truncates a long session title to at most 80 code points ending in ellipsis', async () => { + // Mix ASCII with a surrogate-pair emoji to exercise the codepoint-safe path. + const longTitle = 'x'.repeat(70) + ' 🦊 '.repeat(5) + ' tail'; + const deps = createDeps({ session: { title: longTitle, organizationId: null } }); + + await dispatchCloudAgentSessionPush( + { + userId: 'user-1', + cliSessionId: 'ses_1', + executionId: 'exec_long', + status: 'completed', + body: 'Finished', + }, + deps + ); + + const calls = mockDispatchPush.mock.calls[0]?.[0] as DispatchPushInput; + const pushedTitle = calls.push.title; + expect(pushedTitle.endsWith('...')).toBe(true); + expect(pushedTitle.includes('\n')).toBe(false); + expect(Array.from(pushedTitle).length).toBeLessThanOrEqual(80); + // No lone surrogate: if the truncation keeps the emoji, it must remain intact; + // otherwise the truncation point must be on an ASCII/space boundary. + if (pushedTitle.includes('🦊')) { + // Surviving emoji must be the full code point sequence, not a lone surrogate. + expect(pushedTitle.includes('\uD83E')).toBe(true); + } + }); + + it('truncates surrogate-pair emoji by code points, not UTF-16 code units', async () => { + const emojiTitle = '🦊'.repeat(85); + const expected = '🦊'.repeat(77) + '...'; + const deps = createDeps({ session: { title: emojiTitle, organizationId: null } }); + + await dispatchCloudAgentSessionPush( + { + userId: 'user-1', + cliSessionId: 'ses_1', + executionId: 'exec_emoji_surrogate', + status: 'completed', + body: 'Finished', + }, + deps + ); + + const calls = mockDispatchPush.mock.calls[0]?.[0] as DispatchPushInput; + const pushedTitle = calls.push.title; + expect(pushedTitle).toBe(expected); + expect(Array.from(pushedTitle).length).toBe(80); + expect( + /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { + const deps = createDeps({ + session: { title: 'Refactor\n\nthe\t auth module', organizationId: null }, + }); + + await dispatchCloudAgentSessionPush( + { + userId: 'user-1', + cliSessionId: 'ses_1', + executionId: 'exec_ws', + status: 'completed', + body: 'Finished', + }, + deps + ); + + expect(mockDispatchPush).toHaveBeenCalledWith( + expect.objectContaining({ + push: expect.objectContaining({ title: 'Refactor the auth module' }), + }) + ); + }); }); describe('dispatchSessionReadyPush', () => { @@ -322,6 +465,59 @@ describe('dispatchSessionReadyPush', () => { expect(result).toEqual({ dispatched: false, reason: 'dispatch_failed' }); }); + + it('prefers the heartbeat hint title over the DB title when both are present', async () => { + const deps = createDeps({ + session: { title: 'DB title', organizationId: null }, + }); + + const result = await dispatchSessionReadyPush( + { userId: 'user-1', cliSessionId: 'ses_1', title: 'Heartbeat title' }, + deps + ); + + expect(result).toEqual({ dispatched: true }); + expect(mockDispatchPush).toHaveBeenCalledWith( + expect.objectContaining({ + push: expect.objectContaining({ + title: 'Heartbeat title', + body: 'Your Kilo session is ready to control from your phone', + }), + }) + ); + }); + + it('uses the DB title when no heartbeat hint is provided', async () => { + const deps = createDeps({ + session: { title: 'DB title', organizationId: null }, + }); + + await dispatchSessionReadyPush({ userId: 'user-1', cliSessionId: 'ses_1' }, deps); + + expect(mockDispatchPush).toHaveBeenCalledWith( + expect.objectContaining({ + push: expect.objectContaining({ + title: 'DB title', + body: 'Your Kilo session is ready to control from your phone', + }), + }) + ); + }); + + it('falls back to the fixed copy when no hint and no DB title are present', async () => { + const deps = createDeps({ session: { title: null, organizationId: null } }); + + await dispatchSessionReadyPush({ userId: 'user-1', cliSessionId: 'ses_1' }, deps); + + expect(mockDispatchPush).toHaveBeenCalledWith( + expect.objectContaining({ + push: expect.objectContaining({ + title: 'Kilo session ready', + body: 'Your Kilo session is ready to control from your phone', + }), + }) + ); + }); }); describe('sendCloudAgentSessionNotificationInputSchema', () => { @@ -346,3 +542,33 @@ describe('sendCloudAgentSessionNotificationInputSchema', () => { }); }); }); + +describe('sendSessionReadyNotificationInputSchema', () => { + it('accepts input without title and strips unknown keys', () => { + const parsed = sendSessionReadyNotificationInputSchema.parse({ + userId: 'user-1', + cliSessionId: 'ses_1', + extra: 'stripped', + }); + + expect(parsed).toEqual({ + userId: 'user-1', + cliSessionId: 'ses_1', + }); + }); + + it('accepts input with a title', () => { + const parsed = sendSessionReadyNotificationInputSchema.parse({ + userId: 'user-1', + cliSessionId: 'ses_1', + title: 'Heartbeat title', + extra: 'stripped', + }); + + expect(parsed).toEqual({ + userId: 'user-1', + cliSessionId: 'ses_1', + title: 'Heartbeat title', + }); + }); +}); diff --git a/services/session-ingest/src/dos/SessionIngestDO.test.ts b/services/session-ingest/src/dos/SessionIngestDO.test.ts index 75588abda7..bf9e7dd918 100644 --- a/services/session-ingest/src/dos/SessionIngestDO.test.ts +++ b/services/session-ingest/src/dos/SessionIngestDO.test.ts @@ -291,20 +291,35 @@ describe('SessionIngestDO session-ready push', () => { it('pushes on first claim and never again', async () => { const { durableObject, sendSessionReadyNotification, settle } = makeHarness(); - durableObject.claimSessionReadyPush('usr_push', 'ses_push'); + durableObject.claimSessionReadyPush('usr_push', 'ses_push', 'My title'); await settle(); expect(sendSessionReadyNotification).toHaveBeenCalledTimes(1); expect(sendSessionReadyNotification).toHaveBeenCalledWith({ userId: 'usr_push', cliSessionId: 'ses_push', + title: 'My title', }); // Re-claims (CLI reconnect, UserConnectionDO eviction) must not re-push. + durableObject.claimSessionReadyPush('usr_push', 'ses_push', 'My title'); + await settle(); + + expect(sendSessionReadyNotification).toHaveBeenCalledTimes(1); + }); + + it('forwards an undefined title when none is supplied', async () => { + const { durableObject, sendSessionReadyNotification, settle } = makeHarness(); + durableObject.claimSessionReadyPush('usr_push', 'ses_push'); await settle(); expect(sendSessionReadyNotification).toHaveBeenCalledTimes(1); + expect(sendSessionReadyNotification).toHaveBeenCalledWith({ + userId: 'usr_push', + cliSessionId: 'ses_push', + title: undefined, + }); }); it('never pushes for a deleted session', async () => { diff --git a/services/session-ingest/src/dos/SessionIngestDO.ts b/services/session-ingest/src/dos/SessionIngestDO.ts index 163a8bd8ff..25ca4143fc 100644 --- a/services/session-ingest/src/dos/SessionIngestDO.ts +++ b/services/session-ingest/src/dos/SessionIngestDO.ts @@ -362,7 +362,7 @@ export class SessionIngestDO extends DurableObject { * CLI reconnects and UserConnectionDO evictions can't re-arm it. Push * failures are non-fatal: log and move on. */ - claimSessionReadyPush(kiloUserId: string, sessionId: string): void { + claimSessionReadyPush(kiloUserId: string, sessionId: string, title?: string): void { const deletedRow = this.db .select({ value: ingestMeta.value }) .from(ingestMeta) @@ -380,6 +380,7 @@ export class SessionIngestDO extends DurableObject { this.env.NOTIFICATIONS.sendSessionReadyNotification({ userId: kiloUserId, cliSessionId: sessionId, + title, }).catch((error: unknown) => { console.error('Failed to send session-ready push (non-fatal)', { sessionId, diff --git a/services/session-ingest/src/dos/UserConnectionDO.test.ts b/services/session-ingest/src/dos/UserConnectionDO.test.ts index e660d6555d..c0fc83950c 100644 --- a/services/session-ingest/src/dos/UserConnectionDO.test.ts +++ b/services/session-ingest/src/dos/UserConnectionDO.test.ts @@ -3094,7 +3094,7 @@ describe('UserConnectionDO', () => { sendHeartbeat(doInstance, cliWs, [makeSession('ses_main')]); expect(claimSessionReadyPush).toHaveBeenCalledTimes(1); - expect(claimSessionReadyPush).toHaveBeenCalledWith('usr_1', 'ses_main'); + expect(claimSessionReadyPush).toHaveBeenCalledWith('usr_1', 'ses_main', 'Test'); // Subsequent heartbeats for the same session must not re-claim. sendHeartbeat(doInstance, cliWs, [makeSession('ses_main')]); @@ -3111,7 +3111,7 @@ describe('UserConnectionDO', () => { ]); expect(claimSessionReadyPush).toHaveBeenCalledTimes(1); - expect(claimSessionReadyPush).toHaveBeenCalledWith('usr_1', 'ses_main'); + expect(claimSessionReadyPush).toHaveBeenCalledWith('usr_1', 'ses_main', 'Test'); }); it('does not claim on sockets without a kiloUserId (legacy attachment)', () => { diff --git a/services/session-ingest/src/dos/UserConnectionDO.ts b/services/session-ingest/src/dos/UserConnectionDO.ts index b132a3ff0e..984d1fd2ee 100644 --- a/services/session-ingest/src/dos/UserConnectionDO.ts +++ b/services/session-ingest/src/dos/UserConnectionDO.ts @@ -420,7 +420,7 @@ export class UserConnectionDO extends DurableObject { // remote-controllable — the only moment the session-ready push fires. // The durable claim in SessionIngestDO makes reconnect re-sights no-ops. if (!previousOwner && !session.parentSessionId && attachment.kiloUserId) { - this.claimSessionReadyPush(attachment.kiloUserId, session.id); + this.claimSessionReadyPush(attachment.kiloUserId, session.id, session.title); } this.sessionOwners.set(session.id, connectionId); } @@ -474,10 +474,10 @@ export class UserConnectionDO extends DurableObject { * Fire-and-forget "session ready to control from your phone" push via the * session's SessionIngestDO, which holds the durable once-ever claim. */ - private claimSessionReadyPush(kiloUserId: string, sessionId: string): void { + private claimSessionReadyPush(kiloUserId: string, sessionId: string, title: string): void { const stub = getSessionIngestDO(this.env, { kiloUserId, sessionId }); this.ctx.waitUntil( - stub.claimSessionReadyPush(kiloUserId, sessionId).catch((error: unknown) => { + stub.claimSessionReadyPush(kiloUserId, sessionId, title).catch((error: unknown) => { console.error('Failed to claim session-ready push (non-fatal)', { sessionId, error: error instanceof Error ? error.message : String(error), From ab23d161babb821cf50bb7f4e4e674839d9a7276 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 19 Jul 2026 20:27:19 +0200 Subject: [PATCH 10/13] feat(web): enrich activeSessions.list with platform and timestamps --- .../routers/active-sessions-router.test.ts | 195 ++++++++++++++++++ .../web/src/routers/active-sessions-router.ts | 66 +++++- apps/web/src/routers/cli-sessions-router.ts | 15 +- .../web/src/routers/cli-sessions-v2-router.ts | 2 +- .../src/routers/unified-sessions-router.ts | 2 +- packages/app-shared/package.json | 3 +- packages/app-shared/src/platforms.ts | 13 ++ 7 files changed, 278 insertions(+), 18 deletions(-) create mode 100644 apps/web/src/routers/active-sessions-router.test.ts create mode 100644 packages/app-shared/src/platforms.ts diff --git a/apps/web/src/routers/active-sessions-router.test.ts b/apps/web/src/routers/active-sessions-router.test.ts new file mode 100644 index 0000000000..54691d5581 --- /dev/null +++ b/apps/web/src/routers/active-sessions-router.test.ts @@ -0,0 +1,195 @@ +import { createCallerForUser } from '@/routers/test-utils'; +import { insertTestUser } from '@/tests/helpers/user.helper'; +import { db } from '@/lib/drizzle'; +import { cli_sessions_v2 } from '@kilocode/db/schema'; +import { eq } from 'drizzle-orm'; +import type { User } from '@kilocode/db/schema'; + +jest.mock('@/lib/config.server', () => { + const actual: Record = jest.requireActual('@/lib/config.server'); + return { + ...actual, + SESSION_INGEST_WORKER_URL: 'https://test-ingest.example.com', + }; +}); + +let regularUser: User; + +function mockWorkerSessions(sessions: Array>): jest.SpyInstance { + return jest.spyOn(global, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ sessions }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); +} + +function mockMalformedWorkerResponse(): jest.SpyInstance { + return jest.spyOn(global, 'fetch').mockResolvedValue( + new Response('not valid json', { + status: 200, + headers: { 'Content-Type': 'text/plain' }, + }) + ); +} + +describe('active-sessions-router.list', () => { + beforeAll(async () => { + regularUser = await insertTestUser({ + google_user_email: 'active-sessions-router-user@example.com', + google_user_name: 'Active Sessions Router User', + is_admin: false, + }); + }); + + let fetchSpy: jest.SpyInstance; + + afterEach(() => { + fetchSpy?.mockRestore(); + }); + + it('merges enrichment fields from cli_sessions_v2 with explicit camelCase keys', async () => { + const sessionId = 'ses_active_enrich_match_1234'; + const createdAt = '2026-07-01 10:00:00+00'; + const updatedAt = '2026-07-02 11:00:00+00'; + await db.insert(cli_sessions_v2).values({ + session_id: sessionId, + kilo_user_id: regularUser.id, + created_on_platform: 'cli', + created_at: createdAt, + updated_at: updatedAt, + }); + + fetchSpy = mockWorkerSessions([ + { + id: sessionId, + status: 'running', + title: 'matched', + connectionId: 'conn-1', + gitUrl: 'https://github.com/kilo/repo', + gitBranch: 'main', + }, + ]); + + try { + const caller = await createCallerForUser(regularUser.id); + const result = await caller.activeSessions.list(); + + expect(result.sessions).toEqual([ + { + id: sessionId, + status: 'running', + title: 'matched', + connectionId: 'conn-1', + gitUrl: 'https://github.com/kilo/repo', + gitBranch: 'main', + createdOnPlatform: 'cli', + createdAt, + updatedAt, + }, + ]); + // Assert the explicit camelCase keys exist (not snake_case). + const row = result.sessions[0]!; + expect(Object.keys(row)).toEqual( + expect.arrayContaining(['createdOnPlatform', 'createdAt', 'updatedAt']) + ); + expect(Object.keys(row)).not.toEqual( + expect.arrayContaining(['created_on_platform', 'created_at', 'updated_at']) + ); + } finally { + await db.delete(cli_sessions_v2).where(eq(cli_sessions_v2.session_id, sessionId)); + } + }); + + it('passes sessions with undefined enrichment fields when no matching row exists', async () => { + const unmatchedId = 'ses_active_enrich_unmatched_1234'; + + fetchSpy = mockWorkerSessions([ + { + id: unmatchedId, + status: 'running', + title: 'no row', + connectionId: 'conn-2', + }, + ]); + + const caller = await createCallerForUser(regularUser.id); + const result = await caller.activeSessions.list(); + + expect(result.sessions).toEqual([ + { + id: unmatchedId, + status: 'running', + title: 'no row', + connectionId: 'conn-2', + createdOnPlatform: undefined, + createdAt: undefined, + updatedAt: undefined, + }, + ]); + }); + + it('performs no DB query when the active list is empty', async () => { + fetchSpy = mockWorkerSessions([]); + + const selectSpy = jest.spyOn(db, 'select'); + try { + const caller = await createCallerForUser(regularUser.id); + const result = await caller.activeSessions.list(); + + expect(result.sessions).toEqual([]); + // The router short-circuits the enrichment query when there are no + // sessions to enrich. + expect(selectSpy).not.toHaveBeenCalled(); + } finally { + selectSpy.mockRestore(); + } + }); + + it('returns unenriched sessions when the enrichment DB query fails', async () => { + const sessionId = 'ses_active_enrich_db_fail_1234'; + fetchSpy = mockWorkerSessions([ + { + id: sessionId, + status: 'running', + title: 'db fail', + connectionId: 'conn-3', + }, + ]); + + // Force the enrichment Drizzle query to throw. The router must catch + // the failure and return the parsed sessions unenriched — NOT an empty + // list. + const selectSpy = jest.spyOn(db, 'select').mockImplementationOnce(() => { + throw new Error('synthetic enrichment db failure'); + }); + + try { + const caller = await createCallerForUser(regularUser.id); + const result = await caller.activeSessions.list(); + + expect(result.sessions).toEqual([ + { + id: sessionId, + status: 'running', + title: 'db fail', + connectionId: 'conn-3', + createdOnPlatform: undefined, + createdAt: undefined, + updatedAt: undefined, + }, + ]); + } finally { + selectSpy.mockRestore(); + } + }); + + it('degrades to empty sessions when the worker returns a malformed response', async () => { + fetchSpy = mockMalformedWorkerResponse(); + + const caller = await createCallerForUser(regularUser.id); + const result = await caller.activeSessions.list(); + + expect(result).toEqual({ sessions: [] }); + }); +}); diff --git a/apps/web/src/routers/active-sessions-router.ts b/apps/web/src/routers/active-sessions-router.ts index e41d67aed7..8f0428ea8e 100644 --- a/apps/web/src/routers/active-sessions-router.ts +++ b/apps/web/src/routers/active-sessions-router.ts @@ -3,6 +3,9 @@ import { baseProcedure, createTRPCRouter } from '@/lib/trpc/init'; import { z } from 'zod'; import { SESSION_INGEST_WORKER_URL } from '@/lib/config.server'; import { generateInternalServiceToken } from '@/lib/tokens'; +import { db } from '@/lib/drizzle'; +import { cli_sessions_v2 } from '@kilocode/db/schema'; +import { and, eq, inArray } from 'drizzle-orm'; const activeSessionSchema = z.object({ id: z.string(), @@ -11,6 +14,9 @@ const activeSessionSchema = z.object({ connectionId: z.string(), gitUrl: z.string().optional(), gitBranch: z.string().optional(), + createdOnPlatform: z.string().optional(), + createdAt: z.string().optional(), + updatedAt: z.string().optional(), }); const activeSessionsResponseSchema = z.object({ @@ -33,6 +39,11 @@ export const activeSessionsRouter = createTRPCRouter({ const token = generateInternalServiceToken(ctx.user.id); const url = `${SESSION_INGEST_WORKER_URL}/api/sessions/active`; + // Phase 1: fetch + parse the worker response. Any failure here + // (HTTP error, malformed JSON, schema mismatch) degrades to an empty + // list exactly as before — these are "no data" outcomes from the + // mobile client's point of view. + let parsed: { sessions: ActiveSession[] }; try { const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` }, @@ -47,10 +58,63 @@ export const activeSessionsRouter = createTRPCRouter({ } const raw = await response.json(); - return activeSessionsResponseSchema.parse(raw); + parsed = activeSessionsResponseSchema.parse(raw); } catch (error) { console.warn('[active-sessions] error:', error); return { sessions: [] as ActiveSession[] }; } + + // Phase 2: enrich parsed sessions with per-session platform + timestamps + // by joining against cli_sessions_v2. A DB failure here MUST NOT + // collapse the list to empty — callers fall back to unenriched rows. + if (parsed.sessions.length === 0) { + return parsed; + } + + const ids = parsed.sessions.map(s => s.id); + let rows: Array<{ + session_id: string; + created_on_platform: string | null; + created_at: string; + updated_at: string; + }> = []; + try { + rows = await db + .select({ + session_id: cli_sessions_v2.session_id, + created_on_platform: cli_sessions_v2.created_on_platform, + created_at: cli_sessions_v2.created_at, + updated_at: cli_sessions_v2.updated_at, + }) + .from(cli_sessions_v2) + .where( + and( + eq(cli_sessions_v2.kilo_user_id, ctx.user.id), + inArray(cli_sessions_v2.session_id, ids) + ) + ); + } catch (error) { + console.warn('[active-sessions] enrichment db query failed:', error); + return parsed; + } + + const byId = new Map(rows.map(r => [r.session_id, r])); + const sessions: ActiveSession[] = parsed.sessions.map(session => { + const row = byId.get(session.id); + if (!row) { + return session; + } + // Explicit snake_case → camelCase mapping: the mobile client only + // reads createdOnPlatform/createdAt/updatedAt, so we do not spread + // the DB row (which carries snake_case keys it never uses). + return { + ...session, + createdOnPlatform: row.created_on_platform ?? undefined, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + }); + + return { sessions }; }), }); diff --git a/apps/web/src/routers/cli-sessions-router.ts b/apps/web/src/routers/cli-sessions-router.ts index 30b7241e67..55ca56ca80 100644 --- a/apps/web/src/routers/cli-sessions-router.ts +++ b/apps/web/src/routers/cli-sessions-router.ts @@ -30,6 +30,7 @@ import { import { ensureOrganizationAccess } from '@/routers/organizations/utils'; import { getCodeReviewById } from '@/lib/code-reviews/db/code-reviews'; import { verifyWebhookTriggerAccess } from '@/lib/webhook-trigger-ownership'; +import { KNOWN_PLATFORMS } from '@kilocode/app-shared/platforms'; export const BLOB_TYPES = [ 'api_conversation_history', @@ -38,20 +39,6 @@ export const BLOB_TYPES = [ 'git_state', ] as const satisfies readonly FileName[]; -/** Known platform values that have dedicated filters. "Other" is everything else. */ -export const KNOWN_PLATFORMS = [ - 'cloud-agent', - 'cloud-agent-web', - 'cli', - 'vscode', - 'agent-manager', - 'app-builder', - 'slack', - 'github', - 'linear', - 'gastown', -] as const; - const PAGE_SIZE = 10; const commonSessionFields = { diff --git a/apps/web/src/routers/cli-sessions-v2-router.ts b/apps/web/src/routers/cli-sessions-v2-router.ts index 0c95eaf908..d272e474df 100644 --- a/apps/web/src/routers/cli-sessions-v2-router.ts +++ b/apps/web/src/routers/cli-sessions-v2-router.ts @@ -39,7 +39,7 @@ import { validateKiloSdkMessagesCursor, } from '@kilocode/session-ingest-contracts'; import { baseGetSessionNextOutputSchema } from './cloud-agent-next-schemas'; -import { KNOWN_PLATFORMS } from '@/routers/cli-sessions-router'; +import { KNOWN_PLATFORMS } from '@kilocode/app-shared/platforms'; import { verifyWebhookTriggerAccess } from '@/lib/webhook-trigger-ownership'; import { ensureOrganizationAccess } from '@/routers/organizations/utils'; import { diff --git a/apps/web/src/routers/unified-sessions-router.ts b/apps/web/src/routers/unified-sessions-router.ts index 37a754c3ec..157ae92195 100644 --- a/apps/web/src/routers/unified-sessions-router.ts +++ b/apps/web/src/routers/unified-sessions-router.ts @@ -4,7 +4,7 @@ import * as z from 'zod'; import { db } from '@/lib/drizzle'; import { sql, type SQL } from 'drizzle-orm'; import { cliSessions, cli_sessions_v2 } from '@kilocode/db/schema'; -import { KNOWN_PLATFORMS } from '@/routers/cli-sessions-router'; +import { KNOWN_PLATFORMS } from '@kilocode/app-shared/platforms'; import { ensureOrganizationAccess } from '@/routers/organizations/utils'; const PAGE_SIZE = 10; diff --git a/packages/app-shared/package.json b/packages/app-shared/package.json index 4b15e13b41..7182025696 100644 --- a/packages/app-shared/package.json +++ b/packages/app-shared/package.json @@ -8,7 +8,8 @@ "./utils": "./src/utils.ts", "./security-agent": "./src/security-agent/index.ts", "./code-review": "./src/code-review/index.ts", - "./organizations": "./src/organizations/index.ts" + "./organizations": "./src/organizations/index.ts", + "./platforms": "./src/platforms.ts" }, "scripts": { "typecheck": "tsgo --noEmit", diff --git a/packages/app-shared/src/platforms.ts b/packages/app-shared/src/platforms.ts new file mode 100644 index 0000000000..01f4f1ca4f --- /dev/null +++ b/packages/app-shared/src/platforms.ts @@ -0,0 +1,13 @@ +/** Known platform values that have dedicated filters. "Other" is everything else. */ +export const KNOWN_PLATFORMS = [ + 'cloud-agent', + 'cloud-agent-web', + 'cli', + 'vscode', + 'agent-manager', + 'app-builder', + 'slack', + 'github', + 'linear', + 'gastown', +] as const; From a0fc61fdc16c1f1cd429b622d71277a7e3e57a5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 19 Jul 2026 20:35:57 +0200 Subject: [PATCH 11/13] feat(mobile): pinned-tray selection helpers --- .../agents/session-list-helpers.test.ts | 324 ++++++++++++++++++ .../components/agents/session-list-helpers.ts | 119 +++++++ .../src/components/agents/session-row.tsx | 32 +- apps/mobile/src/lib/agent-session-groups.ts | 2 +- 4 files changed, 445 insertions(+), 32 deletions(-) create mode 100644 apps/mobile/src/components/agents/session-list-helpers.test.ts diff --git a/apps/mobile/src/components/agents/session-list-helpers.test.ts b/apps/mobile/src/components/agents/session-list-helpers.test.ts new file mode 100644 index 0000000000..71ca5ec892 --- /dev/null +++ b/apps/mobile/src/components/agents/session-list-helpers.test.ts @@ -0,0 +1,324 @@ +import { describe, expect, it } from 'vitest'; + +import { type ActiveSession } from '@/lib/hooks/use-agent-sessions'; +import { parseTimestamp, timeAgo } from '@/lib/utils'; + +import { + excludeActiveFromGroups, + expandPlatformFilter, + formatMeta, + platformLabel, + remoteAgentLabel, + remoteMeta, + selectPinnedActiveSessions, +} from './session-list-helpers'; +import { type AgentSessionDateGroup } from '@/lib/agent-session-groups'; + +function makeActive(over: Partial = {}): ActiveSession { + return { + id: 'a1', + status: 'running', + title: 'test', + connectionId: 'c1', + ...over, + }; +} + +describe('selectPinnedActiveSessions', () => { + it('does not dedup against stored pages (returns actives regardless)', () => { + // The helper intentionally has no stored-sessions parameter. The + // exclusivity contract lives on the history side (Task 3); the pinned + // set is always driven solely by filters and the active input. + const actives: ActiveSession[] = [makeActive({ id: 'a1' }), makeActive({ id: 'a2' })]; + const result = selectPinnedActiveSessions({ + activeSessions: actives, + projectFilter: [], + platformFilter: [], + }); + expect(result).toEqual(actives); + }); + + it('has no search parameter (search is ignored by construction)', () => { + // Compile-time assertion: selectPinnedActiveSessions must not accept + // a `search` key. If one is added later, @ts-expect-error stops firing + // and typecheck fails. + selectPinnedActiveSessions({ + activeSessions: [], + projectFilter: [], + platformFilter: [], + // @ts-expect-error — search must not be a param of selectPinnedActiveSessions + search: 'anything', + }); + }); + + describe('project rules', () => { + it('excludes rows with missing gitUrl under an active project filter', () => { + const withUrl = makeActive({ id: 'with', gitUrl: 'git@github.com:org/repo.git' }); + const withoutUrl = makeActive({ id: 'without' }); + const result = selectPinnedActiveSessions({ + activeSessions: [withUrl, withoutUrl], + projectFilter: ['git@github.com:org/repo.git'], + platformFilter: [], + }); + expect(result.map(s => s.id)).toEqual(['with']); + }); + + it('includes rows whose gitUrl is in the active project filter', () => { + const match = makeActive({ id: 'match', gitUrl: 'git@github.com:org/repo.git' }); + const other = makeActive({ id: 'other', gitUrl: 'git@github.com:org/other.git' }); + const result = selectPinnedActiveSessions({ + activeSessions: [match, other], + projectFilter: ['git@github.com:org/repo.git'], + platformFilter: [], + }); + expect(result.map(s => s.id)).toEqual(['match']); + }); + + it('includes every active session when no project filter is set', () => { + const a = makeActive({ id: 'a' }); + const b = makeActive({ id: 'b', gitUrl: 'git@github.com:org/repo.git' }); + const c = makeActive({ id: 'c', gitUrl: 'git@github.com:org/other.git' }); + const result = selectPinnedActiveSessions({ + activeSessions: [a, b, c], + projectFilter: [], + platformFilter: [], + }); + expect(result.map(s => s.id)).toEqual(['a', 'b', 'c']); + }); + }); + + describe('platform rules', () => { + it('includes rows whose createdOnPlatform matches a concrete filter', () => { + const cli = makeActive({ id: 'cli', createdOnPlatform: 'cli' }); + const slack = makeActive({ id: 'slack', createdOnPlatform: 'slack' }); + const result = selectPinnedActiveSessions({ + activeSessions: [cli, slack], + projectFilter: [], + platformFilter: ['cli'], + }); + expect(result.map(s => s.id)).toEqual(['cli']); + }); + + it('expands a cloud-agent filter to also match cloud-agent-web', () => { + const web = makeActive({ id: 'web', createdOnPlatform: 'cloud-agent-web' }); + const ca = makeActive({ id: 'ca', createdOnPlatform: 'cloud-agent' }); + const result = selectPinnedActiveSessions({ + activeSessions: [web, ca], + projectFilter: [], + platformFilter: ['cloud-agent'], + }); + expect(result.map(s => s.id).toSorted()).toEqual(['ca', 'web']); + }); + + it('expands an extension filter to vscode and agent-manager', () => { + const vscode = makeActive({ id: 'vscode', createdOnPlatform: 'vscode' }); + const am = makeActive({ id: 'am', createdOnPlatform: 'agent-manager' }); + const slack = makeActive({ id: 'slack', createdOnPlatform: 'slack' }); + const result = selectPinnedActiveSessions({ + activeSessions: [vscode, am, slack], + projectFilter: [], + platformFilter: ['extension'], + }); + expect(result.map(s => s.id).toSorted()).toEqual(['am', 'vscode']); + }); + + it("'other' filter matches an unknown platform", () => { + const weird = makeActive({ id: 'weird', createdOnPlatform: 'my-new-platform' }); + const result = selectPinnedActiveSessions({ + activeSessions: [weird], + projectFilter: [], + platformFilter: ['other'], + }); + expect(result.map(s => s.id)).toEqual(['weird']); + }); + + it("'other' filter rejects a known platform", () => { + const cli = makeActive({ id: 'cli', createdOnPlatform: 'cli' }); + const result = selectPinnedActiveSessions({ + activeSessions: [cli], + projectFilter: [], + platformFilter: ['other'], + }); + expect(result).toEqual([]); + }); + + it("'other' combined with a concrete filter accepts both", () => { + const cli = makeActive({ id: 'cli', createdOnPlatform: 'cli' }); + const weird = makeActive({ id: 'weird', createdOnPlatform: 'my-new-platform' }); + const vscode = makeActive({ id: 'vscode', createdOnPlatform: 'vscode' }); + const result = selectPinnedActiveSessions({ + activeSessions: [cli, weird, vscode], + projectFilter: [], + platformFilter: ['cli', 'other'], + }); + expect(result.map(s => s.id).toSorted()).toEqual(['cli', 'weird']); + }); + + it('excludes rows with undefined createdOnPlatform under any platform filter', () => { + const unknown = makeActive({ id: 'unknown' }); + const cli = makeActive({ id: 'cli', createdOnPlatform: 'cli' }); + const result = selectPinnedActiveSessions({ + activeSessions: [unknown, cli], + projectFilter: [], + platformFilter: ['cli'], + }); + expect(result.map(s => s.id)).toEqual(['cli']); + }); + + it('includes rows with undefined createdOnPlatform when no platform filter is set', () => { + const unknown = makeActive({ id: 'unknown' }); + const cli = makeActive({ id: 'cli', createdOnPlatform: 'cli' }); + const result = selectPinnedActiveSessions({ + activeSessions: [unknown, cli], + projectFilter: [], + platformFilter: [], + }); + expect(result.map(s => s.id).toSorted()).toEqual(['cli', 'unknown']); + }); + }); + + it('combines project and platform filters with AND semantics', () => { + const pass = makeActive({ + id: 'pass', + gitUrl: 'git@github.com:org/repo.git', + createdOnPlatform: 'cli', + }); + const wrongProject = makeActive({ + id: 'wrongProject', + gitUrl: 'git@github.com:org/other.git', + createdOnPlatform: 'cli', + }); + const wrongPlatform = makeActive({ + id: 'wrongPlatform', + gitUrl: 'git@github.com:org/repo.git', + createdOnPlatform: 'slack', + }); + const result = selectPinnedActiveSessions({ + activeSessions: [pass, wrongProject, wrongPlatform], + projectFilter: ['git@github.com:org/repo.git'], + platformFilter: ['cli'], + }); + expect(result.map(s => s.id)).toEqual(['pass']); + }); +}); + +describe('remoteAgentLabel', () => { + it('returns the platform label for cli', () => { + expect(remoteAgentLabel('cli')).toBe('CLI'); + }); + + it('returns the platform label for cloud-agent-web', () => { + expect(remoteAgentLabel('cloud-agent-web')).toBe('CLOUD AGENT'); + }); + + it("returns 'LIVE' for undefined", () => { + expect(remoteAgentLabel(undefined)).toBe('LIVE'); + }); +}); + +describe('remoteMeta', () => { + it('returns the uppercased relative time when updatedAt is present', () => { + const updatedAt = '2024-01-01T00:00:00.000Z'; + const expected = timeAgo(parseTimestamp(updatedAt)).toUpperCase(); + expect(remoteMeta({ status: 'running', updatedAt })).toBe(expected); + }); + + it('returns the uppercased status when updatedAt is absent', () => { + expect(remoteMeta({ status: 'running' })).toBe('RUNNING'); + expect(remoteMeta({ status: 'needs_input' })).toBe('NEEDS_INPUT'); + }); +}); + +describe('platformLabel (moved helper, regression guard)', () => { + it('matches the original mapping', () => { + expect(platformLabel('cloud-agent')).toBe('CLOUD AGENT'); + expect(platformLabel('cloud-agent-web')).toBe('CLOUD AGENT'); + expect(platformLabel('vscode')).toBe('VSCODE'); + expect(platformLabel('agent-manager')).toBe('VSCODE'); + expect(platformLabel('slack')).toBe('SLACK'); + expect(platformLabel('cli')).toBe('CLI'); + expect(platformLabel('my-new-platform')).toBe('MY-NEW-PLATFORM'); + }); +}); + +describe('formatMeta (moved helper, regression guard)', () => { + it('matches the original timeAgo + toUpperCase behavior', () => { + expect(formatMeta('2024-01-01T00:00:00.000Z')).toBe( + timeAgo(parseTimestamp('2024-01-01T00:00:00.000Z')).toUpperCase() + ); + }); +}); + +type ExcludeRow = { session_id: string; created_at: string; updated_at: string }; + +function makeGroup(label: string, ids: string[]): AgentSessionDateGroup { + return { + label, + sessions: ids.map(id => ({ session_id: id, created_at: '', updated_at: '' })), + }; +} + +describe('excludeActiveFromGroups', () => { + type Row = ExcludeRow; + + it('drops matching sessions and drops groups that become empty', () => { + const groups: AgentSessionDateGroup[] = [ + makeGroup('Today', ['a', 'b', 'c']), + makeGroup('Yesterday', ['d', 'e']), + makeGroup('Older', ['f']), + ]; + const result = excludeActiveFromGroups(groups, new Set(['b', 'e', 'f'])); + expect(result.map(g => g.label)).toEqual(['Today', 'Yesterday']); + expect(result[0]?.sessions.map(s => s.session_id)).toEqual(['a', 'c']); + expect(result[1]?.sessions.map(s => s.session_id)).toEqual(['d']); + }); + + it('preserves order when nothing is excluded', () => { + const groups: AgentSessionDateGroup[] = [ + makeGroup('Today', ['a']), + makeGroup('Yesterday', ['b']), + makeGroup('Older', ['c']), + ]; + const result = excludeActiveFromGroups(groups, new Set()); + expect(result.map(g => g.label)).toEqual(['Today', 'Yesterday', 'Older']); + expect(result.map(g => g.sessions.map(s => s.session_id))).toEqual([['a'], ['b'], ['c']]); + }); + + it('returns an empty array when every session is active', () => { + const groups: AgentSessionDateGroup[] = [ + makeGroup('Today', ['a']), + makeGroup('Yesterday', ['b']), + ]; + const result = excludeActiveFromGroups(groups, new Set(['a', 'b'])); + expect(result).toEqual([]); + }); + + it('preserves group label and remaining session order when partially excluded', () => { + const groups: AgentSessionDateGroup[] = [ + makeGroup('Today', ['x', 'y', 'z']), + makeGroup('Older', ['p', 'q']), + ]; + const result = excludeActiveFromGroups(groups, new Set(['y'])); + expect(result.map(g => g.label)).toEqual(['Today', 'Older']); + expect(result[0]?.sessions.map(s => s.session_id)).toEqual(['x', 'z']); + expect(result[1]?.sessions.map(s => s.session_id)).toEqual(['p', 'q']); + }); +}); + +describe('expandPlatformFilter (regression guard for filter expansion)', () => { + it('expands cloud-agent to include cloud-agent-web', () => { + expect(expandPlatformFilter(['cloud-agent']).toSorted()).toEqual( + ['cloud-agent', 'cloud-agent-web'].toSorted() + ); + }); + + it('expands extension to vscode and agent-manager', () => { + expect(expandPlatformFilter(['extension']).toSorted()).toEqual( + ['agent-manager', 'vscode'].toSorted() + ); + }); + + it('passes through unknown concrete values unchanged', () => { + expect(expandPlatformFilter(['cli', 'other'])).toEqual(['cli', 'other']); + }); +}); diff --git a/apps/mobile/src/components/agents/session-list-helpers.ts b/apps/mobile/src/components/agents/session-list-helpers.ts index 2630621f49..ed6e1659ce 100644 --- a/apps/mobile/src/components/agents/session-list-helpers.ts +++ b/apps/mobile/src/components/agents/session-list-helpers.ts @@ -1,4 +1,8 @@ +import { KNOWN_PLATFORMS } from '@kilocode/app-shared/platforms'; + +import { type AgentSessionDateGroup } from '@/lib/agent-session-groups'; import { type ActiveSession, type StoredSession } from '@/lib/hooks/use-agent-sessions'; +import { parseTimestamp, timeAgo } from '@/lib/utils'; export type StoredSessionItem = { kind: 'stored'; @@ -66,3 +70,118 @@ export function matchesSearch(query: string, title: string | null, gitUrl: strin (title?.toLowerCase().includes(q) ?? false) || (gitUrl?.toLowerCase().includes(q) ?? false) ); } + +/** + * Map backend `created_on_platform` strings to a pretty uppercase label + * for the row eyebrow. The row's hue is hashed from this label. + */ +export function platformLabel(platform: string): string { + switch (platform) { + case 'cloud-agent': + case 'cloud-agent-web': { + return 'CLOUD AGENT'; + } + case 'vscode': + case 'agent-manager': { + return 'VSCODE'; + } + case 'slack': { + return 'SLACK'; + } + case 'cli': { + return 'CLI'; + } + default: { + return platform.toUpperCase(); + } + } +} + +export function formatMeta(timestamp: string): string { + return timeAgo(parseTimestamp(timestamp)).toUpperCase(); +} + +/** + * Pinned-tray label for an active session. Reuses `platformLabel` when the + * platform is known, otherwise falls back to 'LIVE'. + */ +export function remoteAgentLabel(createdOnPlatform: string | undefined): string { + return createdOnPlatform ? platformLabel(createdOnPlatform) : 'LIVE'; +} + +/** + * Pinned-tray meta line for an active session. Mirrors `formatMeta` when an + * `updatedAt` timestamp is available, otherwise falls back to the uppercased + * status string (matches the legacy RemoteSessionRow behavior). + */ +export function remoteMeta(session: { status: string; updatedAt?: string }): string { + return session.updatedAt ? formatMeta(session.updatedAt) : session.status.toUpperCase(); +} + +const KNOWN_PLATFORM_VALUES: readonly string[] = KNOWN_PLATFORMS; + +/** + * Select which active sessions appear in the pinned "Active now" tray. + * + * Free-text search is not a parameter: the pinned set ignores search by + * construction. Filters mirror the server-side platform/project narrowing + * used by the stored-session list so the tray never shows a session that + * the user has explicitly filtered out. + * + * No dedup against stored pages is performed here — exclusivity is enforced + * on the history side by Task 3, so this helper stays pure and symmetric. + */ +export function selectPinnedActiveSessions(params: { + activeSessions: ActiveSession[]; + projectFilter: string[]; + platformFilter: string[]; +}): ActiveSession[] { + const { activeSessions, projectFilter, platformFilter } = params; + const projectActive = projectFilter.length > 0; + const platformActive = platformFilter.length > 0; + + const concretePlatforms = platformFilter.filter(p => p !== 'other'); + const includeOther = platformFilter.includes('other'); + const expanded = expandPlatformFilter(concretePlatforms); + return activeSessions.filter(session => { + if (projectActive && (!session.gitUrl || !projectFilter.includes(session.gitUrl))) { + return false; + } + + if (platformActive) { + if (!session.createdOnPlatform) { + return false; + } + const knownMatch = expanded.includes(session.createdOnPlatform); + const otherMatch = includeOther && !KNOWN_PLATFORM_VALUES.includes(session.createdOnPlatform); + if (!knownMatch && !otherMatch) { + return false; + } + } + + return true; + }); +} + +/** + * Drop sessions whose `session_id` is in the active set from date-bucketed + * groups, and drop any groups that become empty as a result. Preserves the + * original group order. + * + * The generic is constrained to the intersection of what the helper needs + * (`session_id`) and what `AgentSessionDateGroup` itself requires + * (`created_at`/`updated_at`); the Task 3 caller passes + * `AgentSessionDateGroup[]` which satisfies both bounds. + */ +export function excludeActiveFromGroups< + T extends { session_id: string; created_at: string; updated_at: string }, +>(groups: AgentSessionDateGroup[], activeSessionIds: Set): AgentSessionDateGroup[] { + const result: AgentSessionDateGroup[] = []; + for (const group of groups) { + const remaining = group.sessions.filter(s => !activeSessionIds.has(s.session_id)); + if (remaining.length > 0) { + result.push({ label: group.label, sessions: remaining }); + } + } + return result; +} diff --git a/apps/mobile/src/components/agents/session-row.tsx b/apps/mobile/src/components/agents/session-row.tsx index aba8b31640..819bdb7589 100644 --- a/apps/mobile/src/components/agents/session-row.tsx +++ b/apps/mobile/src/components/agents/session-row.tsx @@ -12,7 +12,7 @@ import { shouldShowNeedsInput, useSessionAttentionRevision, } from '@/lib/session-attention'; -import { parseTimestamp, timeAgo } from '@/lib/utils'; +import { formatMeta, platformLabel } from './session-list-helpers'; type StoredSessionRowProps = { session: { @@ -49,36 +49,6 @@ type RemoteSessionRowProps = { onPress: () => void; }; -/** - * Map backend `created_on_platform` strings to a pretty uppercase label - * for the row eyebrow. The row's hue is hashed from this label. - */ -function platformLabel(platform: string): string { - switch (platform) { - case 'cloud-agent': - case 'cloud-agent-web': { - return 'CLOUD AGENT'; - } - case 'vscode': - case 'agent-manager': { - return 'VSCODE'; - } - case 'slack': { - return 'SLACK'; - } - case 'cli': { - return 'CLI'; - } - default: { - return platform.toUpperCase(); - } - } -} - -function formatMeta(timestamp: string): string { - return timeAgo(parseTimestamp(timestamp)).toUpperCase(); -} - function showDeleteConfirm(onDelete: () => void) { void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning); Alert.alert('Delete session?', 'This cannot be undone.', [ diff --git a/apps/mobile/src/lib/agent-session-groups.ts b/apps/mobile/src/lib/agent-session-groups.ts index 0b0ffb3b28..1c2f64b400 100644 --- a/apps/mobile/src/lib/agent-session-groups.ts +++ b/apps/mobile/src/lib/agent-session-groups.ts @@ -7,7 +7,7 @@ import { parseTimestamp } from '@/lib/utils'; type SessionTimestamps = { created_at: string; updated_at: string }; -type AgentSessionDateGroup = { +export type AgentSessionDateGroup = { label: string; sessions: T[]; }; From 6c13618114e8fa04cb87555a4a83f510858c7db7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 19 Jul 2026 21:04:57 +0200 Subject: [PATCH 12/13] feat(mobile): pinned "Active now" tray on the session list --- .../components/agents/active-now-section.tsx | 53 +++++ .../agents/session-list-body-empty.tsx | 81 +++++++ .../agents/session-list-body-model.test.ts | 200 ++++++++++++++++++ .../agents/session-list-body-model.ts | 139 ++++++++++++ .../agents/session-list-content.tsx | 179 +++++++--------- .../components/agents/session-list-helpers.ts | 27 +-- .../components/agents/session-list-screen.tsx | 111 ++++------ .../agents/session-list-section-header.tsx | 30 +++ .../src/components/agents/session-row.tsx | 20 +- .../ui/session-row-eyebrow-right.test.ts | 136 ++++++++++++ .../ui/session-row-eyebrow-right.ts | 43 ++++ apps/mobile/src/components/ui/session-row.tsx | 33 ++- .../src/lib/hooks/use-agent-sessions.ts | 33 ++- 13 files changed, 879 insertions(+), 206 deletions(-) create mode 100644 apps/mobile/src/components/agents/active-now-section.tsx create mode 100644 apps/mobile/src/components/agents/session-list-body-empty.tsx create mode 100644 apps/mobile/src/components/agents/session-list-body-model.test.ts create mode 100644 apps/mobile/src/components/agents/session-list-body-model.ts create mode 100644 apps/mobile/src/components/agents/session-list-section-header.tsx create mode 100644 apps/mobile/src/components/ui/session-row-eyebrow-right.test.ts create mode 100644 apps/mobile/src/components/ui/session-row-eyebrow-right.ts diff --git a/apps/mobile/src/components/agents/active-now-section.tsx b/apps/mobile/src/components/agents/active-now-section.tsx new file mode 100644 index 0000000000..1ceedfa37d --- /dev/null +++ b/apps/mobile/src/components/agents/active-now-section.tsx @@ -0,0 +1,53 @@ +import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; + +import { RemoteSessionRow } from '@/components/agents/session-row'; +import { SessionListSectionHeader } from '@/components/agents/session-list-section-header'; +import { type ActiveSession } from '@/lib/hooks/use-agent-sessions'; + +type ActiveNowSectionProps = { + /** Pinned sessions. The section renders `null` when empty. */ + pinned: ActiveSession[]; + /** + * Organization id for each session id, when one is known from the stored + * pages. Tray rows that also live in history reuse the stored org id so + * navigation stays in the user's org context. + */ + organizationIdBySessionId: Map; + onSessionPress: (sessionId: string, organizationId?: string | null) => void; +}; + +/** + * Pinned "Active now" tray for the Agents session list. Renders above the + * history list. The section never scrolls itself — it sits inside the + * screen's non-scrolling vertical layout so it animates in/out with + * Reanimated `FadeIn`/`FadeOut` while the screen's `LinearTransition` + * wrappers absorb the layout change without jumping the history list. + */ +export function ActiveNowSection({ + pinned, + organizationIdBySessionId, + onSessionPress, +}: Readonly) { + if (pinned.length === 0) { + return null; + } + + return ( + + + {pinned.map(session => ( + { + onSessionPress(session.id, organizationIdBySessionId.get(session.id)); + }} + /> + ))} + + ); +} diff --git a/apps/mobile/src/components/agents/session-list-body-empty.tsx b/apps/mobile/src/components/agents/session-list-body-empty.tsx new file mode 100644 index 0000000000..caa289ce1e --- /dev/null +++ b/apps/mobile/src/components/agents/session-list-body-empty.tsx @@ -0,0 +1,81 @@ +import { History, SearchX } from 'lucide-react-native'; +import { type ReactNode } from 'react'; +import { View } from 'react-native'; + +import { EmptyState } from '@/components/empty-state'; +import { QueryError } from '@/components/query-error'; + +type BodyEmptyProps = { + kind: 'filtered-empty' | 'query-error-empty' | 'no-past-sessions'; + isSearching: boolean; + secondaryAction?: 'clear-search' | 'clear-filters' | 'none'; + emptyStateAction: ReactNode; + clearQueryAction: ReactNode; + onRetry: () => void; +}; + +/** + * Renders the body empty-state for the Agents session list, switched on the + * `kind` returned by the body render model. Each branch is a compact + * `View` matching the design language of the rest of the list (icon + title + * + description + one CTA). + */ +export function BodyEmpty({ + kind, + isSearching, + secondaryAction, + emptyStateAction, + clearQueryAction, + onRetry, +}: Readonly) { + if (kind === 'filtered-empty') { + // Active search/filter narrowed the results to zero matches — never + // show the "create a task" CTA here, it's not the fix for a filter + // that's too narrow. + return ( + + + + ); + } + if (kind === 'query-error-empty') { + // The query in error produced no rows to show — surface a retry for + // it (search or list, whichever `onRetry` targets). A Clear CTA is + // shown whenever the model reports an active query, choosing the + // label that matches the query type. + return ( + + + {secondaryAction === 'clear-search' || secondaryAction === 'clear-filters' + ? clearQueryAction + : null} + + ); + } + // 'no-past-sessions' — body is empty but the tray is populated. The + // screen-level first-use empty ("No sessions yet") is handled by the + // caller when there is no tray either. + return ( + + + + ); +} diff --git a/apps/mobile/src/components/agents/session-list-body-model.test.ts b/apps/mobile/src/components/agents/session-list-body-model.test.ts new file mode 100644 index 0000000000..58a2b7a519 --- /dev/null +++ b/apps/mobile/src/components/agents/session-list-body-model.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from 'vitest'; + +import { selectSessionListBodyModel } from './session-list-body-model'; + +function model(overrides: Partial[0]> = {}) { + return selectSessionListBodyModel({ + hasHistoryContent: false, + hasPinnedActive: false, + hasActiveQuery: false, + isSearching: false, + isError: false, + activeIsError: false, + ...overrides, + }); +} + +describe('selectSessionListBodyModel', () => { + describe('happy (history present)', () => { + it('renders the list with no CTA and no inline error', () => { + expect( + model({ + hasHistoryContent: true, + hasPinnedActive: true, + activeIsError: true, + }) + ).toEqual({ kind: 'render-list', primaryAction: 'none', showInlineError: true }); + }); + + it('hides the inline error when nothing is in error and history is shown', () => { + expect(model({ hasHistoryContent: true })).toEqual({ + kind: 'render-list', + primaryAction: 'none', + showInlineError: false, + }); + }); + }); + + describe('empty body with active query', () => { + it('shows filtered-empty + Clear search when a search is active', () => { + expect( + model({ + hasHistoryContent: false, + hasActiveQuery: true, + isSearching: true, + }) + ).toEqual({ + kind: 'filtered-empty', + primaryAction: 'clear-search', + showInlineError: false, + }); + }); + + it('shows filtered-empty + Clear filters when only a narrowing filter is active', () => { + expect( + model({ + hasHistoryContent: false, + hasActiveQuery: true, + }) + ).toEqual({ + kind: 'filtered-empty', + primaryAction: 'clear-filters', + showInlineError: false, + }); + }); + + it('shows query-error + Retry + Clear search for a search in error', () => { + expect( + model({ + hasHistoryContent: false, + hasActiveQuery: true, + isSearching: true, + isError: true, + }) + ).toEqual({ + kind: 'query-error-empty', + primaryAction: 'retry', + secondaryAction: 'clear-search', + showInlineError: false, + }); + }); + + it('shows query-error + Retry + Clear filters for a filter in error', () => { + expect( + model({ + hasHistoryContent: false, + hasActiveQuery: true, + isError: true, + }) + ).toEqual({ + kind: 'query-error-empty', + primaryAction: 'retry', + secondaryAction: 'clear-filters', + showInlineError: false, + }); + }); + + it('a populated tray does not change the active-query body decision', () => { + expect( + model({ + hasHistoryContent: false, + hasPinnedActive: true, + hasActiveQuery: true, + isSearching: true, + }).kind + ).toBe('filtered-empty'); + }); + }); + + describe('empty body without active query', () => { + it('shows retryable error empty with Retry (no Clear) when the body errored', () => { + expect( + model({ + hasHistoryContent: false, + isError: true, + }) + ).toEqual({ + kind: 'query-error-empty', + primaryAction: 'retry', + secondaryAction: 'none', + showInlineError: false, + }); + }); + + it('shows the compact "No past sessions" empty with New coding task when no error and a tray is present', () => { + expect( + model({ + hasHistoryContent: false, + hasPinnedActive: true, + }) + ).toEqual({ + kind: 'no-past-sessions', + primaryAction: 'new-task', + showInlineError: false, + }); + }); + + it('returns no-past-sessions even when the tray is empty (first-use is handled by the caller)', () => { + expect(model({ hasHistoryContent: false })).toEqual({ + kind: 'no-past-sessions', + primaryAction: 'new-task', + showInlineError: false, + }); + }); + }); + + describe('inline error / staleness surfacing', () => { + it('shows the inline error when only the active poll failed and the tray is present', () => { + expect( + model({ + hasHistoryContent: false, + hasPinnedActive: true, + activeIsError: true, + }) + ).toEqual({ + kind: 'no-past-sessions', + primaryAction: 'new-task', + showInlineError: true, + }); + }); + + it('does not show the inline error when only the active poll failed and nothing is visible', () => { + expect( + model({ + hasHistoryContent: false, + hasPinnedActive: false, + activeIsError: true, + }) + ).toEqual({ + kind: 'no-past-sessions', + primaryAction: 'new-task', + showInlineError: false, + }); + }); + + it('does NOT show the inline error when the body and tray are empty even if search+active errored', () => { + expect( + model({ + hasHistoryContent: false, + hasActiveQuery: true, + isSearching: true, + isError: true, + activeIsError: true, + }).showInlineError + ).toBe(false); + }); + + it('does NOT collapse a simultaneous search+active failure into the search-error message (search surface still wins)', () => { + const result = model({ + hasHistoryContent: false, + hasActiveQuery: true, + isSearching: true, + isError: true, + activeIsError: true, + }); + expect(result.kind).toBe('query-error-empty'); + expect(result.primaryAction).toBe('retry'); + expect(result.showInlineError).toBe(false); + }); + }); +}); diff --git a/apps/mobile/src/components/agents/session-list-body-model.ts b/apps/mobile/src/components/agents/session-list-body-model.ts new file mode 100644 index 0000000000..fef9ddd9dc --- /dev/null +++ b/apps/mobile/src/components/agents/session-list-body-model.ts @@ -0,0 +1,139 @@ +/** + * Pure decision tree for the Agents session list body. + * + * Encapsulates the "what should the body show right now?" question so the + * component only has to map the result onto the existing UI pieces + * (`EmptyState`, `QueryError`, `SectionList`). Every input is a boolean + * flag, the output is a small discriminated union — there is no React or + * native dependency, so this module is unit-testable in plain Node. + * + * Classification rules (see Task 3 feature-state matrix): + * - The screen-level first-use empty ("No sessions yet" + New coding task) + * is handled by the caller BEFORE this function runs — it does not + * appear in the union. The model only decides what to render when + * there is at least some content (the screen-level "has any sessions" + * gate has already been passed, or the caller is asking what to put + * inside the SectionList body). + * - When the list body is empty (no history sections rendered): + * 1. Active search/filter query → filtered-empty OR query-error + * (when in error). The query-error variant always gets a Retry + * CTA; a secondary Clear CTA is shown for any active query + * ("Clear search" or "Clear filters"), while a no-query error + * shows only Retry. + * 2. No query, error → retry-capable error empty state. + * 3. No query, no error → compact "No past sessions" + New coding + * task CTA. + * - `showInlineError` mirrors the prior inline header ("Couldn't refresh. + * Pull down to try again.") and is ADDITIONALLY driven by the + * active-only failure flag (`activeIsError`) so the tray's non-blocking + * staleness surface is rendered whenever there is visible content. + */ + +export type SessionListBodyModel = + | { + kind: 'render-list'; + primaryAction: 'none'; + showInlineError: boolean; + } + | { + kind: 'filtered-empty'; + primaryAction: 'clear-search' | 'clear-filters'; + showInlineError: boolean; + } + | { + kind: 'query-error-empty'; + primaryAction: 'retry'; + secondaryAction: 'clear-search' | 'clear-filters' | 'none'; + showInlineError: boolean; + } + | { + kind: 'no-past-sessions'; + primaryAction: 'new-task'; + showInlineError: boolean; + }; + +type SessionListBodyModelInputs = { + /** True when rendered history sections contain at least one row. */ + hasHistoryContent: boolean; + /** + * True when the pinned "Active now" tray is non-empty. A populated tray + * suppresses the full-screen QueryError (the screen-level first-use + * gate already accounts for it) and contributes to the + * "visible cached content" check that drives the inline error line. + */ + hasPinnedActive: boolean; + /** True when a search query OR a platform/project filter is active. */ + hasActiveQuery: boolean; + /** True when the active search text is non-empty. */ + isSearching: boolean; + /** + * Body-driving error flag. The screen combines search and non-search + * errors here, BUT active-only failures are NOT folded in — they + * surface via `showInlineError` and never select the search-error + * message. + */ + isError: boolean; + /** + * Whether the active-poll query itself failed. Drives ONLY the inline + * error line when content is visible; never selects a body empty state + * or the search-error message. + */ + activeIsError: boolean; +}; + +export function selectSessionListBodyModel( + inputs: SessionListBodyModelInputs +): SessionListBodyModel { + const { + hasHistoryContent, + hasPinnedActive, + hasActiveQuery, + isSearching, + isError, + activeIsError, + } = inputs; + + const showInlineError = (isError || activeIsError) && (hasHistoryContent || hasPinnedActive); + + // History has rows — nothing to decide at the body level. + if (hasHistoryContent) { + return { kind: 'render-list', primaryAction: 'none', showInlineError }; + } + + // History empty: priority is the active query branch (even when the + // tray is populated — a query should still narrow the body). + if (hasActiveQuery) { + if (isError) { + // Query-error empty: Retry is always available. A Clear CTA is also + // shown whenever an active query exists, choosing the label that + // matches the active query type (search vs. narrowing filter). + return { + kind: 'query-error-empty', + primaryAction: 'retry', + secondaryAction: isSearching ? 'clear-search' : 'clear-filters', + showInlineError, + }; + } + return { + kind: 'filtered-empty', + primaryAction: isSearching ? 'clear-search' : 'clear-filters', + showInlineError, + }; + } + + // No active query, history empty. + if (isError) { + return { + kind: 'query-error-empty', + primaryAction: 'retry', + secondaryAction: 'none', + showInlineError, + }; + } + + return { + kind: 'no-past-sessions', + primaryAction: 'new-task', + showInlineError, + }; +} diff --git a/apps/mobile/src/components/agents/session-list-content.tsx b/apps/mobile/src/components/agents/session-list-content.tsx index a127b3dc01..cba1d0fdad 100644 --- a/apps/mobile/src/components/agents/session-list-content.tsx +++ b/apps/mobile/src/components/agents/session-list-content.tsx @@ -1,5 +1,5 @@ import { useFocusEffect } from 'expo-router'; -import { Bot, Plus, Search, SearchX } from 'lucide-react-native'; +import { Bot, Plus, Search } from 'lucide-react-native'; import { useCallback, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, @@ -13,12 +13,17 @@ import { import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { type SessionItem, type SessionSection } from '@/components/agents/session-list-helpers'; -import { RemoteSessionRow, StoredSessionRow } from '@/components/agents/session-row'; +import { BodyEmpty } from '@/components/agents/session-list-body-empty'; +import { + selectSessionListBodyModel, + type SessionListBodyModel, +} from '@/components/agents/session-list-body-model'; +import { type SessionSection } from '@/components/agents/session-list-helpers'; +import { SessionListSectionHeader } from '@/components/agents/session-list-section-header'; +import { StoredSessionRow } from '@/components/agents/session-row'; import { EmptyState } from '@/components/empty-state'; import { QueryError } from '@/components/query-error'; import { Button } from '@/components/ui/button'; -import { Eyebrow } from '@/components/ui/eyebrow'; import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; import { type AgentSessionSortBy } from '@/lib/agent-session-sort'; @@ -33,11 +38,21 @@ const SEARCH_BAR_HEIGHT = 60; type AgentSessionListContentProps = { sections: SessionSection[]; - storedSessions: StoredSession[]; hasAnySessions: boolean; + /** True when the pinned "Active now" tray is non-empty. Used by the + * render model to keep the inline "Couldn't refresh" line visible and + * to suppress the full-screen QueryError when the tray is the only + * thing on screen. */ + hasPinnedActive: boolean; isLoading: boolean; isSearchPending: boolean; + /** Body-driving error flag — a search failure (when searching) OR a + * stored/history failure. Active-only failures are surfaced separately + * via the body's `showInlineError` output, NEVER as the empty-state + * message. */ isError: boolean; + /** Active-poll failure — drives ONLY the inline staleness line. */ + activeIsError: boolean; isFetchingNextPage: boolean; refetch: () => Promise; onRetry: () => void; @@ -53,11 +68,12 @@ type AgentSessionListContentProps = { export function AgentSessionListContent({ sections, - storedSessions, hasAnySessions, + hasPinnedActive, isLoading, isSearchPending, isError, + activeIsError, isFetchingNextPage, refetch, onRetry, @@ -91,9 +107,21 @@ export function AgentSessionListContent({ [bottom, fontScale] ); - // When the list is empty the error surface below (QueryError + retry) - // already covers it — don't double up with the inline header line. - const showInlineError = isError && sections.length > 0; + const hasHistoryContent = sections.length > 0; + + // Pure body decision — see `session-list-body-model.ts`. + const bodyModel = useMemo( + () => + selectSessionListBodyModel({ + hasHistoryContent, + hasPinnedActive, + hasActiveQuery, + isSearching, + isError, + activeIsError, + }), + [activeIsError, hasActiveQuery, hasHistoryContent, hasPinnedActive, isError, isSearching] + ); const listHeader = useMemo( () => ( @@ -119,14 +147,14 @@ export function AgentSessionListContent({ ) : null} - {showInlineError ? ( + {bodyModel.showInlineError ? ( Couldn't refresh. Pull down to try again. ) : null} ), - [colors.mutedForeground, showInlineError, isSearchPending, onSearchChange] + [bodyModel.showInlineError, colors.mutedForeground, isSearchPending, onSearchChange] ); const emptyStateAction = useMemo( @@ -148,49 +176,6 @@ export function AgentSessionListContent({ [handleClearQuery, isSearching] ); - // Only reachable when `hasAnySessions` is true (the true first-use empty - // state is handled separately below), which means an active search or - // filter narrowed the results to zero matches — never show the "create a - // task" CTA here, it's not the fix for a filter that's too narrow. - const filteredEmptyComponent = useMemo( - () => ( - - - - ), - [clearQueryAction, isSearching] - ); - - // The query in error produced no rows to show — surface a retry for it - // (search or list, whichever `onRetry` targets) instead of pretending the - // empty result is a real "no matches". - const queryErrorEmptyComponent = useMemo( - () => ( - - - {clearQueryAction} - - ), - [clearQueryAction, isSearching, onRetry] - ); - - const organizationIdBySessionId = useMemo( - () => new Map(storedSessions.map(s => [s.session_id, s.organization_id])), - [storedSessions] - ); - // The tabs navigator uses `freezeOnBlur`, so while the session detail screen // is pushed the Agents list is frozen. react-freeze reveals the previously // rendered (cached) cells on return WITHOUT re-running them, so the attention @@ -222,53 +207,32 @@ export function AgentSessionListContent({ }, [refetch]); const renderItem = useCallback( - ({ item }: { item: SessionItem }) => { - if (item.kind === 'stored') { - return ( - { - onSessionPress(item.session.session_id, item.session.organization_id); - }} - onDelete={() => { - deleteSession(item.session.session_id); - }} - onRename={newTitle => { - renameSession(item.session.session_id, newTitle); - }} - /> - ); - } - return ( - { - onSessionPress(item.session.id, organizationIdBySessionId.get(item.session.id)); - }} - /> - ); - }, - [onSessionPress, deleteSession, renameSession, organizationIdBySessionId, sortBy] + ({ item }: { item: StoredSession }) => ( + { + onSessionPress(item.session_id, item.organization_id); + }} + onDelete={() => { + deleteSession(item.session_id); + }} + onRename={newTitle => { + renameSession(item.session_id, newTitle); + }} + /> + ), + [onSessionPress, deleteSession, renameSession, sortBy] ); const renderSectionHeader = useCallback( ({ section }: { section: SessionSection }) => ( - - {section.title} - - {section.data.length} - - + ), [] ); - const keyExtractor = useCallback( - (item: SessionItem) => (item.kind === 'stored' ? item.session.session_id : item.session.id), - [] - ); + const keyExtractor = useCallback((item: StoredSession) => item.session_id, []); if (isLoading) { return ( @@ -285,7 +249,8 @@ export function AgentSessionListContent({ // Full-screen error only when there is nothing cached to fall back on — // a background refetch/search failure with stale sessions already in // cache (keepPreviousData) must never blank out what's already rendered. - if (isError && !hasAnySessions) { + // A populated tray counts as "something on screen" and also suppresses. + if (isError && !hasAnySessions && !hasPinnedActive) { return ( + ); } return ( - + key={attentionListKey} sections={sections} renderItem={renderItem} diff --git a/apps/mobile/src/components/agents/session-list-helpers.ts b/apps/mobile/src/components/agents/session-list-helpers.ts index ed6e1659ce..cc5c888df9 100644 --- a/apps/mobile/src/components/agents/session-list-helpers.ts +++ b/apps/mobile/src/components/agents/session-list-helpers.ts @@ -4,22 +4,14 @@ import { type AgentSessionDateGroup } from '@/lib/agent-session-groups'; import { type ActiveSession, type StoredSession } from '@/lib/hooks/use-agent-sessions'; import { parseTimestamp, timeAgo } from '@/lib/utils'; -export type StoredSessionItem = { - kind: 'stored'; - session: StoredSession; - isLive: boolean; -}; - -export type RemoteSessionItem = { - kind: 'remote'; - session: ActiveSession; -}; - -export type SessionItem = StoredSessionItem | RemoteSessionItem; - +/** + * One stored-history section. Exclusivity against the "Active now" tray is + * enforced on the history side via `excludeActiveFromGroups`; active sessions + * no longer appear as `SessionItem`s in any section. + */ export type SessionSection = { title: string; - data: SessionItem[]; + data: StoredSession[]; }; const platformExpansion: Record = { @@ -64,13 +56,6 @@ export function formatGitUrlProject(gitUrl: string): string { return gitUrl; } -export function matchesSearch(query: string, title: string | null, gitUrl: string | null): boolean { - const q = query.toLowerCase(); - return ( - (title?.toLowerCase().includes(q) ?? false) || (gitUrl?.toLowerCase().includes(q) ?? false) - ); -} - /** * Map backend `created_on_platform` strings to a pretty uppercase label * for the row eyebrow. The row's hue is hashed from this label. diff --git a/apps/mobile/src/components/agents/session-list-screen.tsx b/apps/mobile/src/components/agents/session-list-screen.tsx index 55cf54becc..c65f187713 100644 --- a/apps/mobile/src/components/agents/session-list-screen.tsx +++ b/apps/mobile/src/components/agents/session-list-screen.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { View } from 'react-native'; import Animated, { LinearTransition } from 'react-native-reanimated'; +import { ActiveNowSection } from '@/components/agents/active-now-section'; import { getNewAgentSessionPath } from '@/components/agents/session-list-routes'; import { AgentSessionListContent } from '@/components/agents/session-list-content'; import { SessionListHeaderActions } from '@/components/agents/session-list-header-actions'; @@ -11,12 +12,11 @@ import { SessionFilterModal, } from '@/components/agents/platform-filter-modal'; import { + excludeActiveFromGroups, expandPlatformFilter, formatGitUrlProject, - matchesSearch, - type RemoteSessionItem, + selectPinnedActiveSessions, type SessionSection, - type StoredSessionItem, } from '@/components/agents/session-list-helpers'; import { ScreenHeader } from '@/components/screen-header'; import { clearAgentSessionNarrowingFilters } from '@/lib/agent-session-filters'; @@ -88,6 +88,7 @@ export function AgentSessionListScreen() { dateGroups, activeSessions, activeSessionIds, + activeIsError, isLoading, isError, hasNextPage, @@ -118,17 +119,25 @@ export function AgentSessionListScreen() { // While searching, only the search query's own error/pending state matters — // it's the one actually driving what's on screen. Retrying should hit // whichever query is really in error instead of always refetching the base - // list underneath a failed search. + // list underneath a failed search. An active-only failure during search + // additionally retries the active poll so the inline staleness line clears. const contentIsError = isSearching ? search.isError : isError; const isSearchPending = isSearching && search.isPending; const searchRefetch = search.refetch; const handleRetry = useCallback(() => { - if (isSearching) { - void searchRefetch(); - } else { + if (!isSearching) { void refetch(); + return; + } + if (activeIsError) { + // search is the body, active is the tray — retry both. + void (async () => { + await Promise.all([searchRefetch(), refetch()]); + })(); + return; } - }, [isSearching, searchRefetch, refetch]); + void searchRefetch(); + }, [activeIsError, isSearching, refetch, searchRefetch]); // Pull-to-refresh must also retry the search query while one is active — // it's the query actually driving what's on screen. @@ -180,69 +189,33 @@ export function AgentSessionListScreen() { // `isSearchPending` drives a lightweight inline indicator instead. const effectiveSearchQuery = isSearchPending ? '' : searchQuery; - const sections = useMemo(() => { - const result: SessionSection[] = []; - const storedSessionIds = new Set(storedSessions.map(session => session.session_id)); - - const filteredActive = activeSessions.filter(session => { - if (storedSessionIds.has(session.id)) { - return false; - } - - if (projectFilter.length > 0 && !session.gitUrl) { - return false; - } - - if (projectFilter.length > 0 && session.gitUrl && !projectFilter.includes(session.gitUrl)) { - return false; - } - - return effectiveSearchQuery - ? matchesSearch(effectiveSearchQuery, session.title, session.gitUrl ?? null) - : true; - }); + // Pinned "Active now" tray. Free-text search is intentionally NOT a + // narrowing input here — the tray persists while the user types. The + // helper applies only the platform/project filters so the tray never + // shows a session the user has explicitly filtered out. + const pinnedActive = useMemo( + () => selectPinnedActiveSessions({ activeSessions, projectFilter, platformFilter }), + [activeSessions, platformFilter, projectFilter] + ); + const hasPinnedActive = pinnedActive.length > 0; - if (filteredActive.length > 0) { - result.push({ - title: 'Remote', - data: filteredActive.map( - (session): RemoteSessionItem => ({ - kind: 'remote', - session, - }) - ), - }); - } + const organizationIdBySessionId = useMemo( + () => new Map(storedSessions.map(s => [s.session_id, s.organization_id])), + [storedSessions] + ); + // History sections only. The pinned tray takes over for active sessions + // and `excludeActiveFromGroups` keeps history exclusivity. + const sections = useMemo(() => { // Stored sessions are cursor-paginated, so a client-side filter would only // see the loaded pages. When a query is active, use the server search // results (which cover the full history) instead. const storedGroups = effectiveSearchQuery ? search.dateGroups : dateGroups; - for (const group of storedGroups) { - if (group.sessions.length > 0) { - result.push({ - title: group.label, - data: group.sessions.map( - (session): StoredSessionItem => ({ - kind: 'stored', - session, - isLive: activeSessionIds.has(session.session_id), - }) - ), - }); - } - } - - return result; - }, [ - activeSessionIds, - activeSessions, - dateGroups, - effectiveSearchQuery, - projectFilter, - search.dateGroups, - storedSessions, - ]); + return excludeActiveFromGroups(storedGroups, activeSessionIds).map(group => ({ + title: group.label, + data: group.sessions, + })); + }, [activeSessionIds, dateGroups, effectiveSearchQuery, search.dateGroups]); const navigateToSession = useCallback( (sessionId: string, sessionOrgId?: string | null) => { @@ -303,14 +276,20 @@ export function AgentSessionListScreen() { }} /> + ` + a mono count + * `text-[10px] uppercase tracking-[1.5px] text-muted-soft`. + */ +export function SessionListSectionHeader({ + title, + count, +}: Readonly) { + return ( + + {title} + + {count} + + + ); +} diff --git a/apps/mobile/src/components/agents/session-row.tsx b/apps/mobile/src/components/agents/session-row.tsx index 819bdb7589..1361e7794f 100644 --- a/apps/mobile/src/components/agents/session-row.tsx +++ b/apps/mobile/src/components/agents/session-row.tsx @@ -6,13 +6,14 @@ import { SessionRow } from '@/components/ui/session-row'; import { Text } from '@/components/ui/text'; import { type AgentSessionSortBy, getAgentSessionTimestamp } from '@/lib/agent-session-sort'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { type ActiveSession } from '@/lib/hooks/use-agent-sessions'; import { isAttentionAcked, reconcileSessionAttention, shouldShowNeedsInput, useSessionAttentionRevision, } from '@/lib/session-attention'; -import { formatMeta, platformLabel } from './session-list-helpers'; +import { formatMeta, platformLabel, remoteAgentLabel, remoteMeta } from './session-list-helpers'; type StoredSessionRowProps = { session: { @@ -27,7 +28,6 @@ type StoredSessionRowProps = { status: string | null; status_updated_at: string | null; }; - isLive: boolean; /** * Which timestamp drives the row's relative meta label. The list * section the session lands in and the timestamp shown here are @@ -40,12 +40,7 @@ type StoredSessionRowProps = { }; type RemoteSessionRowProps = { - session: { - id: string; - title: string; - status: string; - gitBranch?: string; - }; + session: ActiveSession; onPress: () => void; }; @@ -80,7 +75,6 @@ function showRenamePrompt(currentTitle: string, onRename: (newTitle: string) => export function StoredSessionRow({ session, - isLive, sortBy, onPress, onDelete, @@ -163,7 +157,6 @@ export function StoredSessionRow({ title={title} subtitle={session.git_branch} meta={formatMeta(getAgentSessionTimestamp(session, sortBy))} - live={isLive} needsInput={needsInput} stripMode="inline" className="pl-[22px] pr-[22px]" @@ -243,12 +236,13 @@ export function RemoteSessionRow({ session, onPress }: Readonly diff --git a/apps/mobile/src/components/ui/session-row-eyebrow-right.test.ts b/apps/mobile/src/components/ui/session-row-eyebrow-right.test.ts new file mode 100644 index 0000000000..d7811b0422 --- /dev/null +++ b/apps/mobile/src/components/ui/session-row-eyebrow-right.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest'; + +import { selectSessionRowEyebrowRight } from './session-row-eyebrow-right'; + +describe('selectSessionRowEyebrowRight', () => { + it('returns needs-input regardless of live/meta flags (highest priority)', () => { + expect( + selectSessionRowEyebrowRight({ + needsInput: true, + live: true, + hasMeta: true, + metaWhileLive: true, + }) + ).toEqual({ kind: 'needs-input' }); + expect( + selectSessionRowEyebrowRight({ + needsInput: true, + live: true, + hasMeta: true, + metaWhileLive: false, + }) + ).toEqual({ kind: 'needs-input' }); + expect( + selectSessionRowEyebrowRight({ + needsInput: true, + live: false, + hasMeta: false, + metaWhileLive: false, + }) + ).toEqual({ kind: 'needs-input' }); + }); + + it('returns live-and-meta only when opted in', () => { + expect( + selectSessionRowEyebrowRight({ + needsInput: false, + live: true, + hasMeta: true, + metaWhileLive: true, + }) + ).toEqual({ kind: 'live-and-meta' }); + }); + + it('returns live (not live-and-meta) when metaWhileLive is false even with meta and live', () => { + // Preserves Home's byte-for-byte default behavior. + expect( + selectSessionRowEyebrowRight({ + needsInput: false, + live: true, + hasMeta: true, + metaWhileLive: false, + }) + ).toEqual({ kind: 'live' }); + }); + + it('returns live when live is true and there is no meta', () => { + expect( + selectSessionRowEyebrowRight({ + needsInput: false, + live: true, + hasMeta: false, + metaWhileLive: true, + }) + ).toEqual({ kind: 'live' }); + }); + + it('returns meta when not live and meta is present', () => { + expect( + selectSessionRowEyebrowRight({ + needsInput: false, + live: false, + hasMeta: true, + metaWhileLive: false, + }) + ).toEqual({ kind: 'meta' }); + }); + + it('returns none when nothing is set', () => { + expect( + selectSessionRowEyebrowRight({ + needsInput: false, + live: false, + hasMeta: false, + metaWhileLive: false, + }) + ).toEqual({ kind: 'none' }); + }); + + it('precedence summary: needsInput > live+meta(composition) > live > meta > none', () => { + // needsInput beats everything + expect( + selectSessionRowEyebrowRight({ + needsInput: true, + live: true, + hasMeta: true, + metaWhileLive: true, + }).kind + ).toBe('needs-input'); + // live+meta composition + expect( + selectSessionRowEyebrowRight({ + needsInput: false, + live: true, + hasMeta: true, + metaWhileLive: true, + }).kind + ).toBe('live-and-meta'); + // live alone + expect( + selectSessionRowEyebrowRight({ + needsInput: false, + live: true, + hasMeta: true, + metaWhileLive: false, + }).kind + ).toBe('live'); + // meta alone + expect( + selectSessionRowEyebrowRight({ + needsInput: false, + live: false, + hasMeta: true, + metaWhileLive: false, + }).kind + ).toBe('meta'); + // none + expect( + selectSessionRowEyebrowRight({ + needsInput: false, + live: false, + hasMeta: false, + metaWhileLive: false, + }).kind + ).toBe('none'); + }); +}); diff --git a/apps/mobile/src/components/ui/session-row-eyebrow-right.ts b/apps/mobile/src/components/ui/session-row-eyebrow-right.ts new file mode 100644 index 0000000000..8cbf55a1fb --- /dev/null +++ b/apps/mobile/src/components/ui/session-row-eyebrow-right.ts @@ -0,0 +1,43 @@ +/** + * Pure decision for the right-hand side of `SessionRow`'s eyebrow row. + * + * The eyebrow can show at most one of: + * - a pulsing warn dot + `NEEDS INPUT` (highest priority) + * - a `metaWhileLive` composition: live dot + meta text + * - a live dot alone (default) + * - a meta text alone + * - nothing + * + * Home and the Agents list both call this, but only the Agents tray + * opts into `metaWhileLive`. Keeping the rule here makes it testable + * without a render tree. + */ +export type SessionRowEyebrowRight = + | { kind: 'needs-input' } + | { kind: 'live-and-meta' } + | { kind: 'live' } + | { kind: 'meta' } + | { kind: 'none' }; + +export function selectSessionRowEyebrowRight(inputs: { + needsInput: boolean; + live: boolean; + hasMeta: boolean; + metaWhileLive: boolean; +}): SessionRowEyebrowRight { + const { needsInput, live, hasMeta, metaWhileLive } = inputs; + + if (needsInput) { + return { kind: 'needs-input' }; + } + if (live && hasMeta && metaWhileLive) { + return { kind: 'live-and-meta' }; + } + if (live) { + return { kind: 'live' }; + } + if (hasMeta) { + return { kind: 'meta' }; + } + return { kind: 'none' }; +} diff --git a/apps/mobile/src/components/ui/session-row.tsx b/apps/mobile/src/components/ui/session-row.tsx index fccd6ebf0e..73e194ec94 100644 --- a/apps/mobile/src/components/ui/session-row.tsx +++ b/apps/mobile/src/components/ui/session-row.tsx @@ -4,6 +4,7 @@ import { Pressable, View } from 'react-native'; import { AgentBadge } from '@/components/ui/agent-badge'; import { Eyebrow } from '@/components/ui/eyebrow'; +import { selectSessionRowEyebrowRight } from '@/components/ui/session-row-eyebrow-right'; import { StatusDot } from '@/components/ui/status-dot'; import { Text } from '@/components/ui/text'; import { agentColor } from '@/lib/agent-color'; @@ -17,13 +18,21 @@ type SessionRowProps = { /** Small mono line shown below the title (e.g. git branch). */ subtitle?: string | null; meta?: string; - /** When true, renders a pulsing good-tone StatusDot before the meta. */ + /** When true, renders a good-tone StatusDot in the eyebrow. */ live?: boolean; /** * When true, replaces the live dot / meta with a pulsing warn-tone dot * and a `NEEDS INPUT` label. Highest priority in the eyebrow row. */ needsInput?: boolean; + /** + * Opt-in: when true AND `live` AND `meta` are set (and `needsInput` is + * false), render the live dot AND the meta text side-by-side instead + * of choosing one. Default false — Home passes `meta` with `live` and + * must stay byte-for-byte unchanged. The Agents "Active now" tray + * opts in so tray rows show a dot beside the relative-time meta. + */ + metaWhileLive?: boolean; onPress?: () => void; /** Suppress bottom divider on the last row of a group. */ last?: boolean; @@ -50,6 +59,7 @@ export function SessionRow({ meta, live, needsInput = false, + metaWhileLive = false, onPress, last, stripMode = 'edge', @@ -59,8 +69,14 @@ export function SessionRow({ const color = agentColor(agentLabel); const dimStrip = !live && !needsInput; + const eyebrowDecision = selectSessionRowEyebrowRight({ + needsInput, + live: Boolean(live), + hasMeta: Boolean(meta), + metaWhileLive, + }); let eyebrowRight: React.ReactNode = null; - if (needsInput) { + if (eyebrowDecision.kind === 'needs-input') { eyebrowRight = ( @@ -69,9 +85,18 @@ export function SessionRow({ ); - } else if (live) { + } else if (eyebrowDecision.kind === 'live-and-meta') { + eyebrowRight = ( + + + + {meta} + + + ); + } else if (eyebrowDecision.kind === 'live') { eyebrowRight = ; - } else if (meta) { + } else if (eyebrowDecision.kind === 'meta' && meta) { eyebrowRight = ( {meta} diff --git a/apps/mobile/src/lib/hooks/use-agent-sessions.ts b/apps/mobile/src/lib/hooks/use-agent-sessions.ts index ba69f95fdd..fb8e14ef70 100644 --- a/apps/mobile/src/lib/hooks/use-agent-sessions.ts +++ b/apps/mobile/src/lib/hooks/use-agent-sessions.ts @@ -1,6 +1,6 @@ import { type inferRouterOutputs, type RootRouter } from '@kilocode/trpc'; import { keepPreviousData, useInfiniteQuery, useQuery } from '@tanstack/react-query'; -import { useMemo } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import { buildAgentSessionListInput, @@ -175,6 +175,37 @@ export function useAgentSessions(options?: UseAgentSessionsOptions) { [storedSessions, sortBy] ); + // Departure-triggered stored refetch. Only the active poll has a refetch + // interval (10s); the stored/history list does not. When a session id + // leaves the active set, the just-terminated session has not yet shown up + // in history, so refetching once makes it reappear. We use `refetch()` so + // the fresh fetch bypasses the 30s `staleTime` that would otherwise keep + // the cached page hidden. The refetch only refreshes loaded pages — + // sufficient because a just-terminated session always lands on page 1. + // + // The guard is strictly "id present before, absent now": the empty→populated + // transition (first poll) is ignored, and the initial mount with a non-empty + // set is ignored (no "before" to compare against). + const previousActiveIdsRef = useRef | null>(null); + const refetch = stored.refetch; + useEffect(() => { + const previous = previousActiveIdsRef.current; + previousActiveIdsRef.current = activeSessionIds; + if (!previous) { + return; + } + let departedId: string | undefined = undefined; + for (const id of previous) { + if (!activeSessionIds.has(id)) { + departedId = id; + break; + } + } + if (departedId) { + void refetch(); + } + }, [activeSessionIds, refetch]); + return { storedSessions, activeSessions, From 06b6e0aeface536129862d49f8093c46195345a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 20 Jul 2026 00:03:44 +0200 Subject: [PATCH 13/13] fix(mobile): confine session list body error to the stored-list query The Agents session list body/full-screen error was driven by the combined isError (stored || active), so a transient active-poll failure could wrongly surface a full-screen "Could not load sessions" (or a query-error-empty under the tray) instead of the intended first-use / no-past-sessions state. Active poll failures already surface only through the inline "Couldn't refresh" line via activeIsError, per the useAgentSessions contract. Wire the non-search body error to storedIsError so active blips never hide stored history. --- .../components/agents/session-list-screen.tsx | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/components/agents/session-list-screen.tsx b/apps/mobile/src/components/agents/session-list-screen.tsx index c65f187713..67483dd149 100644 --- a/apps/mobile/src/components/agents/session-list-screen.tsx +++ b/apps/mobile/src/components/agents/session-list-screen.tsx @@ -90,7 +90,7 @@ export function AgentSessionListScreen() { activeSessionIds, activeIsError, isLoading, - isError, + storedIsError, hasNextPage, isFetchingNextPage, fetchNextPage, @@ -116,12 +116,15 @@ export function AgentSessionListScreen() { enabled: ready, }); - // While searching, only the search query's own error/pending state matters — - // it's the one actually driving what's on screen. Retrying should hit - // whichever query is really in error instead of always refetching the base - // list underneath a failed search. An active-only failure during search - // additionally retries the active poll so the inline staleness line clears. - const contentIsError = isSearching ? search.isError : isError; + // The body's error/empty state is driven only by the query that actually + // fills the body: the search query while searching, otherwise the stored + // (history) list. A transient active-poll blip must never fold into this — + // it surfaces solely through the inline "Couldn't refresh" line via + // `activeIsError`. Retrying still hits whichever query is really in error + // instead of always refetching the base list underneath a failed search; + // an active-only failure during search additionally retries the active poll + // so the inline staleness line clears. + const contentIsError = isSearching ? search.isError : storedIsError; const isSearchPending = isSearching && search.isPending; const searchRefetch = search.refetch; const handleRetry = useCallback(() => {