From a5521e80aef15d8c9a3e1e84d9fc65517651c703 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Jul 2026 13:15:17 +0200 Subject: [PATCH 01/17] fix(mobile): keep screen awake while agent works; animate subagent sheet dismiss Install expo-keep-awake and mount a conditional useKeepAwake host while the session is streaming or has pending messages, so the idle timer stays off exactly while the UI shows the agent as working (item 15). Mount the child session sheet behind a separate visible flag instead of unmounting it on close, so iOS plays the native slide-down animation instead of flashing the white window behind (item 21). --- apps/mobile/package.json | 1 + .../agents/child-session-sheet-state.test.ts | 44 +++++++++++++++- .../agents/child-session-sheet-state.ts | 30 ++++++++++- .../components/agents/child-session-sheet.tsx | 9 +++- .../agents/session-detail-content.tsx | 51 ++++++++++++++----- pnpm-lock.yaml | 3 ++ 6 files changed, 121 insertions(+), 17 deletions(-) diff --git a/apps/mobile/package.json b/apps/mobile/package.json index ca8c784a502..9b03f8c0100 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -59,6 +59,7 @@ "expo-image": "57.0.1", "expo-image-picker": "~57.0.6", "expo-insights": "~57.0.6", + "expo-keep-awake": "~57.0.1", "expo-linear-gradient": "~57.0.1", "expo-linking": "57.0.4", "expo-localization": "~57.0.1", diff --git a/apps/mobile/src/components/agents/child-session-sheet-state.test.ts b/apps/mobile/src/components/agents/child-session-sheet-state.test.ts index dff8e27f40f..1679250122c 100644 --- a/apps/mobile/src/components/agents/child-session-sheet-state.test.ts +++ b/apps/mobile/src/components/agents/child-session-sheet-state.test.ts @@ -1,6 +1,16 @@ +import { type KiloSessionId } from '@kilocode/cloud-agent-sdk'; import { describe, expect, it } from 'vitest'; -import { getChildSessionSheetState } from './child-session-sheet-state'; +import { + type ChildSessionSheetMountState, + closeChildSessionSheet, + getChildSessionSheetState, + openChildSessionSheet, +} from './child-session-sheet-state'; + +const emptyMount: ChildSessionSheetMountState = { sheet: null, visible: false }; +const childA = { sessionId: 'child-a' as KiloSessionId, title: 'Subagent A' }; +const childB = { sessionId: 'child-b' as KiloSessionId, title: 'Subagent B' }; describe('getChildSessionSheetState', () => { it('shows loading while hydration has not completed', () => { @@ -19,3 +29,35 @@ describe('getChildSessionSheetState', () => { expect(getChildSessionSheetState({ status: 'error', message: 'Failed' }, 1)).toBe('content'); }); }); + +describe('openChildSessionSheet / closeChildSessionSheet', () => { + it('open sets sheet and visible', () => { + expect(openChildSessionSheet(emptyMount, childA)).toEqual({ + sheet: childA, + visible: true, + }); + }); + + it('close keeps sheet mounted with visible false', () => { + const open = openChildSessionSheet(emptyMount, childA); + expect(closeChildSessionSheet(open)).toEqual({ + sheet: childA, + visible: false, + }); + }); + + it('opening a different child replaces sheet and stays visible', () => { + const openA = openChildSessionSheet(emptyMount, childA); + expect(openChildSessionSheet(openA, childB)).toEqual({ + sheet: childB, + visible: true, + }); + }); + + it('close when sheet is null is a no-op', () => { + expect(closeChildSessionSheet(emptyMount)).toEqual({ + sheet: null, + visible: false, + }); + }); +}); diff --git a/apps/mobile/src/components/agents/child-session-sheet-state.ts b/apps/mobile/src/components/agents/child-session-sheet-state.ts index c1720ff6a5d..91e9bc31f31 100644 --- a/apps/mobile/src/components/agents/child-session-sheet-state.ts +++ b/apps/mobile/src/components/agents/child-session-sheet-state.ts @@ -1,7 +1,22 @@ -import { type ChildSessionHydrationState } from '@kilocode/cloud-agent-sdk'; +import { type ChildSessionHydrationState, type KiloSessionId } from '@kilocode/cloud-agent-sdk'; type ChildSessionSheetState = 'loading' | 'empty' | 'error' | 'content'; +export type ChildSessionSheetIdentity = { + sessionId: KiloSessionId; + title: string; +}; + +/** + * Controls when the native Modal is mounted and when it is visible. Keeping + * the sheet mounted after close lets `visible` transition from true → false + * so the native pageSheet dismissal animation runs. + */ +export type ChildSessionSheetMountState = { + sheet: ChildSessionSheetIdentity | null; + visible: boolean; +}; + export function getChildSessionSheetState( hydrationState: ChildSessionHydrationState, messageCount: number @@ -17,3 +32,16 @@ export function getChildSessionSheetState( } return 'loading'; } + +export function openChildSessionSheet( + _current: ChildSessionSheetMountState, + next: ChildSessionSheetIdentity +): ChildSessionSheetMountState { + return { sheet: next, visible: true }; +} + +export function closeChildSessionSheet( + current: ChildSessionSheetMountState +): ChildSessionSheetMountState { + return { sheet: current.sheet, visible: false }; +} diff --git a/apps/mobile/src/components/agents/child-session-sheet.tsx b/apps/mobile/src/components/agents/child-session-sheet.tsx index 61280232bf9..d9e18d7d8e0 100644 --- a/apps/mobile/src/components/agents/child-session-sheet.tsx +++ b/apps/mobile/src/components/agents/child-session-sheet.tsx @@ -19,6 +19,7 @@ import { SessionMessageList } from './session-message-list'; import { WorkingIndicator } from './working-indicator'; type ChildSessionSheetProps = { + visible: boolean; sessionId: string; title: string; getChildMessages: (sessionId: string) => StoredMessage[]; @@ -34,6 +35,7 @@ type ChildSessionSheetProps = { function noopLoadOlder(): void {} export function ChildSessionSheet({ + visible, sessionId, title, getChildMessages, @@ -111,7 +113,12 @@ export function ChildSessionSheet({ } return ( - + {content} diff --git a/apps/mobile/src/components/agents/session-detail-content.tsx b/apps/mobile/src/components/agents/session-detail-content.tsx index 4ace5e017b8..24d68f28343 100644 --- a/apps/mobile/src/components/agents/session-detail-content.tsx +++ b/apps/mobile/src/components/agents/session-detail-content.tsx @@ -8,6 +8,7 @@ import { type Href, useRouter } from 'expo-router'; import { useAtomValue } from 'jotai'; import { MessageSquare } from 'lucide-react-native'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useKeepAwake } from 'expo-keep-awake'; import { KeyboardAvoidingView, Platform, type Text as RNText, View } from 'react-native'; import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -63,6 +64,11 @@ import { useSessionDetailRename } from '@/components/agents/use-session-detail-r import { WorkingIndicator } from '@/components/agents/working-indicator'; import { getChildSessionStreaming } from '@/components/agents/child-session-card-state'; import { ChildSessionSheet } from '@/components/agents/child-session-sheet'; +import { + type ChildSessionSheetMountState, + closeChildSessionSheet, + openChildSessionSheet, +} from '@/components/agents/child-session-sheet-state'; import { PartRenderer } from '@/components/agents/part-renderer'; import { QueryError } from '@/components/query-error'; import { RenameModal } from '@/components/rename-modal'; @@ -117,10 +123,10 @@ export function SessionDetailContent({ }: Readonly) { const manager = useSessionManager(); const router = useRouter(); - const [childSession, setChildSession] = useState<{ - sessionId: KiloSessionId; - title: string; - }>(); + const [childSessionSheet, setChildSessionSheet] = useState({ + sheet: null, + visible: false, + }); const messages = useAtomValue(manager.atoms.messagesList); const isLoading = useAtomValue(manager.atoms.isLoading); @@ -339,12 +345,18 @@ export function SessionDetailContent({ const handleOpenChildSession = useCallback( (childSessionId: KiloSessionId, childTitle: string) => { - setChildSession({ sessionId: childSessionId, title: childTitle }); + setChildSessionSheet(current => + openChildSessionSheet(current, { sessionId: childSessionId, title: childTitle }) + ); void manager.hydrateChildSession(childSessionId); }, [manager] ); + const handleCloseChildSession = useCallback(() => { + setChildSessionSheet(closeChildSessionSheet); + }, []); + const transcript = useMemo( () => mergeSessionTranscript(messages, preparationAttempts), [messages, preparationAttempts] @@ -671,6 +683,8 @@ export function SessionDetailContent({ [manager, router] ); + const keepScreenAwake = isStreaming || pendingMessages.size > 0; + return ( + {keepScreenAwake ? : null} {!isConnected && } @@ -729,21 +744,24 @@ export function SessionDetailContent({ }} /> - {childSession ? ( + {childSessionSheet.sheet ? ( } onOpenChildSession={handleOpenChildSession} onRetry={() => { - void manager.hydrateChildSession(childSession.sessionId); - }} - onClose={() => { - setChildSession(undefined); + const openSheet = childSessionSheet.sheet; + if (!openSheet) { + return; + } + void manager.hydrateChildSession(openSheet.sessionId); }} + onClose={handleCloseChildSession} /> ) : null} @@ -923,6 +941,11 @@ export function SessionDetailContent({ } } +function ActiveSessionKeepAwake() { + useKeepAwake(); + return null; +} + // Mirrors MessageBubble's bubble geometry (px-4 py-1/py-2 wrapper, // rounded-2xl with an asymmetric "tail" corner, self-start/self-end // alignment) so the loading state reads as a message list, not a spinner. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fcbfd395849..a289963a327 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -401,6 +401,9 @@ importers: expo-insights: specifier: ~57.0.6 version: 57.0.6(expo@57.0.8) + expo-keep-awake: + specifier: ~57.0.1 + version: 57.0.1(expo@57.0.8)(react@19.2.3) expo-linear-gradient: specifier: ~57.0.1 version: 57.0.1(expo@57.0.8)(react-native@0.86.0(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) From d891cb1d6298c72fb9ba6fcaa8751090fae21c71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Jul 2026 13:15:18 +0200 Subject: [PATCH 02/17] docs(mobile): reference kilo-design baseline in AGENTS.md (item 12) --- apps/mobile/AGENTS.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 926d014e01a..be269efa118 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -80,6 +80,10 @@ git diff --check - Set `freezeOnBlur: true` on tabs. Use haptics for commits and outcomes only, never passive interaction. - Set `transition={0}` on small or header `expo-image` images to avoid flicker. +## Design + +The app follows https://github.com/Kilo-Org/kilo-design/ in general, except where this file states otherwise. + ## Debugging Add narrow temporary logs at the real boundaries. Reproduce. Read the tmux service logs. Fix the demonstrated cause. Remove the logs. Do not guess, and do not commit debug logging. From 67acec5c3f1e34abb724ce83c6111922dca9f5df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Jul 2026 13:34:53 +0200 Subject: [PATCH 03/17] fix(mobile): show Older activity residual in session context sheet Reconcile the persisted page total against the live cost breakdown when history is unpaginated: an Older activity row carries the residual exactly when it exceeds the 1 microdollar epsilon, so Total always matches the visible rows (item 23). --- .../agents/session-context-sheet.tsx | 27 +++++++++- ...ssion-cost-breakdown-models-filter.test.ts | 28 +++++++---- .../agents/session-cost-breakdown.test.ts | 49 ++++++++++++++++++- .../agents/session-cost-breakdown.ts | 30 ++++++++++-- 4 files changed, 119 insertions(+), 15 deletions(-) diff --git a/apps/mobile/src/components/agents/session-context-sheet.tsx b/apps/mobile/src/components/agents/session-context-sheet.tsx index fa9ed1365e9..3cbf397827b 100644 --- a/apps/mobile/src/components/agents/session-context-sheet.tsx +++ b/apps/mobile/src/components/agents/session-context-sheet.tsx @@ -22,6 +22,7 @@ import { } from './context-usage-display'; import { getModelsSectionCount, + getOlderActivityCostUsd, getSessionCostBreakdown, getVisibleSessionCostModels, type SessionCostBreakdown, @@ -79,7 +80,12 @@ export function SessionContextSheet({ () => getVisibleSessionCostModels(breakdown.models), [breakdown.models] ); - const modelsSectionCount = getModelsSectionCount(breakdown.models, breakdown.subagentCostUsd); + const olderActivityCostUsd = getOlderActivityCostUsd(totalCostMicrodollars, breakdownCostUsd); + const modelsSectionCount = getModelsSectionCount( + breakdown.models, + breakdown.subagentCostUsd, + olderActivityCostUsd + ); return ( 0 ? ( ) : null} + {olderActivityCostUsd > 0 ? ( + + ) : null} Token totals cover this session only and exclude subagent/child-session tokens. @@ -297,3 +306,19 @@ function SubagentRow({ costUsd }: Readonly<{ costUsd: number }>) { ); } + +function OlderActivityRow({ costUsd }: Readonly<{ costUsd: number }>) { + return ( + + + Older activity + + Cost from activity before the loaded history + + + + {formatCost(costUsd)} + + + ); +} diff --git a/apps/mobile/src/components/agents/session-cost-breakdown-models-filter.test.ts b/apps/mobile/src/components/agents/session-cost-breakdown-models-filter.test.ts index f01ff45b306..52cf3490967 100644 --- a/apps/mobile/src/components/agents/session-cost-breakdown-models-filter.test.ts +++ b/apps/mobile/src/components/agents/session-cost-breakdown-models-filter.test.ts @@ -128,29 +128,37 @@ describe('getVisibleSessionCostModels / getModelsSectionCount', () => { expect(visible.map(m => m.modelID)).toEqual(['anthropic/claude-sonnet-4', 'gpt-4o']); }); - it('derives Models (N) from filtered rows + residual, never unfiltered length', () => { + it('derives Models (N) from filtered rows + residuals, never unfiltered length', () => { const autoOnly = [modelRow({ providerID: 'kilo', modelID: 'kilo-auto/efficient' })]; // Auto-only, no residual → section hidden (count 0) - expect(getModelsSectionCount(autoOnly, 0)).toBe(0); - // Auto-only + residual → count is residual only (1), not 2 - expect(getModelsSectionCount(autoOnly, 0.02)).toBe(1); + expect(getModelsSectionCount(autoOnly, 0, 0)).toBe(0); + // Auto-only + subagent residual → count is residual only (1), not 2 + expect(getModelsSectionCount(autoOnly, 0.02, 0)).toBe(1); + // Auto-only + older activity residual → count is residual only (1) + expect(getModelsSectionCount(autoOnly, 0, 0.03)).toBe(1); + // Auto-only + both residuals → 2 + expect(getModelsSectionCount(autoOnly, 0.02, 0.03)).toBe(2); const mixed = [ modelRow({ providerID: 'kilo', modelID: 'kilo-auto/efficient' }), modelRow({ providerID: 'kilo', modelID: 'anthropic/claude-sonnet-4' }), ]; - // Filtered list (1) + residual → 2, not unfiltered 2 + residual = 3 - expect(getModelsSectionCount(mixed, 0.02)).toBe(2); + // Filtered list (1) + subagent residual → 2, not unfiltered 2 + residual = 3 + expect(getModelsSectionCount(mixed, 0.02, 0)).toBe(2); // Filtered list only → 1 - expect(getModelsSectionCount(mixed, 0)).toBe(1); + expect(getModelsSectionCount(mixed, 0, 0)).toBe(1); + // Filtered list (1) + both residuals → 3 + expect(getModelsSectionCount(mixed, 0.02, 0.01)).toBe(3); }); - it('returns zero when there are no models and no residual', () => { - expect(getModelsSectionCount([], 0)).toBe(0); + it('returns zero when there are no models and no residuals', () => { + expect(getModelsSectionCount([], 0, 0)).toBe(0); }); it('counts residual alone when models list is empty', () => { - expect(getModelsSectionCount([], 0.05)).toBe(1); + expect(getModelsSectionCount([], 0.05, 0)).toBe(1); + expect(getModelsSectionCount([], 0, 0.04)).toBe(1); + expect(getModelsSectionCount([], 0.05, 0.04)).toBe(2); }); }); diff --git a/apps/mobile/src/components/agents/session-cost-breakdown.test.ts b/apps/mobile/src/components/agents/session-cost-breakdown.test.ts index f5665bfdffe..3781c56d98c 100644 --- a/apps/mobile/src/components/agents/session-cost-breakdown.test.ts +++ b/apps/mobile/src/components/agents/session-cost-breakdown.test.ts @@ -1,6 +1,7 @@ +/* eslint-disable max-lines -- cohesive unit suite for cost breakdown + older-activity residual */ import { describe, expect, it } from 'vitest'; -import { getSessionCostBreakdown } from './session-cost-breakdown'; +import { getOlderActivityCostUsd, getSessionCostBreakdown } from './session-cost-breakdown'; import { type AssistantMessage, type Part, @@ -315,3 +316,49 @@ describe('getSessionCostBreakdown', () => { expect(result.subagentCostUsd).toBeCloseTo(0, 6); }); }); + +describe('getOlderActivityCostUsd', () => { + it('returns 0 when total is null or non-finite', () => { + expect(getOlderActivityCostUsd(null, 0.01)).toBe(0); + expect(getOlderActivityCostUsd(Number.NaN, 0.01)).toBe(0); + expect(getOlderActivityCostUsd(Number.POSITIVE_INFINITY, 0.01)).toBe(0); + }); + + it('returns 0 when residual is zero or within epsilon', () => { + expect(getOlderActivityCostUsd(50_000, 0.05)).toBe(0); + // +0.5µ$ and +0.9µ$ are both below COST_RECONCILIATION_EPSILON_USD (1e-6) + expect(getOlderActivityCostUsd(50_000 + 0.5, 0.05)).toBe(0); + expect(getOlderActivityCostUsd(50_000 + 0.9, 0.05)).toBe(0); + }); + + it('returns exact residual when persisted exceeds live beyond epsilon', () => { + // 0.07 USD persisted, 0.05 live → 0.02 residual + expect(getOlderActivityCostUsd(70_000, 0.05)).toBeCloseTo(0.02, 9); + }); + + it('returns 0 when persisted is less than or equal to live', () => { + expect(getOlderActivityCostUsd(30_000, 0.05)).toBe(0); + expect(getOlderActivityCostUsd(50_000, 0.05)).toBe(0); + }); + + it('reconciles attributed + subagent + older activity to page total within epsilon', () => { + // Live breakdown: attributed 0.03 + subagent 0.02 = 0.05 live + // Page total: 0.08 USD (80_000 µ$) → older activity 0.03 + const messages: StoredMessage[] = [ + storedMessage(assistantInfo({ id: 'm1', cost: 0.05 }), [ + stepFinish({ id: 'sf-1', cost: 0.03, tokens: oneOneTokens }), + ]), + ]; + const liveCostUsd = 0.05; + const totalCostMicrodollars = 80_000; + const breakdown = getSessionCostBreakdown(messages, liveCostUsd); + const olderActivityCostUsd = getOlderActivityCostUsd(totalCostMicrodollars, liveCostUsd); + + expect(breakdown.attributedCostUsd).toBeCloseTo(0.03, 6); + expect(breakdown.subagentCostUsd).toBeCloseTo(0.02, 6); + expect(olderActivityCostUsd).toBeCloseTo(0.03, 6); + expect( + breakdown.attributedCostUsd + breakdown.subagentCostUsd + olderActivityCostUsd + ).toBeCloseTo(totalCostMicrodollars / 1e6, 9); + }); +}); diff --git a/apps/mobile/src/components/agents/session-cost-breakdown.ts b/apps/mobile/src/components/agents/session-cost-breakdown.ts index 21925b9c785..ffe34716f7e 100644 --- a/apps/mobile/src/components/agents/session-cost-breakdown.ts +++ b/apps/mobile/src/components/agents/session-cost-breakdown.ts @@ -192,15 +192,39 @@ export function getVisibleSessionCostModels( return models.filter(model => !isHiddenAutoModelRow(model)); } +/** + * Persisted-vs-live cost residual for activity outside the loaded message + * page (unpaginated history / ingest gaps). Distinct from the subagent + * residual, which is computed on the live-message basis alone. + * + * Returns a row-ready amount: 0 when total is null/non-finite, when the + * delta is within ±epsilon, or when live already covers the page total. + */ +export function getOlderActivityCostUsd( + totalCostMicrodollars: number | null, + liveCostUsd: number +): number { + if (totalCostMicrodollars === null || !Number.isFinite(totalCostMicrodollars)) { + return 0; + } + const residualUsd = totalCostMicrodollars / 1e6 - liveCostUsd; + return residualUsd > COST_RECONCILIATION_EPSILON_USD ? residualUsd : 0; +} + /** * Section title count and visibility source: filtered model rows + optional - * Subagents residual. Never derived from the unfiltered list. + * Subagents / Older activity residuals. Never derived from the unfiltered list. */ export function getModelsSectionCount( models: SessionCostBreakdownModel[], - subagentCostUsd: number + subagentCostUsd: number, + olderActivityCostUsd = 0 ): number { - return getVisibleSessionCostModels(models).length + (subagentCostUsd > 0 ? 1 : 0); + return ( + getVisibleSessionCostModels(models).length + + (subagentCostUsd > 0 ? 1 : 0) + + (olderActivityCostUsd > 0 ? 1 : 0) + ); } function collectStepFinishParts(parts: Part[]): StepFinishPart[] { From 4e20dd9ff56cb3c8a6033cf13f4ab41919907746 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Jul 2026 13:38:02 +0200 Subject: [PATCH 04/17] fix(mobile): disable native text selection inside message bubbles Gate selectable behind InMessageBubbleContext so a long-press anywhere in a message opens the details sheet without the iOS selection callout floating over it (item 13). Preparation group and child-sheet texts keep native selection as their only copy path; details-sheet copy and the a11y rotor path are unchanged. --- .../agents/bubble-text-selection-context.ts | 13 ++++++ .../components/agents/message-bubble.test.ts | 45 +++++++++++++++++++ .../src/components/agents/message-bubble.tsx | 41 +++++++++-------- .../components/agents/mono-scroll-block.tsx | 4 +- .../agents/read-markdown-preview.tsx | 4 +- .../agents/reasoning-part-renderer.tsx | 5 ++- .../agents/tool-cards/bash-tool-card.tsx | 9 +++- .../agents/tool-cards/edit-tool-card.tsx | 4 +- .../agents/tool-cards/generic-tool-card.tsx | 4 +- .../agents/tool-cards/glob-tool-card.tsx | 4 +- .../agents/tool-cards/grep-tool-card.tsx | 4 +- .../agents/tool-cards/list-tool-card.tsx | 4 +- .../agents/tool-cards/read-tool-card.tsx | 4 +- .../agents/tool-cards/task-tool-card.tsx | 4 +- .../agents/tool-cards/todo-tool-card.tsx | 4 +- .../tool-cards/web-search-tool-card.tsx | 4 +- .../agents/tool-cards/write-tool-card.tsx | 4 +- 17 files changed, 128 insertions(+), 33 deletions(-) create mode 100644 apps/mobile/src/components/agents/bubble-text-selection-context.ts diff --git a/apps/mobile/src/components/agents/bubble-text-selection-context.ts b/apps/mobile/src/components/agents/bubble-text-selection-context.ts new file mode 100644 index 00000000000..31ef858cc54 --- /dev/null +++ b/apps/mobile/src/components/agents/bubble-text-selection-context.ts @@ -0,0 +1,13 @@ +import { createContext, useContext } from 'react'; + +/** + * Inside a message bubble the long-press opens the details sheet; native + * selection must stay off so the iOS callout cannot float over the sheet + * (extends the rationale at text-part-renderer.tsx:12-14). + */ +export const InMessageBubbleContext = createContext(false); + +/** True when transcript text may use native selection (outside a message bubble). */ +export function useTranscriptTextSelectable(): boolean { + return !useContext(InMessageBubbleContext); +} diff --git a/apps/mobile/src/components/agents/message-bubble.test.ts b/apps/mobile/src/components/agents/message-bubble.test.ts index ebec88413b7..8d69fc34d66 100644 --- a/apps/mobile/src/components/agents/message-bubble.test.ts +++ b/apps/mobile/src/components/agents/message-bubble.test.ts @@ -96,3 +96,48 @@ describe('MessageBubble regressions', () => { expect(findText(dequeuedTree, t => t === 'Queued')).toBe(false); }); }); + +describe('MessageBubble in-bubble text selection context', () => { + it('wraps the assistant parts view in InMessageBubbleContext.Provider with value true', async () => { + const { InMessageBubbleContext } = await import('./bubble-text-selection-context'); + const tree = await renderBubble(assistantMessage('m6')); + + const provider = findProvider(tree, InMessageBubbleContext.Provider); + expect(provider).not.toBeNull(); + expect(provider?.props.value).toBe(true); + }); + + it('wraps the user bubble content in InMessageBubbleContext.Provider with value true', async () => { + const { InMessageBubbleContext } = await import('./bubble-text-selection-context'); + const tree = await renderBubble(userMessage('m7')); + + const provider = findProvider(tree, InMessageBubbleContext.Provider); + expect(provider).not.toBeNull(); + expect(provider?.props.value).toBe(true); + }); +}); + +function findProvider( + node: unknown, + providerType: unknown +): { type: unknown; props: { value?: unknown; children?: unknown } } | null { + if (node == null || typeof node !== 'object') { + return null; + } + const element = node as { type?: unknown; props?: { value?: unknown; children?: unknown } }; + if (element.type === providerType) { + return element as { type: unknown; props: { value?: unknown; children?: unknown } }; + } + const children = element.props?.children; + if (Array.isArray(children)) { + for (const child of children) { + const hit = findProvider(child, providerType); + if (hit) { + return hit; + } + } + } else if (children && typeof children === 'object') { + return findProvider(children, providerType); + } + return null; +} diff --git a/apps/mobile/src/components/agents/message-bubble.tsx b/apps/mobile/src/components/agents/message-bubble.tsx index daaffb146eb..97bc4cb3305 100644 --- a/apps/mobile/src/components/agents/message-bubble.tsx +++ b/apps/mobile/src/components/agents/message-bubble.tsx @@ -7,6 +7,7 @@ import { Bubble } from '@/components/ui/bubble'; import { Text } from '@/components/ui/text'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { InMessageBubbleContext } from './bubble-text-selection-context'; import { ChatMarkdownText } from './chat-markdown-text'; import { CompactionSeparator } from './compaction-separator'; import { FilePartRenderer } from './file-part-renderer'; @@ -84,12 +85,14 @@ export function MessageBubble({ - {textContent ? ( - - ) : null} - {fileParts.map(part => ( - - ))} + + {textContent ? ( + + ) : null} + {fileParts.map(part => ( + + ))} + {showQueuedBadge ? ( - - {message.parts.map(part => ( - - ))} - + + + {message.parts.map(part => ( + + ))} + + {a11y.accessibilityActions.length > 0 ? ( ) { + const textSelectable = useTranscriptTextSelectable(); const { displayText, isTruncated } = prepareMonoScrollContent(content, maxLength); const [heightPin, setHeightPin] = useState(undefined); const contentHeight = resolveMonoScrollPinnedHeight(heightPin, displayText); @@ -69,7 +71,7 @@ export function MonoScrollBlock({ style={contentHeight === undefined ? undefined : { height: contentHeight }} > diff --git a/apps/mobile/src/components/agents/read-markdown-preview.tsx b/apps/mobile/src/components/agents/read-markdown-preview.tsx index 9789c1d081a..898466cf044 100644 --- a/apps/mobile/src/components/agents/read-markdown-preview.tsx +++ b/apps/mobile/src/components/agents/read-markdown-preview.tsx @@ -3,12 +3,14 @@ import { Pressable, View } from 'react-native'; import { Text } from '@/components/ui/text'; +import { useTranscriptTextSelectable } from './bubble-text-selection-context'; import { ChatMarkdownText } from './chat-markdown-text'; import { MarkdownViewerModal } from './markdown-viewer-modal'; import { type MarkdownPreview } from './read-tool-markdown'; import { getFilename } from './tool-card-utils'; export function ReadMarkdownPreview({ preview }: Readonly<{ preview: MarkdownPreview }>) { + const textSelectable = useTranscriptTextSelectable(); const [readerVisible, setReaderVisible] = useState(false); if (preview.text === '') { @@ -25,7 +27,7 @@ export function ReadMarkdownPreview({ preview }: Readonly<{ preview: MarkdownPre accessibilityLabel={`Open ${getFilename(preview.path)} full screen`} className="active:opacity-80" > - + {preview.footer ? ( {preview.footer} diff --git a/apps/mobile/src/components/agents/reasoning-part-renderer.tsx b/apps/mobile/src/components/agents/reasoning-part-renderer.tsx index 766f4787b84..ae8dbd4d8a0 100644 --- a/apps/mobile/src/components/agents/reasoning-part-renderer.tsx +++ b/apps/mobile/src/components/agents/reasoning-part-renderer.tsx @@ -7,6 +7,8 @@ import { Eyebrow } from '@/components/ui/eyebrow'; import { Text } from '@/components/ui/text'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { useTranscriptTextSelectable } from './bubble-text-selection-context'; + type ReasoningPartRendererProps = { text: string; isStreaming?: boolean; @@ -20,6 +22,7 @@ export function ReasoningPartRenderer({ }: Readonly) { const [isExpanded, setIsExpanded] = useState(defaultExpanded); const colors = useThemeColors(); + const textSelectable = useTranscriptTextSelectable(); return ( @@ -43,7 +46,7 @@ export function ReasoningPartRenderer({ {isExpanded && text ? ( - + {text} diff --git a/apps/mobile/src/components/agents/tool-cards/bash-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/bash-tool-card.tsx index b1c7a59ba5a..3dc15bf57c2 100644 --- a/apps/mobile/src/components/agents/tool-cards/bash-tool-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/bash-tool-card.tsx @@ -4,11 +4,13 @@ import { type ToolPart } from '@kilocode/cloud-agent-sdk'; import { Text } from '@/components/ui/text'; +import { useTranscriptTextSelectable } from '../bubble-text-selection-context'; import { MonoScrollBlock } from '../mono-scroll-block'; import { ToolCardShell } from '../tool-card-shell'; import { truncateText } from '../tool-card-utils'; export function BashToolCard({ part }: Readonly<{ part: ToolPart }>) { + const textSelectable = useTranscriptTextSelectable(); const input = part.state.input; const command = typeof input.command === 'string' ? input.command : ''; const description = typeof input.description === 'string' ? input.description : undefined; @@ -26,7 +28,10 @@ export function BashToolCard({ part }: Readonly<{ part: ToolPart }>) { {command.length > 0 ? ( - + $ {command} @@ -39,7 +44,7 @@ export function BashToolCard({ part }: Readonly<{ part: ToolPart }>) { /> ) : null} {error ? ( - + {error} ) : null} diff --git a/apps/mobile/src/components/agents/tool-cards/edit-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/edit-tool-card.tsx index 8cd30c2cdd0..cb6ac0a7e69 100644 --- a/apps/mobile/src/components/agents/tool-cards/edit-tool-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/edit-tool-card.tsx @@ -4,11 +4,13 @@ import { type ToolPart } from '@kilocode/cloud-agent-sdk'; import { Text } from '@/components/ui/text'; +import { useTranscriptTextSelectable } from '../bubble-text-selection-context'; import { MonoScrollBlock } from '../mono-scroll-block'; import { ToolCardShell } from '../tool-card-shell'; import { getFilename } from '../tool-card-utils'; export function EditToolCard({ part }: Readonly<{ part: ToolPart }>) { + const textSelectable = useTranscriptTextSelectable(); const input = part.state.input; const filePath = typeof input.filePath === 'string' ? input.filePath : ''; const oldString = typeof input.oldString === 'string' ? input.oldString : ''; @@ -42,7 +44,7 @@ export function EditToolCard({ part }: Readonly<{ part: ToolPart }>) { ) : null} {error ? ( - + {error} ) : null} diff --git a/apps/mobile/src/components/agents/tool-cards/generic-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/generic-tool-card.tsx index 51b0263b6c2..92a394ee87f 100644 --- a/apps/mobile/src/components/agents/tool-cards/generic-tool-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/generic-tool-card.tsx @@ -4,6 +4,7 @@ import { type ToolPart } from '@kilocode/cloud-agent-sdk'; import { Text } from '@/components/ui/text'; +import { useTranscriptTextSelectable } from '../bubble-text-selection-context'; import { MonoScrollBlock } from '../mono-scroll-block'; import { ToolCardShell } from '../tool-card-shell'; import { getGenericToolTitle } from '../tool-card-utils'; @@ -17,6 +18,7 @@ function formatInput(input: Record): string { } export function GenericToolCard({ part }: Readonly<{ part: ToolPart }>) { + const textSelectable = useTranscriptTextSelectable(); const input = part.state.input; const stateTitle = part.state.status === 'running' || part.state.status === 'completed' @@ -51,7 +53,7 @@ export function GenericToolCard({ part }: Readonly<{ part: ToolPart }>) { ) : null} {error ? ( - + {error} ) : null} diff --git a/apps/mobile/src/components/agents/tool-cards/glob-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/glob-tool-card.tsx index 387fb9e5502..00047e387fb 100644 --- a/apps/mobile/src/components/agents/tool-cards/glob-tool-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/glob-tool-card.tsx @@ -3,6 +3,7 @@ import { type ToolPart } from '@kilocode/cloud-agent-sdk'; import { Text } from '@/components/ui/text'; +import { useTranscriptTextSelectable } from '../bubble-text-selection-context'; import { MonoScrollBlock } from '../mono-scroll-block'; import { ToolCardShell } from '../tool-card-shell'; @@ -14,6 +15,7 @@ function countOutputLines(output: string): number { } export function GlobToolCard({ part }: Readonly<{ part: ToolPart }>) { + const textSelectable = useTranscriptTextSelectable(); const input = part.state.input; const pattern = typeof input.pattern === 'string' ? input.pattern : ''; @@ -37,7 +39,7 @@ export function GlobToolCard({ part }: Readonly<{ part: ToolPart }>) { ) : null} {error ? ( - + {error} ) : null} diff --git a/apps/mobile/src/components/agents/tool-cards/grep-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/grep-tool-card.tsx index eee0d41b174..c8aad15f592 100644 --- a/apps/mobile/src/components/agents/tool-cards/grep-tool-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/grep-tool-card.tsx @@ -3,6 +3,7 @@ import { type ToolPart } from '@kilocode/cloud-agent-sdk'; import { Text } from '@/components/ui/text'; +import { useTranscriptTextSelectable } from '../bubble-text-selection-context'; import { MonoScrollBlock } from '../mono-scroll-block'; import { ToolCardShell } from '../tool-card-shell'; @@ -14,6 +15,7 @@ function countOutputLines(output: string): number { } export function GrepToolCard({ part }: Readonly<{ part: ToolPart }>) { + const textSelectable = useTranscriptTextSelectable(); const input = part.state.input; const pattern = typeof input.pattern === 'string' ? input.pattern : ''; const include = typeof input.include === 'string' ? input.include : undefined; @@ -41,7 +43,7 @@ export function GrepToolCard({ part }: Readonly<{ part: ToolPart }>) { ) : null} {error ? ( - + {error} ) : null} diff --git a/apps/mobile/src/components/agents/tool-cards/list-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/list-tool-card.tsx index 5c009529a9f..381ef247aed 100644 --- a/apps/mobile/src/components/agents/tool-cards/list-tool-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/list-tool-card.tsx @@ -3,11 +3,13 @@ import { type ToolPart } from '@kilocode/cloud-agent-sdk'; import { Text } from '@/components/ui/text'; +import { useTranscriptTextSelectable } from '../bubble-text-selection-context'; import { MonoScrollBlock } from '../mono-scroll-block'; import { ToolCardShell } from '../tool-card-shell'; import { getDirectoryName } from '../tool-card-utils'; export function ListToolCard({ part }: Readonly<{ part: ToolPart }>) { + const textSelectable = useTranscriptTextSelectable(); const input = part.state.input; const filePath = typeof input.filePath === 'string' ? input.filePath : undefined; const path = typeof input.path === 'string' ? input.path : undefined; @@ -24,7 +26,7 @@ export function ListToolCard({ part }: Readonly<{ part: ToolPart }>) { ) : null} {error ? ( - + {error} ) : null} diff --git a/apps/mobile/src/components/agents/tool-cards/read-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/read-tool-card.tsx index a9b37fee6ba..b1b535da9b6 100644 --- a/apps/mobile/src/components/agents/tool-cards/read-tool-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/read-tool-card.tsx @@ -3,6 +3,7 @@ import { type ToolPart } from '@kilocode/cloud-agent-sdk'; import { Text } from '@/components/ui/text'; +import { useTranscriptTextSelectable } from '../bubble-text-selection-context'; import { MonoScrollBlock } from '../mono-scroll-block'; import { ReadMarkdownPreview } from '../read-markdown-preview'; import { isMarkdownPath, resolveMarkdownPreview } from '../read-tool-markdown'; @@ -11,6 +12,7 @@ import { ToolCardShell } from '../tool-card-shell'; import { getFilename } from '../tool-card-utils'; export function ReadToolCard({ part }: Readonly<{ part: ToolPart }>) { + const textSelectable = useTranscriptTextSelectable(); const input = part.state.input; const filePath = typeof input.filePath === 'string' ? input.filePath : ''; const offset = typeof input.offset === 'number' ? input.offset : undefined; @@ -53,7 +55,7 @@ export function ReadToolCard({ part }: Readonly<{ part: ToolPart }>) { ) : null} {error ? ( - + {error} ) : null} diff --git a/apps/mobile/src/components/agents/tool-cards/task-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/task-tool-card.tsx index f93b90d204f..e3f82d842d7 100644 --- a/apps/mobile/src/components/agents/tool-cards/task-tool-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/task-tool-card.tsx @@ -3,11 +3,13 @@ import { type ToolPart } from '@kilocode/cloud-agent-sdk'; import { Text } from '@/components/ui/text'; +import { useTranscriptTextSelectable } from '../bubble-text-selection-context'; import { MonoScrollBlock } from '../mono-scroll-block'; import { ToolCardShell } from '../tool-card-shell'; import { truncateText } from '../tool-card-utils'; export function TaskToolCard({ part }: Readonly<{ part: ToolPart }>) { + const textSelectable = useTranscriptTextSelectable(); const input = part.state.input; const description = typeof input.description === 'string' ? input.description : undefined; const prompt = typeof input.prompt === 'string' ? input.prompt : undefined; @@ -23,7 +25,7 @@ export function TaskToolCard({ part }: Readonly<{ part: ToolPart }>) { ) : null} {error ? ( - + {error} ) : null} diff --git a/apps/mobile/src/components/agents/tool-cards/todo-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/todo-tool-card.tsx index 1bebf971b4d..3c0796398d1 100644 --- a/apps/mobile/src/components/agents/tool-cards/todo-tool-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/todo-tool-card.tsx @@ -3,10 +3,12 @@ import { type ToolPart } from '@kilocode/cloud-agent-sdk'; import { Text } from '@/components/ui/text'; +import { useTranscriptTextSelectable } from '../bubble-text-selection-context'; import { MonoScrollBlock } from '../mono-scroll-block'; import { ToolCardShell } from '../tool-card-shell'; export function TodoToolCard({ part }: Readonly<{ part: ToolPart }>) { + const textSelectable = useTranscriptTextSelectable(); const isWrite = part.tool === 'todowrite'; const subtitle = isWrite ? 'Update todos' : 'Read todos'; @@ -19,7 +21,7 @@ export function TodoToolCard({ part }: Readonly<{ part: ToolPart }>) { ) : null} {error ? ( - + {error} ) : null} diff --git a/apps/mobile/src/components/agents/tool-cards/web-search-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/web-search-tool-card.tsx index 206497626f4..221ce9dc546 100644 --- a/apps/mobile/src/components/agents/tool-cards/web-search-tool-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/web-search-tool-card.tsx @@ -3,11 +3,13 @@ import { type ToolPart } from '@kilocode/cloud-agent-sdk'; import { Text } from '@/components/ui/text'; +import { useTranscriptTextSelectable } from '../bubble-text-selection-context'; import { MonoScrollBlock } from '../mono-scroll-block'; import { ToolCardShell } from '../tool-card-shell'; import { truncateText } from '../tool-card-utils'; export function WebSearchToolCard({ part }: Readonly<{ part: ToolPart }>) { + const textSelectable = useTranscriptTextSelectable(); const input = part.state.input; const query = typeof input.query === 'string' ? input.query : undefined; const url = typeof input.url === 'string' ? input.url : undefined; @@ -28,7 +30,7 @@ export function WebSearchToolCard({ part }: Readonly<{ part: ToolPart }>) { ) : null} {error ? ( - + {error} ) : null} diff --git a/apps/mobile/src/components/agents/tool-cards/write-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/write-tool-card.tsx index 7b97793ddf6..50b82ad3fc2 100644 --- a/apps/mobile/src/components/agents/tool-cards/write-tool-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/write-tool-card.tsx @@ -3,11 +3,13 @@ import { type ToolPart } from '@kilocode/cloud-agent-sdk'; import { Text } from '@/components/ui/text'; +import { useTranscriptTextSelectable } from '../bubble-text-selection-context'; import { MonoScrollBlock } from '../mono-scroll-block'; import { ToolCardShell } from '../tool-card-shell'; import { getFilename } from '../tool-card-utils'; export function WriteToolCard({ part }: Readonly<{ part: ToolPart }>) { + const textSelectable = useTranscriptTextSelectable(); const input = part.state.input; const filePath = typeof input.filePath === 'string' ? input.filePath : ''; const content = typeof input.content === 'string' ? input.content : ''; @@ -21,7 +23,7 @@ export function WriteToolCard({ part }: Readonly<{ part: ToolPart }>) { ) : null} {error ? ( - + {error} ) : null} From 851e386fc55bd9511f8719e45d8e3a1f232f36ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Jul 2026 13:39:27 +0200 Subject: [PATCH 05/17] fix(mobile): slash menu separators + SheetHeader title ellipsis Use the border-border theme token between slash-command rows and drop the trailing separator (item 24). Bound sheet titles in a flex-1 px-24 wrapper with numberOfLines=1 so long titles ellipsize without covering the Done/Cancel controls, which render after the title to stay on top (item 25). --- .../components/agents/slash-command-suggestions.tsx | 8 ++++++-- apps/mobile/src/components/sheet-header.tsx | 12 +++++++++--- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/components/agents/slash-command-suggestions.tsx b/apps/mobile/src/components/agents/slash-command-suggestions.tsx index 5cb60bb19a3..c534f0d5b64 100644 --- a/apps/mobile/src/components/agents/slash-command-suggestions.tsx +++ b/apps/mobile/src/components/agents/slash-command-suggestions.tsx @@ -2,6 +2,7 @@ import { type SlashCommandInfo } from '@kilocode/cloud-agent-sdk'; import { Pressable, ScrollView, View } from 'react-native'; import { Text } from '@/components/ui/text'; +import { cn } from '@/lib/utils'; type SlashCommandSuggestionsProps = { commands: SlashCommandInfo[]; @@ -30,7 +31,7 @@ export function SlashCommandSuggestions({ className="max-h-48 border-t border-border bg-card" keyboardShouldPersistTaps="handled" > - {commands.map(command => ( + {commands.map((command, index) => ( { @@ -40,7 +41,10 @@ export function SlashCommandSuggestions({ accessibilityLabel={`Use /${command.name}`} accessibilityHint={command.description ?? undefined} hitSlop={4} - className="min-h-[44px] flex-row items-center justify-between gap-3 border-b border-black/5 px-4 py-2 active:bg-muted dark:border-white/5" + className={cn( + 'min-h-[44px] flex-row items-center justify-between gap-3 px-4 py-2 active:bg-muted', + index < commands.length - 1 && 'border-b border-border' + )} > /{command.name} diff --git a/apps/mobile/src/components/sheet-header.tsx b/apps/mobile/src/components/sheet-header.tsx index 31a494bd468..00e57c55d33 100644 --- a/apps/mobile/src/components/sheet-header.tsx +++ b/apps/mobile/src/components/sheet-header.tsx @@ -19,6 +19,15 @@ export function SheetHeader({ // flattened header breaks that native pass and the list paints over it. + + + {title} + + {onCancel ? ( Cancel ) : null} - - {title} - Date: Thu, 30 Jul 2026 13:42:40 +0200 Subject: [PATCH 06/17] fix(mobile): composer AppState restore, swipe-down dismiss, reliable growth Move message-input-app-state to src/lib as the shared home and reuse its blur-on-background / delayed-refocus pattern in the agents composer (item 19). Replace the hidden mirror-Text measurement with the TextInput's own onContentSizeChange plus pure, unit-tested reducers, so the composer reliably grows to its max height then scrolls (item 26). Add a downward-pan GestureDetector scoped to the composer input row so a swipe down dismisses the keyboard when the input is not scrollable (item 22). --- .../agents/chat-composer-input-height.test.ts | 144 ++++++++++++ .../agents/chat-composer-input-height.ts | 68 ++++++ .../agents/chat-composer-input-row.tsx | 16 +- .../src/components/agents/chat-composer.tsx | 206 ++++++++++++------ .../kilo-chat/message-input-content.tsx | 2 +- .../message-input-app-state.test.ts | 0 .../message-input-app-state.ts | 0 7 files changed, 367 insertions(+), 69 deletions(-) create mode 100644 apps/mobile/src/components/agents/chat-composer-input-height.test.ts create mode 100644 apps/mobile/src/components/agents/chat-composer-input-height.ts rename apps/mobile/src/{components/kilo-chat => lib}/message-input-app-state.test.ts (100%) rename apps/mobile/src/{components/kilo-chat => lib}/message-input-app-state.ts (100%) diff --git a/apps/mobile/src/components/agents/chat-composer-input-height.test.ts b/apps/mobile/src/components/agents/chat-composer-input-height.test.ts new file mode 100644 index 00000000000..656dd189c8e --- /dev/null +++ b/apps/mobile/src/components/agents/chat-composer-input-height.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest'; + +import { + clampComposerInputHeight, + resolveComposerHeightOnTextChange, + resolveComposerInputHeight, + shouldEnableComposerInputScroll, +} from './chat-composer-input-height'; + +const MIN = 44; +const MAX = 124; + +describe('clampComposerInputHeight', () => { + it('clamps growth to min and max and rounds fractional heights up', () => { + expect(clampComposerInputHeight(20, { minHeight: MIN, maxHeight: MAX })).toBe(MIN); + expect(clampComposerInputHeight(64.1, { minHeight: MIN, maxHeight: MAX })).toBe(65); + expect(clampComposerInputHeight(200, { minHeight: MIN, maxHeight: MAX })).toBe(MAX); + expect(clampComposerInputHeight(MIN, { minHeight: MIN, maxHeight: MAX })).toBe(MIN); + expect(clampComposerInputHeight(MAX, { minHeight: MIN, maxHeight: MAX })).toBe(MAX); + }); +}); + +describe('resolveComposerInputHeight', () => { + it('always returns minHeight when the draft is empty (stale after-clear event)', () => { + expect( + resolveComposerInputHeight({ + draftLength: 0, + contentHeight: 200, + minHeight: MIN, + maxHeight: MAX, + }) + ).toBe(MIN); + }); + + it('clamps non-empty drafts to the latest contentSize within min/max', () => { + expect( + resolveComposerInputHeight({ + draftLength: 10, + contentHeight: 64, + minHeight: MIN, + maxHeight: MAX, + }) + ).toBe(64); + expect( + resolveComposerInputHeight({ + draftLength: 10, + contentHeight: 84.2, + minHeight: MIN, + maxHeight: MAX, + }) + ).toBe(85); + expect( + resolveComposerInputHeight({ + draftLength: 500, + contentHeight: 300, + minHeight: MIN, + maxHeight: MAX, + }) + ).toBe(MAX); + }); + + it('resolves a shrinking draft to the latest smaller contentSize', () => { + const tall = resolveComposerInputHeight({ + draftLength: 80, + contentHeight: 104, + minHeight: MIN, + maxHeight: MAX, + }); + const short = resolveComposerInputHeight({ + draftLength: 5, + contentHeight: 44, + minHeight: MIN, + maxHeight: MAX, + }); + expect(tall).toBe(104); + expect(short).toBe(MIN); + expect(short).toBeLessThan(tall); + }); +}); + +describe('shouldEnableComposerInputScroll', () => { + it('is true at or above max and false below', () => { + expect(shouldEnableComposerInputScroll(MAX, MAX)).toBe(true); + expect(shouldEnableComposerInputScroll(MAX + 1, MAX)).toBe(true); + expect(shouldEnableComposerInputScroll(MAX - 1, MAX)).toBe(false); + expect(shouldEnableComposerInputScroll(MIN, MAX)).toBe(false); + }); +}); + +describe('resolveComposerHeightOnTextChange', () => { + it('returns minHeight when the next draft is empty', () => { + expect( + resolveComposerHeightOnTextChange({ + previousDraftLength: 12, + nextDraftLength: 0, + lastContentHeight: 104, + minHeight: MIN, + maxHeight: MAX, + }) + ).toBe(MIN); + }); + + it('repairs empty→non-empty using the last measured content height (voice ordering)', () => { + expect( + resolveComposerHeightOnTextChange({ + previousDraftLength: 0, + nextDraftLength: 40, + lastContentHeight: 84, + minHeight: MIN, + maxHeight: MAX, + }) + ).toBe(84); + expect( + resolveComposerHeightOnTextChange({ + previousDraftLength: 0, + nextDraftLength: 40, + lastContentHeight: 300, + minHeight: MIN, + maxHeight: MAX, + }) + ).toBe(MAX); + }); + + it('returns null when there is no height decision to make (leave to native event)', () => { + expect( + resolveComposerHeightOnTextChange({ + previousDraftLength: 5, + nextDraftLength: 6, + lastContentHeight: 64, + minHeight: MIN, + maxHeight: MAX, + }) + ).toBeNull(); + expect( + resolveComposerHeightOnTextChange({ + previousDraftLength: 0, + nextDraftLength: 3, + lastContentHeight: null, + minHeight: MIN, + maxHeight: MAX, + }) + ).toBeNull(); + }); +}); diff --git a/apps/mobile/src/components/agents/chat-composer-input-height.ts b/apps/mobile/src/components/agents/chat-composer-input-height.ts new file mode 100644 index 00000000000..e38c9893300 --- /dev/null +++ b/apps/mobile/src/components/agents/chat-composer-input-height.ts @@ -0,0 +1,68 @@ +/** + * Pure height reducers for the agents chat composer TextInput. + * + * RN multiline TextInput `contentSize.height` already includes the + * `paddingVertical` inset, so style height is a ceil+clamp of that value. + * Staleness rules: + * - empty draft always resolves to minHeight (guards after-clear stale events) + * - voice-draft ordering can fire content-size while textRef still holds the + * old empty draft; `resolveComposerHeightOnTextChange` repairs empty→non-empty + * using the last measured content height + */ + +export function clampComposerInputHeight( + contentHeight: number, + { minHeight, maxHeight }: { minHeight: number; maxHeight: number } +): number { + return Math.min(Math.max(Math.ceil(contentHeight), minHeight), maxHeight); +} + +export function resolveComposerInputHeight({ + draftLength, + contentHeight, + minHeight, + maxHeight, +}: { + draftLength: number; + contentHeight: number; + minHeight: number; + maxHeight: number; +}): number { + if (draftLength === 0) { + return minHeight; + } + return clampComposerInputHeight(contentHeight, { minHeight, maxHeight }); +} + +/** + * Optimistic height after a JS text change. + * - empty next draft → minHeight + * - empty→non-empty with a prior content measurement → clamp that measurement + * (voice-ordering repair when setNativeProps races ahead of onChangeText) + * - otherwise null (leave height to the next native onContentSizeChange) + */ +export function resolveComposerHeightOnTextChange({ + previousDraftLength, + nextDraftLength, + lastContentHeight, + minHeight, + maxHeight, +}: { + previousDraftLength: number; + nextDraftLength: number; + lastContentHeight: number | null; + minHeight: number; + maxHeight: number; +}): number | null { + if (nextDraftLength === 0) { + return minHeight; + } + if (previousDraftLength === 0 && nextDraftLength > 0 && lastContentHeight !== null) { + return clampComposerInputHeight(lastContentHeight, { minHeight, maxHeight }); + } + return null; +} + +export function shouldEnableComposerInputScroll(height: number, maxHeight: number): boolean { + return height >= maxHeight; +} 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 5cdfea03e4b..8bdf85ef398 100644 --- a/apps/mobile/src/components/agents/chat-composer-input-row.tsx +++ b/apps/mobile/src/components/agents/chat-composer-input-row.tsx @@ -2,13 +2,14 @@ import { ArrowUp, Paperclip, Square } from 'lucide-react-native'; import { type RefObject } from 'react'; import { ActivityIndicator, - type LayoutChangeEvent, Pressable, TextInput, + type TextInputContentSizeChangeEvent, type TextStyle, View, } from 'react-native'; +import { shouldEnableComposerInputScroll } from '@/components/agents/chat-composer-input-height'; import { VoiceInputButton } from '@/components/voice-input-control'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { cn } from '@/lib/utils'; @@ -23,16 +24,16 @@ type ChatComposerInputRowProps = { disabled: boolean; inputAccessibilityDisabled: boolean; inputEditable: boolean; + inputHeight: number; inputRef: RefObject; isSending: boolean; isStreaming: boolean; maxInputHeight: number; - measureHeight: number; onAddAttachment: () => void; onChangeText: (text: string) => void; + onContentSizeChange: (event: TextInputContentSizeChangeEvent) => void; onInputBlur: () => void; onInputFocus: () => void; - onInputLayout: (event: LayoutChangeEvent) => void; onStop: () => void; onSubmit: () => void; onToggleVoice: () => void; @@ -57,16 +58,16 @@ export function ChatComposerInputRow({ disabled, inputAccessibilityDisabled, inputEditable, + inputHeight, inputRef, isSending, isStreaming, maxInputHeight, - measureHeight, onAddAttachment, onChangeText, + onContentSizeChange, onInputBlur, onInputFocus, - onInputLayout, onStop, onSubmit, onToggleVoice, @@ -78,6 +79,7 @@ export function ChatComposerInputRow({ voiceInputStatus, }: Readonly) { const colors = useThemeColors(); + const inputScrollable = shouldEnableComposerInputScroll(inputHeight, maxInputHeight); return ( @@ -103,7 +105,6 @@ export function ChatComposerInputRow({ 'mx-2.5 flex-1 overflow-hidden rounded-[20px] border border-border bg-card', !inputEditable && 'opacity-50' )} - onLayout={onInputLayout} > = maxInputHeight} + scrollEnabled={inputScrollable} editable={inputEditable} accessibilityState={{ disabled: inputAccessibilityDisabled }} returnKeyType="default" diff --git a/apps/mobile/src/components/agents/chat-composer.tsx b/apps/mobile/src/components/agents/chat-composer.tsx index 85e035ea7fd..2e9efc5726c 100644 --- a/apps/mobile/src/components/agents/chat-composer.tsx +++ b/apps/mobile/src/components/agents/chat-composer.tsx @@ -7,14 +7,16 @@ import * as Haptics from 'expo-haptics'; import { useActionSheet } from '@expo/react-native-action-sheet'; import { type SlashCommandInfo } from '@kilocode/cloud-agent-sdk'; import { type RemoteCommandState } from '@kilocode/cloud-agent-sdk/remote-command-catalog'; -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { + AppState, Keyboard, - type LayoutChangeEvent, type TextInput, + type TextInputContentSizeChangeEvent, type TextStyle, View, } from 'react-native'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; import { toast } from 'sonner-native'; @@ -29,9 +31,13 @@ import { parseChatComposerSubmission, } from '@/components/agents/chat-composer-slash-commands'; import { executeChatComposerSubmission } from '@/components/agents/chat-composer-submission'; +import { + resolveComposerHeightOnTextChange, + resolveComposerInputHeight, + shouldEnableComposerInputScroll, +} from '@/components/agents/chat-composer-input-height'; import { showRemoteSessionExitConfirmation } from '@/components/agents/remote-session-exit-alert'; import { SlashCommandSuggestions } from '@/components/agents/slash-command-suggestions'; -import { useTextHeight } from '@/components/agents/use-text-height'; import { resolveChatComposerControlState } from '@/components/agents/chat-composer-input-state'; import { ChatComposerInputRow } from '@/components/agents/chat-composer-input-row'; import { BlurBar } from '@/components/ui/blur-bar'; @@ -44,6 +50,7 @@ import { } from '@/lib/agent-attachments/use-agent-attachment-upload'; import { type ModelOption } from '@/lib/hooks/use-available-models'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { resolveMessageInputAppStateTransition } from '@/lib/message-input-app-state'; import { cn } from '@/lib/utils'; import { useSharePrefill } from '@/lib/share-prefill'; import { createSubmitLock, type SubmitLock } from '@/lib/submit-lock'; @@ -54,11 +61,11 @@ import { settleVoiceInputBeforeSubmit } from '@/lib/voice-input/voice-input-subm const TEXT_INPUT_MAX_LINES = 5; const TEXT_INPUT_LINE_HEIGHT = 20; const TEXT_INPUT_VERTICAL_PADDING = 24; -const TEXT_INPUT_HORIZONTAL_PADDING = 32; const TEXT_INPUT_MIN_HEIGHT = TEXT_INPUT_LINE_HEIGHT + TEXT_INPUT_VERTICAL_PADDING; const TEXT_INPUT_MAX_HEIGHT = TEXT_INPUT_LINE_HEIGHT * TEXT_INPUT_MAX_LINES + TEXT_INPUT_VERTICAL_PADDING; const TEXT_INPUT_FONT_SIZE = 16; +const COMPOSER_FOCUS_RESTORE_DELAY_MS = 100; type ChatComposerProps = { onSend: ( @@ -122,9 +129,13 @@ export function ChatComposer({ const { showActionSheetWithOptions } = useActionSheet(); const textRef = useRef(''); const inputRef = useRef(null); + const inputFocusedRef = useRef(false); + const restoreFocusOnActiveRef = useRef(false); + const restoreFocusTimeoutRef = useRef | null>(null); + const lastContentHeightRef = useRef(null); const [hasText, setHasText] = useState(false); const [slashCommandInput, setSlashCommandInput] = useState(null); - const [inputWidth, setInputWidth] = useState(0); + const [inputHeight, setInputHeight] = useState(TEXT_INPUT_MIN_HEIGHT); const [isFocused, setIsFocused] = useState(false); const [isSending, setIsSending] = useState(false); @@ -153,15 +164,6 @@ export function ChatComposer({ }; const upload = useAgentAttachmentUpload({ organizationId }); - const measure = useTextHeight({ - minHeight: TEXT_INPUT_MIN_HEIGHT, - maxHeight: TEXT_INPUT_MAX_HEIGHT, - verticalPadding: TEXT_INPUT_VERTICAL_PADDING, - textContentWidth: inputWidth - TEXT_INPUT_HORIZONTAL_PADDING, - fontSize: TEXT_INPUT_FONT_SIZE, - lineHeight: TEXT_INPUT_LINE_HEIGHT, - }); - // Compute base composer disabled before the voice hook so voice can react to it. // `isStreaming` is intentionally NOT a composer gate (see // `chat-composer-input-state.ts`); the user must remain able to type and @@ -169,13 +171,43 @@ export function ChatComposer({ const toolbarDisabled = disabled || isSending; const voiceDisabled = toolbarDisabled; + function applyHeightFromTextChange(previousDraftLength: number, nextDraftLength: number) { + const nextHeight = resolveComposerHeightOnTextChange({ + previousDraftLength, + nextDraftLength, + lastContentHeight: lastContentHeightRef.current, + minHeight: TEXT_INPUT_MIN_HEIGHT, + maxHeight: TEXT_INPUT_MAX_HEIGHT, + }); + if (nextHeight !== null) { + setInputHeight(nextHeight); + } + if (nextDraftLength === 0) { + lastContentHeightRef.current = null; + } + } + function handleChangeText(value: string) { + const previousDraftLength = textRef.current.length; textRef.current = value; - measure.setText(value); + applyHeightFromTextChange(previousDraftLength, value.length); setHasText(value.trim().length > 0); setSlashCommandInput(getSlashCommandCandidate(value)); } + function handleContentSizeChange(event: TextInputContentSizeChangeEvent) { + const contentHeight = event.nativeEvent.contentSize.height; + lastContentHeightRef.current = contentHeight; + setInputHeight( + resolveComposerInputHeight({ + draftLength: textRef.current.length, + contentHeight, + minHeight: TEXT_INPUT_MIN_HEIGHT, + maxHeight: TEXT_INPUT_MAX_HEIGHT, + }) + ); + } + const { addCandidates, removeAttachment, retryAttachment } = upload; useSharePrefill({ @@ -219,11 +251,63 @@ export function ChatComposer({ // The strip must show share-prefilled files before the session resolves. const showAttachments = attachmentsEnabled || upload.attachments.length > 0; + useEffect(() => { + const clearRestoreFocusTimeout = () => { + if (restoreFocusTimeoutRef.current !== null) { + clearTimeout(restoreFocusTimeoutRef.current); + restoreFocusTimeoutRef.current = null; + } + }; + + const subscription = AppState.addEventListener('change', nextAppState => { + const transition = resolveMessageInputAppStateTransition({ + nextAppState, + restoreFocusOnActive: restoreFocusOnActiveRef.current, + wasFocused: inputFocusedRef.current, + }); + restoreFocusOnActiveRef.current = transition.restoreFocusOnActive; + + if (transition.shouldBlur) { + clearRestoreFocusTimeout(); + inputRef.current?.blur(); + } + + if (transition.shouldFocus && !disabled && !isSending) { + clearRestoreFocusTimeout(); + restoreFocusTimeoutRef.current = setTimeout(() => { + restoreFocusTimeoutRef.current = null; + inputRef.current?.focus(); + }, COMPOSER_FOCUS_RESTORE_DELAY_MS); + } + }); + + return () => { + subscription.remove(); + clearRestoreFocusTimeout(); + }; + }, [disabled, isSending]); + + const inputScrollable = shouldEnableComposerInputScroll(inputHeight, TEXT_INPUT_MAX_HEIGHT); + const dismissKeyboardPan = useMemo( + () => + // eslint-disable-next-line new-cap -- RNGH's gesture builder API is Gesture.Pan(). + Gesture.Pan() + .runOnJS(true) + .activeOffsetY(24) + .failOffsetX([-16, 16]) + .enabled(!inputScrollable) + .onStart(() => { + Keyboard.dismiss(); + }), + [inputScrollable] + ); + function clearDraft() { textRef.current = ''; setHasText(false); setSlashCommandInput(null); - measure.reset(); + lastContentHeightRef.current = null; + setInputHeight(TEXT_INPUT_MIN_HEIGHT); inputRef.current?.clear(); } @@ -304,15 +388,16 @@ export function ChatComposer({ if (sendLockRef.current.isLocked()) { return; } + const previousDraftLength = textRef.current.length; const value = `/${command.name} `; textRef.current = value; - measure.setText(value); setHasText(true); setSlashCommandInput(null); inputRef.current?.setNativeProps({ text: value, selection: { start: value.length, end: value.length }, }); + applyHeightFromTextChange(previousDraftLength, value.length); inputRef.current?.focus(); } @@ -335,11 +420,6 @@ export function ChatComposer({ void onStop?.(); } - function handleInputLayout(event: LayoutChangeEvent) { - const nextWidth = Math.max(Math.round(event.nativeEvent.layout.width), 0); - setInputWidth(current => (current === nextWidth ? current : nextWidth)); - } - const handleAddAttachment = useCallback(async () => { // Fire-and-forget: the upload hook owns its own progress + error toasts, // and the composer's send flow consults `upload.isUploading` / @@ -350,7 +430,7 @@ export function ChatComposer({ const textInputStyle: TextStyle = { color: colors.foreground, fontSize: TEXT_INPUT_FONT_SIZE, - height: measure.height, + height: inputHeight, includeFontPadding: false, lineHeight: TEXT_INPUT_LINE_HEIGHT, paddingHorizontal: 16, @@ -361,8 +441,6 @@ export function ChatComposer({ return ( - {measure.measureElement} - {control.showToolbar ? ( - { - void handleAddAttachment(); - }} - onChangeText={handleChangeText} - onInputBlur={() => { - setIsFocused(false); - }} - onInputFocus={() => { - setIsFocused(true); - }} - onInputLayout={handleInputLayout} - onStop={handleStop} - onSubmit={() => { - void submit(); - }} - onToggleVoice={() => { - void voiceInput.toggle(); - }} - paperclipDisabled={control.paperclipDisabled} - placeholder={placeholder} - textInputStyle={textInputStyle} - voiceDisabled={control.voiceDisabled} - voiceInputAvailable={voiceInput.available} - voiceInputStatus={voiceInput.status} - /> + + + { + void handleAddAttachment(); + }} + onChangeText={handleChangeText} + onContentSizeChange={handleContentSizeChange} + onInputBlur={() => { + inputFocusedRef.current = false; + setIsFocused(false); + }} + onInputFocus={() => { + inputFocusedRef.current = true; + setIsFocused(true); + }} + onStop={handleStop} + onSubmit={() => { + void submit(); + }} + onToggleVoice={() => { + void voiceInput.toggle(); + }} + paperclipDisabled={control.paperclipDisabled} + placeholder={placeholder} + textInputStyle={textInputStyle} + voiceDisabled={control.voiceDisabled} + voiceInputAvailable={voiceInput.available} + voiceInputStatus={voiceInput.status} + /> + + ); } diff --git a/apps/mobile/src/components/kilo-chat/message-input-content.tsx b/apps/mobile/src/components/kilo-chat/message-input-content.tsx index a73d6cfe614..024e4859b7b 100644 --- a/apps/mobile/src/components/kilo-chat/message-input-content.tsx +++ b/apps/mobile/src/components/kilo-chat/message-input-content.tsx @@ -6,7 +6,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useTextHeight } from '@/components/agents/use-text-height'; import { applyVoiceDraftToInput } from '@/lib/voice-input/voice-input-draft'; import { useVoiceInput } from '@/lib/voice-input/use-voice-input'; -import { resolveMessageInputAppStateTransition } from './message-input-app-state'; +import { resolveMessageInputAppStateTransition } from '@/lib/message-input-app-state'; import { editableAttachmentToPreviewRow, resolveMessageInputSendDisabled, diff --git a/apps/mobile/src/components/kilo-chat/message-input-app-state.test.ts b/apps/mobile/src/lib/message-input-app-state.test.ts similarity index 100% rename from apps/mobile/src/components/kilo-chat/message-input-app-state.test.ts rename to apps/mobile/src/lib/message-input-app-state.test.ts diff --git a/apps/mobile/src/components/kilo-chat/message-input-app-state.ts b/apps/mobile/src/lib/message-input-app-state.ts similarity index 100% rename from apps/mobile/src/components/kilo-chat/message-input-app-state.ts rename to apps/mobile/src/lib/message-input-app-state.ts From 1add09b4b17269a1348ef723553aeabfd7735864 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Jul 2026 14:32:13 +0200 Subject: [PATCH 07/17] =?UTF-8?q?docs(workflow):=20learning=20=E2=80=94=20?= =?UTF-8?q?CLI=20sessions=20read-only=20until=20TUI=20/remote=20relays=20t?= =?UTF-8?q?hem?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...cli-sessions-read-only-until-tui-remote.md | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .kilo_workflow/learnings/mobile-e2e-cli-sessions-read-only-until-tui-remote.md diff --git a/.kilo_workflow/learnings/mobile-e2e-cli-sessions-read-only-until-tui-remote.md b/.kilo_workflow/learnings/mobile-e2e-cli-sessions-read-only-until-tui-remote.md new file mode 100644 index 00000000000..2937d4dfa95 --- /dev/null +++ b/.kilo_workflow/learnings/mobile-e2e-cli-sessions-read-only-until-tui-remote.md @@ -0,0 +1,23 @@ +# mobile-e2e: CLI sessions show "This is a read-only session" in the app until the TUI's own /remote relays them + +Symptom: on a session page opened from the Agents list (or deep link), the +composer is absent and the banner "This is a read-only session" shows — for +completed `remote-cli.sh exec run` sessions AND for a session the live TUI +just created, even while `remote-cli.sh exec remote` printed +"Remote connection enabled." + +Cause: the app resolves a session writable ('remote') only when it appears in +`activeSessions.list`, which is fed by the OWNING process's relay heartbeat +(`mobile-session-manager.ts` resolveSession → `cliSessionsV2.get` + +`activeSessions.list`). A standalone `exec remote` relay is a separate kilo +process and does not mark sessions held by the TUI (or already-finished +`exec run` sessions) as active. The app also caches the resolved type at +screen-open time, so a session that becomes active later still shows +read-only until re-opened. + +Fix: send `/remote` to the RUNNING TUI pane (`tmux send-keys -t +kilo-e2e-cli-:0 "/remote" Enter`), confirm the `◆ Remote` indicator in +the pane, then re-open the session in the app (deep link again). The composer +mounts and the banner disappears. Composer-bearing E2E cases (typing, slash +menu, swipe-dismiss, growth) require this state; read-only sessions suffice +only for view/long-press/sheet cases. From 7c6a8bbb52d07d2ba8e93a62f00ec83556e42d30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Jul 2026 17:19:13 +0200 Subject: [PATCH 08/17] fix(mobile): restore mirror-Text composer growth (item 26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iOS Fabric emits TextInput onContentSizeChange only from updateLayoutMetrics, so a fixed-height uncontrolled input never reports growth past the initial layout — the onContentSizeChange reducers could never grow the composer. Restore the mirror-Text measurement (useTextHeight), which follows JS text state deterministically and was iOS-proven on this composer. Keep the pure scroll-threshold helper; AppState focus restore (item 19) and swipe-down dismiss (item 22) unchanged. --- .../agents/chat-composer-input-height.test.ts | 131 +----------------- .../agents/chat-composer-input-height.ts | 65 +-------- .../agents/chat-composer-input-row.tsx | 14 +- .../src/components/agents/chat-composer.tsx | 75 ++++------ 4 files changed, 39 insertions(+), 246 deletions(-) diff --git a/apps/mobile/src/components/agents/chat-composer-input-height.test.ts b/apps/mobile/src/components/agents/chat-composer-input-height.test.ts index 656dd189c8e..62870a3b7c6 100644 --- a/apps/mobile/src/components/agents/chat-composer-input-height.test.ts +++ b/apps/mobile/src/components/agents/chat-composer-input-height.test.ts @@ -1,83 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { - clampComposerInputHeight, - resolveComposerHeightOnTextChange, - resolveComposerInputHeight, - shouldEnableComposerInputScroll, -} from './chat-composer-input-height'; +import { shouldEnableComposerInputScroll } from './chat-composer-input-height'; const MIN = 44; const MAX = 124; -describe('clampComposerInputHeight', () => { - it('clamps growth to min and max and rounds fractional heights up', () => { - expect(clampComposerInputHeight(20, { minHeight: MIN, maxHeight: MAX })).toBe(MIN); - expect(clampComposerInputHeight(64.1, { minHeight: MIN, maxHeight: MAX })).toBe(65); - expect(clampComposerInputHeight(200, { minHeight: MIN, maxHeight: MAX })).toBe(MAX); - expect(clampComposerInputHeight(MIN, { minHeight: MIN, maxHeight: MAX })).toBe(MIN); - expect(clampComposerInputHeight(MAX, { minHeight: MIN, maxHeight: MAX })).toBe(MAX); - }); -}); - -describe('resolveComposerInputHeight', () => { - it('always returns minHeight when the draft is empty (stale after-clear event)', () => { - expect( - resolveComposerInputHeight({ - draftLength: 0, - contentHeight: 200, - minHeight: MIN, - maxHeight: MAX, - }) - ).toBe(MIN); - }); - - it('clamps non-empty drafts to the latest contentSize within min/max', () => { - expect( - resolveComposerInputHeight({ - draftLength: 10, - contentHeight: 64, - minHeight: MIN, - maxHeight: MAX, - }) - ).toBe(64); - expect( - resolveComposerInputHeight({ - draftLength: 10, - contentHeight: 84.2, - minHeight: MIN, - maxHeight: MAX, - }) - ).toBe(85); - expect( - resolveComposerInputHeight({ - draftLength: 500, - contentHeight: 300, - minHeight: MIN, - maxHeight: MAX, - }) - ).toBe(MAX); - }); - - it('resolves a shrinking draft to the latest smaller contentSize', () => { - const tall = resolveComposerInputHeight({ - draftLength: 80, - contentHeight: 104, - minHeight: MIN, - maxHeight: MAX, - }); - const short = resolveComposerInputHeight({ - draftLength: 5, - contentHeight: 44, - minHeight: MIN, - maxHeight: MAX, - }); - expect(tall).toBe(104); - expect(short).toBe(MIN); - expect(short).toBeLessThan(tall); - }); -}); - describe('shouldEnableComposerInputScroll', () => { it('is true at or above max and false below', () => { expect(shouldEnableComposerInputScroll(MAX, MAX)).toBe(true); @@ -86,59 +13,3 @@ describe('shouldEnableComposerInputScroll', () => { expect(shouldEnableComposerInputScroll(MIN, MAX)).toBe(false); }); }); - -describe('resolveComposerHeightOnTextChange', () => { - it('returns minHeight when the next draft is empty', () => { - expect( - resolveComposerHeightOnTextChange({ - previousDraftLength: 12, - nextDraftLength: 0, - lastContentHeight: 104, - minHeight: MIN, - maxHeight: MAX, - }) - ).toBe(MIN); - }); - - it('repairs empty→non-empty using the last measured content height (voice ordering)', () => { - expect( - resolveComposerHeightOnTextChange({ - previousDraftLength: 0, - nextDraftLength: 40, - lastContentHeight: 84, - minHeight: MIN, - maxHeight: MAX, - }) - ).toBe(84); - expect( - resolveComposerHeightOnTextChange({ - previousDraftLength: 0, - nextDraftLength: 40, - lastContentHeight: 300, - minHeight: MIN, - maxHeight: MAX, - }) - ).toBe(MAX); - }); - - it('returns null when there is no height decision to make (leave to native event)', () => { - expect( - resolveComposerHeightOnTextChange({ - previousDraftLength: 5, - nextDraftLength: 6, - lastContentHeight: 64, - minHeight: MIN, - maxHeight: MAX, - }) - ).toBeNull(); - expect( - resolveComposerHeightOnTextChange({ - previousDraftLength: 0, - nextDraftLength: 3, - lastContentHeight: null, - minHeight: MIN, - maxHeight: MAX, - }) - ).toBeNull(); - }); -}); diff --git a/apps/mobile/src/components/agents/chat-composer-input-height.ts b/apps/mobile/src/components/agents/chat-composer-input-height.ts index e38c9893300..4b2de64d563 100644 --- a/apps/mobile/src/components/agents/chat-composer-input-height.ts +++ b/apps/mobile/src/components/agents/chat-composer-input-height.ts @@ -1,68 +1,9 @@ /** - * Pure height reducers for the agents chat composer TextInput. - * - * RN multiline TextInput `contentSize.height` already includes the - * `paddingVertical` inset, so style height is a ceil+clamp of that value. - * Staleness rules: - * - empty draft always resolves to minHeight (guards after-clear stale events) - * - voice-draft ordering can fire content-size while textRef still holds the - * old empty draft; `resolveComposerHeightOnTextChange` repairs empty→non-empty - * using the last measured content height + * Shared "scrollable at max height" threshold for the agents chat composer. + * Mirror-Text (`useTextHeight`) owns growth measurement; this module keeps the + * gate used by the row's `scrollEnabled` and the composer's swipe-down pan. */ -export function clampComposerInputHeight( - contentHeight: number, - { minHeight, maxHeight }: { minHeight: number; maxHeight: number } -): number { - return Math.min(Math.max(Math.ceil(contentHeight), minHeight), maxHeight); -} - -export function resolveComposerInputHeight({ - draftLength, - contentHeight, - minHeight, - maxHeight, -}: { - draftLength: number; - contentHeight: number; - minHeight: number; - maxHeight: number; -}): number { - if (draftLength === 0) { - return minHeight; - } - return clampComposerInputHeight(contentHeight, { minHeight, maxHeight }); -} - -/** - * Optimistic height after a JS text change. - * - empty next draft → minHeight - * - empty→non-empty with a prior content measurement → clamp that measurement - * (voice-ordering repair when setNativeProps races ahead of onChangeText) - * - otherwise null (leave height to the next native onContentSizeChange) - */ -export function resolveComposerHeightOnTextChange({ - previousDraftLength, - nextDraftLength, - lastContentHeight, - minHeight, - maxHeight, -}: { - previousDraftLength: number; - nextDraftLength: number; - lastContentHeight: number | null; - minHeight: number; - maxHeight: number; -}): number | null { - if (nextDraftLength === 0) { - return minHeight; - } - if (previousDraftLength === 0 && nextDraftLength > 0 && lastContentHeight !== null) { - return clampComposerInputHeight(lastContentHeight, { minHeight, maxHeight }); - } - return null; -} - export function shouldEnableComposerInputScroll(height: number, maxHeight: number): boolean { return height >= maxHeight; } 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 8bdf85ef398..b68346913de 100644 --- a/apps/mobile/src/components/agents/chat-composer-input-row.tsx +++ b/apps/mobile/src/components/agents/chat-composer-input-row.tsx @@ -2,9 +2,9 @@ import { ArrowUp, Paperclip, Square } from 'lucide-react-native'; import { type RefObject } from 'react'; import { ActivityIndicator, + type LayoutChangeEvent, Pressable, TextInput, - type TextInputContentSizeChangeEvent, type TextStyle, View, } from 'react-native'; @@ -24,16 +24,16 @@ type ChatComposerInputRowProps = { disabled: boolean; inputAccessibilityDisabled: boolean; inputEditable: boolean; - inputHeight: number; inputRef: RefObject; isSending: boolean; isStreaming: boolean; maxInputHeight: number; + measureHeight: number; onAddAttachment: () => void; onChangeText: (text: string) => void; - onContentSizeChange: (event: TextInputContentSizeChangeEvent) => void; onInputBlur: () => void; onInputFocus: () => void; + onInputLayout: (event: LayoutChangeEvent) => void; onStop: () => void; onSubmit: () => void; onToggleVoice: () => void; @@ -58,16 +58,16 @@ export function ChatComposerInputRow({ disabled, inputAccessibilityDisabled, inputEditable, - inputHeight, inputRef, isSending, isStreaming, maxInputHeight, + measureHeight, onAddAttachment, onChangeText, - onContentSizeChange, onInputBlur, onInputFocus, + onInputLayout, onStop, onSubmit, onToggleVoice, @@ -79,7 +79,7 @@ export function ChatComposerInputRow({ voiceInputStatus, }: Readonly) { const colors = useThemeColors(); - const inputScrollable = shouldEnableComposerInputScroll(inputHeight, maxInputHeight); + const inputScrollable = shouldEnableComposerInputScroll(measureHeight, maxInputHeight); return ( @@ -105,6 +105,7 @@ export function ChatComposerInputRow({ 'mx-2.5 flex-1 overflow-hidden rounded-[20px] border border-border bg-card', !inputEditable && 'opacity-50' )} + onLayout={onInputLayout} > | null>(null); - const lastContentHeightRef = useRef(null); const [hasText, setHasText] = useState(false); const [slashCommandInput, setSlashCommandInput] = useState(null); - const [inputHeight, setInputHeight] = useState(TEXT_INPUT_MIN_HEIGHT); + const [inputWidth, setInputWidth] = useState(0); const [isFocused, setIsFocused] = useState(false); const [isSending, setIsSending] = useState(false); @@ -164,6 +161,15 @@ export function ChatComposer({ }; const upload = useAgentAttachmentUpload({ organizationId }); + const measure = useTextHeight({ + minHeight: TEXT_INPUT_MIN_HEIGHT, + maxHeight: TEXT_INPUT_MAX_HEIGHT, + verticalPadding: TEXT_INPUT_VERTICAL_PADDING, + textContentWidth: inputWidth - TEXT_INPUT_HORIZONTAL_PADDING, + fontSize: TEXT_INPUT_FONT_SIZE, + lineHeight: TEXT_INPUT_LINE_HEIGHT, + }); + // Compute base composer disabled before the voice hook so voice can react to it. // `isStreaming` is intentionally NOT a composer gate (see // `chat-composer-input-state.ts`); the user must remain able to type and @@ -171,43 +177,13 @@ export function ChatComposer({ const toolbarDisabled = disabled || isSending; const voiceDisabled = toolbarDisabled; - function applyHeightFromTextChange(previousDraftLength: number, nextDraftLength: number) { - const nextHeight = resolveComposerHeightOnTextChange({ - previousDraftLength, - nextDraftLength, - lastContentHeight: lastContentHeightRef.current, - minHeight: TEXT_INPUT_MIN_HEIGHT, - maxHeight: TEXT_INPUT_MAX_HEIGHT, - }); - if (nextHeight !== null) { - setInputHeight(nextHeight); - } - if (nextDraftLength === 0) { - lastContentHeightRef.current = null; - } - } - function handleChangeText(value: string) { - const previousDraftLength = textRef.current.length; textRef.current = value; - applyHeightFromTextChange(previousDraftLength, value.length); + measure.setText(value); setHasText(value.trim().length > 0); setSlashCommandInput(getSlashCommandCandidate(value)); } - function handleContentSizeChange(event: TextInputContentSizeChangeEvent) { - const contentHeight = event.nativeEvent.contentSize.height; - lastContentHeightRef.current = contentHeight; - setInputHeight( - resolveComposerInputHeight({ - draftLength: textRef.current.length, - contentHeight, - minHeight: TEXT_INPUT_MIN_HEIGHT, - maxHeight: TEXT_INPUT_MAX_HEIGHT, - }) - ); - } - const { addCandidates, removeAttachment, retryAttachment } = upload; useSharePrefill({ @@ -287,7 +263,7 @@ export function ChatComposer({ }; }, [disabled, isSending]); - const inputScrollable = shouldEnableComposerInputScroll(inputHeight, TEXT_INPUT_MAX_HEIGHT); + const inputScrollable = shouldEnableComposerInputScroll(measure.height, TEXT_INPUT_MAX_HEIGHT); const dismissKeyboardPan = useMemo( () => // eslint-disable-next-line new-cap -- RNGH's gesture builder API is Gesture.Pan(). @@ -306,8 +282,7 @@ export function ChatComposer({ textRef.current = ''; setHasText(false); setSlashCommandInput(null); - lastContentHeightRef.current = null; - setInputHeight(TEXT_INPUT_MIN_HEIGHT); + measure.reset(); inputRef.current?.clear(); } @@ -388,16 +363,15 @@ export function ChatComposer({ if (sendLockRef.current.isLocked()) { return; } - const previousDraftLength = textRef.current.length; const value = `/${command.name} `; textRef.current = value; + measure.setText(value); setHasText(true); setSlashCommandInput(null); inputRef.current?.setNativeProps({ text: value, selection: { start: value.length, end: value.length }, }); - applyHeightFromTextChange(previousDraftLength, value.length); inputRef.current?.focus(); } @@ -420,6 +394,11 @@ export function ChatComposer({ void onStop?.(); } + function handleInputLayout(event: LayoutChangeEvent) { + const nextWidth = Math.max(Math.round(event.nativeEvent.layout.width), 0); + setInputWidth(current => (current === nextWidth ? current : nextWidth)); + } + const handleAddAttachment = useCallback(async () => { // Fire-and-forget: the upload hook owns its own progress + error toasts, // and the composer's send flow consults `upload.isUploading` / @@ -430,7 +409,7 @@ export function ChatComposer({ const textInputStyle: TextStyle = { color: colors.foreground, fontSize: TEXT_INPUT_FONT_SIZE, - height: inputHeight, + height: measure.height, includeFontPadding: false, lineHeight: TEXT_INPUT_LINE_HEIGHT, paddingHorizontal: 16, @@ -441,6 +420,8 @@ export function ChatComposer({ return ( + {measure.measureElement} + {control.showToolbar ? ( { void handleAddAttachment(); }} onChangeText={handleChangeText} - onContentSizeChange={handleContentSizeChange} onInputBlur={() => { inputFocusedRef.current = false; setIsFocused(false); @@ -502,6 +482,7 @@ export function ChatComposer({ inputFocusedRef.current = true; setIsFocused(true); }} + onInputLayout={handleInputLayout} onStop={handleStop} onSubmit={() => { void submit(); From e43025a34ddebc751d998ff664eeb6e8c0648a4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Jul 2026 18:48:46 +0200 Subject: [PATCH 09/17] fix(mobile): dismiss keyboard on Android EditText swipe-down (item 22) ReactEditText requests disallow-intercept on every ACTION_DOWN, so the wrapper's RNGH pan never sees the stream start from the focused input. Track bubbled JS touch moves on the composer host View and dismiss at the same 24px threshold, claiming the responder there. Android-only; the iOS RNGH path is unchanged. --- .../src/components/agents/chat-composer.tsx | 83 ++++++++++++++++++- 1 file changed, 80 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/components/agents/chat-composer.tsx b/apps/mobile/src/components/agents/chat-composer.tsx index 3f6b60dd3af..3f5c354dce3 100644 --- a/apps/mobile/src/components/agents/chat-composer.tsx +++ b/apps/mobile/src/components/agents/chat-composer.tsx @@ -10,8 +10,10 @@ import { type RemoteCommandState } from '@kilocode/cloud-agent-sdk/remote-comman import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AppState, + type GestureResponderEvent, Keyboard, type LayoutChangeEvent, + Platform, type TextInput, type TextStyle, View, @@ -64,6 +66,16 @@ const TEXT_INPUT_MAX_HEIGHT = TEXT_INPUT_LINE_HEIGHT * TEXT_INPUT_MAX_LINES + TEXT_INPUT_VERTICAL_PADDING; const TEXT_INPUT_FONT_SIZE = 16; const COMPOSER_FOCUS_RESTORE_DELAY_MS = 100; +/** Match RNGH pan activeOffsetY / failOffsetX so Android JS path and iOS pan agree. */ +const DISMISS_KEYBOARD_ACTIVE_OFFSET_Y = 24; +const DISMISS_KEYBOARD_FAIL_OFFSET_X = 16; + +type AndroidDismissKeyboardGesture = { + startPageX: number; + startPageY: number; + dismissed: boolean; + failed: boolean; +}; type ChatComposerProps = { onSend: ( @@ -269,8 +281,8 @@ export function ChatComposer({ // eslint-disable-next-line new-cap -- RNGH's gesture builder API is Gesture.Pan(). Gesture.Pan() .runOnJS(true) - .activeOffsetY(24) - .failOffsetX([-16, 16]) + .activeOffsetY(DISMISS_KEYBOARD_ACTIVE_OFFSET_Y) + .failOffsetX([-DISMISS_KEYBOARD_FAIL_OFFSET_X, DISMISS_KEYBOARD_FAIL_OFFSET_X]) .enabled(!inputScrollable) .onStart(() => { Keyboard.dismiss(); @@ -278,6 +290,71 @@ export function ChatComposer({ [inputScrollable] ); + // Android: ReactEditText.requestDisallowInterceptTouchEvent(true) on ACTION_DOWN + // (ReactEditText.kt) blocks RNGH Pan from seeing the stream start when the drag + // begins on the focused EditText. JS onTouch* still bubbles to this host View + // (BaseViewConfig.android.js topTouchStart/Move), so track pageY here and dismiss + // once dy crosses the same threshold as the pan. iOS keeps the RNGH path only. + const androidDismissGestureRef = useRef(null); + + const resetAndroidDismissGesture = useCallback(() => { + androidDismissGestureRef.current = null; + }, []); + + const handleAndroidDismissTouchStart = useCallback((event: GestureResponderEvent) => { + androidDismissGestureRef.current = { + startPageX: event.nativeEvent.pageX, + startPageY: event.nativeEvent.pageY, + dismissed: false, + failed: false, + }; + }, []); + + const tryAndroidDismissKeyboardFromTouchMove = useCallback( + (event: GestureResponderEvent): boolean => { + const gesture = androidDismissGestureRef.current; + if (!gesture || gesture.dismissed || gesture.failed || inputScrollable) { + return false; + } + const dx = event.nativeEvent.pageX - gesture.startPageX; + const dy = event.nativeEvent.pageY - gesture.startPageY; + if (Math.abs(dx) > DISMISS_KEYBOARD_FAIL_OFFSET_X) { + gesture.failed = true; + return false; + } + if (dy < DISMISS_KEYBOARD_ACTIVE_OFFSET_Y) { + return false; + } + gesture.dismissed = true; + Keyboard.dismiss(); + return true; + }, + [inputScrollable] + ); + + const handleAndroidDismissTouchMove = useCallback( + (event: GestureResponderEvent) => { + tryAndroidDismissKeyboardFromTouchMove(event); + }, + [tryAndroidDismissKeyboardFromTouchMove] + ); + + const shouldCaptureAndroidDismissMove = useCallback( + (event: GestureResponderEvent) => tryAndroidDismissKeyboardFromTouchMove(event), + [tryAndroidDismissKeyboardFromTouchMove] + ); + + const androidDismissKeyboardTouchProps = + Platform.OS === 'android' + ? { + onTouchStart: handleAndroidDismissTouchStart, + onTouchMove: handleAndroidDismissTouchMove, + onTouchEnd: resetAndroidDismissGesture, + onTouchCancel: resetAndroidDismissGesture, + onMoveShouldSetResponderCapture: shouldCaptureAndroidDismissMove, + } + : undefined; + function clearDraft() { textRef.current = ''; setHasText(false); @@ -458,7 +535,7 @@ export function ChatComposer({ - + Date: Thu, 30 Jul 2026 20:20:52 +0200 Subject: [PATCH 10/17] fix(mobile): evaluate composer AppState refocus gate at fire time (item 19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The effect's [disabled, isSending] deps let any flip re-run the effect and clear the pending 100ms focus restore after 'active' had already consumed the restore flag — Android intermittently (5/16 cycles) never restored the composer keyboard. Subscribe once, schedule unconditionally on shouldFocus, and gate on live refs at fire time. --- .../src/components/agents/chat-composer.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/components/agents/chat-composer.tsx b/apps/mobile/src/components/agents/chat-composer.tsx index 3f5c354dce3..ee4c73a7045 100644 --- a/apps/mobile/src/components/agents/chat-composer.tsx +++ b/apps/mobile/src/components/agents/chat-composer.tsx @@ -147,6 +147,13 @@ export function ChatComposer({ const [inputWidth, setInputWidth] = useState(0); const [isFocused, setIsFocused] = useState(false); const [isSending, setIsSending] = useState(false); + // Live gates for the AppState focus-restore timeout: read at fire time so a + // disabled/isSending flip cannot re-run the subscription effect and cancel a + // pending restore after the restore flag was already consumed. + const disabledRef = useRef(disabled); + const isSendingRef = useRef(isSending); + disabledRef.current = disabled; + isSendingRef.current = isSending; // Single send-admission authority. `settleVoiceInputBeforeSubmit` owns // this lock for the full voice-settle + asynchronous send sequence, and @@ -260,10 +267,16 @@ export function ChatComposer({ inputRef.current?.blur(); } - if (transition.shouldFocus && !disabled && !isSending) { + // Schedule unconditionally when the transition asks for focus; gate at + // fire time via refs so disabled/isSending flips never cancel a pending + // restore after the flag was consumed. + if (transition.shouldFocus) { clearRestoreFocusTimeout(); restoreFocusTimeoutRef.current = setTimeout(() => { restoreFocusTimeoutRef.current = null; + if (disabledRef.current || isSendingRef.current) { + return; + } inputRef.current?.focus(); }, COMPOSER_FOCUS_RESTORE_DELAY_MS); } @@ -273,7 +286,7 @@ export function ChatComposer({ subscription.remove(); clearRestoreFocusTimeout(); }; - }, [disabled, isSending]); + }, []); const inputScrollable = shouldEnableComposerInputScroll(measure.height, TEXT_INPUT_MAX_HEIGHT); const dismissKeyboardPan = useMemo( From feaae2224f4b2fcbca4698a87c9439bee00247a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 31 Jul 2026 09:20:04 +0200 Subject: [PATCH 11/17] chore: drop .kilo_workflow remnants after main merge Main deleted all of .kilo_workflow in #4909; the workflow now lives in a private repo. Remove the branch learning that survived the merge (already ported there) so the PR carries zero workflow diffs. --- ...cli-sessions-read-only-until-tui-remote.md | 23 ------------------- 1 file changed, 23 deletions(-) delete mode 100644 .kilo_workflow/learnings/mobile-e2e-cli-sessions-read-only-until-tui-remote.md diff --git a/.kilo_workflow/learnings/mobile-e2e-cli-sessions-read-only-until-tui-remote.md b/.kilo_workflow/learnings/mobile-e2e-cli-sessions-read-only-until-tui-remote.md deleted file mode 100644 index 2937d4dfa95..00000000000 --- a/.kilo_workflow/learnings/mobile-e2e-cli-sessions-read-only-until-tui-remote.md +++ /dev/null @@ -1,23 +0,0 @@ -# mobile-e2e: CLI sessions show "This is a read-only session" in the app until the TUI's own /remote relays them - -Symptom: on a session page opened from the Agents list (or deep link), the -composer is absent and the banner "This is a read-only session" shows — for -completed `remote-cli.sh exec run` sessions AND for a session the live TUI -just created, even while `remote-cli.sh exec remote` printed -"Remote connection enabled." - -Cause: the app resolves a session writable ('remote') only when it appears in -`activeSessions.list`, which is fed by the OWNING process's relay heartbeat -(`mobile-session-manager.ts` resolveSession → `cliSessionsV2.get` + -`activeSessions.list`). A standalone `exec remote` relay is a separate kilo -process and does not mark sessions held by the TUI (or already-finished -`exec run` sessions) as active. The app also caches the resolved type at -screen-open time, so a session that becomes active later still shows -read-only until re-opened. - -Fix: send `/remote` to the RUNNING TUI pane (`tmux send-keys -t -kilo-e2e-cli-:0 "/remote" Enter`), confirm the `◆ Remote` indicator in -the pane, then re-open the session in the app (deep link again). The composer -mounts and the banner disappears. Composer-bearing E2E cases (typing, slash -menu, swipe-dismiss, growth) require this state; read-only sessions suffice -only for view/long-press/sheet cases. From 2b7cfa90aafae4c7061a6ecf631c9e8c92f4e878 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 31 Jul 2026 10:02:29 +0200 Subject: [PATCH 12/17] fix(mobile): restore copy path for tool output and reasoning (item 13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In-bubble native selection was disabled so the iOS callout cannot float over the details sheet, but both remaining copy paths (details-sheet Copy message, a11y rotor) collect only text parts — tool commands/outputs/ errors and reasoning lost their only copy route. Extend collectCopyableText to serialize reasoning and tool parts; parts with no payload yet stay excluded. Addresses Kilobot thread on mono-scroll-block.tsx. --- .../agents/collect-copyable-text.ts | 77 ++++++++++++++++++- .../agents/message-copy-text.test.ts | 64 +++++++++++++++ 2 files changed, 137 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/components/agents/collect-copyable-text.ts b/apps/mobile/src/components/agents/collect-copyable-text.ts index 84a3f5ff9c8..afaae05f890 100644 --- a/apps/mobile/src/components/agents/collect-copyable-text.ts +++ b/apps/mobile/src/components/agents/collect-copyable-text.ts @@ -1,5 +1,20 @@ type TextPartLike = { type: string; text: string; synthetic?: boolean }; -type CopyablePart = TextPartLike | { type: string }; + +type ReasoningPartLike = { type: string; text: string }; + +type ToolStateLike = + | { status: 'pending'; input: Record } + | { status: 'running'; input: Record; title?: string } + | { status: 'completed'; input: Record; output: string; title: string } + | { status: 'error'; input: Record; error: string }; + +type ToolPartLike = { + type: string; + tool: string; + state: ToolStateLike; +}; + +type CopyablePart = TextPartLike | ReasoningPartLike | ToolPartLike | { type: string }; type CopyableMessage = { parts: readonly CopyablePart[]; @@ -9,14 +24,68 @@ function isTextPartLike(part: CopyablePart): part is TextPartLike { return part.type === 'text' && typeof (part as TextPartLike).text === 'string'; } +function isReasoningPartLike(part: CopyablePart): part is ReasoningPartLike { + return part.type === 'reasoning' && typeof (part as ReasoningPartLike).text === 'string'; +} + +function isToolPartLike(part: CopyablePart): part is ToolPartLike { + return ( + part.type === 'tool' && + typeof (part as ToolPartLike).tool === 'string' && + typeof (part as ToolPartLike).state === 'object' && + typeof (part as ToolPartLike).state.status === 'string' + ); +} + function isSnapshotProgressText(part: TextPartLike): boolean { return part.synthetic === true && part.text.includes('Initializing snapshot'); } +function formatToolInput(input: Record): string { + const keys = Object.keys(input); + if (keys.length === 0) { + return ''; + } + try { + return JSON.stringify(input, null, 2); + } catch { + return '[unserializable input]'; + } +} + +function collectToolPartText(part: ToolPartLike): string { + const payload: string[] = []; + const inputText = formatToolInput(part.state.input); + if (inputText) { + payload.push(inputText); + } + if (part.state.status === 'completed' && part.state.output) { + payload.push(part.state.output); + } + if (part.state.status === 'error' && part.state.error) { + payload.push(`Error: ${part.state.error}`); + } + // No payload yet (pending/running without output): nothing worth copying. + if (payload.length === 0) { + return ''; + } + return [part.tool, ...payload].join('\n'); +} + export function collectCopyableText(message: CopyableMessage): string { return message.parts - .filter(isTextPartLike) - .filter(part => !isSnapshotProgressText(part)) - .map(part => part.text) + .map(part => { + if (isTextPartLike(part)) { + return isSnapshotProgressText(part) ? '' : part.text; + } + if (isReasoningPartLike(part)) { + return part.text; + } + if (isToolPartLike(part)) { + return collectToolPartText(part); + } + return ''; + }) + .filter(text => text.length > 0) .join('\n\n'); } diff --git a/apps/mobile/src/components/agents/message-copy-text.test.ts b/apps/mobile/src/components/agents/message-copy-text.test.ts index f57c8cbfef4..7a7bfe16d86 100644 --- a/apps/mobile/src/components/agents/message-copy-text.test.ts +++ b/apps/mobile/src/components/agents/message-copy-text.test.ts @@ -48,4 +48,68 @@ describe('collectCopyableText', () => { }; expect(collectCopyableText(message)).toBe('User typed this'); }); + + it('includes reasoning text parts', () => { + const message = { + parts: [ + { type: 'text', text: 'First' }, + { type: 'reasoning', text: 'I should think step by step.' }, + { type: 'text', text: 'Second' }, + ], + }; + expect(collectCopyableText(message)).toBe('First\n\nI should think step by step.\n\nSecond'); + }); + + it('includes a bash tool part with command and output', () => { + const message = { + parts: [ + { + type: 'tool', + tool: 'bash', + state: { + status: 'completed', + input: { command: 'ls -la', description: 'List files' }, + output: 'total 0\ndrwxr-xr-x', + }, + }, + ], + }; + expect(collectCopyableText(message)).toBe( + 'bash\n{\n "command": "ls -la",\n "description": "List files"\n}\ntotal 0\ndrwxr-xr-x' + ); + }); + + it('includes a tool part with an error and no output', () => { + const message = { + parts: [ + { + type: 'tool', + tool: 'read', + state: { + status: 'error', + input: { filePath: '/missing.txt' }, + error: 'No such file or directory', + }, + }, + ], + }; + expect(collectCopyableText(message)).toBe( + 'read\n{\n "filePath": "/missing.txt"\n}\nError: No such file or directory' + ); + }); + + it('skips a tool part with no input, output, or error', () => { + const message = { + parts: [ + { type: 'text', text: 'Hello' }, + { + type: 'tool', + tool: 'pending_tool', + state: { status: 'pending', input: {} }, + }, + { type: 'text', text: 'Goodbye' }, + ], + }; + expect(collectCopyableText(message)).toBe('Hello\n\nGoodbye'); + }); }); From cfcb4f2bd45ed0ca18e09b24945bffc77f7be91e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 31 Jul 2026 10:02:39 +0200 Subject: [PATCH 13/17] fix(mobile): bound child-sheet mount and keep-awake to their lifetimes Child sheet (item 21): the mounted-during-close pattern exists only to let the native dismiss animation play, but the sheet identity lived on for the rest of the screen, running child-transcript scans on every streaming render. Release it 350ms after close (covers the animation; the white flash is iOS-specific); a reopen before the release wins and a visible sheet is never released. Keep-awake (item 15): gate on useIsFocused so a screen covered by a pushed route stops holding the OS idle timer, and scope the expo tag by session id so stacked session screens cannot release each other's wake lock. Addresses two Kilobot threads on session-detail-content.tsx + child-session-sheet-state.ts. --- .../agents/child-session-sheet-state.test.ts | 25 +++++++++++ .../agents/child-session-sheet-state.ts | 15 +++++++ .../agents/session-detail-content.tsx | 44 ++++++++++++++++--- 3 files changed, 77 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/components/agents/child-session-sheet-state.test.ts b/apps/mobile/src/components/agents/child-session-sheet-state.test.ts index 1679250122c..a9c1f6b336b 100644 --- a/apps/mobile/src/components/agents/child-session-sheet-state.test.ts +++ b/apps/mobile/src/components/agents/child-session-sheet-state.test.ts @@ -6,6 +6,7 @@ import { closeChildSessionSheet, getChildSessionSheetState, openChildSessionSheet, + releaseChildSessionSheet, } from './child-session-sheet-state'; const emptyMount: ChildSessionSheetMountState = { sheet: null, visible: false }; @@ -61,3 +62,27 @@ describe('openChildSessionSheet / closeChildSessionSheet', () => { }); }); }); + +describe('releaseChildSessionSheet', () => { + it('releases sheet identity after close', () => { + const open = openChildSessionSheet(emptyMount, childA); + const closed = closeChildSessionSheet(open); + expect(releaseChildSessionSheet(closed)).toEqual({ + sheet: null, + visible: false, + }); + }); + + it('is a no-op when sheet is already null', () => { + expect(releaseChildSessionSheet(emptyMount)).toEqual({ + sheet: null, + visible: false, + }); + }); + + it('never releases a visible sheet — a reopen before the scheduled release wins', () => { + const closed = closeChildSessionSheet(openChildSessionSheet(emptyMount, childA)); + const reopened = openChildSessionSheet(closed, childB); + expect(releaseChildSessionSheet(reopened)).toBe(reopened); + }); +}); diff --git a/apps/mobile/src/components/agents/child-session-sheet-state.ts b/apps/mobile/src/components/agents/child-session-sheet-state.ts index 91e9bc31f31..bf246b13ca3 100644 --- a/apps/mobile/src/components/agents/child-session-sheet-state.ts +++ b/apps/mobile/src/components/agents/child-session-sheet-state.ts @@ -45,3 +45,18 @@ export function closeChildSessionSheet( ): ChildSessionSheetMountState { return { sheet: current.sheet, visible: false }; } + +/** + * Release the sheet identity after the native dismiss animation finishes. + * Callers are responsible for scheduling this from the close callback. + * A visible sheet is never released: if a reopen landed before the scheduled + * release runs, the reopen wins and the release is a no-op. + */ +export function releaseChildSessionSheet( + current: ChildSessionSheetMountState +): ChildSessionSheetMountState { + if (!current.sheet || current.visible) { + return current; + } + return { sheet: null, visible: false }; +} diff --git a/apps/mobile/src/components/agents/session-detail-content.tsx b/apps/mobile/src/components/agents/session-detail-content.tsx index 24d68f28343..98710433482 100644 --- a/apps/mobile/src/components/agents/session-detail-content.tsx +++ b/apps/mobile/src/components/agents/session-detail-content.tsx @@ -4,7 +4,7 @@ import { type KiloSessionId, type StoredMessage, } from '@kilocode/cloud-agent-sdk'; -import { type Href, useRouter } from 'expo-router'; +import { type Href, useIsFocused, useRouter } from 'expo-router'; import { useAtomValue } from 'jotai'; import { MessageSquare } from 'lucide-react-native'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -68,6 +68,7 @@ import { type ChildSessionSheetMountState, closeChildSessionSheet, openChildSessionSheet, + releaseChildSessionSheet, } from '@/components/agents/child-session-sheet-state'; import { PartRenderer } from '@/components/agents/part-renderer'; import { QueryError } from '@/components/query-error'; @@ -127,6 +128,14 @@ export function SessionDetailContent({ sheet: null, visible: false, }); + const childSheetReleaseTimeoutRef = useRef | null>(null); + + const clearChildSheetReleaseTimeout = useCallback(() => { + if (childSheetReleaseTimeoutRef.current !== null) { + clearTimeout(childSheetReleaseTimeoutRef.current); + childSheetReleaseTimeoutRef.current = null; + } + }, []); const messages = useAtomValue(manager.atoms.messagesList); const isLoading = useAtomValue(manager.atoms.isLoading); @@ -290,6 +299,13 @@ export function SessionDetailContent({ captureEvent(SESSION_VIEWED_EVENT, { surface: analyticsSurface, via: openedVia }); }, [fetchedData, sessionId, analyticsSurface, openedVia]); + useEffect( + () => () => { + clearChildSheetReleaseTimeout(); + }, + [clearChildSheetReleaseTimeout] + ); + useEffect(() => { void manager.switchSession(sessionId); }, [sessionId, manager]); @@ -345,17 +361,26 @@ export function SessionDetailContent({ const handleOpenChildSession = useCallback( (childSessionId: KiloSessionId, childTitle: string) => { + clearChildSheetReleaseTimeout(); setChildSessionSheet(current => openChildSessionSheet(current, { sessionId: childSessionId, title: childTitle }) ); void manager.hydrateChildSession(childSessionId); }, - [manager] + [manager, clearChildSheetReleaseTimeout] ); + const CHILD_SHEET_RELEASE_DELAY_MS = 350; + const handleCloseChildSession = useCallback(() => { + clearChildSheetReleaseTimeout(); setChildSessionSheet(closeChildSessionSheet); - }, []); + childSheetReleaseTimeoutRef.current = setTimeout(() => { + childSheetReleaseTimeoutRef.current = null; + setChildSessionSheet(current => releaseChildSessionSheet(current)); + // covers the native dismiss animation; the white flash is iOS-specific + }, CHILD_SHEET_RELEASE_DELAY_MS); + }, [clearChildSheetReleaseTimeout]); const transcript = useMemo( () => mergeSessionTranscript(messages, preparationAttempts), @@ -683,7 +708,10 @@ export function SessionDetailContent({ [manager, router] ); - const keepScreenAwake = isStreaming || pendingMessages.size > 0; + const isFocused = useIsFocused(); + // Focus bounds the awake window to the visible working UI; a backgrounded + // or covered screen must not hold the OS idle timer. + const keepScreenAwake = isFocused && (isStreaming || pendingMessages.size > 0); return ( @@ -697,7 +725,7 @@ export function SessionDetailContent({ } : {})} /> - {keepScreenAwake ? : null} + {keepScreenAwake ? : null} {!isConnected && } @@ -941,8 +969,10 @@ export function SessionDetailContent({ } } -function ActiveSessionKeepAwake() { - useKeepAwake(); +function ActiveSessionKeepAwake({ sessionId }: Readonly<{ sessionId: KiloSessionId }>) { + // Scoped tag keeps stacked session screens independent so deactivating one + // does not release the wake lock another visible session still needs. + useKeepAwake(`session-${sessionId}`); return null; } From 27982652ef051c3e7119aba1c381ecb03e787402 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 31 Jul 2026 10:02:49 +0200 Subject: [PATCH 14/17] fix(mobile): suppress sub-display Older-activity residuals (item 23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 1µ$ reconciliation epsilon let 2-49µ$ residuals render an "Older activity $0.0000" row. Gate on what formatCost actually displays, mirroring formatSessionTotalCost's suppression, and reword the subtitle — the residual also absorbs ingest gaps when the full history is loaded. Addresses Kilobot thread on session-cost-breakdown.ts. --- .../src/components/agents/session-context-sheet.tsx | 2 +- .../components/agents/session-cost-breakdown.test.ts | 12 ++++++++++++ .../src/components/agents/session-cost-breakdown.ts | 12 ++++++++++-- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/components/agents/session-context-sheet.tsx b/apps/mobile/src/components/agents/session-context-sheet.tsx index 3cbf397827b..e80761ed3ac 100644 --- a/apps/mobile/src/components/agents/session-context-sheet.tsx +++ b/apps/mobile/src/components/agents/session-context-sheet.tsx @@ -313,7 +313,7 @@ function OlderActivityRow({ costUsd }: Readonly<{ costUsd: number }>) { Older activity - Cost from activity before the loaded history + Cost not attributed to the loaded history diff --git a/apps/mobile/src/components/agents/session-cost-breakdown.test.ts b/apps/mobile/src/components/agents/session-cost-breakdown.test.ts index 3781c56d98c..5cf050278e3 100644 --- a/apps/mobile/src/components/agents/session-cost-breakdown.test.ts +++ b/apps/mobile/src/components/agents/session-cost-breakdown.test.ts @@ -324,6 +324,18 @@ describe('getOlderActivityCostUsd', () => { expect(getOlderActivityCostUsd(Number.POSITIVE_INFINITY, 0.01)).toBe(0); }); + it('returns 0 when residual rounds to $0.0000 at the display boundary', () => { + // 49 µ$ rounds to $0.0000 with toFixed(4) and should be absorbed. + expect(getOlderActivityCostUsd(50_000 + 49, 0.05)).toBe(0); + }); + + it('returns exact residual when it renders a non-zero display amount', () => { + // 200 µ$ renders $0.0002 — safely above the 4-decimal rounding boundary. + // (The gate is display-aligned: whatever formatCost shows decides; a ~50 µ$ + // subtraction residual lands below 0.00005 in floating point and suppresses.) + expect(getOlderActivityCostUsd(50_000 + 200, 0.05)).toBeCloseTo(200e-6, 9); + }); + it('returns 0 when residual is zero or within epsilon', () => { expect(getOlderActivityCostUsd(50_000, 0.05)).toBe(0); // +0.5µ$ and +0.9µ$ are both below COST_RECONCILIATION_EPSILON_USD (1e-6) diff --git a/apps/mobile/src/components/agents/session-cost-breakdown.ts b/apps/mobile/src/components/agents/session-cost-breakdown.ts index ffe34716f7e..cab4943c4b6 100644 --- a/apps/mobile/src/components/agents/session-cost-breakdown.ts +++ b/apps/mobile/src/components/agents/session-cost-breakdown.ts @@ -9,6 +9,8 @@ import { type StoredMessage, } from '@kilocode/cloud-agent-sdk'; +import { formatCost } from './context-usage-display'; + type SessionCostBreakdownTotals = { input: number; output: number; @@ -198,7 +200,10 @@ export function getVisibleSessionCostModels( * residual, which is computed on the live-message basis alone. * * Returns a row-ready amount: 0 when total is null/non-finite, when the - * delta is within ±epsilon, or when live already covers the page total. + * delta is within ±epsilon, when live already covers the page total, or when + * the residual rounds to $0.0000 in the sheet's 4-decimal display. Sub-display + * residuals are absorbed as display rounding; reconciliation fixtures must use + * residuals above the display threshold. */ export function getOlderActivityCostUsd( totalCostMicrodollars: number | null, @@ -208,7 +213,10 @@ export function getOlderActivityCostUsd( return 0; } const residualUsd = totalCostMicrodollars / 1e6 - liveCostUsd; - return residualUsd > COST_RECONCILIATION_EPSILON_USD ? residualUsd : 0; + if (residualUsd <= COST_RECONCILIATION_EPSILON_USD) { + return 0; + } + return formatCost(residualUsd) === '$0.0000' ? 0 : residualUsd; } /** From 44bcf5af4dd25a6036c6424379e16cca0e63c107 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 31 Jul 2026 10:02:49 +0200 Subject: [PATCH 15/17] fix(mobile): scope Android swipe-dismiss touch tracking by identifier (item 22) topTouch* events fire per changed touch: a second finger overwrote the tracked gesture origin or reset it mid-drag, breaking the primary finger's swipe-to-dismiss. Track nativeEvent.identifier and ignore other identifiers, matching the RNGH pan's identifier awareness. Addresses Kilobot thread on chat-composer.tsx. --- .../src/components/agents/chat-composer.tsx | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/components/agents/chat-composer.tsx b/apps/mobile/src/components/agents/chat-composer.tsx index ee4c73a7045..e9bcb6d75a3 100644 --- a/apps/mobile/src/components/agents/chat-composer.tsx +++ b/apps/mobile/src/components/agents/chat-composer.tsx @@ -71,6 +71,7 @@ const DISMISS_KEYBOARD_ACTIVE_OFFSET_Y = 24; const DISMISS_KEYBOARD_FAIL_OFFSET_X = 16; type AndroidDismissKeyboardGesture = { + identifier: string; startPageX: number; startPageY: number; dismissed: boolean; @@ -310,12 +311,21 @@ export function ChatComposer({ // once dy crosses the same threshold as the pan. iOS keeps the RNGH path only. const androidDismissGestureRef = useRef(null); - const resetAndroidDismissGesture = useCallback(() => { + const resetAndroidDismissGesture = useCallback((event: GestureResponderEvent) => { + const gesture = androidDismissGestureRef.current; + if (gesture && gesture.identifier !== event.nativeEvent.identifier) { + return; + } androidDismissGestureRef.current = null; }, []); const handleAndroidDismissTouchStart = useCallback((event: GestureResponderEvent) => { + const gesture = androidDismissGestureRef.current; + if (gesture && gesture.identifier !== event.nativeEvent.identifier) { + return; + } androidDismissGestureRef.current = { + identifier: event.nativeEvent.identifier, startPageX: event.nativeEvent.pageX, startPageY: event.nativeEvent.pageY, dismissed: false, @@ -326,7 +336,13 @@ export function ChatComposer({ const tryAndroidDismissKeyboardFromTouchMove = useCallback( (event: GestureResponderEvent): boolean => { const gesture = androidDismissGestureRef.current; - if (!gesture || gesture.dismissed || gesture.failed || inputScrollable) { + if ( + !gesture || + gesture.identifier !== event.nativeEvent.identifier || + gesture.dismissed || + gesture.failed || + inputScrollable + ) { return false; } const dx = event.nativeEvent.pageX - gesture.startPageX; From 524c31bd816cf8e4483fd77d2f73dd95b07661a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 31 Jul 2026 13:01:28 +0200 Subject: [PATCH 16/17] fix(mobile): release child sheet after iOS dismiss --- .../components/agents/child-session-sheet.tsx | 4 ++++ .../agents/session-detail-content.tsx | 18 +++++++++++++----- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/components/agents/child-session-sheet.tsx b/apps/mobile/src/components/agents/child-session-sheet.tsx index d9e18d7d8e0..352c68cd0d5 100644 --- a/apps/mobile/src/components/agents/child-session-sheet.tsx +++ b/apps/mobile/src/components/agents/child-session-sheet.tsx @@ -29,6 +29,8 @@ type ChildSessionSheetProps = { onOpenChildSession: OpenChildSession; onRetry: () => void; onClose: () => void; + /** Fires on iOS after the native pageSheet dismiss animation completes. */ + onDismiss?: () => void; }; // eslint-disable-next-line no-empty-function -- child sessions are hydrated one-shot, no pagination @@ -45,6 +47,7 @@ export function ChildSessionSheet({ onOpenChildSession, onRetry, onClose, + onDismiss, }: Readonly) { const messages = getChildMessages(sessionId); const state = getChildSessionSheetState(hydrationState, messages.length); @@ -118,6 +121,7 @@ export function ChildSessionSheet({ animationType="slide" presentationStyle="pageSheet" onRequestClose={onClose} + onDismiss={onDismiss} > diff --git a/apps/mobile/src/components/agents/session-detail-content.tsx b/apps/mobile/src/components/agents/session-detail-content.tsx index 98710433482..700365aef66 100644 --- a/apps/mobile/src/components/agents/session-detail-content.tsx +++ b/apps/mobile/src/components/agents/session-detail-content.tsx @@ -372,14 +372,21 @@ export function SessionDetailContent({ const CHILD_SHEET_RELEASE_DELAY_MS = 350; + /** Releases the sheet identity via the native onDismiss path (iOS) or the fallback timer. */ + const handleChildSheetDismiss = useCallback(() => { + clearChildSheetReleaseTimeout(); + setChildSessionSheet(current => releaseChildSessionSheet(current)); + }, [clearChildSheetReleaseTimeout]); + const handleCloseChildSession = useCallback(() => { clearChildSheetReleaseTimeout(); setChildSessionSheet(closeChildSessionSheet); - childSheetReleaseTimeoutRef.current = setTimeout(() => { - childSheetReleaseTimeoutRef.current = null; - setChildSessionSheet(current => releaseChildSessionSheet(current)); - // covers the native dismiss animation; the white flash is iOS-specific - }, CHILD_SHEET_RELEASE_DELAY_MS); + if (Platform.OS !== 'ios') { + childSheetReleaseTimeoutRef.current = setTimeout(() => { + childSheetReleaseTimeoutRef.current = null; + setChildSessionSheet(current => releaseChildSessionSheet(current)); + }, CHILD_SHEET_RELEASE_DELAY_MS); + } }, [clearChildSheetReleaseTimeout]); const transcript = useMemo( @@ -790,6 +797,7 @@ export function SessionDetailContent({ void manager.hydrateChildSession(openSheet.sessionId); }} onClose={handleCloseChildSession} + onDismiss={handleChildSheetDismiss} /> ) : null} From 39d33ef0c7305173e32b82b170dd0385a528adba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 31 Jul 2026 13:23:26 +0200 Subject: [PATCH 17/17] fix(mobile): stop keep-awake after disconnect --- apps/mobile/src/components/agents/session-detail-content.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/components/agents/session-detail-content.tsx b/apps/mobile/src/components/agents/session-detail-content.tsx index 700365aef66..c3f62674a1f 100644 --- a/apps/mobile/src/components/agents/session-detail-content.tsx +++ b/apps/mobile/src/components/agents/session-detail-content.tsx @@ -144,6 +144,7 @@ export function SessionDetailContent({ const sessionConfig = useAtomValue(manager.atoms.sessionConfig); const isStreaming = useAtomValue(manager.atoms.isStreaming); const statusIndicator = useAtomValue(manager.atoms.statusIndicator); + const agentStatus = useAtomValue(manager.atoms.agentStatus); const cloudStatus = useAtomValue(manager.atoms.cloudStatus); const preparationAttempts = useAtomValue(manager.atoms.preparationAttempts); const canSend = useAtomValue(manager.atoms.canSend); @@ -718,7 +719,8 @@ export function SessionDetailContent({ const isFocused = useIsFocused(); // Focus bounds the awake window to the visible working UI; a backgrounded // or covered screen must not hold the OS idle timer. - const keepScreenAwake = isFocused && (isStreaming || pendingMessages.size > 0); + const keepScreenAwake = + isFocused && agentStatus.type !== 'disconnected' && (isStreaming || pendingMessages.size > 0); return (