Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ vi.mock('@/lib/agent-attachments/use-agent-attachment-upload', () => ({
retryAttachment: vi.fn(() => undefined),
reset: vi.fn(() => undefined),
releaseUnclaimedUploads: vi.fn(() => undefined),
commitSent: vi.fn(() => undefined),
isUploading: uploadState.isUploading,
hasFailedAttachments: uploadState.attachments.some(attachment => attachment.status === 'error'),
hasUnclaimedAttachments: uploadState.attachments.some(
Expand Down Expand Up @@ -341,7 +342,11 @@ describe('ChatComposer attachment-only send', () => {
await settle();

expect(onSendMock).toHaveBeenCalledTimes(1);
expect(onSendMock).toHaveBeenCalledWith('', { path: 'path-1', files: ['file.png'] }, undefined);
expect(onSendMock).toHaveBeenCalledWith('', {
attachments: { path: 'path-1', files: ['file.png'] },
submission: undefined,
onOptimisticSend: expect.any(Function),
});
});

it('does not send an empty draft with no attachments', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,13 @@ function createExitSubmissionHarness() {
const dismiss = vi.fn(() => {
order.push('dismiss');
});
const resetAttachments = vi.fn((): void => undefined);

return {
lock,
order,
onExitSession,
clearDraft,
dismiss,
resetAttachments,
submit: async () => {
const submitted = await settleVoiceInputBeforeSubmit({
lock: lockAdapter,
Expand All @@ -72,7 +70,7 @@ function createExitSubmissionHarness() {
onRestartSession: vi.fn(),
onSendPrompt: vi.fn(),
},
{ clearDraft, dismiss, resetAttachments }
{ clearDraft, dismiss }
);
},
});
Expand Down Expand Up @@ -150,7 +148,6 @@ describe('remote session exit submit lock integration', () => {

expect(harness.onExitSession).toHaveBeenCalledTimes(1);
expect(harness.order).toEqual(['exit', 'accepted', 'clear', 'dismiss']);
expect(harness.resetAttachments).not.toHaveBeenCalled();
expect(harness.lock.isLocked()).toBe(false);
});
});
9 changes: 5 additions & 4 deletions apps/mobile/src/components/agents/chat-composer-submission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ type ChatComposerSubmissionHandlers = {

type ChatComposerSubmissionCleanup = {
clearDraft: () => void;
resetAttachments: () => void;
dismiss: () => void;
};

Expand Down Expand Up @@ -82,8 +81,10 @@ export async function executeChatComposerSubmission(
return;
}

// Optimistic prompt send: the caller clears the composer via the SDK's
// onOptimisticSend signal (fired synchronously after the optimistic row is
// inserted). Clearing here would clobber any new text typed during the
// transport round-trip, so this branch only awaits and lets the caller
// restore the draft on failure.
await handlers.onSendPrompt(submission.prompt);
cleanup.clearDraft();
cleanup.resetAttachments();
cleanup.dismiss();
}
23 changes: 16 additions & 7 deletions apps/mobile/src/components/agents/chat-composer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ vi.mock('@/lib/agent-attachments/use-agent-attachment-upload', () => ({
removeAttachment: vi.fn(() => undefined),
retryAttachment: vi.fn(() => undefined),
reset: vi.fn(() => undefined),
commitSent: vi.fn(() => undefined),
isUploading: false,
hasFailedAttachments: false,
uploadPending: vi.fn(async () => ({
Expand Down Expand Up @@ -366,7 +367,11 @@ describe('ChatComposer draft restore', () => {
return;
}
expect(onSendMock).toHaveBeenCalledTimes(1);
expect(onSendMock).toHaveBeenCalledWith(sent, undefined, undefined);
expect(onSendMock).toHaveBeenCalledWith(sent, {
attachments: undefined,
submission: undefined,
onOptimisticSend: expect.any(Function),
});
});

// The host mounts the composer before identity (`user.getMe`) and the draft
Expand All @@ -381,7 +386,11 @@ describe('ChatComposer draft restore', () => {
requireInputRowOnSubmit(settled)();
await settle();

expect(onSendMock).toHaveBeenCalledWith('Restored draft text', undefined, undefined);
expect(onSendMock).toHaveBeenCalledWith('Restored draft text', {
attachments: undefined,
submission: undefined,
onOptimisticSend: expect.any(Function),
});
});

it('keeps text typed before the draft settles instead of restoring over it', async () => {
Expand All @@ -396,10 +405,10 @@ describe('ChatComposer draft restore', () => {
requireInputRowOnSubmit(settled)();
await settle();

expect(onSendMock).toHaveBeenCalledWith(
'typed while identity was loading',
undefined,
undefined
);
expect(onSendMock).toHaveBeenCalledWith('typed while identity was loading', {
attachments: undefined,
submission: undefined,
onOptimisticSend: expect.any(Function),
});
});
});
63 changes: 54 additions & 9 deletions apps/mobile/src/components/agents/chat-composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,14 +127,19 @@ type AndroidDismissKeyboardGesture = {
/** Imperative handle the host uses to set composer text (Retry / Copy to composer). */
export type ChatComposerControl = {
setText: (text: string) => void;
hasContent: () => boolean;
restoreAttachments: (parts: readonly { filename?: string; mime: string; url: string }[]) => void;
};

/** Optional send extras the composer threads through to the host's `onSend`. */
export type ChatComposerSendOptions = {
attachments?: AgentAttachmentWire;
submission?: AgentAttachmentSubmissionPayload;
onOptimisticSend?: () => void;
};

type ChatComposerProps = {
onSend: (
text: string,
attachments?: AgentAttachmentWire,
submission?: AgentAttachmentSubmissionPayload
) => void | Promise<void>;
onSend: (text: string, options?: ChatComposerSendOptions) => void | Promise<void>;
onSendCommand: (command: string, argumentsText: string) => Promise<boolean>;
onCreateSession: () => Promise<boolean>;
onRestartSession: () => Promise<boolean>;
Expand Down Expand Up @@ -464,12 +469,26 @@ export function ChatComposer({
const applyComposerTextRef = useRef(applyComposerText);
applyComposerTextRef.current = applyComposerText;

// Imperative occupancy check: the host asks whether the composer holds any
// text or attachment so a queued-message cancel can pick restore vs drop.
const hasContentRef = useRef(
() => textRef.current.trim().length > 0 || upload.attachments.length > 0
);
hasContentRef.current = () => textRef.current.trim().length > 0 || upload.attachments.length > 0;

const restoreFilePartsRef = useRef(upload.restoreFileParts);
restoreFilePartsRef.current = upload.restoreFileParts;

useImperativeHandle(
controlRef,
() => ({
setText: (text: string) => {
applyComposerTextRef.current(text);
},
hasContent: () => hasContentRef.current(),
restoreAttachments: parts => {
restoreFilePartsRef.current(parts);
},
}),
[]
);
Expand Down Expand Up @@ -819,14 +838,40 @@ export function ChatComposer({
},
confirmExitSession: showRemoteSessionExitConfirmation,
onSendPrompt: async prompt => {
await onSend(prompt, uploaded?.wire, uploaded?.submission);
const optimisticChips = upload.attachments;
try {
await onSend(prompt, {
attachments: uploaded?.wire,
submission: uploaded?.submission,
onOptimisticSend: () => {
// The optimistic row is already in the transcript. Clear the
// draft and chips (non-destructively) and stop the spinner so
// the prompt never renders in both the transcript and the input.
clearDraft();
upload.clearOptimistic();
Keyboard.dismiss();
setIsSending(false);
Comment thread
iscekic marked this conversation as resolved.
},
});
// The optimistic clear keeps the path so a failed-send restore
// can retry under it. Only a completed send rotates the upload
// path and submission messageUuid for the next message.
upload.commitSent();
} catch (error) {
// Transport failure: restore only when the composer is still
// empty, so a newer draft is never overwritten.
if (!hasContentRef.current()) {
if (prompt !== '') {
applyComposerText(prompt);
}
upload.restoreChips(optimisticChips);
}
throw error;
}
},
},
{
clearDraft,
resetAttachments: () => {
upload.reset();
},
dismiss: () => {
Keyboard.dismiss();
},
Expand Down
87 changes: 87 additions & 0 deletions apps/mobile/src/components/agents/message-bubble.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,8 @@ async function renderBubbleWithHandlers(
deliveryState?: MessageDeliveryState;
onRetryMessage?: (m: StoredMessage) => void;
onCopyToComposer?: (text: string) => void;
onCancelQueued?: (m: StoredMessage) => void;
onRestoreQueued?: (m: StoredMessage) => void;
}
): Promise<unknown> {
const { MessageBubble } = await import('./message-bubble');
Expand Down Expand Up @@ -318,6 +320,91 @@ describe('MessageBubble failure footer', () => {
});
});

describe('MessageBubble cancel queued and restore', () => {
it('renders the Cancel button when the message is queued and wired', async () => {
const tree = await renderBubbleWithHandlers(userMessage('m-cancel'), {
deliveryState: { status: 'queued' },
onCancelQueued: vi.fn<(message: StoredMessage) => void>(),
});
expect(findText(tree, t => t === 'Cancel message')).toBe(true);
const cancel = findElementByType(
tree,
'Button',
p => p.accessibilityLabel === 'Cancel queued message'
);
expect(cancel).not.toBeNull();
expect(cancel?.props.accessibilityRole).toBe('button');
});

it('omits the Cancel button when no onCancelQueued is wired', async () => {
const tree = await renderBubbleWithHandlers(userMessage('m-cancel-nowired'), {
deliveryState: { status: 'queued' },
});
expect(
findElementByType(tree, 'Button', p => p.accessibilityLabel === 'Cancel queued message')
).toBeNull();
});

it('omits the Cancel button when the message is dequeued', async () => {
const tree = await renderBubbleWithHandlers(userMessage('m-cancel-dequeued'), {
onCancelQueued: vi.fn<(message: StoredMessage) => void>(),
});
expect(
findElementByType(tree, 'Button', p => p.accessibilityLabel === 'Cancel queued message')
).toBeNull();
});

it('renders the Restore button when wired and the message is not queued', async () => {
const tree = await renderBubbleWithHandlers(userMessage('m-restore'), {
onRestoreQueued: vi.fn<(message: StoredMessage) => void>(),
});
expect(findText(tree, t => t === 'Restore')).toBe(true);
const restore = findElementByType(
tree,
'Button',
p => p.accessibilityLabel === 'Restore canceled message to the composer'
);
expect(restore).not.toBeNull();
expect(restore?.props.accessibilityRole).toBe('button');
});

it('presses Cancel and Restore to call their handlers with the message', async () => {
const message = userMessage('m-press-cancel');
const onCancelQueued = vi.fn<(message: StoredMessage) => void>();
const onRestoreQueued = vi.fn<(message: StoredMessage) => void>();

const cancelTree = await renderBubbleWithHandlers(message, {
deliveryState: { status: 'queued' },
onCancelQueued,
});
const cancel = findElementByType(
cancelTree,
'Button',
p => p.accessibilityLabel === 'Cancel queued message'
);
expect(cancel).not.toBeNull();
if (!cancel) {
throw new Error('expected Cancel button');
}
(cancel.props.onPress as () => void)();
expect(onCancelQueued).toHaveBeenCalledWith(message);

const restoreMessage = userMessage('m-press-restore');
const restoreTree = await renderBubbleWithHandlers(restoreMessage, { onRestoreQueued });
const restore = findElementByType(
restoreTree,
'Button',
p => p.accessibilityLabel === 'Restore canceled message to the composer'
);
expect(restore).not.toBeNull();
if (!restore) {
throw new Error('expected Restore button');
}
(restore.props.onPress as () => void)();
expect(onRestoreQueued).toHaveBeenCalledWith(restoreMessage);
});
});

describe('MessageBubble copy-to-composer human text', () => {
it('passes only the first human text part to Copy, not the synthesized notice', async () => {
const message = userMessage('m-copy-human');
Expand Down
Loading