diff --git a/src/main/ipc/pty/runtime/spawn-preflight.ts b/src/main/ipc/pty/runtime/spawn-preflight.ts index 0e68e3ac25a..39dc59e3afb 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight.ts @@ -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 diff --git a/src/main/ipc/pty/spawn-pane-env.ts b/src/main/ipc/pty/spawn-pane-env.ts index 4fef46e613e..a580da480d9 100644 --- a/src/main/ipc/pty/spawn-pane-env.ts +++ b/src/main/ipc/pty/spawn-pane-env.ts @@ -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' diff --git a/src/main/native-chat/agent-session-wire/agent-session-bounded-frames.test.ts b/src/main/native-chat/agent-session-wire/agent-session-bounded-frames.test.ts index cfd6ef50488..236e36028ad 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-bounded-frames.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-bounded-frames.test.ts @@ -174,7 +174,15 @@ describe('frames to a bounded-frame subscriber', () => { let held: ReadonlySet = 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', diff --git a/src/main/native-chat/agent-session-wire/agent-session-subscriber-catch-up.ts b/src/main/native-chat/agent-session-wire/agent-session-subscriber-catch-up.ts index 83fe1d52edd..52a3d422d02 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-subscriber-catch-up.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-subscriber-catch-up.ts @@ -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, { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts index 9f827f0891b..e6e139c8471 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts @@ -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) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts index fb2344fb05d..d2522cb4202 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts @@ -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() diff --git a/src/main/runtime/spawned-pty-env-fixtures.ts b/src/main/runtime/spawned-pty-env-fixtures.ts index 6f2cae951f9..0d84dd3d5e6 100644 --- a/src/main/runtime/spawned-pty-env-fixtures.ts +++ b/src/main/runtime/spawned-pty-env-fixtures.ts @@ -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 { - const values: Record = typeof call === 'object' && call !== null ? { ...call } : {} + const values: Record = + typeof call === 'object' && call !== null ? { ...call } : {} const env: Record = {} if (typeof values.env === 'object' && values.env !== null) { for (const [key, value] of Object.entries(values.env)) { diff --git a/src/main/runtime/structured-agent-session-codex-held-sends.test.ts b/src/main/runtime/structured-agent-session-codex-held-sends.test.ts index f78e7818bca..2138b219eb2 100644 --- a/src/main/runtime/structured-agent-session-codex-held-sends.test.ts +++ b/src/main/runtime/structured-agent-session-codex-held-sends.test.ts @@ -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, diff --git a/src/renderer/src/components/managed-runtime/ManagedRuntimeInstallRow.tsx b/src/renderer/src/components/managed-runtime/ManagedRuntimeInstallRow.tsx index 7c8c0113296..0279add1b47 100644 --- a/src/renderer/src/components/managed-runtime/ManagedRuntimeInstallRow.tsx +++ b/src/renderer/src/components/managed-runtime/ManagedRuntimeInstallRow.tsx @@ -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 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(null) - const view = managedRuntimeInstallView( - observation.snapshot, - agentLabel, - surface, - pending === 'cancel' - ) + const view = managedRuntimeInstallView(observation.snapshot, agentLabel, pending === 'cancel') const [refusal, setRefusal] = useState(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 ( -
+
- {chat ? ( -

- {translate( - 'components.managed-runtime.chatTitle', - 'Install {{agent}} to start chatting.', - { - agent: agentLabel - } - )} -

- ) : null} - {/* Settings keeps the line while the first read is out, so nothing shifts when it lands. */} - {view.status !== null || !chat ? ( -

- {view.status} -

- ) : null} + {/* The line stays while the first read is out, so nothing shifts when it lands. */} +

+ {view.status} +

{view.progress === null ? null : ( ): 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' } + }) + }) +}) diff --git a/src/renderer/src/components/managed-runtime/managed-runtime-install-card.ts b/src/renderer/src/components/managed-runtime/managed-runtime-install-card.ts new file mode 100644 index 00000000000..e14cfd3c1ee --- /dev/null +++ b/src/renderer/src/components/managed-runtime/managed-runtime-install-card.ts @@ -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) +} diff --git a/src/renderer/src/components/managed-runtime/managed-runtime-install-view.test.ts b/src/renderer/src/components/managed-runtime/managed-runtime-install-view.test.ts index cd8eac3acad..1f881795b2f 100644 --- a/src/renderer/src/components/managed-runtime/managed-runtime-install-view.test.ts +++ b/src/renderer/src/components/managed-runtime/managed-runtime-install-view.test.ts @@ -25,35 +25,30 @@ const op = (patch: Partial): ManagedRuntimeOperation => ...patch }) -function shown(patch: Partial, surface: 'settings' | 'chat') { - const view = managedRuntimeInstallView( - { kind: 'known', state: { ...idle, ...patch } }, - 'Fake', - surface - ) +function shown(patch: Partial) { + 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.') }) diff --git a/src/renderer/src/components/managed-runtime/managed-runtime-install-view.ts b/src/renderer/src/components/managed-runtime/managed-runtime-install-view.ts index 0356c046c3f..f651abd2820 100644 --- a/src/renderer/src/components/managed-runtime/managed-runtime-install-view.ts +++ b/src/renderer/src/components/managed-runtime/managed-runtime-install-view.ts @@ -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, @@ -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) ) } diff --git a/src/renderer/src/components/managed-runtime/use-managed-runtime-install-card.ts b/src/renderer/src/components/managed-runtime/use-managed-runtime-install-card.ts new file mode 100644 index 00000000000..7f218845c7c --- /dev/null +++ b/src/renderer/src/components/managed-runtime/use-managed-runtime-install-card.ts @@ -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, + state: ManagedRuntimeInstallState, + agentLabel: string, + layout: ManagedRuntimeInstallLayout +): { + card: ManagedRuntimeInstallCard + pending: boolean + refusal: string | null + act: (action: ManagedRuntimeInstallAction) => void +} { + const [pending, setPending] = useState(null) + const [refusal, setRefusal] = useState(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 + } +} diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 3b3431a7b1e..02c32b65d0f 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -54,6 +54,7 @@ const NativeChatComposerPane = forwardRef
) : null} diff --git a/src/renderer/src/components/settings/NativeChatProvidersSetting.tsx b/src/renderer/src/components/settings/NativeChatProvidersSetting.tsx index 52ef66588e6..e83a421ea7d 100644 --- a/src/renderer/src/components/settings/NativeChatProvidersSetting.tsx +++ b/src/renderer/src/components/settings/NativeChatProvidersSetting.tsx @@ -104,7 +104,6 @@ function ProviderDetail(props: {
) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 061cf9d118a..f0286bea6cf 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 7310a34089a..e6fd77516b2 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index a58096bb210..42fc8dd87f2 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 1d191dc26d8..6a24ae44e5c 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index f2b37ae5011..0353b159bb0 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 015c734eec1..bffa9b41c59 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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": { diff --git a/src/renderer/src/store/slices/floating-editor-file-id-layout-owner.test.ts b/src/renderer/src/store/slices/floating-editor-file-id-layout-owner.test.ts index 6bf0e61e98a..467078d2585 100644 --- a/src/renderer/src/store/slices/floating-editor-file-id-layout-owner.test.ts +++ b/src/renderer/src/store/slices/floating-editor-file-id-layout-owner.test.ts @@ -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(() => {