Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
89eb675
feat(mobile): add infinite-query retention primitive
iscekic Aug 18, 2026
fbceec7
feat(cloud-agent-sdk): keep failed delivery state and add retry entry…
iscekic Aug 18, 2026
3332442
fix(cloud-agent-sdk): avoid duplicate socket on online-during-connect…
iscekic Aug 19, 2026
35ce1c5
feat(mobile): render failed-delivery and failed-assistant rows with r…
iscekic Aug 19, 2026
ebf2d87
feat(mobile): bound agent session queries and defer cache maintenance
iscekic Aug 19, 2026
318c871
perf(cloud-agent-sdk): publish row-local part deltas via partsRevision
iscekic Aug 19, 2026
b12e4fd
feat(mobile): bound security and PR-review infinite queries
iscekic Aug 19, 2026
a226cb1
feat(mobile): wire failed-row retry and copy-to-composer handlers
iscekic Aug 19, 2026
3073eec
perf(cloud-agent-sdk): coalesce part-delta publication and retain his…
iscekic Aug 19, 2026
b056e3e
fix(mobile): commit session-detail-content-helpers module
iscekic Aug 19, 2026
d584070
feat(cloud-agent-sdk): hydrate child sessions by bounded page
iscekic Aug 19, 2026
185045a
feat(mobile): bound attachment uploads and cancel in-flight work
iscekic Aug 19, 2026
9d0b9e8
feat(mobile): one metadata read per open and paged child sheet
iscekic Aug 19, 2026
9f6cd74
fix(mobile): type discussion-threads retention test comments
iscekic Aug 19, 2026
ae4dce9
fix(mobile): keep the first-focus pending-decision follow-up one-shot
iscekic Aug 19, 2026
6b9ca75
feat(mobile): trim retained transcript history on reaching bottom
iscekic Aug 19, 2026
062d971
perf(mobile): coalesce composer typing and upload progress per frame
iscekic Aug 19, 2026
b4038a2
feat(mobile): persist merge, reply, and comment drafts by destination
iscekic Aug 19, 2026
019af2c
test(mobile): fix lint and hook-mock failures in merge and composer s…
iscekic Aug 19, 2026
d565aa1
fix(mobile): gate reply input on draft settle
iscekic Aug 19, 2026
cdbc19d
chore(mobile): remove dead patchInfiniteEntity and benchmark test
iscekic Aug 19, 2026
4d6eef0
fix(mobile): reset infinite queries to page one on reconcile and depa…
iscekic Aug 19, 2026
2447d31
fix(mobile): fence draft load on identity-change render
iscekic Aug 19, 2026
992dae3
fix(mobile): seed reply input during render
iscekic Aug 19, 2026
b1304ec
fix(mobile): register attachment cancel handle before presign
iscekic Aug 19, 2026
6b533b8
fix(mobile): retry and copy only the first human text part
iscekic Aug 19, 2026
376e153
style(mobile): format reply-input test mock
iscekic Aug 19, 2026
062806d
test(mobile): add firstHumanText to part-types mock
iscekic Aug 19, 2026
faa792f
fix(mobile): retry and copy the synthetic queued prompt text
iscekic Aug 19, 2026
0cf485d
Merge remote-tracking branch 'origin/main' into audit-w5b-agent-chat-…
iscekic Aug 19, 2026
fe46f01
fix(mobile): satisfy anti-slop lint after main merge
iscekic Aug 19, 2026
48edc0f
fix(mobile): look up assistant failure copy without a narrow map
iscekic Aug 19, 2026
df91514
fix: address review findings on queue, trim, and upload cancel
iscekic Aug 19, 2026
b4707f5
test(mobile): avoid calling an optional cancel probe in the upload test
iscekic Aug 19, 2026
081e7f9
Merge branch 'main' into audit-w5b-agent-chat-d08d
iscekic Aug 19, 2026
006e73f
fix(mobile): keep infinite-retention free of react-native
iscekic Aug 19, 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
2 changes: 1 addition & 1 deletion apps/mobile/src/app/(app)/agent-chat/new.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ function NewSessionScreenBody() {
// settles.
const initialPrompt = resolvePrefillOverDraft(
sharePrefillText,
draftState.settled ? draftState.text : null
draftState.settled ? draftState.value : null
);

// Save the new-session draft debounced on every text change, and flush the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,9 @@ function AttachmentChip({
className="h-full w-full"
contentFit="cover"
transition={0}
allowDownscaling
recyclingKey={attachment.id}
cachePolicy="memory"
/>
) : (
<View
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ vi.mock('react', async () => {
return refSlots.slots[index] as React.RefObject<T>;
}),
useState: vi.fn(<T>(initial: T) => [initial, vi.fn() as () => void] as [T, (value: T) => void]),
useImperativeHandle: vi.fn(),
};
});

Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/components/agents/chat-composer.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable max-lines -- the mocked hook surface and draft-restore contract require a long suite */
/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (node env, no jsdom); see src/lib/persist/cache-persistence-mount.test.ts */
/* eslint-disable new-cap -- ChatComposer is called as a plain function, matching repo test convention */
/* eslint-disable require-await, @typescript-eslint/require-await -- the fake hooks and handlers settle without await because they resolve immediately */
Expand Down Expand Up @@ -31,6 +32,7 @@ vi.mock('react', async () => {
useEffect: vi.fn((fn: React.EffectCallback) => {
fn();
}),
useImperativeHandle: vi.fn(() => undefined),
useMemo: vi.fn(<T>(factory: () => T) => factory()),
useRef: vi.fn(<T>(initial: T) => {
const index = refSlots.cursor;
Expand Down
131 changes: 112 additions & 19 deletions apps/mobile/src/components/agents/chat-composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,15 @@ 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, useEffect, useMemo, useRef, useState } from 'react';
import {
type Ref,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import {
AppState,
type GestureResponderEvent,
Expand Down Expand Up @@ -54,7 +62,10 @@ import {
import { ChatComposerInputRow } from '@/components/agents/chat-composer-input-row';
import { BlurBar } from '@/components/ui/blur-bar';
import { VoiceInputStatus } from '@/components/voice-input-control';
import { AGENT_ATTACHMENT_MAX_FILES } from '@/lib/agent-attachments/constants';
import {
AGENT_ATTACHMENT_MAX_BYTES,
AGENT_ATTACHMENT_MAX_FILES,
} from '@/lib/agent-attachments/constants';
import {
type AgentAttachmentSubmissionPayload,
type AgentAttachmentWire,
Expand All @@ -71,6 +82,7 @@ import { type ModeOption } from '@/components/agents/mode-normalize';
import { useCurrentUserId } from '@/lib/hooks/use-current-user-id';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { resolveMessageInputAppStateTransition } from '@/lib/message-input-app-state';
import { createFrameCoalescer, type FrameCoalescer } from '@/lib/coalesce-frame';
import { clearDraft as clearStoredDraft, saveDraft } from '@/lib/persist/drafts';
import { useDraftFlushOnBackground } from '@/lib/persist/use-draft-flush';
import { cn } from '@/lib/utils';
Expand Down Expand Up @@ -104,6 +116,11 @@ type AndroidDismissKeyboardGesture = {
failed: boolean;
};

/** Imperative handle the host uses to set composer text (Retry / Copy to composer). */
export type ChatComposerControl = {
setText: (text: string) => void;
};

type ChatComposerProps = {
onSend: (
text: string,
Expand Down Expand Up @@ -162,6 +179,8 @@ type ChatComposerProps = {
* text the user typed while identity and the draft were still loading.
*/
initialDraft?: string;
/** Imperative handle the host binds to call `setText`. */
controlRef?: Ref<ChatComposerControl>;
};

export function ChatComposer({
Expand Down Expand Up @@ -192,6 +211,7 @@ export function ChatComposer({
autoSend,
draftKey,
initialDraft,
controlRef,
}: Readonly<ChatComposerProps>) {
const colors = useThemeColors();
const { showActionSheetWithOptions } = useActionSheet();
Expand Down Expand Up @@ -277,6 +297,31 @@ export function ChatComposer({
const measureRef = useRef(measure);
measureRef.current = measure;

// Coalesce the three derived setters (measure node, hasText, slash command)
// to at most one publication per animation frame. Typing can fire many
// onChangeText calls in a single frame; publishing derived state once per
// frame keeps the send button and slash suggestions from re-rendering on
// every keystroke. The publish closure reads `measureRef` at call time and
// uses the stable `setHasText`/`setSlashCommandInput` setters, so it stays
// valid for the lifetime of the component.
const composerFrameCoalescerRef = useRef<FrameCoalescer<string> | null>(null);
composerFrameCoalescerRef.current ??= createFrameCoalescer<string>(value => {
measureRef.current.setText(value);
setHasText(value.trim().length > 0);
setSlashCommandInput(getSlashCommandCandidate(value));
});
const composerFrameCoalescer = composerFrameCoalescerRef.current;

// Flush the coalescer on unmount so a pending derived-state publication is
// committed before teardown and the scheduled frame callback becomes a
// no-op instead of firing setState after the component is gone.
useEffect(
() => () => {
composerFrameCoalescer.flush();
},
[composerFrameCoalescer]
);

// Flush the debounced draft write when the app leaves `active` and on
// unmount, so a backgrounded-then-killed app (or a navigation away) does
// not lose the last keystrokes inside the 500 ms window.
Expand Down Expand Up @@ -352,11 +397,51 @@ export function ChatComposer({
const toolbarDisabled = disabled || isSending;
const voiceDisabled = toolbarDisabled;

function handleChangeText(value: string) {
// One place text is written into the live input from an external caller
// (slash-command select, Retry / Copy to composer). Sets text, selection,
// hasText, slash-command state, and the measure node, then persists the
// durable draft exactly like a keystroke.
function applyComposerText(value: string) {
// Drain any pending coalesced typing so a stale value cannot overwrite the
// copied prompt on the next frame. The direct setters below then land the
// copied prompt in one commit.
composerFrameCoalescer.flush();
textRef.current = value;
measure.setText(value);
setHasText(value.trim().length > 0);
setSlashCommandInput(getSlashCommandCandidate(value));
setSlashCommandInput(null);
inputRef.current?.setNativeProps({
text: value,
selection: { start: value.length, end: value.length },
});
selectionRef.current = { start: value.length, end: value.length };
inputRef.current?.focus();
if (draftKey && userId) {
saveDraft(userId, draftKey, value);
}
}

// Hold the latest applyComposerText so the imperative handle stays stable
// while the composer does not remount when identity resolves.
const applyComposerTextRef = useRef(applyComposerText);
applyComposerTextRef.current = applyComposerText;

useImperativeHandle(
controlRef,
() => ({
setText: (text: string) => {
applyComposerTextRef.current(text);
},
}),
[]
);

function handleChangeText(value: string) {
textRef.current = value;
// Derived state (measure node, hasText, slash command) is coalesced to one
// publication per frame; the live submit-time ref and the debounced draft
// write stay synchronous so neither can lag a keystroke.
composerFrameCoalescer.push(value);
// Delivery applies text BEFORE onDelivered fires, so any
// handleChangeText after shareDelivered is a user edit. Disarm
// so a later gate resolution (upload completion) cannot
Expand All @@ -381,6 +466,10 @@ export function ChatComposer({
onChangeText: handleChangeText,
addCandidates,
onDelivered: () => {
// Commit the coalesced `hasText` before the delivery check so the
// auto-send effect sees the delivered text in the same commit, not on
// the next frame.
composerFrameCoalescer.flush();
setAutoSendArmed(
shouldArmAutoSendOnDelivery({
autoSend: autoSendRef.current,
Expand Down Expand Up @@ -440,11 +529,10 @@ export function ChatComposer({
},
onFailure: reason => {
toast.error(
reason === 'empty'
? CLIPBOARD_PASTE_EMPTY_MESSAGE
: describeClassificationFailure('unreadable')
reason === 'empty' ? CLIPBOARD_PASTE_EMPTY_MESSAGE : describeClassificationFailure(reason)
);
},
maxBytes: AGENT_ATTACHMENT_MAX_BYTES,
});

const commandList = useMemo(
Expand Down Expand Up @@ -595,6 +683,11 @@ export function ChatComposer({
: undefined;

function clearDraft() {
// Drain any pending coalesced value (a final voice transcript can `push`
// after `submit`'s `flush`). Publishing it here clears `hasPending` so the
// already-scheduled frame callback becomes a no-op; the direct setters
// below then override the published value in the same batched commit.
composerFrameCoalescer.flush();
textRef.current = '';
setHasText(false);
setSlashCommandInput(null);
Expand All @@ -609,7 +702,13 @@ export function ChatComposer({

async function handleSend() {
const trimmed = textRef.current.trim();
if (!control.canSend) {
// Decide admission from live values, not render-time `control.canSend`,
// which can lag behind a same-frame edit. An empty prompt with no ready
// attachment is never sent.
const readyAttachmentsCount = upload.attachments.filter(
attachment => attachment.status === 'uploaded'
).length;
if ((trimmed.length === 0 && readyAttachmentsCount === 0) || disabled || isSending) {
return;
}
if (upload.isUploading) {
Expand Down Expand Up @@ -689,20 +788,14 @@ export function ChatComposer({
if (sendLockRef.current.isLocked()) {
return;
}
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 },
});
selectionRef.current = { start: value.length, end: value.length };
inputRef.current?.focus();
applyComposerText(`/${command.name} `);
}

async function submit() {
// Commit any coalesced derived state (hasText, measure, slash command)
// before the send decision, so a submit in the same frame as the last
// keystroke never reads stale derived state.
composerFrameCoalescer.flush();
// `settleVoiceInputBeforeSubmit` is the sole admission owner for the
// entire voice-settle + asynchronous send sequence. It acquires the
// SubmitLock, sets pending state, waits for the final transcript, runs
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,19 @@ describe('getChildSessionSheetState', () => {
});

it('shows an empty state after successful hydration with no messages', () => {
expect(getChildSessionSheetState({ status: 'ready' }, 0)).toBe('empty');
expect(
getChildSessionSheetState(
{
status: 'ready',
cursor: null,
hasOlder: false,
isLoadingOlder: false,
olderError: null,
omittedItemCount: 0,
},
0
)
).toBe('empty');
});

it('shows an error after failed hydration with no messages', () => {
Expand Down
29 changes: 20 additions & 9 deletions apps/mobile/src/components/agents/child-session-sheet.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { type ReactNode } from 'react';
import { Modal, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { type ChildSessionHydrationState, type StoredMessage } from '@kilocode/cloud-agent-sdk';
import {
type ChildSessionHydrationState,
type OlderMessagesError,
type StoredMessage,
} from '@kilocode/cloud-agent-sdk';

import { EmptyState } from '@/components/empty-state';
import { QueryError } from '@/components/query-error';
Expand All @@ -26,6 +30,11 @@ type ChildSessionSheetProps = {
getChildMessages: (sessionId: string) => StoredMessage[];
hydrationState: ChildSessionHydrationState;
isStreaming: boolean;
hasOlderMessages: boolean;
isLoadingOlderMessages: boolean;
olderMessagesError: OlderMessagesError | null;
olderMessagesOmittedItemCount: number;
onLoadOlderMessages: () => void;
renderPart: RenderPartFn;
onOpenChildSession: OpenChildSession;
onRetry: () => void;
Expand All @@ -34,16 +43,18 @@ type ChildSessionSheetProps = {
onDismiss?: () => void;
};

// eslint-disable-next-line no-empty-function -- child sessions are hydrated one-shot, no pagination
function noopLoadOlder(): void {}

export function ChildSessionSheet({
visible,
sessionId,
title,
getChildMessages,
hydrationState,
isStreaming,
hasOlderMessages,
isLoadingOlderMessages,
olderMessagesError,
olderMessagesOmittedItemCount,
onLoadOlderMessages,
renderPart,
onOpenChildSession,
onRetry,
Expand All @@ -66,11 +77,11 @@ export function ChildSessionSheet({
sessionId={sessionId}
items={messages}
keyExtractor={message => message.info.id}
hasOlderMessages={false}
isLoadingOlderMessages={false}
olderMessagesError={null}
olderMessagesOmittedItemCount={0}
onLoadOlderMessages={noopLoadOlder}
hasOlderMessages={hasOlderMessages}
isLoadingOlderMessages={isLoadingOlderMessages}
olderMessagesError={olderMessagesError}
olderMessagesOmittedItemCount={olderMessagesOmittedItemCount}
onLoadOlderMessages={onLoadOlderMessages}
renderItem={({ item }) => (
<MessageErrorBoundary>
<View className="px-4 py-1">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ vi.mock('./part-renderer', () => ({
vi.mock('./part-types', () => ({
isFilePart: () => false,
isTextPart: () => false,
firstHumanText: () => '',
}));
vi.mock('./use-message-copy', () => ({
useMessageCopy: () => ({ copyMessage: vi.fn() }),
Expand Down Expand Up @@ -153,8 +154,9 @@ describe('MessageBubble long-press details', () => {
});
const { MessageBubble } = await import('./message-bubble');
const message = userMessage('m-long');
// MessageBubble is wrapped in React.memo; invoke its inner component.
// eslint-disable-next-line new-cap
const tree = MessageBubble({ message, onLongPressDetails });
const tree = MessageBubble.type({ message, onLongPressDetails });
const props = pressableProps(tree);
expect(props).not.toBeNull();
const handler = props === null ? undefined : props.onLongPress;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,10 @@ export async function renderBubble(
holdQueuedSlot?: boolean
): Promise<unknown> {
const { MessageBubble } = await import('./message-bubble');
// MessageBubble is wrapped in React.memo; invoke its inner component directly
// to inspect the unrendered element tree.
// eslint-disable-next-line new-cap
return MessageBubble({ message, deliveryState, holdQueuedSlot });
return MessageBubble.type({ message, deliveryState, holdQueuedSlot });
}

export function findText(node: unknown, predicate: (text: string) => boolean): boolean {
Expand Down
Loading