Antigravity install: one clear message in the chat, with the message box staying in place (#30001)

* feat(native-chat): a chat whose host has no program to run shows one clear install message, with the message box staying in place, disabled

A new chat replaces its Start-a-chat copy with an install message (what it is, its size, one Install button, then steps, progress and Cancel). A chat with messages gets a slim strip above the composer instead. Settings' row is unchanged.

* fix(native-chat): the install message drops its Settings hint until that Settings row exists

* fix(native-chat): satisfy the not-found lint on the install card's phase lookup

* fix(native-chat): the install message follows the design system: a still full bar once the download is done, and a plain ghost Cancel

* fix(native-chat): the install card's size caption uses the download-size string main already has

Main's Antigravity work added the same "downloadSize" key to the install strings, so each locale
held it twice with slightly different text. The card now reads main's one string, so the hero's
caption under its button ends in a full stop like the Settings row's.

* refactor(native-chat): the install row keeps only its Settings layout

A chat now shows its own install card, so nothing renders the row's chat layout any more: Settings
> Chat > Providers and the Antigravity accounts section are its only users. The row and its view
lose the surface choice, the chat-only heading string goes from every locale, and the view always
says and offers what Settings did.

* fix(native-chat): the install message points to Settings › Chat › Providers again, now that main has that row

Only chats on this computer show it: that row installs here, not on an SSH or paired host.

* fix(native-chat): a disabled chat box keeps its placeholder and dims, so it says why it can't take a message

The editor drew its placeholder only while editable, so an install-blocked or held box looked
empty and enabled. The plain text box already dims and keeps its placeholder.

* fix(native-chat): keep setup reachable and honor composer input availability

* fix(native-chat): update picker availability after commit

---------

Co-authored-by: Orca Worker <orca-worker@localhost>
This commit is contained in:
Brennan Benson
2026-10-18 00:28:19 -07:00
committed by GitHub
co-authored by Orca Worker
parent 0d74111ee8
commit a54fd3830e
39 changed files with 1059 additions and 213 deletions
+4 -1
View File
@@ -19,7 +19,10 @@ import { resolvePtySpawnStartupTarget } from '../spawn-startup-target'
import { resolvePtySpawnHostEnv } from '../spawn-host-env'
import { ptySpawnPaneFromIds, stampPtySpawnPaneEnv } from '../spawn-pane-env'
import { getConnectionExecutionHostId } from '../../../../shared/execution-host'
import { ensureJcodeRuntimeDir, JCODE_RUNTIME_DIR_ENV_KEY } from '../../../../shared/jcode-runtime-dir'
import {
ensureJcodeRuntimeDir,
JCODE_RUNTIME_DIR_ENV_KEY
} from '../../../../shared/jcode-runtime-dir'
export async function prepareRuntimePtySpawn(
ctx: RuntimePtySpawnState
+4 -1
View File
@@ -1,7 +1,10 @@
// The pane identity a new PTY's env carries, stamped from the spawn's own ids, for both spawn lanes.
import { mkdirSync } from 'node:fs'
import { buildJcodeRuntimeDirEnv, JCODE_RUNTIME_DIR_ENV_KEY } from '../../../shared/jcode-runtime-dir'
import {
buildJcodeRuntimeDirEnv,
JCODE_RUNTIME_DIR_ENV_KEY
} from '../../../shared/jcode-runtime-dir'
import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id'
import { isValidTerminalTabId } from '../../../shared/terminal-tab-id'
import type { TuiAgent } from '../../../shared/tui-agent'
@@ -174,7 +174,15 @@ describe('frames to a bounded-frame subscriber', () => {
let held: ReadonlySet<string> = new Set()
const subscribers = new AgentSessionSubscribers({
readCurrentWork: () =>
new StructuredAgentSessionCurrentWork(journal, 1, 0, undefined, undefined, false, () => held)
new StructuredAgentSessionCurrentWork(
journal,
1,
0,
undefined,
undefined,
false,
() => held
)
})
const frames: AgentSessionSubscribeEvent[] = []
const emit = (event: AgentSessionSubscribeEvent) => frames.push(event)
@@ -197,9 +205,9 @@ describe('frames to a bounded-frame subscriber', () => {
}
}
expect([...delivered.keys()].sort()).toEqual([...ids].sort())
expect([...delivered.values()].every((submission) => submission.dispatchState === state)).toBe(
true
)
expect(
[...delivered.values()].every((submission) => submission.dispatchState === state)
).toBe(true)
}
const close = subscribers.open({
id: 'phone',
@@ -186,12 +186,7 @@ function emitCaughtUp(
refreshPublishedWork(port, subscriber, current.journal, work, shared)
return
}
if (
emitCheckpoint ||
shared.activity !== undefined ||
commandsChanged ||
queuedChanged
) {
if (emitCheckpoint || shared.activity !== undefined || commandsChanged || queuedChanged) {
port.emit(
subscriber,
{
@@ -125,8 +125,7 @@ export class StructuredAgentSessionClientDelivery {
captureChildWorkStops = (
sessionId: string,
handleIds: readonly string[]
): CapturedChildWorkStops =>
this.statusFeed.captureChildWorkStops(sessionId, handleIds)
): CapturedChildWorkStops => this.statusFeed.captureChildWorkStops(sessionId, handleIds)
childWorkIncomplete = (sessionId: string): boolean =>
this.statusFeed.childWorkIncomplete(sessionId)
@@ -297,10 +297,13 @@ export class AgentSessionSubscribers {
emitCheckpoint = false,
activity?: AgentSessionTurnActivity | null
): void {
deliverToSubscriber(
this.deliveryPort(),
{ subscriber, journal, hostNow, emitCheckpoint, activity }
)
deliverToSubscriber(this.deliveryPort(), {
subscriber,
journal,
hostNow,
emitCheckpoint,
activity
})
}
private now = (): number => this.hooks.now?.() ?? Date.now()
+2 -1
View File
@@ -3,7 +3,8 @@ import { buildJcodeRuntimeDirEnv, JCODE_RUNTIME_DIR_ENV_KEY } from '../../shared
/** Reconstructs identity formulas from fake spawn args; wiring and directory readiness need real-path tests. */
export function spawnedPtyEnv(call: unknown): Record<string, string> {
const values: Record<string, unknown> = typeof call === 'object' && call !== null ? { ...call } : {}
const values: Record<string, unknown> =
typeof call === 'object' && call !== null ? { ...call } : {}
const env: Record<string, string> = {}
if (typeof values.env === 'object' && values.env !== null) {
for (const [key, value] of Object.entries(values.env)) {
@@ -198,7 +198,9 @@ describe('a Codex send whose turn completes without taking it', () => {
.flatMap((event) => (event.type === 'batch' ? event.batch.submissions : []))
.filter((entry) => entry.clientMessageId === compact.clientMessageId)
expect(states.length).toBeGreaterThan(0)
expect(states.every((entry) => entry.dispatchState !== 'unknown' && !entry.recovered)).toBe(true)
expect(states.every((entry) => entry.dispatchState !== 'unknown' && !entry.recovered)).toBe(
true
)
handlers?.onNotification?.('turn/started', {
threadId: THREAD,
@@ -1,6 +1,5 @@
import { useState } from 'react'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import {
cancelManagedRuntimeInstall,
installManagedRuntime
@@ -10,26 +9,19 @@ import { Button } from '../ui/button'
import { Progress } from '../ui/progress'
import {
managedRuntimeInstallView,
type ManagedRuntimeInstallAction,
type ManagedRuntimeInstallSurface
type ManagedRuntimeInstallAction
} from './managed-runtime-install-view'
/** Install, progress and Cancel for the runtime Orca provides an agent, on the host it runs on.
* Settings and a chat show the same host answer, so either one's action moves both. */
/** Settings' Install, progress and Cancel for the runtime Orca provides an agent, on the host it
* runs on. A chat's install card shows the same host answer, so either one's action moves both. */
export function ManagedRuntimeInstallRow(props: {
observation: Extract<ManagedRuntimeInstallObservation, { kind: 'active' }>
agentLabel: string
surface: ManagedRuntimeInstallSurface
}): React.JSX.Element {
const { observation, agentLabel, surface } = props
const { observation, agentLabel } = props
// A click holds its button until the host answers, so a double click starts nothing twice.
const [pending, setPending] = useState<ManagedRuntimeInstallAction['kind'] | null>(null)
const view = managedRuntimeInstallView(
observation.snapshot,
agentLabel,
surface,
pending === 'cancel'
)
const view = managedRuntimeInstallView(observation.snapshot, agentLabel, pending === 'cancel')
const [refusal, setRefusal] = useState<string | null>(null)
const act = (action: ManagedRuntimeInstallAction): void => {
setPending(action.kind)
@@ -54,41 +46,15 @@ export function ManagedRuntimeInstallRow(props: {
)
.finally(() => setPending(null))
}
const chat = surface === 'chat'
const alert = refusal ?? view.error
const { action } = view
return (
<div
data-managed-runtime-install-row={surface}
className={cn(
'flex justify-between gap-3',
chat
? 'flex-col rounded-lg border border-input bg-card px-4 py-3 text-sm shadow-xs'
: 'items-center rounded-md border p-3 text-xs'
)}
>
<div className="flex items-center justify-between gap-3 rounded-md border p-3 text-xs">
<div className="min-w-0 flex-1 space-y-1">
{chat ? (
<p className="font-semibold text-foreground">
{translate(
'components.managed-runtime.chatTitle',
'Install {{agent}} to start chatting.',
{
agent: agentLabel
}
)}
</p>
) : null}
{/* Settings keeps the line while the first read is out, so nothing shifts when it lands. */}
{view.status !== null || !chat ? (
<p
role="status"
aria-live="polite"
className="min-h-4 text-muted-foreground tabular-nums"
>
{view.status}
</p>
) : null}
{/* The line stays while the first read is out, so nothing shifts when it lands. */}
<p role="status" aria-live="polite" className="min-h-4 text-muted-foreground tabular-nums">
{view.status}
</p>
{view.progress === null ? null : (
<Progress
value={view.progress}
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest'
import type {
ManagedRuntimeInstallState,
ManagedRuntimeOperation
} from '../../../../shared/managed-runtime-install'
import { managedRuntimeInstallCard } from './managed-runtime-install-card'
const idle: ManagedRuntimeInstallState = {
agent: 'fake',
revision: 1,
available: true,
version: '1.3.0',
downloadBytes: 111_500_000,
installedVersion: null,
installedCurrent: false,
source: null,
operation: null
}
const op = (patch: Partial<ManagedRuntimeOperation>): ManagedRuntimeOperation => ({
id: 'op-1',
kind: 'install',
phase: 'downloading',
downloadedBytes: 41_200_000,
totalBytes: 111_500_000,
...patch
})
describe('managedRuntimeInstallCard', () => {
it('walks the hero and the strip through offer, run, failure and cancel', () => {
const offer = managedRuntimeInstallCard(idle, 'Fake', 'hero')
expect(offer).toMatchObject({
title: 'Install Fake to start chatting',
detail:
"One-time 112 MB download. It's shared by Fake chats on the computer running this chat.",
hint: 'Also in Settings › Chat › Providers',
action: { kind: 'install', label: 'Install Fake' }
})
expect(managedRuntimeInstallCard(idle, 'Fake', 'strip')).toMatchObject({
title: 'Install Fake to continue',
detail: '112 MB download on the computer running this chat',
action: { label: 'Install' }
})
const running = managedRuntimeInstallCard({ ...idle, operation: op({}) }, 'Fake', 'hero')
expect(running).toMatchObject({
title: 'Downloading Fake',
progress: { detail: '41.2 of 111.5 MB', percentText: '37%' },
action: { kind: 'cancel', label: 'Cancel', operationId: 'op-1' }
})
expect(running.steps?.map((step) => step.state)).toEqual(['current', 'todo', 'todo'])
const checking = managedRuntimeInstallCard(
{ ...idle, operation: op({ phase: 'verifying' }) },
'Fake',
'strip',
true
)
expect(checking.title).toBe('Cancelling…')
expect(checking.progress).toMatchObject({ percent: null })
expect(checking.steps?.map((step) => step.state)).toEqual(['done', 'done', 'current'])
const failed = managedRuntimeInstallCard(
{
...idle,
operation: op({
phase: 'failed',
error: "Couldn't download Fake. Check your network connection or proxy, then try again."
})
},
'Fake',
'hero'
)
expect(failed).toMatchObject({
tone: 'failed',
title: "Couldn't install Fake",
detail: "Couldn't download Fake. Check your network connection or proxy, then try again.",
hint: '112 MB download.',
action: { label: 'Try again' }
})
expect(
managedRuntimeInstallCard({ ...idle, operation: op({ phase: 'cancelled' }) }, 'Fake', 'hero')
).toMatchObject({
title: 'Installation cancelled',
detail: 'Nothing was installed.',
action: { label: 'Retry installation' }
})
})
})
@@ -0,0 +1,223 @@
import { translate } from '@/i18n/i18n'
import type {
ManagedRuntimeInstallState,
ManagedRuntimeOperation
} from '../../../../shared/managed-runtime-install'
import { megabytes, type ManagedRuntimeInstallAction } from './managed-runtime-install-view'
/** `hero`: the whole-pane message of a chat with no messages; `strip`: the slim line above the
* composer outside that pane. Both read one card, so they never disagree. */
export type ManagedRuntimeInstallLayout = 'hero' | 'strip'
export type ManagedRuntimeInstallCardStep = { label: string; state: 'done' | 'current' | 'todo' }
export type ManagedRuntimeInstallCard = {
tone: 'offer' | 'running' | 'failed' | 'cancelled'
title: string
/** The sentence under the title: what it is, why it failed, or what ended. */
detail: string | null
/** `percent` is null while the size of what remains is unknown. */
progress: { percent: number | null; detail: string; percentText: string | null } | null
steps: ManagedRuntimeInstallCardStep[] | null
hint: string | null
action: ManagedRuntimeInstallAction | null
}
const STEP_PHASES: readonly ManagedRuntimeOperation['phase'][] = [
'downloading',
'extracting',
'verifying'
]
function stepLabels(): string[] {
return [
translate('components.managed-runtime.stepDownload', 'Download'),
translate('components.managed-runtime.stepUnpack', 'Unpack'),
translate('components.managed-runtime.stepCheck', 'Check')
]
}
function runningCard(
operation: ManagedRuntimeOperation,
agent: string,
cancelling: boolean
): ManagedRuntimeInstallCard | null {
const current = STEP_PHASES.indexOf(operation.phase)
if (current === -1) {
return null
}
const { downloadedBytes: done, totalBytes: total } = operation
const percent = current === 0 && total ? Math.min(100, (done / total) * 100) : null
const detail =
current === 1
? translate('components.managed-runtime.unpackingDetail', 'Unpacking the runtime')
: current === 2
? translate('components.managed-runtime.checkingDetail', 'Checking the downloaded runtime')
: total === null
? translate('components.managed-runtime.downloading', 'Downloading {{done}} MB.', {
done: megabytes(done)
})
: translate('components.managed-runtime.progressOf', '{{done}} of {{total}} MB', {
done: megabytes(done),
total: megabytes(total)
})
const cancellingText = translate('components.managed-runtime.cancelling', 'Cancelling…')
const title = cancelling
? cancellingText
: current === 0
? translate('components.managed-runtime.downloadingTitle', 'Downloading {{agent}}', { agent })
: current === 1
? translate('components.managed-runtime.unpackingTitle', 'Unpacking {{agent}}', { agent })
: translate('components.managed-runtime.checkingTitle', 'Checking the download')
return {
tone: 'running',
title,
detail: null,
progress: {
percent,
detail,
percentText: percent === null ? null : `${Math.round(percent)}%`
},
steps: stepLabels().map((label, index) => ({
label,
state: index < current ? 'done' : index === current ? 'current' : 'todo'
})),
hint: null,
action: {
kind: 'cancel',
label: cancelling
? cancellingText
: translate('components.managed-runtime.cancelShort', 'Cancel'),
operationId: operation.id
}
}
}
function offerDetail(
state: ManagedRuntimeInstallState,
agent: string,
hero: boolean,
size: string | null
): string | null {
if (state.damaged) {
return translate('components.managed-runtime.damaged', 'This copy of {{agent}} is damaged.', {
agent
})
}
if (!hero) {
return size === null
? null
: translate(
'components.managed-runtime.stripSize',
'{{size}} MB download on the computer running this chat',
{ size }
)
}
return size === null
? translate(
'components.managed-runtime.heroBodyNoSize',
"One-time download. It's shared by {{agent}} chats on the computer running this chat.",
{ agent }
)
: translate(
'components.managed-runtime.heroBody',
"One-time {{size}} MB download. It's shared by {{agent}} chats on the computer running this chat.",
{ size, agent }
)
}
function offerCard(
state: ManagedRuntimeInstallState,
agent: string,
hero: boolean,
size: string | null
): ManagedRuntimeInstallCard {
const title = hero
? translate('components.managed-runtime.installTitle', 'Install {{agent}} to start chatting', {
agent
})
: translate('components.managed-runtime.continueTitle', 'Install {{agent}} to continue', {
agent
})
const label = hero
? translate('components.managed-runtime.install', 'Install {{agent}}', { agent })
: translate('components.managed-runtime.installShort', 'Install')
const hint = translate(
'components.managed-runtime.settingsHint',
'Also in Settings › Chat › Providers'
)
return {
tone: 'offer',
title,
detail: offerDetail(state, agent, hero, size),
progress: null,
steps: null,
hint: hero ? hint : null,
action: { kind: 'install', label }
}
}
/** What an install card says and offers for the host's answer. `cancelling`: this surface's Cancel
* is still out, so it says so until the host's cancel settles. */
export function managedRuntimeInstallCard(
state: ManagedRuntimeInstallState,
agent: string,
layout: ManagedRuntimeInstallLayout,
cancelling = false
): ManagedRuntimeInstallCard {
const { operation } = state
const running = operation ? runningCard(operation, agent, cancelling) : null
if (running) {
return running
}
const hero = layout === 'hero'
const size = state.downloadBytes ? String(Math.ceil(state.downloadBytes / 1_000_000)) : null
const sizeHint =
hero && size !== null
? translate('components.managed-runtime.downloadSize', '{{size}} MB download.', { size })
: null
const install = (label: string): ManagedRuntimeInstallAction => ({ kind: 'install', label })
const retry = translate('components.managed-runtime.retry', 'Retry installation')
if (operation?.kind === 'install' && operation.phase === 'cancelled') {
return {
tone: 'cancelled',
title: translate('components.managed-runtime.cancelledTitle', 'Installation cancelled'),
detail: translate('components.managed-runtime.cancelledBody', 'Nothing was installed.'),
progress: null,
steps: null,
hint: sizeHint,
action: install(retry)
}
}
if (operation?.kind === 'install' && operation.phase === 'failed') {
return {
tone: 'failed',
title: translate('components.managed-runtime.failedTitle', "Couldn't install {{agent}}", {
agent
}),
detail:
operation.error ??
translate('components.managed-runtime.failed', 'Installation failed. Try again.'),
progress: null,
steps: null,
hint: sizeHint,
action: install(translate('components.managed-runtime.tryAgain', 'Try again'))
}
}
if (!state.available) {
return {
tone: 'offer',
title: translate(
'components.managed-runtime.unavailable',
'{{agent}} is not available on this host.',
{ agent }
),
detail: null,
progress: null,
steps: null,
hint: null,
action: null
}
}
return offerCard(state, agent, hero, size)
}
@@ -25,35 +25,30 @@ const op = (patch: Partial<ManagedRuntimeOperation>): ManagedRuntimeOperation =>
...patch
})
function shown(patch: Partial<ManagedRuntimeInstallState>, surface: 'settings' | 'chat') {
const view = managedRuntimeInstallView(
{ kind: 'known', state: { ...idle, ...patch } },
'Fake',
surface
)
function shown(patch: Partial<ManagedRuntimeInstallState>) {
const view = managedRuntimeInstallView({ kind: 'known', state: { ...idle, ...patch } }, 'Fake')
return [view.status, view.action?.label ?? null, view.error]
}
describe('managedRuntimeInstallView', () => {
it('says and offers what the host state allows on each surface', () => {
it('says and offers what the host state allows', () => {
const installed = {
installedVersion: '1.3.0',
installedCurrent: true,
source: 'managed' as const
}
expect(shown({}, 'settings')).toEqual(['Not installed.', 'Install Fake', null])
expect(shown({}, 'chat')).toEqual([null, 'Install Fake', null])
expect(shown({ downloadBytes: 111_456_962 }, 'chat')).toEqual([
expect(shown({})).toEqual(['Not installed.', 'Install Fake', null])
expect(shown({ downloadBytes: 111_456_962 })).toEqual([
'112 MB download.',
'Install Fake',
null
])
expect(shown({ operation: op({}) }, 'chat')).toEqual([
expect(shown({ operation: op({}) })).toEqual([
'Downloading 42.0 MB of 111.0 MB.',
'Cancel installation',
null
])
expect(shown({ operation: op({ phase: 'verifying' }) }, 'settings')).toEqual([
expect(shown({ operation: op({ phase: 'verifying' }) })).toEqual([
'Checking the downloaded runtime.',
'Cancel installation',
null
@@ -62,44 +57,45 @@ describe('managedRuntimeInstallView', () => {
const cancelling = managedRuntimeInstallView(
{ kind: 'known', state: { ...idle, operation: op({}) } },
'Fake',
'chat',
true
)
expect(cancelling.status).toBe('Cancelling…')
expect(shown({ operation: op({ phase: 'cancelled' }) }, 'chat')).toEqual([
expect(shown({ operation: op({ phase: 'cancelled' }) })).toEqual([
'Installation cancelled.',
'Retry installation',
null
])
expect(
shown({ operation: op({ phase: 'failed', error: 'No space left.' }) }, 'settings')
).toEqual(['Not installed.', 'Retry installation', 'No space left.'])
expect(shown(installed, 'settings')).toEqual(['Installed.', 'Reinstall Fake', null])
expect(
shown({ ...installed, installedVersion: '1.2.0', installedCurrent: false }, 'settings')
).toEqual(['Installed.', 'Update Fake', null])
expect(shown(installed, 'chat')[1]).toBeNull()
expect(shown({ available: false, version: null }, 'settings')).toEqual([
expect(shown({ operation: op({ phase: 'failed', error: 'No space left.' }) })).toEqual([
'Not installed.',
'Retry installation',
'No space left.'
])
expect(shown(installed)).toEqual(['Installed.', 'Reinstall Fake', null])
expect(shown({ ...installed, installedVersion: '1.2.0', installedCurrent: false })).toEqual([
'Installed.',
'Update Fake',
null
])
expect(shown({ available: false, version: null })).toEqual([
'Fake is not available on this host.',
null,
null
])
expect(shown({ damaged: true }, 'chat')).toEqual([
expect(shown({ damaged: true })).toEqual([
'This copy of Fake is damaged.',
'Install Fake',
null
])
const external = { source: 'external' as const }
expect(shown(external, 'settings')).toEqual([
expect(shown(external)).toEqual([
'Uses the copy of Fake found on this host.',
"Install Orca's copy",
null
])
expect(shown(external, 'chat')[1]).toBeNull()
})
it('never reads a failed status read as not installed', () => {
const view = managedRuntimeInstallView({ kind: 'readFailed' }, 'Fake', 'chat')
const view = managedRuntimeInstallView({ kind: 'readFailed' }, 'Fake')
expect(view.action).toBeNull()
expect(view.error).toBe('Could not check Fake on this host. Retrying.')
})
@@ -2,8 +2,6 @@ import { translate } from '@/i18n/i18n'
import type { ManagedRuntimeInstallState } from '../../../../shared/managed-runtime-install'
import type { ManagedRuntimeInstallSnapshot } from '@/runtime/managed-runtime-install-status'
export type ManagedRuntimeInstallSurface = 'settings' | 'chat'
export type ManagedRuntimeInstallAction =
| { kind: 'install'; label: string }
| { kind: 'cancel'; label: string; operationId: string }
@@ -17,7 +15,7 @@ export type ManagedRuntimeInstallView = {
action: ManagedRuntimeInstallAction | null
}
const megabytes = (bytes: number): string => (bytes / 1_000_000).toFixed(1)
export const megabytes = (bytes: number): string => (bytes / 1_000_000).toFixed(1)
function runningView(
operation: NonNullable<ManagedRuntimeInstallState['operation']>,
@@ -52,11 +50,7 @@ function runningView(
}
}
function settledView(
state: ManagedRuntimeInstallState,
agent: string,
surface: ManagedRuntimeInstallSurface
): ManagedRuntimeInstallView {
function settledView(state: ManagedRuntimeInstallState, agent: string): ManagedRuntimeInstallView {
const { operation } = state
const failed = operation?.phase === 'failed'
const error = failed
@@ -70,9 +64,7 @@ function settledView(
action: label === null ? null : { kind: 'install', label }
})
const install = translate('components.managed-runtime.install', 'Install {{agent}}', { agent })
// Install, Reinstall and Update belong to Settings; a chat that can start shows its composer.
const settings = surface === 'settings'
// A chat already runs a copy found outside Orca; Settings offers Orca's, which then runs first.
// A copy found outside Orca already runs; Orca's own, once installed, runs first.
if (state.source === 'external') {
return view(
translate(
@@ -80,7 +72,7 @@ function settledView(
'Uses the copy of {{agent}} found on this host.',
{ agent }
),
settings && state.available
state.available
? translate('components.managed-runtime.installManaged', "Install Orca's copy")
: null
)
@@ -89,10 +81,7 @@ function settledView(
const label = state.installedCurrent
? translate('components.managed-runtime.reinstall', 'Reinstall {{agent}}', { agent })
: translate('components.managed-runtime.update', 'Update {{agent}}', { agent })
return view(
translate('components.managed-runtime.installed', 'Installed.'),
settings ? label : null
)
return view(translate('components.managed-runtime.installed', 'Installed.'), label)
}
if (!state.available) {
return view(
@@ -113,16 +102,14 @@ function settledView(
install
)
}
// The chat card's heading already says it is not installed; the size says what Install costs.
// The size says what Install costs.
const size = state.downloadBytes
const idle =
size !== undefined
? translate('components.managed-runtime.downloadSize', '{{size}} MB download.', {
size: Math.ceil(size / 1_000_000)
})
: settings
? translate('components.managed-runtime.notInstalled', 'Not installed.')
: null
: translate('components.managed-runtime.notInstalled', 'Not installed.')
const retry = translate('components.managed-runtime.retry', 'Retry installation')
if (operation?.kind === 'install' && operation.phase === 'cancelled') {
return view(translate('components.managed-runtime.cancelled', 'Installation cancelled.'), retry)
@@ -136,7 +123,6 @@ function settledView(
export function managedRuntimeInstallView(
snapshot: ManagedRuntimeInstallSnapshot,
agent: string,
surface: ManagedRuntimeInstallSurface,
cancelling = false
): ManagedRuntimeInstallView {
if (snapshot.kind !== 'known') {
@@ -157,6 +143,6 @@ export function managedRuntimeInstallView(
const { operation } = snapshot.state
return (
(operation ? runningView(operation, agent, cancelling) : null) ??
settledView(snapshot.state, agent, surface)
settledView(snapshot.state, agent)
)
}
@@ -0,0 +1,66 @@
import { useState } from 'react'
import { translate } from '@/i18n/i18n'
import {
cancelManagedRuntimeInstall,
installManagedRuntime
} from '@/runtime/managed-runtime-install-status'
import type { ManagedRuntimeInstallObservation } from '@/runtime/use-managed-runtime-install'
import type { ManagedRuntimeInstallState } from '../../../../shared/managed-runtime-install'
import {
managedRuntimeInstallCard,
type ManagedRuntimeInstallCard,
type ManagedRuntimeInstallLayout
} from './managed-runtime-install-card'
import type { ManagedRuntimeInstallAction } from './managed-runtime-install-view'
/** The install card and its Install / Cancel clicks. A click holds its button (`pending`) until
* the host answers, so a double click starts nothing twice; `refusal` is why the host turned the
* last click down. */
export function useManagedRuntimeInstallCard(
observation: Extract<ManagedRuntimeInstallObservation, { kind: 'active' }>,
state: ManagedRuntimeInstallState,
agentLabel: string,
layout: ManagedRuntimeInstallLayout
): {
card: ManagedRuntimeInstallCard
pending: boolean
refusal: string | null
act: (action: ManagedRuntimeInstallAction) => void
} {
const [pending, setPending] = useState<ManagedRuntimeInstallAction['kind'] | null>(null)
const [refusal, setRefusal] = useState<string | null>(null)
const act = (action: ManagedRuntimeInstallAction): void => {
setPending(action.kind)
setRefusal(null)
const { subject } = observation
const call =
action.kind === 'cancel'
? cancelManagedRuntimeInstall(subject, action.operationId)
: installManagedRuntime(subject)
void call
.catch((cause: unknown) =>
setRefusal(
cause instanceof Error
? cause.message
: action.kind === 'cancel'
? translate(
'components.managed-runtime.cancelFailed',
"Couldn't cancel the installation. Try again."
)
: translate('components.managed-runtime.failed', 'Installation failed. Try again.')
)
)
.finally(() => setPending(null))
}
const card = managedRuntimeInstallCard(state, agentLabel, layout, pending === 'cancel')
return {
// An offer's hint points at Settings › Chat › Providers, which installs on this computer only.
card:
card.tone === 'offer' && observation.subject.target.kind !== 'local'
? { ...card, hint: null }
: card,
pending: pending !== null,
refusal,
act
}
}
@@ -54,6 +54,7 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
targetPtyId,
agent,
canSend = true,
unavailableHint,
isWorking = false,
isStopping = false,
afterStop,
@@ -118,6 +119,11 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
agent,
structuredTransport
)
// Why inputOwnedByCard: the hidden field can still hold keyboard focus for a frame.
const [hasPty, disabled] = structuredTransport
? [true, !canSend || unavailableHint !== undefined]
: [targetPtyId !== null, targetPtyId === null || !canSend || inputOwnedByCard]
const picker = useNativeChatPickerState({
agent,
terminalTabId,
@@ -126,6 +132,7 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
caret,
agentCommands,
recalledFromHistory: recall.active,
disabled,
sessionSkillNames,
textareaRef,
setDraft,
@@ -155,11 +162,6 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
return { ptyId: targetPtyId }
}, [targetPtyId])
// Why inputOwnedByCard: the hidden field can still hold keyboard focus for a frame.
const [hasPty, disabled] = structuredTransport
? [true, !canSend]
: [targetPtyId !== null, targetPtyId === null || !canSend || inputOwnedByCard]
const attachments = useNativeChatComposerAttachments({
attachmentScopeKey: draftScopeKey,
allowWithoutTarget: Boolean(structuredTransport),
@@ -231,7 +233,7 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
setNotice
})
const dictation = useNativeChatDictation(textareaRef)
const dictation = useNativeChatDictation(textareaRef, disabled)
const { dispatch: dispatchSessionOptionCommand, isDispatching: isDispatchingSessionOption } =
useNativeChatSessionOptionCommand({
agent,
@@ -367,6 +369,7 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
disabled={disabled}
hasPty={hasPty}
canSend={canSend}
unavailableHint={unavailableHint}
autocomplete={autocomplete}
mentionFiles={mentionFiles}
activeSuggestion={activeSuggestion}
@@ -1,9 +1,18 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { act, cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react'
import type { ReactNode } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
const voice = vi.hoisted(() => ({
dictationState: 'idle',
settings: { voice: { enabled: true, sttModel: 'configured' } }
}))
vi.mock('../../store', () => ({
useAppStore: (selector: (state: typeof voice) => unknown) => selector(voice)
}))
vi.mock('../dictation/dictation-control-events', () => ({ dispatchDictationControl: vi.fn() }))
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string) => fallback
}))
@@ -32,10 +41,43 @@ vi.mock('./NativeChatSessionOptionPickers', () => ({
}))
import { NativeChatComposerActions } from './NativeChatComposerActions'
import { useNativeChatDictation } from './use-native-chat-dictation'
import { dispatchDictationControl } from '../dictation/dictation-control-events'
afterEach(() => cleanup())
afterEach(() => {
cleanup()
voice.dictationState = 'idle'
vi.clearAllMocks()
})
describe('NativeChatComposerActions', () => {
it.each(['starting', 'listening'])('blocks new dictation but preserves %s Stop', (state) => {
const input = document.createElement('textarea')
const focus = vi.spyOn(input, 'focus')
const { result, rerender } = renderHook(
(disabled: boolean) => useNativeChatDictation({ current: input }, disabled),
{ initialProps: true }
)
act(() => {
result.current.toggleDictation()
result.current.startHoldDictation()
})
expect(dispatchDictationControl).not.toHaveBeenCalled()
rerender(false)
act(() => result.current.startHoldDictation())
expect(dispatchDictationControl).toHaveBeenLastCalledWith('start')
voice.dictationState = state
rerender(true)
expect(result.current.dictationDisabled).toBe(false)
focus.mockClear()
act(() => {
result.current.toggleDictation()
result.current.stopHoldDictation()
})
expect(dispatchDictationControl).toHaveBeenLastCalledWith('stop')
expect(focus).not.toHaveBeenCalled()
})
it("disables the Stop control and names it Stopping while a person's Stop is ending the turn", () => {
const props = {
attachDisabled: false,
@@ -36,6 +36,7 @@ export type NativeChatComposerFieldProps = {
disabled: boolean
hasPty: boolean
canSend: boolean
unavailableHint?: string | undefined
autocomplete: ComposerAutocomplete
mentionFiles: NativeChatMentionFiles
activeSuggestion: number
@@ -127,6 +128,7 @@ export function NativeChatComposerField({
disabled,
hasPty,
canSend,
unavailableHint,
autocomplete,
mentionFiles,
activeSuggestion,
@@ -329,7 +331,7 @@ export function NativeChatComposerField({
'components.native-chat.goal.placeholder',
'Describe your goal, define measurable outcomes for best results'
)
: nativeChatComposerPlaceholder(hasPty, canSend, afterStop)
: (unavailableHint ?? nativeChatComposerPlaceholder(hasPty, canSend, afterStop))
}
// Why: coarse-pointer min-height follows the app's touch target convention.
// Editable content grows naturally; the 8lh cap (plus
@@ -0,0 +1,19 @@
import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label'
import type { AgentType } from '../../../../shared/agent-status-types'
import { NativeChatEmptyState } from './NativeChatEmptyState'
import { NativeChatInstallHero } from './NativeChatInstallHero'
import type { NativeChatInstallSlotState } from './NativeChatInstallSlot'
/** The pane of a chat with no messages: the install message while its host has no program to run,
* else the Start-a-chat copy. */
export function NativeChatEmptyPane(props: {
slot: NativeChatInstallSlotState
agent: AgentType
}): React.JSX.Element {
const { slot, agent } = props
return slot.kind === 'install' ? (
<NativeChatInstallHero slot={slot} agentLabel={structuredAgentLabel(agent)} />
) : (
<NativeChatEmptyState kind="empty" agent={agent} />
)
}
@@ -0,0 +1,106 @@
import { Check, Download } from 'lucide-react'
import { useManagedRuntimeInstallCard } from '@/components/managed-runtime/use-managed-runtime-install-card'
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import type { NativeChatInstallSlotInstall } from './NativeChatInstallSlot'
import { NativeChatInstallTile } from './NativeChatInstallTile'
/** A new chat's pane while its host has no program to run: what Install is, one action, and the
* steps once it runs. The composer stays below, disabled, so nothing moves when it finishes. */
export function NativeChatInstallHero(props: {
slot: NativeChatInstallSlotInstall
agentLabel: string
}): React.JSX.Element {
const { slot, agentLabel } = props
const { card, pending, refusal, act } = useManagedRuntimeInstallCard(
slot.observation,
slot.state,
agentLabel,
'hero'
)
const { action, progress, steps } = card
return (
<div
data-native-chat-install="hero"
className="flex h-full w-full flex-col items-center justify-center p-6 text-center"
>
<div className="flex w-full max-w-sm flex-col items-center">
<NativeChatInstallTile
agent={slot.observation.subject.agent}
failed={card.tone === 'failed'}
large
/>
<h2 className="mt-4 text-sm font-semibold text-chat-foreground-strong">{card.title}</h2>
{card.detail ? (
<p className="mt-1.5 text-balance text-sm text-chat-foreground-faint">{card.detail}</p>
) : null}
{progress ? (
<div className="mt-3 w-full">
<Progress
value={progress.percent ?? 100}
className="h-1.5"
aria-label={translate('components.managed-runtime.progress', '{{agent}} download', {
agent: agentLabel
})}
/>
<div
role="status"
aria-live="polite"
className="mt-2 flex justify-between text-xs text-chat-foreground-faint tabular-nums"
>
<span>{progress.detail}</span>
<span>{progress.percentText}</span>
</div>
</div>
) : null}
{steps ? (
<ol className="mt-3 mb-3 flex justify-center gap-4 text-xs">
{steps.map((step) => (
<li
key={step.label}
aria-current={step.state === 'current' ? 'step' : undefined}
className={cn(
'flex items-center gap-1.5',
step.state === 'current'
? 'font-medium text-chat-foreground-strong'
: 'text-chat-foreground-faint'
)}
>
{step.state === 'done' ? (
<Check className="size-3" />
) : (
<span
className={cn(
'size-1.5 rounded-full bg-current',
step.state === 'current' ? 'opacity-100' : 'opacity-40'
)}
/>
)}
{step.label}
</li>
))}
</ol>
) : null}
{action ? (
<Button
variant={action.kind === 'cancel' ? 'ghost' : 'default'}
className={action.kind === 'cancel' ? 'mt-1' : 'mt-5'}
disabled={pending}
onClick={() => act(action)}
>
{action.kind === 'install' ? <Download /> : null}
{action.label}
</Button>
) : null}
{card.hint ? <p className="mt-3 text-xs text-chat-foreground-faint">{card.hint}</p> : null}
{refusal ? (
<p role="alert" className="mt-3 break-words text-xs text-destructive">
{refusal}
</p>
) : null}
</div>
</div>
)
}
@@ -1,36 +1,64 @@
import { ManagedRuntimeInstallRow } from '@/components/managed-runtime/ManagedRuntimeInstallRow'
import { translate } from '@/i18n/i18n'
import {
managedRuntimeInstallCard,
type ManagedRuntimeInstallLayout
} from '@/components/managed-runtime/managed-runtime-install-card'
import {
useManagedRuntimeInstall,
type ManagedRuntimeInstallObservation
} from '@/runtime/use-managed-runtime-install'
import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target'
import type { ManagedRuntimeInstallState } from '../../../../shared/managed-runtime-install'
import { NativeChatInstallStrip } from './NativeChatInstallStrip'
import type { NativeChatStructuredViewProps } from './native-chat-view-types'
/** What a chat's input slot holds for the program its host must install first. */
export type NativeChatInstallSlotState =
| { kind: 'none' }
/** The first status read is out: the composer stays, its sends held until the host answers. */
| { kind: 'reserved' }
| { kind: 'install'; observation: Extract<ManagedRuntimeInstallObservation, { kind: 'active' }> }
| {
kind: 'install'
observation: Extract<ManagedRuntimeInstallObservation, { kind: 'active' }>
state: ManagedRuntimeInstallState
layout: ManagedRuntimeInstallLayout
}
export type NativeChatInstallSlotInstall = Extract<NativeChatInstallSlotState, { kind: 'install' }>
const NONE: NativeChatInstallSlotState = { kind: 'none' }
const RESERVED: NativeChatInstallSlotState = { kind: 'reserved' }
/** Install takes the composer's place while the chat's host has nothing to run, its install
* included; anything runnable, or a status the host could not report, leaves the composer. Until
* the host says whether it provides the program, the composer stays but sends nothing. */
/** Install shows while the chat's host has nothing to run, its install included, and the composer
* stays disabled beneath it; anything runnable, or a status the host could not report, leaves
* the composer alone. Until the host says whether it provides the program, the composer stays
* usable but sends nothing. */
export function useNativeChatInstallSlot(
target: RuntimeClientTarget,
agent: string,
shown: boolean,
agentLabel: string
): { slot: NativeChatInstallSlotState; sendBlockedReason: string | null } {
const slot = installSlotState(useManagedRuntimeInstall(target, agent, shown))
return { slot, sendBlockedReason: installSlotSendBlockedReason(slot, agentLabel) }
props: Pick<NativeChatStructuredViewProps, 'target' | 'agent' | 'isVisible'>,
agentLabel: string,
layout: ManagedRuntimeInstallLayout
): {
slot: NativeChatInstallSlotState
sendBlockedReason: string | null
unavailableHint: string | undefined
} {
const slot = installSlotState(
useManagedRuntimeInstall(props.target, props.agent, props.isVisible),
layout
)
const unavailableHint =
slot.kind === 'install'
? managedRuntimeInstallCard(slot.state, agentLabel, slot.layout).title
: undefined
return {
slot,
sendBlockedReason: unavailableHint ?? installSlotSendBlockedReason(slot, agentLabel),
unavailableHint
}
}
function installSlotState(
observation: ManagedRuntimeInstallObservation
observation: ManagedRuntimeInstallObservation,
layout: ManagedRuntimeInstallLayout
): NativeChatInstallSlotState {
if (observation.kind === 'inactive') {
return NONE
@@ -45,7 +73,9 @@ function installSlotState(
if (snapshot.kind === 'unknown') {
return RESERVED
}
return snapshot.state.source === null ? { kind: 'install', observation } : NONE
return snapshot.state.source === null
? { kind: 'install', observation, state: snapshot.state, layout }
: NONE
}
/** Send stays held until the host says it can run this agent. */
@@ -62,22 +92,19 @@ function installSlotSendBlockedReason(
)
}
/** Setup stays reachable whenever the empty pane is not showing the hero. */
export function NativeChatInstallSlot(props: {
slot: NativeChatInstallSlotState
agentLabel: string
}): React.JSX.Element | null {
const { slot } = props
if (slot.kind !== 'install') {
if (slot.kind !== 'install' || slot.layout !== 'strip') {
return null
}
return (
<div className="min-h-0 shrink overflow-hidden bg-chat-canvas">
<div className="mx-auto w-full max-w-(--chat-content-max-width) px-3 pt-2 pb-1 sm:px-4">
<ManagedRuntimeInstallRow
observation={slot.observation}
agentLabel={props.agentLabel}
surface="chat"
/>
<NativeChatInstallStrip slot={slot} agentLabel={props.agentLabel} />
</div>
</div>
)
@@ -0,0 +1,74 @@
import { Download } from 'lucide-react'
import { useManagedRuntimeInstallCard } from '@/components/managed-runtime/use-managed-runtime-install-card'
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
import { translate } from '@/i18n/i18n'
import { NativeChatInstallTile } from './NativeChatInstallTile'
import type { NativeChatInstallSlotInstall } from './NativeChatInstallSlot'
/** The same install card, slimmed to one line above the composer for a chat that has messages. */
export function NativeChatInstallStrip(props: {
slot: NativeChatInstallSlotInstall
agentLabel: string
}): React.JSX.Element {
const { slot, agentLabel } = props
const { card, pending, refusal, act } = useManagedRuntimeInstallCard(
slot.observation,
slot.state,
agentLabel,
'strip'
)
const { action, progress } = card
const status = progress
? [progress.detail, progress.percentText].filter(Boolean).join(' · ')
: card.detail
return (
<div
data-native-chat-install="strip"
className="flex items-center gap-3 rounded-xl border border-input bg-card px-3.5 py-3 text-sm shadow-xs"
>
<NativeChatInstallTile
agent={slot.observation.subject.agent}
failed={card.tone === 'failed'}
large={false}
/>
<div className="min-w-0 flex-1">
<p className="font-semibold text-foreground">{card.title}</p>
{status ? (
<p
role="status"
aria-live="polite"
className="text-xs text-muted-foreground tabular-nums"
>
{status}
</p>
) : null}
{progress ? (
<Progress
value={progress.percent ?? 100}
className="mt-2 h-1.5"
aria-label={translate('components.managed-runtime.progress', '{{agent}} download', {
agent: agentLabel
})}
/>
) : null}
{refusal ? (
<p role="alert" className="mt-1 break-words text-xs text-destructive">
{refusal}
</p>
) : null}
</div>
{action ? (
<Button
size="sm"
variant={action.kind === 'cancel' ? 'ghost' : 'default'}
disabled={pending}
onClick={() => act(action)}
>
{action.kind === 'install' && card.tone === 'offer' ? <Download /> : null}
{action.label}
</Button>
) : null}
</div>
)
}
@@ -0,0 +1,32 @@
import { TriangleAlert } from 'lucide-react'
import { AgentIcon } from '@/lib/agent-catalog'
import { agentTypeToIconAgent } from '@/lib/agent-status'
import { cn } from '@/lib/utils'
import type { AgentType } from '../../../../shared/agent-status-types'
/** The agent's mark in a tile; a failed install swaps it for an amber warning. */
export function NativeChatInstallTile(props: {
agent: AgentType
failed: boolean
large: boolean
}): React.JSX.Element {
const { agent, failed, large } = props
return (
<div
aria-hidden
className={cn(
'flex shrink-0 items-center justify-center border',
large ? 'size-14 rounded-2xl' : 'size-9 rounded-xl',
failed
? 'border-chat-warning/30 bg-chat-warning/10 text-chat-warning'
: 'border-border bg-accent'
)}
>
{failed ? (
<TriangleAlert className={large ? 'size-6' : 'size-4.5'} />
) : (
<AgentIcon agent={agentTypeToIconAgent(agent)} size={large ? 28 : 20} />
)}
</div>
)
}
@@ -82,7 +82,11 @@ export function NativeChatPromptEditor({
NativeChatSkill,
NativeChatFileReference,
NativeChatImageReference,
Placeholder.configure({ placeholder: () => placeholderRef.current })
// A disabled box still says why it is disabled, as the plain text box's placeholder does.
Placeholder.configure({
placeholder: () => placeholderRef.current,
showOnlyWhenEditable: false
})
],
content:
(scopeKey && readNativeChatDraftDocument(scopeKey, initialValue)) ||
@@ -295,7 +299,12 @@ export function NativeChatPromptEditor({
return (
<NativeChatImageRowProvider value={imageRow}>
<EditorContent editor={editor} {...events} />
<EditorContent
editor={editor}
{...events}
data-disabled={disabled || undefined}
className="data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50"
/>
</NativeChatImageRowProvider>
)
}
@@ -83,7 +83,7 @@ describe('NativeChatStructuredSession launch lifecycle', () => {
install.observation = { kind: 'inactive' }
})
it('puts Install in the composer slot until the host has the program, and keeps the draft', () => {
it('shows Install over a disabled composer until the host has the program', () => {
const notInstalled: ManagedRuntimeInstallState = {
agent: 'codex',
revision: 1,
@@ -91,6 +91,7 @@ describe('NativeChatStructuredSession launch lifecycle', () => {
version: '1.3.0',
installedVersion: null,
installedCurrent: false,
downloadBytes: 111_500_000,
source: null,
operation: null
}
@@ -103,21 +104,48 @@ describe('NativeChatStructuredSession launch lifecycle', () => {
}
const draft = structuredAgentSessionDraftScopeKey('session-1')
updateNativeChatComposerDraft(draft, { text: 'hello' }, 'immediate')
mocks.messages = []
// Before the first answer the composer stays (its sends held) and Install doesn't flash.
hostSays(null)
const { rerender } = render(sessionView())
expect(screen.getByTestId('structured-composer')).toBeTruthy()
expect(screen.queryByRole('button', { name: 'Install Codex' })).toBeNull()
expect(mocks.composerProps?.unavailableHint).toBeUndefined()
// A new chat gets the install message in its pane; the composer stays, disabled.
hostSays(notInstalled)
rerender(sessionView())
expect(screen.getByRole('button', { name: 'Install Codex' })).toBeTruthy()
expect(screen.queryByTestId('structured-composer')).toBeNull()
expect(screen.getByText(/One-time 112 MB download/)).toBeTruthy()
expect(screen.getByTestId('structured-composer')).toBeTruthy()
expect(mocks.composerProps?.unavailableHint).toBe('Install Codex to start chatting')
for (const lifecycle of ['pending', 'failed'] as const) {
mocks.launchLifecycle = lifecycle
mocks.launchResumes = true
rerender(<div key={lifecycle}>{sessionView()}</div>)
expect(screen.getAllByRole('button', { name: 'Install' })).toHaveLength(1)
expect(mocks.composerProps?.unavailableHint).toBe('Install Codex to continue')
expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull()
}
mocks.launchLifecycle = null
hostSays({ ...notInstalled, available: false })
rerender(sessionView())
expect(mocks.composerProps?.unavailableHint).toBe('Codex is not available on this host.')
expect(screen.queryByRole('button', { name: /^Install/ })).toBeNull()
hostSays(notInstalled)
// With messages, Install is a strip above the composer instead.
mocks.messages = null
rerender(sessionView())
expect(screen.getByRole('button', { name: 'Install' })).toBeTruthy()
expect(screen.queryByText(/One-time 112 MB download/)).toBeNull()
hostSays({ ...notInstalled, revision: 2, installedVersion: '1.3.0', source: 'managed' })
rerender(sessionView())
expect(screen.getByTestId('structured-composer')).toBeTruthy()
expect(screen.queryByRole('button', { name: 'Install Codex' })).toBeNull()
expect(screen.queryByRole('button', { name: /^Install/ })).toBeNull()
expect(mocks.composerProps?.unavailableHint).toBeUndefined()
expect(readNativeChatComposerDraft(draft).text).toBe('hello')
})
@@ -129,6 +129,7 @@ export function createStructuredSessionMocks() {
}
isWorking?: boolean
isStopping?: boolean
unavailableHint?: string
afterStop?: 'queue' | 'send'
steerQueued?: () => boolean
onStop?: () => void
@@ -10,6 +10,7 @@ import { NativeChatComposer, type NativeChatComposerHandle } from './NativeChatC
import type { NativeChatOptionPickerRequest } from './native-chat-composer-types'
import { structuredAgentSessionDraftScopeKey } from './native-chat-composer-draft-store'
import { NativeChatEmptyState } from './NativeChatEmptyState'
import { NativeChatEmptyPane } from './NativeChatEmptyPane'
import { NativeChatLoadingCue } from './NativeChatLoadingCue'
import { NativeChatMessageList } from './NativeChatMessageList'
import { useStructuredNativeChatSubmitReveal } from './use-structured-native-chat-submit-reveal'
@@ -69,7 +70,6 @@ export function NativeChatStructuredSession(
const ownerWorktreeId = useNativeChatTabOwnerWorktreeId(props.tabId)
const fileLinkContext = useNativeChatFileLinkContext(props.tabId)
const provisionalLaunch = useNativeChatProvisionalLaunch(ownerWorktreeId, props.sessionId)
const { sendThroughLaunch } = provisionalLaunch
// The host's own word on whether the provider child has answered startup yet.
const hostExecution = useStructuredAgentSessionHostExecution(props.sessionId, props.target)
const paneKey = useMemo(
@@ -199,9 +199,9 @@ export function NativeChatStructuredSession(
// cancel then works.
const promptsUnanswerable = pendingPromptsAllUnanswerableHere(controller.prompts)
const composerShown = (prompt === null || promptsUnanswerable) && !readFailedFinally
// An owned prompt keeps its card; otherwise a missing program takes the composer's place.
const install = useNativeChatInstallSlot(props.target, props.agent, props.isVisible, agentLabel)
const composerOpen = composerShown && install.slot.kind !== 'install'
// An owned prompt keeps its card; otherwise a missing program leaves the composer disabled.
const installLayout = viewState.kind === 'empty' ? 'hero' : 'strip'
const install = useNativeChatInstallSlot(props, agentLabel, installLayout)
const approval = prompt?.body.kind === 'approval' ? chatApprovalFromJournal(prompt.body) : null
const cancelPrompt = () => {
if (prompt && (controller.turnId || props.agent === 'pi')) {
@@ -218,14 +218,14 @@ export function NativeChatStructuredSession(
questionAnswerInputRef,
isVisible: props.isVisible,
isFocusedGroup: props.isFocusedGroup,
composerReady: composerOpen
composerReady: composerShown && install.slot.kind !== 'install'
})
const questionBody = prompt?.body.kind === 'question' ? prompt.body : null
const questions = questionBody ? agentSessionPromptQuestions(questionBody) : []
const structuredTransport = useNativeChatStructuredComposerTransport({
props,
controller,
sendThroughLaunch,
sendThroughLaunch: provisionalLaunch.sendThroughLaunch,
starting: provisionalLaunch.starting,
worktreeId: ownerWorktreeId ?? undefined,
optionPickerRequest,
@@ -301,7 +301,7 @@ export function NativeChatStructuredSession(
{...(readFailure?.named ? { headline: readFailure.text } : {})}
/>
) : viewState.kind === 'empty' ? (
<NativeChatEmptyState kind="empty" agent={props.agent} />
<NativeChatEmptyPane slot={install.slot} agent={props.agent} />
) : (
<NativeChatRewindContext.Provider value={controller.rewind.surface}>
<NativeChatOrcaStopContext.Provider value={continuation.view}>
@@ -365,7 +365,7 @@ export function NativeChatStructuredSession(
/>
)}
{/* Prompt cards take the composer's slot, below the background-task dock. */}
{composerOpen ? null : <NativeChatPromptSlotNotices notices={notices} />}
{composerShown ? null : <NativeChatPromptSlotNotices notices={notices} />}
{prompt && approval ? (
<NativeChatApprovalCard
key={`${prompt.itemId}:${prompt.revision}`}
@@ -392,7 +392,7 @@ export function NativeChatStructuredSession(
/>
) : null}
{composerShown && <NativeChatInstallSlot slot={install.slot} agentLabel={agentLabel} />}
{composerOpen ? (
{composerShown ? (
<NativeChatComposer
ref={composerRef}
terminalTabId={props.tabId}
@@ -401,6 +401,7 @@ export function NativeChatStructuredSession(
targetPtyId={null}
agent={props.agent}
{...stopControls.composer}
unavailableHint={install.unavailableHint}
steerQueued={stopControls.stopping ? undefined : submits.queuedMessages.steerNewest}
structuredTransport={structuredTransport}
launchSeed={{ ...launchDraftSignal, ownsTabWideLaunchDraft: true }}
@@ -85,6 +85,9 @@ export type NativeChatComposerProps = {
agent: AgentType
/** Guard desktop sends while a mobile client owns the terminal input lease. */
canSend?: boolean
/** The chat can't take input yet (a program it needs isn't installed): the composer is disabled
* and this is its placeholder. */
unavailableHint?: string | undefined
/** True while the hosted TUI reports an in-flight turn; swaps Send to Stop. */
isWorking?: boolean
/** This client's Stop request is in flight: the Stop control is disabled and says so. */
@@ -4,7 +4,10 @@ import { useAppStore } from '../../store'
import { dispatchDictationControl } from '../dictation/dictation-control-events'
/** Whether the composer can dictate and is dictating now, and the controls that start and stop it. */
export function useNativeChatDictation(textareaRef: RefObject<NativeChatComposerInput | null>): {
export function useNativeChatDictation(
textareaRef: RefObject<NativeChatComposerInput | null>,
disabled: boolean
): {
dictationDisabled: boolean
isDictating: boolean
isDictationHoldMode: boolean
@@ -15,27 +18,37 @@ export function useNativeChatDictation(textareaRef: RefObject<NativeChatComposer
const [dictationPressed, setDictationPressed] = useState(false)
const dictationState = useAppStore((store) => store.dictationState)
const voiceSettings = useAppStore((store) => store.settings?.voice)
const isDictating =
dictationPressed ||
dictationState === 'starting' ||
dictationState === 'listening' ||
dictationState === 'stopping'
const dictationDisabled =
!isDictating && (disabled || voiceSettings?.enabled !== true || !voiceSettings.sttModel)
const focusForDictation = useCallback(() => textareaRef.current?.focus(), [textareaRef])
const toggleDictation = useCallback(() => {
focusForDictation()
dispatchDictationControl('toggle')
}, [focusForDictation])
if (isDictating) {
dispatchDictationControl('stop')
} else if (!dictationDisabled) {
focusForDictation()
dispatchDictationControl('toggle')
}
}, [dictationDisabled, focusForDictation, isDictating])
const startHoldDictation = useCallback(() => {
if (disabled || dictationDisabled || isDictating) {
return
}
setDictationPressed(true)
focusForDictation()
dispatchDictationControl('start')
}, [focusForDictation])
}, [disabled, dictationDisabled, focusForDictation, isDictating])
const stopHoldDictation = useCallback(() => {
setDictationPressed(false)
dispatchDictationControl('stop')
}, [])
return {
dictationDisabled: voiceSettings?.enabled !== true || !voiceSettings.sttModel,
isDictating:
dictationPressed ||
dictationState === 'starting' ||
dictationState === 'listening' ||
dictationState === 'stopping',
dictationDisabled,
isDictating,
isDictationHoldMode: voiceSettings?.dictationMode === 'hold',
toggleDictation,
startHoldDictation,
@@ -8,21 +8,39 @@ vi.mock('./use-native-chat-skills', () => ({
}))
function pickerMode(draft: string, recalledFromHistory: boolean): string {
const { result } = renderHook(() =>
useNativeChatPickerState({
agent: 'claude',
terminalTabId: 'tab-1',
draftScopeKey: 'pane-1',
draft,
caret: draft.length,
agentCommands: [{ name: 'clear' }],
recalledFromHistory,
textareaRef: { current: null },
setDraft: vi.fn(),
setCaret: vi.fn(),
setActiveSuggestion: vi.fn()
})
const setDraft = vi.fn()
const { result, rerender } = renderHook(
(disabled: boolean) =>
useNativeChatPickerState({
agent: 'claude',
terminalTabId: 'tab-1',
draftScopeKey: 'pane-1',
draft,
caret: draft.length,
agentCommands: [{ name: 'clear' }],
recalledFromHistory,
disabled,
textareaRef: { current: null },
setDraft,
setCaret: vi.fn(),
setActiveSuggestion: vi.fn()
}),
{ initialProps: false }
)
const completeMention = result.current.completeMention
const completeItem = result.current.completeItem
const item =
result.current.autocomplete.mode === 'slash' ? result.current.autocomplete.items[0] : null
const typedMode = result.current.autocomplete.mode
rerender(true)
expect(result.current.autocomplete.mode).toBe('none')
completeMention('app.ts')
if (item) {
completeItem(item)
}
expect(setDraft).not.toHaveBeenCalled()
rerender(false)
expect(result.current.autocomplete.mode).toBe(typedMode)
return result.current.autocomplete.mode
}
@@ -3,6 +3,7 @@ import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
@@ -52,6 +53,7 @@ export function useNativeChatPickerState(args: {
agentCommands: readonly SlashCommandSuggestion[]
/** A message recalled from history is resent or walked past, not completed, until it is edited. */
recalledFromHistory: boolean
disabled: boolean
/** Skill names the running session reports; undefined keeps the host disk scan. */
sessionSkillNames?: readonly string[]
textareaRef: RefObject<NativeChatComposerInput | null>
@@ -67,6 +69,7 @@ export function useNativeChatPickerState(args: {
caret,
agentCommands,
recalledFromHistory,
disabled,
sessionSkillNames,
textareaRef,
setDraft,
@@ -74,7 +77,11 @@ export function useNativeChatPickerState(args: {
setActiveSuggestion
} = args
const profile = useMemo(() => getNativeChatAgentProfile(agent), [agent])
const skillPickerTriggered = isSkillPickerTriggered(draft.slice(0, caret), profile)
const disabledRef = useRef(disabled)
useLayoutEffect(() => {
disabledRef.current = disabled
}, [disabled])
const skillPickerTriggered = !disabled && isSkillPickerTriggered(draft.slice(0, caret), profile)
const discovery = useNativeChatSkills(agent, terminalTabId, skillPickerTriggered)
const listboxId = `native-chat-picker-${useId().replaceAll(':', '')}`
const dismissalContext = `${draftScopeKey}:${agent}`
@@ -83,7 +90,7 @@ export function useNativeChatPickerState(args: {
const lastOpenKeyRef = useRef<string | null>(null)
const autocomplete = useMemo<ComposerAutocomplete>(
() =>
recalledFromHistory
recalledFromHistory || disabled
? { mode: 'none' }
: deriveComposerAutocomplete(
draft,
@@ -101,6 +108,7 @@ export function useNativeChatPickerState(args: {
dismissalContext,
dismissed,
discovery,
disabled,
draft,
profile,
recalledFromHistory,
@@ -143,7 +151,7 @@ export function useNativeChatPickerState(args: {
const completeItem = useCallback(
(item: NativeChatPickerItem) => {
if (autocomplete.mode !== 'slash') {
if (disabledRef.current || autocomplete.mode !== 'slash') {
return
}
const result = applyPickerSuggestion(draft, caret, item)
@@ -159,8 +167,13 @@ export function useNativeChatPickerState(args: {
)
const completeMention = useCallback(
(path: string) => commitCompletion(applyMentionSuggestion(draft, caret, path)),
[caret, commitCompletion, draft]
(path: string) => {
if (disabledRef.current || autocomplete.mode !== 'mention') {
return
}
commitCompletion(applyMentionSuggestion(draft, caret, path))
},
[autocomplete, caret, commitCompletion, draft]
)
const handleDraftOrCaretChange = useCallback(
@@ -298,7 +298,6 @@ export function AntigravityAccountsSection({
<ManagedRuntimeInstallRow
observation={chatRuntime}
agentLabel={translate('accounts.antigravity.title', 'Antigravity')}
surface="settings"
/>
</div>
) : null}
@@ -104,7 +104,6 @@ function ProviderDetail(props: {
<ManagedRuntimeInstallRow
observation={runtime}
agentLabel={getAgentLabel(props.agent)}
surface="settings"
/>
</div>
)
+22 -2
View File
@@ -19422,7 +19422,6 @@
"cancel": "Cancel installation",
"cancelled": "Installation cancelled.",
"cancelling": "Cancelling…",
"chatTitle": "Install {{agent}} to start chatting.",
"damaged": "This copy of {{agent}} is damaged.",
"downloadSize": "{{size}} MB download.",
"downloading": "Downloading {{done}} MB.",
@@ -19441,7 +19440,28 @@
"retry": "Retry installation",
"unavailable": "{{agent}} is not available on this host.",
"update": "Update {{agent}}",
"verifying": "Checking the downloaded runtime."
"verifying": "Checking the downloaded runtime.",
"installTitle": "Install {{agent}} to start chatting",
"continueTitle": "Install {{agent}} to continue",
"heroBody": "One-time {{size}} MB download. It's shared by {{agent}} chats on the computer running this chat.",
"heroBodyNoSize": "One-time download. It's shared by {{agent}} chats on the computer running this chat.",
"stripSize": "{{size}} MB download on the computer running this chat",
"settingsHint": "Also in Settings › Chat › Providers",
"installShort": "Install",
"tryAgain": "Try again",
"cancelShort": "Cancel",
"failedTitle": "Couldn't install {{agent}}",
"cancelledTitle": "Installation cancelled",
"cancelledBody": "Nothing was installed.",
"downloadingTitle": "Downloading {{agent}}",
"unpackingTitle": "Unpacking {{agent}}",
"checkingTitle": "Checking the download",
"progressOf": "{{done}} of {{total}} MB",
"unpackingDetail": "Unpacking the runtime",
"checkingDetail": "Checking the downloaded runtime",
"stepDownload": "Download",
"stepUnpack": "Unpack",
"stepCheck": "Check"
}
},
"dashboardPopout": {
+22 -2
View File
@@ -19189,7 +19189,6 @@
"cancel": "Cancelar instalación",
"cancelled": "Instalación cancelada.",
"cancelling": "Cancelando…",
"chatTitle": "Instala {{agent}} para empezar a chatear.",
"damaged": "Esta copia de {{agent}} está dañada.",
"downloadSize": "Descarga de {{size}} MB.",
"downloading": "Descargando {{done}} MB.",
@@ -19208,7 +19207,28 @@
"retry": "Reintentar instalación",
"unavailable": "{{agent}} no está disponible en este host.",
"update": "Actualizar {{agent}}",
"verifying": "Comprobando el entorno descargado."
"verifying": "Comprobando el entorno descargado.",
"installTitle": "Instala {{agent}} para empezar a chatear",
"continueTitle": "Instala {{agent}} para continuar",
"heroBody": "Descarga única de {{size}} MB. La comparten los chats de {{agent}} en el equipo que ejecuta este chat.",
"heroBodyNoSize": "Descarga única. La comparten los chats de {{agent}} en el equipo que ejecuta este chat.",
"stripSize": "Descarga de {{size}} MB en el equipo que ejecuta este chat",
"settingsHint": "También en Ajustes › Chat › Proveedores",
"installShort": "Instalar",
"tryAgain": "Reintentar",
"cancelShort": "Cancelar",
"failedTitle": "No se pudo instalar {{agent}}",
"cancelledTitle": "Instalación cancelada",
"cancelledBody": "No se instaló nada.",
"downloadingTitle": "Descargando {{agent}}",
"unpackingTitle": "Descomprimiendo {{agent}}",
"checkingTitle": "Comprobando la descarga",
"progressOf": "{{done}} de {{total}} MB",
"unpackingDetail": "Descomprimiendo el entorno de ejecución",
"checkingDetail": "Comprobando el entorno de ejecución descargado",
"stepDownload": "Descargar",
"stepUnpack": "Descomprimir",
"stepCheck": "Comprobar"
}
},
"dashboardPopout": {
+22 -2
View File
@@ -19305,7 +19305,6 @@
"cancel": "Annuler l'installation",
"cancelled": "Installation annulée.",
"cancelling": "Annulation…",
"chatTitle": "Installez {{agent}} pour commencer à discuter.",
"damaged": "Cette copie de {{agent}} est endommagée.",
"downloadSize": "Téléchargement de {{size}} Mo.",
"downloading": "Téléchargement de {{done}} Mo.",
@@ -19324,7 +19323,28 @@
"retry": "Réessayer l'installation",
"unavailable": "{{agent}} n'est pas disponible sur cet hôte.",
"update": "Mettre à jour {{agent}}",
"verifying": "Vérification de l’environnement téléchargé."
"verifying": "Vérification de l’environnement téléchargé.",
"installTitle": "Installez {{agent}} pour commencer à discuter",
"continueTitle": "Installez {{agent}} pour continuer",
"heroBody": "Téléchargement unique de {{size}} Mo, partagé par les conversations {{agent}} sur l’ordinateur qui exécute cette conversation.",
"heroBodyNoSize": "Téléchargement unique, partagé par les conversations {{agent}} sur l’ordinateur qui exécute cette conversation.",
"stripSize": "Téléchargement de {{size}} Mo sur l’ordinateur qui exécute cette conversation",
"settingsHint": "Aussi dans Paramètres › Chat › Fournisseurs",
"installShort": "Installer",
"tryAgain": "Réessayer",
"cancelShort": "Annuler",
"failedTitle": "Impossible d’installer {{agent}}",
"cancelledTitle": "Installation annulée",
"cancelledBody": "Rien n'a été installé.",
"downloadingTitle": "Téléchargement de {{agent}}",
"unpackingTitle": "Décompression de {{agent}}",
"checkingTitle": "Vérification du téléchargement",
"progressOf": "{{done}} sur {{total}} Mo",
"unpackingDetail": "Décompression de l'environnement d'exécution",
"checkingDetail": "Vérification de l'environnement d'exécution téléchargé",
"stepDownload": "Télécharger",
"stepUnpack": "Décompresser",
"stepCheck": "Vérifier"
}
},
"dashboardPopout": {
+22 -2
View File
@@ -19201,7 +19201,6 @@
"cancel": "インストールをキャンセル",
"cancelled": "インストールをキャンセルしました。",
"cancelling": "キャンセルしています…",
"chatTitle": "チャットを始めるには {{agent}} をインストールしてください。",
"damaged": "この {{agent}} のコピーは破損しています。",
"downloadSize": "{{size}} MB のダウンロード。",
"downloading": "{{done}} MB をダウンロード中です。",
@@ -19220,7 +19219,28 @@
"retry": "インストールを再試行",
"unavailable": "{{agent}} はこのホストでは利用できません。",
"update": "{{agent}} を更新",
"verifying": "ダウンロードしたランタイムを確認しています。"
"verifying": "ダウンロードしたランタイムを確認しています。",
"installTitle": "チャットを始めるには {{agent}} をインストールしてください",
"continueTitle": "続けるには {{agent}} をインストールしてください",
"heroBody": "{{size}} MB の一度きりのダウンロードです。このチャットを実行するコンピューター上の {{agent}} チャットで共有されます。",
"heroBodyNoSize": "一度きりのダウンロードです。このチャットを実行するコンピューター上の {{agent}} チャットで共有されます。",
"stripSize": "このチャットを実行するコンピューターに {{size}} MB をダウンロード",
"settingsHint": "設定 › チャット › プロバイダー からも行えます",
"installShort": "インストール",
"tryAgain": "再試行",
"cancelShort": "キャンセル",
"failedTitle": "{{agent}} をインストールできませんでした",
"cancelledTitle": "インストールをキャンセルしました",
"cancelledBody": "何もインストールされていません。",
"downloadingTitle": "{{agent}} をダウンロード中",
"unpackingTitle": "{{agent}} を展開中",
"checkingTitle": "ダウンロードを確認中",
"progressOf": "{{total}} MB 中 {{done}} MB",
"unpackingDetail": "ランタイムを展開しています",
"checkingDetail": "ダウンロードしたランタイムを確認しています",
"stepDownload": "ダウンロード",
"stepUnpack": "展開",
"stepCheck": "確認"
}
},
"dashboardPopout": {
+22 -2
View File
@@ -19201,7 +19201,6 @@
"cancel": "설치 취소",
"cancelled": "설치가 취소되었습니다.",
"cancelling": "취소하는 중…",
"chatTitle": "채팅을 시작하려면 {{agent}}을(를) 설치하세요.",
"damaged": "이 {{agent}} 사본이 손상되었습니다.",
"downloadSize": "{{size}}MB 다운로드.",
"downloading": "{{done}}MB 다운로드 중입니다.",
@@ -19220,7 +19219,28 @@
"retry": "설치 다시 시도",
"unavailable": "{{agent}}은(는) 이 호스트에서 사용할 수 없습니다.",
"update": "{{agent}} 업데이트",
"verifying": "다운로드한 런타임을 확인하는 중입니다."
"verifying": "다운로드한 런타임을 확인하는 중입니다.",
"installTitle": "채팅을 시작하려면 {{agent}}을(를) 설치하세요",
"continueTitle": "계속하려면 {{agent}}을(를) 설치하세요",
"heroBody": "한 번만 {{size}}MB를 다운로드합니다. 이 채팅을 실행하는 컴퓨터의 {{agent}} 채팅이 함께 사용합니다.",
"heroBodyNoSize": "한 번만 다운로드합니다. 이 채팅을 실행하는 컴퓨터의 {{agent}} 채팅이 함께 사용합니다.",
"stripSize": "이 채팅을 실행하는 컴퓨터에 {{size}}MB 다운로드",
"settingsHint": "설정 › 채팅 › 공급자에서도 설치할 수 있습니다",
"installShort": "설치",
"tryAgain": "다시 시도",
"cancelShort": "취소",
"failedTitle": "{{agent}}을(를) 설치하지 못했습니다",
"cancelledTitle": "설치가 취소되었습니다",
"cancelledBody": "아무것도 설치되지 않았습니다.",
"downloadingTitle": "{{agent}} 다운로드 중",
"unpackingTitle": "{{agent}} 압축 해제 중",
"checkingTitle": "다운로드 확인 중",
"progressOf": "{{total}}MB 중 {{done}}MB",
"unpackingDetail": "런타임 압축을 푸는 중",
"checkingDetail": "다운로드한 런타임을 확인하는 중",
"stepDownload": "다운로드",
"stepUnpack": "압축 해제",
"stepCheck": "확인"
}
},
"dashboardPopout": {
+22 -2
View File
@@ -19201,7 +19201,6 @@
"cancel": "取消安装",
"cancelled": "已取消安装。",
"cancelling": "正在取消…",
"chatTitle": "安装 {{agent}} 即可开始聊天。",
"damaged": "此 {{agent}} 副本已损坏。",
"downloadSize": "下载大小 {{size}} MB。",
"downloading": "正在下载 {{done}} MB。",
@@ -19220,7 +19219,28 @@
"retry": "重试安装",
"unavailable": "{{agent}} 在此主机上不可用。",
"update": "更新 {{agent}}",
"verifying": "正在检查已下载的运行时。"
"verifying": "正在检查已下载的运行时。",
"installTitle": "安装 {{agent}} 以开始聊天",
"continueTitle": "安装 {{agent}} 以继续",
"heroBody": "一次性下载 {{size}} MB,由运行此聊天的电脑上的 {{agent}} 聊天共用。",
"heroBodyNoSize": "一次性下载,由运行此聊天的电脑上的 {{agent}} 聊天共用。",
"stripSize": "在运行此聊天的电脑上下载 {{size}} MB",
"settingsHint": "也可在 设置 › 聊天 › 供应商 中安装",
"installShort": "安装",
"tryAgain": "重试",
"cancelShort": "取消",
"failedTitle": "无法安装 {{agent}}",
"cancelledTitle": "安装已取消",
"cancelledBody": "未安装任何内容。",
"downloadingTitle": "正在下载 {{agent}}",
"unpackingTitle": "正在解压 {{agent}}",
"checkingTitle": "正在检查下载内容",
"progressOf": "{{done}} / {{total}} MB",
"unpackingDetail": "正在解压运行时",
"checkingDetail": "正在检查已下载的运行时",
"stepDownload": "下载",
"stepUnpack": "解压",
"stepCheck": "检查"
}
},
"dashboardPopout": {
@@ -36,18 +36,16 @@ function openFloatingFile(): string {
activeGroupIdByWorktree: { [WT]: 'g1' },
unifiedTabsByWorktree: { [WT]: [] }
})
return store
.getState()
.openFile(
{
filePath: '/notes/a.md',
relativePath: 'a.md',
worktreeId: WT,
language: 'md',
mode: 'edit'
},
{ preview: false }
)
return store.getState().openFile(
{
filePath: '/notes/a.md',
relativePath: 'a.md',
worktreeId: WT,
language: 'md',
mode: 'edit'
},
{ preview: false }
)
}
beforeEach(() => {