Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
a5521e8
fix(mobile): keep screen awake while agent works; animate subagent sh…
iscekic Jul 30, 2026
d891cb1
docs(mobile): reference kilo-design baseline in AGENTS.md (item 12)
iscekic Jul 30, 2026
67acec5
fix(mobile): show Older activity residual in session context sheet
iscekic Jul 30, 2026
4e20dd9
fix(mobile): disable native text selection inside message bubbles
iscekic Jul 30, 2026
851e386
fix(mobile): slash menu separators + SheetHeader title ellipsis
iscekic Jul 30, 2026
2981b47
fix(mobile): composer AppState restore, swipe-down dismiss, reliable …
iscekic Jul 30, 2026
1add09b
docs(workflow): learning — CLI sessions read-only until TUI /remote r…
iscekic Jul 30, 2026
7c6a8bb
fix(mobile): restore mirror-Text composer growth (item 26)
iscekic Jul 30, 2026
e43025a
fix(mobile): dismiss keyboard on Android EditText swipe-down (item 22)
iscekic Jul 30, 2026
e442877
fix(mobile): evaluate composer AppState refocus gate at fire time (it…
iscekic Jul 30, 2026
ee1e04d
Merge remote-tracking branch 'origin/main' into session-page-ux-8225
iscekic Jul 31, 2026
feaae22
chore: drop .kilo_workflow remnants after main merge
iscekic Jul 31, 2026
2b7cfa9
fix(mobile): restore copy path for tool output and reasoning (item 13)
iscekic Jul 31, 2026
cfcb4f2
fix(mobile): bound child-sheet mount and keep-awake to their lifetimes
iscekic Jul 31, 2026
2798265
fix(mobile): suppress sub-display Older-activity residuals (item 23)
iscekic Jul 31, 2026
44bcf5a
fix(mobile): scope Android swipe-dismiss touch tracking by identifier…
iscekic Jul 31, 2026
524c31b
fix(mobile): release child sheet after iOS dismiss
iscekic Jul 31, 2026
39d33ef
fix(mobile): stop keep-awake after disconnect
iscekic Jul 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/mobile/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,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",
Expand Down
13 changes: 13 additions & 0 deletions apps/mobile/src/components/agents/bubble-text-selection-context.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest';

import { shouldEnableComposerInputScroll } from './chat-composer-input-height';

const MIN = 44;
const MAX = 124;

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);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* 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 shouldEnableComposerInputScroll(height: number, maxHeight: number): boolean {
return height >= maxHeight;
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
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';
Expand Down Expand Up @@ -78,6 +79,7 @@ export function ChatComposerInputRow({
voiceInputStatus,
}: Readonly<ChatComposerInputRowProps>) {
const colors = useThemeColors();
const inputScrollable = shouldEnableComposerInputScroll(measureHeight, maxInputHeight);

return (
<View className="flex-row items-center p-2.5 px-3">
Expand Down Expand Up @@ -115,7 +117,7 @@ export function ChatComposerInputRow({
onFocus={onInputFocus}
onBlur={onInputBlur}
style={textInputStyle}
scrollEnabled={measureHeight >= maxInputHeight}
scrollEnabled={inputScrollable}
editable={inputEditable}
accessibilityState={{ disabled: inputAccessibilityDisabled }}
returnKeyType="default"
Expand Down
245 changes: 208 additions & 37 deletions apps/mobile/src/components/agents/chat-composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,18 @@ 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,
type GestureResponderEvent,
Keyboard,
type LayoutChangeEvent,
Platform,
type TextInput,
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';

Expand All @@ -29,6 +33,7 @@ import {
parseChatComposerSubmission,
} from '@/components/agents/chat-composer-slash-commands';
import { executeChatComposerSubmission } from '@/components/agents/chat-composer-submission';
import { 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';
Expand All @@ -44,6 +49,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';
Expand All @@ -59,6 +65,18 @@ const TEXT_INPUT_MIN_HEIGHT = TEXT_INPUT_LINE_HEIGHT + TEXT_INPUT_VERTICAL_PADDI
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 = {
identifier: string;
startPageX: number;
startPageY: number;
dismissed: boolean;
failed: boolean;
};

type ChatComposerProps = {
onSend: (
Expand Down Expand Up @@ -122,11 +140,21 @@ export function ChatComposer({
const { showActionSheetWithOptions } = useActionSheet();
const textRef = useRef('');
const inputRef = useRef<TextInput>(null);
const inputFocusedRef = useRef(false);
const restoreFocusOnActiveRef = useRef(false);
const restoreFocusTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [hasText, setHasText] = useState(false);
const [slashCommandInput, setSlashCommandInput] = useState<string | null>(null);
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
Expand Down Expand Up @@ -219,6 +247,143 @@ 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();
}

// 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);
}
});

return () => {
subscription.remove();
clearRestoreFocusTimeout();
};
}, []);

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().
Gesture.Pan()
.runOnJS(true)
.activeOffsetY(DISMISS_KEYBOARD_ACTIVE_OFFSET_Y)
.failOffsetX([-DISMISS_KEYBOARD_FAIL_OFFSET_X, DISMISS_KEYBOARD_FAIL_OFFSET_X])
.enabled(!inputScrollable)
.onStart(() => {
Keyboard.dismiss();
}),
[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<AndroidDismissKeyboardGesture | null>(null);

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) => {
Comment thread
iscekic marked this conversation as resolved.
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,
failed: false,
};
}, []);

const tryAndroidDismissKeyboardFromTouchMove = useCallback(
(event: GestureResponderEvent): boolean => {
const gesture = androidDismissGestureRef.current;
if (
!gesture ||
gesture.identifier !== event.nativeEvent.identifier ||
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);
Expand Down Expand Up @@ -398,42 +563,48 @@ export function ChatComposer({
<VoiceInputStatus status={voiceInput.status} />
</View>

<ChatComposerInputRow
attachmentsEnabled={attachmentsEnabled}
canSend={control.canSend}
disabled={disabled}
inputAccessibilityDisabled={control.inputAccessibilityDisabled}
inputEditable={control.inputEditable}
inputRef={inputRef}
isSending={isSending}
isStreaming={isStreaming}
maxInputHeight={TEXT_INPUT_MAX_HEIGHT}
measureHeight={measure.height}
onAddAttachment={() => {
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}
/>
<GestureDetector gesture={dismissKeyboardPan}>
<View collapsable={false} className="w-full" {...androidDismissKeyboardTouchProps}>
<ChatComposerInputRow
attachmentsEnabled={attachmentsEnabled}
canSend={control.canSend}
disabled={disabled}
inputAccessibilityDisabled={control.inputAccessibilityDisabled}
inputEditable={control.inputEditable}
inputRef={inputRef}
isSending={isSending}
isStreaming={isStreaming}
maxInputHeight={TEXT_INPUT_MAX_HEIGHT}
measureHeight={measure.height}
onAddAttachment={() => {
void handleAddAttachment();
}}
onChangeText={handleChangeText}
onInputBlur={() => {
inputFocusedRef.current = false;
setIsFocused(false);
}}
onInputFocus={() => {
inputFocusedRef.current = true;
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}
/>
</View>
</GestureDetector>
</BlurBar>
);
}
Loading
Loading