mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 00:03:46 +00:00
* fix(mobile): label tool rows with a clean summary, expand full input (STA-3333)
Mobile tool rows showed the raw input JSON (`{"file_path":…}`) as the row
label, and the expanded detail just repeated that same truncated string.
- `describeToolInput` labels a row with the target file path, else the
primary argument (command/cmd/query/pattern/url/description), else the
bounded JSON preview.
- Codex delivers tool arguments as a JSON string; normalize those into the
object shape the helpers already understand, so labels, file links,
run summaries and the expanded detail all work for Codex calls too.
- The expanded detail now renders the fully formatted input, capped at
MAX_TOOL_RESULT_CHARS like desktop's tool detail (and like the result
body), and a structured input makes the row expandable.
* fix(mobile): name search rows by their term and keep the filename in path labels (STA-3333)
Review follow-ups to the tool-row summary, all in the shared helper:
- A Grep/Glob row labelled itself with the directory it scanned and dropped
the pattern entirely, because `toolFilePath` treats `path` as a file target.
That path is a scan root, so it also rendered a tap-to-open link that asked
the app to open a folder. `toolFilePath` now ignores the generic `path` key
for search-shaped input, which lets the pattern win the label and drops the
bogus link; an explicit `file_path` still wins.
- An overlong path was truncated from the head, cutting off the basename —
the one part that tells two rows apart. Trim from the front instead, so
the label reads `…/session/MobileNativeChatMessage.tsx`.
- The primary-argument chain used `??`, so a present-but-blank key selected
itself and swallowed the keys ranked after it, dropping the label all the
way back to raw JSON. Take the first key that actually yields a label.
Refs STA-3333.
* fix(mobile): don't offer an expander whose detail repeats the row (STA-3333)
An empty tool input formats back to the row label verbatim, so `{}` and `[]`
advertised an expander and then re-showed the label — the same repeat-the-JSON
problem this change set out to remove. Gate `isStructuredToolInput` on the
collection actually having contents; the lazy detail path is untouched.
Also pins the overlong-path test to the path itself: asserting only length<=80
plus a `…` passed just as well with path labelling deleted.
* fix(mobile): gate the tool detail panel on having detail (STA-3333)
The Tools toggle opens every row at once, bypassing the row's tap guard,
so a row with nothing to expand rendered its own label again underneath
itself — and the tap that would dismiss it is a no-op. Matches desktop.
* fix(mobile): keep a blank tool argument out of the run header (STA-3333)
Skipping a present-but-blank primary key let `briefToolArg` fall through
to the raw JSON preview, so a run header read `Bash {"command":""}` where
it used to read `Bash`. Also state the search-path trade-off honestly:
suppressing the link costs a file-scoped search its tap target.
* fix(mobile): only treat a blank primary key as a missing argument (STA-3333)
The previous guard tested key presence, so a populated but non-string
argument — a mixed argv like ['kill','-9',pid], or a structured query —
dropped out of the run header instead of falling back to the preview.
* test(mobile): pin the tool-row chevron to the detail panel (STA-3333)
The panel gate was covered but the chevron beside it was not: swapping
`showDetail` back to `expanded` on the icon alone left all 909 mobile
tests green, so the affordance lie this branch fixes could return
unnoticed — a down-chevron over no panel, on a row whose tap is guarded
off.
Asserts both icon counts on the fixture that test already renders. The
two halves now die for distinct reasons: the panel gate on the duplicate
label text, the chevron on the icon count.
* test(shared): pin the blank-search-key guard in the tool label (STA-3333)
Dropping `.trim()` from summarizePrimaryToolArg left all 32 tests green,
yet it leaks through isSearchToolInput: a whitespace-only `query` starts
counting as a search term, which suppresses `path`. One character takes
the row's label, its tap-to-open link and its run-header argument at
once, and puts the raw JSON label back — the bug this branch removes.
Asserts all three outputs on that shape. Kills only that mutant; the
isSearchToolInput mutant still dies on the existing search test.
* fix(native-chat): share tool input display semantics (STA-3333)
Build the tool row label, file target, detail eligibility and bounded detail from one normalized input model. Mobile no longer reparses JSON-string input across independent helpers or repeats an already-complete plain label, and desktop now uses the same clean row summary instead of retaining raw JSON.\n\nKeep full detail formatting lazy for collapsed rows and share the 4000-character detail cap across both renderers. Tests pin desktop adoption, mobile disclosure parity, one-pass JSON parsing and the shared bound.
170 lines
6.6 KiB
TypeScript
170 lines
6.6 KiB
TypeScript
import { createElement } from 'react'
|
||
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||
import { MAX_TOOL_DETAIL_LENGTH } from './mobile-native-chat-tool-summary'
|
||
|
||
vi.mock('react-native', async () => {
|
||
const React = await import('react')
|
||
return {
|
||
Image: 'Image',
|
||
Pressable: 'Pressable',
|
||
Text: ({ children, ...props }: { children?: unknown }) =>
|
||
React.createElement('Text', props, children),
|
||
View: ({ children, ...props }: { children?: unknown }) =>
|
||
React.createElement('View', props, children),
|
||
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }
|
||
}
|
||
})
|
||
vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() }))
|
||
vi.mock('lucide-react-native', () => ({
|
||
ArrowUp: 'ArrowUp',
|
||
ChevronDown: 'ChevronDown',
|
||
Copy: 'Copy',
|
||
SquareChevronRight: 'SquareChevronRight'
|
||
}))
|
||
vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: 'MobileMarkdown' }))
|
||
|
||
import { MobileNativeChatMessage } from './MobileNativeChatMessage'
|
||
|
||
function userMessage(blocks: NativeChatMessage['blocks']): NativeChatMessage {
|
||
return { id: 'u1', role: 'user', blocks, timestamp: null, source: 'transcript' }
|
||
}
|
||
|
||
function toolMessage(blocks: NativeChatMessage['blocks']): NativeChatMessage {
|
||
return { id: 'a1', role: 'assistant', blocks, timestamp: null, source: 'transcript' }
|
||
}
|
||
|
||
describe('MobileNativeChatMessage', () => {
|
||
let renderer: ReactTestRenderer | null = null
|
||
|
||
beforeEach(() => {
|
||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||
})
|
||
afterEach(() => {
|
||
act(() => renderer?.unmount())
|
||
renderer = null
|
||
})
|
||
|
||
function render(
|
||
message: NativeChatMessage,
|
||
props: { toolsExpanded?: boolean } = {}
|
||
): ReactTestRenderer {
|
||
const original = console.error
|
||
const spy = vi.spyOn(console, 'error').mockImplementation((...a) => {
|
||
if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) {
|
||
return
|
||
}
|
||
original(...a)
|
||
})
|
||
try {
|
||
act(() => {
|
||
renderer = create(createElement(MobileNativeChatMessage, { message, ...props }))
|
||
})
|
||
} finally {
|
||
spy.mockRestore()
|
||
}
|
||
return renderer!
|
||
}
|
||
|
||
const textIn = (node: ReactTestInstance): string[] =>
|
||
node.findAllByType('Text' as never).map((text) => String(text.children.join('')))
|
||
|
||
it('renders a loadable preview URI as an image thumbnail', () => {
|
||
const tree = render(userMessage([{ type: 'image-ref', url: 'file:///a.jpg', alt: 'a photo' }]))
|
||
const image = tree.root.findByType('Image' as never)
|
||
expect(image.props.source).toEqual({ uri: 'file:///a.jpg' })
|
||
expect(image.props.accessibilityLabel).toBe('a photo')
|
||
})
|
||
|
||
it('prefers the url over the path when both are present', () => {
|
||
const tree = render(
|
||
userMessage([{ type: 'image-ref', url: 'file:///local.jpg', path: '/tmp/host.png' }])
|
||
)
|
||
expect(tree.root.findByType('Image' as never).props.source).toEqual({
|
||
uri: 'file:///local.jpg'
|
||
})
|
||
})
|
||
|
||
it('falls back to a text placeholder for a bare host path', () => {
|
||
// A host temp path (e.g. on an SSH host) is not loadable on the device.
|
||
const tree = render(userMessage([{ type: 'image-ref', path: '/tmp/host.png' }]))
|
||
expect(tree.root.findAllByType('Image' as never)).toHaveLength(0)
|
||
const texts = tree.root
|
||
.findAllByType('Text' as never)
|
||
.map((node) => String(node.children.join('')))
|
||
expect(texts.some((text) => text.includes('/tmp/host.png'))).toBe(true)
|
||
})
|
||
|
||
it('labels a tool row with the target path instead of raw input JSON', () => {
|
||
const tree = render(
|
||
toolMessage([{ type: 'tool-call', name: 'Read', input: { file_path: 'src/index.ts' } }]),
|
||
{ toolsExpanded: true }
|
||
)
|
||
const texts = textIn(tree.root)
|
||
expect(texts).toContain('src/index.ts')
|
||
expect(texts.some((text) => text.includes('"file_path":"src/index.ts"'))).toBe(false)
|
||
})
|
||
|
||
it('bounds expanded diff-less tool input before native text layout', () => {
|
||
const tree = render(
|
||
toolMessage([
|
||
{ type: 'tool-call', name: 'CustomTool', input: { payload: 'x'.repeat(100_000) } }
|
||
]),
|
||
{ toolsExpanded: true }
|
||
)
|
||
const detail = textIn(tree.root).find((text) => text.startsWith('{\n'))
|
||
expect(detail).toHaveLength(MAX_TOOL_DETAIL_LENGTH + 1)
|
||
expect(detail?.endsWith('…')).toBe(true)
|
||
})
|
||
|
||
it('expands formatted detail for a collapsed JSON-string tool input', () => {
|
||
const tree = render(
|
||
toolMessage([
|
||
{
|
||
type: 'tool-call',
|
||
name: 'CustomTool',
|
||
input: '{"cmd":"git status","description":"Inspect changes"}'
|
||
}
|
||
])
|
||
)
|
||
const pressableWith = (label: string): ReactTestInstance =>
|
||
tree.root.findAllByType('Pressable' as never).find((node) => textIn(node).includes(label))!
|
||
|
||
act(() => pressableWith('1×').props.onPress())
|
||
// The row label is the command, and the detail stays closed until tapped.
|
||
expect(textIn(tree.root)).toContain('git status')
|
||
expect(textIn(tree.root).some((text) => text.startsWith('{\n'))).toBe(false)
|
||
|
||
act(() => pressableWith('CustomTool').props.onPress())
|
||
expect(textIn(tree.root)).toContain(
|
||
'{\n "cmd": "git status",\n "description": "Inspect changes"\n}'
|
||
)
|
||
})
|
||
|
||
it('does not echo the row label as detail when a row has nothing to expand', () => {
|
||
// The Tools toggle opens every row at once, bypassing the tap guard — a row
|
||
// whose formatted input is its own label would echo itself in a panel that
|
||
// no tap can dismiss.
|
||
const tree = render(toolMessage([{ type: 'tool-call', name: 'ListTodos', input: '{}' }]), {
|
||
toolsExpanded: true
|
||
})
|
||
expect(textIn(tree.root).filter((text) => text === '{}')).toHaveLength(1)
|
||
// The chevron has to agree with the panel, or the row claims to be open over
|
||
// nothing and the tap that would close it is guarded off. Only the run header
|
||
// is open here; the row itself stays collapsed.
|
||
expect(tree.root.findAllByType('ChevronDown' as never)).toHaveLength(1)
|
||
expect(tree.root.findAllByType('SquareChevronRight' as never)).toHaveLength(1)
|
||
})
|
||
|
||
it('does not expand a plain input that already fits in the row label', () => {
|
||
const input = 'x'.repeat(60)
|
||
const tree = render(toolMessage([{ type: 'tool-call', name: 'CustomTool', input }]), {
|
||
toolsExpanded: true
|
||
})
|
||
expect(textIn(tree.root).filter((text) => text === input)).toHaveLength(1)
|
||
expect(tree.root.findAllByType('ChevronDown' as never)).toHaveLength(1)
|
||
expect(tree.root.findAllByType('SquareChevronRight' as never)).toHaveLength(1)
|
||
})
|
||
})
|