mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 08:03:22 +00:00
Merge remote-tracking branch 'origin/main' into mobile-native-chat-toggle
# Conflicts: # resources/skills/current-manifest.json
This commit is contained in:
@@ -472,7 +472,10 @@ jobs:
|
||||
# message and tag name explicitly (avoids npm's `v1.2.3` prefix
|
||||
# assumptions and any lifecycle scripts that would run on bump).
|
||||
npm version "$VERSION" --no-git-tag-version --allow-same-version
|
||||
git add package.json
|
||||
# Why: package version is embedded in generated skill provenance; full tag
|
||||
# history preserves released snapshots while the current version is updated.
|
||||
node config/scripts/generate-skill-bundle-manifest.mjs --write
|
||||
git add package.json resources/skills
|
||||
commit_message="release: v$VERSION"
|
||||
if [[ "$EVENT_NAME" == "schedule" ]]; then
|
||||
commit_message="$commit_message [rc-slot:$SLOT]"
|
||||
|
||||
@@ -374,16 +374,28 @@ describe('Electron runtime package contract', () => {
|
||||
expect(afterInstallScript).not.toContain('chmod 0755 "$sandbox"')
|
||||
})
|
||||
|
||||
it('lets release-cut tag a version that is already present on main', () => {
|
||||
it('keeps release-cut version commits self-healing and taggable on retries', () => {
|
||||
const releaseWorkflow = readFileSync(
|
||||
join(projectDir, '.github/workflows/release-cut.yml'),
|
||||
'utf8'
|
||||
)
|
||||
const parsedWorkflow = parse(releaseWorkflow)
|
||||
const checkoutStep = parsedWorkflow.jobs.cut.steps.find((step) => step.name === 'Checkout ref')
|
||||
const bumpStep = parsedWorkflow.jobs.cut.steps.find(
|
||||
(step) => step.name === 'Bump package.json and tag'
|
||||
)
|
||||
|
||||
const bumpIndex = bumpStep.run.indexOf(
|
||||
'npm version "$VERSION" --no-git-tag-version --allow-same-version'
|
||||
)
|
||||
const generateIndex = bumpStep.run.indexOf(
|
||||
'node config/scripts/generate-skill-bundle-manifest.mjs --write'
|
||||
)
|
||||
const stageIndex = bumpStep.run.indexOf('git add package.json resources/skills')
|
||||
expect(checkoutStep.with['fetch-depth']).toBe(0)
|
||||
expect(bumpIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(generateIndex).toBeGreaterThan(bumpIndex)
|
||||
expect(stageIndex).toBeGreaterThan(generateIndex)
|
||||
expect(bumpStep.run).toContain('git diff --cached --quiet')
|
||||
expect(bumpStep.run).toContain('git commit --allow-empty -m "$commit_message"')
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 5.8m">
|
||||
<title>downloads: 5.8m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 5.9m">
|
||||
<title>downloads: 5.9m</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,7 +15,7 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
|
||||
<text x="37" y="14">downloads</text>
|
||||
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">5.8m</text>
|
||||
<text x="93.5" y="14">5.8m</text>
|
||||
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">5.9m</text>
|
||||
<text x="93.5" y="14">5.9m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 943 B After Width: | Height: | Size: 943 B |
@@ -141,7 +141,6 @@ import {
|
||||
saveCustomKeys,
|
||||
type CustomKey
|
||||
} from '../../../../src/components/CustomKeyModal'
|
||||
import { buildMobileDiffLines } from '../../../../src/session/mobile-diff-lines'
|
||||
import {
|
||||
addMobileDiffComment,
|
||||
formatDiffComments,
|
||||
@@ -177,7 +176,7 @@ import { useMobileTerminalPaste } from '../../../../src/session/use-mobile-termi
|
||||
import { useTerminalLiveInputModePreference } from '../../../../src/session/use-terminal-live-input-mode-preference'
|
||||
import { MobileTerminalLiveInputStatus } from '../../../../src/session/MobileTerminalLiveInputStatus'
|
||||
import { MobileTerminalInputActions } from '../../../../src/session/MobileTerminalInputActions'
|
||||
import { classifyMobileArtifact } from '../../../../src/session/mobile-artifact-kind'
|
||||
import { resolveMobileFileTabDoc } from '../../../../src/files/mobile-file-tab-doc'
|
||||
import { openMobileTerminalFileTap } from '../../../../src/session/mobile-terminal-file-tap-open'
|
||||
import { useLiveWorktreeName } from '../../../../src/session/use-live-worktree-name'
|
||||
import {
|
||||
@@ -2043,93 +2042,12 @@ export default function SessionScreen() {
|
||||
}
|
||||
setFileDocs((prev) => new Map(prev).set(tab.id, { status: 'loading' }))
|
||||
try {
|
||||
if (tab.diffSource === 'staged' || tab.diffSource === 'unstaged') {
|
||||
const response = await client.sendRequest('git.diff', {
|
||||
worktree: `id:${worktreeId}`,
|
||||
filePath: tab.relativePath,
|
||||
staged: tab.diffSource === 'staged'
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error((response as RpcFailure).error.message)
|
||||
}
|
||||
const result = (response as RpcSuccess).result as
|
||||
| {
|
||||
kind: 'text'
|
||||
originalContent: string
|
||||
modifiedContent: string
|
||||
}
|
||||
| { kind: 'binary' }
|
||||
if (result.kind !== 'text') {
|
||||
throw new Error('binary_file')
|
||||
}
|
||||
const diff = buildMobileDiffLines(result.originalContent, result.modifiedContent)
|
||||
setFileDocs((prev) =>
|
||||
new Map(prev).set(tab.id, {
|
||||
status: 'ready',
|
||||
kind: 'diff',
|
||||
lines: diff.lines,
|
||||
truncated: diff.truncated
|
||||
})
|
||||
)
|
||||
return
|
||||
}
|
||||
const artifactKind = classifyMobileArtifact(tab.relativePath)
|
||||
if (artifactKind === 'image') {
|
||||
const preview = await client.sendRequest('files.readPreview', {
|
||||
worktree: `id:${worktreeId}`,
|
||||
relativePath: tab.relativePath
|
||||
})
|
||||
if (!preview.ok) {
|
||||
throw new Error((preview as RpcFailure).error.message)
|
||||
}
|
||||
const result = (preview as RpcSuccess).result as {
|
||||
content: string
|
||||
isImage?: boolean
|
||||
mimeType?: string
|
||||
}
|
||||
if (!result.isImage || !result.mimeType || result.content.length === 0) {
|
||||
throw new Error('binary_file')
|
||||
}
|
||||
setFileDocs((prev) =>
|
||||
new Map(prev).set(tab.id, {
|
||||
status: 'ready',
|
||||
kind: 'image',
|
||||
dataUri: `data:${result.mimeType};base64,${result.content}`
|
||||
})
|
||||
)
|
||||
return
|
||||
}
|
||||
const response = await client.sendRequest('files.read', {
|
||||
worktree: `id:${worktreeId}`,
|
||||
relativePath: tab.relativePath
|
||||
const doc = await resolveMobileFileTabDoc(client, {
|
||||
worktreeId,
|
||||
relativePath: tab.relativePath,
|
||||
diffSource: tab.diffSource
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error((response as RpcFailure).error.message)
|
||||
}
|
||||
const result = (response as RpcSuccess).result as {
|
||||
content: string
|
||||
truncated: boolean
|
||||
byteLength: number
|
||||
}
|
||||
if (artifactKind === 'html') {
|
||||
setFileDocs((prev) =>
|
||||
new Map(prev).set(tab.id, {
|
||||
status: 'ready',
|
||||
kind: 'html',
|
||||
content: result.content
|
||||
})
|
||||
)
|
||||
return
|
||||
}
|
||||
setFileDocs((prev) =>
|
||||
new Map(prev).set(tab.id, {
|
||||
status: 'ready',
|
||||
kind: 'file',
|
||||
content: result.content,
|
||||
truncated: result.truncated,
|
||||
byteLength: result.byteLength
|
||||
})
|
||||
)
|
||||
setFileDocs((prev) => new Map(prev).set(tab.id, doc))
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : ''
|
||||
const previewMessage =
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mobileDiffImageDataUri } from './mobile-diff-image-preview'
|
||||
|
||||
describe('mobileDiffImageDataUri', () => {
|
||||
it('renders a modified image diff from the post-change bytes', () => {
|
||||
expect(
|
||||
mobileDiffImageDataUri({
|
||||
kind: 'binary',
|
||||
originalContent: 'b2xk',
|
||||
modifiedContent: 'bmV3',
|
||||
isImage: true,
|
||||
mimeType: 'image/png'
|
||||
})
|
||||
).toBe('data:image/png;base64,bmV3')
|
||||
})
|
||||
|
||||
it('renders an added image diff (no original) from the modified bytes', () => {
|
||||
expect(
|
||||
mobileDiffImageDataUri({
|
||||
kind: 'binary',
|
||||
originalContent: '',
|
||||
modifiedContent: 'bmV3',
|
||||
isImage: true,
|
||||
mimeType: 'image/png'
|
||||
})
|
||||
).toBe('data:image/png;base64,bmV3')
|
||||
})
|
||||
|
||||
it('falls back to the original bytes for a proven deletion (modifiedDeleted)', () => {
|
||||
expect(
|
||||
mobileDiffImageDataUri({
|
||||
kind: 'binary',
|
||||
originalContent: 'b2xk',
|
||||
originalIsBinary: true,
|
||||
modifiedContent: '',
|
||||
modifiedIsBinary: false,
|
||||
modifiedDeleted: true,
|
||||
isImage: true,
|
||||
mimeType: 'image/jpeg'
|
||||
})
|
||||
).toBe('data:image/jpeg;base64,b2xk')
|
||||
})
|
||||
|
||||
// The reviewer's read-failure case: a relay/SSH read returns an empty modified
|
||||
// side with modifiedIsBinary false and no modifiedDeleted flag. Without a proven
|
||||
// deletion, falling back to the original would show a stale pre-change image, so
|
||||
// "unavailable" (null) is the honest result.
|
||||
it('returns null on a read failure (empty modified, no modifiedDeleted flag)', () => {
|
||||
expect(
|
||||
mobileDiffImageDataUri({
|
||||
kind: 'binary',
|
||||
originalContent: 'b2xk',
|
||||
originalIsBinary: true,
|
||||
modifiedContent: '',
|
||||
modifiedIsBinary: false,
|
||||
isImage: true,
|
||||
mimeType: 'image/png'
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
// Guards the >size-cap case: the modified side IS a binary image but its bytes
|
||||
// arrive empty. Falling back to the original would render the stale pre-change
|
||||
// image; "unavailable" is the honest result.
|
||||
it('returns null for a modify whose binary modified side is empty (no stale fallback)', () => {
|
||||
expect(
|
||||
mobileDiffImageDataUri({
|
||||
kind: 'binary',
|
||||
originalContent: 'b2xk',
|
||||
originalIsBinary: true,
|
||||
modifiedContent: '',
|
||||
modifiedIsBinary: true,
|
||||
isImage: true,
|
||||
mimeType: 'image/png'
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for a non-previewable binary (no isImage/mimeType)', () => {
|
||||
expect(
|
||||
mobileDiffImageDataUri({
|
||||
kind: 'binary',
|
||||
originalContent: '',
|
||||
modifiedContent: 'AAAA'
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when flagged as image but carrying no bytes', () => {
|
||||
expect(
|
||||
mobileDiffImageDataUri({
|
||||
kind: 'binary',
|
||||
originalContent: '',
|
||||
modifiedContent: '',
|
||||
isImage: true,
|
||||
mimeType: 'image/png'
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for application/pdf even when flagged as previewable', () => {
|
||||
expect(
|
||||
mobileDiffImageDataUri({
|
||||
kind: 'binary',
|
||||
originalContent: '',
|
||||
modifiedContent: 'JVBER',
|
||||
isImage: true,
|
||||
mimeType: 'application/pdf'
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when flagged as image but mimeType is missing', () => {
|
||||
expect(
|
||||
mobileDiffImageDataUri({
|
||||
kind: 'binary',
|
||||
originalContent: '',
|
||||
modifiedContent: 'bmV3',
|
||||
isImage: true
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { buildImageDataUri } from '../../../src/shared/image-data-uri'
|
||||
|
||||
// modifiedDeleted marks a proven deletion (modified side genuinely absent); an
|
||||
// empty modifiedContent alone can't, since a relay/SSH read failure also arrives
|
||||
// empty with modifiedIsBinary false.
|
||||
export type MobileBinaryDiffResult = {
|
||||
kind: 'binary'
|
||||
originalContent?: string
|
||||
modifiedContent?: string
|
||||
originalIsBinary?: boolean
|
||||
modifiedIsBinary?: boolean
|
||||
modifiedDeleted?: boolean
|
||||
isImage?: boolean
|
||||
mimeType?: string
|
||||
}
|
||||
|
||||
// Falls back to the original bytes only for a proven deletion; a read failure or
|
||||
// size-capped modify — also empty on the modified side — returns null instead of
|
||||
// the stale pre-change image.
|
||||
export function mobileDiffImageDataUri(result: MobileBinaryDiffResult): string | null {
|
||||
if (result.isImage !== true) {
|
||||
return null
|
||||
}
|
||||
const modified = result.modifiedContent || ''
|
||||
if (modified) {
|
||||
return buildImageDataUri(result.mimeType, modified)
|
||||
}
|
||||
if (result.modifiedDeleted === true) {
|
||||
return buildImageDataUri(result.mimeType, result.originalContent || '')
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { RpcResponse } from '../transport/types'
|
||||
import { resolveMobileFileTabDoc } from './mobile-file-tab-doc'
|
||||
|
||||
function ok(result: unknown): RpcResponse {
|
||||
return { id: 'x', ok: true, result, _meta: { runtimeId: 'r' } }
|
||||
}
|
||||
|
||||
function fail(code: string, message: string): RpcResponse {
|
||||
return { id: 'x', ok: false, error: { code, message }, _meta: { runtimeId: 'r' } }
|
||||
}
|
||||
|
||||
// Fake client that returns a canned response per RPC method.
|
||||
function clientOf(byMethod: Record<string, RpcResponse>): {
|
||||
sendRequest: (method: string) => Promise<RpcResponse>
|
||||
calls: string[]
|
||||
} {
|
||||
const calls: string[] = []
|
||||
return {
|
||||
calls,
|
||||
sendRequest: (method: string) => {
|
||||
calls.push(method)
|
||||
const response = byMethod[method]
|
||||
if (!response) {
|
||||
throw new Error(`unexpected method ${method}`)
|
||||
}
|
||||
return Promise.resolve(response)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const WT = { worktreeId: 'wt1' }
|
||||
|
||||
describe('resolveMobileFileTabDoc', () => {
|
||||
it('renders a staged text diff', async () => {
|
||||
const client = clientOf({
|
||||
'git.diff': ok({ kind: 'text', originalContent: 'a\n', modifiedContent: 'a\nb\n' })
|
||||
})
|
||||
const doc = await resolveMobileFileTabDoc(client, {
|
||||
...WT,
|
||||
relativePath: 'a.ts',
|
||||
diffSource: 'staged'
|
||||
})
|
||||
expect(doc.kind).toBe('diff')
|
||||
expect(client.calls).toEqual(['git.diff'])
|
||||
})
|
||||
|
||||
it('renders an unstaged image diff from the modified bytes', async () => {
|
||||
const client = clientOf({
|
||||
'git.diff': ok({
|
||||
kind: 'binary',
|
||||
originalContent: 'b2xk',
|
||||
modifiedContent: 'bmV3',
|
||||
modifiedIsBinary: true,
|
||||
isImage: true,
|
||||
mimeType: 'image/png'
|
||||
})
|
||||
})
|
||||
const doc = await resolveMobileFileTabDoc(client, {
|
||||
...WT,
|
||||
relativePath: 'm1.png',
|
||||
diffSource: 'unstaged'
|
||||
})
|
||||
expect(doc).toEqual({ status: 'ready', kind: 'image', dataUri: 'data:image/png;base64,bmV3' })
|
||||
})
|
||||
|
||||
it('throws binary_file for an image modify whose bytes are empty (no stale fallback)', async () => {
|
||||
const client = clientOf({
|
||||
'git.diff': ok({
|
||||
kind: 'binary',
|
||||
originalContent: 'b2xk',
|
||||
modifiedContent: '',
|
||||
modifiedIsBinary: true,
|
||||
isImage: true,
|
||||
mimeType: 'image/png'
|
||||
})
|
||||
})
|
||||
await expect(
|
||||
resolveMobileFileTabDoc(client, { ...WT, relativePath: 'm1.png', diffSource: 'unstaged' })
|
||||
).rejects.toThrow('binary_file')
|
||||
})
|
||||
|
||||
it('throws binary_file for a non-image binary diff', async () => {
|
||||
const client = clientOf({ 'git.diff': ok({ kind: 'binary', modifiedContent: 'AAAA' }) })
|
||||
await expect(
|
||||
resolveMobileFileTabDoc(client, { ...WT, relativePath: 'a.bin', diffSource: 'unstaged' })
|
||||
).rejects.toThrow('binary_file')
|
||||
})
|
||||
|
||||
it('renders a live image preview via files.readPreview', async () => {
|
||||
const client = clientOf({
|
||||
'files.readPreview': ok({ content: 'bmV3', isImage: true, mimeType: 'image/png' })
|
||||
})
|
||||
const doc = await resolveMobileFileTabDoc(client, { ...WT, relativePath: 'logo.png' })
|
||||
expect(doc).toEqual({ status: 'ready', kind: 'image', dataUri: 'data:image/png;base64,bmV3' })
|
||||
expect(client.calls).toEqual(['files.readPreview'])
|
||||
})
|
||||
|
||||
it('throws binary_file when readPreview returns no image bytes', async () => {
|
||||
const client = clientOf({
|
||||
'files.readPreview': ok({ content: '', isImage: true, mimeType: 'image/png' })
|
||||
})
|
||||
await expect(
|
||||
resolveMobileFileTabDoc(client, { ...WT, relativePath: 'logo.png' })
|
||||
).rejects.toThrow('binary_file')
|
||||
})
|
||||
|
||||
it('renders html source via files.read', async () => {
|
||||
const client = clientOf({
|
||||
'files.read': ok({ content: '<h1>hi</h1>', truncated: false, byteLength: 11 })
|
||||
})
|
||||
const doc = await resolveMobileFileTabDoc(client, { ...WT, relativePath: 'page.html' })
|
||||
expect(doc).toEqual({ status: 'ready', kind: 'html', content: '<h1>hi</h1>' })
|
||||
})
|
||||
|
||||
it('renders a plain text file via files.read', async () => {
|
||||
const client = clientOf({
|
||||
'files.read': ok({ content: 'hello', truncated: true, byteLength: 5 })
|
||||
})
|
||||
const doc = await resolveMobileFileTabDoc(client, { ...WT, relativePath: 'notes.txt' })
|
||||
expect(doc).toEqual({
|
||||
status: 'ready',
|
||||
kind: 'file',
|
||||
content: 'hello',
|
||||
truncated: true,
|
||||
byteLength: 5
|
||||
})
|
||||
})
|
||||
|
||||
it('propagates the RPC error message when a read fails', async () => {
|
||||
const client = clientOf({ 'files.read': fail('EIO', 'file_too_large') })
|
||||
await expect(
|
||||
resolveMobileFileTabDoc(client, { ...WT, relativePath: 'notes.txt' })
|
||||
).rejects.toThrow('file_too_large')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import { buildImageDataUri } from '../../../src/shared/image-data-uri'
|
||||
import { classifyMobileArtifact } from '../session/mobile-artifact-kind'
|
||||
import { buildMobileDiffLines, type MobileDiffLine } from '../session/mobile-diff-lines'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcFailure, RpcSuccess } from '../transport/types'
|
||||
import { mobileDiffImageDataUri, type MobileBinaryDiffResult } from './mobile-diff-image-preview'
|
||||
|
||||
type FileTabDocClient = Pick<RpcClient, 'sendRequest'>
|
||||
|
||||
// The ready doc a session file tab renders. Mirrors the ready arm of the route's
|
||||
// FileDocState; kept in src so the loader stays testable without the route.
|
||||
export type MobileFileTabDoc =
|
||||
| { status: 'ready'; kind: 'file'; content: string; truncated: boolean; byteLength: number }
|
||||
| { status: 'ready'; kind: 'diff'; lines: MobileDiffLine[]; truncated: boolean }
|
||||
| { status: 'ready'; kind: 'image'; dataUri: string }
|
||||
| { status: 'ready'; kind: 'html'; content: string }
|
||||
|
||||
export type MobileFileTabDocRequest = {
|
||||
worktreeId: string
|
||||
relativePath: string
|
||||
diffSource?: 'staged' | 'unstaged' | 'branch' | 'commit'
|
||||
}
|
||||
|
||||
// Throws 'binary_file'/'file_too_large'/the RPC error message; callers map those
|
||||
// to error docs.
|
||||
export async function resolveMobileFileTabDoc(
|
||||
client: FileTabDocClient,
|
||||
request: MobileFileTabDocRequest
|
||||
): Promise<MobileFileTabDoc> {
|
||||
const worktree = `id:${request.worktreeId}`
|
||||
const { relativePath } = request
|
||||
if (request.diffSource === 'staged' || request.diffSource === 'unstaged') {
|
||||
const response = await client.sendRequest('git.diff', {
|
||||
worktree,
|
||||
filePath: relativePath,
|
||||
staged: request.diffSource === 'staged'
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error((response as RpcFailure).error.message)
|
||||
}
|
||||
const result = (response as RpcSuccess).result as
|
||||
| { kind: 'text'; originalContent: string; modifiedContent: string }
|
||||
| MobileBinaryDiffResult
|
||||
if (result.kind !== 'text') {
|
||||
// Render image diffs (add/modify/delete) from the base64 the host already
|
||||
// sends; only non-previewable binaries stay unavailable.
|
||||
const dataUri = mobileDiffImageDataUri(result)
|
||||
if (!dataUri) {
|
||||
throw new Error('binary_file')
|
||||
}
|
||||
return { status: 'ready', kind: 'image', dataUri }
|
||||
}
|
||||
const diff = buildMobileDiffLines(result.originalContent, result.modifiedContent)
|
||||
return { status: 'ready', kind: 'diff', lines: diff.lines, truncated: diff.truncated }
|
||||
}
|
||||
|
||||
const artifactKind = classifyMobileArtifact(relativePath)
|
||||
if (artifactKind === 'image') {
|
||||
const preview = await client.sendRequest('files.readPreview', { worktree, relativePath })
|
||||
if (!preview.ok) {
|
||||
throw new Error((preview as RpcFailure).error.message)
|
||||
}
|
||||
const result = (preview as RpcSuccess).result as {
|
||||
content: string
|
||||
isImage?: boolean
|
||||
mimeType?: string
|
||||
}
|
||||
const dataUri = result.isImage ? buildImageDataUri(result.mimeType, result.content) : null
|
||||
if (!dataUri) {
|
||||
throw new Error('binary_file')
|
||||
}
|
||||
return { status: 'ready', kind: 'image', dataUri }
|
||||
}
|
||||
|
||||
const response = await client.sendRequest('files.read', { worktree, relativePath })
|
||||
if (!response.ok) {
|
||||
throw new Error((response as RpcFailure).error.message)
|
||||
}
|
||||
const result = (response as RpcSuccess).result as {
|
||||
content: string
|
||||
truncated: boolean
|
||||
byteLength: number
|
||||
}
|
||||
if (artifactKind === 'html') {
|
||||
return { status: 'ready', kind: 'html', content: result.content }
|
||||
}
|
||||
return {
|
||||
status: 'ready',
|
||||
kind: 'file',
|
||||
content: result.content,
|
||||
truncated: result.truncated,
|
||||
byteLength: result.byteLength
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, expectTypeOf, it } from 'vitest'
|
||||
import type { GitStatusResult } from '../../../src/shared/git-status-types'
|
||||
import {
|
||||
buildMobileSourceControlSections,
|
||||
canOpenMobileGitStatusEntry,
|
||||
countStagedEntries,
|
||||
countUnstagedEntries,
|
||||
getStageablePaths,
|
||||
@@ -65,6 +66,31 @@ describe('mobile source control status helpers', () => {
|
||||
expect(isMobileGitDiscardableEntry(conflictedEntries[2])).toBe(false)
|
||||
})
|
||||
|
||||
it('allows opening deleted files so pre-delete text/image diffs can load', () => {
|
||||
expect(
|
||||
canOpenMobileGitStatusEntry({ path: 'deleted.png', status: 'deleted', area: 'unstaged' })
|
||||
).toBe(true)
|
||||
expect(
|
||||
canOpenMobileGitStatusEntry({ path: 'logo.png', status: 'modified', area: 'unstaged' })
|
||||
).toBe(true)
|
||||
expect(
|
||||
canOpenMobileGitStatusEntry({
|
||||
path: 'conflict.ts',
|
||||
status: 'modified',
|
||||
area: 'unstaged',
|
||||
conflictStatus: 'unresolved'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
canOpenMobileGitStatusEntry({
|
||||
path: 'resolved.ts',
|
||||
status: 'modified',
|
||||
area: 'unstaged',
|
||||
conflictStatus: 'resolved_locally'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('sorts entries by desktop-compatible conflict rank, then path', () => {
|
||||
const sections = buildMobileSourceControlSections([
|
||||
{ path: 'zeta.ts', status: 'modified', area: 'unstaged' },
|
||||
|
||||
@@ -90,6 +90,12 @@ export function isMobileGitDiscardableEntry(entry: MobileGitStatusEntry): boolea
|
||||
return entry.conflictStatus !== 'unresolved' && entry.conflictStatus !== 'resolved_locally'
|
||||
}
|
||||
|
||||
// Why: unresolved conflicts are not a stable file to open. Deletions are —
|
||||
// git.diff still returns the pre-delete side (text or image via modifiedDeleted).
|
||||
export function canOpenMobileGitStatusEntry(entry: MobileGitStatusEntry): boolean {
|
||||
return entry.conflictStatus !== 'unresolved'
|
||||
}
|
||||
|
||||
export function isMobileGitUnavailable(code: string | undefined, message: string | undefined) {
|
||||
return (
|
||||
code === 'forbidden' ||
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
MobileGitBranchCompareSummary
|
||||
} from './mobile-branch-compare'
|
||||
import {
|
||||
canOpenMobileGitStatusEntry,
|
||||
isMobileGitDiscardableEntry,
|
||||
isMobileGitStageableEntry,
|
||||
type MobileGitFileStatus,
|
||||
@@ -58,15 +59,14 @@ export type MobileGitStatusEntryView = MobileGitStatusEntry & {
|
||||
}
|
||||
|
||||
// Decorate raw status entries with the row-level capability/action-id fields the
|
||||
// file list needs. Deleted/unresolved entries are not openable (matches the
|
||||
// opener guards).
|
||||
// file list needs. Opener guards must use the same canOpen rule.
|
||||
export function buildMobileGitStatusEntryViews(
|
||||
entries: readonly MobileGitStatusEntry[]
|
||||
): MobileGitStatusEntryView[] {
|
||||
return entries.map((entry) => ({
|
||||
...entry,
|
||||
canDiscard: isMobileGitDiscardableEntry(entry),
|
||||
canOpen: entry.status !== 'deleted' && entry.conflictStatus !== 'unresolved',
|
||||
canOpen: canOpenMobileGitStatusEntry(entry),
|
||||
canStage: isMobileGitStageableEntry(entry),
|
||||
discardActionId: `discard:${entry.path}`,
|
||||
stageActionId: `stage:${entry.path}`,
|
||||
|
||||
@@ -12,7 +12,11 @@ import {
|
||||
canOpenMobileBranchCompareDiff,
|
||||
type MobileGitBranchChangeEntry
|
||||
} from './mobile-branch-compare'
|
||||
import { isMobileGitUnavailable, type MobileGitStatusEntry } from './mobile-git-status'
|
||||
import {
|
||||
canOpenMobileGitStatusEntry,
|
||||
isMobileGitUnavailable,
|
||||
type MobileGitStatusEntry
|
||||
} from './mobile-git-status'
|
||||
import { buildMobileReviewFileRoute } from './mobile-review-route'
|
||||
import type {
|
||||
GitDiffTextResult,
|
||||
@@ -70,7 +74,9 @@ export function useMobileSourceControlOpeners(params: Params) {
|
||||
|
||||
const openFile = useCallback(
|
||||
async (entry: MobileGitStatusEntry) => {
|
||||
if (entry.status === 'deleted' || entry.conflictStatus === 'unresolved') {
|
||||
// Deletions are openable (pre-delete text/image via git.diff); only block
|
||||
// unresolved conflicts, matching canOpenMobileGitStatusEntry / row UI.
|
||||
if (!canOpenMobileGitStatusEntry(entry)) {
|
||||
return
|
||||
}
|
||||
if (openingPathRef.current || busyActionRef.current) {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "orca",
|
||||
"version": "1.4.144-rc.2",
|
||||
"version": "1.4.144-rc.4",
|
||||
"description": "Next-gen IDE for parallel agentic development",
|
||||
"homepage": "https://github.com/stablyai/orca",
|
||||
"author": "stablyai",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"appVersion": "1.4.144-rc.2",
|
||||
"appVersion": "1.4.144-rc.4",
|
||||
"skills": [
|
||||
{
|
||||
"name": "computer-use",
|
||||
"sourcePath": "skills/computer-use",
|
||||
"appVersion": "1.4.144-rc.2",
|
||||
"appVersion": "1.4.144-rc.4",
|
||||
"releaseRevision": 5,
|
||||
"packageDigest": "cd2809474d57fd7277adb277448e6fa446810d3cbad71ac0b473b9e8ff1bad68",
|
||||
"gitTreeSha": "306c0f8cb63bcac265a5b7975dc2f855be4f1344",
|
||||
@@ -24,7 +24,7 @@
|
||||
{
|
||||
"name": "linear-tickets",
|
||||
"sourcePath": "skills/linear-tickets",
|
||||
"appVersion": "1.4.144-rc.2",
|
||||
"appVersion": "1.4.144-rc.4",
|
||||
"releaseRevision": 4,
|
||||
"packageDigest": "f198d7b22e5ee1673dac403f9cca0553b124e0a90e4fdd05d2c23b7344e32d2b",
|
||||
"gitTreeSha": "de9fc106bbb4e313a90ff9a9513a720909bbd176",
|
||||
@@ -43,7 +43,7 @@
|
||||
{
|
||||
"name": "orca-cli",
|
||||
"sourcePath": "skills/orca-cli",
|
||||
"appVersion": "1.4.144-rc.2",
|
||||
"appVersion": "1.4.144-rc.4",
|
||||
"releaseRevision": 32,
|
||||
"packageDigest": "51740ff13f379ac5743d3fd28a14b17168dcef40f7048c20182dce166098c45f",
|
||||
"gitTreeSha": "ded93000a5f654e2b4f324501282459bd56afe19",
|
||||
@@ -62,7 +62,7 @@
|
||||
{
|
||||
"name": "orca-emulator",
|
||||
"sourcePath": "skills/orca-emulator",
|
||||
"appVersion": "1.4.144-rc.2",
|
||||
"appVersion": "1.4.144-rc.4",
|
||||
"releaseRevision": 4,
|
||||
"packageDigest": "453b1d9aa20b51b8a4d32c7b6def6a93f7ef9c730de32abbcbc1788ad1b1820b",
|
||||
"gitTreeSha": "66be6abe99f1807da85934aee0e22daefc8f7656",
|
||||
@@ -81,7 +81,7 @@
|
||||
{
|
||||
"name": "orca-emulator-android",
|
||||
"sourcePath": "skills/orca-emulator-android",
|
||||
"appVersion": "1.4.144-rc.2",
|
||||
"appVersion": "1.4.144-rc.4",
|
||||
"releaseRevision": 2,
|
||||
"packageDigest": "12272cf82e0731f11e424822b961882457034e730358cc65ea28e4eb9c8ff7f5",
|
||||
"gitTreeSha": "f7b0fc8cbf5cd78ca5156f6bbe3a20f1462d8f83",
|
||||
@@ -100,7 +100,7 @@
|
||||
{
|
||||
"name": "orca-linear",
|
||||
"sourcePath": "skills/orca-linear",
|
||||
"appVersion": "1.4.144-rc.2",
|
||||
"appVersion": "1.4.144-rc.4",
|
||||
"releaseRevision": 2,
|
||||
"packageDigest": "d44d09e6ecb6a64da177083aad26a95f031cd1cf26ba059fdc888c2628aef64f",
|
||||
"gitTreeSha": "c34f42030f43e5a85737996fa375bbd79cb5bea8",
|
||||
@@ -119,7 +119,7 @@
|
||||
{
|
||||
"name": "orca-per-workspace-env",
|
||||
"sourcePath": "skills/orca-per-workspace-env",
|
||||
"appVersion": "1.4.144-rc.2",
|
||||
"appVersion": "1.4.144-rc.4",
|
||||
"releaseRevision": 2,
|
||||
"packageDigest": "fa3b65a1a107fca3f0375c696852477b62f58c154b9eb5c0663c41edc4bcd30d",
|
||||
"gitTreeSha": "354e775b79ea6952ec63acac4d3ee8a9ae07a650",
|
||||
@@ -138,7 +138,7 @@
|
||||
{
|
||||
"name": "orchestration",
|
||||
"sourcePath": "skills/orchestration",
|
||||
"appVersion": "1.4.144-rc.2",
|
||||
"appVersion": "1.4.144-rc.4",
|
||||
"releaseRevision": 24,
|
||||
"packageDigest": "9fbfa2ae3f3f99441563a4b8b1c6302107944480db8718ddb326a862a51f7ab9",
|
||||
"gitTreeSha": "086c41e0b353b4908d2963694b4a7c791d4b3982",
|
||||
|
||||
@@ -140,7 +140,7 @@ describe('maybeAutoRenameBranchOnFirstWork', () => {
|
||||
})
|
||||
|
||||
it('strips a prefix the model leaked into the slug from both branch and display name', async () => {
|
||||
// Model ignored "no prefixes" and echoed `you/worktree-spinner`, which the
|
||||
// Model echoed `you/worktree-spinner`, which the
|
||||
// sanitizer folds to `you-worktree-spinner`; without stripping it would
|
||||
// double-prefix the branch (`you/you-...`) and show "You worktree spinner".
|
||||
generateBranchNameMock.mockResolvedValue({ success: true, slug: 'you-worktree-spinner' })
|
||||
|
||||
@@ -283,9 +283,8 @@ async function runAutoRename(
|
||||
const username = provider
|
||||
? (await getSshGitUsername(provider, repo.path)) || null
|
||||
: (await resolveLocalGitUsername(repo.path)) || null
|
||||
// The model is told not to add a prefix, but sometimes echoes the configured
|
||||
// one (e.g. `tmchow/...`); strip it so it doesn't double-prefix the branch or
|
||||
// leak into the display name.
|
||||
// The model sometimes echoes the configured prefix (e.g. `tmchow/...`); strip
|
||||
// it so it doesn't double-prefix the branch or leak into the display name.
|
||||
const slug = stripConfiguredBranchPrefix(
|
||||
generated.slug,
|
||||
getConfiguredBranchPrefix(settings, username)
|
||||
|
||||
@@ -2210,7 +2210,7 @@ describe('createPtySubprocess', () => {
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'C:\\PortableGit\\bin\\bash.exe',
|
||||
['--login', '-i'],
|
||||
['-c', 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i'],
|
||||
expect.objectContaining({
|
||||
cwd: 'C:\\Users\\jin\\repo',
|
||||
env: expect.objectContaining({ CHERE_INVOKING: '1' })
|
||||
|
||||
@@ -476,6 +476,37 @@ describe('getDiff', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('flags a deleted image so previewers can fall back to the original bytes', async () => {
|
||||
const pngBuffer = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00])
|
||||
gitExecFileAsyncBufferMock.mockResolvedValueOnce({ stdout: pngBuffer })
|
||||
statMock.mockRejectedValueOnce(Object.assign(new Error('missing'), { code: 'ENOENT' }))
|
||||
|
||||
const result = await getDiff('/repo', 'assets/deleted.png', false)
|
||||
|
||||
expect(result.kind).toBe('binary')
|
||||
if (result.kind !== 'binary') {
|
||||
throw new Error('expected binary diff result')
|
||||
}
|
||||
expect(result.modifiedDeleted).toBe(true)
|
||||
expect(result.originalContent).toBe(pngBuffer.toString('base64'))
|
||||
expect(result.modifiedContent).toBe('')
|
||||
})
|
||||
|
||||
it('does not treat an unreadable working-tree image as a deletion', async () => {
|
||||
const pngBuffer = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00])
|
||||
gitExecFileAsyncBufferMock.mockResolvedValueOnce({ stdout: pngBuffer })
|
||||
statMock.mockResolvedValueOnce({ isFile: () => true, size: 5 })
|
||||
readFileMock.mockRejectedValueOnce(new Error('EIO'))
|
||||
|
||||
const result = await getDiff('/repo', 'assets/unreadable.png', false)
|
||||
|
||||
expect(result.kind).toBe('binary')
|
||||
if (result.kind !== 'binary') {
|
||||
throw new Error('expected binary diff result')
|
||||
}
|
||||
expect(result.modifiedDeleted).toBeUndefined()
|
||||
})
|
||||
|
||||
it('coalesces concurrent identical staged diff reads while in flight', async () => {
|
||||
const leftBlob = deferredBuffer('head-content\n')
|
||||
const rightBlob = deferredBuffer('index-content\n')
|
||||
|
||||
+32
-10
@@ -1329,6 +1329,7 @@ async function loadDiff(
|
||||
let modifiedContent = ''
|
||||
let originalIsBinary = false
|
||||
let modifiedIsBinary = false
|
||||
let modifiedDeleted = false
|
||||
|
||||
try {
|
||||
const leftBlob = staged
|
||||
@@ -1343,22 +1344,30 @@ async function loadDiff(
|
||||
const rightBlob = await readGitBlobAtIndexPath(worktreePath, filePath, options)
|
||||
modifiedContent = rightBlob.content
|
||||
modifiedIsBinary = rightBlob.isBinary
|
||||
modifiedDeleted = !rightBlob.exists
|
||||
} else {
|
||||
const workingTreeBlob = await readWorkingTreeFile(path.join(worktreePath, filePath))
|
||||
modifiedContent = workingTreeBlob.content
|
||||
modifiedIsBinary = workingTreeBlob.isBinary
|
||||
modifiedDeleted = !workingTreeBlob.exists
|
||||
}
|
||||
} catch {
|
||||
// Fallback
|
||||
}
|
||||
|
||||
return buildDiffResult(
|
||||
const result = buildDiffResult(
|
||||
originalContent,
|
||||
modifiedContent,
|
||||
originalIsBinary,
|
||||
modifiedIsBinary,
|
||||
filePath
|
||||
)
|
||||
// Why: mark a proven deletion so previewers can fall back to the original bytes
|
||||
// without mistaking a read failure's empty modified side for a deletion.
|
||||
if (result.kind === 'binary' && modifiedDeleted) {
|
||||
return { ...result, modifiedDeleted: true }
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export async function getBranchCompare(
|
||||
@@ -1859,20 +1868,33 @@ async function readGitBlobAtOidPath(
|
||||
}
|
||||
|
||||
async function readWorkingTreeFile(filePath: string): Promise<GitBlobReadResult> {
|
||||
let fileStat
|
||||
try {
|
||||
const fileStat = await stat(filePath)
|
||||
if (!fileStat.isFile()) {
|
||||
return { content: '', isBinary: false, exists: false }
|
||||
}
|
||||
if (fileStat.size > MAX_GIT_SHOW_BYTES) {
|
||||
// Why: git blob reads are capped through maxBuffer; mirror that bound for
|
||||
// unstaged working-tree content before readFile can pull in huge assets.
|
||||
return { content: '', isBinary: true, exists: true }
|
||||
fileStat = await stat(filePath)
|
||||
} catch (error) {
|
||||
// Why: ENOENT means the working-tree file is genuinely gone (a deletion);
|
||||
// any other stat error is a read failure, which must not be reported as an
|
||||
// absence since callers fall back to the original bytes only for deletions.
|
||||
return {
|
||||
content: '',
|
||||
isBinary: false,
|
||||
exists: (error as NodeJS.ErrnoException)?.code !== 'ENOENT'
|
||||
}
|
||||
}
|
||||
if (!fileStat.isFile()) {
|
||||
return { content: '', isBinary: false, exists: false }
|
||||
}
|
||||
if (fileStat.size > MAX_GIT_SHOW_BYTES) {
|
||||
// Why: git blob reads are capped through maxBuffer; mirror that bound for
|
||||
// unstaged working-tree content before readFile can pull in huge assets.
|
||||
return { content: '', isBinary: true, exists: true }
|
||||
}
|
||||
try {
|
||||
const buffer = await readFile(filePath)
|
||||
return bufferToBlob(buffer, filePath)
|
||||
} catch {
|
||||
return { content: '', isBinary: false, exists: false }
|
||||
// Why: the file exists but could not be read — a read failure, not a deletion.
|
||||
return { content: '', isBinary: false, exists: true }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+9
-1
@@ -2178,7 +2178,15 @@ app.whenReady().then(async () => {
|
||||
enableWebSocket: true,
|
||||
...(isE2E ? { wsPort: 0 } : {}),
|
||||
...(devWsPort !== undefined ? { wsPort: devWsPort } : {}),
|
||||
...(serveOptions?.wsPort !== undefined ? { wsPort: serveOptions.wsPort } : {}),
|
||||
...(serveOptions?.wsPort !== undefined
|
||||
? {
|
||||
wsPort: serveOptions.wsPort,
|
||||
// Why: only an explicit `orca serve --port` pin prefers the requested
|
||||
// port over a stale STA-1511 fallback (issue #8535). Default 6768 /
|
||||
// dev 6769 keep fallback-first so mobile pairings stay stable.
|
||||
preferPinnedWsPort: true
|
||||
}
|
||||
: {}),
|
||||
webClientRoot: getBundledWebClientRoot()
|
||||
})
|
||||
registerMobileHandlers(runtimeRpc, { getRelayStatus: () => desktopRelayStatus })
|
||||
|
||||
@@ -6497,7 +6497,7 @@ describe('registerPtyHandlers', () => {
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'C:\\Program Files\\Git\\bin\\bash.exe',
|
||||
['--login', '-i'],
|
||||
['-c', 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i'],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({ CHERE_INVOKING: '1' })
|
||||
})
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtemp, mkdir, realpath, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { subscribeViaWatcherProcess } from './parcel-watcher-process'
|
||||
import type {
|
||||
WatcherProcessCallback,
|
||||
WatcherProcessHooks
|
||||
} from './parcel-watcher-process-subscription'
|
||||
import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter'
|
||||
import type { WorktreeBasePollEvent } from './worktree-base-directory-poller'
|
||||
import { startGitCommonWatch } from './worktree-git-common-watch'
|
||||
|
||||
vi.mock('./parcel-watcher-process', () => ({
|
||||
subscribeViaWatcherProcess: vi.fn()
|
||||
}))
|
||||
|
||||
const POLL_MS = 25
|
||||
|
||||
type ChildSubscription = {
|
||||
dir: string
|
||||
callback: WatcherProcessCallback
|
||||
hooks: WatcherProcessHooks
|
||||
unsubscribe: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
describe('worktree git-common narrow watch (darwin)', () => {
|
||||
const cleanups: (() => Promise<void>)[] = []
|
||||
const subscribeMock = vi.mocked(subscribeViaWatcherProcess)
|
||||
let childSubscriptions: ChildSubscription[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))
|
||||
childSubscriptions = []
|
||||
subscribeMock.mockReset()
|
||||
})
|
||||
|
||||
function installSubscribeMock(): void {
|
||||
subscribeMock.mockImplementation(async (dir, callback, _opts, hooks = {}) => {
|
||||
const unsubscribe = vi.fn(async () => {})
|
||||
childSubscriptions.push({ dir, callback, hooks, unsubscribe })
|
||||
return { unsubscribe }
|
||||
})
|
||||
}
|
||||
|
||||
async function makeCommonDir(withWorktrees: boolean): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-git-common-watch-'))
|
||||
cleanups.push(() => rm(root, { recursive: true, force: true }))
|
||||
const commonDir = await realpath(root)
|
||||
if (withWorktrees) {
|
||||
await mkdir(join(commonDir, 'worktrees'))
|
||||
}
|
||||
return commonDir
|
||||
}
|
||||
|
||||
function makeTarget(path: string): WorktreeBaseWatchTarget {
|
||||
return {
|
||||
key: `git-common:local:${path}`,
|
||||
kind: 'git-common',
|
||||
path,
|
||||
repos: new Map([['repo-1', { repoId: 'repo-1', repoName: 'project', nestWorkspaces: false }]])
|
||||
}
|
||||
}
|
||||
|
||||
async function startWatch(commonDir: string, received: WorktreeBasePollEvent[][]): Promise<void> {
|
||||
const watch = await startGitCommonWatch(
|
||||
makeTarget(commonDir),
|
||||
(events) => received.push(events),
|
||||
POLL_MS,
|
||||
'darwin'
|
||||
)
|
||||
cleanups.push(() => watch.unsubscribe())
|
||||
}
|
||||
|
||||
it('hosts the narrow stream in the watcher child, not in-process', async () => {
|
||||
installSubscribeMock()
|
||||
const commonDir = await makeCommonDir(true)
|
||||
const received: WorktreeBasePollEvent[][] = []
|
||||
await startWatch(commonDir, received)
|
||||
|
||||
expect(subscribeMock).toHaveBeenCalledTimes(1)
|
||||
const [dir, , opts] = subscribeMock.mock.calls[0]
|
||||
expect(dir).toBe(join(commonDir, 'worktrees'))
|
||||
expect(opts).toEqual({})
|
||||
|
||||
const entryPath = join(commonDir, 'worktrees', 'wt-a')
|
||||
childSubscriptions[0].callback(null, [{ type: 'create', path: entryPath }])
|
||||
expect(received.flat()).toContainEqual({ type: 'create', path: entryPath })
|
||||
})
|
||||
|
||||
it('tears down and re-arms when the watched root is deleted', async () => {
|
||||
installSubscribeMock()
|
||||
const commonDir = await makeCommonDir(true)
|
||||
const worktreesDir = join(commonDir, 'worktrees')
|
||||
const received: WorktreeBasePollEvent[][] = []
|
||||
await startWatch(commonDir, received)
|
||||
|
||||
await rm(worktreesDir, { recursive: true, force: true })
|
||||
childSubscriptions[0].callback(null, [{ type: 'delete', path: worktreesDir }])
|
||||
await vi.waitFor(() => {
|
||||
expect(childSubscriptions[0].unsubscribe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(received.flat()).toContainEqual({ type: 'delete', path: worktreesDir })
|
||||
|
||||
// The existence poll re-subscribes once a new first worktree recreates it.
|
||||
await mkdir(worktreesDir)
|
||||
await vi.waitFor(() => {
|
||||
expect(subscribeMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
expect(received.flat()).toContainEqual({ type: 'create', path: worktreesDir })
|
||||
})
|
||||
|
||||
it('tears down and re-arms on watcher errors', async () => {
|
||||
installSubscribeMock()
|
||||
const commonDir = await makeCommonDir(true)
|
||||
const worktreesDir = join(commonDir, 'worktrees')
|
||||
const received: WorktreeBasePollEvent[][] = []
|
||||
await startWatch(commonDir, received)
|
||||
|
||||
childSubscriptions[0].callback(new Error('watcher child reported failure'), [])
|
||||
await vi.waitFor(() => {
|
||||
expect(childSubscriptions[0].unsubscribe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
// The error is surfaced as a structural change so worktrees re-sync.
|
||||
expect(received.flat()).toContainEqual({ type: 'update', path: worktreesDir })
|
||||
|
||||
// The dir still exists, so the existence poll re-subscribes.
|
||||
await vi.waitFor(() => {
|
||||
expect(subscribeMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
const receivedAfterRearm = received.length
|
||||
childSubscriptions[0].callback(new Error('late error from replaced watcher'), [])
|
||||
childSubscriptions[0].callback(null, [
|
||||
{ type: 'create', path: join(worktreesDir, 'late-old-event') }
|
||||
])
|
||||
childSubscriptions[0].hooks.onInterruption?.()
|
||||
|
||||
// A replaced watch cannot tear down its successor or report stale events.
|
||||
expect(received).toHaveLength(receivedAfterRearm)
|
||||
expect(childSubscriptions[1].unsubscribe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a structural change after a watcher-child interruption', async () => {
|
||||
installSubscribeMock()
|
||||
const commonDir = await makeCommonDir(true)
|
||||
const worktreesDir = join(commonDir, 'worktrees')
|
||||
const received: WorktreeBasePollEvent[][] = []
|
||||
await startWatch(commonDir, received)
|
||||
|
||||
childSubscriptions[0].hooks.onInterruption?.()
|
||||
expect(received.flat()).toContainEqual({ type: 'update', path: worktreesDir })
|
||||
// The supervisor resubscribed the same record; no teardown should happen.
|
||||
expect(childSubscriptions[0].unsubscribe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('arms via existence polling when the worktrees dir appears later', async () => {
|
||||
installSubscribeMock()
|
||||
const commonDir = await makeCommonDir(false)
|
||||
const received: WorktreeBasePollEvent[][] = []
|
||||
await startWatch(commonDir, received)
|
||||
expect(subscribeMock).not.toHaveBeenCalled()
|
||||
|
||||
await mkdir(join(commonDir, 'worktrees'))
|
||||
await vi.waitFor(() => {
|
||||
expect(subscribeMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('stops forwarding events and unsubscribes the child on dispose', async () => {
|
||||
installSubscribeMock()
|
||||
const commonDir = await makeCommonDir(true)
|
||||
const received: WorktreeBasePollEvent[][] = []
|
||||
const watch = await startGitCommonWatch(
|
||||
makeTarget(commonDir),
|
||||
(events) => received.push(events),
|
||||
POLL_MS,
|
||||
'darwin'
|
||||
)
|
||||
await watch.unsubscribe()
|
||||
expect(childSubscriptions[0].unsubscribe).toHaveBeenCalledTimes(1)
|
||||
|
||||
received.length = 0
|
||||
childSubscriptions[0].callback(null, [
|
||||
{ type: 'create', path: join(commonDir, 'worktrees', 'late') }
|
||||
])
|
||||
expect(received).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { subscribeViaWatcherProcess } from './parcel-watcher-process'
|
||||
import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter'
|
||||
import type {
|
||||
WorktreeBasePollEvent,
|
||||
@@ -20,6 +21,10 @@ import {
|
||||
// included). Other platforms: dir-listing poll (no fseventsd to protect, and
|
||||
// on Windows an open directory handle on `worktrees/` could interfere with
|
||||
// `git worktree prune` removing it).
|
||||
// The native stream is hosted in the crash-isolated watcher child, never the
|
||||
// Electron main process: watcher.node teardown races heap-corrupt the hosting
|
||||
// process when unsubscribe overlaps in-flight callbacks (issue #8732), and
|
||||
// root deletion via `git worktree prune` makes that overlap routine here.
|
||||
|
||||
// Why: branch switches and commits made in the primary checkout rewrite these
|
||||
// top-level files (linked-worktree equivalents live under `worktrees/`).
|
||||
@@ -157,12 +162,14 @@ async function startGitCommonNarrowWatch(
|
||||
return false
|
||||
}
|
||||
let errored = false
|
||||
let active = true
|
||||
// Why: parcel tears its native stream down when the watched root is
|
||||
// deleted (e.g. `git worktree prune` removing an empty worktrees dir) —
|
||||
// sometimes surfaced as an error, sometimes as a delete event for the
|
||||
// root. Either way: notify, drop the dead stream, and let the existence
|
||||
// poll re-arm when a future worktree add recreates the dir.
|
||||
const teardownAndRearm = (): void => {
|
||||
active = false
|
||||
errored = true
|
||||
const current = subscription
|
||||
subscription = null
|
||||
@@ -172,26 +179,38 @@ async function startGitCommonNarrowWatch(
|
||||
armExistencePoll()
|
||||
}
|
||||
try {
|
||||
const watcher = await import('@parcel/watcher')
|
||||
const sub = await watcher.subscribe(worktreesDir, (error, events) => {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
if (error) {
|
||||
onEvents([{ type: 'update', path: worktreesDir }])
|
||||
teardownAndRearm()
|
||||
return
|
||||
}
|
||||
if (events.length > 0) {
|
||||
const rootGone = events.some(
|
||||
(event) => event.type === 'delete' && event.path === worktreesDir
|
||||
)
|
||||
onEvents(events.map((event) => ({ type: event.type, path: event.path })))
|
||||
if (rootGone) {
|
||||
const sub = await subscribeViaWatcherProcess(
|
||||
worktreesDir,
|
||||
(error, events) => {
|
||||
if (disposed || !active) {
|
||||
return
|
||||
}
|
||||
if (error) {
|
||||
onEvents([{ type: 'update', path: worktreesDir }])
|
||||
teardownAndRearm()
|
||||
return
|
||||
}
|
||||
if (events.length > 0) {
|
||||
const rootGone = events.some(
|
||||
(event) => event.type === 'delete' && event.path === worktreesDir
|
||||
)
|
||||
onEvents(events.map((event) => ({ type: event.type, path: event.path })))
|
||||
if (rootGone) {
|
||||
teardownAndRearm()
|
||||
}
|
||||
}
|
||||
},
|
||||
{},
|
||||
{
|
||||
// Why: a watcher-child crash drops events during the automatic
|
||||
// resubscribe gap; report a structural change so worktrees re-sync.
|
||||
onInterruption: () => {
|
||||
if (!disposed && active) {
|
||||
onEvents([{ type: 'update', path: worktreesDir }])
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
if (disposed || errored) {
|
||||
void sub.unsubscribe().catch(() => {})
|
||||
return !errored
|
||||
|
||||
@@ -690,6 +690,31 @@ describe('Store', () => {
|
||||
expect(store.getSettings().minimizeToTrayOnClose).toBe(false)
|
||||
})
|
||||
|
||||
it('persists native chat session options with per-model isolation', async () => {
|
||||
const store = await createStore()
|
||||
store.updateSettings({
|
||||
nativeChatSessionOptions: {
|
||||
claude: {
|
||||
model: 'opus',
|
||||
valuesByModel: {
|
||||
opus: { effort: 'xhigh', fastMode: true },
|
||||
sonnet: { effort: 'medium' }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
store.flush()
|
||||
|
||||
const reloaded = await createStore()
|
||||
expect(reloaded.getSettings().nativeChatSessionOptions?.claude).toEqual({
|
||||
model: 'opus',
|
||||
valuesByModel: {
|
||||
opus: { effort: 'xhigh', fastMode: true },
|
||||
sonnet: { effort: 'medium' }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('coerces non-boolean minimizeToTrayOnClose payloads to a strict boolean', async () => {
|
||||
const store = await createStore()
|
||||
// Why: a renderer-supplied non-bool must never persist as a truthy non-bool
|
||||
@@ -2483,7 +2508,7 @@ describe('Store', () => {
|
||||
})
|
||||
expect(persisted.settings.sourceControlAi.actions.branchName).toEqual({
|
||||
agentId: 'claude',
|
||||
commandInputTemplate: '{basePrompt}\n\nRollback commit prompt'
|
||||
commandInputTemplate: 'Rollback commit prompt\n\n{basePrompt}'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -936,7 +936,7 @@ describe('LocalPtyProvider', () => {
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'C:\\Program Files\\Git\\bin\\bash.exe',
|
||||
['--login', '-i'],
|
||||
['-c', 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i'],
|
||||
expect.objectContaining({
|
||||
cwd: 'C:\\Users\\jin\\repo',
|
||||
env: expect.objectContaining({
|
||||
|
||||
@@ -196,14 +196,27 @@ describe('resolveWindowsShellLaunchArgs', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('starts Git Bash as an interactive login shell without changing cwd', () => {
|
||||
it('starts Git Bash as an interactive login shell with UTF-8 console setup', () => {
|
||||
const result = resolveWindowsShellLaunchArgs(
|
||||
'C:\\Program Files\\Git\\bin\\bash.exe',
|
||||
'C:\\Users\\alice\\code',
|
||||
'C:\\Users\\alice'
|
||||
)
|
||||
|
||||
expect(result.shellArgs).toEqual(['--login', '-i'])
|
||||
// Why: Git Bash inherits the ConPTY OEM code page (CP437), so a byte-writing
|
||||
// UTF-8 TUI mojibakes unless the console is switched to UTF-8 before it runs.
|
||||
// Assert the contract behaviorally: chcp.com 65001 must precede the exec'd
|
||||
// interactive login shell (which preserves `--login -i` and the shell's
|
||||
// foreground identity), rather than pinning the exact command string.
|
||||
expect(result.shellArgs[0]).toBe('-c')
|
||||
const bashCommand = result.shellArgs[1]
|
||||
expect(bashCommand).toContain('chcp.com 65001')
|
||||
expect(bashCommand).toMatch(/exec "\$BASH" --login -i$/)
|
||||
// The UTF-8 switch must run before the login shell is exec'd.
|
||||
expect(bashCommand.indexOf('chcp.com')).toBeLessThan(bashCommand.indexOf('exec'))
|
||||
// Must stay fail-open: `;` (not `&&`) so a missing chcp.com can't abort the
|
||||
// exec and kill the terminal on startup.
|
||||
expect(bashCommand).not.toContain('&&')
|
||||
expect(result.effectiveCwd).toBe('C:\\Users\\alice\\code')
|
||||
expect(result.validationCwd).toBe('C:\\Users\\alice\\code')
|
||||
})
|
||||
|
||||
@@ -16,6 +16,13 @@ const CMD_EXE_COMMAND_LINE_MAX_CHARS = 8191
|
||||
const STARTUP_COMMAND_TEXT_MAX_CHARS = 6000
|
||||
const POWERSHELL_ENCODED_COMMAND_ARG_MAX_CHARS = 28_000
|
||||
const CMD_UTF8_SETUP_COMMAND = 'chcp 65001 > nul'
|
||||
// Why: Git for Windows' bash inherits the ConPTY console's OEM code page
|
||||
// (CP437), so a TUI that writes UTF-8 bytes straight to the console — agents
|
||||
// like Claude Code use WriteFile, not WriteConsoleW — renders as mojibake
|
||||
// (`❯` -> `Γ¥»`). Switch the console to UTF-8, then exec the normal interactive
|
||||
// login shell; cmd.exe and PowerShell already do the equivalent. The `;` (not
|
||||
// `&&`) keeps startup working even if chcp.com is missing.
|
||||
const GIT_BASH_UTF8_LOGIN_COMMAND = 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i'
|
||||
|
||||
/** Result of resolving a Windows shell to its launch args + effective cwd.
|
||||
*
|
||||
@@ -176,7 +183,7 @@ export function resolveWindowsShellLaunchArgs(
|
||||
|
||||
if (isWindowsGitBashShellPath(shellPath)) {
|
||||
return {
|
||||
shellArgs: ['--login', '-i'],
|
||||
shellArgs: ['-c', GIT_BASH_UTF8_LOGIN_COMMAND],
|
||||
effectiveCwd: nativeCwd,
|
||||
validationCwd: nativeCwd
|
||||
}
|
||||
|
||||
@@ -252,6 +252,8 @@ describe('RuntimeFileCommands', () => {
|
||||
it('opens text files through the renderer host (inheriting active runtime env)', async () => {
|
||||
const openFile = vi.fn()
|
||||
const { commands } = createRuntimeFileCommands({ openFile })
|
||||
resolveAuthorizedPathMock.mockResolvedValue('/repo/docs/readme.md')
|
||||
statMock.mockResolvedValue({ isDirectory: () => false })
|
||||
|
||||
const result = await commands.openMobileFile('id:wt-1', 'docs/readme.md')
|
||||
|
||||
@@ -272,6 +274,8 @@ describe('RuntimeFileCommands', () => {
|
||||
it('opens previewable images through the renderer host as an image tab', async () => {
|
||||
const openFile = vi.fn()
|
||||
const { commands } = createRuntimeFileCommands({ openFile })
|
||||
resolveAuthorizedPathMock.mockResolvedValue('/repo/assets/logo.png')
|
||||
statMock.mockResolvedValue({ isDirectory: () => false })
|
||||
|
||||
const result = await commands.openMobileFile('id:wt-1', 'assets/logo.png')
|
||||
|
||||
@@ -296,6 +300,7 @@ describe('RuntimeFileCommands', () => {
|
||||
const result = await commands.openMobileFile('id:wt-1', 'dist/bundle.zip')
|
||||
|
||||
expect(openFile).not.toHaveBeenCalled()
|
||||
expect(statMock).not.toHaveBeenCalled()
|
||||
expect(result).toEqual({
|
||||
worktree: 'wt-1',
|
||||
relativePath: 'dist/bundle.zip',
|
||||
@@ -304,6 +309,43 @@ describe('RuntimeFileCommands', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects missing local files without creating an editor tab', async () => {
|
||||
const openFile = vi.fn()
|
||||
const { commands } = createRuntimeFileCommands({ openFile })
|
||||
resolveAuthorizedPathMock.mockResolvedValue('/repo/docs/missing.md')
|
||||
statMock.mockRejectedValue(enoent())
|
||||
|
||||
await expect(commands.openMobileFile('id:wt-1', 'docs/missing.md')).rejects.toThrow(
|
||||
"ENOENT: no such file or directory, open '/repo/docs/missing.md'"
|
||||
)
|
||||
expect(openFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects missing remote files without creating an editor tab', async () => {
|
||||
const openFile = vi.fn()
|
||||
const resolveRuntimeFileTarget = vi.fn(async () => ({
|
||||
worktree: {
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1',
|
||||
path: '/remote/repo'
|
||||
},
|
||||
connectionId: 'ssh-1'
|
||||
}))
|
||||
const { commands } = createRuntimeFileCommands({
|
||||
openFile,
|
||||
path: '/remote/repo',
|
||||
resolveRuntimeFileTarget
|
||||
})
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue({
|
||||
stat: vi.fn().mockRejectedValue(new Error('ENOENT: no such file or directory'))
|
||||
} as never)
|
||||
|
||||
await expect(commands.openMobileFile('id:wt-1', 'docs/missing.md')).rejects.toThrow(
|
||||
"ENOENT: no such file or directory, open '/remote/repo/docs/missing.md'"
|
||||
)
|
||||
expect(openFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not follow symlinks when reading runtime-local file explorer dirs', async () => {
|
||||
const { commands } = createRuntimeFileCommands()
|
||||
resolveAuthorizedPathMock.mockResolvedValue('/repo')
|
||||
|
||||
@@ -498,7 +498,7 @@ export class RuntimeFileCommands {
|
||||
worktreeSelector: string,
|
||||
relativePath: string
|
||||
): Promise<RuntimeFileOpenResult> {
|
||||
const { worktree } = await this.host.resolveRuntimeFileTarget(worktreeSelector)
|
||||
const { worktree, connectionId } = await this.host.resolveRuntimeFileTarget(worktreeSelector)
|
||||
if (!isSafeMobileRelativePath(relativePath)) {
|
||||
throw new Error('invalid_relative_path')
|
||||
}
|
||||
@@ -515,6 +515,9 @@ export class RuntimeFileCommands {
|
||||
return { worktree: worktree.id, relativePath, kind, opened: false }
|
||||
}
|
||||
const filePath = joinWorktreeRelativePath(worktree.path, relativePath)
|
||||
// Why: CLI/agents treat opened:true as success. Stat first so missing paths
|
||||
// fail the RPC instead of creating a ghost editor tab that only errors on read.
|
||||
await this.assertMobileOpenTargetExists(filePath, connectionId)
|
||||
// Why: the service's internal runtimeId is not a registered runtime env selector
|
||||
// (those live in orca-environments.json). Passing it caused Unknown environment
|
||||
// errors on content load for CLI-initiated opens (via files.open from orca cli
|
||||
@@ -525,6 +528,25 @@ export class RuntimeFileCommands {
|
||||
return { worktree: worktree.id, relativePath, kind, opened: true }
|
||||
}
|
||||
|
||||
private async assertMobileOpenTargetExists(
|
||||
filePath: string,
|
||||
connectionId?: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
await (connectionId
|
||||
? this.statRemoteTerminalPath(filePath, connectionId)
|
||||
: stat(await resolveAuthorizedPath(filePath, this.host.requireStore())))
|
||||
} catch (error) {
|
||||
if (
|
||||
isENOENT(error) ||
|
||||
(connectionId && RuntimeFileCommands.isRemoteNotFoundErrorMessage(error))
|
||||
) {
|
||||
throw new Error(`ENOENT: no such file or directory, open '${filePath}'`)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async openMobileDiff(
|
||||
worktreeSelector: string,
|
||||
relativePath: string,
|
||||
|
||||
@@ -19722,6 +19722,134 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(getSession().terminalLayoutsByTabId['host-tab']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('durably tears down a runtime-owned SSH headless tab when renderer cleanup fails', async () => {
|
||||
// #8958 regression: the acknowledged renderer relay cannot see headless tabs;
|
||||
// its advisory fallback must not prevent authoritative teardown or flushing.
|
||||
const persistedPtyId = 'ssh:ssh-1@@relay-pty'
|
||||
const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(
|
||||
makeWorkspaceSessionWithHeadlessTerminal({
|
||||
tabsByWorktree: {
|
||||
[TEST_WORKTREE_ID]: [
|
||||
{
|
||||
id: 'host-tab',
|
||||
ptyId: persistedPtyId,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'Remote Terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: persistedPtyId })
|
||||
}
|
||||
})
|
||||
)
|
||||
const kill = vi.fn(() => true)
|
||||
const flushOrThrow = vi.fn()
|
||||
const rendererError = new Error('renderer unavailable')
|
||||
const closeTerminal = vi.fn(() => {
|
||||
throw rendererError
|
||||
})
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const closeTerminalTab = vi.fn(async () => {})
|
||||
const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow } as never)
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill,
|
||||
getForegroundProcess: async () => null,
|
||||
listProcesses: async () => [{ id: persistedPtyId, cwd: TEST_WORKTREE_PATH, title: 'Remote' }]
|
||||
})
|
||||
runtime.setNotifier({ closeTerminal, closeTerminalTab } as never)
|
||||
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
|
||||
|
||||
await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab')
|
||||
|
||||
expect(closeTerminalTab).not.toHaveBeenCalled()
|
||||
expect(kill).toHaveBeenCalledWith(persistedPtyId)
|
||||
expect(closeTerminal).toHaveBeenCalledWith('host-tab')
|
||||
expect(flushOrThrow).toHaveBeenCalledTimes(1)
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[runtime] failed to notify renderer after headless terminal close',
|
||||
{ parentTabId: 'host-tab', error: rendererError }
|
||||
)
|
||||
expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toEqual([])
|
||||
expect(getSession().terminalLayoutsByTabId['host-tab']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps the renderer close transaction for an adopted runtime-owned tab', async () => {
|
||||
// The renderer pin state can be newer than the debounced workspace session.
|
||||
// Once adopted, its live close guard must win over stale persisted metadata.
|
||||
const servePtyId = 'serve-adopted-1'
|
||||
const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(
|
||||
makeWorkspaceSessionWithHeadlessTerminal({
|
||||
tabsByWorktree: {
|
||||
[TEST_WORKTREE_ID]: [
|
||||
{
|
||||
id: 'host-tab',
|
||||
ptyId: servePtyId,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'Adopted Terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1,
|
||||
isPinned: false
|
||||
}
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: servePtyId })
|
||||
}
|
||||
})
|
||||
)
|
||||
const kill = vi.fn(() => true)
|
||||
const closeTerminal = vi.fn()
|
||||
const closeTerminalTab = vi.fn(async () => {
|
||||
throw new Error('terminal_tab_pinned')
|
||||
})
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill,
|
||||
getForegroundProcess: async () => null,
|
||||
listProcesses: async () => [{ id: servePtyId, cwd: TEST_WORKTREE_PATH, title: 'Adopted' }]
|
||||
})
|
||||
runtime.setNotifier({ closeTerminal, closeTerminalTab } as never)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'host-tab',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'Adopted Terminal',
|
||||
activeLeafId: HEADLESS_LEAF_ID,
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'host-tab',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId: HEADLESS_LEAF_ID,
|
||||
paneRuntimeId: 1,
|
||||
ptyId: servePtyId
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
await expect(
|
||||
runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab')
|
||||
).rejects.toThrow('terminal_tab_pinned')
|
||||
|
||||
expect(closeTerminalTab).toHaveBeenCalledWith('host-tab')
|
||||
expect(closeTerminal).not.toHaveBeenCalled()
|
||||
expect(kill).not.toHaveBeenCalled()
|
||||
expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toHaveLength(1)
|
||||
expect(getSession().terminalLayoutsByTabId['host-tab']).toBeDefined()
|
||||
})
|
||||
|
||||
it('materializes hydrated pending headless terminals with the persisted session identity', async () => {
|
||||
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(
|
||||
makeWorkspaceSessionWithHeadlessTerminal()
|
||||
|
||||
@@ -4685,26 +4685,33 @@ export class OrcaRuntimeService {
|
||||
(candidate) => candidate.type === 'terminal' && candidate.parentTabId === tab.parentTabId
|
||||
).length
|
||||
const closingWholeParent = tab.id !== tabId || parentLeafCount <= 1
|
||||
// Why: a runtime-owned headless tab is absent from renderer state, so the
|
||||
// closeTerminalTab relay below would ack success without killing its PTY,
|
||||
// and syncMobileSessionTabs would republish the "closed" tab. Only bypass
|
||||
// the relay when no renderer owns the parent: an adopted tab needs the
|
||||
// renderer's live pin guard and durable close transaction.
|
||||
if (closingWholeParent && !this.tabs.has(tab.parentTabId)) {
|
||||
this.closeHeadlessMobileTerminalTab(worktreeId, snapshot!, tab)
|
||||
this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId)
|
||||
this.store?.flushOrThrow?.()
|
||||
return { closed: true }
|
||||
}
|
||||
if (closingWholeParent && this.notifier?.closeTerminalTab) {
|
||||
// Why: whole-tab close is a lifecycle transaction. The renderer reply
|
||||
// arrives only after canonical retirement and a forced session flush.
|
||||
await this.notifier.closeTerminalTab(tab.parentTabId)
|
||||
return { closed: true }
|
||||
}
|
||||
if (!this.notifier?.closeTerminal) {
|
||||
// Why: notifier implementations without the acknowledged relay may expose
|
||||
// only raw pane close. Runtime-owned parents still need de-persist + kill.
|
||||
if (closingWholeParent && this.isRuntimeOwnedHeadlessMobileTab(worktreeId, tab)) {
|
||||
this.closeHeadlessMobileTerminalTab(worktreeId, snapshot!, tab)
|
||||
this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId)
|
||||
this.store?.flushOrThrow?.()
|
||||
return { closed: true }
|
||||
}
|
||||
// Why: a runtime-owned headless tab whose whole parent is being closed must
|
||||
// be torn down authoritatively even with a renderer attached — kill the
|
||||
// PTY, drop the persisted binding, and prune+emit — or syncMobileSessionTabs
|
||||
// keeps republishing the "closed" tab with a live PTY. Best-effort notify the
|
||||
// renderer too so any adopted pane closes (no dead pane). A single split leaf
|
||||
// (exact id, multi-leaf parent) keeps the per-leaf path so siblings survive.
|
||||
if (closingWholeParent && this.isRuntimeOwnedHeadlessMobileTab(worktreeId, tab)) {
|
||||
if (!this.notifier?.closeTerminal) {
|
||||
this.closeHeadlessMobileTerminalTab(worktreeId, snapshot!, tab)
|
||||
this.notifier?.closeTerminal(tab.parentTabId)
|
||||
this.store?.flushOrThrow?.()
|
||||
return { closed: true }
|
||||
}
|
||||
@@ -4732,6 +4739,19 @@ export class OrcaRuntimeService {
|
||||
return { closed: true }
|
||||
}
|
||||
|
||||
private notifyRendererOfHeadlessTerminalClose(parentTabId: string): void {
|
||||
// Why: this relay is advisory after main owns teardown; renderer failure must
|
||||
// not prevent the authoritative session flush or turn the close into failure.
|
||||
try {
|
||||
this.notifier?.closeTerminal(parentTabId)
|
||||
} catch (error) {
|
||||
console.warn('[runtime] failed to notify renderer after headless terminal close', {
|
||||
parentTabId,
|
||||
error
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private async closeHeadlessMobileBrowserTab(
|
||||
worktreeId: string,
|
||||
snapshot: RuntimeMobileSessionTabsSnapshot,
|
||||
|
||||
@@ -472,6 +472,43 @@ describe('WebSocketTransport', () => {
|
||||
expect(transport.resolvedPort).toBe(fallbackPort)
|
||||
})
|
||||
|
||||
it('binds the preferred port first when preferPinnedPort is set and both are free', async () => {
|
||||
// Why: issue #8535 — `orca serve --port <P>` clients dial the pin. A
|
||||
// free but stale mobile-ws-fallback-port.json must not pre-empt it.
|
||||
const preferredPort = await reserveFreePort()
|
||||
const fallbackPort = await reserveFreePort()
|
||||
|
||||
const transport = new WebSocketTransport({
|
||||
host: '127.0.0.1',
|
||||
port: preferredPort,
|
||||
fallbackPort,
|
||||
preferPinnedPort: true
|
||||
})
|
||||
transports.push(transport)
|
||||
await transport.start()
|
||||
expect(transport.resolvedPort).toBe(preferredPort)
|
||||
})
|
||||
|
||||
it('falls back when preferPinnedPort is set but the preferred port is taken', async () => {
|
||||
// Why: explicit pins still degrade to the STA-1511 fallback on
|
||||
// EADDRINUSE so previously-paired mobile devices remain reachable.
|
||||
const preferredHolder = new WebSocketTransport({ host: '127.0.0.1', port: 0 })
|
||||
transports.push(preferredHolder)
|
||||
await preferredHolder.start()
|
||||
const preferredPort = preferredHolder.resolvedPort
|
||||
const fallbackPort = await reserveFreePort()
|
||||
|
||||
const transport = new WebSocketTransport({
|
||||
host: '127.0.0.1',
|
||||
port: preferredPort,
|
||||
fallbackPort,
|
||||
preferPinnedPort: true
|
||||
})
|
||||
transports.push(transport)
|
||||
await transport.start()
|
||||
expect(transport.resolvedPort).toBe(fallbackPort)
|
||||
})
|
||||
|
||||
it('binds the preferred port when the persisted fallback is taken', async () => {
|
||||
const fallbackHolder = new WebSocketTransport({ host: '127.0.0.1', port: 0 })
|
||||
transports.push(fallbackHolder)
|
||||
|
||||
@@ -54,6 +54,11 @@ export type WebSocketTransportOptions = {
|
||||
// the (now free) preferred port instead would strand those pairings
|
||||
// (STA-1511). Callers pass the previously assigned fallback port here.
|
||||
fallbackPort?: number
|
||||
// Why: `orca serve --port <P>` clients dial the pinned port. Prefer that port
|
||||
// first (fallback second) so a stale mobile-ws-fallback-port.json cannot
|
||||
// silently steal the pin (issue #8535). Default auto/desktop keeps
|
||||
// fallback-first for STA-1511 pairing stability.
|
||||
preferPinnedPort?: boolean
|
||||
}
|
||||
|
||||
export class WebSocketTransport implements RpcTransport {
|
||||
@@ -65,6 +70,7 @@ export class WebSocketTransport implements RpcTransport {
|
||||
private readonly preAuthTimeoutMs: number
|
||||
private readonly staticRoot: string | undefined
|
||||
private readonly fallbackPort: number | undefined
|
||||
private readonly preferPinnedPort: boolean
|
||||
private httpServer: HttpsServer | HttpServer | null = null
|
||||
private wss: WebSocketServer | null = null
|
||||
private heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
||||
@@ -89,7 +95,8 @@ export class WebSocketTransport implements RpcTransport {
|
||||
heartbeatIntervalMs,
|
||||
preAuthTimeoutMs,
|
||||
staticRoot,
|
||||
fallbackPort
|
||||
fallbackPort,
|
||||
preferPinnedPort
|
||||
}: WebSocketTransportOptions) {
|
||||
this.host = host
|
||||
this.port = port
|
||||
@@ -99,6 +106,7 @@ export class WebSocketTransport implements RpcTransport {
|
||||
this.preAuthTimeoutMs = preAuthTimeoutMs ?? PRE_AUTH_TIMEOUT_MS
|
||||
this.staticRoot = staticRoot
|
||||
this.fallbackPort = fallbackPort
|
||||
this.preferPinnedPort = preferPinnedPort === true
|
||||
}
|
||||
|
||||
onMessage(handler: WebSocketMessageHandler): void {
|
||||
@@ -150,10 +158,12 @@ export class WebSocketTransport implements RpcTransport {
|
||||
return
|
||||
}
|
||||
|
||||
// Why: a persisted fallback port is bound FIRST — devices paired while it
|
||||
// was active store ws://ip:<fallback> and would be permanently stranded if
|
||||
// a later launch grabbed the (now free) preferred port instead (STA-1511).
|
||||
// Without a persisted fallback the preferred port is tried first. On
|
||||
// Why: default order binds a persisted fallback FIRST — devices paired
|
||||
// while it was active store ws://ip:<fallback> and would be stranded if a
|
||||
// later launch grabbed the (now free) preferred port instead (STA-1511).
|
||||
// Explicit serve --port flips the order so the pinned port wins when free
|
||||
// (issue #8535); fallback remains a secondary candidate for EADDRINUSE.
|
||||
// Without a persisted fallback only the preferred port is tried. On
|
||||
// EADDRINUSE each candidate falls through to the next, ending at port 0
|
||||
// (OS-assigned) so mobile pairing still works when everything is taken.
|
||||
// The QR code reads resolvedPort after start, so it always advertises the
|
||||
@@ -163,7 +173,11 @@ export class WebSocketTransport implements RpcTransport {
|
||||
? this.fallbackPort
|
||||
: undefined
|
||||
const candidatePorts =
|
||||
persistedFallbackPort !== undefined ? [persistedFallbackPort, this.port] : [this.port]
|
||||
persistedFallbackPort === undefined
|
||||
? [this.port]
|
||||
: this.preferPinnedPort
|
||||
? [this.port, persistedFallbackPort]
|
||||
: [persistedFallbackPort, this.port]
|
||||
for (const port of candidatePorts) {
|
||||
try {
|
||||
await this.tryListen(port)
|
||||
|
||||
@@ -53,6 +53,10 @@ type OrcaRuntimeRpcServerOptions = {
|
||||
platform?: NodeJS.Platform
|
||||
enableWebSocket?: boolean
|
||||
wsPort?: number
|
||||
// Why: true when the caller set an explicit port (e.g. `orca serve --port`).
|
||||
// Distinguishes that pin from the DEFAULT_WS_PORT default so transport bind
|
||||
// order can prefer the pin over a stale STA-1511 fallback (issue #8535).
|
||||
preferPinnedWsPort?: boolean
|
||||
webClientRoot?: string
|
||||
// Why: test-only overrides for the two time-bound constants below.
|
||||
// Production callers must not pass these — defaults are set by the design
|
||||
@@ -450,6 +454,7 @@ export class OrcaRuntimeRpcServer {
|
||||
private readonly platform: NodeJS.Platform
|
||||
private readonly enableWebSocket: boolean
|
||||
private readonly wsPort: number
|
||||
private readonly preferPinnedWsPort: boolean
|
||||
private readonly webClientRoot: string | undefined
|
||||
private readonly authToken = randomBytes(24).toString('hex')
|
||||
private readonly keepaliveIntervalMs: number
|
||||
@@ -482,6 +487,7 @@ export class OrcaRuntimeRpcServer {
|
||||
platform = process.platform,
|
||||
enableWebSocket = false,
|
||||
wsPort = DEFAULT_WS_PORT,
|
||||
preferPinnedWsPort = false,
|
||||
webClientRoot,
|
||||
keepaliveIntervalMs = KEEPALIVE_INTERVAL_MS,
|
||||
longPollCap = LONG_POLL_CAP
|
||||
@@ -493,6 +499,7 @@ export class OrcaRuntimeRpcServer {
|
||||
this.platform = platform
|
||||
this.enableWebSocket = enableWebSocket
|
||||
this.wsPort = wsPort
|
||||
this.preferPinnedWsPort = preferPinnedWsPort
|
||||
this.webClientRoot = webClientRoot
|
||||
this.keepaliveIntervalMs = keepaliveIntervalMs
|
||||
this.longPollCap = longPollCap
|
||||
@@ -863,10 +870,11 @@ export class OrcaRuntimeRpcServer {
|
||||
staticRoot: this.webClientRoot,
|
||||
// Why: keep the fallback port stable across restarts so paired
|
||||
// devices' stored endpoints stay valid (STA-1511) — the transport
|
||||
// binds a persisted fallback before the preferred port. wsPort 0
|
||||
// means the caller explicitly wants a random port (E2E) — don't
|
||||
// pin it.
|
||||
...(this.wsPort !== 0 ? { fallbackPort: readWsFallbackPort(this.userDataPath) } : {})
|
||||
// binds a persisted fallback before the preferred port unless the
|
||||
// caller explicitly pinned a port (serve --port). wsPort 0 means
|
||||
// the caller wants a random port (E2E) — don't pin it.
|
||||
...(this.wsPort !== 0 ? { fallbackPort: readWsFallbackPort(this.userDataPath) } : {}),
|
||||
...(this.preferPinnedWsPort ? { preferPinnedPort: true } : {})
|
||||
})
|
||||
const mobileSocketWiring = new MobileSocketWiring({
|
||||
deviceRegistry: this.deviceRegistry,
|
||||
|
||||
@@ -1853,7 +1853,7 @@ describe('generateBranchNameFromContext', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('includes the branch-name custom prompt in the generated prompt', async () => {
|
||||
it('keeps branch-name guidance first without dropping the output contract', async () => {
|
||||
let prompt = ''
|
||||
await generateBranchNameFromContext(
|
||||
{ firstPrompt: 'Fix login flow' },
|
||||
@@ -1861,7 +1861,8 @@ describe('generateBranchNameFromContext', () => {
|
||||
agentId: 'custom',
|
||||
model: '',
|
||||
customAgentCommand: 'agent',
|
||||
customPrompt: 'Prefer auth terminology.'
|
||||
customPrompt: 'Prefer auth terminology.',
|
||||
commandInputTemplate: 'Prefer auth terminology.\n\n{basePrompt}'
|
||||
},
|
||||
{
|
||||
kind: 'remote',
|
||||
@@ -1879,8 +1880,11 @@ describe('generateBranchNameFromContext', () => {
|
||||
}
|
||||
)
|
||||
|
||||
expect(prompt).toContain('Additional user prompt:')
|
||||
expect(prompt.startsWith('Prefer auth terminology.')).toBe(true)
|
||||
expect(prompt).toContain('Prefer auth terminology.')
|
||||
expect(prompt).not.toContain('Additional user prompt:')
|
||||
expect(prompt).toContain('Generate a short git branch name')
|
||||
expect(prompt).toContain('Output ONLY the branch name on a single line')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ describe('git blob readers', () => {
|
||||
|
||||
const result = await readBlobAtIndex(gitBuffer, '/repo', 'large.log')
|
||||
|
||||
expect(result).toEqual({ content: '', isBinary: true })
|
||||
// Why: overflow is size-capped content, not a staged deletion (missing: false).
|
||||
expect(result).toEqual({ content: '', isBinary: true, missing: false })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -52,17 +52,19 @@ export async function readBlobAtIndex(
|
||||
gitBuffer: GitBufferExec,
|
||||
cwd: string,
|
||||
filePath: string
|
||||
): Promise<{ content: string; isBinary: boolean }> {
|
||||
): Promise<{ content: string; isBinary: boolean; missing: boolean }> {
|
||||
// Why: Git's `:<path>` syntax expects forward slashes even on Windows.
|
||||
const gitPath = filePath.replace(/\\/g, '/')
|
||||
try {
|
||||
const buf = await gitBuffer(['show', '--end-of-options', `:${gitPath}`], cwd)
|
||||
return bufferToBlob(buf, filePath)
|
||||
return { ...bufferToBlob(buf, filePath), missing: false }
|
||||
} catch (error) {
|
||||
if (isGitBufferOverflowError(error)) {
|
||||
return { content: '', isBinary: true }
|
||||
return { content: '', isBinary: true, missing: false }
|
||||
}
|
||||
return { content: '', isBinary: false }
|
||||
// Why: a non-overflow failure means the path is absent from the index (a
|
||||
// staged deletion), distinct from the size-capped case handled above.
|
||||
return { content: '', isBinary: false, missing: true }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +93,7 @@ export async function computeDiff(
|
||||
let modifiedContent = ''
|
||||
let originalIsBinary = false
|
||||
let modifiedIsBinary = false
|
||||
let modifiedDeleted = false
|
||||
|
||||
try {
|
||||
if (staged) {
|
||||
@@ -101,6 +104,7 @@ export async function computeDiff(
|
||||
const right = await readBlobAtIndex(git, worktreePath, filePath)
|
||||
modifiedContent = right.content
|
||||
modifiedIsBinary = right.isBinary
|
||||
modifiedDeleted = right.missing
|
||||
} else {
|
||||
const left = compareAgainstHead
|
||||
? await readBlobAtOid(git, worktreePath, 'HEAD', filePath)
|
||||
@@ -111,18 +115,25 @@ export async function computeDiff(
|
||||
const right = await readWorkingDiffFile(path.join(worktreePath, filePath))
|
||||
modifiedContent = right.content
|
||||
modifiedIsBinary = right.isBinary
|
||||
modifiedDeleted = right.missing
|
||||
}
|
||||
} catch {
|
||||
// Fallback to empty
|
||||
}
|
||||
|
||||
return buildDiffResult(
|
||||
const result = buildDiffResult(
|
||||
originalContent,
|
||||
modifiedContent,
|
||||
originalIsBinary,
|
||||
modifiedIsBinary,
|
||||
filePath
|
||||
)
|
||||
// Why: mark a proven deletion so previewers can fall back to the original bytes
|
||||
// without mistaking a read failure's empty modified side for a deletion.
|
||||
if (result.kind === 'binary' && modifiedDeleted) {
|
||||
return { ...result, modifiedDeleted: true }
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ─── Branch compare ──────────────────────────────────────────────────
|
||||
|
||||
@@ -21,7 +21,8 @@ describe('readWorkingDiffFile', () => {
|
||||
|
||||
await expect(readWorkingDiffFile(filePath)).resolves.toEqual({
|
||||
content: 'hello',
|
||||
isBinary: false
|
||||
isBinary: false,
|
||||
missing: false
|
||||
})
|
||||
})
|
||||
|
||||
@@ -32,7 +33,61 @@ describe('readWorkingDiffFile', () => {
|
||||
|
||||
await expect(readWorkingDiffFile(filePath)).resolves.toEqual({
|
||||
content: '',
|
||||
isBinary: true
|
||||
isBinary: true,
|
||||
missing: false
|
||||
})
|
||||
})
|
||||
|
||||
it('base64-encodes a previewable image by its extension', async () => {
|
||||
tmpDir = await mkdtemp(path.join(tmpdir(), 'relay-working-file-'))
|
||||
const filePath = path.join(tmpDir, 'icon.png')
|
||||
const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x01])
|
||||
await writeFile(filePath, pngBytes)
|
||||
|
||||
await expect(readWorkingDiffFile(filePath)).resolves.toEqual({
|
||||
content: pngBytes.toString('base64'),
|
||||
isBinary: true,
|
||||
missing: false
|
||||
})
|
||||
})
|
||||
|
||||
it('reports an absent working-tree file as a deletion', async () => {
|
||||
tmpDir = await mkdtemp(path.join(tmpdir(), 'relay-working-file-'))
|
||||
const filePath = path.join(tmpDir, 'deleted.png')
|
||||
|
||||
await expect(readWorkingDiffFile(filePath)).resolves.toEqual({
|
||||
content: '',
|
||||
isBinary: false,
|
||||
missing: true
|
||||
})
|
||||
})
|
||||
|
||||
it('returns base64 content for previewable image working-tree files', async () => {
|
||||
tmpDir = await mkdtemp(path.join(tmpdir(), 'relay-working-file-'))
|
||||
const filePath = path.join(tmpDir, 'image.png')
|
||||
// Why: PNG signature + trailing null byte forces the binary-detection heuristic to trip.
|
||||
const pngBuffer = Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
Buffer.from([0x00])
|
||||
])
|
||||
await writeFile(filePath, pngBuffer)
|
||||
|
||||
await expect(readWorkingDiffFile(filePath)).resolves.toEqual({
|
||||
content: pngBuffer.toString('base64'),
|
||||
isBinary: true,
|
||||
missing: false
|
||||
})
|
||||
})
|
||||
|
||||
it('returns empty content for non-previewable binary working-tree files', async () => {
|
||||
tmpDir = await mkdtemp(path.join(tmpdir(), 'relay-working-file-'))
|
||||
const filePath = path.join(tmpDir, 'archive.bin')
|
||||
await writeFile(filePath, Buffer.from([0x00, 0x01, 0x02, 0x00, 0x03]))
|
||||
|
||||
await expect(readWorkingDiffFile(filePath)).resolves.toEqual({
|
||||
content: '',
|
||||
isBinary: true,
|
||||
missing: false
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,19 +5,31 @@ const MAX_RELAY_DIFF_WORKING_FILE_BYTES = 10 * 1024 * 1024
|
||||
|
||||
export async function readWorkingDiffFile(
|
||||
absPath: string
|
||||
): Promise<{ content: string; isBinary: boolean }> {
|
||||
): Promise<{ content: string; isBinary: boolean; missing: boolean }> {
|
||||
let fileStat
|
||||
try {
|
||||
fileStat = await stat(absPath)
|
||||
} catch (error) {
|
||||
// Why: ENOENT means the working-tree file is genuinely gone (a deletion);
|
||||
// any other stat error is a read failure we must not mistake for one, since
|
||||
// callers fall back to the original bytes only for a proven deletion.
|
||||
const missing = (error as NodeJS.ErrnoException)?.code === 'ENOENT'
|
||||
return { content: '', isBinary: false, missing }
|
||||
}
|
||||
if (!fileStat.isFile()) {
|
||||
return { content: '', isBinary: false, missing: true }
|
||||
}
|
||||
if (fileStat.size > MAX_RELAY_DIFF_WORKING_FILE_BYTES) {
|
||||
// Why: mirror local git diff reads, which cap blob transfer at 10MB.
|
||||
return { content: '', isBinary: true, missing: false }
|
||||
}
|
||||
try {
|
||||
const fileStat = await stat(absPath)
|
||||
if (!fileStat.isFile()) {
|
||||
return { content: '', isBinary: false }
|
||||
}
|
||||
if (fileStat.size > MAX_RELAY_DIFF_WORKING_FILE_BYTES) {
|
||||
// Why: mirror local git diff reads, which cap blob transfer at 10MB.
|
||||
return { content: '', isBinary: true }
|
||||
}
|
||||
const buffer = await readFile(absPath)
|
||||
return bufferToBlob(buffer)
|
||||
// Why: bufferToBlob needs the path's extension to know an image is
|
||||
// previewable; omitting it made every relay-side binary diff empty.
|
||||
return { ...bufferToBlob(buffer, absPath), missing: false }
|
||||
} catch {
|
||||
return { content: '', isBinary: false }
|
||||
// Why: the file exists but could not be read — a read failure, not a deletion.
|
||||
return { content: '', isBinary: false, missing: false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
getZoomedImageLayoutSize
|
||||
} from './image-viewer-zoom'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { buildImageDataUri } from '../../../../shared/image-data-uri'
|
||||
|
||||
const FALLBACK_IMAGE_MIME_TYPE = 'image/png'
|
||||
|
||||
@@ -59,8 +60,8 @@ export default function ImageViewer({
|
||||
const isPdf = mimeType === 'application/pdf'
|
||||
const isIntrinsicLayout = layout === 'intrinsic'
|
||||
const previewSrc = useMemo(
|
||||
() => (cleanedContent && !isPdf ? `data:${mimeType};base64,${cleanedContent}` : null),
|
||||
[cleanedContent, isPdf, mimeType]
|
||||
() => buildImageDataUri(mimeType, cleanedContent),
|
||||
[cleanedContent, mimeType]
|
||||
)
|
||||
const imageError = previewSrc !== null && failedPreviewSrc === previewSrc
|
||||
const estimatedSize = useMemo(() => {
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
} from '../../../../shared/tui-agent-launch-defaults'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel'
|
||||
import { resolveNativeChatSessionOptionDefaults } from '../../../../shared/native-chat-session-option-defaults'
|
||||
import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache'
|
||||
|
||||
type FloatingTerminalWindowControlsProps = {
|
||||
maximized: boolean
|
||||
@@ -73,6 +75,10 @@ export function FloatingTerminalWindowControls({
|
||||
cmdOverrides: state.settings?.agentCmdOverrides ?? {},
|
||||
agentArgs: resolveTuiAgentLaunchArgs(defaultAgent, state.settings?.agentDefaultArgs),
|
||||
agentEnv: resolveTuiAgentLaunchEnv(defaultAgent, state.settings?.agentDefaultEnv),
|
||||
sessionOptions: resolveNativeChatSessionOptionDefaults(
|
||||
state.settings?.nativeChatSessionOptions,
|
||||
defaultAgent
|
||||
),
|
||||
platform: CLIENT_PLATFORM,
|
||||
allowEmptyPromptLaunch: true
|
||||
})
|
||||
@@ -87,6 +93,7 @@ export function FloatingTerminalWindowControls({
|
||||
return
|
||||
}
|
||||
const tab = createTab(FLOATING_TERMINAL_WORKTREE_ID, undefined, undefined, { activate: false })
|
||||
seedNativeChatAppliedSessionOptions(tab.id, defaultAgent, startupPlan.sessionOptions)
|
||||
state.queueTabStartupCommand(tab.id, {
|
||||
command: startupPlan.launchCommand,
|
||||
...(startupPlan.env ? { env: startupPlan.env } : {}),
|
||||
|
||||
@@ -1,21 +1,48 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { act, cleanup, render, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
SessionOptionDescriptor,
|
||||
SessionOptionsSurface
|
||||
} from '../../../../shared/native-chat-session-options'
|
||||
import { clearNativeChatSessionOptionCacheForTests } from './native-chat-session-option-cache'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
cancelPendingSends: vi.fn(),
|
||||
fieldProps: null as { onSend?: () => void; onStop?: () => void } | null,
|
||||
fieldProps: null as {
|
||||
onSend?: () => void
|
||||
onStop?: () => void
|
||||
sessionOptionsSurface?: SessionOptionsSurface | null
|
||||
sessionOptionsSnapshot?: SessionOptionDescriptor[]
|
||||
} | null,
|
||||
modelSwitchOutcome: 'applied' as 'applied' | 'rejected' | 'interaction-required' | 'unknown',
|
||||
confirmationObserver: null as {
|
||||
ready: Promise<void>
|
||||
result: Promise<'applied' | 'rejected' | 'interaction-required' | 'unknown'>
|
||||
arm: ReturnType<typeof vi.fn>
|
||||
startDetection: ReturnType<typeof vi.fn>
|
||||
dispose: ReturnType<typeof vi.fn>
|
||||
} | null,
|
||||
createClaudeModelSwitchConfirmationObserver: vi.fn(),
|
||||
getMainBufferSnapshot: vi.fn(),
|
||||
sendHandle: { cancel: vi.fn(), settleAfterMs: 500 },
|
||||
sendNativeChatMessage: vi.fn(),
|
||||
sendNativeChatMessageVerified: vi.fn(),
|
||||
trackPendingSend: vi.fn(),
|
||||
setDraft: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
useAppStore: (selector: (state: unknown) => unknown) =>
|
||||
selector({ dictationState: 'idle', settings: { voice: { enabled: false } } })
|
||||
}))
|
||||
vi.mock('../../store', () => {
|
||||
const state = {
|
||||
dictationState: 'idle',
|
||||
settings: { voice: { enabled: false }, nativeChatSessionOptions: {} },
|
||||
updateSettings: vi.fn()
|
||||
}
|
||||
const useAppStore = (selector: (value: typeof state) => unknown) => selector(state)
|
||||
useAppStore.getState = () => state
|
||||
return { useAppStore }
|
||||
})
|
||||
|
||||
vi.mock('@/runtime/runtime-terminal-inspection', () => ({
|
||||
isRemoteRuntimePtyId: () => false,
|
||||
@@ -26,15 +53,27 @@ vi.mock('@/lib/agent-paste-draft', () => ({
|
||||
}))
|
||||
vi.mock('./native-chat-runtime-send', () => ({
|
||||
sendNativeChatMessage: (...args: unknown[]) => mocks.sendNativeChatMessage(...args),
|
||||
sendNativeChatMessageVerified: (...args: unknown[]) =>
|
||||
mocks.sendNativeChatMessageVerified(...args),
|
||||
sendNativeChatMessageWithImageAttachments: vi.fn(),
|
||||
submitNativeChatPrompt: vi.fn()
|
||||
}))
|
||||
vi.mock('./native-chat-agent-commands', () => ({ getAgentSlashCommands: () => [] }))
|
||||
vi.mock('@/lib/native-chat-telemetry', () => ({ emitNativeChatMessageSent: vi.fn() }))
|
||||
vi.mock('./claude-model-switch-confirmation', () => ({
|
||||
createClaudeModelSwitchConfirmationObserver: (...args: unknown[]) =>
|
||||
mocks.createClaudeModelSwitchConfirmationObserver(...args)
|
||||
}))
|
||||
vi.mock('./native-chat-agent-commands', () => ({
|
||||
getAgentSlashCommands: () => []
|
||||
}))
|
||||
vi.mock('@/lib/native-chat-telemetry', () => ({
|
||||
emitNativeChatMessageSent: vi.fn()
|
||||
}))
|
||||
vi.mock('./use-native-chat-draft', () => ({
|
||||
useNativeChatDraft: () => ({ draft: 'hello', setDraft: mocks.setDraft })
|
||||
}))
|
||||
vi.mock('./native-chat-draft-cache', () => ({ readNativeChatDraftCache: () => '' }))
|
||||
vi.mock('./native-chat-draft-cache', () => ({
|
||||
readNativeChatDraftCache: () => ''
|
||||
}))
|
||||
vi.mock('./NativeChatComposerField', () => ({
|
||||
NativeChatComposerField: (props: { onSend?: () => void; onStop?: () => void }) => {
|
||||
mocks.fieldProps = props
|
||||
@@ -51,7 +90,10 @@ vi.mock('./use-native-chat-composer-attachments', () => ({
|
||||
})
|
||||
}))
|
||||
vi.mock('./use-native-chat-composer-paste', () => ({
|
||||
useNativeChatComposerPaste: () => ({ handlePaste: vi.fn(), pasteFromClipboard: vi.fn() })
|
||||
useNativeChatComposerPaste: () => ({
|
||||
handlePaste: vi.fn(),
|
||||
pasteFromClipboard: vi.fn()
|
||||
})
|
||||
}))
|
||||
vi.mock('./use-native-chat-external-attachments', () => ({
|
||||
useNativeChatExternalAttachments: () => ({
|
||||
@@ -59,7 +101,9 @@ vi.mock('./use-native-chat-external-attachments', () => ({
|
||||
resolveAttachmentOwner: vi.fn()
|
||||
})
|
||||
}))
|
||||
vi.mock('../dictation/dictation-control-events', () => ({ dispatchDictationControl: vi.fn() }))
|
||||
vi.mock('../dictation/dictation-control-events', () => ({
|
||||
dispatchDictationControl: vi.fn()
|
||||
}))
|
||||
vi.mock('./use-native-chat-composer-keydown', () => ({
|
||||
useNativeChatComposerKeyDown: () => vi.fn()
|
||||
}))
|
||||
@@ -75,11 +119,31 @@ import { NativeChatComposer } from './NativeChatComposer'
|
||||
describe('NativeChatComposer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
clearNativeChatSessionOptionCacheForTests()
|
||||
mocks.fieldProps = null
|
||||
mocks.modelSwitchOutcome = 'applied'
|
||||
mocks.confirmationObserver = null
|
||||
mocks.createClaudeModelSwitchConfirmationObserver.mockImplementation(() => {
|
||||
const observer = {
|
||||
ready: Promise.resolve(),
|
||||
result: Promise.resolve(mocks.modelSwitchOutcome),
|
||||
arm: vi.fn(),
|
||||
startDetection: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
}
|
||||
mocks.confirmationObserver = observer
|
||||
return observer
|
||||
})
|
||||
mocks.getMainBufferSnapshot.mockResolvedValue(null)
|
||||
mocks.sendNativeChatMessage.mockReturnValue(mocks.sendHandle)
|
||||
mocks.sendNativeChatMessageVerified.mockResolvedValue(true)
|
||||
mocks.sendHandle.settleAfterMs = 500
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: { ui: { onFileDrop: () => vi.fn() } }
|
||||
value: {
|
||||
pty: { getMainBufferSnapshot: mocks.getMainBufferSnapshot },
|
||||
ui: { onFileDrop: () => vi.fn() }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -122,4 +186,201 @@ describe('NativeChatComposer', () => {
|
||||
expect(onOptimisticSend).toHaveBeenCalledWith('hello', [])
|
||||
expect(mocks.trackPendingSend).toHaveBeenCalledWith(mocks.sendHandle, 'pending-1')
|
||||
})
|
||||
|
||||
it('shows the model already selected in the Claude TUI when chat opens', async () => {
|
||||
mocks.getMainBufferSnapshot.mockResolvedValue({
|
||||
data: 'Claude Code v2.1.211\r\nOpus 4.8 with medium effort · API Usage Billing',
|
||||
cols: 120,
|
||||
rows: 40
|
||||
})
|
||||
render(
|
||||
<NativeChatComposer
|
||||
terminalTabId="tab-1"
|
||||
targetPtyId="pty-1"
|
||||
agent="claude"
|
||||
readTerminalScreen={() => null}
|
||||
/>
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.fieldProps?.sessionOptionsSnapshot).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'model',
|
||||
valueSource: 'reported',
|
||||
kind: expect.objectContaining({ currentValue: 'opus' })
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'effort',
|
||||
valueSource: 'reported',
|
||||
kind: expect.objectContaining({ currentValue: 'medium' })
|
||||
})
|
||||
])
|
||||
)
|
||||
)
|
||||
expect(mocks.getMainBufferSnapshot).toHaveBeenCalledWith('pty-1', { scrollbackRows: 0 })
|
||||
})
|
||||
|
||||
it('reads Claude state from mounted xterm while its alternate screen is active', async () => {
|
||||
mocks.getMainBufferSnapshot.mockResolvedValue({
|
||||
data: 'Claude Code v2.1.211\r\nOpus 4.8 with high effort · stale main buffer',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
alternateScreen: true
|
||||
})
|
||||
render(
|
||||
<NativeChatComposer
|
||||
terminalTabId="tab-1"
|
||||
targetPtyId="pty-1"
|
||||
agent="claude"
|
||||
readTerminalScreen={() =>
|
||||
'\u001b[?1049h\u001b[HClaude Codev2.1.211\r\n' +
|
||||
'Sonnet 5 with medium effort · API Usage Billing'
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.fieldProps?.sessionOptionsSnapshot).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'model',
|
||||
valueSource: 'reported',
|
||||
kind: expect.objectContaining({ currentValue: 'sonnet' })
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'effort',
|
||||
valueSource: 'reported',
|
||||
kind: expect.objectContaining({ currentValue: 'medium' })
|
||||
})
|
||||
])
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('observes a fresh Claude model choice and stays native on success', async () => {
|
||||
mocks.sendHandle.settleAfterMs = 0
|
||||
const onSlashCommand = vi.fn()
|
||||
const onOptimisticSend = vi.fn()
|
||||
const onSwitchToTerminal = vi.fn()
|
||||
render(
|
||||
<NativeChatComposer
|
||||
terminalTabId="tab-1"
|
||||
targetPtyId="pty-1"
|
||||
agent="claude"
|
||||
onSlashCommand={onSlashCommand}
|
||||
onOptimisticSend={onOptimisticSend}
|
||||
onSwitchToTerminal={onSwitchToTerminal}
|
||||
/>
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await mocks.fieldProps?.sessionOptionsSurface?.setOption('model', 'opus')
|
||||
})
|
||||
|
||||
expect(mocks.sendNativeChatMessageVerified).toHaveBeenCalledWith(
|
||||
{},
|
||||
'pty-1',
|
||||
'/model opus',
|
||||
expect.any(AbortSignal)
|
||||
)
|
||||
expect(onSlashCommand).toHaveBeenCalledWith('/model opus')
|
||||
expect(onOptimisticSend).not.toHaveBeenCalled()
|
||||
expect(mocks.createClaudeModelSwitchConfirmationObserver).toHaveBeenCalledWith({
|
||||
ptyId: 'pty-1',
|
||||
settings: {},
|
||||
expectedModelLabel: 'Opus 4.8'
|
||||
})
|
||||
expect(onSwitchToTerminal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps a successful Claude model change after a conversation in native chat', async () => {
|
||||
mocks.sendHandle.settleAfterMs = 0
|
||||
const onSwitchToTerminal = vi.fn()
|
||||
render(
|
||||
<NativeChatComposer
|
||||
terminalTabId="tab-1"
|
||||
targetPtyId="pty-1"
|
||||
agent="claude"
|
||||
onSwitchToTerminal={onSwitchToTerminal}
|
||||
/>
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await mocks.fieldProps?.sessionOptionsSurface?.setOption('model', 'fable')
|
||||
})
|
||||
|
||||
expect(mocks.sendNativeChatMessageVerified).toHaveBeenCalledWith(
|
||||
{},
|
||||
'pty-1',
|
||||
'/model fable',
|
||||
expect.any(AbortSignal)
|
||||
)
|
||||
expect(mocks.createClaudeModelSwitchConfirmationObserver).toHaveBeenCalledWith({
|
||||
ptyId: 'pty-1',
|
||||
settings: {},
|
||||
expectedModelLabel: 'Fable 5'
|
||||
})
|
||||
expect(mocks.confirmationObserver?.arm).toHaveBeenCalledOnce()
|
||||
expect(mocks.confirmationObserver?.arm.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mocks.sendNativeChatMessageVerified.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY
|
||||
)
|
||||
expect(mocks.confirmationObserver?.startDetection).toHaveBeenCalledOnce()
|
||||
expect(mocks.confirmationObserver?.startDetection.mock.invocationCallOrder[0]).toBeGreaterThan(
|
||||
mocks.sendNativeChatMessageVerified.mock.invocationCallOrder[0] ?? Number.NEGATIVE_INFINITY
|
||||
)
|
||||
expect(mocks.confirmationObserver?.dispose).toHaveBeenCalledOnce()
|
||||
expect(onSwitchToTerminal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reveals Claude interaction only when the model switch needs user input', async () => {
|
||||
mocks.sendHandle.settleAfterMs = 0
|
||||
mocks.modelSwitchOutcome = 'interaction-required'
|
||||
const onSwitchToTerminal = vi.fn()
|
||||
render(
|
||||
<NativeChatComposer
|
||||
terminalTabId="tab-1"
|
||||
targetPtyId="pty-1"
|
||||
agent="claude"
|
||||
onSwitchToTerminal={onSwitchToTerminal}
|
||||
/>
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await mocks.fieldProps?.sessionOptionsSurface?.setOption('model', 'fable')
|
||||
})
|
||||
|
||||
expect(mocks.sendNativeChatMessageVerified).toHaveBeenCalledWith(
|
||||
{},
|
||||
'pty-1',
|
||||
'/model fable',
|
||||
expect.any(AbortSignal)
|
||||
)
|
||||
expect(onSwitchToTerminal).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('waits for the Codex picker command before switching to the terminal', async () => {
|
||||
mocks.sendHandle.settleAfterMs = 0
|
||||
const onSwitchToTerminal = vi.fn()
|
||||
render(
|
||||
<NativeChatComposer
|
||||
terminalTabId="tab-1"
|
||||
targetPtyId="pty-1"
|
||||
agent="codex"
|
||||
onSwitchToTerminal={onSwitchToTerminal}
|
||||
/>
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await mocks.fieldProps?.sessionOptionsSurface?.setOption('model', '')
|
||||
})
|
||||
|
||||
expect(mocks.sendNativeChatMessageVerified).toHaveBeenCalledWith(
|
||||
{},
|
||||
'pty-1',
|
||||
'/model',
|
||||
expect.any(AbortSignal)
|
||||
)
|
||||
expect(onSwitchToTerminal).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
import { forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react'
|
||||
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 {
|
||||
@@ -43,9 +34,12 @@ import { useNativeChatSkills } from './use-native-chat-skills'
|
||||
import { useNativeChatComposerAttachments } from './use-native-chat-composer-attachments'
|
||||
import { useNativeChatComposerPaste } from './use-native-chat-composer-paste'
|
||||
import { useNativeChatExternalAttachments } from './use-native-chat-external-attachments'
|
||||
import { dispatchDictationControl } from '../dictation/dictation-control-events'
|
||||
import { useNativeChatComposerKeyDown } from './use-native-chat-composer-keydown'
|
||||
import { useNativeChatSendLifecycle } from './use-native-chat-send-lifecycle'
|
||||
import { useNativeChatSessionOptions } from './use-native-chat-session-options'
|
||||
import { useNativeChatFileAttachmentActions } from './use-native-chat-file-attachment-actions'
|
||||
import { useNativeChatDictationActions } from './use-native-chat-dictation-actions'
|
||||
import { useNativeChatSessionOptionCommand } from './use-native-chat-session-option-command'
|
||||
|
||||
// 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
|
||||
@@ -79,6 +73,10 @@ export type NativeChatComposerProps = {
|
||||
* 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
|
||||
/** Picker-only agent commands continue in the hosted TUI after dispatch. */
|
||||
onSwitchToTerminal?: () => void
|
||||
/** Reads the hosted TUI's current rendered screen when chat is entered. */
|
||||
readTerminalScreen?: () => string | null
|
||||
}
|
||||
|
||||
export type NativeChatComposerHandle = {
|
||||
@@ -116,7 +114,9 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
|
||||
onStop,
|
||||
onOptimisticSend,
|
||||
onOptimisticSendCanceled,
|
||||
onSlashCommand
|
||||
onSlashCommand,
|
||||
onSwitchToTerminal,
|
||||
readTerminalScreen
|
||||
},
|
||||
ref
|
||||
): React.JSX.Element {
|
||||
@@ -248,44 +248,27 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
|
||||
[focus, insertTypedText, handlePaste, pasteFromClipboard]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return window.api.ui.onFileDrop((payload) => {
|
||||
if (payload.target !== NATIVE_FILE_DROP_TARGET.composer) {
|
||||
return
|
||||
}
|
||||
attachExternalPaths(payload.paths)
|
||||
const { pickAttachment } = useNativeChatFileAttachmentActions(attachExternalPaths)
|
||||
const { toggleDictation, startHoldDictation, stopHoldDictation } =
|
||||
useNativeChatDictationActions({ textareaRef, setDictationPressed })
|
||||
const { dispatch: dispatchSessionOptionCommand, isDispatching: isDispatchingSessionOption } =
|
||||
useNativeChatSessionOptionCommand({
|
||||
agent,
|
||||
disabled,
|
||||
onSlashCommand,
|
||||
resolveTarget,
|
||||
setHistory
|
||||
})
|
||||
}, [attachExternalPaths])
|
||||
|
||||
const pickAttachment = useCallback(() => {
|
||||
void (async () => {
|
||||
const filePath = await window.api.shell.pickAttachment()
|
||||
if (!filePath) {
|
||||
return
|
||||
}
|
||||
attachExternalPaths([filePath])
|
||||
})()
|
||||
}, [attachExternalPaths])
|
||||
|
||||
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 { surface: sessionOptionsSurface, snapshot: sessionOptionsSnapshot } =
|
||||
useNativeChatSessionOptions({
|
||||
agent,
|
||||
terminalTabId,
|
||||
targetPtyId,
|
||||
dispatchCommand: dispatchSessionOptionCommand,
|
||||
onAgentPicker: onSwitchToTerminal,
|
||||
readTerminalScreen
|
||||
})
|
||||
|
||||
const send = useCallback(() => {
|
||||
const text = draft
|
||||
@@ -293,6 +276,12 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
|
||||
if ((text.trim() === '' && imagePaths.length === 0) || disabled) {
|
||||
return
|
||||
}
|
||||
// Why: block a normal send while a session-option command (e.g. /model) is
|
||||
// still writing its body+delayed-Enter to the same pty, so the two write
|
||||
// sequences can't interleave on one input line.
|
||||
if (isDispatchingSessionOption) {
|
||||
return
|
||||
}
|
||||
const target = resolveTarget()
|
||||
if (!target) {
|
||||
return
|
||||
@@ -325,6 +314,7 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
|
||||
trackPendingSend(pendingHandle)
|
||||
}
|
||||
onSlashCommand?.(text.trim())
|
||||
sessionOptionsSurface?.recordOutgoingCommand(text.trim())
|
||||
} else {
|
||||
const pendingId = onOptimisticSend?.(text, imagePaths)
|
||||
if (pendingHandle) {
|
||||
@@ -348,9 +338,11 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
|
||||
draft,
|
||||
imageAttachments,
|
||||
disabled,
|
||||
isDispatchingSessionOption,
|
||||
resolveTarget,
|
||||
onOptimisticSend,
|
||||
onSlashCommand,
|
||||
sessionOptionsSurface,
|
||||
trackPendingSend,
|
||||
setDraft
|
||||
])
|
||||
@@ -390,6 +382,7 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
|
||||
// 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())
|
||||
sessionOptionsSurface?.recordOutgoingCommand(next.trim())
|
||||
emitNativeChatMessageSent({
|
||||
agent,
|
||||
runtime: nativeChatComposerTargetIsRemote(target.ptyId) ? 'remote' : 'local'
|
||||
@@ -400,7 +393,15 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
|
||||
setActiveSuggestion(0)
|
||||
setNotice(null)
|
||||
},
|
||||
[agent, disabled, resolveTarget, onSlashCommand, setDraft, trackPendingSend]
|
||||
[
|
||||
agent,
|
||||
disabled,
|
||||
resolveTarget,
|
||||
onSlashCommand,
|
||||
sessionOptionsSurface,
|
||||
setDraft,
|
||||
trackPendingSend
|
||||
]
|
||||
)
|
||||
|
||||
const handleKeyDown = useNativeChatComposerKeyDown({
|
||||
@@ -469,6 +470,8 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
|
||||
onDictationHoldEnd={stopHoldDictation}
|
||||
onSend={send}
|
||||
onStop={interrupt}
|
||||
sessionOptionsSurface={sessionOptionsSurface}
|
||||
sessionOptionsSnapshot={sessionOptionsSnapshot}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (_key: string, fallback: string) => fallback
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/button', () => ({
|
||||
Button: ({
|
||||
children,
|
||||
variant: _variant,
|
||||
size: _size,
|
||||
...props
|
||||
}: {
|
||||
children: ReactNode
|
||||
variant?: string
|
||||
size?: string
|
||||
} & React.ButtonHTMLAttributes<HTMLButtonElement>) => <button {...props}>{children}</button>
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
TooltipContent: ({ children }: { children: ReactNode }) => <div>{children}</div>
|
||||
}))
|
||||
|
||||
vi.mock('./NativeChatSessionOptionPickers', () => ({
|
||||
NativeChatSessionOptionPickers: () => <div data-testid="session-option-pickers" />
|
||||
}))
|
||||
|
||||
import { NativeChatComposerActions } from './NativeChatComposerActions'
|
||||
|
||||
afterEach(() => cleanup())
|
||||
|
||||
describe('NativeChatComposerActions', () => {
|
||||
it('places session option pickers immediately beside dictation', () => {
|
||||
render(
|
||||
<NativeChatComposerActions
|
||||
attachDisabled={false}
|
||||
dictationDisabled={false}
|
||||
sendDisabled={false}
|
||||
isWorking={false}
|
||||
isDictating={false}
|
||||
isDictationHoldMode={false}
|
||||
onAttach={vi.fn()}
|
||||
onDictationToggle={vi.fn()}
|
||||
onDictationHoldStart={vi.fn()}
|
||||
onDictationHoldEnd={vi.fn()}
|
||||
onSend={vi.fn()}
|
||||
sessionOptionsSurface={null}
|
||||
sessionOptionsSnapshot={[]}
|
||||
/>
|
||||
)
|
||||
|
||||
const pickers = screen.getByTestId('session-option-pickers')
|
||||
const dictation = screen.getByRole('button', { name: 'Start dictation' })
|
||||
expect(pickers.nextElementSibling).toBe(dictation)
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,11 @@ 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'
|
||||
import type {
|
||||
SessionOptionDescriptor,
|
||||
SessionOptionsSurface
|
||||
} from '../../../../shared/native-chat-session-options'
|
||||
import { NativeChatSessionOptionPickers } from './NativeChatSessionOptionPickers'
|
||||
|
||||
export type NativeChatComposerActionsProps = {
|
||||
attachDisabled: boolean
|
||||
@@ -16,6 +21,8 @@ export type NativeChatComposerActionsProps = {
|
||||
onDictationHoldEnd: () => void
|
||||
onSend: () => void
|
||||
onStop?: () => void
|
||||
sessionOptionsSurface: SessionOptionsSurface | null
|
||||
sessionOptionsSnapshot: SessionOptionDescriptor[]
|
||||
}
|
||||
|
||||
export function NativeChatComposerActions({
|
||||
@@ -30,32 +37,43 @@ export function NativeChatComposerActions({
|
||||
onDictationHoldStart,
|
||||
onDictationHoldEnd,
|
||||
onSend,
|
||||
onStop
|
||||
onStop,
|
||||
sessionOptionsSurface,
|
||||
sessionOptionsSnapshot
|
||||
}: 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="flex min-w-0 items-center gap-0.5">
|
||||
<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>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
{/* Why: keep session controls beside the actions they affect; the
|
||||
model trigger is ordered last so it sits directly next to dictation. */}
|
||||
<NativeChatSessionOptionPickers
|
||||
surface={sessionOptionsSurface}
|
||||
snapshot={sessionOptionsSnapshot}
|
||||
isWorking={isWorking}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
|
||||
@@ -14,6 +14,10 @@ import {
|
||||
import { NativeChatComposerActions } from './NativeChatComposerActions'
|
||||
import { nativeChatComposerPlaceholder } from './native-chat-composer-target'
|
||||
import type { DiscoveredSkill } from '../../../../shared/skills'
|
||||
import type {
|
||||
SessionOptionDescriptor,
|
||||
SessionOptionsSurface
|
||||
} from '../../../../shared/native-chat-session-options'
|
||||
|
||||
export type NativeChatComposerFieldProps = {
|
||||
textareaRef: RefObject<HTMLTextAreaElement | null>
|
||||
@@ -45,6 +49,8 @@ export type NativeChatComposerFieldProps = {
|
||||
onDictationHoldEnd: () => void
|
||||
onSend: () => void
|
||||
onStop?: () => void
|
||||
sessionOptionsSurface: SessionOptionsSurface | null
|
||||
sessionOptionsSnapshot: SessionOptionDescriptor[]
|
||||
}
|
||||
|
||||
export type NativeChatComposerImageAttachment = {
|
||||
@@ -81,7 +87,9 @@ export function NativeChatComposerField({
|
||||
onDictationHoldStart,
|
||||
onDictationHoldEnd,
|
||||
onSend,
|
||||
onStop
|
||||
onStop,
|
||||
sessionOptionsSurface,
|
||||
sessionOptionsSnapshot
|
||||
}: NativeChatComposerFieldProps): React.JSX.Element {
|
||||
return (
|
||||
<div className="shrink-0 bg-background">
|
||||
@@ -182,6 +190,8 @@ export function NativeChatComposerField({
|
||||
onDictationHoldEnd={onDictationHoldEnd}
|
||||
onSend={onSend}
|
||||
onStop={onStop}
|
||||
sessionOptionsSurface={sessionOptionsSurface}
|
||||
sessionOptionsSnapshot={sessionOptionsSnapshot}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options'
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (_key: string, fallback: string, values?: Record<string, string>) =>
|
||||
fallback.replace('{{value0}}', values?.value0 ?? '')
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/button', () => ({
|
||||
Button: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
|
||||
<button {...props}>{children}</button>
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
TooltipContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/dropdown-menu', () => ({
|
||||
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuTrigger: ({
|
||||
children,
|
||||
disabled
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
disabled?: boolean
|
||||
}) => <div data-disabled={disabled || undefined}>{children}</div>,
|
||||
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuSeparator: () => <hr />,
|
||||
DropdownMenuItem: ({ children, disabled }: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
|
||||
<button disabled={disabled}>{children}</button>
|
||||
),
|
||||
DropdownMenuCheckboxItem: ({
|
||||
children,
|
||||
disabled
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
|
||||
<button disabled={disabled}>{children}</button>
|
||||
),
|
||||
DropdownMenuRadioGroup: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuRadioItem: ({
|
||||
children,
|
||||
disabled,
|
||||
value
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { value: string }) => (
|
||||
<button disabled={disabled} data-value={value}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}))
|
||||
|
||||
import { NativeChatSessionOptionPickers } from './NativeChatSessionOptionPickers'
|
||||
|
||||
const surface = {
|
||||
getSnapshot: vi.fn(() => []),
|
||||
setOption: vi.fn(),
|
||||
subscribe: vi.fn(() => vi.fn())
|
||||
}
|
||||
|
||||
function model(overrides: Partial<SessionOptionDescriptor> = {}): SessionOptionDescriptor {
|
||||
return {
|
||||
id: 'model',
|
||||
label: 'Model',
|
||||
category: 'model',
|
||||
kind: {
|
||||
type: 'select',
|
||||
currentValue: 'opus',
|
||||
choices: [
|
||||
{ value: 'opus', label: 'Opus 4.8' },
|
||||
{ value: 'sonnet', label: 'Sonnet 5' }
|
||||
]
|
||||
},
|
||||
valueSource: 'applied',
|
||||
settable: true,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
const effort: SessionOptionDescriptor = {
|
||||
id: 'effort',
|
||||
label: 'Effort',
|
||||
category: 'thought_level',
|
||||
kind: {
|
||||
type: 'select',
|
||||
currentValue: 'high',
|
||||
choices: [
|
||||
{ value: 'low', label: 'Low' },
|
||||
{ value: 'high', label: 'High' }
|
||||
]
|
||||
},
|
||||
valueSource: 'applied',
|
||||
settable: true
|
||||
}
|
||||
|
||||
const fast: SessionOptionDescriptor = {
|
||||
id: 'fastMode',
|
||||
label: 'Fast mode',
|
||||
category: 'mode',
|
||||
kind: { type: 'boolean', currentValue: true },
|
||||
valueSource: 'applied',
|
||||
settable: true
|
||||
}
|
||||
|
||||
afterEach(() => cleanup())
|
||||
|
||||
describe('NativeChatSessionOptionPickers', () => {
|
||||
it('renders model and joined option labels, and hides an empty options pill', () => {
|
||||
const { rerender } = render(
|
||||
<NativeChatSessionOptionPickers
|
||||
surface={surface}
|
||||
snapshot={[model(), effort, fast]}
|
||||
isWorking={false}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Model' }).textContent).toContain('Model: Opus 4.8')
|
||||
expect(screen.getByRole('button', { name: 'Effort' }).textContent).toContain(
|
||||
'Effort: High · Fast'
|
||||
)
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: 'Effort' })
|
||||
.compareDocumentPosition(screen.getByRole('button', { name: 'Model' })) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING
|
||||
).not.toBe(0)
|
||||
|
||||
rerender(
|
||||
<NativeChatSessionOptionPickers surface={surface} snapshot={[model()]} isWorking={false} />
|
||||
)
|
||||
expect(screen.queryByRole('button', { name: 'Effort' })).toBeNull()
|
||||
})
|
||||
|
||||
it('names a lone unknown effort control explicitly', () => {
|
||||
render(
|
||||
<NativeChatSessionOptionPickers
|
||||
surface={surface}
|
||||
snapshot={[
|
||||
model(),
|
||||
{ ...effort, kind: { ...effort.kind, currentValue: undefined }, valueSource: 'unknown' }
|
||||
]}
|
||||
isWorking={false}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Effort' }).textContent).toContain('Effort')
|
||||
})
|
||||
|
||||
it('disables both picker triggers while the agent is working', () => {
|
||||
render(
|
||||
<NativeChatSessionOptionPickers surface={surface} snapshot={[model(), effort]} isWorking />
|
||||
)
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Model' }).parentElement?.getAttribute('data-disabled')
|
||||
).toBe('true')
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Effort' }).parentElement?.getAttribute('data-disabled')
|
||||
).toBe('true')
|
||||
})
|
||||
|
||||
it('does not duplicate titles for unknown values or misname generic controls', () => {
|
||||
const { rerender } = render(
|
||||
<NativeChatSessionOptionPickers
|
||||
surface={surface}
|
||||
snapshot={[
|
||||
model({
|
||||
kind: { type: 'select', choices: [] },
|
||||
valueSource: 'unknown'
|
||||
}),
|
||||
{ ...effort, kind: { ...effort.kind, currentValue: undefined }, valueSource: 'unknown' }
|
||||
]}
|
||||
isWorking={false}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Model' }).textContent).toContain('Model')
|
||||
expect(screen.getByRole('button', { name: 'Model' }).textContent).not.toContain('Model: Model')
|
||||
expect(screen.getByRole('button', { name: 'Effort' }).textContent).not.toContain(
|
||||
'Effort: Effort'
|
||||
)
|
||||
|
||||
rerender(
|
||||
<NativeChatSessionOptionPickers
|
||||
surface={surface}
|
||||
snapshot={[model(), fast]}
|
||||
isWorking={false}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Session options' }).textContent).toContain('Fast')
|
||||
expect(screen.queryByRole('button', { name: 'Effort' })).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the unconfirmed hint for dispatched values', () => {
|
||||
render(
|
||||
<NativeChatSessionOptionPickers
|
||||
surface={surface}
|
||||
snapshot={[model({ valueSource: 'dispatched' })]}
|
||||
isWorking={false}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Sent to the agent — not confirmed')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('renders agent-picker routes as one action instead of radio choices', () => {
|
||||
render(
|
||||
<NativeChatSessionOptionPickers
|
||||
surface={surface}
|
||||
snapshot={[
|
||||
model({
|
||||
kind: {
|
||||
type: 'select',
|
||||
choices: [
|
||||
{ value: 'gpt-5.5', label: 'GPT-5.5' },
|
||||
{ value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex' }
|
||||
]
|
||||
},
|
||||
valueSource: 'unknown',
|
||||
action: { type: 'agent-picker' }
|
||||
})
|
||||
]}
|
||||
isWorking={false}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Choose in agent picker…' })).not.toBeNull()
|
||||
expect(screen.queryByText('GPT-5.5')).toBeNull()
|
||||
expect(screen.queryByText('GPT-5.2 Codex')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,276 @@
|
||||
import { memo, useState } from 'react'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type {
|
||||
SessionOptionDescriptor,
|
||||
SessionOptionsSurface,
|
||||
SessionOptionValue
|
||||
} from '../../../../shared/native-chat-session-options'
|
||||
import {
|
||||
nativeChatModelPillLabel,
|
||||
nativeChatOptionsPillLabel,
|
||||
nativeChatOptionsPillTitle,
|
||||
nativeChatSessionChoiceLabel,
|
||||
nativeChatSessionOptionDisabledReason,
|
||||
nativeChatSessionOptionLabel
|
||||
} from './native-chat-session-option-labels'
|
||||
|
||||
export type NativeChatSessionOptionPickersProps = {
|
||||
surface: SessionOptionsSurface | null
|
||||
snapshot: SessionOptionDescriptor[]
|
||||
isWorking: boolean
|
||||
}
|
||||
|
||||
const CATEGORY_ORDER: Record<string, number> = {
|
||||
thought_level: 0,
|
||||
model_config: 1,
|
||||
mode: 2
|
||||
}
|
||||
|
||||
function sortedOptions(snapshot: readonly SessionOptionDescriptor[]): SessionOptionDescriptor[] {
|
||||
return snapshot
|
||||
.filter((descriptor) => descriptor.category !== 'model')
|
||||
.sort((left, right) => {
|
||||
const leftOrder = CATEGORY_ORDER[left.category ?? ''] ?? 3
|
||||
const rightOrder = CATEGORY_ORDER[right.category ?? ''] ?? 3
|
||||
return leftOrder - rightOrder
|
||||
})
|
||||
}
|
||||
|
||||
function PickerTooltipContent(props: {
|
||||
label: string
|
||||
disabledReason?: string | null
|
||||
dispatched: boolean
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<div>{props.disabledReason ?? props.label}</div>
|
||||
{props.dispatched ? (
|
||||
<div>
|
||||
{translate(
|
||||
'components.native-chat.composer.sentNotConfirmed',
|
||||
'Sent to the agent — not confirmed'
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PickerTrigger(props: {
|
||||
label: string
|
||||
tooltipLabel: string
|
||||
disabled: boolean
|
||||
disabledReason?: string | null
|
||||
dispatched: boolean
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild disabled={props.disabled}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
aria-label={props.tooltipLabel}
|
||||
className="max-w-48 text-muted-foreground"
|
||||
>
|
||||
<span className="truncate">{props.label}</span>
|
||||
<ChevronDown className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
<PickerTooltipContent
|
||||
label={props.tooltipLabel}
|
||||
disabledReason={props.disabledReason}
|
||||
dispatched={props.dispatched}
|
||||
/>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function ChoiceBody(props: { label: string; description?: string }): React.JSX.Element {
|
||||
return (
|
||||
<div className="min-w-0 py-0.5">
|
||||
<div>{props.label}</div>
|
||||
{props.description ? (
|
||||
<div className="text-xs font-normal text-muted-foreground">{props.description}</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function actionLabel(descriptor: SessionOptionDescriptor): string {
|
||||
if (descriptor.action?.type === 'agent-picker') {
|
||||
return translate(
|
||||
'components.native-chat.composer.chooseInAgentPicker',
|
||||
'Choose in agent picker…'
|
||||
)
|
||||
}
|
||||
return translate('components.native-chat.composer.toggleOption', 'Toggle {{value0}}', {
|
||||
value0: nativeChatSessionOptionLabel(descriptor).toLowerCase()
|
||||
})
|
||||
}
|
||||
|
||||
function DescriptorMenuRows(props: {
|
||||
descriptor: SessionOptionDescriptor
|
||||
pending: boolean
|
||||
setValue: (value: SessionOptionValue) => void
|
||||
}): React.JSX.Element {
|
||||
const { descriptor, pending, setValue } = props
|
||||
if (descriptor.action) {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
disabled={!descriptor.settable || pending}
|
||||
onSelect={() =>
|
||||
setValue(
|
||||
descriptor.kind.type === 'boolean'
|
||||
? !(descriptor.kind.currentValue ?? false)
|
||||
: (descriptor.kind.currentValue ?? '')
|
||||
)
|
||||
}
|
||||
>
|
||||
{actionLabel(descriptor)}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
if (descriptor.kind.type === 'boolean') {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={descriptor.kind.currentValue ?? false}
|
||||
disabled={!descriptor.settable || pending}
|
||||
onCheckedChange={(checked) => setValue(checked === true)}
|
||||
>
|
||||
{nativeChatSessionOptionLabel(descriptor)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<DropdownMenuRadioGroup
|
||||
value={descriptor.kind.currentValue}
|
||||
onValueChange={(value) => setValue(value)}
|
||||
>
|
||||
{descriptor.kind.choices.map((choice) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={choice.value}
|
||||
value={choice.value}
|
||||
disabled={!descriptor.settable || pending}
|
||||
>
|
||||
<ChoiceBody
|
||||
label={nativeChatSessionChoiceLabel(choice)}
|
||||
description={choice.description}
|
||||
/>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function NativeChatSessionOptionPickersInner({
|
||||
surface,
|
||||
snapshot,
|
||||
isWorking
|
||||
}: NativeChatSessionOptionPickersProps): React.JSX.Element | null {
|
||||
const [pendingId, setPendingId] = useState<string | null>(null)
|
||||
const model = snapshot.find((descriptor) => descriptor.category === 'model')
|
||||
const options = sortedOptions(snapshot)
|
||||
if (!surface || !model) {
|
||||
return null
|
||||
}
|
||||
|
||||
const setOption = (descriptor: SessionOptionDescriptor, value: SessionOptionValue): void => {
|
||||
setPendingId(descriptor.id)
|
||||
void surface
|
||||
.setOption(descriptor.id, value)
|
||||
.catch((error) => {
|
||||
toast.error(
|
||||
translate(
|
||||
'components.native-chat.composer.optionUpdateFailed',
|
||||
'Could not update option'
|
||||
),
|
||||
{ description: error instanceof Error ? error.message : String(error) }
|
||||
)
|
||||
})
|
||||
.finally(() => setPendingId(null))
|
||||
}
|
||||
|
||||
const modelReason = nativeChatSessionOptionDisabledReason(model.disabledReason)
|
||||
const modelTooltip = translate('components.native-chat.composer.model', 'Model')
|
||||
const optionsTooltip = nativeChatOptionsPillTitle(options)
|
||||
const optionsReason =
|
||||
options.length > 0 && options.every((descriptor) => !descriptor.settable)
|
||||
? nativeChatSessionOptionDisabledReason(options[0]?.disabledReason)
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-0.5">
|
||||
{options.length > 0 ? (
|
||||
<DropdownMenu>
|
||||
<PickerTrigger
|
||||
label={nativeChatOptionsPillLabel(options)}
|
||||
tooltipLabel={optionsTooltip}
|
||||
disabled={isWorking || pendingId !== null}
|
||||
disabledReason={optionsReason}
|
||||
dispatched={options.some((descriptor) => descriptor.valueSource === 'dispatched')}
|
||||
/>
|
||||
<DropdownMenuContent align="start" className="w-60">
|
||||
{options.map((descriptor, index) => {
|
||||
const reason = nativeChatSessionOptionDisabledReason(descriptor.disabledReason)
|
||||
return (
|
||||
<div key={descriptor.id}>
|
||||
{index > 0 ? <DropdownMenuSeparator /> : null}
|
||||
<DropdownMenuLabel>{nativeChatSessionOptionLabel(descriptor)}</DropdownMenuLabel>
|
||||
{reason && !descriptor.settable ? (
|
||||
<DropdownMenuLabel className="font-normal">{reason}</DropdownMenuLabel>
|
||||
) : null}
|
||||
<DescriptorMenuRows
|
||||
descriptor={descriptor}
|
||||
pending={pendingId !== null}
|
||||
setValue={(value) => setOption(descriptor, value)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
<DropdownMenu>
|
||||
<PickerTrigger
|
||||
label={nativeChatModelPillLabel(model)}
|
||||
tooltipLabel={modelTooltip}
|
||||
disabled={isWorking || pendingId !== null}
|
||||
disabledReason={modelReason}
|
||||
dispatched={model.valueSource === 'dispatched'}
|
||||
/>
|
||||
<DropdownMenuContent align="start" className="w-64">
|
||||
{modelReason && !model.settable ? (
|
||||
<DropdownMenuLabel className="font-normal">{modelReason}</DropdownMenuLabel>
|
||||
) : null}
|
||||
<DescriptorMenuRows
|
||||
descriptor={model}
|
||||
pending={pendingId !== null}
|
||||
setValue={(value) => setOption(model, value)}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const NativeChatSessionOptionPickers = memo(NativeChatSessionOptionPickersInner)
|
||||
@@ -48,14 +48,10 @@ import {
|
||||
useNativeChatContextMenu
|
||||
} from './use-native-chat-context-menu'
|
||||
import type { NativeChatContextMenuActions } from './use-native-chat-context-menu'
|
||||
import {
|
||||
resolveNativeChatFileLink,
|
||||
resolveNativeChatFileLinkContext
|
||||
} from './native-chat-file-link'
|
||||
import { resolveNativeChatFileLinkContext } from './native-chat-file-link'
|
||||
import { selectNativeChatRuntimeEnvironmentId } from './native-chat-runtime-owner'
|
||||
import { useNativeChatPasteBridge } from './use-native-chat-paste-bridge'
|
||||
import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown'
|
||||
import { openDetectedFilePath } from '@/components/terminal-pane/terminal-file-open-routing'
|
||||
import { useNativeChatFileLinkClick } from './use-native-chat-file-link-click'
|
||||
|
||||
export type NativeChatViewProps = {
|
||||
/** The terminal tab hosting the agent. paneKey is `${tabId}:${leafId}`. */
|
||||
@@ -70,17 +66,12 @@ export type NativeChatViewProps = {
|
||||
resolvedAgent?: TuiAgent | null
|
||||
/** Return this pane to the hosted terminal surface. */
|
||||
onSwitchToTerminal?: () => void
|
||||
/** Current xterm screen reader used to recover agent-reported session state. */
|
||||
readTerminalScreen?: () => string | null
|
||||
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.
|
||||
*/
|
||||
/** Resolves an agent terminal into its native conversation and composer UI. */
|
||||
export default function NativeChatView({
|
||||
terminalTabId,
|
||||
paneKey: preferredPaneKey,
|
||||
@@ -88,6 +79,7 @@ export default function NativeChatView({
|
||||
launchAgent,
|
||||
resolvedAgent,
|
||||
onSwitchToTerminal,
|
||||
readTerminalScreen,
|
||||
contextMenuActions
|
||||
}: NativeChatViewProps): React.JSX.Element {
|
||||
// Select only this tab's status entry (shallow-compared) so an unrelated
|
||||
@@ -120,6 +112,7 @@ export default function NativeChatView({
|
||||
targetPtyId={targetPtyId}
|
||||
terminalTabId={terminalTabId}
|
||||
onSwitchToTerminal={onSwitchToTerminal}
|
||||
readTerminalScreen={readTerminalScreen}
|
||||
contextMenuActions={contextMenuActions}
|
||||
/>
|
||||
)}
|
||||
@@ -135,6 +128,7 @@ function NativeChatResolvedView({
|
||||
targetPtyId,
|
||||
terminalTabId,
|
||||
onSwitchToTerminal,
|
||||
readTerminalScreen,
|
||||
contextMenuActions
|
||||
}: {
|
||||
paneKey: string
|
||||
@@ -144,6 +138,7 @@ function NativeChatResolvedView({
|
||||
targetPtyId: string | null
|
||||
terminalTabId: string
|
||||
onSwitchToTerminal?: () => void
|
||||
readTerminalScreen?: () => string | null
|
||||
contextMenuActions?: Omit<NativeChatContextMenuActions, 'onPaste'>
|
||||
}): React.JSX.Element {
|
||||
// Primitive owner selection (no useShallow): routes the pane's read/subscribe to
|
||||
@@ -356,24 +351,7 @@ function NativeChatResolvedView({
|
||||
setWorkingInterrupted(true)
|
||||
interactiveSend.cancel()
|
||||
}, [interactiveSend])
|
||||
const openNativeChatFileLink = useCallback<CommentMarkdownLinkClickHandler>(
|
||||
(event, href) => {
|
||||
const target = resolveNativeChatFileLink(href, fileLinkContext)
|
||||
if (!target || !fileLinkContext) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
openDetectedFilePath(target.absolutePath, target.line, target.column, {
|
||||
worktreeId: fileLinkContext.worktreeId,
|
||||
worktreePath: fileLinkContext.worktreePath,
|
||||
runtimeEnvironmentId: fileLinkContext.runtimeEnvironmentId,
|
||||
openWithSystemDefault: event.shiftKey
|
||||
})
|
||||
},
|
||||
[fileLinkContext]
|
||||
)
|
||||
const nativeChatFileLinkClick = fileLinkContext ? openNativeChatFileLink : undefined
|
||||
const nativeChatFileLinkClick = useNativeChatFileLinkClick(fileLinkContext)
|
||||
|
||||
// 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.
|
||||
@@ -460,6 +438,8 @@ function NativeChatResolvedView({
|
||||
onOptimisticSend={onOptimisticSend}
|
||||
onOptimisticSendCanceled={onOptimisticSendCanceled}
|
||||
onSlashCommand={onSlashCommand}
|
||||
onSwitchToTerminal={onSwitchToTerminal}
|
||||
readTerminalScreen={readTerminalScreen}
|
||||
/>
|
||||
)}
|
||||
{contextMenu.menu}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createClaudeModelSwitchConfirmationObserver,
|
||||
hasClaudeModelSwitchConfirmation
|
||||
} from './claude-model-switch-confirmation'
|
||||
|
||||
describe('Claude model switch confirmation detection', () => {
|
||||
it('recognizes the rendered cache warning across ANSI and wrapped whitespace', () => {
|
||||
expect(
|
||||
hasClaudeModelSwitchConfirmation(
|
||||
'\u001b[1mSwitch model?\u001b[0m\r\nThis\u001b[9Gconversation\u001b[22Gis\u001b[25Gcached\r\nfor the current model.'
|
||||
)
|
||||
).toBe(true)
|
||||
expect(hasClaudeModelSwitchConfirmation('Set model to Haiku 4.5')).toBe(false)
|
||||
})
|
||||
|
||||
it('reports matching success output split across PTY chunks', async () => {
|
||||
const dataObserver = { current: (_data: string): void => {} }
|
||||
const unsubscribe = vi.fn(() => {})
|
||||
const observer = createClaudeModelSwitchConfirmationObserver({
|
||||
ptyId: 'pty-1',
|
||||
settings: {},
|
||||
expectedModelLabel: 'Fable 5',
|
||||
subscribeToData: (watcher) => {
|
||||
dataObserver.current = watcher
|
||||
return unsubscribe
|
||||
},
|
||||
timeoutMs: 100
|
||||
})
|
||||
|
||||
await observer.ready
|
||||
dataObserver.current('historical Set model to Fable 5')
|
||||
observer.arm()
|
||||
dataObserver.current('\u001b[2mSet\u001b[10Gmo')
|
||||
dataObserver.current('del\u001b[16Gto\u001b[19GFable 5 and saved as your default\u001b[0m')
|
||||
|
||||
await expect(observer.result).resolves.toBe('applied')
|
||||
expect(unsubscribe).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('accepts the exact cached-history confirmation once and keeps observing', async () => {
|
||||
const dataObserver = { current: (_data: string): void => {} }
|
||||
const submitConfirmation = vi.fn()
|
||||
const observer = createClaudeModelSwitchConfirmationObserver({
|
||||
ptyId: 'pty-1',
|
||||
settings: {},
|
||||
expectedModelLabel: 'Fable 5',
|
||||
subscribeToData: (watcher) => {
|
||||
dataObserver.current = watcher
|
||||
return vi.fn(() => {})
|
||||
},
|
||||
submitConfirmation,
|
||||
timeoutMs: 100
|
||||
})
|
||||
|
||||
await observer.ready
|
||||
observer.arm()
|
||||
dataObserver.current('Switch model? This\u001b[9Gconversation\u001b[22Gis\u001b[25Gcached ')
|
||||
dataObserver.current('for\u001b[36Gthe\u001b[40Gcurrent\u001b[48Gmodel.')
|
||||
dataObserver.current(' redraw of the same Switch model? prompt')
|
||||
|
||||
expect(submitConfirmation).toHaveBeenCalledOnce()
|
||||
dataObserver.current('Set model to Fable 5 and saved as your default for new sessions')
|
||||
|
||||
await expect(observer.result).resolves.toBe('applied')
|
||||
})
|
||||
|
||||
it('reports a canceled model switch without opening an interaction', async () => {
|
||||
const dataObserver = { current: (_data: string): void => {} }
|
||||
const observer = createClaudeModelSwitchConfirmationObserver({
|
||||
ptyId: 'pty-1',
|
||||
settings: {},
|
||||
expectedModelLabel: 'Haiku',
|
||||
subscribeToData: (watcher) => {
|
||||
dataObserver.current = watcher
|
||||
return vi.fn(() => {})
|
||||
},
|
||||
timeoutMs: 100
|
||||
})
|
||||
|
||||
await observer.ready
|
||||
observer.arm()
|
||||
dataObserver.current('\u001b[2mKept\u001b[9Gmodel\u001b[16Gas ')
|
||||
dataObserver.current('Fable 5\u001b[0m')
|
||||
|
||||
await expect(observer.result).resolves.toBe('rejected')
|
||||
})
|
||||
|
||||
it('requests interaction for Fable one-time usage-credit consent', async () => {
|
||||
const dataObserver = { current: (_data: string): void => {} }
|
||||
const observer = createClaudeModelSwitchConfirmationObserver({
|
||||
ptyId: 'pty-1',
|
||||
settings: {},
|
||||
expectedModelLabel: 'Fable 5',
|
||||
subscribeToData: (watcher) => {
|
||||
dataObserver.current = watcher
|
||||
return vi.fn(() => {})
|
||||
},
|
||||
timeoutMs: 100
|
||||
})
|
||||
|
||||
await observer.ready
|
||||
observer.arm()
|
||||
dataObserver.current('Fable 5 uses usage credits and needs a one-time consent — ')
|
||||
dataObserver.current('pick Fable from /model in an interactive session to set it up')
|
||||
|
||||
await expect(observer.result).resolves.toBe('interaction-required')
|
||||
})
|
||||
|
||||
it('reports unknown when the PTY observer cannot be established', async () => {
|
||||
const observer = createClaudeModelSwitchConfirmationObserver({
|
||||
ptyId: 'pty-1',
|
||||
settings: {},
|
||||
expectedModelLabel: 'Fable 5',
|
||||
subscribeToData: () => Promise.reject(new Error('unavailable')),
|
||||
timeoutMs: 100
|
||||
})
|
||||
|
||||
await observer.ready
|
||||
observer.arm()
|
||||
await expect(observer.result).resolves.toBe('unknown')
|
||||
})
|
||||
|
||||
it('reports unknown on timeout instead of requesting the terminal', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const observer = createClaudeModelSwitchConfirmationObserver({
|
||||
ptyId: 'pty-1',
|
||||
settings: {},
|
||||
expectedModelLabel: 'Fable 5',
|
||||
subscribeToData: () => vi.fn(() => {}),
|
||||
timeoutMs: 100
|
||||
})
|
||||
|
||||
await observer.ready
|
||||
observer.arm()
|
||||
observer.startDetection()
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await expect(observer.result).resolves.toBe('unknown')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not start the detection timeout until startDetection() is called', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
let settled = false
|
||||
const observer = createClaudeModelSwitchConfirmationObserver({
|
||||
ptyId: 'pty-1',
|
||||
settings: {},
|
||||
expectedModelLabel: 'Fable 5',
|
||||
subscribeToData: () => vi.fn(() => {}),
|
||||
timeoutMs: 100
|
||||
})
|
||||
void observer.result.then(() => {
|
||||
settled = true
|
||||
})
|
||||
|
||||
await observer.ready
|
||||
observer.arm()
|
||||
// Simulates slow SSH send latency between arm() and command delivery: the
|
||||
// clock must not run yet, or a successful switch would time out to unknown.
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(settled).toBe(false)
|
||||
|
||||
observer.startDetection()
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await expect(observer.result).resolves.toBe('unknown')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,199 @@
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
import { subscribeToPtyData } from '../terminal-pane/pty-data-sidecar-subscriptions'
|
||||
import { isRemoteRuntimePtyId, sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection'
|
||||
import { subscribeToRuntimeTerminalData } from '@/runtime/runtime-terminal-stream'
|
||||
import { NATIVE_CHAT_SUBMIT } from './native-chat-send'
|
||||
import { stripScrollbackAnsi } from './native-chat-scrape-fallback'
|
||||
|
||||
const DETECTION_TIMEOUT_MS = 5_000
|
||||
const MAX_OBSERVED_BYTES = 64 * 1024
|
||||
|
||||
type SubscribeToData = (watcher: (data: string) => void) => Promise<() => void> | (() => void)
|
||||
|
||||
export type ClaudeModelSwitchOutcome = 'applied' | 'rejected' | 'interaction-required' | 'unknown'
|
||||
|
||||
export type ClaudeModelSwitchConfirmationObserver = {
|
||||
ready: Promise<void>
|
||||
result: Promise<ClaudeModelSwitchOutcome>
|
||||
arm(): void
|
||||
startDetection(): void
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
export function hasClaudeModelSwitchConfirmation(buffer: string): boolean {
|
||||
const text = compactTerminalText(buffer)
|
||||
return (
|
||||
text.includes('switchmodel?') && text.includes('thisconversationiscachedforthecurrentmodel')
|
||||
)
|
||||
}
|
||||
|
||||
function compactTerminalText(buffer: string): string {
|
||||
// Why: Claude positions TUI words with cursor-column escapes instead of
|
||||
// emitting literal spaces, so matching must not depend on rendered gaps.
|
||||
return stripScrollbackAnsi(buffer).replace(/\s+/g, '').toLowerCase()
|
||||
}
|
||||
|
||||
function hasClaudeModelSwitchSuccess(buffer: string, modelLabel: string): boolean {
|
||||
const text = compactTerminalText(buffer)
|
||||
const marker = `setmodelto${modelLabel.replace(/\s+/g, '').toLowerCase()}`
|
||||
return text.includes(marker)
|
||||
}
|
||||
|
||||
function hasClaudeModelSwitchRejection(buffer: string): boolean {
|
||||
return compactTerminalText(buffer).includes('keptmodelas')
|
||||
}
|
||||
|
||||
function hasClaudeModelSwitchInteraction(buffer: string): boolean {
|
||||
const text = compactTerminalText(buffer)
|
||||
return (
|
||||
text.includes('fable5usesusagecreditsandneedsaone-timeconsent') ||
|
||||
text.includes('pickfablefrom/modelinaninteractivesessiontosetitup') ||
|
||||
(text.includes('switchtofable5?') && text.includes('usagecredits'))
|
||||
)
|
||||
}
|
||||
|
||||
function subscribeToClaudeModelSwitchData(args: {
|
||||
ptyId: string
|
||||
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
|
||||
subscribeToData?: SubscribeToData
|
||||
watcher: (data: string) => void
|
||||
}): Promise<() => void> | (() => void) {
|
||||
if (args.subscribeToData) {
|
||||
return args.subscribeToData(args.watcher)
|
||||
}
|
||||
if (isRemoteRuntimePtyId(args.ptyId)) {
|
||||
return subscribeToRuntimeTerminalData(
|
||||
args.settings,
|
||||
args.ptyId,
|
||||
`desktop:native-chat-model-switch:${args.ptyId}`,
|
||||
args.watcher,
|
||||
{ startAtLiveTail: true }
|
||||
)
|
||||
}
|
||||
return subscribeToPtyData(args.ptyId, args.watcher)
|
||||
}
|
||||
|
||||
export function createClaudeModelSwitchConfirmationObserver(args: {
|
||||
ptyId: string
|
||||
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
|
||||
expectedModelLabel: string | null
|
||||
subscribeToData?: SubscribeToData
|
||||
submitConfirmation?: () => boolean | void
|
||||
timeoutMs?: number
|
||||
}): ClaudeModelSwitchConfirmationObserver {
|
||||
let armed = false
|
||||
let settled = false
|
||||
let confirmationSubmitted = false
|
||||
let observed = ''
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
let unsubscribe: (() => void) | null = null
|
||||
let resolveResult!: (outcome: ClaudeModelSwitchOutcome) => void
|
||||
let resolveReady!: () => void
|
||||
const result = new Promise<ClaudeModelSwitchOutcome>((resolve) => {
|
||||
resolveResult = resolve
|
||||
})
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
resolveReady = resolve
|
||||
})
|
||||
|
||||
const finish = (outcome: ClaudeModelSwitchOutcome): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
if (timeout !== null) {
|
||||
clearTimeout(timeout)
|
||||
timeout = null
|
||||
}
|
||||
unsubscribe?.()
|
||||
unsubscribe = null
|
||||
resolveResult(outcome)
|
||||
}
|
||||
|
||||
const scheduleTimeout = (): void => {
|
||||
if (timeout !== null) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
timeout = setTimeout(() => finish('unknown'), args.timeoutMs ?? DETECTION_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
const observeData = (data: string): void => {
|
||||
if (!armed || settled) {
|
||||
return
|
||||
}
|
||||
observed = `${observed}${data}`.slice(-MAX_OBSERVED_BYTES)
|
||||
if (args.expectedModelLabel && hasClaudeModelSwitchSuccess(observed, args.expectedModelLabel)) {
|
||||
finish('applied')
|
||||
return
|
||||
}
|
||||
if (hasClaudeModelSwitchRejection(observed)) {
|
||||
finish('rejected')
|
||||
return
|
||||
}
|
||||
if (hasClaudeModelSwitchInteraction(observed)) {
|
||||
finish('interaction-required')
|
||||
return
|
||||
}
|
||||
if (!confirmationSubmitted && hasClaudeModelSwitchConfirmation(observed)) {
|
||||
confirmationSubmitted = true
|
||||
try {
|
||||
// Why: the picker selection already expresses consent to switch; this
|
||||
// exact Claude warning defaults to “Yes” and needs only one Enter.
|
||||
const accepted = args.submitConfirmation
|
||||
? args.submitConfirmation() !== false
|
||||
: sendRuntimePtyInput(args.settings, args.ptyId, NATIVE_CHAT_SUBMIT)
|
||||
if (!accepted) {
|
||||
finish('unknown')
|
||||
return
|
||||
}
|
||||
scheduleTimeout()
|
||||
} catch {
|
||||
finish('unknown')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const subscription = subscribeToClaudeModelSwitchData({
|
||||
ptyId: args.ptyId,
|
||||
settings: args.settings,
|
||||
subscribeToData: args.subscribeToData,
|
||||
watcher: observeData
|
||||
})
|
||||
void Promise.resolve(subscription)
|
||||
.then((dispose) => {
|
||||
if (settled) {
|
||||
dispose()
|
||||
} else {
|
||||
unsubscribe = dispose
|
||||
}
|
||||
})
|
||||
.catch(() => finish('unknown'))
|
||||
.finally(resolveReady)
|
||||
} catch {
|
||||
finish('unknown')
|
||||
resolveReady()
|
||||
}
|
||||
|
||||
return {
|
||||
ready,
|
||||
result,
|
||||
arm: () => {
|
||||
if (settled || armed) {
|
||||
return
|
||||
}
|
||||
armed = true
|
||||
},
|
||||
startDetection: () => {
|
||||
// Why: measure the detection window from when the command is actually
|
||||
// delivered, not from arm(). On SSH/remote the body+Enter round-trips can
|
||||
// otherwise burn the timeout before the agent has even responded, turning
|
||||
// a successful switch into a false "could not verify the model change".
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
scheduleTimeout()
|
||||
},
|
||||
dispose: () => finish('unknown')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readClaudeSessionOptionsFromTerminalScreen } from './claude-terminal-session-options'
|
||||
|
||||
describe('Claude terminal session option detection', () => {
|
||||
it('reads the current model and effort from Claude header chrome', () => {
|
||||
const screen =
|
||||
'\u001b[1mClaude Code\u001b[0m v2.1.211\r\n' +
|
||||
'\u001b[38;2;102;102;102mOpus 4.8 with high effort · API Usage Billing\r\n' +
|
||||
'~/Documents/projects/orca'
|
||||
|
||||
expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({
|
||||
model: 'opus',
|
||||
effort: 'high'
|
||||
})
|
||||
})
|
||||
|
||||
it('reads a Claude header whose xterm serialization joins the version to the title', () => {
|
||||
const screen =
|
||||
'\u001b[?1049h\u001b[H▐▛███▜▌Claude Codev2.1.211\r\n' +
|
||||
'▝▜█████▛▘Sonnet 5 with medium effort · API Usage Billing'
|
||||
|
||||
expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({
|
||||
model: 'sonnet',
|
||||
effort: 'medium'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not mistake old conversation output for the current model', () => {
|
||||
const screen =
|
||||
'Set model to Opus 4.8 and saved as your default\r\n' +
|
||||
'Claude Code v2.1.211\r\n' +
|
||||
'Sonnet 5 with medium effort · API Usage Billing'
|
||||
|
||||
expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({
|
||||
model: 'sonnet',
|
||||
effort: 'medium'
|
||||
})
|
||||
})
|
||||
|
||||
it('reports an option-less Haiku model without inventing effort', () => {
|
||||
expect(
|
||||
readClaudeSessionOptionsFromTerminalScreen(
|
||||
'Claude Code v2.1.211\r\nHaiku · API Usage Billing\r\n~/repo'
|
||||
)
|
||||
).toEqual({ model: 'haiku' })
|
||||
})
|
||||
|
||||
it('ignores text without Claude header chrome', () => {
|
||||
expect(
|
||||
readClaudeSessionOptionsFromTerminalScreen('I recommend Opus 4.8 for this task.')
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import { getAgentSessionOptionCatalog } from '../../../../shared/agent-session-option-catalog'
|
||||
import type { SessionOptionValue } from '../../../../shared/native-chat-session-options'
|
||||
import { stripScrollbackAnsi } from './native-chat-scrape-fallback'
|
||||
|
||||
const EFFORT_ID_BY_LABEL: Record<string, string> = {
|
||||
low: 'low',
|
||||
medium: 'medium',
|
||||
high: 'high',
|
||||
'extra high': 'xhigh',
|
||||
xhigh: 'xhigh',
|
||||
max: 'max'
|
||||
}
|
||||
|
||||
function normalizedScreenLines(screen: string): string[] {
|
||||
return stripScrollbackAnsi(screen)
|
||||
.split('\n')
|
||||
.map((line) => line.replace(/\s+/g, ' ').trim())
|
||||
}
|
||||
|
||||
export function readClaudeSessionOptionsFromTerminalScreen(
|
||||
screen: string | null | undefined
|
||||
): Record<string, SessionOptionValue> | null {
|
||||
if (!screen) {
|
||||
return null
|
||||
}
|
||||
const lines = normalizedScreenLines(screen)
|
||||
const headerIndex = lines.findIndex((line) =>
|
||||
// xterm serialization can remove cursor-positioning cells between the
|
||||
// product name and version, producing `Claude Codev2.1.211`.
|
||||
/\bClaude Code\s*v?\d+(?:\.\d+){1,2}\b/i.test(line)
|
||||
)
|
||||
if (headerIndex < 0) {
|
||||
return null
|
||||
}
|
||||
// Why: only Claude's fixed header rows describe live state; conversation
|
||||
// text and old command confirmations elsewhere in the buffer can be stale.
|
||||
const header = lines.slice(headerIndex, headerIndex + 3).join(' ')
|
||||
const catalog = getAgentSessionOptionCatalog('claude')
|
||||
const model = [...(catalog?.models ?? [])]
|
||||
.sort((left, right) => right.label.length - left.label.length)
|
||||
.find(({ label }) => header.toLowerCase().includes(label.toLowerCase()))
|
||||
if (!model) {
|
||||
return null
|
||||
}
|
||||
const result: Record<string, SessionOptionValue> = { model: model.id }
|
||||
const effortLabel = header.match(
|
||||
/\bwith\s+(low|medium|high|extra high|xhigh|max)\s+effort\b/i
|
||||
)?.[1]
|
||||
const effort = effortLabel ? EFFORT_ID_BY_LABEL[effortLabel.toLowerCase()] : undefined
|
||||
if (effort && model.options.some((option) => option.id === 'effort')) {
|
||||
result.effort = effort
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
clearNativeChatSessionOptionCacheForTests,
|
||||
seedNativeChatAppliedSessionOptions
|
||||
} from './native-chat-session-option-cache'
|
||||
import { createNativeChatPtySessionOptions } from './native-chat-pty-session-options'
|
||||
|
||||
describe('native chat PTY session options', () => {
|
||||
beforeEach(() => clearNativeChatSessionOptionCacheForTests())
|
||||
|
||||
it('starts attached sessions unknown and hides model-scoped options', () => {
|
||||
const surface = createNativeChatPtySessionOptions({
|
||||
agent: 'claude',
|
||||
scopeKey: 'pty-1',
|
||||
mode: 'live',
|
||||
dispatchCommand: vi.fn()
|
||||
})!
|
||||
expect(surface.getSnapshot()).toHaveLength(1)
|
||||
expect(surface.getSnapshot()[0]).toMatchObject({
|
||||
id: 'model',
|
||||
valueSource: 'unknown'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses model and effort reported by the live Claude terminal', () => {
|
||||
const surface = createNativeChatPtySessionOptions({
|
||||
agent: 'claude',
|
||||
scopeKey: 'pty-1',
|
||||
mode: 'live',
|
||||
reportedValues: { model: 'opus', effort: 'medium' },
|
||||
dispatchCommand: vi.fn()
|
||||
})!
|
||||
|
||||
expect(surface.getSnapshot()).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'model',
|
||||
valueSource: 'reported',
|
||||
kind: expect.objectContaining({ currentValue: 'opus' })
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'effort',
|
||||
valueSource: 'reported',
|
||||
kind: expect.objectContaining({ currentValue: 'medium' })
|
||||
})
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('restores launch-backed values through the tab-to-PTY cache handoff', () => {
|
||||
seedNativeChatAppliedSessionOptions('tab-1', 'claude', {
|
||||
model: 'opus',
|
||||
effort: 'xhigh'
|
||||
})
|
||||
const surface = createNativeChatPtySessionOptions({
|
||||
agent: 'claude',
|
||||
scopeKey: 'pty-1',
|
||||
fallbackScopeKey: 'tab-1',
|
||||
mode: 'live',
|
||||
dispatchCommand: vi.fn()
|
||||
})!
|
||||
expect(surface.getSnapshot()).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: 'model', valueSource: 'applied' }),
|
||||
expect.objectContaining({ id: 'effort', valueSource: 'applied' }),
|
||||
expect.objectContaining({ id: 'fastMode', valueSource: 'unknown' })
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('dispatches a Claude effort setter and publishes the full snapshot', async () => {
|
||||
seedNativeChatAppliedSessionOptions('pty-1', 'claude', {
|
||||
model: 'opus',
|
||||
effort: 'xhigh'
|
||||
})
|
||||
const dispatch = vi.fn()
|
||||
const persist = vi.fn()
|
||||
const listener = vi.fn()
|
||||
const surface = createNativeChatPtySessionOptions({
|
||||
agent: 'claude',
|
||||
scopeKey: 'pty-1',
|
||||
mode: 'live',
|
||||
dispatchCommand: dispatch,
|
||||
persistSelection: persist
|
||||
})!
|
||||
surface.subscribe(listener)
|
||||
|
||||
const effortResult = await surface.setOption('effort', 'high')
|
||||
expect(dispatch).toHaveBeenCalledWith('/effort high')
|
||||
expect(effortResult.snapshot.map(({ id }) => id)).toEqual(['model', 'effort', 'fastMode'])
|
||||
expect(effortResult.snapshot.find(({ id }) => id === 'effort')).toMatchObject({
|
||||
valueSource: 'dispatched',
|
||||
kind: { currentValue: 'high' }
|
||||
})
|
||||
expect(listener).toHaveBeenCalledOnce()
|
||||
expect(listener.mock.calls.every(([snapshot]) => Array.isArray(snapshot))).toBe(true)
|
||||
expect(persist).toHaveBeenCalledWith({
|
||||
modelId: 'opus',
|
||||
optionId: 'effort',
|
||||
value: 'high'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a normal Claude model choice native and dispatches the selected model', async () => {
|
||||
seedNativeChatAppliedSessionOptions('pty-1', 'claude', {
|
||||
model: 'sonnet',
|
||||
effort: 'high'
|
||||
})
|
||||
const dispatch = vi.fn().mockResolvedValue({ outcome: 'applied' })
|
||||
const onAgentPicker = vi.fn()
|
||||
const surface = createNativeChatPtySessionOptions({
|
||||
agent: 'claude',
|
||||
scopeKey: 'pty-1',
|
||||
mode: 'live',
|
||||
dispatchCommand: dispatch,
|
||||
onAgentPicker
|
||||
})!
|
||||
expect(surface.getSnapshot()[0]?.action).toBeUndefined()
|
||||
|
||||
const result = await surface.setOption('model', 'fable')
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith('/model fable', {
|
||||
detectAgentInteraction: 'claude-model-switch-confirmation',
|
||||
expectedChoiceLabel: 'Fable 5'
|
||||
})
|
||||
expect(onAgentPicker).not.toHaveBeenCalled()
|
||||
expect(result.snapshot[0]).toMatchObject({
|
||||
valueSource: 'dispatched',
|
||||
kind: { currentValue: 'fable' }
|
||||
})
|
||||
})
|
||||
|
||||
it('reveals the terminal only when Claude actually requires model-switch interaction', async () => {
|
||||
seedNativeChatAppliedSessionOptions('pty-1', 'claude', { model: 'sonnet' })
|
||||
const dispatch = vi.fn().mockResolvedValue({ outcome: 'interaction-required' })
|
||||
const onAgentPicker = vi.fn()
|
||||
const surface = createNativeChatPtySessionOptions({
|
||||
agent: 'claude',
|
||||
scopeKey: 'pty-1',
|
||||
mode: 'live',
|
||||
dispatchCommand: dispatch,
|
||||
onAgentPicker
|
||||
})!
|
||||
|
||||
const result = await surface.setOption('model', 'haiku')
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith('/model haiku', {
|
||||
detectAgentInteraction: 'claude-model-switch-confirmation',
|
||||
expectedChoiceLabel: 'Haiku'
|
||||
})
|
||||
expect(onAgentPicker).toHaveBeenCalledOnce()
|
||||
expect(result.snapshot[0]).toMatchObject({ valueSource: 'unknown' })
|
||||
})
|
||||
|
||||
it('keeps the prior model and persistence when Claude rejects the switch', async () => {
|
||||
seedNativeChatAppliedSessionOptions('pty-1', 'claude', {
|
||||
model: 'fable',
|
||||
effort: 'high'
|
||||
})
|
||||
const persist = vi.fn()
|
||||
const onAgentPicker = vi.fn()
|
||||
const surface = createNativeChatPtySessionOptions({
|
||||
agent: 'claude',
|
||||
scopeKey: 'pty-1',
|
||||
mode: 'live',
|
||||
dispatchCommand: vi.fn().mockResolvedValue({ outcome: 'rejected' }),
|
||||
persistSelection: persist,
|
||||
onAgentPicker
|
||||
})!
|
||||
|
||||
await expect(surface.setOption('model', 'haiku')).rejects.toThrow(
|
||||
'Claude kept the current model.'
|
||||
)
|
||||
|
||||
expect(surface.getSnapshot()[0]).toMatchObject({
|
||||
valueSource: 'applied',
|
||||
kind: { currentValue: 'fable' }
|
||||
})
|
||||
expect(persist).not.toHaveBeenCalled()
|
||||
expect(onAgentPicker).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stays native and clears stale truth when the switch cannot be verified', async () => {
|
||||
seedNativeChatAppliedSessionOptions('pty-1', 'claude', {
|
||||
model: 'fable',
|
||||
effort: 'high'
|
||||
})
|
||||
const persist = vi.fn()
|
||||
const onAgentPicker = vi.fn()
|
||||
const surface = createNativeChatPtySessionOptions({
|
||||
agent: 'claude',
|
||||
scopeKey: 'pty-1',
|
||||
mode: 'live',
|
||||
dispatchCommand: vi.fn().mockResolvedValue({ outcome: 'unknown' }),
|
||||
persistSelection: persist,
|
||||
onAgentPicker
|
||||
})!
|
||||
|
||||
await expect(surface.setOption('model', 'haiku')).rejects.toThrow(
|
||||
'Could not verify the model change; open the terminal to check.'
|
||||
)
|
||||
|
||||
expect(surface.getSnapshot()).toHaveLength(1)
|
||||
expect(surface.getSnapshot()[0]).toMatchObject({ valueSource: 'unknown' })
|
||||
expect(persist).not.toHaveBeenCalled()
|
||||
expect(onAgentPicker).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps an unknown toggle unknown after the one-shot action', async () => {
|
||||
seedNativeChatAppliedSessionOptions('pty-1', 'claude', {
|
||||
model: 'opus',
|
||||
effort: 'high'
|
||||
})
|
||||
const dispatch = vi.fn()
|
||||
const surface = createNativeChatPtySessionOptions({
|
||||
agent: 'claude',
|
||||
scopeKey: 'pty-1',
|
||||
mode: 'live',
|
||||
dispatchCommand: dispatch
|
||||
})!
|
||||
const fastBefore = surface.getSnapshot().find(({ id }) => id === 'fastMode')
|
||||
expect(fastBefore?.action?.type).toBe('toggle-command')
|
||||
|
||||
const result = await surface.setOption('fastMode', true)
|
||||
expect(dispatch).toHaveBeenCalledWith('/fast')
|
||||
expect(result.snapshot.find(({ id }) => id === 'fastMode')).toMatchObject({
|
||||
valueSource: 'unknown',
|
||||
action: { type: 'toggle-command' }
|
||||
})
|
||||
})
|
||||
|
||||
it('hands Codex model changes to the TUI picker and drops stale truth', async () => {
|
||||
seedNativeChatAppliedSessionOptions('pty-1', 'codex', {
|
||||
model: 'gpt-5.5',
|
||||
effort: 'high'
|
||||
})
|
||||
const dispatch = vi.fn()
|
||||
const onAgentPicker = vi.fn()
|
||||
const surface = createNativeChatPtySessionOptions({
|
||||
agent: 'codex',
|
||||
scopeKey: 'pty-1',
|
||||
mode: 'live',
|
||||
dispatchCommand: dispatch,
|
||||
onAgentPicker
|
||||
})!
|
||||
expect(surface.getSnapshot().find(({ id }) => id === 'effort')?.action?.type).toBe(
|
||||
'agent-picker'
|
||||
)
|
||||
|
||||
const result = await surface.setOption('effort', 'xhigh')
|
||||
expect(dispatch).toHaveBeenCalledWith('/model')
|
||||
expect(onAgentPicker).toHaveBeenCalledOnce()
|
||||
expect(result.snapshot).toHaveLength(1)
|
||||
expect(result.snapshot[0]).toMatchObject({ valueSource: 'unknown' })
|
||||
})
|
||||
|
||||
it('tracks typed effort commands and downgrades typed toggles', () => {
|
||||
seedNativeChatAppliedSessionOptions('pty-1', 'claude', {
|
||||
model: 'opus',
|
||||
effort: 'xhigh'
|
||||
})
|
||||
const surface = createNativeChatPtySessionOptions({
|
||||
agent: 'claude',
|
||||
scopeKey: 'pty-1',
|
||||
mode: 'live',
|
||||
dispatchCommand: vi.fn()
|
||||
})!
|
||||
surface.recordOutgoingCommand('/effort high')
|
||||
expect(surface.getSnapshot().find(({ id }) => id === 'effort')).toMatchObject({
|
||||
valueSource: 'dispatched',
|
||||
kind: { currentValue: 'high' }
|
||||
})
|
||||
surface.recordOutgoingCommand('/fast')
|
||||
expect(surface.getSnapshot().find(({ id }) => id === 'fastMode')).toMatchObject({
|
||||
valueSource: 'unknown'
|
||||
})
|
||||
})
|
||||
|
||||
it('switches to the terminal and drops stale truth for a typed picker command', () => {
|
||||
seedNativeChatAppliedSessionOptions('pty-1', 'claude', {
|
||||
model: 'opus',
|
||||
effort: 'xhigh'
|
||||
})
|
||||
const onAgentPicker = vi.fn()
|
||||
const surface = createNativeChatPtySessionOptions({
|
||||
agent: 'claude',
|
||||
scopeKey: 'pty-1',
|
||||
mode: 'live',
|
||||
dispatchCommand: vi.fn(),
|
||||
onAgentPicker
|
||||
})!
|
||||
|
||||
surface.recordOutgoingCommand('/model')
|
||||
|
||||
expect(onAgentPicker).toHaveBeenCalledOnce()
|
||||
expect(surface.getSnapshot()).toHaveLength(1)
|
||||
expect(surface.getSnapshot()[0]).toMatchObject({ valueSource: 'unknown' })
|
||||
})
|
||||
|
||||
it('passes an unknown persisted model through as a literal choice', () => {
|
||||
seedNativeChatAppliedSessionOptions('pty-1', 'claude', {
|
||||
model: 'future-model'
|
||||
})
|
||||
const surface = createNativeChatPtySessionOptions({
|
||||
agent: 'claude',
|
||||
scopeKey: 'pty-1',
|
||||
mode: 'live',
|
||||
dispatchCommand: vi.fn()
|
||||
})!
|
||||
const model = surface.getSnapshot()[0]
|
||||
expect(model.kind).toMatchObject({
|
||||
currentValue: 'future-model',
|
||||
choices: expect.arrayContaining([{ value: 'future-model', label: 'future-model' }])
|
||||
})
|
||||
})
|
||||
|
||||
it('recomposes Cursor model slugs for live option changes', async () => {
|
||||
seedNativeChatAppliedSessionOptions('pty-1', 'cursor', {
|
||||
model: 'gpt-5.3-codex',
|
||||
effort: 'medium',
|
||||
fastMode: true
|
||||
})
|
||||
const dispatch = vi.fn()
|
||||
const surface = createNativeChatPtySessionOptions({
|
||||
agent: 'cursor',
|
||||
scopeKey: 'pty-1',
|
||||
mode: 'live',
|
||||
dispatchCommand: dispatch
|
||||
})!
|
||||
expect(surface.getSnapshot().find(({ id }) => id === 'effort')?.settable).toBe(true)
|
||||
|
||||
await surface.setOption('effort', 'high')
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith('/model gpt-5.3-codex-high-fast')
|
||||
expect(surface.getSnapshot().find(({ id }) => id === 'effort')).toMatchObject({
|
||||
valueSource: 'dispatched',
|
||||
kind: { currentValue: 'high' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,276 @@
|
||||
import {
|
||||
findCatalogModel,
|
||||
findCatalogOption,
|
||||
getAgentSessionOptionCatalog,
|
||||
type CatalogMidSessionApply,
|
||||
type CatalogModel,
|
||||
type CatalogOptionApply
|
||||
} from '../../../../shared/agent-session-option-catalog'
|
||||
import type { AgentType } from '../../../../shared/agent-status-types'
|
||||
import type {
|
||||
SessionOptionDescriptor,
|
||||
SessionOptionsSurface,
|
||||
SessionOptionValue
|
||||
} from '../../../../shared/native-chat-session-options'
|
||||
import {
|
||||
createNativeChatSessionOptionRecord,
|
||||
readNativeChatSessionOptionCache,
|
||||
writeNativeChatSessionOptionCache
|
||||
} from './native-chat-session-option-cache'
|
||||
import {
|
||||
buildNativeChatSessionOptionSnapshot,
|
||||
flattenNativeChatSessionOptionRecord,
|
||||
type NativeChatSessionOptionMode
|
||||
} from './native-chat-session-option-snapshot'
|
||||
import { buildNativeChatSessionOptionCommand } from './native-chat-session-option-command-builder'
|
||||
import type {
|
||||
NativeChatSessionOptionDispatchCommand,
|
||||
NativeChatSessionOptionDispatchResult
|
||||
} from './native-chat-session-option-command-dispatch'
|
||||
import { applyNativeChatReportedSessionOptions } from './native-chat-session-option-reporting'
|
||||
import { recordNativeChatSessionOptionCommand } from './native-chat-session-option-command-recording'
|
||||
|
||||
type PersistSelection = (args: {
|
||||
modelId: string
|
||||
optionId: string
|
||||
value: SessionOptionValue
|
||||
}) => Promise<void> | void
|
||||
|
||||
export type NativeChatPtySessionOptionsSurface = SessionOptionsSurface & {
|
||||
recordOutgoingCommand(command: string): void
|
||||
reportSessionOptions(values: Record<string, SessionOptionValue>): void
|
||||
replaceModels(models: CatalogModel[]): void
|
||||
}
|
||||
|
||||
export type CreateNativeChatPtySessionOptionsArgs = {
|
||||
agent: AgentType
|
||||
scopeKey: string
|
||||
fallbackScopeKey?: string
|
||||
initialModels?: readonly CatalogModel[]
|
||||
mode: NativeChatSessionOptionMode
|
||||
reportedValues?: Record<string, SessionOptionValue> | null
|
||||
dispatchCommand: NativeChatSessionOptionDispatchCommand
|
||||
onAgentPicker?: () => void
|
||||
persistSelection?: PersistSelection
|
||||
onDraftValuesChanged?: (values: Record<string, SessionOptionValue>) => void
|
||||
}
|
||||
|
||||
export function createNativeChatPtySessionOptions(
|
||||
args: CreateNativeChatPtySessionOptionsArgs
|
||||
): NativeChatPtySessionOptionsSurface | null {
|
||||
const catalog = getAgentSessionOptionCatalog(args.agent)
|
||||
if (!catalog) {
|
||||
return null
|
||||
}
|
||||
let models = [...(args.initialModels ?? catalog.models)]
|
||||
let record =
|
||||
readNativeChatSessionOptionCache(args.scopeKey, args.fallbackScopeKey) ??
|
||||
createNativeChatSessionOptionRecord(args.agent)
|
||||
if (record.agent !== args.agent) {
|
||||
record = createNativeChatSessionOptionRecord(args.agent)
|
||||
}
|
||||
|
||||
if (args.reportedValues && applyNativeChatReportedSessionOptions(record, args.reportedValues)) {
|
||||
writeNativeChatSessionOptionCache(args.scopeKey, record)
|
||||
}
|
||||
let snapshot = buildNativeChatSessionOptionSnapshot({
|
||||
catalog,
|
||||
models,
|
||||
record,
|
||||
mode: args.mode
|
||||
})
|
||||
const listeners = new Set<(value: SessionOptionDescriptor[]) => void>()
|
||||
|
||||
const publish = (): SessionOptionDescriptor[] => {
|
||||
writeNativeChatSessionOptionCache(args.scopeKey, record)
|
||||
snapshot = buildNativeChatSessionOptionSnapshot({
|
||||
catalog,
|
||||
models,
|
||||
record,
|
||||
mode: args.mode
|
||||
})
|
||||
for (const listener of listeners) {
|
||||
listener(snapshot)
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
const clearModelTruth = (): void => {
|
||||
const modelId = typeof record.model?.value === 'string' ? record.model.value : null
|
||||
record.model = undefined
|
||||
if (modelId) {
|
||||
delete record.valuesByModel[modelId]
|
||||
}
|
||||
}
|
||||
|
||||
const setTrackedValue = (
|
||||
optionId: string,
|
||||
value: SessionOptionValue,
|
||||
source: 'applied' | 'dispatched'
|
||||
): string | null => {
|
||||
if (optionId === 'model') {
|
||||
record.model = { value, source }
|
||||
return typeof value === 'string' ? value : null
|
||||
}
|
||||
const modelId = typeof record.model?.value === 'string' ? record.model.value : null
|
||||
if (!modelId) {
|
||||
return null
|
||||
}
|
||||
record.valuesByModel[modelId] = {
|
||||
...record.valuesByModel[modelId],
|
||||
[optionId]: { value, source }
|
||||
}
|
||||
return modelId
|
||||
}
|
||||
|
||||
const persist = (modelId: string | null, optionId: string, value: SessionOptionValue): void => {
|
||||
if (modelId) {
|
||||
void args.persistSelection?.({ modelId, optionId, value })
|
||||
}
|
||||
}
|
||||
|
||||
const currentApply = (
|
||||
optionId: string
|
||||
): { apply: CatalogOptionApply; modelId: string | null } | null => {
|
||||
const modelId = typeof record.model?.value === 'string' ? record.model.value : null
|
||||
if (optionId === 'model') {
|
||||
return { apply: catalog.modelApply, modelId }
|
||||
}
|
||||
const model = modelId ? findCatalogModel({ ...catalog, models }, modelId) : undefined
|
||||
const option = findCatalogOption(model, optionId)
|
||||
return option ? { apply: option.apply, modelId } : null
|
||||
}
|
||||
|
||||
const handleAgentPicker = async (midSession: CatalogMidSessionApply): Promise<void> => {
|
||||
if (midSession.kind !== 'agent-picker') {
|
||||
return
|
||||
}
|
||||
await args.dispatchCommand(midSession.command)
|
||||
clearModelTruth()
|
||||
publish()
|
||||
args.onAgentPicker?.()
|
||||
}
|
||||
|
||||
const setOption = async (id: string, value: SessionOptionValue) => {
|
||||
const resolved = currentApply(id)
|
||||
if (!resolved) {
|
||||
throw new Error(`Unknown session option: ${id}`)
|
||||
}
|
||||
const { apply, modelId: previousModelId } = resolved
|
||||
if (args.mode === 'live' && apply.midSession?.kind === 'agent-picker') {
|
||||
await handleAgentPicker(apply.midSession)
|
||||
return { snapshot }
|
||||
}
|
||||
const source = args.mode === 'live' ? 'dispatched' : 'applied'
|
||||
let dispatchResult: NativeChatSessionOptionDispatchResult | void = undefined
|
||||
const toggleWasKnown =
|
||||
apply.midSession?.kind === 'toggle-command' && previousModelId
|
||||
? record.valuesByModel[previousModelId]?.[id] !== undefined
|
||||
: false
|
||||
if (args.mode === 'live') {
|
||||
const command = buildNativeChatSessionOptionCommand({
|
||||
optionId: id,
|
||||
value,
|
||||
apply,
|
||||
modelId: previousModelId,
|
||||
catalog,
|
||||
models,
|
||||
record
|
||||
})
|
||||
if (!command) {
|
||||
throw new Error('This option can only be set when the session starts.')
|
||||
}
|
||||
const detectAgentInteraction =
|
||||
apply.midSession?.kind === 'command'
|
||||
? apply.midSession.detectAgentInteraction
|
||||
: apply.composedIntoModel && catalog.modelApply.midSession?.kind === 'command'
|
||||
? catalog.modelApply.midSession.detectAgentInteraction
|
||||
: undefined
|
||||
const expectedChoiceLabel =
|
||||
id === 'model' && typeof value === 'string'
|
||||
? (findCatalogModel({ ...catalog, models }, value)?.label ?? value)
|
||||
: undefined
|
||||
dispatchResult = detectAgentInteraction
|
||||
? await args.dispatchCommand(command, {
|
||||
detectAgentInteraction,
|
||||
expectedChoiceLabel
|
||||
})
|
||||
: await args.dispatchCommand(command)
|
||||
} else if (!apply.launchArgs && !apply.composedIntoModel) {
|
||||
throw new Error('This option is only available after the session starts.')
|
||||
}
|
||||
|
||||
if (dispatchResult?.outcome === 'rejected') {
|
||||
throw new Error('Claude kept the current model.')
|
||||
}
|
||||
if (dispatchResult?.outcome === 'unknown') {
|
||||
clearModelTruth()
|
||||
publish()
|
||||
throw new Error('Could not verify the model change; open the terminal to check.')
|
||||
}
|
||||
if (dispatchResult?.outcome === 'interaction-required') {
|
||||
clearModelTruth()
|
||||
publish()
|
||||
args.onAgentPicker?.()
|
||||
return { snapshot }
|
||||
}
|
||||
|
||||
if (apply.midSession?.kind === 'toggle-command' && !toggleWasKnown) {
|
||||
return { snapshot: publish() }
|
||||
}
|
||||
if (id === 'model' && previousModelId !== value) {
|
||||
record.model = undefined
|
||||
if (args.mode === 'live' && typeof value === 'string') {
|
||||
// Why: switching models can reset its effort/toggles. A value cached
|
||||
// from an earlier visit to that model is no longer live evidence.
|
||||
delete record.valuesByModel[value]
|
||||
}
|
||||
}
|
||||
const modelId = setTrackedValue(id, value, source)
|
||||
if (apply.midSession?.kind === 'toggle-command' && previousModelId && source === 'dispatched') {
|
||||
record.valuesByModel[previousModelId] = {
|
||||
...record.valuesByModel[previousModelId],
|
||||
[id]: { value, source }
|
||||
}
|
||||
}
|
||||
persist(modelId ?? previousModelId, id, value)
|
||||
const next = publish()
|
||||
if (args.mode === 'draft' && typeof record.model?.value === 'string') {
|
||||
args.onDraftValuesChanged?.(flattenNativeChatSessionOptionRecord(record, record.model.value))
|
||||
}
|
||||
return { snapshot: next }
|
||||
}
|
||||
|
||||
return {
|
||||
getSnapshot: () => snapshot,
|
||||
setOption,
|
||||
subscribe: (listener) => {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
},
|
||||
recordOutgoingCommand: (command) => {
|
||||
const result = recordNativeChatSessionOptionCommand({
|
||||
catalog,
|
||||
models,
|
||||
record,
|
||||
command,
|
||||
persist
|
||||
})
|
||||
if (result.changed) {
|
||||
publish()
|
||||
}
|
||||
if (result.opensAgentPicker) {
|
||||
args.onAgentPicker?.()
|
||||
}
|
||||
},
|
||||
reportSessionOptions: (values) => {
|
||||
if (applyNativeChatReportedSessionOptions(record, values)) {
|
||||
publish()
|
||||
}
|
||||
},
|
||||
replaceModels: (nextModels) => {
|
||||
models = [...nextModels]
|
||||
publish()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,15 @@ 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()
|
||||
const sendRuntimePtyInputVerified = vi.fn()
|
||||
vi.mock('@/runtime/runtime-terminal-inspection', () => ({
|
||||
sendRuntimePtyInput: (...args: unknown[]) => sendRuntimePtyInput(...args)
|
||||
sendRuntimePtyInput: (...args: unknown[]) => sendRuntimePtyInput(...args),
|
||||
sendRuntimePtyInputVerified: (...args: unknown[]) => sendRuntimePtyInputVerified(...args)
|
||||
}))
|
||||
|
||||
import {
|
||||
sendNativeChatMessage,
|
||||
sendNativeChatMessageVerified,
|
||||
sendNativeChatMessageWithImageAttachments,
|
||||
submitNativeChatPrompt,
|
||||
sendNativeChatAskAnswer,
|
||||
@@ -75,6 +78,52 @@ describe('sendNativeChatMessage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('sendNativeChatMessageVerified', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
sendRuntimePtyInputVerified.mockReset().mockResolvedValue(true)
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('awaits body acceptance before the delayed Enter write', async () => {
|
||||
const result = sendNativeChatMessageVerified(SETTINGS, PTY, '/model sonnet')
|
||||
await Promise.resolve()
|
||||
|
||||
expect(sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1)
|
||||
expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(
|
||||
SETTINGS,
|
||||
PTY,
|
||||
buildNativeChatPasteBytes('/model sonnet')
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(NATIVE_CHAT_SUBMIT_DELAY_MS)
|
||||
|
||||
expect(await result).toBe(true)
|
||||
expect(sendRuntimePtyInputVerified).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT)
|
||||
})
|
||||
|
||||
it('does not send Enter when the body is rejected', async () => {
|
||||
sendRuntimePtyInputVerified.mockResolvedValueOnce(false)
|
||||
|
||||
await expect(sendNativeChatMessageVerified(SETTINGS, PTY, '/model sonnet')).resolves.toBe(false)
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('cancels the pending Enter when its composer detaches', async () => {
|
||||
const controller = new AbortController()
|
||||
const result = sendNativeChatMessageVerified(SETTINGS, PTY, '/model sonnet', controller.signal)
|
||||
await Promise.resolve()
|
||||
controller.abort()
|
||||
|
||||
expect(await result).toBe(false)
|
||||
expect(sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sendNativeChatMessageWithImageAttachments', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
// 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 {
|
||||
sendRuntimePtyInput,
|
||||
sendRuntimePtyInputVerified
|
||||
} from '@/runtime/runtime-terminal-inspection'
|
||||
import type { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft'
|
||||
import type { AskAnswerKeyGroup } from './native-chat-interactive-prompt'
|
||||
import {
|
||||
@@ -20,7 +23,6 @@ export { NATIVE_CHAT_ADVANCE_BUFFER_MS, NATIVE_CHAT_QUESTION_STEP_MS, NATIVE_CHA
|
||||
|
||||
export const NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS = 300
|
||||
|
||||
|
||||
/** 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 = {
|
||||
@@ -47,6 +49,46 @@ export function sendNativeChatMessage(
|
||||
return { cancel: () => clearTimeout(timer), settleAfterMs: NATIVE_CHAT_SUBMIT_DELAY_MS }
|
||||
}
|
||||
|
||||
function waitForNativeChatSubmit(signal?: AbortSignal): Promise<boolean> {
|
||||
if (signal?.aborted) {
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
const finish = (completed: boolean): void => {
|
||||
if (timer === null) {
|
||||
return
|
||||
}
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
resolve(completed)
|
||||
}
|
||||
const onAbort = (): void => finish(false)
|
||||
timer = setTimeout(() => finish(true), NATIVE_CHAT_SUBMIT_DELAY_MS)
|
||||
signal?.addEventListener('abort', onAbort, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
export async function sendNativeChatMessageVerified(
|
||||
settings: ReturnType<typeof getSettingsForAgentTabRuntimeOwner>,
|
||||
ptyId: string,
|
||||
text: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<boolean> {
|
||||
// Why: option commands await remote/SSH acceptance so the Enter cannot race
|
||||
// ahead of the body while a model-change observer is already armed.
|
||||
const bodyAccepted = await sendRuntimePtyInputVerified(
|
||||
settings,
|
||||
ptyId,
|
||||
buildNativeChatPasteBytes(text)
|
||||
)
|
||||
if (!bodyAccepted || signal?.aborted || !(await waitForNativeChatSubmit(signal))) {
|
||||
return false
|
||||
}
|
||||
return sendRuntimePtyInputVerified(settings, ptyId, NATIVE_CHAT_SUBMIT)
|
||||
}
|
||||
|
||||
export function sendNativeChatMessageWithImageAttachments(
|
||||
settings: ReturnType<typeof getSettingsForAgentTabRuntimeOwner>,
|
||||
ptyId: string,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { AgentType } from '../../../../shared/agent-status-types'
|
||||
import type {
|
||||
SessionOptionValue,
|
||||
SessionOptionValueSource
|
||||
} from '../../../../shared/native-chat-session-options'
|
||||
import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache'
|
||||
|
||||
export type TrackedNativeChatSessionOption = {
|
||||
value: SessionOptionValue
|
||||
source: Exclude<SessionOptionValueSource, 'unknown'>
|
||||
}
|
||||
|
||||
export type NativeChatSessionOptionRecord = {
|
||||
agent: AgentType
|
||||
model?: TrackedNativeChatSessionOption
|
||||
valuesByModel: Record<string, Record<string, TrackedNativeChatSessionOption>>
|
||||
}
|
||||
|
||||
const sessionOptionCache = new Map<string, NativeChatSessionOptionRecord>()
|
||||
|
||||
export function createNativeChatSessionOptionRecord(
|
||||
agent: AgentType
|
||||
): NativeChatSessionOptionRecord {
|
||||
return { agent, valuesByModel: {} }
|
||||
}
|
||||
|
||||
export function cloneNativeChatSessionOptionRecord(
|
||||
record: NativeChatSessionOptionRecord
|
||||
): NativeChatSessionOptionRecord {
|
||||
return {
|
||||
agent: record.agent,
|
||||
...(record.model ? { model: { ...record.model } } : {}),
|
||||
valuesByModel: Object.fromEntries(
|
||||
Object.entries(record.valuesByModel).map(([modelId, values]) => [
|
||||
modelId,
|
||||
Object.fromEntries(Object.entries(values).map(([id, tracked]) => [id, { ...tracked }]))
|
||||
])
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function readNativeChatSessionOptionCache(
|
||||
scopeKey: string,
|
||||
fallbackScopeKey?: string
|
||||
): NativeChatSessionOptionRecord | null {
|
||||
const record = sessionOptionCache.get(scopeKey) ?? sessionOptionCache.get(fallbackScopeKey ?? '')
|
||||
return record ? cloneNativeChatSessionOptionRecord(record) : null
|
||||
}
|
||||
|
||||
export function writeNativeChatSessionOptionCache(
|
||||
scopeKey: string,
|
||||
record: NativeChatSessionOptionRecord
|
||||
): void {
|
||||
setBoundedScopeCacheEntry(
|
||||
sessionOptionCache,
|
||||
scopeKey,
|
||||
cloneNativeChatSessionOptionRecord(record)
|
||||
)
|
||||
}
|
||||
|
||||
export function seedNativeChatAppliedSessionOptions(
|
||||
scopeKey: string,
|
||||
agent: AgentType,
|
||||
values: Record<string, SessionOptionValue> | null | undefined
|
||||
): void {
|
||||
const modelId = typeof values?.model === 'string' ? values.model : null
|
||||
if (!modelId) {
|
||||
return
|
||||
}
|
||||
const record = createNativeChatSessionOptionRecord(agent)
|
||||
record.model = { value: modelId, source: 'applied' }
|
||||
const modelValues: Record<string, TrackedNativeChatSessionOption> = {}
|
||||
for (const [id, value] of Object.entries(values ?? {})) {
|
||||
if (id !== 'model') {
|
||||
modelValues[id] = { value, source: 'applied' }
|
||||
}
|
||||
}
|
||||
record.valuesByModel[modelId] = modelValues
|
||||
writeNativeChatSessionOptionCache(scopeKey, record)
|
||||
}
|
||||
|
||||
export function clearNativeChatSessionOptionCacheForTests(): void {
|
||||
sessionOptionCache.clear()
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
findCatalogModel,
|
||||
type AgentSessionOptionCatalog,
|
||||
type CatalogModel,
|
||||
type CatalogOptionApply
|
||||
} from '../../../../shared/agent-session-option-catalog'
|
||||
import type { SessionOptionValue } from '../../../../shared/native-chat-session-options'
|
||||
import type { NativeChatSessionOptionRecord } from './native-chat-session-option-cache'
|
||||
import { flattenNativeChatSessionOptionRecord } from './native-chat-session-option-snapshot'
|
||||
|
||||
export function buildNativeChatSessionOptionCommand(args: {
|
||||
optionId: string
|
||||
value: SessionOptionValue
|
||||
apply: CatalogOptionApply
|
||||
modelId: string | null
|
||||
catalog: AgentSessionOptionCatalog
|
||||
models: CatalogModel[]
|
||||
record: NativeChatSessionOptionRecord
|
||||
}): string | null {
|
||||
const midSession = args.apply.midSession
|
||||
if (midSession?.kind === 'command') {
|
||||
return midSession.build(args.value)
|
||||
}
|
||||
if (midSession?.kind === 'toggle-command' || midSession?.kind === 'agent-picker') {
|
||||
return midSession.command
|
||||
}
|
||||
if (!args.apply.composedIntoModel || !args.modelId || !args.catalog.composeModelValue) {
|
||||
return null
|
||||
}
|
||||
const model = findCatalogModel({ ...args.catalog, models: args.models }, args.modelId)
|
||||
const values = flattenNativeChatSessionOptionRecord(args.record, args.modelId)
|
||||
for (const option of model?.options ?? []) {
|
||||
values[option.id] ??= option.kind.defaultValue
|
||||
}
|
||||
values[args.optionId] = args.value
|
||||
const composed = args.catalog.composeModelValue(args.modelId, values)
|
||||
return args.catalog.modelApply.midSession?.kind === 'command'
|
||||
? args.catalog.modelApply.midSession.build(composed)
|
||||
: null
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import type { CatalogAgentInteractionDetection } from '../../../../shared/agent-session-option-catalog'
|
||||
import type { ClaudeModelSwitchOutcome } from './claude-model-switch-confirmation'
|
||||
|
||||
export type NativeChatSessionOptionDispatchResult = {
|
||||
outcome?: ClaudeModelSwitchOutcome
|
||||
}
|
||||
|
||||
export type NativeChatSessionOptionDispatchCommand = (
|
||||
command: string,
|
||||
options?: {
|
||||
detectAgentInteraction?: CatalogAgentInteractionDetection
|
||||
expectedChoiceLabel?: string
|
||||
}
|
||||
) =>
|
||||
| Promise<NativeChatSessionOptionDispatchResult | void>
|
||||
| NativeChatSessionOptionDispatchResult
|
||||
| void
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import type { CatalogMidSessionApply } from '../../../../shared/agent-session-option-catalog'
|
||||
import type { SessionOptionValue } from '../../../../shared/native-chat-session-options'
|
||||
|
||||
export function parseBuiltSessionOptionCommand(
|
||||
build: (value: SessionOptionValue) => string,
|
||||
command: string
|
||||
): string | null {
|
||||
const marker = '__orca_session_option_value__'
|
||||
const template = build(marker)
|
||||
const markerIndex = template.indexOf(marker)
|
||||
if (markerIndex < 0) {
|
||||
return null
|
||||
}
|
||||
const prefix = template.slice(0, markerIndex)
|
||||
const suffix = template.slice(markerIndex + marker.length)
|
||||
if (!command.startsWith(prefix) || !command.endsWith(suffix)) {
|
||||
return null
|
||||
}
|
||||
const value = command.slice(prefix.length, command.length - suffix.length).trim()
|
||||
return value || null
|
||||
}
|
||||
|
||||
export function isSessionOptionAgentPickerCommand(
|
||||
midSession: CatalogMidSessionApply | undefined,
|
||||
command: string
|
||||
): boolean {
|
||||
return (
|
||||
(midSession?.kind === 'agent-picker' && command === midSession.command) ||
|
||||
(midSession?.kind === 'command' && command === midSession.pickerCommand)
|
||||
)
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
findCatalogModel,
|
||||
type AgentSessionOptionCatalog,
|
||||
type CatalogMidSessionApply,
|
||||
type CatalogModel
|
||||
} from '../../../../shared/agent-session-option-catalog'
|
||||
import type { SessionOptionValue } from '../../../../shared/native-chat-session-options'
|
||||
import type { NativeChatSessionOptionRecord } from './native-chat-session-option-cache'
|
||||
import {
|
||||
isSessionOptionAgentPickerCommand,
|
||||
parseBuiltSessionOptionCommand
|
||||
} from './native-chat-session-option-command-matching'
|
||||
|
||||
function clearModel(record: NativeChatSessionOptionRecord): void {
|
||||
const modelId = typeof record.model?.value === 'string' ? record.model.value : null
|
||||
record.model = undefined
|
||||
if (modelId) {
|
||||
delete record.valuesByModel[modelId]
|
||||
}
|
||||
}
|
||||
|
||||
function recordCommandApply(args: {
|
||||
record: NativeChatSessionOptionRecord
|
||||
optionId: string
|
||||
midSession: CatalogMidSessionApply | undefined
|
||||
command: string
|
||||
persist: (modelId: string | null, optionId: string, value: SessionOptionValue) => void
|
||||
}): boolean {
|
||||
const { record, optionId, midSession, command, persist } = args
|
||||
if (!midSession || midSession.kind === 'unsupported') {
|
||||
return false
|
||||
}
|
||||
if (midSession.kind === 'toggle-command' && command === midSession.command) {
|
||||
const modelId = typeof record.model?.value === 'string' ? record.model.value : null
|
||||
if (modelId) {
|
||||
delete record.valuesByModel[modelId]?.[optionId]
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (isSessionOptionAgentPickerCommand(midSession, command)) {
|
||||
clearModel(record)
|
||||
return true
|
||||
}
|
||||
if (midSession.kind !== 'command') {
|
||||
return false
|
||||
}
|
||||
const value = parseBuiltSessionOptionCommand(midSession.build, command)
|
||||
if (!value) {
|
||||
return false
|
||||
}
|
||||
const previousModelId = typeof record.model?.value === 'string' ? record.model.value : null
|
||||
if (optionId === 'model') {
|
||||
if (previousModelId !== value) {
|
||||
// Why: a model command can reset model-scoped state, so an older value
|
||||
// from a prior visit is no longer evidence about this live session.
|
||||
delete record.valuesByModel[value]
|
||||
}
|
||||
record.model = { value, source: 'dispatched' }
|
||||
persist(value, optionId, value)
|
||||
return true
|
||||
}
|
||||
if (!previousModelId) {
|
||||
return true
|
||||
}
|
||||
record.valuesByModel[previousModelId] = {
|
||||
...record.valuesByModel[previousModelId],
|
||||
[optionId]: { value, source: 'dispatched' }
|
||||
}
|
||||
persist(previousModelId, optionId, value)
|
||||
return true
|
||||
}
|
||||
|
||||
export function recordNativeChatSessionOptionCommand(args: {
|
||||
catalog: AgentSessionOptionCatalog
|
||||
models: CatalogModel[]
|
||||
record: NativeChatSessionOptionRecord
|
||||
command: string
|
||||
persist: (modelId: string | null, optionId: string, value: SessionOptionValue) => void
|
||||
}): { changed: boolean; opensAgentPicker: boolean } {
|
||||
const { catalog, models, record, persist } = args
|
||||
const command = args.command.trim()
|
||||
let opensAgentPicker = isSessionOptionAgentPickerCommand(catalog.modelApply.midSession, command)
|
||||
let changed = recordCommandApply({
|
||||
record,
|
||||
optionId: 'model',
|
||||
midSession: catalog.modelApply.midSession,
|
||||
command,
|
||||
persist
|
||||
})
|
||||
const modelId = typeof record.model?.value === 'string' ? record.model.value : null
|
||||
const model = modelId ? findCatalogModel({ ...catalog, models }, modelId) : undefined
|
||||
for (const option of model?.options ?? []) {
|
||||
opensAgentPicker =
|
||||
opensAgentPicker || isSessionOptionAgentPickerCommand(option.apply.midSession, command)
|
||||
changed =
|
||||
recordCommandApply({
|
||||
record,
|
||||
optionId: option.id,
|
||||
midSession: option.apply.midSession,
|
||||
command,
|
||||
persist
|
||||
}) || changed
|
||||
}
|
||||
return { changed, opensAgentPicker }
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { AgentType } from '../../../../shared/agent-status-types'
|
||||
import type { CatalogModel } from '../../../../shared/agent-session-option-catalog'
|
||||
import { getCommitMessageModelDiscoveryHostKeyForScope } from '../../../../shared/commit-message-host-key'
|
||||
import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft'
|
||||
import { getConnectionIdFromState } from '@/lib/connection-context'
|
||||
import {
|
||||
discoverRuntimeCommitMessageModels,
|
||||
getRuntimeGitScope,
|
||||
type RuntimeGitContext
|
||||
} from '@/runtime/runtime-git-client'
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
export type NativeChatModelDiscoveryContext = {
|
||||
hostKey: string
|
||||
runtime: RuntimeGitContext
|
||||
}
|
||||
|
||||
export function resolveNativeChatModelDiscoveryContext(
|
||||
terminalTabId: string
|
||||
): NativeChatModelDiscoveryContext | null {
|
||||
const state = useAppStore.getState()
|
||||
const worktreeId =
|
||||
Object.entries(state.tabsByWorktree ?? {}).find(([, tabs]) =>
|
||||
tabs.some((tab) => tab.id === terminalTabId)
|
||||
)?.[0] ?? null
|
||||
const connectionId = getConnectionIdFromState(state, worktreeId)
|
||||
if (worktreeId && connectionId === undefined) {
|
||||
return null
|
||||
}
|
||||
const settings = getSettingsForAgentTabRuntimeOwner(terminalTabId)
|
||||
const worktreePath = worktreeId ? (state.getKnownWorktreeById?.(worktreeId)?.path ?? '') : ''
|
||||
const scope = getRuntimeGitScope(settings, connectionId)
|
||||
return {
|
||||
hostKey: getCommitMessageModelDiscoveryHostKeyForScope(scope),
|
||||
runtime: {
|
||||
settings,
|
||||
worktreeId,
|
||||
worktreePath,
|
||||
...(connectionId ? { connectionId } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function discoverNativeChatCatalogModels(
|
||||
agent: AgentType,
|
||||
context: RuntimeGitContext
|
||||
): Promise<CatalogModel[] | null> {
|
||||
const result = await discoverRuntimeCommitMessageModels(context, agent)
|
||||
if (!result.success || result.models.length === 0) {
|
||||
return null
|
||||
}
|
||||
return result.models.map((model) => ({
|
||||
id: model.id,
|
||||
label: model.label,
|
||||
options: []
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { CatalogModel } from '../../../../shared/agent-session-option-catalog'
|
||||
import {
|
||||
clearNativeChatModelEnrichmentForTests,
|
||||
ensureNativeChatModelEnrichment,
|
||||
readNativeChatEnrichedModels,
|
||||
subscribeNativeChatEnrichedModels
|
||||
} from './native-chat-session-option-enrichment'
|
||||
|
||||
describe('native chat session option enrichment', () => {
|
||||
beforeEach(() => clearNativeChatModelEnrichmentForTests())
|
||||
|
||||
it('keeps reads synchronous while one host-scoped probe is in flight', async () => {
|
||||
let resolveDiscovery: ((models: CatalogModel[]) => void) | undefined
|
||||
const discover = vi.fn(
|
||||
() =>
|
||||
new Promise<CatalogModel[]>((resolve) => {
|
||||
resolveDiscovery = resolve
|
||||
})
|
||||
)
|
||||
const listener = vi.fn()
|
||||
subscribeNativeChatEnrichedModels('cursor', 'ssh:one', listener)
|
||||
|
||||
ensureNativeChatModelEnrichment({ agent: 'cursor', hostKey: 'ssh:one', discover })
|
||||
ensureNativeChatModelEnrichment({ agent: 'cursor', hostKey: 'ssh:one', discover })
|
||||
|
||||
expect(readNativeChatEnrichedModels('cursor', 'ssh:one')).toBeNull()
|
||||
expect(discover).toHaveBeenCalledOnce()
|
||||
|
||||
resolveDiscovery?.([
|
||||
{ id: 'gpt-5.3-codex', label: 'GPT 5.3 live', options: [] },
|
||||
{ id: 'account-model', label: 'Account model', options: [] }
|
||||
])
|
||||
await vi.waitFor(() => expect(listener).toHaveBeenCalledOnce())
|
||||
|
||||
const models = readNativeChatEnrichedModels('cursor', 'ssh:one')!
|
||||
expect(models.find((model) => model.id === 'gpt-5.3-codex')).toMatchObject({
|
||||
label: 'GPT 5.3 live',
|
||||
options: expect.arrayContaining([expect.objectContaining({ id: 'effort' })])
|
||||
})
|
||||
expect(models.at(-1)).toMatchObject({ id: 'account-model' })
|
||||
expect(readNativeChatEnrichedModels('cursor', 'ssh:two')).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back permanently to the seed after a failed once-per-host probe', async () => {
|
||||
const discover = vi.fn().mockRejectedValue(new Error('offline'))
|
||||
ensureNativeChatModelEnrichment({ agent: 'cursor', hostKey: 'local', discover })
|
||||
await vi.waitFor(() => expect(discover).toHaveBeenCalledOnce())
|
||||
await Promise.resolve()
|
||||
|
||||
ensureNativeChatModelEnrichment({ agent: 'cursor', hostKey: 'local', discover })
|
||||
expect(discover).toHaveBeenCalledOnce()
|
||||
expect(readNativeChatEnrichedModels('cursor', 'local')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not probe agents whose catalogs have no discovery command', () => {
|
||||
const discover = vi.fn()
|
||||
ensureNativeChatModelEnrichment({ agent: 'claude', hostKey: 'local', discover })
|
||||
expect(discover).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { AgentType } from '../../../../shared/agent-status-types'
|
||||
import {
|
||||
getAgentSessionOptionCatalog,
|
||||
mergeCatalogModels,
|
||||
type CatalogModel
|
||||
} from '../../../../shared/agent-session-option-catalog'
|
||||
|
||||
type CatalogEnrichmentEntry = {
|
||||
state: 'idle' | 'pending' | 'settled'
|
||||
models: CatalogModel[] | null
|
||||
listeners: Set<(models: CatalogModel[]) => void>
|
||||
}
|
||||
|
||||
const enrichmentByAgentHost = new Map<string, CatalogEnrichmentEntry>()
|
||||
|
||||
function enrichmentKey(agent: AgentType, hostKey: string): string {
|
||||
return JSON.stringify([agent, hostKey])
|
||||
}
|
||||
|
||||
export function readNativeChatEnrichedModels(
|
||||
agent: AgentType,
|
||||
hostKey: string
|
||||
): CatalogModel[] | null {
|
||||
const models = enrichmentByAgentHost.get(enrichmentKey(agent, hostKey))?.models
|
||||
return models ? [...models] : null
|
||||
}
|
||||
|
||||
export function subscribeNativeChatEnrichedModels(
|
||||
agent: AgentType,
|
||||
hostKey: string,
|
||||
listener: (models: CatalogModel[]) => void
|
||||
): () => void {
|
||||
const key = enrichmentKey(agent, hostKey)
|
||||
const entry = enrichmentByAgentHost.get(key) ?? {
|
||||
state: 'idle' as const,
|
||||
models: null,
|
||||
listeners: new Set<(models: CatalogModel[]) => void>()
|
||||
}
|
||||
entry.listeners.add(listener)
|
||||
enrichmentByAgentHost.set(key, entry)
|
||||
return () => entry.listeners.delete(listener)
|
||||
}
|
||||
|
||||
export function ensureNativeChatModelEnrichment(args: {
|
||||
agent: AgentType
|
||||
hostKey: string
|
||||
discover: () => Promise<readonly CatalogModel[] | null>
|
||||
}): void {
|
||||
const catalog = getAgentSessionOptionCatalog(args.agent)
|
||||
if (!catalog?.listModels) {
|
||||
return
|
||||
}
|
||||
const key = enrichmentKey(args.agent, args.hostKey)
|
||||
const existing = enrichmentByAgentHost.get(key)
|
||||
if (existing?.state === 'pending' || existing?.state === 'settled') {
|
||||
return
|
||||
}
|
||||
const entry: CatalogEnrichmentEntry = existing ?? {
|
||||
state: 'idle',
|
||||
models: null,
|
||||
listeners: new Set()
|
||||
}
|
||||
entry.state = 'pending'
|
||||
enrichmentByAgentHost.set(key, entry)
|
||||
|
||||
// Why: model discovery must never delay rendering or launching; the seed is
|
||||
// immediately usable while this once-per-host probe runs in the background.
|
||||
void args
|
||||
.discover()
|
||||
.then((discovered) => {
|
||||
entry.state = 'settled'
|
||||
if (!discovered || discovered.length === 0) {
|
||||
return
|
||||
}
|
||||
entry.models = mergeCatalogModels(catalog.models, discovered)
|
||||
for (const listener of entry.listeners) {
|
||||
listener([...entry.models])
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
entry.state = 'settled'
|
||||
})
|
||||
}
|
||||
|
||||
export function clearNativeChatModelEnrichmentForTests(): void {
|
||||
enrichmentByAgentHost.clear()
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import type {
|
||||
SessionOptionDescriptor,
|
||||
SessionOptionDisabledReason,
|
||||
SessionOptionSelectChoice
|
||||
} from '../../../../shared/native-chat-session-options'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export function nativeChatSessionOptionLabel(descriptor: SessionOptionDescriptor): string {
|
||||
switch (descriptor.id) {
|
||||
case 'model':
|
||||
return translate('components.native-chat.composer.model', 'Model')
|
||||
case 'effort':
|
||||
return translate('components.native-chat.composer.effort', descriptor.label)
|
||||
case 'fastMode':
|
||||
return translate('components.native-chat.composer.fastMode', 'Fast mode')
|
||||
case 'thinking':
|
||||
return translate('components.native-chat.composer.thinking', 'Thinking')
|
||||
default:
|
||||
return descriptor.label
|
||||
}
|
||||
}
|
||||
|
||||
export function nativeChatSessionChoiceLabel(choice: SessionOptionSelectChoice): string {
|
||||
switch (choice.value) {
|
||||
case 'minimal':
|
||||
return translate('components.native-chat.composer.optionValue.minimal', 'Minimal')
|
||||
case 'low':
|
||||
return translate('components.native-chat.composer.optionValue.low', 'Low')
|
||||
case 'medium':
|
||||
return translate('components.native-chat.composer.optionValue.medium', 'Medium')
|
||||
case 'high':
|
||||
return translate('components.native-chat.composer.optionValue.high', 'High')
|
||||
case 'xhigh':
|
||||
return translate('components.native-chat.composer.optionValue.xhigh', 'Extra high')
|
||||
case 'max':
|
||||
return translate('components.native-chat.composer.optionValue.max', 'Max')
|
||||
default:
|
||||
return choice.label
|
||||
}
|
||||
}
|
||||
|
||||
export function nativeChatSessionOptionDisabledReason(
|
||||
reason: SessionOptionDisabledReason | undefined
|
||||
): string | null {
|
||||
// Exhaustive over SessionOptionDisabledReason: a new key is a compile error
|
||||
// here, so the localized label can never silently drift from the producer.
|
||||
switch (reason) {
|
||||
case 'set-when-session-starts':
|
||||
return translate(
|
||||
'components.native-chat.composer.setWhenSessionStarts',
|
||||
'Set when the session starts.'
|
||||
)
|
||||
case 'available-after-session-start':
|
||||
return translate(
|
||||
'components.native-chat.composer.availableAfterSessionStarts',
|
||||
'Available after the session starts.'
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function nativeChatModelPillLabel(descriptor: SessionOptionDescriptor): string {
|
||||
const modelLabel = translate('components.native-chat.composer.model', 'Model')
|
||||
if (
|
||||
descriptor.valueSource === 'unknown' ||
|
||||
descriptor.kind.type !== 'select' ||
|
||||
!descriptor.kind.currentValue
|
||||
) {
|
||||
return modelLabel
|
||||
}
|
||||
const valueLabel = nativeChatSessionChoiceLabel(
|
||||
descriptor.kind.choices.find((choice) => choice.value === descriptor.kind.currentValue) ?? {
|
||||
value: descriptor.kind.currentValue,
|
||||
label: descriptor.kind.currentValue
|
||||
}
|
||||
)
|
||||
return translate('components.native-chat.composer.modelWithValue', 'Model: {{value0}}', {
|
||||
value0: valueLabel
|
||||
})
|
||||
}
|
||||
|
||||
export function nativeChatOptionsPillTitle(
|
||||
descriptors: readonly SessionOptionDescriptor[]
|
||||
): string {
|
||||
const effort = descriptors.find((descriptor) => descriptor.id === 'effort')
|
||||
// Why: an effort-backed group is primarily the effort picker, even when it also reports modes.
|
||||
return effort
|
||||
? nativeChatSessionOptionLabel(effort)
|
||||
: translate('components.native-chat.composer.sessionOptions', 'Session options')
|
||||
}
|
||||
|
||||
export function nativeChatOptionsPillLabel(
|
||||
descriptors: readonly SessionOptionDescriptor[]
|
||||
): string {
|
||||
const effort = descriptors.find((descriptor) => descriptor.id === 'effort')
|
||||
const labels: string[] = []
|
||||
for (const descriptor of descriptors) {
|
||||
if (descriptor.valueSource === 'unknown') {
|
||||
continue
|
||||
}
|
||||
if (descriptor.kind.type === 'select' && descriptor.kind.currentValue) {
|
||||
const choice = descriptor.kind.choices.find(
|
||||
(candidate) => candidate.value === descriptor.kind.currentValue
|
||||
)
|
||||
labels.push(
|
||||
nativeChatSessionChoiceLabel(
|
||||
choice ?? {
|
||||
value: descriptor.kind.currentValue,
|
||||
label: descriptor.kind.currentValue
|
||||
}
|
||||
)
|
||||
)
|
||||
} else if (descriptor.kind.type === 'boolean' && descriptor.kind.currentValue === true) {
|
||||
labels.push(
|
||||
descriptor.id === 'fastMode'
|
||||
? translate('components.native-chat.composer.optionValue.fast', 'Fast')
|
||||
: nativeChatSessionOptionLabel(descriptor)
|
||||
)
|
||||
}
|
||||
}
|
||||
if (labels.length > 0) {
|
||||
const joinedLabels = labels.join(' · ')
|
||||
if (effort) {
|
||||
return translate('components.native-chat.composer.effortWithValue', 'Effort: {{value0}}', {
|
||||
value0: joinedLabels
|
||||
})
|
||||
}
|
||||
return joinedLabels
|
||||
}
|
||||
if (effort) {
|
||||
return nativeChatSessionOptionLabel(effort)
|
||||
}
|
||||
return translate('components.native-chat.composer.options', 'Options')
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { SessionOptionValue } from '../../../../shared/native-chat-session-options'
|
||||
import type { NativeChatSessionOptionRecord } from './native-chat-session-option-cache'
|
||||
|
||||
export function applyNativeChatReportedSessionOptions(
|
||||
record: NativeChatSessionOptionRecord,
|
||||
values: Record<string, SessionOptionValue>
|
||||
): boolean {
|
||||
const modelId = typeof values.model === 'string' ? values.model : null
|
||||
if (!modelId) {
|
||||
return false
|
||||
}
|
||||
const modelChanged = record.model?.value !== modelId
|
||||
let changed = modelChanged || record.model?.source !== 'reported'
|
||||
record.model = { value: modelId, source: 'reported' }
|
||||
const modelValues = modelChanged ? {} : { ...record.valuesByModel[modelId] }
|
||||
for (const [id, value] of Object.entries(values)) {
|
||||
if (id === 'model') {
|
||||
continue
|
||||
}
|
||||
const current = modelValues[id]
|
||||
if (current?.value !== value || current.source !== 'reported') {
|
||||
changed = true
|
||||
}
|
||||
modelValues[id] = { value, source: 'reported' }
|
||||
}
|
||||
record.valuesByModel[modelId] = modelValues
|
||||
return changed
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import {
|
||||
findCatalogModel,
|
||||
type AgentSessionOptionCatalog,
|
||||
type CatalogMidSessionApply,
|
||||
type CatalogModel,
|
||||
type CatalogOption
|
||||
} from '../../../../shared/agent-session-option-catalog'
|
||||
import type {
|
||||
SessionOptionDescriptor,
|
||||
SessionOptionSelectChoice,
|
||||
SessionOptionValue
|
||||
} from '../../../../shared/native-chat-session-options'
|
||||
import type {
|
||||
NativeChatSessionOptionRecord,
|
||||
TrackedNativeChatSessionOption
|
||||
} from './native-chat-session-option-cache'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export type NativeChatSessionOptionMode = 'draft' | 'live'
|
||||
|
||||
function choiceWithCurrent(
|
||||
choices: readonly SessionOptionSelectChoice[],
|
||||
tracked: TrackedNativeChatSessionOption | undefined
|
||||
): SessionOptionSelectChoice[] {
|
||||
const result = [...choices]
|
||||
const current = typeof tracked?.value === 'string' ? tracked.value : null
|
||||
if (current && !result.some((choice) => choice.value === current)) {
|
||||
result.push({ value: current, label: current })
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function settableState(args: {
|
||||
mode: NativeChatSessionOptionMode
|
||||
apply: { launchArgs?: unknown; composedIntoModel?: true; midSession?: CatalogMidSessionApply }
|
||||
composedModelApply?: { midSession?: CatalogMidSessionApply }
|
||||
}): Pick<SessionOptionDescriptor, 'settable' | 'disabledReason'> {
|
||||
if (args.mode === 'draft') {
|
||||
return args.apply.launchArgs || args.apply.composedIntoModel
|
||||
? { settable: true }
|
||||
: { settable: false, disabledReason: 'available-after-session-start' }
|
||||
}
|
||||
const midSession = args.apply.midSession
|
||||
if (args.apply.composedIntoModel && args.composedModelApply?.midSession?.kind === 'command') {
|
||||
return { settable: true }
|
||||
}
|
||||
return midSession && midSession.kind !== 'unsupported'
|
||||
? { settable: true }
|
||||
: { settable: false, disabledReason: 'set-when-session-starts' }
|
||||
}
|
||||
|
||||
function actionForApply(
|
||||
apply: { midSession?: CatalogMidSessionApply },
|
||||
tracked: TrackedNativeChatSessionOption | undefined,
|
||||
mode: NativeChatSessionOptionMode
|
||||
): SessionOptionDescriptor['action'] {
|
||||
if (mode !== 'live') {
|
||||
return undefined
|
||||
}
|
||||
if (apply.midSession?.kind === 'agent-picker') {
|
||||
return { type: 'agent-picker' }
|
||||
}
|
||||
if (apply.midSession?.kind === 'toggle-command' && !tracked) {
|
||||
return { type: 'toggle-command' }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function optionDescriptor(args: {
|
||||
option: CatalogOption
|
||||
tracked: TrackedNativeChatSessionOption | undefined
|
||||
mode: NativeChatSessionOptionMode
|
||||
composedModelApply: AgentSessionOptionCatalog['modelApply']
|
||||
}): SessionOptionDescriptor | null {
|
||||
const { option, tracked, mode, composedModelApply } = args
|
||||
const action = actionForApply(option.apply, tracked, mode)
|
||||
const settable = settableState({ mode, apply: option.apply, composedModelApply })
|
||||
if (option.kind.type === 'select') {
|
||||
const choices = choiceWithCurrent(option.kind.choices, tracked)
|
||||
if (choices.length <= 1) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
id: option.id,
|
||||
label: option.label,
|
||||
...(option.description ? { description: option.description } : {}),
|
||||
...(option.category ? { category: option.category } : {}),
|
||||
kind: {
|
||||
type: 'select',
|
||||
...(typeof tracked?.value === 'string' ? { currentValue: tracked.value } : {}),
|
||||
choices
|
||||
},
|
||||
valueSource: tracked?.source ?? 'unknown',
|
||||
...settable,
|
||||
...(action ? { action } : {})
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: option.id,
|
||||
label: option.label,
|
||||
...(option.description ? { description: option.description } : {}),
|
||||
...(option.category ? { category: option.category } : {}),
|
||||
kind: {
|
||||
type: 'boolean',
|
||||
...(typeof tracked?.value === 'boolean' ? { currentValue: tracked.value } : {})
|
||||
},
|
||||
valueSource: tracked?.source ?? 'unknown',
|
||||
...settable,
|
||||
...(action ? { action } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export function buildNativeChatSessionOptionSnapshot(args: {
|
||||
catalog: AgentSessionOptionCatalog
|
||||
models: readonly CatalogModel[]
|
||||
record: NativeChatSessionOptionRecord
|
||||
mode: NativeChatSessionOptionMode
|
||||
}): SessionOptionDescriptor[] {
|
||||
const { catalog, models, record, mode } = args
|
||||
const modelTracked = record.model
|
||||
const modelChoices = choiceWithCurrent(
|
||||
models.map(({ id, label, description }) => ({
|
||||
value: id,
|
||||
label,
|
||||
...(description ? { description } : {})
|
||||
})),
|
||||
modelTracked
|
||||
)
|
||||
const modelSettable = settableState({ mode, apply: catalog.modelApply })
|
||||
const modelAction = actionForApply(catalog.modelApply, modelTracked, mode)
|
||||
const snapshot: SessionOptionDescriptor[] = [
|
||||
{
|
||||
id: 'model',
|
||||
label: translate('components.native-chat.composer.model', 'Model'),
|
||||
category: 'model',
|
||||
kind: {
|
||||
type: 'select',
|
||||
...(typeof modelTracked?.value === 'string' ? { currentValue: modelTracked.value } : {}),
|
||||
choices: modelChoices
|
||||
},
|
||||
valueSource: modelTracked?.source ?? 'unknown',
|
||||
...modelSettable,
|
||||
...(modelAction ? { action: modelAction } : {})
|
||||
}
|
||||
]
|
||||
if (typeof modelTracked?.value !== 'string') {
|
||||
return snapshot
|
||||
}
|
||||
const model = findCatalogModel({ ...catalog, models: [...models] }, modelTracked.value)
|
||||
const trackedValues = record.valuesByModel[modelTracked.value] ?? {}
|
||||
for (const option of model?.options ?? []) {
|
||||
const descriptor = optionDescriptor({
|
||||
option,
|
||||
tracked: trackedValues[option.id],
|
||||
mode,
|
||||
composedModelApply: catalog.modelApply
|
||||
})
|
||||
if (descriptor) {
|
||||
snapshot.push(descriptor)
|
||||
}
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
export function flattenNativeChatSessionOptionRecord(
|
||||
record: NativeChatSessionOptionRecord,
|
||||
modelId: string
|
||||
): Record<string, SessionOptionValue> {
|
||||
return {
|
||||
model: modelId,
|
||||
...Object.fromEntries(
|
||||
Object.entries(record.valuesByModel[modelId] ?? {}).map(([id, tracked]) => [
|
||||
id,
|
||||
tracked.value
|
||||
])
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useCallback, type Dispatch, type RefObject, type SetStateAction } from 'react'
|
||||
import { dispatchDictationControl } from '../dictation/dictation-control-events'
|
||||
|
||||
export function useNativeChatDictationActions(args: {
|
||||
textareaRef: RefObject<HTMLTextAreaElement | null>
|
||||
setDictationPressed: Dispatch<SetStateAction<boolean>>
|
||||
}): {
|
||||
toggleDictation: () => void
|
||||
startHoldDictation: () => void
|
||||
stopHoldDictation: () => void
|
||||
} {
|
||||
const { setDictationPressed, textareaRef } = args
|
||||
const focusForDictation = useCallback(() => textareaRef.current?.focus(), [textareaRef])
|
||||
const toggleDictation = useCallback(() => {
|
||||
focusForDictation()
|
||||
dispatchDictationControl('toggle')
|
||||
}, [focusForDictation])
|
||||
const startHoldDictation = useCallback(() => {
|
||||
setDictationPressed(true)
|
||||
focusForDictation()
|
||||
dispatchDictationControl('start')
|
||||
}, [focusForDictation, setDictationPressed])
|
||||
const stopHoldDictation = useCallback(() => {
|
||||
setDictationPressed(false)
|
||||
dispatchDictationControl('stop')
|
||||
}, [setDictationPressed])
|
||||
return { toggleDictation, startHoldDictation, stopHoldDictation }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { NATIVE_FILE_DROP_TARGET } from '../../../../shared/native-file-drop'
|
||||
|
||||
export function useNativeChatFileAttachmentActions(
|
||||
attachExternalPaths: (paths: string[]) => void
|
||||
): { pickAttachment: () => void } {
|
||||
useEffect(
|
||||
() =>
|
||||
window.api.ui.onFileDrop((payload) => {
|
||||
if (payload.target === NATIVE_FILE_DROP_TARGET.composer) {
|
||||
attachExternalPaths(payload.paths)
|
||||
}
|
||||
}),
|
||||
[attachExternalPaths]
|
||||
)
|
||||
|
||||
const pickAttachment = useCallback(() => {
|
||||
void (async () => {
|
||||
const filePath = await window.api.shell.pickAttachment()
|
||||
if (filePath) {
|
||||
attachExternalPaths([filePath])
|
||||
}
|
||||
})()
|
||||
}, [attachExternalPaths])
|
||||
|
||||
return { pickAttachment }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useCallback } from 'react'
|
||||
import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown'
|
||||
import { openDetectedFilePath } from '@/components/terminal-pane/terminal-file-open-routing'
|
||||
import { resolveNativeChatFileLink, type NativeChatFileLinkContext } from './native-chat-file-link'
|
||||
|
||||
export function useNativeChatFileLinkClick(
|
||||
context: NativeChatFileLinkContext | null
|
||||
): CommentMarkdownLinkClickHandler | undefined {
|
||||
const openFileLink = useCallback<CommentMarkdownLinkClickHandler>(
|
||||
(event, href) => {
|
||||
const target = resolveNativeChatFileLink(href, context)
|
||||
if (!target || !context) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
openDetectedFilePath(target.absolutePath, target.line, target.column, {
|
||||
worktreeId: context.worktreeId,
|
||||
worktreePath: context.worktreePath,
|
||||
runtimeEnvironmentId: context.runtimeEnvironmentId,
|
||||
openWithSystemDefault: event.shiftKey
|
||||
})
|
||||
},
|
||||
[context]
|
||||
)
|
||||
return context ? openFileLink : undefined
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react'
|
||||
import type { AgentType } from '../../../../shared/agent-status-types'
|
||||
import { emitNativeChatMessageSent } from '@/lib/native-chat-telemetry'
|
||||
import {
|
||||
nativeChatComposerTargetIsRemote,
|
||||
type NativeChatResolvedTarget
|
||||
} from './native-chat-composer-target'
|
||||
import { pushHistory, type HistoryState } from './native-chat-composer-state'
|
||||
import { sendNativeChatMessageVerified } from './native-chat-runtime-send'
|
||||
import {
|
||||
createClaudeModelSwitchConfirmationObserver,
|
||||
type ClaudeModelSwitchConfirmationObserver
|
||||
} from './claude-model-switch-confirmation'
|
||||
import type { NativeChatSessionOptionDispatchCommand } from './native-chat-session-option-command-dispatch'
|
||||
|
||||
export function useNativeChatSessionOptionCommand(args: {
|
||||
agent: AgentType
|
||||
disabled: boolean
|
||||
onSlashCommand?: (command: string) => void
|
||||
resolveTarget: () => NativeChatResolvedTarget | null
|
||||
setHistory: Dispatch<SetStateAction<HistoryState>>
|
||||
}): { dispatch: NativeChatSessionOptionDispatchCommand; isDispatching: boolean } {
|
||||
const { agent, disabled, onSlashCommand, resolveTarget, setHistory } = args
|
||||
const mountedRef = useRef(true)
|
||||
const activeObserversRef = useRef(new Set<ClaudeModelSwitchConfirmationObserver>())
|
||||
const activeSendsRef = useRef(new Set<AbortController>())
|
||||
// Why: expose an in-flight flag so the composer can block a normal send while
|
||||
// an option command's body+delayed-Enter (and its confirmation observer) is
|
||||
// still writing to the same pty — otherwise the two write sequences interleave
|
||||
// on one input line and corrupt both.
|
||||
const [isDispatching, setIsDispatching] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
const observers = activeObserversRef.current
|
||||
const sends = activeSendsRef.current
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
for (const send of sends) {
|
||||
send.abort()
|
||||
}
|
||||
sends.clear()
|
||||
for (const observer of observers) {
|
||||
observer.dispose()
|
||||
}
|
||||
observers.clear()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const dispatch = useCallback(
|
||||
async (command, options) => {
|
||||
const target = resolveTarget()
|
||||
if (!target || disabled) {
|
||||
throw new Error('No live terminal is available.')
|
||||
}
|
||||
const detectClaudeConfirmation =
|
||||
options?.detectAgentInteraction === 'claude-model-switch-confirmation'
|
||||
let observer: ClaudeModelSwitchConfirmationObserver | null = null
|
||||
if (detectClaudeConfirmation) {
|
||||
observer = createClaudeModelSwitchConfirmationObserver({
|
||||
ptyId: target.ptyId,
|
||||
settings: target.settings,
|
||||
expectedModelLabel: options?.expectedChoiceLabel ?? null
|
||||
})
|
||||
activeObserversRef.current.add(observer)
|
||||
await observer.ready
|
||||
if (!mountedRef.current) {
|
||||
activeObserversRef.current.delete(observer)
|
||||
observer.dispose()
|
||||
throw new Error('Native chat command was canceled because the composer closed.')
|
||||
}
|
||||
// Why: arm only after the observer reaches the live PTY tail, then
|
||||
// submit immediately so historical output cannot satisfy the match.
|
||||
observer.arm()
|
||||
}
|
||||
const sendController = new AbortController()
|
||||
activeSendsRef.current.add(sendController)
|
||||
setIsDispatching(true)
|
||||
try {
|
||||
const accepted = await sendNativeChatMessageVerified(
|
||||
target.settings,
|
||||
target.ptyId,
|
||||
command,
|
||||
sendController.signal
|
||||
)
|
||||
if (!accepted) {
|
||||
throw new Error('The terminal did not accept the command.')
|
||||
}
|
||||
// Why: start the model-switch detection clock only now that the command
|
||||
// has been accepted, so SSH/remote send latency doesn't eat the window
|
||||
// before the agent has responded.
|
||||
observer?.startDetection()
|
||||
onSlashCommand?.(command.trim())
|
||||
emitNativeChatMessageSent({
|
||||
agent,
|
||||
runtime: nativeChatComposerTargetIsRemote(target.ptyId) ? 'remote' : 'local'
|
||||
})
|
||||
setHistory((previous) => pushHistory(previous, command))
|
||||
const outcome = observer ? await observer.result : undefined
|
||||
return { outcome }
|
||||
} finally {
|
||||
activeSendsRef.current.delete(sendController)
|
||||
setIsDispatching(activeSendsRef.current.size > 0)
|
||||
if (observer) {
|
||||
activeObserversRef.current.delete(observer)
|
||||
observer.dispose()
|
||||
}
|
||||
}
|
||||
},
|
||||
[agent, disabled, onSlashCommand, resolveTarget, setHistory]
|
||||
)
|
||||
|
||||
return { dispatch, isDispatching }
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useEffect, useMemo, useSyncExternalStore } from 'react'
|
||||
import type { AgentType } from '../../../../shared/agent-status-types'
|
||||
import { updateNativeChatSessionOptionDefaults } from '../../../../shared/native-chat-session-option-defaults'
|
||||
import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options'
|
||||
import { useAppStore } from '../../store'
|
||||
import {
|
||||
createNativeChatPtySessionOptions,
|
||||
type NativeChatPtySessionOptionsSurface
|
||||
} from './native-chat-pty-session-options'
|
||||
import type { NativeChatSessionOptionDispatchCommand } from './native-chat-session-option-command-dispatch'
|
||||
import {
|
||||
ensureNativeChatModelEnrichment,
|
||||
readNativeChatEnrichedModels,
|
||||
subscribeNativeChatEnrichedModels
|
||||
} from './native-chat-session-option-enrichment'
|
||||
import {
|
||||
discoverNativeChatCatalogModels,
|
||||
resolveNativeChatModelDiscoveryContext
|
||||
} from './native-chat-session-option-discovery'
|
||||
import { readClaudeSessionOptionsFromTerminalScreen } from './claude-terminal-session-options'
|
||||
|
||||
const EMPTY_SNAPSHOT: SessionOptionDescriptor[] = []
|
||||
const subscribeEmpty = (): (() => void) => () => {}
|
||||
const getEmptySnapshot = (): SessionOptionDescriptor[] => EMPTY_SNAPSHOT
|
||||
|
||||
export function useNativeChatSessionOptions(args: {
|
||||
agent: AgentType
|
||||
terminalTabId: string
|
||||
targetPtyId: string | null
|
||||
dispatchCommand: NativeChatSessionOptionDispatchCommand
|
||||
onAgentPicker?: () => void
|
||||
readTerminalScreen?: () => string | null
|
||||
}): {
|
||||
surface: NativeChatPtySessionOptionsSurface | null
|
||||
snapshot: SessionOptionDescriptor[]
|
||||
} {
|
||||
const { agent, terminalTabId, targetPtyId, dispatchCommand, onAgentPicker, readTerminalScreen } =
|
||||
args
|
||||
const discoveryContext = useMemo(
|
||||
() => resolveNativeChatModelDiscoveryContext(terminalTabId),
|
||||
[terminalTabId]
|
||||
)
|
||||
const surface = useMemo(() => {
|
||||
// Why: native chat currently attaches only after startup is already queued;
|
||||
// exposing a draft picker here would claim it can still mutate that command.
|
||||
if (!targetPtyId) {
|
||||
return null
|
||||
}
|
||||
const scopeKey = targetPtyId ?? terminalTabId
|
||||
const reportedValues =
|
||||
agent === 'claude' ? readClaudeSessionOptionsFromTerminalScreen(readTerminalScreen?.()) : null
|
||||
let settingsWrite = Promise.resolve()
|
||||
return createNativeChatPtySessionOptions({
|
||||
agent,
|
||||
scopeKey,
|
||||
...(targetPtyId ? { fallbackScopeKey: terminalTabId } : {}),
|
||||
...(discoveryContext
|
||||
? {
|
||||
initialModels:
|
||||
readNativeChatEnrichedModels(agent, discoveryContext.hostKey) ?? undefined
|
||||
}
|
||||
: {}),
|
||||
mode: targetPtyId ? 'live' : 'draft',
|
||||
reportedValues,
|
||||
dispatchCommand,
|
||||
onAgentPicker,
|
||||
persistSelection: async ({ modelId, optionId, value }) => {
|
||||
// Why: read the live persisted defaults at write time (after any prior
|
||||
// write in this chain settles) and merge only this selection onto them,
|
||||
// rather than a baseline captured once at surface creation. A frozen
|
||||
// baseline would let a second same-agent pane's write be clobbered,
|
||||
// since updateSettings shallow-merges nativeChatSessionOptions. Chaining
|
||||
// still keeps rapid consecutive picks in selection order.
|
||||
settingsWrite = settingsWrite
|
||||
.catch(() => undefined)
|
||||
.then(() => {
|
||||
const base = useAppStore.getState().settings?.nativeChatSessionOptions
|
||||
const next = updateNativeChatSessionOptionDefaults({
|
||||
persisted: base,
|
||||
agent,
|
||||
modelId,
|
||||
optionId,
|
||||
value
|
||||
})
|
||||
return useAppStore.getState().updateSettings({ nativeChatSessionOptions: next })
|
||||
})
|
||||
await settingsWrite
|
||||
}
|
||||
})
|
||||
}, [
|
||||
agent,
|
||||
dispatchCommand,
|
||||
discoveryContext,
|
||||
onAgentPicker,
|
||||
readTerminalScreen,
|
||||
targetPtyId,
|
||||
terminalTabId
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!surface || agent !== 'claude') {
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
const reportCurrentValues = async (): Promise<void> => {
|
||||
let authoritativeScreen: string | null = null
|
||||
if (targetPtyId && window.api?.pty?.getMainBufferSnapshot) {
|
||||
try {
|
||||
const snapshot = await window.api.pty.getMainBufferSnapshot(targetPtyId, {
|
||||
scrollbackRows: 0
|
||||
})
|
||||
// Why: the API snapshots the main buffer, which is stale while a TUI
|
||||
// owns the alternate screen. The mounted xterm is authoritative then.
|
||||
authoritativeScreen = snapshot?.alternateScreen ? null : (snapshot?.data ?? null)
|
||||
} catch {
|
||||
// The mounted renderer buffer remains a transport-neutral fallback.
|
||||
}
|
||||
}
|
||||
const reportedValues =
|
||||
readClaudeSessionOptionsFromTerminalScreen(authoritativeScreen) ??
|
||||
readClaudeSessionOptionsFromTerminalScreen(readTerminalScreen?.())
|
||||
if (!cancelled && reportedValues) {
|
||||
surface.reportSessionOptions(reportedValues)
|
||||
}
|
||||
}
|
||||
void reportCurrentValues()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [agent, readTerminalScreen, surface, targetPtyId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!surface || !discoveryContext) {
|
||||
return
|
||||
}
|
||||
const unsubscribe = subscribeNativeChatEnrichedModels(
|
||||
agent,
|
||||
discoveryContext.hostKey,
|
||||
(models) => surface.replaceModels(models)
|
||||
)
|
||||
ensureNativeChatModelEnrichment({
|
||||
agent,
|
||||
hostKey: discoveryContext.hostKey,
|
||||
discover: () => discoverNativeChatCatalogModels(agent, discoveryContext.runtime)
|
||||
})
|
||||
return unsubscribe
|
||||
}, [agent, discoveryContext, surface])
|
||||
|
||||
const snapshot = useSyncExternalStore(
|
||||
surface?.subscribe ?? subscribeEmpty,
|
||||
surface?.getSnapshot ?? getEmptySnapshot,
|
||||
surface?.getSnapshot ?? getEmptySnapshot
|
||||
)
|
||||
return { surface, snapshot }
|
||||
}
|
||||
@@ -294,6 +294,7 @@ export function OnboardingInlineCommandTerminal({
|
||||
cwd={cwd}
|
||||
isActive
|
||||
isVisible
|
||||
showSplitButton={false}
|
||||
onPtyExit={() => {
|
||||
onTerminalExit?.()
|
||||
closeTab(tabId, { recordInteraction: false, reason: 'pty-exit' })
|
||||
|
||||
@@ -14,7 +14,8 @@ describe('buildOnboardingFolderAgentStartup', () => {
|
||||
})
|
||||
|
||||
expect(startup).toEqual({
|
||||
command: "codex '--dangerously-bypass-approvals-and-sandbox'",
|
||||
command:
|
||||
"codex '-m' 'gpt-5.6-sol' '-c' 'model_reasoning_effort=medium' '--dangerously-bypass-approvals-and-sandbox'",
|
||||
env: {},
|
||||
launchAgent: 'codex',
|
||||
launchConfig: {
|
||||
@@ -22,6 +23,7 @@ describe('buildOnboardingFolderAgentStartup', () => {
|
||||
agentArgs: '--dangerously-bypass-approvals-and-sandbox',
|
||||
agentEnv: {}
|
||||
},
|
||||
sessionOptions: { model: 'gpt-5.6-sol', effort: 'medium' },
|
||||
telemetry: {
|
||||
agent_kind: 'codex',
|
||||
launch_source: 'onboarding',
|
||||
@@ -97,7 +99,8 @@ describe('buildOnboardingFolderAgentStartup', () => {
|
||||
false
|
||||
)
|
||||
).toEqual({
|
||||
command: "echo onboarding-folder-agent '--dangerously-bypass-approvals-and-sandbox'",
|
||||
command:
|
||||
"echo onboarding-folder-agent '-m' 'gpt-5.6-sol' '-c' 'model_reasoning_effort=medium' '--dangerously-bypass-approvals-and-sandbox'",
|
||||
env: {},
|
||||
launchAgent: 'codex',
|
||||
launchConfig: {
|
||||
@@ -105,6 +108,7 @@ describe('buildOnboardingFolderAgentStartup', () => {
|
||||
agentArgs: '--dangerously-bypass-approvals-and-sandbox',
|
||||
agentEnv: {}
|
||||
},
|
||||
sessionOptions: { model: 'gpt-5.6-sol', effort: 'medium' },
|
||||
telemetry: {
|
||||
agent_kind: 'codex',
|
||||
launch_source: 'onboarding',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useAppStore } from '@/store'
|
||||
import type { TuiAgent } from '../../../../shared/types'
|
||||
import type { SourceControlAgentActionDeliveryPlanState } from './SourceControlAgentActionDialogForm'
|
||||
import { buildSourceControlAgentConnectionErrorPlan } from './source-control-agent-action-dialog-support'
|
||||
import { resolveNativeChatSessionOptionDefaults } from '../../../../shared/native-chat-session-option-defaults'
|
||||
|
||||
type BuildSourceControlAgentDeliveryPlanArgs = {
|
||||
selectedAgent: TuiAgent | null
|
||||
@@ -34,6 +35,12 @@ export function buildSourceControlAgentDeliveryPlan({
|
||||
agent: selectedAgent,
|
||||
commandInput,
|
||||
agentArgs,
|
||||
sessionOptions: selectedAgent
|
||||
? resolveNativeChatSessionOptionDefaults(
|
||||
useAppStore.getState().settings?.nativeChatSessionOptions,
|
||||
selectedAgent
|
||||
)
|
||||
: undefined,
|
||||
promptDelivery,
|
||||
detectedAgents,
|
||||
disabledAgents: useAppStore.getState().settings?.disabledTuiAgents,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { toast } from 'sonner'
|
||||
import type { GlobalSettings, OrcaHooks, ProjectHostSetup, Repo } from '../../../../shared/types'
|
||||
import type { SpeechModelState } from '../../../../shared/speech-types'
|
||||
import type { SkillFreshnessInventory } from '../../../../shared/skill-freshness'
|
||||
import type {
|
||||
SourceControlAiSettings,
|
||||
SourceControlAiSettingsPatch
|
||||
@@ -101,6 +102,7 @@ import {
|
||||
} from '@/hooks/useInstalledAgentSkills'
|
||||
import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime'
|
||||
import { useSkillFreshness } from '@/hooks/useSkillFreshness'
|
||||
import { getSkillFreshnessDisplayStatus } from '@/lib/skill-freshness-display-status'
|
||||
import { deriveNeededSectionIds, getInitialMountedSectionIds } from './settings-load-performance'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { getProjectHostSetupProjectionFromState } from '../../store/selectors'
|
||||
@@ -206,9 +208,10 @@ function getSettingsNavGroupDefinitionsForSearch(
|
||||
}
|
||||
|
||||
function getSkillNavInstallStatus(skill: {
|
||||
name: string
|
||||
installed: boolean
|
||||
loading: boolean
|
||||
updateAvailable?: boolean
|
||||
inventory: SkillFreshnessInventory | null
|
||||
}): SettingsNavInstallStatus {
|
||||
if (skill.loading) {
|
||||
return 'checking'
|
||||
@@ -216,7 +219,7 @@ function getSkillNavInstallStatus(skill: {
|
||||
if (!skill.installed) {
|
||||
return 'install'
|
||||
}
|
||||
return skill.updateAvailable ? 'update-available' : 'installed'
|
||||
return getSkillFreshnessDisplayStatus(skill.inventory, skill.name)
|
||||
}
|
||||
|
||||
function hasReadyVoiceModel(
|
||||
@@ -733,16 +736,16 @@ function Settings(): React.JSX.Element {
|
||||
orchestrationSkill
|
||||
const { installed: computerUseSkillInstalled, loading: computerUseSkillLoading } =
|
||||
computerUseSkill
|
||||
const eligibleUpdateSkillNames = skillFreshnessInventory?.eligibleUpdateNames
|
||||
const capabilityInstallStatusBySectionId = useMemo(() => {
|
||||
const eligibleUpdates = new Set(skillFreshnessApplies ? (eligibleUpdateSkillNames ?? []) : [])
|
||||
const applicableFreshnessInventory = skillFreshnessApplies ? skillFreshnessInventory : null
|
||||
const next = new Map<string, SettingsNavInstallStatus>([
|
||||
[
|
||||
'orchestration',
|
||||
getSkillNavInstallStatus({
|
||||
name: ORCHESTRATION_SKILL_NAME,
|
||||
installed: orchestrationSkillInstalled,
|
||||
loading: orchestrationSkillLoading,
|
||||
updateAvailable: eligibleUpdates.has(ORCHESTRATION_SKILL_NAME)
|
||||
inventory: applicableFreshnessInventory
|
||||
})
|
||||
]
|
||||
])
|
||||
@@ -750,9 +753,10 @@ function Settings(): React.JSX.Element {
|
||||
next.set(
|
||||
'computer-use',
|
||||
getSkillNavInstallStatus({
|
||||
name: COMPUTER_USE_SKILL_NAME,
|
||||
installed: computerUseSkillInstalled,
|
||||
loading: computerUseSkillLoading,
|
||||
updateAvailable: eligibleUpdates.has(COMPUTER_USE_SKILL_NAME)
|
||||
inventory: applicableFreshnessInventory
|
||||
})
|
||||
)
|
||||
if (settings) {
|
||||
@@ -770,13 +774,13 @@ function Settings(): React.JSX.Element {
|
||||
}, [
|
||||
computerUseSkillInstalled,
|
||||
computerUseSkillLoading,
|
||||
eligibleUpdateSkillNames,
|
||||
modelStates,
|
||||
orchestrationSkillInstalled,
|
||||
orchestrationSkillLoading,
|
||||
settings,
|
||||
showDesktopOnlySettings,
|
||||
skillFreshnessApplies,
|
||||
skillFreshnessInventory,
|
||||
voiceModelStatesLoading
|
||||
])
|
||||
const navSections = useMemo(
|
||||
|
||||
@@ -62,6 +62,12 @@ function renderSidebar(
|
||||
title: 'Voice',
|
||||
icon: Mic,
|
||||
installStatus: 'installed'
|
||||
},
|
||||
{
|
||||
id: 'computer-use',
|
||||
title: 'Computer Use',
|
||||
icon: Bot,
|
||||
installStatus: 'up-to-date'
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -114,6 +120,7 @@ describe('SettingsSidebar', () => {
|
||||
|
||||
expect(markup).toContain('Not installed')
|
||||
expect(markup).toContain('Installed')
|
||||
expect(markup).toContain('Up to date')
|
||||
expect(markup).toContain('Optional')
|
||||
})
|
||||
|
||||
|
||||
@@ -155,6 +155,8 @@ export function SettingsSidebar({
|
||||
)
|
||||
case 'installed':
|
||||
return translate('auto.components.settings.AgentSkillSetupPanel.9fcebceb2a', 'Installed')
|
||||
case 'up-to-date':
|
||||
return translate('auto.components.skills.SkillFreshnessStatusPill.upToDate', 'Up to date')
|
||||
case 'update-available':
|
||||
return translate(
|
||||
'auto.components.skills.SkillFreshnessStatusPill.updateAvailable',
|
||||
@@ -167,7 +169,7 @@ export function SettingsSidebar({
|
||||
const installStatusClassName = (status: SettingsNavInstallStatus): string =>
|
||||
cn(
|
||||
'ml-auto shrink-0 rounded-full border px-1.5 py-0.5 text-[10px] font-medium leading-none',
|
||||
status === 'installed'
|
||||
status === 'installed' || status === 'up-to-date'
|
||||
? 'border-status-success-border bg-status-success-background text-status-success'
|
||||
: status === 'update-available'
|
||||
? 'border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300'
|
||||
|
||||
@@ -20,6 +20,7 @@ import { isWslUncPath } from '../../../../shared/wsl-paths'
|
||||
import { resolveLocalWindowsAgentStartupShell } from '../../../../shared/windows-terminal-shell'
|
||||
import type { AgentStartupShell } from '../../../../shared/tui-agent-startup-shell'
|
||||
import type { LaunchSource } from '../../../../shared/telemetry-events'
|
||||
import type { SessionOptionValue } from '../../../../shared/native-chat-session-options'
|
||||
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import {
|
||||
getLinkedItemDisplayName,
|
||||
@@ -46,6 +47,7 @@ type SubmitFolderWorkspaceCreateParams = {
|
||||
agentCmdOverrides: Record<string, string> | undefined
|
||||
agentArgs?: string | null
|
||||
agentEnv?: Record<string, string>
|
||||
sessionOptions?: Record<string, SessionOptionValue>
|
||||
terminalWindowsShell?: string | null
|
||||
isRemote?: boolean
|
||||
launchSource?: LaunchSource
|
||||
@@ -71,6 +73,7 @@ export function buildFolderWorkspaceLinkedStartupPlan(args: {
|
||||
agentCmdOverrides: Record<string, string> | undefined
|
||||
agentArgs?: string | null
|
||||
agentEnv?: Record<string, string>
|
||||
sessionOptions?: Record<string, SessionOptionValue>
|
||||
platform: NodeJS.Platform
|
||||
shell?: AgentStartupShell
|
||||
isRemote: boolean
|
||||
@@ -87,6 +90,7 @@ export function buildFolderWorkspaceLinkedStartupPlan(args: {
|
||||
cmdOverrides: args.agentCmdOverrides ?? {},
|
||||
agentArgs: args.agentArgs,
|
||||
agentEnv: args.agentEnv,
|
||||
sessionOptions: args.sessionOptions,
|
||||
platform: args.platform,
|
||||
shell: args.shell,
|
||||
isRemote: args.isRemote
|
||||
@@ -99,6 +103,7 @@ export function buildFolderWorkspaceLinkedStartupPlan(args: {
|
||||
expectedProcess: draftLaunchPlan.expectedProcess,
|
||||
followupPrompt: null,
|
||||
launchConfig: draftLaunchPlan.launchConfig,
|
||||
...(draftLaunchPlan.sessionOptions ? { sessionOptions: draftLaunchPlan.sessionOptions } : {}),
|
||||
...(draftLaunchPlan.startupCommandDelivery
|
||||
? { startupCommandDelivery: draftLaunchPlan.startupCommandDelivery }
|
||||
: {}),
|
||||
@@ -114,6 +119,7 @@ export function buildFolderWorkspaceLinkedStartupPlan(args: {
|
||||
cmdOverrides: args.agentCmdOverrides ?? {},
|
||||
agentArgs: args.agentArgs,
|
||||
agentEnv: args.agentEnv,
|
||||
sessionOptions: args.sessionOptions,
|
||||
platform: args.platform,
|
||||
shell: args.shell,
|
||||
isRemote: args.isRemote,
|
||||
@@ -159,6 +165,7 @@ export async function submitFolderWorkspaceCreate({
|
||||
agentCmdOverrides,
|
||||
agentArgs,
|
||||
agentEnv,
|
||||
sessionOptions,
|
||||
terminalWindowsShell,
|
||||
launchSource = 'sidebar',
|
||||
runtimeEnvironmentId = null,
|
||||
@@ -189,6 +196,7 @@ export async function submitFolderWorkspaceCreate({
|
||||
agentCmdOverrides,
|
||||
agentArgs,
|
||||
agentEnv,
|
||||
sessionOptions,
|
||||
platform: launchPlatform,
|
||||
shell: launchShell,
|
||||
isRemote: launchIsRemote
|
||||
@@ -200,6 +208,7 @@ export async function submitFolderWorkspaceCreate({
|
||||
cmdOverrides: agentCmdOverrides ?? {},
|
||||
agentArgs,
|
||||
agentEnv,
|
||||
sessionOptions,
|
||||
platform: launchPlatform,
|
||||
shell: launchShell,
|
||||
isRemote: launchIsRemote,
|
||||
@@ -247,6 +256,7 @@ export async function submitFolderWorkspaceCreate({
|
||||
launchConfig: startupPlan.launchConfig,
|
||||
...(startupPlan.launchToken ? { launchToken: startupPlan.launchToken } : {}),
|
||||
launchAgent: quickAgent,
|
||||
...(startupPlan.sessionOptions ? { sessionOptions: startupPlan.sessionOptions } : {}),
|
||||
...(startupPlan.draftPrompt ? { draftPrompt: startupPlan.draftPrompt } : {}),
|
||||
...(startupPlan.startupCommandDelivery
|
||||
? { startupCommandDelivery: startupPlan.startupCommandDelivery }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useSkillFreshness } from '@/hooks/useSkillFreshness'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { IntegrationStatusPill } from '@/components/integration-status-pill'
|
||||
import { getSkillFreshnessDisplayStatus } from '@/lib/skill-freshness-display-status'
|
||||
|
||||
// Why: the setup rails' Installed pill is presence-only; when freshness knows a
|
||||
// safe update exists (or that every copy is current) the pill should say so.
|
||||
@@ -8,7 +9,8 @@ import { IntegrationStatusPill } from '@/components/integration-status-pill'
|
||||
// placement is never advertised as updatable here.
|
||||
export function SkillFreshnessStatusPill({ skillName }: { skillName: string }): React.JSX.Element {
|
||||
const { inventory } = useSkillFreshness()
|
||||
if (inventory?.eligibleUpdateNames.includes(skillName)) {
|
||||
const status = getSkillFreshnessDisplayStatus(inventory, skillName)
|
||||
if (status === 'update-available') {
|
||||
return (
|
||||
<IntegrationStatusPill tone="attention">
|
||||
{translate(
|
||||
@@ -18,14 +20,7 @@ export function SkillFreshnessStatusPill({ skillName }: { skillName: string }):
|
||||
</IntegrationStatusPill>
|
||||
)
|
||||
}
|
||||
const placements = inventory?.installations.filter(
|
||||
(installation) => installation.name === skillName
|
||||
)
|
||||
if (
|
||||
placements &&
|
||||
placements.length > 0 &&
|
||||
placements.every((installation) => installation.status === 'current')
|
||||
) {
|
||||
if (status === 'up-to-date') {
|
||||
return (
|
||||
<IntegrationStatusPill tone="connected">
|
||||
{translate('auto.components.skills.SkillFreshnessStatusPill.upToDate', 'Up to date')}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, useState, type ReactNode } from 'react'
|
||||
import { act, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
@@ -24,6 +24,7 @@ const mocks = vi.hoisted(() => ({
|
||||
onInteracted?: (method: 'keyboard' | 'pointer', event?: { key?: string }) => void
|
||||
onTerminalExit?: () => void
|
||||
}[],
|
||||
terminalCommits: [] as [command: string, inert: boolean][],
|
||||
notifyChanged: vi.fn()
|
||||
}))
|
||||
|
||||
@@ -42,8 +43,20 @@ vi.mock('@/hooks/useInstalledAgentSkills', () => ({
|
||||
|
||||
vi.mock('@/components/onboarding/OnboardingInlineCommandTerminal', () => ({
|
||||
OnboardingInlineCommandTerminal: (props: (typeof mocks.terminalProps)[number]) => {
|
||||
const terminalRef = useRef<HTMLDivElement>(null)
|
||||
mocks.terminalProps.push(props)
|
||||
return <div data-testid="update-terminal">{props.command}</div>
|
||||
useLayoutEffect(() => {
|
||||
mocks.terminalCommits.push([
|
||||
props.command,
|
||||
terminalRef.current?.parentElement?.inert ?? false
|
||||
])
|
||||
})
|
||||
return (
|
||||
<div ref={terminalRef} data-testid="update-terminal">
|
||||
{props.command}
|
||||
<textarea data-testid="update-terminal-input" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -155,6 +168,7 @@ describe('SkillFreshnessUpdateDialog', () => {
|
||||
mocks.refresh.mockReset()
|
||||
mocks.notifyChanged.mockReset()
|
||||
mocks.terminalProps.length = 0
|
||||
mocks.terminalCommits.length = 0
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -200,6 +214,7 @@ describe('SkillFreshnessUpdateDialog', () => {
|
||||
it('shows the up-to-date state once every installation is current', async () => {
|
||||
await renderDialog()
|
||||
await openViaRequest()
|
||||
mocks.terminalCommits.length = 0
|
||||
|
||||
mocks.inventory = {
|
||||
schemaVersion: 1,
|
||||
@@ -211,6 +226,7 @@ describe('SkillFreshnessUpdateDialog', () => {
|
||||
}
|
||||
await rerender()
|
||||
|
||||
expect(mocks.terminalCommits).toContainEqual(['npx skills update orca-cli --global', true])
|
||||
expect(container?.textContent).toContain('All installed Orca skills are up to date.')
|
||||
expect(container?.querySelector('[data-testid="update-terminal"]')).toBeNull()
|
||||
})
|
||||
@@ -263,17 +279,47 @@ describe('SkillFreshnessUpdateDialog', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('removes an unsubmitted draft as soon as its inventory is invalidated', async () => {
|
||||
it('keeps the same terminal inert while inventory is revalidated', async () => {
|
||||
await renderDialog()
|
||||
await openViaRequest()
|
||||
expect(container?.querySelector('[data-testid="update-terminal"]')).not.toBeNull()
|
||||
const firstTerminal = container?.querySelector('[data-testid="update-terminal"]')
|
||||
const terminalInput = container?.querySelector<HTMLTextAreaElement>(
|
||||
'[data-testid="update-terminal-input"]'
|
||||
)
|
||||
const terminalWrapper = container?.querySelector<HTMLElement>('[data-skill-update-terminal]')
|
||||
terminalInput?.focus()
|
||||
expect(firstTerminal).not.toBeNull()
|
||||
expect(terminalWrapper?.hasAttribute('inert')).toBe(false)
|
||||
|
||||
mocks.inventory = null
|
||||
mocks.loading = true
|
||||
await rerender()
|
||||
|
||||
expect(container?.textContent).toContain('Checking installed Orca skills')
|
||||
expect(container?.querySelector('[data-testid="update-terminal"]')).toBeNull()
|
||||
expect(container?.querySelector('[data-testid="update-terminal"]')).toBe(firstTerminal)
|
||||
expect(terminalWrapper?.hasAttribute('inert')).toBe(true)
|
||||
terminalInput?.blur()
|
||||
|
||||
mocks.inventory = { ...eligibleInventory(), scannedAt: 2 }
|
||||
mocks.loading = false
|
||||
await rerender()
|
||||
|
||||
expect(container?.querySelector('[data-testid="update-terminal"]')).toBe(firstTerminal)
|
||||
expect(terminalWrapper?.hasAttribute('inert')).toBe(false)
|
||||
expect(document.activeElement).toBe(terminalInput)
|
||||
|
||||
mocks.inventory = null
|
||||
mocks.loading = true
|
||||
await rerender()
|
||||
const closeButton = Array.from(container?.querySelectorAll('button') ?? []).find(
|
||||
(candidate) => candidate.textContent?.trim() === 'Close'
|
||||
)
|
||||
closeButton?.focus()
|
||||
mocks.inventory = { ...eligibleInventory(), scannedAt: 3 }
|
||||
mocks.loading = false
|
||||
await rerender()
|
||||
|
||||
expect(document.activeElement).toBe(closeButton)
|
||||
})
|
||||
|
||||
it('shows a failed scan as an error instead of indefinite progress', async () => {
|
||||
|
||||
@@ -138,6 +138,9 @@ export function SkillFreshnessUpdateDialog(): React.JSX.Element {
|
||||
const [terminalCommand, setTerminalCommand] = useState<string | null>(null)
|
||||
const [awaitingExitRefresh, setAwaitingExitRefresh] = useState(false)
|
||||
const terminalSubmittedRef = useRef(false)
|
||||
const terminalWrapperRef = useRef<HTMLDivElement>(null)
|
||||
const terminalFocusTargetRef = useRef<HTMLElement | null>(null)
|
||||
const terminalAuthorizationWasPendingRef = useRef(false)
|
||||
const inventoryAtTerminalExitRef = useRef<SkillFreshnessInventory | null>(null)
|
||||
const inventory = state.inventory
|
||||
const eligibleNames = useMemo(() => inventory?.eligibleUpdateNames ?? [], [inventory])
|
||||
@@ -149,14 +152,26 @@ export function SkillFreshnessUpdateDialog(): React.JSX.Element {
|
||||
const hasBlockedGroup = groups.some((group) => group.status === 'cannot-update')
|
||||
const updateCommand = buildTargetedSkillUpdateCommand(eligibleNames)
|
||||
const summaryKind = summarizeInventory(inventory, hasBlockedGroup)
|
||||
const terminalAuthorizationPending =
|
||||
terminalCommand !== null &&
|
||||
!terminalSubmittedRef.current &&
|
||||
(state.loading ||
|
||||
state.error !== null ||
|
||||
inventory === null ||
|
||||
terminalCommand !== updateCommand)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
if (state.loading || state.error || !inventory) {
|
||||
if (state.loading) {
|
||||
// Why: keep the PTY mounted across focus revalidation, but the inert
|
||||
// wrapper below prevents stale draft input until fresh authority returns.
|
||||
return
|
||||
}
|
||||
if (state.error || !inventory) {
|
||||
// Why: a scan invalidates the authorization behind an unsubmitted draft.
|
||||
// A running command keeps its PTY until exit, but stale drafts fail closed.
|
||||
// A failed scan cannot restore it, so stale drafts fail closed.
|
||||
if (!terminalSubmittedRef.current && terminalCommand !== null) {
|
||||
setTerminalCommand(null)
|
||||
}
|
||||
@@ -186,6 +201,27 @@ export function SkillFreshnessUpdateDialog(): React.JSX.Element {
|
||||
updateCommand
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (terminalCommand === null) {
|
||||
// Why: the dialog stays mounted while closed, so do not retain a detached
|
||||
// xterm subtree after the draft exits or loses authorization.
|
||||
terminalFocusTargetRef.current = null
|
||||
}
|
||||
const wasPending = terminalAuthorizationWasPendingRef.current
|
||||
terminalAuthorizationWasPendingRef.current = terminalAuthorizationPending
|
||||
if (!wasPending || terminalAuthorizationPending) {
|
||||
return
|
||||
}
|
||||
const focusTarget = terminalFocusTargetRef.current
|
||||
if (!focusTarget?.isConnected || !terminalWrapperRef.current?.contains(focusTarget)) {
|
||||
terminalFocusTargetRef.current = null
|
||||
return
|
||||
}
|
||||
// Why: Chromium drops focus when an ancestor becomes inert; unchanged
|
||||
// authorization should return focus to the preserved xterm instance.
|
||||
focusTarget.focus()
|
||||
}, [terminalAuthorizationPending, terminalCommand])
|
||||
|
||||
const handleOpenChange = (next: boolean): void => {
|
||||
// Why: closing is the natural point to re-observe bytes so a completed update
|
||||
// clears the state and the lingering nudge does not fire again.
|
||||
@@ -237,28 +273,49 @@ export function SkillFreshnessUpdateDialog(): React.JSX.Element {
|
||||
)}
|
||||
|
||||
{terminalCommand ? (
|
||||
<OnboardingInlineCommandTerminal
|
||||
key={terminalCommand}
|
||||
command={terminalCommand}
|
||||
title={translate(
|
||||
'auto.components.skills.SkillFreshnessUpdateDialog.terminalTitle',
|
||||
'Update Orca skills'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.skills.SkillFreshnessUpdateDialog.terminalDescription',
|
||||
'Review the pre-filled command, then press Enter to run it.'
|
||||
)}
|
||||
ariaLabel={translate(
|
||||
'auto.components.skills.SkillFreshnessUpdateDialog.terminalAria',
|
||||
'Orca skill update terminal'
|
||||
)}
|
||||
worktreeId="skill-freshness-update-terminal"
|
||||
terminalHeightPx={200}
|
||||
terminalTopMarginPx={0}
|
||||
autoScrollIntoView={false}
|
||||
onInteracted={handleTerminalInteraction}
|
||||
onTerminalExit={handleTerminalExit}
|
||||
/>
|
||||
<div
|
||||
ref={terminalWrapperRef}
|
||||
data-skill-update-terminal=""
|
||||
aria-busy={terminalAuthorizationPending}
|
||||
inert={terminalAuthorizationPending}
|
||||
className={terminalAuthorizationPending ? 'opacity-50' : undefined}
|
||||
onFocusCapture={(event) => {
|
||||
terminalFocusTargetRef.current = event.target as HTMLElement
|
||||
}}
|
||||
onBlurCapture={(event) => {
|
||||
// Why: a concrete next target means the user moved focus; inert
|
||||
// uses a null target, which should be restored after revalidation.
|
||||
if (
|
||||
event.relatedTarget instanceof Node &&
|
||||
!event.currentTarget.contains(event.relatedTarget)
|
||||
) {
|
||||
terminalFocusTargetRef.current = null
|
||||
}
|
||||
}}
|
||||
>
|
||||
<OnboardingInlineCommandTerminal
|
||||
key={terminalCommand}
|
||||
command={terminalCommand}
|
||||
title={translate(
|
||||
'auto.components.skills.SkillFreshnessUpdateDialog.terminalTitle',
|
||||
'Update Orca skills'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.skills.SkillFreshnessUpdateDialog.terminalDescription',
|
||||
'Review the pre-filled command, then press Enter to run it.'
|
||||
)}
|
||||
ariaLabel={translate(
|
||||
'auto.components.skills.SkillFreshnessUpdateDialog.terminalAria',
|
||||
'Orca skill update terminal'
|
||||
)}
|
||||
worktreeId="skill-freshness-update-terminal"
|
||||
terminalHeightPx={200}
|
||||
terminalTopMarginPx={0}
|
||||
autoScrollIntoView={false}
|
||||
onInteracted={handleTerminalInteraction}
|
||||
onTerminalExit={handleTerminalExit}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Why: Radix reads defaultOpen only on mount. Remount when a scan
|
||||
|
||||
@@ -57,7 +57,7 @@ import {
|
||||
} from './tooltip'
|
||||
import { ClaudeIcon, GeminiIcon, MiniMaxIcon, OpenAIIcon, OpenCodeGoIcon } from './icons'
|
||||
import { AgentIcon } from '@/lib/agent-catalog'
|
||||
import { formatWindowLabel } from '@/lib/window-label-formatter'
|
||||
import { formatRateLimitWindowChipLabel } from '@/lib/window-label-formatter'
|
||||
import { markLiveCodexSessionsForRestart } from '@/lib/codex-session-restart'
|
||||
import { UpdateStatusSegment } from './UpdateStatusSegment'
|
||||
import { isStatusBarItemAvailable } from './status-bar-agent-gating'
|
||||
@@ -1191,7 +1191,7 @@ export function ProviderSegment({
|
||||
{visibleBuckets.length === 0 && p.session && (
|
||||
<WindowLabel
|
||||
w={p.session}
|
||||
label={formatWindowLabel(p.session.windowMinutes)}
|
||||
label={formatRateLimitWindowChipLabel(p.session)}
|
||||
display={display}
|
||||
/>
|
||||
)}
|
||||
@@ -1205,14 +1205,14 @@ export function ProviderSegment({
|
||||
? {
|
||||
key: 'session',
|
||||
window: p.session,
|
||||
label: formatWindowLabel(p.session.windowMinutes)
|
||||
label: formatRateLimitWindowChipLabel(p.session)
|
||||
}
|
||||
: null,
|
||||
p.weekly
|
||||
? {
|
||||
key: 'weekly',
|
||||
window: p.weekly,
|
||||
label: formatWindowLabel(p.weekly.windowMinutes)
|
||||
label: formatRateLimitWindowChipLabel(p.weekly)
|
||||
}
|
||||
: null,
|
||||
p.fableWeekly
|
||||
@@ -1229,7 +1229,7 @@ export function ProviderSegment({
|
||||
? {
|
||||
key: 'monthly',
|
||||
window: p.monthly,
|
||||
label: formatWindowLabel(p.monthly.windowMinutes)
|
||||
label: formatRateLimitWindowChipLabel(p.monthly)
|
||||
}
|
||||
: null
|
||||
].filter((w): w is { key: string; window: RateLimitWindow; label: string } => w !== null)
|
||||
|
||||
@@ -22,8 +22,12 @@ vi.mock('../../store', () => ({
|
||||
selector({ usagePercentageDisplay: 'used' })
|
||||
}))
|
||||
|
||||
function windowOf(usedPercent: number, windowMinutes: number): RateLimitWindow {
|
||||
return { usedPercent, windowMinutes, resetsAt: null, resetDescription: null }
|
||||
function windowOf(
|
||||
usedPercent: number,
|
||||
windowMinutes: number,
|
||||
resetsAt: number | null = null
|
||||
): RateLimitWindow {
|
||||
return { usedPercent, windowMinutes, resetsAt, resetDescription: null }
|
||||
}
|
||||
|
||||
// Grok unified-billing accounts surface a monthly window and nothing else.
|
||||
@@ -83,4 +87,34 @@ describe('ProviderSegment monthly window', () => {
|
||||
expect(markup).toContain('20% used wk')
|
||||
expect(markup).not.toContain('30d')
|
||||
})
|
||||
|
||||
// Why: #8378 — status-bar chip showed fixed window size ("5h") while the
|
||||
// usage popup showed remaining time for the same resetsAt.
|
||||
it('shows remaining session time on the chip when resetsAt is known (repro-8378)', async () => {
|
||||
const { ProviderSegment } = await import('./StatusBar')
|
||||
const now = 1_700_000_000_000
|
||||
const dateNow = vi.spyOn(Date, 'now').mockReturnValue(now)
|
||||
const remainingMs = 2 * 60 * 60_000 + 33 * 60_000
|
||||
|
||||
try {
|
||||
const limits: ProviderRateLimits = {
|
||||
provider: 'codex',
|
||||
session: windowOf(42, 300, now + remainingMs),
|
||||
weekly: windowOf(10, 10080, now + 6 * 24 * 60 * 60_000),
|
||||
updatedAt: now,
|
||||
error: null,
|
||||
status: 'ok'
|
||||
}
|
||||
const markup = renderToStaticMarkup(
|
||||
<ProviderSegment p={limits} compact={false} display="used" />
|
||||
)
|
||||
|
||||
expect(markup).toContain('42% used 2h 33m')
|
||||
expect(markup).not.toContain('5h')
|
||||
expect(markup).toContain('10% used 6d')
|
||||
expect(markup).not.toContain('wk')
|
||||
} finally {
|
||||
dateNow.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -215,6 +215,9 @@ type TerminalPaneProps = {
|
||||
// or persist to the layout snapshot, so returning to the workspace shows
|
||||
// the original split layout unchanged.
|
||||
isolatedPaneKey?: string | null
|
||||
// Why: ephemeral one-off command terminals don't need the regular pane header's
|
||||
// prominent split affordance, though standard split shortcuts remain available.
|
||||
showSplitButton?: boolean
|
||||
onPtyExit: (ptyId: string) => void
|
||||
onCloseTab: () => void
|
||||
}
|
||||
@@ -281,6 +284,7 @@ export default function TerminalPane({
|
||||
isVisible = true,
|
||||
isWorktreeActive = isVisible,
|
||||
isolatedPaneKey = null,
|
||||
showSplitButton = true,
|
||||
onPtyExit,
|
||||
onCloseTab
|
||||
}: TerminalPaneProps): React.JSX.Element {
|
||||
@@ -813,6 +817,13 @@ export default function TerminalPane({
|
||||
}
|
||||
toggleNativeChatForLeaf(activeLeafId)
|
||||
}, [toggleNativeChatForLeaf])
|
||||
const readNativeChatTerminalScreen = useCallback((): string | null => {
|
||||
if (!chatLeafId) {
|
||||
return null
|
||||
}
|
||||
const pane = managerRef.current?.getPanes().find((candidate) => candidate.leafId === chatLeafId)
|
||||
return pane?.serializeAddon.serialize({ scrollback: 0 }) ?? null
|
||||
}, [chatLeafId])
|
||||
const setTabLayout = useAppStore((store) => store.setTabLayout)
|
||||
const expectedLayoutLeafIdsAttr =
|
||||
expectedLayoutLeafIds.length > 0 ? expectedLayoutLeafIds.join(' ') : undefined
|
||||
@@ -3112,6 +3123,7 @@ export default function TerminalPane({
|
||||
launchAgent={chatPaneLaunchAgent}
|
||||
resolvedAgent={chatPaneResolvedAgent}
|
||||
onSwitchToTerminal={() => toggleNativeChatForLeaf(chatPane.leafId)}
|
||||
readTerminalScreen={readNativeChatTerminalScreen}
|
||||
contextMenuActions={{
|
||||
onSplitRight: () => contextMenu.runForPane(chatPane.id, contextMenu.onSplitRight),
|
||||
onSplitDown: () => contextMenu.runForPane(chatPane.id, contextMenu.onSplitDown),
|
||||
@@ -3200,6 +3212,7 @@ export default function TerminalPane({
|
||||
worktreeId={worktreeId}
|
||||
cwd={cwd ?? ''}
|
||||
showAlwaysOnHeaders={isActive && terminalContentVisible}
|
||||
showSplitButton={showSplitButton}
|
||||
paneCount={paneCount}
|
||||
activePaneId={activePane?.id}
|
||||
panes={managedPanes}
|
||||
|
||||
@@ -44,6 +44,7 @@ function renderOverlay({
|
||||
paneTitles,
|
||||
paneCount = 2,
|
||||
showAlwaysOnHeaders = true,
|
||||
showSplitButton = true,
|
||||
onClosePane = vi.fn(),
|
||||
onRemoveTitle = vi.fn(),
|
||||
onRenameSubmit = vi.fn(),
|
||||
@@ -53,6 +54,7 @@ function renderOverlay({
|
||||
paneTitles: Record<number, string>
|
||||
paneCount?: number
|
||||
showAlwaysOnHeaders?: boolean
|
||||
showSplitButton?: boolean
|
||||
onClosePane?: ReturnType<typeof vi.fn>
|
||||
onRemoveTitle?: ReturnType<typeof vi.fn>
|
||||
onRenameSubmit?: ReturnType<typeof vi.fn>
|
||||
@@ -75,6 +77,7 @@ function renderOverlay({
|
||||
worktreeId="wt-1"
|
||||
cwd={path.join(path.sep, 'tmp')}
|
||||
showAlwaysOnHeaders={showAlwaysOnHeaders}
|
||||
showSplitButton={showSplitButton}
|
||||
paneCount={paneCount}
|
||||
activePaneId={1}
|
||||
panes={panes}
|
||||
@@ -167,6 +170,16 @@ describe('TerminalPaneHeaderOverlay', () => {
|
||||
expect(onRemoveTitle).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('omits the split control when the header affordance is hidden', () => {
|
||||
const { container } = renderOverlay({
|
||||
paneTitles: { 1: '', 2: '' },
|
||||
paneCount: 1,
|
||||
showSplitButton: false
|
||||
})
|
||||
|
||||
expect(container.querySelector('button[aria-label="Split Terminal Right"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores IME composition Enter before submitting a pane title rename', () => {
|
||||
const { container, onRenameSubmit } = renderOverlay({
|
||||
paneTitles: { 1: 'server', 2: '' },
|
||||
|
||||
@@ -20,6 +20,8 @@ type TerminalPaneHeaderOverlayProps = {
|
||||
worktreeId: string
|
||||
cwd: string
|
||||
showAlwaysOnHeaders: boolean
|
||||
/** Used by ephemeral one-off command terminals that omit the header affordance. */
|
||||
showSplitButton?: boolean
|
||||
paneCount: number
|
||||
activePaneId: number | null | undefined
|
||||
panes: readonly ManagedPane[]
|
||||
@@ -60,6 +62,7 @@ export default function TerminalPaneHeaderOverlay({
|
||||
worktreeId,
|
||||
cwd,
|
||||
showAlwaysOnHeaders,
|
||||
showSplitButton = true,
|
||||
paneCount,
|
||||
activePaneId,
|
||||
panes,
|
||||
@@ -272,7 +275,7 @@ export default function TerminalPaneHeaderOverlay({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{showAlwaysOnHeaders ? (
|
||||
{showAlwaysOnHeaders && showSplitButton ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
|
||||
@@ -23,6 +23,8 @@ import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '@/lib/tui-agen
|
||||
import { filterEnabledTuiAgents, isTuiAgentEnabled } from '../../../shared/tui-agent-selection'
|
||||
import { repoIsRemote } from '../../../shared/agent-launch-remote'
|
||||
import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell'
|
||||
import { resolveNativeChatSessionOptionDefaults } from '../../../shared/native-chat-session-option-defaults'
|
||||
import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache'
|
||||
import {
|
||||
resolveTuiAgentLaunchArgs,
|
||||
resolveTuiAgentLaunchEnv
|
||||
@@ -3356,6 +3358,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
? resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs)
|
||||
: undefined,
|
||||
agentEnv: agent ? resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv) : undefined,
|
||||
sessionOptions: agent
|
||||
? resolveNativeChatSessionOptionDefaults(settings?.nativeChatSessionOptions, agent)
|
||||
: undefined,
|
||||
terminalWindowsShell: settings?.terminalWindowsShell,
|
||||
isRemote: folderTargetIsRemote,
|
||||
launchSource: telemetrySource === 'onboarding' ? 'onboarding' : 'new_workspace_composer',
|
||||
@@ -3412,6 +3417,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
settings?.agentDefaultArgs,
|
||||
settings?.agentDefaultEnv,
|
||||
settings?.autoRenameBranchFromWork,
|
||||
settings?.nativeChatSessionOptions,
|
||||
settings?.terminalWindowsShell,
|
||||
telemetrySource
|
||||
]
|
||||
@@ -3611,6 +3617,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
cmdOverrides: settings?.agentCmdOverrides ?? {},
|
||||
agentArgs: resolveTuiAgentLaunchArgs(tuiAgent, settings?.agentDefaultArgs),
|
||||
agentEnv: resolveTuiAgentLaunchEnv(tuiAgent, settings?.agentDefaultEnv),
|
||||
sessionOptions: resolveNativeChatSessionOptionDefaults(
|
||||
settings?.nativeChatSessionOptions,
|
||||
tuiAgent
|
||||
),
|
||||
platform: selectedRepoAgentLaunchPlatform,
|
||||
shell: selectedRepoStartupShell,
|
||||
isRemote: selectedRepoIsRemote
|
||||
@@ -3731,6 +3741,13 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
}
|
||||
: {})
|
||||
})
|
||||
if (startupPlan) {
|
||||
const optionScopeKey =
|
||||
(activation !== false ? activation.primaryTabId : null) ?? result.startupTerminal?.tabId
|
||||
if (optionScopeKey) {
|
||||
seedNativeChatAppliedSessionOptions(optionScopeKey, tuiAgent, startupPlan.sessionOptions)
|
||||
}
|
||||
}
|
||||
if (startupPlan && !backendSpawnedStartup) {
|
||||
void ensureAgentStartupInTerminal({
|
||||
worktreeId: worktree.id,
|
||||
@@ -3792,6 +3809,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
settings?.agentDefaultArgs,
|
||||
settings?.agentDefaultEnv,
|
||||
settings?.autoRenameBranchFromWork,
|
||||
settings?.nativeChatSessionOptions,
|
||||
smartNameMode,
|
||||
setSidebarOpen,
|
||||
setupDecision,
|
||||
@@ -4030,6 +4048,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
cmdOverrides: settings?.agentCmdOverrides ?? {},
|
||||
agentArgs: resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs),
|
||||
agentEnv: resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv),
|
||||
sessionOptions: resolveNativeChatSessionOptionDefaults(
|
||||
settings?.nativeChatSessionOptions,
|
||||
agent
|
||||
),
|
||||
platform: selectedRepoAgentLaunchPlatform,
|
||||
shell: selectedRepoStartupShell,
|
||||
isRemote: selectedRepoIsRemote
|
||||
@@ -4043,6 +4065,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
expectedProcess: draftLaunchPlan.expectedProcess,
|
||||
followupPrompt: null,
|
||||
launchConfig: draftLaunchPlan.launchConfig,
|
||||
...(draftLaunchPlan.sessionOptions
|
||||
? { sessionOptions: draftLaunchPlan.sessionOptions }
|
||||
: {}),
|
||||
...(draftLaunchPlan.startupCommandDelivery
|
||||
? { startupCommandDelivery: draftLaunchPlan.startupCommandDelivery }
|
||||
: {}),
|
||||
@@ -4055,6 +4080,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
cmdOverrides: settings?.agentCmdOverrides ?? {},
|
||||
agentArgs: resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs),
|
||||
agentEnv: resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv),
|
||||
sessionOptions: resolveNativeChatSessionOptionDefaults(
|
||||
settings?.nativeChatSessionOptions,
|
||||
agent
|
||||
),
|
||||
platform: selectedRepoAgentLaunchPlatform,
|
||||
shell: selectedRepoStartupShell,
|
||||
isRemote: selectedRepoIsRemote,
|
||||
@@ -4244,6 +4273,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
settings?.agentDefaultArgs,
|
||||
settings?.agentDefaultEnv,
|
||||
settings?.autoRenameBranchFromWork,
|
||||
settings?.nativeChatSessionOptions,
|
||||
smartNameMode,
|
||||
disabledTuiAgents,
|
||||
setupDecision,
|
||||
|
||||
@@ -13184,7 +13184,30 @@
|
||||
"pastedImageLabel": "Pasted image",
|
||||
"imagePasteFailed": "Image paste failed.",
|
||||
"worktreeNotReady": "Worktree not ready — try again in a moment.",
|
||||
"uploadingAttachments": "Uploading {{value0}} file(s) to remote…"
|
||||
"uploadingAttachments": "Uploading {{value0}} file(s) to remote…",
|
||||
"model": "Model",
|
||||
"modelWithValue": "Model: {{value0}}",
|
||||
"effort": "Effort",
|
||||
"effortWithValue": "Effort: {{value0}}",
|
||||
"fastMode": "Fast mode",
|
||||
"thinking": "Thinking",
|
||||
"options": "Options",
|
||||
"sessionOptions": "Session options",
|
||||
"chooseInAgentPicker": "Choose in agent picker…",
|
||||
"toggleOption": "Toggle {{value0}}",
|
||||
"sentNotConfirmed": "Sent to the agent — not confirmed",
|
||||
"setWhenSessionStarts": "Set when the session starts.",
|
||||
"availableAfterSessionStarts": "Available after the session starts.",
|
||||
"optionUpdateFailed": "Could not update option",
|
||||
"optionValue": {
|
||||
"fast": "Fast",
|
||||
"minimal": "Minimal",
|
||||
"low": "Low",
|
||||
"medium": "Medium",
|
||||
"high": "High",
|
||||
"xhigh": "Extra high",
|
||||
"max": "Max"
|
||||
}
|
||||
},
|
||||
"tool": {
|
||||
"running": "Running…",
|
||||
|
||||
@@ -13184,7 +13184,30 @@
|
||||
"pastedImageLabel": "Imagen pegada",
|
||||
"imagePasteFailed": "Error al pegar imagen.",
|
||||
"worktreeNotReady": "Worktree no está listo — intenta de nuevo en un momento.",
|
||||
"uploadingAttachments": "Subiendo {{value0}} archivo(s) al remoto…"
|
||||
"uploadingAttachments": "Subiendo {{value0}} archivo(s) al remoto…",
|
||||
"model": "Modelo",
|
||||
"modelWithValue": "Modelo: {{value0}}",
|
||||
"effort": "Esfuerzo",
|
||||
"effortWithValue": "Esfuerzo: {{value0}}",
|
||||
"fastMode": "Modo rápido",
|
||||
"thinking": "Razonamiento",
|
||||
"options": "Opciones",
|
||||
"sessionOptions": "Opciones de sesión",
|
||||
"chooseInAgentPicker": "Elegir en el selector del agente…",
|
||||
"toggleOption": "Alternar {{value0}}",
|
||||
"sentNotConfirmed": "Enviado al agente — sin confirmar",
|
||||
"setWhenSessionStarts": "Configúralo al iniciar la sesión.",
|
||||
"availableAfterSessionStarts": "Disponible después de iniciar la sesión.",
|
||||
"optionUpdateFailed": "No se pudo actualizar la opción",
|
||||
"optionValue": {
|
||||
"fast": "Rápido",
|
||||
"minimal": "Mínimo",
|
||||
"low": "Bajo",
|
||||
"medium": "Medio",
|
||||
"high": "Alto",
|
||||
"xhigh": "Muy alto",
|
||||
"max": "Máximo"
|
||||
}
|
||||
},
|
||||
"tool": {
|
||||
"running": "Ejecutando…",
|
||||
|
||||
@@ -13184,7 +13184,30 @@
|
||||
"pastedImageLabel": "貼り付けた画像",
|
||||
"imagePasteFailed": "画像の貼り付けに失敗しました。",
|
||||
"worktreeNotReady": "Worktreeが準備できていません — しばらくしてから再試行してください。",
|
||||
"uploadingAttachments": "{{value0}}個のファイルをリモートにアップロード中…"
|
||||
"uploadingAttachments": "{{value0}}個のファイルをリモートにアップロード中…",
|
||||
"model": "モデル",
|
||||
"modelWithValue": "モデル: {{value0}}",
|
||||
"effort": "推論レベル",
|
||||
"effortWithValue": "推論レベル: {{value0}}",
|
||||
"fastMode": "高速モード",
|
||||
"thinking": "思考",
|
||||
"options": "オプション",
|
||||
"sessionOptions": "セッションオプション",
|
||||
"chooseInAgentPicker": "エージェントの選択画面で選ぶ…",
|
||||
"toggleOption": "{{value0}}を切り替える",
|
||||
"sentNotConfirmed": "エージェントに送信済み — 未確認",
|
||||
"setWhenSessionStarts": "セッション開始時に設定してください。",
|
||||
"availableAfterSessionStarts": "セッション開始後に利用できます。",
|
||||
"optionUpdateFailed": "オプションを更新できませんでした",
|
||||
"optionValue": {
|
||||
"fast": "高速",
|
||||
"minimal": "最小",
|
||||
"low": "低",
|
||||
"medium": "中",
|
||||
"high": "高",
|
||||
"xhigh": "最高",
|
||||
"max": "最大"
|
||||
}
|
||||
},
|
||||
"tool": {
|
||||
"running": "実行中…",
|
||||
|
||||
@@ -13184,7 +13184,30 @@
|
||||
"pastedImageLabel": "붙여넣은 이미지",
|
||||
"imagePasteFailed": "이미지 붙여넣기 실패.",
|
||||
"worktreeNotReady": "워크트리가 준비되지 않았습니다 — 잠시 후 다시 시도하세요.",
|
||||
"uploadingAttachments": "{{value0}}개 파일을 원격으로 업로드 중…"
|
||||
"uploadingAttachments": "{{value0}}개 파일을 원격으로 업로드 중…",
|
||||
"model": "모델",
|
||||
"modelWithValue": "모델: {{value0}}",
|
||||
"effort": "추론 수준",
|
||||
"effortWithValue": "추론 수준: {{value0}}",
|
||||
"fastMode": "빠른 모드",
|
||||
"thinking": "사고",
|
||||
"options": "옵션",
|
||||
"sessionOptions": "세션 옵션",
|
||||
"chooseInAgentPicker": "에이전트 선택기에서 선택…",
|
||||
"toggleOption": "{{value0}} 전환",
|
||||
"sentNotConfirmed": "에이전트에 전송됨 — 확인되지 않음",
|
||||
"setWhenSessionStarts": "세션을 시작할 때 설정하세요.",
|
||||
"availableAfterSessionStarts": "세션이 시작된 후 사용할 수 있습니다.",
|
||||
"optionUpdateFailed": "옵션을 업데이트하지 못했습니다",
|
||||
"optionValue": {
|
||||
"fast": "빠름",
|
||||
"minimal": "최소",
|
||||
"low": "낮음",
|
||||
"medium": "중간",
|
||||
"high": "높음",
|
||||
"xhigh": "매우 높음",
|
||||
"max": "최대"
|
||||
}
|
||||
},
|
||||
"tool": {
|
||||
"running": "실행 중…",
|
||||
|
||||
@@ -13184,7 +13184,30 @@
|
||||
"pastedImageLabel": "粘贴的图片",
|
||||
"imagePasteFailed": "图片粘贴失败。",
|
||||
"worktreeNotReady": "工作树未就绪 — 请稍后重试。",
|
||||
"uploadingAttachments": "正在上传 {{value0}} 个文件到远程…"
|
||||
"uploadingAttachments": "正在上传 {{value0}} 个文件到远程…",
|
||||
"model": "模型",
|
||||
"modelWithValue": "模型:{{value0}}",
|
||||
"effort": "推理强度",
|
||||
"effortWithValue": "推理强度:{{value0}}",
|
||||
"fastMode": "快速模式",
|
||||
"thinking": "思考",
|
||||
"options": "选项",
|
||||
"sessionOptions": "会话选项",
|
||||
"chooseInAgentPicker": "在代理选择器中选择…",
|
||||
"toggleOption": "切换{{value0}}",
|
||||
"sentNotConfirmed": "已发送给代理 — 尚未确认",
|
||||
"setWhenSessionStarts": "请在会话开始时设置。",
|
||||
"availableAfterSessionStarts": "会话开始后可用。",
|
||||
"optionUpdateFailed": "无法更新选项",
|
||||
"optionValue": {
|
||||
"fast": "快速",
|
||||
"minimal": "最小",
|
||||
"low": "低",
|
||||
"medium": "中",
|
||||
"high": "高",
|
||||
"xhigh": "极高",
|
||||
"max": "最大"
|
||||
}
|
||||
},
|
||||
"tool": {
|
||||
"running": "正在运行…",
|
||||
|
||||
@@ -8,6 +8,7 @@ import zh from './locales/zh.json'
|
||||
const localizedCatalogs = { es, ja, ko, zh }
|
||||
const englishSetting = en.auto.components.settings.ExperimentalPane.nativeChat
|
||||
const englishSearch = en.auto.components.settings.experimental.search.nativeChat
|
||||
const englishComposer = en.components['native-chat'].composer
|
||||
|
||||
describe('native chat locale copy', () => {
|
||||
it.each(Object.entries(localizedCatalogs))(
|
||||
@@ -25,6 +26,25 @@ describe('native chat locale copy', () => {
|
||||
expect(localized).not.toBe(english)
|
||||
}
|
||||
expect(search.grok).toBe('grok')
|
||||
const composer = catalog.components['native-chat'].composer
|
||||
for (const key of [
|
||||
'model',
|
||||
'modelWithValue',
|
||||
'effort',
|
||||
'effortWithValue',
|
||||
'fastMode',
|
||||
'thinking',
|
||||
'options',
|
||||
'sessionOptions',
|
||||
'chooseInAgentPicker',
|
||||
'sentNotConfirmed'
|
||||
] as const) {
|
||||
expect(composer[key].trim()).not.toBe('')
|
||||
expect(composer[key]).not.toBe(englishComposer[key])
|
||||
}
|
||||
for (const key of ['fast', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] as const) {
|
||||
expect(composer.optionValue[key]).not.toBe(englishComposer.optionValue[key])
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -58,4 +58,25 @@ describe('resolveAgentStatusTerminalTitle', () => {
|
||||
resolveAgentStatusTerminalTitle({ agentType: 'devin', state: 'waiting' }, '\u280b Devin')
|
||||
).toBe('Devin - action required')
|
||||
})
|
||||
|
||||
it('preserves native OpenCode titles through hook status transitions', () => {
|
||||
expect(
|
||||
resolveAgentStatusTerminalTitle(
|
||||
{ agentType: 'opencode', state: 'done' },
|
||||
'OC | Native Stable Session'
|
||||
)
|
||||
).toBe('OC | Native Stable Session')
|
||||
expect(
|
||||
resolveAgentStatusTerminalTitle(
|
||||
{ agentType: 'opencode', state: 'waiting' },
|
||||
'OC | Native Stable Session'
|
||||
)
|
||||
).toBe('OC | Native Stable Session')
|
||||
})
|
||||
|
||||
it('does not invent an OpenCode title when no native title exists', () => {
|
||||
expect(
|
||||
resolveAgentStatusTerminalTitle({ agentType: 'opencode', state: 'done' }, undefined)
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -340,7 +340,8 @@ describe('launchAgentInNewTab', () => {
|
||||
environmentId: 'web-runtime',
|
||||
targetGroupId: 'group-1',
|
||||
activate: true,
|
||||
command: "codex '--model' 'gpt-5' '--reasoning-effort' 'high' 'fix the spinner'",
|
||||
command:
|
||||
"codex '-m' 'gpt-5.6-sol' '-c' 'model_reasoning_effort=medium' '--model' 'gpt-5' '--reasoning-effort' 'high' 'fix the spinner'",
|
||||
env: { CODEX_PROFILE: 'captured' },
|
||||
startupCommandDelivery: 'shell-ready',
|
||||
launchConfig: {
|
||||
@@ -485,7 +486,8 @@ describe('launchAgentInNewTab', () => {
|
||||
expect(mockQueueTabStartupCommand).toHaveBeenCalledWith(
|
||||
'tab-1',
|
||||
expect.objectContaining({
|
||||
command: "claude '--dangerously-skip-permissions' --prefill 'review Bob''s change'"
|
||||
command:
|
||||
"claude '--model' 'sonnet' '--effort' 'high' '--dangerously-skip-permissions' --prefill 'review Bob''s change'"
|
||||
})
|
||||
)
|
||||
})
|
||||
@@ -503,7 +505,7 @@ describe('launchAgentInNewTab', () => {
|
||||
expect(mockQueueTabStartupCommand).toHaveBeenCalledWith(
|
||||
'tab-1',
|
||||
expect.objectContaining({
|
||||
command: 'claude "--dangerously-skip-permissions"'
|
||||
command: 'claude "--model" "sonnet" "--effort" "high" "--dangerously-skip-permissions"'
|
||||
})
|
||||
)
|
||||
})
|
||||
@@ -521,7 +523,7 @@ describe('launchAgentInNewTab', () => {
|
||||
expect(mockQueueTabStartupCommand).toHaveBeenCalledWith(
|
||||
'tab-1',
|
||||
expect.objectContaining({
|
||||
command: "claude '--dangerously-skip-permissions'"
|
||||
command: "claude '--model' 'sonnet' '--effort' 'high' '--dangerously-skip-permissions'"
|
||||
})
|
||||
)
|
||||
})
|
||||
@@ -541,7 +543,8 @@ describe('launchAgentInNewTab', () => {
|
||||
expect(mockQueueTabStartupCommand).toHaveBeenCalledWith(
|
||||
'tab-1',
|
||||
expect.objectContaining({
|
||||
command: 'codex "--model" "gpt-5" "fix the spinner"'
|
||||
command:
|
||||
'codex "-m" "gpt-5.6-sol" "-c" "model_reasoning_effort=medium" "--model" "gpt-5" "fix the spinner"'
|
||||
})
|
||||
)
|
||||
})
|
||||
@@ -561,7 +564,8 @@ describe('launchAgentInNewTab', () => {
|
||||
expect(mockQueueTabStartupCommand).toHaveBeenCalledWith(
|
||||
'tab-1',
|
||||
expect.objectContaining({
|
||||
command: "claude '--dangerously-skip-permissions' --prefill 'review Bob'\\''s change'"
|
||||
command:
|
||||
"claude '--model' 'sonnet' '--effort' 'high' '--dangerously-skip-permissions' --prefill 'review Bob'\\''s change'"
|
||||
})
|
||||
)
|
||||
})
|
||||
@@ -579,7 +583,7 @@ describe('launchAgentInNewTab', () => {
|
||||
expect(mockQueueTabStartupCommand).toHaveBeenCalledWith(
|
||||
'tab-1',
|
||||
expect.objectContaining({
|
||||
command: "claude '--dangerously-skip-permissions'"
|
||||
command: "claude '--model' 'sonnet' '--effort' 'high' '--dangerously-skip-permissions'"
|
||||
})
|
||||
)
|
||||
})
|
||||
@@ -616,7 +620,8 @@ describe('launchAgentInNewTab', () => {
|
||||
expect(mockQueueTabStartupCommand).toHaveBeenCalledWith(
|
||||
'tab-1',
|
||||
expect.objectContaining({
|
||||
command: "claude '--dangerously-skip-permissions' --prefill 'review Bob'\\''s change'"
|
||||
command:
|
||||
"claude '--model' 'sonnet' '--effort' 'high' '--dangerously-skip-permissions' --prefill 'review Bob'\\''s change'"
|
||||
})
|
||||
)
|
||||
})
|
||||
@@ -637,7 +642,7 @@ describe('launchAgentInNewTab', () => {
|
||||
expect(mockQueueTabStartupCommand).toHaveBeenCalledWith(
|
||||
'tab-1',
|
||||
expect.objectContaining({
|
||||
command: "claude '--dangerously-skip-permissions'"
|
||||
command: "claude '--model' 'sonnet' '--effort' 'high' '--dangerously-skip-permissions'"
|
||||
})
|
||||
)
|
||||
expect(mockPasteDraftWhenAgentReady).toHaveBeenCalledWith(
|
||||
@@ -843,7 +848,7 @@ describe('launchAgentInNewTab', () => {
|
||||
expect(mockQueueTabStartupCommand).toHaveBeenCalledWith(
|
||||
'tab-1',
|
||||
expect.objectContaining({
|
||||
command: "codex '--model' 'gpt-5.5'"
|
||||
command: "codex '-m' 'gpt-5.6-sol' '-c' 'model_reasoning_effort=medium' '--model' 'gpt-5.5'"
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
@@ -28,6 +28,8 @@ import type { TuiAgent } from '../../../shared/types'
|
||||
import type { LaunchSource } from '../../../shared/telemetry-events'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { getConnectionIdFromState } from '@/lib/connection-context'
|
||||
import { resolveNativeChatSessionOptionDefaults } from '../../../shared/native-chat-session-option-defaults'
|
||||
import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache'
|
||||
|
||||
export type LaunchAgentInNewTabArgs = {
|
||||
agent: TuiAgent
|
||||
@@ -129,7 +131,11 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI
|
||||
shell: queuedShell,
|
||||
isRemote,
|
||||
agentArgs: effectiveAgentArgs,
|
||||
agentEnv
|
||||
agentEnv,
|
||||
sessionOptions: resolveNativeChatSessionOptionDefaults(
|
||||
store.settings?.nativeChatSessionOptions,
|
||||
agent
|
||||
)
|
||||
}
|
||||
const trimmedPrompt = prompt?.trim() ?? ''
|
||||
const hasPrompt = trimmedPrompt.length > 0
|
||||
@@ -169,6 +175,9 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI
|
||||
expectedProcess: draftLaunchPlan.expectedProcess,
|
||||
followupPrompt: null,
|
||||
launchConfig: draftLaunchPlan.launchConfig,
|
||||
...(draftLaunchPlan.sessionOptions
|
||||
? { sessionOptions: draftLaunchPlan.sessionOptions }
|
||||
: {}),
|
||||
...(draftLaunchPlan.startupCommandDelivery
|
||||
? { startupCommandDelivery: draftLaunchPlan.startupCommandDelivery }
|
||||
: {}),
|
||||
@@ -244,6 +253,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI
|
||||
quickCommandLabel,
|
||||
...initialViewModeProps
|
||||
})
|
||||
seedNativeChatAppliedSessionOptions(tab.id, agent, startupPlan.sessionOptions)
|
||||
store.queueTabStartupCommand(tab.id, {
|
||||
command: startupPlan.launchCommand,
|
||||
...(startupPlan.env ? { env: startupPlan.env } : {}),
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
resolveTuiAgentLaunchEnv
|
||||
} from '../../../shared/tui-agent-launch-defaults'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { resolveNativeChatSessionOptionDefaults } from '../../../shared/native-chat-session-option-defaults'
|
||||
import type { PersistedNativeChatSessionOptions } from '../../../shared/native-chat-session-options'
|
||||
|
||||
export function buildDirectWorkItemAgentStartupPlan(args: {
|
||||
agent: TuiAgent | null
|
||||
@@ -27,6 +29,7 @@ export function buildDirectWorkItemAgentStartupPlan(args: {
|
||||
agentCmdOverrides?: Partial<Record<TuiAgent, string>>
|
||||
agentDefaultArgs?: Partial<Record<TuiAgent, string>>
|
||||
agentDefaultEnv?: Partial<Record<TuiAgent, Record<string, string>>>
|
||||
nativeChatSessionOptions?: PersistedNativeChatSessionOptions
|
||||
}
|
||||
| null
|
||||
| undefined
|
||||
@@ -48,6 +51,10 @@ export function buildDirectWorkItemAgentStartupPlan(args: {
|
||||
? resolveTuiAgentLaunchArgs(args.agent, args.settings?.agentDefaultArgs)
|
||||
: args.agentArgs
|
||||
const effectiveAgentEnv = resolveTuiAgentLaunchEnv(args.agent, args.settings?.agentDefaultEnv)
|
||||
const sessionOptions = resolveNativeChatSessionOptionDefaults(
|
||||
args.settings?.nativeChatSessionOptions,
|
||||
args.agent
|
||||
)
|
||||
const draftLaunchPlan =
|
||||
args.promptDelivery === 'submit-after-ready'
|
||||
? null
|
||||
@@ -58,7 +65,8 @@ export function buildDirectWorkItemAgentStartupPlan(args: {
|
||||
platform: args.launchPlatform,
|
||||
isRemote: args.isRemote,
|
||||
agentArgs: effectiveAgentArgs,
|
||||
agentEnv: effectiveAgentEnv
|
||||
agentEnv: effectiveAgentEnv,
|
||||
sessionOptions
|
||||
})
|
||||
|
||||
if (draftLaunchPlan) {
|
||||
@@ -69,6 +77,9 @@ export function buildDirectWorkItemAgentStartupPlan(args: {
|
||||
expectedProcess: draftLaunchPlan.expectedProcess,
|
||||
followupPrompt: null,
|
||||
launchConfig: draftLaunchPlan.launchConfig,
|
||||
...(draftLaunchPlan.sessionOptions
|
||||
? { sessionOptions: draftLaunchPlan.sessionOptions }
|
||||
: {}),
|
||||
...(draftLaunchPlan.startupCommandDelivery
|
||||
? { startupCommandDelivery: draftLaunchPlan.startupCommandDelivery }
|
||||
: {}),
|
||||
@@ -87,6 +98,7 @@ export function buildDirectWorkItemAgentStartupPlan(args: {
|
||||
isRemote: args.isRemote,
|
||||
agentArgs: effectiveAgentArgs,
|
||||
agentEnv: effectiveAgentEnv,
|
||||
sessionOptions,
|
||||
allowEmptyPromptLaunch: true
|
||||
})
|
||||
if (startupPlan && args.promptDelivery === 'draft') {
|
||||
@@ -110,6 +122,7 @@ export function buildDirectWorkItemStartupOpts(
|
||||
launchConfig?: SleepingAgentLaunchConfig
|
||||
launchAgent?: TuiAgent
|
||||
draftPrompt?: string
|
||||
sessionOptions?: AgentStartupPlan['sessionOptions']
|
||||
startupCommandDelivery?: StartupCommandDelivery
|
||||
telemetry?: AgentStartedTelemetry
|
||||
}
|
||||
@@ -126,6 +139,7 @@ export function buildDirectWorkItemStartupOpts(
|
||||
command: plan.launchCommand,
|
||||
...(plan.env ? { env: plan.env } : {}),
|
||||
launchConfig: plan.launchConfig,
|
||||
...(plan.sessionOptions ? { sessionOptions: plan.sessionOptions } : {}),
|
||||
...(agent ? { launchAgent: agent } : {}),
|
||||
...(plan.draftPrompt ? { draftPrompt: plan.draftPrompt } : {}),
|
||||
...(plan.startupCommandDelivery
|
||||
|
||||
@@ -402,6 +402,7 @@ describe('launchWorkItemDirect', () => {
|
||||
cmdOverrides: {},
|
||||
agentArgs: '--dangerously-skip-permissions',
|
||||
agentEnv: {},
|
||||
sessionOptions: { model: 'sonnet', effort: 'high' },
|
||||
platform: 'win32',
|
||||
isRemote: false
|
||||
})
|
||||
@@ -525,6 +526,7 @@ describe('launchWorkItemDirect', () => {
|
||||
cmdOverrides: {},
|
||||
agentArgs: '--yolo',
|
||||
agentEnv: {},
|
||||
sessionOptions: { model: 'auto' },
|
||||
platform: 'linux',
|
||||
isRemote: true
|
||||
})
|
||||
@@ -534,6 +536,7 @@ describe('launchWorkItemDirect', () => {
|
||||
cmdOverrides: {},
|
||||
agentArgs: '--yolo',
|
||||
agentEnv: {},
|
||||
sessionOptions: { model: 'auto' },
|
||||
platform: 'linux',
|
||||
isRemote: true,
|
||||
allowEmptyPromptLaunch: true
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user