Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
1f804dd
Use bounded OpenCode context for vault session continuation
nwparker Oct 2, 2026
8bb7fc1
Read real OpenCode sessions in terminal-backed native Chat
nwparker Oct 2, 2026
763afa8
fix(opencode): publish approval cards for permission requests
nwparker Oct 2, 2026
9d8dbbc
Send OpenCode native approval through its Enter selector
nwparker Oct 2, 2026
b9b3c95
Resolve mobile Chat readability for folder workspaces
nwparker Oct 2, 2026
430ada0
Bound OpenCode part batches and preserve v2 image attachments
nwparker Oct 2, 2026
211ea32
Prefer live migrated OpenCode sessions over legacy copies
nwparker Oct 2, 2026
a5c605a
Consolidate mobile Chat eligibility test imports
nwparker Oct 2, 2026
581e9ae
Consolidate OpenCode SQLite protocol type imports
nwparker Oct 2, 2026
d0154b5
fix(native-chat): preserve OpenCode reasoning and patch parts
nwparker Oct 2, 2026
b5937bc
fix(native-chat): keep split OpenCode rows intact on desktop and mobile
nwparker Oct 2, 2026
b9a92b6
fix(native-chat): keep reasoning and answers together in live windows
nwparker Oct 3, 2026
7323d83
Preserve complete OpenCode reasoning pairs on desktop and mobile
nwparker Oct 3, 2026
2a55a43
Bound OpenCode mobile pages by serialized bytes and localize omitted …
nwparker Oct 3, 2026
ae502c5
Bound OpenCode transcript RPC pages and present omission notices
nwparker Oct 3, 2026
d66ba4f
Bound OpenCode transcript RPC pages and present omission notices
nwparker Oct 3, 2026
f63bf8e
Bound OpenCode transcript RPC pages and present omission notices
nwparker Oct 3, 2026
2a4fc79
Preserve current main and complete reviewed OpenCode mobile correction
nwparker Oct 3, 2026
1c33cdc
Update native worker oversized-history notice contract
nwparker Oct 3, 2026
43c8773
Retain complete OpenCode mobile pair fidelity on CSP-compatible main
nwparker Oct 3, 2026
acc1285
Merge current main into mobile without changing accepted scope
nwparker Oct 3, 2026
6b8cafb
Merge current main into OpenCode mobile integration
nwparker Oct 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions mobile/src/session/MobileNativeChatMessage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'rea
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MAX_TOOL_DETAIL_LENGTH } from '../../../src/shared/native-chat-tool-summary'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { AGENT_SESSION_HOST_STATUS_COPY } from '../../../src/shared/agent-session-host-status-rows'
import { colors } from '../theme/mobile-theme'

