mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 01:27:35 +00:00
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:
co-authored by
Orca Worker
parent
0d74111ee8
commit
a54fd3830e
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+31
-3
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user