mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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.
This commit is contained in:
@@ -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']
|
||||
}
|
||||
]
|
||||
|
||||
@@ -14,12 +14,17 @@ type Props<Value extends string> = {
|
||||
fill?: boolean
|
||||
/** The track; its default color suits a panel-colored row. */
|
||||
style?: StyleProp<ViewStyle>
|
||||
/** 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<Value extends string>(props: Props<Value>) {
|
||||
return (
|
||||
<View style={[styles.segmented, props.style]} accessibilityRole="radiogroup">
|
||||
<View
|
||||
style={[styles.segmented, props.style, props.disabled && styles.disabled]}
|
||||
accessibilityRole="radiogroup"
|
||||
>
|
||||
{props.segments.map((segment) => {
|
||||
const active = segment.value === props.selected
|
||||
return (
|
||||
@@ -27,6 +32,8 @@ export function MobileSegmentedControl<Value extends string>(props: Props<Value>
|
||||
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 }
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -115,3 +115,20 @@ export type BridgeMediaChunk = z.infer<typeof mediaReadResultSchema>
|
||||
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()
|
||||
})
|
||||
|
||||
@@ -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<Record<BridgeNativeVerb, BridgeNative
|
||||
params: mediaReleaseParamsSchema,
|
||||
result: mediaReleaseResultSchema
|
||||
},
|
||||
// Optional beside the trio: a page without it names a picked file from its mime.
|
||||
'native.media.name': {
|
||||
params: mediaNameParamsSchema,
|
||||
result: mediaNameResultSchema
|
||||
},
|
||||
// Dictation's capture. Their shapes live in `bridge-audio-verbs.ts` for the media trio's reason:
|
||||
// the pull, its ring and the tail the stop carries back are one contract.
|
||||
'native.audio.start': {
|
||||
|
||||
@@ -189,6 +189,8 @@ function defaultVerbAnswer(verb: BridgeNativeVerb): unknown {
|
||||
return { base64: '', eof: true }
|
||||
case 'native.media.release':
|
||||
return { released: false }
|
||||
case 'native.media.name':
|
||||
return { name: null }
|
||||
case 'native.audio.start':
|
||||
return { started: true, sampleRate: 16_000, permission: 'granted' }
|
||||
case 'native.audio.read':
|
||||
|
||||
@@ -265,10 +265,12 @@ describe('what the surface offers a screen', () => {
|
||||
// `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',
|
||||
|
||||
@@ -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<boolean>
|
||||
readClipboardText: () => Promise<string>
|
||||
/** 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<BridgeMediaChunk>
|
||||
/** False for a handle this session no longer holds, which is not a fault. */
|
||||
releaseMedia: (handle: string) => Promise<boolean>
|
||||
/** The picker's name for a staged item; null where it reported none. */
|
||||
nameMedia: (handle: string) => Promise<string | null>
|
||||
/** 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<z.infer<typeof audioStartResultSchema>>
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<PickedMobileImage | null>
|
||||
pickImages: (source: MobileImageSource) => AsyncIterable<PickedMobileImage>
|
||||
/** Files of any type, several at once; a cancelled picker yields nothing. */
|
||||
pickFiles: () => AsyncIterable<PickedMobileFile>
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<readonly BridgeMediaItem[]> {
|
||||
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<PickedMobileFile> {
|
||||
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]
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/** Extensions for the types a phone's file picker commonly reports. */
|
||||
const EXTENSION_BY_MIME: Readonly<Record<string, string>> = {
|
||||
'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-<n>.<ext>`. */
|
||||
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}`
|
||||
}
|
||||
@@ -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<string> {
|
||||
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()
|
||||
|
||||
@@ -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 ?? [])
|
||||
}
|
||||
|
||||
@@ -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<ComponentProps<typeof NativeChatComposer>, '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<NonNullable<ComponentProps<typeof NativeChatComposer>['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<ComponentProps<typeof MobileNativeChatComposer>>) =>
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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<TextInput | null>
|
||||
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<TextInput>(null)
|
||||
const fieldRef = inputRef ?? fallbackInputRef
|
||||
const [focused, setFocused] = useState(false)
|
||||
const [attachOpen, setAttachOpen] = useState(false)
|
||||
const [picker, setPicker] = useState<MobileNativeChatPickerPresentation | null>(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<ComposerSuggestion[]>(() => {
|
||||
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<void> => {
|
||||
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 ? (
|
||||
<Pressable
|
||||
accessibilityLabel="Attach image"
|
||||
style={({ pressed }) => [styles.iconButton, pressed && styles.pressed]}
|
||||
onPress={onAttachImage}
|
||||
disabled={isAttaching || disabled}
|
||||
>
|
||||
{isAttaching ? (
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
) : (
|
||||
<ImagePlus size={20} color={colors.textSecondary} strokeWidth={2} />
|
||||
)}
|
||||
</Pressable>
|
||||
) : 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 (
|
||||
<View>
|
||||
{suggestions.length > 0 ? (
|
||||
<MobileNativeChatComposerSuggestions suggestions={suggestions} onPick={pickSuggestion} />
|
||||
) : null}
|
||||
{attachments.length > 0 ? (
|
||||
<ScrollView
|
||||
horizontal
|
||||
keyboardShouldPersistTaps="always"
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.attachmentStrip}
|
||||
contentContainerStyle={styles.attachmentStripContent}
|
||||
>
|
||||
{attachments.map((attachment) => (
|
||||
<View key={attachment.id} style={styles.attachmentThumb}>
|
||||
<Image
|
||||
source={{ uri: attachment.previewUri }}
|
||||
style={styles.attachmentImage}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
{onRemoveAttachment ? (
|
||||
<Pressable
|
||||
accessibilityLabel="Remove image"
|
||||
style={styles.attachmentRemove}
|
||||
onPress={() => onRemoveAttachment(attachment.id)}
|
||||
hitSlop={8}
|
||||
>
|
||||
<X size={12} color={colors.textPrimary} strokeWidth={2.6} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
{menu.rows.length > 0 ? (
|
||||
<MobileNativeChatComposerMenu rows={menu.rows} onPick={menu.pick} />
|
||||
) : null}
|
||||
<View style={styles.composerInset} testID="native-chat-composer-inset">
|
||||
<View style={expanded ? styles.bar : styles.barCollapsed} testID="native-chat-composer">
|
||||
{/* 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}
|
||||
<TextInput
|
||||
<View style={styles.bar} testID="native-chat-composer">
|
||||
<MobileNativeChatComposerAttachments
|
||||
images={attachments}
|
||||
files={files}
|
||||
onRemoveImage={onRemoveAttachment}
|
||||
onRemoveFile={structured?.onRemoveFile}
|
||||
/>
|
||||
<MobileNativeChatComposerField
|
||||
ref={fieldRef}
|
||||
style={
|
||||
expanded
|
||||
? mobileNativeChatInputStyles.input
|
||||
: [mobileNativeChatInputStyles.freeInput, styles.inputCollapsed]
|
||||
}
|
||||
value={value}
|
||||
onChangeText={handleChange}
|
||||
skillToken={menu.skillToken}
|
||||
onChangeText={menu.onFieldChange}
|
||||
onFocus={() => 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"
|
||||
/>
|
||||
<View
|
||||
style={expanded ? styles.actionRow : styles.actionRowCollapsed}
|
||||
testID="native-chat-composer-actions"
|
||||
>
|
||||
{expanded ? attachButton : null}
|
||||
<View style={styles.actionRow} testID="native-chat-composer-actions">
|
||||
{attachPress ? (
|
||||
<Pressable
|
||||
accessibilityLabel={attachLabel}
|
||||
style={({ pressed }) => [styles.controlButton, pressed && styles.pressed]}
|
||||
onPress={attachPress}
|
||||
disabled={isAttaching || disabled}
|
||||
>
|
||||
{isAttaching ? (
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
) : (
|
||||
<Plus size={19} color={colors.textPrimary} strokeWidth={2} />
|
||||
)}
|
||||
</Pressable>
|
||||
) : null}
|
||||
{sessionOptions ? (
|
||||
// Stays mounted collapsed so an open drawer, or a picker a command asks for, survives.
|
||||
<MobileNativeChatSessionOptionPickers
|
||||
{...sessionOptions}
|
||||
sendInFlight={sending || isAttaching}
|
||||
pillsHidden={!expanded}
|
||||
presentation={picker}
|
||||
onNavigate={(page) => setPicker((current) => current && { ...current, page })}
|
||||
onDrawerOpen={(page) => setPicker({ page, refocus: focused })}
|
||||
@@ -351,11 +265,11 @@ export function MobileNativeChatComposer({
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{expanded ? <View style={styles.actionSpacer} /> : null}
|
||||
<View style={styles.actionSpacer} />
|
||||
{onMicPress ? (
|
||||
<Pressable
|
||||
accessibilityLabel={micActive ? 'Stop dictation' : 'Dictate'}
|
||||
style={({ pressed }) => [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 ? (
|
||||
<Square
|
||||
pointerEvents="none"
|
||||
size={18}
|
||||
size={16}
|
||||
color={colors.statusRed}
|
||||
strokeWidth={2.4}
|
||||
fill={colors.statusRed}
|
||||
@@ -376,7 +290,7 @@ export function MobileNativeChatComposer({
|
||||
) : (
|
||||
<Mic
|
||||
pointerEvents="none"
|
||||
size={20}
|
||||
size={19}
|
||||
color={colors.textSecondary}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
@@ -398,15 +312,29 @@ export function MobileNativeChatComposer({
|
||||
disabled={!canSend}
|
||||
>
|
||||
<ArrowUp
|
||||
size={20}
|
||||
size={19}
|
||||
color={canSend ? colors.bgBase : colors.textMuted}
|
||||
strokeWidth={2.6}
|
||||
strokeWidth={2.4}
|
||||
/>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
{structured ? (
|
||||
<ActionSheetModal
|
||||
visible={attachOpen}
|
||||
onClose={() => setAttachOpen(false)}
|
||||
actions={mobileNativeChatAttachActions({
|
||||
onPhotos: onAttachImage,
|
||||
onFiles: structured.onAttachFiles,
|
||||
onWorkspaceFile: menu.openFileMenu,
|
||||
onSkills: () => menu.openSlashMenu('skill'),
|
||||
onCommands: () => menu.openSlashMenu('command'),
|
||||
skillCount: menu.skillCount
|
||||
})}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<PendingNativeChatFile, 'name' | 'mime' | 'byteLength'>) {
|
||||
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 (
|
||||
<ScrollView
|
||||
horizontal
|
||||
keyboardShouldPersistTaps="always"
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.strip}
|
||||
contentContainerStyle={styles.stripContent}
|
||||
>
|
||||
{images.map((image) => (
|
||||
<View key={image.id} style={styles.thumb}>
|
||||
<Image source={{ uri: image.previewUri }} style={styles.thumbImage} resizeMode="cover" />
|
||||
{onRemoveImage ? (
|
||||
<RemoveBadge label="Remove image" onPress={() => onRemoveImage(image.id)} />
|
||||
) : null}
|
||||
</View>
|
||||
))}
|
||||
{files.map((file) => (
|
||||
<View key={file.id} style={styles.fileCard}>
|
||||
<View style={styles.fileIcon}>
|
||||
<FileText size={17} color={colors.textSecondary} strokeWidth={2} />
|
||||
</View>
|
||||
<View style={styles.fileText}>
|
||||
<Text style={styles.fileName} numberOfLines={1}>
|
||||
{file.name}
|
||||
</Text>
|
||||
<Text style={styles.fileMeta} numberOfLines={1}>
|
||||
{composerFileMeta(file)}
|
||||
</Text>
|
||||
</View>
|
||||
{file.path === null ? (
|
||||
<View style={styles.uploading} accessibilityLabel={`Uploading ${file.name}`}>
|
||||
<ActivityIndicator size="small" color={colors.textPrimary} />
|
||||
</View>
|
||||
) : null}
|
||||
{onRemoveFile ? (
|
||||
<RemoveBadge label={`Remove ${file.name}`} onPress={() => onRemoveFile(file.id)} />
|
||||
) : null}
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
function RemoveBadge({ label, onPress }: { label: string; onPress: () => void }) {
|
||||
return (
|
||||
<Pressable accessibilityLabel={label} style={styles.remove} onPress={onPress} hitSlop={8}>
|
||||
<X size={11} color={colors.textPrimary} strokeWidth={3} />
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
})
|
||||
@@ -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 <TextInput ref={ref} value={value} style={inputStyles.input} {...props} />
|
||||
}
|
||||
return (
|
||||
<TextInput ref={ref} style={inputStyles.input} {...props}>
|
||||
<Text style={styles.token}>{skillToken}</Text>
|
||||
{value.slice(skillToken.length)}
|
||||
</TextInput>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<View style={styles.frame}>
|
||||
{skillToken ? (
|
||||
// Positioned, so it paints over the field: touches pass through to the field below.
|
||||
<View pointerEvents="none" style={styles.copyLayer}>
|
||||
<Text
|
||||
aria-hidden
|
||||
style={[inputStyles.input, styles.copy, { transform: [{ translateY: -scrollTop }] }]}
|
||||
>
|
||||
<Text style={skillStyles.token}>{skillToken}</Text>
|
||||
{value.slice(skillToken.length)}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
<TextInput
|
||||
ref={ref}
|
||||
value={value}
|
||||
style={skillToken ? [inputStyles.input, clearText.field] : inputStyles.input}
|
||||
// Only the copy reads it, and there is a copy only while a skill leads.
|
||||
onScroll={skillToken ? (event) => setScrollTop(scrollTopOf(event)) : undefined}
|
||||
{...props}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
frame: {
|
||||
width: '100%',
|
||||
overflow: 'hidden'
|
||||
},
|
||||
copyLayer: {
|
||||
...StyleSheet.absoluteFillObject
|
||||
},
|
||||
copy: {
|
||||
maxHeight: undefined
|
||||
}
|
||||
})
|
||||
@@ -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 (
|
||||
<View style={styles.card}>
|
||||
<ScrollView keyboardShouldPersistTaps="always" style={styles.scroll}>
|
||||
{rows.map((row, index) => (
|
||||
<View key={row.id}>
|
||||
{rows[index - 1]?.kind !== row.kind ? (
|
||||
<Text style={styles.heading}>{HEADINGS[row.kind]}</Text>
|
||||
) : null}
|
||||
<MenuRow row={row} onPick={onPick} />
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={composerMenuInsertText(row)}
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => onPick(row)}
|
||||
>
|
||||
<Icon size={16} color={colors.textSecondary} strokeWidth={2} style={styles.icon} />
|
||||
<View style={styles.body}>
|
||||
<View style={styles.nameLine}>
|
||||
<Text style={styles.name} numberOfLines={1}>
|
||||
{row.kind === 'file' ? row.path : row.token}
|
||||
</Text>
|
||||
{row.kind === 'command' && row.argumentHint ? (
|
||||
<Text style={styles.hint} numberOfLines={1}>
|
||||
{row.argumentHint}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{description ? (
|
||||
<Text style={styles.description} numberOfLines={2}>
|
||||
{description}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
})
|
||||
@@ -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 (
|
||||
<View style={styles.suggestions}>
|
||||
<ScrollView keyboardShouldPersistTaps="always" style={styles.suggestionScroll}>
|
||||
{suggestions.map((suggestion) => (
|
||||
<Pressable
|
||||
key={composerSuggestionKey(suggestion)}
|
||||
accessibilityRole="button"
|
||||
style={({ pressed }) => [styles.suggestion, pressed && styles.suggestionPressed]}
|
||||
onPress={() => onPick(suggestion)}
|
||||
>
|
||||
<Text style={styles.suggestionText} numberOfLines={1}>
|
||||
{composerSuggestionInsertText(suggestion)}
|
||||
</Text>
|
||||
{suggestion.kind === 'command' && suggestion.command.description ? (
|
||||
<Text style={styles.suggestionDescription} numberOfLines={1}>
|
||||
{suggestion.command.description}
|
||||
</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
})
|
||||
@@ -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}
|
||||
/>
|
||||
</MobileNativeChatReplyRevealsContext.Provider>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -122,7 +122,7 @@ export function MobileNativeChatQueuedMessages({
|
||||
return (
|
||||
<View style={styles.list}>
|
||||
{/* One box: the header is its first row, and the capped list of cards below it. */}
|
||||
<View style={styles.box}>
|
||||
<View style={styles.box} testID="queued-box">
|
||||
{/* A polite region, as the app's other notices: React Native has no status role. */}
|
||||
<View
|
||||
testID="queued-header"
|
||||
|
||||
@@ -13,6 +13,7 @@ vi.mock('react-native', () => ({
|
||||
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())
|
||||
|
||||
@@ -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.
|
||||
<View style={pillsHidden ? styles.pillsHidden : styles.pills}>
|
||||
{!pillsHidden && permission && permissionChoice ? (
|
||||
<Pill
|
||||
label={permissionChoice.label}
|
||||
accessibleName={`Permissions, ${permissionChoice.label}`}
|
||||
disabled={pillsBusy}
|
||||
labelColor={permissionChoiceColor(permissionChoice.value)}
|
||||
onPress={() => openPicker(permission.id)}
|
||||
/>
|
||||
) : null}
|
||||
{model && !pillsHidden ? (
|
||||
<Pill
|
||||
label={pillLabel}
|
||||
accessibleName={`Model, ${pillLabel}`}
|
||||
disabled={isWorking || pillsBusy}
|
||||
onPress={() => openPicker(model.id)}
|
||||
/>
|
||||
) : null}
|
||||
<View style={styles.pillSlot}>
|
||||
<ModelPill
|
||||
primary={primary}
|
||||
secondary={secondary}
|
||||
warning={fullAccess}
|
||||
accessibleName={
|
||||
model
|
||||
? `Model, ${secondary ? `${primary} ${secondary}` : primary}${fullAccess ? ', full access' : ''}`
|
||||
: `Permissions, ${primary}`
|
||||
}
|
||||
disabled={busy}
|
||||
onPress={() => {
|
||||
onDrawerOpen(home.id)
|
||||
Keyboard.dismiss()
|
||||
}}
|
||||
/>
|
||||
<BottomDrawer
|
||||
visible={activeDescriptor !== undefined}
|
||||
onClose={closePicker}
|
||||
@@ -152,23 +151,19 @@ export function MobileNativeChatSessionOptionPickers({
|
||||
<View style={styles.sheet}>
|
||||
<View style={styles.sheetHeader}>
|
||||
<Pressable
|
||||
accessibilityLabel={modelView || permissionView ? 'Close picker' : 'Back to models'}
|
||||
accessibilityLabel={homeView ? 'Close picker' : 'Back to models'}
|
||||
accessibilityRole="button"
|
||||
style={({ pressed }) => [styles.sheetNav, pressed && styles.pressed]}
|
||||
onPress={
|
||||
modelView || permissionView ? closePicker : () => onNavigate(model?.id ?? null)
|
||||
}
|
||||
onPress={homeView ? closePicker : () => onNavigate(home.id)}
|
||||
hitSlop={8}
|
||||
>
|
||||
{modelView || permissionView ? (
|
||||
{homeView ? (
|
||||
<X size={18} color={colors.textSecondary} strokeWidth={2.2} />
|
||||
) : (
|
||||
<ChevronLeft size={18} color={colors.textSecondary} strokeWidth={2.2} />
|
||||
)}
|
||||
</Pressable>
|
||||
<Text style={styles.sheetTitle}>
|
||||
{modelView ? 'Select model' : `Select ${activeDescriptor.label.toLowerCase()}`}
|
||||
</Text>
|
||||
<Text style={styles.sheetTitle}>{activeDescriptor.label}</Text>
|
||||
<View style={styles.sheetHeaderSide}>
|
||||
{pendingId !== null ? (
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
@@ -179,29 +174,36 @@ export function MobileNativeChatSessionOptionPickers({
|
||||
<SessionOptionCaption>Sent to the agent — not confirmed</SessionOptionCaption>
|
||||
) : null}
|
||||
{reason ? <SessionOptionCaption>{reason}</SessionOptionCaption> : null}
|
||||
<View style={styles.choiceGroup}>
|
||||
<View style={styles.group}>
|
||||
<DescriptorRows
|
||||
descriptor={activeDescriptor}
|
||||
disabled={disabled}
|
||||
disabled={permissionView ? busy : locked}
|
||||
grouped
|
||||
onSetOption={(value) => applyOption(activeDescriptor, value)}
|
||||
onInvokeAction={() => invokeAction(activeDescriptor)}
|
||||
{...(permissionView ? { choiceLabelColor: permissionChoiceColor } : {})}
|
||||
/>
|
||||
</View>
|
||||
{modelView && options.length > 0 ? (
|
||||
<View style={styles.optionGroup}>
|
||||
{options.map((descriptor, index) => (
|
||||
<SessionOptionSummaryRow
|
||||
{homeView && model ? (
|
||||
<>
|
||||
{segmentOptions.map((descriptor) => (
|
||||
<SessionOptionSegments
|
||||
key={descriptor.id}
|
||||
label={descriptor.label}
|
||||
value={mobileSessionOptionSummaryValue(descriptor)}
|
||||
disabled={disabled}
|
||||
divided={index < options.length - 1}
|
||||
onPress={() => onNavigate(descriptor.id)}
|
||||
descriptor={descriptor}
|
||||
disabled={locked}
|
||||
onSetOption={(value) => applyOption(descriptor, value)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<OptionRows
|
||||
descriptors={[...rowOptions, ...(permission ? [permission] : [])]}
|
||||
permission={permission}
|
||||
permissionColor={permissionColor}
|
||||
locked={locked}
|
||||
busy={busy}
|
||||
onNavigate={onNavigate}
|
||||
onSetOption={applyOption}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
) : 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 (
|
||||
<View style={[styles.group, styles.groupSpaced]}>
|
||||
{descriptors.map((descriptor, index) => {
|
||||
const isPermission = descriptor === permission
|
||||
const disabled = isPermission ? busy : locked
|
||||
if (descriptor.kind.type === 'boolean' && !descriptor.action) {
|
||||
return (
|
||||
<View key={descriptor.id} style={index < descriptors.length - 1 && styles.divided}>
|
||||
<DescriptorRows
|
||||
descriptor={descriptor}
|
||||
disabled={disabled}
|
||||
grouped
|
||||
onSetOption={(value) => onSetOption(descriptor, value)}
|
||||
onInvokeAction={() => onNavigate(descriptor.id)}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
const value = mobileSessionOptionSummaryValue(descriptor)
|
||||
return (
|
||||
<SessionOptionSummaryRow
|
||||
key={descriptor.id}
|
||||
label={isPermission ? 'Permissions' : descriptor.label}
|
||||
value={value}
|
||||
valueColor={isPermission ? permissionColor : undefined}
|
||||
disabled={disabled}
|
||||
divided={index < descriptors.length - 1}
|
||||
onPress={() => onNavigate(descriptor.id)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -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 <Text style={styles.caption}>{children}</Text>
|
||||
}
|
||||
|
||||
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 (
|
||||
<Pressable
|
||||
@@ -39,18 +42,11 @@ export function Pill({
|
||||
disabled={disabled}
|
||||
hitSlop={6}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.pillText,
|
||||
labelColor ? { color: labelColor } : null,
|
||||
disabled && styles.pillTextDisabled
|
||||
]}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="tail"
|
||||
>
|
||||
{label}
|
||||
{warning ? <ShieldAlert size={15} color={colors.statusAmber} strokeWidth={2.2} /> : null}
|
||||
<Text style={styles.pillText} numberOfLines={1} ellipsizeMode="tail">
|
||||
<Text style={[styles.pillPrimary, disabled && styles.pillTextDisabled]}>{primary}</Text>
|
||||
{secondary ? <Text style={styles.pillSecondary}>{` ${secondary}`}</Text> : null}
|
||||
</Text>
|
||||
<ChevronDown size={12} color={disabled ? colors.textMuted : colors.textSecondary} />
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
@@ -88,9 +84,6 @@ function ChoiceRow({
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
>
|
||||
<View style={[styles.radio, selected && styles.radioOn]}>
|
||||
{selected ? <Check size={12} color={colors.bgBase} strokeWidth={3} /> : null}
|
||||
</View>
|
||||
<View style={styles.rowBody}>
|
||||
<Text style={[styles.rowLabel, labelColor ? { color: labelColor } : null]}>{label}</Text>
|
||||
{description ? (
|
||||
@@ -99,6 +92,7 @@ function ChoiceRow({
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{selected ? <Check size={18} color={colors.textPrimary} strokeWidth={2.4} /> : null}
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
>
|
||||
<Text style={styles.summaryLabel}>{label}</Text>
|
||||
<Text style={styles.summaryValue} numberOfLines={1}>
|
||||
<Text
|
||||
style={[styles.summaryValue, valueColor ? { color: valueColor } : null]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
<ChevronRight size={16} color={colors.textMuted} strokeWidth={2.2} />
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 (
|
||||
<View style={styles.block}>
|
||||
<Text style={styles.label}>{descriptor.label}</Text>
|
||||
<MobileSegmentedControl
|
||||
segments={choices}
|
||||
// An unknown value marks nothing, rather than reading as a confirmed choice.
|
||||
selected={descriptor.valueSource === 'unknown' ? undefined : currentValue}
|
||||
onSelect={onSetOption}
|
||||
fill
|
||||
disabled={disabled || !descriptor.settable}
|
||||
/>
|
||||
{sessionOptionDispatchUnconfirmed(descriptor) ? (
|
||||
<SessionOptionCaption>Sent to the agent — not confirmed</SessionOptionCaption>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
block: {
|
||||
marginTop: spacing.md
|
||||
},
|
||||
label: {
|
||||
color: colors.textMuted,
|
||||
fontSize: typography.metaSize,
|
||||
paddingHorizontal: spacing.xs,
|
||||
paddingBottom: 6
|
||||
}
|
||||
})
|
||||
@@ -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 (
|
||||
<Pressable
|
||||
style={({ pressed }) => [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 ? (
|
||||
<ActivityIndicator size="small" color={colors.statusRed} />
|
||||
<ActivityIndicator size="small" color={colors.textPrimary} />
|
||||
) : (
|
||||
<Square size={16} color={colors.statusRed} strokeWidth={2.4} fill={colors.statusRed} />
|
||||
<Square size={13} color={colors.textPrimary} strokeWidth={2.4} fill={colors.textPrimary} />
|
||||
)}
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
stopButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
pressed: {
|
||||
opacity: 0.7
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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' }))
|
||||
|
||||
@@ -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({
|
||||
/>
|
||||
</GestureHandlerRootView>
|
||||
)}
|
||||
{trayContent}
|
||||
<MobileNativeChatPromptCard
|
||||
key={promptKey ?? undefined}
|
||||
{...{ ask, askKey, onDismissAsk, onAnswerAsk, onCancelAsk, onCancelPrompt, onCollapseAsk }}
|
||||
@@ -424,10 +429,10 @@ export function MobileNativeChatView({
|
||||
<Text style={styles.sendErrorText}>{sendErrorMessage}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{/* Directly on the composer, which it tucks into. */}
|
||||
{trayContent}
|
||||
<MobileNativeChatComposer
|
||||
structuredCommands={
|
||||
structuredActivityUi ? (sessionOptions?.controller.conversationCommands ?? []) : undefined
|
||||
}
|
||||
structured={structuredActivityUi ? structuredComposer : null}
|
||||
value={composerText}
|
||||
onChangeText={onComposerTextChange}
|
||||
onSend={handleSend}
|
||||
@@ -446,7 +451,11 @@ export function MobileNativeChatView({
|
||||
onMicPressOut={onMicPressOut}
|
||||
disabled={lockReason !== null}
|
||||
sendDisabled={expandedPromptOwnsSend}
|
||||
placeholder={mobileNativeChatComposerPlaceholder(lockReason, turnIndicator?.afterStop)}
|
||||
placeholder={mobileNativeChatComposerPlaceholder(
|
||||
lockReason,
|
||||
turnIndicator?.afterStop,
|
||||
agent
|
||||
)}
|
||||
filePaths={filePaths}
|
||||
onNeedFiles={onNeedFiles}
|
||||
stop={
|
||||
|
||||
@@ -69,6 +69,7 @@ export function MobileSessionActiveContent({
|
||||
handleKeyboardAvoidanceMetrics,
|
||||
handleHaptic,
|
||||
nativeChatImages,
|
||||
nativeChatStructuredComposer,
|
||||
activeMarkdownTab,
|
||||
activeFileTab,
|
||||
activeBrowserTab,
|
||||
@@ -229,6 +230,7 @@ export function MobileSessionActiveContent({
|
||||
controller={nativeChatController}
|
||||
onOpenFile={handleNativeChatFileTap}
|
||||
images={nativeChatImages}
|
||||
structuredComposer={nativeChatStructuredComposer}
|
||||
onMicPress={handleDictationToggle}
|
||||
micActive={dictation.isRecording}
|
||||
dictationMode={dictationMode}
|
||||
|
||||
@@ -57,6 +57,7 @@ vi.mock('lucide-react-native', () =>
|
||||
'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' }))
|
||||
|
||||
@@ -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<string, unknown> }[] = []
|
||||
const client: Pick<RpcClient, 'sendRequest'> = {
|
||||
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'])
|
||||
})
|
||||
})
|
||||
@@ -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<typeof attachmentUploadStart.request>[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<AgentSessionAttachmentUploadCommitResult> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}))
|
||||
|
||||
@@ -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<string, Uint8Array>) {
|
||||
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<PickedMobileFile>): Promise<PickedMobileFile[]> {
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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<PickedMobileFile> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string> {
|
||||
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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: '<branch>' },
|
||||
{ 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([
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<TextInput>
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<SlashMenuInput, 'query'>
|
||||
): ReadonlySet<string> {
|
||||
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>): 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<MobileNativeChatSendOutcome>
|
||||
/** Launch-context text still parked on the agent's TUI input line, or null.
|
||||
|
||||
@@ -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<PickedMobileFile>, failName?: string) {
|
||||
const chips = new Map<string, PendingNativeChatFile>()
|
||||
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])
|
||||
})
|
||||
})
|
||||
@@ -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<PickedMobileFile>
|
||||
upload: (file: PickedMobileFile) => Promise<AgentSessionAttachmentUploadCommitResult>
|
||||
nextId: () => string
|
||||
onPicked: (file: PendingNativeChatFile) => void
|
||||
onStored: (id: string, stored: AgentSessionAttachmentUploadCommitResult) => void
|
||||
onFailed: (id: string) => void
|
||||
onError: (message: string) => void
|
||||
}): Promise<void> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,12 @@ export const NO_NATIVE_CHAT_IMAGE_ATTACHMENTS: PendingNativeChatImage[] = []
|
||||
|
||||
export type MobileNativeChatImagesByScope = Record<string, PendingNativeChatImage[]>
|
||||
|
||||
export function withScopeAttachments(
|
||||
byScope: MobileNativeChatImagesByScope,
|
||||
/** One scope's list replaced, and the scope dropped once its list is empty. */
|
||||
export function withScopeAttachments<List extends readonly unknown[]>(
|
||||
byScope: Readonly<Record<string, List>>,
|
||||
scope: string,
|
||||
next: PendingNativeChatImage[]
|
||||
): MobileNativeChatImagesByScope {
|
||||
next: List
|
||||
): Record<string, List> {
|
||||
if (next.length > 0) {
|
||||
return { ...byScope, [scope]: next }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -95,7 +95,8 @@ const HOST_SUPPORT = {
|
||||
quietRepeatedStop: true,
|
||||
sendOptions: false,
|
||||
conversationStop: false,
|
||||
permissionModes: false
|
||||
permissionModes: false,
|
||||
phoneAttachments: false
|
||||
}
|
||||
|
||||
type Phone = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 `<token> `, 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<Selection | null>(null)
|
||||
const [menuKind, setMenuKind] = useState<MenuKind | null>(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<ComposerMenuRow[]>(() => {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<Record<string, readonly PendingNativeChatFile[]>>
|
||||
|
||||
const NO_FILES: readonly PendingNativeChatFile[] = []
|
||||
|
||||
export type MobileNativeChatFileAttachments = {
|
||||
/** Files for the active scope (tab) only. */
|
||||
readonly files: readonly PendingNativeChatFile[]
|
||||
readonly attachFiles: () => Promise<void>
|
||||
/** 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<FilesByScope>({})
|
||||
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<void> => {
|
||||
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 }
|
||||
}
|
||||
@@ -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<T> = { 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<MobileNativeChatSendOutcome>
|
||||
/** 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<boolean>
|
||||
}
|
||||
|
||||
const NO_FILES: readonly PendingNativeChatFile[] = []
|
||||
|
||||
const defaultSleep = (ms: number): Promise<void> =>
|
||||
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,
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<typeof useMobileSessionImageAttachments>[0]
|
||||
|
||||
function baseArgs(overrides: Partial<HookArgs> = {}): HookArgs {
|
||||
@@ -33,6 +42,7 @@ function baseArgs(overrides: Partial<HookArgs> = {}): HookArgs {
|
||||
beforeTerminalSend: async () => true,
|
||||
nativeChatBaseSend: vi.fn().mockResolvedValue('accepted'),
|
||||
structuredNativeChat: true,
|
||||
nativeChatSessionId: 'session-1',
|
||||
readSeededLaunchDraft: () => null,
|
||||
showToast: vi.fn(),
|
||||
onNativeChatSendError: vi.fn(),
|
||||
|
||||
@@ -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<T> = { 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<MobileNativeChatSendOutcome>
|
||||
/** 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<void>
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -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) ??
|
||||
|
||||
@@ -127,7 +127,8 @@ function Harness({
|
||||
quietRepeatedStop: false,
|
||||
sendOptions: false,
|
||||
conversationStop: false,
|
||||
permissionModes: false
|
||||
permissionModes: false,
|
||||
phoneAttachments: false
|
||||
},
|
||||
onSendError: vi.fn()
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<MobileNativeChatSendOutcome>
|
||||
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<MobileNativeChatSendOutcome>
|
||||
} {
|
||||
const {
|
||||
@@ -44,7 +39,7 @@ export function useMobileStructuredNativeChatSendBridge(args: {
|
||||
text: string,
|
||||
images?: string[],
|
||||
deadline?: number,
|
||||
attachments?: readonly StructuredNativeChatAttachment[]
|
||||
attachments?: readonly StructuredMobileSendAttachment[]
|
||||
): Promise<MobileNativeChatSendOutcome> => {
|
||||
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]
|
||||
)
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
|
||||
@@ -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<typeof useMobileStructuredSessionCommands>
|
||||
|
||||
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<string, { name: string; kind: 'skill' | 'command' }[]> = {
|
||||
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<void>((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<void>((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'])
|
||||
})
|
||||
})
|
||||
@@ -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<Reported | null>(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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string> {
|
||||
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 () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
|
||||
{ inputOwnedByCard, onPendingSendRetired: optimisticSendOutcome?.reject }
|
||||
)
|
||||
|
||||
const { agentCommands, sessionSkillNames } = useNativeChatComposerCatalog(
|
||||
const { agentCommands, sessionSkills } = useNativeChatComposerCatalog(
|
||||
agent,
|
||||
structuredTransport
|
||||
)
|
||||
@@ -133,7 +133,7 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
|
||||
agentCommands,
|
||||
recalledFromHistory: recall.active,
|
||||
disabled,
|
||||
sessionSkillNames,
|
||||
sessionSkills,
|
||||
textareaRef,
|
||||
setDraft,
|
||||
setCaret,
|
||||
|
||||
@@ -338,7 +338,7 @@ describe('native skill and command picker', () => {
|
||||
],
|
||||
'',
|
||||
'/',
|
||||
['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'])
|
||||
})
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<typeof items>) =>
|
||||
items(...args).map((item) => `${item.kind}:${item.name}`)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<NativeChatComposerInput | null>
|
||||
setDraft: (value: string) => void
|
||||
setCaret: Dispatch<SetStateAction<number>>
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user