vi.mock('react-native', async () => {
const React = await import('react')
Expand Down Expand Up @@ -60,6 +62,7 @@ describe('MobileNativeChatMessage', () => {
function render(
message: NativeChatMessage,
props: {
fontScale?: number
toolsExpanded?: boolean
structuredActivityUi?: boolean
activeTurnIsWorking?: boolean
Expand All @@ -81,6 +84,38 @@ describe('MobileNativeChatMessage', () => {
const textIn = (node: ReactTestInstance): string[] =>
node.findAllByType('Text' as never).map((text) => String(text.children.join('')))

it.each(['system', 'user'] as const)(
'renders a %s host notice as selectable muted text rather than a markdown answer',
(role) => {
const tree = render(
{
id: 'notice',
role,
timestamp: 1,
blocks: [
{ type: 'text', text: 'provider fallback', presentation: 'history-item-too-large' }
]
},
{ fontScale: 1.5 }
)
expect(tree.root.findAll((node) => String(node.type) === 'MobileMarkdown')).toHaveLength(0)
const text = tree.root.find((node) => String(node.type) === 'Text')
expect(text.props.children).toBe(AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'])
expect(text.props.selectable).toBe(true)
expect(Object.assign({}, ...text.props.style)).toMatchObject({
color: colors.textMuted,
fontSize: 25.5
})
}
)

it('preserves an ordinary assistant answer without interpreting its text as a host notice', () => {
const tree = render(toolMessage([{ type: 'text', text: 'provider fallback' }]))
expect(tree.root.find((node) => String(node.type) === 'MobileMarkdown').props.content).toBe(
'provider fallback'
)
})

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)
Expand Down
11 changes: 11 additions & 0 deletions mobile/src/session/MobileNativeChatMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import { Image, Text as NativeText, View } from 'react-native'
import { splitNativeChatBlocks } from '../../../src/shared/native-chat-tool-fold'
import { selectActiveToolCall } from '../../../src/shared/native-chat-tool-activity'
import { isImageRefBlock, isTextBlock } from '../../../src/shared/native-chat-types'
import {
AGENT_SESSION_HOST_STATUS_COPY,
isAgentSessionHostStatusPresentation
} from '../../../src/shared/agent-session-host-status-rows'
import type { NativeChatBlock, NativeChatMessage } from '../../../src/shared/native-chat-types'
import { MobileMarkdown } from '../components/MobileMarkdown'
import { MobileNativeChatTurnStatus } from './MobileNativeChatTurnStatus'
Expand All @@ -24,6 +28,13 @@ function Prose({
onOpenFile?: (relativePath: string) => void
}): React.JSX.Element | null {
if (isTextBlock(block)) {
if (isAgentSessionHostStatusPresentation(block.presentation)) {
return (
<Text selectable style={[styles.hostNotice, { fontSize: TEXT_SIZE * fontScale }]}>
{AGENT_SESSION_HOST_STATUS_COPY[block.presentation]}
</Text>
)
}
// Inverted (user) bubbles use a fixed dark-on-light text rather than the
// markdown renderer's light-on-dark palette.
if (invert) {
Expand Down
5 changes: 5 additions & 0 deletions mobile/src/session/mobile-native-chat-message-styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ export const styles = StyleSheet.create({
lineHeight: TEXT_SIZE + 6,
fontWeight: '500'
},
hostNotice: {
color: colors.textMuted,
fontSize: TEXT_SIZE,
lineHeight: TEXT_SIZE + 6
},
reasoning: {
opacity: 0.7
},
Expand Down
134 changes: 134 additions & 0 deletions mobile/src/session/mobile-native-chat-pair-window.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { describe, expect, it } from 'vitest'
import { createNativeChatMerger, replaceList } from '../../../src/shared/native-chat-merge'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { applyMobileNativeChatStreamFrame } from './mobile-native-chat-stream-frame'

function row(id: string, role: NativeChatMessage['role'] = 'assistant'): NativeChatMessage {
return { id, role, source: 'transcript', timestamp: 1, blocks: [{ type: 'text', text: id }] }
}

function pair(id: string): NativeChatMessage[] {
return [row(`${id}:reasoning`, 'reasoning'), row(id)]
}

describe('mobile complete-pair stream windows', () => {
it.each(['appended', 'snapshot'])(
'keeps a pair at the %s cutoff without moving the cursor',
(type) => {
const merger = createNativeChatMerger()
const base = [
...pair('opencode:oldest'),
...Array.from({ length: 38 }, (_, i) => row(`m${i}`))
]
replaceList(merger, base)
const result = applyMobileNativeChatStreamFrame({
merger,
frame: {
type,
messages: type === 'snapshot' ? [...base.slice(-1), row('latest')] : [row('latest')],
hasMore: true,
beforeOffset: 1
},
limit: 40,
replaceSnapshot: false
})
expect(result).toEqual({ kind: 'messages', messages: [...base, row('latest')] })
expect(merger.indexById.get('opencode:oldest:reasoning')).toBe(0)
}
)

it.each(['appended', 'snapshot'])(
'keeps reasoning, answer and omission from one raw row at the %s cutoff',
(type) => {
const group = [...pair('opencode:oldest'), row('opencode:oldest:omission', 'system')].map(
(message) => ({ ...message, transcriptOffset: 7 })
)
const base = [...group, ...Array.from({ length: 38 }, (_, i) => row(`m${i}`))]
const merger = createNativeChatMerger()
replaceList(merger, base)
const result = applyMobileNativeChatStreamFrame({
merger,
frame: {
type,
messages: type === 'snapshot' ? [...base.slice(-1), row('latest')] : [row('latest')],
hasMore: true,
beforeOffset: 7
},
limit: 40,
replaceSnapshot: false
})
expect(result).toEqual({ kind: 'messages', messages: [...base, row('latest')] })
expect(merger.list.slice(0, 3)).toEqual(group)
expect(merger.list).toHaveLength(42)
}
)

it('invalidates the cursor when the oldest complete pair leaves', () => {
const merger = createNativeChatMerger()
const base = [...pair('opencode:oldest'), ...Array.from({ length: 39 }, (_, i) => row(`m${i}`))]
replaceList(merger, base)
const result = applyMobileNativeChatStreamFrame({
merger,
frame: { type: 'appended', messages: [row('latest')] },
limit: 40,
replaceSnapshot: false
})
expect(result).toEqual({
kind: 'messages',
messages: [...base.slice(2), row('latest')],
cursorInvalidated: true
})
expect(merger.indexById.has('opencode:oldest')).toBe(false)
expect(merger.indexById.has('opencode:oldest:reasoning')).toBe(false)
})

it('retains a host page with an extra pair row across a user append followed by a pair append', () => {
const merger = createNativeChatMerger()
const base = [
...pair('opencode:first'),
...Array.from({ length: 13 }, (_, i) => [
row(`user-${i}`, 'user'),
...pair(`opencode:${i}`)
]).flat()
]
applyMobileNativeChatStreamFrame({
merger,
frame: { type: 'snapshot', messages: base, hasMore: true, beforeOffset: 123 },
limit: 40,
replaceSnapshot: true
})
applyMobileNativeChatStreamFrame({
merger,
frame: { type: 'appended', messages: [row('latest-user', 'user')] },
limit: 40,
replaceSnapshot: false
})
const result = applyMobileNativeChatStreamFrame({
merger,
frame: { type: 'appended', messages: pair('opencode:latest') },
limit: 40,
replaceSnapshot: false
})
expect(result).toMatchObject({ kind: 'messages', cursorInvalidated: true })
expect(merger.list).toHaveLength(41)
expect(merger.list.slice(0, 2)).toEqual(pair('opencode:0'))
expect(merger.list.slice(-2)).toEqual(pair('opencode:latest'))
})

it('keeps legacy peers with independent reasoning IDs at the raw row limit', () => {
const merger = createNativeChatMerger()
replaceList(merger, [row('claude-thinking', 'reasoning'), row('claude-answer')])
const result = applyMobileNativeChatStreamFrame({
merger,
frame: { type: 'appended', messages: [row('latest')] },
limit: 2,
replaceSnapshot: false
})
expect(result).toEqual({
kind: 'messages',
messages: [row('claude-answer'), row('latest')],
cursorInvalidated: true
})
expect(merger.list).toHaveLength(2)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,13 @@ describe('OpenCode reads through the production shared worker', () => {
throw new Error('Expected the bounded native page')
}
expect(page.items.map((item) => item.rowid)).toEqual([2, 3])
expect(page.items[0]?.message.role).toBe('system')
expect(page.items[0]?.message.blocks).toEqual([
{ type: 'text', text: AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'] }
{
type: 'text',
text: AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'],
presentation: 'history-item-too-large'
}
])
const repeat = await client.readNativeChat(args)
expect(repeat).toEqual(page)
Expand Down
68 changes: 62 additions & 6 deletions src/main/native-chat/transcript-opencode-part-blocks.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { fileURLToPath } from 'node:url'
import type { NativeChatBlock, NativeChatImageRefBlock } from '../../shared/native-chat-types'
import { AGENT_SESSION_HOST_STATUS_COPY } from '../../shared/agent-session-host-status-rows'
import type {
NativeChatBlock,
NativeChatImageRefBlock,
NativeChatMessage,
NativeChatTextBlock
} from '../../shared/native-chat-types'
import { agentSessionHostStatusBody } from '../../shared/agent-session-host-status-rows'
import { asRecord, extractString, parseJsonObject } from '../ai-vault/session-scanner-values'
// query module so each stays under the repo's file-size cap. Electron-free:
// runs on the OpenCode SQLite worker thread (#8864).
Expand All @@ -13,11 +18,50 @@ export type OpenCodePartRow = {

export const OPENCODE_TRANSCRIPT_MAX_ROW_BYTES = 2 * 1024 * 1024

export function opencodeMessageBlocks(partRows: OpenCodePartRow[]): NativeChatBlock[] {
export function opencodeMessages(
message: Pick<NativeChatMessage, 'id' | 'role' | 'timestamp'>,
partRows: OpenCodePartRow[],
additionalBlocks: NativeChatBlock[] = []
): NativeChatMessage[] {
const { blocks, reasoning, notices } = opencodeMessageContent(partRows)
blocks.push(...additionalBlocks)
const messages: NativeChatMessage[] = []
if (reasoning.length > 0) {
messages.push({
...message,
id: blocks.length > 0 || notices.length > 0 ? `${message.id}:reasoning` : message.id,
role: 'reasoning',
blocks: reasoning,
source: 'transcript'
})
}
if (blocks.length > 0) {
messages.push({ ...message, blocks, source: 'transcript' })
}
if (notices.length > 0) {
messages.push({
...message,
id: messages.length > 0 ? `${message.id}:omission` : message.id,
role: 'system',
blocks: notices,
source: 'transcript'
})
}
return messages
}

function opencodeMessageContent(partRows: OpenCodePartRow[]): {
blocks: NativeChatBlock[]
reasoning: NativeChatBlock[]
notices: NativeChatTextBlock[]
} {
const blocks: NativeChatBlock[] = []
const reasoning: NativeChatBlock[] = []
const notices: NativeChatTextBlock[] = []
for (const partRow of partRows) {
if (partRow.data === null) {
blocks.push({ type: 'text', text: AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'] })
const { text, presentation } = agentSessionHostStatusBody('history-item-too-large')
notices.push({ type: 'text', text, presentation })
continue
}
const part = parseJsonObject(partRow.data)
Expand All @@ -38,14 +82,26 @@ export function opencodeMessageBlocks(partRows: OpenCodePartRow[]): NativeChatBl
case 'reasoning': {
const text = extractString(part.text)
if (text) {
blocks.push({ type: 'text', text })
reasoning.push({ type: 'text', text })
}
break
}
case 'tool': {
blocks.push(...opencodeToolBlocks(part))
break
}
case 'patch': {
const files = Array.isArray(part.files)
? part.files.filter((file): file is string => typeof file === 'string')
: []
blocks.push({
type: 'tool-call',
name: 'patch',
state: 'completed',
input: { hash: extractString(part.hash), files }
})
break
}
case 'file': {
const block = opencodeFileBlock(part)
if (block) {
Expand All @@ -58,7 +114,7 @@ export function opencodeMessageBlocks(partRows: OpenCodePartRow[]): NativeChatBl
break
}
}
return blocks
return { blocks, reasoning, notices }
}

function opencodeFileBlock(part: Record<string, unknown>): NativeChatImageRefBlock | null {
Expand Down
Loading
Loading