From cf7423c8f4ef4b53c5004f7ca4c99f9435fd3686 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:03:26 +0000 Subject: [PATCH] feat(mobile): redesign the chat composer with file attachments and inline skills (#30359) * refactor(native-chat): move the slash picker builder and upload chunking to shared Mobile builds the same skill and command list as desktop. * feat(mobile): redesign the chat composer with file attachments and inline skills * fix(mobile): the composer field owns its input style The page's font-size census cannot follow a style passed in as a prop. --- config/scripts/mobile-web-page-routes.mjs | 6 +- .../src/components/MobileSegmentedControl.tsx | 10 +- .../mobile-web-shell/bridge-host-init.test.ts | 1 + .../bridge/bridge-media-verbs.ts | 17 ++ .../bridge/bridge-native-verbs.ts | 10 +- .../bridge/bridge-port-pair-test-harness.ts | 2 + .../bridge/use-native-verbs.test.tsx | 2 + .../bridge/use-native-verbs.ts | 8 + .../mobile-web-shell/media-handle-registry.ts | 9 + .../page-route-policy.test.ts | 1 + mobile/src/platform/media-picker-contract.ts | 25 ++ .../src/platform/media-picker-test-shell.ts | 8 + mobile/src/platform/media-picker.test.ts | 18 ++ mobile/src/platform/media-picker.ts | 4 +- mobile/src/platform/media-picker.web.test.tsx | 72 ++++- mobile/src/platform/media-picker.web.ts | 54 +++- .../src/platform/mobile-picked-file-name.ts | 25 ++ mobile/src/platform/native-media.test.ts | 62 ++++ mobile/src/platform/native-media.ts | 29 +- ...obileNativeChatComposer.structured.test.ts | 157 ++++++++++ .../session/MobileNativeChatComposer.test.ts | 50 ++-- .../src/session/MobileNativeChatComposer.tsx | 280 +++++++----------- .../MobileNativeChatComposerAttachments.tsx | 171 +++++++++++ .../session/MobileNativeChatComposerField.tsx | 24 ++ .../MobileNativeChatComposerField.web.tsx | 73 +++++ .../session/MobileNativeChatComposerMenu.tsx | 142 +++++++++ .../MobileNativeChatComposerSuggestions.tsx | 82 ----- .../src/session/MobileNativeChatOverlay.tsx | 5 + .../MobileNativeChatQueuedMessages.test.tsx | 7 +- .../MobileNativeChatQueuedMessages.tsx | 2 +- ...bileNativeChatSessionOptionPickers.test.ts | 56 +++- .../MobileNativeChatSessionOptionPickers.tsx | 218 ++++++++------ .../MobileNativeChatSessionOptionRows.tsx | 96 +++--- .../MobileNativeChatSessionOptionSegments.tsx | 66 +++++ .../session/MobileNativeChatStopButton.tsx | 23 +- ...NativeChatView.prompt-presentation.test.ts | 8 +- mobile/src/session/MobileNativeChatView.tsx | 21 +- .../session/MobileSessionActiveContent.tsx | 2 + .../__mocks__/mobile-prompt-controller.ts | 7 +- ...le-agent-session-attachment-upload.test.ts | 129 ++++++++ .../mobile-agent-session-attachment-upload.ts | 88 ++++++ .../mobile-file-search-owner-cleanup.test.tsx | 5 + .../session/mobile-file-source-picker.test.ts | 95 ++++++ .../src/session/mobile-file-source-picker.ts | 53 ++++ .../src/session/mobile-image-source-picker.ts | 18 +- .../mobile-native-chat-attach-actions.ts | 45 +++ .../mobile-native-chat-autocomplete.test.ts | 65 +--- .../mobile-native-chat-autocomplete.ts | 34 +-- ...mobile-native-chat-composer-field-skill.ts | 22 ++ .../mobile-native-chat-composer-layout.ts | 11 - .../mobile-native-chat-composer-menu.test.ts | 128 ++++++++ .../mobile-native-chat-composer-menu.ts | 104 +++++++ .../mobile-native-chat-composer-styles.ts | 122 +++----- .../mobile-native-chat-controller-contract.ts | 7 +- ...mobile-native-chat-file-attachment.test.ts | 89 ++++++ .../mobile-native-chat-file-attachment.ts | 69 +++++ .../mobile-native-chat-image-scope-state.ts | 9 +- .../mobile-native-chat-input-base-styles.ts | 18 +- .../mobile-native-chat-input-styles.ts | 6 +- ...chat-merged-snapshot-parity-hooks.test.tsx | 3 +- ...obile-native-chat-queued-message-styles.ts | 17 +- ...uctured-agent-session-host-support.test.ts | 12 +- ...e-structured-agent-session-host-support.ts | 6 +- .../use-mobile-native-chat-composer-menu.ts | 159 ++++++++++ ...use-mobile-native-chat-file-attachments.ts | 89 ++++++ ...se-mobile-native-chat-image-attachments.ts | 25 +- .../use-mobile-native-chat-input-lease.ts | 9 +- ...-mobile-native-chat-structured-composer.ts | 50 ++++ .../session/use-mobile-session-attachments.ts | 60 ++-- ...-mobile-session-image-attachments.test.tsx | 12 +- .../use-mobile-session-image-attachments.ts | 31 +- .../use-mobile-structured-agent-options.ts | 4 +- ...tured-agent-session-prompt-cancel.test.tsx | 3 +- ...tured-agent-session-queued.test-fixture.ts | 3 +- ...ile-structured-agent-session-send.test.tsx | 21 ++ ...bile-structured-native-chat-send-bridge.ts | 15 +- ...use-mobile-structured-send-with-outcome.ts | 25 +- ...obile-structured-session-commands.test.tsx | 110 +++++++ .../use-mobile-structured-session-commands.ts | 78 +++++ ...-mobile-structured-turn-indicator.test.tsx | 3 +- mobile/src/theme/mobile-theme.ts | 12 +- mobile/web-entry/web-overrides.json | 6 +- src/main/runtime/mobile-rpc-allowlist.test.ts | 11 + ...ructured-agent-session-attachments.test.ts | 2 + ...untime-rpc-mobile-agent-session-methods.ts | 7 +- .../jira-issue-assignee-control.tsx | 2 +- .../src/components/jira-issue-sorter.ts | 2 +- .../NativeChatAutocompleteMenus.test.tsx | 2 +- .../native-chat/NativeChatComposer.tsx | 4 +- .../native-chat-composer-state.test.ts | 8 +- .../native-chat/native-chat-composer-state.ts | 21 +- .../use-native-chat-composer-catalog.test.tsx | 17 +- .../use-native-chat-composer-catalog.ts | 13 +- .../use-native-chat-composer-submit.test.tsx | 2 +- .../use-native-chat-composer-submit.ts | 5 +- .../use-native-chat-picker-state.ts | 19 +- .../components/skills/skill-delete-copy.ts | 2 +- .../task-page-linear-jira-list-model.tsx | 2 +- .../src/lib/browser-palette-search.ts | 2 +- .../src/lib/simulator-palette-search.ts | 2 +- .../src/lib/workspace-tab-palette-results.ts | 2 +- .../web-agent-session-attachment-upload.ts | 23 +- src/shared/agent-session-attachments.ts | 24 ++ .../locale-text-collators.test.ts | 0 .../lib => shared}/locale-text-collators.ts | 0 .../native-chat-picker-items.ts | 48 ++- src/shared/native-chat-slash-commands.test.ts | 4 +- src/shared/native-chat-slash-commands.ts | 15 +- src/shared/protocol-version.ts | 2 + src/shared/runtime-capability-constants.ts | 3 + 110 files changed, 3112 insertions(+), 855 deletions(-) create mode 100644 mobile/src/platform/mobile-picked-file-name.ts create mode 100644 mobile/src/session/MobileNativeChatComposer.structured.test.ts create mode 100644 mobile/src/session/MobileNativeChatComposerAttachments.tsx create mode 100644 mobile/src/session/MobileNativeChatComposerField.tsx create mode 100644 mobile/src/session/MobileNativeChatComposerField.web.tsx create mode 100644 mobile/src/session/MobileNativeChatComposerMenu.tsx delete mode 100644 mobile/src/session/MobileNativeChatComposerSuggestions.tsx create mode 100644 mobile/src/session/MobileNativeChatSessionOptionSegments.tsx create mode 100644 mobile/src/session/mobile-agent-session-attachment-upload.test.ts create mode 100644 mobile/src/session/mobile-agent-session-attachment-upload.ts create mode 100644 mobile/src/session/mobile-file-source-picker.test.ts create mode 100644 mobile/src/session/mobile-file-source-picker.ts create mode 100644 mobile/src/session/mobile-native-chat-attach-actions.ts create mode 100644 mobile/src/session/mobile-native-chat-composer-field-skill.ts create mode 100644 mobile/src/session/mobile-native-chat-composer-menu.test.ts create mode 100644 mobile/src/session/mobile-native-chat-composer-menu.ts create mode 100644 mobile/src/session/mobile-native-chat-file-attachment.test.ts create mode 100644 mobile/src/session/mobile-native-chat-file-attachment.ts create mode 100644 mobile/src/session/use-mobile-native-chat-composer-menu.ts create mode 100644 mobile/src/session/use-mobile-native-chat-file-attachments.ts create mode 100644 mobile/src/session/use-mobile-native-chat-structured-composer.ts create mode 100644 mobile/src/session/use-mobile-structured-session-commands.test.tsx create mode 100644 mobile/src/session/use-mobile-structured-session-commands.ts rename src/{renderer/src/lib => shared}/locale-text-collators.test.ts (100%) rename src/{renderer/src/lib => shared}/locale-text-collators.ts (100%) rename src/{renderer/src/components/native-chat => shared}/native-chat-picker-items.ts (89%) diff --git a/config/scripts/mobile-web-page-routes.mjs b/config/scripts/mobile-web-page-routes.mjs index 9d0f912f9fc..e75ea674f17 100644 --- a/config/scripts/mobile-web-page-routes.mjs +++ b/config/scripts/mobile-web-page-routes.mjs @@ -145,7 +145,7 @@ export const MOBILE_WEB_PAGE_ROUTES = [ // is why the fourth verb this list carried is gone). Ruling 4's degradation is retired with them: // the page no longer falls back to the vendored module's denied microphone. // - // `externalNavigation` is the one optional grant in this list, and it is C8.1's. The HTML preview + // `externalNavigation` is the first optional grant in this list, and it is C8.1's. The HTML preview // renders an agent's artifact in a sealed frame, and a tap on a link inside it becomes a top-frame // navigation only the shell can cancel and open. Without the grant the preview renders the // artifact with its links as text: the document paints, the Preview/Source toggle works, and @@ -170,6 +170,8 @@ export const MOBILE_WEB_PAGE_ROUTES = [ 'native.audio.read', 'native.audio.stop' ], - optionalGrants: ['externalNavigation'] + // `native.media.name` gives a file the composer attaches its real name; an older shell lacks it + // and the page names the file from its type, so the attach still works. + optionalGrants: ['externalNavigation', 'native.media.name'] } ] diff --git a/mobile/src/components/MobileSegmentedControl.tsx b/mobile/src/components/MobileSegmentedControl.tsx index 6637a270563..5c7449dda32 100644 --- a/mobile/src/components/MobileSegmentedControl.tsx +++ b/mobile/src/components/MobileSegmentedControl.tsx @@ -14,12 +14,17 @@ type Props = { fill?: boolean /** The track; its default color suits a panel-colored row. */ style?: StyleProp + /** Shown but not pickable, e.g. while the value it sets is locked. */ + disabled?: boolean } /** A pick-one pill control; each segment reads as a radio button. */ export function MobileSegmentedControl(props: Props) { return ( - + {props.segments.map((segment) => { const active = segment.value === props.selected return ( @@ -27,6 +32,8 @@ export function MobileSegmentedControl(props: Props key={segment.value} accessibilityRole="radio" aria-checked={active} + accessibilityState={{ disabled: props.disabled === true }} + disabled={props.disabled} accessibilityLabel={segment.label} testID={props.testIDPrefix ? `${props.testIDPrefix}${segment.value}` : undefined} onPress={() => props.onSelect(segment.value)} @@ -64,6 +71,7 @@ const styles = StyleSheet.create({ }, segmentFill: { flex: 1, alignItems: 'center', paddingHorizontal: spacing.xs }, segmentActive: { backgroundColor: colors.bgRaised }, + disabled: { opacity: 0.5 }, segmentText: { fontSize: typography.metaSize, color: colors.textSecondary, fontWeight: '600' }, segmentTextActive: { color: colors.textPrimary } }) diff --git a/mobile/src/mobile-web-shell/bridge-host-init.test.ts b/mobile/src/mobile-web-shell/bridge-host-init.test.ts index bda982ee066..63fa2b305d7 100644 --- a/mobile/src/mobile-web-shell/bridge-host-init.test.ts +++ b/mobile/src/mobile-web-shell/bridge-host-init.test.ts @@ -52,6 +52,7 @@ describe('init and state', () => { 'native.media.pick', 'native.media.read', 'native.media.release', + 'native.media.name', 'native.audio.start', 'native.audio.read', 'native.audio.stop', diff --git a/mobile/src/mobile-web-shell/bridge/bridge-media-verbs.ts b/mobile/src/mobile-web-shell/bridge/bridge-media-verbs.ts index 7648c606bd9..3317e5f3578 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-media-verbs.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-media-verbs.ts @@ -115,3 +115,20 @@ export type BridgeMediaChunk = z.infer export const mediaReleaseParamsSchema = z.strictObject({ handle: handleSchema }) export const mediaReleaseResultSchema = z.strictObject({ released: z.boolean() }) + +/** Longest picked-file name the shell hands back; a leaf name, so the usual file-system bound. */ +export const BRIDGE_MEDIA_NAME_MAX_CHARS = 255 + +/** + * The picked file's own name, asked for after `pick` by a page that wants it. + * + * A verb of its own rather than a field on the item, because `mediaItemSchema` is strict on both + * sides: a shell adding the field would refuse every pick of a page built before it. A page reads + * the grant first and names the file itself when an older shell does not serve this. + */ +export const mediaNameParamsSchema = z.strictObject({ handle: handleSchema }) + +/** Null where the picker reported no name, as the photo library and the pasteboard may not. */ +export const mediaNameResultSchema = z.strictObject({ + name: z.string().min(1).max(BRIDGE_MEDIA_NAME_MAX_CHARS).nullable() +}) diff --git a/mobile/src/mobile-web-shell/bridge/bridge-native-verbs.ts b/mobile/src/mobile-web-shell/bridge/bridge-native-verbs.ts index f6cca001e88..25eb3525fa1 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-native-verbs.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-native-verbs.ts @@ -13,7 +13,9 @@ import { mediaReadParamsSchema, mediaReadResultSchema, mediaReleaseParamsSchema, - mediaReleaseResultSchema + mediaReleaseResultSchema, + mediaNameParamsSchema, + mediaNameResultSchema } from './bridge-media-verbs' import { PAGE_STORAGE_MAX_KEY_CHARS } from '../page-storage-keys' @@ -41,6 +43,7 @@ export const BRIDGE_NATIVE_VERB_NAMES = [ 'native.media.pick', 'native.media.read', 'native.media.release', + 'native.media.name', 'native.audio.start', 'native.audio.read', 'native.audio.stop', @@ -119,6 +122,11 @@ export const BRIDGE_NATIVE_VERBS: Readonly { // `ungranted` check every member already makes before a frame is sent. A flag no screen reads // is a capability negotiation that exists only in this file. expect(Object.keys(verbs).sort()).toEqual([ + 'canNameMedia', 'canPickMedia', 'canReadClipboardText', 'canWriteClipboardText', 'granted', + 'nameMedia', 'pickMedia', 'readAudio', 'readClipboardText', diff --git a/mobile/src/mobile-web-shell/bridge/use-native-verbs.ts b/mobile/src/mobile-web-shell/bridge/use-native-verbs.ts index d37af544a1d..4881e597362 100644 --- a/mobile/src/mobile-web-shell/bridge/use-native-verbs.ts +++ b/mobile/src/mobile-web-shell/bridge/use-native-verbs.ts @@ -2,6 +2,7 @@ import { useMemo } from 'react' import { z } from 'zod' import { usePageBridgeClient } from '../../transport/client-context.web' import { + mediaNameResultSchema, mediaPickResultSchema, mediaReadResultSchema, mediaReleaseResultSchema, @@ -62,6 +63,8 @@ export type NativeVerbs = { * three may have an image on the pasteboard, and one that is not never can. */ canPickMedia: boolean + /** Whether the shell can name a picked file; an older one cannot, and the page names it. */ + canNameMedia: boolean writeClipboardText: (value: string) => Promise readClipboardText: () => Promise /** Opens the shell's picker and answers a handle per item; an empty list is a cancelled picker, @@ -72,6 +75,8 @@ export type NativeVerbs = { readMedia: (handle: string, offset: number, length: number) => Promise /** False for a handle this session no longer holds, which is not a fault. */ releaseMedia: (handle: string) => Promise + /** The picker's name for a staged item; null where it reported none. */ + nameMedia: (handle: string) => Promise /** Opens the microphone, running the OS prompt if there is one. A denied microphone and an * engine that would not open are both answers here rather than rejections. */ startAudio: (sampleRate: number) => Promise> @@ -208,6 +213,7 @@ export function useNativeVerbs(): NativeVerbs { canReadClipboardText, canPickMedia: has('native.media.pick') && has('native.media.read') && has('native.media.release'), + canNameMedia: has('native.media.name'), writeClipboardText: async (value) => (await call('native.clipboard.write', { mime, value }, clipboardWriteResultSchema)).written, readClipboardText: async () => @@ -218,6 +224,8 @@ export function useNativeVerbs(): NativeVerbs { call('native.media.read', { handle, offset, length }, mediaReadResultSchema), releaseMedia: async (handle) => (await call('native.media.release', { handle }, mediaReleaseResultSchema)).released, + nameMedia: async (handle) => + (await call('native.media.name', { handle }, mediaNameResultSchema)).name, startAudio: (sampleRate) => call('native.audio.start', { sampleRate }, audioStartResultSchema), readAudio: (maxBytes) => call('native.audio.read', { maxBytes }, audioReadResultSchema), diff --git a/mobile/src/mobile-web-shell/media-handle-registry.ts b/mobile/src/mobile-web-shell/media-handle-registry.ts index b3f93ff9f00..91fe55066f1 100644 --- a/mobile/src/mobile-web-shell/media-handle-registry.ts +++ b/mobile/src/mobile-web-shell/media-handle-registry.ts @@ -31,6 +31,8 @@ export type StagedMedia = { readonly byteLength: number readonly width?: number readonly height?: number + /** The picker's own name for the file, kept for `native.media.name`; never on the minted item. */ + readonly name?: string } /** The byte range one `read` covers, and whether it is the last. */ @@ -134,6 +136,13 @@ export class MediaHandleRegistry { return { uri: record.uri, start: offset, end, eof: end >= record.byteLength } } + /** The picked file's name, or null where the picker reported none. Counts as a touch. */ + name(handle: string): string | null { + const record = this.hold(handle) + record.touchedAt = this.deps.now() + return record.name ?? null + } + /** * False for a handle this session no longer holds: releasing twice is not a fault. * diff --git a/mobile/src/mobile-web-shell/page-route-policy.test.ts b/mobile/src/mobile-web-shell/page-route-policy.test.ts index 10fbeba14e2..9e6e1dec16f 100644 --- a/mobile/src/mobile-web-shell/page-route-policy.test.ts +++ b/mobile/src/mobile-web-shell/page-route-policy.test.ts @@ -204,6 +204,7 @@ describe('the grants this app implements', () => { 'native.media.pick', 'native.media.read', 'native.media.release', + 'native.media.name', 'native.audio.start', 'native.audio.read', 'native.audio.stop', diff --git a/mobile/src/platform/media-picker-contract.ts b/mobile/src/platform/media-picker-contract.ts index 447603d598e..3b776e82016 100644 --- a/mobile/src/platform/media-picker-contract.ts +++ b/mobile/src/platform/media-picker-contract.ts @@ -1,3 +1,5 @@ +import { CLIPBOARD_IMAGE_MAX_SOURCE_BYTES } from '../../../src/shared/clipboard-image' + /** * What picking media means to the screens, free of any device API. * @@ -22,6 +24,27 @@ export type PickedMobileImage = { readonly uri?: string } +/** Any file from the phone's file picker, read whole for the chat attachment upload. */ +export type PickedMobileFile = { + readonly name: string + readonly mime: string + readonly byteLength: number + // Raw base64 (no data: prefix). + readonly base64: string +} + +/** The most a picked file may weigh: what the OTA shell stages, so both halves refuse alike. */ +export const MOBILE_PICKED_FILE_MAX_BYTES = CLIPBOARD_IMAGE_MAX_SOURCE_BYTES + +export class MobileFileTooLargeError extends Error { + constructor(fileName: string | null) { + super( + `${fileName ?? 'That file'} is over ${Math.floor(MOBILE_PICKED_FILE_MAX_BYTES / (1024 * 1024))} MB` + ) + this.name = 'MobileFileTooLargeError' + } +} + export class ImageLibraryPermissionError extends Error { constructor() { super('Photo library permission denied') @@ -43,4 +66,6 @@ export class ImageLibraryPermissionError extends Error { export type MediaPicker = { pickImage: (source: MobileImageSource) => Promise pickImages: (source: MobileImageSource) => AsyncIterable + /** Files of any type, several at once; a cancelled picker yields nothing. */ + pickFiles: () => AsyncIterable } diff --git a/mobile/src/platform/media-picker-test-shell.ts b/mobile/src/platform/media-picker-test-shell.ts index 66b7b484983..6d61910a46c 100644 --- a/mobile/src/platform/media-picker-test-shell.ts +++ b/mobile/src/platform/media-picker-test-shell.ts @@ -8,6 +8,7 @@ */ import { BRIDGE_MEDIA_READ_MAX_BYTES, + mediaNameParamsSchema, mediaPickParamsSchema, mediaReadParamsSchema, mediaReleaseParamsSchema, @@ -21,6 +22,8 @@ export type StagedTestMedia = { readonly mime?: string readonly width?: number readonly height?: number + /** What `native.media.name` answers for this item. */ + readonly name?: string } export type MediaTestShell = { @@ -116,6 +119,11 @@ export function createMediaTestShell(options: { eof: end >= last }) } + if (verb === 'native.media.name') { + const { handle } = mediaNameParamsSchema.parse(params) + calls.push(`name ${handle}`) + return Promise.resolve({ name: hold(handle).name ?? null }) + } const { handle } = mediaReleaseParamsSchema.parse(params) calls.push(`release ${handle}`) const held = live.delete(handle) diff --git a/mobile/src/platform/media-picker.test.ts b/mobile/src/platform/media-picker.test.ts index 7525c9a4112..65625a59de3 100644 --- a/mobile/src/platform/media-picker.test.ts +++ b/mobile/src/platform/media-picker.test.ts @@ -23,6 +23,15 @@ vi.mock('../session/mobile-image-source-picker', () => ({ } })) +vi.mock('../session/mobile-file-source-picker', () => ({ + pickMobileFiles: () => { + calls.push('pickMobileFiles') + return (async function* () { + yield { name: 'spec.pdf', mime: 'application/pdf', byteLength: 2, base64: 'AAA=' } + })() + } +})) + import { useMediaPicker } from './media-picker' beforeEach(() => { @@ -47,6 +56,15 @@ describe('picking media on a phone', () => { expect(calls).toEqual(['pickMobileImages files']) }) + it('opens the document picker for files of any type', async () => { + const names: string[] = [] + for await (const file of useMediaPicker().pickFiles()) { + names.push(file.name) + } + expect(names).toEqual(['spec.pdf']) + expect(calls).toEqual(['pickMobileFiles']) + }) + it('answers one object across renders, so a caller may hold it in a dependency list', () => { expect(useMediaPicker()).toBe(useMediaPicker()) }) diff --git a/mobile/src/platform/media-picker.ts b/mobile/src/platform/media-picker.ts index 4125d7f4550..1e9fe94c928 100644 --- a/mobile/src/platform/media-picker.ts +++ b/mobile/src/platform/media-picker.ts @@ -1,4 +1,5 @@ import { pickMobileImage, pickMobileImages } from '../session/mobile-image-source-picker' +import { pickMobileFiles } from '../session/mobile-file-source-picker' import type { MediaPicker } from './media-picker-contract' /** @@ -14,7 +15,8 @@ import type { MediaPicker } from './media-picker-contract' */ const devicePicker: MediaPicker = { pickImage: (source) => pickMobileImage(source), - pickImages: (source) => pickMobileImages(source) + pickImages: (source) => pickMobileImages(source), + pickFiles: () => pickMobileFiles() } export function useMediaPicker(): MediaPicker { diff --git a/mobile/src/platform/media-picker.web.test.tsx b/mobile/src/platform/media-picker.web.test.tsx index dbf1adf185a..07647edf07f 100644 --- a/mobile/src/platform/media-picker.web.test.tsx +++ b/mobile/src/platform/media-picker.web.test.tsx @@ -36,7 +36,12 @@ import { } from './media-picker-test-shell' import { NativeVerbError } from '../mobile-web-shell/bridge/use-native-verbs' import { useMediaPicker } from './media-picker.web' -import type { MediaPicker, PickedMobileImage } from './media-picker-contract' +import { + MobileFileTooLargeError, + type MediaPicker, + type PickedMobileFile, + type PickedMobileImage +} from './media-picker-contract' /** Three chunks: two full ones and a short tail, so the `eof` boundary is not the cap boundary. */ const THREE_CHUNKS = BRIDGE_MEDIA_READ_MAX_BYTES * 2 + 17 @@ -296,3 +301,68 @@ describe('picking media from inside the shell', () => { expect(shell.calls).toEqual([]) }) }) + +describe('picking files from inside the shell', () => { + async function collectFiles(pair: BridgePortPair, picker: MediaPicker) { + const taken: PickedMobileFile[] = [] + const iterator = picker.pickFiles()[Symbol.asyncIterator]() + for (;;) { + const next = await settle(pair, iterator.next()) + if (next.done === true) { + return taken + } + taken.push(next.value) + } + } + + it("names each file with the shell's own name when the shell serves it", async () => { + const shell = createMediaTestShell({ + staged: { + files: [{ bytes: stagedTestBytes(12), mime: 'application/pdf', name: 'auth-spec.pdf' }] + } + }) + const pair = pairFor(shell) + const picker = await mount(pair) + + expect(await collectFiles(pair, picker)).toEqual([ + { + name: 'auth-spec.pdf', + mime: 'application/pdf', + byteLength: 12, + base64: encodeTestBase64(stagedTestBytes(12)) + } + ]) + expect(shell.released).toEqual(['media-1']) + }) + + it('names the file from its type on a shell too old to name it', async () => { + const shell = createMediaTestShell({ + staged: { + files: [ + { bytes: stagedTestBytes(3), mime: 'application/pdf', name: 'ignored.pdf' }, + { bytes: stagedTestBytes(3), mime: 'text/markdown' } + ] + } + }) + const pair = pairFor(shell, ['native.media.pick', 'native.media.read', 'native.media.release']) + const picker = await mount(pair) + + const files = await collectFiles(pair, picker) + + expect(files.map((file) => file.name)).toEqual(['attachment-1.pdf', 'attachment-2.md']) + expect(shell.calls.filter((call) => call.startsWith('name'))).toEqual([]) + }) + + it('reads the shell refusing a file over what it stages as the size error', async () => { + const shell = createMediaTestShell({ + staged: {}, + refusePick: new BridgeNativeVerbRefusedError('native_media_too_large', 'too large') + }) + const pair = pairFor(shell) + const picker = await mount(pair) + + const refused = await collectFiles(pair, picker).catch((error: unknown) => error) + + expect(refused).toBeInstanceOf(MobileFileTooLargeError) + }) +}) diff --git a/mobile/src/platform/media-picker.web.ts b/mobile/src/platform/media-picker.web.ts index 59fd6e1c456..deaaf2d212d 100644 --- a/mobile/src/platform/media-picker.web.ts +++ b/mobile/src/platform/media-picker.web.ts @@ -5,8 +5,18 @@ import { releaseAllStagedMedia, releaseStagedMedia } from '../mobile-web-shell/bridge/staged-media-bytes' -import { useNativeVerbs, type NativeVerbs } from '../mobile-web-shell/bridge/use-native-verbs' -import type { MediaPicker, PickedMobileImage } from './media-picker-contract' +import { + NativeVerbError, + useNativeVerbs, + type NativeVerbs +} from '../mobile-web-shell/bridge/use-native-verbs' +import { + MobileFileTooLargeError, + type MediaPicker, + type PickedMobileFile, + type PickedMobileImage +} from './media-picker-contract' +import { mobilePickedFileFallbackName } from './mobile-picked-file-name' /** * Web sibling: the page has no photo library and no Files app, so the shell picks for it and hands @@ -54,6 +64,41 @@ async function* readPicked( } } +/** The shell refuses a file over what it stages; that refusal reads as the size error. */ +async function pickFileItems(verbs: NativeVerbs): Promise { + try { + return await verbs.pickMedia('files', true) + } catch (error) { + throw error instanceof NativeVerbError && error.reason === 'native_media_too_large' + ? new MobileFileTooLargeError(null) + : error + } +} + +async function* readPickedFiles( + verbs: NativeVerbs, + items: readonly BridgeMediaItem[] +): AsyncGenerator { + const unread = [...items] + try { + let index = 0 + while (unread.length > 0) { + const item = unread[0]! + index += 1 + // An older shell cannot name the file; the page names it from its type instead. + const name = + (verbs.canNameMedia ? await verbs.nameMedia(item.handle) : null) ?? + mobilePickedFileFallbackName(item.mime, index) + const base64 = await readStagedMediaItem(verbs, item) + unread.shift() + await releaseStagedMedia(verbs, item.handle) + yield { name, mime: item.mime, byteLength: item.byteLength, base64 } + } + } finally { + await releaseAllStagedMedia(verbs, unread) + } +} + export function useMediaPicker(): MediaPicker { const verbs = useNativeVerbs() @@ -69,6 +114,11 @@ export function useMediaPicker(): MediaPicker { [Symbol.asyncIterator]: async function* () { yield* readPicked(verbs, await verbs.pickMedia(source, true)) } + }), + pickFiles: () => ({ + [Symbol.asyncIterator]: async function* () { + yield* readPickedFiles(verbs, await pickFileItems(verbs)) + } }) }), [verbs] diff --git a/mobile/src/platform/mobile-picked-file-name.ts b/mobile/src/platform/mobile-picked-file-name.ts new file mode 100644 index 00000000000..de379a3b1bb --- /dev/null +++ b/mobile/src/platform/mobile-picked-file-name.ts @@ -0,0 +1,25 @@ +/** Extensions for the types a phone's file picker commonly reports. */ +const EXTENSION_BY_MIME: Readonly> = { + 'application/pdf': 'pdf', + 'application/json': 'json', + 'application/zip': 'zip', + 'application/xml': 'xml', + 'text/plain': 'txt', + 'text/markdown': 'md', + 'text/csv': 'csv', + 'text/html': 'html', + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/gif': 'gif', + 'image/webp': 'webp', + 'image/heic': 'heic' +} + +/** A name for a picked file whose shell could not report one: `attachment-.`. */ +export function mobilePickedFileFallbackName(mime: string, index: number): string { + const base = mime.split(';')[0]!.trim().toLowerCase() + const known = EXTENSION_BY_MIME[base] + const subtype = base.split('/')[1] ?? '' + const extension = known ?? (/^[a-z0-9]{1,8}$/.test(subtype) ? subtype : '') + return extension ? `attachment-${index}.${extension}` : `attachment-${index}` +} diff --git a/mobile/src/platform/native-media.test.ts b/mobile/src/platform/native-media.test.ts index f7a65e6fac6..c917ba85877 100644 --- a/mobile/src/platform/native-media.test.ts +++ b/mobile/src/platform/native-media.test.ts @@ -9,7 +9,9 @@ import { } from '../mobile-web-shell/bridge/bridge-caps' import { BRIDGE_MEDIA_MAX_LIVE_HANDLES, + BRIDGE_MEDIA_NAME_MAX_CHARS, BRIDGE_MEDIA_READ_MAX_BYTES, + mediaNameResultSchema, mediaPickResultSchema, mediaReadResultSchema } from '../mobile-web-shell/bridge/bridge-media-verbs' @@ -471,6 +473,66 @@ describe('reading chunks', () => { }) }) +describe('naming', () => { + async function pickOne(probe: Harness, source: 'library' | 'files'): Promise { + const result = mediaPickResultSchema.parse( + await probe.serve('native.media.pick', { source, multiple: false }) + ) + return result.items[0]?.handle ?? '' + } + + it("answers the document picker's name for a file, never on the picked item itself", async () => { + const probe = harness({ + launchFiles: () => + Promise.resolve({ + canceled: false, + assets: [{ uri: `${CACHE}/doc.pdf`, mimeType: 'application/pdf', name: 'auth-spec.pdf' }] + }) + }) + probe.files.set(`${CACHE}/doc.pdf`, bytesOf(10)) + const result = mediaPickResultSchema.parse( + await probe.serve('native.media.pick', { source: 'files', multiple: false }) + ) + expect(result.items[0]).not.toHaveProperty('name') + await expect( + probe.serve('native.media.name', { handle: result.items[0]?.handle ?? '' }) + ).resolves.toEqual({ name: 'auth-spec.pdf' }) + }) + + it('answers null where the picker reported no name', async () => { + const probe = harness() + probe.files.set(`${CACHE}/lib.png`, bytesOf(4)) + const handle = await pickOne(probe, 'library') + await expect(probe.serve('native.media.name', { handle })).resolves.toEqual({ name: null }) + }) + + it('keeps the extension of a name too long for the verb', async () => { + const long = `${'a'.repeat(400)}.pdf` + const probe = harness({ + launchFiles: () => + Promise.resolve({ + canceled: false, + assets: [{ uri: `${CACHE}/doc.pdf`, mimeType: 'application/pdf', name: long }] + }) + }) + probe.files.set(`${CACHE}/doc.pdf`, bytesOf(4)) + const handle = await pickOne(probe, 'files') + const { name } = mediaNameResultSchema.parse(await probe.serve('native.media.name', { handle })) + expect(name).toHaveLength(BRIDGE_MEDIA_NAME_MAX_CHARS) + expect(name?.endsWith('.pdf')).toBe(true) + }) + + it('refuses a handle the page already released', async () => { + const probe = harness() + probe.files.set(`${CACHE}/lib.png`, bytesOf(4)) + const handle = await pickOne(probe, 'library') + await probe.serve('native.media.release', { handle }) + await expect(probe.serve('native.media.name', { handle })).rejects.toSatisfy( + (error) => refusalOf(error) === 'native_media_handle_unknown' + ) + }) +}) + describe('releasing', () => { it('deletes the staged file and answers false the second time', async () => { const probe = harness() diff --git a/mobile/src/platform/native-media.ts b/mobile/src/platform/native-media.ts index 4569363969f..0f0d8cd333f 100644 --- a/mobile/src/platform/native-media.ts +++ b/mobile/src/platform/native-media.ts @@ -1,5 +1,7 @@ import { + BRIDGE_MEDIA_NAME_MAX_CHARS, BRIDGE_MEDIA_READ_MAX_BYTES, + mediaNameParamsSchema, mediaPickParamsSchema, mediaReadParamsSchema, mediaReleaseParamsSchema, @@ -46,6 +48,10 @@ type PickedAsset = { readonly mimeType?: string | null readonly width?: number readonly height?: number + /** The document picker's name for the file. */ + readonly name?: string | null + /** The photo library's name for the file, where it reports one. */ + readonly fileName?: string | null } type PickerResult = { readonly canceled: boolean; readonly assets?: readonly PickedAsset[] | null } @@ -100,12 +106,14 @@ export function createNativeMediaVerbServer( // answered 0 above, and this is the file the handle will actually be read from. const { size } = deps.openFile(uri) refuseOverStagingCeiling(size) + const name = pickedName(asset) staged.push({ uri, mime: asset.mimeType ?? UNKNOWN_STAGED_MIME, byteLength: size, ...(asset.width === undefined ? {} : { width: asset.width }), - ...(asset.height === undefined ? {} : { height: asset.height }) + ...(asset.height === undefined ? {} : { height: asset.height }), + ...(name === null ? {} : { name }) }) } } catch (error) { @@ -268,11 +276,30 @@ export function createNativeMediaVerbServer( const range = deps.registry.read(handle, offset, length) return { base64: readRange(range.uri, range.start, range.end), eof: range.eof } } + if (verb === 'native.media.name') { + const { handle } = mediaNameParamsSchema.parse(params) + return { name: deps.registry.name(handle) } + } const { handle } = mediaReleaseParamsSchema.parse(params) return { released: deps.registry.release(handle) } } } +/** The picker's name for an asset, cut to what the name verb may answer; null when it gave none. */ +function pickedName(asset: PickedAsset): string | null { + const name = (asset.name ?? asset.fileName ?? '').trim() + if (name.length === 0) { + return null + } + if (name.length <= BRIDGE_MEDIA_NAME_MAX_CHARS) { + return name + } + // Keep a short extension: agents read meaning into it. + const dot = name.lastIndexOf('.') + const extension = dot > 0 && name.length - dot <= 16 ? name.slice(dot) : '' + return name.slice(0, BRIDGE_MEDIA_NAME_MAX_CHARS - extension.length) + extension +} + function readAssets(result: PickerResult): readonly PickedAsset[] { return result.canceled ? [] : (result.assets ?? []) } diff --git a/mobile/src/session/MobileNativeChatComposer.structured.test.ts b/mobile/src/session/MobileNativeChatComposer.structured.test.ts new file mode 100644 index 00000000000..aff184d9332 --- /dev/null +++ b/mobile/src/session/MobileNativeChatComposer.structured.test.ts @@ -0,0 +1,157 @@ +import { createElement, type ComponentProps } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MobileNativeChatComposer as NativeChatComposer } from './MobileNativeChatComposer' + +const getNoComposerEditGeneration = () => 0 + +function MobileNativeChatComposer({ + getComposerEditGeneration = getNoComposerEditGeneration, + ...props +}: Omit, 'getComposerEditGeneration'> & { + getComposerEditGeneration?: () => number +}): React.JSX.Element { + return createElement(NativeChatComposer, { ...props, getComposerEditGeneration }) +} + +vi.mock('react-native', async () => { + const React = await import('react') + return { + ActivityIndicator: 'ActivityIndicator', + Image: 'Image', + Keyboard: { dismiss: vi.fn() }, + Pressable: 'Pressable', + ScrollView: ({ children, ...props }: { children?: React.ReactNode }) => + React.createElement('ScrollView', props, children), + StyleSheet: { + create: (styles: unknown) => styles, + hairlineWidth: 1 + }, + Text: 'Text', + TextInput: 'TextInput', + View: 'View' + } +}) + +vi.mock('lucide-react-native', () => ({ + ArrowUp: 'ArrowUp', + AtSign: 'AtSign', + Image: 'Image', + Paperclip: 'Paperclip', + Check: 'Check', + ChevronDown: 'ChevronDown', + ChevronLeft: 'ChevronLeft', + ChevronRight: 'ChevronRight', + FileText: 'FileText', + Mic: 'Mic', + Package: 'Package', + Plus: 'Plus', + ShieldAlert: 'ShieldAlert', + Square: 'Square', + Terminal: 'Terminal', + X: 'X' +})) + +vi.mock('../components/mounted-bottom-drawer', () => ({ MountedBottomDrawer: 'BottomDrawer' })) +vi.mock('../components/bottom-drawer-modal-host-scope', () => ({ + useBottomDrawerModalHost: () => null +})) + +const structured = ( + overrides: Partial['structured']>> = {} +) => ({ + catalog: { + reported: [ + { name: 'compact', kind: 'command' as const }, + { name: 'review-pr', kind: 'skill' as const, description: 'Review a pull request' } + ], + conversationCommands: [] + }, + onSlashMenuOpen: vi.fn(), + files: [], + onRemoveFile: vi.fn(), + ...overrides +}) + +describe('MobileNativeChatComposer on a structured chat', () => { + let renderer: ReactTestRenderer | null = null + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + const draw = (props: Partial>) => + createElement(MobileNativeChatComposer, { + value: '', + onChangeText: vi.fn(), + onSend: vi.fn().mockResolvedValue(true), + sendSurfaceId: 'tab-a', + getSendCompletionGeneration: () => 0, + agent: 'claude', + ...props + }) + const field = () => renderer!.root.find((node) => node.props.multiline === true) + const labelled = (label: string) => + renderer!.root.findAll( + (node) => node.props.accessibilityLabel === label && node.props.onPress !== undefined + ) + const drawers = () => renderer!.root.findAll((node) => node.props.onHidden !== undefined) + + it('puts a picked skill at the start of the draft, highlighted, and deletes it as one unit', async () => { + const onChangeText = vi.fn() + const catalog = structured() + await act(async () => { + renderer = create(draw({ value: '/re', onChangeText, structured: catalog })) + }) + await act(async () => + field().props.onSelectionChange({ nativeEvent: { selection: { end: 3 } } }) + ) + expect(catalog.onSlashMenuOpen).toHaveBeenCalled() + await act(async () => labelled('/review-pr')[0]!.props.onPress()) + expect(onChangeText).toHaveBeenLastCalledWith('/review-pr ') + expect(field().props.selection).toEqual({ start: 11, end: 11 }) + + await act(async () => + renderer!.update(draw({ value: '/review-pr focus', onChangeText, structured: catalog })) + ) + const token = field().find((node) => node.props.children === '/review-pr') + expect(token.props.style).toMatchObject({ backgroundColor: expect.any(String) }) + await act(async () => field().props.onChangeText('/review-pr focus on auth')) + expect(onChangeText).toHaveBeenLastCalledWith('/review-pr focus on auth') + await act(async () => field().props.onChangeText('/review-prfocus')) + expect(onChangeText).toHaveBeenLastCalledWith('focus') + }) + + it('opens the + sheet on a structured chat, and the photo library at once otherwise', async () => { + const onAttachImage = vi.fn() + await act(async () => { + renderer = create(draw({ onAttachImage, structured: structured() })) + }) + await act(async () => labelled('Add to message')[0]!.props.onPress()) + expect(onAttachImage).not.toHaveBeenCalled() + expect(drawers()).toHaveLength(1) + + await act(async () => renderer!.update(draw({ onAttachImage, structured: null }))) + await act(async () => labelled('Attach image')[0]!.props.onPress()) + expect(onAttachImage).toHaveBeenCalledTimes(1) + }) + + it('holds Send while a file is still uploading, and sends a stored file alone', async () => { + const file = { id: 'f1', name: 'spec.pdf', mime: 'application/pdf', byteLength: 10 } + await act(async () => { + renderer = create( + draw({ + value: 'see attached', + structured: structured({ files: [{ ...file, path: null }] }) + }) + ) + }) + expect(labelled('Send message')[0]!.props.disabled).toBe(true) + await act(async () => + renderer!.update( + draw({ structured: structured({ files: [{ ...file, path: '/s/spec.pdf' }] }) }) + ) + ) + expect(labelled('Send message')[0]!.props.disabled).toBe(false) + }) +}) diff --git a/mobile/src/session/MobileNativeChatComposer.test.ts b/mobile/src/session/MobileNativeChatComposer.test.ts index 48729df8601..a2b92a701c8 100644 --- a/mobile/src/session/MobileNativeChatComposer.test.ts +++ b/mobile/src/session/MobileNativeChatComposer.test.ts @@ -41,9 +41,13 @@ vi.mock('lucide-react-native', () => ({ ChevronDown: 'ChevronDown', ChevronLeft: 'ChevronLeft', ChevronRight: 'ChevronRight', - ImagePlus: 'ImagePlus', + FileText: 'FileText', Mic: 'Mic', + Package: 'Package', + Plus: 'Plus', + ShieldAlert: 'ShieldAlert', Square: 'Square', + Terminal: 'Terminal', X: 'X' })) @@ -116,17 +120,9 @@ describe('MobileNativeChatComposer', () => { const inset = renderer!.root.findByProps({ testID: 'native-chat-composer-inset' }) const actions = renderer!.root.findByProps({ testID: 'native-chat-composer-actions' }) expect(composer.findAllByType('TextInput')).toHaveLength(1) - expect(composer.children[1]).toBe(actions) - expect(inset.props.style).toMatchObject({ - paddingHorizontal: spacing.md, - paddingTop: spacing.sm, - paddingBottom: spacing.md - }) - expect(composer.props.style).toMatchObject({ - borderWidth: 1, - borderRadius: radii.card, - overflow: 'hidden' - }) + expect(composer.children.at(-1)).toBe(actions) + expect(inset.props.style).toMatchObject({ paddingBottom: spacing.sm }) + expect(composer.props.style).toMatchObject({ borderWidth: 1, borderRadius: radii.composer }) }) it('preserves leading whitespace so prose is not turned into a slash command', async () => { @@ -312,7 +308,7 @@ describe('MobileNativeChatComposer', () => { expect(focus).toHaveBeenCalledTimes(1) }) - it('is one row until focused or drafted, keeps its field across the switch, and puts Stop in Send’s slot', async () => { + it('keeps one shape focused or not, and puts Stop in Send’s slot only while nothing is typed', async () => { let fieldMounts = 0 const props = { onChangeText: vi.fn(), @@ -336,18 +332,17 @@ describe('MobileNativeChatComposer', () => { const bar = () => renderer!.root.findByProps({ testID: 'native-chat-composer' }) const labelled = (label: string) => renderer!.root.findAllByProps({ accessibilityLabel: label }) const field = () => renderer!.root.findByType('TextInput') - expect(bar().props.style).toMatchObject({ flexDirection: 'row' }) + const unfocused = bar().props.style expect(labelled('Stop the agent')).toHaveLength(1) expect(labelled('Send message')).toHaveLength(0) await act(async () => field().props.onFocus()) - expect(bar().props.style).not.toHaveProperty('flexDirection') + expect(bar().props.style).toBe(unfocused) await act(async () => field().props.onBlur()) - expect(bar().props.style).toMatchObject({ flexDirection: 'row' }) - // A typed draft stays expanded unfocused and takes Send back, unless a prompt card owns Send. + // A typed draft takes Send back, unless a prompt card owns Send. await act(async () => renderer!.update(draw('queue this'))) - expect(bar().props.style).not.toHaveProperty('flexDirection') + expect(bar().props.style).toBe(unfocused) expect(labelled('Send message')).toHaveLength(1) await act(async () => renderer!.update(draw('queue this', true))) expect(labelled('Stop the agent')).toHaveLength(1) @@ -386,7 +381,6 @@ describe('MobileNativeChatComposer', () => { }) }) const field = () => renderer!.root.findByType('TextInput') - const bar = () => renderer!.root.findByProps({ testID: 'native-chat-composer' }) const press = async (label: string) => act(async () => renderer!.root.findByProps({ accessibilityLabel: label }).props.onPress()) expect(renderer!.root.findAllByType('BottomDrawer')).toHaveLength(0) @@ -396,7 +390,6 @@ describe('MobileNativeChatComposer', () => { await press('effort, Not set') await press('Back to models') await press('Close picker') - expect(bar().props.style).not.toHaveProperty('flexDirection') await act(async () => renderer!.root.findByType('BottomDrawer').props.onHidden()) expect(focus).toHaveBeenCalledTimes(1) for (const id of ['model', 'effort']) { @@ -404,11 +397,8 @@ describe('MobileNativeChatComposer', () => { controller.optionPickerRequest = { id, sequence: controller.optionPickerRequest.sequence + 1 } await act(async () => renderer!.update(draw())) await act(async () => field().props.onBlur()) - expect(bar().props.style).not.toHaveProperty('flexDirection') await act(async () => renderer!.root.findByType('BottomDrawer').props.onClose()) - expect(bar().props.style).not.toHaveProperty('flexDirection') await act(async () => renderer!.root.findByType('BottomDrawer').props.onHidden()) - expect(bar().props.style).toMatchObject({ flexDirection: 'row' }) expect(focus).toHaveBeenCalledTimes(1) } await act(async () => renderer!.unmount()) @@ -494,10 +484,10 @@ describe('MobileNativeChatComposer', () => { await act(async () => input().props.onSelectionChange({ nativeEvent: { selection: { end: 2 } } }) ) - const firstSuggestion = renderer!.root.findAll( - (node) => node.type === 'Pressable' && !node.props.accessibilityLabel - )[0] as { props: { onPress: () => void } } - await act(async () => firstSuggestion.props.onPress()) + const clear = renderer!.root.find( + (node) => node.props.accessibilityLabel === '/clear' && node.props.onPress !== undefined + ) + await act(async () => clear.props.onPress()) expect(onChangeText).toHaveBeenCalledWith('/clear ') // `/clear ` is 7 chars — the caret jumps just past the inserted command + space. expect(input().props.selection).toEqual({ start: 7, end: 7 }) @@ -727,9 +717,9 @@ describe('MobileNativeChatComposer', () => { pendingSend = sendButton().props.onPress() await Promise.resolve() }) - const suggestion = renderer!.root.findAll( - (node) => node.type === 'Pressable' && !node.props.accessibilityLabel - )[0] as { props: { onPress: () => void } } + const suggestion = renderer!.root.find( + (node) => node.props.accessibilityLabel === '/clear' && node.props.onPress !== undefined + ) await act(async () => suggestion.props.onPress()) await act(async () => { resolveSend?.(true) diff --git a/mobile/src/session/MobileNativeChatComposer.tsx b/mobile/src/session/MobileNativeChatComposer.tsx index 6d38153995f..be9060f5974 100644 --- a/mobile/src/session/MobileNativeChatComposer.tsx +++ b/mobile/src/session/MobileNativeChatComposer.tsx @@ -1,51 +1,48 @@ -import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' -import { - ActivityIndicator, - Image, - Keyboard, - Pressable, - ScrollView, - TextInput, - View -} from 'react-native' -import { ArrowUp, ImagePlus, Mic, Square, X } from 'lucide-react-native' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { ActivityIndicator, Keyboard, Pressable, type TextInput, View } from 'react-native' +import { ArrowUp, Mic, Plus, Square } from 'lucide-react-native' import { colors } from '../theme/mobile-theme' -import { structuredSlashCommands } from '../../../src/shared/structured-agent-session-composer' -import type { AgentSessionConversationCommand } from '../../../src/shared/agent-session-conversation-command' -import { - applyAutocomplete, - detectAutocompleteTrigger, - rankSlashCommandSuggestions, - rankSuggestions -} from './mobile-native-chat-autocomplete' -import { - composerSuggestionInsertText, - MobileNativeChatComposerSuggestions, - type ComposerSuggestion -} from './MobileNativeChatComposerSuggestions' +import { MobileNativeChatComposerMenu } from './MobileNativeChatComposerMenu' +import { MobileNativeChatComposerField } from './MobileNativeChatComposerField' +import { MobileNativeChatComposerAttachments } from './MobileNativeChatComposerAttachments' +import type { PendingNativeChatFile } from './mobile-native-chat-file-attachment' +import { ActionSheetModal } from '../components/ActionSheetModal' +import { mobileNativeChatAttachActions } from './mobile-native-chat-attach-actions' import { MobileNativeChatSessionOptionPickers, type MobileNativeChatPickerPresentation, type MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers' import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment' -import { mobileNativeChatInputStyles } from './mobile-native-chat-input-styles' -import { getMobileNativeChatCommands } from './mobile-native-chat-send-classification' +import type { MobileComposerStructuredCatalog } from './mobile-native-chat-composer-menu' +import { useMobileNativeChatComposerMenu } from './use-mobile-native-chat-composer-menu' import { keepHeldPressThroughLongPress } from './held-press-long-press' -import { - mobileNativeChatComposerExpanded, - mobileNativeChatComposerShowsStop -} from './mobile-native-chat-composer-layout' +import { mobileNativeChatComposerShowsStop } from './mobile-native-chat-composer-layout' import { MobileNativeChatStopButton } from './MobileNativeChatStopButton' import { mobileNativeChatComposerStyles as styles } from './mobile-native-chat-composer-styles' const NO_FILE_PATHS: string[] = [] const NO_ATTACHMENTS: PendingNativeChatImage[] = [] +const NO_FILES: PendingNativeChatFile[] = [] + +/** What only a structured chat's composer has: the + sheet, files, and the session's skills. */ +export type MobileNativeChatStructuredComposer = { + catalog: MobileComposerStructuredCatalog + /** The `/` menu opened, so the session's list is worth reading again. */ + onSlashMenuOpen: () => void + /** Any file from the phone, uploaded to the host; absent where the host cannot take one. */ + onAttachFiles?: () => void + /** Files riding on the next send; one still uploading holds Send. */ + files: readonly PendingNativeChatFile[] + onRemoveFile: (id: string) => void +} type Props = { /** Lets the owner focus the field, e.g. after Edit moves a queued message into it. */ inputRef?: React.RefObject - structuredCommands?: readonly AgentSessionConversationCommand[] + /** Absent on a terminal-backed chat, whose + only adds photos and whose menu keeps its curated + * commands. */ + structured?: MobileNativeChatStructuredComposer | null /** Controlled composer text — owned by the parent so dictation can write to it. */ value: string onChangeText: (text: string) => void @@ -58,8 +55,7 @@ type Props = { getComposerEditGeneration: () => number /** Active tab's agent — the slash autocomplete serves its command catalog. */ agent?: string | null - /** Model/session-option pickers shown in the composer action row; null when - * the agent has no session-option catalog. */ + /** The model pill and its sheet; null when the agent has no session-option catalog. */ sessionOptions?: MobileNativeChatSessionOptionPickersProps | null onAttachImage?: () => void /** Images picked-and-uploaded but not yet sent — shown as removable thumbnails @@ -91,8 +87,8 @@ export function MobileNativeChatComposer({ sendSurfaceId, getSendCompletionGeneration, getComposerEditGeneration, - agent, - structuredCommands, + agent = null, + structured = null, sessionOptions, onAttachImage, attachments = NO_ATTACHMENTS, @@ -110,10 +106,10 @@ export function MobileNativeChatComposer({ onNeedFiles, stop = null }: Props): React.JSX.Element { - const [cursor, setCursor] = useState(0) const fallbackInputRef = useRef(null) const fieldRef = inputRef ?? fallbackInputRef const [focused, setFocused] = useState(false) + const [attachOpen, setAttachOpen] = useState(false) const [picker, setPicker] = useState(null) const request = sessionOptions?.controller.optionPickerRequest const [lastRequest, setLastRequest] = useState(request) @@ -121,12 +117,17 @@ export function MobileNativeChatComposer({ setLastRequest(request) setPicker({ page: request.id, refocus: false }) } - // Transiently drives the native caret after a mid-text autocomplete insert, - // then released on the next selection change so manual caret placement still - // works (a permanently controlled `selection` breaks it in React Native). - const [pendingSelection, setPendingSelection] = useState<{ start: number; end: number } | null>( - null - ) + const files = structured?.files ?? NO_FILES + const menu = useMobileNativeChatComposerMenu({ + value, + onChangeText, + agent, + structuredCatalog: structured?.catalog, + filePaths, + onNeedFiles, + onSlashMenuOpen: structured?.onSlashMenuOpen, + focusField: () => fieldRef.current?.focus() + }) const sendingRef = useRef(false) const mountedRef = useRef(true) const sendSurfaceIdRef = useRef(sendSurfaceId) @@ -138,60 +139,27 @@ export function MobileNativeChatComposer({ } }, [sendSurfaceId]) const [sending, setSending] = useState(false) - const trimmed = value.trim() const sessionOptionDispatching = sessionOptions?.controller.pendingId != null - // An attached image alone is a valid send (desktop parity), so the image rides - // along even when the user sends no accompanying text. + const filesUploading = files.some((file) => file.path === null) + const attachmentCount = attachments.length + files.length + // An attachment alone is a valid send (desktop parity), so it rides even with no text. const canSend = - (trimmed.length > 0 || attachments.length > 0) && + (value.trim().length > 0 || attachmentCount > 0) && !disabled && !sendDisabled && !sending && !isAttaching && + !filesUploading && !sessionOptionDispatching - const composerContent = { draft: value, attachmentCount: attachments.length } - const pickerOpen = picker !== null && sessionOptions != null - const expanded = mobileNativeChatComposerExpanded({ focused, pickerOpen, ...composerContent }) const showsStop = mobileNativeChatComposerShowsStop({ canStop: stop !== null, sendBlocked: sendDisabled, canSend, - editing: isAttaching || micActive, - ...composerContent + editing: isAttaching || filesUploading || micActive, + draft: value, + attachmentCount }) - const trigger = useMemo(() => detectAutocompleteTrigger(value, cursor), [value, cursor]) - const suggestions = useMemo(() => { - if (!trigger) { - return [] - } - if (trigger.kind === 'slash') { - const commands = - structuredCommands !== undefined - ? structuredSlashCommands(structuredCommands, agent) - : agent - ? getMobileNativeChatCommands(agent) - : [] - // Why: Codex's catalog is 45 commands and this list is a plain ScrollView - // (~5 rows visible), so an uncapped `/` would mount every row and - // re-reconcile them on each streaming tick right above the transcript. - return rankSlashCommandSuggestions(commands, trigger.query, 12).map((command) => ({ - kind: 'command' as const, - command - })) - } - return rankSuggestions(filePaths, trigger.query).map((path) => ({ - kind: 'file' as const, - path - })) - }, [trigger, filePaths, agent, structuredCommands]) - - useEffect(() => { - if (trigger?.kind === 'file') { - onNeedFiles?.(trigger.query) - } - }, [onNeedFiles, trigger?.kind, trigger?.query]) - useEffect(() => { mountedRef.current = true return () => { @@ -200,24 +168,6 @@ export function MobileNativeChatComposer({ } }, []) - const handleChange = (next: string): void => { - onChangeText(next) - } - - const pickSuggestion = (suggestion: ComposerSuggestion): void => { - if (!trigger) { - return - } - const { text: nextText, cursor: nextCursor } = applyAutocomplete( - value, - trigger, - composerSuggestionInsertText(suggestion) - ) - onChangeText(nextText) - setCursor(nextCursor) - setPendingSelection({ start: nextCursor, end: nextCursor }) - } - const handleSend = async (): Promise => { if (!canSend || sendingRef.current) { return @@ -238,7 +188,7 @@ export function MobileNativeChatComposer({ sendCompletionGeneration === getSendCompletionGeneration() && composerEditGeneration === getComposerEditGeneration() ) { - setCursor(0) + menu.resetCursor() // Why: the turn is now the agent's — the keyboard would cover the reply. // A rejected send keeps it up so the handed-back draft stays editable. Keyboard.dismiss() @@ -249,77 +199,33 @@ export function MobileNativeChatComposer({ } } - const attachButton = onAttachImage ? ( - [styles.iconButton, pressed && styles.pressed]} - onPress={onAttachImage} - disabled={isAttaching || disabled} - > - {isAttaching ? ( - - ) : ( - - )} - - ) : null + // A terminal-backed chat has only photos to add, so its + opens the library at once. + const attachPress = structured ? () => setAttachOpen(true) : onAttachImage + const attachLabel = structured ? 'Add to message' : 'Attach image' return ( - {suggestions.length > 0 ? ( - - ) : null} - {attachments.length > 0 ? ( - - {attachments.map((attachment) => ( - - - {onRemoveAttachment ? ( - onRemoveAttachment(attachment.id)} - hitSlop={8} - > - - - ) : null} - - ))} - + {menu.rows.length > 0 ? ( + ) : null} - - {/* Collapsed puts attach before the field; the field keeps its child slot either way so - the switch never remounts it (that would drop focus mid-tap). */} - {expanded ? null : attachButton} - + + setFocused(true)} onBlur={() => setFocused(false)} // Controlled only transiently right after an autocomplete insert. - selection={pendingSelection ?? undefined} - onSelectionChange={(e) => { - setCursor(e.nativeEvent.selection.end) - setPendingSelection(null) - }} + selection={menu.pendingSelection ?? undefined} + onSelectionChange={(e) => menu.onSelectionChange(e.nativeEvent.selection)} placeholder={placeholder} placeholderTextColor={colors.textMuted} selectionColor={colors.accentBlue} @@ -329,17 +235,25 @@ export function MobileNativeChatComposer({ // The lock gates sending; the draft survives and rides the next send. textAlignVertical="top" /> - - {expanded ? attachButton : null} + + {attachPress ? ( + [styles.controlButton, pressed && styles.pressed]} + onPress={attachPress} + disabled={isAttaching || disabled} + > + {isAttaching ? ( + + ) : ( + + )} + + ) : null} {sessionOptions ? ( - // Stays mounted collapsed so an open drawer, or a picker a command asks for, survives. setPicker((current) => current && { ...current, page })} onDrawerOpen={(page) => setPicker({ page, refocus: focused })} @@ -351,11 +265,11 @@ export function MobileNativeChatComposer({ }} /> ) : null} - {expanded ? : null} + {onMicPress ? ( [styles.iconButton, pressed && styles.pressed]} + style={({ pressed }) => [styles.ghostButton, pressed && styles.pressed]} // Hold mode is walkie-talkie (press-in/out); toggle mode taps. onPress={dictationMode === 'hold' ? undefined : onMicPress} onPressIn={dictationMode === 'hold' ? onMicPressIn : undefined} @@ -368,7 +282,7 @@ export function MobileNativeChatComposer({ {micActive ? ( @@ -398,15 +312,29 @@ export function MobileNativeChatComposer({ disabled={!canSend} > )} + {structured ? ( + setAttachOpen(false)} + actions={mobileNativeChatAttachActions({ + onPhotos: onAttachImage, + onFiles: structured.onAttachFiles, + onWorkspaceFile: menu.openFileMenu, + onSkills: () => menu.openSlashMenu('skill'), + onCommands: () => menu.openSlashMenu('command'), + skillCount: menu.skillCount + })} + /> + ) : null} ) } diff --git a/mobile/src/session/MobileNativeChatComposerAttachments.tsx b/mobile/src/session/MobileNativeChatComposerAttachments.tsx new file mode 100644 index 00000000000..b2a77424c3e --- /dev/null +++ b/mobile/src/session/MobileNativeChatComposerAttachments.tsx @@ -0,0 +1,171 @@ +import { + ActivityIndicator, + Image, + Pressable, + ScrollView, + StyleSheet, + Text, + View +} from 'react-native' +import { FileText, X } from 'lucide-react-native' +import { colors, radii, spacing } from '../theme/mobile-theme' +import { formatPreviewByteLength } from '../files/mobile-file-preview-response' +import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment' +import type { PendingNativeChatFile } from './mobile-native-chat-file-attachment' + +const THUMB_SIZE = 58 + +/** The card's second line: the file's type, then its size. */ +function composerFileMeta(file: Pick) { + const extension = /\.([a-z0-9]{1,8})$/i.exec(file.name)?.[1] + const subtype = /^[^/]+\/([a-z0-9]{1,8})$/i.exec(file.mime)?.[1] + const type = (extension ?? subtype ?? 'File').toUpperCase() + return `${type} · ${formatPreviewByteLength(file.byteLength)}` +} + +/** Everything riding on the next send, inside the composer box above the text. */ +export function MobileNativeChatComposerAttachments({ + images, + files, + onRemoveImage, + onRemoveFile +}: { + images: readonly PendingNativeChatImage[] + files: readonly PendingNativeChatFile[] + onRemoveImage?: (id: string) => void + onRemoveFile?: (id: string) => void +}): React.JSX.Element | null { + if (images.length === 0 && files.length === 0) { + return null + } + return ( + + {images.map((image) => ( + + + {onRemoveImage ? ( + onRemoveImage(image.id)} /> + ) : null} + + ))} + {files.map((file) => ( + + + + + + + {file.name} + + + {composerFileMeta(file)} + + + {file.path === null ? ( + + + + ) : null} + {onRemoveFile ? ( + onRemoveFile(file.id)} /> + ) : null} + + ))} + + ) +} + +function RemoveBadge({ label, onPress }: { label: string; onPress: () => void }) { + return ( + + + + ) +} + +const styles = StyleSheet.create({ + strip: { + flexGrow: 0, + marginBottom: 10 + }, + stripContent: { + gap: spacing.sm, + // Room for the remove badges, which overhang their item's top-right corner. + paddingTop: 5, + paddingLeft: 2, + paddingRight: 5 + }, + thumb: { + width: THUMB_SIZE, + height: THUMB_SIZE, + borderRadius: radii.composerItem, + backgroundColor: colors.composerControl + }, + thumbImage: { + width: '100%', + height: '100%', + borderRadius: radii.composerItem + }, + fileCard: { + height: THUMB_SIZE, + minWidth: 140, + maxWidth: 220, + flexDirection: 'row', + alignItems: 'center', + gap: 9, + paddingLeft: 10, + paddingRight: spacing.md, + borderRadius: radii.composerItem, + backgroundColor: colors.composerControl + }, + fileIcon: { + width: 32, + height: 36, + borderRadius: 7, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.bgRaised + }, + fileText: { + flexShrink: 1, + minWidth: 0 + }, + fileName: { + color: colors.textPrimary, + fontSize: 13, + fontWeight: '500' + }, + fileMeta: { + color: colors.textMuted, + fontSize: 11.5, + marginTop: 1 + }, + uploading: { + ...StyleSheet.absoluteFillObject, + borderRadius: radii.composerItem, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(0, 0, 0, 0.45)' + }, + // Most of the badge sits inside its item, which Android needs to deliver the tap; the strip's + // padding keeps the 5pt overhang from being clipped. + remove: { + position: 'absolute', + top: -5, + right: -5, + width: 20, + height: 20, + borderRadius: 10, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.bgRaised, + borderWidth: 2, + borderColor: colors.composerSurface + } +}) diff --git a/mobile/src/session/MobileNativeChatComposerField.tsx b/mobile/src/session/MobileNativeChatComposerField.tsx new file mode 100644 index 00000000000..77f17210a3d --- /dev/null +++ b/mobile/src/session/MobileNativeChatComposerField.tsx @@ -0,0 +1,24 @@ +import { Text, TextInput } from 'react-native' +import { + mobileNativeChatComposerSkillStyles as styles, + type MobileNativeChatComposerFieldProps +} from './mobile-native-chat-composer-field-skill' +import { mobileNativeChatInputStyles as inputStyles } from './mobile-native-chat-input-styles' + +/** The composer's text field. Native: the skill is a styled span of the field's own text. */ +export function MobileNativeChatComposerField({ + value, + skillToken, + ref, + ...props +}: MobileNativeChatComposerFieldProps) { + if (!skillToken) { + return + } + return ( + + {skillToken} + {value.slice(skillToken.length)} + + ) +} diff --git a/mobile/src/session/MobileNativeChatComposerField.web.tsx b/mobile/src/session/MobileNativeChatComposerField.web.tsx new file mode 100644 index 00000000000..518aa4fba24 --- /dev/null +++ b/mobile/src/session/MobileNativeChatComposerField.web.tsx @@ -0,0 +1,73 @@ +import { useState } from 'react' +import { StyleSheet, Text, TextInput, View } from 'react-native' +import { colors } from '../theme/mobile-theme' +import { mobileNativeChatInputStyles as inputStyles } from './mobile-native-chat-input-styles' +import { + mobileNativeChatComposerSkillStyles as skillStyles, + type MobileNativeChatComposerFieldProps +} from './mobile-native-chat-composer-field-skill' + +// A textarea cannot style part of its text, so its own text goes clear and a copy laid over it +// draws the skill. The caret stays visible. Plain object: React Native's types lack `caretColor`. +const clearText = { field: { color: 'transparent', caretColor: colors.textPrimary } } + +function scrollTopOf(event: unknown): number { + const target = + typeof event === 'object' && event !== null && 'currentTarget' in event + ? event.currentTarget + : null + return typeof target === 'object' && + target !== null && + 'scrollTop' in target && + typeof target.scrollTop === 'number' + ? target.scrollTop + : 0 +} + +/** The composer's text field on the page, where a copy laid over the field draws the skill. */ +export function MobileNativeChatComposerField({ + value, + skillToken, + ref, + ...props +}: MobileNativeChatComposerFieldProps) { + const [scrollTop, setScrollTop] = useState(0) + // One tree whether or not a skill leads: swapping it would remount the field and drop focus. + return ( + + {skillToken ? ( + // Positioned, so it paints over the field: touches pass through to the field below. + + + {skillToken} + {value.slice(skillToken.length)} + + + ) : null} + setScrollTop(scrollTopOf(event)) : undefined} + {...props} + /> + + ) +} + +const styles = StyleSheet.create({ + frame: { + width: '100%', + overflow: 'hidden' + }, + copyLayer: { + ...StyleSheet.absoluteFillObject + }, + copy: { + maxHeight: undefined + } +}) diff --git a/mobile/src/session/MobileNativeChatComposerMenu.tsx b/mobile/src/session/MobileNativeChatComposerMenu.tsx new file mode 100644 index 00000000000..b71b8e2fb06 --- /dev/null +++ b/mobile/src/session/MobileNativeChatComposerMenu.tsx @@ -0,0 +1,142 @@ +import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native' +import { FileText, Package, Terminal } from 'lucide-react-native' +import type { NativeChatPickerItem } from '../../../src/shared/native-chat-picker-items' +import { colors, spacing, typography } from '../theme/mobile-theme' + +/** One row of the composer menu: a `/` command or skill, or a worktree file for `@`. */ +export type ComposerMenuRow = NativeChatPickerItem | { kind: 'file'; id: string; path: string } + +/** The text a row puts in place of the typed trigger. */ +export function composerMenuInsertText(row: ComposerMenuRow): string { + return row.kind === 'file' ? `@${row.path}` : row.token +} + +const HEADINGS = { command: 'Commands', skill: 'Skills', file: 'Files' } as const + +/** The floating card above the composer, grouped and labelled as desktop's `/` menu is. */ +export function MobileNativeChatComposerMenu({ + rows, + onPick +}: { + rows: readonly ComposerMenuRow[] + onPick: (row: ComposerMenuRow) => void +}): React.JSX.Element { + return ( + + + {rows.map((row, index) => ( + + {rows[index - 1]?.kind !== row.kind ? ( + {HEADINGS[row.kind]} + ) : null} + + + ))} + + + ) +} + +function MenuRow({ + row, + onPick +}: { + row: ComposerMenuRow + onPick: (row: ComposerMenuRow) => void +}): React.JSX.Element { + const Icon = row.kind === 'skill' ? Package : row.kind === 'command' ? Terminal : FileText + const description = row.kind === 'file' ? null : row.description + return ( + [styles.row, pressed && styles.rowPressed]} + onPress={() => onPick(row)} + > + + + + + {row.kind === 'file' ? row.path : row.token} + + {row.kind === 'command' && row.argumentHint ? ( + + {row.argumentHint} + + ) : null} + + {description ? ( + + {description} + + ) : null} + + + ) +} + +const styles = StyleSheet.create({ + card: { + marginHorizontal: 10, + marginBottom: spacing.sm, + paddingVertical: 6, + borderRadius: 20, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.composerBorder, + backgroundColor: colors.bgPanel, + overflow: 'hidden' + }, + scroll: { + maxHeight: 300 + }, + heading: { + color: colors.textMuted, + fontSize: 11, + fontWeight: '600', + letterSpacing: 0.5, + textTransform: 'uppercase', + paddingHorizontal: spacing.lg, + paddingTop: spacing.sm, + paddingBottom: spacing.xs + }, + row: { + flexDirection: 'row', + gap: 10, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + minHeight: 44 + }, + rowPressed: { + backgroundColor: colors.bgRaised + }, + icon: { + marginTop: 2 + }, + body: { + flex: 1, + minWidth: 0, + gap: 2 + }, + nameLine: { + flexDirection: 'row', + alignItems: 'baseline', + gap: 6 + }, + name: { + flexShrink: 1, + color: colors.textPrimary, + fontFamily: typography.monoFamily, + fontSize: 13.5 + }, + hint: { + flexShrink: 1, + color: colors.textMuted, + fontFamily: typography.monoFamily, + fontSize: typography.metaSize + }, + description: { + color: colors.textMuted, + fontSize: 12.5, + lineHeight: 17 + } +}) diff --git a/mobile/src/session/MobileNativeChatComposerSuggestions.tsx b/mobile/src/session/MobileNativeChatComposerSuggestions.tsx deleted file mode 100644 index a9ac71f33ea..00000000000 --- a/mobile/src/session/MobileNativeChatComposerSuggestions.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native' -import { colors, spacing, typography } from '../theme/mobile-theme' -import type { SlashCommandSuggestion } from '../../../src/shared/native-chat-slash-commands' - -/** One row of the composer autocomplete: an agent slash command (with its - * catalog description, desktop parity) or a worktree file path. */ -export type ComposerSuggestion = - | { kind: 'command'; command: SlashCommandSuggestion } - | { kind: 'file'; path: string } - -export function composerSuggestionKey(suggestion: ComposerSuggestion): string { - return suggestion.kind === 'command' - ? `command:${suggestion.command.name}` - : `file:${suggestion.path}` -} - -/** The text the suggestion inserts at the trigger span. */ -export function composerSuggestionInsertText(suggestion: ComposerSuggestion): string { - return suggestion.kind === 'command' ? `/${suggestion.command.name}` : `@${suggestion.path}` -} - -export function MobileNativeChatComposerSuggestions({ - suggestions, - onPick -}: { - suggestions: readonly ComposerSuggestion[] - onPick: (suggestion: ComposerSuggestion) => void -}): React.JSX.Element { - return ( - - - {suggestions.map((suggestion) => ( - [styles.suggestion, pressed && styles.suggestionPressed]} - onPress={() => onPick(suggestion)} - > - - {composerSuggestionInsertText(suggestion)} - - {suggestion.kind === 'command' && suggestion.command.description ? ( - - {suggestion.command.description} - - ) : null} - - ))} - - - ) -} - -const styles = StyleSheet.create({ - suggestions: { - borderTopWidth: StyleSheet.hairlineWidth, - borderTopColor: colors.borderSubtle, - backgroundColor: colors.bgPanel - }, - suggestionScroll: { - maxHeight: 220 - }, - suggestion: { - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: colors.borderSubtle, - gap: 1 - }, - suggestionPressed: { - backgroundColor: colors.bgRaised - }, - suggestionText: { - color: colors.textPrimary, - fontFamily: typography.monoFamily, - fontSize: typography.metaSize - }, - suggestionDescription: { - color: colors.textSecondary, - fontSize: typography.metaSize - } -}) diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index 7950b9caa2f..23a09de9343 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -4,6 +4,7 @@ import { MobileNativeChatView, type MobileNativeChatInputLockReason } from './Mo import { foldMobileNativeChatMessages } from './mobile-native-chat-render-data' import type { MobileNativeChatImageAttachments } from './use-mobile-native-chat-image-attachments' import type { MobileNativeChatController } from './use-mobile-native-chat-controller' +import type { MobileNativeChatStructuredComposer } from './MobileNativeChatComposer' import { useMobileNativeChatStreamingBubble } from './use-mobile-native-chat-streaming-bubble' import { useMobileNativeChatComposerTray } from './use-mobile-native-chat-composer-tray' import { useMobileNativeChatVisualRenderer } from './MobileNativeChatVisual' @@ -22,6 +23,8 @@ type Props = { /** Native-chat image attachments: picking adds a composer chip, and sending * rides the pending images along with the message text (desktop parity). */ images: MobileNativeChatImageAttachments + /** A structured chat's + sheet, files and skills; null on a terminal-backed chat. */ + structuredComposer?: MobileNativeChatStructuredComposer | null onMicPress: () => void micActive: boolean dictationMode: string | undefined @@ -49,6 +52,7 @@ export function MobileNativeChatOverlay({ controller, onOpenFile, images, + structuredComposer = null, onMicPress, micActive, dictationMode, @@ -167,6 +171,7 @@ export function MobileNativeChatOverlay({ filePaths={controller.nativeChatFilePaths} onNeedFiles={controller.loadNativeChatFiles} sessionOptions={controller.nativeChatSessionOptions} + structuredComposer={structuredComposer} keyboardInset={keyboardInset} /> diff --git a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx index 619f6ae76b3..061d01d95ac 100644 --- a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx +++ b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx @@ -607,9 +607,10 @@ describe('MobileNativeChatQueuedMessages', () => { const row = mounted.root.findByProps({ testID: 'queued-header' }) expect(row.props.accessibilityLiveRegion).toBe('polite') // Android drops touches outside the parent: nothing may pull a row or a button past its edge. - const styled = mounted.root.findAll( - (node) => typeof node.type === 'string' && node.props.style !== undefined - ) + // The box itself tucks under the composer, by padding nothing in it can be pressed on. + const styled = mounted.root + .findByProps({ testID: 'queued-box' }) + .findAll((node) => typeof node.type === 'string' && node.props.style !== undefined) expect(styled.length).toBeGreaterThan(0) for (const node of styled) { const style = flatStyle( diff --git a/mobile/src/session/MobileNativeChatQueuedMessages.tsx b/mobile/src/session/MobileNativeChatQueuedMessages.tsx index 0316947ca70..91a964b3bca 100644 --- a/mobile/src/session/MobileNativeChatQueuedMessages.tsx +++ b/mobile/src/session/MobileNativeChatQueuedMessages.tsx @@ -122,7 +122,7 @@ export function MobileNativeChatQueuedMessages({ return ( {/* One box: the header is its first row, and the capped list of cards below it. */} - + {/* A polite region, as the app's other notices: React Native has no status role. */} ({ Keyboard: { dismiss: vi.fn() }, Pressable: 'Pressable', StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + Switch: 'Switch', Text: 'Text', View: 'View' })) @@ -21,6 +22,7 @@ vi.mock('lucide-react-native', () => ({ ChevronDown: 'ChevronDown', ChevronLeft: 'ChevronLeft', ChevronRight: 'ChevronRight', + ShieldAlert: 'ShieldAlert', X: 'X' })) vi.mock('../components/BottomDrawer', async () => { @@ -141,7 +143,9 @@ describe('MobileNativeChatSessionOptionPickers', () => { accessibilityState?: { checked?: boolean; disabled?: boolean } } } => { + // The sheet's rows only: the pill names the model too. const label = renderer!.root + .findByType('BottomDrawer') .findAll((node) => node.type === 'Text') .find((node) => (node.props as { children?: unknown }).children === text) if (!label) { @@ -188,10 +192,6 @@ describe('MobileNativeChatSessionOptionPickers', () => { accessibilityLabel: 'Model, Sonnet 5 High', disabled: false }) - const labels = renderer!.root - .findAll((node) => node.type === 'Text') - .map((node) => (node.props as { children?: unknown }).children) - expect(labels).toContain('Sonnet 5 High') }) it('lets the pill shrink so a long label never pushes the composer buttons off the row', () => { @@ -208,18 +208,27 @@ describe('MobileNativeChatSessionOptionPickers', () => { expect(setOption).toHaveBeenCalledWith('model', 'opus') }) - it('opens an option picker from the model sheet summary', async () => { + it('picks effort in place on the model sheet', async () => { mount([MODEL_DESCRIPTOR, EFFORT_DESCRIPTOR]) await act(async () => pill('Model').props.onPress()) + await act(async () => pill('Low').props.onPress()) + expect(setOption).toHaveBeenCalledWith('effort', 'low') + }) + + it('opens an option that is not effort on a page of its own from the model sheet', async () => { + mount([MODEL_DESCRIPTOR, { ...EFFORT_DESCRIPTOR, category: 'model_config' }]) + await act(async () => pill('Model').props.onPress()) await act(async () => rowByText('Effort').props.onPress()) await act(async () => rowByText('Low').props.onPress()) expect(setOption).toHaveBeenCalledWith('effort', 'low') }) - it('shows absolute boolean values in option summaries', async () => { + it('flips a yes-or-no option in place', async () => { mount([MODEL_DESCRIPTOR, FAST_MODE_DESCRIPTOR]) await act(async () => pill('Model').props.onPress()) - expect(rowByText('Off').props.accessibilityLabel).toBe('Fast mode, Off') + const toggle = renderer!.root.findByProps({ accessibilityLabel: 'Fast mode' }) + await act(async () => toggle.props.onValueChange(true)) + expect(setOption).toHaveBeenCalledWith('fastMode', true) }) it('announces choice selection and disabled state', async () => { @@ -271,7 +280,6 @@ describe('MobileNativeChatSessionOptionPickers', () => { { ...EFFORT_DESCRIPTOR, valueSource: 'dispatched', transport: scenario.transport } ]) await act(async () => pill('Model').props.onPress()) - await act(async () => rowByText('Effort').props.onPress()) const captions = renderer!.root .findAll((node) => node.type === 'Text') .filter( @@ -286,7 +294,6 @@ describe('MobileNativeChatSessionOptionPickers', () => { async (transport) => { mount([MODEL_DESCRIPTOR, { ...EFFORT_DESCRIPTOR, valueSource: 'reported', transport }]) await act(async () => pill('Model').props.onPress()) - await act(async () => rowByText('Effort').props.onPress()) expect( renderer!.root .findAll((node) => node.type === 'Text') @@ -299,18 +306,39 @@ describe('MobileNativeChatSessionOptionPickers', () => { } ) - it('locks the pills while the agent is working', () => { - mount([MODEL_DESCRIPTOR, EFFORT_DESCRIPTOR], true) - expect(pill('Model').props).toMatchObject({ disabled: true }) + it('locks the model and effort while the agent works, but never the permission mode', async () => { + mount([MODEL_DESCRIPTOR, EFFORT_DESCRIPTOR, PERMISSION_DESCRIPTOR], true) + expect(pill('Model').props).toMatchObject({ disabled: false }) + await act(async () => pill('Model').props.onPress()) + expect(rowByText('Opus 4.8').props.accessibilityState?.disabled).toBe(true) + expect(pill('Low').props.accessibilityState?.disabled).toBe(true) + expect(rowByText('Permissions').props.accessibilityState?.disabled).toBe(false) }) - it('shows the permission pill before any model pill, and closes at once on a pick', async () => { + it('keeps the permission mode in the model sheet and warns on the pill at full access', async () => { + mount([ + MODEL_DESCRIPTOR, + { + ...PERMISSION_DESCRIPTOR, + kind: { ...PERMISSION_DESCRIPTOR.kind, currentValue: 'full-access' } + } + ]) + expect(pill('Model').props.accessibilityLabel).toBe('Model, Sonnet 5, full access') + // A write that waits behind a starting agent never holds the page open. + setOption.mockImplementation(() => new Promise(() => {})) + await act(async () => pill('Model').props.onPress()) + await act(async () => rowByText('Permissions').props.onPress()) + await act(async () => rowByText('Ask for approval').props.onPress()) + expect(setOption).toHaveBeenCalledWith('permissionMode', 'ask') + expect(rowByText('Sonnet 5').props.accessibilityRole).toBe('radio') + }) + + it('names the pill for the permission mode where the agent has no model, and closes on a pick', async () => { mount([PERMISSION_DESCRIPTOR], true) expect(pill('Permissions').props).toMatchObject({ accessibilityLabel: 'Permissions, Ask for approval', disabled: false }) - // A write that waits behind a starting agent never holds the sheet open. setOption.mockImplementation(() => new Promise(() => {})) await act(async () => pill('Permissions').props.onPress()) await act(async () => rowByText('Full access').props.onPress()) diff --git a/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx b/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx index 5e011b66900..c5928708fcb 100644 --- a/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx +++ b/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx @@ -15,10 +15,14 @@ import { } from './mobile-native-chat-session-option-labels' import { DescriptorRows, - Pill, + ModelPill, SessionOptionCaption, SessionOptionSummaryRow } from './MobileNativeChatSessionOptionRows' +import { + SessionOptionSegments, + showsSessionOptionAsSegments +} from './MobileNativeChatSessionOptionSegments' import { sortNativeChatSessionOptions } from '../../../src/shared/native-chat-session-option-snapshot' import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../src/shared/agent-chat-permission-mode' import type { MobileNativeChatSessionOptionsController } from './use-mobile-native-chat-session-options' @@ -35,14 +39,12 @@ export type MobileNativeChatSessionOptionPickersProps = { * window would be submitted as part of the user's prompt. The composer blocks * the reverse direction on `pendingId`; this is the same guard mirrored. */ sendInFlight?: boolean - /** The collapsed composer hides the pills but keeps the drawer mounted. */ - pillsHidden?: boolean } type PresentationProps = { presentation: MobileNativeChatPickerPresentation | null onNavigate: (page: string | null) => void - /** A pill opened the drawer; fires before the keyboard is dismissed. */ + /** The pill opened the drawer; fires before the keyboard is dismissed. */ onDrawerOpen: (page: string) => void /** The drawer's sheet has fully left the screen, however it closed. */ onDrawerAfterClose?: () => void @@ -53,12 +55,17 @@ function permissionChoiceColor(value: string | undefined): string | undefined { return value === 'full-access' ? colors.statusAmber : undefined } -/** The permission pill, then the combined model/session-option pill, sharing one bottom drawer. */ +function selectedChoice(descriptor: SessionOptionDescriptor | undefined) { + return descriptor?.kind.type === 'select' + ? descriptor.kind.choices.find((choice) => choice.value === descriptor.kind.currentValue) + : undefined +} + +/** One pill for the session's model and options; its sheet also holds the permission mode. */ export function MobileNativeChatSessionOptionPickers({ controller, isWorking, sendInFlight = false, - pillsHidden = false, presentation, onNavigate, onDrawerOpen, @@ -73,22 +80,23 @@ export function MobileNativeChatSessionOptionPickers({ if (!model && !permission) { return null } + const home = model ?? permission! const activeDescriptor = snapshot.find((descriptor) => descriptor.id === presentation?.page) const permissionView = activeDescriptor !== undefined && activeDescriptor === permission + const homeView = activeDescriptor !== undefined && activeDescriptor === home + const busy = pendingId !== null || sendInFlight // The permission mode applies from the next send, so a running turn never locks it. - const disabled = (!permissionView && isWorking) || pendingId !== null || sendInFlight - const modelView = model !== undefined && activeDescriptor?.id === model.id - const modelLabel = model ? mobileModelPillLabel(model) : '' - const optionsLabel = options.length > 0 ? mobileOptionsPillLabel(options) : null - const pillLabel = optionsLabel ? `${modelLabel} ${optionsLabel}` : modelLabel + const locked = isWorking || busy const reason = mobileSessionOptionDisabledReason(activeDescriptor?.disabledReason) + const permissionChoice = selectedChoice(permission) + const permissionColor = permissionChoiceColor(permissionChoice?.value) + const fullAccess = permissionColor !== undefined + const segmentOptions = options.filter(showsSessionOptionAsSegments) + const rowOptions = options.filter((descriptor) => !segmentOptions.includes(descriptor)) + const primary = model ? mobileModelPillLabel(model) : (permissionChoice?.label ?? 'Permissions') + const secondary = model && options.length > 0 ? mobileOptionsPillLabel(options) : null const closePicker = (): void => onNavigate(null) - const openPicker = (page: string): void => { - onDrawerOpen(page) - Keyboard.dismiss() - } - const applyOption = (descriptor: SessionOptionDescriptor, value: SessionOptionValue): void => { // Re-picking the tracked value is a no-op — never re-dispatch it. if ( @@ -96,16 +104,16 @@ export function MobileNativeChatSessionOptionPickers({ descriptor.kind.type === 'select' && descriptor.kind.currentValue === value ) { - closePicker() return } // A permission pick shows at once; its write may wait behind a starting agent. if (descriptor === permission) { - closePicker() + onNavigate(descriptor === home ? null : home.id) } void controller.setOption(descriptor.id, value).then((applied) => { - if (applied) { - closePicker() + // The sheet stays on its home page so the next setting is one tap away; a sub page returns. + if (applied && descriptor !== home && descriptor !== permission) { + onNavigate(home.id) } }) } @@ -117,32 +125,23 @@ export function MobileNativeChatSessionOptionPickers({ }) } - const permissionChoice = - permission?.kind.type === 'select' - ? permission.kind.choices.find((choice) => choice.value === permission.kind.currentValue) - : undefined - const pillsBusy = pendingId !== null || sendInFlight - return ( - // One tree in both modes: re-parenting the drawer would remount an open sheet. - - {!pillsHidden && permission && permissionChoice ? ( - openPicker(permission.id)} - /> - ) : null} - {model && !pillsHidden ? ( - openPicker(model.id)} - /> - ) : null} + + { + onDrawerOpen(home.id) + Keyboard.dismiss() + }} + /> [styles.sheetNav, pressed && styles.pressed]} - onPress={ - modelView || permissionView ? closePicker : () => onNavigate(model?.id ?? null) - } + onPress={homeView ? closePicker : () => onNavigate(home.id)} hitSlop={8} > - {modelView || permissionView ? ( + {homeView ? ( ) : ( )} - - {modelView ? 'Select model' : `Select ${activeDescriptor.label.toLowerCase()}`} - + {activeDescriptor.label} {pendingId !== null ? ( @@ -179,29 +174,36 @@ export function MobileNativeChatSessionOptionPickers({ Sent to the agent — not confirmed ) : null} {reason ? {reason} : null} - + applyOption(activeDescriptor, value)} onInvokeAction={() => invokeAction(activeDescriptor)} {...(permissionView ? { choiceLabelColor: permissionChoiceColor } : {})} /> - {modelView && options.length > 0 ? ( - - {options.map((descriptor, index) => ( - + {segmentOptions.map((descriptor) => ( + onNavigate(descriptor.id)} + descriptor={descriptor} + disabled={locked} + onSetOption={(value) => applyOption(descriptor, value)} /> ))} - + + ) : null} ) : null} @@ -210,18 +212,67 @@ export function MobileNativeChatSessionOptionPickers({ ) } +/** The model page's other settings: a switch where one value flips, else a row to its page. */ +function OptionRows({ + descriptors, + permission, + permissionColor, + locked, + busy, + onNavigate, + onSetOption +}: { + descriptors: readonly SessionOptionDescriptor[] + permission: SessionOptionDescriptor | undefined + permissionColor: string | undefined + locked: boolean + busy: boolean + onNavigate: (page: string) => void + onSetOption: (descriptor: SessionOptionDescriptor, value: SessionOptionValue) => void +}): React.JSX.Element | null { + if (descriptors.length === 0) { + return null + } + return ( + + {descriptors.map((descriptor, index) => { + const isPermission = descriptor === permission + const disabled = isPermission ? busy : locked + if (descriptor.kind.type === 'boolean' && !descriptor.action) { + return ( + + onSetOption(descriptor, value)} + onInvokeAction={() => onNavigate(descriptor.id)} + /> + + ) + } + const value = mobileSessionOptionSummaryValue(descriptor) + return ( + onNavigate(descriptor.id)} + /> + ) + })} + + ) +} + const styles = StyleSheet.create({ - // The composer row's only shrinkable item: a long label truncates before it pushes Send off. - pills: { - flexDirection: 'row', - alignItems: 'center', + // The action row's only shrinkable item: a long label truncates before it pushes Send off. + pillSlot: { flexShrink: 1, - minWidth: 0, - gap: spacing.xs - }, - // Out of the row's flow, so the collapsed composer spends no gap on an empty slot. - pillsHidden: { - position: 'absolute' + minWidth: 0 }, sheet: { paddingBottom: spacing.xs @@ -254,20 +305,17 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center' }, - choiceGroup: { + group: { overflow: 'hidden', borderRadius: radii.card, - borderWidth: StyleSheet.hairlineWidth, - borderColor: colors.borderSubtle, backgroundColor: colors.bgRaised }, - optionGroup: { - overflow: 'hidden', - marginTop: spacing.md, - borderRadius: radii.card, - borderWidth: StyleSheet.hairlineWidth, - borderColor: colors.borderSubtle, - backgroundColor: colors.bgRaised + groupSpaced: { + marginTop: spacing.md + }, + divided: { + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.composerBorder }, pressed: { opacity: 0.7 diff --git a/mobile/src/session/MobileNativeChatSessionOptionRows.tsx b/mobile/src/session/MobileNativeChatSessionOptionRows.tsx index d84b0ca6125..621258cfcf8 100644 --- a/mobile/src/session/MobileNativeChatSessionOptionRows.tsx +++ b/mobile/src/session/MobileNativeChatSessionOptionRows.tsx @@ -2,7 +2,7 @@ // beside it so the card file stays about layout and apply wiring. import { Pressable, StyleSheet, Switch, Text, View } from 'react-native' -import { Check, ChevronDown, ChevronRight } from 'lucide-react-native' +import { Check, ChevronRight, ShieldAlert } from 'lucide-react-native' import { colors, radii, spacing, typography } from '../theme/mobile-theme' import { sessionOptionValueMarker, @@ -16,18 +16,21 @@ export function SessionOptionCaption({ children }: { children: string }): React. return {children} } -export function Pill({ - label, +/** The composer's model pill: the model, then its options muted, with a shield for full access. */ +export function ModelPill({ + primary, + secondary, + warning, accessibleName, disabled, - onPress, - labelColor + onPress }: { - label: string + primary: string + secondary: string | null + warning: boolean accessibleName: string disabled: boolean onPress: () => void - labelColor?: string }): React.JSX.Element { return ( - - {label} + {warning ? : null} + + {primary} + {secondary ? {` ${secondary}`} : null} - ) } @@ -88,9 +84,6 @@ function ChoiceRow({ onPress={onPress} disabled={disabled} > - - {selected ? : null} - {label} {description ? ( @@ -99,6 +92,7 @@ function ChoiceRow({ ) : null} + {selected ? : null} ) } @@ -168,12 +162,14 @@ function ActionRow({ export function SessionOptionSummaryRow({ label, value, + valueColor, disabled, divided, onPress }: { label: string value: string + valueColor?: string disabled: boolean divided: boolean onPress: () => void @@ -193,7 +189,10 @@ export function SessionOptionSummaryRow({ disabled={disabled} > {label} - + {value} @@ -279,21 +278,22 @@ const styles = StyleSheet.create({ pill: { flexDirection: 'row', alignItems: 'center', - gap: 4, - maxWidth: 180, - minHeight: 28, - paddingHorizontal: spacing.sm, - paddingVertical: 4, - borderRadius: radii.button, - borderWidth: StyleSheet.hairlineWidth, - borderColor: colors.borderSubtle, - backgroundColor: colors.bgRaised + gap: 6, + height: 38, + paddingHorizontal: 14, + borderRadius: 19, + backgroundColor: colors.composerControl }, pillText: { - color: colors.textSecondary, - fontSize: typography.metaSize, - fontWeight: '600', - flexShrink: 1 + flexShrink: 1, + fontSize: typography.bodySize + }, + pillPrimary: { + color: colors.textPrimary, + fontWeight: '500' + }, + pillSecondary: { + color: colors.textMuted }, pillTextDisabled: { color: colors.textMuted @@ -314,8 +314,9 @@ const styles = StyleSheet.create({ row: { flexDirection: 'row', gap: spacing.sm, - padding: spacing.sm, - minHeight: 44, + paddingHorizontal: 14, + paddingVertical: spacing.sm, + minHeight: 50, alignItems: 'center', borderRadius: radii.card, backgroundColor: colors.bgRaised, @@ -324,7 +325,7 @@ const styles = StyleSheet.create({ marginBottom: spacing.xs }, rowSelected: { - borderColor: colors.statusGreen + borderColor: colors.textSecondary }, rowGrouped: { marginBottom: 0, @@ -334,24 +335,11 @@ const styles = StyleSheet.create({ }, rowDivided: { borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: colors.borderSubtle + borderBottomColor: colors.composerBorder }, rowDisabled: { opacity: 0.5 }, - radio: { - width: 18, - height: 18, - borderRadius: 9, - borderWidth: 1.5, - borderColor: colors.textMuted, - alignItems: 'center', - justifyContent: 'center' - }, - radioOn: { - backgroundColor: colors.statusGreen, - borderColor: colors.statusGreen - }, rowBody: { flex: 1, gap: 2 @@ -366,11 +354,11 @@ const styles = StyleSheet.create({ fontSize: typography.metaSize }, summaryRow: { - minHeight: 48, + minHeight: 50, flexDirection: 'row', alignItems: 'center', gap: spacing.sm, - paddingHorizontal: spacing.md, + paddingHorizontal: 14, paddingVertical: spacing.sm }, summaryLabel: { diff --git a/mobile/src/session/MobileNativeChatSessionOptionSegments.tsx b/mobile/src/session/MobileNativeChatSessionOptionSegments.tsx new file mode 100644 index 00000000000..94776355ec5 --- /dev/null +++ b/mobile/src/session/MobileNativeChatSessionOptionSegments.tsx @@ -0,0 +1,66 @@ +import { StyleSheet, Text, View } from 'react-native' +import { MobileSegmentedControl } from '../components/MobileSegmentedControl' +import { colors, spacing, typography } from '../theme/mobile-theme' +import { + sessionOptionDispatchUnconfirmed, + type SessionOptionDescriptor, + type SessionOptionValue +} from '../../../src/shared/native-chat-session-options' +import { SessionOptionCaption } from './MobileNativeChatSessionOptionRows' + +// Five fit across a phone's sheet; more read better as a list on their own page. +const MAX_SEGMENTS = 5 + +/** An effort-like choice the model page can show in place, as one segmented control. */ +export function showsSessionOptionAsSegments(descriptor: SessionOptionDescriptor): boolean { + return ( + descriptor.category === 'thought_level' && + descriptor.kind.type === 'select' && + !descriptor.action && + descriptor.kind.choices.length > 1 && + descriptor.kind.choices.length <= MAX_SEGMENTS + ) +} + +export function SessionOptionSegments({ + descriptor, + disabled, + onSetOption +}: { + descriptor: SessionOptionDescriptor + disabled: boolean + onSetOption: (value: SessionOptionValue) => void +}): React.JSX.Element | null { + if (descriptor.kind.type !== 'select') { + return null + } + const { choices, currentValue } = descriptor.kind + return ( + + {descriptor.label} + + {sessionOptionDispatchUnconfirmed(descriptor) ? ( + Sent to the agent — not confirmed + ) : null} + + ) +} + +const styles = StyleSheet.create({ + block: { + marginTop: spacing.md + }, + label: { + color: colors.textMuted, + fontSize: typography.metaSize, + paddingHorizontal: spacing.xs, + paddingBottom: 6 + } +}) diff --git a/mobile/src/session/MobileNativeChatStopButton.tsx b/mobile/src/session/MobileNativeChatStopButton.tsx index a882e33318a..bf646444dc0 100644 --- a/mobile/src/session/MobileNativeChatStopButton.tsx +++ b/mobile/src/session/MobileNativeChatStopButton.tsx @@ -1,7 +1,8 @@ -import { ActivityIndicator, Pressable, StyleSheet } from 'react-native' +import { ActivityIndicator, Pressable } from 'react-native' import { Square } from 'lucide-react-native' import { NATIVE_CHAT_TURN_STATUS_COPY } from '../../../src/shared/native-chat-turn-status' import { colors } from '../theme/mobile-theme' +import { mobileNativeChatComposerStyles as styles } from './mobile-native-chat-composer-styles' /** The composer's Stop, in Send's slot. `held`: this phone's own Stop request is still in flight. */ export function MobileNativeChatStopButton({ @@ -13,31 +14,17 @@ export function MobileNativeChatStopButton({ }): React.JSX.Element { return ( [styles.stopButton, pressed && styles.pressed]} + style={({ pressed }) => [styles.controlButton, pressed && styles.pressed]} onPress={onStop} disabled={held} accessibilityRole="button" accessibilityLabel={held ? NATIVE_CHAT_TURN_STATUS_COPY.stopping : 'Stop the agent'} > {held ? ( - + ) : ( - + )} ) } - -const styles = StyleSheet.create({ - stopButton: { - width: 40, - height: 40, - borderRadius: 20, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: colors.bgRaised - }, - pressed: { - opacity: 0.7 - } -}) diff --git a/mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts b/mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts index 14efe3a7dc6..e320c78a53c 100644 --- a/mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts +++ b/mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts @@ -51,6 +51,8 @@ vi.mock('lucide-react-native', () => 'ChevronsUpDown', 'ShieldQuestion', 'ImagePlus', + 'Package', + 'Plus', 'Mic', 'Square', 'X' @@ -64,9 +66,9 @@ vi.mock('./MobileNativeChatQuestion', () => ({ MobileNativeChatQuestion: 'ChatQu vi.mock('./MobileNativeChatSessionOptionPickers', () => ({ MobileNativeChatSessionOptionPickers: 'SessionOptions' })) -vi.mock('./MobileNativeChatComposerSuggestions', () => ({ - MobileNativeChatComposerSuggestions: 'Suggestions', - composerSuggestionInsertText: () => '' +vi.mock('./MobileNativeChatComposerMenu', () => ({ + MobileNativeChatComposerMenu: 'Suggestions', + composerMenuInsertText: () => '' })) vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' })) vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: 'Markdown' })) diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index e90e7c09b70..4a811e92b64 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -45,7 +45,10 @@ import { import { MobileNativeChatLiveLine } from './MobileNativeChatLiveLine' import { MobileNativeChatFloatingControls } from './MobileNativeChatFloatingControls' import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment' -import { MobileNativeChatComposer } from './MobileNativeChatComposer' +import { + MobileNativeChatComposer, + type MobileNativeChatStructuredComposer +} from './MobileNativeChatComposer' import { mobileNativeChatToolCallCount } from './mobile-native-chat-composer-layout' import { MobileNativeChatPromptCard } from './MobileNativeChatPromptCard' import { NO_COMPOSER_TRAY, type ComposerTrayProps } from './use-mobile-native-chat-composer-tray' @@ -130,6 +133,8 @@ type Props = ComposerTrayProps & { onNeedFiles?: (query: string) => void /** Model/session-option pickers for the composer action row (desktop parity). */ sessionOptions?: MobileNativeChatSessionOptionPickersProps | null + /** A structured chat's + sheet, files and skills; absent on a terminal-backed chat. */ + structuredComposer?: MobileNativeChatStructuredComposer | null /** A pending agent question/permission detected from live status, shown as a * native card above the composer; answering sends text to the agent. */ /** Structured AskUserQuestion prompt parsed from the transcript (preferred over @@ -205,6 +210,7 @@ export function MobileNativeChatView({ filePaths, onNeedFiles, sessionOptions, + structuredComposer = null, ask, askKey, onDismissAsk, @@ -407,7 +413,6 @@ export function MobileNativeChatView({ /> )} - {trayContent} {sendErrorMessage} ) : null} + {/* Directly on the composer, which it tucks into. */} + {trayContent} 'ChevronsUpDown', 'ShieldQuestion', 'ImagePlus', + 'Plus', 'Mic', 'Square', 'X', @@ -74,9 +75,9 @@ vi.mock('../MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' })) vi.mock('../MobileNativeChatSessionOptionPickers', () => ({ MobileNativeChatSessionOptionPickers: 'SessionOptions' })) -vi.mock('../MobileNativeChatComposerSuggestions', () => ({ - MobileNativeChatComposerSuggestions: 'Suggestions', - composerSuggestionInsertText: () => '' +vi.mock('../MobileNativeChatComposerMenu', () => ({ + MobileNativeChatComposerMenu: 'Suggestions', + composerMenuInsertText: () => '' })) vi.mock('../../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' })) vi.mock('../../components/MobileMarkdown', () => ({ MobileMarkdown: 'Markdown' })) diff --git a/mobile/src/session/mobile-agent-session-attachment-upload.test.ts b/mobile/src/session/mobile-agent-session-attachment-upload.test.ts new file mode 100644 index 00000000000..3da4d2aee3b --- /dev/null +++ b/mobile/src/session/mobile-agent-session-attachment-upload.test.ts @@ -0,0 +1,129 @@ +import { Buffer } from 'node:buffer' +import { describe, expect, it, vi } from 'vitest' +import { + AGENT_SESSION_ATTACHMENT_CHUNK_BASE64_CHARS, + AGENT_SESSION_ATTACHMENT_CHUNK_BYTES +} from '../../../src/shared/agent-session-attachments' +import type { RpcClient } from '../transport/rpc-client' +import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types' +import { uploadMobileAgentSessionAttachment } from './mobile-agent-session-attachment-upload' + +function ok(result: unknown): RpcSuccess { + return { id: 'r', ok: true, result, _meta: { runtimeId: 'runtime-1' } } +} + +function fail(message: string): RpcFailure { + return { + id: 'r', + ok: false, + error: { code: 'failed', message }, + _meta: { runtimeId: 'runtime-1' } + } +} + +/** A host that answers by method, recording what reached it. */ +function fakeHost(answer: (method: string, params: unknown) => RpcResponse) { + const calls: { method: string; params: Record }[] = [] + const client: Pick = { + sendRequest: vi.fn(async (method: string, params?: unknown) => { + calls.push({ method, params: Object.fromEntries(Object.entries(params ?? {})) }) + return answer(method, params) + }) + } + return { client, calls } +} + +function storingHost(failAppend = false) { + return fakeHost((method) => { + if (method === 'agentSessionAttachment.uploadStart') { + return ok({ uploadId: 'upload-1' }) + } + if (method === 'agentSessionAttachment.uploadAppend') { + return failAppend ? fail('disk full') : ok({}) + } + if (method === 'agentSessionAttachment.uploadCommit') { + return ok({ path: '/store/upload-1/spec.pdf', name: 'spec.pdf', byteLength: 3 }) + } + return ok({ aborted: true }) + }) +} + +describe('uploadMobileAgentSessionAttachment', () => { + it('stores a file across chunks addressed by byte offset and answers the host path', async () => { + const bytes = Buffer.alloc(AGENT_SESSION_ATTACHMENT_CHUNK_BYTES * 2 + 5, 7) + const host = storingHost() + + const stored = await uploadMobileAgentSessionAttachment(host.client, { + sessionId: 'session-1', + name: 'spec.pdf', + base64: bytes.toString('base64') + }) + + expect(stored).toEqual({ path: '/store/upload-1/spec.pdf', name: 'spec.pdf', byteLength: 3 }) + expect(host.calls[0]).toEqual({ + method: 'agentSessionAttachment.uploadStart', + params: { sessionId: 'session-1', name: 'spec.pdf', byteLength: bytes.byteLength } + }) + const appends = host.calls.filter( + (call) => call.method === 'agentSessionAttachment.uploadAppend' + ) + expect(appends.map((call) => call.params.offset)).toEqual([ + 0, + AGENT_SESSION_ATTACHMENT_CHUNK_BYTES, + AGENT_SESSION_ATTACHMENT_CHUNK_BYTES * 2 + ]) + const reassembled = Buffer.concat( + appends.map((call) => Buffer.from(String(call.params.contentBase64), 'base64')) + ) + expect(reassembled.equals(bytes)).toBe(true) + expect( + appends.every( + (call) => + String(call.params.contentBase64).length <= AGENT_SESSION_ATTACHMENT_CHUNK_BASE64_CHARS + ) + ).toBe(true) + expect(host.calls.at(-1)?.method).toBe('agentSessionAttachment.uploadCommit') + }) + + it('sends an empty file as one empty append', async () => { + const host = storingHost() + await uploadMobileAgentSessionAttachment(host.client, { + sessionId: 'session-1', + name: 'empty.txt', + base64: '' + }) + expect(host.calls.map((call) => call.method)).toEqual([ + 'agentSessionAttachment.uploadStart', + 'agentSessionAttachment.uploadAppend', + 'agentSessionAttachment.uploadCommit' + ]) + expect(host.calls[0]?.params.byteLength).toBe(0) + }) + + it("aborts the slot when a chunk fails and reports the host's reason", async () => { + const host = storingHost(true) + await expect( + uploadMobileAgentSessionAttachment(host.client, { + sessionId: 'session-1', + name: 'spec.pdf', + base64: Buffer.from('abc').toString('base64') + }) + ).rejects.toThrow('disk full') + expect(host.calls.at(-1)).toEqual({ + method: 'agentSessionAttachment.uploadAbort', + params: { uploadId: 'upload-1' } + }) + }) + + it('opens no slot to abort when the host refuses the start', async () => { + const host = fakeHost(() => fail('Attachment is too large')) + await expect( + uploadMobileAgentSessionAttachment(host.client, { + sessionId: 'session-1', + name: 'big.bin', + base64: 'AAAA' + }) + ).rejects.toThrow('Attachment is too large') + expect(host.calls.map((call) => call.method)).toEqual(['agentSessionAttachment.uploadStart']) + }) +}) diff --git a/mobile/src/session/mobile-agent-session-attachment-upload.ts b/mobile/src/session/mobile-agent-session-attachment-upload.ts new file mode 100644 index 00000000000..143153e7df1 --- /dev/null +++ b/mobile/src/session/mobile-agent-session-attachment-upload.ts @@ -0,0 +1,88 @@ +import { z } from 'zod' +import { + agentSessionAttachmentAppends, + base64DecodedByteLength, + type AgentSessionAttachmentUploadCommitResult +} from '../../../src/shared/agent-session-attachments' +import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc-operation' +import { rpcResultVariant } from '../transport/rpc-operation-result-reader' + +// A file the phone attaches to a structured chat, pumped into the host's attachment store: open a +// slot, append the bytes in chunks, commit. The agent only ever sees the host path it answers. + +const uploadSlotSchema = z.looseObject({ uploadId: z.string() }) +const storedAttachmentSchema = z.looseObject({ + path: z.string(), + name: z.string(), + byteLength: z.number() +}) + +const attachmentUploadStart = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'agent-session-attachment.upload-start', + method: 'agentSessionAttachment.uploadStart', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcResultVariant('agent-session-attachment-upload-slot', uploadSlotSchema) + }) +) + +const attachmentUploadAppend = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'agent-session-attachment.upload-append', + method: 'agentSessionAttachment.uploadAppend', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcResultVariant('agent-session-attachment-chunk-appended', z.unknown()) + }) +) + +const attachmentUploadCommit = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'agent-session-attachment.upload-commit', + method: 'agentSessionAttachment.uploadCommit', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcResultVariant('agent-session-attachment-stored', storedAttachmentSchema) + }) +) + +/** Its own outcome is discarded: the failure that brought the caller here is the one to report. */ +const attachmentUploadAbort = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'agent-session-attachment.upload-abort-or-skip', + method: 'agentSessionAttachment.uploadAbort', + acceptance: 'success-result-or-skip', + barrier: 'after-caller-barrier', + read: rpcResultVariant('agent-session-attachment-upload-aborted', z.unknown()) + }) +) + +type MobileAgentSessionAttachmentRpcSender = Parameters[0] + +/** Stores `base64` in the host's attachment store for `sessionId`; aborts the slot on any failure. */ +export async function uploadMobileAgentSessionAttachment( + client: MobileAgentSessionAttachmentRpcSender, + args: { sessionId: string; name: string; base64: string } +): Promise { + const { uploadId } = attachmentUploadStart.interpret( + await attachmentUploadStart.request(client, { + sessionId: args.sessionId, + name: args.name, + byteLength: base64DecodedByteLength(args.base64) + }) + ) + try { + for (const chunk of agentSessionAttachmentAppends(args.base64)) { + attachmentUploadAppend.interpret( + await attachmentUploadAppend.request(client, { uploadId, ...chunk }) + ) + } + return attachmentUploadCommit.interpret( + await attachmentUploadCommit.request(client, { uploadId }) + ) + } catch (error) { + await attachmentUploadAbort.request(client, { uploadId }).catch(() => {}) + throw error + } +} diff --git a/mobile/src/session/mobile-file-search-owner-cleanup.test.tsx b/mobile/src/session/mobile-file-search-owner-cleanup.test.tsx index 3cecdb9ac0a..91aa30fbc57 100644 --- a/mobile/src/session/mobile-file-search-owner-cleanup.test.tsx +++ b/mobile/src/session/mobile-file-search-owner-cleanup.test.tsx @@ -26,8 +26,13 @@ vi.mock('lucide-react-native', () => ({ ChevronDown: 'ChevronDown', ChevronLeft: 'ChevronLeft', ChevronRight: 'ChevronRight', + FileText: 'FileText', ImagePlus: 'ImagePlus', Mic: 'Mic', + Package: 'Package', + Plus: 'Plus', + ShieldAlert: 'ShieldAlert', + Terminal: 'Terminal', Square: 'Square', X: 'X' })) diff --git a/mobile/src/session/mobile-file-source-picker.test.ts b/mobile/src/session/mobile-file-source-picker.test.ts new file mode 100644 index 00000000000..e19b7e91cf9 --- /dev/null +++ b/mobile/src/session/mobile-file-source-picker.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('expo-image-picker', () => ({})) +vi.mock('expo-document-picker', () => ({ getDocumentAsync: vi.fn() })) +vi.mock('expo-file-system', () => ({ File: vi.fn() })) + +import { + MOBILE_PICKED_FILE_MAX_BYTES, + MobileFileTooLargeError, + type PickedMobileFile +} from '../platform/media-picker-contract' +import { pickMobileFiles } from './mobile-file-source-picker' + +type Asset = { uri: string; name: string; size?: number; mimeType?: string } + +function launcher(assets: Asset[] | null) { + return vi.fn(async () => + assets === null + ? { canceled: true as const, assets: null } + : { canceled: false as const, assets: assets.map((asset) => ({ lastModified: 0, ...asset })) } + ) +} + +function filesByUri(contents: Record) { + return (uri: string) => { + const bytes = contents[uri] ?? new Uint8Array() + return { + size: bytes.byteLength, + open: () => { + let done = false + return { + size: bytes.byteLength, + readBytes: () => { + const chunk = done ? new Uint8Array() : bytes + done = true + return chunk + }, + close: () => {} + } + } + } + } +} + +async function collect(files: AsyncIterable): Promise { + const taken: PickedMobileFile[] = [] + for await (const file of files) { + taken.push(file) + } + return taken +} + +describe('pickMobileFiles', () => { + it('reads each picked file with its own name and type', async () => { + const launch = launcher([ + { uri: 'file:///c/a', name: 'spec.pdf', mimeType: 'application/pdf', size: 3 }, + { uri: 'file:///c/b', name: 'notes' } + ]) + const files = await collect( + pickMobileFiles({ + launch, + createFile: filesByUri({ + 'file:///c/a': new Uint8Array([1, 2, 3]), + 'file:///c/b': new Uint8Array([4]) + }) + }) + ) + expect(files).toEqual([ + { name: 'spec.pdf', mime: 'application/pdf', byteLength: 3, base64: 'AQID' }, + { name: 'notes', mime: 'application/octet-stream', byteLength: 1, base64: 'BA==' } + ]) + expect(launch).toHaveBeenCalledWith({ type: '*/*', multiple: true, copyToCacheDirectory: true }) + }) + + it('yields nothing for a cancelled picker', async () => { + expect(await collect(pickMobileFiles({ launch: launcher(null) }))).toEqual([]) + }) + + it('refuses a file over the cap by name before reading a byte of it', async () => { + const createFile = vi.fn(filesByUri({})) + const refused = await collect( + pickMobileFiles({ + launch: launcher([ + { uri: 'file:///c/big', name: 'clip.mov', size: MOBILE_PICKED_FILE_MAX_BYTES + 1 } + ]), + createFile + }) + ).catch((error: unknown) => error) + expect(refused).toBeInstanceOf(MobileFileTooLargeError) + expect(refused instanceof MobileFileTooLargeError && refused.message).toMatch( + /^clip.mov is over/ + ) + expect(createFile).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/session/mobile-file-source-picker.ts b/mobile/src/session/mobile-file-source-picker.ts new file mode 100644 index 00000000000..5c6bb33496f --- /dev/null +++ b/mobile/src/session/mobile-file-source-picker.ts @@ -0,0 +1,53 @@ +import * as DocumentPicker from 'expo-document-picker' +import { + MOBILE_PICKED_FILE_MAX_BYTES, + MobileFileTooLargeError, + type PickedMobileFile +} from '../platform/media-picker-contract' +import { + defaultMobileImageFileFactory, + readUriAsBase64, + type MobileImageFileFactory +} from './mobile-image-source-picker' +import { base64DecodedByteLength } from '../../../src/shared/agent-session-attachments' + +const UNKNOWN_FILE_MIME = 'application/octet-stream' + +type MobileFilePickerDeps = { + readonly launch?: typeof DocumentPicker.getDocumentAsync + readonly createFile?: MobileImageFileFactory +} + +/** + * Files of any type from the OS document picker, each read whole. + * + * A file over the cap stops the pick with `MobileFileTooLargeError`; the files before it have + * already been yielded and stay with the caller. + */ +export async function* pickMobileFiles( + deps?: MobileFilePickerDeps +): AsyncGenerator { + const launch = deps?.launch ?? DocumentPicker.getDocumentAsync + const createFile = deps?.createFile ?? defaultMobileImageFileFactory + const result = await launch({ type: '*/*', multiple: true, copyToCacheDirectory: true }) + if (result.canceled) { + return + } + for (const asset of result.assets) { + if (!asset.uri) { + continue + } + const name = asset.name.trim() || 'attachment' + const base64 = await readUriAsBase64(asset.uri, asset.size, createFile, (byteLength) => { + if (!Number.isFinite(byteLength) || byteLength > MOBILE_PICKED_FILE_MAX_BYTES) { + throw new MobileFileTooLargeError(name) + } + }) + yield { + name, + mime: asset.mimeType ?? UNKNOWN_FILE_MIME, + byteLength: base64DecodedByteLength(base64), + base64 + } + } +} diff --git a/mobile/src/session/mobile-image-source-picker.ts b/mobile/src/session/mobile-image-source-picker.ts index eb585af5768..1ee0412d997 100644 --- a/mobile/src/session/mobile-image-source-picker.ts +++ b/mobile/src/session/mobile-image-source-picker.ts @@ -30,25 +30,27 @@ type MobileImageFile = { export type MobileImageFileFactory = (uri: string) => MobileImageFile -function defaultMobileImageFileFactory(uri: string): MobileImageFile { +export function defaultMobileImageFileFactory(uri: string): MobileImageFile { return new FsFile(uri) } -async function readUriAsBase64( +/** Reads a picked uri as base64 in chunks, refusing through `assertBytes` once it passes the cap. */ +export async function readUriAsBase64( uri: string, - declaredSize: number | undefined, - createFile: MobileImageFileFactory + declaredSize: number | undefined | null, + createFile: MobileImageFileFactory, + assertBytes: (byteLength: number) => void = assertClipboardImageByteLengthWithinLimit ): Promise { if (typeof declaredSize === 'number' && Number.isFinite(declaredSize)) { - assertClipboardImageByteLengthWithinLimit(declaredSize) + assertBytes(declaredSize) } const file = createFile(uri) - assertClipboardImageByteLengthWithinLimit(file.size) + assertBytes(file.size) const handle = file.open() try { if (handle.size !== null) { - assertClipboardImageByteLengthWithinLimit(handle.size) + assertBytes(handle.size) } const accumulator = new MobileImageBase64Accumulator() let bytesRead = 0 @@ -62,7 +64,7 @@ async function readUriAsBase64( break } bytesRead += bytes.byteLength - assertClipboardImageByteLengthWithinLimit(bytesRead) + assertBytes(bytesRead) accumulator.append(bytes) } const base64 = accumulator.finish() diff --git a/mobile/src/session/mobile-native-chat-attach-actions.ts b/mobile/src/session/mobile-native-chat-attach-actions.ts new file mode 100644 index 00000000000..e10df5d0f5a --- /dev/null +++ b/mobile/src/session/mobile-native-chat-attach-actions.ts @@ -0,0 +1,45 @@ +import { AtSign, Image, Package, Paperclip, Terminal } from 'lucide-react-native' +import type { ActionSheetAction } from '../components/ActionSheetModal' + +/** The composer's + sheet: every way to add to the message, then the session's skills and + * commands. Each runs once the sheet is gone, since iOS cannot present a picker over it. */ +export function mobileNativeChatAttachActions(args: { + onPhotos: (() => void) | undefined + /** Absent where the host cannot take a file from a phone. */ + onFiles: (() => void) | undefined + onWorkspaceFile: () => void + onSkills: () => void + onCommands: () => void + skillCount: number +}): ActionSheetAction[] { + const actions: ActionSheetAction[] = [] + if (args.onPhotos) { + actions.push({ label: 'Photos', icon: Image, closeBeforePress: true, onPress: args.onPhotos }) + } + if (args.onFiles) { + actions.push({ + label: 'Files', + hint: 'PDF, text, code…', + icon: Paperclip, + closeBeforePress: true, + onPress: args.onFiles + }) + } + actions.push( + { + label: 'Workspace file', + icon: AtSign, + closeBeforePress: true, + onPress: args.onWorkspaceFile + }, + { + label: 'Skills', + ...(args.skillCount > 0 ? { hint: `${args.skillCount} available` } : {}), + icon: Package, + closeBeforePress: true, + onPress: args.onSkills + }, + { label: 'Commands', icon: Terminal, closeBeforePress: true, onPress: args.onCommands } + ) + return actions +} diff --git a/mobile/src/session/mobile-native-chat-autocomplete.test.ts b/mobile/src/session/mobile-native-chat-autocomplete.test.ts index 7fac03e7354..7cf0f29183f 100644 --- a/mobile/src/session/mobile-native-chat-autocomplete.test.ts +++ b/mobile/src/session/mobile-native-chat-autocomplete.test.ts @@ -2,11 +2,16 @@ import { describe, expect, it, vi } from 'vitest' import { applyAutocomplete, detectAutocompleteTrigger, - rankSlashCommandSuggestions, rankSuggestions } from './mobile-native-chat-autocomplete' describe('detectAutocompleteTrigger', () => { + it('opens nothing while the caret sits before the trigger character', () => { + expect(detectAutocompleteTrigger('/review-pr focus', 0)).toBeNull() + expect(detectAutocompleteTrigger('see @src', 4)).toBeNull() + expect(detectAutocompleteTrigger('/re', 1)).toMatchObject({ kind: 'slash', query: '' }) + }) + it('detects a slash command only at the start', () => { expect(detectAutocompleteTrigger('/rev', 4)).toEqual({ kind: 'slash', @@ -59,63 +64,7 @@ describe('rankSuggestions', () => { }) }) -describe('rankSlashCommandSuggestions', () => { - const commands = [ - { name: 'clear', description: 'Clear conversation history' }, - { name: 'compact', description: 'Summarize and compact' }, - { name: 'mcp', description: 'List MCP tools' } - ] - - it('shows the whole catalog for a bare slash', () => { - expect(rankSlashCommandSuggestions(commands, '').map((c) => c.name)).toEqual([ - 'clear', - 'compact', - 'mcp' - ]) - }) - - it('ranks prefix matches ahead of substring matches', () => { - expect(rankSlashCommandSuggestions(commands, 'c').map((c) => c.name)).toEqual([ - 'clear', - 'compact', - 'mcp' - ]) - expect(rankSlashCommandSuggestions(commands, 'm').map((c) => c.name)).toEqual([ - 'mcp', - 'compact' - ]) - }) - - it('is case-insensitive and drops non-matches', () => { - expect(rankSlashCommandSuggestions(commands, 'CLE').map((c) => c.name)).toEqual(['clear']) - expect(rankSlashCommandSuggestions(commands, 'zzz')).toEqual([]) - }) - - it('preserves command identity and metadata for duplicate names', () => { - const catalog = [ - { name: 'team-review', description: 'First' }, - { name: 'team-review', argumentHint: '' }, - { name: 'review', kindUnspecified: true as const } - ] - const result = rankSlashCommandSuggestions(catalog, 'review', 3) - expect(result[0]).toBe(catalog[2]) - expect(result[1]).toBe(catalog[0]) - expect(result[2]).toBe(catalog[1]) - }) -}) - -describe.each([ - { name: 'file', rank: rankSuggestions }, - { - name: 'slash', - rank: (names: readonly string[], query: string, limit: number): string[] => - rankSlashCommandSuggestions( - names.map((name) => ({ name })), - query, - limit - ).map((command) => command.name) - } -])('$name suggestion bounds', ({ rank }) => { +describe.each([{ name: 'file', rank: rankSuggestions }])('$name suggestion bounds', ({ rank }) => { it('keeps later prefixes ahead of the earliest substring matches', () => { const candidates = ['team-review', 'team-review', 'pre-review', 'review-a', 'REVIEW-b'] expect(rank(candidates, 'REVIEW', 4)).toEqual([ diff --git a/mobile/src/session/mobile-native-chat-autocomplete.ts b/mobile/src/session/mobile-native-chat-autocomplete.ts index 5bbeb8a5d65..371883cb6da 100644 --- a/mobile/src/session/mobile-native-chat-autocomplete.ts +++ b/mobile/src/session/mobile-native-chat-autocomplete.ts @@ -2,8 +2,6 @@ // `/slash` commands. Detection is pure (text + cursor → active trigger) so it's // unit-testable and the composer stays a thin view over it. -import type { SlashCommandSuggestion } from '../../../src/shared/native-chat-slash-commands' - export type AutocompleteKind = 'file' | 'slash' export type AutocompleteTrigger = { @@ -33,6 +31,10 @@ export function detectAutocompleteTrigger( i-- } const triggerIndex = i + 1 + // A caret before the trigger character is not in its token. + if (pos <= triggerIndex) { + return null + } const triggerChar = text[triggerIndex] if (triggerChar !== '@' && triggerChar !== '/') { return null @@ -93,31 +95,3 @@ export function rankSuggestions(candidates: readonly string[], query: string, li } return [...prefix, ...substring].slice(0, limit) } - -/** Rank an agent's slash commands: prefix matches first, then substring, in - * catalog order. A bare `/` shows the whole catalog (desktop parity) — the - * suggestion list scrolls, so the cap only guards against absurd catalogs. */ -export function rankSlashCommandSuggestions( - commands: readonly SlashCommandSuggestion[], - query: string, - limit = 50 -): SlashCommandSuggestion[] { - const q = query.toLowerCase() - if (q.length === 0) { - return commands.slice(0, limit) - } - const prefix: SlashCommandSuggestion[] = [] - const substring: SlashCommandSuggestion[] = [] - for (const command of commands) { - const lower = command.name.toLowerCase() - if (lower.startsWith(q)) { - prefix.push(command) - } else if (substring.length < limit && lower.includes(q)) { - substring.push(command) - } - if (prefix.length >= limit) { - break - } - } - return [...prefix, ...substring].slice(0, limit) -} diff --git a/mobile/src/session/mobile-native-chat-composer-field-skill.ts b/mobile/src/session/mobile-native-chat-composer-field-skill.ts new file mode 100644 index 00000000000..dbd8cf08261 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-composer-field-skill.ts @@ -0,0 +1,22 @@ +import { StyleSheet, type TextInput, type TextInputProps } from 'react-native' +import { colors } from '../theme/mobile-theme' + +/** The composer's text field on both platforms: a TextInput that draws a leading skill in place. */ +export type MobileNativeChatComposerFieldProps = Omit< + TextInputProps, + 'value' | 'children' | 'onScroll' | 'style' +> & { + value: string + /** The skill the draft leads with, drawn highlighted in place. */ + skillToken: string | null + ref?: React.Ref +} + +/** The picked skill at the start of the draft. Same font as the text around it: on the page a copy + * over the field draws it, so the token must take the width the field gives it. */ +export const mobileNativeChatComposerSkillStyles = StyleSheet.create({ + token: { + color: colors.skillToken, + backgroundColor: colors.skillTokenSurface + } +}) diff --git a/mobile/src/session/mobile-native-chat-composer-layout.ts b/mobile/src/session/mobile-native-chat-composer-layout.ts index ba0f8579067..2099f4aac39 100644 --- a/mobile/src/session/mobile-native-chat-composer-layout.ts +++ b/mobile/src/session/mobile-native-chat-composer-layout.ts @@ -1,16 +1,5 @@ import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -/** The pickers row only shows while the user is writing; a typed draft, a picked image or an open - * picker drawer keeps it, so collapsing never hides what they were editing. */ -export function mobileNativeChatComposerExpanded(input: { - focused: boolean - pickerOpen: boolean - draft: string - attachmentCount: number -}): boolean { - return input.focused || input.pickerOpen || input.draft.length > 0 || input.attachmentCount > 0 -} - /** Keep Send for an active upload/dictation edit; otherwise offer the action available now. */ export function mobileNativeChatComposerShowsStop(input: { canStop: boolean diff --git a/mobile/src/session/mobile-native-chat-composer-menu.test.ts b/mobile/src/session/mobile-native-chat-composer-menu.test.ts new file mode 100644 index 00000000000..26eed4aa61e --- /dev/null +++ b/mobile/src/session/mobile-native-chat-composer-menu.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest' +import { + draftAfterSkillEdit, + narrowedMenuItems, + leadingSkillToken, + mobileComposerSkillTokens, + mobileComposerSlashItems +} from './mobile-native-chat-composer-menu' + +const reported = [ + { name: 'compact', kind: 'command' as const, description: 'Compact the chat' }, + { name: 'review-pr', kind: 'skill' as const, description: 'Review a pull request' }, + { name: 'research', kind: 'skill' as const } +] + +describe('mobileComposerSlashItems', () => { + it('offers what the session reported: its commands, then its skills with their descriptions', () => { + const items = mobileComposerSlashItems({ + agent: 'claude', + structured: { reported, conversationCommands: ['clear', 'compact'] }, + query: '' + }) + expect(items.map((item) => [item.kind, item.token, item.description ?? null])).toEqual([ + ['command', '/compact', 'Compact the chat'], + ['skill', '/research', null], + ['skill', '/review-pr', 'Review a pull request'] + ]) + }) + + it('narrows by the typed query across both groups', () => { + const items = mobileComposerSlashItems({ + agent: 'claude', + structured: { reported, conversationCommands: [] }, + query: 'rev' + }) + expect(items.map((item) => item.token)).toEqual(['/review-pr']) + }) + + it('names skills the way the agent invokes them', () => { + const items = mobileComposerSlashItems({ + agent: 'codex', + structured: { reported, conversationCommands: [] }, + query: 'review' + }) + expect(items.map((item) => item.token)).toEqual(['$review-pr']) + }) + + it('falls back to the host commands, with no skills, before the session reports', () => { + const items = mobileComposerSlashItems({ + agent: 'claude', + structured: { reported: undefined, conversationCommands: ['compact'] }, + query: '' + }) + expect(items.some((item) => item.token === '/compact')).toBe(true) + expect(items.some((item) => item.kind === 'skill')).toBe(false) + }) + + it('keeps a terminal-backed chat on its curated commands', () => { + const items = mobileComposerSlashItems({ agent: 'claude', structured: undefined, query: '' }) + expect(items.length).toBeGreaterThan(0) + expect(items.every((item) => item.kind === 'command')).toBe(true) + }) +}) + +describe('mobileComposerSkillTokens', () => { + it('knows every skill the session loaded, though the menu draws only the first rows', () => { + const many = Array.from({ length: 60 }, (_, index) => ({ + name: `skill-${String(index).padStart(2, '0')}`, + kind: 'skill' as const + })) + const structured = { reported: many, conversationCommands: [] } + const drawn = mobileComposerSlashItems({ agent: 'claude', structured, query: '' }) + expect(drawn.filter((item) => item.kind === 'skill')).toHaveLength(10) + const tokens = mobileComposerSkillTokens({ agent: 'claude', structured }) + expect(tokens.size).toBe(60) + expect(tokens.has('/skill-59')).toBe(true) + }) +}) + +describe('leadingSkillToken', () => { + const skills = new Set(['/review-pr', '$review-pr']) + + it('reads a known skill followed by a space at the start of the draft', () => { + expect(leadingSkillToken('/review-pr focus on auth', skills)).toBe('/review-pr') + expect(leadingSkillToken('/review-pr ', skills)).toBe('/review-pr') + }) + + it('reads nothing until the token is closed, or for a command or prose', () => { + expect(leadingSkillToken('/review-pr', skills)).toBeNull() + expect(leadingSkillToken('/compact now', skills)).toBeNull() + expect(leadingSkillToken(' /review-pr x', skills)).toBeNull() + }) +}) + +describe('draftAfterSkillEdit', () => { + it('takes the whole skill out when a delete reaches into it', () => { + expect(draftAfterSkillEdit('/review-pr ', '/review-pr', '/review-pr')).toBe('') + expect(draftAfterSkillEdit('/review-pr focus', '/review-prfocus', '/review-pr')).toBe('focus') + expect(draftAfterSkillEdit('/review-pr focus', '/review-p focus', '/review-pr')).toBe('focus') + }) + + it('keeps every other edit as typed', () => { + expect(draftAfterSkillEdit('/review-pr focus', '/review-pr focus!', '/review-pr')).toBe( + '/review-pr focus!' + ) + expect(draftAfterSkillEdit('/review-pr focus', '/review-pr focu', '/review-pr')).toBe( + '/review-pr focu' + ) + expect(draftAfterSkillEdit('/review-pr x', '/review-pXr x', '/review-pr')).toBe('/review-pXr x') + }) +}) + +describe('narrowedMenuItems', () => { + const items = mobileComposerSlashItems({ + agent: 'claude', + structured: { reported, conversationCommands: [] }, + query: '' + }) + + it('keeps one kind when the + sheet asks for it', () => { + expect(narrowedMenuItems(items, 'skill').every((item) => item.kind === 'skill')).toBe(true) + }) + + it('shows everything when that kind is empty, as for a chat at rest', () => { + const commandsOnly = items.filter((item) => item.kind === 'command') + expect(narrowedMenuItems(commandsOnly, 'skill')).toEqual(commandsOnly) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-composer-menu.ts b/mobile/src/session/mobile-native-chat-composer-menu.ts new file mode 100644 index 00000000000..1b6b43bb72e --- /dev/null +++ b/mobile/src/session/mobile-native-chat-composer-menu.ts @@ -0,0 +1,104 @@ +import type { AgentSessionConversationCommand } from '../../../src/shared/agent-session-conversation-command' +import type { AgentSessionSlashCommand } from '../../../src/shared/agent-session-wire' +import { getNativeChatAgentProfile } from '../../../src/shared/native-chat-agent-profiles' +import { + buildNativeChatPickerItems, + type NativeChatPickerItem +} from '../../../src/shared/native-chat-picker-items' +import { + sessionReportedSkills, + sessionSlashCommandSuggestions +} from '../../../src/shared/native-chat-slash-commands' +import { structuredSlashCommands } from '../../../src/shared/structured-agent-session-composer' +import { getMobileNativeChatCommands } from './mobile-native-chat-send-classification' + +// Why: the menu is a plain ScrollView right above a streaming transcript, so it mounts a bounded +// number of rows; typing narrows the rest. +const MENU_ROWS_PER_GROUP = 10 + +export type MobileComposerStructuredCatalog = { + /** What the session reported for its `/` menu; undefined until read or on an older host. */ + reported: readonly AgentSessionSlashCommand[] | undefined + conversationCommands: readonly AgentSessionConversationCommand[] +} + +type SlashMenuInput = { + agent: string | null + structured: MobileComposerStructuredCatalog | undefined + query: string +} + +/** + * The `/` menu, built by the same rules as desktop's. A structured chat offers the session's own + * commands and skills when it reported them, else the host's commands; a terminal-backed chat keeps + * its curated commands and no skills. + */ +function slashItems( + { agent, structured, query }: SlashMenuInput, + limit: number +): NativeChatPickerItem[] { + if (!agent) { + return [] + } + const reported = structured?.reported + const commands = !structured + ? getMobileNativeChatCommands(agent) + : reported === undefined + ? structuredSlashCommands(structured.conversationCommands, agent) + : sessionSlashCommandSuggestions(agent, reported) + return buildNativeChatPickerItems( + commands, + [], + query, + getNativeChatAgentProfile(agent)?.skillPrefix ?? '/', + reported ? sessionReportedSkills(reported) : [], + limit + ) +} + +/** The rows the menu draws for a query: each group capped, since typing narrows the rest. */ +export function mobileComposerSlashItems(input: SlashMenuInput): NativeChatPickerItem[] { + return slashItems(input, MENU_ROWS_PER_GROUP) +} + +/** Every skill the chat can invoke, as typed, by the menu's own rules but uncapped. */ +export function mobileComposerSkillTokens( + input: Omit +): ReadonlySet { + return new Set( + slashItems({ ...input, query: '' }, Infinity).flatMap((item) => + item.kind === 'skill' ? [item.token] : [] + ) + ) +} + +/** The menu narrowed to one kind, as the + sheet's Skills and Commands rows open it. A chat at rest + * may report its skills as plain commands, so a narrowing that leaves nothing shows everything. */ +export function narrowedMenuItems( + items: NativeChatPickerItem[], + kind: NativeChatPickerItem['kind'] | null +): NativeChatPickerItem[] { + const narrowed = kind ? items.filter((item) => item.kind === kind) : [] + return narrowed.length > 0 ? narrowed : items +} + +/** The skill a draft leads with: a known skill token and a space. */ +export function leadingSkillToken(draft: string, skillTokens: ReadonlySet): string | null { + const space = draft.indexOf(' ') + if (space <= 0) { + return null + } + const token = draft.slice(0, space) + return skillTokens.has(token) ? token : null +} + +/** An edit that cuts into the leading skill removes all of it: it is one unit, as desktop's + * skill pill is. Any other edit passes through as typed. */ +export function draftAfterSkillEdit(previous: string, next: string, skillToken: string): string { + const lead = `${skillToken} ` + if (next.startsWith(lead)) { + return next + } + const rest = previous.slice(lead.length) + return next.length < previous.length && next.endsWith(rest) ? rest : next +} diff --git a/mobile/src/session/mobile-native-chat-composer-styles.ts b/mobile/src/session/mobile-native-chat-composer-styles.ts index 7916e3a3222..e95bebaa83c 100644 --- a/mobile/src/session/mobile-native-chat-composer-styles.ts +++ b/mobile/src/session/mobile-native-chat-composer-styles.ts @@ -1,109 +1,61 @@ import { StyleSheet } from 'react-native' import { colors, radii, spacing } from '../theme/mobile-theme' +/** Round controls on the action row: the attach button, the mic and Send/Stop. */ +const COMPOSER_CONTROL_SIZE = 38 +/** Space above the composer box; the queue box above reaches through it. */ +export const COMPOSER_INSET_TOP = 6 + +const roundControl = { + width: COMPOSER_CONTROL_SIZE, + height: COMPOSER_CONTROL_SIZE, + borderRadius: COMPOSER_CONTROL_SIZE / 2, + alignItems: 'center', + justifyContent: 'center' +} as const + export const mobileNativeChatComposerStyles = StyleSheet.create({ - attachmentStrip: { - maxHeight: 76, - borderTopWidth: StyleSheet.hairlineWidth, - borderTopColor: colors.borderSubtle, - backgroundColor: colors.bgPanel - }, - attachmentStripContent: { - gap: spacing.sm, - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm - }, - attachmentThumb: { - width: 60, - height: 60, - borderRadius: radii.button, - borderWidth: StyleSheet.hairlineWidth, - borderColor: colors.borderSubtle, - backgroundColor: colors.bgRaised - }, - attachmentImage: { - width: '100%', - height: '100%', - borderRadius: radii.button - }, - attachmentRemove: { - // Inset inside the thumb: Android drops touches outside the parent's bounds, - // so an overhanging badge would lose part of its tap target. - position: 'absolute', - top: 2, - right: 2, - width: 20, - height: 20, - borderRadius: 10, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: colors.bgRaised, - borderWidth: StyleSheet.hairlineWidth, - borderColor: colors.borderSubtle - }, composerInset: { - paddingHorizontal: spacing.md, - paddingTop: spacing.sm, - paddingBottom: spacing.md + paddingHorizontal: 10, + paddingTop: COMPOSER_INSET_TOP, + paddingBottom: spacing.sm }, bar: { - gap: spacing.xs, - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, + paddingTop: spacing.md, + paddingHorizontal: 10, + paddingBottom: 10, borderWidth: StyleSheet.hairlineWidth, - borderColor: colors.borderSubtle, - borderRadius: radii.card, - backgroundColor: colors.bgPanel, - overflow: 'hidden' - }, - barCollapsed: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.xs, - padding: spacing.xs, - borderWidth: StyleSheet.hairlineWidth, - borderColor: colors.borderSubtle, - borderRadius: radii.card, - backgroundColor: colors.bgPanel, - overflow: 'hidden' - }, - // The collapsed field is always empty (a draft keeps it expanded), so one line on the bar. - inputCollapsed: { - minWidth: 0, - paddingHorizontal: spacing.xs, - backgroundColor: colors.bgPanel + borderColor: colors.composerBorder, + borderRadius: radii.composer, + backgroundColor: colors.composerSurface, + shadowColor: '#000', + shadowOpacity: 0.45, + shadowRadius: 15, + shadowOffset: { width: 0, height: 10 }, + elevation: 8 }, actionRow: { - minHeight: 40, + marginTop: 10, flexDirection: 'row', alignItems: 'center', gap: spacing.sm }, - actionRowCollapsed: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.xs - }, actionSpacer: { flex: 1 }, - iconButton: { - width: 40, - height: 40, - alignItems: 'center', - justifyContent: 'center' + controlButton: { + ...roundControl, + backgroundColor: colors.composerControl + }, + ghostButton: { + ...roundControl }, sendButton: { - width: 40, - height: 40, - borderRadius: 20, - alignItems: 'center', - justifyContent: 'center', - // White send affordance per design — dark arrow on a light circle. - backgroundColor: colors.textPrimary + ...roundControl, + backgroundColor: colors.surfaceBright }, sendButtonDisabled: { - backgroundColor: colors.bgRaised + backgroundColor: colors.composerControl }, pressed: { opacity: 0.7 diff --git a/mobile/src/session/mobile-native-chat-controller-contract.ts b/mobile/src/session/mobile-native-chat-controller-contract.ts index 0127e36b02c..f99c233c22b 100644 --- a/mobile/src/session/mobile-native-chat-controller-contract.ts +++ b/mobile/src/session/mobile-native-chat-controller-contract.ts @@ -1,3 +1,4 @@ +import type { StructuredMobileSendAttachment } from './use-mobile-structured-send-with-outcome' import type { MobileSessionView } from '../storage/session-view-preferences' import type { Dispatch, MutableRefObject, SetStateAction } from 'react' import type { @@ -104,11 +105,7 @@ export type MobileNativeChatController = { text: string, images?: string[], deadline?: number, - attachments?: readonly { - id?: string - path: string - previewUri: string - }[], + attachments?: readonly StructuredMobileSendAttachment[], sentAt?: number ) => Promise /** Launch-context text still parked on the agent's TUI input line, or null. diff --git a/mobile/src/session/mobile-native-chat-file-attachment.test.ts b/mobile/src/session/mobile-native-chat-file-attachment.test.ts new file mode 100644 index 00000000000..15c437f422a --- /dev/null +++ b/mobile/src/session/mobile-native-chat-file-attachment.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' +import { MobileFileTooLargeError, type PickedMobileFile } from '../platform/media-picker-contract' +import { + nativeChatTextWithFileReferences, + uploadPickedNativeChatFiles, + type PendingNativeChatFile +} from './mobile-native-chat-file-attachment' + +function picked(name: string): PickedMobileFile { + return { name, mime: 'text/plain', byteLength: 1, base64: 'YQ==' } +} + +async function* pickedFiles(files: PickedMobileFile[], thenThrow?: Error) { + for (const file of files) { + yield file + } + if (thenThrow) { + throw thenThrow + } +} + +describe('nativeChatTextWithFileReferences', () => { + it('references each stored file on a line after the text', () => { + expect( + nativeChatTextWithFileReferences('Review these ', [ + { path: '/store/1/spec.pdf' }, + { path: '/store/2/my notes.md' } + ]) + ).toBe('Review these\n@/store/1/spec.pdf @"/store/2/my notes.md"') + }) + + it('sends the references alone when there is no text, and the text alone with no files', () => { + expect(nativeChatTextWithFileReferences(' ', [{ path: '/store/1/spec.pdf' }])).toBe( + '@/store/1/spec.pdf' + ) + expect(nativeChatTextWithFileReferences('hi', [])).toBe('hi') + }) +}) + +describe('uploadPickedNativeChatFiles', () => { + function run(files: AsyncIterable, failName?: string) { + const chips = new Map() + const errors: string[] = [] + let id = 0 + const done = uploadPickedNativeChatFiles({ + picked: files, + upload: async (file) => { + if (file.name === failName) { + throw new Error('host refused') + } + return { path: `/store/${file.name}`, name: file.name, byteLength: 1 } + }, + nextId: () => `file-${(id += 1)}`, + onPicked: (file) => chips.set(file.id, file), + onStored: (fileId, stored) => { + const chip = chips.get(fileId) + if (chip) { + chips.set(fileId, { ...chip, path: stored.path }) + } + }, + onFailed: (fileId) => chips.delete(fileId), + onError: (message) => errors.push(message) + }) + return { chips, errors, done } + } + + it('keeps every stored file as a chip with its host path', async () => { + const { chips, errors, done } = run(pickedFiles([picked('a.txt'), picked('b.txt')])) + await done + expect([...chips.values()].map((chip) => chip.path)).toEqual(['/store/a.txt', '/store/b.txt']) + expect(errors).toEqual([]) + }) + + it('drops the chip of a failed upload, says which file, and carries on', async () => { + const { chips, errors, done } = run(pickedFiles([picked('a.txt'), picked('b.txt')]), 'a.txt') + await done + expect([...chips.values()].map((chip) => chip.name)).toEqual(['b.txt']) + expect(errors).toEqual(["Couldn't upload a.txt"]) + }) + + it('keeps what was stored before the picker refused an oversized file', async () => { + const { chips, errors, done } = run( + pickedFiles([picked('a.txt')], new MobileFileTooLargeError('clip.mov')) + ) + await done + expect([...chips.values()].map((chip) => chip.name)).toEqual(['a.txt']) + expect(errors).toEqual([new MobileFileTooLargeError('clip.mov').message]) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-file-attachment.ts b/mobile/src/session/mobile-native-chat-file-attachment.ts new file mode 100644 index 00000000000..f0c48bc9571 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-file-attachment.ts @@ -0,0 +1,69 @@ +import { formatNativeChatFileReference } from '../../../src/shared/agent-image-paste' +import { MobileFileTooLargeError, type PickedMobileFile } from '../platform/media-picker-contract' +import type { AgentSessionAttachmentUploadCommitResult } from '../../../src/shared/agent-session-attachments' + +/** A file in the composer: its chip shows from the pick, and `path` arrives once the host stored it. */ +export type PendingNativeChatFile = { + readonly id: string + readonly name: string + readonly mime: string + readonly byteLength: number + /** The host's stored path; null while the upload runs. */ + readonly path: string | null +} + +/** The message text with each stored file referenced after it, the way desktop sends a file. */ +export function nativeChatTextWithFileReferences( + text: string, + files: readonly { readonly path: string }[] +): string { + if (files.length === 0) { + return text + } + const joined = files.map((file) => formatNativeChatFileReference(file.path)).join(' ') + return text.trim().length === 0 ? joined : `${text.trimEnd()}\n${joined}` +} + +/** One line a person reads when an attach goes wrong. */ +function nativeChatFileAttachFailureMessage(error: unknown, fileName?: string): string { + if (error instanceof MobileFileTooLargeError) { + return error.message + } + return fileName ? `Couldn't upload ${fileName}` : "Couldn't attach that file" +} + +/** + * Uploads each picked file in turn: a chip per file as soon as it is picked, its path once stored, + * and the chip gone again when its upload fails. A picker failure ends the pick; uploads already + * stored stay. + */ +export async function uploadPickedNativeChatFiles(args: { + picked: AsyncIterable + upload: (file: PickedMobileFile) => Promise + nextId: () => string + onPicked: (file: PendingNativeChatFile) => void + onStored: (id: string, stored: AgentSessionAttachmentUploadCommitResult) => void + onFailed: (id: string) => void + onError: (message: string) => void +}): Promise { + try { + for await (const file of args.picked) { + const id = args.nextId() + args.onPicked({ + id, + name: file.name, + mime: file.mime, + byteLength: file.byteLength, + path: null + }) + try { + args.onStored(id, await args.upload(file)) + } catch (error) { + args.onFailed(id) + args.onError(nativeChatFileAttachFailureMessage(error, file.name)) + } + } + } catch (error) { + args.onError(nativeChatFileAttachFailureMessage(error)) + } +} diff --git a/mobile/src/session/mobile-native-chat-image-scope-state.ts b/mobile/src/session/mobile-native-chat-image-scope-state.ts index 8d7de510e3a..1dc33515277 100644 --- a/mobile/src/session/mobile-native-chat-image-scope-state.ts +++ b/mobile/src/session/mobile-native-chat-image-scope-state.ts @@ -4,11 +4,12 @@ export const NO_NATIVE_CHAT_IMAGE_ATTACHMENTS: PendingNativeChatImage[] = [] export type MobileNativeChatImagesByScope = Record -export function withScopeAttachments( - byScope: MobileNativeChatImagesByScope, +/** One scope's list replaced, and the scope dropped once its list is empty. */ +export function withScopeAttachments( + byScope: Readonly>, scope: string, - next: PendingNativeChatImage[] -): MobileNativeChatImagesByScope { + next: List +): Record { if (next.length > 0) { return { ...byScope, [scope]: next } } diff --git a/mobile/src/session/mobile-native-chat-input-base-styles.ts b/mobile/src/session/mobile-native-chat-input-base-styles.ts index 6cc2453bed6..6016f8ed67a 100644 --- a/mobile/src/session/mobile-native-chat-input-base-styles.ts +++ b/mobile/src/session/mobile-native-chat-input-base-styles.ts @@ -3,10 +3,9 @@ import { colors, radii, spacing } from '../theme/mobile-theme' /** * The chat's two free-text fields, minus the one thing that is a platform answer. * - * The composer spans its row and grows to 140; the question's field shares the row with a send - * button and stops at 120. Everything else about them is the same and is here, because a `.web.ts` - * cannot import a value from the file it shadows and two copies of a style object drift apart on - * everything except the difference that was meant to be between them. + * The composer's field sits flush inside its box, which carries the surface, and grows to about + * seven lines; the question's field shares the row with a send button and stops at 120. They live + * here because a `.web.ts` cannot import a value from the file it shadows. */ const chatInputSurface = { minHeight: 40, @@ -19,6 +18,15 @@ const chatInputSurface = { } as const export const mobileNativeChatInputBase = { - input: { ...chatInputSurface, width: '100%', maxHeight: 140 }, + input: { + width: '100%', + minHeight: 30, + maxHeight: 154, + lineHeight: 22, + color: colors.textPrimary, + paddingHorizontal: 6, + paddingTop: 2, + paddingBottom: 2 + }, freeInput: { ...chatInputSurface, flex: 1, maxHeight: 120 } } as const diff --git a/mobile/src/session/mobile-native-chat-input-styles.ts b/mobile/src/session/mobile-native-chat-input-styles.ts index 2d1591db9e7..f95487077cb 100644 --- a/mobile/src/session/mobile-native-chat-input-styles.ts +++ b/mobile/src/session/mobile-native-chat-input-styles.ts @@ -3,15 +3,17 @@ import { typography } from '../theme/mobile-theme' import { mobileNativeChatInputBase } from './mobile-native-chat-input-base-styles' /** - * Native: both chat fields sit one point above the body size, which is what they have rendered at. + * Native: the question's field sits one point above the body size; the composer reads at 16, the + * size the page's floor already gives it. * * The `.web.ts` sibling raises them to the text-input seam, because 15 is under the size below * which iOS zooms the page on focus — and this screen is the one that cannot afford that zoom, the * terminal's keyboard lift being pure geometry on a document it assumes is at scale 1. */ const CHAT_INPUT_FONT_SIZE = typography.bodySize + 1 +const COMPOSER_INPUT_FONT_SIZE = 16 export const mobileNativeChatInputStyles = StyleSheet.create({ - input: { ...mobileNativeChatInputBase.input, fontSize: CHAT_INPUT_FONT_SIZE }, + input: { ...mobileNativeChatInputBase.input, fontSize: COMPOSER_INPUT_FONT_SIZE }, freeInput: { ...mobileNativeChatInputBase.freeInput, fontSize: CHAT_INPUT_FONT_SIZE } }) diff --git a/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx b/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx index 84eb4112b81..2c0d9ebaf57 100644 --- a/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx +++ b/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx @@ -95,7 +95,8 @@ const HOST_SUPPORT = { quietRepeatedStop: true, sendOptions: false, conversationStop: false, - permissionModes: false + permissionModes: false, + phoneAttachments: false } type Phone = { diff --git a/mobile/src/session/mobile-native-chat-queued-message-styles.ts b/mobile/src/session/mobile-native-chat-queued-message-styles.ts index dab516bcaa3..b8084dfb267 100644 --- a/mobile/src/session/mobile-native-chat-queued-message-styles.ts +++ b/mobile/src/session/mobile-native-chat-queued-message-styles.ts @@ -1,5 +1,6 @@ import { StyleSheet } from 'react-native' import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { COMPOSER_INSET_TOP } from './mobile-native-chat-composer-styles' // A card's actions touch as 44pt targets (platform floor) inside its row: Android drops touches // outside the parent, so the row is at least that tall and nothing overhangs it. @@ -7,16 +8,24 @@ const MIN_TOUCH_TARGET = 44 const HEADER_HEIGHT = spacing.xl + spacing.sm const META_LINE_HEIGHT = typography.metaSize + spacing.xs +// How far the box runs under the composer's top edge, so the two read as one piece. +const COMPOSER_TUCK = 12 + export const queuedMessageStyles = StyleSheet.create({ + // Inset from the composer's sides, and pulled down behind it: the composer draws after it. list: { marginHorizontal: spacing.lg, - marginVertical: spacing.xs + marginTop: spacing.xs, + marginBottom: -(COMPOSER_INSET_TOP + COMPOSER_TUCK) }, box: { + paddingBottom: COMPOSER_TUCK, backgroundColor: colors.bgPanel, - borderRadius: radii.row, + borderTopLeftRadius: 18, + borderTopRightRadius: 18, borderWidth: StyleSheet.hairlineWidth, - borderColor: colors.borderSubtle, + borderBottomWidth: 0, + borderColor: colors.composerBorder, overflow: 'hidden' }, header: { @@ -107,7 +116,7 @@ export const queuedMessageStyles = StyleSheet.create({ }, divided: { borderTopWidth: StyleSheet.hairlineWidth, - borderTopColor: colors.borderSubtle + borderTopColor: colors.composerBorder }, textColumn: { flex: 1, diff --git a/mobile/src/session/mobile-structured-agent-session-host-support.test.ts b/mobile/src/session/mobile-structured-agent-session-host-support.test.ts index 1e4be6f6f7d..62f2109fba6 100644 --- a/mobile/src/session/mobile-structured-agent-session-host-support.test.ts +++ b/mobile/src/session/mobile-structured-agent-session-host-support.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' import { + AGENT_SESSION_ATTACHMENTS_RUNTIME_CAPABILITY, AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY, + AGENT_SESSION_PHONE_ATTACHMENTS_RUNTIME_CAPABILITY, AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY, AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY, AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY, @@ -25,7 +27,8 @@ describe('structuredAgentSessionHostSupport', () => { statusFeed: false, sendOptions: false, conversationStop: false, - permissionModes: false + permissionModes: false, + phoneAttachments: false } expect(structuredAgentSessionHostSupport([])).toEqual(none) expect( @@ -58,5 +61,12 @@ describe('structuredAgentSessionHostSupport', () => { expect( structuredAgentSessionHostSupport([AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY]) ).toEqual({ ...none, permissionModes: true }) + expect( + structuredAgentSessionHostSupport([AGENT_SESSION_PHONE_ATTACHMENTS_RUNTIME_CAPABILITY]) + ).toEqual({ ...none, phoneAttachments: true }) + // A host with the store but from before phones could upload into it offers them nothing. + expect( + structuredAgentSessionHostSupport([AGENT_SESSION_ATTACHMENTS_RUNTIME_CAPABILITY]) + ).toEqual(none) }) }) diff --git a/mobile/src/session/mobile-structured-agent-session-host-support.ts b/mobile/src/session/mobile-structured-agent-session-host-support.ts index c58ac06176f..3dacdac96e8 100644 --- a/mobile/src/session/mobile-structured-agent-session-host-support.ts +++ b/mobile/src/session/mobile-structured-agent-session-host-support.ts @@ -10,6 +10,7 @@ import { } from '../../../src/shared/protocol-version' import { AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session-create-capabilities' import { AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session-permission-modes-capability' +import { AGENT_SESSION_PHONE_ATTACHMENTS_RUNTIME_CAPABILITY } from '../../../src/shared/runtime-capability-constants' /** Structured-session features the connected host advertised; null until the status probe answers. */ export type StructuredAgentSessionHostSupport = { @@ -32,6 +33,8 @@ export type StructuredAgentSessionHostSupport = { conversationStop: boolean /** The host applies each chat's permission mode; 1.4.225–1.4.226 do without advertising it. */ permissionModes: boolean + /** The phone may upload files into the host's chat attachment store. */ + phoneAttachments: boolean } export function structuredAgentSessionHostSupport( @@ -47,6 +50,7 @@ export function structuredAgentSessionHostSupport( quietRepeatedStop: capabilities.includes(AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY), sendOptions: capabilities.includes(AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY), conversationStop: capabilities.includes(AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY), - permissionModes: capabilities.includes(AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY) + permissionModes: capabilities.includes(AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY), + phoneAttachments: capabilities.includes(AGENT_SESSION_PHONE_ATTACHMENTS_RUNTIME_CAPABILITY) } } diff --git a/mobile/src/session/use-mobile-native-chat-composer-menu.ts b/mobile/src/session/use-mobile-native-chat-composer-menu.ts new file mode 100644 index 00000000000..56ad2e15ed3 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-composer-menu.ts @@ -0,0 +1,159 @@ +import { useEffect, useMemo, useState } from 'react' +import { + applyAutocomplete, + detectAutocompleteTrigger, + rankSuggestions +} from './mobile-native-chat-autocomplete' +import { + draftAfterSkillEdit, + narrowedMenuItems, + leadingSkillToken, + mobileComposerSkillTokens, + mobileComposerSlashItems, + type MobileComposerStructuredCatalog +} from './mobile-native-chat-composer-menu' +import { composerMenuInsertText, type ComposerMenuRow } from './MobileNativeChatComposerMenu' + +type Selection = { start: number; end: number } +/** Opening the menu from the + sheet narrows it to what that row named. */ +type MenuKind = 'skill' | 'command' + +/** + * The composer's draft and the `/`/`@` menu over it. + * + * A picked skill leads the draft as ` `, which is exactly what a send carries; the field + * draws that token highlighted and treats it as one unit. + */ +export function useMobileNativeChatComposerMenu(input: { + value: string + onChangeText: (text: string) => void + agent: string | null + /** Undefined on a terminal-backed chat. */ + structuredCatalog: MobileComposerStructuredCatalog | undefined + filePaths: readonly string[] + onNeedFiles?: (query: string) => void + /** The `/` menu opened; the session's list may have changed since it was read. */ + onSlashMenuOpen?: () => void + focusField: () => void +}) { + const { value, onChangeText, agent, structuredCatalog, filePaths } = input + const [cursor, setCursor] = useState(0) + // Drives the caret only right after an insert; a permanently controlled `selection` breaks + // manual caret placement in React Native. + const [pendingSelection, setPendingSelection] = useState(null) + const [menuKind, setMenuKind] = useState(null) + + const skillTokens = useMemo( + () => mobileComposerSkillTokens({ agent, structured: structuredCatalog }), + [agent, structuredCatalog] + ) + const skillToken = leadingSkillToken(value, skillTokens) + const setDraft = (next: string): void => { + // The narrowed menu lasts while its `/` does. + if (!next.startsWith('/')) { + setMenuKind(null) + } + onChangeText(next) + } + const onFieldChange = (next: string): void => { + const draft = skillToken ? draftAfterSkillEdit(value, next, skillToken) : next + setDraft(draft) + if (draft !== next) { + placeCaret(0) + } + } + + const trigger = useMemo(() => detectAutocompleteTrigger(value, cursor), [value, cursor]) + // Keyed on what the menu shows, so a caret move inside the same query rebuilds nothing. + const menuMode = trigger?.kind ?? null + const menuQuery = trigger?.query ?? '' + const rows = useMemo(() => { + if (menuMode === 'file') { + return rankSuggestions(filePaths, menuQuery).map((path) => ({ + kind: 'file' as const, + id: `file:${path}`, + path + })) + } + if (menuMode !== 'slash') { + return [] + } + const items = mobileComposerSlashItems({ + agent, + structured: structuredCatalog, + query: menuQuery + }) + return narrowedMenuItems(items, menuKind) + }, [agent, filePaths, menuKind, menuMode, menuQuery, structuredCatalog]) + + const { onNeedFiles, onSlashMenuOpen } = input + useEffect(() => { + if (trigger?.kind === 'file') { + onNeedFiles?.(trigger.query) + } + }, [onNeedFiles, trigger?.kind, trigger?.query]) + const slashOpen = trigger?.kind === 'slash' + useEffect(() => { + if (slashOpen) { + onSlashMenuOpen?.() + } + }, [onSlashMenuOpen, slashOpen]) + + const placeCaret = (at: number): void => { + setCursor(at) + setPendingSelection({ start: at, end: at }) + } + + const pick = (row: ComposerMenuRow): void => { + if (!trigger) { + return + } + setMenuKind(null) + // On the page a tapped row takes focus from the field; typing goes on where the pick left off. + input.focusField() + if (row.kind === 'skill') { + const rest = value.slice(trigger.end).trimStart() + onChangeText(`${row.token} ${rest}`) + placeCaret(row.token.length + 1) + return + } + const next = applyAutocomplete(value, trigger, composerMenuInsertText(row)) + setDraft(next.text) + placeCaret(next.cursor) + } + + /** The + sheet's Skills and Commands rows: a leading `/` opens the menu, narrowed. */ + const openSlashMenu = (kind: MenuKind): void => { + // A skill already picked is replaced by the next one. + const rest = (skillToken ? value.slice(skillToken.length + 1) : value).trimStart() + onChangeText(rest ? `/ ${rest}` : '/') + setMenuKind(kind) + placeCaret(1) + input.focusField() + } + + /** The + sheet's Workspace file row: an `@` at the caret opens the file search. */ + const openFileMenu = (): void => { + const before = value.slice(0, cursor) + const lead = before.length === 0 || /\s$/.test(before) ? '@' : ' @' + setDraft(before + lead + value.slice(cursor)) + placeCaret(before.length + lead.length) + input.focusField() + } + + return { + onFieldChange, + skillToken, + skillCount: skillTokens.size, + rows, + pick, + openSlashMenu, + openFileMenu, + pendingSelection, + onSelectionChange: (selection: Selection): void => { + setCursor(selection.end) + setPendingSelection(null) + }, + resetCursor: () => setCursor(0) + } +} diff --git a/mobile/src/session/use-mobile-native-chat-file-attachments.ts b/mobile/src/session/use-mobile-native-chat-file-attachments.ts new file mode 100644 index 00000000000..6f58bd5fc72 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-file-attachments.ts @@ -0,0 +1,89 @@ +import { useCallback, useRef, useState } from 'react' +import type { RpcClient } from '../transport/rpc-client' +import { useMediaPicker } from '../platform/media-picker' +import { uploadMobileAgentSessionAttachment } from './mobile-agent-session-attachment-upload' +import { withScopeAttachments } from './mobile-native-chat-image-scope-state' +import { + uploadPickedNativeChatFiles, + type PendingNativeChatFile +} from './mobile-native-chat-file-attachment' + +type ShowToast = (message: string, durationMs?: number) => void +type FilesByScope = Readonly> + +const NO_FILES: readonly PendingNativeChatFile[] = [] + +export type MobileNativeChatFileAttachments = { + /** Files for the active scope (tab) only. */ + readonly files: readonly PendingNativeChatFile[] + readonly attachFiles: () => Promise + /** Drops these files: a removed card, or the files a send carried (one attached while it was in + * flight stays). */ + readonly removeFiles: (ids: readonly string[]) => void +} + +/** Files attached to a structured chat, uploaded into the host's attachment store on pick. */ +export function useMobileNativeChatFileAttachments(args: { + client: RpcClient | null + sessionId: string | null + /** The composer surface the files belong to, so a tab switch never carries them over. */ + scopeKey: string | null + enabled: boolean + showToast: ShowToast +}): MobileNativeChatFileAttachments { + const { client, sessionId, scopeKey, enabled, showToast } = args + const [filesByScope, setFilesByScope] = useState({}) + const picker = useMediaPicker() + const idCounter = useRef(0) + const files = (scopeKey ? filesByScope[scopeKey] : undefined) ?? NO_FILES + + const updateScope = useCallback( + ( + scope: string, + next: (current: readonly PendingNativeChatFile[]) => readonly PendingNativeChatFile[] + ) => + setFilesByScope((prev) => withScopeAttachments(prev, scope, next(prev[scope] ?? NO_FILES))), + [] + ) + + const attachFiles = useCallback(async (): Promise => { + const scope = scopeKey + if (!client || !sessionId || !scope || !enabled) { + return + } + await uploadPickedNativeChatFiles({ + picked: picker.pickFiles(), + upload: (file) => + uploadMobileAgentSessionAttachment(client, { + sessionId, + name: file.name, + base64: file.base64 + }), + nextId: () => { + idCounter.current += 1 + return `file-${idCounter.current}` + }, + onPicked: (file) => updateScope(scope, (current) => [...current, file]), + onStored: (id, stored) => + updateScope(scope, (current) => + current.map((file) => + file.id === id ? { ...file, name: stored.name, path: stored.path } : file + ) + ), + onFailed: (id) => updateScope(scope, (current) => current.filter((file) => file.id !== id)), + onError: (message) => showToast(message) + }) + }, [client, enabled, picker, scopeKey, sessionId, showToast, updateScope]) + + const removeFiles = useCallback( + (ids: readonly string[]) => { + if (scopeKey) { + const removed = new Set(ids) + updateScope(scopeKey, (current) => current.filter((file) => !removed.has(file.id))) + } + }, + [scopeKey, updateScope] + ) + + return { files, attachFiles, removeFiles } +} diff --git a/mobile/src/session/use-mobile-native-chat-image-attachments.ts b/mobile/src/session/use-mobile-native-chat-image-attachments.ts index 419b662206e..ee342f0ad10 100644 --- a/mobile/src/session/use-mobile-native-chat-image-attachments.ts +++ b/mobile/src/session/use-mobile-native-chat-image-attachments.ts @@ -31,6 +31,8 @@ import { releaseMobileNativeChatTerminalWrite } from './mobile-native-chat-terminal-write-lock' import { useMobileNativeChatImageUpload } from './use-mobile-native-chat-image-upload' +import type { PendingNativeChatFile } from './mobile-native-chat-file-attachment' +import type { StructuredMobileSendAttachment } from './use-mobile-structured-send-with-outcome' type CurrentRef = { readonly current: T } type ShowToast = (message: string, durationMs?: number) => void @@ -62,11 +64,15 @@ type Args = { text: string, imagePreviewUris?: string[], deadline?: number, - attachments?: readonly PendingNativeChatImage[], + attachments?: readonly StructuredMobileSendAttachment[], sentAt?: number ) => Promise /** Structured sessions send attachments without the terminal paste path. */ readonly structuredNativeChat: boolean + /** A structured chat's stored files ride the next send beside its images. */ + readonly files?: readonly PendingNativeChatFile[] + /** Drops the files a send carried once the host took it. */ + readonly onFilesSent?: (ids: readonly string[]) => void /** Launch-context text parked on the agent's TUI input line, or null. The * paste's leading clear must cover every line of it, or the draft's earlier * lines survive and ride along with the image. */ @@ -88,6 +94,8 @@ export type MobileNativeChatImageAttachments = { readonly sendNativeChat: (text: string) => Promise } +const NO_FILES: readonly PendingNativeChatFile[] = [] + const defaultSleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) @@ -104,6 +112,8 @@ export function useMobileNativeChatImageAttachments({ onSendError, baseSend, structuredNativeChat, + files = NO_FILES, + onFilesSent, readSeededLaunchDraft, onAttachSuccess, onError, @@ -175,7 +185,13 @@ export function useMobileNativeChatImageAttachments({ const scope = scopeKey const pendingImages = (scope ? attachmentsByScope[scope] : undefined) ?? NO_NATIVE_CHAT_IMAGE_ATTACHMENTS - if (structuredNativeChat && pendingImages.length > 0 && scope) { + // Only stored files: one still uploading holds Send in the composer. + const storedFiles = structuredNativeChat + ? files.flatMap((file) => + file.path === null ? [] : [{ kind: 'file' as const, id: file.id, path: file.path }] + ) + : [] + if (structuredNativeChat && pendingImages.length + storedFiles.length > 0 && scope) { if (!client || !enabled || connState !== 'connected') { onError?.() onSendError('Message not sent (disconnected)') @@ -185,7 +201,7 @@ export function useMobileNativeChatImageAttachments({ text, pendingImages.map((attachment) => attachment.previewUri), deadline, - pendingImages + [...pendingImages, ...storedFiles] ) if (outcome !== 'rejected') { const sentIds = new Set(pendingImages.map((attachment) => attachment.id)) @@ -196,6 +212,7 @@ export function useMobileNativeChatImageAttachments({ (prev[scope] ?? []).filter((attachment) => !sentIds.has(attachment.id)) ) ) + onFilesSent?.(storedFiles.map((file) => file.id)) } return outcome !== 'rejected' } @@ -328,7 +345,9 @@ export function useMobileNativeChatImageAttachments({ agent, deviceTokenRef, enabled, + files, onError, + onFilesSent, onSendError, readSeededLaunchDraft, scopeKey, diff --git a/mobile/src/session/use-mobile-native-chat-input-lease.ts b/mobile/src/session/use-mobile-native-chat-input-lease.ts index 02cb57849d4..74e0227aaca 100644 --- a/mobile/src/session/use-mobile-native-chat-input-lease.ts +++ b/mobile/src/session/use-mobile-native-chat-input-lease.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react' +import { tuiAgentDisplayName } from '../../../src/shared/tui-agent-display-names' import { NATIVE_CHAT_TURN_STATUS_COPY } from '../../../src/shared/native-chat-turn-status' import type { MobileNativeChatInputLockReason } from './MobileNativeChatView' @@ -95,11 +96,12 @@ export function useSettledMobileNativeChatInputLock( } /** What the composer says while the input lease blocks it, else that a message sent now runs - * after a Stop the chat reads as stopping, else its normal prompt. */ + * after a Stop the chat reads as stopping, else its normal prompt naming the agent. */ export function mobileNativeChatComposerPlaceholder( lockReason: MobileNativeChatInputLockReason | null, /** While stopping: whether a message sent now is queued, or sent for the host to hold. */ - afterStop?: 'queue' | 'send' + afterStop?: 'queue' | 'send', + agent?: string | null ): string { if (lockReason === 'disconnected') { return 'Reconnecting…' @@ -112,5 +114,6 @@ export function mobileNativeChatComposerPlaceholder( ? NATIVE_CHAT_TURN_STATUS_COPY.queueAfterStop : NATIVE_CHAT_TURN_STATUS_COPY.sendAfterStop } - return 'Message, @files, /commands' + const name = agent ? tuiAgentDisplayName(agent) : null + return name ? `Message ${name}` : 'Message, @files, /commands' } diff --git a/mobile/src/session/use-mobile-native-chat-structured-composer.ts b/mobile/src/session/use-mobile-native-chat-structured-composer.ts new file mode 100644 index 00000000000..002d73c48f7 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-structured-composer.ts @@ -0,0 +1,50 @@ +import { useMemo } from 'react' +import type { AgentSessionConversationCommand } from '../../../src/shared/agent-session-conversation-command' +import type { RpcClient } from '../transport/rpc-client' +import type { MobileNativeChatStructuredComposer } from './MobileNativeChatComposer' +import type { MobileNativeChatFileAttachments } from './use-mobile-native-chat-file-attachments' +import { useMobileStructuredSessionCommands } from './use-mobile-structured-session-commands' + +const NO_COMMANDS: readonly AgentSessionConversationCommand[] = [] + +/** What a structured chat's composer adds: its session's `/` list, and files where the host + * stores them for phones. Null on a terminal-backed chat. */ +export function useMobileNativeChatStructuredComposer(args: { + client: RpcClient | null + hostId: string | null + sessionId: string | null + structured: boolean + connected: boolean + /** The host keeps files a phone uploads; an older one refuses the upload. */ + acceptsFiles: boolean + conversationCommands: readonly AgentSessionConversationCommand[] | undefined + files: MobileNativeChatFileAttachments +}): MobileNativeChatStructuredComposer | null { + const { client, hostId, sessionId, structured, connected, acceptsFiles, files } = args + const conversationCommands = args.conversationCommands ?? NO_COMMANDS + const { commands, refresh } = useMobileStructuredSessionCommands({ + client, + hostId, + sessionId, + enabled: structured && connected + }) + const { files: pendingFiles, attachFiles, removeFiles } = files + // Its own memo: an attach or an upload landing leaves the `/` menu's catalog as it was. + const catalog = useMemo( + () => ({ reported: commands, conversationCommands }), + [commands, conversationCommands] + ) + return useMemo( + () => + structured + ? { + catalog, + onSlashMenuOpen: refresh, + ...(acceptsFiles ? { onAttachFiles: () => void attachFiles() } : {}), + files: pendingFiles, + onRemoveFile: (id: string) => removeFiles([id]) + } + : null, + [acceptsFiles, attachFiles, catalog, pendingFiles, refresh, removeFiles, structured] + ) +} diff --git a/mobile/src/session/use-mobile-session-attachments.ts b/mobile/src/session/use-mobile-session-attachments.ts index 0abf3c97f4d..6d88ae44ed8 100644 --- a/mobile/src/session/use-mobile-session-attachments.ts +++ b/mobile/src/session/use-mobile-session-attachments.ts @@ -8,6 +8,8 @@ import { MobileWorkspaceOnOtherRuntimeError } from './mobile-new-tab-agent-loader' import { useMobileSessionImageAttachments } from './use-mobile-session-image-attachments' +import { useMobileNativeChatStructuredComposer } from './use-mobile-native-chat-structured-composer' +import { resolveMobileNativeChatSessionId } from './mobile-native-chat-eligibility' import { useMobileAttachmentInputLeaseGate } from './use-mobile-attachment-input-lease-gate' import { useMobileTerminalPaste } from './use-mobile-terminal-paste' import type { MobileSessionAccessorySelectionModel } from './use-mobile-session-accessory-selection' @@ -42,7 +44,9 @@ export function useMobileSessionAttachments(scope: MobileSessionAccessorySelecti nativeChatController, getActiveWorktreeConnectionId, refreshCanPaste, - activeSessionTab + activeSessionTab, + agentSessionHostSupport, + hostId } = scope const clipboardContents = useClipboardReader().contents const agent = @@ -78,27 +82,42 @@ export function useMobileSessionAttachments(scope: MobileSessionAccessorySelecti showToast }) + const structuredNativeChat = activeSessionTab?.type === 'agent-session' + const nativeChatSessionId = resolveMobileNativeChatSessionId(activeSessionTab) // Terminal input pastes an attached image straight into the visible terminal; // native chat instead holds it as a composer chip and rides it along on submit. - const { attachImage, isAttaching, nativeChatImages } = useMobileSessionImageAttachments({ + const { attachImage, isAttaching, nativeChatImages, nativeChatFiles } = + useMobileSessionImageAttachments({ + client, + agent, + activeHandle, + activeHandleRef, + canSend, + connState, + deviceTokenRef, + nativeChatScopeKey, + nativeChatInputLeaseReady, + getActiveWorktreeConnectionId, + beforeTerminalSend: flushPendingLiveInputBeforeAttachmentSend, + nativeChatBaseSend: nativeChatController.handleNativeChatSendWithOutcome, + structuredNativeChat, + nativeChatSessionId, + readSeededLaunchDraft: nativeChatController.readSeededLaunchDraft, + showToast, + onNativeChatSendError: nativeChatSendError.show, + onSuccess: triggerSelection, + onError: triggerError + }) + const nativeChatStructuredComposer = useMobileNativeChatStructuredComposer({ client, - agent, - activeHandle, - activeHandleRef, - canSend, - connState, - deviceTokenRef, - nativeChatScopeKey, - nativeChatInputLeaseReady, - getActiveWorktreeConnectionId, - beforeTerminalSend: flushPendingLiveInputBeforeAttachmentSend, - nativeChatBaseSend: nativeChatController.handleNativeChatSendWithOutcome, - structuredNativeChat: activeSessionTab?.type === 'agent-session', - readSeededLaunchDraft: nativeChatController.readSeededLaunchDraft, - showToast, - onNativeChatSendError: nativeChatSendError.show, - onSuccess: triggerSelection, - onError: triggerError + hostId, + sessionId: nativeChatSessionId, + structured: structuredNativeChat, + connected: connState === 'connected', + acceptsFiles: agentSessionHostSupport?.phoneAttachments === true, + conversationCommands: + nativeChatController.nativeChatSessionOptions?.controller.conversationCommands, + files: nativeChatFiles }) // Why: refresh canPaste on mount, AppState active, after paste. @@ -179,7 +198,8 @@ export function useMobileSessionAttachments(scope: MobileSessionAccessorySelecti flushPendingLiveInputBeforeAttachmentSend, attachImage, isAttaching, - nativeChatImages + nativeChatImages, + nativeChatStructuredComposer } } diff --git a/mobile/src/session/use-mobile-session-image-attachments.test.tsx b/mobile/src/session/use-mobile-session-image-attachments.test.tsx index 68aeafaea6f..349182aef69 100644 --- a/mobile/src/session/use-mobile-session-image-attachments.test.tsx +++ b/mobile/src/session/use-mobile-session-image-attachments.test.tsx @@ -6,7 +6,12 @@ import { useMobileSessionImageAttachments } from './use-mobile-session-image-att const mocks = vi.hoisted(() => ({ useMobileImageAttachment: vi.fn(), - useMobileNativeChatImageAttachments: vi.fn() + useMobileNativeChatImageAttachments: vi.fn(), + useMobileNativeChatFileAttachments: vi.fn(() => ({ + files: [], + attachFiles: vi.fn(), + removeFiles: vi.fn() + })) })) vi.mock('./use-mobile-image-attachment', () => ({ @@ -17,6 +22,10 @@ vi.mock('./use-mobile-native-chat-image-attachments', () => ({ useMobileNativeChatImageAttachments: mocks.useMobileNativeChatImageAttachments })) +vi.mock('./use-mobile-native-chat-file-attachments', () => ({ + useMobileNativeChatFileAttachments: mocks.useMobileNativeChatFileAttachments +})) + type HookArgs = Parameters[0] function baseArgs(overrides: Partial = {}): HookArgs { @@ -33,6 +42,7 @@ function baseArgs(overrides: Partial = {}): HookArgs { beforeTerminalSend: async () => true, nativeChatBaseSend: vi.fn().mockResolvedValue('accepted'), structuredNativeChat: true, + nativeChatSessionId: 'session-1', readSeededLaunchDraft: () => null, showToast: vi.fn(), onNativeChatSendError: vi.fn(), diff --git a/mobile/src/session/use-mobile-session-image-attachments.ts b/mobile/src/session/use-mobile-session-image-attachments.ts index 8c771ebbe19..72faef35f52 100644 --- a/mobile/src/session/use-mobile-session-image-attachments.ts +++ b/mobile/src/session/use-mobile-session-image-attachments.ts @@ -2,11 +2,16 @@ import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' import type { MobileImageSource } from './mobile-image-source-picker' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' +import type { StructuredMobileSendAttachment } from './use-mobile-structured-send-with-outcome' import { useMobileImageAttachment } from './use-mobile-image-attachment' import { useMobileNativeChatImageAttachments, type MobileNativeChatImageAttachments } from './use-mobile-native-chat-image-attachments' +import { + useMobileNativeChatFileAttachments, + type MobileNativeChatFileAttachments +} from './use-mobile-native-chat-file-attachments' type CurrentRef = { readonly current: T } @@ -31,15 +36,13 @@ type Args = { text: string, images?: string[], deadline?: number, - attachments?: readonly { - id: string - path: string - previewUri: string - }[], + attachments?: readonly StructuredMobileSendAttachment[], sentAt?: number ) => Promise /** Structured agent sessions do not have a terminal paste path. */ readonly structuredNativeChat: boolean + /** The structured chat's session, which owns the files uploaded for it. */ + readonly nativeChatSessionId: string | null /** Launch-context text parked on the agent's TUI input line, or null — sizes * the image paste's leading clear so a multi-line draft cannot ride along. */ readonly readSeededLaunchDraft: () => string | null @@ -52,8 +55,9 @@ type Args = { /** A session exposes image attachment on two surfaces that share one upload * pipeline and host wiring: the visible terminal input (immediate bracketed - * paste) and the native-chat composer (chips deferred to submit). Owning both - * here keeps the already-dense session route to a single wiring point. */ + * paste) and the native-chat composer (chips deferred to submit), plus a + * structured chat's files. Owning them here keeps the already-dense session + * route to a single wiring point. */ export function useMobileSessionImageAttachments({ client, agent, @@ -68,6 +72,7 @@ export function useMobileSessionImageAttachments({ beforeTerminalSend, nativeChatBaseSend, structuredNativeChat, + nativeChatSessionId, readSeededLaunchDraft, showToast, onNativeChatSendError, @@ -77,6 +82,7 @@ export function useMobileSessionImageAttachments({ attachImage: (source: MobileImageSource) => Promise isAttaching: boolean nativeChatImages: MobileNativeChatImageAttachments + nativeChatFiles: MobileNativeChatFileAttachments } { const { attachImage, isAttaching } = useMobileImageAttachment({ client, @@ -91,6 +97,13 @@ export function useMobileSessionImageAttachments({ onSuccess, onError }) + const nativeChatFiles = useMobileNativeChatFileAttachments({ + client, + sessionId: structuredNativeChat ? nativeChatSessionId : null, + scopeKey: nativeChatScopeKey, + enabled: structuredNativeChat && connState === 'connected', + showToast + }) const nativeChatImages = useMobileNativeChatImageAttachments({ client, agent, @@ -101,6 +114,8 @@ export function useMobileSessionImageAttachments({ scopeKey: nativeChatScopeKey, enabled: structuredNativeChat ? connState === 'connected' : nativeChatInputLeaseReady, structuredNativeChat, + files: nativeChatFiles.files, + onFilesSent: nativeChatFiles.removeFiles, showToast, onSendError: onNativeChatSendError, baseSend: nativeChatBaseSend, @@ -108,5 +123,5 @@ export function useMobileSessionImageAttachments({ onAttachSuccess: onSuccess, onError }) - return { attachImage, isAttaching, nativeChatImages } + return { attachImage, isAttaching, nativeChatImages, nativeChatFiles } } diff --git a/mobile/src/session/use-mobile-structured-agent-options.ts b/mobile/src/session/use-mobile-structured-agent-options.ts index 788451e164d..c6071c042c7 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.ts @@ -40,6 +40,8 @@ import { import { encodeStructuredAgentSessionOptionValue } from '../../../src/shared/structured-agent-session-option-codec' import { useMobileStructuredOptionSurface } from './use-mobile-structured-option-surface' +const NO_COMMANDS: readonly AgentSessionConversationCommand[] = [] + export function useMobileStructuredAgentOptions(args: { agent: string | null client: RpcClient | null @@ -299,7 +301,7 @@ export function useMobileStructuredAgentOptions(args: { return { optionPickerRequest, conversationCommands: - conversationSupport?.sessionId === sessionId ? conversationSupport.commands : [], + conversationSupport?.sessionId === sessionId ? conversationSupport.commands : NO_COMMANDS, /** The chat takes images, as its options report says; else only Claude and Codex, as before. */ acceptsImages: (imageSupport?.sessionId === sessionId ? imageSupport.acceptsImages : undefined) ?? diff --git a/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx index 78d6c65f83b..9de2d488616 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx @@ -127,7 +127,8 @@ function Harness({ quietRepeatedStop: false, sendOptions: false, conversationStop: false, - permissionModes: false + permissionModes: false, + phoneAttachments: false }, onSendError: vi.fn() }) diff --git a/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts b/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts index 318a0d10efa..08d38dbce27 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts +++ b/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts @@ -22,7 +22,8 @@ export const CAPABLE: StructuredAgentSessionHostSupport = { quietRepeatedStop: false, sendOptions: false, conversationStop: false, - permissionModes: false + permissionModes: false, + phoneAttachments: false } export const LEGACY: StructuredAgentSessionHostSupport = { ...CAPABLE, diff --git a/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx index 6a3cac4d48f..81b18f45756 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx @@ -413,6 +413,27 @@ describe('mobile structured send actions', () => { }) }) + it('sends an attached file as a reference after the text, and only images as image blocks', async () => { + sendRequest.mockImplementation(async (method) => + method === 'agentSession.send' ? sendResult('accepted') : ok({ models: [], current: {} }) + ) + await mountSession() + await act(async () => { + await hook!.sendWithOutcome('read these', undefined, undefined, [ + { path: '/tmp/shot.png', previewUri: 'file:///shot.png' }, + { kind: 'file', path: '/store/u1/spec.pdf' } + ]) + }) + expect(calls()[0]?.[1]).toMatchObject({ + body: { + blocks: [ + { type: 'text', text: 'read these\n@/store/u1/spec.pdf' }, + { type: 'image-ref', path: '/tmp/shot.png' } + ] + } + }) + }) + it('reports a send stopped before the agent started it without sending it twice', async () => { sendRequest.mockImplementation(async (method) => method === 'agentSession.send' diff --git a/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts index 959c27f75fa..c9e19224aa9 100644 --- a/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts +++ b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts @@ -3,12 +3,7 @@ import type { StructuredAgentId } from '../../../src/shared/agent-session-provid import { isStructuredAgentSessionComposerCommand } from '../../../src/shared/structured-agent-session-composer' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import type { MobileNativeChatSendOrigin } from './use-mobile-native-chat-drafts' - -type StructuredNativeChatAttachment = { - id?: string - path: string - previewUri: string -} +import type { StructuredMobileSendAttachment } from './use-mobile-structured-send-with-outcome' export function useMobileStructuredNativeChatSendBridge(args: { agent: StructuredAgentId | undefined @@ -16,7 +11,7 @@ export function useMobileStructuredNativeChatSendBridge(args: { text: string, images?: string[], deadline?: number, - attachments?: readonly StructuredNativeChatAttachment[] + attachments?: readonly StructuredMobileSendAttachment[] ) => Promise captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null clearDraftForSend: (origin: MobileNativeChatSendOrigin, text: string) => void @@ -28,7 +23,7 @@ export function useMobileStructuredNativeChatSendBridge(args: { text: string, images?: string[], deadline?: number, - attachments?: readonly StructuredNativeChatAttachment[] + attachments?: readonly StructuredMobileSendAttachment[] ) => Promise } { const { @@ -44,7 +39,7 @@ export function useMobileStructuredNativeChatSendBridge(args: { text: string, images?: string[], deadline?: number, - attachments?: readonly StructuredNativeChatAttachment[] + attachments?: readonly StructuredMobileSendAttachment[] ): Promise => { const origin = captureSendOrigin(text.trimEnd()) if (!origin) { @@ -75,7 +70,7 @@ export function useMobileStructuredNativeChatSendBridge(args: { text: string, images?: string[], deadline?: number, - attachments?: readonly StructuredNativeChatAttachment[] + attachments?: readonly StructuredMobileSendAttachment[] ) => (await sendWithOutcome(text, images, deadline, attachments)) !== 'rejected', [sendWithOutcome] ) diff --git a/mobile/src/session/use-mobile-structured-send-with-outcome.ts b/mobile/src/session/use-mobile-structured-send-with-outcome.ts index 72565d7863d..724acfb5c2e 100644 --- a/mobile/src/session/use-mobile-structured-send-with-outcome.ts +++ b/mobile/src/session/use-mobile-structured-send-with-outcome.ts @@ -22,6 +22,7 @@ import type { MobileStructuredSends } from './use-mobile-structured-sends' import { structuredSessionOperationId } from './structured-session-operation-id' import { timeoutForDeadline } from './mobile-structured-agent-session-rpc' import { structuredAgentSessionSendSelection } from '../../../src/shared/structured-agent-session-option-view' +import { nativeChatTextWithFileReferences } from './mobile-native-chat-file-attachment' import { actionableStructuredApproval, actionableStructuredQuestion @@ -38,9 +39,11 @@ export function mobileStructuredSendQueues( ) } -export type StructuredMobileSendAttachment = StructuredAgentSessionAttachment & { - id?: string -} +/** What rides a send: an image the agent is shown, or a file it reads from a reference the send + * writes after the text, as desktop sends one. */ +export type StructuredMobileSendAttachment = + | (StructuredAgentSessionAttachment & { kind?: 'image'; id?: string }) + | { kind: 'file'; id?: string; path: string } export function useMobileStructuredSendWithOutcome(args: { agent: string | null @@ -133,7 +136,17 @@ export function useMobileStructuredSendWithOutcome(args: { if (commandOutcome !== null) { return commandOutcome } - const body = structuredAgentSessionSendBody(text, sendAttachments) + const imageAttachments: StructuredAgentSessionAttachment[] = [] + const files: { path: string }[] = [] + for (const attachment of sendAttachments) { + if (attachment.kind === 'file') { + files.push(attachment) + } else { + imageAttachments.push(attachment) + } + } + const wireText = nativeChatTextWithFileReferences(text, files) + const body = structuredAgentSessionSendBody(wireText, imageAttachments) if (body.blocks.length === 0) { return 'rejected' } @@ -153,8 +166,8 @@ export function useMobileStructuredSendWithOutcome(args: { client, sessionId, expectedRuntimeFence: currentFence, - text, - attachments: sendAttachments, + text: wireText, + attachments: imageAttachments, ...(mobileStructuredSendQueues(queueCapable, stateRef.current) ? { delivery: 'queue-if-active' as const } : {}), diff --git a/mobile/src/session/use-mobile-structured-session-commands.test.tsx b/mobile/src/session/use-mobile-structured-session-commands.test.tsx new file mode 100644 index 00000000000..c1457f2ac84 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-session-commands.test.tsx @@ -0,0 +1,110 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { useMobileStructuredSessionCommands } from './use-mobile-structured-session-commands' + +type Hook = ReturnType + +describe('useMobileStructuredSessionCommands', () => { + let renderer: ReactTestRenderer | null = null + let hook: Hook | null = null + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + function Probe(props: { client: RpcClient; sessionId: string }) { + hook = useMobileStructuredSessionCommands({ ...props, hostId: 'host-1', enabled: true }) + return null + } + + it('reads the session’s list, again when the menu opens, and never shows another session’s', async () => { + const lists: Record = { + a: [{ name: 'review-pr', kind: 'skill' }], + b: [{ name: 'compact', kind: 'command' }] + } + const answers: { sessionId: string; answer: () => void }[] = [] + let holdNext = false + const sendRequest = vi.fn(async (_method: string, params: unknown) => { + const sessionId = + typeof params === 'object' && params !== null && 'sessionId' in params + ? String(params.sessionId) + : '' + if (holdNext) { + holdNext = false + await new Promise((resolve) => answers.push({ sessionId, answer: resolve })) + } + return { + id: 'rpc-1', + ok: true as const, + result: { commands: lists[sessionId] }, + _meta: { runtimeId: 'runtime-1' } + } + }) + const client: RpcClient = { + sendRequest, + subscribe: () => () => {}, + updateTerminalSubscriptionViewport: () => {}, + getState: () => 'connected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + onStateChange: () => () => {}, + notifyForeground: () => {}, + close: () => {} + } + await act(async () => { + renderer = create(createElement(Probe, { client, sessionId: 'a' })) + }) + expect(sendRequest).toHaveBeenCalledWith('agentSession.commands', { sessionId: 'a' }) + expect(hook!.commands).toEqual(lists.a) + + lists.a = [...lists.a!, { name: 'research', kind: 'skill' }] + await act(async () => hook!.refresh()) + expect(hook!.commands?.map((entry) => entry.name)).toEqual(['review-pr', 'research']) + + holdNext = true + await act(async () => renderer!.update(createElement(Probe, { client, sessionId: 'b' }))) + expect(hook!.commands).toBeUndefined() + await act(async () => answers.shift()!.answer()) + expect(hook!.commands).toEqual(lists.b) + }) + + it('reads again after the current read when the menu opens during it', async () => { + let catalog = [{ name: 'review-pr', kind: 'skill' as const }] + const answers: (() => void)[] = [] + const sendRequest = vi.fn(async () => { + const answered = [...catalog] + await new Promise((resolve) => answers.push(resolve)) + return { + id: 'rpc-1', + ok: true as const, + result: { commands: answered }, + _meta: { runtimeId: 'runtime-1' } + } + }) + const client: RpcClient = { + sendRequest, + subscribe: () => () => {}, + updateTerminalSubscriptionViewport: () => {}, + getState: () => 'connected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + onStateChange: () => () => {}, + notifyForeground: () => {}, + close: () => {} + } + await act(async () => { + renderer = create(createElement(Probe, { client, sessionId: 'a' })) + }) + // A skill lands on the host while the first read is still out, and the menu opens. + catalog = [...catalog, { name: 'research', kind: 'skill' as const }] + await act(async () => hook!.refresh()) + expect(sendRequest).toHaveBeenCalledTimes(1) + + await act(async () => answers.shift()!()) + expect(sendRequest).toHaveBeenCalledTimes(2) + await act(async () => answers.shift()!()) + expect(hook!.commands?.map((entry) => entry.name)).toEqual(['review-pr', 'research']) + }) +}) diff --git a/mobile/src/session/use-mobile-structured-session-commands.ts b/mobile/src/session/use-mobile-structured-session-commands.ts new file mode 100644 index 00000000000..61a65599c30 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-session-commands.ts @@ -0,0 +1,78 @@ +import { useCallback, useEffect, useState } from 'react' +import { z } from 'zod' +import type { AgentSessionSlashCommand } from '../../../src/shared/agent-session-wire' +import type { RpcClient } from '../transport/rpc-client' +import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc-operation' +import { rpcResultVariant } from '../transport/rpc-operation-result-reader' + +type Reported = { sessionId: string; commands: readonly AgentSessionSlashCommand[] } + +const sessionCommandsSchema = z.looseObject({ + commands: z + .array( + z.looseObject({ + name: z.string(), + kind: z.enum(['command', 'skill']), + kindUnspecified: z.literal(true).optional(), + description: z.string().optional(), + argumentHint: z.string().optional() + }) + ) + .optional() +}) + +/** The `/` menu a session reports. A host that predates the read refuses it: a skip, so the menu + * keeps the host's own commands. */ +const sessionCommandsRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'agent-session.commands-or-skip', + method: 'agentSession.commands', + acceptance: 'success-result-or-skip', + barrier: 'after-caller-barrier', + read: rpcResultVariant('agent-session-commands', sessionCommandsSchema) + }) +) + +/** + * The `/` menu the running session reports: its commands and the skills it loaded. + * + * Phones get bounded subscribe frames, which never carry the list, so it is read on its own: once + * per session and again each time the menu opens, since a skill installed mid-chat lands there. A + * read asked for while one is out follows it rather than being dropped. + */ +export function useMobileStructuredSessionCommands(args: { + client: RpcClient | null + hostId: string | null + sessionId: string | null + enabled: boolean +}): { commands: readonly AgentSessionSlashCommand[] | undefined; refresh: () => void } { + const { client, hostId, sessionId, enabled } = args + const [reported, setReported] = useState(null) + const [readCount, setReadCount] = useState(0) + + useEffect(() => { + if (!client || !hostId || !sessionId || !enabled) { + return + } + let stale = false + void sessionCommandsRead + .requestSingleFlight(client, hostId, { sessionId }) + .then((reply) => { + const read = sessionCommandsRead.interpret(reply) + if (!stale && read.accepted && read.value.commands) { + setReported({ sessionId, commands: read.value.commands }) + } + }) + .catch(() => undefined) + return () => { + stale = true + } + }, [client, enabled, hostId, readCount, sessionId]) + + const refresh = useCallback(() => setReadCount((count) => count + 1), []) + + return { + commands: reported?.sessionId === sessionId ? reported.commands : undefined, + refresh + } +} diff --git a/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx b/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx index fb44eef3e45..33f358828f6 100644 --- a/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx +++ b/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx @@ -270,7 +270,8 @@ describe("useMobileStructuredAgentSession and the host's Stopping", () => { statusFeed, sendOptions: false, conversationStop: false, - permissionModes: false + permissionModes: false, + phoneAttachments: false }, agent: 'codex', onSendError diff --git a/mobile/src/theme/mobile-theme.ts b/mobile/src/theme/mobile-theme.ts index ae4beb2c282..da1d04a19e7 100644 --- a/mobile/src/theme/mobile-theme.ts +++ b/mobile/src/theme/mobile-theme.ts @@ -7,6 +7,13 @@ export const colors = { bgRaised: '#242424', borderSubtle: '#2a2a2a', editorSurface: '#1e1e1e', + // The chat composer: one lifted box whose round controls sit a step above it. + composerSurface: '#1d1d1d', + composerBorder: 'rgba(255, 255, 255, 0.08)', + composerControl: '#2a2a2a', + // A picked skill inside the composer's text. + skillToken: '#8ab4f8', + skillTokenSurface: 'rgba(59, 130, 246, 0.2)', textPrimary: '#e0e0e0', textSecondary: '#a1a1a1', @@ -77,7 +84,10 @@ export const radii = { card: 14, button: 6, input: 6, - camera: 8 + camera: 8, + composer: 24, + // Attachment thumbnails and file cards inside the composer. + composerItem: 12 } as const export const typography = { diff --git a/mobile/web-entry/web-overrides.json b/mobile/web-entry/web-overrides.json index 21837a94823..8becc40b93b 100644 --- a/mobile/web-entry/web-overrides.json +++ b/mobile/web-entry/web-overrides.json @@ -113,9 +113,13 @@ "file": "src/session/terminal-snapshot-byte-budget.web.ts", "reason": "Not an RN Web API gap but a transport one that exists only in the page. A terminal's first frame is its scrollback snapshot, which the desktop trims to 512 KiB of raw terminal text while the bridge measures the serialized event against BRIDGE_MAX_MESSAGE_BYTES. An ANSI snapshot is mostly ESC bytes and JSON spends six on each, so a colour-dense 80-column screen comes back at 669,268 bytes against a 655,360-byte cap and the stream ends with overflow before a live byte is painted. This file derives the budget the page sends in terminal.subscribe from the cap less the event envelope; the native sibling sends nothing, because a socket frame has no ceiling above it and a budget there would shrink a phone's scrollback for a limit that does not exist." }, + { + "file": "src/session/MobileNativeChatComposerField.web.tsx", + "reason": "A picked skill leads the composer's draft and is drawn highlighted in place. Natively that is a styled span of the TextInput's own text, but react-native-web renders TextInput as a textarea, which cannot style part of its text and ignores Text children. This one draws a copy of the draft behind the field with the skill highlighted, clears the field's own text while keeping its caret, and moves the copy with the field's scroll." + }, { "file": "src/session/mobile-native-chat-input-styles.web.ts", - "reason": "The chat's composer and its question field render one point above the app's body size, which is 15 and under the floor below which iOS zooms the page on focus. keyboard-occlusion.web.ts reads a scale other than 1 as 'no keyboard' and answers 0, and on this screen that lift is the terminal's only feedback that its hidden input has focus, so one focus of the composer would cost the rest of the session. This file puts both fields on TEXT_INPUT_FONT_SIZE; the native sibling keeps 15." + "reason": "The chat's composer and its question field render one point above the app's body size, which is 15 and under the floor below which iOS zooms the page on focus. keyboard-occlusion.web.ts reads a scale other than 1 as 'no keyboard' and answers 0, and on this screen that lift is the terminal's only feedback that its hidden input has focus, so one focus of the composer would cost the rest of the session. This file puts both fields on TEXT_INPUT_FONT_SIZE; the native sibling keeps the question field at 15 and the composer at 16." }, { "file": "src/browser/browser-address-field-styles.web.ts", diff --git a/src/main/runtime/mobile-rpc-allowlist.test.ts b/src/main/runtime/mobile-rpc-allowlist.test.ts index ea9afdab8f5..cb91f06bc1d 100644 --- a/src/main/runtime/mobile-rpc-allowlist.test.ts +++ b/src/main/runtime/mobile-rpc-allowlist.test.ts @@ -202,4 +202,15 @@ describe('mobile RPC allowlist', () => { ]) expect(mobileRpcAllowlist().has('agentSession.attach')).toBe(false) }) + + it('lets a phone upload chat attachments but not read the store back', () => { + expect( + [...mobileRpcAllowlist()].filter((method) => method.startsWith('agentSessionAttachment.')) + ).toEqual([ + 'agentSessionAttachment.uploadStart', + 'agentSessionAttachment.uploadAppend', + 'agentSessionAttachment.uploadCommit', + 'agentSessionAttachment.uploadAbort' + ]) + }) }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-attachments.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-attachments.test.ts index f2a79711711..c8102bc7de6 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-attachments.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-attachments.test.ts @@ -8,6 +8,7 @@ import type { RpcResponse } from '../core' import { RpcDispatcher } from '../dispatcher' import { AGENT_SESSION_ATTACHMENTS_RUNTIME_CAPABILITY, + AGENT_SESSION_PHONE_ATTACHMENTS_RUNTIME_CAPABILITY, RUNTIME_CAPABILITIES } from '../../../../shared/protocol-version' import { AgentSessionAttachmentStore } from '../../../native-chat/agent-session-attachments/agent-session-attachment-store' @@ -105,6 +106,7 @@ async function uploadBytes(name: string, bytes: Buffer): Promise { describe('agentSessionAttachment.*', () => { it('is advertised, so a client can tell a host with the store from an older one', () => { expect(RUNTIME_CAPABILITIES).toContain(AGENT_SESSION_ATTACHMENTS_RUNTIME_CAPABILITY) + expect(RUNTIME_CAPABILITIES).toContain(AGENT_SESSION_PHONE_ATTACHMENTS_RUNTIME_CAPABILITY) }) it('stores an upload for the chat and hands back the server path', async () => { diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-agent-session-methods.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-agent-session-methods.ts index 13f9b9b60e4..3e3b99c7652 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-agent-session-methods.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-agent-session-methods.ts @@ -1,4 +1,4 @@ -/** The structured agent-session methods a phone may call; spread into the execution-host routes. */ +/** The structured agent-session methods (and chat attachment uploads) a phone may call; spread into the execution-host routes. */ export const MOBILE_AGENT_SESSION_RPC_METHODS = [ 'agentSession.createSupport', 'agentSession.create', @@ -28,6 +28,11 @@ export const MOBILE_AGENT_SESSION_RPC_METHODS = [ 'agentSession.subscribeStatus', // One visual from the chat's own scratch folder, for the transcript's `::orca-visual` lines. 'agentSession.readVisual', + // A file the phone attaches to a chat, stored on this host; the agent sees only the host path. + 'agentSessionAttachment.uploadStart', + 'agentSessionAttachment.uploadAppend', + 'agentSessionAttachment.uploadCommit', + 'agentSessionAttachment.uploadAbort', // No-ops on a current host; kept until MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION passes the // mobile builds that still call them. 'agentSession.hold', diff --git a/src/renderer/src/components/jira-issue-assignee-control.tsx b/src/renderer/src/components/jira-issue-assignee-control.tsx index 8167d73deb8..b6e2de9738a 100644 --- a/src/renderer/src/components/jira-issue-assignee-control.tsx +++ b/src/renderer/src/components/jira-issue-assignee-control.tsx @@ -4,7 +4,7 @@ import { JiraUserPicker } from '@/components/jira-user-picker' import { getJiraSelfUser } from '@/components/jira-self-user' import { useJiraSourceConnection } from '@/components/new-workspace/use-jira-source-connection' import { useAppStore } from '@/store' -import { compareBaseSensitivityLocaleText } from '@/lib/locale-text-collators' +import { compareBaseSensitivityLocaleText } from '../../../shared/locale-text-collators' import { jiraListAssignableUsersForTarget } from '@/runtime/runtime-jira-client' import { translate } from '@/i18n/i18n' import { diff --git a/src/renderer/src/components/jira-issue-sorter.ts b/src/renderer/src/components/jira-issue-sorter.ts index 20e889b41b5..0a1449bb6cc 100644 --- a/src/renderer/src/components/jira-issue-sorter.ts +++ b/src/renderer/src/components/jira-issue-sorter.ts @@ -1,6 +1,6 @@ import type { JiraListColumnId } from './jira-list-columns' import type { JiraIssue, JiraPriority } from '../../../shared/jira-types' -import { compareNumericLocaleText } from '@/lib/locale-text-collators' +import { compareNumericLocaleText } from '../../../shared/locale-text-collators' export type JiraIssueSortColumn = JiraListColumnId diff --git a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx index 3fa393584b1..1c287be52bd 100644 --- a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx @@ -3,7 +3,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { NativeChatMentionMenu, NativeChatPickerMenu } from './NativeChatAutocompleteMenus' -import { buildNativeChatPickerItems } from './native-chat-picker-items' +import { buildNativeChatPickerItems } from '../../../../shared/native-chat-picker-items' import { sessionSlashCommandSuggestions } from '../../../../shared/native-chat-slash-commands' import type { ComposerAutocomplete } from './native-chat-composer-state' diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 02c32b65d0f..d5243afda95 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -115,7 +115,7 @@ const NativeChatComposerPane = forwardRef { ], '', '/', - ['dataviz', 'ref-oss'] + [{ name: 'dataviz' }, { name: 'ref-oss' }] ) // The scanned-but-unreported skill is gone; the reported-but-unscanned one is // offered without a scope, and sorts after the one the scan located. @@ -360,7 +360,11 @@ describe('native skill and command picker', () => { }) it('rejects a session-reported name that is not a safe insertion token', () => { - const items = buildNativeChatPickerItems([], [], '', '/', ['ok', 'two words', 'cle\u200bar']) + const items = buildNativeChatPickerItems([], [], '', '/', [ + { name: 'ok' }, + { name: 'two words' }, + { name: 'cle\u200bar' } + ]) expect(items.map((item) => item.name)).toEqual(['ok']) }) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-state.ts b/src/renderer/src/components/native-chat/native-chat-composer-state.ts index abee40a1f01..35247296b8c 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-state.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-state.ts @@ -6,6 +6,7 @@ import { isSlashCommandDraft, applySlashSuggestion, slashCommandDispatchText, + type NativeChatSessionSkill, type SlashCommandSuggestion } from '../../../../shared/native-chat-slash-commands' import { @@ -14,18 +15,20 @@ import { MID_PROMPT_SLASH_TRIGGER, type NativeChatPickerItem, type NativeChatSkillDiscoverySnapshot -} from './native-chat-picker-items' +} from '../../../../shared/native-chat-picker-items' export type { SlashCommandSuggestion } export { filterSlashCommands, isSlashCommandDraft, applySlashSuggestion, slashCommandDispatchText } +export { + classifyNativeChatSend, + type NativeChatSendClassification +} from '../../../../shared/native-chat-slash-commands' export { applyPickerSuggestion, buildNativeChatPickerItems, - classifyNativeChatSend, type NativeChatPickerItem, - type NativeChatSendClassification, type NativeChatSkillDiscoverySnapshot -} from './native-chat-picker-items' +} from '../../../../shared/native-chat-picker-items' type PickerAutocomplete = { query: string @@ -67,7 +70,7 @@ export function deriveComposerAutocomplete( profile: NativeChatAgentProfile | null = null, discovery: NativeChatSkillDiscoverySnapshot = { ...EMPTY_DISCOVERY, skills }, dismissedTriggerKey: string | null = null, - sessionSkillNames?: readonly string[] + sessionSkills?: readonly NativeChatSessionSkill[] ): ComposerAutocomplete { const before = draft.slice(0, caret) const leadingMatch = before.match(LEADING_SLASH_TRIGGER) @@ -79,7 +82,7 @@ export function deriveComposerAutocomplete( profile, discovery, dismissedTriggerKey, - sessionSkillNames + sessionSkills ) } const mentionMatch = before.match(/(?:^|\s)@(\S*)$/) @@ -103,7 +106,7 @@ export function deriveComposerAutocomplete( profile, discovery, dismissedTriggerKey, - sessionSkillNames + sessionSkills ) } @@ -114,7 +117,7 @@ function deriveSlashAutocomplete( profile: NativeChatAgentProfile | null, discovery: NativeChatSkillDiscoverySnapshot, dismissedTriggerKey: string | null, - sessionSkillNames: readonly string[] | undefined + sessionSkills: readonly NativeChatSessionSkill[] | undefined ): ComposerAutocomplete { const triggerKey = `/:${triggerPosition}` if (dismissedTriggerKey === triggerKey) { @@ -129,7 +132,7 @@ function deriveSlashAutocomplete( skillsEnabled ? discovery.skills : [], query, profile?.skillPrefix ?? '/', - skillsEnabled ? sessionSkillNames : [] + skillsEnabled ? sessionSkills : [] ) return { mode: 'slash', diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.test.tsx index b98df2e2a01..a6731be13a7 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.test.tsx @@ -1,7 +1,10 @@ // @vitest-environment happy-dom import { renderHook } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import { buildNativeChatPickerItems, classifyPickerCommands } from './native-chat-picker-items' +import { + buildNativeChatPickerItems, + classifyPickerCommands +} from '../../../../shared/native-chat-picker-items' import { useNativeChatComposerKeyDown } from './use-native-chat-composer-keydown' import { useNativeChatComposerCatalog } from './use-native-chat-composer-catalog' import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types' @@ -21,10 +24,10 @@ describe('composer catalog authority', () => { it('keeps PTY and unsupported structured providers on their original catalogs', () => { const pty = renderHook(() => useNativeChatComposerCatalog('claude')) expect(pty.result.current.agentCommands).toEqual(getVerifiedNativeChatCommands('claude')) - expect(pty.result.current.sessionSkillNames).toBeUndefined() + expect(pty.result.current.sessionSkills).toBeUndefined() const oldHost = renderHook(() => useNativeChatComposerCatalog('claude', transport())) expect(oldHost.result.current.agentCommands).toEqual(structuredSlashCommands()) - expect(oldHost.result.current.sessionSkillNames).toBeUndefined() + expect(oldHost.result.current.sessionSkills).toBeUndefined() }) it('offers supported conversation commands when the host has no reported catalog', () => { const { result, rerender } = renderHook( @@ -79,11 +82,11 @@ describe('composer catalog authority', () => { } } ) - expect(result.current).toEqual({ agentCommands: [], sessionSkillNames: [] }) + expect(result.current).toEqual({ agentCommands: [], sessionSkills: [] }) rerender({ reported: [{ name: 'custom-command', kind: 'command' }] }) expect(result.current).toEqual({ agentCommands: [{ name: 'custom-command' }], - sessionSkillNames: [] + sessionSkills: [] }) }) }) @@ -117,7 +120,7 @@ it('Enter completes a known pre-init skill while still dispatching a built-in co ], '', '/', - catalog.sessionSkillNames + catalog.sessionSkills ) return useNativeChatComposerKeyDown({ autocomplete: { @@ -191,7 +194,7 @@ describe('OpenCode chat skills', () => { reportMayListSkills: agent !== 'claude', isSendCommand: (name) => isStructuredAgentSessionComposerCommand(`/${name}`, agent) }) - return buildNativeChatPickerItems(commands, skills, '', '/', result.current.sessionSkillNames) + return buildNativeChatPickerItems(commands, skills, '', '/', result.current.sessionSkills) } const rows = (...args: Parameters) => items(...args).map((item) => `${item.kind}:${item.name}`) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.ts index dd933336f53..917d6619fbc 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.ts @@ -5,8 +5,9 @@ import { getVerifiedNativeChatCommands } from '../../../../shared/native-chat-agent-profiles' import { - sessionReportedSkillNames, + sessionReportedSkills, sessionSlashCommandSuggestions, + type NativeChatSessionSkill, type SlashCommandSuggestion } from '../../../../shared/native-chat-slash-commands' import { structuredSlashCommands } from '../../../../shared/structured-agent-session-composer' @@ -14,7 +15,7 @@ import type { NativeChatStructuredComposerTransport } from './native-chat-compos export type NativeChatComposerCatalog = { agentCommands: readonly SlashCommandSuggestion[] - sessionSkillNames: readonly string[] | undefined + sessionSkills: readonly NativeChatSessionSkill[] | undefined } /** @@ -41,12 +42,10 @@ export function useNativeChatComposerCatalog( } return sessionSlashCommandSuggestions(agent, reported) }, [agent, conversationCommands, reported, structured]) - const sessionSkillNames = useMemo( + const sessionSkills = useMemo( () => - reported !== undefined && !keepsScannedSkills - ? sessionReportedSkillNames(reported) - : undefined, + reported !== undefined && !keepsScannedSkills ? sessionReportedSkills(reported) : undefined, [keepsScannedSkills, reported] ) - return { agentCommands, sessionSkillNames } + return { agentCommands, sessionSkills } } diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-submit.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-submit.test.tsx index 62c652a7d18..eb923ea24a4 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-submit.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-submit.test.tsx @@ -2,7 +2,7 @@ import { act, renderHook } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types' -import type { NativeChatPickerItem } from './native-chat-picker-items' +import type { NativeChatPickerItem } from '../../../../shared/native-chat-picker-items' import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' import { useNativeChatComposerSubmit } from './use-native-chat-composer-submit' import { diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-submit.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-submit.ts index 81def992611..1d5cd1e10d9 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-submit.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-submit.ts @@ -1,6 +1,9 @@ import { useCallback, useMemo, useState } from 'react' import { translate } from '@/i18n/i18n' -import { applyPickerSuggestion, type NativeChatPickerItem } from './native-chat-picker-items' +import { + applyPickerSuggestion, + type NativeChatPickerItem +} from '../../../../shared/native-chat-picker-items' import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types' import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' import { nativeChatImagesHoldSend } from './native-chat-image-reattach' diff --git a/src/renderer/src/components/native-chat/use-native-chat-picker-state.ts b/src/renderer/src/components/native-chat/use-native-chat-picker-state.ts index 1bc06758be5..82dc634c123 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-picker-state.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-picker-state.ts @@ -13,7 +13,10 @@ import { } from 'react' import type { AgentType } from '../../../../shared/agent-status-types' import { getNativeChatAgentProfile } from '../../../../shared/native-chat-agent-profiles' -import type { SlashCommandSuggestion } from '../../../../shared/native-chat-slash-commands' +import type { + NativeChatSessionSkill, + SlashCommandSuggestion +} from '../../../../shared/native-chat-slash-commands' import { isStructuredAgentSessionComposerCommand } from '../../../../shared/structured-agent-session-composer' import { applyMentionSuggestion, @@ -26,7 +29,7 @@ import { type NativeChatPickerItem, type NativeChatSendClassification } from './native-chat-composer-state' -import { classifyPickerCommands } from './native-chat-picker-items' +import { classifyPickerCommands } from '../../../../shared/native-chat-picker-items' import { useNativeChatSkills } from './use-native-chat-skills' import { emitNativeChatPickerItemAccepted, @@ -57,7 +60,7 @@ export function useNativeChatPickerState(args: { recalledFromHistory: boolean disabled: boolean /** Skill names the running session reports; undefined keeps the host disk scan. */ - sessionSkillNames?: readonly string[] + sessionSkills?: readonly NativeChatSessionSkill[] textareaRef: RefObject setDraft: (value: string) => void setCaret: Dispatch> @@ -72,7 +75,7 @@ export function useNativeChatPickerState(args: { agentCommands, recalledFromHistory, disabled, - sessionSkillNames, + sessionSkills, textareaRef, setDraft, setCaret, @@ -111,7 +114,7 @@ export function useNativeChatPickerState(args: { profile, discovery, dismissed?.context === dismissalContext ? dismissed.triggerKey : null, - sessionSkillNames + sessionSkills ), [ caret, @@ -123,7 +126,7 @@ export function useNativeChatPickerState(args: { pickerCommands, profile, recalledFromHistory, - sessionSkillNames + sessionSkills ] ) @@ -211,13 +214,13 @@ export function useNativeChatPickerState(args: { profile, discovery, null, - sessionSkillNames + sessionSkills ) if (next.mode === 'none' || next.triggerKey !== dismissed.triggerKey) { setDismissed(null) } }, - [dismissalContext, dismissed, discovery, draft, pickerCommands, profile, sessionSkillNames] + [dismissalContext, dismissed, discovery, draft, pickerCommands, profile, sessionSkills] ) const classifySend = useCallback( diff --git a/src/renderer/src/components/skills/skill-delete-copy.ts b/src/renderer/src/components/skills/skill-delete-copy.ts index 07dc7075d75..ebcba29abae 100644 --- a/src/renderer/src/components/skills/skill-delete-copy.ts +++ b/src/renderer/src/components/skills/skill-delete-copy.ts @@ -1,5 +1,5 @@ import { translate } from '@/i18n/i18n' -import { compareBaseSensitivityLocaleText } from '@/lib/locale-text-collators' +import { compareBaseSensitivityLocaleText } from '../../../../shared/locale-text-collators' import type { SkillDeleteBlockReason, SkillDeletePlan, diff --git a/src/renderer/src/components/task-page-linear-jira-list-model.tsx b/src/renderer/src/components/task-page-linear-jira-list-model.tsx index 42cff688a42..34e6b4848c4 100644 --- a/src/renderer/src/components/task-page-linear-jira-list-model.tsx +++ b/src/renderer/src/components/task-page-linear-jira-list-model.tsx @@ -15,7 +15,7 @@ import { Button } from '@/components/ui/button' import type { TaskPageGitHubMutationIntent } from '@/components/task-page-github-work-item-mutation-patches' import type { TaskSourceContext } from '../../../shared/task-source-context' import type { LinearGroupSection } from './task-page-linear-issue-model' -import { compareNumericLocaleText } from '@/lib/locale-text-collators' +import { compareNumericLocaleText } from '../../../shared/locale-text-collators' export function getLinearPriorityRank(priority: number): number { return priority === 0 ? 5 : priority } diff --git a/src/renderer/src/lib/browser-palette-search.ts b/src/renderer/src/lib/browser-palette-search.ts index 1f7e771242a..b03022910e4 100644 --- a/src/renderer/src/lib/browser-palette-search.ts +++ b/src/renderer/src/lib/browser-palette-search.ts @@ -2,7 +2,7 @@ import { ORCA_BROWSER_BLANK_URL } from '../../../shared/constants' import type { BrowserPage, BrowserWorkspace } from '../../../shared/browser-workspace-types' import type { Worktree } from '../../../shared/worktree/types' import { isClipboardTextByteLengthOverLimit } from '../../../shared/clipboard-text' -import { compareBaseSensitivityLocaleText } from './locale-text-collators' +import { compareBaseSensitivityLocaleText } from '../../../shared/locale-text-collators' import { comparePaletteTabResults, isOmniboxPaletteTabFieldAllowed, diff --git a/src/renderer/src/lib/simulator-palette-search.ts b/src/renderer/src/lib/simulator-palette-search.ts index a2e841d13fb..f453525622c 100644 --- a/src/renderer/src/lib/simulator-palette-search.ts +++ b/src/renderer/src/lib/simulator-palette-search.ts @@ -8,7 +8,7 @@ import { } from './palette-repo-resolution' import { getActiveSimulatorTabId } from './simulator-palette-active-tab' import { isClipboardTextByteLengthOverLimit } from '../../../shared/clipboard-text' -import { compareBaseSensitivityLocaleText } from './locale-text-collators' +import { compareBaseSensitivityLocaleText } from '../../../shared/locale-text-collators' import { comparePaletteTabResults, isOmniboxPaletteTabFieldAllowed, diff --git a/src/renderer/src/lib/workspace-tab-palette-results.ts b/src/renderer/src/lib/workspace-tab-palette-results.ts index 64e9cd63718..464269dbbf5 100644 --- a/src/renderer/src/lib/workspace-tab-palette-results.ts +++ b/src/renderer/src/lib/workspace-tab-palette-results.ts @@ -1,4 +1,4 @@ -import { compareBaseSensitivityLocaleText } from './locale-text-collators' +import { compareBaseSensitivityLocaleText } from '../../../shared/locale-text-collators' import { comparePaletteTabResults, isOmniboxPaletteTabFieldAllowed, diff --git a/src/renderer/src/web/preload-api/web-agent-session-attachment-upload.ts b/src/renderer/src/web/preload-api/web-agent-session-attachment-upload.ts index b32c0bc6340..fe3096c8146 100644 --- a/src/renderer/src/web/preload-api/web-agent-session-attachment-upload.ts +++ b/src/renderer/src/web/preload-api/web-agent-session-attachment-upload.ts @@ -1,6 +1,7 @@ import { - AGENT_SESSION_ATTACHMENT_CHUNK_BASE64_CHARS, + agentSessionAttachmentAppends, agentSessionPastedImageName, + base64DecodedByteLength, type AgentSessionAttachmentUploadCommitResult, type AgentSessionAttachmentUploadTarget } from '../../../../shared/agent-session-attachments' @@ -48,32 +49,18 @@ export async function saveClipboardImageAsWebAgentSessionAttachment( contentBase64: string, target: AgentSessionAttachmentUploadTarget ): Promise { - const padding = contentBase64.endsWith('==') ? 2 : contentBase64.endsWith('=') ? 1 : 0 - const byteLength = (contentBase64.length / 4) * 3 - padding const { uploadId } = await callPinned<{ uploadId: string }>( target, 'agentSessionAttachment.uploadStart', { sessionId: target.sessionId, name: agentSessionPastedImageName(Date.now(), createBrowserUuid()), - byteLength + byteLength: base64DecodedByteLength(contentBase64) } ) try { - // Chunks are a multiple of 4 base64 characters, so each starts on a byte boundary. - for ( - let charOffset = 0; - charOffset < Math.max(contentBase64.length, 1); - charOffset += AGENT_SESSION_ATTACHMENT_CHUNK_BASE64_CHARS - ) { - await callPinned(target, 'agentSessionAttachment.uploadAppend', { - uploadId, - offset: (charOffset / 4) * 3, - contentBase64: contentBase64.slice( - charOffset, - charOffset + AGENT_SESSION_ATTACHMENT_CHUNK_BASE64_CHARS - ) - }) + for (const chunk of agentSessionAttachmentAppends(contentBase64)) { + await callPinned(target, 'agentSessionAttachment.uploadAppend', { uploadId, ...chunk }) } const stored = await callPinned( target, diff --git a/src/shared/agent-session-attachments.ts b/src/shared/agent-session-attachments.ts index d2fabcabd0e..e135edba75b 100644 --- a/src/shared/agent-session-attachments.ts +++ b/src/shared/agent-session-attachments.ts @@ -17,6 +17,30 @@ export const AGENT_SESSION_ATTACHMENT_CHUNK_BASE64_CHARS = export const AGENT_SESSION_ATTACHMENT_NAME_MAX_BYTES = 200 +/** Bytes a padded base64 string decodes to, without decoding it. */ +export function base64DecodedByteLength(base64: string): number { + const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0 + return (base64.length / 4) * 3 - padding +} + +/** The appends that carry a base64 body: whole groups at byte offsets, and one empty append for an + * empty file, which the store still expects before a commit. */ +export function* agentSessionAttachmentAppends( + contentBase64: string +): Generator<{ offset: number; contentBase64: string }> { + let charOffset = 0 + do { + yield { + offset: (charOffset / 4) * 3, + contentBase64: contentBase64.slice( + charOffset, + charOffset + AGENT_SESSION_ATTACHMENT_CHUNK_BASE64_CHARS + ) + } + charOffset += AGENT_SESSION_ATTACHMENT_CHUNK_BASE64_CHARS + } while (charOffset < contentBase64.length) +} + export type AgentSessionAttachmentUploadStartResult = { uploadId: string } export type AgentSessionAttachmentUploadCommitResult = { path: string diff --git a/src/renderer/src/lib/locale-text-collators.test.ts b/src/shared/locale-text-collators.test.ts similarity index 100% rename from src/renderer/src/lib/locale-text-collators.test.ts rename to src/shared/locale-text-collators.test.ts diff --git a/src/renderer/src/lib/locale-text-collators.ts b/src/shared/locale-text-collators.ts similarity index 100% rename from src/renderer/src/lib/locale-text-collators.ts rename to src/shared/locale-text-collators.ts diff --git a/src/renderer/src/components/native-chat/native-chat-picker-items.ts b/src/shared/native-chat-picker-items.ts similarity index 89% rename from src/renderer/src/components/native-chat/native-chat-picker-items.ts rename to src/shared/native-chat-picker-items.ts index 5c76e6e635d..210ca327f56 100644 --- a/src/renderer/src/components/native-chat/native-chat-picker-items.ts +++ b/src/shared/native-chat-picker-items.ts @@ -1,17 +1,7 @@ -import type { DiscoveredSkill, SkillSourceKind } from '../../../../shared/skills' -import type { SlashCommandSuggestion } from '../../../../shared/native-chat-slash-commands' -import { - isSafeDisplayCharacter, - stripUnsafeDisplayCharacters -} from '../../../../shared/skill-display-text' -import { compareBaseSensitivityLocaleText } from '@/lib/locale-text-collators' - -// Send classification lives in shared so mobile gates optimistic echoes with -// the same rules; re-exported here to keep renderer import paths stable. -export { - classifyNativeChatSend, - type NativeChatSendClassification -} from '../../../../shared/native-chat-slash-commands' +import type { DiscoveredSkill, SkillSourceKind } from './skills' +import type { NativeChatSessionSkill, SlashCommandSuggestion } from './native-chat-slash-commands' +import { isSafeDisplayCharacter, stripUnsafeDisplayCharacters } from './skill-display-text' +import { compareBaseSensitivityLocaleText } from './locale-text-collators' export type NativeChatPickerItem = | { @@ -85,7 +75,9 @@ export function buildNativeChatPickerItems( skills: readonly DiscoveredSkill[], query: string, skillSigil: '/' | '$', - sessionSkillNames?: readonly string[] + sessionSkills?: readonly NativeChatSessionSkill[], + /** Rows kept per group; a caller that needs every skill passes Infinity. */ + limit = PICKER_RESULT_LIMIT ): NativeChatPickerItem[] { // A name can only collide when both kinds invoke through the same sigil; // where skills carry their own, `/review` and `$review` are distinct entries. @@ -93,12 +85,7 @@ export function buildNativeChatPickerItems( const unclassifiedNames = new Set( commands.filter((command) => command.kindUnspecified).map((command) => command.name) ) - const mergedSkills = mergeNativeChatSkills( - skills, - sessionSkillNames, - unclassifiedNames, - skillSigil - ) + const mergedSkills = mergeNativeChatSkills(skills, sessionSkills, unclassifiedNames, skillSigil) const skillNames = new Set(mergedSkills.map((skill) => skill.name)) const resolvedCommands = commands.filter( (command) => !(sharedSigil && command.kindUnspecified && skillNames.has(command.name)) @@ -129,15 +116,12 @@ export function buildNativeChatPickerItems( .map((item, index) => ({ item, stableOrder: index })), query ) - return [ - ...commandItems.slice(0, PICKER_RESULT_LIMIT), - ...skillItems.slice(0, PICKER_RESULT_LIMIT) - ] + return [...commandItems.slice(0, limit), ...skillItems.slice(0, limit)] } function mergeNativeChatSkills( skills: readonly DiscoveredSkill[], - sessionSkillNames: readonly string[] | undefined, + sessionSkills: readonly NativeChatSessionSkill[] | undefined, unclassifiedNames: ReadonlySet, skillSigil: '/' | '$' ): Extract[] { @@ -167,14 +151,20 @@ function mergeNativeChatSkills( // the session ignored must not be offered. The scan stays the source of // description and scope for the names both know about. const names = - sessionSkillNames !== undefined + sessionSkills !== undefined ? [ - ...sessionSkillNames.filter(isTokenSafe), + ...sessionSkills.map((skill) => skill.name).filter(isTokenSafe), ...[...discovered.keys()].filter((name) => unclassifiedNames.has(name)) ] : [...discovered.keys()] return [...new Set(names)] - .map((name) => discovered.get(name) ?? pickerSkill(name, [], skillSigil)) + .map((name) => { + const skill = discovered.get(name) ?? pickerSkill(name, [], skillSigil) + const reported = sessionSkills?.find((entry) => entry.name === name)?.description + return skill.description === null && reported + ? { ...skill, description: sanitizePickerText(reported, 240) } + : skill + }) .sort(comparePickerSkills) } diff --git a/src/shared/native-chat-slash-commands.test.ts b/src/shared/native-chat-slash-commands.test.ts index caccdb56162..11c07f4a930 100644 --- a/src/shared/native-chat-slash-commands.test.ts +++ b/src/shared/native-chat-slash-commands.test.ts @@ -4,7 +4,7 @@ import { filterSlashCommands, getAgentSlashCommands, isSlashCommandDraft, - sessionReportedSkillNames, + sessionReportedSkills, sessionSlashCommandSuggestions, slashCommandDispatchText } from './native-chat-slash-commands' @@ -94,7 +94,7 @@ describe('a session that reports its own command surface', () => { }) it('splits skills out for the picker to group on its own', () => { - expect(sessionReportedSkillNames(reported)).toEqual(['ref-oss']) + expect(sessionReportedSkills(reported).map((skill) => skill.name)).toEqual(['ref-oss']) }) it('prefers the description the session reported over the curated one', () => { diff --git a/src/shared/native-chat-slash-commands.ts b/src/shared/native-chat-slash-commands.ts index 9defeab6e4f..98c1253ada0 100644 --- a/src/shared/native-chat-slash-commands.ts +++ b/src/shared/native-chat-slash-commands.ts @@ -164,11 +164,18 @@ export function sessionSlashCommandSuggestions( }) } -/** Names the session reported as skills, in the order it reported them. */ -export function sessionReportedSkillNames( +/** A skill the running session reported: its name, and what it said the skill does. */ +export type NativeChatSessionSkill = Pick + +/** The skills the session reported, in the order it reported them. */ +export function sessionReportedSkills( reported: readonly AgentSessionSlashCommand[] -): readonly string[] { - return reported.filter((entry) => entry.kind === 'skill').map((entry) => entry.name) +): readonly NativeChatSessionSkill[] { + return reported.flatMap((entry) => + entry.kind === 'skill' + ? [{ name: entry.name, ...(entry.description ? { description: entry.description } : {}) }] + : [] + ) } /** Whether the draft is a slash command (leading `/`, ignoring leading space). diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index ac2679f7d79..6c57f0c6f50 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -75,6 +75,7 @@ import * as agentLaunchCapabilities from './agent-launch-runtime-capability' import { AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, AGENT_SESSION_ATTACHMENTS_RUNTIME_CAPABILITY, + AGENT_SESSION_PHONE_ATTACHMENTS_RUNTIME_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, @@ -259,6 +260,7 @@ export const RUNTIME_CAPABILITIES = [ AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY, AGENT_SESSION_ATTACHMENTS_RUNTIME_CAPABILITY, + AGENT_SESSION_PHONE_ATTACHMENTS_RUNTIME_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY, diff --git a/src/shared/runtime-capability-constants.ts b/src/shared/runtime-capability-constants.ts index 2d17f00343a..3465ec1f1f8 100644 --- a/src/shared/runtime-capability-constants.ts +++ b/src/shared/runtime-capability-constants.ts @@ -208,6 +208,9 @@ export const AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY = // (agentSessionAttachment.*); an older server has no store, so clients must refuse // the attach rather than hand the agent a path from this machine. export const AGENT_SESSION_ATTACHMENTS_RUNTIME_CAPABILITY = 'agent-session.attachments.v1' as const +// Why: hosts before this advertise attachments.v1 but refuse the upload methods from a phone. +export const AGENT_SESSION_PHONE_ATTACHMENTS_RUNTIME_CAPABILITY = + 'agent-session.attachments.phone.v1' as const // Why: the host now publishes rows for work that is live inside a turn, and such // a row carries `stoppable: false` because no targeted stop can reach it. A // reader that predates the field draws a per-row Stop on every row it is given,