mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
Polish desktop native chat view (#6641)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Orca <help@stably.ai> Co-authored-by: gsxdsm <gsxdsm@users.noreply.github.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
Orca
gsxdsm
parent
bcbcf4126b
commit
b916248294
@@ -0,0 +1,217 @@
|
||||
# Native Chat Codex TUI Parity
|
||||
|
||||
This note maps Codex TUI behavior to Orca native chat on branch
|
||||
`inspect/pr-5824-native-chat`. It is intentionally concrete: the current Orca
|
||||
surface is a PTY harness around the running TUI, while real native parity should
|
||||
move selected paths to Codex app-server protocol v2.
|
||||
|
||||
## Source Map
|
||||
|
||||
- Codex TUI composer: `/Users/jinwoohong/stably/codex/codex-rs/tui/src/bottom_pane/chat_composer.rs`
|
||||
- Slash command parsing and popup:
|
||||
`/Users/jinwoohong/stably/codex/codex-rs/tui/src/bottom_pane/prompt_args.rs`,
|
||||
`/Users/jinwoohong/stably/codex/codex-rs/tui/src/bottom_pane/slash_commands.rs`,
|
||||
`/Users/jinwoohong/stably/codex/codex-rs/tui/src/slash_command.rs`,
|
||||
`/Users/jinwoohong/stably/codex/codex-rs/tui/src/chatwidget/slash_dispatch.rs`
|
||||
- Skills and mentions:
|
||||
`/Users/jinwoohong/stably/codex/codex-rs/tui/src/bottom_pane/skill_popup.rs`,
|
||||
`/Users/jinwoohong/stably/codex/codex-rs/tui/src/skills_helpers.rs`,
|
||||
`/Users/jinwoohong/stably/codex/codex-rs/core-skills/src/loader.rs`,
|
||||
`/Users/jinwoohong/stably/codex/codex-rs/core-skills/src/root_loader.rs`,
|
||||
`/Users/jinwoohong/stably/codex/codex-rs/core-skills/src/injection.rs`
|
||||
- Structured input and native protocol:
|
||||
`/Users/jinwoohong/stably/codex/codex-rs/protocol/src/user_input.rs`,
|
||||
`/Users/jinwoohong/stably/codex/codex-rs/app-server-protocol/src/protocol/v2/turn.rs`,
|
||||
`/Users/jinwoohong/stably/codex/codex-rs/app-server-protocol/src/protocol/common.rs`
|
||||
|
||||
## Current Orca Architecture
|
||||
|
||||
Orca native chat currently sends through the hosted terminal PTY. The composer
|
||||
builds paste bytes, writes them through `sendRuntimePtyInput`, then sends a
|
||||
delayed Enter. This preserves local and SSH behavior because it uses the same
|
||||
runtime path as terminal typing.
|
||||
|
||||
That architecture is useful for incremental adoption, but it means native chat
|
||||
does not own Codex state. It cannot directly set model, reasoning, permissions,
|
||||
skills, or session lifecycle. It can only type commands into the TUI and observe
|
||||
agent hooks/transcripts after the fact.
|
||||
|
||||
## Slash Commands
|
||||
|
||||
Codex behavior:
|
||||
|
||||
- The parser accepts a first-line command of `/name <rest>`.
|
||||
- The slash popup uses Codex's `SlashCommand` enum order as presentation order.
|
||||
- Enter on a selected popup row dispatches the command. Tab completes it into
|
||||
the draft.
|
||||
- Some commands accept inline args: `review`, `rename`, `plan`, `goal`, `ide`,
|
||||
`keymap`, `mcp`, `raw`, `usage`, `pets`, `side`, `resume`, and
|
||||
`sandbox-add-read-dir`.
|
||||
- Commands are control actions, not ordinary user chat turns. For example
|
||||
`/clear` sends `AppEvent::ClearUi`; `/compact` starts compaction; `/model`
|
||||
opens the model picker; `/skills` opens skill management.
|
||||
|
||||
Current Orca behavior:
|
||||
|
||||
- Slash commands are still typed into the TUI over PTY.
|
||||
- Native optimistic chat bubbles are suppressed for slash drafts so `/clear`
|
||||
does not render as a fake queued user message.
|
||||
- The Codex slash catalog now mirrors the visible TUI command list much more
|
||||
closely, but it is still a copied catalog, not a live TUI query.
|
||||
|
||||
Recommended route:
|
||||
|
||||
- Short term: keep PTY dispatch for slash commands, but treat them as command
|
||||
submissions. No optimistic chat bubbles. Enter dispatches; Tab completes.
|
||||
- Medium term: route commands with app-server equivalents directly. Examples:
|
||||
`thread/compact/start`, `thread/list`, `thread/archive`, `thread/delete`,
|
||||
`model/list`, permissions/config reads and writes, `skills/list`.
|
||||
- Long term: stop maintaining a renderer-side Codex command catalog. Either ask
|
||||
Codex for the command inventory or host the Codex composer state machine.
|
||||
|
||||
## Skills And `$`
|
||||
|
||||
Codex behavior:
|
||||
|
||||
- `$` opens the skill popup. Rows show display name, description, category tags,
|
||||
selection state, filtering, sorting, and scrolling.
|
||||
- Codex discovers skills from repo, user, system, admin, and plugin roots. Repo
|
||||
scope sorts before user/system/admin. Exact duplicate paths are deduped.
|
||||
- Skill selection is structured. `UserInput` has `Skill { name, path }`, and
|
||||
app-server protocol v2 mirrors it. Text `$skill` mentions are only the
|
||||
fallback path and must be unambiguous.
|
||||
- Skill injection reads the selected `SKILL.md` by path, records telemetry, and
|
||||
avoids double-injecting already provided host skill prompts.
|
||||
|
||||
Current Orca behavior:
|
||||
|
||||
- Native chat discovers skills through Orca's skills IPC with the active
|
||||
terminal tab's cwd. This is important for worktree symlinks like
|
||||
`.agents/skills`.
|
||||
- `$` autocomplete inserts plain `$skillName` text. That can work through the
|
||||
TUI's text fallback, but it is not equivalent to structured
|
||||
`UserInput::Skill { name, path }`.
|
||||
|
||||
Recommended route:
|
||||
|
||||
- PTY mode: keep `$skill` text insertion, but preserve Codex-like filtering,
|
||||
scrolling, dedupe, and active-cwd discovery.
|
||||
- Native mode: retain the selected skill's path and submit
|
||||
`UserInput::Skill { name, path }` through app-server `turn` input. This avoids
|
||||
ambiguity when multiple skills share a name and lets Codex inject the exact
|
||||
file the user selected.
|
||||
|
||||
## Files, Mentions, And Images
|
||||
|
||||
Codex behavior:
|
||||
|
||||
- User input supports `Text` with text elements, `Image`, `LocalImage`,
|
||||
`Skill`, and `Mention`.
|
||||
- The TUI has file search/mentions and image placeholders. Large pastes become
|
||||
placeholders so text element ranges stay aligned.
|
||||
- Remote image rows are first-class composer attachments and can be removed with
|
||||
keyboard navigation.
|
||||
|
||||
Current Orca behavior:
|
||||
|
||||
- File attach inserts a path/reference into the draft and relies on the TUI to
|
||||
interpret it.
|
||||
- Image paste saves a temp file, then inserts the agent-specific reference.
|
||||
- Local attachments are blocked for remote sessions because the local path may
|
||||
not exist on the SSH target.
|
||||
|
||||
Recommended route:
|
||||
|
||||
- PTY mode: keep conservative path insertion and remote-session blocking.
|
||||
- Native mode: send structured `LocalImage` or `Image` input through Codex
|
||||
protocol and use remote runtime file transfer semantics for SSH.
|
||||
|
||||
## Model, Reasoning, Permissions
|
||||
|
||||
Codex behavior:
|
||||
|
||||
- `/model`, `/permissions`, `/keymap`, `/vim`, `/experimental`, and related
|
||||
commands are stateful TUI/app-server surfaces.
|
||||
- App-server v2 already exposes model listing, config requirements, approval
|
||||
policies, permission profiles, and reasoning effort fields.
|
||||
|
||||
Current Orca behavior:
|
||||
|
||||
- Native chat does not know or set Codex model/reasoning directly. Typing
|
||||
`/model` opens Codex's TUI picker.
|
||||
- Earlier UI controls for model/thinking were removed because they were not
|
||||
wired to real Codex state.
|
||||
|
||||
Recommended route:
|
||||
|
||||
- Do not re-add model or reasoning dropdowns until they read from and write to
|
||||
Codex app-server state.
|
||||
- In PTY mode, expose `/model` as a command shortcut only.
|
||||
|
||||
## Approvals, Elicitations, And Tool UI
|
||||
|
||||
Codex behavior:
|
||||
|
||||
- Approval overlays cover exec approval, permission approval, file change
|
||||
approval, network approval, MCP elicitation, and request-user-input forms.
|
||||
- App-server notifications include thread status, waiting-on-approval/user-input
|
||||
flags, item start/completion, diff/plan updates, and skill changes.
|
||||
|
||||
Current Orca behavior:
|
||||
|
||||
- Native chat has interactive cards sourced from Orca's existing agent status
|
||||
hooks. This is good for common question/approval flows, but it is not the full
|
||||
Codex approval overlay model.
|
||||
|
||||
Recommended route:
|
||||
|
||||
- Keep PTY fallback for anything not represented in Orca hooks.
|
||||
- For Codex-native mode, subscribe to app-server notifications and render
|
||||
approvals/tool calls from protocol events rather than scraping terminal text.
|
||||
|
||||
## Session And History
|
||||
|
||||
Codex behavior:
|
||||
|
||||
- `/new`, `/resume`, `/fork`, `/archive`, `/delete`, `/compact`, and `/clear`
|
||||
are session lifecycle commands.
|
||||
- The composer has local and persistent history; Up/Down recall, Ctrl+R reverse
|
||||
search, Esc edit/interrupt behavior, Ctrl+J newline, Ctrl+T transcript, and
|
||||
Ctrl+C quit/interrupt behavior.
|
||||
|
||||
Current Orca behavior:
|
||||
|
||||
- Native chat has small in-memory draft history and Enter/Shift+Enter.
|
||||
- Session commands are typed into the hosted TUI.
|
||||
|
||||
Recommended route:
|
||||
|
||||
- Short term: keep TUI command dispatch and avoid fake optimistic bubbles for
|
||||
lifecycle commands.
|
||||
- Native mode: use app-server thread APIs for lifecycle and expose real thread
|
||||
transitions in the Orca UI.
|
||||
|
||||
## Priority
|
||||
|
||||
1. Fix PTY-command correctness: Enter dispatches slash commands, Tab completes,
|
||||
slash commands never render as queued chat turns, interrupt clears working UI.
|
||||
2. Make `$` skill popup match Codex basics: active cwd, dedupe, scrolling,
|
||||
filtering, source labels, and no product-specific hardcoding.
|
||||
3. Keep fake model/thinking controls out until backed by Codex app-server state.
|
||||
4. Add an app-server integration spike for Codex native mode: `skills/list`,
|
||||
structured `UserInput::Skill`, model list/settings, and thread lifecycle.
|
||||
5. Move approvals/tool rendering from hook approximations to protocol events.
|
||||
|
||||
## Test Targets
|
||||
|
||||
- `/clear` from native slash popup dispatches immediately and produces no
|
||||
pending user bubble.
|
||||
- `/compact`, `/model`, `/skills`, `/resume`, `/diff`, `/status`, and unknown
|
||||
slash commands behave like the hosted TUI.
|
||||
- `$ref-oss` appears exactly once when the worktree has `.agents/skills` as a
|
||||
symlink.
|
||||
- Down-arrow in `$` suggestions scrolls the popup window.
|
||||
- Interrupt during work returns the composer from Stop to Send after the agent
|
||||
status settles.
|
||||
- SSH sessions never insert local-only attachment paths as if they were remote
|
||||
files.
|
||||
@@ -256,6 +256,7 @@ function equivalentParsedAgentStatusPayload(
|
||||
a.agentType === b.agentType &&
|
||||
a.toolName === b.toolName &&
|
||||
a.toolInput === b.toolInput &&
|
||||
a.interactivePrompt === b.interactivePrompt &&
|
||||
a.lastAssistantMessage === b.lastAssistantMessage &&
|
||||
a.interrupted === b.interrupted
|
||||
)
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import { appendFile, mkdir, mkdtemp, rm, writeFile } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { handlers, listeners } = vi.hoisted(() => ({
|
||||
handlers: new Map<string, (_event: unknown, args?: unknown) => unknown>(),
|
||||
listeners: new Map<string, (_event: unknown, args?: unknown) => unknown>()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => {
|
||||
handlers.set(channel, handler)
|
||||
}),
|
||||
on: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => {
|
||||
listeners.set(channel, handler)
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
import {
|
||||
clearNativeChatSubscriptions,
|
||||
clearNativeChatTranscriptCache,
|
||||
registerNativeChatHandlers
|
||||
} from './native-chat'
|
||||
|
||||
let tempRoots: string[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
handlers.clear()
|
||||
listeners.clear()
|
||||
clearNativeChatTranscriptCache()
|
||||
clearNativeChatSubscriptions()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
|
||||
tempRoots = []
|
||||
})
|
||||
|
||||
function jsonLines(records: unknown[]): string {
|
||||
return records.map((record) => JSON.stringify(record)).join('\n')
|
||||
}
|
||||
|
||||
async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> {
|
||||
const start = Date.now()
|
||||
while (!predicate()) {
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new Error('timed out waiting for condition')
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
}
|
||||
|
||||
async function invokeReadSession(args: {
|
||||
agent: string
|
||||
sessionId: string
|
||||
limit?: number
|
||||
}): Promise<unknown> {
|
||||
registerNativeChatHandlers()
|
||||
const handler = handlers.get('nativeChat:readSession')
|
||||
if (!handler) {
|
||||
throw new Error('handler not registered')
|
||||
}
|
||||
return handler({}, args)
|
||||
}
|
||||
|
||||
describe('nativeChat:readSession handler', () => {
|
||||
it('resolves a Claude transcript and returns the full conversation', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-'))
|
||||
tempRoots.push(root)
|
||||
const projectsDir = join(root, '.claude', 'projects')
|
||||
const projectDir = join(projectsDir, '-repo')
|
||||
await mkdir(projectDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(projectDir, 'sess-ipc.jsonl'),
|
||||
jsonLines([
|
||||
{
|
||||
type: 'user',
|
||||
uuid: 'u-1',
|
||||
timestamp: '2026-06-01T10:00:00.000Z',
|
||||
message: { role: 'user', content: 'Hi' }
|
||||
},
|
||||
{
|
||||
type: 'assistant',
|
||||
uuid: 'a-1',
|
||||
timestamp: '2026-06-01T10:00:01.000Z',
|
||||
message: { role: 'assistant', content: [{ type: 'text', text: 'Hello' }] }
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
// Point homedir-derived Claude root at our fixture via HOME so the resolver
|
||||
// (which reads homedir() internally) finds the transcript.
|
||||
const previousHome = process.env.HOME
|
||||
process.env.HOME = root
|
||||
try {
|
||||
const result = (await invokeReadSession({ agent: 'claude', sessionId: 'sess-ipc' })) as {
|
||||
messages?: unknown[]
|
||||
error?: string
|
||||
}
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.messages).toHaveLength(2)
|
||||
} finally {
|
||||
if (previousHome === undefined) {
|
||||
delete process.env.HOME
|
||||
} else {
|
||||
process.env.HOME = previousHome
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('windows to the most-recent `limit` turns and pages older history when raised', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-limit-'))
|
||||
tempRoots.push(root)
|
||||
const projectDir = join(root, '.claude', 'projects', '-repo')
|
||||
await mkdir(projectDir, { recursive: true })
|
||||
// Five user turns; reading with limit 2 returns only the last two, and a
|
||||
// larger limit pages in older ones (chronological order preserved).
|
||||
const records = [1, 2, 3, 4, 5].map((n) => ({
|
||||
type: 'user',
|
||||
uuid: `u-${n}`,
|
||||
timestamp: `2026-06-01T10:00:0${n}.000Z`,
|
||||
message: { role: 'user', content: `m${n}` }
|
||||
}))
|
||||
await writeFile(join(projectDir, 'sess-limit.jsonl'), jsonLines(records))
|
||||
|
||||
const previousHome = process.env.HOME
|
||||
process.env.HOME = root
|
||||
try {
|
||||
const windowed = (await invokeReadSession({
|
||||
agent: 'claude',
|
||||
sessionId: 'sess-limit',
|
||||
limit: 2
|
||||
})) as { messages: { id: string }[] }
|
||||
expect(windowed.messages.map((m) => m.id)).toEqual(['u-4', 'u-5'])
|
||||
|
||||
const wider = (await invokeReadSession({
|
||||
agent: 'claude',
|
||||
sessionId: 'sess-limit',
|
||||
limit: 4
|
||||
})) as { messages: { id: string }[] }
|
||||
expect(wider.messages.map((m) => m.id)).toEqual(['u-2', 'u-3', 'u-4', 'u-5'])
|
||||
} finally {
|
||||
if (previousHome === undefined) {
|
||||
delete process.env.HOME
|
||||
} else {
|
||||
process.env.HOME = previousHome
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('emits appended messages over nativeChat:appended and tears down on destroy', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-sub-'))
|
||||
tempRoots.push(root)
|
||||
const projectsDir = join(root, '.claude', 'projects')
|
||||
const projectDir = join(projectsDir, '-repo')
|
||||
await mkdir(projectDir, { recursive: true })
|
||||
const filePath = join(projectDir, 'sess-sub.jsonl')
|
||||
await writeFile(
|
||||
filePath,
|
||||
`${jsonLines([
|
||||
{
|
||||
type: 'user',
|
||||
uuid: 'u-1',
|
||||
timestamp: '2026-06-01T10:00:00.000Z',
|
||||
message: { role: 'user', content: 'Hi' }
|
||||
}
|
||||
])}\n`
|
||||
)
|
||||
|
||||
registerNativeChatHandlers()
|
||||
const subscribe = listeners.get('nativeChat:subscribe')
|
||||
expect(subscribe).toBeDefined()
|
||||
|
||||
const sent: { channel: string; payload: unknown }[] = []
|
||||
let destroyedCb: (() => void) | undefined
|
||||
const sender = {
|
||||
id: 1,
|
||||
isDestroyed: () => false,
|
||||
once: (event: string, cb: () => void) => {
|
||||
if (event === 'destroyed') {
|
||||
destroyedCb = cb
|
||||
}
|
||||
},
|
||||
send: (channel: string, payload: unknown) => sent.push({ channel, payload })
|
||||
}
|
||||
|
||||
const previousHome = process.env.HOME
|
||||
process.env.HOME = root
|
||||
try {
|
||||
subscribe!(
|
||||
{ sender },
|
||||
{
|
||||
subscriptionId: 'sub-1',
|
||||
agent: 'claude',
|
||||
sessionId: 'sess-sub'
|
||||
}
|
||||
)
|
||||
|
||||
// The listener dispatches handleSubscribe fire-and-forget; give it a beat
|
||||
// to resolve the path and install the watcher before we append.
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
await appendFile(
|
||||
filePath,
|
||||
`${JSON.stringify({
|
||||
type: 'assistant',
|
||||
uuid: 'a-1',
|
||||
timestamp: '2026-06-01T10:00:01.000Z',
|
||||
message: { role: 'assistant', content: [{ type: 'text', text: 'Hello' }] }
|
||||
})}\n`
|
||||
)
|
||||
|
||||
// Seed-at-0 means the first appended event carries the whole-file re-read;
|
||||
// the new turn 'a-1' arrives across one of the appended events. Collect ids
|
||||
// from every appended event and assert the new turn shows up.
|
||||
const appendedIds = (): string[] =>
|
||||
sent
|
||||
.filter((s) => s.channel === 'nativeChat:appended')
|
||||
.flatMap((s) => (s.payload as { messages: { id: string }[] }).messages.map((m) => m.id))
|
||||
await waitFor(() => appendedIds().includes('a-1'))
|
||||
const appendedEvent = sent.find((s) => s.channel === 'nativeChat:appended')!
|
||||
const payload = appendedEvent.payload as { subscriptionId: string }
|
||||
expect(payload.subscriptionId).toBe('sub-1')
|
||||
expect(appendedIds()).toContain('a-1')
|
||||
|
||||
// Destroyed window tears down the watcher without error.
|
||||
expect(destroyedCb).toBeDefined()
|
||||
destroyedCb!()
|
||||
} finally {
|
||||
if (previousHome === undefined) {
|
||||
delete process.env.HOME
|
||||
} else {
|
||||
process.env.HOME = previousHome
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('returns an error for an unknown session without throwing', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-missing-'))
|
||||
tempRoots.push(root)
|
||||
const previousHome = process.env.HOME
|
||||
process.env.HOME = root
|
||||
try {
|
||||
const result = (await invokeReadSession({ agent: 'claude', sessionId: 'nope' })) as {
|
||||
error?: string
|
||||
}
|
||||
expect(result.error).toBeTruthy()
|
||||
} finally {
|
||||
if (previousHome === undefined) {
|
||||
delete process.env.HOME
|
||||
} else {
|
||||
process.env.HOME = previousHome
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,168 @@
|
||||
import { ipcMain, type IpcMainEvent, type WebContents } from 'electron'
|
||||
import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types'
|
||||
import {
|
||||
clearNativeChatTranscriptCache,
|
||||
readNativeChatTranscriptCached
|
||||
} from '../native-chat/transcript-read-cache'
|
||||
import type { ReadTranscriptResult } from '../native-chat/transcript-reader'
|
||||
import {
|
||||
subscribeNativeChatTranscript,
|
||||
type NativeChatTranscriptSubscription
|
||||
} from '../native-chat/transcript-watch'
|
||||
|
||||
// Re-export so existing test imports of `clearNativeChatTranscriptCache` from
|
||||
// this module keep working after the cache moved to transcript-read-cache.ts.
|
||||
export { clearNativeChatTranscriptCache }
|
||||
|
||||
export type NativeChatReadSessionArgs = {
|
||||
agent: AgentType
|
||||
sessionId: string
|
||||
/** How many of the most-recent turns to return. The renderer starts at the
|
||||
* default window and raises this to page in older history as it scrolls up. */
|
||||
limit?: number
|
||||
/** Authoritative transcript path from the agent hook (providerSession), used to
|
||||
* locate the file when the session id no longer names it (recent Claude Code). */
|
||||
transcriptPath?: string
|
||||
}
|
||||
|
||||
// Why: render only the most recent turns so switching to chat view on a long
|
||||
// session (thousands of messages) doesn't stall on building that many message
|
||||
// components. The full transcript is still cached; only the returned slice is
|
||||
// capped. The renderer raises `limit` to page in older history; live appends
|
||||
// extend it from there.
|
||||
const DESKTOP_READ_WINDOW = 300
|
||||
|
||||
function windowTranscript(result: ReadTranscriptResult, limit: number): ReadTranscriptResult {
|
||||
if (!('messages' in result) || result.messages.length <= limit) {
|
||||
return result
|
||||
}
|
||||
return { ...result, messages: result.messages.slice(-limit) }
|
||||
}
|
||||
|
||||
async function readSession(args: NativeChatReadSessionArgs): Promise<ReadTranscriptResult> {
|
||||
const { agent, sessionId } = args
|
||||
// Clamp to a positive window; default to the desktop window for the first page.
|
||||
const limit = args.limit && args.limit > 0 ? Math.floor(args.limit) : DESKTOP_READ_WINDOW
|
||||
// Desktop is full-class: window by count only, no char truncation.
|
||||
const result = await readNativeChatTranscriptCached(agent, sessionId, args.transcriptPath)
|
||||
return windowTranscript(result, limit)
|
||||
}
|
||||
|
||||
export type NativeChatSubscribeArgs = {
|
||||
/** Renderer-minted id, unique per webContents, echoed back on every emit so
|
||||
* the renderer can route appends to the right hook instance. */
|
||||
subscriptionId: string
|
||||
agent: AgentType
|
||||
sessionId: string
|
||||
/** Authoritative transcript path from the agent hook (providerSession). */
|
||||
transcriptPath?: string
|
||||
}
|
||||
|
||||
export type NativeChatAppendedPayload = {
|
||||
subscriptionId: string
|
||||
messages: NativeChatMessage[]
|
||||
}
|
||||
|
||||
type LiveSubscription = {
|
||||
subscription: NativeChatTranscriptSubscription
|
||||
}
|
||||
|
||||
// Why: live subscriptions are keyed by (webContents.id, subscriptionId) so the
|
||||
// same renderer can watch several panes, and a destroyed window tears down all
|
||||
// of its watchers — strict teardown to avoid fd leaks (plan U4 risk).
|
||||
const liveSubscriptions = new Map<number, Map<string, LiveSubscription>>()
|
||||
const senderCleanupRegistered = new Set<number>()
|
||||
|
||||
function teardownSubscription(senderId: number, subscriptionId: string): void {
|
||||
const bySubId = liveSubscriptions.get(senderId)
|
||||
const live = bySubId?.get(subscriptionId)
|
||||
if (!live || !bySubId) {
|
||||
return
|
||||
}
|
||||
live.subscription.unsubscribe()
|
||||
bySubId.delete(subscriptionId)
|
||||
if (bySubId.size === 0) {
|
||||
liveSubscriptions.delete(senderId)
|
||||
}
|
||||
}
|
||||
|
||||
function teardownAllForSender(senderId: number): void {
|
||||
const bySubId = liveSubscriptions.get(senderId)
|
||||
if (!bySubId) {
|
||||
return
|
||||
}
|
||||
for (const live of bySubId.values()) {
|
||||
live.subscription.unsubscribe()
|
||||
}
|
||||
liveSubscriptions.delete(senderId)
|
||||
senderCleanupRegistered.delete(senderId)
|
||||
}
|
||||
|
||||
function registerSenderCleanup(sender: WebContents): void {
|
||||
if (senderCleanupRegistered.has(sender.id)) {
|
||||
return
|
||||
}
|
||||
senderCleanupRegistered.add(sender.id)
|
||||
// Strict teardown: a closed/reloaded window releases every watcher it owns.
|
||||
sender.once('destroyed', () => teardownAllForSender(sender.id))
|
||||
}
|
||||
|
||||
async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArgs): Promise<void> {
|
||||
const sender = event.sender
|
||||
if (sender.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
const { subscriptionId, agent, sessionId, transcriptPath } = args
|
||||
// Replace any prior subscription under the same id (session change/resubscribe).
|
||||
teardownSubscription(sender.id, subscriptionId)
|
||||
registerSenderCleanup(sender)
|
||||
|
||||
const subscription = await subscribeNativeChatTranscript({
|
||||
agent,
|
||||
sessionId,
|
||||
transcriptPath,
|
||||
onAppend: (messages) => {
|
||||
if (sender.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
const payload: NativeChatAppendedPayload = { subscriptionId, messages }
|
||||
sender.send('nativeChat:appended', payload)
|
||||
}
|
||||
})
|
||||
|
||||
// The window may have gone away (or the subscription been replaced) while we
|
||||
// resolved the file path — don't register a now-orphaned watcher.
|
||||
const stillCurrent = !sender.isDestroyed()
|
||||
if (!stillCurrent) {
|
||||
subscription.unsubscribe()
|
||||
return
|
||||
}
|
||||
const bySubId = liveSubscriptions.get(sender.id) ?? new Map<string, LiveSubscription>()
|
||||
// A concurrent subscribe with the same id beat us here; honor the latest.
|
||||
const existing = bySubId.get(subscriptionId)
|
||||
if (existing) {
|
||||
existing.subscription.unsubscribe()
|
||||
}
|
||||
bySubId.set(subscriptionId, { subscription })
|
||||
liveSubscriptions.set(sender.id, bySubId)
|
||||
}
|
||||
|
||||
/** Test-only: drop all live transcript subscriptions between runs. */
|
||||
export function clearNativeChatSubscriptions(): void {
|
||||
const senderIds = Array.from(liveSubscriptions.keys())
|
||||
for (const senderId of senderIds) {
|
||||
teardownAllForSender(senderId)
|
||||
}
|
||||
}
|
||||
|
||||
export function registerNativeChatHandlers(): void {
|
||||
ipcMain.handle('nativeChat:readSession', (_event, args: NativeChatReadSessionArgs) =>
|
||||
readSession(args)
|
||||
)
|
||||
ipcMain.on('nativeChat:subscribe', (event, args: NativeChatSubscribeArgs) => {
|
||||
void handleSubscribe(event, args)
|
||||
})
|
||||
ipcMain.on('nativeChat:unsubscribe', (event, args: { subscriptionId: string }) => {
|
||||
teardownSubscription(event.sender.id, args.subscriptionId)
|
||||
})
|
||||
}
|
||||
@@ -52,6 +52,7 @@ const {
|
||||
registerSkillsHandlersMock,
|
||||
registerWorkspaceSpaceHandlersMock,
|
||||
registerWorkspacePortHandlersMock,
|
||||
registerNativeChatHandlersMock,
|
||||
registerEmulatorFrameStreamHandlersMock
|
||||
} = vi.hoisted(() => ({
|
||||
registerCliHandlersMock: vi.fn(),
|
||||
@@ -103,6 +104,7 @@ const {
|
||||
registerSkillsHandlersMock: vi.fn(),
|
||||
registerWorkspaceSpaceHandlersMock: vi.fn(),
|
||||
registerWorkspacePortHandlersMock: vi.fn(),
|
||||
registerNativeChatHandlersMock: vi.fn(),
|
||||
registerEmulatorFrameStreamHandlersMock: vi.fn()
|
||||
}))
|
||||
|
||||
@@ -294,6 +296,10 @@ vi.mock('./hosted-review', () => ({
|
||||
registerHostedReviewHandlers: registerHostedReviewHandlersMock
|
||||
}))
|
||||
|
||||
vi.mock('./native-chat', () => ({
|
||||
registerNativeChatHandlers: registerNativeChatHandlersMock
|
||||
}))
|
||||
|
||||
import { registerCoreHandlers } from './register-core-handlers'
|
||||
|
||||
describe('registerCoreHandlers', () => {
|
||||
@@ -346,6 +352,7 @@ describe('registerCoreHandlers', () => {
|
||||
registerSkillsHandlersMock.mockReset()
|
||||
registerWorkspaceSpaceHandlersMock.mockReset()
|
||||
registerWorkspacePortHandlersMock.mockReset()
|
||||
registerNativeChatHandlersMock.mockReset()
|
||||
registerEmulatorFrameStreamHandlersMock.mockReset()
|
||||
})
|
||||
|
||||
@@ -417,6 +424,7 @@ describe('registerCoreHandlers', () => {
|
||||
expect(registerAiVaultHandlersMock).toHaveBeenCalledWith({
|
||||
getAdditionalCodexHomePaths: getAdditionalAiVaultCodexHomePaths
|
||||
})
|
||||
expect(registerNativeChatHandlersMock).toHaveBeenCalled()
|
||||
expect(registerCliHandlersMock).toHaveBeenCalled()
|
||||
expect(registerPreflightHandlersMock).toHaveBeenCalled()
|
||||
expect(registerShellHandlersMock).toHaveBeenCalled()
|
||||
|
||||
@@ -24,6 +24,7 @@ import { registerRateLimitHandlers } from './rate-limits'
|
||||
import { registerRuntimeHandlers } from './runtime'
|
||||
import { registerRuntimeEnvironmentHandlers } from './runtime-environments'
|
||||
import { registerAiVaultHandlers } from './ai-vault'
|
||||
import { registerNativeChatHandlers } from './native-chat'
|
||||
import { registerNotificationHandlers } from './notifications'
|
||||
import { registerNotebookHandlers } from './notebook'
|
||||
import { registerOnboardingHandlers } from './onboarding'
|
||||
@@ -164,6 +165,7 @@ export function registerCoreHandlers(
|
||||
registerAiVaultHandlers({
|
||||
getAdditionalCodexHomePaths: lifecycleOptions.getAdditionalAiVaultCodexHomePaths
|
||||
})
|
||||
registerNativeChatHandlers()
|
||||
registerClipboardHandlers(store)
|
||||
registerUpdaterHandlers(store)
|
||||
registerSpeechHandlers(store)
|
||||
|
||||
@@ -83,6 +83,14 @@ describe('registerSkillsHandlers', () => {
|
||||
expect(getWslHomeMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('scopes host skill discovery to the active workspace cwd when provided', async () => {
|
||||
const handler = getDiscoverHandler()
|
||||
|
||||
await handler(null, { cwd: '/repo/worktree' })
|
||||
|
||||
expect(discoverSkillsMock).toHaveBeenCalledWith({ repos: [], cwd: '/repo/worktree' })
|
||||
})
|
||||
|
||||
it('uses the selected project WSL distro for skill discovery', async () => {
|
||||
const handler = getDiscoverHandler()
|
||||
|
||||
|
||||
@@ -51,7 +51,8 @@ export function registerSkillsHandlers(store: Store): void {
|
||||
return discoverSkills({ repos: [], homeDir, cwd: homeDir })
|
||||
}
|
||||
|
||||
return discoverSkills({ repos: store.getRepos() })
|
||||
const cwd = target?.cwd?.trim() || undefined
|
||||
return cwd ? discoverSkills({ repos: [], cwd }) : discoverSkills({ repos: store.getRepos() })
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -179,17 +179,21 @@ describe('registerAppMenu', () => {
|
||||
expect(paletteItem?.accelerator).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps Edit > Paste on the native Electron paste role in this split', () => {
|
||||
it('routes Edit > Paste through Orca coordinated paste ownership', () => {
|
||||
const send = vi.fn()
|
||||
getFocusedWindowMock.mockReturnValue({ webContents: { send } })
|
||||
registerAppMenu(buildMenuOptions())
|
||||
|
||||
const editSubmenu = getSubmenu(getTemplate(), 'Edit')
|
||||
const pasteItem = editSubmenu.find((item) => item.role === 'paste')
|
||||
const pasteItem = editSubmenu.find((item) => item.label === 'Paste')
|
||||
|
||||
expect(pasteItem).toBeDefined()
|
||||
expect(pasteItem?.click).toBeUndefined()
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
expect(pasteItem?.role).toBeUndefined()
|
||||
expect(pasteItem?.accelerator).toBe('CmdOrCtrl+V')
|
||||
|
||||
pasteItem?.click?.({} as never, {} as never, {} as never)
|
||||
|
||||
expect(send).toHaveBeenCalledWith('ui:appMenuPaste')
|
||||
})
|
||||
|
||||
it.runIf(!isMac)('puts Settings and Exit under File on Windows/Linux', () => {
|
||||
|
||||
@@ -162,7 +162,15 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void {
|
||||
{ type: 'separator' },
|
||||
{ role: 'cut' },
|
||||
{ role: 'copy' },
|
||||
{ role: 'paste' },
|
||||
{
|
||||
label: translateMain('menu.paste', 'Paste'),
|
||||
accelerator: 'CmdOrCtrl+V',
|
||||
click: () => {
|
||||
// Why: a focused terminal/native-chat pane is not a native editable
|
||||
// control, so raw Electron paste cannot know which Orca surface owns it.
|
||||
BrowserWindow.getFocusedWindow()?.webContents.send('ui:appMenuPaste')
|
||||
}
|
||||
},
|
||||
{ role: 'selectAll' }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { resolveSessionFilePath } from './session-file-resolver'
|
||||
|
||||
let tempRoots: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
|
||||
tempRoots = []
|
||||
})
|
||||
|
||||
async function makeRoot(prefix: string): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), prefix))
|
||||
tempRoots.push(root)
|
||||
return root
|
||||
}
|
||||
|
||||
function restoreEnv(key: string, previous: string | undefined): void {
|
||||
if (previous === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = previous
|
||||
}
|
||||
}
|
||||
|
||||
describe('resolveSessionFilePath', () => {
|
||||
it('globs Claude project subdirs for <sessionId>.jsonl', async () => {
|
||||
const root = await makeRoot('orca-native-chat-resolve-claude-')
|
||||
const claudeProjectsDir = join(root, 'claude-projects')
|
||||
const projectDir = join(claudeProjectsDir, '-Users-ada-repo')
|
||||
await mkdir(projectDir, { recursive: true })
|
||||
const target = join(projectDir, 'sess-123.jsonl')
|
||||
await writeFile(target, '{}\n')
|
||||
|
||||
const resolved = await resolveSessionFilePath('claude', 'sess-123', { claudeProjectsDir })
|
||||
expect(resolved).toBe(target)
|
||||
})
|
||||
|
||||
it('matches Codex rollout files by session id suffix', async () => {
|
||||
const root = await makeRoot('orca-native-chat-resolve-codex-')
|
||||
const codexSessionsDir = join(root, 'codex-sessions')
|
||||
const dayDir = join(codexSessionsDir, '2026', '06', '04')
|
||||
await mkdir(dayDir, { recursive: true })
|
||||
const target = join(dayDir, 'rollout-2026-06-04T10-00-00-abc-session.jsonl')
|
||||
await writeFile(target, '{}\n')
|
||||
|
||||
const resolved = await resolveSessionFilePath('codex', 'abc-session', {
|
||||
codexSessionsDirs: [codexSessionsDir]
|
||||
})
|
||||
expect(resolved).toBe(target)
|
||||
})
|
||||
|
||||
it('resolves a rollout from the orca-managed Codex home (ORCA_USER_DATA_PATH)', async () => {
|
||||
// Orca launches Codex with its own managed CODEX_HOME, so rollout files land
|
||||
// under <userData>/codex-runtime-home/home/sessions, NOT ~/.codex/sessions.
|
||||
const root = await makeRoot('orca-native-chat-resolve-managed-')
|
||||
const managedSessionsDir = join(root, 'codex-runtime-home', 'home', 'sessions')
|
||||
const dayDir = join(managedSessionsDir, '2026', '06', '19')
|
||||
await mkdir(dayDir, { recursive: true })
|
||||
const target = join(dayDir, 'rollout-2026-06-19T04-20-39-019edf9c-managed.jsonl')
|
||||
await writeFile(target, '{}\n')
|
||||
|
||||
const previous = process.env.ORCA_USER_DATA_PATH
|
||||
process.env.ORCA_USER_DATA_PATH = root
|
||||
try {
|
||||
const resolved = await resolveSessionFilePath('codex', '019edf9c-managed')
|
||||
expect(resolved).toBe(target)
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.ORCA_USER_DATA_PATH
|
||||
} else {
|
||||
process.env.ORCA_USER_DATA_PATH = previous
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to CODEX_HOME when the managed home has no match', async () => {
|
||||
const root = await makeRoot('orca-native-chat-resolve-codex-home-')
|
||||
const managedRoot = join(root, 'managed-userdata')
|
||||
await mkdir(managedRoot, { recursive: true })
|
||||
const codexHome = join(root, 'custom-codex-home')
|
||||
const dayDir = join(codexHome, 'sessions', '2026', '06', '05')
|
||||
await mkdir(dayDir, { recursive: true })
|
||||
const target = join(dayDir, 'rollout-xyz-session.jsonl')
|
||||
await writeFile(target, '{}\n')
|
||||
|
||||
const previousCodex = process.env.CODEX_HOME
|
||||
const previousUserData = process.env.ORCA_USER_DATA_PATH
|
||||
process.env.CODEX_HOME = codexHome
|
||||
// Point the managed home at an empty dir so the fallback is exercised.
|
||||
process.env.ORCA_USER_DATA_PATH = managedRoot
|
||||
try {
|
||||
const resolved = await resolveSessionFilePath('codex', 'xyz-session')
|
||||
expect(resolved).toBe(target)
|
||||
} finally {
|
||||
restoreEnv('CODEX_HOME', previousCodex)
|
||||
restoreEnv('ORCA_USER_DATA_PATH', previousUserData)
|
||||
}
|
||||
})
|
||||
|
||||
it('returns null when no transcript matches', async () => {
|
||||
const root = await makeRoot('orca-native-chat-resolve-missing-')
|
||||
const claudeProjectsDir = join(root, 'claude-projects')
|
||||
await mkdir(claudeProjectsDir, { recursive: true })
|
||||
expect(await resolveSessionFilePath('claude', 'nope', { claudeProjectsDir })).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for unsupported agents', async () => {
|
||||
expect(await resolveSessionFilePath('gemini', 'whatever')).toBeNull()
|
||||
})
|
||||
|
||||
it('prefers the hook transcriptPath when it exists (Claude id != file name)', async () => {
|
||||
// Recent Claude Code names the file with a UUID that differs from the hook
|
||||
// session_id, so the id glob would miss it — but transcript_path is exact.
|
||||
const root = await makeRoot('orca-native-chat-resolve-path-')
|
||||
const claudeProjectsDir = join(root, 'claude-projects')
|
||||
const projectDir = join(claudeProjectsDir, '-Users-ada-repo')
|
||||
await mkdir(projectDir, { recursive: true })
|
||||
// The real transcript is named by a DIFFERENT id than the hook session id.
|
||||
const realFile = join(projectDir, 'real-file-uuid.jsonl')
|
||||
await writeFile(realFile, '{}\n')
|
||||
|
||||
const resolved = await resolveSessionFilePath('claude', 'hook-session-id', {
|
||||
claudeProjectsDir,
|
||||
transcriptPath: realFile
|
||||
})
|
||||
expect(resolved).toBe(realFile)
|
||||
})
|
||||
|
||||
it('falls back to the id glob when the hook transcriptPath does not exist', async () => {
|
||||
const root = await makeRoot('orca-native-chat-resolve-path-stale-')
|
||||
const claudeProjectsDir = join(root, 'claude-projects')
|
||||
const projectDir = join(claudeProjectsDir, '-Users-ada-repo')
|
||||
await mkdir(projectDir, { recursive: true })
|
||||
const target = join(projectDir, 'sess-xyz.jsonl')
|
||||
await writeFile(target, '{}\n')
|
||||
|
||||
const resolved = await resolveSessionFilePath('claude', 'sess-xyz', {
|
||||
claudeProjectsDir,
|
||||
transcriptPath: join(projectDir, 'does-not-exist.jsonl')
|
||||
})
|
||||
expect(resolved).toBe(target)
|
||||
})
|
||||
|
||||
it('ignores a non-jsonl transcriptPath and falls back to the glob', async () => {
|
||||
const root = await makeRoot('orca-native-chat-resolve-path-ext-')
|
||||
const claudeProjectsDir = join(root, 'claude-projects')
|
||||
const projectDir = join(claudeProjectsDir, '-Users-ada-repo')
|
||||
await mkdir(projectDir, { recursive: true })
|
||||
const bogus = join(projectDir, 'not-a-transcript.txt')
|
||||
await writeFile(bogus, 'x')
|
||||
const target = join(projectDir, 'sess-ok.jsonl')
|
||||
await writeFile(target, '{}\n')
|
||||
|
||||
const resolved = await resolveSessionFilePath('claude', 'sess-ok', {
|
||||
claudeProjectsDir,
|
||||
transcriptPath: bogus
|
||||
})
|
||||
expect(resolved).toBe(target)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
import { existsSync } from 'fs'
|
||||
import { homedir } from 'os'
|
||||
import { basename, extname, join } from 'path'
|
||||
import type { AgentType } from '../../shared/native-chat-types'
|
||||
import { walkSessionFiles } from '../ai-vault/session-scanner-discovery'
|
||||
import { getOrcaManagedCodexHomePath } from '../codex/codex-home-paths'
|
||||
|
||||
// Why: these mirror the path constants in ai-vault/session-scanner.ts. Reads
|
||||
// run in the main process against the runtime's own home directory; over SSH
|
||||
// the remote main resolves its local home, so we never hardcode an absolute
|
||||
// user path — homedir()/CODEX_HOME resolution stays runtime-relative and is
|
||||
// computed per call (not at module load) so it tracks the live home.
|
||||
function claudeProjectsDir(): string {
|
||||
return join(homedir(), '.claude', 'projects')
|
||||
}
|
||||
|
||||
// Why: Orca launches Codex with ORCA_CODEX_HOME pointing at its own managed
|
||||
// runtime home, so Orca-started Codex rollout files land under
|
||||
// `<managed home>/sessions`, NOT `~/.codex/sessions`. Search the managed home
|
||||
// first (that's where this main process's Codex sessions actually live), then
|
||||
// fall back to CODEX_HOME/~/.codex so a non-Orca Codex transcript still resolves.
|
||||
// Duplicates are filtered so a managed-home symlink to ~/.codex isn't scanned twice.
|
||||
function codexSessionsDirs(): string[] {
|
||||
const candidates = [
|
||||
join(getOrcaManagedCodexHomePath(), 'sessions'),
|
||||
join(process.env.CODEX_HOME?.trim() || join(homedir(), '.codex'), 'sessions')
|
||||
]
|
||||
return candidates.filter((dir, index) => candidates.indexOf(dir) === index)
|
||||
}
|
||||
|
||||
export type ResolveSessionFileOptions = {
|
||||
/** Override the Claude projects root (used by tests / isolated scans). */
|
||||
claudeProjectsDir?: string
|
||||
/** Override the Codex sessions roots, searched in order (tests / isolated
|
||||
* scans). Defaults to the orca-managed home then CODEX_HOME/~/.codex. */
|
||||
codexSessionsDirs?: string[]
|
||||
/** Authoritative transcript path reported by the agent hook
|
||||
* (`providerSession.transcriptPath`). When set and the file exists, it is used
|
||||
* directly — recent Claude Code names the transcript with a UUID that differs
|
||||
* from the hook session_id, so the id-based glob below would miss it. */
|
||||
transcriptPath?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the on-disk JSONL transcript path for a given agent + session id.
|
||||
*
|
||||
* Prefers the hook-reported `transcriptPath` when it exists on disk (authoritative).
|
||||
* Otherwise: Claude nests transcripts by project slug
|
||||
* (`~/.claude/projects/<slug>/<id>.jsonl`), so we glob the projects subdirs for
|
||||
* `<id>.jsonl`. Codex stores rollout files under date-nested dirs whose file name
|
||||
* embeds the session id, so we match by the session id appearing in the file name.
|
||||
* Returns null when no matching transcript exists.
|
||||
*/
|
||||
export async function resolveSessionFilePath(
|
||||
agent: AgentType,
|
||||
sessionId: string,
|
||||
options: ResolveSessionFileOptions = {}
|
||||
): Promise<string | null> {
|
||||
// Why: the hook's transcript_path is the exact file the agent is writing, so it
|
||||
// beats reconstructing a path from the session id. Guard with existsSync so a
|
||||
// stale/remote path falls through to the id-based search rather than returning
|
||||
// a non-existent file.
|
||||
const hookPath = options.transcriptPath?.trim()
|
||||
if (hookPath && extname(hookPath) === '.jsonl' && existsSync(hookPath)) {
|
||||
return hookPath
|
||||
}
|
||||
|
||||
const trimmedId = sessionId.trim()
|
||||
if (!trimmedId) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (agent === 'claude') {
|
||||
return resolveClaudeSessionFile(trimmedId, options.claudeProjectsDir ?? claudeProjectsDir())
|
||||
}
|
||||
if (agent === 'codex') {
|
||||
return resolveCodexSessionFile(trimmedId, options.codexSessionsDirs ?? codexSessionsDirs())
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function resolveClaudeSessionFile(
|
||||
sessionId: string,
|
||||
projectsDir: string
|
||||
): Promise<string | null> {
|
||||
const targetName = `${sessionId}.jsonl`
|
||||
const files = await walkSessionFiles(projectsDir, 'claude', [], {
|
||||
extensions: new Set(['.jsonl']),
|
||||
filePredicate: (path) => basename(path) === targetName
|
||||
})
|
||||
return files[0] ?? null
|
||||
}
|
||||
|
||||
async function resolveCodexSessionFile(
|
||||
sessionId: string,
|
||||
sessionsDirs: string[]
|
||||
): Promise<string | null> {
|
||||
// Codex rollout file names embed the session id (rollout-<ts>-<id>.jsonl), so
|
||||
// match the id as a suffix of the file's base name rather than an exact name.
|
||||
// Search each candidate root (managed home first) and stop at the first match.
|
||||
for (const sessionsDir of sessionsDirs) {
|
||||
if (!existsSync(sessionsDir)) {
|
||||
continue
|
||||
}
|
||||
const files = await walkSessionFiles(sessionsDir, 'codex', [], {
|
||||
extensions: new Set(['.jsonl']),
|
||||
filePredicate: (path) => {
|
||||
const name = basename(path, extname(path))
|
||||
return name === sessionId || name.endsWith(`-${sessionId}`)
|
||||
}
|
||||
})
|
||||
if (files[0]) {
|
||||
return files[0]
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
// Per-line record→NativeChatMessage decoders, shared by the full transcript
|
||||
// reader (transcript-reader.ts) and the live tailer (transcript-watch.ts) so
|
||||
// both paths apply identical record-shape mapping. Each decoder is stateless:
|
||||
// it takes a single JSONL line plus a stable fallback id and returns one message
|
||||
// or null (unknown/empty records are skipped, never thrown — plan KTD risk:
|
||||
// schema drift). `fallbackId` is used only when the record carries no intrinsic
|
||||
// id; the caller supplies a value unique per line.
|
||||
|
||||
import type { NativeChatBlock, NativeChatMessage } from '../../shared/native-chat-types'
|
||||
import {
|
||||
asRecord,
|
||||
extractString,
|
||||
parseJsonObject,
|
||||
timestampMs
|
||||
} from '../ai-vault/session-scanner-values'
|
||||
import { claudeContentBlocks, toolResultOutput } from './transcript-record-blocks'
|
||||
|
||||
export function decodeClaudeTranscriptLine(
|
||||
line: string,
|
||||
fallbackId: string
|
||||
): NativeChatMessage | null {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
const role = record.type
|
||||
if (role !== 'user' && role !== 'assistant') {
|
||||
return null
|
||||
}
|
||||
const message = asRecord(record.message)
|
||||
const blocks = claudeContentBlocks(message?.content)
|
||||
if (blocks.length === 0) {
|
||||
return null
|
||||
}
|
||||
const messageId = extractString(record.uuid) ?? extractString(message?.id)
|
||||
return {
|
||||
id: messageId ?? fallbackId,
|
||||
role: claudeMessageRole(role, blocks),
|
||||
blocks,
|
||||
timestamp: parseTimestamp(record.timestamp),
|
||||
source: 'transcript'
|
||||
}
|
||||
}
|
||||
|
||||
// Claude marks reasoning via `thinking` content blocks; when a message is made
|
||||
// up solely of reasoning, surface it as a reasoning-role message.
|
||||
function claudeMessageRole(
|
||||
role: 'user' | 'assistant',
|
||||
blocks: NativeChatBlock[]
|
||||
): NativeChatMessage['role'] {
|
||||
if (role === 'user') {
|
||||
const onlyToolResults = blocks.every((block) => block.type === 'tool-result')
|
||||
return onlyToolResults && blocks.length > 0 ? 'tool' : 'user'
|
||||
}
|
||||
return role
|
||||
}
|
||||
|
||||
export function decodeCodexTranscriptLine(
|
||||
line: string,
|
||||
fallbackId: string
|
||||
): NativeChatMessage | null {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
const payload = asRecord(record.payload)
|
||||
if (!payload) {
|
||||
return null
|
||||
}
|
||||
const timestamp = parseTimestamp(record.timestamp)
|
||||
const baseId = extractString(payload.id) ?? fallbackId
|
||||
|
||||
if (record.type === 'response_item') {
|
||||
return codexResponseItem(payload, baseId, timestamp)
|
||||
}
|
||||
if (record.type === 'event_msg') {
|
||||
return codexEventMessage(payload, baseId, timestamp)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function codexResponseItem(
|
||||
payload: Record<string, unknown>,
|
||||
id: string,
|
||||
timestamp: number | null
|
||||
): NativeChatMessage | null {
|
||||
if (payload.type === 'message') {
|
||||
const blocks = claudeContentBlocks(payload.content)
|
||||
if (blocks.length === 0) {
|
||||
return null
|
||||
}
|
||||
const role =
|
||||
payload.role === 'assistant' ? 'assistant' : payload.role === 'user' ? 'user' : 'system'
|
||||
return { id, role, blocks, timestamp, source: 'transcript' }
|
||||
}
|
||||
if (payload.type === 'reasoning') {
|
||||
const text = extractString(payload.text) ?? codexSummaryText(payload.summary)
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
id,
|
||||
role: 'reasoning',
|
||||
blocks: [{ type: 'text', text }],
|
||||
timestamp,
|
||||
source: 'transcript'
|
||||
}
|
||||
}
|
||||
if (payload.type === 'function_call' || payload.type === 'local_shell_call') {
|
||||
const name = extractString(payload.name) ?? 'tool'
|
||||
return {
|
||||
id,
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'tool-call', name, input: codexCallInput(payload) }],
|
||||
timestamp,
|
||||
source: 'transcript'
|
||||
}
|
||||
}
|
||||
if (payload.type === 'function_call_output') {
|
||||
return {
|
||||
id,
|
||||
role: 'tool',
|
||||
blocks: [codexToolResult(payload.output)],
|
||||
timestamp,
|
||||
source: 'transcript'
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function codexEventMessage(
|
||||
payload: Record<string, unknown>,
|
||||
id: string,
|
||||
timestamp: number | null
|
||||
): NativeChatMessage | null {
|
||||
if (payload.type === 'user_message') {
|
||||
const text = extractString(payload.message)
|
||||
return text
|
||||
? { id, role: 'user', blocks: [{ type: 'text', text }], timestamp, source: 'transcript' }
|
||||
: null
|
||||
}
|
||||
if (payload.type === 'agent_message') {
|
||||
const text = extractString(payload.message)
|
||||
return text
|
||||
? { id, role: 'assistant', blocks: [{ type: 'text', text }], timestamp, source: 'transcript' }
|
||||
: null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function codexCallInput(payload: Record<string, unknown>): unknown {
|
||||
if (payload.arguments !== undefined) {
|
||||
return payload.arguments
|
||||
}
|
||||
return payload.input ?? payload.action ?? null
|
||||
}
|
||||
|
||||
function codexToolResult(output: unknown): NativeChatBlock {
|
||||
const record = asRecord(output)
|
||||
const isError = record?.success === false || record?.is_error === true
|
||||
return {
|
||||
type: 'tool-result',
|
||||
output: toolResultOutput(record?.content ?? record?.output ?? output),
|
||||
...(isError ? { isError: true } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function codexSummaryText(summary: unknown): string | null {
|
||||
if (!Array.isArray(summary)) {
|
||||
return null
|
||||
}
|
||||
const parts: string[] = []
|
||||
for (const item of summary) {
|
||||
const text = extractString(asRecord(item)?.text) ?? extractString(item)
|
||||
if (text) {
|
||||
parts.push(text)
|
||||
}
|
||||
}
|
||||
return parts.length ? parts.join('\n') : null
|
||||
}
|
||||
|
||||
function parseTimestamp(value: unknown): number | null {
|
||||
const parsed = timestampMs(value)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { mkdir, mkdtemp, rm, utimes, writeFile } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as TranscriptReader from './transcript-reader'
|
||||
|
||||
// Spy on the underlying reader so we can assert cache hits issue zero reads.
|
||||
const readSpy = vi.hoisted(() => vi.fn())
|
||||
vi.mock('./transcript-reader', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof TranscriptReader>()
|
||||
return {
|
||||
...actual,
|
||||
readNativeChatTranscript: (...args: Parameters<typeof actual.readNativeChatTranscript>) => {
|
||||
readSpy(...args)
|
||||
return actual.readNativeChatTranscript(...args)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
import {
|
||||
clearNativeChatTranscriptCache,
|
||||
readNativeChatTranscriptCached
|
||||
} from './transcript-read-cache'
|
||||
|
||||
let tempRoots: string[] = []
|
||||
|
||||
function jsonLines(records: unknown[]): string {
|
||||
return records.map((record) => JSON.stringify(record)).join('\n')
|
||||
}
|
||||
|
||||
async function seedSession(sessionId: string, turns: number): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-cache-'))
|
||||
tempRoots.push(root)
|
||||
const projectDir = join(root, '.claude', 'projects', '-repo')
|
||||
await mkdir(projectDir, { recursive: true })
|
||||
const records = Array.from({ length: turns }, (_unused, n) => ({
|
||||
type: 'user',
|
||||
uuid: `u-${n}`,
|
||||
timestamp: `2026-06-01T10:00:0${n}.000Z`,
|
||||
message: { role: 'user', content: `m${n}` }
|
||||
}))
|
||||
const filePath = join(projectDir, `${sessionId}.jsonl`)
|
||||
await writeFile(filePath, jsonLines(records))
|
||||
process.env.HOME = root
|
||||
return filePath
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearNativeChatTranscriptCache()
|
||||
readSpy.mockClear()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
|
||||
tempRoots = []
|
||||
})
|
||||
|
||||
describe('readNativeChatTranscriptCached', () => {
|
||||
it('returns the same cached object on an mtime hit without re-reading', async () => {
|
||||
await seedSession('sess-hit', 3)
|
||||
const first = await readNativeChatTranscriptCached('claude', 'sess-hit')
|
||||
const second = await readNativeChatTranscriptCached('claude', 'sess-hit')
|
||||
expect(readSpy).toHaveBeenCalledTimes(1)
|
||||
// Same reference: the second call served the cached parse.
|
||||
expect(second).toBe(first)
|
||||
})
|
||||
|
||||
it('re-reads when the file mtime changes', async () => {
|
||||
const filePath = await seedSession('sess-mtime', 2)
|
||||
await readNativeChatTranscriptCached('claude', 'sess-mtime')
|
||||
expect(readSpy).toHaveBeenCalledTimes(1)
|
||||
// Bump mtime into the future to invalidate without changing content shape.
|
||||
const future = new Date(Date.now() + 5_000)
|
||||
await utimes(filePath, future, future)
|
||||
await readNativeChatTranscriptCached('claude', 'sess-mtime')
|
||||
expect(readSpy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('clear() empties the cache so the next read re-reads', async () => {
|
||||
await seedSession('sess-clear', 1)
|
||||
await readNativeChatTranscriptCached('claude', 'sess-clear')
|
||||
clearNativeChatTranscriptCache()
|
||||
await readNativeChatTranscriptCached('claude', 'sess-clear')
|
||||
expect(readSpy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('returns an error result for an unknown session without throwing', async () => {
|
||||
await seedSession('present', 1)
|
||||
const result = await readNativeChatTranscriptCached('claude', 'absent')
|
||||
expect('error' in result && result.error).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
import { stat } from 'fs/promises'
|
||||
import type { AgentType } from '../../shared/native-chat-types'
|
||||
import { resolveSessionFilePath } from './session-file-resolver'
|
||||
import { readNativeChatTranscript, type ReadTranscriptResult } from './transcript-reader'
|
||||
|
||||
// Why: both the desktop IPC handler and the runtime RPC handler read the same
|
||||
// host-filesystem transcript, so a single process-global cache keyed by
|
||||
// agent:sessionId maximizes the hit rate across desktop + every paired
|
||||
// web/mobile client. Keying by connection instead would defeat the multi-client
|
||||
// case this feature targets and multiply memory by the connection count.
|
||||
// The cache stores ONE canonical, unwindowed parse; windowing and per-surface
|
||||
// truncation stay in the callers so the same parse is reused across all `limit`
|
||||
// values and every client kind.
|
||||
|
||||
type CachedTranscript = {
|
||||
result: ReadTranscriptResult
|
||||
/** mtime of the resolved file when cached; a newer mtime invalidates it. */
|
||||
mtimeMs: number
|
||||
}
|
||||
|
||||
const cache = new Map<string, CachedTranscript>()
|
||||
|
||||
// Why: cap the cache so a long-lived process browsing many sessions can't grow
|
||||
// it unbounded. Map preserves insertion order, so evicting the first key drops
|
||||
// the oldest entry (a simple LRU once re-inserts bump recency; see setCached).
|
||||
// Entry-count cap is fine for v1; a byte-aware cap is the follow-up if profiling
|
||||
// shows RSS pressure now that one process serves many remote clients.
|
||||
const MAX_CACHE_ENTRIES = 50
|
||||
|
||||
function setCached(key: string, value: CachedTranscript): void {
|
||||
// Re-insert moves the key to the most-recent position for LRU eviction.
|
||||
cache.delete(key)
|
||||
cache.set(key, value)
|
||||
while (cache.size > MAX_CACHE_ENTRIES) {
|
||||
const oldest = cache.keys().next().value
|
||||
if (oldest === undefined) {
|
||||
break
|
||||
}
|
||||
cache.delete(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
function cacheKey(agent: AgentType, sessionId: string): string {
|
||||
return `${agent}:${sessionId}`
|
||||
}
|
||||
|
||||
async function fileMtimeMs(filePath: string): Promise<number> {
|
||||
try {
|
||||
return (await stat(filePath)).mtimeMs
|
||||
} catch {
|
||||
return Number.NaN
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the full transcript for an agent + session, returning the cached parse on
|
||||
* an mtime hit and re-reading (and re-caching) when the file changed. Returns the
|
||||
* canonical, unwindowed result; callers apply their own windowing/truncation.
|
||||
*/
|
||||
export async function readNativeChatTranscriptCached(
|
||||
agent: AgentType,
|
||||
sessionId: string,
|
||||
/** Hook-reported authoritative transcript path, preferred over the id glob. */
|
||||
transcriptPath?: string
|
||||
): Promise<ReadTranscriptResult> {
|
||||
const filePath = await resolveSessionFilePath(agent, sessionId, { transcriptPath })
|
||||
if (!filePath) {
|
||||
return { error: `No transcript found for ${agent} session ${sessionId}` }
|
||||
}
|
||||
|
||||
const key = cacheKey(agent, sessionId)
|
||||
const mtimeMs = await fileMtimeMs(filePath)
|
||||
const cached = cache.get(key)
|
||||
if (cached && Number.isFinite(mtimeMs) && cached.mtimeMs === mtimeMs) {
|
||||
// Bump recency so a frequently-read session survives eviction.
|
||||
setCached(key, cached)
|
||||
return cached.result
|
||||
}
|
||||
|
||||
const result = await readNativeChatTranscript(agent, sessionId, { filePath })
|
||||
if (Number.isFinite(mtimeMs)) {
|
||||
setCached(key, { result, mtimeMs })
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Test-only: drop the per-session transcript cache between runs. */
|
||||
export function clearNativeChatTranscriptCache(): void {
|
||||
cache.clear()
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { mkdtemp, rm, writeFile } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { readNativeChatTranscript } from './transcript-reader'
|
||||
|
||||
let tempRoots: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
|
||||
tempRoots = []
|
||||
})
|
||||
|
||||
function jsonLines(records: unknown[]): string {
|
||||
return records.map((record) => JSON.stringify(record)).join('\n')
|
||||
}
|
||||
|
||||
async function writeFixture(prefix: string, records: unknown[]): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), prefix))
|
||||
tempRoots.push(root)
|
||||
const filePath = join(root, 'transcript.jsonl')
|
||||
await writeFile(filePath, jsonLines(records))
|
||||
return filePath
|
||||
}
|
||||
|
||||
describe('readNativeChatTranscript (claude)', () => {
|
||||
it('returns ordered user/assistant/tool messages with no 5-message cap', async () => {
|
||||
const records: unknown[] = []
|
||||
// 4 user/assistant turns = 8 messages, well past the AI-Vault preview cap.
|
||||
for (let turn = 0; turn < 4; turn++) {
|
||||
records.push({
|
||||
type: 'user',
|
||||
uuid: `u-${turn}`,
|
||||
timestamp: `2026-06-01T10:0${turn}:00.000Z`,
|
||||
message: { role: 'user', content: `Prompt **${turn}**` }
|
||||
})
|
||||
records.push({
|
||||
type: 'assistant',
|
||||
uuid: `a-${turn}`,
|
||||
timestamp: `2026-06-01T10:0${turn}:30.000Z`,
|
||||
message: { role: 'assistant', content: [{ type: 'text', text: `Reply _${turn}_` }] }
|
||||
})
|
||||
}
|
||||
// A tool_use then a tool_result (carried on a user record).
|
||||
records.push({
|
||||
type: 'assistant',
|
||||
uuid: 'a-tool',
|
||||
timestamp: '2026-06-01T10:05:00.000Z',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool_use', name: 'Bash', input: { command: 'ls' } }]
|
||||
}
|
||||
})
|
||||
records.push({
|
||||
type: 'user',
|
||||
uuid: 'u-toolresult',
|
||||
timestamp: '2026-06-01T10:05:01.000Z',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'tool_result', content: 'file-a\nfile-b', is_error: false }]
|
||||
}
|
||||
})
|
||||
|
||||
const filePath = await writeFixture('orca-native-chat-claude-', records)
|
||||
const result = await readNativeChatTranscript('claude', 'sess', { filePath })
|
||||
expect('messages' in result).toBe(true)
|
||||
if (!('messages' in result)) {
|
||||
return
|
||||
}
|
||||
|
||||
expect(result.messages.length).toBe(10)
|
||||
expect(result.messages.length).toBeGreaterThan(5)
|
||||
expect(result.messages[0]).toMatchObject({ role: 'user', source: 'transcript' })
|
||||
// Markdown text preserved verbatim.
|
||||
expect(result.messages[0].blocks[0]).toEqual({ type: 'text', text: 'Prompt **0**' })
|
||||
expect(result.messages[1].blocks[0]).toEqual({ type: 'text', text: 'Reply _0_' })
|
||||
|
||||
const toolCall = result.messages.find((m) => m.blocks[0]?.type === 'tool-call')
|
||||
expect(toolCall?.blocks[0]).toEqual({
|
||||
type: 'tool-call',
|
||||
name: 'Bash',
|
||||
input: { command: 'ls' }
|
||||
})
|
||||
|
||||
const toolResult = result.messages.at(-1)
|
||||
expect(toolResult?.role).toBe('tool')
|
||||
expect(toolResult?.blocks[0]).toEqual({ type: 'tool-result', output: 'file-a\nfile-b' })
|
||||
})
|
||||
|
||||
it('marks thinking-only assistant content as a reasoning surface', async () => {
|
||||
const filePath = await writeFixture('orca-native-chat-claude-think-', [
|
||||
{
|
||||
type: 'assistant',
|
||||
uuid: 'a-think',
|
||||
timestamp: '2026-06-01T10:00:00.000Z',
|
||||
message: { role: 'assistant', content: [{ type: 'thinking', thinking: 'pondering' }] }
|
||||
}
|
||||
])
|
||||
const result = await readNativeChatTranscript('claude', 'sess', { filePath })
|
||||
if (!('messages' in result)) {
|
||||
throw new Error('expected messages')
|
||||
}
|
||||
expect(result.messages[0].blocks[0]).toEqual({ type: 'text', text: 'pondering' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('readNativeChatTranscript (codex)', () => {
|
||||
it('maps tool calls and results to tool-call/tool-result blocks', async () => {
|
||||
const filePath = await writeFixture('orca-native-chat-codex-', [
|
||||
{
|
||||
type: 'session_meta',
|
||||
timestamp: '2026-06-01T10:00:00.000Z',
|
||||
payload: { id: 'codex-sess', cwd: '/repo' }
|
||||
},
|
||||
{
|
||||
type: 'response_item',
|
||||
timestamp: '2026-06-01T10:00:01.000Z',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Run the build' }]
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'response_item',
|
||||
timestamp: '2026-06-01T10:00:02.000Z',
|
||||
payload: { type: 'reasoning', summary: [{ type: 'summary_text', text: 'I will run it' }] }
|
||||
},
|
||||
{
|
||||
type: 'response_item',
|
||||
timestamp: '2026-06-01T10:00:03.000Z',
|
||||
payload: {
|
||||
type: 'function_call',
|
||||
name: 'shell',
|
||||
arguments: '{"command":["bash","-lc","make"]}'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'response_item',
|
||||
timestamp: '2026-06-01T10:00:04.000Z',
|
||||
payload: {
|
||||
type: 'function_call_output',
|
||||
output: { content: 'build ok', success: true }
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'response_item',
|
||||
timestamp: '2026-06-01T10:00:05.000Z',
|
||||
payload: { type: 'message', role: 'assistant', content: [{ type: 'text', text: 'Done.' }] }
|
||||
}
|
||||
])
|
||||
|
||||
const result = await readNativeChatTranscript('codex', 'codex-sess', { filePath })
|
||||
if (!('messages' in result)) {
|
||||
throw new Error(`expected messages, got error`)
|
||||
}
|
||||
|
||||
const roles = result.messages.map((m) => m.role)
|
||||
expect(roles).toEqual(['user', 'reasoning', 'assistant', 'tool', 'assistant'])
|
||||
|
||||
const call = result.messages.find((m) => m.blocks[0]?.type === 'tool-call')
|
||||
expect(call?.blocks[0]).toEqual({
|
||||
type: 'tool-call',
|
||||
name: 'shell',
|
||||
input: '{"command":["bash","-lc","make"]}'
|
||||
})
|
||||
|
||||
const toolResult = result.messages.find((m) => m.blocks[0]?.type === 'tool-result')
|
||||
expect(toolResult?.blocks[0]).toEqual({ type: 'tool-result', output: 'build ok' })
|
||||
|
||||
const reasoning = result.messages.find((m) => m.role === 'reasoning')
|
||||
expect(reasoning?.blocks[0]).toEqual({ type: 'text', text: 'I will run it' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('readNativeChatTranscript (errors)', () => {
|
||||
it('returns an error for an unreadable/missing file without throwing', async () => {
|
||||
const result = await readNativeChatTranscript('claude', 'sess', {
|
||||
filePath: join(tmpdir(), 'orca-native-chat-does-not-exist.jsonl')
|
||||
})
|
||||
expect('error' in result).toBe(true)
|
||||
})
|
||||
|
||||
it('returns an error when no transcript can be resolved', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-noresolve-'))
|
||||
tempRoots.push(root)
|
||||
const result = await readNativeChatTranscript('claude', 'missing', {
|
||||
claudeProjectsDir: join(root, 'empty')
|
||||
})
|
||||
expect('error' in result).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import { createReadStream } from 'fs'
|
||||
import { createInterface } from 'readline'
|
||||
import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types'
|
||||
import { errorMessage } from '../ai-vault/session-scanner-values'
|
||||
import { resolveSessionFilePath, type ResolveSessionFileOptions } from './session-file-resolver'
|
||||
import { decodeClaudeTranscriptLine, decodeCodexTranscriptLine } from './transcript-line-decoders'
|
||||
|
||||
export type ReadTranscriptResult = { messages: NativeChatMessage[] } | { error: string }
|
||||
|
||||
export type ReadTranscriptOptions = ResolveSessionFileOptions & {
|
||||
/** Resolve directly to this file, skipping path discovery (used by tests). */
|
||||
filePath?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the ENTIRE Claude/Codex JSONL transcript for an agent + session id into
|
||||
* the NativeChatMessage model. Unlike the AI-Vault preview scan, this applies
|
||||
* NO message cap. Unknown record types are skipped rather than throwing, so a
|
||||
* single malformed/unrecognized line cannot fail the whole read. The per-line
|
||||
* record→message mapping is shared with the live tailer (transcript-watch.ts)
|
||||
* via transcript-line-decoders.ts.
|
||||
*/
|
||||
export async function readNativeChatTranscript(
|
||||
agent: AgentType,
|
||||
sessionId: string,
|
||||
options: ReadTranscriptOptions = {}
|
||||
): Promise<ReadTranscriptResult> {
|
||||
const filePath = options.filePath ?? (await resolveSessionFilePath(agent, sessionId, options))
|
||||
if (!filePath) {
|
||||
return { error: `No transcript found for ${agent} session ${sessionId}` }
|
||||
}
|
||||
try {
|
||||
if (agent === 'claude') {
|
||||
return { messages: await readTranscript(filePath, decodeClaudeTranscriptLine) }
|
||||
}
|
||||
if (agent === 'codex') {
|
||||
return { messages: await readTranscript(filePath, decodeCodexTranscriptLine) }
|
||||
}
|
||||
return { error: `Unsupported agent for native chat transcript: ${agent}` }
|
||||
} catch (err) {
|
||||
return { error: errorMessage(err) }
|
||||
}
|
||||
}
|
||||
|
||||
async function readTranscript(
|
||||
filePath: string,
|
||||
decode: (line: string, fallbackId: string) => NativeChatMessage | null
|
||||
): Promise<NativeChatMessage[]> {
|
||||
const reader = createInterface({
|
||||
input: createReadStream(filePath, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
const messages: NativeChatMessage[] = []
|
||||
let index = 0
|
||||
for await (const line of reader) {
|
||||
// Why: fallback id embeds start offset 0 so it matches the live tailer's id
|
||||
// for the same record (the tailer's first drain reads from offset 0 too).
|
||||
// Records that re-emit then collapse by id in the assembler — no dup, no drop.
|
||||
const message = decode(line, `${filePath}:0:${index}`)
|
||||
if (message) {
|
||||
messages.push(message)
|
||||
}
|
||||
index++
|
||||
}
|
||||
return messages
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// Centralized record→block mapping for native-chat transcripts. Kept separate
|
||||
// from the reader so the Claude and Codex per-record decoders share one place
|
||||
// to evolve as CLI transcript schemas drift (plan KTD risk: schema drift).
|
||||
|
||||
import type {
|
||||
NativeChatBlock,
|
||||
NativeChatImageRefBlock,
|
||||
NativeChatToolResultBlock
|
||||
} from '../../shared/native-chat-types'
|
||||
import { asRecord, extractString } from '../ai-vault/session-scanner-values'
|
||||
|
||||
/** Coerce an arbitrary tool-result payload into a single output string. */
|
||||
export function toolResultOutput(value: unknown): string {
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
if (!Array.isArray(value)) {
|
||||
const record = asRecord(value)
|
||||
if (record) {
|
||||
const text = extractString(record.text) ?? extractString(record.content)
|
||||
if (text) {
|
||||
return text
|
||||
}
|
||||
}
|
||||
return value === undefined || value === null ? '' : JSON.stringify(value)
|
||||
}
|
||||
const parts: string[] = []
|
||||
for (const item of value) {
|
||||
if (typeof item === 'string') {
|
||||
parts.push(item)
|
||||
continue
|
||||
}
|
||||
const record = asRecord(item)
|
||||
const text = extractString(record?.text) ?? extractString(record?.content)
|
||||
if (text) {
|
||||
parts.push(text)
|
||||
}
|
||||
}
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
/** Build the blocks for one Claude content array (string or block[]). */
|
||||
export function claudeContentBlocks(content: unknown): NativeChatBlock[] {
|
||||
if (typeof content === 'string') {
|
||||
const text = content.trim()
|
||||
return text ? [{ type: 'text', text: content }] : []
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return []
|
||||
}
|
||||
const blocks: NativeChatBlock[] = []
|
||||
for (const item of content) {
|
||||
if (typeof item === 'string') {
|
||||
if (item.trim()) {
|
||||
blocks.push({ type: 'text', text: item })
|
||||
}
|
||||
continue
|
||||
}
|
||||
const record = asRecord(item)
|
||||
if (!record) {
|
||||
continue
|
||||
}
|
||||
const block = claudeContentBlock(record)
|
||||
if (block) {
|
||||
blocks.push(block)
|
||||
}
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
function claudeContentBlock(record: Record<string, unknown>): NativeChatBlock | null {
|
||||
switch (record.type) {
|
||||
case 'text': {
|
||||
const text = extractString(record.text)
|
||||
return text ? { type: 'text', text } : null
|
||||
}
|
||||
case 'thinking': {
|
||||
// Reasoning surfaces as a text block; the message role marks it as reasoning.
|
||||
const text = extractString(record.thinking) ?? extractString(record.text)
|
||||
return text ? { type: 'text', text } : null
|
||||
}
|
||||
case 'tool_use': {
|
||||
const name = extractString(record.name) ?? 'tool'
|
||||
return { type: 'tool-call', name, input: record.input }
|
||||
}
|
||||
case 'tool_result':
|
||||
return toolResultBlock(record)
|
||||
case 'image':
|
||||
return imageRefBlock(record)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function toolResultBlock(record: Record<string, unknown>): NativeChatToolResultBlock {
|
||||
return {
|
||||
type: 'tool-result',
|
||||
output: toolResultOutput(record.content),
|
||||
...(record.is_error === true ? { isError: true } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function imageRefBlock(record: Record<string, unknown>): NativeChatImageRefBlock | null {
|
||||
const source = asRecord(record.source)
|
||||
const url = extractString(source?.url) ?? extractString(record.url)
|
||||
const path = extractString(record.path)
|
||||
const alt = extractString(record.alt) ?? undefined
|
||||
if (!url && !path) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
type: 'image-ref',
|
||||
...(path ? { path } : {}),
|
||||
...(url ? { url } : {}),
|
||||
...(alt ? { alt } : {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { appendFile, mkdtemp, rm, writeFile } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../shared/native-chat-types'
|
||||
import { getActiveNativeChatWatcherCount, subscribeNativeChatTranscript } from './transcript-watch'
|
||||
|
||||
let tempRoots: string[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
tempRoots = []
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
|
||||
tempRoots = []
|
||||
})
|
||||
|
||||
async function tempFile(initial: string): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-watch-'))
|
||||
tempRoots.push(root)
|
||||
const filePath = join(root, 'rollout.jsonl')
|
||||
await writeFile(filePath, initial)
|
||||
return filePath
|
||||
}
|
||||
|
||||
function claudeLine(uuid: string, role: 'user' | 'assistant', text: string): string {
|
||||
return `${JSON.stringify({
|
||||
type: role,
|
||||
uuid,
|
||||
timestamp: '2026-06-01T10:00:00.000Z',
|
||||
message: { role, content: role === 'user' ? text : [{ type: 'text', text }] }
|
||||
})}\n`
|
||||
}
|
||||
|
||||
async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> {
|
||||
const start = Date.now()
|
||||
while (!predicate()) {
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new Error('timed out waiting for condition')
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
}
|
||||
|
||||
describe('subscribeNativeChatTranscript', () => {
|
||||
it('re-emits from the top on first drain so appended turns are never dropped', async () => {
|
||||
const filePath = await tempFile(claudeLine('u-1', 'user', 'first'))
|
||||
const batches: NativeChatMessage[][] = []
|
||||
|
||||
const sub = await subscribeNativeChatTranscript({
|
||||
agent: 'claude',
|
||||
sessionId: 'ignored',
|
||||
filePath,
|
||||
onAppend: (messages) => batches.push(messages),
|
||||
debounceMs: 5
|
||||
})
|
||||
|
||||
await appendFile(filePath, claudeLine('a-1', 'assistant', 'reply'))
|
||||
await waitFor(() => batches.flat().some((m) => m.id === 'a-1'))
|
||||
|
||||
sub.unsubscribe()
|
||||
|
||||
// Seed-at-0 means the first drain re-reads the whole file; the assembler
|
||||
// dedups by id. The appended turn must appear; the pre-existing line may
|
||||
// appear too (collapsed downstream by id).
|
||||
const ids = batches.flat().map((m) => m.id)
|
||||
expect(ids).toContain('a-1')
|
||||
})
|
||||
|
||||
it('appends a turn in the gap between initial read and first watcher drain exactly once', async () => {
|
||||
// Simulate the read/subscribe race: a turn lands after the caller's
|
||||
// readSession EOF but before the watcher's first drain. Seeding at 0 means
|
||||
// the first drain reads it; the assembler later dedups by deterministic id.
|
||||
const filePath = await tempFile(claudeLine('u-1', 'user', 'first'))
|
||||
const seen: NativeChatMessage[] = []
|
||||
|
||||
// The gap turn is written BEFORE subscribe completes its first drain.
|
||||
await appendFile(filePath, claudeLine('a-gap', 'assistant', 'raced reply'))
|
||||
|
||||
const sub = await subscribeNativeChatTranscript({
|
||||
agent: 'claude',
|
||||
sessionId: 'ignored',
|
||||
filePath,
|
||||
onAppend: (messages) => seen.push(...messages),
|
||||
debounceMs: 5
|
||||
})
|
||||
|
||||
await waitFor(() => seen.some((m) => m.id === 'a-gap'))
|
||||
sub.unsubscribe()
|
||||
|
||||
// The raced turn is present, and not duplicated within a single drain pass.
|
||||
expect(seen.filter((m) => m.id === 'a-gap')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('recovers cleanly when a read throws (subscription not left deaf)', async () => {
|
||||
const filePath = await tempFile(claudeLine('u-1', 'user', 'hi'))
|
||||
const seen: NativeChatMessage[] = []
|
||||
|
||||
const sub = await subscribeNativeChatTranscript({
|
||||
agent: 'claude',
|
||||
sessionId: 'ignored',
|
||||
filePath,
|
||||
onAppend: (messages) => seen.push(...messages),
|
||||
debounceMs: 5
|
||||
})
|
||||
|
||||
// Make the file unreadable mid-flight (EACCES on the read path). The drain's
|
||||
// try/catch must break and reset `reading` in finally so a later append
|
||||
// still tails once permissions are restored.
|
||||
await waitFor(() => seen.some((m) => m.id === 'u-1'))
|
||||
const { chmod } = await import('fs/promises')
|
||||
await chmod(filePath, 0o000)
|
||||
await appendFile(filePath, claudeLine('a-1', 'assistant', 'reply')).catch(() => {})
|
||||
// Give the watcher a chance to attempt (and fail) a drain.
|
||||
await new Promise((resolve) => setTimeout(resolve, 40))
|
||||
await chmod(filePath, 0o644)
|
||||
await appendFile(filePath, claudeLine('a-2', 'assistant', 'recovered'))
|
||||
|
||||
await waitFor(() => seen.some((m) => m.id === 'a-2'))
|
||||
sub.unsubscribe()
|
||||
expect(seen.some((m) => m.id === 'a-2')).toBe(true)
|
||||
})
|
||||
|
||||
it('releases the watcher on unsubscribe (no leak)', async () => {
|
||||
const filePath = await tempFile(claudeLine('u-1', 'user', 'hi'))
|
||||
const before = getActiveNativeChatWatcherCount()
|
||||
|
||||
const sub = await subscribeNativeChatTranscript({
|
||||
agent: 'claude',
|
||||
sessionId: 'ignored',
|
||||
filePath,
|
||||
onAppend: () => {},
|
||||
debounceMs: 5
|
||||
})
|
||||
expect(getActiveNativeChatWatcherCount()).toBe(before + 1)
|
||||
|
||||
sub.unsubscribe()
|
||||
expect(getActiveNativeChatWatcherCount()).toBe(before)
|
||||
|
||||
// Idempotent: a second unsubscribe must not under-count.
|
||||
sub.unsubscribe()
|
||||
expect(getActiveNativeChatWatcherCount()).toBe(before)
|
||||
})
|
||||
|
||||
it('coalesces rapid successive appends without dropping messages', async () => {
|
||||
const filePath = await tempFile(claudeLine('u-1', 'user', 'hi'))
|
||||
const seen: NativeChatMessage[] = []
|
||||
|
||||
const sub = await subscribeNativeChatTranscript({
|
||||
agent: 'claude',
|
||||
sessionId: 'ignored',
|
||||
filePath,
|
||||
onAppend: (messages) => seen.push(...messages),
|
||||
debounceMs: 10
|
||||
})
|
||||
|
||||
// Fire several appends back-to-back within the debounce window.
|
||||
await appendFile(filePath, claudeLine('a-1', 'assistant', 'one'))
|
||||
await appendFile(filePath, claudeLine('a-2', 'assistant', 'two'))
|
||||
await appendFile(filePath, claudeLine('a-3', 'assistant', 'three'))
|
||||
|
||||
await waitFor(() => ['a-1', 'a-2', 'a-3'].every((id) => seen.some((m) => m.id === id)))
|
||||
sub.unsubscribe()
|
||||
|
||||
// Order is preserved for the appended turns (the seed re-read may also carry
|
||||
// the pre-existing u-1, which the assembler dedups downstream).
|
||||
const appendedIds = seen.map((m) => m.id).filter((id) => id !== 'u-1')
|
||||
expect(appendedIds).toEqual(['a-1', 'a-2', 'a-3'])
|
||||
})
|
||||
|
||||
it('waits for an incomplete trailing JSONL line before advancing the offset', async () => {
|
||||
const filePath = await tempFile(claudeLine('u-1', 'user', 'hi'))
|
||||
const seen: NativeChatMessage[] = []
|
||||
|
||||
const sub = await subscribeNativeChatTranscript({
|
||||
agent: 'claude',
|
||||
sessionId: 'ignored',
|
||||
filePath,
|
||||
onAppend: (messages) => seen.push(...messages),
|
||||
debounceMs: 5
|
||||
})
|
||||
|
||||
await waitFor(() => seen.some((m) => m.id === 'u-1'))
|
||||
|
||||
const line = claudeLine('a-partial', 'assistant', 'split reply')
|
||||
const splitAt = Math.floor(line.length / 2)
|
||||
await appendFile(filePath, line.slice(0, splitAt))
|
||||
await new Promise((resolve) => setTimeout(resolve, 40))
|
||||
expect(seen.some((m) => m.id === 'a-partial')).toBe(false)
|
||||
|
||||
await appendFile(filePath, line.slice(splitAt))
|
||||
await waitFor(() => seen.some((m) => m.id === 'a-partial'))
|
||||
|
||||
sub.unsubscribe()
|
||||
expect(seen.filter((m) => m.id === 'a-partial')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('survives file replacement / rotation (offset reset on shrink)', async () => {
|
||||
const filePath = await tempFile(
|
||||
claudeLine('u-1', 'user', 'old') + claudeLine('a-1', 'assistant', 'old-reply')
|
||||
)
|
||||
const seen: NativeChatMessage[] = []
|
||||
|
||||
const sub = await subscribeNativeChatTranscript({
|
||||
agent: 'claude',
|
||||
sessionId: 'ignored',
|
||||
filePath,
|
||||
onAppend: (messages) => seen.push(...messages),
|
||||
debounceMs: 5
|
||||
})
|
||||
|
||||
// Replace the file with shorter content (simulates rotation to a new,
|
||||
// smaller session file at the same resolved path).
|
||||
await writeFile(filePath, claudeLine('u-2', 'user', 'fresh'))
|
||||
await waitFor(() => seen.some((m) => m.id === 'u-2'))
|
||||
|
||||
// A subsequent append on the rotated file is still tailed.
|
||||
await appendFile(filePath, claudeLine('a-2', 'assistant', 'fresh-reply'))
|
||||
await waitFor(() => seen.some((m) => m.id === 'a-2'))
|
||||
|
||||
sub.unsubscribe()
|
||||
const ids = seen.map((m) => m.id)
|
||||
expect(ids).toContain('u-2')
|
||||
expect(ids).toContain('a-2')
|
||||
})
|
||||
|
||||
it('returns a no-op unsubscribe when the file cannot be resolved', async () => {
|
||||
const before = getActiveNativeChatWatcherCount()
|
||||
const sub = await subscribeNativeChatTranscript({
|
||||
agent: 'claude',
|
||||
sessionId: '',
|
||||
onAppend: () => {}
|
||||
})
|
||||
expect(getActiveNativeChatWatcherCount()).toBe(before)
|
||||
// Must not throw.
|
||||
sub.unsubscribe()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,254 @@
|
||||
// Live transcript tailing: watch a resolved session JSONL file and emit only
|
||||
// the messages parsed from bytes appended since the last read. Modeled on the
|
||||
// incremental byte-offset read in codex-usage/scanner.ts (parseCodexUsageFile's
|
||||
// skipInitialBytes), but specialized to the NativeChatMessage record decoders.
|
||||
//
|
||||
// Teardown discipline (plan U4 risk: file-watch fd leaks): every subscription
|
||||
// owns exactly one fs.FSWatcher and one debounce timer. unsubscribe() closes
|
||||
// the watcher and clears the timer synchronously, and the module tracks the live
|
||||
// watcher count so tests can assert no watcher survives teardown.
|
||||
|
||||
import { watch, type FSWatcher } from 'fs'
|
||||
import { open, stat } from 'fs/promises'
|
||||
import type { Readable } from 'stream'
|
||||
import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types'
|
||||
import { resolveSessionFilePath, type ResolveSessionFileOptions } from './session-file-resolver'
|
||||
import { decodeClaudeTranscriptLine, decodeCodexTranscriptLine } from './transcript-line-decoders'
|
||||
|
||||
export type SubscribeNativeChatTranscriptArgs = ResolveSessionFileOptions & {
|
||||
agent: AgentType
|
||||
sessionId: string
|
||||
/** Called with the newly-appended messages whenever the file grows. Never
|
||||
* called with an empty array. */
|
||||
onAppend: (messages: NativeChatMessage[]) => void
|
||||
/** Resolve directly to this file, skipping path discovery (used by tests). */
|
||||
filePath?: string
|
||||
/** Coalesce window for rapid fs.watch events (ms). Defaults to 40ms. */
|
||||
debounceMs?: number
|
||||
}
|
||||
|
||||
export type NativeChatTranscriptSubscription = {
|
||||
/** Closes the watcher and releases the file handle. Idempotent. */
|
||||
unsubscribe: () => void
|
||||
}
|
||||
|
||||
// Why: a single watch event can fire several times for one append; we read from
|
||||
// the last byte offset so re-entrant reads never re-emit prior messages. Each
|
||||
// decoder is stateless per-line, so tailing reuses the same record→message
|
||||
// mapping the full reader uses.
|
||||
const DEFAULT_DEBOUNCE_MS = 40
|
||||
|
||||
// Why: process-wide count of live FSWatchers opened by this module. The U4 leak
|
||||
// test asserts this returns to zero after unsubscribe so a forgotten handle is
|
||||
// caught deterministically rather than relying on OS fd inspection.
|
||||
let activeWatcherCount = 0
|
||||
|
||||
/** Test-only: number of fs watchers this module currently holds open. */
|
||||
export function getActiveNativeChatWatcherCount(): number {
|
||||
return activeWatcherCount
|
||||
}
|
||||
|
||||
function lineDecoderForAgent(
|
||||
agent: AgentType
|
||||
): ((line: string, fallbackId: string) => NativeChatMessage | null) | null {
|
||||
if (agent === 'claude') {
|
||||
return decodeClaudeTranscriptLine
|
||||
}
|
||||
if (agent === 'codex') {
|
||||
return decodeCodexTranscriptLine
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function fileSize(filePath: string): Promise<number> {
|
||||
try {
|
||||
return (await stat(filePath)).size
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read bytes [start, end) of the file and decode each complete line into a
|
||||
* NativeChatMessage. Opens its own fd and always closes it (no leak on the read
|
||||
* path, distinct from the long-lived watcher). Returns the messages plus the
|
||||
* byte offset actually consumed so a partially-written trailing line is re-read
|
||||
* on the next append rather than dropped.
|
||||
*/
|
||||
async function readAppendedMessages(
|
||||
filePath: string,
|
||||
start: number,
|
||||
decode: (line: string, fallbackId: string) => NativeChatMessage | null
|
||||
): Promise<{ messages: NativeChatMessage[]; consumedTo: number }> {
|
||||
const end = await fileSize(filePath)
|
||||
if (end <= start) {
|
||||
// File shrank (rotation/replacement) or unchanged — caller resets offset.
|
||||
return { messages: [], consumedTo: end }
|
||||
}
|
||||
|
||||
const handle = await open(filePath, 'r')
|
||||
try {
|
||||
const stream = handle.createReadStream({
|
||||
encoding: 'utf-8',
|
||||
start,
|
||||
end: end - 1,
|
||||
autoClose: false
|
||||
})
|
||||
const { messages, consumedBytes } = await decodeStreamLines(stream, filePath, start, decode)
|
||||
return { messages, consumedTo: start + consumedBytes }
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function decodeStreamLines(
|
||||
stream: Readable,
|
||||
filePath: string,
|
||||
start: number,
|
||||
decode: (line: string, fallbackId: string) => NativeChatMessage | null
|
||||
): Promise<{ messages: NativeChatMessage[]; consumedBytes: number }> {
|
||||
const text = await readStreamText(stream)
|
||||
const completeEnd = text.lastIndexOf('\n')
|
||||
if (completeEnd === -1) {
|
||||
return { messages: [], consumedBytes: 0 }
|
||||
}
|
||||
|
||||
// Why: transcript writers can flush mid-record. Only advance through
|
||||
// newline-terminated JSONL so invalid partial JSON is retried on the next
|
||||
// append instead of being lost forever.
|
||||
const completeText = text.slice(0, completeEnd + 1)
|
||||
const lines = completeText.split('\n')
|
||||
const messages: NativeChatMessage[] = []
|
||||
for (const [index, line] of lines.entries()) {
|
||||
if (!line) {
|
||||
continue
|
||||
}
|
||||
// Fallback id embeds the byte offset so ids stay stable+unique across
|
||||
// appends even when a record carries no intrinsic id.
|
||||
const message = decode(line, `${filePath}:${start}:${index}`)
|
||||
if (message) {
|
||||
messages.push(message)
|
||||
}
|
||||
}
|
||||
return { messages, consumedBytes: Buffer.byteLength(completeText, 'utf8') }
|
||||
}
|
||||
|
||||
async function readStreamText(stream: Readable): Promise<string> {
|
||||
const chunks: string[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'))
|
||||
}
|
||||
return chunks.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to live appends on an agent's transcript file. Returns an
|
||||
* unsubscribe fn that tears the watcher down completely.
|
||||
*
|
||||
* Handles file rotation/replacement: when the file shrinks (a new session id
|
||||
* resolved to a smaller/newer file, or the file was truncated), the offset is
|
||||
* reset to 0 so the replacement's content is read from the top.
|
||||
*/
|
||||
export async function subscribeNativeChatTranscript(
|
||||
args: SubscribeNativeChatTranscriptArgs
|
||||
): Promise<NativeChatTranscriptSubscription> {
|
||||
const { agent, sessionId, onAppend, debounceMs } = args
|
||||
const decode = lineDecoderForAgent(agent)
|
||||
const filePath = args.filePath ?? (await resolveSessionFilePath(agent, sessionId, args))
|
||||
|
||||
if (!filePath || !decode) {
|
||||
// Nothing watchable — return a no-op teardown so callers can unconditionally
|
||||
// unsubscribe without null-checks.
|
||||
return { unsubscribe: () => {} }
|
||||
}
|
||||
|
||||
// Why: seed the offset at 0 so the FIRST drain re-reads the whole file. This
|
||||
// closes the read/subscribe race — a turn appended between the caller's
|
||||
// readSession EOF and the watcher install is still emitted. Re-emitted lines
|
||||
// collapse by deterministic id in the assembler (no dup, no drop). Subsequent
|
||||
// drains use the incremental offset so the full re-read happens only once.
|
||||
let offset = 0
|
||||
let closed = false
|
||||
let reading = false
|
||||
let pendingReadRequested = false
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
async function drain(): Promise<void> {
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
if (reading) {
|
||||
// A read is already in flight; mark that another pass is needed so rapid
|
||||
// successive appends coalesce without dropping the trailing one.
|
||||
pendingReadRequested = true
|
||||
return
|
||||
}
|
||||
reading = true
|
||||
try {
|
||||
do {
|
||||
pendingReadRequested = false
|
||||
try {
|
||||
const currentSize = await fileSize(filePath!)
|
||||
if (currentSize < offset) {
|
||||
// Rotation/replacement/truncation: re-read from the top.
|
||||
offset = 0
|
||||
}
|
||||
const { messages, consumedTo } = await readAppendedMessages(filePath!, offset, decode!)
|
||||
offset = consumedTo
|
||||
if (!closed && messages.length > 0) {
|
||||
onAppend(messages)
|
||||
}
|
||||
} catch {
|
||||
// Why: a transient read failure (EACCES/EIO/ENOENT during rotation)
|
||||
// must not leave the subscription permanently deaf. Stop this drain;
|
||||
// the finally resets `reading` so a later fs event re-arms the read.
|
||||
break
|
||||
}
|
||||
} while (pendingReadRequested && !closed)
|
||||
} finally {
|
||||
reading = false
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleDrain(): void {
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer)
|
||||
}
|
||||
debounceTimer = setTimeout(() => {
|
||||
debounceTimer = null
|
||||
void drain()
|
||||
}, debounceMs ?? DEFAULT_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
let watcher: FSWatcher
|
||||
try {
|
||||
watcher = watch(filePath, scheduleDrain)
|
||||
} catch {
|
||||
// File vanished between resolve and watch — return a no-op teardown.
|
||||
return { unsubscribe: () => {} }
|
||||
}
|
||||
activeWatcherCount++
|
||||
|
||||
// Why: on some platforms fs.watch can miss the very first append that lands
|
||||
// between offset-seed and watcher install. Kick one debounced drain so a
|
||||
// turn written immediately after subscribe is still picked up.
|
||||
scheduleDrain()
|
||||
|
||||
return {
|
||||
unsubscribe: () => {
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
closed = true
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = null
|
||||
}
|
||||
watcher.close()
|
||||
activeWatcherCount--
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8621,3 +8621,85 @@ describe('Store host-partitioned workspace sessions', () => {
|
||||
expect(store.getWorkspaceSession('runtime:bad').activeRepoId).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Store native-chat tab viewMode persistence', () => {
|
||||
beforeEach(() => {
|
||||
testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(testState.dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
// Why: a tab persisted in 'chat' must restore to 'chat' (R1), and a tab
|
||||
// persisted before the field existed must default to 'terminal' — i.e. the
|
||||
// field is absent on restore — so older sessions stay backward-compatible.
|
||||
it('round-trips viewMode for unified tabs and defaults legacy tabs to terminal', async () => {
|
||||
const WORKTREE = 'repo1::/worktree'
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
repos: [makeRepo()],
|
||||
worktreeMeta: {},
|
||||
settings: {},
|
||||
ui: {},
|
||||
githubCache: { pr: {}, issue: {} },
|
||||
workspaceSession: {
|
||||
activeRepoId: 'r1',
|
||||
activeWorktreeId: WORKTREE,
|
||||
activeTabId: 'chat-tab',
|
||||
tabsByWorktree: {},
|
||||
terminalLayoutsByTabId: {},
|
||||
sleepingAgentSessionsByPaneKey: {},
|
||||
unifiedTabs: {
|
||||
[WORKTREE]: [
|
||||
{
|
||||
id: 'chat-tab',
|
||||
entityId: 'chat-tab',
|
||||
groupId: 'g1',
|
||||
worktreeId: WORKTREE,
|
||||
contentType: 'terminal',
|
||||
label: 'Agent',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1,
|
||||
viewMode: 'chat'
|
||||
},
|
||||
{
|
||||
// Legacy tab persisted before viewMode existed — no field at all.
|
||||
id: 'legacy-tab',
|
||||
entityId: 'legacy-tab',
|
||||
groupId: 'g1',
|
||||
worktreeId: WORKTREE,
|
||||
contentType: 'terminal',
|
||||
label: 'Legacy',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 1,
|
||||
createdAt: 2
|
||||
}
|
||||
]
|
||||
},
|
||||
tabGroups: {
|
||||
[WORKTREE]: [
|
||||
{
|
||||
id: 'g1',
|
||||
worktreeId: WORKTREE,
|
||||
activeTabId: 'chat-tab',
|
||||
tabOrder: ['chat-tab', 'legacy-tab']
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const store = await createStore()
|
||||
const restored = store.getWorkspaceSession().unifiedTabs?.[WORKTREE] ?? []
|
||||
const chatTab = restored.find((tab) => tab.id === 'chat-tab')
|
||||
const legacyTab = restored.find((tab) => tab.id === 'legacy-tab')
|
||||
|
||||
expect(chatTab?.viewMode).toBe('chat')
|
||||
// Missing on a legacy tab; renderer hydration treats absent as 'terminal'.
|
||||
expect(legacyTab?.viewMode).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11111,7 +11111,12 @@ describe('OrcaRuntimeService', () => {
|
||||
title: 'claude agents'
|
||||
})
|
||||
)
|
||||
expect(result.tabs[0]).not.toHaveProperty('agentStatus')
|
||||
// The stale "working" status is suppressed (no spinner), but agent identity
|
||||
// is retained so native chat can still address the idle agent's transcript.
|
||||
const suppressed = result.tabs[0]
|
||||
expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.state).toBe('done')
|
||||
expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.agentType).toBe('claude')
|
||||
expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.terminalTitle).toBeUndefined()
|
||||
})
|
||||
|
||||
it('suppresses saved mobile agent status when the current terminal title is neutral', async () => {
|
||||
@@ -11178,7 +11183,11 @@ describe('OrcaRuntimeService', () => {
|
||||
title: 'bash'
|
||||
})
|
||||
)
|
||||
expect(result.tabs[0]).not.toHaveProperty('agentStatus')
|
||||
// Stale "working" suppressed; agent identity retained for native chat.
|
||||
const suppressed = result.tabs[0]
|
||||
expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.state).toBe('done')
|
||||
expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.agentType).toBe('claude')
|
||||
expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.terminalTitle).toBeUndefined()
|
||||
})
|
||||
|
||||
it('suppresses saved mobile agent status when fresh live OSC title is Claude agents', async () => {
|
||||
@@ -11251,7 +11260,11 @@ describe('OrcaRuntimeService', () => {
|
||||
title: 'claude agents'
|
||||
})
|
||||
)
|
||||
expect(result.tabs[0]).not.toHaveProperty('agentStatus')
|
||||
// Stale "working" suppressed; agent identity retained for native chat.
|
||||
const suppressed = result.tabs[0]
|
||||
expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.state).toBe('done')
|
||||
expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.agentType).toBe('claude')
|
||||
expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.terminalTitle).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps saved PTY bindings pending until the runtime knows the PTY is connected', async () => {
|
||||
@@ -12793,7 +12806,11 @@ describe('OrcaRuntimeService', () => {
|
||||
title: 'claude agents'
|
||||
})
|
||||
)
|
||||
expect(result.tabs[0]).not.toHaveProperty('agentStatus')
|
||||
// Stale "working" suppressed; agent identity retained for native chat.
|
||||
const suppressed = result.tabs[0]
|
||||
expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.state).toBe('done')
|
||||
expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.agentType).toBe('claude')
|
||||
expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.terminalTitle).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses fresh neutral PTY titles over stale mobile snapshot and OSC titles', async () => {
|
||||
@@ -12864,7 +12881,11 @@ describe('OrcaRuntimeService', () => {
|
||||
title: 'zsh'
|
||||
})
|
||||
)
|
||||
expect(result.tabs[0]).not.toHaveProperty('agentStatus')
|
||||
// Stale "working" suppressed; agent identity retained for native chat.
|
||||
const suppressed = result.tabs[0]
|
||||
expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.state).toBe('done')
|
||||
expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.agentType).toBe('claude')
|
||||
expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.terminalTitle).toBeUndefined()
|
||||
})
|
||||
|
||||
it('pushes PTY-backed mobile session readiness changes when a server PTY exits', async () => {
|
||||
@@ -13391,6 +13412,36 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(clearedSurface?.type === 'browser' && clearedSurface.isPinned).toBe(false)
|
||||
})
|
||||
|
||||
it('persists headless tab viewMode and surfaces it through a cold rehydrate', async () => {
|
||||
const session = makeWorkspaceSessionWithHeadlessTerminal()
|
||||
const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session)
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
|
||||
await runtime.setMobileSessionTabProps(`id:${TEST_WORKTREE_ID}`, {
|
||||
tabId: 'host-tab',
|
||||
viewMode: 'chat'
|
||||
})
|
||||
|
||||
const persisted = getSession().tabsByWorktree[TEST_WORKTREE_ID]!.find(
|
||||
(tab) => tab.id === 'host-tab'
|
||||
)!
|
||||
expect(persisted.viewMode).toBe('chat')
|
||||
|
||||
const live = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
|
||||
const liveSurface = live.tabs.find(
|
||||
(tab) => tab.type === 'terminal' && tab.parentTabId === 'host-tab'
|
||||
)
|
||||
expect(liveSurface?.type === 'terminal' && liveSurface.viewMode).toBe('chat')
|
||||
|
||||
runtime['mobileSessionTabsByWorktree'].delete(TEST_WORKTREE_ID)
|
||||
runtime['hydrateHeadlessMobileSessionTabsFromWorkspaceSession'](TEST_WORKTREE_ID)
|
||||
const rehydrated = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
|
||||
const surface = rehydrated.tabs.find(
|
||||
(tab) => tab.type === 'terminal' && tab.parentTabId === 'host-tab'
|
||||
)
|
||||
expect(surface?.type === 'terminal' && surface.viewMode).toBe('chat')
|
||||
})
|
||||
|
||||
it('still persists tab props in serve mode after syncWindowGraph(0) (gate does not fire)', async () => {
|
||||
// Why: the renderer-authoritative gate uses getAvailableAuthoritativeWindow,
|
||||
// and serve startup calls syncWindowGraph(0,...) which sets authoritativeWindowId=0.
|
||||
|
||||
@@ -3293,6 +3293,7 @@ export class OrcaRuntimeService {
|
||||
...(layout ? { parentLayout: this.cloneTerminalLayoutSnapshot(layout) } : {}),
|
||||
...(tab.color != null ? { color: tab.color } : {}),
|
||||
...(tab.isPinned ? { isPinned: true } : {}),
|
||||
...(tab.viewMode ? { viewMode: tab.viewMode } : {}),
|
||||
isActive: this.isPersistedTerminalLeafActive(worktreeId, tab.id, leafId, layout)
|
||||
}
|
||||
})
|
||||
@@ -4208,7 +4209,12 @@ export class OrcaRuntimeService {
|
||||
// was never persisted. Persist to the workspace session + live snapshot.
|
||||
async setMobileSessionTabProps(
|
||||
worktreeSelector: string,
|
||||
args: { tabId: string; color?: string | null; isPinned?: boolean }
|
||||
args: {
|
||||
tabId: string
|
||||
color?: string | null
|
||||
isPinned?: boolean
|
||||
viewMode?: 'terminal' | 'chat'
|
||||
}
|
||||
): Promise<{ updated: true }> {
|
||||
const explicitWorktreeId = getExplicitWorktreeIdSelector(worktreeSelector)
|
||||
const worktreeId =
|
||||
@@ -4230,7 +4236,7 @@ export class OrcaRuntimeService {
|
||||
private persistHeadlessSessionTabProps(
|
||||
worktreeId: string,
|
||||
tabId: string,
|
||||
props: { color?: string | null; isPinned?: boolean }
|
||||
props: { color?: string | null; isPinned?: boolean; viewMode?: 'terminal' | 'chat' }
|
||||
): void {
|
||||
const session = this.store?.getWorkspaceSession?.()
|
||||
if (!session || !this.store?.setWorkspaceSession) {
|
||||
@@ -4248,7 +4254,8 @@ export class OrcaRuntimeService {
|
||||
? {
|
||||
...tab,
|
||||
...(props.color !== undefined ? { color: props.color } : {}),
|
||||
...(props.isPinned !== undefined ? { isPinned: props.isPinned } : {})
|
||||
...(props.isPinned !== undefined ? { isPinned: props.isPinned } : {}),
|
||||
...(props.viewMode !== undefined ? { viewMode: props.viewMode } : {})
|
||||
}
|
||||
: tab
|
||||
)
|
||||
@@ -4281,7 +4288,7 @@ export class OrcaRuntimeService {
|
||||
private applyHeadlessSessionTabPropsToSnapshot(
|
||||
worktreeId: string,
|
||||
tabId: string,
|
||||
props: { color?: string | null; isPinned?: boolean }
|
||||
props: { color?: string | null; isPinned?: boolean; viewMode?: 'terminal' | 'chat' }
|
||||
): void {
|
||||
const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId)
|
||||
if (!snapshot) {
|
||||
@@ -4296,7 +4303,8 @@ export class OrcaRuntimeService {
|
||||
return {
|
||||
...tab,
|
||||
...(props.color !== undefined ? { color: props.color } : {}),
|
||||
...(props.isPinned !== undefined ? { isPinned: props.isPinned } : {})
|
||||
...(props.isPinned !== undefined ? { isPinned: props.isPinned } : {}),
|
||||
...(props.viewMode !== undefined ? { viewMode: props.viewMode } : {})
|
||||
}
|
||||
})
|
||||
if (!changed) {
|
||||
@@ -17818,10 +17826,41 @@ export class OrcaRuntimeService {
|
||||
const title = leafTitle ?? ptyTitle ?? syncedTab?.title ?? tab.title
|
||||
const liveTitleEvidence = leafTitle ?? ptyTitle
|
||||
const liveTitleEvidenceClassification = classifyAgentTitle(liveTitleEvidence)
|
||||
const agentStatus =
|
||||
// Why: keep the rich hook-driven status when the agent has a live
|
||||
// interactive prompt or an active tool — those are authoritative agent
|
||||
// activity even if the terminal's title isn't agent-classified (e.g. it
|
||||
// shows a task/branch name). Otherwise the mobile/web client falls back to
|
||||
// the OSC-title-only status and never sees interactivePrompt (the question
|
||||
// card never renders).
|
||||
const hasLiveAgentSignal =
|
||||
tab.agentStatus?.interactivePrompt != null || tab.agentStatus?.toolName != null
|
||||
const keepFullAgentStatus =
|
||||
tab.agentStatus &&
|
||||
(liveTitleEvidence === null || liveTitleEvidenceClassification === 'agent')
|
||||
? { agentStatus: tab.agentStatus }
|
||||
(liveTitleEvidence === null ||
|
||||
liveTitleEvidenceClassification === 'agent' ||
|
||||
hasLiveAgentSignal)
|
||||
const agentStatus = keepFullAgentStatus
|
||||
? { agentStatus: tab.agentStatus }
|
||||
: // Why: when live title evidence says the pane is idle (e.g. the Claude
|
||||
// agents picker or a neutral shell title), suppress the stale "working"
|
||||
// state so the client shows no spinner — but retain agent identity
|
||||
// (agentType + providerSession) so native chat can still address an
|
||||
// idle agent's transcript. Reset the transient state to 'done'.
|
||||
tab.agentStatus?.agentType != null
|
||||
? {
|
||||
agentStatus: {
|
||||
state: 'done' as const,
|
||||
prompt: '',
|
||||
updatedAt: tab.agentStatus.updatedAt,
|
||||
stateStartedAt: tab.agentStatus.stateStartedAt,
|
||||
paneKey: tab.agentStatus.paneKey,
|
||||
stateHistory: [],
|
||||
agentType: tab.agentStatus.agentType,
|
||||
...(tab.agentStatus.providerSession
|
||||
? { providerSession: tab.agentStatus.providerSession }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
: null
|
||||
// Why: web/mobile clients hold these handles across renderer graph syncs;
|
||||
// leaf handles are graph-epoch-bound, but PTY handles remain streamable.
|
||||
@@ -17849,6 +17888,7 @@ export class OrcaRuntimeService {
|
||||
...(tab.parentLayout ? { parentLayout: tab.parentLayout } : {}),
|
||||
...(tab.color != null ? { color: tab.color } : {}),
|
||||
...(tab.isPinned ? { isPinned: true } : {}),
|
||||
...(tab.viewMode ? { viewMode: tab.viewMode } : {}),
|
||||
isActive: tab.isActive,
|
||||
...(terminalHandle
|
||||
? { status: 'ready' as const, terminal: terminalHandle }
|
||||
|
||||
@@ -59,6 +59,11 @@ export type RpcContext = {
|
||||
// Why: WebSocket RPCs authenticate by mobile device token. State-owning
|
||||
// handlers use this to clean up when that paired device disconnects.
|
||||
clientId?: string
|
||||
// Why: payload windowing/truncation tuned for the constrained mobile payload
|
||||
// (e.g. native-chat block char cap) must not clip full-screen web/desktop
|
||||
// clients. Carries the paired device's scope so handlers can gate the diet to
|
||||
// phones only. Undefined for in-process callers → treat as full-class (no clip).
|
||||
clientKind?: 'mobile' | 'runtime'
|
||||
// Why: mobile terminal traffic is byte-oriented and bypasses JSON streaming
|
||||
// responses after the binary terminal cutover. Undefined on Unix/socket
|
||||
// transports and non-E2EE WebSocket paths.
|
||||
|
||||
@@ -93,6 +93,7 @@ export class RpcDispatcher {
|
||||
connectionId?: string
|
||||
signal?: AbortSignal
|
||||
clientId?: string
|
||||
clientKind?: 'mobile' | 'runtime'
|
||||
sendBinary?: (bytes: Uint8Array<ArrayBufferLike>) => void
|
||||
registerBinaryStreamHandler?: (
|
||||
streamId: number,
|
||||
@@ -125,6 +126,7 @@ export class RpcDispatcher {
|
||||
requestId: request.id,
|
||||
connectionId: options?.connectionId,
|
||||
clientId: options?.clientId,
|
||||
clientKind: options?.clientKind,
|
||||
sendBinary: options?.sendBinary,
|
||||
registerBinaryStreamHandler: options?.registerBinaryStreamHandler
|
||||
})
|
||||
@@ -158,6 +160,7 @@ export class RpcDispatcher {
|
||||
requestId: request.id,
|
||||
connectionId: options?.connectionId,
|
||||
clientId: options?.clientId,
|
||||
clientKind: options?.clientKind,
|
||||
sendBinary: options?.sendBinary,
|
||||
registerBinaryStreamHandler: options?.registerBinaryStreamHandler
|
||||
},
|
||||
|
||||
@@ -15,6 +15,7 @@ import { ACCOUNT_METHODS } from './accounts'
|
||||
import { PREFLIGHT_METHODS } from './preflight'
|
||||
import { COMPUTER_METHODS } from './computer'
|
||||
import { SESSION_TAB_METHODS } from './session-tabs'
|
||||
import { NATIVE_CHAT_METHODS } from './native-chat'
|
||||
import { FILE_METHODS } from './files'
|
||||
import { GIT_METHODS } from './git'
|
||||
import { GITHUB_METHODS } from './github'
|
||||
@@ -53,6 +54,7 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [
|
||||
...PREFLIGHT_METHODS,
|
||||
...COMPUTER_METHODS,
|
||||
...SESSION_TAB_METHODS,
|
||||
...NATIVE_CHAT_METHODS,
|
||||
...FILE_METHODS,
|
||||
...GIT_METHODS,
|
||||
...GITHUB_METHODS,
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import type { RpcContext } from '../core'
|
||||
|
||||
// Stub the shared cache so the handler returns a deterministic transcript with
|
||||
// one oversized tool-result block; the test then asserts clip behavior per client.
|
||||
const OVERSIZED = 'x'.repeat(5000)
|
||||
const cachedResult = vi.hoisted(() => ({ value: { messages: [] as NativeChatMessage[] } }))
|
||||
vi.mock('../../../native-chat/transcript-read-cache', () => ({
|
||||
readNativeChatTranscriptCached: () => Promise.resolve(cachedResult.value)
|
||||
}))
|
||||
|
||||
import { NATIVE_CHAT_METHODS } from './native-chat'
|
||||
|
||||
function makeMessage(text: string): NativeChatMessage {
|
||||
return {
|
||||
id: 'a-1',
|
||||
role: 'assistant',
|
||||
timestamp: 1_717_236_000_000,
|
||||
source: 'transcript',
|
||||
blocks: [{ type: 'tool-result', output: text, isError: false }]
|
||||
}
|
||||
}
|
||||
|
||||
function readSessionHandler(): (params: unknown, ctx: RpcContext) => Promise<unknown> {
|
||||
const method = NATIVE_CHAT_METHODS.find((m) => m.name === 'nativeChat.readSession')
|
||||
if (!method) {
|
||||
throw new Error('readSession method not registered')
|
||||
}
|
||||
return method.handler as (params: unknown, ctx: RpcContext) => Promise<unknown>
|
||||
}
|
||||
|
||||
function ctxWith(clientKind: RpcContext['clientKind']): RpcContext {
|
||||
return { runtime: {} as RpcContext['runtime'], clientKind }
|
||||
}
|
||||
|
||||
function firstOutput(result: unknown): string {
|
||||
const messages = (result as { messages: NativeChatMessage[] }).messages
|
||||
const block = messages[0].blocks[0] as { output: string }
|
||||
return block.output
|
||||
}
|
||||
|
||||
describe('nativeChat.readSession clientKind truncation gating', () => {
|
||||
it('clips oversized tool output for mobile clients', async () => {
|
||||
cachedResult.value = { messages: [makeMessage(OVERSIZED)] }
|
||||
const result = await readSessionHandler()(
|
||||
{ agent: 'claude', sessionId: 's' },
|
||||
ctxWith('mobile')
|
||||
)
|
||||
const output = firstOutput(result)
|
||||
expect(output.length).toBeLessThan(OVERSIZED.length)
|
||||
expect(output).toContain('truncated')
|
||||
})
|
||||
|
||||
it('passes oversized tool output through intact for runtime (web/desktop) clients', async () => {
|
||||
cachedResult.value = { messages: [makeMessage(OVERSIZED)] }
|
||||
const result = await readSessionHandler()(
|
||||
{ agent: 'claude', sessionId: 's' },
|
||||
ctxWith('runtime')
|
||||
)
|
||||
expect(firstOutput(result)).toBe(OVERSIZED)
|
||||
})
|
||||
|
||||
it('defaults to no clip when clientKind is undefined (in-process callers)', async () => {
|
||||
cachedResult.value = { messages: [makeMessage(OVERSIZED)] }
|
||||
const result = await readSessionHandler()(
|
||||
{ agent: 'claude', sessionId: 's' },
|
||||
ctxWith(undefined)
|
||||
)
|
||||
expect(firstOutput(result)).toBe(OVERSIZED)
|
||||
})
|
||||
|
||||
it('windows by count for all client kinds', async () => {
|
||||
const many = Array.from({ length: 60 }, (_unused, n) => {
|
||||
const message = makeMessage('small')
|
||||
return { ...message, id: `m-${n}` }
|
||||
})
|
||||
cachedResult.value = { messages: many }
|
||||
const result = await readSessionHandler()(
|
||||
{ agent: 'claude', sessionId: 's', limit: 40 },
|
||||
ctxWith('runtime')
|
||||
)
|
||||
const messages = (result as { messages: NativeChatMessage[] }).messages
|
||||
expect(messages).toHaveLength(40)
|
||||
// Tail-only: the last id survives, the first is dropped.
|
||||
expect(messages.at(-1)?.id).toBe('m-59')
|
||||
expect(messages[0].id).toBe('m-20')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,180 @@
|
||||
import { z } from 'zod'
|
||||
import type { NativeChatBlock, NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import type { AgentType } from '../../../../shared/native-chat-types'
|
||||
import { readNativeChatTranscriptCached } from '../../../native-chat/transcript-read-cache'
|
||||
import { subscribeNativeChatTranscript } from '../../../native-chat/transcript-watch'
|
||||
import { defineMethod, defineStreamingMethod, type RpcAnyMethod, type RpcContext } from '../core'
|
||||
|
||||
// Why: native chat renders an agent's own transcript (Claude/Codex JSONL). The
|
||||
// desktop reaches the readers via Electron IPC; mobile/web clients reach the
|
||||
// same pure readers through these runtime RPC methods so the native chat view
|
||||
// works over the paired connection, not just in the desktop renderer.
|
||||
|
||||
const NativeChatSession = z.object({
|
||||
agent: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
.pipe(z.string().min(1, 'Missing agent'))
|
||||
.transform((v) => v as AgentType),
|
||||
sessionId: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
.pipe(z.string().min(1, 'Missing session id')),
|
||||
// How many of the most-recent messages to return. Clients start small for a
|
||||
// fast first paint and raise it to page older history in as the user scrolls.
|
||||
limit: z.number().int().positive().max(2000).optional(),
|
||||
// Optional client-supplied cleanup token. When present, the subscribe handler
|
||||
// keys the fs-watcher cleanup under it so registration and unsubscribe derive
|
||||
// from the SAME token (back-compat: falls back to `agent:sessionId` when absent,
|
||||
// which is exactly what existing mobile clients rely on).
|
||||
subscriptionId: z.string().min(1).optional(),
|
||||
// Authoritative transcript path from the agent hook (providerSession), used to
|
||||
// locate the file directly when the session id no longer names it (recent
|
||||
// Claude Code). Optional for back-compat with older clients.
|
||||
transcriptPath: z.string().min(1).optional()
|
||||
})
|
||||
|
||||
const NativeChatUnsubscribe = z.object({
|
||||
subscriptionId: z.string().min(1).optional()
|
||||
})
|
||||
|
||||
// Why: a long agent session can hold thousands of turns (with full tool I/O).
|
||||
// Shipping all of them over the paired connection and rendering them at once
|
||||
// freezes the mobile app, so the runtime RPC windows to the most recent slice —
|
||||
// the conversation tail is what the chat view shows first. The desktop IPC path
|
||||
// is unaffected (it reads locally with a virtualized list).
|
||||
// Small first page for a fast initial paint; the client raises `limit` to load
|
||||
// older history as the user scrolls back.
|
||||
const MOBILE_NATIVE_CHAT_DEFAULT_WINDOW = 40
|
||||
const MOBILE_NATIVE_CHAT_MAX_WINDOW = 2000
|
||||
// Why: a single tool result (a big file read, a long diff) can be hundreds of KB.
|
||||
// The mobile view only previews block bodies, so truncate them on the wire to
|
||||
// keep the payload small; the marker tells the user content was clipped.
|
||||
const MOBILE_BLOCK_CHAR_CAP = 4000
|
||||
const TRUNCATION_MARKER = '\n… (truncated)'
|
||||
|
||||
function clip(text: string): string {
|
||||
return text.length > MOBILE_BLOCK_CHAR_CAP
|
||||
? text.slice(0, MOBILE_BLOCK_CHAR_CAP) + TRUNCATION_MARKER
|
||||
: text
|
||||
}
|
||||
|
||||
function clipBlock(block: NativeChatBlock): NativeChatBlock {
|
||||
if (block.type === 'text') {
|
||||
return block.text.length > MOBILE_BLOCK_CHAR_CAP ? { ...block, text: clip(block.text) } : block
|
||||
}
|
||||
if (block.type === 'tool-result') {
|
||||
return block.output.length > MOBILE_BLOCK_CHAR_CAP
|
||||
? { ...block, output: clip(block.output) }
|
||||
: block
|
||||
}
|
||||
return block
|
||||
}
|
||||
|
||||
function sanitizeMessage(message: NativeChatMessage): NativeChatMessage {
|
||||
return { ...message, blocks: message.blocks.map(clipBlock) }
|
||||
}
|
||||
|
||||
/** Window a transcript to its most recent `limit` messages so a long session
|
||||
* can't freeze the client. Windowing by count applies to ALL RPC clients —
|
||||
* shipping thousands of turns over the paired link is bad for web and mobile
|
||||
* alike. Char-clipping (the mobile-only payload diet) is applied separately. */
|
||||
function windowTranscript(
|
||||
messages: readonly NativeChatMessage[],
|
||||
limit = MOBILE_NATIVE_CHAT_DEFAULT_WINDOW
|
||||
): NativeChatMessage[] {
|
||||
const window = Math.min(Math.max(limit, 1), MOBILE_NATIVE_CHAT_MAX_WINDOW)
|
||||
return messages.length > window ? messages.slice(-window) : messages.slice()
|
||||
}
|
||||
|
||||
/** Apply the windowed slice plus, for `mobile` clients only, oversized-block
|
||||
* char truncation. Web/desktop (`runtime`, or undefined for in-process callers)
|
||||
* are full-class surfaces and pass block bodies through untruncated — matching
|
||||
* the desktop IPC path, which never clips. */
|
||||
function windowForClient(
|
||||
messages: readonly NativeChatMessage[],
|
||||
clientKind: RpcContext['clientKind'],
|
||||
limit = MOBILE_NATIVE_CHAT_DEFAULT_WINDOW
|
||||
): NativeChatMessage[] {
|
||||
const windowed = windowTranscript(messages, limit)
|
||||
return clientKind === 'mobile' ? windowed.map(sanitizeMessage) : windowed
|
||||
}
|
||||
|
||||
export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [
|
||||
defineMethod({
|
||||
name: 'nativeChat.readSession',
|
||||
params: NativeChatSession,
|
||||
handler: async (params, { clientKind }) => {
|
||||
const result = await readNativeChatTranscriptCached(
|
||||
params.agent,
|
||||
params.sessionId,
|
||||
params.transcriptPath
|
||||
)
|
||||
// Window to the conversation tail (all clients); clip blocks for mobile only.
|
||||
return 'messages' in result
|
||||
? { messages: windowForClient(result.messages, clientKind, params.limit) }
|
||||
: result
|
||||
}
|
||||
}),
|
||||
defineStreamingMethod({
|
||||
name: 'nativeChat.subscribe',
|
||||
params: NativeChatSession,
|
||||
handler: async (params, { runtime, connectionId, clientKind }, emit) => {
|
||||
let closed = false
|
||||
let unsubscribe = (): void => {}
|
||||
// Why: the subscriber seeds its read offset at 0, so the first drain emits
|
||||
// the whole transcript and later drains emit only appended turns. The first
|
||||
// batch is windowed to the tail (a full transcript would freeze mobile);
|
||||
// later incremental batches are smaller than the window so they pass through.
|
||||
// Clients merge by message id, so the initial windowed batch doubles as the
|
||||
// snapshot. Keyed by the client-supplied subscriptionId when present so
|
||||
// registration and unsubscribe derive from the same token; otherwise by
|
||||
// agent:sessionId, which is exactly the token existing mobile clients send to
|
||||
// unsubscribe (no wire break).
|
||||
const cleanupToken = params.subscriptionId ?? `${params.agent}:${params.sessionId}`
|
||||
const subscriptionId = `nativeChat:${connectionId ?? 'local'}:${cleanupToken}`
|
||||
runtime.registerSubscriptionCleanup(
|
||||
subscriptionId,
|
||||
() => {
|
||||
closed = true
|
||||
unsubscribe()
|
||||
emit({ type: 'end' })
|
||||
},
|
||||
connectionId
|
||||
)
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
const subscription = await subscribeNativeChatTranscript({
|
||||
agent: params.agent,
|
||||
sessionId: params.sessionId,
|
||||
transcriptPath: params.transcriptPath,
|
||||
onAppend: (messages) => {
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
emit({ type: 'appended', messages: windowForClient(messages, clientKind) })
|
||||
}
|
||||
})
|
||||
// The connection may have closed while the file was being resolved.
|
||||
if (closed) {
|
||||
subscription.unsubscribe()
|
||||
return
|
||||
}
|
||||
unsubscribe = subscription.unsubscribe
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'nativeChat.unsubscribe',
|
||||
params: NativeChatUnsubscribe,
|
||||
handler: async (params, { runtime, connectionId }) => {
|
||||
const connection = connectionId ?? 'local'
|
||||
if (params.subscriptionId) {
|
||||
runtime.cleanupSubscription(`nativeChat:${connection}:${params.subscriptionId}`)
|
||||
return { unsubscribed: true }
|
||||
}
|
||||
runtime.cleanupSubscriptionsByPrefix(`nativeChat:${connection}:`)
|
||||
return { unsubscribed: true }
|
||||
}
|
||||
})
|
||||
]
|
||||
@@ -104,7 +104,9 @@ export const SetTabProps = WorktreeTabSelector.extend({
|
||||
.pipe(z.string().min(1, 'Missing tab id')),
|
||||
// undefined = leave unchanged; null = clear color / unset.
|
||||
color: z.string().max(64).nullable().optional(),
|
||||
isPinned: z.boolean().optional()
|
||||
isPinned: z.boolean().optional(),
|
||||
// undefined = leave unchanged; no "clear" semantic (absence means default 'terminal').
|
||||
viewMode: z.enum(['terminal', 'chat']).optional()
|
||||
})
|
||||
|
||||
export const CreateTerminalTab = WorktreeTabSelector.extend({
|
||||
|
||||
@@ -103,7 +103,8 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
|
||||
runtime.setMobileSessionTabProps(params.worktree, {
|
||||
tabId: params.tabId,
|
||||
...(params.color !== undefined ? { color: params.color } : {}),
|
||||
...(params.isPinned !== undefined ? { isPinned: params.isPinned } : {})
|
||||
...(params.isPinned !== undefined ? { isPinned: params.isPinned } : {}),
|
||||
...(params.viewMode !== undefined ? { viewMode: params.viewMode } : {})
|
||||
})
|
||||
}),
|
||||
defineStreamingMethod({
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import { z } from 'zod'
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
import { discoverSkills } from '../../../skills/discovery'
|
||||
|
||||
const SkillDiscoveryParams = z.object({
|
||||
cwd: z.string().optional().nullable()
|
||||
})
|
||||
|
||||
export const SKILL_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'skills.discover',
|
||||
params: null,
|
||||
handler: async (_params, { runtime }) => discoverSkills({ repos: runtime.listRepos() })
|
||||
params: SkillDiscoveryParams,
|
||||
handler: async (params, { runtime }) => {
|
||||
const cwd = params.cwd?.trim() || undefined
|
||||
return cwd
|
||||
? discoverSkills({ repos: [], cwd })
|
||||
: discoverSkills({ repos: runtime.listRepos() })
|
||||
}
|
||||
})
|
||||
]
|
||||
|
||||
@@ -297,6 +297,9 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
|
||||
'session.tabs.subscribeAll',
|
||||
'session.tabs.unsubscribe',
|
||||
'session.tabs.unsubscribeAll',
|
||||
'nativeChat.readSession',
|
||||
'nativeChat.subscribe',
|
||||
'nativeChat.unsubscribe',
|
||||
'settings.get',
|
||||
'settings.update',
|
||||
'ssh.connect',
|
||||
@@ -971,6 +974,9 @@ export class OrcaRuntimeRpcServer {
|
||||
await this.dispatcher.dispatchStreaming(request, reply, {
|
||||
connectionId,
|
||||
clientId: token,
|
||||
// Why: gates the mobile-only payload diet (native-chat char clipping) so
|
||||
// full-screen web/desktop runtime clients aren't truncated.
|
||||
clientKind: device.scope,
|
||||
signal: abortRegistration?.signal,
|
||||
sendBinary,
|
||||
registerBinaryStreamHandler: (streamId, handler) =>
|
||||
|
||||
@@ -77,6 +77,33 @@ describe('skill discovery', () => {
|
||||
expect(skill?.directoryPath).toBe(linkedSkill)
|
||||
})
|
||||
|
||||
it('discovers worktree .agents skill symlinks from the requested cwd', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-skills-'))
|
||||
const home = join(root, 'home')
|
||||
const worktree = join(root, 'worktree')
|
||||
const realSkill = join(root, 'central-skills', 'ref-oss')
|
||||
const linkedSkill = join(worktree, '.agents', 'skills', 'ref-oss')
|
||||
await mkdir(realSkill, { recursive: true })
|
||||
await mkdir(join(worktree, '.agents', 'skills'), { recursive: true })
|
||||
await writeFile(join(realSkill, 'SKILL.md'), '# ref-oss\n\nUse local OSS reference repos.')
|
||||
await symlink(realSkill, linkedSkill, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
|
||||
const result = await discoverSkills({
|
||||
homeDir: home,
|
||||
cwd: worktree,
|
||||
repos: []
|
||||
})
|
||||
|
||||
expect(result.skills.filter((entry) => entry.name === 'ref-oss')).toMatchObject([
|
||||
{
|
||||
sourceKind: 'repo',
|
||||
sourceLabel: 'Repo worktree .agents',
|
||||
directoryPath: linkedSkill,
|
||||
providers: ['agent-skills']
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps home classification when cwd points at the same directory as home', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-skills-'))
|
||||
const home = join(root, 'home')
|
||||
|
||||
@@ -92,6 +92,18 @@ function nativeZoomCommandMatchesKeybindings(
|
||||
)
|
||||
}
|
||||
|
||||
function isMacAppPasteInput(input: Electron.Input): boolean {
|
||||
return (
|
||||
process.platform === 'darwin' &&
|
||||
input.type === 'keyDown' &&
|
||||
input.meta &&
|
||||
!input.control &&
|
||||
!input.alt &&
|
||||
!input.shift &&
|
||||
(input.code === 'KeyV' || input.key.toLowerCase() === 'v')
|
||||
)
|
||||
}
|
||||
|
||||
// Why: the titlebar is 36px (border-box, 1px border-bottom). The visual
|
||||
// center of the CSS-centered content sits at ~18 CSS px from the top.
|
||||
// At zoom factor z that becomes 18·z window px. Traffic lights are
|
||||
@@ -844,6 +856,14 @@ export function createMainWindow(
|
||||
return
|
||||
}
|
||||
|
||||
if (isMacAppPasteInput(input)) {
|
||||
// Why: native chat/terminal panes can own focus without being native
|
||||
// editable controls, so route Cmd+V through Orca's paste ownership first.
|
||||
event.preventDefault()
|
||||
mainWindow.webContents.send('ui:appMenuPaste')
|
||||
return
|
||||
}
|
||||
|
||||
const keybindings = opts?.getKeybindings?.()
|
||||
const terminalShortcutContext: KeybindingMatchOptions = {
|
||||
context: terminalInputFocused || floatingTerminalInputFocused ? 'terminal' : 'app',
|
||||
|
||||
@@ -362,6 +362,7 @@ import type {
|
||||
OpenCodeUsageSummary
|
||||
} from '../shared/opencode-usage-types'
|
||||
import type { AiVaultListArgs, AiVaultListResult } from '../shared/ai-vault-types'
|
||||
import type { AgentType, NativeChatMessage } from '../shared/native-chat-types'
|
||||
import type { TelemetryConsentState } from '../shared/telemetry-consent-types'
|
||||
import type { AgentKind, LaunchSource, RequestKind } from '../shared/telemetry-events'
|
||||
import type { AppStarSource } from '../shared/gh-star-source'
|
||||
@@ -720,6 +721,46 @@ export type AiVaultApi = {
|
||||
listSessions: (args?: AiVaultListArgs) => Promise<AiVaultListResult>
|
||||
}
|
||||
|
||||
export type NativeChatReadSessionResult = { messages: NativeChatMessage[] } | { error: string }
|
||||
|
||||
/** Messages appended to a live-tailed transcript since the previous emit. */
|
||||
export type NativeChatAppendedMessages = NativeChatMessage[]
|
||||
|
||||
/** Wire payload for the `nativeChat:appended` push channel. */
|
||||
export type NativeChatAppendedPayload = {
|
||||
subscriptionId: string
|
||||
messages: NativeChatAppendedMessages
|
||||
}
|
||||
|
||||
export type NativeChatSubscribeArgs = {
|
||||
/** Unique per-caller id, echoed on every append so multiple live panes in
|
||||
* one renderer don't cross-talk. */
|
||||
subscriptionId: string
|
||||
agent: AgentType
|
||||
sessionId: string
|
||||
/** Authoritative transcript path from the agent hook (providerSession). */
|
||||
transcriptPath?: string
|
||||
}
|
||||
|
||||
export type NativeChatApi = {
|
||||
/** Read the on-disk transcript for an agent + session id, windowed to the most
|
||||
* recent `limit` turns (defaults to the desktop window). The renderer raises
|
||||
* `limit` to page in older history as it scrolls to the top. `transcriptPath`
|
||||
* is the hook-reported authoritative file path, preferred over the id glob. */
|
||||
readSession: (
|
||||
agent: AgentType,
|
||||
sessionId: string,
|
||||
limit?: number,
|
||||
transcriptPath?: string
|
||||
) => Promise<NativeChatReadSessionResult>
|
||||
/** Live-tail a transcript: `onAppended` fires with only newly-appended
|
||||
* messages. Returns an unsubscribe fn that closes the main-process watcher. */
|
||||
subscribe: (
|
||||
args: NativeChatSubscribeArgs,
|
||||
onAppended: (messages: NativeChatAppendedMessages) => void
|
||||
) => () => void
|
||||
}
|
||||
|
||||
export type AppApi = {
|
||||
/** Returns the app identity currently exposed to native chrome and the titlebar. */
|
||||
getIdentity: () => Promise<AppIdentity>
|
||||
@@ -2014,6 +2055,7 @@ export type PreloadApi = {
|
||||
codexUsage: CodexUsageApi
|
||||
openCodeUsage: OpenCodeUsageApi
|
||||
aiVault: AiVaultApi
|
||||
nativeChat: NativeChatApi
|
||||
fs: {
|
||||
readDir: (args: { dirPath: string; connectionId?: string }) => Promise<DirEntry[]>
|
||||
readFile: (args: {
|
||||
|
||||
@@ -153,6 +153,12 @@ import type {
|
||||
} from '../shared/automations-types'
|
||||
import type { KeybindingActionId, KeybindingFileSnapshot } from '../shared/keybindings'
|
||||
import type { AiVaultListArgs } from '../shared/ai-vault-types'
|
||||
import type { AgentType } from '../shared/native-chat-types'
|
||||
import type {
|
||||
NativeChatAppendedMessages,
|
||||
NativeChatAppendedPayload,
|
||||
NativeChatReadSessionResult
|
||||
} from './api-types'
|
||||
import {
|
||||
ORCA_EDITOR_PREPARE_HOT_EXIT_EVENT,
|
||||
type EditorPrepareHotExitDetail
|
||||
@@ -3489,6 +3495,40 @@ const api = {
|
||||
ipcRenderer.invoke('aiVault:listSessions', args)
|
||||
},
|
||||
|
||||
nativeChat: {
|
||||
readSession: (
|
||||
agent: AgentType,
|
||||
sessionId: string,
|
||||
limit?: number,
|
||||
transcriptPath?: string
|
||||
): Promise<NativeChatReadSessionResult> =>
|
||||
ipcRenderer.invoke('nativeChat:readSession', { agent, sessionId, limit, transcriptPath }),
|
||||
/** Start live tailing for a transcript. `onAppended` fires with only the
|
||||
* newly-appended messages. Returns an unsubscribe fn that closes the
|
||||
* main-process watcher (subscriptionId routes appends to this caller). */
|
||||
subscribe: (
|
||||
args: {
|
||||
subscriptionId: string
|
||||
agent: AgentType
|
||||
sessionId: string
|
||||
transcriptPath?: string
|
||||
},
|
||||
onAppended: (messages: NativeChatAppendedMessages) => void
|
||||
): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, payload: NativeChatAppendedPayload) => {
|
||||
if (payload.subscriptionId === args.subscriptionId) {
|
||||
onAppended(payload.messages)
|
||||
}
|
||||
}
|
||||
ipcRenderer.on('nativeChat:appended', listener)
|
||||
ipcRenderer.send('nativeChat:subscribe', args)
|
||||
return () => {
|
||||
ipcRenderer.removeListener('nativeChat:appended', listener)
|
||||
ipcRenderer.send('nativeChat:unsubscribe', { subscriptionId: args.subscriptionId })
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
runtime: {
|
||||
syncWindowGraph: (graph: RuntimeSyncWindowGraph): Promise<RuntimeSyncWindowGraphResult> =>
|
||||
ipcRenderer.invoke('runtime:syncWindowGraph', graph),
|
||||
|
||||
@@ -13,6 +13,7 @@ import { recordStoppedSession, waitForStoppedSession } from './dictation-stopped
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { showDictationStartErrorToast } from './dictation-start-error-toast'
|
||||
import { useHoldDictationGesture } from './use-hold-dictation-gesture'
|
||||
import { DICTATION_CONTROL_EVENT, type DictationControlAction } from './dictation-control-events'
|
||||
|
||||
export function DictationController() {
|
||||
const dictationState = useAppStore((s) => s.dictationState)
|
||||
@@ -265,6 +266,35 @@ export function DictationController() {
|
||||
stopDictation
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
const canDictate = (): boolean => Boolean(settings?.voice?.enabled && settings.voice.sttModel)
|
||||
const handleControl = (event: Event): void => {
|
||||
if (!canDictate() || dictationStateRef.current === 'stopping') {
|
||||
return
|
||||
}
|
||||
const action = (event as CustomEvent<DictationControlAction>).detail
|
||||
if (action === 'start') {
|
||||
if (dictationStateRef.current === 'idle') {
|
||||
void startDictation()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (action === 'stop') {
|
||||
if (dictationStateRef.current === 'listening' || dictationStateRef.current === 'starting') {
|
||||
void stopDictation()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (dictationStateRef.current === 'listening' || dictationStateRef.current === 'starting') {
|
||||
void stopDictation()
|
||||
} else {
|
||||
void startDictation()
|
||||
}
|
||||
}
|
||||
document.addEventListener(DICTATION_CONTROL_EVENT, handleControl)
|
||||
return () => document.removeEventListener(DICTATION_CONTROL_EVENT, handleControl)
|
||||
}, [settings?.voice?.enabled, settings?.voice?.sttModel, startDictation, stopDictation])
|
||||
|
||||
useHoldDictationGesture({
|
||||
dictationStateRef,
|
||||
holdGestureActiveRef,
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export const DICTATION_CONTROL_EVENT = 'dictation:control'
|
||||
|
||||
export type DictationControlAction = 'toggle' | 'start' | 'stop'
|
||||
|
||||
export function dispatchDictationControl(action: DictationControlAction): void {
|
||||
document.dispatchEvent(
|
||||
new CustomEvent<DictationControlAction>(DICTATION_CONTROL_EVENT, { detail: action })
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { ShieldQuestion } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ChatApproval } from './native-chat-interactive-prompt'
|
||||
|
||||
export type NativeChatApprovalCardProps = {
|
||||
approval: ChatApproval
|
||||
/** Send the chosen option's literal string to the agent's PTY. */
|
||||
onChoose: (send: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Native renderer for an agent tool-approval (PermissionRequest) as an
|
||||
* Allow/Deny card. Each button writes its option's literal `send` string back
|
||||
* to the agent (a number to allow; ESC to deny). The first option reads as the
|
||||
* affirmative action and gets the primary styling.
|
||||
*/
|
||||
export function NativeChatApprovalCard({
|
||||
approval,
|
||||
onChoose
|
||||
}: NativeChatApprovalCardProps): React.JSX.Element {
|
||||
return (
|
||||
<div className="shrink-0 border-t border-border bg-muted/30">
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-col gap-2 px-3 py-3 sm:px-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<ShieldQuestion className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-foreground">{approval.title}</p>
|
||||
{approval.detail ? (
|
||||
<p className="mt-0.5 break-words font-mono text-xs text-muted-foreground">
|
||||
{approval.detail}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{approval.options.map((opt, i) => (
|
||||
<button
|
||||
key={`${opt.label}-${i}`}
|
||||
type="button"
|
||||
onClick={() => onChoose(opt.send)}
|
||||
className={cn(
|
||||
'rounded-md px-4 py-1.5 text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
i === 0
|
||||
? 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||
: 'border border-border bg-background text-foreground hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { SlashCommandSuggestion } from './native-chat-composer-state'
|
||||
import type { DiscoveredSkill } from '../../../../shared/skills'
|
||||
|
||||
export function NativeChatSlashMenu({
|
||||
suggestions,
|
||||
activeIndex,
|
||||
onChoose
|
||||
}: {
|
||||
suggestions: SlashCommandSuggestion[]
|
||||
activeIndex: number
|
||||
onChoose: (command: SlashCommandSuggestion) => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className="absolute bottom-full left-3 right-3 mb-1 overflow-hidden rounded-md border border-border bg-popover shadow-md sm:left-4 sm:right-4">
|
||||
{suggestions.map((command, index) => (
|
||||
<button
|
||||
key={command.name}
|
||||
type="button"
|
||||
onClick={() => onChoose(command)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm',
|
||||
index === activeIndex ? 'bg-accent text-accent-foreground' : 'text-foreground'
|
||||
)}
|
||||
>
|
||||
<span className="font-medium">/{command.name}</span>
|
||||
{command.description ? (
|
||||
<span className="truncate text-xs text-muted-foreground">{command.description}</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function NativeChatMentionHint({
|
||||
query,
|
||||
onAccept
|
||||
}: {
|
||||
query: string
|
||||
onAccept: () => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAccept}
|
||||
className="absolute bottom-full left-3 right-3 mb-1 flex w-auto items-center gap-2 rounded-md border border-border bg-popover px-3 py-1.5 text-left text-xs text-muted-foreground shadow-md sm:left-4 sm:right-4"
|
||||
>
|
||||
{translate('components.native-chat.composer.mentionHint', 'Referencing file:')}{' '}
|
||||
<span className="font-medium text-foreground">@{query || '…'}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function NativeChatSkillMenu({
|
||||
suggestions,
|
||||
activeIndex,
|
||||
onChoose
|
||||
}: {
|
||||
suggestions: DiscoveredSkill[]
|
||||
activeIndex: number
|
||||
onChoose: (skill: DiscoveredSkill) => void
|
||||
}): React.JSX.Element {
|
||||
const activeItemRef = useRef<HTMLButtonElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
activeItemRef.current?.scrollIntoView({ block: 'nearest' })
|
||||
}, [activeIndex, suggestions])
|
||||
|
||||
return (
|
||||
<div className="scrollbar-sleek absolute bottom-full left-0 right-0 mb-1 max-h-64 overflow-y-auto rounded-md border border-border bg-popover p-1 shadow-md">
|
||||
{suggestions.length === 0 ? (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
{translate('components.native-chat.composer.noSkills', 'No matching skills')}
|
||||
</div>
|
||||
) : null}
|
||||
{suggestions.map((skill, index) => (
|
||||
<button
|
||||
key={skill.id}
|
||||
ref={index === activeIndex ? activeItemRef : null}
|
||||
type="button"
|
||||
onClick={() => onChoose(skill)}
|
||||
className={cn(
|
||||
'flex w-full items-start gap-2 rounded-sm px-2 py-1.5 text-left text-sm',
|
||||
index === activeIndex ? 'bg-accent text-accent-foreground' : 'text-foreground'
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium">${skill.name}</span>
|
||||
{skill.description ? (
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{skill.description}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground">{skill.sourceLabel}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useAppStore } from '../../store'
|
||||
import type { AgentType } from '../../../../shared/agent-status-types'
|
||||
import { NATIVE_FILE_DROP_TARGET } from '../../../../shared/native-file-drop'
|
||||
import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection'
|
||||
import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft'
|
||||
import { sendNativeChatMessage, submitNativeChatPrompt } from './native-chat-runtime-send'
|
||||
import { getAgentSlashCommands } from './native-chat-agent-commands'
|
||||
import { emitNativeChatMessageSent } from '@/lib/native-chat-telemetry'
|
||||
import {
|
||||
applyMentionSuggestion,
|
||||
applySkillSuggestion,
|
||||
applySlashSuggestion,
|
||||
deriveComposerAutocomplete,
|
||||
EMPTY_HISTORY,
|
||||
isSlashCommandDraft,
|
||||
pushHistory,
|
||||
slashCommandDispatchText,
|
||||
type HistoryState,
|
||||
type SlashCommandSuggestion
|
||||
} from './native-chat-composer-state'
|
||||
import { resolveImagePaste } from './native-chat-image-paste'
|
||||
import { NativeChatComposerField } from './NativeChatComposerField'
|
||||
import {
|
||||
nativeChatComposerTargetIsRemote,
|
||||
type NativeChatResolvedTarget
|
||||
} from './native-chat-composer-target'
|
||||
import { useNativeChatSkills } from './use-native-chat-skills'
|
||||
import { useNativeChatComposerAttachments } from './use-native-chat-composer-attachments'
|
||||
import { dispatchDictationControl } from '../dictation/dictation-control-events'
|
||||
import { useNativeChatComposerKeyDown } from './use-native-chat-composer-keydown'
|
||||
|
||||
// Why: a plain ESC byte is what the agent TUIs read as the interrupt key over a
|
||||
// PTY (matching how xterm forwards Escape). The richer interrupt-intent
|
||||
// inference (agent-interrupt-intent.ts) is driven by the existing PTY input
|
||||
// observers, so writing ESC through the same send path feeds that machinery.
|
||||
const ESC = '\x1b'
|
||||
|
||||
export type NativeChatComposerProps = {
|
||||
/** Tab hosting the agent; used to resolve the live ptyId + runtime settings. */
|
||||
terminalTabId: string
|
||||
/** Specific split-pane PTY this chat view owns. */
|
||||
targetPtyId: string | null
|
||||
agent: AgentType
|
||||
/**
|
||||
* Mobile presence-lock seam (R8): when a mobile client holds the pty, desktop
|
||||
* sends must be guarded rather than silently dropped. U9 wires the real lock
|
||||
* state in; until then this defaults to `true` (sendable) and the composer
|
||||
* already renders the guarded/disabled affordance when it is `false`.
|
||||
*/
|
||||
canSend?: boolean
|
||||
/** True while the hosted TUI reports an in-flight turn; swaps Send to Stop. */
|
||||
isWorking?: boolean
|
||||
/** Interrupt the hosted agent, usually by sending ESC into the PTY. */
|
||||
onStop?: () => void
|
||||
/** Optional optimistic-send hook: called with the sent text so the view can
|
||||
* render a "queued" echo until the real transcript turn lands (mobile parity). */
|
||||
onOptimisticSend?: (text: string, imagePaths?: string[]) => void
|
||||
/** Called with a dispatched slash command (e.g. `/clear`) so the view can show
|
||||
* a small "Ran /clear" system line — slash commands aren't chat turns and
|
||||
* otherwise leave no visible trace that anything happened. */
|
||||
onSlashCommand?: (command: string) => void
|
||||
}
|
||||
|
||||
export type NativeChatComposerHandle = {
|
||||
focus: () => boolean
|
||||
insertTypedText: (text: string) => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Rich native input for the chat view. Sends prompts into the running agent
|
||||
* through the same verified runtime path as typed input (KTD4), so the agent
|
||||
* cannot distinguish native input from keystrokes. Enter sends; Shift+Enter
|
||||
* inserts a newline; multi-line is bracketed-paste wrapped; Esc interrupts.
|
||||
* Slash-command and `@file` autocomplete are agent-aware; image paste persists a
|
||||
* temp file and injects the agent-appropriate path (or reports unsupported).
|
||||
*/
|
||||
export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeChatComposerProps>(
|
||||
function NativeChatComposer(
|
||||
{
|
||||
terminalTabId,
|
||||
targetPtyId,
|
||||
agent,
|
||||
canSend = true,
|
||||
isWorking = false,
|
||||
onStop,
|
||||
onOptimisticSend,
|
||||
onSlashCommand
|
||||
},
|
||||
ref
|
||||
): React.JSX.Element {
|
||||
const [draft, setDraft] = useState('')
|
||||
const [caret, setCaret] = useState(0)
|
||||
const [history, setHistory] = useState<HistoryState>(EMPTY_HISTORY)
|
||||
const [activeSuggestion, setActiveSuggestion] = useState(0)
|
||||
const [notice, setNotice] = useState<string | null>(null)
|
||||
const [dictationPressed, setDictationPressed] = useState(false)
|
||||
const skills = useNativeChatSkills(agent, terminalTabId)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const dictationState = useAppStore((store) => store.dictationState)
|
||||
const voiceSettings = useAppStore((store) => store.settings?.voice)
|
||||
const isDictationHoldMode = voiceSettings?.dictationMode === 'hold'
|
||||
const dictationDisabled = voiceSettings?.enabled !== true || !voiceSettings.sttModel
|
||||
const isDictating =
|
||||
dictationPressed ||
|
||||
dictationState === 'starting' ||
|
||||
dictationState === 'listening' ||
|
||||
dictationState === 'stopping'
|
||||
|
||||
const agentCommands = useMemo(() => getAgentSlashCommands(agent), [agent])
|
||||
const autocomplete = useMemo(
|
||||
() =>
|
||||
deriveComposerAutocomplete(draft, caret, agentCommands, agent === 'codex' ? skills : []),
|
||||
[draft, caret, agentCommands, agent, skills]
|
||||
)
|
||||
|
||||
// Resolve the live ptyId for this chat leaf; runtime owner settings route
|
||||
// local vs remote (SSH) sends.
|
||||
const resolveTarget = useCallback((): NativeChatResolvedTarget | null => {
|
||||
if (!targetPtyId) {
|
||||
return null
|
||||
}
|
||||
return { ptyId: targetPtyId, settings: getSettingsForAgentTabRuntimeOwner(terminalTabId) }
|
||||
}, [targetPtyId, terminalTabId])
|
||||
|
||||
const hasPty = targetPtyId !== null
|
||||
const disabled = !hasPty || !canSend
|
||||
|
||||
const syncCaret = useCallback((el: HTMLTextAreaElement) => {
|
||||
setCaret(el.selectionStart ?? el.value.length)
|
||||
}, [])
|
||||
|
||||
const { imageAttachments, attachLocalPaths, clearImageAttachments, removeImageAttachment } =
|
||||
useNativeChatComposerAttachments({
|
||||
attachmentScopeKey: targetPtyId ?? terminalTabId,
|
||||
caret,
|
||||
resolveTarget,
|
||||
textareaRef,
|
||||
setCaret,
|
||||
setDraft,
|
||||
setNotice
|
||||
})
|
||||
const sendButtonDisabled = isWorking
|
||||
? !hasPty || !onStop
|
||||
: disabled || (draft.trim() === '' && imageAttachments.length === 0)
|
||||
|
||||
const insertTypedText = useCallback(
|
||||
(text: string): boolean => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea || textarea.disabled) {
|
||||
return false
|
||||
}
|
||||
const selectionStart = textarea.selectionStart ?? caret
|
||||
const selectionEnd = textarea.selectionEnd ?? selectionStart
|
||||
const next = `${draft.slice(0, selectionStart)}${text}${draft.slice(selectionEnd)}`
|
||||
const nextCaret = selectionStart + text.length
|
||||
textarea.focus()
|
||||
setDraft(next)
|
||||
setCaret(nextCaret)
|
||||
setHistory((prev) => ({ entries: prev.entries, index: null }))
|
||||
setActiveSuggestion(0)
|
||||
requestAnimationFrame(() => {
|
||||
textarea.setSelectionRange(nextCaret, nextCaret)
|
||||
})
|
||||
return true
|
||||
},
|
||||
[caret, draft]
|
||||
)
|
||||
|
||||
const focus = useCallback((): boolean => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea || textarea.disabled) {
|
||||
return false
|
||||
}
|
||||
textarea.focus()
|
||||
return true
|
||||
}, [])
|
||||
|
||||
useImperativeHandle(ref, () => ({ focus, insertTypedText }), [focus, insertTypedText])
|
||||
|
||||
useEffect(() => {
|
||||
return window.api.ui.onFileDrop((payload) => {
|
||||
if (payload.target !== NATIVE_FILE_DROP_TARGET.composer) {
|
||||
return
|
||||
}
|
||||
attachLocalPaths(payload.paths)
|
||||
})
|
||||
}, [attachLocalPaths])
|
||||
|
||||
const pickAttachment = useCallback(() => {
|
||||
void (async () => {
|
||||
const filePath = await window.api.shell.pickAttachment()
|
||||
if (!filePath) {
|
||||
return
|
||||
}
|
||||
attachLocalPaths([filePath])
|
||||
})()
|
||||
}, [attachLocalPaths])
|
||||
|
||||
const focusForDictation = useCallback(() => {
|
||||
textareaRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const toggleDictation = useCallback(() => {
|
||||
focusForDictation()
|
||||
dispatchDictationControl('toggle')
|
||||
}, [focusForDictation])
|
||||
|
||||
const startHoldDictation = useCallback(() => {
|
||||
setDictationPressed(true)
|
||||
focusForDictation()
|
||||
dispatchDictationControl('start')
|
||||
}, [focusForDictation])
|
||||
|
||||
const stopHoldDictation = useCallback(() => {
|
||||
setDictationPressed(false)
|
||||
dispatchDictationControl('stop')
|
||||
}, [])
|
||||
|
||||
const send = useCallback(() => {
|
||||
const text = draft
|
||||
const imagePaths = imageAttachments.map((attachment) => attachment.path)
|
||||
if ((text.trim() === '' && imagePaths.length === 0) || disabled) {
|
||||
return
|
||||
}
|
||||
const target = resolveTarget()
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
// Images are pasted into the hosted TUI as soon as they are attached, so the
|
||||
// Send action only submits the text body (if any) plus Enter.
|
||||
if (text.trim().length > 0) {
|
||||
sendNativeChatMessage(target.settings, target.ptyId, text)
|
||||
} else {
|
||||
submitNativeChatPrompt(target.settings, target.ptyId)
|
||||
}
|
||||
// Slash commands are TUI controls, not chat turns: don't echo a user bubble,
|
||||
// but DO surface a small "Ran /clear" system line so the command leaves a
|
||||
// visible trace instead of seeming to do nothing.
|
||||
if (isSlashCommandDraft(text)) {
|
||||
onSlashCommand?.(text.trim())
|
||||
} else {
|
||||
onOptimisticSend?.(text, imagePaths)
|
||||
}
|
||||
// Why: U10 telemetry — record adoption + local-vs-remote runtime split. The
|
||||
// agent prop is the loose AgentType; the emitter narrows unknowns to 'other'.
|
||||
emitNativeChatMessageSent({
|
||||
agent,
|
||||
runtime: nativeChatComposerTargetIsRemote(target.ptyId) ? 'remote' : 'local'
|
||||
})
|
||||
setHistory((prev) => pushHistory(prev, text))
|
||||
setDraft('')
|
||||
setCaret(0)
|
||||
clearImageAttachments()
|
||||
setNotice(null)
|
||||
}, [
|
||||
agent,
|
||||
clearImageAttachments,
|
||||
draft,
|
||||
imageAttachments,
|
||||
disabled,
|
||||
resolveTarget,
|
||||
onOptimisticSend,
|
||||
onSlashCommand
|
||||
])
|
||||
|
||||
const interrupt = useCallback(() => {
|
||||
if (isWorking && onStop) {
|
||||
onStop()
|
||||
return
|
||||
}
|
||||
const target = resolveTarget()
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
sendRuntimePtyInput(target.settings, target.ptyId, ESC)
|
||||
}, [isWorking, onStop, resolveTarget])
|
||||
|
||||
const chooseSlash = useCallback((command: SlashCommandSuggestion) => {
|
||||
const next = applySlashSuggestion(command)
|
||||
setDraft(next)
|
||||
setCaret(next.length)
|
||||
setActiveSuggestion(0)
|
||||
textareaRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const dispatchSlash = useCallback(
|
||||
(command: SlashCommandSuggestion) => {
|
||||
const next = slashCommandDispatchText(command)
|
||||
const target = resolveTarget()
|
||||
if (!target || disabled) {
|
||||
return
|
||||
}
|
||||
sendNativeChatMessage(target.settings, target.ptyId, next)
|
||||
// Surface the command as a system line (this is the autocomplete-menu
|
||||
// dispatch path; the typed-Enter path in `send` does the same).
|
||||
onSlashCommand?.(next.trim())
|
||||
emitNativeChatMessageSent({
|
||||
agent,
|
||||
runtime: nativeChatComposerTargetIsRemote(target.ptyId) ? 'remote' : 'local'
|
||||
})
|
||||
setHistory((prev) => pushHistory(prev, next))
|
||||
setDraft('')
|
||||
setCaret(0)
|
||||
setActiveSuggestion(0)
|
||||
setNotice(null)
|
||||
},
|
||||
[agent, disabled, resolveTarget, onSlashCommand]
|
||||
)
|
||||
|
||||
const handlePaste = useCallback(
|
||||
(event: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const hasImage = Array.from(event.clipboardData.items).some((item) =>
|
||||
item.type.startsWith('image/')
|
||||
)
|
||||
if (!hasImage) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
// Why: snapshot the caret before the async temp-file round-trip — `caret`
|
||||
// state can move (further typing/selection) while the await is in flight.
|
||||
const caretAtPaste = caret
|
||||
void (async () => {
|
||||
const tempPath = await window.api.ui.saveClipboardImageAsTempFile()
|
||||
if (!tempPath) {
|
||||
return
|
||||
}
|
||||
const result = resolveImagePaste(agent, tempPath)
|
||||
if (result.kind === 'unsupported') {
|
||||
setNotice(
|
||||
translate(
|
||||
'components.native-chat.composer.imageUnsupported',
|
||||
'Image paste is not supported for this agent.'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
attachLocalPaths([result.path])
|
||||
setCaret(caretAtPaste)
|
||||
setNotice(null)
|
||||
})()
|
||||
},
|
||||
[agent, attachLocalPaths, caret]
|
||||
)
|
||||
|
||||
const handleKeyDown = useNativeChatComposerKeyDown({
|
||||
autocomplete,
|
||||
activeSuggestion,
|
||||
draft,
|
||||
caret,
|
||||
history,
|
||||
chooseSlash,
|
||||
dispatchSlash,
|
||||
interrupt,
|
||||
send,
|
||||
setActiveSuggestion,
|
||||
setDraft,
|
||||
setCaret,
|
||||
setHistory
|
||||
})
|
||||
|
||||
return (
|
||||
<NativeChatComposerField
|
||||
textareaRef={textareaRef}
|
||||
draft={draft}
|
||||
disabled={disabled}
|
||||
hasPty={hasPty}
|
||||
canSend={canSend}
|
||||
autocomplete={autocomplete}
|
||||
activeSuggestion={activeSuggestion}
|
||||
notice={notice}
|
||||
imageAttachments={imageAttachments}
|
||||
sendButtonDisabled={sendButtonDisabled}
|
||||
isWorking={isWorking}
|
||||
attachDisabled={disabled}
|
||||
dictationDisabled={dictationDisabled}
|
||||
isDictating={isDictating}
|
||||
isDictationHoldMode={isDictationHoldMode}
|
||||
onDraftChange={(value, element) => {
|
||||
setDraft(value)
|
||||
setHistory((prev) => ({ entries: prev.entries, index: null }))
|
||||
syncCaret(element)
|
||||
setActiveSuggestion(0)
|
||||
}}
|
||||
onTextareaSelect={syncCaret}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
onChooseSlash={chooseSlash}
|
||||
onAcceptMention={() => {
|
||||
if (autocomplete.mode !== 'mention') {
|
||||
return
|
||||
}
|
||||
const result = applyMentionSuggestion(draft, caret, autocomplete.query)
|
||||
setDraft(result.draft)
|
||||
setCaret(result.caret)
|
||||
textareaRef.current?.focus()
|
||||
}}
|
||||
onChooseSkill={(skill) => {
|
||||
const result = applySkillSuggestion(draft, caret, skill.name)
|
||||
setDraft(result.draft)
|
||||
setCaret(result.caret)
|
||||
setActiveSuggestion(0)
|
||||
textareaRef.current?.focus()
|
||||
}}
|
||||
onRemoveImageAttachment={(id) => removeImageAttachment(id)}
|
||||
onAttach={pickAttachment}
|
||||
onDictationToggle={toggleDictation}
|
||||
onDictationHoldStart={startHoldDictation}
|
||||
onDictationHoldEnd={stopHoldDictation}
|
||||
onSend={send}
|
||||
onStop={onStop}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,125 @@
|
||||
import { ArrowUp, Mic, Plus, Square } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export type NativeChatComposerActionsProps = {
|
||||
attachDisabled: boolean
|
||||
dictationDisabled: boolean
|
||||
sendDisabled: boolean
|
||||
isWorking: boolean
|
||||
isDictating: boolean
|
||||
isDictationHoldMode: boolean
|
||||
onAttach: () => void
|
||||
onDictationToggle: () => void
|
||||
onDictationHoldStart: () => void
|
||||
onDictationHoldEnd: () => void
|
||||
onSend: () => void
|
||||
onStop?: () => void
|
||||
}
|
||||
|
||||
export function NativeChatComposerActions({
|
||||
attachDisabled,
|
||||
dictationDisabled,
|
||||
sendDisabled,
|
||||
isWorking,
|
||||
isDictating,
|
||||
isDictationHoldMode,
|
||||
onAttach,
|
||||
onDictationToggle,
|
||||
onDictationHoldStart,
|
||||
onDictationHoldEnd,
|
||||
onSend,
|
||||
onStop
|
||||
}: NativeChatComposerActionsProps): React.JSX.Element {
|
||||
const dictationLabel = isDictating
|
||||
? translate('components.native-chat.composer.stopDictation', 'Stop dictation')
|
||||
: translate('components.native-chat.composer.startDictation', 'Start dictation')
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={translate('components.native-chat.composer.attach', 'Attach file')}
|
||||
disabled={attachDisabled}
|
||||
onClick={onAttach}
|
||||
className="pointer-coarse:size-11"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{translate('components.native-chat.composer.attach', 'Attach file')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant={isDictating ? 'secondary' : 'ghost'}
|
||||
size="icon-sm"
|
||||
aria-label={dictationLabel}
|
||||
disabled={dictationDisabled}
|
||||
onClick={isDictationHoldMode ? undefined : onDictationToggle}
|
||||
onPointerDown={(event) => {
|
||||
if (!isDictationHoldMode || dictationDisabled) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
onDictationHoldStart()
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
if (isDictationHoldMode && !dictationDisabled) {
|
||||
onDictationHoldEnd()
|
||||
}
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
if (isDictationHoldMode && !dictationDisabled) {
|
||||
onDictationHoldEnd()
|
||||
}
|
||||
}}
|
||||
onPointerLeave={(event) => {
|
||||
if (isDictationHoldMode && event.buttons === 1 && !dictationDisabled) {
|
||||
onDictationHoldEnd()
|
||||
}
|
||||
}}
|
||||
className="pointer-coarse:size-11"
|
||||
>
|
||||
{isDictating ? (
|
||||
<Square className="size-3.5 fill-current" />
|
||||
) : (
|
||||
<Mic className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{dictationLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
type="button"
|
||||
aria-label={
|
||||
isWorking
|
||||
? translate('components.native-chat.stop', 'Stop the agent')
|
||||
: translate('components.native-chat.composer.send', 'Send')
|
||||
}
|
||||
disabled={sendDisabled}
|
||||
onClick={isWorking ? onStop : onSend}
|
||||
variant={isWorking ? 'secondary' : 'default'}
|
||||
size="icon"
|
||||
className="size-8 rounded-full pointer-coarse:size-10"
|
||||
>
|
||||
{isWorking ? (
|
||||
<Square className="size-3.5 fill-current" />
|
||||
) : (
|
||||
<ArrowUp className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { ClipboardEventHandler, KeyboardEventHandler, RefObject } from 'react'
|
||||
import { Image as ImageIcon, ImageOff, X } from 'lucide-react'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { NATIVE_FILE_DROP_TARGET } from '../../../../shared/native-file-drop'
|
||||
import { basename } from '@/lib/path'
|
||||
import type { ComposerAutocomplete, SlashCommandSuggestion } from './native-chat-composer-state'
|
||||
import {
|
||||
NativeChatMentionHint,
|
||||
NativeChatSkillMenu,
|
||||
NativeChatSlashMenu
|
||||
} from './NativeChatAutocompleteMenus'
|
||||
import { NativeChatComposerActions } from './NativeChatComposerActions'
|
||||
import { nativeChatComposerPlaceholder } from './native-chat-composer-target'
|
||||
import type { DiscoveredSkill } from '../../../../shared/skills'
|
||||
|
||||
export type NativeChatComposerFieldProps = {
|
||||
textareaRef: RefObject<HTMLTextAreaElement | null>
|
||||
draft: string
|
||||
disabled: boolean
|
||||
hasPty: boolean
|
||||
canSend: boolean
|
||||
autocomplete: ComposerAutocomplete
|
||||
activeSuggestion: number
|
||||
notice: string | null
|
||||
imageAttachments: readonly NativeChatComposerImageAttachment[]
|
||||
sendButtonDisabled: boolean
|
||||
isWorking: boolean
|
||||
attachDisabled: boolean
|
||||
dictationDisabled: boolean
|
||||
isDictating: boolean
|
||||
isDictationHoldMode: boolean
|
||||
onDraftChange: (value: string, element: HTMLTextAreaElement) => void
|
||||
onTextareaSelect: (element: HTMLTextAreaElement) => void
|
||||
onKeyDown: KeyboardEventHandler<HTMLTextAreaElement>
|
||||
onPaste: ClipboardEventHandler<HTMLTextAreaElement>
|
||||
onChooseSlash: (command: SlashCommandSuggestion) => void
|
||||
onAcceptMention: () => void
|
||||
onChooseSkill: (skill: DiscoveredSkill) => void
|
||||
onRemoveImageAttachment: (id: string) => void
|
||||
onAttach: () => void
|
||||
onDictationToggle: () => void
|
||||
onDictationHoldStart: () => void
|
||||
onDictationHoldEnd: () => void
|
||||
onSend: () => void
|
||||
onStop?: () => void
|
||||
}
|
||||
|
||||
export type NativeChatComposerImageAttachment = {
|
||||
id: string
|
||||
path: string
|
||||
}
|
||||
|
||||
export function NativeChatComposerField({
|
||||
textareaRef,
|
||||
draft,
|
||||
disabled,
|
||||
hasPty,
|
||||
canSend,
|
||||
autocomplete,
|
||||
activeSuggestion,
|
||||
notice,
|
||||
imageAttachments,
|
||||
sendButtonDisabled,
|
||||
isWorking,
|
||||
attachDisabled,
|
||||
dictationDisabled,
|
||||
isDictating,
|
||||
isDictationHoldMode,
|
||||
onDraftChange,
|
||||
onTextareaSelect,
|
||||
onKeyDown,
|
||||
onPaste,
|
||||
onChooseSlash,
|
||||
onAcceptMention,
|
||||
onChooseSkill,
|
||||
onRemoveImageAttachment,
|
||||
onAttach,
|
||||
onDictationToggle,
|
||||
onDictationHoldStart,
|
||||
onDictationHoldEnd,
|
||||
onSend,
|
||||
onStop
|
||||
}: NativeChatComposerFieldProps): React.JSX.Element {
|
||||
return (
|
||||
<div className="shrink-0 bg-background">
|
||||
<div className="px-3 py-2 sm:px-4">
|
||||
<div className="relative mx-auto w-full max-w-3xl">
|
||||
{autocomplete.mode === 'slash' && autocomplete.suggestions.length > 0 ? (
|
||||
<NativeChatSlashMenu
|
||||
suggestions={autocomplete.suggestions}
|
||||
activeIndex={activeSuggestion}
|
||||
onChoose={onChooseSlash}
|
||||
/>
|
||||
) : null}
|
||||
{autocomplete.mode === 'mention' ? (
|
||||
<NativeChatMentionHint query={autocomplete.query} onAccept={onAcceptMention} />
|
||||
) : null}
|
||||
{autocomplete.mode === 'skill' ? (
|
||||
<NativeChatSkillMenu
|
||||
suggestions={autocomplete.suggestions}
|
||||
activeIndex={activeSuggestion}
|
||||
onChoose={onChooseSkill}
|
||||
/>
|
||||
) : null}
|
||||
{notice ? (
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<ImageOff className="size-3.5 shrink-0" />
|
||||
<span>{notice}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
data-native-file-drop-target={NATIVE_FILE_DROP_TARGET.composer}
|
||||
className={cn(
|
||||
'rounded-xl border border-input bg-card p-1.5 shadow-xs transition-colors',
|
||||
'focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 dark:bg-input/30'
|
||||
)}
|
||||
>
|
||||
{imageAttachments.length > 0 ? (
|
||||
<div className="mb-2 flex flex-wrap gap-1.5 px-1">
|
||||
{imageAttachments.map((attachment) => (
|
||||
<div
|
||||
key={attachment.id}
|
||||
className="flex max-w-full items-center gap-1.5 rounded-md border border-border bg-background px-2 py-1 text-xs text-muted-foreground"
|
||||
title={attachment.path}
|
||||
>
|
||||
<ImageIcon className="size-3.5 shrink-0" />
|
||||
<span className="max-w-56 truncate">{basename(attachment.path)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveImageAttachment(attachment.id)}
|
||||
aria-label={translate(
|
||||
'components.native-chat.composer.removeAttachment',
|
||||
'Remove attachment'
|
||||
)}
|
||||
className="flex size-4 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={draft}
|
||||
disabled={disabled}
|
||||
rows={2}
|
||||
onChange={(e) => onDraftChange(e.target.value, e.currentTarget)}
|
||||
onKeyDown={onKeyDown}
|
||||
onPaste={onPaste}
|
||||
onSelect={(e) => onTextareaSelect(e.currentTarget)}
|
||||
placeholder={nativeChatComposerPlaceholder(hasPty, canSend)}
|
||||
// Why: coarse-pointer min-height follows the app's touch target convention.
|
||||
className={cn(
|
||||
'min-h-12 max-h-28 w-full resize-none bg-transparent px-2 py-1 text-sm outline-none pointer-coarse:min-h-14',
|
||||
'placeholder:text-muted-foreground/60 disabled:cursor-not-allowed disabled:opacity-50'
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2 pt-0.5">
|
||||
<NativeChatComposerActions
|
||||
attachDisabled={attachDisabled}
|
||||
dictationDisabled={dictationDisabled}
|
||||
sendDisabled={sendButtonDisabled}
|
||||
isWorking={isWorking}
|
||||
isDictating={isDictating}
|
||||
isDictationHoldMode={isDictationHoldMode}
|
||||
onAttach={onAttach}
|
||||
onDictationToggle={onDictationToggle}
|
||||
onDictationHoldStart={onDictationHoldStart}
|
||||
onDictationHoldEnd={onDictationHoldEnd}
|
||||
onSend={onSend}
|
||||
onStop={onStop}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Check, Copy } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
/**
|
||||
* Per-message copy affordance for the native chat. Copies the message's text to
|
||||
* the clipboard and briefly swaps the icon to a check tint as success feedback —
|
||||
* matching the app's other inline copy buttons (icon swap, no toast). Uses
|
||||
* Electron's clipboard IPC, which wraps navigator.clipboard.writeText and avoids
|
||||
* the silent failures navigator.clipboard hits inside some renderer contexts.
|
||||
*/
|
||||
export function NativeChatCopyButton({
|
||||
text,
|
||||
className
|
||||
}: {
|
||||
text: string
|
||||
className?: string
|
||||
}): React.JSX.Element {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const resetTimerRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (resetTimerRef.current !== null) {
|
||||
window.clearTimeout(resetTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
await window.api.ui.writeClipboardText(text)
|
||||
setCopied(true)
|
||||
if (resetTimerRef.current !== null) {
|
||||
window.clearTimeout(resetTimerRef.current)
|
||||
}
|
||||
resetTimerRef.current = window.setTimeout(() => {
|
||||
resetTimerRef.current = null
|
||||
setCopied(false)
|
||||
}, 1500)
|
||||
} catch {
|
||||
/* best-effort: clipboard can reject when unfocused */
|
||||
}
|
||||
}, [text])
|
||||
|
||||
const label = copied
|
||||
? translate('components.native-chat.copyMessage.copied', 'Copied')
|
||||
: translate('components.native-chat.copyMessage.copy', 'Copy message')
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={cn(
|
||||
'flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
copied && 'text-status-success',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { DiffLine } from './native-chat-diff'
|
||||
|
||||
/** Inline coloured diff, used for Edit/Write tool calls and diff-style tool
|
||||
* results. Adds/dels use the git-decoration tokens with a faint tinted ground,
|
||||
* matching the terminal's diff palette (no invented colors). */
|
||||
export function NativeChatDiffView({ lines }: { lines: DiffLine[] }): React.JSX.Element {
|
||||
return (
|
||||
<div className="overflow-hidden rounded bg-accent py-1 font-mono text-[11px] leading-relaxed">
|
||||
{lines.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'whitespace-pre-wrap break-words px-2',
|
||||
line.kind === 'add' && 'bg-emerald-500/10 text-[var(--git-decoration-added)]',
|
||||
line.kind === 'del' && 'bg-rose-500/10 text-[var(--git-decoration-deleted)]',
|
||||
line.kind === 'meta' && 'text-muted-foreground',
|
||||
line.kind === 'context' && 'text-foreground/70'
|
||||
)}
|
||||
>
|
||||
{line.kind === 'add' ? '+' : line.kind === 'del' ? '-' : ' '}
|
||||
{line.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { MessageSquare, TriangleAlert } from 'lucide-react'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { formatAgentTypeLabel } from '@/lib/agent-status'
|
||||
import type { NativeChatSession } from '../../../../shared/native-chat-types'
|
||||
|
||||
export function NativeChatEmptyState({
|
||||
kind,
|
||||
message,
|
||||
agent
|
||||
}: {
|
||||
kind: 'loading' | 'empty' | 'error' | 'not-agent'
|
||||
message?: string
|
||||
agent?: NativeChatSession['agent']
|
||||
}): React.JSX.Element {
|
||||
const copy = emptyStateCopy(kind, message, agent)
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3 p-6 text-center">
|
||||
<div
|
||||
className={
|
||||
kind === 'error'
|
||||
? 'flex size-12 items-center justify-center rounded-full bg-destructive/10 text-destructive'
|
||||
: 'flex size-12 items-center justify-center rounded-full bg-accent text-accent-foreground'
|
||||
}
|
||||
>
|
||||
{kind === 'error' ? (
|
||||
<TriangleAlert className="size-6" />
|
||||
) : (
|
||||
<MessageSquare className="size-6" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm font-medium text-foreground">{copy.title}</p>
|
||||
{copy.subtitle ? (
|
||||
<p className="max-w-sm text-balance text-xs text-muted-foreground">{copy.subtitle}</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function emptyStateCopy(
|
||||
kind: 'loading' | 'empty' | 'error' | 'not-agent',
|
||||
message?: string,
|
||||
agent?: NativeChatSession['agent']
|
||||
): { title: string; subtitle: string | null } {
|
||||
switch (kind) {
|
||||
case 'loading':
|
||||
return {
|
||||
title: translate('components.native-chat.state.loading.title', 'Loading conversation…'),
|
||||
subtitle: translate(
|
||||
'components.native-chat.state.loading.subtitle',
|
||||
'Reading the agent transcript.'
|
||||
)
|
||||
}
|
||||
case 'error':
|
||||
return {
|
||||
title: translate('components.native-chat.state.error.title', 'Could not load conversation'),
|
||||
subtitle:
|
||||
message ??
|
||||
translate(
|
||||
'components.native-chat.state.error.subtitle',
|
||||
'The transcript could not be read. Toggle back to the terminal to keep working.'
|
||||
)
|
||||
}
|
||||
case 'not-agent':
|
||||
return {
|
||||
title: translate('components.native-chat.state.notAgent.title', 'No conversation here'),
|
||||
subtitle: translate(
|
||||
'components.native-chat.state.notAgent.subtitle',
|
||||
'This terminal is not running a recognized coding agent.'
|
||||
)
|
||||
}
|
||||
case 'empty': {
|
||||
const agentName = agent ? formatAgentTypeLabel(agent) : 'the agent'
|
||||
return {
|
||||
title: translate(
|
||||
'components.native-chat.state.empty.title',
|
||||
'Start a chat with {{value0}}',
|
||||
{ value0: agentName }
|
||||
),
|
||||
subtitle: translate(
|
||||
'components.native-chat.state.empty.subtitle',
|
||||
'Ask {{value0}} to inspect code, explain output, or make a change.',
|
||||
{ value0: agentName }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useAppStore } from '../../store'
|
||||
import { parseInteractivePrompt } from './native-chat-interactive-prompt'
|
||||
import { nativeChatCardDismissKey } from './native-chat-dismiss-key'
|
||||
import { NativeChatQuestionCard } from './NativeChatQuestionCard'
|
||||
import { NativeChatApprovalCard } from './NativeChatApprovalCard'
|
||||
import type { NativeChatInteractiveSend } from './use-native-chat-interactive-send'
|
||||
|
||||
/**
|
||||
* Render the live interactive card for the pane while the agent's
|
||||
* `interactivePrompt` is present: a question wizard (precedence) or a tool
|
||||
* approval. Cleared by the host once the agent moves on, so it disappears
|
||||
* automatically. Sends through the composer's verified runtime path (R8/R6):
|
||||
* answers as bracketed-paste + Enter; cancel/deny as ESC. Guarded by `canSend`
|
||||
* so a mobile presence-lock blocks desktop sends the same way it guards xterm.
|
||||
*
|
||||
* Dismiss-on-answer (mobile parity): the live status lingers after answering —
|
||||
* the agent emits a post-tool event carrying the same prompt — so we track the
|
||||
* answered prompt by content key and hide the card until a genuinely different
|
||||
* prompt arrives. The dismissal resets once the prompt clears, so a later
|
||||
* (even identical) prompt shows again instead of staying hidden.
|
||||
*/
|
||||
export function NativeChatInteractiveCard({
|
||||
paneKey,
|
||||
send,
|
||||
canSend
|
||||
}: {
|
||||
paneKey: string
|
||||
send: NativeChatInteractiveSend
|
||||
canSend: boolean
|
||||
}): React.JSX.Element | null {
|
||||
const interactivePrompt = useAppStore(
|
||||
(s) => s.agentStatusByPaneKey[paneKey]?.interactivePrompt ?? null
|
||||
)
|
||||
// Thread the sibling `toolName` from the same status entry so the question
|
||||
// parser can dispatch through the tool's registered parser (mobile parity).
|
||||
const interactiveToolName = useAppStore(
|
||||
(s) => s.agentStatusByPaneKey[paneKey]?.toolName ?? null
|
||||
)
|
||||
const { sendAnswer, sendRaw, cancel } = send
|
||||
|
||||
const card = useMemo(
|
||||
() => parseInteractivePrompt(interactivePrompt, interactiveToolName ?? undefined),
|
||||
[interactivePrompt, interactiveToolName]
|
||||
)
|
||||
const cardKey = useMemo(() => nativeChatCardDismissKey(card), [card])
|
||||
const [dismissedKey, setDismissedKey] = useState<string | null>(null)
|
||||
|
||||
// Forget the dismissal once the prompt clears so a fresh prompt can show.
|
||||
const present = card != null
|
||||
useEffect(() => {
|
||||
if (!present) {
|
||||
setDismissedKey(null)
|
||||
}
|
||||
}, [present])
|
||||
|
||||
if (!card || !canSend || cardKey === dismissedKey) {
|
||||
return null
|
||||
}
|
||||
if (card.kind === 'question') {
|
||||
return (
|
||||
<NativeChatQuestionCard
|
||||
key={cardKey ?? 'question'}
|
||||
prompt={card.prompt}
|
||||
onAnswer={(text) => {
|
||||
setDismissedKey(cardKey)
|
||||
sendAnswer(text)
|
||||
}}
|
||||
onCancel={() => {
|
||||
setDismissedKey(cardKey)
|
||||
cancel()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<NativeChatApprovalCard
|
||||
approval={card.approval}
|
||||
onChoose={(raw) => {
|
||||
setDismissedKey(cardKey)
|
||||
sendRaw(raw)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ArrowDown, ArrowUp, Image as ImageIcon } from 'lucide-react'
|
||||
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { basename } from '@/lib/path'
|
||||
import {
|
||||
isTextBlock,
|
||||
type NativeChatBlock,
|
||||
type NativeChatMessage
|
||||
} from '../../../../shared/native-chat-types'
|
||||
import type { NativeChatLiveSession } from './use-native-chat-live-session'
|
||||
import { orderNativeChatMessages } from './native-chat-message-grouping'
|
||||
import { stripNoiseMessages } from './native-chat-noise'
|
||||
import { foldToolMessages, splitNativeChatBlocks } from './native-chat-tool-fold'
|
||||
import { isNearBottom, shouldShowJumpToLatest, type ScrollGeometry } from './native-chat-autoscroll'
|
||||
import { NativeChatToolRun } from './NativeChatToolRun'
|
||||
import { NativeChatCopyButton } from './NativeChatCopyButton'
|
||||
import { NATIVE_CHAT_STREAMING_ID } from '../../../../shared/native-chat-streaming'
|
||||
|
||||
function geometryOf(el: HTMLElement): ScrollGeometry {
|
||||
return { scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight }
|
||||
}
|
||||
|
||||
function proseToMarkdown(blocks: NativeChatBlock[]): string {
|
||||
return blocks
|
||||
.map((block) => {
|
||||
if (isTextBlock(block)) {
|
||||
return block.text
|
||||
}
|
||||
return ''
|
||||
})
|
||||
.filter((part) => part.length > 0)
|
||||
.join('\n\n')
|
||||
}
|
||||
|
||||
function ImageAttachmentRefs({ blocks }: { blocks: NativeChatBlock[] }): React.JSX.Element | null {
|
||||
const images = blocks.filter((block) => block.type === 'image-ref')
|
||||
if (images.length === 0) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
{images.map((image, index) => {
|
||||
const label = image.alt ?? image.path ?? image.url ?? 'Image'
|
||||
const name = image.path ? basename(image.path) : label
|
||||
return (
|
||||
<div
|
||||
key={`${label}-${index}`}
|
||||
className="flex max-w-full items-center gap-1.5 rounded-md border border-border bg-background px-2 py-1 text-xs text-muted-foreground"
|
||||
title={label}
|
||||
>
|
||||
<ImageIcon className="size-3.5 shrink-0" />
|
||||
<span className="truncate">{name}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Inline controls for an agent message (mobile AgentControls parity): copy the
|
||||
* message's prose, and scroll so this message's top aligns to the viewport top.
|
||||
* Reveals on hover / keyboard focus like the prior copy affordance. */
|
||||
function AgentControls({
|
||||
markdown,
|
||||
onScrollToTop,
|
||||
className
|
||||
}: {
|
||||
markdown: string
|
||||
onScrollToTop: () => void
|
||||
className?: string
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className={cn('flex items-center gap-1', className)}>
|
||||
<NativeChatCopyButton text={markdown} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onScrollToTop}
|
||||
aria-label={translate(
|
||||
'components.native-chat.scrollMessageToTop',
|
||||
'Scroll this message to top'
|
||||
)}
|
||||
title={translate('components.native-chat.scrollMessageToTop', 'Scroll this message to top')}
|
||||
className="flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<ArrowUp className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TypingIndicatorRow(): React.JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-start"
|
||||
aria-label={translate('components.native-chat.status.responding', 'Agent is responding')}
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="flex h-8 items-center gap-1.5 text-muted-foreground">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="size-1.5 animate-bounce rounded-full bg-muted-foreground/70"
|
||||
// Stagger the three dots so they ripple rather than pulse in unison.
|
||||
style={{ animationDelay: `${i * 160}ms` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** One message: its prose first, then a collapsible run folding all of the
|
||||
* turn's tool activity. Monochrome per STYLEGUIDE: user prompts read as a
|
||||
* lifted card, assistant prose as body copy, reasoning de-emphasized. */
|
||||
function MessageRow({
|
||||
message,
|
||||
expandSignal,
|
||||
onScrollMessageToTop
|
||||
}: {
|
||||
message: NativeChatMessage
|
||||
expandSignal: boolean
|
||||
/** Align this message's top to the top of the scroll viewport. */
|
||||
onScrollMessageToTop: (el: HTMLElement) => void
|
||||
}): React.JSX.Element | null {
|
||||
const rowRef = useRef<HTMLDivElement | null>(null)
|
||||
const { prose, tools } = useMemo(() => splitNativeChatBlocks(message.blocks), [message.blocks])
|
||||
const markdown = proseToMarkdown(prose)
|
||||
const hasImages = prose.some((block) => block.type === 'image-ref')
|
||||
const isUser = message.role === 'user'
|
||||
const isReasoning = message.role === 'reasoning'
|
||||
const isSystem = message.role === 'system'
|
||||
|
||||
const scrollToTop = useCallback(() => {
|
||||
if (rowRef.current) {
|
||||
onScrollMessageToTop(rowRef.current)
|
||||
}
|
||||
}, [onScrollMessageToTop])
|
||||
|
||||
// Skip rows with nothing renderable so the transcript shows no empty/ghost
|
||||
// bubble.
|
||||
// After all hooks, so hook order stays unconditional.
|
||||
if (markdown.length === 0 && !hasImages && tools.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (isUser) {
|
||||
// Why: an optimistic echo is rendered identically to a real user turn (no
|
||||
// muting, no "Queued" label) so that when the real transcript turn lands and
|
||||
// replaces it, there is no visible state change — the send just appears and
|
||||
// stays. (A distinct "queued" treatment flickered normal→queued→normal as the
|
||||
// transcript caught up.)
|
||||
return (
|
||||
<div ref={rowRef} className="flex flex-col items-end gap-0.5">
|
||||
<div className="max-w-[85%] rounded-xl rounded-tr-sm border border-border bg-card px-3 py-2 text-sm text-card-foreground">
|
||||
{markdown ? (
|
||||
<>
|
||||
<ImageAttachmentRefs blocks={prose} />
|
||||
<CommentMarkdown content={markdown} variant="document" className="text-sm" />
|
||||
</>
|
||||
) : (
|
||||
<ImageAttachmentRefs blocks={prose} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Plain assistant prose is the copyable unit; reasoning/system asides stay
|
||||
// chrome-free. The controls reveal on hover (and on keyboard focus-within).
|
||||
const showControls = !isReasoning && !isSystem && markdown.length > 0
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rowRef}
|
||||
className={cn(
|
||||
'group relative max-w-full text-sm leading-relaxed text-foreground',
|
||||
// Reasoning is the agent thinking aloud — quieter, italic, like an aside.
|
||||
isReasoning && 'border-l-2 border-border/60 pl-3 italic text-muted-foreground',
|
||||
isSystem && 'text-xs text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{showControls ? (
|
||||
<AgentControls
|
||||
markdown={markdown}
|
||||
onScrollToTop={scrollToTop}
|
||||
className="absolute -top-1 right-0 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
|
||||
/>
|
||||
) : null}
|
||||
<ImageAttachmentRefs blocks={prose} />
|
||||
{markdown ? (
|
||||
<CommentMarkdown content={markdown} variant="document" className="text-sm" />
|
||||
) : null}
|
||||
{tools.length > 0 ? <NativeChatToolRun blocks={tools} expandSignal={expandSignal} /> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function NativeChatMessageList({
|
||||
session,
|
||||
isWorking,
|
||||
expandSignal,
|
||||
fontScale
|
||||
}: {
|
||||
session: NativeChatLiveSession
|
||||
isWorking: boolean
|
||||
/** Toolbar-driven desired open state for every tool run; each flip re-syncs. */
|
||||
expandSignal: boolean
|
||||
/** Chat-only text multiplier (1 = default), driven by the zoom shortcuts. */
|
||||
fontScale: number
|
||||
}): React.JSX.Element {
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null)
|
||||
const [stuckToBottom, setStuckToBottom] = useState(true)
|
||||
const [showJump, setShowJump] = useState(false)
|
||||
|
||||
// Why: mirror stuck state into a ref so the auto-scroll layout effect can read
|
||||
// it without depending on it — depending on stuckToBottom (which scrollToBottom
|
||||
// sets) would re-fire the effect in a self-loop.
|
||||
const stuckToBottomRef = useRef(stuckToBottom)
|
||||
stuckToBottomRef.current = stuckToBottom
|
||||
|
||||
const { hasMore, loadingEarlier, loadEarlier } = session
|
||||
|
||||
// Strip harness noise (task-notifications, system reminders, slash-command
|
||||
// envelopes) before folding so they don't render as the user's own bubbles —
|
||||
// matching the mobile chat. Then fold each turn's tool activity into the
|
||||
// assistant message it belongs to, ordered stably, so a turn's tools collapse
|
||||
// under one run.
|
||||
const messages = useMemo(
|
||||
() => foldToolMessages(orderNativeChatMessages(stripNoiseMessages(session.messages))),
|
||||
[session.messages]
|
||||
)
|
||||
const showTypingIndicator =
|
||||
isWorking && !messages.some((message) => message.id === NATIVE_CHAT_STREAMING_ID)
|
||||
|
||||
// When an older page prepends, the scroll content grows above the viewport.
|
||||
// Capture the pre-render scroll height so the layout effect can restore the
|
||||
// user's position (no jump) instead of letting the browser keep scrollTop.
|
||||
const prependAnchorRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null)
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
const geometry = geometryOf(el)
|
||||
const stick = isNearBottom(geometry)
|
||||
setStuckToBottom(stick)
|
||||
setShowJump(shouldShowJumpToLatest(stick, geometry))
|
||||
// Near the top — page in older history, anchoring the current position so the
|
||||
// prepend doesn't yank the view.
|
||||
if (geometry.scrollTop < 80 && hasMore && !loadingEarlier) {
|
||||
prependAnchorRef.current = { scrollHeight: el.scrollHeight, scrollTop: el.scrollTop }
|
||||
loadEarlier()
|
||||
}
|
||||
}, [hasMore, loadingEarlier, loadEarlier])
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
el.scrollTop = el.scrollHeight
|
||||
setStuckToBottom(true)
|
||||
setShowJump(false)
|
||||
}, [])
|
||||
|
||||
// Align a single message's top to the top of the scroll viewport.
|
||||
const scrollMessageToTop = useCallback((el: HTMLElement) => {
|
||||
const container = scrollRef.current
|
||||
if (!container) {
|
||||
return
|
||||
}
|
||||
const delta = el.getBoundingClientRect().top - container.getBoundingClientRect().top
|
||||
container.scrollTo({ top: container.scrollTop + delta, behavior: 'smooth' })
|
||||
}, [])
|
||||
|
||||
// Re-pin to the bottom when new content arrives, but only if the user hasn't
|
||||
// scrolled up. Layout effect so the jump happens before paint (no flicker).
|
||||
// When an older page just prepended, restore the prior position instead.
|
||||
useLayoutEffect(() => {
|
||||
const el = scrollRef.current
|
||||
if (el && prependAnchorRef.current) {
|
||||
// Preserve the viewport: shift scrollTop by however much taller the content
|
||||
// got, so the message the user was reading stays put.
|
||||
const grew = el.scrollHeight - prependAnchorRef.current.scrollHeight
|
||||
el.scrollTop = prependAnchorRef.current.scrollTop + grew
|
||||
prependAnchorRef.current = null
|
||||
return
|
||||
}
|
||||
if (stuckToBottomRef.current) {
|
||||
scrollToBottom()
|
||||
}
|
||||
}, [messages.length, isWorking, showTypingIndicator, scrollToBottom])
|
||||
|
||||
// Keep the affordances in sync if the container resizes (e.g. composer mounts,
|
||||
// viewport reflow) without a scroll event.
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el || typeof ResizeObserver === 'undefined') {
|
||||
return
|
||||
}
|
||||
const observer = new ResizeObserver(handleScroll)
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [handleScroll])
|
||||
|
||||
return (
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="scrollbar-sleek h-full overflow-y-auto px-3 pt-10 pb-4 sm:px-4"
|
||||
>
|
||||
<div
|
||||
className="mx-auto flex w-full max-w-3xl flex-col gap-3"
|
||||
// Why: `zoom` scales the chat transcript's text and layout together,
|
||||
// scoped to this container so the rest of the app is untouched. It's
|
||||
// the desktop analog of the mobile pinch-zoom (Chromium/Electron only).
|
||||
style={{ zoom: fontScale }}
|
||||
>
|
||||
{hasMore ? (
|
||||
<div className="flex justify-center py-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadEarlier}
|
||||
disabled={loadingEarlier}
|
||||
className="rounded-md px-3 py-1 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
{loadingEarlier
|
||||
? translate('components.native-chat.loadingEarlier', 'Loading…')
|
||||
: translate('components.native-chat.loadEarlier', 'Load earlier messages')}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{messages.map((message) => (
|
||||
<MessageRow
|
||||
key={message.id}
|
||||
message={message}
|
||||
expandSignal={expandSignal}
|
||||
onScrollMessageToTop={scrollMessageToTop}
|
||||
/>
|
||||
))}
|
||||
{showTypingIndicator ? <TypingIndicatorRow /> : null}
|
||||
</div>
|
||||
</div>
|
||||
{showJump ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={scrollToBottom}
|
||||
aria-label={translate('components.native-chat.jumpToLatest', 'Jump to latest')}
|
||||
className="absolute bottom-3 left-1/2 flex -translate-x-1/2 items-center gap-1.5 rounded-full border border-border bg-card/90 px-3 py-1.5 text-xs text-muted-foreground shadow-sm backdrop-blur hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<ArrowDown className="size-3.5" />
|
||||
<span>{translate('components.native-chat.jumpToLatest', 'Jump to latest')}</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Check } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { formatAskAnswer, type AskPrompt } from './native-chat-interactive-prompt'
|
||||
|
||||
export type NativeChatQuestionCardProps = {
|
||||
prompt: AskPrompt
|
||||
/** Send the formatted answer text to the agent. */
|
||||
onAnswer: (text: string) => void
|
||||
/** Dismiss the prompt (sends Escape to the agent). */
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
// Synthetic option value for the "Other…" free-text row, kept out of the
|
||||
// answer text and replaced by the typed value when selected.
|
||||
const OTHER = '__other__'
|
||||
|
||||
/**
|
||||
* Native renderer for an agent's AskUserQuestion prompt as a wizard: one
|
||||
* question per step with tabs across the top (tap to jump, a check once
|
||||
* answered), single- or multi-select option rows, an "Other…" row that reveals a
|
||||
* free-text input, and a Next button that advances and becomes "Send answer" on
|
||||
* the last step. Matches the desktop chat's neutral shadcn styling.
|
||||
*/
|
||||
export function NativeChatQuestionCard({
|
||||
prompt,
|
||||
onAnswer,
|
||||
onCancel
|
||||
}: NativeChatQuestionCardProps): React.JSX.Element {
|
||||
const [index, setIndex] = useState(0)
|
||||
const [selections, setSelections] = useState<string[][]>(() => prompt.questions.map(() => []))
|
||||
const [otherText, setOtherText] = useState<string[]>(() => prompt.questions.map(() => ''))
|
||||
|
||||
const toggle = (qi: number, label: string, multi: boolean): void => {
|
||||
setSelections((prev) => {
|
||||
const next = prev.map((s) => [...s])
|
||||
const cur = next[qi] ?? []
|
||||
if (multi) {
|
||||
next[qi] = cur.includes(label) ? cur.filter((l) => l !== label) : [...cur, label]
|
||||
} else {
|
||||
next[qi] = cur.includes(label) ? [] : [label]
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const setOther = (qi: number, value: string): void => {
|
||||
setOtherText((prev) => {
|
||||
const next = [...prev]
|
||||
next[qi] = value
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// The resolved answer for a question: picked labels plus the typed "Other"
|
||||
// value (which replaces the synthetic OTHER marker).
|
||||
const answerFor = (qi: number): string => {
|
||||
const picked = (selections[qi] ?? []).filter((l) => l !== OTHER)
|
||||
const other = (selections[qi] ?? []).includes(OTHER) ? (otherText[qi] ?? '').trim() : ''
|
||||
return [...picked, other].filter((p) => p.length > 0).join(', ')
|
||||
}
|
||||
|
||||
const total = prompt.questions.length
|
||||
const isLast = index === total - 1
|
||||
const currentAnswered = useMemo(
|
||||
() => answerFor(index).length > 0,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[selections, otherText, index]
|
||||
)
|
||||
|
||||
const submit = (): void => {
|
||||
// Build per-question label lists, substituting the typed Other value, then
|
||||
// format to one line per answered question.
|
||||
const resolved = prompt.questions.map((_, i) => {
|
||||
const picked = (selections[i] ?? []).filter((l) => l !== OTHER)
|
||||
const other = (selections[i] ?? []).includes(OTHER) ? (otherText[i] ?? '').trim() : ''
|
||||
return [...picked, ...(other ? [other] : [])]
|
||||
})
|
||||
const text = formatAskAnswer(prompt, resolved)
|
||||
if (text.length > 0) {
|
||||
onAnswer(text)
|
||||
}
|
||||
}
|
||||
|
||||
const advance = (): void => {
|
||||
if (isLast) {
|
||||
submit()
|
||||
} else {
|
||||
setIndex((i) => Math.min(i + 1, total - 1))
|
||||
}
|
||||
}
|
||||
|
||||
const q = prompt.questions[index]!
|
||||
const otherSelected = (selections[index] ?? []).includes(OTHER)
|
||||
|
||||
return (
|
||||
<div className="shrink-0 border-t border-border bg-muted/30">
|
||||
<div className="mx-auto flex max-h-[22rem] w-full max-w-3xl flex-col px-3 py-2 sm:px-4">
|
||||
{total > 1 ? (
|
||||
<div className="flex gap-1 overflow-x-auto border-b border-border pb-2 scrollbar-sleek">
|
||||
{prompt.questions.map((qq, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => setIndex(i)}
|
||||
className={cn(
|
||||
'flex shrink-0 items-center gap-1 rounded-md px-2 py-1 text-xs font-medium',
|
||||
i === index
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<span className="max-w-[10rem] truncate">
|
||||
{qq.header ||
|
||||
translate('components.native-chat.question.step', 'Step {{value0}}', {
|
||||
value0: i + 1
|
||||
})}
|
||||
</span>
|
||||
{answerFor(i).length > 0 ? (
|
||||
<Check className="size-3 text-primary" strokeWidth={3} />
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto py-2 scrollbar-sleek">
|
||||
<p className="mb-2 text-sm font-semibold text-foreground">{q.question}</p>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{q.options.map((opt) => (
|
||||
<OptionRow
|
||||
key={opt.label}
|
||||
label={opt.label}
|
||||
description={opt.description}
|
||||
selected={(selections[index] ?? []).includes(opt.label)}
|
||||
onSelect={() => toggle(index, opt.label, q.multiSelect)}
|
||||
/>
|
||||
))}
|
||||
<OptionRow
|
||||
label={translate('components.native-chat.question.other', 'Other…')}
|
||||
selected={otherSelected}
|
||||
onSelect={() => toggle(index, OTHER, q.multiSelect)}
|
||||
/>
|
||||
{otherSelected ? (
|
||||
<textarea
|
||||
autoFocus
|
||||
value={otherText[index]}
|
||||
onChange={(e) => setOther(index, e.target.value)}
|
||||
placeholder={translate(
|
||||
'components.native-chat.question.otherPlaceholder',
|
||||
'Type your answer'
|
||||
)}
|
||||
rows={2}
|
||||
className="w-full resize-none rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs outline-none placeholder:text-muted-foreground/60 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-input/30"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 border-t border-border pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded-md px-2 py-1 text-sm font-medium text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{translate('components.native-chat.question.cancel', 'Cancel')}
|
||||
</button>
|
||||
{total > 1 ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{index + 1}/{total}
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={advance}
|
||||
disabled={!currentAnswered}
|
||||
className={cn(
|
||||
'rounded-md bg-primary px-4 py-1.5 text-sm font-semibold text-primary-foreground transition-colors',
|
||||
'hover:bg-primary/90 disabled:pointer-events-none disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
{isLast
|
||||
? translate('components.native-chat.question.send', 'Send answer')
|
||||
: translate('components.native-chat.question.next', 'Next')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OptionRow({
|
||||
label,
|
||||
description,
|
||||
selected,
|
||||
onSelect
|
||||
}: {
|
||||
label: string
|
||||
description?: string
|
||||
selected: boolean
|
||||
onSelect: () => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
'flex w-full items-start gap-2.5 rounded-md border bg-background px-3 py-2 text-left transition-colors',
|
||||
selected ? 'border-primary' : 'border-border hover:bg-accent/50'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border',
|
||||
selected ? 'border-primary bg-primary text-primary-foreground' : 'border-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{selected ? <Check className="size-3" strokeWidth={3} /> : null}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium text-foreground">{label}</span>
|
||||
{description ? (
|
||||
<span className="block text-xs text-muted-foreground">{description}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ChevronDown, SquareChevronRight } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
isToolCallBlock,
|
||||
isToolResultBlock,
|
||||
type NativeChatBlock
|
||||
} from '../../../../shared/native-chat-types'
|
||||
import { diffFromText, diffFromToolCall, type DiffLine } from './native-chat-diff'
|
||||
import { countToolCalls, summarizeToolInput, summarizeToolRun } from './native-chat-tool-summary'
|
||||
import { NativeChatDiffView } from './NativeChatDiffView'
|
||||
|
||||
const MAX_TOOL_RESULT_CHARS = 4000
|
||||
|
||||
/** A single inline tool line — `▸ ToolName preview` — that expands in place to
|
||||
* show the call's diff/input or the result's body. Tool calls read as flat
|
||||
* lines in the conversation rather than boxed blocks (mobile parity). */
|
||||
function ToolLine({ block }: { block: NativeChatBlock }): React.JSX.Element | null {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
let name: string
|
||||
let preview: string
|
||||
let diff: DiffLine[] | null = null
|
||||
let body: { output: string; isError?: boolean } | null = null
|
||||
|
||||
if (isToolCallBlock(block)) {
|
||||
name = block.name
|
||||
preview = summarizeToolInput(block.input)
|
||||
diff = diffFromToolCall(block.name, block.input)
|
||||
} else if (isToolResultBlock(block)) {
|
||||
name = translate('components.native-chat.tool.result', 'Result')
|
||||
preview = block.output.split('\n')[0]?.slice(0, 80) ?? ''
|
||||
diff = diffFromText(block.output)
|
||||
body = { output: block.output, isError: block.isError }
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
|
||||
const hasDetail = diff !== null || body !== null || preview.length > 40
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => hasDetail && setExpanded((v) => !v)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-1.5 py-0.5 text-left',
|
||||
hasDetail ? 'cursor-pointer' : 'cursor-default'
|
||||
)}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<SquareChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<code className="shrink-0 font-mono text-xs font-semibold text-foreground/90">{name}</code>
|
||||
{preview ? (
|
||||
<span
|
||||
className="min-w-0 truncate font-mono text-[11px] text-muted-foreground"
|
||||
title={preview}
|
||||
>
|
||||
{preview}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
{expanded ? (
|
||||
<div className="space-y-1.5 py-1 pl-5">
|
||||
{diff ? <NativeChatDiffView lines={diff} /> : null}
|
||||
{!diff && body ? (
|
||||
<pre
|
||||
className={cn(
|
||||
'max-h-64 overflow-auto whitespace-pre-wrap break-words rounded bg-accent p-2 font-mono text-[11px] scrollbar-sleek',
|
||||
body.isError ? 'text-destructive' : 'text-foreground/80'
|
||||
)}
|
||||
>
|
||||
{body.output.length > MAX_TOOL_RESULT_CHARS
|
||||
? `${body.output.slice(0, MAX_TOOL_RESULT_CHARS)}…`
|
||||
: body.output}
|
||||
</pre>
|
||||
) : null}
|
||||
{!diff && !body && preview ? (
|
||||
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-words rounded bg-accent p-2 font-mono text-[11px] text-foreground/80 scrollbar-sleek">
|
||||
{preview}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A run of a message's tool calls/results, collapsed to a one-line summary that
|
||||
* expands to the individual inline tool lines. `expandSignal` lets the global
|
||||
* toolbar toggle drive every run at once while still allowing per-run override. */
|
||||
export function NativeChatToolRun({
|
||||
blocks,
|
||||
expandSignal
|
||||
}: {
|
||||
blocks: NativeChatBlock[]
|
||||
/** Toolbar-driven desired open state. Each change re-syncs this run's state. */
|
||||
expandSignal: boolean
|
||||
}): React.JSX.Element {
|
||||
const [open, setOpen] = useState(expandSignal)
|
||||
// Re-sync when the global toolbar toggle flips.
|
||||
useEffect(() => setOpen(expandSignal), [expandSignal])
|
||||
|
||||
const callCount = countToolCalls(blocks) || blocks.length
|
||||
const summary = summarizeToolRun(blocks)
|
||||
const fallbackLabel = translate(
|
||||
callCount === 1 ? 'components.native-chat.tool.countOne' : 'components.native-chat.tool.countN',
|
||||
callCount === 1 ? '1 tool call' : `${callCount} tool calls`,
|
||||
{ count: callCount }
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="mt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center gap-1.5 py-0.5 text-left"
|
||||
>
|
||||
{open ? (
|
||||
<ChevronDown className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<SquareChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="shrink-0 font-mono text-[11px] font-bold text-muted-foreground">
|
||||
{callCount}×
|
||||
</span>
|
||||
<span className="min-w-0 truncate font-mono text-[11px] text-muted-foreground">
|
||||
{summary || fallbackLabel}
|
||||
</span>
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="mt-1 border-l-2 border-border/60 pl-2.5">
|
||||
{blocks.map((block, i) => (
|
||||
<ToolLine key={i} block={block} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useAppStore } from '../../store'
|
||||
import { APP_MENU_PASTE_EVENT } from '@/lib/app-menu-paste'
|
||||
import type { TuiAgent } from '../../../../shared/types'
|
||||
import type { NativeChatSession } from '../../../../shared/native-chat-types'
|
||||
import { resolveNativeChatSession } from './native-chat-pane-resolution'
|
||||
import { useNativeChatLiveSession } from './use-native-chat-live-session'
|
||||
import { selectNativeChatViewState } from './native-chat-view-state'
|
||||
import { NativeChatMessageList } from './NativeChatMessageList'
|
||||
import { NativeChatComposer, type NativeChatComposerHandle } from './NativeChatComposer'
|
||||
import { useNativeChatFontScale } from './use-native-chat-font-scale'
|
||||
import { useNativeChatCanSend } from './use-native-chat-can-send'
|
||||
import { NativeChatInteractiveCard } from './NativeChatInteractiveCard'
|
||||
import { NativeChatEmptyState } from './NativeChatEmptyState'
|
||||
import { useNativeChatInteractiveSend } from './use-native-chat-interactive-send'
|
||||
import { findTabAgentEntry } from './native-chat-tab-agent-entry'
|
||||
import {
|
||||
shouldClearNativeChatWorkingSuppression,
|
||||
shouldShowNativeChatWorking
|
||||
} from './native-chat-working-suppression'
|
||||
import {
|
||||
applyCommandMarkerBoundaries,
|
||||
appendPendingSendCache,
|
||||
commandMarkersAsMessages,
|
||||
appendCommandMarkerCache,
|
||||
pendingSendsAsMessages,
|
||||
prunePendingSends,
|
||||
readCommandMarkerCache,
|
||||
readPendingSendCache,
|
||||
writePendingSendCache,
|
||||
type NativeChatCommandMarker,
|
||||
type NativeChatPendingSend
|
||||
} from './native-chat-pending'
|
||||
import {
|
||||
deriveNativeChatStreamingText,
|
||||
nativeChatStreamingMessage
|
||||
} from '../../../../shared/native-chat-streaming'
|
||||
import {
|
||||
shouldFocusNativeChatPaneFromPointerTarget,
|
||||
shouldRedirectNativeChatTyping
|
||||
} from './native-chat-typing-redirect'
|
||||
import { useNativeChatContextMenu } from './use-native-chat-context-menu'
|
||||
import type { NativeChatContextMenuActions } from './use-native-chat-context-menu'
|
||||
import { isMacPlatform } from './native-chat-shortcut'
|
||||
|
||||
const NATIVE_CHAT_CONTEXT_PASTE_MAX_BYTES = 16 * 1024 * 1024
|
||||
const emptyNativeChatContextMenuActions: Omit<NativeChatContextMenuActions, 'onPaste'> = {
|
||||
onSplitRight: () => {},
|
||||
onSplitDown: () => {},
|
||||
canEqualizePaneSizes: false,
|
||||
onEqualizePaneSizes: () => {},
|
||||
canExpandPane: false,
|
||||
isPaneExpanded: false,
|
||||
onToggleExpand: () => {},
|
||||
onForkAgentSession: () => {},
|
||||
onSetTitle: () => {},
|
||||
onCopyTerminalId: () => {},
|
||||
onCopyPaneId: () => {},
|
||||
canClosePane: false,
|
||||
onClosePane: () => {}
|
||||
}
|
||||
|
||||
export type NativeChatViewProps = {
|
||||
/** The terminal tab hosting the agent. paneKey is `${tabId}:${leafId}`. */
|
||||
terminalTabId: string
|
||||
/** Specific split leaf this chat surface replaces. */
|
||||
paneKey?: string
|
||||
/** PTY bound to `paneKey`, used for composer and interactive-card sends. */
|
||||
targetPtyId?: string | null
|
||||
/** Launch-time agent hint from the TerminalTab, when Orca started one. */
|
||||
launchAgent?: TuiAgent | null
|
||||
/** Return this pane to the hosted terminal surface. */
|
||||
onSwitchToTerminal?: () => void
|
||||
contextMenuActions?: Omit<NativeChatContextMenuActions, 'onPaste'>
|
||||
}
|
||||
|
||||
/**
|
||||
* Native chat surface for an agent terminal. Resolves the pane to its agent +
|
||||
* session id, streams the assembled conversation via the U4 live-session hook,
|
||||
* and renders the message list, live status, and all empty/loading/error
|
||||
* states. When no session id is known yet the hook surfaces live hook state on
|
||||
* an empty transcript; a true scrollback-scrape fallback (U6) is wired but only
|
||||
* runs when scrollback is obtainable — it degrades to the empty state otherwise.
|
||||
*/
|
||||
export default function NativeChatView({
|
||||
terminalTabId,
|
||||
paneKey: preferredPaneKey,
|
||||
targetPtyId = null,
|
||||
launchAgent,
|
||||
onSwitchToTerminal,
|
||||
contextMenuActions
|
||||
}: NativeChatViewProps): React.JSX.Element {
|
||||
// Select only this tab's status entry (shallow-compared) so an unrelated
|
||||
// pane's status tick doesn't re-render this view or re-run the resolution.
|
||||
const agentStatusEntry = useAppStore(
|
||||
useShallow((s) =>
|
||||
preferredPaneKey
|
||||
? s.agentStatusByPaneKey[preferredPaneKey]
|
||||
: findTabAgentEntry(s.agentStatusByPaneKey, terminalTabId)
|
||||
)
|
||||
)
|
||||
|
||||
const resolution = useMemo(() => {
|
||||
// paneKey: prefer the live entry's key; fall back to the tab id so the hook
|
||||
// still has a stable key to select live status by before any pane reports.
|
||||
const paneKey = preferredPaneKey ?? agentStatusEntry?.paneKey ?? `${terminalTabId}:`
|
||||
return resolveNativeChatSession({
|
||||
paneKey,
|
||||
launchAgent,
|
||||
...(agentStatusEntry ? { agentStatusEntry } : {}),
|
||||
ptyId: targetPtyId
|
||||
})
|
||||
}, [agentStatusEntry, terminalTabId, preferredPaneKey, targetPtyId, launchAgent])
|
||||
|
||||
if (!resolution) {
|
||||
return <NativeChatEmptyState kind="not-agent" />
|
||||
}
|
||||
|
||||
return (
|
||||
<NativeChatResolvedView
|
||||
paneKey={resolution.paneKey}
|
||||
agent={resolution.agent}
|
||||
sessionId={resolution.sessionId}
|
||||
transcriptPath={resolution.transcriptPath}
|
||||
targetPtyId={targetPtyId}
|
||||
terminalTabId={terminalTabId}
|
||||
onSwitchToTerminal={onSwitchToTerminal}
|
||||
contextMenuActions={contextMenuActions}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NativeChatResolvedView({
|
||||
paneKey,
|
||||
agent,
|
||||
sessionId,
|
||||
transcriptPath,
|
||||
targetPtyId,
|
||||
terminalTabId,
|
||||
onSwitchToTerminal,
|
||||
contextMenuActions
|
||||
}: {
|
||||
paneKey: string
|
||||
agent: NativeChatSession['agent']
|
||||
sessionId: string | null
|
||||
transcriptPath: string | null
|
||||
targetPtyId: string | null
|
||||
terminalTabId: string
|
||||
onSwitchToTerminal?: () => void
|
||||
contextMenuActions?: Omit<NativeChatContextMenuActions, 'onPaste'>
|
||||
}): React.JSX.Element {
|
||||
const session = useNativeChatLiveSession({ paneKey, agent, sessionId, transcriptPath })
|
||||
// Live hook state for this pane, selected directly so the working indicator
|
||||
// flips the instant the agent reports 'working' — even when switching to chat
|
||||
// mid-turn before the transcript merge has caught up.
|
||||
const hookWorking = useAppStore((s) => s.agentStatusByPaneKey[paneKey]?.state === 'working')
|
||||
// The agent's in-progress reply preview (hook), shown as a live streaming
|
||||
// bubble while it works — before the completed turn flushes to the transcript.
|
||||
const hookPreview = useAppStore((s) => s.agentStatusByPaneKey[paneKey]?.lastAssistantMessage)
|
||||
const canSend = useNativeChatCanSend(targetPtyId)
|
||||
// Reuse the verified composer send path for interactive cards and composer
|
||||
// stop (Stop sends ESC, the agent-TUI interrupt key).
|
||||
const interactiveSend = useNativeChatInteractiveSend(terminalTabId, targetPtyId, agent)
|
||||
const [workingInterrupted, setWorkingInterrupted] = useState(false)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const composerRef = useRef<NativeChatComposerHandle>(null)
|
||||
const isMac = useMemo(() => isMacPlatform(), [])
|
||||
const pasteClipboardIntoComposer = useCallback(() => {
|
||||
void window.api.ui
|
||||
.readClipboardText({ maxBytes: NATIVE_CHAT_CONTEXT_PASTE_MAX_BYTES })
|
||||
.then((text) => {
|
||||
if (text.length > 0) {
|
||||
composerRef.current?.insertTypedText(text)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
const contextMenu = useNativeChatContextMenu({
|
||||
rootRef,
|
||||
onSwitchToTerminal,
|
||||
actions: {
|
||||
onPaste: pasteClipboardIntoComposer,
|
||||
...(contextMenuActions ?? emptyNativeChatContextMenuActions)
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const root = rootRef.current
|
||||
if (!root) {
|
||||
return
|
||||
}
|
||||
const onKeyPaste = (event: KeyboardEvent): void => {
|
||||
if (
|
||||
!matchesNativeChatPasteShortcut(event, isMac) ||
|
||||
!shouldFocusNativeChatPaneFromPointerTarget(event.target)
|
||||
) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
pasteClipboardIntoComposer()
|
||||
}
|
||||
|
||||
root.addEventListener('keydown', onKeyPaste, { capture: true })
|
||||
return () => {
|
||||
root.removeEventListener('keydown', onKeyPaste, { capture: true })
|
||||
}
|
||||
}, [isMac, pasteClipboardIntoComposer])
|
||||
|
||||
useEffect(() => {
|
||||
const onAppMenuPaste = (event: Event): void => {
|
||||
const root = rootRef.current
|
||||
const activeElement = document.activeElement
|
||||
if (
|
||||
!root ||
|
||||
!(activeElement instanceof Element) ||
|
||||
!root.contains(activeElement) ||
|
||||
!shouldFocusNativeChatPaneFromPointerTarget(activeElement)
|
||||
) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
pasteClipboardIntoComposer()
|
||||
}
|
||||
|
||||
window.addEventListener(APP_MENU_PASTE_EVENT, onAppMenuPaste)
|
||||
return () => {
|
||||
window.removeEventListener(APP_MENU_PASTE_EVENT, onAppMenuPaste)
|
||||
}
|
||||
}, [pasteClipboardIntoComposer])
|
||||
|
||||
// Optimistic "queued" sends (mobile parity): a composer send is echoed
|
||||
// immediately and pruned once its real user turn lands in the transcript, so
|
||||
// the message never vanishes between send and transcript catch-up.
|
||||
const commandMarkerScope = useMemo(
|
||||
() => ({ paneKey, agent, sessionId }),
|
||||
[paneKey, agent, sessionId]
|
||||
)
|
||||
const pendingScope = useMemo(() => ({ paneKey, agent }), [paneKey, agent])
|
||||
const [pending, setPending] = useState<NativeChatPendingSend[]>(() =>
|
||||
readPendingSendCache(pendingScope)
|
||||
)
|
||||
const pendingCounter = useRef(0)
|
||||
// Slash commands aren't chat turns, so they get a small local "Ran /clear"
|
||||
// system line instead of a user bubble. Capped + cached per conversation.
|
||||
const [commandMarkers, setCommandMarkers] = useState<NativeChatCommandMarker[]>(() =>
|
||||
readCommandMarkerCache(commandMarkerScope)
|
||||
)
|
||||
// Reset the optimistic queue only when the pane/agent changes. A fresh launch
|
||||
// often learns its provider session id after the first send; clearing pending
|
||||
// on that transition briefly flashes the empty state before the transcript
|
||||
// user turn lands.
|
||||
useEffect(() => {
|
||||
setPending(readPendingSendCache(pendingScope))
|
||||
setWorkingInterrupted(false)
|
||||
}, [pendingScope])
|
||||
// Command markers are session-scoped because slash commands like /clear are
|
||||
// local feedback for a specific transcript boundary.
|
||||
useEffect(() => {
|
||||
setCommandMarkers(readCommandMarkerCache(commandMarkerScope))
|
||||
setWorkingInterrupted(false)
|
||||
}, [commandMarkerScope])
|
||||
// Prune echoes whose real user turn is now in the transcript.
|
||||
useEffect(() => {
|
||||
setPending((prev) =>
|
||||
writePendingSendCache(pendingScope, prunePendingSends(prev, session.messages))
|
||||
)
|
||||
}, [session.messages, pendingScope])
|
||||
const onOptimisticSend = useCallback(
|
||||
(text: string, imagePaths?: string[]) => {
|
||||
setWorkingInterrupted(false)
|
||||
pendingCounter.current += 1
|
||||
const entry: NativeChatPendingSend = {
|
||||
id: `${pendingCounter.current}`,
|
||||
text,
|
||||
sentAt: Date.now(),
|
||||
...(imagePaths ? { imagePaths } : {})
|
||||
}
|
||||
setPending(appendPendingSendCache(pendingScope, entry))
|
||||
},
|
||||
[pendingScope]
|
||||
)
|
||||
const onSlashCommand = useCallback(
|
||||
(command: string) => {
|
||||
setCommandMarkers(appendCommandMarkerCache(commandMarkerScope, command))
|
||||
},
|
||||
[commandMarkerScope]
|
||||
)
|
||||
|
||||
const sessionAfterCommandBoundaries = useMemo<typeof session>(() => {
|
||||
const messages = applyCommandMarkerBoundaries(session.messages, commandMarkers)
|
||||
return messages === session.messages ? session : { ...session, messages }
|
||||
}, [session, commandMarkers])
|
||||
|
||||
// The streaming preview bubble (if any) sits after the transcript but before
|
||||
// the optimistic user echoes — same order mobile uses.
|
||||
const streamingText = useMemo(
|
||||
() =>
|
||||
deriveNativeChatStreamingText({
|
||||
messages: sessionAfterCommandBoundaries.messages,
|
||||
previewText: hookPreview,
|
||||
working: hookWorking
|
||||
}),
|
||||
[sessionAfterCommandBoundaries.messages, hookPreview, hookWorking]
|
||||
)
|
||||
const sessionWithPending = useMemo<typeof session>(() => {
|
||||
if (pending.length === 0 && commandMarkers.length === 0 && !streamingText) {
|
||||
return sessionAfterCommandBoundaries
|
||||
}
|
||||
return {
|
||||
...sessionAfterCommandBoundaries,
|
||||
messages: [
|
||||
...sessionAfterCommandBoundaries.messages,
|
||||
...commandMarkersAsMessages(commandMarkers),
|
||||
...(streamingText ? [nativeChatStreamingMessage(streamingText)] : []),
|
||||
...pendingSendsAsMessages(pending, sessionAfterCommandBoundaries.messages)
|
||||
]
|
||||
}
|
||||
}, [sessionAfterCommandBoundaries, pending, commandMarkers, streamingText])
|
||||
// Derive the view state from the pending-augmented session so a send into an
|
||||
// otherwise-empty conversation flips to the list (showing the queued bubble)
|
||||
// instead of staying on the empty state.
|
||||
const viewState = selectNativeChatViewState(sessionWithPending)
|
||||
|
||||
const isConversation = viewState.kind === 'ready'
|
||||
// Drive "working" from the live hook state too: when toggling to chat while the
|
||||
// agent is mid-turn, the merged transcript may not yet reflect the in-flight
|
||||
// turn, but the hook already says 'working' — show the indicator immediately.
|
||||
const viewWorking = viewState.kind === 'ready' && viewState.isWorking
|
||||
useEffect(() => {
|
||||
if (shouldClearNativeChatWorkingSuppression({ viewWorking, hookWorking })) {
|
||||
setWorkingInterrupted(false)
|
||||
}
|
||||
}, [viewWorking, hookWorking])
|
||||
const isWorking = shouldShowNativeChatWorking({
|
||||
isConversation,
|
||||
viewWorking,
|
||||
hookWorking,
|
||||
interrupted: workingInterrupted
|
||||
})
|
||||
|
||||
const stopAgent = useCallback(() => {
|
||||
setWorkingInterrupted(true)
|
||||
interactiveSend.cancel()
|
||||
}, [interactiveSend])
|
||||
|
||||
// Chat-only font zoom via Cmd/Ctrl +/-/0, gated to the live conversation so
|
||||
// the chord is inert on the loading/empty/error states and elsewhere.
|
||||
const fontScale = useNativeChatFontScale(isConversation)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
data-native-chat-root="true"
|
||||
tabIndex={-1}
|
||||
onPointerDownCapture={(event) => {
|
||||
if (event.button === 2) {
|
||||
contextMenu.onSelectionCapture()
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
if (event.button === 0 && shouldFocusNativeChatPaneFromPointerTarget(event.target)) {
|
||||
rootRef.current?.focus({ preventScroll: true })
|
||||
}
|
||||
}}
|
||||
onKeyDownCapture={(event) => {
|
||||
if (!shouldRedirectNativeChatTyping(event)) {
|
||||
return
|
||||
}
|
||||
if (!composerRef.current?.insertTypedText(event.key)) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onMouseUpCapture={contextMenu.onSelectionCapture}
|
||||
onKeyUpCapture={contextMenu.onSelectionCapture}
|
||||
onContextMenuCapture={contextMenu.onContextMenuCapture}
|
||||
className="flex h-full min-h-0 w-full flex-col bg-background focus:outline-none"
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{viewState.kind === 'loading' ? (
|
||||
<NativeChatEmptyState kind="loading" />
|
||||
) : viewState.kind === 'error' ? (
|
||||
<NativeChatEmptyState kind="error" message={viewState.message} />
|
||||
) : viewState.kind === 'empty' ? (
|
||||
<NativeChatEmptyState kind="empty" agent={agent} />
|
||||
) : (
|
||||
<NativeChatMessageList
|
||||
session={sessionWithPending}
|
||||
isWorking={isWorking}
|
||||
expandSignal={false}
|
||||
fontScale={fontScale.scale}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* Live interactive cards (question / approval) render just above the
|
||||
composer while the agent's interactivePrompt is present (mobile parity). */}
|
||||
<NativeChatInteractiveCard paneKey={paneKey} send={interactiveSend} canSend={canSend} />
|
||||
{/* canSend reflects the mobile presence-lock: when a mobile client holds
|
||||
the pty, the composer shows its guarded state instead of racing the
|
||||
mobile driver (R8). */}
|
||||
<NativeChatComposer
|
||||
ref={composerRef}
|
||||
terminalTabId={terminalTabId}
|
||||
targetPtyId={targetPtyId}
|
||||
agent={agent}
|
||||
canSend={canSend}
|
||||
isWorking={isWorking}
|
||||
onStop={stopAgent}
|
||||
onOptimisticSend={onOptimisticSend}
|
||||
onSlashCommand={onSlashCommand}
|
||||
/>
|
||||
{contextMenu.menu}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function matchesNativeChatPasteShortcut(
|
||||
event: Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey'>,
|
||||
isMac: boolean
|
||||
): boolean {
|
||||
if (event.altKey || event.shiftKey || event.key.toLowerCase() !== 'v') {
|
||||
return false
|
||||
}
|
||||
return isMac ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// The per-agent slash-command catalog now lives in src/shared so the desktop
|
||||
// renderer and the mobile app share one source of truth (no drift). This file
|
||||
// re-exports it for existing desktop import sites.
|
||||
|
||||
export {
|
||||
getAgentSlashCommands,
|
||||
type SlashCommandSuggestion
|
||||
} from '../../../../shared/native-chat-slash-commands'
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { NATIVE_CHAT_SOURCE_PRIORITY, type NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import { mergeNativeChatMessagesWith } from '../../../../shared/native-chat-merge'
|
||||
import { assembleNativeChatSession } from './native-chat-session-assembler'
|
||||
|
||||
// On single-source (pure transcript) data the desktop assembler's cross-source
|
||||
// turnKey pass is a no-op, so it must agree with mobile's id-only merge on the
|
||||
// final id set and order. This locks the "mobile is single-source, so id-only ≡
|
||||
// assembler-with-same-source-gated-fallback" invariant from the design (#10).
|
||||
|
||||
function msg(
|
||||
overrides: Partial<NativeChatMessage> & Pick<NativeChatMessage, 'id'>
|
||||
): NativeChatMessage {
|
||||
return {
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'text', text: overrides.id }],
|
||||
timestamp: 0,
|
||||
source: 'transcript',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('assembler ↔ id-merge parity on single-source data', () => {
|
||||
it('produces the same ids in the same order as the id-only merge', () => {
|
||||
// Includes two identical-text same-source prompts (distinct ids): both must
|
||||
// survive in both implementations.
|
||||
const transcript: NativeChatMessage[] = [
|
||||
msg({ id: 'u1', role: 'user', timestamp: 10, blocks: [{ type: 'text', text: 'run tests' }] }),
|
||||
msg({ id: 'a1', timestamp: 20, blocks: [{ type: 'text', text: 'ok' }] }),
|
||||
msg({ id: 'u2', role: 'user', timestamp: 30, blocks: [{ type: 'text', text: 'run tests' }] }),
|
||||
msg({ id: 'a2', timestamp: 40, blocks: [{ type: 'text', text: 'done' }] })
|
||||
]
|
||||
|
||||
const assembled = assembleNativeChatSession({
|
||||
sources: { transcript },
|
||||
sessionId: 's1',
|
||||
agent: 'claude'
|
||||
}).messages
|
||||
const merged = mergeNativeChatMessagesWith([], transcript, NATIVE_CHAT_SOURCE_PRIORITY)
|
||||
|
||||
// The assembler sorts by (timestamp, id); the merge preserves arrival order.
|
||||
// The fixture's arrival order already matches timestamp order, so ids align.
|
||||
expect(assembled.map((m) => m.id)).toEqual(merged.map((m) => m.id))
|
||||
expect(assembled).toHaveLength(transcript.length)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
distanceFromBottom,
|
||||
isNearBottom,
|
||||
shouldShowJumpToLatest,
|
||||
NATIVE_CHAT_BOTTOM_THRESHOLD_PX
|
||||
} from './native-chat-autoscroll'
|
||||
|
||||
const atBottom = { scrollTop: 952, scrollHeight: 1000, clientHeight: 48 }
|
||||
const scrolledUp = { scrollTop: 0, scrollHeight: 1000, clientHeight: 48 }
|
||||
const noOverflow = { scrollTop: 0, scrollHeight: 48, clientHeight: 48 }
|
||||
|
||||
describe('distanceFromBottom', () => {
|
||||
it('is zero at the exact bottom and never negative', () => {
|
||||
expect(distanceFromBottom(atBottom)).toBe(0)
|
||||
expect(distanceFromBottom({ scrollTop: 5000, scrollHeight: 1000, clientHeight: 48 })).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isNearBottom', () => {
|
||||
it('sticks within the threshold and detaches beyond it', () => {
|
||||
expect(isNearBottom(atBottom)).toBe(true)
|
||||
expect(
|
||||
isNearBottom({
|
||||
scrollTop: 952 - NATIVE_CHAT_BOTTOM_THRESHOLD_PX,
|
||||
scrollHeight: 1000,
|
||||
clientHeight: 48
|
||||
})
|
||||
).toBe(true)
|
||||
expect(isNearBottom(scrolledUp)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldShowJumpToLatest', () => {
|
||||
it('shows only when detached with content below', () => {
|
||||
expect(shouldShowJumpToLatest(false, scrolledUp)).toBe(true)
|
||||
})
|
||||
it('hides while stuck to bottom', () => {
|
||||
expect(shouldShowJumpToLatest(true, scrolledUp)).toBe(false)
|
||||
})
|
||||
it('hides when there is nothing to scroll', () => {
|
||||
expect(shouldShowJumpToLatest(false, noOverflow)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
// Pure auto-scroll logic for the native chat message list. The component owns
|
||||
// the DOM ref and the imperative scroll; this module owns only the decisions —
|
||||
// "are we near the bottom?", "should we stick on new content?", "show the jump
|
||||
// affordance?" — so they can be unit-tested without a scroll container.
|
||||
|
||||
/** A scroll container's geometry. Mirrors the three DOM props we read so tests
|
||||
* can pass plain numbers instead of a fake element. */
|
||||
export type ScrollGeometry = {
|
||||
scrollTop: number
|
||||
scrollHeight: number
|
||||
clientHeight: number
|
||||
}
|
||||
|
||||
/** Pixels from the bottom within which we treat the view as "at the bottom" and
|
||||
* keep it pinned as content arrives. A small slack absorbs sub-pixel rounding
|
||||
* and the height jitter of a streaming last message. */
|
||||
export const NATIVE_CHAT_BOTTOM_THRESHOLD_PX = 48
|
||||
|
||||
/** Distance in px from the bottom edge of the scroll range. */
|
||||
export function distanceFromBottom(geometry: ScrollGeometry): number {
|
||||
return Math.max(0, geometry.scrollHeight - geometry.clientHeight - geometry.scrollTop)
|
||||
}
|
||||
|
||||
/** True when the viewport is close enough to the bottom that new content should
|
||||
* keep it pinned (auto-scroll "attached"). */
|
||||
export function isNearBottom(
|
||||
geometry: ScrollGeometry,
|
||||
threshold: number = NATIVE_CHAT_BOTTOM_THRESHOLD_PX
|
||||
): boolean {
|
||||
return distanceFromBottom(geometry) <= threshold
|
||||
}
|
||||
|
||||
/** Whether the "jump to latest" affordance should show: only when the user has
|
||||
* detached (scrolled up) and there is actually scrollable content below. */
|
||||
export function shouldShowJumpToLatest(
|
||||
isStuckToBottom: boolean,
|
||||
geometry: ScrollGeometry,
|
||||
threshold: number = NATIVE_CHAT_BOTTOM_THRESHOLD_PX
|
||||
): boolean {
|
||||
if (isStuckToBottom) {
|
||||
return false
|
||||
}
|
||||
return distanceFromBottom(geometry) > threshold
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { canToggleNativeChat } from './native-chat-availability'
|
||||
|
||||
describe('canToggleNativeChat', () => {
|
||||
it('allows a terminal launched with a coding agent', () => {
|
||||
expect(
|
||||
canToggleNativeChat({
|
||||
experimentalNativeChatEnabled: true,
|
||||
contentType: 'terminal',
|
||||
launchAgent: 'claude'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('allows a terminal with a live detected agent but no launchAgent', () => {
|
||||
expect(
|
||||
canToggleNativeChat({
|
||||
experimentalNativeChatEnabled: true,
|
||||
contentType: 'terminal',
|
||||
launchAgent: null,
|
||||
hasDetectedAgent: true
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('allows a terminal with a resolved title/foreground agent before hooks arrive', () => {
|
||||
expect(
|
||||
canToggleNativeChat({
|
||||
experimentalNativeChatEnabled: true,
|
||||
contentType: 'terminal',
|
||||
launchAgent: null,
|
||||
hasResolvedAgent: true
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('allows an existing chat view to toggle back after live signals are gone', () => {
|
||||
expect(
|
||||
canToggleNativeChat({
|
||||
experimentalNativeChatEnabled: true,
|
||||
contentType: 'terminal',
|
||||
launchAgent: null,
|
||||
isChatViewMode: true
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects otherwise eligible terminals while the experimental flag is off', () => {
|
||||
expect(
|
||||
canToggleNativeChat({
|
||||
experimentalNativeChatEnabled: false,
|
||||
contentType: 'terminal',
|
||||
launchAgent: 'claude'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a plain shell terminal with no agent', () => {
|
||||
expect(
|
||||
canToggleNativeChat({
|
||||
experimentalNativeChatEnabled: true,
|
||||
contentType: 'terminal',
|
||||
launchAgent: null,
|
||||
hasDetectedAgent: false
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a plain shell terminal with everything omitted', () => {
|
||||
expect(
|
||||
canToggleNativeChat({ experimentalNativeChatEnabled: true, contentType: 'terminal' })
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an editor tab even if an agent hint were somehow present', () => {
|
||||
expect(
|
||||
canToggleNativeChat({
|
||||
experimentalNativeChatEnabled: true,
|
||||
contentType: 'editor',
|
||||
launchAgent: 'codex',
|
||||
hasDetectedAgent: true
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a browser tab', () => {
|
||||
expect(
|
||||
canToggleNativeChat({
|
||||
experimentalNativeChatEnabled: true,
|
||||
contentType: 'browser',
|
||||
hasDetectedAgent: true
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Tab, TuiAgent } from '../../../../shared/types'
|
||||
|
||||
/** Inputs that decide whether a tab may toggle into the native chat view.
|
||||
* Kept as a plain shape (not the live store) so the decision stays pure and
|
||||
* unit-testable; call sites resolve `launchAgent`/`hasDetectedAgent` from the
|
||||
* terminal tab + agent-status before calling. */
|
||||
export type NativeChatAvailabilityInput = {
|
||||
/** Feature flag: hidden unless enabled from Settings > Experimental. */
|
||||
experimentalNativeChatEnabled?: boolean
|
||||
contentType: Tab['contentType']
|
||||
/** The coding-agent Orca launched in this terminal, if any (from TerminalTab). */
|
||||
launchAgent?: TuiAgent | null
|
||||
/** True when a live agent-status entry exists for any pane of this tab — i.e.
|
||||
* an agent was detected at runtime even though `launchAgent` was not set
|
||||
* (manually-started agents, resumed sessions). */
|
||||
hasDetectedAgent?: boolean
|
||||
/** True when another trusted tab signal (for example the terminal title
|
||||
* resolver) identifies the foreground as an agent before hooks arrive. */
|
||||
hasResolvedAgent?: boolean
|
||||
/** Already-chat tabs must always be allowed to toggle back to terminal, even
|
||||
* if live hook state was lost during a dev/app restart. */
|
||||
isChatViewMode?: boolean
|
||||
}
|
||||
|
||||
/** Native chat is a rendering of a coding-agent conversation, so the toggle is
|
||||
* only meaningful on terminals that actually run an agent. Plain shells and
|
||||
* non-terminal surfaces (editor, browser, …) never qualify. Eligibility is the
|
||||
* union of the launch-time hint and live detection so the control appears both
|
||||
* for Orca-launched agents and for agents the user started themselves. */
|
||||
export function canToggleNativeChat(input: NativeChatAvailabilityInput): boolean {
|
||||
if (input.experimentalNativeChatEnabled !== true) {
|
||||
return false
|
||||
}
|
||||
if (input.contentType !== 'terminal') {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
input.isChatViewMode === true ||
|
||||
Boolean(input.launchAgent) ||
|
||||
input.hasDetectedAgent === true ||
|
||||
input.hasResolvedAgent === true
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
applyMentionSuggestion,
|
||||
applySkillSuggestion,
|
||||
applySlashSuggestion,
|
||||
deriveComposerAutocomplete,
|
||||
EMPTY_HISTORY,
|
||||
filterSkillSuggestions,
|
||||
filterSlashCommands,
|
||||
isSlashCommandDraft,
|
||||
pushHistory,
|
||||
recallNext,
|
||||
recallPrevious,
|
||||
slashCommandDispatchText,
|
||||
type SlashCommandSuggestion
|
||||
} from './native-chat-composer-state'
|
||||
import type { DiscoveredSkill } from '../../../../shared/skills'
|
||||
|
||||
const COMMANDS: SlashCommandSuggestion[] = [
|
||||
{ name: 'clear' },
|
||||
{ name: 'compact' },
|
||||
{ name: 'help' }
|
||||
]
|
||||
|
||||
function skill(overrides: Partial<DiscoveredSkill>): DiscoveredSkill {
|
||||
return {
|
||||
id: overrides.name ?? 'skill',
|
||||
name: 'typescript',
|
||||
description: null,
|
||||
providers: ['codex'],
|
||||
sourceKind: 'repo',
|
||||
sourceLabel: 'Repository',
|
||||
rootPath: '/repo/.agents/skills',
|
||||
directoryPath: '/repo/.agents/skills/typescript',
|
||||
skillFilePath: '/repo/.agents/skills/typescript/SKILL.md',
|
||||
installed: true,
|
||||
fileCount: 1,
|
||||
updatedAt: null,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('deriveComposerAutocomplete — slash', () => {
|
||||
it('enters slash mode for `/` at the start and filters by query', () => {
|
||||
const result = deriveComposerAutocomplete('/cl', 3, COMMANDS)
|
||||
expect(result.mode).toBe('slash')
|
||||
if (result.mode !== 'slash') {
|
||||
return
|
||||
}
|
||||
expect(result.query).toBe('cl')
|
||||
expect(result.suggestions.map((c) => c.name)).toEqual(['clear'])
|
||||
})
|
||||
|
||||
it('a bare `/` returns the full command list', () => {
|
||||
const result = deriveComposerAutocomplete('/', 1, COMMANDS)
|
||||
expect(result.mode).toBe('slash')
|
||||
if (result.mode !== 'slash') {
|
||||
return
|
||||
}
|
||||
expect(result.suggestions).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('does not fire slash mode after a space', () => {
|
||||
expect(deriveComposerAutocomplete('/clear now', 10, COMMANDS).mode).toBe('none')
|
||||
})
|
||||
|
||||
it('does not fire slash mode mid-line', () => {
|
||||
expect(deriveComposerAutocomplete('hi /clear', 9, COMMANDS).mode).toBe('none')
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveComposerAutocomplete — mention', () => {
|
||||
it('enters mention mode with the query after `@`', () => {
|
||||
const result = deriveComposerAutocomplete('look at @src/ind', 16, COMMANDS)
|
||||
expect(result.mode).toBe('mention')
|
||||
if (result.mode !== 'mention') {
|
||||
return
|
||||
}
|
||||
expect(result.query).toBe('src/ind')
|
||||
})
|
||||
|
||||
it('fires at the start of input too', () => {
|
||||
const result = deriveComposerAutocomplete('@foo', 4, COMMANDS)
|
||||
expect(result.mode).toBe('mention')
|
||||
if (result.mode !== 'mention') {
|
||||
return
|
||||
}
|
||||
expect(result.query).toBe('foo')
|
||||
})
|
||||
|
||||
it('does not fire for an email-like `@` (no preceding whitespace)', () => {
|
||||
expect(deriveComposerAutocomplete('me@example', 10, COMMANDS).mode).toBe('none')
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveComposerAutocomplete — skill', () => {
|
||||
const skills = [
|
||||
skill({ name: 'typescript' }),
|
||||
skill({ name: 'react-useeffect', directoryPath: '/repo/.agents/skills/react-useeffect' })
|
||||
]
|
||||
|
||||
it('enters skill mode with the query after `$`', () => {
|
||||
const result = deriveComposerAutocomplete('use $type', 9, COMMANDS, skills)
|
||||
expect(result.mode).toBe('skill')
|
||||
if (result.mode !== 'skill') {
|
||||
return
|
||||
}
|
||||
expect(result.query).toBe('type')
|
||||
expect(result.suggestions.map((entry) => entry.name)).toEqual(['typescript'])
|
||||
})
|
||||
|
||||
it('fires at the start of input too', () => {
|
||||
expect(deriveComposerAutocomplete('$react', 6, COMMANDS, skills).mode).toBe('skill')
|
||||
})
|
||||
|
||||
it('does not fire inside shell-style text', () => {
|
||||
expect(deriveComposerAutocomplete('price$tag', 9, COMMANDS, skills).mode).toBe('none')
|
||||
})
|
||||
})
|
||||
|
||||
describe('filterSlashCommands', () => {
|
||||
it('is case-insensitive prefix match', () => {
|
||||
expect(filterSlashCommands(COMMANDS, 'C').map((c) => c.name)).toEqual(['clear', 'compact'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSlashCommandDraft', () => {
|
||||
it('treats leading slash drafts as TUI commands, not chat prompts', () => {
|
||||
expect(isSlashCommandDraft('/clear')).toBe(true)
|
||||
expect(isSlashCommandDraft(' /compact')).toBe(true)
|
||||
expect(isSlashCommandDraft('please run /clear')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('filterSkillSuggestions', () => {
|
||||
it('filters installed skills by name or directory basename', () => {
|
||||
const skills = [
|
||||
skill({ name: 'TypeScript' }),
|
||||
skill({ name: 'Display Name', directoryPath: '/repo/.agents/skills/ref-oss' }),
|
||||
skill({ name: 'hidden', installed: false })
|
||||
]
|
||||
expect(filterSkillSuggestions(skills, 'ref').map((entry) => entry.name)).toEqual([
|
||||
'Display Name'
|
||||
])
|
||||
expect(filterSkillSuggestions(skills, 'h')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('history recall', () => {
|
||||
it('up-arrow on empty composer recalls the last sent input', () => {
|
||||
const history = pushHistory(EMPTY_HISTORY, 'first')
|
||||
const recall = recallPrevious(history)
|
||||
expect(recall.draft).toBe('first')
|
||||
expect(recall.history.index).toBe(0)
|
||||
})
|
||||
|
||||
it('walks backward and clamps at the oldest entry', () => {
|
||||
let history = pushHistory(EMPTY_HISTORY, 'a')
|
||||
history = pushHistory(history, 'b')
|
||||
const first = recallPrevious(history)
|
||||
expect(first.draft).toBe('b')
|
||||
const second = recallPrevious(first.history)
|
||||
expect(second.draft).toBe('a')
|
||||
const third = recallPrevious(second.history)
|
||||
expect(third.draft).toBe('a') // clamped
|
||||
})
|
||||
|
||||
it('down-arrow walks forward and returns to a live empty draft', () => {
|
||||
let history = pushHistory(EMPTY_HISTORY, 'a')
|
||||
history = pushHistory(history, 'b')
|
||||
const up1 = recallPrevious(history) // 'b'
|
||||
const up2 = recallPrevious(up1.history) // 'a'
|
||||
const down = recallNext(up2.history) // 'b'
|
||||
expect(down.draft).toBe('b')
|
||||
const back = recallNext(down.history) // live
|
||||
expect(back.draft).toBe('')
|
||||
expect(back.history.index).toBeNull()
|
||||
})
|
||||
|
||||
it('does not record blank sends or immediate duplicates', () => {
|
||||
let history = pushHistory(EMPTY_HISTORY, ' ')
|
||||
expect(history.entries).toHaveLength(0)
|
||||
history = pushHistory(history, 'x')
|
||||
history = pushHistory(history, 'x')
|
||||
expect(history.entries).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('recall on empty history is a no-op', () => {
|
||||
expect(recallPrevious(EMPTY_HISTORY).draft).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('apply suggestions', () => {
|
||||
it('applySlashSuggestion replaces the token with a trailing space', () => {
|
||||
expect(applySlashSuggestion({ name: 'clear' })).toBe('/clear ')
|
||||
})
|
||||
|
||||
it('slashCommandDispatchText returns the command without completion whitespace', () => {
|
||||
expect(slashCommandDispatchText({ name: 'clear' })).toBe('/clear')
|
||||
})
|
||||
|
||||
it('applyMentionSuggestion replaces the active @token at the caret', () => {
|
||||
const result = applyMentionSuggestion('open @sr more', 8, 'src/app.ts')
|
||||
expect(result.draft).toBe('open @src/app.ts more')
|
||||
expect(result.caret).toBe('open @src/app.ts '.length)
|
||||
})
|
||||
|
||||
it('applySkillSuggestion replaces the active $token at the caret', () => {
|
||||
const result = applySkillSuggestion('use $typ now', 8, 'typescript')
|
||||
expect(result.draft).toBe('use $typescript now')
|
||||
expect(result.caret).toBe('use $typescript '.length)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,187 @@
|
||||
// Pure state machine for the native chat composer. Given the current draft,
|
||||
// caret position, sent-input history, and the active agent's known slash
|
||||
// commands/skills, it derives the autocomplete mode, the
|
||||
// query, and the filtered suggestions. Keeping this DOM-free makes the slash /
|
||||
// mention / skill / history behavior unit-testable; the .tsx only owns rendering and
|
||||
// the actual textarea/caret wiring.
|
||||
|
||||
import type { DiscoveredSkill } from '../../../../shared/skills'
|
||||
import {
|
||||
filterSlashCommands,
|
||||
isSlashCommandDraft,
|
||||
applySlashSuggestion,
|
||||
slashCommandDispatchText,
|
||||
type SlashCommandSuggestion
|
||||
} from '../../../../shared/native-chat-slash-commands'
|
||||
|
||||
export type { SlashCommandSuggestion }
|
||||
export { filterSlashCommands, isSlashCommandDraft, applySlashSuggestion, slashCommandDispatchText }
|
||||
|
||||
export type ComposerAutocompleteMode = 'none' | 'slash' | 'mention' | 'skill'
|
||||
|
||||
export type ComposerAutocomplete =
|
||||
| { mode: 'none' }
|
||||
| { mode: 'slash'; query: string; suggestions: SlashCommandSuggestion[] }
|
||||
| { mode: 'mention'; query: string }
|
||||
| { mode: 'skill'; query: string; suggestions: DiscoveredSkill[] }
|
||||
|
||||
export type ComposerDerivation = {
|
||||
autocomplete: ComposerAutocomplete
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the active autocomplete trigger from the text before the caret.
|
||||
*
|
||||
* Rules (intentionally conservative to avoid firing inside ordinary prose):
|
||||
* - Slash: the draft starts with `/` and the caret is within that first token
|
||||
* (no whitespace between `/` and the caret). Agent slash commands are only
|
||||
* valid at the very start of the input, mirroring how the TUIs accept them.
|
||||
* - Mention: an `@` immediately preceded by start-of-input or whitespace, with
|
||||
* no whitespace between it and the caret. Works anywhere in the line so you
|
||||
* can reference a file mid-sentence.
|
||||
*/
|
||||
export function deriveComposerAutocomplete(
|
||||
draft: string,
|
||||
caret: number,
|
||||
agentCommands: readonly SlashCommandSuggestion[],
|
||||
skills: readonly DiscoveredSkill[] = []
|
||||
): ComposerAutocomplete {
|
||||
const before = draft.slice(0, caret)
|
||||
|
||||
// Slash: only at the absolute start of the input, and only while the caret is
|
||||
// still inside the unbroken command token.
|
||||
if (before.startsWith('/') && !/\s/.test(before)) {
|
||||
const query = before.slice(1)
|
||||
return { mode: 'slash', query, suggestions: filterSlashCommands(agentCommands, query) }
|
||||
}
|
||||
|
||||
const mentionMatch = before.match(/(?:^|\s)@(\S*)$/)
|
||||
if (mentionMatch) {
|
||||
return { mode: 'mention', query: mentionMatch[1] }
|
||||
}
|
||||
|
||||
const skillMatch = before.match(/(?:^|\s)\$(\S*)$/)
|
||||
if (skillMatch) {
|
||||
const query = skillMatch[1]
|
||||
return { mode: 'skill', query, suggestions: filterSkillSuggestions(skills, query) }
|
||||
}
|
||||
|
||||
return { mode: 'none' }
|
||||
}
|
||||
|
||||
export function filterSkillSuggestions(
|
||||
skills: readonly DiscoveredSkill[],
|
||||
query: string
|
||||
): DiscoveredSkill[] {
|
||||
const normalized = query.toLowerCase()
|
||||
const installed = skills.filter((skill) => skill.installed)
|
||||
if (normalized === '') {
|
||||
return installed.slice(0, 12)
|
||||
}
|
||||
return installed
|
||||
.filter((skill) => {
|
||||
const name = skill.name.toLowerCase()
|
||||
const dirName = skill.directoryPath.split(/[\\/]/).filter(Boolean).at(-1)?.toLowerCase()
|
||||
return name.startsWith(normalized) || dirName?.startsWith(normalized)
|
||||
})
|
||||
.slice(0, 12)
|
||||
}
|
||||
|
||||
export type HistoryState = {
|
||||
/** Most-recent-last list of previously sent drafts. */
|
||||
entries: readonly string[]
|
||||
/** Cursor into `entries`; null means "live draft" (not recalling). */
|
||||
index: number | null
|
||||
}
|
||||
|
||||
export const EMPTY_HISTORY: HistoryState = { entries: [], index: null }
|
||||
|
||||
/** Append a sent draft to history and reset the recall cursor. Blank sends and
|
||||
* immediate duplicates of the last entry are not recorded (shell-style). */
|
||||
export function pushHistory(history: HistoryState, sent: string): HistoryState {
|
||||
if (sent.trim() === '') {
|
||||
return { entries: history.entries, index: null }
|
||||
}
|
||||
if (history.entries.at(-1) === sent) {
|
||||
return { entries: history.entries, index: null }
|
||||
}
|
||||
return { entries: [...history.entries, sent], index: null }
|
||||
}
|
||||
|
||||
export type HistoryRecall = {
|
||||
history: HistoryState
|
||||
/** The draft text to show, or null to leave the live draft untouched. */
|
||||
draft: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Move one step toward older entries (Up arrow). From the live draft this jumps
|
||||
* to the most recent entry; thereafter it walks backward and clamps at the
|
||||
* oldest. Returns the recalled draft, or null when there is nothing to recall.
|
||||
*/
|
||||
export function recallPrevious(history: HistoryState): HistoryRecall {
|
||||
if (history.entries.length === 0) {
|
||||
return { history, draft: null }
|
||||
}
|
||||
const nextIndex =
|
||||
history.index === null ? history.entries.length - 1 : Math.max(0, history.index - 1)
|
||||
return {
|
||||
history: { entries: history.entries, index: nextIndex },
|
||||
draft: history.entries[nextIndex]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move one step toward newer entries (Down arrow). Walking past the newest entry
|
||||
* returns to the live (empty) draft and clears the recall cursor. Returns null
|
||||
* draft when not currently recalling.
|
||||
*/
|
||||
export function recallNext(history: HistoryState): HistoryRecall {
|
||||
if (history.index === null) {
|
||||
return { history, draft: null }
|
||||
}
|
||||
const nextIndex = history.index + 1
|
||||
if (nextIndex >= history.entries.length) {
|
||||
return { history: { entries: history.entries, index: null }, draft: '' }
|
||||
}
|
||||
return {
|
||||
history: { entries: history.entries, index: nextIndex },
|
||||
draft: history.entries[nextIndex]
|
||||
}
|
||||
}
|
||||
|
||||
/** Replace the active `@query` token before the caret with the chosen path.
|
||||
* Returns the new full draft and the caret offset to place after insertion. */
|
||||
export function applyMentionSuggestion(
|
||||
draft: string,
|
||||
caret: number,
|
||||
path: string
|
||||
): { draft: string; caret: number } {
|
||||
const before = draft.slice(0, caret)
|
||||
const after = draft.slice(caret)
|
||||
const match = before.match(/(^|\s)@(\S*)$/)
|
||||
if (!match) {
|
||||
return { draft, caret }
|
||||
}
|
||||
const tokenStart = before.length - match[2].length - 1 // -1 for the '@'
|
||||
const insertion = `@${path} `
|
||||
const nextBefore = before.slice(0, tokenStart) + insertion
|
||||
return { draft: nextBefore + after, caret: nextBefore.length }
|
||||
}
|
||||
|
||||
export function applySkillSuggestion(
|
||||
draft: string,
|
||||
caret: number,
|
||||
skillName: string
|
||||
): { draft: string; caret: number } {
|
||||
const before = draft.slice(0, caret)
|
||||
const after = draft.slice(caret)
|
||||
const match = before.match(/(^|\s)\$(\S*)$/)
|
||||
if (!match) {
|
||||
return { draft, caret }
|
||||
}
|
||||
const tokenStart = before.length - match[2].length - 1 // -1 for the '$'
|
||||
const insertion = `$${skillName} `
|
||||
const nextBefore = before.slice(0, tokenStart) + insertion
|
||||
return { draft: nextBefore + after, caret: nextBefore.length }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection'
|
||||
import type { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft'
|
||||
|
||||
export type NativeChatResolvedTarget = {
|
||||
ptyId: string
|
||||
settings: ReturnType<typeof getSettingsForAgentTabRuntimeOwner>
|
||||
}
|
||||
|
||||
export function nativeChatComposerPlaceholder(hasPty: boolean, canSend: boolean): string {
|
||||
if (!hasPty) {
|
||||
return translate(
|
||||
'components.native-chat.composer.noPty',
|
||||
'No live terminal — toggle back to reconnect.'
|
||||
)
|
||||
}
|
||||
if (!canSend) {
|
||||
return translate('components.native-chat.composer.locked', 'Input is held by another device.')
|
||||
}
|
||||
return translate('components.native-chat.composer.placeholder', 'Send a message…')
|
||||
}
|
||||
|
||||
export function nativeChatComposerTargetIsRemote(ptyId: string | null): boolean {
|
||||
return ptyId !== null && isRemoteRuntimePtyId(ptyId)
|
||||
}
|
||||
|
||||
export function formatNativeChatFileReference(filePath: string): string {
|
||||
const escaped = filePath.replace(/"/g, '\\"')
|
||||
return /\s/.test(filePath) ? `@"${escaped}"` : `@${filePath}`
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { diffFromText, diffFromToolCall } from './native-chat-diff'
|
||||
|
||||
describe('diffFromToolCall', () => {
|
||||
it('returns null for non-edit tools', () => {
|
||||
expect(diffFromToolCall('Bash', { command: 'ls' })).toBeNull()
|
||||
})
|
||||
|
||||
it('builds del/add lines from old_string/new_string', () => {
|
||||
const diff = diffFromToolCall('Edit', {
|
||||
file_path: '/app.ts',
|
||||
old_string: 'a\nb',
|
||||
new_string: 'a\nc'
|
||||
})
|
||||
expect(diff).toEqual([
|
||||
{ kind: 'meta', text: '/app.ts' },
|
||||
{ kind: 'del', text: 'a' },
|
||||
{ kind: 'del', text: 'b' },
|
||||
{ kind: 'add', text: 'a' },
|
||||
{ kind: 'add', text: 'c' }
|
||||
])
|
||||
})
|
||||
|
||||
it('reads Write content as adds', () => {
|
||||
const diff = diffFromToolCall('Write', { path: '/new.ts', content: 'line1\nline2' })
|
||||
expect(diff).toEqual([
|
||||
{ kind: 'meta', text: '/new.ts' },
|
||||
{ kind: 'add', text: 'line1' },
|
||||
{ kind: 'add', text: 'line2' }
|
||||
])
|
||||
})
|
||||
|
||||
it('returns null when there is no old/new payload', () => {
|
||||
expect(diffFromToolCall('Edit', { file_path: '/x' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('diffFromText', () => {
|
||||
it('parses unified-diff text into coloured lines', () => {
|
||||
const diff = diffFromText('@@ -1,2 +1,2 @@\n context\n-old\n+new')
|
||||
expect(diff).toEqual([
|
||||
{ kind: 'meta', text: '@@ -1,2 +1,2 @@' },
|
||||
{ kind: 'context', text: ' context' },
|
||||
{ kind: 'del', text: 'old' },
|
||||
{ kind: 'add', text: 'new' }
|
||||
])
|
||||
})
|
||||
|
||||
it('returns null when there is not enough diff signal', () => {
|
||||
expect(diffFromText('just a sentence with - a dash')).toBeNull()
|
||||
expect(diffFromText('+only one add line')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for empty input', () => {
|
||||
expect(diffFromText('')).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores +++/--- file headers as add/del', () => {
|
||||
const diff = diffFromText('--- a/x\n+++ b/x\n-old\n+new')
|
||||
expect(diff?.filter((l) => l.kind === 'add').map((l) => l.text)).toEqual(['new'])
|
||||
expect(diff?.filter((l) => l.kind === 'del').map((l) => l.text)).toEqual(['old'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
// Why: agent edits show up as tool calls (Edit/Write with old/new strings) and
|
||||
// tool results that contain unified-diff text. The chat renders these as inline
|
||||
// coloured diffs like the terminal, so detection/parsing is pure and testable.
|
||||
// Ported from the mobile diffFromToolCall/diffFromText (desktop parity).
|
||||
|
||||
export type DiffLineKind = 'add' | 'del' | 'context' | 'meta'
|
||||
|
||||
export type DiffLine = {
|
||||
kind: DiffLineKind
|
||||
text: string
|
||||
}
|
||||
|
||||
const EDIT_TOOL_NAMES = new Set(['Edit', 'MultiEdit', 'Write', 'str_replace', 'apply_patch'])
|
||||
|
||||
function toLines(value: unknown): string[] {
|
||||
return typeof value === 'string' ? value.replace(/\n$/, '').split('\n') : []
|
||||
}
|
||||
|
||||
/** Build diff lines from an Edit-style tool call input (old_string/new_string),
|
||||
* or null when the input isn't an editing payload. Old lines render as deletes,
|
||||
* new lines as adds — a simple, readable before/after rather than a full LCS. */
|
||||
export function diffFromToolCall(name: string, input: unknown): DiffLine[] | null {
|
||||
if (!EDIT_TOOL_NAMES.has(name) || typeof input !== 'object' || input === null) {
|
||||
return null
|
||||
}
|
||||
const obj = input as Record<string, unknown>
|
||||
const oldText = obj.old_string ?? obj.oldString ?? obj.old
|
||||
const newText = obj.new_string ?? obj.newString ?? obj.new ?? obj.content ?? obj.file_text
|
||||
const dels = toLines(oldText).map((text): DiffLine => ({ kind: 'del', text }))
|
||||
const adds = toLines(newText).map((text): DiffLine => ({ kind: 'add', text }))
|
||||
if (dels.length === 0 && adds.length === 0) {
|
||||
return null
|
||||
}
|
||||
const lines: DiffLine[] = []
|
||||
if (typeof obj.file_path === 'string' || typeof obj.path === 'string') {
|
||||
lines.push({ kind: 'meta', text: String(obj.file_path ?? obj.path) })
|
||||
}
|
||||
return [...lines, ...dels, ...adds]
|
||||
}
|
||||
|
||||
/** Parse unified-diff-looking text into coloured lines, or null when the text
|
||||
* doesn't read as a diff (no +/- lines). */
|
||||
export function diffFromText(text: string): DiffLine[] | null {
|
||||
if (typeof text !== 'string' || text.length === 0) {
|
||||
return null
|
||||
}
|
||||
const raw = text.split('\n')
|
||||
let added = 0
|
||||
let removed = 0
|
||||
const lines: DiffLine[] = raw.map((line): DiffLine => {
|
||||
if (line.startsWith('@@') || line.startsWith('diff ') || line.startsWith('index ')) {
|
||||
return { kind: 'meta', text: line }
|
||||
}
|
||||
if (line.startsWith('+') && !line.startsWith('+++')) {
|
||||
added++
|
||||
return { kind: 'add', text: line.slice(1) }
|
||||
}
|
||||
if (line.startsWith('-') && !line.startsWith('---')) {
|
||||
removed++
|
||||
return { kind: 'del', text: line.slice(1) }
|
||||
}
|
||||
return { kind: 'context', text: line }
|
||||
})
|
||||
// Require a meaningful amount of diff signal so ordinary prose isn't mistaken
|
||||
// for a diff (a stray leading '-' bullet shouldn't trigger diff rendering).
|
||||
if (added + removed < 2) {
|
||||
return null
|
||||
}
|
||||
return lines
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { nativeChatCardDismissKey } from './native-chat-dismiss-key'
|
||||
|
||||
describe('nativeChatCardDismissKey', () => {
|
||||
it('returns null for no card', () => {
|
||||
expect(nativeChatCardDismissKey(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('keys a question by its count and first question text', () => {
|
||||
const key = nativeChatCardDismissKey({
|
||||
kind: 'question',
|
||||
prompt: {
|
||||
questions: [
|
||||
{ question: 'Pick a color', multiSelect: false, options: [{ label: 'Red' }] },
|
||||
{ question: 'Pick a size', multiSelect: false, options: [{ label: 'L' }] }
|
||||
]
|
||||
}
|
||||
})
|
||||
expect(key).toBe('question:2:Pick a color')
|
||||
})
|
||||
|
||||
it('gives identical questions the same key (so a lingering re-emit stays hidden)', () => {
|
||||
const make = (): ReturnType<typeof nativeChatCardDismissKey> =>
|
||||
nativeChatCardDismissKey({
|
||||
kind: 'question',
|
||||
prompt: { questions: [{ question: 'Continue?', multiSelect: false, options: [] }] }
|
||||
})
|
||||
expect(make()).toBe(make())
|
||||
})
|
||||
|
||||
it('keys an approval by its title and detail', () => {
|
||||
const key = nativeChatCardDismissKey({
|
||||
kind: 'approval',
|
||||
approval: {
|
||||
title: 'Allow Bash?',
|
||||
detail: 'rm -rf build',
|
||||
options: [{ label: 'Allow', send: '1' }]
|
||||
}
|
||||
})
|
||||
expect(key).toBe('approval:Allow Bash?:rm -rf build')
|
||||
})
|
||||
|
||||
it('distinguishes different approvals', () => {
|
||||
const a = nativeChatCardDismissKey({
|
||||
kind: 'approval',
|
||||
approval: { title: 'Allow Bash?', options: [] }
|
||||
})
|
||||
const b = nativeChatCardDismissKey({
|
||||
kind: 'approval',
|
||||
approval: { title: 'Allow Write?', options: [] }
|
||||
})
|
||||
expect(a).not.toBe(b)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
// Pure content key for an interactive card (question / approval), used to dismiss
|
||||
// it once answered. The live status lingers briefly — the agent emits a post-tool
|
||||
// event carrying the same prompt — so the view hides the card until a genuinely
|
||||
// different prompt arrives. Keying by content (not identity) means an identical
|
||||
// follow-up prompt only re-shows after the prompt has cleared in between. Mirrors
|
||||
// mobile's askKey/dismissedAskKey. Kept pure so the keying is unit-testable.
|
||||
|
||||
import type { InteractivePromptCard } from './native-chat-interactive-prompt'
|
||||
|
||||
/** A stable string identifying a card by its content, or null when there is no
|
||||
* card. Two cards with the same key are treated as "the same prompt". */
|
||||
export function nativeChatCardDismissKey(card: InteractivePromptCard): string | null {
|
||||
if (!card) {
|
||||
return null
|
||||
}
|
||||
if (card.kind === 'question') {
|
||||
const { questions } = card.prompt
|
||||
return `question:${questions.length}:${questions[0]?.question ?? ''}`
|
||||
}
|
||||
return `approval:${card.approval.title}:${card.approval.detail ?? ''}`
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
chatFontScaleActionForEvent,
|
||||
chatFontScaleShortcutLabels,
|
||||
clampChatFontScale,
|
||||
decreaseChatFontScale,
|
||||
DEFAULT_CHAT_FONT_SCALE,
|
||||
increaseChatFontScale,
|
||||
MAX_CHAT_FONT_SCALE,
|
||||
MIN_CHAT_FONT_SCALE
|
||||
} from './native-chat-font-scale'
|
||||
|
||||
type Combo = Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey'>
|
||||
|
||||
function combo(overrides: Partial<Combo>): Combo {
|
||||
return { key: '=', metaKey: false, ctrlKey: false, ...overrides }
|
||||
}
|
||||
|
||||
describe('clampChatFontScale', () => {
|
||||
it('keeps a value inside the band untouched', () => {
|
||||
expect(clampChatFontScale(1.2)).toBe(1.2)
|
||||
})
|
||||
|
||||
it('clamps below the minimum', () => {
|
||||
expect(clampChatFontScale(0.1)).toBe(MIN_CHAT_FONT_SCALE)
|
||||
})
|
||||
|
||||
it('clamps above the maximum', () => {
|
||||
expect(clampChatFontScale(5)).toBe(MAX_CHAT_FONT_SCALE)
|
||||
})
|
||||
|
||||
it('rounds away float drift to clean tenths', () => {
|
||||
expect(clampChatFontScale(0.7999999999)).toBe(0.8)
|
||||
})
|
||||
})
|
||||
|
||||
describe('increase/decreaseChatFontScale', () => {
|
||||
it('steps up by a tenth without drift', () => {
|
||||
expect(increaseChatFontScale(1)).toBe(1.1)
|
||||
expect(increaseChatFontScale(1.1)).toBe(1.2)
|
||||
})
|
||||
|
||||
it('steps down by a tenth without drift', () => {
|
||||
expect(decreaseChatFontScale(1)).toBe(0.9)
|
||||
expect(decreaseChatFontScale(0.9)).toBe(0.8)
|
||||
})
|
||||
|
||||
it('does not exceed the max when stepping up at the ceiling', () => {
|
||||
expect(increaseChatFontScale(MAX_CHAT_FONT_SCALE)).toBe(MAX_CHAT_FONT_SCALE)
|
||||
})
|
||||
|
||||
it('does not drop below the min when stepping down at the floor', () => {
|
||||
expect(decreaseChatFontScale(MIN_CHAT_FONT_SCALE)).toBe(MIN_CHAT_FONT_SCALE)
|
||||
})
|
||||
})
|
||||
|
||||
describe('chatFontScaleActionForEvent', () => {
|
||||
it('maps Cmd+= to increase on Mac', () => {
|
||||
expect(chatFontScaleActionForEvent(combo({ key: '=', metaKey: true }), true)).toBe('increase')
|
||||
})
|
||||
|
||||
it('maps Cmd++ (shifted equals) to increase on Mac', () => {
|
||||
expect(chatFontScaleActionForEvent(combo({ key: '+', metaKey: true }), true)).toBe('increase')
|
||||
})
|
||||
|
||||
it('maps Cmd+- to decrease on Mac', () => {
|
||||
expect(chatFontScaleActionForEvent(combo({ key: '-', metaKey: true }), true)).toBe('decrease')
|
||||
})
|
||||
|
||||
it('maps Cmd+0 to reset on Mac', () => {
|
||||
expect(chatFontScaleActionForEvent(combo({ key: '0', metaKey: true }), true)).toBe('reset')
|
||||
})
|
||||
|
||||
it('maps Ctrl+= to increase on Windows/Linux', () => {
|
||||
expect(chatFontScaleActionForEvent(combo({ key: '=', ctrlKey: true }), false)).toBe('increase')
|
||||
})
|
||||
|
||||
it('ignores the wrong primary modifier on Mac', () => {
|
||||
expect(chatFontScaleActionForEvent(combo({ key: '=', ctrlKey: true }), true)).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores Cmd+Ctrl chords', () => {
|
||||
expect(
|
||||
chatFontScaleActionForEvent(combo({ key: '=', metaKey: true, ctrlKey: true }), true)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for an unrelated key', () => {
|
||||
expect(chatFontScaleActionForEvent(combo({ key: 'a', metaKey: true }), true)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null without a primary modifier', () => {
|
||||
expect(chatFontScaleActionForEvent(combo({ key: '=' }), true)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('chatFontScaleShortcutLabels', () => {
|
||||
it('uses Cmd glyphs on Mac', () => {
|
||||
expect(chatFontScaleShortcutLabels(true)).toEqual({
|
||||
increase: '⌘+',
|
||||
decrease: '⌘-',
|
||||
reset: '⌘0'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses Ctrl+ text elsewhere', () => {
|
||||
expect(chatFontScaleShortcutLabels(false)).toEqual({
|
||||
increase: 'Ctrl++',
|
||||
decrease: 'Ctrl+-',
|
||||
reset: 'Ctrl+0'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('default scale sits inside the band', () => {
|
||||
expect(DEFAULT_CHAT_FONT_SCALE).toBeGreaterThanOrEqual(MIN_CHAT_FONT_SCALE)
|
||||
expect(DEFAULT_CHAT_FONT_SCALE).toBeLessThanOrEqual(MAX_CHAT_FONT_SCALE)
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
/** Pure font-scale logic for the desktop native chat view — the keyboard analog
|
||||
* of the mobile pinch-zoom. The chat text scale is clamped to a readable band
|
||||
* and adjusted in fixed steps so Cmd/Ctrl +/-/0 behave like a browser zoom but
|
||||
* scoped to the chat surface only. Kept DOM-free so it can be unit-tested. */
|
||||
|
||||
import { isMacPlatform } from './native-chat-shortcut'
|
||||
|
||||
export const MIN_CHAT_FONT_SCALE = 0.8
|
||||
export const MAX_CHAT_FONT_SCALE = 1.6
|
||||
export const DEFAULT_CHAT_FONT_SCALE = 1
|
||||
export const CHAT_FONT_SCALE_STEP = 0.1
|
||||
|
||||
/** Clamp a scale into the readable band and round away float drift so repeated
|
||||
* steps land on clean tenths (e.g. 0.7999999 -> 0.8). */
|
||||
export function clampChatFontScale(scale: number): number {
|
||||
const clamped = Math.min(MAX_CHAT_FONT_SCALE, Math.max(MIN_CHAT_FONT_SCALE, scale))
|
||||
return Math.round(clamped * 100) / 100
|
||||
}
|
||||
|
||||
export function increaseChatFontScale(scale: number): number {
|
||||
return clampChatFontScale(scale + CHAT_FONT_SCALE_STEP)
|
||||
}
|
||||
|
||||
export function decreaseChatFontScale(scale: number): number {
|
||||
return clampChatFontScale(scale - CHAT_FONT_SCALE_STEP)
|
||||
}
|
||||
|
||||
export type ChatFontScaleAction = 'increase' | 'decrease' | 'reset' | null
|
||||
|
||||
/** Map a keydown to a font-scale action when it's the Cmd/Ctrl +/-/0 chord.
|
||||
* Primary modifier follows AGENTS.md (metaKey on Mac, ctrlKey elsewhere) and
|
||||
* must be the only primary modifier so it can't collide with Cmd+Ctrl chords.
|
||||
* Shift/Alt are ignored on purpose: `+` is Shift+`=` on many layouts. Pure so
|
||||
* it can be unit-tested without a DOM. */
|
||||
export function chatFontScaleActionForEvent(
|
||||
e: Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey'>,
|
||||
isMac: boolean
|
||||
): ChatFontScaleAction {
|
||||
const primary = isMac ? e.metaKey && !e.ctrlKey : e.ctrlKey && !e.metaKey
|
||||
if (!primary) {
|
||||
return null
|
||||
}
|
||||
switch (e.key) {
|
||||
case '=':
|
||||
case '+':
|
||||
return 'increase'
|
||||
case '-':
|
||||
case '_':
|
||||
return 'decrease'
|
||||
case '0':
|
||||
return 'reset'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Human-readable labels for the chat font-scale shortcuts, platform-correct. */
|
||||
export function chatFontScaleShortcutLabels(isMac = isMacPlatform()): {
|
||||
increase: string
|
||||
decrease: string
|
||||
reset: string
|
||||
} {
|
||||
const mod = isMac ? '⌘' : 'Ctrl+'
|
||||
return {
|
||||
increase: `${mod}+`,
|
||||
decrease: `${mod}-`,
|
||||
reset: `${mod}0`
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getAgentImageHandling, resolveImagePaste } from './native-chat-image-paste'
|
||||
|
||||
describe('image paste agent map', () => {
|
||||
it('known image-capable agent attaches the temp file path', () => {
|
||||
expect(getAgentImageHandling('claude')).toBe('attachment')
|
||||
const result = resolveImagePaste('claude', '/tmp/orca-img-123.png')
|
||||
expect(result).toEqual({ kind: 'attach', path: '/tmp/orca-img-123.png' })
|
||||
})
|
||||
|
||||
it('codex also attaches image paths', () => {
|
||||
expect(resolveImagePaste('codex', '/tmp/x.png')).toEqual({
|
||||
kind: 'attach',
|
||||
path: '/tmp/x.png'
|
||||
})
|
||||
})
|
||||
|
||||
it('unknown/custom agent is unsupported', () => {
|
||||
expect(getAgentImageHandling('some-custom-agent')).toBe('unsupported')
|
||||
expect(resolveImagePaste('some-custom-agent', '/tmp/x.png')).toEqual({
|
||||
kind: 'unsupported',
|
||||
agent: 'some-custom-agent'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
// Pure decision layer for image paste. The composer persists a pasted image to
|
||||
// a temp file (via the preload clipboard API) and then needs to know, per
|
||||
// agent, whether that file can be sent as a TUI image attachment. Confirmed
|
||||
// agents get a native attachment chip; unsupported/custom agents get a clear
|
||||
// message instead of silently injecting a path that the model reads as text.
|
||||
|
||||
import type { AgentType } from '../../../../shared/agent-status-types'
|
||||
import { isImageDropPath } from '../terminal-pane/terminal-drop-image-path'
|
||||
|
||||
/** How a given agent consumes a pasted image. `attachment` = bracket-paste the
|
||||
* image path into the hosted TUI so it becomes an image chip; `unsupported` =
|
||||
* no confirmed mechanism. */
|
||||
export type AgentImageHandling = 'attachment' | 'unsupported'
|
||||
|
||||
const IMAGE_ATTACHMENT_AGENTS: ReadonlySet<AgentType> = new Set<AgentType>([
|
||||
'claude',
|
||||
'openclaude',
|
||||
'codex',
|
||||
'gemini',
|
||||
'cursor',
|
||||
'copilot',
|
||||
'droid'
|
||||
])
|
||||
|
||||
export function getAgentImageHandling(agent: AgentType): AgentImageHandling {
|
||||
return IMAGE_ATTACHMENT_AGENTS.has(agent) ? 'attachment' : 'unsupported'
|
||||
}
|
||||
|
||||
export type ImagePasteResult =
|
||||
| { kind: 'attach'; path: string }
|
||||
| { kind: 'unsupported'; agent: AgentType }
|
||||
|
||||
/**
|
||||
* Given the agent and the temp-file path the image was written to, decide what
|
||||
* (if anything) to attach. Attachment-capable agents receive the path through
|
||||
* the same bracketed image-paste channel as the terminal TUI.
|
||||
*/
|
||||
export function resolveImagePaste(agent: AgentType, tempFilePath: string): ImagePasteResult {
|
||||
if (getAgentImageHandling(agent) === 'attachment') {
|
||||
return { kind: 'attach', path: tempFilePath }
|
||||
}
|
||||
return { kind: 'unsupported', agent }
|
||||
}
|
||||
|
||||
export function isNativeChatImageAttachmentPath(path: string): boolean {
|
||||
return isImageDropPath(path)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
isTextBlock,
|
||||
type NativeChatBlock,
|
||||
type NativeChatMessage
|
||||
} from '../../../../shared/native-chat-types'
|
||||
|
||||
const IMAGE_SOURCE_MARKER = /^\[Image:\s*source:\s*(.+?)\]\s*$/
|
||||
const IMAGE_PROMPT_MARKER = /^\[Image #\d+\]\s*/
|
||||
|
||||
function soleText(message: NativeChatMessage): string | null {
|
||||
return message.blocks.length === 1 && isTextBlock(message.blocks[0])
|
||||
? message.blocks[0].text
|
||||
: null
|
||||
}
|
||||
|
||||
export function imageSourcePathFromText(text: string): string | null {
|
||||
return text.match(IMAGE_SOURCE_MARKER)?.[1]?.trim() ?? null
|
||||
}
|
||||
|
||||
export function stripImagePromptMarker(text: string): string {
|
||||
return text.replace(IMAGE_PROMPT_MARKER, '')
|
||||
}
|
||||
|
||||
function stripFirstImagePromptMarker(blocks: readonly NativeChatBlock[]): NativeChatBlock[] {
|
||||
let stripped = false
|
||||
const next: NativeChatBlock[] = []
|
||||
for (const block of blocks) {
|
||||
if (!stripped && isTextBlock(block)) {
|
||||
stripped = true
|
||||
const text = stripImagePromptMarker(block.text)
|
||||
if (text.trim().length > 0) {
|
||||
next.push({ ...block, text })
|
||||
}
|
||||
continue
|
||||
}
|
||||
next.push(block)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function imagePromptMarkerStartsMessage(message: NativeChatMessage): boolean {
|
||||
const firstText = message.blocks.find(isTextBlock)
|
||||
return firstText ? IMAGE_PROMPT_MARKER.test(firstText.text) : false
|
||||
}
|
||||
|
||||
/** Claude records an attached image as two user transcript turns:
|
||||
* `[Image: source: /path]` and then `[Image #1] prompt`. Merge them back into
|
||||
* one native turn so the UI keeps the same chip+text shape as the optimistic
|
||||
* send and does not show raw TUI marker text after a view remount. */
|
||||
export function normalizeImageTranscriptMessages(
|
||||
messages: readonly NativeChatMessage[]
|
||||
): NativeChatMessage[] {
|
||||
const normalized: NativeChatMessage[] = []
|
||||
for (let index = 0; index < messages.length; index += 1) {
|
||||
const message = messages[index]!
|
||||
if (message.role !== 'user') {
|
||||
normalized.push(message)
|
||||
continue
|
||||
}
|
||||
const imagePath = imageSourcePathFromText(soleText(message) ?? '')
|
||||
const next = messages[index + 1]
|
||||
if (
|
||||
imagePath &&
|
||||
next?.role === 'user' &&
|
||||
next.source === message.source &&
|
||||
imagePromptMarkerStartsMessage(next)
|
||||
) {
|
||||
normalized.push({
|
||||
...next,
|
||||
blocks: [
|
||||
{ type: 'image-ref', path: imagePath },
|
||||
...stripFirstImagePromptMarker(next.blocks)
|
||||
]
|
||||
})
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
normalized.push({
|
||||
...message,
|
||||
blocks: stripFirstImagePromptMarker(message.blocks)
|
||||
})
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import { assembleNativeChatSession } from './native-chat-session-assembler'
|
||||
import {
|
||||
applyAppends,
|
||||
createIncrementalAssembler,
|
||||
reset
|
||||
} from './native-chat-incremental-assembler'
|
||||
|
||||
function msg(
|
||||
overrides: Partial<NativeChatMessage> & Pick<NativeChatMessage, 'id'>
|
||||
): NativeChatMessage {
|
||||
return {
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'text', text: overrides.id }],
|
||||
timestamp: 0,
|
||||
source: 'transcript',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
// Canonical reference: assemble the full transcript from scratch.
|
||||
function fullRebuild(messages: NativeChatMessage[]): NativeChatMessage[] {
|
||||
return assembleNativeChatSession({
|
||||
sources: { transcript: messages },
|
||||
sessionId: 's1',
|
||||
agent: 'claude'
|
||||
}).messages
|
||||
}
|
||||
|
||||
// Drive the incremental assembler over base + each append batch, capturing the
|
||||
// emitted list after every batch so we can compare each prefix to a full rebuild.
|
||||
function incrementalPrefixes(
|
||||
base: NativeChatMessage[],
|
||||
batches: NativeChatMessage[][]
|
||||
): NativeChatMessage[][] {
|
||||
const assembler = createIncrementalAssembler()
|
||||
const out: NativeChatMessage[][] = []
|
||||
out.push(reset(assembler, base))
|
||||
for (const batch of batches) {
|
||||
out.push(applyAppends(assembler, batch))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
describe('incremental assembler — oracle differential', () => {
|
||||
// An adversarial append sequence exercising every hard case the design calls
|
||||
// out: re-emitted ids, transcript-supersedes-hook, turnKey collisions,
|
||||
// out-of-order + null timestamps, empty batches.
|
||||
const base: NativeChatMessage[] = [
|
||||
msg({ id: 'a', timestamp: 10, role: 'user', blocks: [{ type: 'text', text: 'hello' }] }),
|
||||
msg({ id: 'b', timestamp: 20, blocks: [{ type: 'text', text: 'partial' }], source: 'hook' })
|
||||
]
|
||||
|
||||
const batches: NativeChatMessage[][] = [
|
||||
// pure tail append (fast path)
|
||||
[msg({ id: 'c', timestamp: 30, blocks: [{ type: 'text', text: 'c' }] })],
|
||||
// empty batch
|
||||
[],
|
||||
// re-emit id 'b' from transcript — supersedes the hook copy in place
|
||||
[msg({ id: 'b', timestamp: 20, source: 'transcript', blocks: [{ type: 'text', text: 'final' }] })],
|
||||
// out-of-order timestamp: sorts BEFORE the current tail → forces re-sort
|
||||
[msg({ id: 'd', timestamp: 5, blocks: [{ type: 'text', text: 'early' }] })],
|
||||
// turnKey collision with 'a': same role+text+timestamp, DIFFERENT source
|
||||
// (scrape) — lower priority, must be dropped by the cross-source gate
|
||||
[msg({ id: 'a-scrape', timestamp: 10, role: 'user', source: 'scrape', blocks: [{ type: 'text', text: 'hello' }] })],
|
||||
// same-source identical prompt (distinct id) — must NOT collapse (#10)
|
||||
[msg({ id: 'e', timestamp: 40, role: 'user', blocks: [{ type: 'text', text: 'hello' }] })],
|
||||
// null timestamp append → forces re-sort, sorts to the front
|
||||
[msg({ id: 'f', timestamp: null, blocks: [{ type: 'text', text: 'f' }] })],
|
||||
// re-emitted id at the tail (already seen 'c'), no change
|
||||
[msg({ id: 'c', timestamp: 30, blocks: [{ type: 'text', text: 'c' }] })],
|
||||
// multi-message tail batch
|
||||
[
|
||||
msg({ id: 'g', timestamp: 50, blocks: [{ type: 'text', text: 'g' }] }),
|
||||
msg({ id: 'h', timestamp: 60, blocks: [{ type: 'text', text: 'h' }] })
|
||||
]
|
||||
]
|
||||
|
||||
it('matches a full rebuild for every prefix of the append sequence', () => {
|
||||
const inc = incrementalPrefixes(base, batches)
|
||||
let cumulative = [...base]
|
||||
// Prefix 0 = base only.
|
||||
expect(inc[0]).toEqual(fullRebuild(cumulative))
|
||||
for (let i = 0; i < batches.length; i += 1) {
|
||||
cumulative = [...cumulative, ...batches[i]!]
|
||||
expect(inc[i + 1]).toEqual(fullRebuild(cumulative))
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps prior message object identity on a pure tail append', () => {
|
||||
const assembler = createIncrementalAssembler()
|
||||
reset(assembler, base)
|
||||
const before = assembler.messages
|
||||
const tail = msg({ id: 'z', timestamp: 99, blocks: [{ type: 'text', text: 'z' }] })
|
||||
const after = applyAppends(assembler, [tail])
|
||||
// New array reference (React needs it) but the existing rows keep identity.
|
||||
expect(after).not.toBe(before)
|
||||
expect(after[0]).toBe(before[0])
|
||||
expect(after.at(-1)).toBe(tail)
|
||||
})
|
||||
|
||||
it('returns the same reference for an empty append batch', () => {
|
||||
const assembler = createIncrementalAssembler()
|
||||
reset(assembler, base)
|
||||
const out = assembler.messages
|
||||
expect(applyAppends(assembler, [])).toBe(out)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
// Incremental native-chat assembler. The full `assembleNativeChatSession` does
|
||||
// an O(n log n) Map-build + sort on every call; on the hot streaming path the
|
||||
// agent emits many small append batches over a growing transcript, so the full
|
||||
// rebuild is quadratic per turn (#17). This splits the two mutation axes:
|
||||
//
|
||||
// - base axis (session swap / loadEarlier re-read): rare, user-driven → reset,
|
||||
// a full rebuild that is byte-for-byte identical to assembleNativeChatSession.
|
||||
// - append axis (live streaming): hot → applyAppends, which feeds only the new
|
||||
// batch through the SAME mergeOne rule and splices at the tail when the batch
|
||||
// is purely-new and already-sorted, falling back to a full re-sort otherwise.
|
||||
//
|
||||
// Correctness invariant: applyAppends output deep-equals a full rebuild over
|
||||
// base ++ all-appends for every prefix (locked by the oracle differential test).
|
||||
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import { compareMessages, mergeOne } from './native-chat-session-assembler'
|
||||
|
||||
export type IncrementalChatAssembler = {
|
||||
byId: Map<string, NativeChatMessage>
|
||||
byTurn: Map<string, NativeChatMessage>
|
||||
// Last emitted sorted output; stable reference until a mutation occurs.
|
||||
messages: NativeChatMessage[]
|
||||
}
|
||||
|
||||
export function createIncrementalAssembler(): IncrementalChatAssembler {
|
||||
return { byId: new Map(), byTurn: new Map(), messages: [] }
|
||||
}
|
||||
|
||||
/** Rebuild the assembled state from a base list (the windowed read). Canonical
|
||||
* path — equivalent to assembleNativeChatSession over `{ transcript: base }`. */
|
||||
export function reset(
|
||||
assembler: IncrementalChatAssembler,
|
||||
base: readonly NativeChatMessage[]
|
||||
): NativeChatMessage[] {
|
||||
assembler.byId = new Map()
|
||||
assembler.byTurn = new Map()
|
||||
for (const message of base) {
|
||||
mergeOne(assembler.byId, assembler.byTurn, message)
|
||||
}
|
||||
assembler.messages = Array.from(assembler.byId.values()).sort(compareMessages)
|
||||
return assembler.messages
|
||||
}
|
||||
|
||||
/** Fold a live append batch through the same merge rule as the full rebuild.
|
||||
* Fast path: when every incoming message is a brand-new id, has a brand-new
|
||||
* turnKey-free identity (no merge/removal), and sorts at/after the current
|
||||
* tail, splice the batch in (O(k log k)). Any ambiguity → full re-sort of the
|
||||
* whole map (still correct, just O(n log n) for that one rare batch). */
|
||||
export function applyAppends(
|
||||
assembler: IncrementalChatAssembler,
|
||||
incoming: readonly NativeChatMessage[]
|
||||
): NativeChatMessage[] {
|
||||
if (incoming.length === 0) {
|
||||
return assembler.messages
|
||||
}
|
||||
|
||||
const sizeBefore = assembler.byId.size
|
||||
for (const message of incoming) {
|
||||
mergeOne(assembler.byId, assembler.byTurn, message)
|
||||
}
|
||||
|
||||
// A merge or removal happened if the map didn't grow by exactly the batch
|
||||
// size — some incoming id/turn collided with or superseded an existing entry,
|
||||
// which can change an existing entry's sort position. Fall back to re-sort.
|
||||
const grewByBatch = assembler.byId.size === sizeBefore + incoming.length
|
||||
if (grewByBatch && isTailAppend(assembler.messages, incoming)) {
|
||||
// Every incoming message is new and sorts at/after the tail: splice the
|
||||
// batch in its own sorted order without touching the existing prefix.
|
||||
const tail = [...incoming].sort(compareMessages)
|
||||
assembler.messages = [...assembler.messages, ...tail]
|
||||
return assembler.messages
|
||||
}
|
||||
|
||||
assembler.messages = Array.from(assembler.byId.values()).sort(compareMessages)
|
||||
return assembler.messages
|
||||
}
|
||||
|
||||
/** True when the whole batch sorts strictly at/after the current last message
|
||||
* AND is internally unambiguous to splice. A null timestamp in the batch sorts
|
||||
* before any real timestamp, so it can never be a pure tail append — bail to
|
||||
* the full re-sort. */
|
||||
function isTailAppend(
|
||||
current: readonly NativeChatMessage[],
|
||||
incoming: readonly NativeChatMessage[]
|
||||
): boolean {
|
||||
const last = current.at(-1)
|
||||
if (!last) {
|
||||
return true
|
||||
}
|
||||
for (const message of incoming) {
|
||||
// Null timestamp (sorts to the front) can never be a tail append.
|
||||
if (message.timestamp === null) {
|
||||
return false
|
||||
}
|
||||
if (compareMessages(message, last) < 0) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
formatAskAnswer,
|
||||
parseApprovalFromStatus,
|
||||
parseAskFromStatus,
|
||||
parseInteractivePrompt
|
||||
} from './native-chat-interactive-prompt'
|
||||
|
||||
const ESC = String.fromCharCode(27)
|
||||
|
||||
describe('parseAskFromStatus', () => {
|
||||
it('returns null for empty/invalid input', () => {
|
||||
expect(parseAskFromStatus(null)).toBeNull()
|
||||
expect(parseAskFromStatus(undefined)).toBeNull()
|
||||
expect(parseAskFromStatus('')).toBeNull()
|
||||
expect(parseAskFromStatus('not json')).toBeNull()
|
||||
expect(parseAskFromStatus('{}')).toBeNull()
|
||||
expect(parseAskFromStatus('{"questions":[]}')).toBeNull()
|
||||
})
|
||||
|
||||
it('parses the canonical AskUserQuestion shape', () => {
|
||||
const prompt = parseAskFromStatus(
|
||||
JSON.stringify({
|
||||
questions: [
|
||||
{
|
||||
question: 'Pick a color',
|
||||
header: 'Color',
|
||||
multiSelect: false,
|
||||
options: [{ label: 'Red', description: 'warm' }, { label: 'Blue' }]
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
expect(prompt).toEqual({
|
||||
questions: [
|
||||
{
|
||||
question: 'Pick a color',
|
||||
header: 'Color',
|
||||
multiSelect: false,
|
||||
options: [{ label: 'Red', description: 'warm' }, { label: 'Blue' }]
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts string options and defaults multiSelect to false', () => {
|
||||
const prompt = parseAskFromStatus(
|
||||
JSON.stringify({ questions: [{ question: 'q', options: ['A', 'B'] }] })
|
||||
)
|
||||
expect(prompt?.questions[0]).toMatchObject({
|
||||
multiSelect: false,
|
||||
options: [{ label: 'A' }, { label: 'B' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('honors multiSelect: true and multiple questions', () => {
|
||||
const prompt = parseAskFromStatus(
|
||||
JSON.stringify({
|
||||
questions: [
|
||||
{ question: 'q1', multiSelect: true, options: [{ label: 'X' }] },
|
||||
{ question: 'q2', options: [{ label: 'Y' }] }
|
||||
]
|
||||
})
|
||||
)
|
||||
expect(prompt?.questions).toHaveLength(2)
|
||||
expect(prompt?.questions[0]?.multiSelect).toBe(true)
|
||||
expect(prompt?.questions[1]?.multiSelect).toBe(false)
|
||||
})
|
||||
|
||||
it('skips malformed question entries', () => {
|
||||
const prompt = parseAskFromStatus(
|
||||
JSON.stringify({ questions: [null, 42, { question: 'ok', options: ['A'] }] })
|
||||
)
|
||||
expect(prompt?.questions).toHaveLength(1)
|
||||
expect(prompt?.questions[0]?.question).toBe('ok')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseApprovalFromStatus', () => {
|
||||
it('returns null for non-approval envelopes', () => {
|
||||
expect(parseApprovalFromStatus(null)).toBeNull()
|
||||
expect(parseApprovalFromStatus('not json')).toBeNull()
|
||||
expect(parseApprovalFromStatus('{}')).toBeNull()
|
||||
expect(parseApprovalFromStatus(JSON.stringify({ approval: {} }))).toBeNull()
|
||||
expect(parseApprovalFromStatus(JSON.stringify({ approval: { tool: '' } }))).toBeNull()
|
||||
})
|
||||
|
||||
it('builds an Allow/Deny card from { approval: { tool, summary } }', () => {
|
||||
const approval = parseApprovalFromStatus(
|
||||
JSON.stringify({ approval: { tool: 'Bash', summary: 'rm -rf build' } })
|
||||
)
|
||||
expect(approval).toEqual({
|
||||
title: 'Allow Bash?',
|
||||
detail: 'rm -rf build',
|
||||
options: [
|
||||
{ label: 'Allow', send: '1' },
|
||||
{ label: 'Deny', send: ESC }
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('omits detail when summary is missing', () => {
|
||||
const approval = parseApprovalFromStatus(JSON.stringify({ approval: { tool: 'Edit' } }))
|
||||
expect(approval?.title).toBe('Allow Edit?')
|
||||
expect(approval?.detail).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseInteractivePrompt', () => {
|
||||
it('returns a question card, with question taking precedence', () => {
|
||||
const card = parseInteractivePrompt(
|
||||
JSON.stringify({
|
||||
questions: [{ question: 'q', options: ['A'] }],
|
||||
approval: { tool: 'Bash', summary: 's' }
|
||||
})
|
||||
)
|
||||
expect(card?.kind).toBe('question')
|
||||
})
|
||||
|
||||
it('returns an approval card when no question is present', () => {
|
||||
const card = parseInteractivePrompt(JSON.stringify({ approval: { tool: 'Bash' } }))
|
||||
expect(card?.kind).toBe('approval')
|
||||
})
|
||||
|
||||
it('returns null when neither parses', () => {
|
||||
expect(parseInteractivePrompt(null)).toBeNull()
|
||||
expect(parseInteractivePrompt('{}')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatAskAnswer', () => {
|
||||
it('joins selected labels per question, one line each', () => {
|
||||
const prompt = {
|
||||
questions: [
|
||||
{ question: 'q1', multiSelect: true, options: [{ label: 'A' }, { label: 'B' }] },
|
||||
{ question: 'q2', multiSelect: false, options: [{ label: 'C' }] }
|
||||
]
|
||||
}
|
||||
expect(formatAskAnswer(prompt, [['A', 'B'], ['C']])).toBe('A, B\nC')
|
||||
})
|
||||
|
||||
it('preserves empty answers as empty lines so N lines == N questions', () => {
|
||||
const prompt = {
|
||||
questions: [
|
||||
{ question: 'q1', multiSelect: false, options: [{ label: 'A' }] },
|
||||
{ question: 'q2', multiSelect: false, options: [{ label: 'B' }] }
|
||||
]
|
||||
}
|
||||
// Leading blank stays an empty line: '\nB' (2 lines), not 'B'.
|
||||
expect(formatAskAnswer(prompt, [[], ['B']])).toBe('\nB')
|
||||
})
|
||||
|
||||
it('keeps one line per question with a blank middle answer (3 questions)', () => {
|
||||
const prompt = {
|
||||
questions: [
|
||||
{ question: 'q1', multiSelect: false, options: [{ label: 'A' }] },
|
||||
{ question: 'q2', multiSelect: false, options: [{ label: 'B' }] },
|
||||
{ question: 'q3', multiSelect: false, options: [{ label: 'C' }] }
|
||||
]
|
||||
}
|
||||
const answer = formatAskAnswer(prompt, [['A'], [], ['C']])
|
||||
expect(answer).toBe('A\n\nC')
|
||||
expect(answer.split('\n')).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,200 @@
|
||||
// Pure parser for the live `agentStatus.interactivePrompt` envelope (JSON the
|
||||
// host captures from the agent's hook). It resolves to either a structured
|
||||
// question prompt (AskUserQuestion) or a tool-approval (PermissionRequest), the
|
||||
// two interactive cards the native chat renders just above the composer. Kept
|
||||
// pure (no React/IO) so the envelope rules are unit-testable.
|
||||
//
|
||||
// Why: the Ask question parser (parseQuestionsShape/parseOptions/the
|
||||
// QUESTION_TOOL_PARSERS registry/parseToolInput/formatAskAnswer) is a byte-for-byte
|
||||
// mirror of mobile's `mobile-native-chat-ask.ts` — Metro can't import these runtime
|
||||
// values from src/shared, so both copies must stay in sync; parity is asserted by
|
||||
// `src/shared/native-chat-ask-parser-parity.test.ts`. The approval-card logic below
|
||||
// (ChatApproval/parseApprovalFromStatus/ESCAPE) is desktop-only.
|
||||
|
||||
import type {
|
||||
AskOption,
|
||||
AskPrompt,
|
||||
AskQuestion,
|
||||
InteractiveQuestionParser
|
||||
} from '../../../../shared/native-chat-ask-types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export type { AskOption, AskPrompt, AskQuestion, InteractiveQuestionParser }
|
||||
|
||||
/** A detected tool-approval, rendered as an Allow/Deny card. Each option's
|
||||
* `send` is the literal string written back to the agent's PTY when chosen
|
||||
* (a number to allow; the ESC char to deny). */
|
||||
export type ChatApproval = {
|
||||
title: string
|
||||
detail?: string
|
||||
options: { label: string; send: string }[]
|
||||
}
|
||||
|
||||
/** The interactive card to render for the current live status, or null. A
|
||||
* question takes precedence over an approval when both somehow parse. */
|
||||
export type InteractivePromptCard =
|
||||
| { kind: 'question'; prompt: AskPrompt }
|
||||
| { kind: 'approval'; approval: ChatApproval }
|
||||
| null
|
||||
|
||||
// ESC interrupts the agent over the PTY (matches how the composer forwards
|
||||
// Escape), so "Cancel"/"Deny" sends this byte.
|
||||
const ESCAPE = String.fromCharCode(27)
|
||||
|
||||
// Registry of question-tool parsers keyed by the tool name the agent reports.
|
||||
// To support a new terminal/agent's question tool, register its parser here (or
|
||||
// via registerQuestionTool) — the renderer and wiring stay unchanged.
|
||||
const QUESTION_TOOL_PARSERS = new Map<string, InteractiveQuestionParser>()
|
||||
|
||||
export function registerQuestionTool(toolName: string, parser: InteractiveQuestionParser): void {
|
||||
QUESTION_TOOL_PARSERS.set(toolName, parser)
|
||||
}
|
||||
|
||||
/** Claude's AskUserQuestion shape: `{ questions: [{ question, header,
|
||||
* multiSelect, options: [{ label, description }] }] }`. Also the de-facto
|
||||
* default shape, so a new agent that reuses it works without registration. */
|
||||
function parseQuestionsShape(input: unknown): AskPrompt | null {
|
||||
if (!input || typeof input !== 'object') {
|
||||
return null
|
||||
}
|
||||
const rawQuestions = (input as { questions?: unknown }).questions
|
||||
if (!Array.isArray(rawQuestions) || rawQuestions.length === 0) {
|
||||
return null
|
||||
}
|
||||
const questions: AskQuestion[] = []
|
||||
for (const raw of rawQuestions) {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
continue
|
||||
}
|
||||
const q = raw as Record<string, unknown>
|
||||
const question = typeof q.question === 'string' ? q.question : ''
|
||||
const options = parseOptions(q.options)
|
||||
if (question || options.length > 0) {
|
||||
questions.push({
|
||||
question,
|
||||
header: typeof q.header === 'string' ? q.header : undefined,
|
||||
multiSelect: q.multiSelect === true,
|
||||
options
|
||||
})
|
||||
}
|
||||
}
|
||||
return questions.length > 0 ? { questions } : null
|
||||
}
|
||||
|
||||
function parseOptions(raw: unknown): AskOption[] {
|
||||
if (!Array.isArray(raw)) {
|
||||
return []
|
||||
}
|
||||
return raw
|
||||
.map((o): AskOption | null => {
|
||||
if (typeof o === 'string') {
|
||||
return { label: o }
|
||||
}
|
||||
if (o && typeof o === 'object' && typeof (o as { label?: unknown }).label === 'string') {
|
||||
const obj = o as { label: string; description?: unknown }
|
||||
return {
|
||||
label: obj.label,
|
||||
description: typeof obj.description === 'string' ? obj.description : undefined
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
.filter((o): o is AskOption => o !== null)
|
||||
}
|
||||
|
||||
// Claude's AskUserQuestion (and aliases) ship the canonical questions shape.
|
||||
for (const name of ['AskUserQuestion', 'ask_user_question', 'askUserQuestion']) {
|
||||
QUESTION_TOOL_PARSERS.set(name, parseQuestionsShape)
|
||||
}
|
||||
|
||||
/** Resolve an interactive-prompt payload to an AskPrompt: try the tool's
|
||||
* registered parser first, then fall back to the canonical questions shape so a
|
||||
* new agent that happens to use the same structure works without registration. */
|
||||
function parseToolInput(toolName: string | undefined, input: unknown): AskPrompt | null {
|
||||
const parser = toolName ? QUESTION_TOOL_PARSERS.get(toolName) : undefined
|
||||
return (parser ? parser(input) : null) ?? parseQuestionsShape(input)
|
||||
}
|
||||
|
||||
/** Parse the live `agentStatus.interactivePrompt` (the agent's untruncated
|
||||
* question-tool input as JSON) into an AskPrompt, or null. Dispatches through
|
||||
* the tool's registered parser (keyed by `toolName`) with the canonical
|
||||
* questions shape as the fallback. */
|
||||
export function parseAskFromStatus(
|
||||
interactivePrompt: string | undefined | null,
|
||||
toolName?: string
|
||||
): AskPrompt | null {
|
||||
if (!interactivePrompt) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return parseToolInput(toolName, JSON.parse(interactivePrompt))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse the `{ approval: { tool, summary } }` envelope (emitted by the host on
|
||||
* a PermissionRequest) into an Allow/Deny card, or null. Allow sends "1"; Deny
|
||||
* sends ESC — matching the common TUI approval prompt. */
|
||||
export function parseApprovalFromStatus(
|
||||
interactivePrompt: string | undefined | null
|
||||
): ChatApproval | null {
|
||||
if (!interactivePrompt) {
|
||||
return null
|
||||
}
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(interactivePrompt)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return null
|
||||
}
|
||||
const approval = (parsed as { approval?: unknown }).approval
|
||||
if (!approval || typeof approval !== 'object') {
|
||||
return null
|
||||
}
|
||||
const tool = (approval as { tool?: unknown }).tool
|
||||
if (typeof tool !== 'string' || tool.length === 0) {
|
||||
return null
|
||||
}
|
||||
const summary = (approval as { summary?: unknown }).summary
|
||||
return {
|
||||
title: translate('components.native-chat.approval.title', 'Allow {{value0}}?', {
|
||||
value0: tool
|
||||
}),
|
||||
detail: typeof summary === 'string' && summary.length > 0 ? summary : undefined,
|
||||
options: [
|
||||
{ label: translate('components.native-chat.approval.allow', 'Allow'), send: '1' },
|
||||
{ label: translate('components.native-chat.approval.deny', 'Deny'), send: ESCAPE }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve the live `interactivePrompt` to the single card to render. A question
|
||||
* takes precedence over an approval. `toolName` is forwarded to the Ask branch
|
||||
* for registry dispatch; the approval branch ignores it. */
|
||||
export function parseInteractivePrompt(
|
||||
interactivePrompt: string | undefined | null,
|
||||
toolName?: string
|
||||
): InteractivePromptCard {
|
||||
const prompt = parseAskFromStatus(interactivePrompt, toolName)
|
||||
if (prompt) {
|
||||
return { kind: 'question', prompt }
|
||||
}
|
||||
const approval = parseApprovalFromStatus(interactivePrompt)
|
||||
if (approval) {
|
||||
return { kind: 'approval', approval }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Build the answer text to send: exactly one line per question, in question
|
||||
* order, each line the selected option label(s) joined by ", ". Empty answers
|
||||
* stay as empty lines (not dropped) so N lines always == N questions — the
|
||||
* per-question Enter stepping counts one Enter per line, so dropping a blank
|
||||
* middle answer would misalign the count and leave the prompt unsubmitted. */
|
||||
export function formatAskAnswer(prompt: AskPrompt, selections: string[][]): string {
|
||||
return prompt.questions.map((_, i) => (selections[i] ?? []).join(', ')).join('\n')
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Pure merge of live hook turn-state into a NativeChatSession status override.
|
||||
// Kept separate from the React hook so the precedence rule (live 'working'
|
||||
// surfaces before the transcript flushes the final assistant message, then is
|
||||
// superseded once it lands) is unit-testable without IPC or the store.
|
||||
|
||||
import type { AgentStatusState } from '../../../../shared/agent-status-types'
|
||||
import { assembleNativeChatSession, type NativeChatSources } from './native-chat-session-assembler'
|
||||
import type {
|
||||
AgentType,
|
||||
NativeChatSession,
|
||||
NativeChatSessionStatus
|
||||
} from '../../../../shared/native-chat-types'
|
||||
|
||||
export type NativeChatLiveMergeInput = {
|
||||
sources: NativeChatSources
|
||||
sessionId: string | null
|
||||
agent: AgentType
|
||||
/** Live hook state for the pane, or null when no hook entry exists. */
|
||||
hookState: AgentStatusState | null
|
||||
/** True before the initial readSession resolves; forces 'loading'. */
|
||||
loading?: boolean
|
||||
/** Set when the initial read failed; forces 'error'. */
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide the session status given the merged transcript/append messages and the
|
||||
* live hook state. The transcript is the source of truth for content; the hook
|
||||
* only fills the gap while the agent is mid-turn.
|
||||
*
|
||||
* Precedence:
|
||||
* - error / loading overrides win outright.
|
||||
* - hook 'working' shows a live working indicator BEFORE the assistant turn
|
||||
* lands in the transcript. Once the transcript's last message is an
|
||||
* assistant reply (the turn flushed), 'working' is no longer asserted and
|
||||
* the derived 'ready' status from the assembler stands.
|
||||
*/
|
||||
export function mergeNativeChatLiveSession(input: NativeChatLiveMergeInput): NativeChatSession {
|
||||
const { sources, sessionId, agent, hookState, loading, error } = input
|
||||
if (error) {
|
||||
return assembleNativeChatSession({ sources, sessionId, agent, status: 'error', error })
|
||||
}
|
||||
if (loading) {
|
||||
return assembleNativeChatSession({ sources, sessionId, agent, status: 'loading' })
|
||||
}
|
||||
|
||||
const status = liveStatusOverride(sources, hookState)
|
||||
return assembleNativeChatSession({
|
||||
sources,
|
||||
sessionId,
|
||||
agent,
|
||||
...(status ? { status } : {})
|
||||
})
|
||||
}
|
||||
|
||||
function liveStatusOverride(
|
||||
sources: NativeChatSources,
|
||||
hookState: AgentStatusState | null
|
||||
): NativeChatSessionStatus | undefined {
|
||||
// Only 'working' drives a live override; blocked/waiting/done leave the
|
||||
// derived (ready/empty) status alone so completed turns render normally.
|
||||
if (hookState !== 'working') {
|
||||
return undefined
|
||||
}
|
||||
// If the transcript has already flushed the in-flight assistant reply, the
|
||||
// turn is effectively visible — don't keep asserting 'working' on top of it.
|
||||
if (lastMessageIsFreshAssistant(sources)) {
|
||||
return undefined
|
||||
}
|
||||
return 'working'
|
||||
}
|
||||
|
||||
function lastMessageIsFreshAssistant(sources: NativeChatSources): boolean {
|
||||
const transcript = sources.transcript ?? []
|
||||
const last = transcript.at(-1)
|
||||
return last?.role === 'assistant'
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import { buildNativeChatRenderItems, orderNativeChatMessages } from './native-chat-message-grouping'
|
||||
|
||||
function msg(
|
||||
overrides: Partial<NativeChatMessage> & Pick<NativeChatMessage, 'id'>
|
||||
): NativeChatMessage {
|
||||
return {
|
||||
role: 'assistant',
|
||||
blocks: [],
|
||||
timestamp: 0,
|
||||
source: 'transcript',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('orderNativeChatMessages', () => {
|
||||
it('orders by ascending timestamp, null first', () => {
|
||||
const ordered = orderNativeChatMessages([
|
||||
msg({ id: 'b', timestamp: 20 }),
|
||||
msg({ id: 'a', timestamp: 10 }),
|
||||
msg({ id: 'n', timestamp: null })
|
||||
])
|
||||
expect(ordered.map((m) => m.id)).toEqual(['n', 'a', 'b'])
|
||||
})
|
||||
|
||||
it('breaks timestamp ties by id deterministically', () => {
|
||||
const ordered = orderNativeChatMessages([
|
||||
msg({ id: 'z', timestamp: 5 }),
|
||||
msg({ id: 'a', timestamp: 5 })
|
||||
])
|
||||
expect(ordered.map((m) => m.id)).toEqual(['a', 'z'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildNativeChatRenderItems', () => {
|
||||
it('renders messages in order', () => {
|
||||
const items = buildNativeChatRenderItems([
|
||||
msg({ id: 'u', role: 'user', timestamp: 1, blocks: [{ type: 'text', text: 'hi' }] }),
|
||||
msg({ id: 'a', role: 'assistant', timestamp: 2, blocks: [{ type: 'text', text: 'hello' }] })
|
||||
])
|
||||
expect(items.map((i) => i.id)).toEqual(['u', 'a'])
|
||||
expect(items[0]?.kind).toBe('message')
|
||||
})
|
||||
|
||||
it('pairs a tool-call with its tool-result into one step', () => {
|
||||
const items = buildNativeChatRenderItems([
|
||||
msg({
|
||||
id: 'a',
|
||||
role: 'assistant',
|
||||
timestamp: 1,
|
||||
blocks: [{ type: 'tool-call', name: 'Bash', input: { cmd: 'ls' } }]
|
||||
}),
|
||||
msg({
|
||||
id: 't',
|
||||
role: 'tool',
|
||||
timestamp: 2,
|
||||
blocks: [{ type: 'tool-result', output: 'file.txt' }]
|
||||
})
|
||||
])
|
||||
const steps = items.filter((i) => i.kind === 'tool-step')
|
||||
expect(steps).toHaveLength(1)
|
||||
const step = steps[0]
|
||||
if (step?.kind !== 'tool-step') {
|
||||
throw new Error('expected tool-step')
|
||||
}
|
||||
expect(step.step.call.name).toBe('Bash')
|
||||
expect(step.step.result?.output).toBe('file.txt')
|
||||
})
|
||||
|
||||
it('leaves an unanswered tool-call in flight (result null)', () => {
|
||||
const items = buildNativeChatRenderItems([
|
||||
msg({
|
||||
id: 'a',
|
||||
role: 'assistant',
|
||||
timestamp: 1,
|
||||
blocks: [{ type: 'tool-call', name: 'Read', input: {} }]
|
||||
})
|
||||
])
|
||||
const step = items.find((i) => i.kind === 'tool-step')
|
||||
if (step?.kind !== 'tool-step') {
|
||||
throw new Error('expected tool-step')
|
||||
}
|
||||
expect(step.step.result).toBeNull()
|
||||
})
|
||||
|
||||
it('separates prose blocks from tool blocks in the same message', () => {
|
||||
const items = buildNativeChatRenderItems([
|
||||
msg({
|
||||
id: 'a',
|
||||
role: 'assistant',
|
||||
timestamp: 1,
|
||||
blocks: [
|
||||
{ type: 'text', text: 'running it' },
|
||||
{ type: 'tool-call', name: 'Bash', input: {} }
|
||||
]
|
||||
})
|
||||
])
|
||||
expect(items.map((i) => i.kind)).toEqual(['message', 'tool-step'])
|
||||
const message = items[0]
|
||||
if (message?.kind !== 'message') {
|
||||
throw new Error('expected message')
|
||||
}
|
||||
expect(message.blocks).toEqual([{ type: 'text', text: 'running it' }])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
// Pure grouping logic for the native chat message list. Kept out of the .tsx so
|
||||
// the pairing/ordering rules are unit-testable without rendering. Two jobs:
|
||||
// 1. Order messages stably (timestamp then id; null timestamps sort first as
|
||||
// the shared model documents) — the assembler already sorts, but the list
|
||||
// re-sorts defensively so a caller passing unordered fixtures still reads
|
||||
// correctly.
|
||||
// 2. Within an assistant turn, pair each tool-call block with the tool-result
|
||||
// that answers it so the view can render one collapsible step instead of
|
||||
// two disconnected rows.
|
||||
|
||||
import {
|
||||
isToolCallBlock,
|
||||
isToolResultBlock,
|
||||
type NativeChatBlock,
|
||||
type NativeChatMessage,
|
||||
type NativeChatToolCallBlock,
|
||||
type NativeChatToolResultBlock
|
||||
} from '../../../../shared/native-chat-types'
|
||||
import { compareMessages } from './native-chat-session-assembler'
|
||||
|
||||
/** A tool-call block paired with the result that answered it, when one exists.
|
||||
* `result` is null while the call is still in flight (no result yet). */
|
||||
export type NativeChatToolStep = {
|
||||
call: NativeChatToolCallBlock
|
||||
result: NativeChatToolResultBlock | null
|
||||
}
|
||||
|
||||
/** One renderable item in the list: either a prose/role message carrying its
|
||||
* non-tool blocks, or a tool step (call + optional result). The view renders
|
||||
* each variant differently. */
|
||||
export type NativeChatRenderItem =
|
||||
| {
|
||||
kind: 'message'
|
||||
id: string
|
||||
message: NativeChatMessage
|
||||
/** The message's blocks minus tool-call/tool-result (those become steps). */
|
||||
blocks: NativeChatBlock[]
|
||||
}
|
||||
| {
|
||||
kind: 'tool-step'
|
||||
id: string
|
||||
/** Role of the message the call originated from (assistant/tool). */
|
||||
role: NativeChatMessage['role']
|
||||
timestamp: number | null
|
||||
step: NativeChatToolStep
|
||||
}
|
||||
|
||||
/** Order messages stably: null timestamps first (model rule), then ascending
|
||||
* timestamp, ties broken by id. Shares the assembler's comparator so both
|
||||
* paths order identically. */
|
||||
export function orderNativeChatMessages(messages: NativeChatMessage[]): NativeChatMessage[] {
|
||||
return [...messages].sort(compareMessages)
|
||||
}
|
||||
|
||||
/** Collect every tool-result across the whole conversation in document order so
|
||||
* a call can find its answer even when the result lands in a later message (the
|
||||
* common transcript shape: assistant emits the call, a following tool message
|
||||
* carries the result). Results carry no originating name in our model, so they
|
||||
* are handed out FIFO to calls. */
|
||||
function collectToolResults(messages: NativeChatMessage[]): NativeChatToolResultBlock[] {
|
||||
const results: NativeChatToolResultBlock[] = []
|
||||
for (const message of messages) {
|
||||
for (const block of message.blocks) {
|
||||
if (isToolResultBlock(block)) {
|
||||
results.push(block)
|
||||
}
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten ordered messages into render items, pairing tool calls with results.
|
||||
* Result pairing is FIFO across the conversation: tool results in our model
|
||||
* carry no back-reference to a call id, so we match the Nth call to the Nth
|
||||
* result in document order — the order both providers emit them. A call with no
|
||||
* remaining result renders as in-flight (`result: null`).
|
||||
*/
|
||||
export function buildNativeChatRenderItems(messages: NativeChatMessage[]): NativeChatRenderItem[] {
|
||||
const ordered = orderNativeChatMessages(messages)
|
||||
const resultQueue = collectToolResults(ordered)
|
||||
let resultCursor = 0
|
||||
|
||||
const items: NativeChatRenderItem[] = []
|
||||
for (const message of ordered) {
|
||||
const nonToolBlocks: NativeChatBlock[] = []
|
||||
const steps: NativeChatToolStep[] = []
|
||||
|
||||
for (const block of message.blocks) {
|
||||
if (isToolCallBlock(block)) {
|
||||
const result = resultQueue[resultCursor] ?? null
|
||||
if (result) {
|
||||
resultCursor += 1
|
||||
}
|
||||
steps.push({ call: block, result })
|
||||
} else if (isToolResultBlock(block)) {
|
||||
// Results are emitted as steps from the call side; skip standalone ones.
|
||||
continue
|
||||
} else {
|
||||
nonToolBlocks.push(block)
|
||||
}
|
||||
}
|
||||
|
||||
if (nonToolBlocks.length > 0) {
|
||||
items.push({ kind: 'message', id: message.id, message, blocks: nonToolBlocks })
|
||||
}
|
||||
for (const [index, step] of steps.entries()) {
|
||||
items.push({
|
||||
kind: 'tool-step',
|
||||
id: `${message.id}:tool:${index}`,
|
||||
role: message.role,
|
||||
timestamp: message.timestamp,
|
||||
step
|
||||
})
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import { isNoiseMessage, stripNoiseMessages } from './native-chat-noise'
|
||||
|
||||
function msg(
|
||||
role: NativeChatMessage['role'],
|
||||
text: string,
|
||||
blocks?: NativeChatMessage['blocks']
|
||||
): NativeChatMessage {
|
||||
return {
|
||||
id: text.slice(0, 8),
|
||||
role,
|
||||
blocks: blocks ?? [{ type: 'text', text }],
|
||||
timestamp: 0,
|
||||
source: 'transcript'
|
||||
}
|
||||
}
|
||||
|
||||
describe('isNoiseMessage', () => {
|
||||
it('flags task-notification and system-reminder user turns', () => {
|
||||
expect(isNoiseMessage(msg('user', '<task-notification>\n<task-id>x</task-id>'))).toBe(true)
|
||||
expect(isNoiseMessage(msg('user', '<system-reminder>\nbe careful'))).toBe(true)
|
||||
expect(
|
||||
isNoiseMessage(
|
||||
msg(
|
||||
'user',
|
||||
'Caveat: The messages below were generated by the user while running local commands'
|
||||
)
|
||||
)
|
||||
).toBe(true)
|
||||
expect(isNoiseMessage(msg('user', '[Request interrupted by user]'))).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps real user messages', () => {
|
||||
expect(isNoiseMessage(msg('user', 'make it work for codex'))).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps assistant and tool turns', () => {
|
||||
expect(isNoiseMessage(msg('assistant', '<system-reminder> in prose'))).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a user turn that carries tool results', () => {
|
||||
expect(isNoiseMessage(msg('user', '', [{ type: 'tool-result', output: 'ok' }]))).toBe(false)
|
||||
})
|
||||
|
||||
it('stripNoiseMessages removes only the noise', () => {
|
||||
const out = stripNoiseMessages([
|
||||
msg('user', 'hello'),
|
||||
msg('user', '<task-notification>done'),
|
||||
msg('assistant', 'hi')
|
||||
])
|
||||
expect(out.map((m) => m.role)).toEqual(['user', 'assistant'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import { isTextBlock, type NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
|
||||
// Why: the harness injects machinery into the agent's conversation as user-role
|
||||
// turns — background task-notifications, system reminders, local-command output,
|
||||
// slash-command envelopes, interruption/compaction notices. These land in the
|
||||
// transcript but are not real user messages, so the chat filters them out (they
|
||||
// were confusingly rendered as the user's own bubbles). Mirrors the mobile
|
||||
// predicate in mobile/src/session/mobile-native-chat-noise.ts.
|
||||
|
||||
const NOISE_PREFIXES = [
|
||||
'<task-notification>',
|
||||
'<system-reminder>',
|
||||
'<local-command-stdout>',
|
||||
'<local-command-caveat>',
|
||||
'<command-name>',
|
||||
'<command-message>',
|
||||
'<command-args>',
|
||||
'<bash-',
|
||||
'[request interrupted',
|
||||
'caveat: the messages below were generated by the user while running local commands',
|
||||
'this session is being continued from a previous conversation'
|
||||
]
|
||||
|
||||
function messageText(message: NativeChatMessage): string {
|
||||
return message.blocks
|
||||
.filter(isTextBlock)
|
||||
.map((b) => b.text)
|
||||
.join('')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/** True when a message is harness machinery rather than real conversation. Only
|
||||
* user/system turns qualify — assistant/tool turns and any turn carrying real
|
||||
* tool activity are always kept. */
|
||||
export function isNoiseMessage(message: NativeChatMessage): boolean {
|
||||
if (message.role !== 'user' && message.role !== 'system') {
|
||||
return false
|
||||
}
|
||||
// Keep turns that carry tool activity (e.g. a user turn with tool results).
|
||||
if (message.blocks.some((b) => b.type === 'tool-call' || b.type === 'tool-result')) {
|
||||
return false
|
||||
}
|
||||
const text = messageText(message).toLowerCase()
|
||||
if (text.length === 0) {
|
||||
return false
|
||||
}
|
||||
return NOISE_PREFIXES.some((prefix) => text.startsWith(prefix))
|
||||
}
|
||||
|
||||
/** Drop harness-noise messages from a transcript. */
|
||||
export function stripNoiseMessages(messages: readonly NativeChatMessage[]): NativeChatMessage[] {
|
||||
return messages.filter((m) => !isNoiseMessage(m))
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
hasMoreNativeChatHistory,
|
||||
NATIVE_CHAT_INITIAL_LIMIT,
|
||||
NATIVE_CHAT_PAGE,
|
||||
nextNativeChatLimit
|
||||
} from './native-chat-pagination'
|
||||
|
||||
describe('nextNativeChatLimit', () => {
|
||||
it('grows the limit by one page', () => {
|
||||
expect(nextNativeChatLimit(NATIVE_CHAT_INITIAL_LIMIT)).toBe(
|
||||
NATIVE_CHAT_INITIAL_LIMIT + NATIVE_CHAT_PAGE
|
||||
)
|
||||
expect(nextNativeChatLimit(NATIVE_CHAT_INITIAL_LIMIT + NATIVE_CHAT_PAGE)).toBe(
|
||||
NATIVE_CHAT_INITIAL_LIMIT + 2 * NATIVE_CHAT_PAGE
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasMoreNativeChatHistory', () => {
|
||||
it('reports more when the read filled the requested window', () => {
|
||||
expect(hasMoreNativeChatHistory(300, 300)).toBe(true)
|
||||
expect(hasMoreNativeChatHistory(301, 300)).toBe(true)
|
||||
})
|
||||
|
||||
it('reports done when the read returned fewer than requested (head reached)', () => {
|
||||
expect(hasMoreNativeChatHistory(120, 300)).toBe(false)
|
||||
expect(hasMoreNativeChatHistory(0, 300)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
// Pure pagination math for the native-chat read window. The renderer reads the
|
||||
// transcript tail with a `limit`; when the user scrolls to the top it raises the
|
||||
// limit by a page to load older history. Kept pure (no React/IO) so the limit
|
||||
// growth and the "is there more?" decision are unit-testable.
|
||||
|
||||
// First page mirrors the desktop default window (300 most-recent turns) so the
|
||||
// initial paint matches the prior behavior; each load-earlier grows by a page.
|
||||
export const NATIVE_CHAT_INITIAL_LIMIT = 300
|
||||
export const NATIVE_CHAT_PAGE = 200
|
||||
|
||||
/** The limit to request for the next older page. */
|
||||
export function nextNativeChatLimit(currentLimit: number): number {
|
||||
return currentLimit + NATIVE_CHAT_PAGE
|
||||
}
|
||||
|
||||
/** Whether an older page may still exist: the last read filled the window, so
|
||||
* there could be more behind it. If the read returned fewer than requested we
|
||||
* reached the head of the transcript and there is nothing older to load. */
|
||||
export function hasMoreNativeChatHistory(returnedCount: number, requestedLimit: number): boolean {
|
||||
return returnedCount >= requestedLimit
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
import { resolveNativeChatSession } from './native-chat-pane-resolution'
|
||||
|
||||
function entry(
|
||||
overrides: Partial<AgentStatusEntry> & Pick<AgentStatusEntry, 'paneKey'>
|
||||
): AgentStatusEntry {
|
||||
return {
|
||||
state: 'working',
|
||||
prompt: '',
|
||||
updatedAt: 0,
|
||||
stateStartedAt: 0,
|
||||
stateHistory: [],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('resolveNativeChatSession', () => {
|
||||
it('resolves a pane with a captured Claude session', () => {
|
||||
const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111'
|
||||
expect(
|
||||
resolveNativeChatSession({
|
||||
paneKey,
|
||||
launchAgent: 'claude',
|
||||
agentStatusEntry: entry({
|
||||
paneKey,
|
||||
agentType: 'claude',
|
||||
providerSession: { key: 'session_id', id: 'sess-abc' }
|
||||
}),
|
||||
ptyId: 'pty-1'
|
||||
})
|
||||
).toEqual({
|
||||
agent: 'claude',
|
||||
sessionId: 'sess-abc',
|
||||
transcriptPath: null,
|
||||
ptyId: 'pty-1',
|
||||
paneKey
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces the hook transcriptPath when the providerSession carries one', () => {
|
||||
const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111'
|
||||
expect(
|
||||
resolveNativeChatSession({
|
||||
paneKey,
|
||||
launchAgent: 'claude',
|
||||
agentStatusEntry: entry({
|
||||
paneKey,
|
||||
agentType: 'claude',
|
||||
providerSession: {
|
||||
key: 'session_id',
|
||||
id: 'sess-abc',
|
||||
transcriptPath: '/home/u/.claude/projects/slug/real-uuid.jsonl'
|
||||
}
|
||||
}),
|
||||
ptyId: 'pty-1'
|
||||
})
|
||||
).toEqual({
|
||||
agent: 'claude',
|
||||
sessionId: 'sess-abc',
|
||||
transcriptPath: '/home/u/.claude/projects/slug/real-uuid.jsonl',
|
||||
ptyId: 'pty-1',
|
||||
paneKey
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves a just-launched pane with sessionId null', () => {
|
||||
const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111'
|
||||
expect(
|
||||
resolveNativeChatSession({
|
||||
paneKey,
|
||||
launchAgent: 'claude',
|
||||
// Entry exists (agent launched) but no providerSession reported yet.
|
||||
agentStatusEntry: entry({ paneKey, agentType: 'claude' }),
|
||||
ptyId: 'pty-1'
|
||||
})
|
||||
).toEqual({ agent: 'claude', sessionId: null, transcriptPath: null, ptyId: 'pty-1', paneKey })
|
||||
})
|
||||
|
||||
it('resolves two split leaves independently to their own values', () => {
|
||||
const leftKey = 'tab-1:11111111-1111-4111-8111-111111111111'
|
||||
const rightKey = 'tab-1:22222222-2222-4222-8222-222222222222'
|
||||
const left = resolveNativeChatSession({
|
||||
paneKey: leftKey,
|
||||
launchAgent: 'claude',
|
||||
agentStatusEntry: entry({
|
||||
paneKey: leftKey,
|
||||
agentType: 'claude',
|
||||
providerSession: { key: 'session_id', id: 'left-sess' }
|
||||
}),
|
||||
ptyId: 'pty-left'
|
||||
})
|
||||
const right = resolveNativeChatSession({
|
||||
paneKey: rightKey,
|
||||
launchAgent: 'codex',
|
||||
agentStatusEntry: entry({
|
||||
paneKey: rightKey,
|
||||
agentType: 'codex',
|
||||
providerSession: { key: 'session_id', id: 'right-sess' }
|
||||
}),
|
||||
ptyId: 'pty-right'
|
||||
})
|
||||
expect(left).toEqual({
|
||||
agent: 'claude',
|
||||
sessionId: 'left-sess',
|
||||
transcriptPath: null,
|
||||
ptyId: 'pty-left',
|
||||
paneKey: leftKey
|
||||
})
|
||||
expect(right).toEqual({
|
||||
agent: 'codex',
|
||||
sessionId: 'right-sess',
|
||||
transcriptPath: null,
|
||||
ptyId: 'pty-right',
|
||||
paneKey: rightKey
|
||||
})
|
||||
})
|
||||
|
||||
it('derives the agent from the status entry when no launchAgent is set', () => {
|
||||
const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111'
|
||||
expect(
|
||||
resolveNativeChatSession({
|
||||
paneKey,
|
||||
launchAgent: null,
|
||||
agentStatusEntry: entry({
|
||||
paneKey,
|
||||
agentType: 'gemini',
|
||||
providerSession: { key: 'session_id', id: 'g-1' }
|
||||
}),
|
||||
ptyId: 'pty-1'
|
||||
})
|
||||
).toEqual({ agent: 'gemini', sessionId: 'g-1', transcriptPath: null, ptyId: 'pty-1', paneKey })
|
||||
})
|
||||
|
||||
it('returns null for a non-agent pane (no launchAgent, no entry)', () => {
|
||||
expect(
|
||||
resolveNativeChatSession({
|
||||
paneKey: 'tab-1:11111111-1111-4111-8111-111111111111',
|
||||
launchAgent: null,
|
||||
ptyId: 'pty-1'
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types'
|
||||
import type { TuiAgent } from '../../../../shared/types'
|
||||
|
||||
/** Inputs that resolve the active pane to the agent/session/pty triple the
|
||||
* native-chat data + input layers need. Kept as a plain shape (not the live
|
||||
* store or pane-manager singleton) so the resolver stays pure and unit-
|
||||
* testable — call sites read the agent-status entry and the runtime ptyId for
|
||||
* the pane's `paneKey` before calling. */
|
||||
export type NativeChatPaneResolutionInput = {
|
||||
/** Composite `${tabId}:${leafId}` key of the active leaf. */
|
||||
paneKey: string
|
||||
/** The coding-agent Orca launched in this terminal, if any (from TerminalTab).
|
||||
* Drives the agent label when no live status entry has reported one yet. */
|
||||
launchAgent?: TuiAgent | null
|
||||
/** Live agent-status entry for this pane, when one exists. Carries the
|
||||
* captured `providerSession` (the agent's own session id) once the agent has
|
||||
* reported it, plus the detected `agentType`. */
|
||||
agentStatusEntry?: AgentStatusEntry
|
||||
/** Runtime PTY id bound to this pane. ptyId is pane-manager runtime state, so
|
||||
* it's passed in rather than looked up inside this pure function. */
|
||||
ptyId: string | null
|
||||
}
|
||||
|
||||
export type NativeChatPaneResolution = {
|
||||
agent: AgentType
|
||||
/** The agent's own captured session/conversation id, or null before the
|
||||
* agent has reported one (entry exists but no providerSession yet). */
|
||||
sessionId: string | null
|
||||
/** Authoritative transcript path from the hook, when reported. Preferred over
|
||||
* reconstructing the path from sessionId (recent Claude Code diverges them). */
|
||||
transcriptPath: string | null
|
||||
ptyId: string | null
|
||||
paneKey: string
|
||||
}
|
||||
|
||||
/** Resolve the active pane to `{ agent, sessionId, ptyId, paneKey }`, or null
|
||||
* when the pane runs no agent. A pane qualifies when either a launch-time
|
||||
* agent hint or a live agent-status entry is present (mirrors the eligibility
|
||||
* union in native-chat-availability). sessionId comes from the entry's
|
||||
* `providerSession.id` (the captured agent session id) — null until the agent
|
||||
* reports one, so a just-launched pane resolves without throwing. */
|
||||
export function resolveNativeChatSession(
|
||||
input: NativeChatPaneResolutionInput
|
||||
): NativeChatPaneResolution | null {
|
||||
const agent = input.launchAgent ?? input.agentStatusEntry?.agentType
|
||||
if (!agent) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
agent,
|
||||
sessionId: input.agentStatusEntry?.providerSession?.id ?? null,
|
||||
transcriptPath: input.agentStatusEntry?.providerSession?.transcriptPath ?? null,
|
||||
ptyId: input.ptyId,
|
||||
paneKey: input.paneKey
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import {
|
||||
appendPendingSendCache,
|
||||
appendCommandMarkerCache,
|
||||
applyCommandMarkerBoundaries,
|
||||
clearCommandMarkerCacheForTests,
|
||||
clearPendingSendCacheForTests,
|
||||
commandMarkersAsMessages,
|
||||
isCommandMarkerId,
|
||||
isPendingMessageId,
|
||||
pendingSendsAsMessages,
|
||||
prunePendingSends,
|
||||
readCommandMarkerCache,
|
||||
readPendingSendCache,
|
||||
writePendingSendCache,
|
||||
type NativeChatPendingSend
|
||||
} from './native-chat-pending'
|
||||
import { stripNoiseMessages } from './native-chat-noise'
|
||||
|
||||
function userMessage(id: string, text: string): NativeChatMessage {
|
||||
return {
|
||||
id,
|
||||
role: 'user',
|
||||
blocks: [{ type: 'text', text }],
|
||||
timestamp: 1,
|
||||
source: 'transcript'
|
||||
}
|
||||
}
|
||||
|
||||
function assistantMessage(id: string, text: string): NativeChatMessage {
|
||||
return {
|
||||
id,
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'text', text }],
|
||||
timestamp: 2,
|
||||
source: 'transcript'
|
||||
}
|
||||
}
|
||||
|
||||
const pendingOf = (id: string, text: string): NativeChatPendingSend => ({ id, text, sentAt: 100 })
|
||||
|
||||
describe('prunePendingSends', () => {
|
||||
it('returns the same reference when there is nothing pending', () => {
|
||||
const pending: NativeChatPendingSend[] = []
|
||||
expect(prunePendingSends(pending, [userMessage('m1', 'hi')])).toBe(pending)
|
||||
})
|
||||
|
||||
it('keeps a pending send while only its user turn has landed', () => {
|
||||
const pending = [pendingOf('p1', 'fix the bug')]
|
||||
const next = prunePendingSends(pending, [userMessage('m1', 'fix the bug')])
|
||||
expect(next).toBe(pending)
|
||||
})
|
||||
|
||||
it('drops a pending send once the transcript advances beyond its user turn', () => {
|
||||
const pending = [pendingOf('p1', 'fix the bug')]
|
||||
const next = prunePendingSends(pending, [
|
||||
userMessage('m1', 'fix the bug'),
|
||||
assistantMessage('m2', 'working on it')
|
||||
])
|
||||
expect(next).toEqual([])
|
||||
})
|
||||
|
||||
it('matches advanced turns ignoring surrounding/collapsed whitespace', () => {
|
||||
const pending = [pendingOf('p1', ' do the thing ')]
|
||||
const next = prunePendingSends(pending, [
|
||||
userMessage('m1', 'do the thing'),
|
||||
assistantMessage('m2', 'done')
|
||||
])
|
||||
expect(next).toEqual([])
|
||||
})
|
||||
|
||||
it('drops an attachment pending send once a prefixed transcript prompt advances', () => {
|
||||
const pending = [
|
||||
{ ...pendingOf('p1', 'what do you see'), imagePaths: ['/Users/me/Downloads/3d.png'] }
|
||||
]
|
||||
const next = prunePendingSends(pending, [
|
||||
userMessage('m1', '[Image #1] what do you see'),
|
||||
assistantMessage('m2', 'an image')
|
||||
])
|
||||
expect(next).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps a pending send that has not landed yet', () => {
|
||||
const pending = [pendingOf('p1', 'not yet')]
|
||||
const next = prunePendingSends(pending, [assistantMessage('m1', 'working on it')])
|
||||
expect(next).toBe(pending)
|
||||
})
|
||||
|
||||
it('does not match an assistant message with the same text', () => {
|
||||
const pending = [pendingOf('p1', 'echo me')]
|
||||
const next = prunePendingSends(pending, [assistantMessage('m1', 'echo me')])
|
||||
expect(next).toBe(pending)
|
||||
})
|
||||
|
||||
it('prunes only the matched entry, keeping others', () => {
|
||||
const pending = [pendingOf('p1', 'first'), pendingOf('p2', 'second')]
|
||||
const next = prunePendingSends(pending, [
|
||||
userMessage('m1', 'first'),
|
||||
assistantMessage('m2', 'first answer')
|
||||
])
|
||||
expect(next).toEqual([pendingOf('p2', 'second')])
|
||||
})
|
||||
})
|
||||
|
||||
describe('pendingSendsAsMessages', () => {
|
||||
it('maps pending sends to prefixed scrape-source user messages sorted by sentAt', () => {
|
||||
const messages = pendingSendsAsMessages([{ id: 'p1', text: 'queued text', sentAt: 42 }])
|
||||
expect(messages).toEqual([
|
||||
{
|
||||
id: 'pending:p1',
|
||||
role: 'user',
|
||||
blocks: [{ type: 'text', text: 'queued text' }],
|
||||
timestamp: 42,
|
||||
source: 'scrape'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('includes image refs for pending attachment sends', () => {
|
||||
const messages = pendingSendsAsMessages([
|
||||
{ id: 'p1', text: 'what do you see?', imagePaths: ['/tmp/shot.png'], sentAt: 42 }
|
||||
])
|
||||
expect(messages[0]?.blocks).toEqual([
|
||||
{ type: 'image-ref', path: '/tmp/shot.png' },
|
||||
{ type: 'text', text: 'what do you see?' }
|
||||
])
|
||||
})
|
||||
|
||||
it('hides a pending send while its real user turn is visible', () => {
|
||||
const pending = [pendingOf('p1', 'first prompt')]
|
||||
|
||||
expect(pendingSendsAsMessages(pending, [userMessage('u1', 'first prompt')])).toEqual([])
|
||||
expect(pendingSendsAsMessages(pending, [])).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pending send cache', () => {
|
||||
it('persists optimistic sends for the same pane and agent', () => {
|
||||
clearPendingSendCacheForTests()
|
||||
const scope = { paneKey: 'tab-a:leaf-a', agent: 'codex' }
|
||||
|
||||
const appended = appendPendingSendCache(scope, pendingOf('p1', 'first prompt'))
|
||||
|
||||
expect(appended).toEqual([pendingOf('p1', 'first prompt')])
|
||||
expect(readPendingSendCache(scope)).toEqual(appended)
|
||||
expect(readPendingSendCache({ ...scope, agent: 'claude' })).toEqual([])
|
||||
})
|
||||
|
||||
it('clears cached pending sends when pruning removes all entries', () => {
|
||||
clearPendingSendCacheForTests()
|
||||
const scope = { paneKey: 'tab-a:leaf-a', agent: 'codex' }
|
||||
appendPendingSendCache(scope, pendingOf('p1', 'first prompt'))
|
||||
|
||||
writePendingSendCache(scope, [])
|
||||
|
||||
expect(readPendingSendCache(scope)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPendingMessageId', () => {
|
||||
it('recognizes the pending id prefix', () => {
|
||||
expect(isPendingMessageId('pending:p1')).toBe(true)
|
||||
expect(isPendingMessageId('transcript-123')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('commandMarkersAsMessages', () => {
|
||||
it('renders a slash command as a system "Ran <cmd>" message', () => {
|
||||
expect(commandMarkersAsMessages([{ id: 'c1', command: '/clear', sentAt: 7 }])).toEqual([
|
||||
{
|
||||
id: 'command:c1',
|
||||
role: 'system',
|
||||
blocks: [{ type: 'text', text: 'Ran /clear' }],
|
||||
timestamp: 7,
|
||||
source: 'scrape'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('survives stripNoiseMessages (the "Ran" text is not a noise prefix)', () => {
|
||||
const markers = commandMarkersAsMessages([{ id: 'c1', command: '/compact', sentAt: 1 }])
|
||||
expect(stripNoiseMessages(markers)).toEqual(markers)
|
||||
})
|
||||
|
||||
it('isCommandMarkerId recognizes the prefix', () => {
|
||||
expect(isCommandMarkerId('command:c1')).toBe(true)
|
||||
expect(isCommandMarkerId('pending:p1')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('command marker cache', () => {
|
||||
it('persists slash command markers for the same pane conversation', () => {
|
||||
clearCommandMarkerCacheForTests()
|
||||
const scope = { paneKey: 'tab-a:leaf-a', agent: 'codex', sessionId: 'session-1' }
|
||||
|
||||
const appended = appendCommandMarkerCache(scope, '/clear', 10)
|
||||
|
||||
expect(appended).toEqual([{ id: '10-1', command: '/clear', sentAt: 10 }])
|
||||
expect(readCommandMarkerCache(scope)).toEqual(appended)
|
||||
expect(readCommandMarkerCache({ ...scope, sessionId: 'session-2' })).toEqual([])
|
||||
})
|
||||
|
||||
it('caps cached command markers to the latest eight', () => {
|
||||
clearCommandMarkerCacheForTests()
|
||||
const scope = { paneKey: 'tab-a:leaf-a', agent: 'claude', sessionId: 'session-1' }
|
||||
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
appendCommandMarkerCache(scope, `/cmd-${i}`, i)
|
||||
}
|
||||
|
||||
expect(readCommandMarkerCache(scope).map((marker) => marker.command)).toEqual([
|
||||
'/cmd-2',
|
||||
'/cmd-3',
|
||||
'/cmd-4',
|
||||
'/cmd-5',
|
||||
'/cmd-6',
|
||||
'/cmd-7',
|
||||
'/cmd-8',
|
||||
'/cmd-9'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyCommandMarkerBoundaries', () => {
|
||||
it('hides existing transcript messages after a local /clear marker', () => {
|
||||
const messages = [
|
||||
userMessage('before', 'old prompt'),
|
||||
{ ...assistantMessage('after', 'new answer'), timestamp: 20 }
|
||||
]
|
||||
|
||||
expect(
|
||||
applyCommandMarkerBoundaries(messages, [{ id: 'c1', command: '/clear', sentAt: 10 }])
|
||||
).toEqual([{ ...assistantMessage('after', 'new answer'), timestamp: 20 }])
|
||||
})
|
||||
|
||||
it('keeps messages for non-clear commands like /compact', () => {
|
||||
const messages = [userMessage('before', 'old prompt')]
|
||||
|
||||
expect(
|
||||
applyCommandMarkerBoundaries(messages, [{ id: 'c1', command: '/compact', sentAt: 10 }])
|
||||
).toBe(messages)
|
||||
})
|
||||
|
||||
it('uses the latest clear marker as the visible boundary', () => {
|
||||
const messages = [
|
||||
{ ...userMessage('old', 'old'), timestamp: 5 },
|
||||
{ ...userMessage('middle', 'middle'), timestamp: 15 },
|
||||
{ ...userMessage('new', 'new'), timestamp: 25 }
|
||||
]
|
||||
|
||||
expect(
|
||||
applyCommandMarkerBoundaries(messages, [
|
||||
{ id: 'c1', command: '/clear', sentAt: 10 },
|
||||
{ id: 'c2', command: '/clear', sentAt: 20 }
|
||||
]).map((message) => message.id)
|
||||
).toEqual(['new'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,253 @@
|
||||
// Pure logic for desktop optimistic "queued" composer sends (mobile parity).
|
||||
// A sent prompt is echoed immediately as a queued entry and pruned once its real
|
||||
// user turn lands in the transcript. Kept separate from the view so the prune
|
||||
// rule (match on normalized user-message text) is unit-testable without React.
|
||||
|
||||
import { isTextBlock, type NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import { stripImagePromptMarker } from './native-chat-image-transcript-markers'
|
||||
|
||||
/** An optimistic, not-yet-confirmed composer send. */
|
||||
export type NativeChatPendingSend = {
|
||||
/** Renderer-minted id, unique per send, used as the list key. */
|
||||
id: string
|
||||
/** The exact draft text the user submitted. */
|
||||
text: string
|
||||
/** Image paths that were sent through the TUI image attachment paste path. */
|
||||
imagePaths?: string[]
|
||||
/** Epoch ms when the send was issued, so the queued bubble sorts to the end. */
|
||||
sentAt: number
|
||||
}
|
||||
|
||||
export type NativeChatPendingSendScope = {
|
||||
paneKey: string
|
||||
agent: string
|
||||
}
|
||||
|
||||
const PENDING_SEND_LIMIT = 8
|
||||
const pendingSendCache = new Map<string, NativeChatPendingSend[]>()
|
||||
|
||||
function pendingSendScopeKey(scope: NativeChatPendingSendScope): string {
|
||||
return `${scope.paneKey}\0${scope.agent}`
|
||||
}
|
||||
|
||||
export function readPendingSendCache(scope: NativeChatPendingSendScope): NativeChatPendingSend[] {
|
||||
return [...(pendingSendCache.get(pendingSendScopeKey(scope)) ?? [])]
|
||||
}
|
||||
|
||||
export function writePendingSendCache(
|
||||
scope: NativeChatPendingSendScope,
|
||||
pending: NativeChatPendingSend[]
|
||||
): NativeChatPendingSend[] {
|
||||
const next = pending.slice(-PENDING_SEND_LIMIT)
|
||||
const key = pendingSendScopeKey(scope)
|
||||
if (next.length === 0) {
|
||||
pendingSendCache.delete(key)
|
||||
} else {
|
||||
pendingSendCache.set(key, next)
|
||||
}
|
||||
return [...next]
|
||||
}
|
||||
|
||||
export function appendPendingSendCache(
|
||||
scope: NativeChatPendingSendScope,
|
||||
entry: NativeChatPendingSend
|
||||
): NativeChatPendingSend[] {
|
||||
return writePendingSendCache(scope, [...readPendingSendCache(scope), entry])
|
||||
}
|
||||
|
||||
export function clearPendingSendCacheForTests(): void {
|
||||
pendingSendCache.clear()
|
||||
}
|
||||
|
||||
function normalize(text: string): string {
|
||||
return stripImagePromptMarker(text).trim().replace(/\s+/g, ' ')
|
||||
}
|
||||
|
||||
/** The prose of a user message, normalized for matching against a pending send. */
|
||||
function userMessageText(message: NativeChatMessage): string | null {
|
||||
if (message.role !== 'user') {
|
||||
return null
|
||||
}
|
||||
const text = message.blocks
|
||||
.filter(isTextBlock)
|
||||
.map((block) => block.text)
|
||||
.join(' ')
|
||||
return normalize(text)
|
||||
}
|
||||
|
||||
function matchingUserMessageTexts(messages: NativeChatMessage[]): Set<string> {
|
||||
const texts = new Set<string>()
|
||||
for (const message of messages) {
|
||||
const text = userMessageText(message)
|
||||
if (text) {
|
||||
texts.add(text)
|
||||
}
|
||||
}
|
||||
return texts
|
||||
}
|
||||
|
||||
function advancedPastUserMessageTexts(messages: NativeChatMessage[]): Set<string> {
|
||||
const advanced = new Set<string>()
|
||||
const waiting = new Set<string>()
|
||||
for (const message of messages) {
|
||||
if (message.role === 'user') {
|
||||
const text = userMessageText(message)
|
||||
if (text) {
|
||||
waiting.add(text)
|
||||
}
|
||||
continue
|
||||
}
|
||||
for (const text of waiting) {
|
||||
advanced.add(text)
|
||||
}
|
||||
}
|
||||
return advanced
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop any pending send only after the transcript has advanced beyond its real
|
||||
* user turn. Keeping the echo through the user-only transcript phase prevents a
|
||||
* first-turn empty-state flash if the live transcript briefly reports [] before
|
||||
* the assistant response lands.
|
||||
*/
|
||||
export function prunePendingSends(
|
||||
pending: NativeChatPendingSend[],
|
||||
messages: NativeChatMessage[]
|
||||
): NativeChatPendingSend[] {
|
||||
if (pending.length === 0) {
|
||||
return pending
|
||||
}
|
||||
const advanced = advancedPastUserMessageTexts(messages)
|
||||
const next = pending.filter((entry) => !advanced.has(normalize(entry.text)))
|
||||
return next.length === pending.length ? pending : next
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn pending sends into chat messages so they render in the list as queued
|
||||
* user bubbles. They carry the `scrape` source (lowest priority) so the real
|
||||
* transcript turn always supersedes them if both are briefly present, and the
|
||||
* send time as the timestamp so they sort to the end (most recent) of the list.
|
||||
*/
|
||||
export function pendingSendsAsMessages(
|
||||
pending: NativeChatPendingSend[],
|
||||
existingMessages: NativeChatMessage[] = []
|
||||
): NativeChatMessage[] {
|
||||
const represented = matchingUserMessageTexts(existingMessages)
|
||||
return pending
|
||||
.filter((entry) => !represented.has(normalize(entry.text)))
|
||||
.map((entry) => ({
|
||||
id: `pending:${entry.id}`,
|
||||
role: 'user' as const,
|
||||
blocks: [
|
||||
...(entry.imagePaths ?? []).map((path) => ({ type: 'image-ref' as const, path })),
|
||||
...(entry.text.trim().length > 0 ? [{ type: 'text' as const, text: entry.text }] : [])
|
||||
],
|
||||
timestamp: entry.sentAt,
|
||||
source: 'scrape' as const
|
||||
}))
|
||||
}
|
||||
|
||||
/** True when a message id was minted for an optimistic pending send. */
|
||||
export function isPendingMessageId(id: string): boolean {
|
||||
return id.startsWith('pending:')
|
||||
}
|
||||
|
||||
/** A locally-recorded slash command (e.g. `/clear`). Slash commands dispatch to
|
||||
* the agent's TUI and are not chat turns, so we surface a small system line as
|
||||
* feedback that the command ran rather than echoing a user bubble. */
|
||||
export type NativeChatCommandMarker = {
|
||||
id: string
|
||||
/** The command as typed, e.g. `/clear`. */
|
||||
command: string
|
||||
sentAt: number
|
||||
}
|
||||
|
||||
export type NativeChatCommandMarkerScope = {
|
||||
paneKey: string
|
||||
agent: string
|
||||
sessionId: string | null
|
||||
}
|
||||
|
||||
const COMMAND_MARKER_LIMIT = 8
|
||||
const commandMarkerCache = new Map<string, NativeChatCommandMarker[]>()
|
||||
let commandMarkerCounter = 0
|
||||
|
||||
function commandMarkerScopeKey(scope: NativeChatCommandMarkerScope): string {
|
||||
return `${scope.paneKey}\0${scope.agent}\0${scope.sessionId ?? ''}`
|
||||
}
|
||||
|
||||
export function readCommandMarkerCache(
|
||||
scope: NativeChatCommandMarkerScope
|
||||
): NativeChatCommandMarker[] {
|
||||
return [...(commandMarkerCache.get(commandMarkerScopeKey(scope)) ?? [])]
|
||||
}
|
||||
|
||||
export function appendCommandMarkerCache(
|
||||
scope: NativeChatCommandMarkerScope,
|
||||
command: string,
|
||||
sentAt = Date.now()
|
||||
): NativeChatCommandMarker[] {
|
||||
commandMarkerCounter += 1
|
||||
const key = commandMarkerScopeKey(scope)
|
||||
// Why: native/TUI view switches remount the chat surface, but slash commands
|
||||
// are not transcript turns, so their local feedback needs a pane-scoped cache.
|
||||
const next = [
|
||||
...(commandMarkerCache.get(key) ?? []),
|
||||
{ id: `${sentAt}-${commandMarkerCounter}`, command, sentAt }
|
||||
].slice(-COMMAND_MARKER_LIMIT)
|
||||
commandMarkerCache.set(key, next)
|
||||
return [...next]
|
||||
}
|
||||
|
||||
export function clearCommandMarkerCacheForTests(): void {
|
||||
commandMarkerCache.clear()
|
||||
commandMarkerCounter = 0
|
||||
}
|
||||
|
||||
function isClearCommand(command: string): boolean {
|
||||
return command.trim().toLowerCase().split(/\s+/)[0] === '/clear'
|
||||
}
|
||||
|
||||
function latestClearSentAt(markers: readonly NativeChatCommandMarker[]): number | null {
|
||||
let latest: number | null = null
|
||||
for (const marker of markers) {
|
||||
if (isClearCommand(marker.command) && (latest === null || marker.sentAt > latest)) {
|
||||
latest = marker.sentAt
|
||||
}
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
export function applyCommandMarkerBoundaries(
|
||||
messages: readonly NativeChatMessage[],
|
||||
markers: readonly NativeChatCommandMarker[]
|
||||
): NativeChatMessage[] {
|
||||
const clearSentAt = latestClearSentAt(markers)
|
||||
if (clearSentAt === null) {
|
||||
return messages as NativeChatMessage[]
|
||||
}
|
||||
// Why: `/clear` mutates the TUI/transcript asynchronously. Hide the current
|
||||
// transcript immediately so native chat reflects the command before the agent
|
||||
// writes a replacement session or truncates the file.
|
||||
return messages.filter((message) => message.timestamp !== null && message.timestamp > clearSentAt)
|
||||
}
|
||||
|
||||
/** Render command markers as compact `system` messages. The `system` role draws
|
||||
* as a muted aside (not a user bubble); the text avoids the harness noise
|
||||
* prefixes so stripNoiseMessages keeps it. */
|
||||
export function commandMarkersAsMessages(
|
||||
markers: readonly NativeChatCommandMarker[]
|
||||
): NativeChatMessage[] {
|
||||
return markers.map((marker) => ({
|
||||
id: `command:${marker.id}`,
|
||||
role: 'system' as const,
|
||||
blocks: [{ type: 'text' as const, text: `Ran ${marker.command}` }],
|
||||
timestamp: marker.sentAt,
|
||||
source: 'scrape' as const
|
||||
}))
|
||||
}
|
||||
|
||||
/** True when a message id was minted for a slash-command marker. */
|
||||
export function isCommandMarkerId(id: string): boolean {
|
||||
return id.startsWith('command:')
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Mock the IO seam so the test stays pure: we only assert the write order and
|
||||
// the inter-write delay, not the local-vs-remote pty branching.
|
||||
const sendRuntimePtyInput = vi.fn()
|
||||
vi.mock('@/runtime/runtime-terminal-inspection', () => ({
|
||||
sendRuntimePtyInput: (...args: unknown[]) => sendRuntimePtyInput(...args)
|
||||
}))
|
||||
|
||||
import {
|
||||
sendNativeChatMessage,
|
||||
sendNativeChatMessageWithImageAttachments,
|
||||
sendNativeChatImageAttachments,
|
||||
submitNativeChatPrompt,
|
||||
sendNativeChatAnswer,
|
||||
nativeChatQuestionOffsets,
|
||||
NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS,
|
||||
NATIVE_CHAT_SUBMIT_DELAY_MS,
|
||||
NATIVE_CHAT_QUESTION_STEP_MS,
|
||||
NATIVE_CHAT_ADVANCE_BUFFER_MS
|
||||
} from './native-chat-runtime-send'
|
||||
import {
|
||||
buildNativeChatImagePasteBytes,
|
||||
buildNativeChatPasteBytes,
|
||||
NATIVE_CHAT_SUBMIT
|
||||
} from './native-chat-send'
|
||||
|
||||
const SETTINGS = {} as Parameters<typeof sendNativeChatMessage>[0]
|
||||
const PTY = 'pty-1'
|
||||
|
||||
describe('sendNativeChatMessage', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
sendRuntimePtyInput.mockClear()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('writes the framed body immediately, before the Enter', () => {
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'hello world')
|
||||
// Body lands synchronously; Enter is still pending on the timer.
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1)
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledWith(
|
||||
SETTINGS,
|
||||
PTY,
|
||||
buildNativeChatPasteBytes('hello world')
|
||||
)
|
||||
})
|
||||
|
||||
it('does not fire Enter before the proven 500ms gap (busy-agent safety)', () => {
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'hi')
|
||||
// A short gap would fire Enter while a busy Codex has not yet landed the
|
||||
// paste, submitting an empty box — so nothing must happen before 500ms.
|
||||
vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS - 1)
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('writes the bare carriage-return Enter as a separate delayed write', () => {
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'hi')
|
||||
vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS)
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(2)
|
||||
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT)
|
||||
})
|
||||
|
||||
it('matches orca-runtime writeTerminalAction Enter gap (500ms)', () => {
|
||||
expect(NATIVE_CHAT_SUBMIT_DELAY_MS).toBe(500)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sendNativeChatMessageWithImageAttachments', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
sendRuntimePtyInput.mockClear()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('bracket-pastes image paths before prompt text so the TUI creates image chips', () => {
|
||||
sendNativeChatMessageWithImageAttachments(SETTINGS, PTY, 'what do you see?', [
|
||||
'/tmp/orca-paste-image.png'
|
||||
])
|
||||
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1)
|
||||
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(
|
||||
SETTINGS,
|
||||
PTY,
|
||||
buildNativeChatImagePasteBytes('/tmp/orca-paste-image.png')
|
||||
)
|
||||
|
||||
vi.advanceTimersByTime(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS)
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(2)
|
||||
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(
|
||||
SETTINGS,
|
||||
PTY,
|
||||
buildNativeChatPasteBytes('what do you see?')
|
||||
)
|
||||
|
||||
vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS)
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(3)
|
||||
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT)
|
||||
})
|
||||
|
||||
it('waits the normal submit gap for an attachment-only send', () => {
|
||||
sendNativeChatMessageWithImageAttachments(SETTINGS, PTY, '', ['/tmp/orca-paste-image.png'])
|
||||
|
||||
vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS - 1)
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1)
|
||||
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(2)
|
||||
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pre-pasted image attachment sends', () => {
|
||||
beforeEach(() => {
|
||||
sendRuntimePtyInput.mockClear()
|
||||
})
|
||||
|
||||
it('pastes image attachments immediately without submitting the prompt', () => {
|
||||
sendNativeChatImageAttachments(SETTINGS, PTY, ['/tmp/orca-paste-image.png'])
|
||||
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1)
|
||||
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(
|
||||
SETTINGS,
|
||||
PTY,
|
||||
buildNativeChatImagePasteBytes('/tmp/orca-paste-image.png')
|
||||
)
|
||||
})
|
||||
|
||||
it('submits a prompt whose image attachments were already pasted', () => {
|
||||
submitNativeChatPrompt(SETTINGS, PTY)
|
||||
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1)
|
||||
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT)
|
||||
})
|
||||
})
|
||||
|
||||
describe('nativeChatQuestionOffsets', () => {
|
||||
it('paces each question a full step apart, Enter 500ms after its body', () => {
|
||||
expect(NATIVE_CHAT_QUESTION_STEP_MS).toBe(800)
|
||||
expect(NATIVE_CHAT_ADVANCE_BUFFER_MS).toBe(300)
|
||||
expect(nativeChatQuestionOffsets(0)).toEqual({ bodyAt: 0, enterAt: 500 })
|
||||
expect(nativeChatQuestionOffsets(1)).toEqual({ bodyAt: 800, enterAt: 1300 })
|
||||
expect(nativeChatQuestionOffsets(2)).toEqual({ bodyAt: 1600, enterAt: 2100 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('sendNativeChatAnswer', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
sendRuntimePtyInput.mockClear()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('single-line answer behaves exactly like sendNativeChatMessage', () => {
|
||||
sendNativeChatAnswer(SETTINGS, PTY, ['only one'])
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1)
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledWith(
|
||||
SETTINGS,
|
||||
PTY,
|
||||
buildNativeChatPasteBytes('only one')
|
||||
)
|
||||
vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS)
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(2)
|
||||
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT)
|
||||
})
|
||||
|
||||
it('multi-line: 3 bodies + 3 Enters in order, each Enter 500ms after its body, next body only after prior Enter+buffer', () => {
|
||||
const lines = ['answer one', 'answer two', 'answer three']
|
||||
sendNativeChatAnswer(SETTINGS, PTY, lines)
|
||||
|
||||
// Nothing fires synchronously: even question 0's body is scheduled (setTimeout 0).
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(0)
|
||||
|
||||
// t=0: question 0 body.
|
||||
vi.advanceTimersByTime(0)
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1)
|
||||
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(
|
||||
SETTINGS,
|
||||
PTY,
|
||||
buildNativeChatPasteBytes('answer one')
|
||||
)
|
||||
|
||||
// t=500: question 0 Enter (500ms after its body); question 1 body NOT yet.
|
||||
vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS)
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(2)
|
||||
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT)
|
||||
|
||||
// Question 1 body must wait the advance buffer past question 0's Enter.
|
||||
vi.advanceTimersByTime(NATIVE_CHAT_ADVANCE_BUFFER_MS - 1)
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(2)
|
||||
|
||||
// t=800: question 1 body.
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(3)
|
||||
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(
|
||||
SETTINGS,
|
||||
PTY,
|
||||
buildNativeChatPasteBytes('answer two')
|
||||
)
|
||||
|
||||
// t=1300: question 1 Enter.
|
||||
vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS)
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(4)
|
||||
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT)
|
||||
|
||||
// t=1600: question 2 body.
|
||||
vi.advanceTimersByTime(NATIVE_CHAT_ADVANCE_BUFFER_MS)
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(5)
|
||||
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(
|
||||
SETTINGS,
|
||||
PTY,
|
||||
buildNativeChatPasteBytes('answer three')
|
||||
)
|
||||
|
||||
// t=2100: question 2 Enter — the final submit. No trailing writes after.
|
||||
vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS)
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(6)
|
||||
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT)
|
||||
|
||||
// Exactly 3 bodies + 3 Enters; running all timers adds nothing more.
|
||||
vi.runAllTimers()
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(6)
|
||||
|
||||
// Verify body/Enter ordering across the whole sequence.
|
||||
const calls = sendRuntimePtyInput.mock.calls.map((c) => c[2])
|
||||
expect(calls).toEqual([
|
||||
buildNativeChatPasteBytes('answer one'),
|
||||
NATIVE_CHAT_SUBMIT,
|
||||
buildNativeChatPasteBytes('answer two'),
|
||||
NATIVE_CHAT_SUBMIT,
|
||||
buildNativeChatPasteBytes('answer three'),
|
||||
NATIVE_CHAT_SUBMIT
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,173 @@
|
||||
// Runtime send for native chat: writes the framed message body, then the Enter
|
||||
// as a SEPARATE delayed pty write. Kept apart from the pure byte builders in
|
||||
// native-chat-send.ts so those stay IO-free and unit-testable without aliases.
|
||||
|
||||
import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection'
|
||||
import type { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft'
|
||||
import {
|
||||
buildNativeChatImagePasteBytes,
|
||||
buildNativeChatPasteBytes,
|
||||
NATIVE_CHAT_SUBMIT
|
||||
} from './native-chat-send'
|
||||
|
||||
// Why: agent TUIs swallow a `\r` bundled into the same pty write as a framed
|
||||
// paste, so a one-shot send leaves the text sitting in the input box, unsent.
|
||||
// Write the body first, then the Enter after a delay so the agent processes the
|
||||
// paste before the submit. The gap must clear the agent's paste-handling latency
|
||||
// even while it's BUSY (Codex): a short gap (60ms) fires Enter before a busy
|
||||
// Codex has landed the paste into its input, so the submit hits an empty box and
|
||||
// the message sits "Queued" forever. 500ms is orca-runtime's proven value in
|
||||
// writeTerminalAction({enter:true}), so match it here.
|
||||
export const NATIVE_CHAT_SUBMIT_DELAY_MS = 500
|
||||
export const NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS = 300
|
||||
|
||||
// Why: Claude Code's AskUserQuestion is a MULTI-STEP prompt — it renders one
|
||||
// question at a time and each Enter advances to the next (the final Enter
|
||||
// submits the whole thing). After firing a question's Enter we must let the TUI
|
||||
// render the next question before writing its body, or that body lands on the
|
||||
// wrong (or no) active question. This buffer is added ON TOP of the body→Enter
|
||||
// gap; a previous attempt that spaced Enters only ~350ms apart fired them faster
|
||||
// than the next question rendered, leaking answers as fresh prompts.
|
||||
export const NATIVE_CHAT_ADVANCE_BUFFER_MS = 300
|
||||
|
||||
/** Per-question wall-clock cadence: body→Enter gap plus the advance buffer that
|
||||
* lets the next AskUserQuestion step render before its body is written. */
|
||||
export const NATIVE_CHAT_QUESTION_STEP_MS =
|
||||
NATIVE_CHAT_SUBMIT_DELAY_MS + NATIVE_CHAT_ADVANCE_BUFFER_MS
|
||||
|
||||
/** Pure scheduling math for a per-question answer sequence. For question index
|
||||
* `i` (0-based) returns the offsets (ms from the start of the send) at which to
|
||||
* write its framed body and its Enter. Body for question 0 fires at 0; each
|
||||
* later question starts a full step after the previous, so its body is never
|
||||
* written until the previous question's Enter has fired plus the advance
|
||||
* buffer. Exactly one Enter per question; the last Enter submits the prompt. */
|
||||
export function nativeChatQuestionOffsets(index: number): {
|
||||
bodyAt: number
|
||||
enterAt: number
|
||||
} {
|
||||
const bodyAt = index * NATIVE_CHAT_QUESTION_STEP_MS
|
||||
return { bodyAt, enterAt: bodyAt + NATIVE_CHAT_SUBMIT_DELAY_MS }
|
||||
}
|
||||
|
||||
/** Cancels an in-flight send's pending pty writes (the delayed Enter, and any
|
||||
* later question bodies/Enters). Safe to call after the send completes. */
|
||||
export type NativeChatSendHandle = { cancel: () => void }
|
||||
|
||||
/**
|
||||
* Send a native-chat message through the verified runtime pty path: framed body
|
||||
* first, then a separate delayed Enter. `sendRuntimePtyInput` branches local
|
||||
* pty:write vs remote runtime RPC, so this works for SSH panes too. Returns a
|
||||
* cancel handle so callers can drop the still-pending Enter on unmount/stop.
|
||||
*/
|
||||
export function sendNativeChatMessage(
|
||||
settings: ReturnType<typeof getSettingsForAgentTabRuntimeOwner>,
|
||||
ptyId: string,
|
||||
text: string
|
||||
): NativeChatSendHandle {
|
||||
sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text))
|
||||
const timer = setTimeout(() => {
|
||||
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
|
||||
}, NATIVE_CHAT_SUBMIT_DELAY_MS)
|
||||
return { cancel: () => clearTimeout(timer) }
|
||||
}
|
||||
|
||||
export function sendNativeChatMessageWithImageAttachments(
|
||||
settings: ReturnType<typeof getSettingsForAgentTabRuntimeOwner>,
|
||||
ptyId: string,
|
||||
text: string,
|
||||
imagePaths: readonly string[]
|
||||
): NativeChatSendHandle {
|
||||
if (imagePaths.length === 0) {
|
||||
return sendNativeChatMessage(settings, ptyId, text)
|
||||
}
|
||||
const timers: ReturnType<typeof setTimeout>[] = []
|
||||
for (const imagePath of imagePaths) {
|
||||
sendRuntimePtyInput(settings, ptyId, buildNativeChatImagePasteBytes(imagePath))
|
||||
}
|
||||
const trimmedText = text.trim()
|
||||
if (trimmedText.length > 0) {
|
||||
timers.push(
|
||||
setTimeout(() => {
|
||||
sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text))
|
||||
}, NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS)
|
||||
)
|
||||
}
|
||||
timers.push(
|
||||
setTimeout(
|
||||
() => {
|
||||
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
|
||||
},
|
||||
trimmedText.length > 0
|
||||
? NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + NATIVE_CHAT_SUBMIT_DELAY_MS
|
||||
: NATIVE_CHAT_SUBMIT_DELAY_MS
|
||||
)
|
||||
)
|
||||
return {
|
||||
cancel: () => {
|
||||
for (const timer of timers) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Paste image attachments into the hosted TUI immediately so switching back to
|
||||
* the terminal shows the same image chips a direct terminal paste would show. */
|
||||
export function sendNativeChatImageAttachments(
|
||||
settings: ReturnType<typeof getSettingsForAgentTabRuntimeOwner>,
|
||||
ptyId: string,
|
||||
imagePaths: readonly string[]
|
||||
): void {
|
||||
for (const imagePath of imagePaths) {
|
||||
sendRuntimePtyInput(settings, ptyId, buildNativeChatImagePasteBytes(imagePath))
|
||||
}
|
||||
}
|
||||
|
||||
/** Submit a TUI prompt whose image attachments were already pasted earlier. */
|
||||
export function submitNativeChatPrompt(
|
||||
settings: ReturnType<typeof getSettingsForAgentTabRuntimeOwner>,
|
||||
ptyId: string
|
||||
): void {
|
||||
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an AskUserQuestion answer that may span multiple questions. Each line is
|
||||
* one question's answer (exactly how `formatAskAnswer` builds it). A single line
|
||||
* is just `sendNativeChatMessage` (no behavior change). For multiple lines we
|
||||
* write each question's framed body then its Enter as a per-question sequence,
|
||||
* paced by `NATIVE_CHAT_QUESTION_STEP_MS` so each Enter lands on its own
|
||||
* rendered question and the LAST Enter submits — exactly N Enters for N lines,
|
||||
* never a trailing one. Returns a cancel handle that clears every pending timer
|
||||
* so a detached sequence can't keep writing PTY bytes after unmount/stop.
|
||||
*/
|
||||
export function sendNativeChatAnswer(
|
||||
settings: ReturnType<typeof getSettingsForAgentTabRuntimeOwner>,
|
||||
ptyId: string,
|
||||
lines: string[]
|
||||
): NativeChatSendHandle {
|
||||
if (lines.length <= 1) {
|
||||
return sendNativeChatMessage(settings, ptyId, lines[0] ?? '')
|
||||
}
|
||||
const timers: ReturnType<typeof setTimeout>[] = []
|
||||
lines.forEach((line, index) => {
|
||||
const { bodyAt, enterAt } = nativeChatQuestionOffsets(index)
|
||||
timers.push(
|
||||
setTimeout(() => {
|
||||
sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(line))
|
||||
}, bodyAt)
|
||||
)
|
||||
timers.push(
|
||||
setTimeout(() => {
|
||||
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
|
||||
}, enterAt)
|
||||
)
|
||||
})
|
||||
return {
|
||||
cancel: () => {
|
||||
for (const timer of timers) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
scrapeScrollbackToMessages,
|
||||
scrapeNativeChatSession,
|
||||
stripScrollbackAnsi
|
||||
} from './native-chat-scrape-fallback'
|
||||
|
||||
const ESC = String.fromCharCode(27)
|
||||
|
||||
describe('scrapeScrollbackToMessages', () => {
|
||||
it('strips ANSI escapes and segments into >1 ordered messages', () => {
|
||||
const raw = [
|
||||
`${ESC}[32m$ run the build${ESC}[0m`,
|
||||
'',
|
||||
`${ESC}[1mBuilding project...${ESC}[0m`,
|
||||
'Done in 2s.'
|
||||
].join('\n')
|
||||
|
||||
const messages = scrapeScrollbackToMessages(raw)
|
||||
|
||||
expect(messages.length).toBeGreaterThan(1)
|
||||
for (const message of messages) {
|
||||
for (const block of message.blocks) {
|
||||
if (block.type === 'text') {
|
||||
// ANSI is gone — no raw escape character survives.
|
||||
expect(block.text).not.toContain(ESC)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Order is preserved from the scrollback.
|
||||
expect(messages[0].id).toBe('scrape-0')
|
||||
expect(messages[1].id).toBe('scrape-1')
|
||||
})
|
||||
|
||||
it('returns an empty list for empty / whitespace scrollback without throwing', () => {
|
||||
expect(scrapeScrollbackToMessages('')).toEqual([])
|
||||
expect(scrapeScrollbackToMessages(' \n\t \n')).toEqual([])
|
||||
})
|
||||
|
||||
it('marks every produced message with source "scrape" and a null timestamp', () => {
|
||||
const raw = '$ hello\n\nworld output\n\nmore output'
|
||||
const messages = scrapeScrollbackToMessages(raw)
|
||||
|
||||
expect(messages.length).toBeGreaterThan(0)
|
||||
for (const message of messages) {
|
||||
expect(message.source).toBe('scrape')
|
||||
expect(message.timestamp).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('assigns roles per the prompt-marker heuristic', () => {
|
||||
const raw = [
|
||||
'$ deploy to staging',
|
||||
'',
|
||||
'Deploying to staging environment...',
|
||||
'Deployed.'
|
||||
].join('\n')
|
||||
|
||||
const messages = scrapeScrollbackToMessages(raw)
|
||||
|
||||
// First segment starts with a shell prompt marker -> user.
|
||||
expect(messages[0].role).toBe('user')
|
||||
// Plain output -> assistant.
|
||||
expect(messages[1].role).toBe('assistant')
|
||||
})
|
||||
})
|
||||
|
||||
describe('stripScrollbackAnsi', () => {
|
||||
it('removes escape sequences and normalizes carriage returns', () => {
|
||||
const raw = `${ESC}[31mred\r\nnext`
|
||||
expect(stripScrollbackAnsi(raw)).toBe('red\nnext')
|
||||
})
|
||||
})
|
||||
|
||||
describe('scrapeNativeChatSession', () => {
|
||||
it('builds a ready, approximate session from non-empty scrollback', () => {
|
||||
const { session, isApproximate } = scrapeNativeChatSession('$ ls\n\noutput here', 'claude')
|
||||
|
||||
expect(isApproximate).toBe(true)
|
||||
expect(session.status).toBe('ready')
|
||||
expect(session.sessionId).toBeNull()
|
||||
expect(session.agent).toBe('claude')
|
||||
expect(session.messages.length).toBeGreaterThan(0)
|
||||
expect(session.messages.every((message) => message.source === 'scrape')).toBe(true)
|
||||
})
|
||||
|
||||
it('builds an empty session from blank scrollback', () => {
|
||||
const { session, isApproximate } = scrapeNativeChatSession(' \n ', 'claude')
|
||||
|
||||
expect(isApproximate).toBe(true)
|
||||
expect(session.status).toBe('empty')
|
||||
expect(session.messages).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
// Degraded conversation source for panes with no on-disk transcript and no live
|
||||
// agent-hook session id. We have nothing structured to work with — only the raw
|
||||
// terminal scrollback — so we strip ANSI and best-effort segment it into coarse
|
||||
// user/assistant turns. This is intentionally approximate: no per-agent TUI
|
||||
// parsing happens here, and every produced message is marked `source:'scrape'`
|
||||
// so the assembler ranks it below transcript/hook copies of the same turn. See
|
||||
// docs/plans/2026-06-17-001-feat-native-chat-view-plan.md (U6).
|
||||
|
||||
import {
|
||||
type AgentType,
|
||||
type NativeChatMessage,
|
||||
type NativeChatSession
|
||||
} from '../../../../shared/native-chat-types'
|
||||
import { assembleNativeChatSession } from './native-chat-session-assembler'
|
||||
|
||||
// Why: replicate (not import) the minimal ANSI/control-sequence strip used by
|
||||
// agent-session-fork-context.ts so we don't modify that file. Same three
|
||||
// patterns: CSI sequences, OSC sequences, and stray single-char escapes.
|
||||
const ESC = String.fromCharCode(27)
|
||||
const ANSI_ESCAPE_PATTERN = new RegExp(`${ESC}\\[[0-?]*[ -/]*[@-~]`, 'g')
|
||||
const OSC_SEQUENCE_PATTERN = new RegExp(`${ESC}\\][^\\u0007]*(?:\\u0007|${ESC}\\\\)`, 'g')
|
||||
const SINGLE_ESCAPE_PATTERN = new RegExp(`${ESC}(?:[@-Z\\\\-_]|[()*+\\-./][0-~]|c)`, 'g')
|
||||
|
||||
function stripUnsupportedControlCharacters(value: string): string {
|
||||
let result = ''
|
||||
for (const char of value) {
|
||||
const code = char.charCodeAt(0)
|
||||
// Drop C0 control chars except tab (9) and newline (10); keep DEL (127) out.
|
||||
if (code <= 8 || code === 11 || code === 12 || (code >= 14 && code <= 31) || code === 127) {
|
||||
continue
|
||||
}
|
||||
result += char
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Strip ANSI/OSC escapes and normalize newlines so the raw scrollback reads as
|
||||
* plain text. Pure; safe to reuse in tests. */
|
||||
export function stripScrollbackAnsi(value: string): string {
|
||||
return stripUnsupportedControlCharacters(
|
||||
value
|
||||
.replace(OSC_SEQUENCE_PATTERN, '')
|
||||
.replace(ANSI_ESCAPE_PATTERN, '')
|
||||
.replace(SINGLE_ESCAPE_PATTERN, '')
|
||||
)
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\r/g, '\n')
|
||||
}
|
||||
|
||||
// Why: a user prompt in a terminal almost always begins with a recognizable
|
||||
// shell/agent prompt marker. We treat a segment whose first line starts with one
|
||||
// of these as 'user'; everything else is assistant output. This is the
|
||||
// documented role heuristic — coarse and deliberately conservative.
|
||||
const USER_PROMPT_MARKERS = ['$', '%', '>', '#', '❯', '➜', '»']
|
||||
|
||||
function looksLikeUserPrompt(segment: string): boolean {
|
||||
const firstLine = segment.split('\n', 1)[0]?.trimStart() ?? ''
|
||||
if (firstLine.length === 0) {
|
||||
return false
|
||||
}
|
||||
const firstChar = firstLine[0]
|
||||
return USER_PROMPT_MARKERS.includes(firstChar)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure: strip ANSI from raw scrollback, then segment into coarse, ordered
|
||||
* messages. Segmentation rule (intentionally approximate): split on runs of one
|
||||
* or more blank lines — these are the most reliable visual turn boundary in a
|
||||
* terminal without per-agent TUI parsing. Each non-empty segment becomes one
|
||||
* message: role is best-effort via the prompt-marker heuristic, timestamp is
|
||||
* null (scrollback carries no reliable wall-clock), source is always 'scrape',
|
||||
* and the id is derived from the segment index so it's stable across re-scrapes.
|
||||
*/
|
||||
export function scrapeScrollbackToMessages(rawScrollback: string): NativeChatMessage[] {
|
||||
const cleaned = stripScrollbackAnsi(rawScrollback)
|
||||
if (cleaned.trim().length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const segments = cleaned
|
||||
.split(/\n[ \t]*\n+/)
|
||||
.map((segment) => segment.replace(/\s+$/g, '').replace(/^\n+/, ''))
|
||||
.filter((segment) => segment.trim().length > 0)
|
||||
|
||||
return segments.map((segment, index) => ({
|
||||
id: `scrape-${index}`,
|
||||
role: looksLikeUserPrompt(segment) ? 'user' : 'assistant',
|
||||
blocks: [{ type: 'text', text: segment }],
|
||||
timestamp: null,
|
||||
source: 'scrape'
|
||||
}))
|
||||
}
|
||||
|
||||
/** A scrape-derived session plus the always-true `isApproximate` flag the UI
|
||||
* uses to render an "approximate view" banner. Scrape sessions can never be
|
||||
* authoritative, so the flag is structural, not conditional. */
|
||||
export type ScrapeNativeChatSession = {
|
||||
session: NativeChatSession
|
||||
isApproximate: true
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience that assembles a `NativeChatSession` from scrollback scrape.
|
||||
* Status is the assembler's derived value: 'empty' for blank scrollback,
|
||||
* 'ready' otherwise. `sessionId` is null because scrape has no provider id.
|
||||
* Reuses `assembleNativeChatSession` read-only (no edits to the assembler).
|
||||
*
|
||||
* Remote/SSH: this entry takes an already-serialized scrollback string and is
|
||||
* transport-agnostic. The caller obtains it via the runtime-appropriate API —
|
||||
* `getMainBufferSnapshot`/serializer for local panes, or the remote serialize
|
||||
* RPC (remote-runtime-terminal-multiplexer) for remote panes — so no remote
|
||||
* branch is needed inside this fallback.
|
||||
*/
|
||||
export function scrapeNativeChatSession(
|
||||
rawScrollback: string,
|
||||
agent: AgentType
|
||||
): ScrapeNativeChatSession {
|
||||
const messages = scrapeScrollbackToMessages(rawScrollback)
|
||||
const session = assembleNativeChatSession({
|
||||
sources: { scrape: messages },
|
||||
sessionId: null,
|
||||
agent
|
||||
})
|
||||
return { session, isApproximate: true }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
deriveNativeChatCanSend,
|
||||
shouldChatTakeOverMobileSurface
|
||||
} from './native-chat-send-eligibility'
|
||||
|
||||
describe('deriveNativeChatCanSend', () => {
|
||||
it('blocks sends when a mobile client holds the pty (presence-lock active)', () => {
|
||||
expect(deriveNativeChatCanSend({ kind: 'mobile', clientId: 'phone-1' })).toBe(false)
|
||||
})
|
||||
|
||||
it('allows sends when the desktop drives the pty', () => {
|
||||
expect(deriveNativeChatCanSend({ kind: 'desktop' })).toBe(true)
|
||||
})
|
||||
|
||||
it('allows sends when the pty is idle', () => {
|
||||
expect(deriveNativeChatCanSend({ kind: 'idle' })).toBe(true)
|
||||
})
|
||||
|
||||
it('treats an unresolved driver (null/undefined) as unlocked', () => {
|
||||
expect(deriveNativeChatCanSend(null)).toBe(true)
|
||||
expect(deriveNativeChatCanSend(undefined)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldChatTakeOverMobileSurface', () => {
|
||||
it('takes over the mobile surface when the tab is in chat view', () => {
|
||||
expect(shouldChatTakeOverMobileSurface('chat')).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves the terminal mobile overlay in place in terminal view', () => {
|
||||
expect(shouldChatTakeOverMobileSurface('terminal')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { DriverState } from '@/lib/pane-manager/mobile-driver-state'
|
||||
|
||||
/**
|
||||
* Pure derivation of the composer's `canSend` (R8). A pty held by a mobile
|
||||
* client (`driver.kind === 'mobile'`) means the mobile presence-lock is active:
|
||||
* the renderer already drops xterm input for that pty, so native-chat sends must
|
||||
* be guarded identically rather than silently racing the mobile driver. Desktop
|
||||
* and idle drivers leave the pty writable. A null driver (pty not yet resolved)
|
||||
* is treated as unlocked so the composer stays usable while the lock state loads;
|
||||
* the actual send still no-ops without a ptyId.
|
||||
*/
|
||||
export function deriveNativeChatCanSend(driver: DriverState | null | undefined): boolean {
|
||||
return driver?.kind !== 'mobile'
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure predicate for whether the native chat surface should take over the mobile
|
||||
* driver surface for a pane. When a tab is in chat view, the chat view is the
|
||||
* visible/active layer above the still-mounted terminal, so the terminal's own
|
||||
* mobile-driver overlay (presence-lock banner / phone-fit hold) must not render
|
||||
* on top of it — the composer's guarded `canSend` state communicates the lock
|
||||
* inside the chat surface instead. Keeps the terminal mounted underneath either
|
||||
* way (R2).
|
||||
*/
|
||||
export function shouldChatTakeOverMobileSurface(viewMode: 'terminal' | 'chat'): boolean {
|
||||
return viewMode === 'chat'
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildNativeChatImagePasteBytes,
|
||||
buildNativeChatPasteBytes,
|
||||
buildNativeChatSendBytes,
|
||||
isMultilineDraft,
|
||||
NATIVE_CHAT_SUBMIT
|
||||
} from './native-chat-send'
|
||||
|
||||
const BEGIN = '\x1b[200~'
|
||||
const END = '\x1b[201~'
|
||||
|
||||
describe('NATIVE_CHAT_SUBMIT', () => {
|
||||
it('is a bare carriage return so the Enter write is unambiguous', () => {
|
||||
expect(NATIVE_CHAT_SUBMIT).toBe('\r')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildNativeChatPasteBytes', () => {
|
||||
it('single-line text has no trailing submit (Enter is written separately)', () => {
|
||||
expect(buildNativeChatPasteBytes('hello world')).toBe('hello world')
|
||||
expect(buildNativeChatPasteBytes('hello world')).not.toContain('\r')
|
||||
})
|
||||
|
||||
it('multi-line text is bracketed-paste wrapped with NO trailing submit', () => {
|
||||
const text = 'line one\nline two'
|
||||
expect(buildNativeChatPasteBytes(text)).toBe(`${BEGIN}${text}${END}`)
|
||||
})
|
||||
|
||||
it('treats a trailing newline as multi-line', () => {
|
||||
expect(buildNativeChatPasteBytes('a\n')).toBe(`${BEGIN}a\n${END}`)
|
||||
})
|
||||
|
||||
it('sanitizes an embedded bracketed-paste end and bare ESC before framing', () => {
|
||||
const malicious = 'before\nmid\x1b[201~ rm -rf /\x1b tail'
|
||||
const bytes = buildNativeChatPasteBytes(malicious)
|
||||
expect(bytes.startsWith(BEGIN)).toBe(true)
|
||||
expect(bytes.endsWith(END)).toBe(true)
|
||||
const inner = bytes.slice(BEGIN.length, bytes.length - END.length)
|
||||
expect(inner).not.toContain('\x1b')
|
||||
expect(inner).toContain('␛[201~')
|
||||
})
|
||||
|
||||
it('neutralizes a stray ESC in the single-line branch', () => {
|
||||
expect(buildNativeChatPasteBytes('hi\x1b there')).toBe('hi␛ there')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildNativeChatImagePasteBytes', () => {
|
||||
it('always bracket-pastes the image path so agent TUIs attach it as an image', () => {
|
||||
expect(buildNativeChatImagePasteBytes('/tmp/orca-paste-image.png')).toBe(
|
||||
`${BEGIN}/tmp/orca-paste-image.png${END}`
|
||||
)
|
||||
})
|
||||
|
||||
it('sanitizes embedded escape bytes before framing', () => {
|
||||
expect(buildNativeChatImagePasteBytes('/tmp/before\x1b[201~after.png')).toBe(
|
||||
`${BEGIN}/tmp/before␛[201~after.png${END}`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildNativeChatSendBytes', () => {
|
||||
it('single-line text sends as text + carriage return', () => {
|
||||
expect(buildNativeChatSendBytes('hello world')).toBe('hello world\r')
|
||||
})
|
||||
|
||||
it('multi-line text is bracketed-paste wrapped then submitted', () => {
|
||||
const text = 'line one\nline two'
|
||||
expect(buildNativeChatSendBytes(text)).toBe(`${BEGIN}${text}${END}\r`)
|
||||
})
|
||||
|
||||
it('treats a trailing newline as multi-line', () => {
|
||||
expect(buildNativeChatSendBytes('a\n')).toBe(`${BEGIN}a\n${END}\r`)
|
||||
})
|
||||
|
||||
it('handles CR-style line breaks as multi-line', () => {
|
||||
expect(buildNativeChatSendBytes('a\rb')).toBe(`${BEGIN}a\rb${END}\r`)
|
||||
})
|
||||
|
||||
it('sanitizes an embedded bracketed-paste end and bare ESC before framing', () => {
|
||||
// A pasted scrollback line could carry its own `\x1b[201~` which would
|
||||
// otherwise close the frame early and run the tail as keystrokes.
|
||||
const malicious = 'before\nmid\x1b[201~ rm -rf /\x1b tail'
|
||||
const bytes = buildNativeChatSendBytes(malicious)
|
||||
// No raw ESC survives the sanitize, so the only `\x1b` bytes are the frame.
|
||||
expect(bytes.startsWith(BEGIN)).toBe(true)
|
||||
expect(bytes.endsWith(`${END}\r`)).toBe(true)
|
||||
const inner = bytes.slice(BEGIN.length, bytes.length - END.length - 1)
|
||||
expect(inner).not.toContain('\x1b')
|
||||
expect(inner).toContain('␛[201~')
|
||||
})
|
||||
|
||||
it('neutralizes a stray ESC in the single-line branch', () => {
|
||||
const bytes = buildNativeChatSendBytes('hi\x1b there')
|
||||
expect(bytes).toBe('hi␛ there\r')
|
||||
expect(bytes).not.toContain('\x1b')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isMultilineDraft', () => {
|
||||
it('is false for single-line', () => {
|
||||
expect(isMultilineDraft('one line')).toBe(false)
|
||||
})
|
||||
it('is true when a newline is present', () => {
|
||||
expect(isMultilineDraft('a\nb')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
// Pure: turn raw composer text into the exact PTY bytes to write. Kept separate
|
||||
// from the React composer so the byte rules are unit-testable without a DOM.
|
||||
|
||||
import { sanitizeBracketedPasteText } from '../terminal-pane/terminal-bracketed-paste'
|
||||
|
||||
// Why: bracketed-paste markers let modern agent TUIs (Claude / Codex / etc.)
|
||||
// treat injected multi-line text as one atomic paste instead of running each
|
||||
// embedded newline as a line-edit / submit. Mirrors agent-paste-draft.ts and
|
||||
// terminal-bracketed-paste.ts so native input is byte-identical to a real paste.
|
||||
const BRACKETED_PASTE_BEGIN = '\x1b[200~'
|
||||
const BRACKETED_PASTE_END = '\x1b[201~'
|
||||
|
||||
// Why: carriage return (not \n) is what xterm/agent composers treat as the
|
||||
// submit/Enter key over a PTY.
|
||||
const SUBMIT = '\r'
|
||||
|
||||
/** True when the draft spans more than one line (so it needs bracketed-paste
|
||||
* wrapping). A trailing newline alone still counts as multi-line. */
|
||||
export function isMultilineDraft(text: string): boolean {
|
||||
return /[\r\n]/.test(text)
|
||||
}
|
||||
|
||||
/** The carriage-return submit byte, exported so send paths can write Enter as a
|
||||
* SEPARATE pty write after the framed body (see buildNativeChatPasteBytes). */
|
||||
export const NATIVE_CHAT_SUBMIT = SUBMIT
|
||||
|
||||
/**
|
||||
* Compute the bytes for `text` WITHOUT the trailing submit:
|
||||
* - single-line → `text`
|
||||
* - multi-line → `\x1b[200~…\x1b[201~` (bracketed-paste wrapped, no submit)
|
||||
*
|
||||
* Why split the submit out: agent TUIs treat a framed paste that carries a
|
||||
* trailing `\r` in the SAME pty write as part of the paste body rather than an
|
||||
* Enter, so the text lands in the input box but never sends. Callers write this
|
||||
* body first, then write `NATIVE_CHAT_SUBMIT` as a separate, slightly-delayed
|
||||
* write (mirrors orca-runtime's writeTerminalAction Enter handling).
|
||||
*/
|
||||
export function buildNativeChatPasteBytes(text: string): string {
|
||||
// Why: a stray ESC in the draft (e.g. pasted scrollback carrying its own
|
||||
// `\x1b[201~`) would otherwise close the bracketed-paste frame early and run
|
||||
// the tail as live keystrokes. Sanitize ESC on both branches before framing.
|
||||
const safe = sanitizeBracketedPasteText(text)
|
||||
if (isMultilineDraft(safe)) {
|
||||
return `${BRACKETED_PASTE_BEGIN}${safe}${BRACKETED_PASTE_END}`
|
||||
}
|
||||
return safe
|
||||
}
|
||||
|
||||
/** Image attachments must look like a real terminal image paste to Claude/Codex
|
||||
* TUIs. A plain typed path (or @file mention) is treated as text/file-read. */
|
||||
export function buildNativeChatImagePasteBytes(filePath: string): string {
|
||||
return `${BRACKETED_PASTE_BEGIN}${sanitizeBracketedPasteText(filePath)}${BRACKETED_PASTE_END}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the bytes to write for `text` + Enter in ONE write:
|
||||
* - single-line → `text\r`
|
||||
* - multi-line → `\x1b[200~…\x1b[201~\r` (bracketed-paste wrapped, then submit)
|
||||
*
|
||||
* Prefer `buildNativeChatPasteBytes` + a separate `NATIVE_CHAT_SUBMIT` write for
|
||||
* live sends; this combined form is kept for callers/tests that need the framed
|
||||
* body and submit as a single string.
|
||||
*/
|
||||
export function buildNativeChatSendBytes(text: string): string {
|
||||
return `${buildNativeChatPasteBytes(text)}${SUBMIT}`
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import { assembleNativeChatSession } from './native-chat-session-assembler'
|
||||
|
||||
function msg(
|
||||
overrides: Partial<NativeChatMessage> & Pick<NativeChatMessage, 'id'>
|
||||
): NativeChatMessage {
|
||||
return {
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'text', text: '' }],
|
||||
timestamp: 0,
|
||||
source: 'transcript',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('assembleNativeChatSession', () => {
|
||||
it('collapses the same turn from hook + transcript to one message, transcript wins', () => {
|
||||
const hook = msg({
|
||||
id: 'hook-1',
|
||||
source: 'hook',
|
||||
turnId: 't1',
|
||||
blocks: [{ type: 'text', text: 'partial...' }],
|
||||
timestamp: 100
|
||||
})
|
||||
const transcript = msg({
|
||||
id: 'transcript-1',
|
||||
source: 'transcript',
|
||||
turnId: 't1',
|
||||
blocks: [{ type: 'text', text: 'final answer' }],
|
||||
timestamp: 100
|
||||
})
|
||||
|
||||
const session = assembleNativeChatSession({
|
||||
sources: { transcript: [transcript], hook: [hook] },
|
||||
sessionId: 's1',
|
||||
agent: 'claude'
|
||||
})
|
||||
|
||||
expect(session.messages).toHaveLength(1)
|
||||
expect(session.messages[0].source).toBe('transcript')
|
||||
expect(session.messages[0].blocks).toEqual([{ type: 'text', text: 'final answer' }])
|
||||
expect(session.status).toBe('ready')
|
||||
})
|
||||
|
||||
it('sorts stably by timestamp then id for out-of-order appends', () => {
|
||||
const a = msg({ id: 'b', timestamp: 200, blocks: [{ type: 'text', text: 'four' }] })
|
||||
const b = msg({ id: 'a', timestamp: 100, blocks: [{ type: 'text', text: 'one' }] })
|
||||
const c = msg({ id: 'a2', timestamp: 100, blocks: [{ type: 'text', text: 'three' }] })
|
||||
const d = msg({ id: 'a1', timestamp: 100, blocks: [{ type: 'text', text: 'two' }] })
|
||||
|
||||
const session = assembleNativeChatSession({
|
||||
sources: { transcript: [a, b, c, d] },
|
||||
sessionId: 's1',
|
||||
agent: 'claude'
|
||||
})
|
||||
|
||||
expect(session.messages.map((m) => m.id)).toEqual(['a', 'a1', 'a2', 'b'])
|
||||
})
|
||||
|
||||
it('drops a scrape message when a transcript message covers the same turn', () => {
|
||||
const scrape = msg({
|
||||
id: 'scrape-1',
|
||||
source: 'scrape',
|
||||
role: 'user',
|
||||
blocks: [{ type: 'text', text: 'Run the tests' }],
|
||||
timestamp: null
|
||||
})
|
||||
const transcript = msg({
|
||||
id: 'transcript-1',
|
||||
source: 'transcript',
|
||||
role: 'user',
|
||||
blocks: [{ type: 'text', text: 'run the tests' }],
|
||||
timestamp: 50
|
||||
})
|
||||
|
||||
const session = assembleNativeChatSession({
|
||||
sources: { transcript: [transcript], scrape: [scrape] },
|
||||
sessionId: 's1',
|
||||
agent: 'claude'
|
||||
})
|
||||
|
||||
expect(session.messages).toHaveLength(1)
|
||||
expect(session.messages[0].source).toBe('transcript')
|
||||
})
|
||||
|
||||
it('merges Claude image source marker records into the following user prompt', () => {
|
||||
const imageSource = msg({
|
||||
id: 'u-image-source',
|
||||
role: 'user',
|
||||
timestamp: 100,
|
||||
blocks: [{ type: 'text', text: '[Image: source: /Users/me/Downloads/3d.png]' }]
|
||||
})
|
||||
const prompt = msg({
|
||||
id: 'u-prompt',
|
||||
role: 'user',
|
||||
timestamp: 101,
|
||||
blocks: [{ type: 'text', text: '[Image #1] what do you see' }]
|
||||
})
|
||||
|
||||
const session = assembleNativeChatSession({
|
||||
sources: { transcript: [imageSource, prompt] },
|
||||
sessionId: 's1',
|
||||
agent: 'claude'
|
||||
})
|
||||
|
||||
expect(session.messages).toHaveLength(1)
|
||||
expect(session.messages[0]).toMatchObject({ id: 'u-prompt', role: 'user' })
|
||||
expect(session.messages[0].blocks).toEqual([
|
||||
{ type: 'image-ref', path: '/Users/me/Downloads/3d.png' },
|
||||
{ type: 'text', text: 'what do you see' }
|
||||
])
|
||||
})
|
||||
|
||||
it('drops a scrape duplicate even when scrape is processed first by id', () => {
|
||||
const scrape = msg({
|
||||
id: 'shared-id',
|
||||
source: 'scrape',
|
||||
turnId: 't9',
|
||||
timestamp: 10
|
||||
})
|
||||
const transcript = msg({
|
||||
id: 'shared-id',
|
||||
source: 'transcript',
|
||||
turnId: 't9',
|
||||
timestamp: 10
|
||||
})
|
||||
|
||||
const session = assembleNativeChatSession({
|
||||
sources: { transcript: [transcript], scrape: [scrape] },
|
||||
sessionId: 's1',
|
||||
agent: 'claude'
|
||||
})
|
||||
|
||||
expect(session.messages).toHaveLength(1)
|
||||
expect(session.messages[0].source).toBe('transcript')
|
||||
})
|
||||
|
||||
it('assembles an empty session to status empty without throwing', () => {
|
||||
const session = assembleNativeChatSession({
|
||||
sources: {},
|
||||
sessionId: null,
|
||||
agent: 'claude'
|
||||
})
|
||||
|
||||
expect(session.messages).toEqual([])
|
||||
expect(session.status).toBe('empty')
|
||||
expect(session.sessionId).toBeNull()
|
||||
})
|
||||
|
||||
it('honors an explicit status override', () => {
|
||||
const session = assembleNativeChatSession({
|
||||
sources: {},
|
||||
sessionId: null,
|
||||
agent: 'claude',
|
||||
status: 'loading'
|
||||
})
|
||||
expect(session.status).toBe('loading')
|
||||
})
|
||||
|
||||
it('keeps two distinct tool-call-only same-role messages (no turnId, no text)', () => {
|
||||
const first = msg({
|
||||
id: 'tc-1',
|
||||
role: 'assistant',
|
||||
timestamp: 100,
|
||||
blocks: [{ type: 'tool-call', name: 'read', input: { path: 'a.txt' } }]
|
||||
})
|
||||
const second = msg({
|
||||
id: 'tc-2',
|
||||
role: 'assistant',
|
||||
timestamp: 200,
|
||||
blocks: [{ type: 'tool-call', name: 'read', input: { path: 'b.txt' } }]
|
||||
})
|
||||
|
||||
const session = assembleNativeChatSession({
|
||||
sources: { transcript: [first, second] },
|
||||
sessionId: 's1',
|
||||
agent: 'claude'
|
||||
})
|
||||
|
||||
// Different tool inputs digest to different turn keys, so neither is dropped.
|
||||
expect(session.messages.map((m) => m.id)).toEqual(['tc-1', 'tc-2'])
|
||||
})
|
||||
|
||||
it('keeps two identical consecutive user prompts (distinct ids) — #10', () => {
|
||||
// Same role, identical text, distinct ids: two genuinely distinct turns that
|
||||
// happen to share a prompt. Same source (transcript), so the text fallback
|
||||
// must NOT collapse them.
|
||||
const first = msg({
|
||||
id: 'u-1',
|
||||
role: 'user',
|
||||
timestamp: 100,
|
||||
blocks: [{ type: 'text', text: 'run the tests' }]
|
||||
})
|
||||
const second = msg({
|
||||
id: 'u-2',
|
||||
role: 'user',
|
||||
timestamp: 200,
|
||||
blocks: [{ type: 'text', text: 'run the tests' }]
|
||||
})
|
||||
|
||||
const session = assembleNativeChatSession({
|
||||
sources: { transcript: [first, second] },
|
||||
sessionId: 's1',
|
||||
agent: 'claude'
|
||||
})
|
||||
|
||||
expect(session.messages.map((m) => m.id)).toEqual(['u-1', 'u-2'])
|
||||
})
|
||||
|
||||
it('keeps identical same-source prompts even at the SAME timestamp — #10', () => {
|
||||
const first = msg({
|
||||
id: 'u-1',
|
||||
role: 'user',
|
||||
timestamp: 100,
|
||||
blocks: [{ type: 'text', text: 'go' }]
|
||||
})
|
||||
const second = msg({
|
||||
id: 'u-2',
|
||||
role: 'user',
|
||||
timestamp: 100,
|
||||
blocks: [{ type: 'text', text: 'go' }]
|
||||
})
|
||||
|
||||
const session = assembleNativeChatSession({
|
||||
sources: { transcript: [first, second] },
|
||||
sessionId: 's1',
|
||||
agent: 'claude'
|
||||
})
|
||||
|
||||
// Same source, so the source-gate keeps both even though text+timestamp match.
|
||||
expect(session.messages.map((m) => m.id).sort()).toEqual(['u-1', 'u-2'])
|
||||
})
|
||||
|
||||
it('still collapses a cross-source same turn by text+timestamp, transcript wins', () => {
|
||||
const hook = msg({
|
||||
id: 'hook-1',
|
||||
source: 'hook',
|
||||
role: 'assistant',
|
||||
timestamp: 100,
|
||||
blocks: [{ type: 'text', text: 'the answer' }]
|
||||
})
|
||||
const transcript = msg({
|
||||
id: 'transcript-1',
|
||||
source: 'transcript',
|
||||
role: 'assistant',
|
||||
timestamp: 100,
|
||||
blocks: [{ type: 'text', text: 'the answer' }]
|
||||
})
|
||||
|
||||
const session = assembleNativeChatSession({
|
||||
sources: { transcript: [transcript], hook: [hook] },
|
||||
sessionId: 's1',
|
||||
agent: 'claude'
|
||||
})
|
||||
|
||||
expect(session.messages).toHaveLength(1)
|
||||
expect(session.messages[0].source).toBe('transcript')
|
||||
})
|
||||
|
||||
it('carries an error message when provided', () => {
|
||||
const session = assembleNativeChatSession({
|
||||
sources: {},
|
||||
sessionId: null,
|
||||
agent: 'claude',
|
||||
status: 'error',
|
||||
error: 'transcript unreadable'
|
||||
})
|
||||
expect(session.status).toBe('error')
|
||||
expect(session.error).toBe('transcript unreadable')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,191 @@
|
||||
import {
|
||||
isTextBlock,
|
||||
NATIVE_CHAT_SOURCE_PRIORITY,
|
||||
type AgentType,
|
||||
type NativeChatMessage,
|
||||
type NativeChatSession,
|
||||
type NativeChatSessionStatus
|
||||
} from '../../../../shared/native-chat-types'
|
||||
import { normalizeImageTranscriptMessages } from './native-chat-image-transcript-markers'
|
||||
|
||||
/** Messages grouped by source. Higher-priority sources (transcript > hook >
|
||||
* scrape) supersede lower ones when they describe the same turn. */
|
||||
export type NativeChatSources = {
|
||||
transcript?: NativeChatMessage[]
|
||||
hook?: NativeChatMessage[]
|
||||
scrape?: NativeChatMessage[]
|
||||
}
|
||||
|
||||
export type AssembleNativeChatSessionInput = {
|
||||
sources: NativeChatSources
|
||||
sessionId: string | null
|
||||
agent: AgentType
|
||||
/** Overrides the derived status. The derived value is 'empty' when no
|
||||
* messages survive merge, otherwise 'ready'. Callers pass 'loading',
|
||||
* 'working', or 'error' when out-of-band signals apply. */
|
||||
status?: NativeChatSessionStatus
|
||||
error?: string
|
||||
}
|
||||
|
||||
// Why: a turn can surface from several sources with different ids (a hook event
|
||||
// and the transcript record for the same assistant reply rarely share an id).
|
||||
// We dedup on an explicit `turnId` when present; otherwise fall back to
|
||||
// role + normalized text so the same logical turn collapses to one message.
|
||||
// Normalization lowercases and collapses whitespace so cosmetic ANSI/scrape
|
||||
// differences don't defeat the match. The fallback only ever merges records of
|
||||
// DIFFERENT sources (gated in mergeOne), so two identical SAME-source prompts
|
||||
// stay distinct (#10). Timestamp is deliberately NOT folded into the key: a
|
||||
// scrape copy of a turn often has a null timestamp while the transcript copy
|
||||
// has a real one, and folding it would wrongly stop that legitimate
|
||||
// cross-source pair from collapsing.
|
||||
function turnKey(message: NativeChatMessage): string {
|
||||
if (message.turnId) {
|
||||
return `turn:${message.turnId}`
|
||||
}
|
||||
const text = message.blocks
|
||||
.filter(isTextBlock)
|
||||
.map((block) => block.text)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
// Why: two same-role messages with no turnId and no text (e.g. distinct
|
||||
// tool-call-only turns) would otherwise share `${role}:` and the second would
|
||||
// be dropped. Fold a digest of the non-text blocks (tool name+input, result
|
||||
// output) into the key so different tool turns stay distinct.
|
||||
return `${message.role}:${text}:${nonTextBlockDigest(message)}`
|
||||
}
|
||||
|
||||
function nonTextBlockDigest(message: NativeChatMessage): string {
|
||||
const parts: string[] = []
|
||||
for (const block of message.blocks) {
|
||||
if (block.type === 'tool-call') {
|
||||
parts.push(`call:${block.name}:${stableStringify(block.input)}`)
|
||||
} else if (block.type === 'tool-result') {
|
||||
parts.push(`result:${block.output}`)
|
||||
} else if (block.type === 'image-ref') {
|
||||
parts.push(`image:${block.path ?? block.url ?? block.alt ?? ''}`)
|
||||
}
|
||||
}
|
||||
return parts.join('|')
|
||||
}
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
try {
|
||||
return typeof value === 'string' ? value : JSON.stringify(value)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
function supersedes(candidate: NativeChatMessage, existing: NativeChatMessage): boolean {
|
||||
const candidateRank = NATIVE_CHAT_SOURCE_PRIORITY[candidate.source]
|
||||
const existingRank = NATIVE_CHAT_SOURCE_PRIORITY[existing.source]
|
||||
return candidateRank > existingRank
|
||||
}
|
||||
|
||||
// Why: null timestamps (sources that can't supply one, e.g. scrape segments)
|
||||
// sort before any real timestamp so they don't jump to the end. Ties break on
|
||||
// id for a stable, deterministic order.
|
||||
export function compareMessages(a: NativeChatMessage, b: NativeChatMessage): number {
|
||||
const at = a.timestamp ?? Number.NEGATIVE_INFINITY
|
||||
const bt = b.timestamp ?? Number.NEGATIVE_INFINITY
|
||||
if (at !== bt) {
|
||||
return at - bt
|
||||
}
|
||||
if (a.id < b.id) {
|
||||
return -1
|
||||
}
|
||||
if (a.id > b.id) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure merge of layered conversation sources into a single ordered, deduped
|
||||
* `NativeChatSession`. Precedence: transcript > hook > scrape. Dedup happens on
|
||||
* message id and on turn key (explicit turnId, else role + normalized text), so
|
||||
* the same turn from multiple sources collapses to the highest-priority copy.
|
||||
*/
|
||||
export function assembleNativeChatSession(
|
||||
input: AssembleNativeChatSessionInput
|
||||
): NativeChatSession {
|
||||
const { sources, sessionId, agent, status, error } = input
|
||||
|
||||
// Process highest priority first so a later, lower-priority duplicate is
|
||||
// dropped rather than overwriting. Within a source, order is preserved.
|
||||
const ordered: NativeChatMessage[] = [
|
||||
...normalizeImageTranscriptMessages(sources.transcript ?? []),
|
||||
...(sources.hook ?? []),
|
||||
...(sources.scrape ?? [])
|
||||
]
|
||||
|
||||
const byId = new Map<string, NativeChatMessage>()
|
||||
const byTurn = new Map<string, NativeChatMessage>()
|
||||
|
||||
for (const message of ordered) {
|
||||
mergeOne(byId, byTurn, message)
|
||||
}
|
||||
|
||||
const messages = Array.from(byId.values()).sort(compareMessages)
|
||||
|
||||
const derivedStatus: NativeChatSessionStatus = messages.length === 0 ? 'empty' : 'ready'
|
||||
|
||||
return {
|
||||
messages,
|
||||
status: status ?? derivedStatus,
|
||||
sessionId,
|
||||
agent,
|
||||
...(error ? { error } : {})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The single per-message merge rule, shared by the full rebuild and the
|
||||
* incremental assembler so there is exactly one copy of the dedup logic.
|
||||
* Dedups by `id`, then by `turnKey` — but the turnKey fallback only merges a
|
||||
* candidate against an existing message of a DIFFERENT source (#10): the text
|
||||
* fallback exists for cross-source dedup, so two distinct same-source records
|
||||
* with identical text must never collapse. The explicit-`turnId` path is
|
||||
* cross-source identity and is unaffected (it never collides within a source).
|
||||
*/
|
||||
export function mergeOne(
|
||||
byId: Map<string, NativeChatMessage>,
|
||||
byTurn: Map<string, NativeChatMessage>,
|
||||
message: NativeChatMessage
|
||||
): void {
|
||||
const existingById = byId.get(message.id)
|
||||
if (existingById) {
|
||||
if (supersedes(message, existingById)) {
|
||||
replace(byId, byTurn, existingById, message)
|
||||
}
|
||||
return
|
||||
}
|
||||
const key = turnKey(message)
|
||||
const existingByTurn = byTurn.get(key)
|
||||
if (existingByTurn && existingByTurn.source !== message.source) {
|
||||
if (supersedes(message, existingByTurn)) {
|
||||
replace(byId, byTurn, existingByTurn, message)
|
||||
}
|
||||
return
|
||||
}
|
||||
// No id match and no cross-source turn match: a distinct record. Indexing it
|
||||
// under its turnKey may overwrite a same-source entry that shares the key —
|
||||
// that's fine, the turn index only needs one representative per key for the
|
||||
// cross-source pass; both distinct records still live in `byId`.
|
||||
byId.set(message.id, message)
|
||||
byTurn.set(key, message)
|
||||
}
|
||||
|
||||
function replace(
|
||||
byId: Map<string, NativeChatMessage>,
|
||||
byTurn: Map<string, NativeChatMessage>,
|
||||
old: NativeChatMessage,
|
||||
next: NativeChatMessage
|
||||
): void {
|
||||
byId.delete(old.id)
|
||||
byTurn.delete(turnKey(old))
|
||||
byId.set(next.id, next)
|
||||
byTurn.set(turnKey(next), next)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
matchesNativeChatToggleShortcut,
|
||||
nativeChatToggleShortcutLabel
|
||||
} from './native-chat-shortcut'
|
||||
|
||||
type Combo = Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey' | 'shiftKey' | 'altKey'>
|
||||
|
||||
function combo(overrides: Partial<Combo>): Combo {
|
||||
return { key: 'j', metaKey: false, ctrlKey: false, shiftKey: false, altKey: false, ...overrides }
|
||||
}
|
||||
|
||||
describe('nativeChatToggleShortcutLabel', () => {
|
||||
it('uses Cmd/Shift glyphs on Mac', () => {
|
||||
expect(nativeChatToggleShortcutLabel(true)).toBe('⌘⇧J')
|
||||
})
|
||||
|
||||
it('uses Ctrl+/Shift+ text elsewhere', () => {
|
||||
expect(nativeChatToggleShortcutLabel(false)).toBe('Ctrl+Shift+J')
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchesNativeChatToggleShortcut', () => {
|
||||
it('matches Cmd+Shift+J on Mac', () => {
|
||||
expect(matchesNativeChatToggleShortcut(combo({ metaKey: true, shiftKey: true }), true)).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('does not match Ctrl+Shift+J on Mac (wrong primary modifier)', () => {
|
||||
expect(matchesNativeChatToggleShortcut(combo({ ctrlKey: true, shiftKey: true }), true)).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('matches Ctrl+Shift+J on Windows/Linux', () => {
|
||||
expect(matchesNativeChatToggleShortcut(combo({ ctrlKey: true, shiftKey: true }), false)).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('requires the shift modifier', () => {
|
||||
expect(matchesNativeChatToggleShortcut(combo({ metaKey: true }), true)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects when alt is held', () => {
|
||||
expect(
|
||||
matchesNativeChatToggleShortcut(combo({ metaKey: true, shiftKey: true, altKey: true }), true)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a different key', () => {
|
||||
expect(
|
||||
matchesNativeChatToggleShortcut(combo({ key: 'k', metaKey: true, shiftKey: true }), true)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
/** Platform-correct binding for the native-chat view toggle.
|
||||
*
|
||||
* Key: Cmd/Ctrl + Shift + J. The primary modifier follows AGENTS.md — metaKey
|
||||
* on Mac, ctrlKey elsewhere — and the displayed label uses `⌘`/`⇧` on Mac and
|
||||
* `Ctrl+`/`Shift+` on Linux/Windows.
|
||||
*/
|
||||
|
||||
export function isMacPlatform(): boolean {
|
||||
return typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac')
|
||||
}
|
||||
|
||||
/** Human-readable label for the toggle shortcut, platform-correct. */
|
||||
export function nativeChatToggleShortcutLabel(isMac: boolean): string {
|
||||
return isMac ? '⌘⇧J' : 'Ctrl+Shift+J'
|
||||
}
|
||||
|
||||
/** True when the event is the native-chat toggle chord for the given platform.
|
||||
* Pure so it can be unit-tested without a DOM. */
|
||||
export function matchesNativeChatToggleShortcut(
|
||||
e: Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey' | 'shiftKey' | 'altKey'>,
|
||||
isMac: boolean
|
||||
): boolean {
|
||||
if (e.altKey || !e.shiftKey) {
|
||||
return false
|
||||
}
|
||||
// Primary modifier is Cmd on Mac, Ctrl on Linux/Windows — and must be the
|
||||
// *only* primary modifier so this can't collide with Cmd+Ctrl chords.
|
||||
const primary = isMac ? e.metaKey && !e.ctrlKey : e.ctrlKey && !e.metaKey
|
||||
if (!primary) {
|
||||
return false
|
||||
}
|
||||
return e.key.toLowerCase() === 'j'
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
import { resolveNativeChatSession } from './native-chat-pane-resolution'
|
||||
import { findTabAgentEntry } from './native-chat-tab-agent-entry'
|
||||
|
||||
function entry(
|
||||
overrides: Partial<AgentStatusEntry> & Pick<AgentStatusEntry, 'paneKey'>
|
||||
): AgentStatusEntry {
|
||||
return {
|
||||
state: 'working',
|
||||
prompt: '',
|
||||
updatedAt: 0,
|
||||
stateStartedAt: 0,
|
||||
stateHistory: [],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #19 guard: the narrowed `useShallow(findTabAgentEntry(...))` selector must
|
||||
* resolve to exactly the same pane entry — and thus the same
|
||||
* resolveNativeChatSession result — as the old whole-map scan. These tests lock
|
||||
* the selector's resolution semantics so the perf narrowing can't drift.
|
||||
*/
|
||||
describe('findTabAgentEntry (#19 selector)', () => {
|
||||
it('returns the entry whose paneKey carries the tab id prefix', () => {
|
||||
const target = entry({ paneKey: 'tab-1:leaf-a', agentType: 'claude' })
|
||||
const map: Record<string, AgentStatusEntry> = {
|
||||
'tab-0:leaf-z': entry({ paneKey: 'tab-0:leaf-z' }),
|
||||
'tab-1:leaf-a': target,
|
||||
'tab-2:leaf-b': entry({ paneKey: 'tab-2:leaf-b' })
|
||||
}
|
||||
expect(findTabAgentEntry(map, 'tab-1')).toBe(target)
|
||||
})
|
||||
|
||||
it('returns undefined when no pane matches the tab id', () => {
|
||||
const map: Record<string, AgentStatusEntry> = {
|
||||
'tab-0:leaf-z': entry({ paneKey: 'tab-0:leaf-z' })
|
||||
}
|
||||
expect(findTabAgentEntry(map, 'tab-1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns the first matching pane (deterministic insertion order)', () => {
|
||||
const first = entry({ paneKey: 'tab-1:leaf-a' })
|
||||
const second = entry({ paneKey: 'tab-1:leaf-b' })
|
||||
const map: Record<string, AgentStatusEntry> = {
|
||||
'tab-1:leaf-a': first,
|
||||
'tab-1:leaf-b': second
|
||||
}
|
||||
expect(findTabAgentEntry(map, 'tab-1')).toBe(first)
|
||||
})
|
||||
|
||||
it('does not match a tab id that is only a substring of another tab id', () => {
|
||||
const map: Record<string, AgentStatusEntry> = {
|
||||
'tab-10:leaf-a': entry({ paneKey: 'tab-10:leaf-a' })
|
||||
}
|
||||
// `tab-1:` prefix must not match `tab-10:` — the colon delimiter guards this.
|
||||
expect(findTabAgentEntry(map, 'tab-1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resolves identically to the whole-map scan, including the empty-tabid fallback', () => {
|
||||
const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111'
|
||||
const target = entry({
|
||||
paneKey,
|
||||
agentType: 'claude',
|
||||
providerSession: { key: 'session_id', id: 'sess-abc' }
|
||||
})
|
||||
const map: Record<string, AgentStatusEntry> = {
|
||||
'tab-0:other': entry({ paneKey: 'tab-0:other', agentType: 'codex' }),
|
||||
[paneKey]: target
|
||||
}
|
||||
|
||||
// Old path: scan whole map, then fall back to `${tabId}:` when absent.
|
||||
const oldEntry = findTabAgentEntry(map, 'tab-1')
|
||||
const oldResolution = resolveNativeChatSession({
|
||||
paneKey: oldEntry?.paneKey ?? 'tab-1:',
|
||||
launchAgent: 'claude',
|
||||
...(oldEntry ? { agentStatusEntry: oldEntry } : {}),
|
||||
ptyId: null
|
||||
})
|
||||
|
||||
// New path: narrowed selector returns the same entry; same resolution.
|
||||
const newEntry = findTabAgentEntry(map, 'tab-1')
|
||||
const newResolution = resolveNativeChatSession({
|
||||
paneKey: newEntry?.paneKey ?? 'tab-1:',
|
||||
launchAgent: 'claude',
|
||||
...(newEntry ? { agentStatusEntry: newEntry } : {}),
|
||||
ptyId: null
|
||||
})
|
||||
|
||||
expect(newEntry).toBe(oldEntry)
|
||||
expect(newResolution).toEqual(oldResolution)
|
||||
})
|
||||
|
||||
it('falls back to `${terminalTabId}:` paneKey when the tab has no entry', () => {
|
||||
const map: Record<string, AgentStatusEntry> = {}
|
||||
const found = findTabAgentEntry(map, 'tab-9')
|
||||
const paneKey = found?.paneKey ?? 'tab-9:'
|
||||
expect(found).toBeUndefined()
|
||||
expect(paneKey).toBe('tab-9:')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
|
||||
/** Pick the live agent-status entry for this tab. A tab's panes are keyed
|
||||
* `${tabId}:${leafId}`; the single active agent pane is the one whose paneKey
|
||||
* carries this tab id. (Split-aware resolution refines per-leaf in U8/U9; the
|
||||
* view today resolves the tab's agent pane.)
|
||||
*
|
||||
* Lives in its own module so the #19 selector (`useShallow(findTabAgentEntry)`)
|
||||
* is unit-testable without importing the store-coupled view component. */
|
||||
export function findTabAgentEntry(
|
||||
agentStatusByPaneKey: Record<string, AgentStatusEntry>,
|
||||
terminalTabId: string
|
||||
): AgentStatusEntry | undefined {
|
||||
const prefix = `${terminalTabId}:`
|
||||
for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey)) {
|
||||
if (paneKey.startsWith(prefix)) {
|
||||
return entry
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import { foldToolMessages, splitNativeChatBlocks } from './native-chat-tool-fold'
|
||||
|
||||
function msg(
|
||||
overrides: Partial<NativeChatMessage> & Pick<NativeChatMessage, 'id'>
|
||||
): NativeChatMessage {
|
||||
return {
|
||||
role: 'assistant',
|
||||
blocks: [],
|
||||
timestamp: 0,
|
||||
source: 'transcript',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('foldToolMessages', () => {
|
||||
it('merges a tool-only message into the preceding assistant turn', () => {
|
||||
const folded = foldToolMessages([
|
||||
msg({ id: 'a', role: 'assistant', blocks: [{ type: 'text', text: 'running it' }] }),
|
||||
msg({ id: 't', role: 'tool', blocks: [{ type: 'tool-result', output: 'done' }] })
|
||||
])
|
||||
expect(folded).toHaveLength(1)
|
||||
expect(folded[0]?.id).toBe('a')
|
||||
expect(folded[0]?.blocks).toEqual([
|
||||
{ type: 'text', text: 'running it' },
|
||||
{ type: 'tool-result', output: 'done' }
|
||||
])
|
||||
})
|
||||
|
||||
it('merges a chain of tool-only assistant + tool messages into one turn', () => {
|
||||
const folded = foldToolMessages([
|
||||
msg({ id: 'a', role: 'assistant', blocks: [{ type: 'text', text: 'go' }] }),
|
||||
msg({ id: 'c', role: 'assistant', blocks: [{ type: 'tool-call', name: 'Bash', input: {} }] }),
|
||||
msg({ id: 'r', role: 'tool', blocks: [{ type: 'tool-result', output: 'ok' }] })
|
||||
])
|
||||
expect(folded).toHaveLength(1)
|
||||
expect(folded[0]?.blocks).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('leaves an orphan tool message standalone when no assistant precedes it', () => {
|
||||
const folded = foldToolMessages([
|
||||
msg({ id: 'u', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }),
|
||||
msg({ id: 't', role: 'tool', blocks: [{ type: 'tool-result', output: 'x' }] })
|
||||
])
|
||||
expect(folded.map((m) => m.id)).toEqual(['u', 't'])
|
||||
})
|
||||
|
||||
it('does not fold a message carrying prose alongside a tool block', () => {
|
||||
const folded = foldToolMessages([
|
||||
msg({ id: 'a', role: 'assistant', blocks: [{ type: 'text', text: 'first' }] }),
|
||||
msg({
|
||||
id: 'b',
|
||||
role: 'assistant',
|
||||
blocks: [
|
||||
{ type: 'text', text: 'more' },
|
||||
{ type: 'tool-call', name: 'Read', input: {} }
|
||||
]
|
||||
})
|
||||
])
|
||||
expect(folded.map((m) => m.id)).toEqual(['a', 'b'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('splitNativeChatBlocks', () => {
|
||||
it('separates prose from tool blocks', () => {
|
||||
const { prose, tools } = splitNativeChatBlocks([
|
||||
{ type: 'text', text: 'hi' },
|
||||
{ type: 'tool-call', name: 'Bash', input: {} },
|
||||
{ type: 'tool-result', output: 'ok' },
|
||||
{ type: 'image-ref', path: '/x.png' }
|
||||
])
|
||||
expect(prose.map((b) => b.type)).toEqual(['text', 'image-ref'])
|
||||
expect(tools.map((b) => b.type)).toEqual(['tool-call', 'tool-result'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
// Pure folding logic for the native chat tool runs. Claude emits each tool call
|
||||
// as its own assistant message and each result as a tool-role message; folding
|
||||
// every tool-only message into the preceding assistant turn lets the view
|
||||
// collapse a whole turn's tool activity under one "N tool calls" line. Kept out
|
||||
// of the .tsx so the merge/split rules are unit-testable without rendering.
|
||||
// Ported from the mobile foldToolMessages/splitNativeChatBlocks (mobile parity).
|
||||
|
||||
import {
|
||||
isToolCallBlock,
|
||||
isToolResultBlock,
|
||||
type NativeChatBlock,
|
||||
type NativeChatMessage
|
||||
} from '../../../../shared/native-chat-types'
|
||||
|
||||
/** True when a message carries nothing but tool calls/results — the shape Claude
|
||||
* emits for the tool half of a turn. */
|
||||
function isToolOnlyMessage(message: NativeChatMessage): boolean {
|
||||
return (
|
||||
message.blocks.length > 0 &&
|
||||
message.blocks.every((block) => isToolCallBlock(block) || isToolResultBlock(block))
|
||||
)
|
||||
}
|
||||
|
||||
/** Fold a turn's tool activity into the assistant message it belongs to, so the
|
||||
* view can collapse a whole turn's tools under one line. A tool-only message is
|
||||
* merged into the preceding assistant turn when one exists; otherwise it stands
|
||||
* on its own (e.g. an orphan result with no preceding assistant prose). */
|
||||
export function foldToolMessages(messages: readonly NativeChatMessage[]): NativeChatMessage[] {
|
||||
const out: NativeChatMessage[] = []
|
||||
for (const message of messages) {
|
||||
const prev = out.at(-1)
|
||||
if (isToolOnlyMessage(message) && prev && prev.role === 'assistant') {
|
||||
out[out.length - 1] = { ...prev, blocks: [...prev.blocks, ...message.blocks] }
|
||||
} else {
|
||||
out.push(message)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Split a message's blocks into prose (text/image) and tool (call/result), so
|
||||
* the view renders the agent's words first and folds the tool activity into a
|
||||
* separate collapsible run beneath it. */
|
||||
export function splitNativeChatBlocks(blocks: readonly NativeChatBlock[]): {
|
||||
prose: NativeChatBlock[]
|
||||
tools: NativeChatBlock[]
|
||||
} {
|
||||
const prose: NativeChatBlock[] = []
|
||||
const tools: NativeChatBlock[] = []
|
||||
for (const block of blocks) {
|
||||
if (isToolCallBlock(block) || isToolResultBlock(block)) {
|
||||
tools.push(block)
|
||||
} else {
|
||||
prose.push(block)
|
||||
}
|
||||
}
|
||||
return { prose, tools }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user