From 6dd6f077440ec3e5a09360fcd0d6a74bc46688d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 16 Jul 2026 22:45:45 +0200 Subject: [PATCH 1/3] feat(mobile): allow composing and sending while the agent is running --- .../agents/chat-composer-input-row.tsx | 2 +- .../agents/chat-composer-input-state.test.ts | 68 +++++++++++++++---- .../agents/chat-composer-input-state.ts | 11 +-- .../src/components/agents/chat-composer.tsx | 6 +- 4 files changed, 67 insertions(+), 20 deletions(-) diff --git a/apps/mobile/src/components/agents/chat-composer-input-row.tsx b/apps/mobile/src/components/agents/chat-composer-input-row.tsx index f332806667..9599ae03d3 100644 --- a/apps/mobile/src/components/agents/chat-composer-input-row.tsx +++ b/apps/mobile/src/components/agents/chat-composer-input-row.tsx @@ -135,7 +135,7 @@ export function ChatComposerInputRow({ ) : null} - {isStreaming ? ( + {isStreaming && !canSend && !isSending ? ( { hasText: true, isFocused: false, isSending: false, - isStreaming: false, voiceInputActive: false, }); @@ -26,11 +25,10 @@ describe('resolveChatComposerControlState', () => { }); }); - it('collapses send, voice, and toolbar when disabled, streaming, or sending', () => { + it('collapses send, voice, and toolbar when disabled or sending', () => { for (const override of [ - { disabled: true, isStreaming: false, isSending: false }, - { disabled: false, isStreaming: true, isSending: false }, - { disabled: false, isStreaming: false, isSending: true }, + { disabled: true, isSending: false }, + { disabled: false, isSending: true }, ]) { const state = resolveChatComposerControlState({ attachmentsCount: 0, @@ -39,7 +37,6 @@ describe('resolveChatComposerControlState', () => { hasText: true, isFocused: false, isSending: override.isSending, - isStreaming: override.isStreaming, voiceInputActive: false, }); @@ -51,6 +48,57 @@ describe('resolveChatComposerControlState', () => { } }); + it('keeps the input editable and toolbar enabled while streaming when text is present', () => { + const state = resolveChatComposerControlState({ + attachmentsCount: 0, + attachmentMax: 5, + disabled: false, + hasText: true, + isFocused: false, + isSending: false, + voiceInputActive: false, + }); + + expect(state.inputEditable).toBe(true); + expect(state.inputAccessibilityDisabled).toBe(false); + expect(state.toolbarDisabled).toBe(false); + expect(state.voiceDisabled).toBe(false); + expect(state.canSend).toBe(true); + }); + + it('keeps the input editable while streaming with an empty draft (canSend stays false)', () => { + const state = resolveChatComposerControlState({ + attachmentsCount: 0, + attachmentMax: 5, + disabled: false, + hasText: false, + isFocused: false, + isSending: false, + voiceInputActive: false, + }); + + expect(state.inputEditable).toBe(true); + expect(state.inputAccessibilityDisabled).toBe(false); + expect(state.toolbarDisabled).toBe(false); + expect(state.canSend).toBe(false); + }); + + it('still blocks send mid-stream when the parent disabled flag is on (e.g. read-only or capability gate)', () => { + const state = resolveChatComposerControlState({ + attachmentsCount: 0, + attachmentMax: 5, + disabled: true, + hasText: true, + isFocused: false, + isSending: false, + voiceInputActive: false, + }); + + expect(state.canSend).toBe(false); + expect(state.inputEditable).toBe(false); + expect(state.toolbarDisabled).toBe(true); + }); + it('does not allow send when the draft is empty even with attachments and no overrides', () => { const state = resolveChatComposerControlState({ attachmentsCount: 2, @@ -59,7 +107,6 @@ describe('resolveChatComposerControlState', () => { hasText: false, isFocused: false, isSending: false, - isStreaming: false, voiceInputActive: false, }); @@ -76,7 +123,6 @@ describe('resolveChatComposerControlState', () => { hasText: false, isFocused: false, isSending: false, - isStreaming: false, voiceInputActive: false, }; @@ -99,7 +145,6 @@ describe('resolveChatComposerControlState', () => { hasText: true, isFocused: false, isSending: false, - isStreaming: false, voiceInputActive: false, }); @@ -113,8 +158,7 @@ describe('resolveChatComposerControlState', () => { disabled: false, hasText: true, isFocused: false, - isSending: false, - isStreaming: true, + isSending: true, voiceInputActive: false, }); @@ -129,7 +173,6 @@ describe('resolveChatComposerControlState', () => { hasText: true, isFocused: false, isSending: false, - isStreaming: false, voiceInputActive: true, }); @@ -146,7 +189,6 @@ describe('resolveChatComposerControlState', () => { hasText: false, isFocused: false, isSending: false, - isStreaming: false, voiceInputActive: false, }); diff --git a/apps/mobile/src/components/agents/chat-composer-input-state.ts b/apps/mobile/src/components/agents/chat-composer-input-state.ts index 65bb27c682..6825e09ffd 100644 --- a/apps/mobile/src/components/agents/chat-composer-input-state.ts +++ b/apps/mobile/src/components/agents/chat-composer-input-state.ts @@ -5,7 +5,6 @@ type ChatComposerControlInput = { hasText: boolean; isFocused: boolean; isSending: boolean; - isStreaming: boolean; voiceInputActive: boolean; }; @@ -44,17 +43,21 @@ export function resolveChatComposerControlState( hasText, isFocused, isSending, - isStreaming, voiceInputActive, } = input; - const toolbarDisabled = disabled || isStreaming || isSending; + // Streaming is intentionally NOT a composer gate. The user must be able to + // type and send while the agent runs (plan §3.3): the row component chooses + // Stop vs Send based on `isStreaming` + `hasText`. The session manager, the + // parent, and `disabled` already cover every other lock (read-only, missing + // model, blocking interaction, upload-in-progress, interrupt-in-flight). + const toolbarDisabled = disabled || isSending; const voiceDisabled = toolbarDisabled; const paperclipDisabled = toolbarDisabled || voiceInputActive || attachmentsCount >= attachmentMax; const inputEditable = !toolbarDisabled && !voiceInputActive; const showToolbar = isFocused || hasText || attachmentsCount > 0 || voiceInputActive; return { - canSend: hasText && !disabled && !isStreaming && !isSending, + canSend: hasText && !disabled && !isSending, inputAccessibilityDisabled: !inputEditable, inputEditable, paperclipDisabled, diff --git a/apps/mobile/src/components/agents/chat-composer.tsx b/apps/mobile/src/components/agents/chat-composer.tsx index eb0ed6f5e7..18c07b56b9 100644 --- a/apps/mobile/src/components/agents/chat-composer.tsx +++ b/apps/mobile/src/components/agents/chat-composer.tsx @@ -94,7 +94,10 @@ export function ChatComposer({ }); // Compute base composer disabled before the voice hook so voice can react to it. - const toolbarDisabled = disabled || isStreaming || isSending; + // `isStreaming` is intentionally NOT a composer gate (see + // `chat-composer-input-state.ts`); the user must remain able to type and + // send while the agent runs. + const toolbarDisabled = disabled || isSending; const voiceDisabled = toolbarDisabled; const voiceInput = useVoiceInput({ @@ -117,7 +120,6 @@ export function ChatComposer({ hasText, isFocused, isSending, - isStreaming, voiceInputActive: voiceInput.isActive, }); From 2cb254652bbe9a1e34ca62872718fba74805d03f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 16 Jul 2026 22:59:37 +0200 Subject: [PATCH 2/3] feat(cloud-agent-sdk): support CLI session.queue.changed event for queued-message reconciliation --- .../lib/cloud-agent-sdk/normalizer.test.ts | 65 ++++++ .../web/src/lib/cloud-agent-sdk/normalizer.ts | 16 ++ apps/web/src/lib/cloud-agent-sdk/schemas.ts | 13 ++ .../lib/cloud-agent-sdk/service-state.test.ts | 185 ++++++++++++++++++ .../src/lib/cloud-agent-sdk/service-state.ts | 50 +++++ 5 files changed, 329 insertions(+) diff --git a/apps/web/src/lib/cloud-agent-sdk/normalizer.test.ts b/apps/web/src/lib/cloud-agent-sdk/normalizer.test.ts index e009f6099c..71e22d2f2f 100644 --- a/apps/web/src/lib/cloud-agent-sdk/normalizer.test.ts +++ b/apps/web/src/lib/cloud-agent-sdk/normalizer.test.ts @@ -656,6 +656,71 @@ describe('normalize', () => { }); }); + describe('session.queue.changed', () => { + it('normalizes a populated snapshot with sessionId and queued list', () => { + const result = normalize( + createRaw('session.queue.changed', { + sessionID: 'ses-1', + queued: ['m1', 'm2', 'm3'], + }) + ); + expect(result).toEqual({ + type: 'queue.changed', + sessionId: 'ses-1', + queued: ['m1', 'm2', 'm3'], + }); + }); + + it('normalizes an empty snapshot (replay-on-subscribe empty case)', () => { + const result = normalize( + createRaw('session.queue.changed', { sessionID: 'ses-1', queued: [] }) + ); + expect(result).toEqual({ + type: 'queue.changed', + sessionId: 'ses-1', + queued: [], + }); + }); + + it('returns null when sessionID is missing', () => { + expect(normalize(createRaw('session.queue.changed', { queued: ['m1'] }))).toBeNull(); + }); + + it('returns null when sessionID is not a string', () => { + expect( + normalize(createRaw('session.queue.changed', { sessionID: 42, queued: [] })) + ).toBeNull(); + }); + + it('returns null when queued is missing', () => { + expect(normalize(createRaw('session.queue.changed', { sessionID: 'ses-1' }))).toBeNull(); + }); + + it('returns null when queued is not an array', () => { + expect( + normalize(createRaw('session.queue.changed', { sessionID: 'ses-1', queued: 'm1' })) + ).toBeNull(); + }); + + it('is classified as a ServiceEvent, not a ChatEvent', () => { + const result = normalize( + createRaw('session.queue.changed', { sessionID: 'ses-1', queued: ['m1'] }) + ); + expect(result).not.toBeNull(); + expect(isChatEvent(result!)).toBe(false); + }); + + it('normalizes via normalizeCliEvent without a CloudAgentEvent envelope', () => { + expect( + normalizeCliEvent('session.queue.changed', { sessionID: 'ses-1', queued: ['m1'] }) + ).toEqual({ + type: 'queue.changed', + sessionId: 'ses-1', + queued: ['m1'], + }); + }); + }); + describe('question.asked', () => { it('extracts requestId and questions from tool.callID', () => { const data = { diff --git a/apps/web/src/lib/cloud-agent-sdk/normalizer.ts b/apps/web/src/lib/cloud-agent-sdk/normalizer.ts index 31ccd857ff..9e26c149dd 100644 --- a/apps/web/src/lib/cloud-agent-sdk/normalizer.ts +++ b/apps/web/src/lib/cloud-agent-sdk/normalizer.ts @@ -25,6 +25,7 @@ import { sessionErrorDataSchema, sessionIdleDataSchema, sessionTurnCloseDataSchema, + sessionQueueChangedDataSchema, questionAskedDataSchema, questionRepliedDataSchema, questionRejectedDataSchema, @@ -179,6 +180,11 @@ export type ServiceEvent = error: string; reason: 'interrupted' | 'exhausted' | 'execution'; attempts?: number; + } + | { + type: 'queue.changed'; + sessionId: string; + queued: string[]; }; export type NormalizedEvent = ChatEvent | ServiceEvent; @@ -317,6 +323,16 @@ function normalizeInnerEvent(eventType: string, data: unknown): NormalizedEvent return { type: 'session.turn.close', sessionId: r.data.sessionID, reason: r.data.reason }; } + case 'session.queue.changed': { + const r = sessionQueueChangedDataSchema.safeParse(data); + if (!r.success) return null; + return { + type: 'queue.changed', + sessionId: r.data.sessionID, + queued: r.data.queued, + }; + } + case 'question.asked': { const r = questionAskedDataSchema.safeParse(data); if (!r.success) return null; diff --git a/apps/web/src/lib/cloud-agent-sdk/schemas.ts b/apps/web/src/lib/cloud-agent-sdk/schemas.ts index f2b6d12bfa..01adead368 100644 --- a/apps/web/src/lib/cloud-agent-sdk/schemas.ts +++ b/apps/web/src/lib/cloud-agent-sdk/schemas.ts @@ -551,6 +551,19 @@ export const sessionTurnCloseDataSchema = z .passthrough(); export type SessionTurnCloseData = z.infer; +/** + * `session.queue.changed` — CLI-only event carrying the authoritative FIFO + * snapshot of user-message IDs that are queued but not yet running. Replayed + * on every (re)subscribe, so each occurrence is a full reconciliation. + */ +export const sessionQueueChangedDataSchema = z + .object({ + sessionID: z.string(), + queued: z.array(z.string()), + }) + .passthrough(); +export type SessionQueueChangedData = z.infer; + export const questionAskedDataSchema = z.object({ id: z.string(), tool: z.object({ callID: z.string() }).optional(), diff --git a/apps/web/src/lib/cloud-agent-sdk/service-state.test.ts b/apps/web/src/lib/cloud-agent-sdk/service-state.test.ts index 78aea55ed5..810958a6bd 100644 --- a/apps/web/src/lib/cloud-agent-sdk/service-state.test.ts +++ b/apps/web/src/lib/cloud-agent-sdk/service-state.test.ts @@ -1781,4 +1781,189 @@ describe('createServiceState', () => { }); }); }); + + describe('queue.changed (CLI reconciliation)', () => { + it('adds entries on first snapshot (empty → non-empty)', () => { + const state = createServiceState(makeConfig()); + + state.process({ type: 'queue.changed', sessionId: 'root-1', queued: ['m1', 'm2'] }); + + expect(state.getPendingMessages().size).toBe(2); + expect(state.getPendingMessages().get('m1')).toEqual({ status: 'queued' }); + expect(state.getPendingMessages().get('m2')).toEqual({ status: 'queued' }); + }); + + it('shrinks the snapshot by removing entries not in the new list', () => { + const state = createServiceState(makeConfig()); + + state.process({ type: 'queue.changed', sessionId: 'root-1', queued: ['m1', 'm2', 'm3'] }); + state.process({ type: 'queue.changed', sessionId: 'root-1', queued: ['m2', 'm3'] }); + + const pending = state.getPendingMessages(); + expect(pending.size).toBe(2); + expect(pending.has('m1')).toBe(false); + expect(pending.get('m2')).toEqual({ status: 'queued' }); + expect(pending.get('m3')).toEqual({ status: 'queued' }); + }); + + it('clears all entries when the snapshot is empty', () => { + const state = createServiceState(makeConfig()); + + state.process({ type: 'queue.changed', sessionId: 'root-1', queued: ['m1', 'm2'] }); + state.process({ type: 'queue.changed', sessionId: 'root-1', queued: [] }); + + expect(state.getPendingMessages().size).toBe(0); + }); + + it('treats every event as a full snapshot, never a delta', () => { + const state = createServiceState(makeConfig()); + + state.process({ type: 'queue.changed', sessionId: 'root-1', queued: ['m1', 'm2'] }); + // m3 was not in the prior snapshot and is not in this one either — + // it must NOT survive because the new event is authoritative. + state.process({ type: 'queue.changed', sessionId: 'root-1', queued: ['m1', 'm2'] }); + + const pending = state.getPendingMessages(); + expect(pending.size).toBe(2); + expect(pending.has('m1')).toBe(true); + expect(pending.has('m2')).toBe(true); + }); + + it('notifies subscribers on every change', () => { + const state = createServiceState(makeConfig()); + const cb = jest.fn(); + state.subscribe(cb); + + state.process({ type: 'queue.changed', sessionId: 'root-1', queued: ['m1'] }); + state.process({ type: 'queue.changed', sessionId: 'root-1', queued: [] }); + state.process({ type: 'queue.changed', sessionId: 'root-1', queued: ['m2', 'm3'] }); + + expect(cb).toHaveBeenCalledTimes(3); + }); + }); + + describe('stopped × pendingMessages interaction', () => { + it('stopped(complete) does NOT clear pendingMessages', () => { + const state = createServiceState(makeConfig()); + + state.process({ type: 'queue.changed', sessionId: 'root-1', queued: ['m1', 'm2'] }); + state.process({ type: 'stopped', reason: 'complete' }); + + expect(state.getPendingMessages().size).toBe(2); + expect(state.getPendingMessages().get('m1')).toEqual({ status: 'queued' }); + expect(state.getPendingMessages().get('m2')).toEqual({ status: 'queued' }); + }); + + it('stopped(disconnected) after stopped(complete) still clears pendingMessages', () => { + const state = createServiceState(makeConfig()); + + state.process({ type: 'queue.changed', sessionId: 'root-1', queued: ['m1'] }); + state.process({ type: 'stopped', reason: 'complete' }); + // The disconnected branch is reached even when completed === true; + // the clear runs before the `if (completed) break` short-circuit. + state.process({ type: 'stopped', reason: 'disconnected' }); + + expect(state.getPendingMessages().size).toBe(0); + }); + + it('stopped(disconnected) clears pendingMessages', () => { + const state = createServiceState(makeConfig()); + + state.process({ type: 'queue.changed', sessionId: 'root-1', queued: ['m1', 'm2'] }); + state.process({ type: 'stopped', reason: 'disconnected' }); + + expect(state.getPendingMessages().size).toBe(0); + }); + + it('stopped(disconnected) clears pendingMessages even when completed === true', () => { + const state = createServiceState(makeConfig()); + + state.process({ type: 'queue.changed', sessionId: 'root-1', queued: ['m1', 'm2'] }); + state.process({ type: 'stopped', reason: 'complete' }); + // completed is now true — a subsequent disconnect must still clear the + // CLI pending queue because the session can no longer dequeue from it. + state.process({ type: 'stopped', reason: 'disconnected' }); + + expect(state.getPendingMessages().size).toBe(0); + }); + + it('stopped(transport-disconnected) does NOT clear pendingMessages (CLI queue.changed)', () => { + // transport-disconnected is never emitted by the CLI transport (only + // cli-live-transport's `disconnected`), so this case existing for a + // CLI-populated queue is a defensive/no-op check. + const state = createServiceState(makeConfig()); + + state.process({ type: 'queue.changed', sessionId: 'root-1', queued: ['m1', 'm2'] }); + state.process({ type: 'stopped', reason: 'transport-disconnected' }); + + expect(state.getPendingMessages().size).toBe(2); + }); + + it('stopped(transport-disconnected) does NOT clear pendingMessages (cloud-agent cloud.message.queued)', () => { + // Only cloud-agent-transport.ts emits transport-disconnected, and only + // on a purely client-side WebSocket blip with no backend-durable event + // and no snapshot-replay path that would repopulate pendingMessages + // afterward. Clearing here would permanently drop a "Queued" badge for + // a message that is still genuinely queued server-side. + const state = createServiceState(makeConfig()); + + state.process({ type: 'cloud.message.queued', messageId: 'm1' }); + state.process({ type: 'cloud.message.queued', messageId: 'm2' }); + state.process({ type: 'stopped', reason: 'transport-disconnected' }); + + expect(state.getPendingMessages().size).toBe(2); + expect(state.getPendingMessages().get('m1')).toEqual({ status: 'queued' }); + expect(state.getPendingMessages().get('m2')).toEqual({ status: 'queued' }); + }); + + it('stopped(interrupted) does NOT clear pendingMessages — cloud.message.failed handles that', () => { + const state = createServiceState(makeConfig()); + + state.process({ type: 'queue.changed', sessionId: 'root-1', queued: ['m1', 'm2'] }); + state.process({ type: 'stopped', reason: 'interrupted' }); + + expect(state.getPendingMessages().size).toBe(2); + expect(state.getPendingMessages().get('m1')).toEqual({ status: 'queued' }); + expect(state.getPendingMessages().get('m2')).toEqual({ status: 'queued' }); + }); + + it('no cross-talk: a session that only receives cloud.message.* events is unaffected by queue.changed handling', () => { + const state = createServiceState(makeConfig()); + + state.process({ type: 'cloud.message.queued', messageId: 'm1' }); + state.process({ type: 'cloud.message.queued', messageId: 'm2' }); + // The cloud-agent session's pending set uses the same map shape, so an + // empty queue.changed snapshot (which a CLI session might receive during + // subscribe) would wipe it. Verify that a real cloud-agent session that + // never receives queue.changed keeps its own entries intact. + expect(state.getPendingMessages().size).toBe(2); + expect(state.getPendingMessages().get('m1')).toEqual({ status: 'queued' }); + expect(state.getPendingMessages().get('m2')).toEqual({ status: 'queued' }); + }); + + it('ignores a queue.changed snapshot from a non-root (child/subagent) session', () => { + // Child/subagent sessions forward their own session.queue.changed + // events through the same shared pendingMessages map (cli-live-transport + // forwards events whose parentSessionId matches the root, and + // remote-sender always replays an empty snapshot for children on + // subscribe). A root session's real queued messages must survive an + // empty (or non-empty) snapshot that names a different sessionId. + const state = createServiceState(makeConfig()); + + state.process({ type: 'queue.changed', sessionId: 'root-1', queued: ['m1', 'm2'] }); + expect(state.getPendingMessages().size).toBe(2); + + // A child session's empty snapshot must not wipe the root's entries. + state.process({ type: 'queue.changed', sessionId: 'child-1', queued: [] }); + expect(state.getPendingMessages().size).toBe(2); + expect(state.getPendingMessages().get('m1')).toEqual({ status: 'queued' }); + expect(state.getPendingMessages().get('m2')).toEqual({ status: 'queued' }); + + // A child session's non-empty snapshot must not overwrite the root's + // entries with unrelated child message IDs. + state.process({ type: 'queue.changed', sessionId: 'child-1', queued: ['child-m1'] }); + expect(state.getPendingMessages().size).toBe(2); + expect(state.getPendingMessages().has('child-m1')).toBe(false); + }); + }); }); diff --git a/apps/web/src/lib/cloud-agent-sdk/service-state.ts b/apps/web/src/lib/cloud-agent-sdk/service-state.ts index adc37c197f..9e48217d69 100644 --- a/apps/web/src/lib/cloud-agent-sdk/service-state.ts +++ b/apps/web/src/lib/cloud-agent-sdk/service-state.ts @@ -178,6 +178,11 @@ function createServiceState(config: ServiceStateConfig): ServiceState { config.onError?.('Session terminated'); break; case 'disconnected': + // Clear CLI pending-message state unconditionally — including when the + // last turn had `completed === true`. Only `cli-live-transport.ts` + // emits this reason (via `wrapper_disconnected`); the clear is scoped + // to it, not `transport-disconnected` (see below). + pendingMessages.clear(); if (completed) break; terminated = true; disconnectedSource = 'wrapper'; @@ -185,6 +190,15 @@ function createServiceState(config: ServiceStateConfig): ServiceState { config.onError?.('Connection to agent lost'); break; case 'transport-disconnected': + // Do NOT clear `pendingMessages` here. Only `cloud-agent-transport.ts` + // emits this reason, synthesized locally on any WebSocket hiccup + // (frequent, purely client-side, self-recovering via reconnect) — + // it never fires for CLI sessions. Cloud-agent sessions genuinely + // populate `pendingMessages` via `cloud.message.queued`, and there is + // no snapshot-replay mechanism that would repopulate it afterward + // (unlike the CLI's always-on `session.queue.changed` replay), so + // clearing here would silently and permanently drop "Queued" badges + // for messages that are still queued server-side. terminated = true; disconnectedSource = 'transport'; completed = false; @@ -544,6 +558,39 @@ function createServiceState(config: ServiceStateConfig): ServiceState { notify(); } + /** + * CLI-only: `queue.changed` carries the authoritative FIFO snapshot of + * queued user-message IDs. Each emission is a full reconciliation, not a + * delta — entries absent from the new snapshot are dropped. + * + * `pendingMessages` is a single map shared by the whole session tree, but + * child/subagent sessions also forward their own `session.queue.changed` + * events here (see `cli-live-transport.ts`'s parent-session forwarding and + * `remote-sender.ts`'s always-empty replay for children). Only the root + * session's snapshot may reconcile this map — otherwise an empty child + * snapshot on reconnect would wipe a genuinely queued root message. + */ + function processQueueChanged(event: Extract): void { + if (!isRootSession(event.sessionId)) return; + if (event.queued.length === 0) { + if (pendingMessages.size === 0) return; + pendingMessages.clear(); + notify(); + return; + } + const next = new Map(); + for (const messageId of event.queued) { + next.set(messageId, { status: 'queued' }); + } + // Reuse the same Map identity where possible to avoid invalidating + // existing subscribers that hold onto the previous reference. + pendingMessages.clear(); + for (const [messageId, state] of next) { + pendingMessages.set(messageId, state); + } + notify(); + } + function processConnected(event: Extract): void { // Set activity from sessionStatus. When sessionStatus is absent (server // has no execution-derived state yet), default to idle — we know the @@ -686,6 +733,9 @@ function createServiceState(config: ServiceStateConfig): ServiceState { case 'cloud.message.failed': processMessageFailed(event); break; + case 'queue.changed': + processQueueChanged(event); + break; case 'session.idle': case 'session.turn.close': case 'warning': From 004c528a24f3fe371cc422fea93c1fde7b199762 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 16 Jul 2026 23:00:49 +0200 Subject: [PATCH 3/3] feat(mobile): show queued state on pending messages --- .../components/agents/message-bubble.test.ts | 181 ++++++++++++++++++ .../src/components/agents/message-bubble.tsx | 39 +++- .../agents/session-detail-content.tsx | 20 +- 3 files changed, 227 insertions(+), 13 deletions(-) create mode 100644 apps/mobile/src/components/agents/message-bubble.test.ts diff --git a/apps/mobile/src/components/agents/message-bubble.test.ts b/apps/mobile/src/components/agents/message-bubble.test.ts new file mode 100644 index 0000000000..91820bfa09 --- /dev/null +++ b/apps/mobile/src/components/agents/message-bubble.test.ts @@ -0,0 +1,181 @@ +import { type MessageDeliveryState, type StoredMessage } from 'cloud-agent-sdk'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('react-native', () => ({ + Pressable: 'Pressable', + View: 'View', + Platform: { OS: 'android' }, +})); +vi.mock('react-native-reanimated', () => ({ + default: { View: 'Animated.View' }, + FadeIn: { duration: vi.fn() }, + FadeOut: { duration: vi.fn() }, +})); +vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() })); +vi.mock('expo-haptics', () => ({ + notificationAsync: vi.fn(), + NotificationFeedbackType: { Success: 'success' }, +})); +vi.mock('sonner-native', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); +vi.mock('lucide-react-native', () => ({ + Clock: () => null, +})); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({ mutedForeground: '#6F6A61' }), +})); +vi.mock('@/components/ui/bubble', () => ({ + Bubble: ({ children }: { children?: unknown }) => children, +})); +vi.mock('@/components/ui/text', () => ({ + Text: ({ children }: { children?: unknown }) => children, +})); +vi.mock('./chat-markdown-text', () => ({ + ChatMarkdownText: () => null, +})); +vi.mock('./compaction-separator', () => ({ + CompactionSeparator: () => null, +})); +vi.mock('./file-part-renderer', () => ({ + FilePartRenderer: () => null, +})); +vi.mock('./part-renderer', () => ({ + PartRenderer: () => null, +})); +vi.mock('./part-types', () => ({ + isFilePart: () => false, + isTextPart: () => false, +})); +vi.mock('./use-message-copy', () => ({ + useMessageCopy: () => ({ copyMessage: vi.fn() }), +})); + +function userMessage(id: string): StoredMessage { + return { + info: { + id, + sessionID: 'ses_1', + role: 'user', + time: { created: 1_761_000_000_000 }, + agent: 'build', + model: { providerID: 'openrouter', modelID: 'anthropic/claude-sonnet-4' }, + }, + parts: [ + { + id: `${id}-text`, + sessionID: 'ses_1', + messageID: id, + type: 'text', + text: 'hi', + }, + ], + }; +} + +async function renderBubble( + message: StoredMessage, + deliveryState?: MessageDeliveryState +): Promise { + const { MessageBubble } = await import('./message-bubble'); + // eslint-disable-next-line new-cap + return MessageBubble({ message, deliveryState }); +} + +function findText(node: unknown, predicate: (text: string) => boolean): boolean { + if (typeof node === 'string') { + return predicate(node); + } + if (node == null || typeof node !== 'object') { + return false; + } + const element = node as { type?: unknown; props?: { children?: unknown } }; + // The mock for the Text component is a plain function; we inspect the + // unrendered React element tree, so the string sits in props.children. + if (typeof element.props?.children === 'string' && predicate(element.props.children)) { + return true; + } + const children = element.props?.children; + if (Array.isArray(children)) { + return children.some(child => findText(child, predicate)); + } + if (children && typeof children === 'object') { + return findText(children, predicate); + } + return false; +} + +function hasAnimatedBadge(node: unknown): boolean { + if (node == null || typeof node !== 'object') { + return false; + } + const element = node as { type?: unknown; props?: Record }; + if (element.type === 'Animated.View') { + return true; + } + const children = element.props?.children; + if (Array.isArray(children)) { + return children.some(child => hasAnimatedBadge(child)); + } + if (children && typeof children === 'object') { + return hasAnimatedBadge(children); + } + return false; +} + +describe('MessageBubble queued badge', () => { + it('renders the Queued badge when deliveryState is queued on a user message', async () => { + const tree = await renderBubble(userMessage('m1'), { status: 'queued' }); + expect(findText(tree, t => t === 'Queued')).toBe(true); + expect(hasAnimatedBadge(tree)).toBe(true); + }); + + it('does not render the Queued badge for a failed delivery state on a user message', async () => { + const tree = await renderBubble(userMessage('m2'), { + status: 'failed', + error: 'nope', + reason: 'exhausted', + }); + expect(findText(tree, t => t === 'Queued')).toBe(false); + }); + + it('does not render the Queued badge when no delivery state is provided', async () => { + const tree = await renderBubble(userMessage('m3')); + expect(findText(tree, t => t === 'Queued')).toBe(false); + }); + + it('does not render the Queued badge for assistant messages even when delivery state is queued', async () => { + const base = userMessage('m4'); + const assistant: StoredMessage = { + info: { + id: base.info.id, + sessionID: base.info.sessionID, + role: 'assistant', + time: { created: base.info.time.created }, + parentID: 'm0', + modelID: 'anthropic/claude-sonnet-4', + providerID: 'kilo', + mode: 'code', + agent: 'build', + path: { cwd: '/', root: '/' }, + cost: 0, + tokens: { total: 0, input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }, + parts: [], + }; + const tree = await renderBubble(assistant, { status: 'queued' }); + expect(findText(tree, t => t === 'Queued')).toBe(false); + }); +}); + +describe('MessageBubble regressions', () => { + it('renders without error when deliveryState transitions from queued to undefined (badge unmounts on dequeue)', async () => { + const message = userMessage('m5'); + const queuedTree = await renderBubble(message, { status: 'queued' }); + expect(findText(queuedTree, t => t === 'Queued')).toBe(true); + + // Same message, no more delivery state (as when `pendingMessages` drops + // the entry once the CLI/cloud-agent starts processing it) — the badge + // must be absent, not stuck from a prior render. + const dequeuedTree = await renderBubble(message); + expect(findText(dequeuedTree, t => t === 'Queued')).toBe(false); + }); +}); diff --git a/apps/mobile/src/components/agents/message-bubble.tsx b/apps/mobile/src/components/agents/message-bubble.tsx index 5bbf19d5a3..6febcfa4b5 100644 --- a/apps/mobile/src/components/agents/message-bubble.tsx +++ b/apps/mobile/src/components/agents/message-bubble.tsx @@ -1,7 +1,11 @@ -import { type StoredMessage } from 'cloud-agent-sdk'; +import { type MessageDeliveryState, type StoredMessage } from 'cloud-agent-sdk'; +import { Clock } from 'lucide-react-native'; import { type AccessibilityActionEvent, Pressable, View } from 'react-native'; +import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; import { Bubble } from '@/components/ui/bubble'; +import { Text } from '@/components/ui/text'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { ChatMarkdownText } from './chat-markdown-text'; import { CompactionSeparator } from './compaction-separator'; @@ -18,6 +22,8 @@ type MessageBubbleProps = { getChildMessages?: (sessionId: string) => StoredMessage[]; defaultReasoningExpanded?: boolean; onOpenChildSession?: OpenChildSession; + /** Per-user-message delivery state. v1 surfaces only a "Queued" badge. */ + deliveryState?: MessageDeliveryState; }; export function MessageBubble({ @@ -27,9 +33,11 @@ export function MessageBubble({ getChildMessages, defaultReasoningExpanded, onOpenChildSession, + deliveryState, }: Readonly) { const isUser = message.info.role === 'user'; const { copyMessage } = useMessageCopy(); + const colors = useThemeColors(); const handleLongPress = () => { void copyMessage(message); @@ -61,6 +69,7 @@ export function MessageBubble({ .map(p => p.text) .join(''); const fileParts = message.parts.filter(isFilePart); + const showQueuedBadge = deliveryState?.status === 'queued'; return ( - - {textContent ? ( - + + + {textContent ? ( + + ) : null} + {fileParts.map(part => ( + + ))} + + {showQueuedBadge ? ( + + + Queued + ) : null} - {fileParts.map(part => ( - - ))} - + ); } diff --git a/apps/mobile/src/components/agents/session-detail-content.tsx b/apps/mobile/src/components/agents/session-detail-content.tsx index c2927bba06..ea9c97057a 100644 --- a/apps/mobile/src/components/agents/session-detail-content.tsx +++ b/apps/mobile/src/components/agents/session-detail-content.tsx @@ -334,10 +334,17 @@ export function SessionDetailContent({ ); const renderItem = useCallback( - ({ item }: { item: SessionTranscriptItem }) => - item.type === 'preparation' ? ( - - ) : ( + ({ item }: { item: SessionTranscriptItem }) => { + if (item.type === 'preparation') { + return ; + } + // Look up delivery state by message id. The map is keyed by user-message + // id and may briefly contain an entry before the bubble has rendered + // (ServiceEvents can be applied while chat events are still buffered), + // so a plain lookup is enough — no render-order guard. + const deliveryState = + item.message.info.role === 'user' ? pendingMessages.get(item.message.info.id) : undefined; + return ( - ), + ); + }, [ lastAssistantMessageId, isStreaming, getChildMessages, reasoningDefaultExpanded, handleOpenChildSession, + pendingMessages, ] );