diff --git a/src/main/runtime/rpc/methods/client-ui-schemas.ts b/src/main/runtime/rpc/methods/client-ui-schemas.ts index 50f199abc..21a6fa8f4 100644 --- a/src/main/runtime/rpc/methods/client-ui-schemas.ts +++ b/src/main/runtime/rpc/methods/client-ui-schemas.ts @@ -12,10 +12,14 @@ import { isTuiAgent } from '../../../../shared/tui-agent-config' import { isTaskProvider } from '../../../../shared/task-providers' import { normalizeDisabledTuiAgents } from '../../../../shared/tui-agent-selection' import { normalizePRBotAuthorOverrides } from '../../../../shared/pr-bot-author-overrides' -import { normalizeWorktreeCardProperties } from '../../../../shared/worktree-card-properties' -import type { PersistedUIState, TaskProvider } from '../../../../shared/types' +import { + normalizeWorktreeCardProperties, + WORKTREE_CARD_PROPERTIES +} from '../../../../shared/worktree-card-properties' +import { isPluginPanelTabKey } from '../../../../shared/plugins/plugin-manifest' +import type { TaskProvider } from '../../../../shared/types' import { TaskResumeState } from './task-resume-state-schema' -import type { AssertNoMissingKeys } from './ui-state-schema-parity' +import { omitUndefinedValues, tolerateUnknownValues } from './ui-update-value-tolerance' const NullableString = z.string().nullable() const StringArray = z.array(z.string()) @@ -25,22 +29,32 @@ const TaskProviderParam = z.custom(isTaskProvider, { const FeatureTipIds = z.array(z.custom(isFeatureTipId, { message: 'Unknown feature tip id' })) const UnknownRecord = z.record(z.string(), z.unknown()) const UnknownRecordArray = z.array(UnknownRecord) -const LegacyWorktreeCardProperty = z.enum([ - 'status', - 'unread', - 'ci', - 'branch', - 'issue', - 'linear-issue', - 'pr', - 'automation', - 'comment', - 'ports', - 'inline-agents' -]) +type StaticRightSidebarTab = (typeof STATIC_RIGHT_SIDEBAR_TABS)[number] +// Derived from the shared union so a new card property cannot drift out of the +// client schema — it previously omitted 'cli' and rejected the whole payload. +const WorktreeCardPropertyParam = z.enum(WORKTREE_CARD_PROPERTIES) const WorktreeCardProperties = z - .array(LegacyWorktreeCardProperty) + .array(WorktreeCardPropertyParam) .transform((value) => normalizeWorktreeCardProperties(value)) +const STATIC_RIGHT_SIDEBAR_TABS = [ + 'explorer', + 'search', + 'vault', + 'workspaces', + 'pr-checks', + 'source-control', + 'checks', + 'ports' +] as const +// Plugin panels are open-ended `plugin:./` keys, so the +// schema validates their shape rather than enumerating them. +const RightSidebarTabParam = z.custom( + (value) => + typeof value === 'string' && + (STATIC_RIGHT_SIDEBAR_TABS.includes(value as StaticRightSidebarTab) || + isPluginPanelTabKey(value)), + { message: 'Unknown right sidebar tab' } +) const AgentActivityDisplayMode = z.enum(['compact', 'full']) const StatusBarItem = z.enum([ 'claude', @@ -161,7 +175,7 @@ export const SettingsUpdate = z .strict() .default({}) -export const UiUpdate = z +const UiUpdateFields = z .object({ lastActiveRepoId: NullableString.optional(), lastActiveWorktreeId: NullableString.optional(), @@ -182,9 +196,7 @@ export const UiUpdate = z .optional(), sidebarWidth: z.number().finite().optional(), rightSidebarOpen: z.boolean().optional(), - rightSidebarTab: z - .enum(['explorer', 'search', 'vault', 'source-control', 'checks', 'ports']) - .optional(), + rightSidebarTab: RightSidebarTabParam.optional(), rightSidebarExplorerView: z.enum(['files', 'search']).optional(), rightSidebarWidth: z.number().finite().optional(), markdownTocPanelWidth: z.number().finite().optional(), @@ -291,24 +303,12 @@ export const UiUpdate = z contextualToursAutoEligible: z.boolean().optional() }) .strict() - .default({}) -// Why: state only the main process ever writes (store.updateUI, star-nag's own -// IPC, window lifecycle). Clients never send these, so keeping them out of the -// strict schema is deliberate — but it must stay deliberate rather than -// forgotten, which is what the parity assertion below enforces. -type MainOwnedUIState = - | 'trayMinimizeNoticeShown' - | 'dashboardPopoutBounds' - | '_expandedWorktreeCardPropertiesDefaulted' - | 'starNagBaselineAgents' - | 'starNagAppVersion' - | 'starNagNextThreshold' - | 'starNagCompleted' - | 'starNagDeferredUntil' - | 'starNagAgentValueMomentAppVersion' -const _uiUpdateParity: AssertNoMissingKeys< - Omit, - z.infer -> = true -void _uiUpdateParity +export const UiUpdate = z + .object(tolerateUnknownValues(UiUpdateFields.shape)) + .strict() + .default({}) + .transform(omitUndefinedValues) + +// The key/value parity assertions over this live in ui-state-schema-parity-checks.ts. +export type UiUpdateFieldsSchema = typeof UiUpdateFields diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index 03b0f6987..6343cfe30 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -7,6 +7,7 @@ import { MAX_QUICK_COMMAND_REPO_ID_LENGTH, MAX_QUICK_COMMAND_TERMINAL_TEXT_LENGTH } from '../../../../shared/terminal-quick-commands' +import { DEFAULT_WORKTREE_CARD_PROPERTIES } from '../../../../shared/worktree-card-properties' import type { PersistedUIState } from '../../../../shared/types' import type { OrcaRuntimeService } from '../../orca-runtime' import type { RpcRequest } from '../core' @@ -636,21 +637,69 @@ describe('client UI RPC methods', () => { expect(runtime.updateUIState).not.toHaveBeenCalled() }) - it('rejects unknown worktree card properties', async () => { + // Why the contract flipped: an unknown VALUE used to fail the whole batch, so + // one drifted enum member took sidebar widths, filters and agent acks down + // with it. Unknown KEYS still reject — the parity assertions catch those. + it.each([ + ['worktree card property', { worktreeCardProperties: ['status', 'pr-status'] }], + ['feature interaction id', { featureInteractions: { unknown: { firstInteractedAt: 100 } } }], + ['feature tip id', { featureTipsSeenIds: ['voice-dictation', 'unknown-tip'] }], + ['right sidebar tab', { rightSidebarTab: 'not-a-tab' }] + ])('drops an unknown %s instead of rejecting the batch around it', async (_label, drifted) => { const runtime = { getRuntimeId: () => 'test-runtime', - updateUIState: vi.fn() + updateUIState: vi.fn(() => getDefaultUIState()) } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS }) const response = await dispatcher.dispatch( - makeRequest('ui.set', { worktreeCardProperties: ['status', 'pr-status'] }) + makeRequest('ui.set', { ...drifted, sidebarWidth: 280, filterRepoIds: ['repo-1'] }) ) - expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } }) - expect(runtime.updateUIState).not.toHaveBeenCalled() + expect(response).toMatchObject({ ok: true }) + expect(runtime.updateUIState).toHaveBeenCalledWith({ + sidebarWidth: 280, + filterRepoIds: ['repo-1'] + }) }) + it('accepts every worktree card property the shared union defines', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + updateUIState: vi.fn(() => getDefaultUIState()) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS }) + + // 'cli' was missing from the schema, so Settings → Default card mode sent a + // payload the host rejected outright. + const response = await dispatcher.dispatch( + makeRequest('ui.set', { worktreeCardProperties: [...DEFAULT_WORKTREE_CARD_PROPERTIES] }) + ) + + expect(response).toMatchObject({ ok: true }) + expect(runtime.updateUIState).toHaveBeenCalledWith({ + worktreeCardProperties: [...DEFAULT_WORKTREE_CARD_PROPERTIES] + }) + }) + + it.each(['workspaces', 'pr-checks', 'plugin:acme.tools/inspector'])( + 'accepts the %s right sidebar tab a paired client can be sitting on', + async (rightSidebarTab) => { + const runtime = { + getRuntimeId: () => 'test-runtime', + updateUIState: vi.fn(() => getDefaultUIState()) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('ui.set', { rightSidebarTab, sidebarWidth: 280 }) + ) + + expect(response).toMatchObject({ ok: true }) + expect(runtime.updateUIState).toHaveBeenCalledWith({ rightSidebarTab, sidebarWidth: 280 }) + } + ) + it('rejects star-nag persisted state mutations from remote clients', async () => { const runtime = { getRuntimeId: () => 'test-runtime', @@ -716,40 +765,6 @@ describe('client UI RPC methods', () => { expect(runtime.updateUIState).not.toHaveBeenCalled() }) - it('rejects unknown feature interaction ids', async () => { - const runtime = { - getRuntimeId: () => 'test-runtime', - updateUIState: vi.fn() - } as unknown as OrcaRuntimeService - const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS }) - - const response = await dispatcher.dispatch( - makeRequest('ui.set', { - featureInteractions: { - unknown: { firstInteractedAt: 100 } - } - }) - ) - - expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } }) - expect(runtime.updateUIState).not.toHaveBeenCalled() - }) - - it('rejects unknown feature tip ids', async () => { - const runtime = { - getRuntimeId: () => 'test-runtime', - updateUIState: vi.fn() - } as unknown as OrcaRuntimeService - const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS }) - - const response = await dispatcher.dispatch( - makeRequest('ui.set', { featureTipsSeenIds: ['voice-dictation', 'unknown-tip'] }) - ) - - expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } }) - expect(runtime.updateUIState).not.toHaveBeenCalled() - }) - it('rejects unknown feature interaction ids for increment RPC', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index 4ec6b1c5b..17ebfade6 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -6,6 +6,9 @@ import { SettingsUpdate, UiUpdate } from './client-ui-schemas' +// Type-only side effect: keeps the schema/PersistedUIState parity assertions in +// the typecheck graph so drift fails the build instead of a paired client. +import type {} from './ui-state-schema-parity-checks' import { TerminalQuickCommandsUpdate } from './terminal-quick-command-rpc-schema' export const CLIENT_UI_METHODS: RpcMethod[] = [ diff --git a/src/main/runtime/rpc/methods/ui-state-schema-parity-checks.ts b/src/main/runtime/rpc/methods/ui-state-schema-parity-checks.ts new file mode 100644 index 000000000..bbdece031 --- /dev/null +++ b/src/main/runtime/rpc/methods/ui-state-schema-parity-checks.ts @@ -0,0 +1,35 @@ +import type { z } from 'zod' +import type { PersistedUIState } from '../../../../shared/types' +import type { UiUpdateFieldsSchema } from './client-ui-schemas' +import type { AssertNoMissingKeys, AssertNoMissingValues } from './ui-state-schema-parity' + +// Why: state only the main process ever writes (store.updateUI, star-nag's own +// IPC, window lifecycle). Clients never send these, so keeping them out of the +// strict schema is deliberate — but it must stay deliberate rather than +// forgotten, which is what the parity assertion below enforces. +type MainOwnedUIState = + | 'trayMinimizeNoticeShown' + | 'dashboardPopoutBounds' + | '_expandedWorktreeCardPropertiesDefaulted' + | 'starNagBaselineAgents' + | 'starNagAppVersion' + | 'starNagNextThreshold' + | 'starNagCompleted' + | 'starNagDeferredUntil' + | 'starNagAgentValueMomentAppVersion' +const _uiUpdateParity: AssertNoMissingKeys< + Omit, + z.infer +> = true +void _uiUpdateParity + +// Why: key parity is blind to enum drift, which is how 'cli' and three +// rightSidebarTab members went missing while the guard above stayed green. +// Checked over every shared key, not a hand-picked pair — naming the two known +// offenders would leave the next field to drift exactly as unguarded. +// z.input, not z.infer: what a client may SEND, before `.transform()` narrows it. +const _uiUpdateValueParity: AssertNoMissingValues< + Omit, + z.input +> = true +void _uiUpdateValueParity diff --git a/src/main/runtime/rpc/methods/ui-state-schema-parity.ts b/src/main/runtime/rpc/methods/ui-state-schema-parity.ts index 5ed5e5d91..555e8f44c 100644 --- a/src/main/runtime/rpc/methods/ui-state-schema-parity.ts +++ b/src/main/runtime/rpc/methods/ui-state-schema-parity.ts @@ -12,3 +12,29 @@ export type AssertNoMissingKeys> Exclude extends never ? true : { missingFromSchema: Exclude } + +/** + * Key parity alone is blind to VALUE drift: a schema can list `rightSidebarTab` + * yet omit half its union members, which the strict schema then rejects. This + * asserts the schema's accepted value domain still covers the shared type for + * EVERY shared key, so a field nobody thought to name is covered too. + * + * `TSchema` must be the schema's INPUT type: what a client is allowed to send, + * before any `.transform()` narrows it. + */ +export type AssertNoMissingValues = + MissingValueKeys extends never + ? true + : { valueDomainTooNarrowFor: MissingValueKeys } + +// Only `undefined` is stripped: optionality is the key guard's job, and a schema +// field is always `| undefined` once `.optional()` is applied. `null` must stay — +// dropping `.nullable()` from a `| null` field is the same batch-rejecting drift. +type MissingValueKeys = { + [K in Extract]: Exclude extends Exclude< + TSchema[K], + undefined + > + ? never + : K +}[Extract] diff --git a/src/main/runtime/rpc/methods/ui-update-value-tolerance.ts b/src/main/runtime/rpc/methods/ui-update-value-tolerance.ts new file mode 100644 index 000000000..1b8ca0c2c --- /dev/null +++ b/src/main/runtime/rpc/methods/ui-update-value-tolerance.ts @@ -0,0 +1,25 @@ +import type { z } from 'zod' + +/** + * `UiUpdate` rides App.tsx's debounced writer, so one drifted enum member used + * to fail the WHOLE batch and silently drop sidebar widths, filters and agent + * acks alongside it. Degrade instead: a value the schema cannot express is + * dropped from the payload and the rest of the batch still lands. Unknown KEYS + * stay a hard rejection — the parity assertions exist to catch those. + */ +export function tolerateUnknownValues(shape: TShape): TShape { + return Object.fromEntries( + Object.entries(shape).map(([key, schema]) => [ + key, + (schema as z.ZodType).catch(() => undefined) + ]) + ) as unknown as TShape +} + +/** Drops the `undefined` entries `tolerateUnknownValues` leaves behind, so a + * rejected value reads as absent rather than as an explicit clear. */ +export function omitUndefinedValues>(value: TValue): TValue { + return Object.fromEntries( + Object.entries(value).filter(([, entry]) => entry !== undefined) + ) as TValue +} diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index a544b4e97..928057815 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -141,7 +141,9 @@ import { browserPageZoomLevelToPercent, DEFAULT_BROWSER_PAGE_ZOOM_LEVEL, getBrowserPageZoomIndicatorState, + getExplicitBrowserPageZoomLevel, normalizeBrowserPageZoomLevel, + rememberExplicitBrowserPageZoomLevel, setBrowserPageZoomLevel, type BrowserPageZoomDirection } from './browser-page-zoom' @@ -2827,10 +2829,14 @@ function BrowserPagePane({ const setBrowserDefaultZoomLevel = useAppStore((state) => state.setBrowserDefaultZoomLevel) const normalizedBrowserDefaultZoomLevel = normalizeBrowserPageZoomLevel(browserDefaultZoomLevel) const browserDefaultZoomPercent = browserPageZoomLevelToPercent(normalizedBrowserDefaultZoomLevel) - // Why: the level THIS pane should hold. Seeded once from the configured default ("applied to newly + // Why: the level THIS pane should hold. Seeded from the configured default ("applied to newly // opened browser tabs") and moved only by zooming this pane, so a reload can't broadcast another - // tab's zoom through the shared setting. - const paneZoomLevelRef = useRef(normalizedBrowserDefaultZoomLevel) + // tab's zoom through the shared setting. Why the module-level lookup: the guest webview outlives + // this component (worktree switch, Settings visit), so re-seeding on remount would let a later + // Settings change retroactively hijack a tab the user already zoomed. + const paneZoomLevelRef = useRef( + getExplicitBrowserPageZoomLevel(browserTab.id) ?? normalizedBrowserDefaultZoomLevel + ) const grabElementShortcut = useShortcutLabel('browser.grabElement') const faviconUrlRef = useRef(browserTab.faviconUrl) const initialBrowserUrlRef = useRef(browserTab.url) @@ -3566,6 +3572,7 @@ function BrowserPagePane({ const nextLevel = applyBrowserPageZoom(webviewRef.current, direction) if (nextLevel !== null) { paneZoomLevelRef.current = nextLevel + rememberExplicitBrowserPageZoomLevel(browserTabIdRef.current, nextLevel) setBrowserDefaultZoomLevel(nextLevel) showBrowserZoomFeedback(nextLevel) } diff --git a/src/renderer/src/components/browser-pane/browser-page-zoom.test.ts b/src/renderer/src/components/browser-pane/browser-page-zoom.test.ts index fe56f16f7..16e942b81 100644 --- a/src/renderer/src/components/browser-pane/browser-page-zoom.test.ts +++ b/src/renderer/src/components/browser-pane/browser-page-zoom.test.ts @@ -2,9 +2,12 @@ import { describe, expect, it, vi } from 'vitest' import { applyBrowserPageZoom, browserPageZoomLevelToPercent, + forgetExplicitBrowserPageZoomLevel, getBrowserPageZoomIndicatorState, + getExplicitBrowserPageZoomLevel, nextBrowserPageZoomLevel, normalizeBrowserPageZoomLevel, + rememberExplicitBrowserPageZoomLevel, setBrowserPageZoomLevel } from './browser-page-zoom' @@ -108,15 +111,73 @@ describe('setBrowserPageZoomLevel', () => { }) it('restores the configured level when Chromium carries zoom across reloads', () => { + let live = 0.5 const webview = { - getZoomLevel: vi.fn(() => 0.5), + getZoomLevel: vi.fn(() => live), + setZoomLevel: vi.fn((level: number) => { + live = level + }) + } + + expect(setBrowserPageZoomLevel(webview, 0)).toBe(0) + expect(webview.setZoomLevel).toHaveBeenNthCalledWith(1, 0) + // Chromium hands the stale level back again on the next load. + live = 0.5 + expect(setBrowserPageZoomLevel(webview, 0)).toBe(0) + expect(webview.setZoomLevel).toHaveBeenNthCalledWith(2, 0) + }) + + // Why: HostZoomMap is keyed by host per partition, so even a no-op write + // overwrites the host-wide zoom a sibling tab on the same hostname set. + it('does not write when the webview already holds the level', () => { + const webview = { + getZoomLevel: vi.fn(() => 0), setZoomLevel: vi.fn() } expect(setBrowserPageZoomLevel(webview, 0)).toBe(0) - expect(setBrowserPageZoomLevel(webview, 0)).toBe(0) - expect(webview.setZoomLevel).toHaveBeenNthCalledWith(1, 0) - expect(webview.setZoomLevel).toHaveBeenNthCalledWith(2, 0) + expect(webview.setZoomLevel).not.toHaveBeenCalled() + }) + + it('treats an unnormalized held level as already applied', () => { + const webview = { + getZoomLevel: vi.fn(() => 1.26), + setZoomLevel: vi.fn() + } + + expect(setBrowserPageZoomLevel(webview, 1.5)).toBe(1.5) + expect(webview.setZoomLevel).not.toHaveBeenCalled() + }) +}) + +// A guest webview outlives its React pane (worktree switch, Settings visit), so +// the level the USER applied has to outlive the pane too — otherwise the pane +// re-seeds from the shared Settings default and hijacks an already-zoomed tab. +describe('explicit pane zoom levels', () => { + it('reports null until the user zooms the tab', () => { + forgetExplicitBrowserPageZoomLevel('page-1') + expect(getExplicitBrowserPageZoomLevel('page-1')).toBeNull() + }) + + it('survives a pane remount so a later Settings default cannot hijack the tab', () => { + forgetExplicitBrowserPageZoomLevel('page-1') + rememberExplicitBrowserPageZoomLevel('page-1', 1.5) + + // Remount: the pane seeds from the explicit level, not the shared default. + const settingsDefault = 0 + expect(getExplicitBrowserPageZoomLevel('page-1') ?? settingsDefault).toBe(1.5) + }) + + it('is dropped when the guest is destroyed so a reused id cannot inherit it', () => { + rememberExplicitBrowserPageZoomLevel('page-1', 1.5) + forgetExplicitBrowserPageZoomLevel('page-1') + expect(getExplicitBrowserPageZoomLevel('page-1')).toBeNull() + }) + + it('keeps tabs independent', () => { + forgetExplicitBrowserPageZoomLevel('page-2') + rememberExplicitBrowserPageZoomLevel('page-1', 1.5) + expect(getExplicitBrowserPageZoomLevel('page-2')).toBeNull() }) }) @@ -223,6 +284,34 @@ describe('browser pane zoom across reloads', () => { }) }) +/** + * Chromium's HostZoomMap is keyed by HOST per partition, so two tabs on one + * hostname share live zoom and a reassert by either moves both. Suppressing the + * reassert to protect the sibling is exactly what #10800 fixed (an + * externally-changed level must snap back on reload), so the two requirements + * are the same write seen from opposite sides. What IS in our control is not + * emitting a redundant host-wide write. + */ +describe('browser panes sharing one hostname', () => { + it('skips the host-wide write when the pane already holds its level', () => { + let hostLevel = 0 + const webviewFor = (): { getZoomLevel: () => number; setZoomLevel: (n: number) => void } => ({ + getZoomLevel: () => hostLevel, + setZoomLevel: (level: number) => { + hostLevel = level + } + }) + const tabA = webviewFor() + const tabB = webviewFor() + + expect(applyBrowserPageZoom(tabA, 'in')).toBe(0.5) + // Tab B reasserts the level the host already carries: no write at all. + setBrowserPageZoomLevel(tabB, 0.5) + + expect(hostLevel).toBe(0.5) + }) +}) + describe('getBrowserPageZoomIndicatorState', () => { it('shows browser zoom percent only while feedback is active', () => { expect( diff --git a/src/renderer/src/components/browser-pane/browser-page-zoom.ts b/src/renderer/src/components/browser-pane/browser-page-zoom.ts index 79d878d36..4a4f39249 100644 --- a/src/renderer/src/components/browser-pane/browser-page-zoom.ts +++ b/src/renderer/src/components/browser-pane/browser-page-zoom.ts @@ -63,6 +63,12 @@ export function setBrowserPageZoomLevel( return null } const next = normalizeBrowserPageZoomLevel(level) + // Why compare first: Chromium's HostZoomMap is keyed by host per partition, + // so a no-op write still overwrites the host-wide zoom a sibling tab on the + // same hostname set. Only write when this pane actually needs to move. + if (normalizeBrowserPageZoomLevel(webview.getZoomLevel()) === next) { + return next + } webview.setZoomLevel(next) return next } catch { @@ -70,6 +76,26 @@ export function setBrowserPageZoomLevel( } } +// Why module-level: the guest webview outlives its React pane (a worktree +// switch or a Settings visit unmounts the chrome and parks the viewport), so a +// pane-local ref re-seeds from the shared Settings default on every remount and +// retroactively hijacks a tab the user already zoomed. +const explicitPaneZoomLevels = new Map() + +/** The level this tab holds because the USER zoomed it, or null while it is + * still sitting on the configured new-tab seed. */ +export function getExplicitBrowserPageZoomLevel(browserPageId: string): number | null { + return explicitPaneZoomLevels.get(browserPageId) ?? null +} + +export function rememberExplicitBrowserPageZoomLevel(browserPageId: string, level: number): void { + explicitPaneZoomLevels.set(browserPageId, level) +} + +export function forgetExplicitBrowserPageZoomLevel(browserPageId: string): void { + explicitPaneZoomLevels.delete(browserPageId) +} + export function getBrowserPageZoomIndicatorState({ feedbackVisible }: BrowserPageZoomIndicatorInput): BrowserPageZoomIndicatorState { diff --git a/src/renderer/src/components/browser-pane/webview-registry.ts b/src/renderer/src/components/browser-pane/webview-registry.ts index 52299e5bf..51e3721cc 100644 --- a/src/renderer/src/components/browser-pane/webview-registry.ts +++ b/src/renderer/src/components/browser-pane/webview-registry.ts @@ -1,5 +1,6 @@ import { clearLiveBrowserUrl } from './browser-runtime' import { removeBrowserPageViewport } from './browser-page-viewport' +import { forgetExplicitBrowserPageZoomLevel } from './browser-page-zoom' // Why: the webview registry is shared coordination state between BrowserPane // (React component) and store-layer cleanup helpers (shutdownWorktreeBrowsers, @@ -190,6 +191,9 @@ export function moveFocusToRendererBeforeWebviewDetach(webview: Electron.Webview export function destroyPersistentWebview(browserTabId: string): void { const webview = webviewRegistry.get(browserTabId) + // The guest is gone, so its user-applied zoom must not be inherited by a + // later tab that reuses the id. + forgetExplicitBrowserPageZoomLevel(browserTabId) if (!webview) { // Why: the viewport can outlive a missing webview entry; tear it down on // explicit close paths so overlay slots do not leak parked shells. diff --git a/src/renderer/src/components/native-chat/native-chat-live-status.test.ts b/src/renderer/src/components/native-chat/native-chat-live-status.test.ts new file mode 100644 index 000000000..99e3c7194 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-live-status.test.ts @@ -0,0 +1,273 @@ +// Pure status-merge tests for native-chat-live-status.ts. Kept beside the module +// they cover rather than in the hook's test file, which owns the IO harness. + +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { mergeNativeChatLiveSession } from './native-chat-live-status' +import { selectNativeChatViewState } from './native-chat-view-state' +import { shouldShowNativeChatWorking } from './native-chat-working-suppression' + +function assistant(id: string, text: string): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text }], + timestamp: 2, + source: 'transcript' + } +} + +function user(id: string, text: string): NativeChatMessage { + return { id, role: 'user', blocks: [{ type: 'text', text }], timestamp: 1, source: 'transcript' } +} + +describe('mergeNativeChatLiveSession', () => { + it("surfaces live 'working' before the assistant turn lands in the transcript", () => { + const session = mergeNativeChatLiveSession({ + sources: { transcript: [user('u-1', 'do a thing')] }, + sessionId: 'sess', + agent: 'claude', + hookState: 'working' + }) + expect(session.status).toBe('working') + expect(session.messages).toHaveLength(1) + }) + + it("keeps 'working' authoritative when a prior assistant message is present", () => { + const session = mergeNativeChatLiveSession({ + sources: { transcript: [user('u-1', 'do a thing'), assistant('a-1', 'done')] }, + sessionId: 'sess', + agent: 'claude', + hookState: 'working' + }) + expect(session.status).toBe('working') + }) + + it('does not treat assistant prose as turn completion while lifecycle is mid-generation', () => { + const session = mergeNativeChatLiveSession({ + sources: { transcript: [user('u-1', 'go'), assistant('a-1', 'done')] }, + sessionId: 'sess', + agent: 'claude', + hookState: 'working', + stateStartedAt: 1, + transcriptLifecycle: { state: 'working', turnId: 'u-1', timestamp: 1 } + }) + expect(session.status).toBe('working') + }) + + it('recovers via assistant prose when capable host has no in-progress lifecycle', () => { + const session = mergeNativeChatLiveSession({ + sources: { transcript: [user('u-1', 'go'), assistant('a-1', 'done')] }, + sessionId: 'sess', + agent: 'claude', + hookState: 'working', + stateStartedAt: 1 + }) + expect(session.status).toBe('ready') + }) + + it('settles a dropped working hook from an explicit completion marker', () => { + const session = mergeNativeChatLiveSession({ + sources: { transcript: [user('u-1', 'go'), assistant('a-1', 'done')] }, + sessionId: 'sess', + agent: 'claude', + hookState: 'working', + stateStartedAt: 1, + transcriptLifecycle: { state: 'completed', turnId: 'turn-1', timestamp: 2 } + }) + expect(session.status).toBe('ready') + }) + + it('settles a dropped working hook from an explicit interruption marker', () => { + const session = mergeNativeChatLiveSession({ + sources: { transcript: [user('u-1', 'go')] }, + sessionId: 'sess', + agent: 'claude', + hookState: 'working', + stateStartedAt: 1, + transcriptLifecycle: { state: 'interrupted', turnId: 'turn-1', timestamp: 2 } + }) + expect(session.status).toBe('ready') + }) + + it('does not apply an older completion marker to a newer working turn', () => { + const session = mergeNativeChatLiveSession({ + sources: { transcript: [assistant('a-1', 'prior')] }, + sessionId: 'sess', + agent: 'claude', + hookState: 'working', + stateStartedAt: 5, + transcriptLifecycle: { state: 'completed', turnId: 'turn-1', timestamp: 2 } + }) + expect(session.status).toBe('working') + }) + + it('does not apply an older interruption marker to a newer working turn', () => { + const session = mergeNativeChatLiveSession({ + sources: { transcript: [assistant('a-1', 'prior')] }, + sessionId: 'sess', + agent: 'claude', + hookState: 'working', + stateStartedAt: 5, + transcriptLifecycle: { state: 'interrupted', turnId: 'turn-1', timestamp: 2 } + }) + expect(session.status).toBe('working') + }) + + it('settles an unorderable (null-timestamp) completion marker for live work', () => { + const session = mergeNativeChatLiveSession({ + sources: { transcript: [assistant('a-1', 'prior')] }, + sessionId: 'sess', + agent: 'claude', + hookState: 'working', + stateStartedAt: 5, + transcriptLifecycle: { state: 'completed', turnId: 'turn-1', timestamp: null } + }) + expect(session.status).toBe('ready') + }) + + it('settles a completion slightly before hook receipt within clock-skew slack', () => { + const hookStartedAt = 1_700_000_000_000 + const session = mergeNativeChatLiveSession({ + sources: { transcript: [assistant('a-1', 'done')] }, + sessionId: 'sess', + agent: 'claude', + hookState: 'working', + stateStartedAt: hookStartedAt, + transcriptLifecycle: { + state: 'completed', + turnId: 'turn-1', + timestamp: hookStartedAt - 500 + } + }) + expect(session.status).toBe('ready') + }) + + it('preserves the assistant fallback when the serving host lacks explicit boundaries', () => { + const session = mergeNativeChatLiveSession({ + sources: { transcript: [assistant('a-1', 'done')] }, + sessionId: 'sess', + agent: 'grok', + hookState: 'working', + stateStartedAt: 1 + }) + expect(session.status).toBe('ready') + }) + + it('keeps working while the hook reports a live background child', () => { + const session = mergeNativeChatLiveSession({ + sources: { transcript: [assistant('a-1', 'lead done')] }, + sessionId: 'sess', + agent: 'claude', + hookState: 'working', + stateStartedAt: 1, + transcriptLifecycle: { state: 'completed', turnId: 'turn-1', timestamp: 2 }, + hookHasWorkingSubagents: true + }) + expect(session.status).toBe('working') + }) + + it('settles on an interruption even while the hook reports a live background child', () => { + const session = mergeNativeChatLiveSession({ + sources: { transcript: [assistant('a-1', 'lead done')] }, + sessionId: 'sess', + agent: 'claude', + hookState: 'working', + stateStartedAt: 1, + transcriptLifecycle: { state: 'interrupted', turnId: 'turn-1', timestamp: 2 }, + hookHasWorkingSubagents: true + }) + expect(session.status).toBe('ready') + }) + + it('leaves completed states (done/waiting/blocked) on the derived status', () => { + const session = mergeNativeChatLiveSession({ + sources: { transcript: [user('u-1', 'hi')] }, + sessionId: 'sess', + agent: 'claude', + hookState: 'done' + }) + expect(session.status).toBe('ready') + }) + + it('surfaces live work while the transcript loads and honors errors outright', () => { + expect( + mergeNativeChatLiveSession({ + sources: { transcript: [] }, + sessionId: null, + agent: 'claude', + hookState: 'working', + loading: true + }).status + ).toBe('working') + // Regression: a non-null sessionId used to force 'loading' over live work, + // so the pane rendered idle mid-turn — Send instead of Stop, no typing + // indicator, no streaming preview. The empty-transcript loading SURFACE is + // selectNativeChatViewState's job; the status must stay 'working'. + expect( + mergeNativeChatLiveSession({ + sources: { transcript: [] }, + sessionId: 'sess', + agent: 'claude', + hookState: 'working', + loading: true + }).status + ).toBe('working') + + // With any message present (a pending send echo, launch-prompt bubble or + // slash-command marker) the pane is a live conversation, not a spinner. + expect( + mergeNativeChatLiveSession({ + sources: { transcript: [user('u-1', 'run it')] }, + sessionId: 'sess', + agent: 'claude', + hookState: 'working', + loading: true + }).status + ).toBe('working') + + const errored = mergeNativeChatLiveSession({ + sources: { transcript: [] }, + sessionId: 'sess', + agent: 'claude', + hookState: null, + error: 'unreadable' + }) + expect(errored.status).toBe('error') + expect(errored.error).toBe('unreadable') + }) + + it('assembles an empty transcript with no live work as empty', () => { + const session = mergeNativeChatLiveSession({ + sources: { transcript: [] }, + sessionId: 'sess', + agent: 'claude', + hookState: null + }) + expect(session.status).toBe('empty') + }) + + // The whole chain the defect broke: a fresh Claude session reports its id + // before the transcript flushes, so the pane rendered Send (not Stop) with no + // typing indicator while the agent was working. + it('keeps the Stop affordance for a working known session mid-flush', () => { + const session = mergeNativeChatLiveSession({ + sources: { transcript: [user('u-1', 'run it')] }, + sessionId: 'sess', + agent: 'claude', + hookState: 'working', + loading: true + }) + const viewState = selectNativeChatViewState(session) + const isConversation = viewState.kind === 'ready' + + expect(viewState).toEqual({ kind: 'ready', isWorking: true }) + expect( + shouldShowNativeChatWorking({ + isConversation, + working: session.status === 'working', + interrupted: false + }) + ).toBe(true) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-live-status.ts b/src/renderer/src/components/native-chat/native-chat-live-status.ts index 7a49ddb1e..b69a39517 100644 --- a/src/renderer/src/components/native-chat/native-chat-live-status.ts +++ b/src/renderer/src/components/native-chat/native-chat-live-status.ts @@ -65,7 +65,11 @@ export function mergeNativeChatLiveSession(input: NativeChatLiveMergeInput): Nat transcriptLifecycle, hookHasWorkingSubagents ?? false ) - if (loading && (sessionId !== null || status !== 'working')) { + // Why live work still wins: 'working' is what drives Stop-vs-Send, the typing + // indicator and the streaming preview, so forcing 'loading' over it renders an + // idle pane while the agent works. A known session with nothing to show yet is + // held on the loading surface by selectNativeChatViewState instead. + if (loading && status !== 'working') { return assembleNativeChatSession({ sources, sessionId, agent, status: 'loading' }) } return assembleNativeChatSession({ diff --git a/src/renderer/src/components/native-chat/native-chat-view-state.test.ts b/src/renderer/src/components/native-chat/native-chat-view-state.test.ts index 557651be9..ea97ec99a 100644 --- a/src/renderer/src/components/native-chat/native-chat-view-state.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-view-state.test.ts @@ -43,12 +43,29 @@ describe('selectNativeChatViewState', () => { expect(selectNativeChatViewState(session({ messages: [], status: 'ready' })).kind).toBe('empty') }) - it('empty wins over a working hook on an empty conversation', () => { + it('empty wins over a working hook on a pre-session conversation', () => { + expect( + selectNativeChatViewState(session({ messages: [], status: 'working', sessionId: null })).kind + ).toBe('empty') + }) + + // A known session working with nothing to show is a transcript that has not + // flushed yet (#11032), so hold loading rather than flash the empty state. + it('holds loading for a known session working before its transcript flushes', () => { expect(selectNativeChatViewState(session({ messages: [], status: 'working' })).kind).toBe( - 'empty' + 'loading' ) }) + // Why status, not view state: the composer reads session.status, so the pane + // must offer Stop the moment a bubble lands mid-turn. + it('keeps working status for a known session so the composer can offer Stop', () => { + expect(selectNativeChatViewState(session({ status: 'working' }))).toEqual({ + kind: 'ready', + isWorking: true + }) + }) + it('maps ready (not working)', () => { expect(selectNativeChatViewState(session({ status: 'ready' }))).toEqual({ kind: 'ready', diff --git a/src/renderer/src/components/native-chat/native-chat-view-state.ts b/src/renderer/src/components/native-chat/native-chat-view-state.ts index 3ea96641c..775635df1 100644 --- a/src/renderer/src/components/native-chat/native-chat-view-state.ts +++ b/src/renderer/src/components/native-chat/native-chat-view-state.ts @@ -30,6 +30,13 @@ export function selectNativeChatViewState(session: NativeChatSession): NativeCha if (session.status === 'loading') { return { kind: 'loading' } } + // A KNOWN session working with nothing to show is a transcript that has not + // flushed yet, so hold the loading surface rather than flashing empty (#11032). + // The status stays 'working', so the composer keeps Stop the moment a bubble + // lands — forcing 'loading' upstream instead rendered an idle pane mid-turn. + if (session.status === 'working' && session.sessionId !== null) { + return { kind: 'loading' } + } // Empty wins over a transient 'working' hook so a just-toggled, pre-session // pane shows a clear empty state instead of a spinner over nothing. return { kind: 'empty' } diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts index c642d891b..3d3a3206c 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts @@ -5,7 +5,6 @@ import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { NativeChatMessage } from '../../../../shared/native-chat-types' import { useAppStore } from '@/store' -import { mergeNativeChatLiveSession } from './native-chat-live-status' import { NATIVE_CHAT_INITIAL_LIMIT } from './native-chat-pagination' // Mock the session transport so the hook's IO is observable and controllable @@ -77,217 +76,6 @@ function user(id: string, text: string): NativeChatMessage { return { id, role: 'user', blocks: [{ type: 'text', text }], timestamp: 1, source: 'transcript' } } -describe('mergeNativeChatLiveSession', () => { - it("surfaces live 'working' before the assistant turn lands in the transcript", () => { - const session = mergeNativeChatLiveSession({ - sources: { transcript: [user('u-1', 'do a thing')] }, - sessionId: 'sess', - agent: 'claude', - hookState: 'working' - }) - expect(session.status).toBe('working') - expect(session.messages).toHaveLength(1) - }) - - it("keeps 'working' authoritative when a prior assistant message is present", () => { - const session = mergeNativeChatLiveSession({ - sources: { transcript: [user('u-1', 'do a thing'), assistant('a-1', 'done')] }, - sessionId: 'sess', - agent: 'claude', - hookState: 'working' - }) - expect(session.status).toBe('working') - }) - - it('does not treat assistant prose as turn completion while lifecycle is mid-generation', () => { - const session = mergeNativeChatLiveSession({ - sources: { transcript: [user('u-1', 'go'), assistant('a-1', 'done')] }, - sessionId: 'sess', - agent: 'claude', - hookState: 'working', - stateStartedAt: 1, - transcriptLifecycle: { state: 'working', turnId: 'u-1', timestamp: 1 } - }) - expect(session.status).toBe('working') - }) - - it('recovers via assistant prose when capable host has no in-progress lifecycle', () => { - const session = mergeNativeChatLiveSession({ - sources: { transcript: [user('u-1', 'go'), assistant('a-1', 'done')] }, - sessionId: 'sess', - agent: 'claude', - hookState: 'working', - stateStartedAt: 1 - }) - expect(session.status).toBe('ready') - }) - - it('settles a dropped working hook from an explicit completion marker', () => { - const session = mergeNativeChatLiveSession({ - sources: { transcript: [user('u-1', 'go'), assistant('a-1', 'done')] }, - sessionId: 'sess', - agent: 'claude', - hookState: 'working', - stateStartedAt: 1, - transcriptLifecycle: { state: 'completed', turnId: 'turn-1', timestamp: 2 } - }) - expect(session.status).toBe('ready') - }) - - it('settles a dropped working hook from an explicit interruption marker', () => { - const session = mergeNativeChatLiveSession({ - sources: { transcript: [user('u-1', 'go')] }, - sessionId: 'sess', - agent: 'claude', - hookState: 'working', - stateStartedAt: 1, - transcriptLifecycle: { state: 'interrupted', turnId: 'turn-1', timestamp: 2 } - }) - expect(session.status).toBe('ready') - }) - - it('does not apply an older completion marker to a newer working turn', () => { - const session = mergeNativeChatLiveSession({ - sources: { transcript: [assistant('a-1', 'prior')] }, - sessionId: 'sess', - agent: 'claude', - hookState: 'working', - stateStartedAt: 5, - transcriptLifecycle: { state: 'completed', turnId: 'turn-1', timestamp: 2 } - }) - expect(session.status).toBe('working') - }) - - it('does not apply an older interruption marker to a newer working turn', () => { - const session = mergeNativeChatLiveSession({ - sources: { transcript: [assistant('a-1', 'prior')] }, - sessionId: 'sess', - agent: 'claude', - hookState: 'working', - stateStartedAt: 5, - transcriptLifecycle: { state: 'interrupted', turnId: 'turn-1', timestamp: 2 } - }) - expect(session.status).toBe('working') - }) - - it('settles an unorderable (null-timestamp) completion marker for live work', () => { - const session = mergeNativeChatLiveSession({ - sources: { transcript: [assistant('a-1', 'prior')] }, - sessionId: 'sess', - agent: 'claude', - hookState: 'working', - stateStartedAt: 5, - transcriptLifecycle: { state: 'completed', turnId: 'turn-1', timestamp: null } - }) - expect(session.status).toBe('ready') - }) - - it('settles a completion slightly before hook receipt within clock-skew slack', () => { - const hookStartedAt = 1_700_000_000_000 - const session = mergeNativeChatLiveSession({ - sources: { transcript: [assistant('a-1', 'done')] }, - sessionId: 'sess', - agent: 'claude', - hookState: 'working', - stateStartedAt: hookStartedAt, - transcriptLifecycle: { - state: 'completed', - turnId: 'turn-1', - timestamp: hookStartedAt - 500 - } - }) - expect(session.status).toBe('ready') - }) - - it('preserves the assistant fallback when the serving host lacks explicit boundaries', () => { - const session = mergeNativeChatLiveSession({ - sources: { transcript: [assistant('a-1', 'done')] }, - sessionId: 'sess', - agent: 'grok', - hookState: 'working', - stateStartedAt: 1 - }) - expect(session.status).toBe('ready') - }) - - it('keeps working while the hook reports a live background child', () => { - const session = mergeNativeChatLiveSession({ - sources: { transcript: [assistant('a-1', 'lead done')] }, - sessionId: 'sess', - agent: 'claude', - hookState: 'working', - stateStartedAt: 1, - transcriptLifecycle: { state: 'completed', turnId: 'turn-1', timestamp: 2 }, - hookHasWorkingSubagents: true - }) - expect(session.status).toBe('working') - }) - - it('settles on an interruption even while the hook reports a live background child', () => { - const session = mergeNativeChatLiveSession({ - sources: { transcript: [assistant('a-1', 'lead done')] }, - sessionId: 'sess', - agent: 'claude', - hookState: 'working', - stateStartedAt: 1, - transcriptLifecycle: { state: 'interrupted', turnId: 'turn-1', timestamp: 2 }, - hookHasWorkingSubagents: true - }) - expect(session.status).toBe('ready') - }) - - it('leaves completed states (done/waiting/blocked) on the derived status', () => { - const session = mergeNativeChatLiveSession({ - sources: { transcript: [user('u-1', 'hi')] }, - sessionId: 'sess', - agent: 'claude', - hookState: 'done' - }) - expect(session.status).toBe('ready') - }) - - it('surfaces live work while the transcript loads and honors errors outright', () => { - expect( - mergeNativeChatLiveSession({ - sources: { transcript: [] }, - sessionId: null, - agent: 'claude', - hookState: 'working', - loading: true - }).status - ).toBe('working') - expect( - mergeNativeChatLiveSession({ - sources: { transcript: [] }, - sessionId: 'sess', - agent: 'claude', - hookState: 'working', - loading: true - }).status - ).toBe('loading') - - const errored = mergeNativeChatLiveSession({ - sources: { transcript: [] }, - sessionId: 'sess', - agent: 'claude', - hookState: null, - error: 'unreadable' - }) - expect(errored.status).toBe('error') - expect(errored.error).toBe('unreadable') - }) - - it('assembles an empty transcript with no live work as empty', () => { - const session = mergeNativeChatLiveSession({ - sources: { transcript: [] }, - sessionId: 'sess', - agent: 'claude', - hookState: null - }) - expect(session.status).toBe('empty') - }) -}) - describe('useNativeChatLiveSession — transport routing', () => { const AGENT = 'claude' as const const SESSION = 'sess-1' @@ -885,6 +673,24 @@ describe('useNativeChatLiveSession — notFound retry (#8401)', () => { expect(latest?.readPhase).toBe('loading') }) + // #11032 previously forced 'loading' whenever the session id was known, which + // masked a live working hook. Unmasking it (so the composer offers Stop) adds + // a second way status leaves 'loading' mid-read, so re-pin #9802's contract on + // exactly that path: the launch-draft gate must still see the raw read phase. + it('reports the loading readPhase for a known session whose working hook unmasks the status', async () => { + vi.useFakeTimers() + const transport = getMockTransport('env-1', { autoSnapshot: false }) + transport.readSession.mockResolvedValue({ error: 'No transcript found', notFound: true }) + useAppStore.setState({ + agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 1 } } + } as never) + + await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) + + expect(latest?.status).toBe('working') + expect(latest?.readPhase).toBe('loading') + }) + it('renders live-appended content even when the initial read settled into a permanent error', async () => { const transport = getMockTransport('env-1', { autoSnapshot: false }) transport.readSession.mockResolvedValueOnce({ error: 'unreadable transcript' }) diff --git a/src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx b/src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx index c1bd289f0..3b7931238 100644 --- a/src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx +++ b/src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx @@ -403,6 +403,80 @@ describe('ProjectCombobox', () => { expect(field().getAttribute('aria-describedby')).toBe('project-error') }) + // Empty query + >=6 matches + a folder group triggers sectioning, which sinks + // folders below Projects. Arming raw rank order armed the BOTTOM row, so Enter + // on open created the workspace in the folder group. + const sectionedOptions: NewWorkspaceProjectOption[] = [ + ...Array.from({ length: 5 }, (_, index) => ({ + kind: 'project' as const, + id: `project-${index}`, + projectId: `project-${index}`, + displayName: `svc-${index}`, + badgeColor: '#111111', + detail: `stablyai/svc-${index}` + })), + { + kind: 'project-group', + id: 'project-group:apps', + projectGroupId: 'apps', + displayName: 'Apps', + badgeColor: '#333333', + detail: '/tmp/apps', + parentPath: '/tmp/apps', + connectionId: null + } + ] + + it('arms the first rendered row when sections reorder the list', () => { + act(() => { + root.render( + + ) + }) + openList() + + const rows = Array.from(container.querySelectorAll('[role="option"]')) + expect(rows.length).toBeGreaterThan(1) + expect(rows[0]?.getAttribute('data-armed')).toBe('true') + expect(rows.filter((row) => row.getAttribute('data-armed') === 'true')).toHaveLength(1) + }) + + it('commits the top row on Enter rather than the folder group sectioned to the bottom', () => { + const onValueChange = vi.fn() + + act(() => { + root.render( + + ) + }) + act(() => { + container + .querySelector('[data-project-combobox-root="true"]') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + act(() => { + field().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + }) + + expect(onValueChange).toHaveBeenCalledWith('project-0') + expect(onValueChange).not.toHaveBeenCalledWith('project-group:apps') + }) + + it('steps ArrowDown to the next rendered row instead of jumping across sections', () => { + act(() => { + root.render( + + ) + }) + openList() + act(() => { + field().dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })) + }) + + const rows = Array.from(container.querySelectorAll('[role="option"]')) + expect(rows[1]?.getAttribute('data-armed')).toBe('true') + }) + it('owns every option from the listbox, with no unroled wrapper in between', () => { act(() => { root.render( diff --git a/src/renderer/src/components/new-workspace/ProjectCombobox.tsx b/src/renderer/src/components/new-workspace/ProjectCombobox.tsx index 9f29eb4c4..a4cabb008 100644 --- a/src/renderer/src/components/new-workspace/ProjectCombobox.tsx +++ b/src/renderer/src/components/new-workspace/ProjectCombobox.tsx @@ -50,9 +50,16 @@ export default function ProjectCombobox({ const recentIds = useRecentProjectIds() // Ranking depends on the query the hook owns, so rows are derived from it and // handed back; `matches`/`sections` are recomputed from the same query below. + // Why sections, not raw rank: sectioning reorders the list (folders sink to + // the bottom), so arming raw rank order armed a row the user never sees first + // and Enter created the workspace in the wrong project. const deriveRowKeys = useCallback( (query: string): string[] => [ - ...rankProjectOptions(options, query, recentIds).map((match) => match.option.id), + ...sectionProjectOptions( + rankProjectOptions(options, query, recentIds), + query, + recentIds + ).flatMap((section) => section.items.map((match) => match.option.id)), ...(onAddProject ? [ADD_PROJECT_KEY] : []) ], [onAddProject, options, recentIds] diff --git a/src/renderer/src/components/new-workspace/ProjectComboboxRow.test.tsx b/src/renderer/src/components/new-workspace/ProjectComboboxRow.test.tsx new file mode 100644 index 000000000..69f85ee8d --- /dev/null +++ b/src/renderer/src/components/new-workspace/ProjectComboboxRow.test.tsx @@ -0,0 +1,71 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { MatchedText } from './ProjectComboboxRow' +import { rankProjectOptions } from './project-combobox-matching' +import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options' + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() +}) + +function markedText(): string { + return Array.from(container.querySelectorAll('mark')) + .map((mark) => mark.textContent ?? '') + .join('') +} + +describe('MatchedText', () => { + it('underlines the matched run', () => { + act(() => { + root.render() + }) + + expect(markedText()).toBe('orca') + }) + + // Hits are UTF-16 offsets; rendering splits by code point. An astral glyph is + // two code units but one rendered character, so every mark landed one glyph + // late (and the last one fell off the end). + it('aligns marks with the query when the name starts with an emoji', () => { + const option: NewWorkspaceProjectOption = { + kind: 'project', + id: 'p1', + projectId: 'p1', + displayName: '🚀 orca', + badgeColor: '#111111', + detail: '~/dev/orca' + } + const [match] = rankProjectOptions([option], 'orca', []) + expect(match).toBeDefined() + + act(() => { + root.render() + }) + + expect(markedText()).toBe('orca') + }) + + it('leaves text unmarked when there are no hits', () => { + act(() => { + root.render() + }) + + expect(container.querySelectorAll('mark')).toHaveLength(0) + expect(container.textContent).toBe('🚀 orca') + }) +}) diff --git a/src/renderer/src/components/new-workspace/ProjectComboboxRow.tsx b/src/renderer/src/components/new-workspace/ProjectComboboxRow.tsx index e031bed5c..471fd9a42 100644 --- a/src/renderer/src/components/new-workspace/ProjectComboboxRow.tsx +++ b/src/renderer/src/components/new-workspace/ProjectComboboxRow.tsx @@ -33,10 +33,15 @@ export function MatchedText({ if (marks.size === 0) { return {text} } + // Hits are UTF-16 offsets but rendering splits by code point, so an astral + // glyph (an emoji-named folder) shifted every mark one position late. + let codeUnit = 0 return ( - {[...text].map((char, index) => - marks.has(index) ? ( + {[...text].map((char) => { + const index = codeUnit + codeUnit += char.length + return marks.has(index) ? ( ) } diff --git a/src/renderer/src/components/new-workspace/project-combobox-matching.ts b/src/renderer/src/components/new-workspace/project-combobox-matching.ts index edbd5595e..ac298ebd6 100644 --- a/src/renderer/src/components/new-workspace/project-combobox-matching.ts +++ b/src/renderer/src/components/new-workspace/project-combobox-matching.ts @@ -123,7 +123,9 @@ export function sectionProjectOptions( { key: 'folders', heading: 'Folders', - items: matches.filter((m) => m.option.kind === 'project-group') + // Same recent exclusion as Projects: a row must appear in exactly one + // section, or arming and rendering see two rows for one option. + items: matches.filter((m) => m.option.kind === 'project-group' && !recentSet.has(m.option.id)) } ] return sections.filter((section) => section.items.length > 0) diff --git a/src/renderer/src/i18n/locale-english-regression.test.ts b/src/renderer/src/i18n/locale-english-regression.test.ts new file mode 100644 index 000000000..dc20755de --- /dev/null +++ b/src/renderer/src/i18n/locale-english-regression.test.ts @@ -0,0 +1,70 @@ +/** + * #10770 merged from a base predating #8549, so its stale locale copies + * overwrote ~185 already-translated strings per locale back to English. A + * present catalog value always beats the English `translate()` fallback, so + * those strings render English at runtime with nothing to signal the loss. + * + * Later translation passes (#11205 and friends) independently re-covered most of + * ja/ko/zh, so the re-applied set is only what is still English at HEAD: es 182, + * zh 55, ja 32, ko 17. The keys sampled below stayed English in all four. + * + * These are keys whose English source is UNCHANGED, so an English catalog value + * can only be a revert. Locale-wide coverage is deliberately not asserted here: + * genuinely-untranslated new keys are normal, and a blanket gate would fail on + * every future English-source addition. + */ +import { describe, expect, it } from 'vitest' +import en from './locales/en.json' +import es from './locales/es.json' +import ja from './locales/ja.json' +import ko from './locales/ko.json' +import zh from './locales/zh.json' + +const catalogs = { es, ja, ko, zh } + +function lookup(catalog: unknown, key: string): string | undefined { + const value = key + .split('.') + .reduce( + (node, part) => + node && typeof node === 'object' ? (node as Record)[part] : undefined, + catalog + ) + return typeof value === 'string' ? value : undefined +} + +// Sampled across the reverted set: an unconditional sidebar filter row, plugin +// command failures on two surfaces, a settings description, and a status-bar +// metric — so a partial re-revert cannot pass by covering one namespace. +// (workingSetDescription was re-translated upstream for ja/ko; it still guards +// es/zh, and every other key here was English in all four.) +const REVERTED_KEYS = [ + 'auto.components.sidebar.SidebarFilter.detachedHead', + 'auto.App.pluginCommandFailed', + 'auto.components.WorktreeJumpPalette.pluginCommandFailed', + 'auto.hooks.useSettingsNavigationMetadata.pluginsDescription', + 'auto.components.status.bar.resource.memory.metric.workingSetDescription', + 'auto.components.settings.EphemeralVmsPane.recipesHelp' +] + +describe('locale catalogs reverted by a stale branch base (#10770)', () => { + it.each(Object.entries(catalogs))('%s translates the reverted keys', (_code, catalog) => { + for (const key of REVERTED_KEYS) { + const english = lookup(en, key) + const localized = lookup(catalog, key) + expect(english, `${key} missing from en.json`).toBeDefined() + expect(localized, `${key} missing from catalog`).toBeDefined() + expect(localized?.trim()).not.toBe('') + expect(localized, `${key} fell back to the English source`).not.toBe(english) + } + }) + + // The same stale base regressed English itself: the catalog kept pre-plugins + // copy while EphemeralVmsPane.tsx already shipped the newer sentence, and a + // present catalog value wins over the source fallback. + it('keeps the English catalog in step with its live source string', () => { + expect(lookup(en, 'auto.components.settings.EphemeralVmsPane.recipesHelp')).toBe( + 'Recipes from orca.yaml and enabled plugins show up here, ready to launch a workspace on.' + ) + }) +}) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 6b5161741..72937a37d 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -9368,7 +9368,7 @@ "copy": "Copy", "copied": "Copied", "recipes": "Recipes", - "recipesHelp": "Recipes your agent adds to orca.yaml show up here, ready to launch a workspace on.", + "recipesHelp": "Recipes from orca.yaml and enabled plugins show up here, ready to launch a workspace on.", "refresh": "Refresh ephemeral VM recipes", "checking": "Checking recipes...", "none": "No recipes found yet." diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index f50259107..d97f38a33 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -133,7 +133,7 @@ "064bd07810": "Volver", "ce37cf5279": "Alternar barra lateral ({{value0}})", "e4b9e7dff7": "Alternar barra lateral", - "pluginCommandFailed": "Could not run the plugin command." + "pluginCommandFailed": "No se pudo ejecutar el comando del plugin." }, "web": { "WebConnect": { @@ -762,7 +762,7 @@ "linearTitle": "Linear", "linearDescription": "Give agents the skill to read and update your linked Linear tickets.", "pluginsTitle": "Plugins", - "pluginsDescription": "Install and manage experimental Orca plugins." + "pluginsDescription": "Instala y administra plugins experimentales de Orca." }, "useAppMenuPaste": { "pasteTooLarge": "El contenido pegado es demasiado grande." @@ -1951,7 +1951,7 @@ "projectsGroupsHeader": "Proyectos y grupos", "projectBadge": "Proyecto", "repoGroupBadge": "Grupo de repositorios", - "pluginCommandFailed": "Could not run the plugin command." + "pluginCommandFailed": "No se pudo ejecutar el comando del plugin." }, "github": { "pr": { @@ -3385,8 +3385,8 @@ "resource": { "memory": { "metric": { - "workingSetDescription": "Summed working set (WS). Shared pages can appear in more than one process.", - "rssDescription": "Summed resident set size (RSS). Shared or aliased pages can appear in more than one process." + "workingSetDescription": "Suma del conjunto de trabajo (WS). Las páginas compartidas pueden aparecer en más de un proceso.", + "rssDescription": "Suma del tamaño del conjunto residente (RSS). Las páginas compartidas o con alias pueden aparecer en más de un proceso." } } }, @@ -4193,7 +4193,7 @@ "ee240a39eb": "Editar filtros", "automationCreated": "Ocultar creados por automatizaciones", "cliCreated": "Hide CLI-created", - "detachedHead": "Hide detached HEAD" + "detachedHead": "Ocultar HEAD desacoplado" }, "SidebarHeader": { "92154beb7e": "Nuevo espacio de trabajo", @@ -4258,7 +4258,7 @@ "82594419ba": "Filtros", "automationCreated": "Ocultar creados por automatizaciones", "cliCreated": "Hide CLI-created", - "detachedHead": "Hide detached HEAD" + "detachedHead": "Ocultar HEAD desacoplado" }, "SidebarWorkspaceOptionsMenu": { "95c9754653": "Diseño de actividad del Agent", @@ -6810,7 +6810,7 @@ "781cb74d22": "Se ejecuta desde paneles de terminal.", "cb02e00202": "Terminal", "d8c988dab4": "~/.orca/keybindings.json", - "shortcutUnavailable": "Shortcut is no longer available." + "shortcutUnavailable": "El atajo ya no está disponible." }, "SourceControlAiActionRecipeDefaults": { "2576299196": "argumentos", @@ -9345,7 +9345,7 @@ "copy": "Copiar", "copied": "Copiado", "recipes": "Recetas", - "recipesHelp": "Las recetas que tu agente agrega a orca.yaml aparecen aquí, listas para iniciar un espacio de trabajo.", + "recipesHelp": "Las recetas de orca.yaml y de los plugins activados aparecen aquí, listas para iniciar un espacio de trabajo.", "refresh": "Actualizar recetas de VMs efímeras", "checking": "Comprobando recetas…", "none": "Aún no se han encontrado recetas." @@ -9544,37 +9544,37 @@ "legacyHelp": "Update this server manually once to enable remote updates." }, "PluginConsentDialog": { - "workerTrust": "Background worker — runs its own process", - "instructionalTrust": "Instructional content — runs later under user or agent authority", + "workerTrust": "Proceso en segundo plano — se ejecuta en su propio proceso", + "instructionalTrust": "Contenido instructivo — se ejecuta más tarde con la autoridad del usuario o del agente", "declarativeTrust": "Declarative content — no plugin code", - "panelTrust": "Panel or host-integrated content — no worker process", + "panelTrust": "Panel o contenido integrado con el host — sin proceso en segundo plano", "trustShortWorker": "Worker", "trustShortInstructional": "Instructional", "trustShortPanel": "Panel", "trustShortDeclarative": "Declarative", "reviewTitle": "Review plugin", - "title": "Review permissions", - "mixedTitle": "Review access and content", - "instructionalTitle": "Review plugin content", + "title": "Revisar permisos", + "mixedTitle": "Revisar acceso y contenido", + "instructionalTitle": "Revisar el contenido del plugin", "subtitle": "{{value0}} v{{value1}} · {{value2}}", - "reconsent": "Permissions, the worker trust tier, or instructional content changed since you last reviewed this plugin. Review it again before it can run.", - "capabilities": "This plugin can", - "warning": "These permissions limit how the plugin uses Orca's API. Its worker still runs as a normal process on your computer with full access to your files, network, and other processes.", - "instructionalWarning": "This plugin has no worker process. Its instructional content can still cause actions when you or an agent use it. Review the instructions and commands below before enabling it.", - "panelWarning": "These permissions limit how the plugin uses Orca's API. This plugin has no background worker.", + "reconsent": "Los permisos, el nivel de confianza del proceso o el contenido instructivo cambiaron desde la última revisión de este plugin. Revísalo de nuevo antes de ejecutarlo.", + "capabilities": "Este plugin puede", + "warning": "Estos permisos limitan cómo usa el plugin la API de Orca. Su proceso se ejecuta como un proceso normal en tu equipo, con acceso completo a tus archivos, red y otros procesos.", + "instructionalWarning": "Este plugin no tiene ningún proceso en segundo plano. Aun así, su contenido instructivo puede provocar acciones cuando tú o un agente lo usen. Revisa las instrucciones y los comandos siguientes antes de habilitarlo.", + "panelWarning": "Estos permisos limitan cómo usa el plugin la API de Orca. Este plugin no tiene ningún proceso en segundo plano.", "declarativeWarning": "This plugin contributes validated content only. It does not run a background worker or receive access to Orca's API.", - "keepDisabled": "Keep Disabled", - "enable": "Enable plugin", + "keepDisabled": "Mantener desactivado", + "enable": "Activar plugin", "capability": { - "workspaceRead": "Read the name, branch, and terminal list of your focused worktree", - "terminalSend": "Type text into a terminal you can see (always a specific terminal)", - "notificationsShow": "Show desktop notifications labeled with the plugin name", - "storage": "Store data in the plugin's own storage folder", - "secrets": "Store and read secrets in the plugin's own encrypted vault", - "eventsSubscribe": "Get notified when worktrees are created or removed and when agent status changes", - "settingsOwn": "Read and change the plugin's own settings" + "workspaceRead": "Leer el nombre, la rama y la lista de terminales del worktree seleccionado", + "terminalSend": "Escribir texto en una terminal visible (siempre en una terminal específica)", + "notificationsShow": "Mostrar notificaciones de escritorio con el nombre del plugin", + "storage": "Guardar datos en la carpeta de almacenamiento propia del plugin", + "secrets": "Guardar y leer secretos en la bóveda cifrada propia del plugin", + "eventsSubscribe": "Recibir avisos cuando se creen o eliminen worktrees y cuando cambie el estado de un agente", + "settingsOwn": "Leer y cambiar la configuración propia del plugin" }, - "decisionFailed": "Could not save the permission decision. Try again." + "decisionFailed": "No se pudo guardar la decisión sobre los permisos. Inténtalo de nuevo." }, "PluginConsentProvenance": { "official": "Official", @@ -9588,209 +9588,209 @@ "indexCommit": "Index commit" }, "PluginDevelopmentSection": { - "saveFailed": "Could not save development plugin paths.", - "pathRequired": "Enter a plugin folder path.", - "title": "Development", - "help": "Load plugins directly from folders on this computer while you develop them. Dev plugins still require permission review. Workers run on this desktop host; SSH workspace actions route through Orca, so paths here are desktop paths.", - "remove": "Remove", - "pathLabel": "Development plugin folder path", + "saveFailed": "No se pudieron guardar las rutas de plugins de desarrollo.", + "pathRequired": "Introduce la ruta de una carpeta de plugin.", + "title": "Desarrollo", + "help": "Carga plugins directamente desde carpetas de este equipo mientras los desarrollas. Los plugins de desarrollo también requieren revisar los permisos. Sus procesos se ejecutan en este equipo; las acciones de espacios de trabajo SSH se enrutan mediante Orca, por lo que estas rutas son rutas del equipo local.", + "remove": "Eliminar", + "pathLabel": "Ruta de la carpeta del plugin de desarrollo", "placeholder": "/Users/you/plugins/my-plugin or C:\\Users\\you\\plugins\\my-plugin", - "add": "Add path" + "add": "Añadir ruta" }, "PluginInstallDialog": { - "localRequired": "Enter the plugin folder path.", - "gitUrlRequired": "Enter a repository URL.", - "gitUrlInvalid": "Use an HTTPS or SSH Git URL. Executable Git helper protocols are not allowed.", - "gitRefRequired": "Add an explicit #ref (tag or commit) so the install is pinned — for example #v0.1.0.", - "title": "Install plugin", - "description": "Installing copies the plugin into Orca and shows its permissions for review. No plugin code runs until you enable it.", - "source": "Install source", - "localTab": "Local folder", + "localRequired": "Introduce la ruta de la carpeta del plugin.", + "gitUrlRequired": "Introduce una URL de repositorio.", + "gitUrlInvalid": "Usa una URL de Git HTTPS o SSH. No se permiten protocolos auxiliares ejecutables de Git.", + "gitRefRequired": "Añade una #ref explícita (etiqueta o commit) para fijar la instalación; por ejemplo, #v0.1.0.", + "title": "Instalar plugin", + "description": "La instalación copia el plugin en Orca y muestra sus permisos para que los revises. No se ejecuta ningún código del plugin hasta que lo actives.", + "source": "Origen de instalación", + "localTab": "Carpeta local", "gitTab": "Git URL", - "localLabel": "Plugin folder path", + "localLabel": "Ruta de la carpeta del plugin", "localPlaceholder": "/Users/you/plugins/my-plugin or C:\\Users\\you\\plugins\\my-plugin", - "localHelp": "Full path to a folder containing orca-plugin.json on this computer. The path is used exactly as entered.", - "gitLabel": "Repository URL with #ref", + "localHelp": "Ruta completa a una carpeta de este equipo que contenga orca-plugin.json. La ruta se usa exactamente como se introduce.", + "gitLabel": "URL del repositorio con #ref", "gitPlaceholder": "https://git.example/acme/orca-notes#v0.1.0", - "gitHelp": "Append an explicit #ref — a tag or commit — so the install is pinned. Works with GitHub, GitLab, and any git host.", - "cancel": "Cancel", - "installing": "Installing…", - "install": "Install", - "installFailed": "Plugin installation failed. Check the source and try again." + "gitHelp": "Añade una #ref explícita —una etiqueta o commit— para fijar la instalación. Funciona con GitHub, GitLab y cualquier servidor Git.", + "cancel": "Cancelar", + "installing": "Instalando…", + "install": "Instalar", + "installFailed": "No se pudo instalar el plugin. Comprueba el origen e inténtalo de nuevo." }, "PluginKeybindingConsentPreview": { - "heading": "Keyboard shortcuts", - "worktree": "Runs only while a workspace is active.", - "global": "Runs in the app without requiring an active workspace.", - "shadows": "Replaces: {{value0}}" + "heading": "Atajos de teclado", + "worktree": "Solo se ejecuta mientras hay un espacio de trabajo activo.", + "global": "Se ejecuta en la aplicación sin requerir un espacio de trabajo activo.", + "shadows": "Reemplaza: {{value0}}" }, "PluginMarketplaceBrowser": { - "loadFailed": "Could not load marketplace plugins.", - "refreshFailed": "Could not refresh marketplaces. Cached listings remain available.", - "previewFailed": "Could not prepare this plugin for review. Refresh the marketplace and try again.", - "installFailed": "Could not install this plugin. The reviewed source may have changed.", - "manageSources": "Manage sources", - "refreshing": "Refreshing…", - "refresh": "Refresh", + "loadFailed": "No se pudieron cargar los plugins del marketplace.", + "refreshFailed": "No se pudieron actualizar los marketplaces. Los listados en caché siguen disponibles.", + "previewFailed": "No se pudo preparar este plugin para revisarlo. Actualiza el marketplace e inténtalo de nuevo.", + "installFailed": "No se pudo instalar este plugin. Es posible que la fuente revisada haya cambiado.", + "manageSources": "Gestionar fuentes", + "refreshing": "Actualizando…", + "refresh": "Actualizar", "noInstalledTitle": "No plugins installed", "noInstalled": "Plugins you install appear here.", - "loading": "Loading marketplace plugins…", - "tryAgain": "Try again", + "loading": "Cargando plugins del marketplace…", + "tryAgain": "Intentar de nuevo", "noSourcesTitle": "No marketplaces configured", - "noSources": "Add an official, community, or private Git marketplace to browse plugins.", - "addSource": "Add marketplace", + "noSources": "Añade un marketplace de Git oficial, comunitario o privado para explorar plugins.", + "addSource": "Añadir marketplace", "noResultsTitle": "No matching plugins", - "noResults": "No marketplace plugins match this search.", + "noResults": "Ningún plugin del marketplace coincide con esta búsqueda.", "clearSearch": "Clear search", "emptyTitle": "Nothing listed yet", - "empty": "The configured marketplaces do not list any plugins." + "empty": "Los marketplaces configurados no incluyen ningún plugin." }, "PluginMarketplaceListingRow": { - "official": "Official", - "installed": "Installed", + "official": "Oficial", + "installed": "Instalado", "noDescription": "No description provided.", - "blocked": "Blocked by Orca's safety list: {{value0}}", - "blockedAction": "Blocked", - "checkUpdate": "Check for update", + "blocked": "Bloqueado por la lista de seguridad de Orca: {{value0}}", + "blockedAction": "Bloqueado", + "checkUpdate": "Buscar actualización", "install": "Install" }, "PluginMarketplacePreviewDialog": { "languagePacksOne": "1 language pack", - "languagePacks": "{{value0}} language packs", + "languagePacks": "{{value0}} paquetes de idioma", "commandsOne": "1 command", - "commands": "{{value0}} commands", + "commands": "{{value0}} comandos", "keybindingsOne": "1 keyboard shortcut", - "keybindings": "{{value0}} keyboard shortcuts", + "keybindings": "{{value0}} atajos de teclado", "vmRecipesOne": "1 VM recipe", - "vmRecipes": "{{value0}} VM recipes", + "vmRecipes": "{{value0}} recetas de VM", "panelsOne": "1 panel", - "panels": "{{value0}} panels", + "panels": "{{value0}} paneles", "eventsOne": "1 event subscription", - "events": "{{value0}} event subscriptions", - "worker": "Background worker", + "events": "{{value0}} suscripciones a eventos", + "worker": "Proceso en segundo plano", "versionLine": "v{{value0}} · {{value1}}", - "includes": "Includes", - "noContributions": "Manifest metadata only", - "capabilities": "Requested access", - "workerWarning": "Capabilities limit how this plugin uses Orca's API. Its worker still runs as a normal process on this computer with full access to your files, network, and other processes.", - "blocked": "Orca's safety list blocks this plugin: {{value0}}", - "current": "This exact plugin content is already installed.", + "includes": "Incluye", + "noContributions": "Solo metadatos del manifiesto", + "capabilities": "Acceso solicitado", + "workerWarning": "Las capacidades limitan cómo usa este plugin la API de Orca. Su proceso sigue ejecutándose como un proceso normal en este ordenador, con acceso completo a tus archivos, red y otros procesos.", + "blocked": "La lista de seguridad de Orca bloquea este plugin: {{value0}}", + "current": "Este contenido exacto del plugin ya está instalado.", "close": "Close", - "cancel": "Cancel", - "update": "Update plugin", - "install": "Install plugin" + "cancel": "Cancelar", + "update": "Actualizar plugin", + "install": "Instalar plugin" }, "PluginMarketplaceSourceDialog": { - "addFailed": "Could not add this marketplace. Check the Git URL, ref, and your Git credentials.", - "refreshFailed": "Could not refresh this marketplace. Its last valid cached index is still available.", - "removeFailed": "Could not remove this marketplace.", - "title": "Marketplace sources", - "description": "Marketplaces are pinned Git repositories. Orca uses your existing system Git credentials for private repositories.", + "addFailed": "No se pudo añadir este marketplace. Comprueba la URL de Git, la referencia y tus credenciales de Git.", + "refreshFailed": "No se pudo actualizar este marketplace. Su último índice válido en caché sigue disponible.", + "removeFailed": "No se pudo eliminar este marketplace.", + "title": "Fuentes de marketplace", + "description": "Los marketplaces son repositorios de Git fijados. Orca usa tus credenciales existentes del Git del sistema para los repositorios privados.", "urlLabel": "Git URL", - "urlDescription": "Use an HTTPS or SSH repository URL containing orca-marketplace.json.", + "urlDescription": "Usa la URL HTTPS o SSH de un repositorio que contenga orca-marketplace.json.", "urlPlaceholder": "https://git.example.com/team/plugins.git", - "refLabel": "Git ref", - "refDescription": "Choose a branch, tag, or commit. Every fetched index is recorded at an exact commit.", - "adding": "Adding…", - "add": "Add source", - "configured": "Configured sources", - "empty": "No marketplace sources configured.", - "official": "Official", - "owner": "Owner: {{value0}}", - "pinnedCommit": "Pinned at {{value0}}", - "stale": "Refresh failed. Browsing the last valid cached index.", - "refreshLabel": "Refresh {{value0}}", - "removeLabel": "Remove {{value0}}", - "done": "Done" + "refLabel": "Referencia de Git", + "refDescription": "Elige una rama, etiqueta o commit. Cada índice obtenido se registra en un commit exacto.", + "adding": "Añadiendo…", + "add": "Añadir fuente", + "configured": "Fuentes configuradas", + "empty": "No hay fuentes de marketplace configuradas.", + "official": "Oficial", + "owner": "Propietario: {{value0}}", + "pinnedCommit": "Fijado en {{value0}}", + "stale": "La actualización falló. Se muestra el último índice válido en caché.", + "refreshLabel": "Actualizar {{value0}}", + "removeLabel": "Eliminar {{value0}}", + "done": "Listo" }, "PluginRemoveDialog": { - "title": "Remove plugin?", - "description": "This removes {{value0}} and its stored plugin data from this computer. You can install it again later.", - "cancel": "Cancel", - "remove": "Remove plugin" + "title": "¿Eliminar plugin?", + "description": "Esto elimina {{value0}} y los datos guardados del plugin en este equipo. Puedes volver a instalarlo más adelante.", + "cancel": "Cancelar", + "remove": "Eliminar plugin" }, "PluginRollbackDialog": { - "title": "Roll back plugin?", - "description": "This deactivates {{value0}} and restores its previous immutable version. If that version requests different access or instructional content, Orca will require another review.", - "cancel": "Cancel", - "confirm": "Roll back plugin" + "title": "¿Revertir el plugin?", + "description": "Esto desactiva {{value0}} y restaura su versión inmutable anterior. Si esa versión solicita otro acceso o contenido instructivo, Orca requerirá una nueva revisión.", + "cancel": "Cancelar", + "confirm": "Revertir plugin" }, "PluginsSettingsSection": { - "systemLabel": "Plugin system", - "systemDescription": "Discovers installed plugins and lets you enable them individually. Nothing runs until you review and enable it. Workers always run on this computer; SSH workspace actions route through Orca.", - "featureOff": "Turn on the plugin system to see and manage installed plugins. Anything already installed stays on disk and stays disabled while the system is off.", - "loading": "Loading plugins…", + "systemLabel": "Sistema de plugins", + "systemDescription": "Descubre los plugins instalados y permite activarlos por separado. Nada se ejecuta hasta que lo revises y actives. Sus procesos siempre se ejecutan en este equipo; las acciones de espacios de trabajo SSH se enrutan mediante Orca.", + "featureOff": "Activa el sistema de plugins para ver y administrar los plugins instalados. Todo lo que ya esté instalado permanece en el disco y desactivado mientras el sistema esté apagado.", + "loading": "Cargando plugins…", "noInstalledResultsTitle": "No matching plugins", "noInstalledResults": "No installed plugins match this search.", "emptyTitle": "No plugins installed yet", "empty": "Browse the All tab to install plugins from a marketplace.", - "loadFailed": "Could not load plugins.", - "settingsUpdateFailed": "Could not save plugin settings.", - "install": "Install plugin", + "loadFailed": "No se pudieron cargar los plugins.", + "settingsUpdateFailed": "No se pudo guardar la configuración de plugins.", + "install": "Instalar plugin", "title": "Plugins", "experimental": "Experimental", - "description": "Install and manage Orca plugins. Plugins run on this computer, even for SSH workspaces.", - "logsFailed": "Could not load plugin logs.", - "rollbackFailed": "Could not roll back this plugin. A previous immutable version may not be available." + "description": "Instala y administra plugins de Orca. Los plugins se ejecutan en este equipo, incluso en espacios de trabajo SSH.", + "logsFailed": "No se pudieron cargar los registros del plugin.", + "rollbackFailed": "No se pudo revertir este plugin. Es posible que no haya una versión inmutable anterior disponible." }, "PluginSettingsRow": { - "blocked": "Blocked", - "needsReview": "Needs review", - "restarting": "Restarting", - "invalid": "Invalid", + "blocked": "Bloqueado", + "needsReview": "Requiere revisión", + "restarting": "Reiniciando", + "invalid": "No válido", "error": "Error", - "disabled": "Disabled", - "running": "Running", - "enabled": "Enabled", - "loadingLogs": "Loading logs…", - "noLogs": "No log lines recorded.", - "logCount": "Last {{value0}} of up to 200 retained lines", + "disabled": "Desactivado", + "running": "En ejecución", + "enabled": "Activado", + "loadingLogs": "Cargando registros…", + "noLogs": "No hay líneas de registro.", + "logCount": "Últimas {{value0}} de hasta 200 líneas conservadas", "reviewAndEnable": "Review & enable", - "official": "Official", - "dev": "Dev", - "bundled": "Bundled", + "official": "Oficial", + "dev": "Desarrollo", + "bundled": "Incluido", "noDescription": "No description provided.", - "killListMessage": "Orca's safety list disabled this plugin: {{value0}}", - "viewAdvisory": "View advisory", - "runtimeError": "The plugin stopped after an activation or worker error.", - "restartCount": " · {{value0}} restarts", - "moreActions": "More actions for {{value0}}", - "hideLogs": "Hide logs", - "viewLogs": "View logs", - "rollback": "Roll back", - "remove": "Remove", - "disableLabel": "Disable {{value0}}", - "enableLabel": "Enable {{value0}}", - "invalidPluginError": "The plugin manifest or installed files are invalid. Fix the plugin, then refresh." + "killListMessage": "La lista de seguridad de Orca desactivó este plugin: {{value0}}", + "viewAdvisory": "Ver aviso", + "runtimeError": "El plugin se detuvo tras un error de activación o del proceso.", + "restartCount": " · {{value0}} reinicios", + "moreActions": "Más acciones para {{value0}}", + "hideLogs": "Ocultar registros", + "viewLogs": "Ver registros", + "rollback": "Revertir", + "remove": "Eliminar", + "disableLabel": "Desactivar {{value0}}", + "enableLabel": "Activar {{value0}}", + "invalidPluginError": "El manifiesto o los archivos instalados del plugin no son válidos. Corrige el plugin y actualiza." }, "PluginVmRecipeConsentPreview": { - "create": "Create", - "suspend": "Suspend", - "resume": "Resume", - "destroy": "Destroy", - "heading": "VM recipe commands", - "commandLabel": "{{value0}} · {{value1}} command" + "create": "Crear", + "suspend": "Suspender", + "resume": "Reanudar", + "destroy": "Destruir", + "heading": "Comandos de recetas de VM", + "commandLabel": "{{value0}} · comando de {{value1}}" }, "pluginError": { - "installManifestMissing": "No readable orca-plugin.json was found. Choose the plugin's root folder.", - "installManifestInvalid": "orca-plugin.json is invalid. Ask the plugin author to fix the manifest.", - "incompatible": "This plugin requires a different Orca version.", - "installUnsafePath": "The plugin contains an unsafe file path or symlink and was not installed.", - "installLimit": "The plugin exceeds Orca's install size or file-count limits.", - "installGit": "Orca could not fetch the pinned Git revision. Check the URL, #ref, access, and system Git setup.", - "invalidManifestMissing": "The plugin root is missing orca-plugin.json. Add it, then refresh plugins.", - "invalidManifest": "orca-plugin.json is invalid. Fix it, then refresh plugins.", - "invalidArtifact": "A declared worker or panel file is missing or unsafe. Fix the plugin files, then refresh.", - "consentChanged": "The plugin changed while you were reviewing it. Close this dialog and review the updated permissions." + "installManifestMissing": "No se encontró un orca-plugin.json legible. Elige la carpeta raíz del plugin.", + "installManifestInvalid": "orca-plugin.json no es válido. Pide al autor del plugin que corrija el manifiesto.", + "incompatible": "Este plugin requiere otra versión de Orca.", + "installUnsafePath": "El plugin contiene una ruta de archivo o un enlace simbólico no seguros y no se instaló.", + "installLimit": "El plugin supera los límites de tamaño o cantidad de archivos de Orca.", + "installGit": "Orca no pudo obtener la revisión Git fijada. Comprueba la URL, la #ref, el acceso y la configuración de Git del sistema.", + "invalidManifestMissing": "A la raíz del plugin le falta orca-plugin.json. Añádelo y actualiza los plugins.", + "invalidManifest": "orca-plugin.json no es válido. Corrígelo y actualiza los plugins.", + "invalidArtifact": "Falta un archivo de proceso o panel declarado, o no es seguro. Corrige los archivos del plugin y actualiza.", + "consentChanged": "El plugin cambió mientras lo revisabas. Cierra este diálogo y revisa los permisos actualizados." }, "plugins": { "search": { "title": "Plugins", - "description": "Install and manage experimental Orca plugins.", - "install": "install plugin", - "permissions": "plugin permissions", - "logs": "plugin logs", - "development": "development plugins" + "description": "Instala y administra plugins experimentales de Orca.", + "install": "instalar plugin", + "permissions": "permisos de plugins", + "logs": "registros de plugins", + "development": "plugins de desarrollo" } } }, @@ -11068,10 +11068,10 @@ } }, "PluginPanel": { - "unavailable": "This plugin panel is no longer available.", - "loading": "Loading plugin panel...", - "unresponsive": "This plugin panel stopped responding and was suspended.", - "loadFailed": "The plugin panel could not be loaded." + "unavailable": "Este panel de plugin ya no está disponible.", + "loading": "Cargando el panel del plugin...", + "unresponsive": "El panel del plugin dejó de responder y se suspendió.", + "loadFailed": "No se pudo cargar el panel del plugin." }, "activityBar": { "error": "Error" @@ -13072,8 +13072,8 @@ } }, "pluginQuickActions": { - "description": "{{value0}} plugin command", - "keyword": "plugin command" + "description": "Comando del plugin {{value0}}", + "keyword": "comando del plugin" } } }, @@ -13701,9 +13701,9 @@ } }, "pluginPanelBridgeHost": { - "actionsUnavailable": "Plugin actions are not available in this client.", - "messageTooLarge": "Message exceeds the size limit.", - "tooManyRequests": "Too many requests." + "actionsUnavailable": "Las acciones de plugins no están disponibles en este cliente.", + "messageTooLarge": "El mensaje supera el límite de tamaño.", + "tooManyRequests": "Demasiadas solicitudes." } }, "link": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 5eb25d004..61619a7fc 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -133,7 +133,7 @@ "064bd07810": "戻る", "ce37cf5279": "サイドバーの切り替え ({{value0}})", "e4b9e7dff7": "サイドバーの切り替え", - "pluginCommandFailed": "Could not run the plugin command." + "pluginCommandFailed": "プラグインのコマンドを実行できませんでした。" }, "web": { "WebConnect": { @@ -761,8 +761,8 @@ "projectHostsSummary": "{{value0}} 台のホスト", "linearTitle": "Linear", "linearDescription": "Give agents the skill to read and update your linked Linear tickets.", - "pluginsTitle": "Plugins", - "pluginsDescription": "Install and manage experimental Orca plugins." + "pluginsTitle": "プラグイン", + "pluginsDescription": "試験的な Orca プラグインをインストールして管理します。" }, "useAppMenuPaste": { "pasteTooLarge": "貼り付け内容が大きすぎます。" @@ -1951,7 +1951,7 @@ "projectsGroupsHeader": "プロジェクトとグループ", "projectBadge": "プロジェクト", "repoGroupBadge": "リポジトリグループ", - "pluginCommandFailed": "Could not run the plugin command." + "pluginCommandFailed": "プラグインのコマンドを実行できませんでした。" }, "github": { "pr": { @@ -4174,7 +4174,7 @@ "ee240a39eb": "フィルターの編集", "automationCreated": "自動化で作成されたワークスペースを非表示", "cliCreated": "Hide CLI-created", - "detachedHead": "Hide detached HEAD" + "detachedHead": "分離HEADを非表示" }, "SidebarHeader": { "92154beb7e": "新規ワークスペース", @@ -4239,7 +4239,7 @@ "82594419ba": "フィルター", "automationCreated": "自動化で作成されたワークスペースを非表示", "cliCreated": "Hide CLI-created", - "detachedHead": "Hide detached HEAD" + "detachedHead": "分離HEADを非表示" }, "SidebarWorkspaceOptionsMenu": { "95c9754653": "Agent アクティビティのレイアウト", @@ -6832,7 +6832,7 @@ "781cb74d22": "terminal ペインから実行します。", "cb02e00202": "ターミナル", "d8c988dab4": "~/.orca/keybindings.json", - "shortcutUnavailable": "Shortcut is no longer available." + "shortcutUnavailable": "ショートカットは利用できなくなりました。" }, "SourceControlAiActionRecipeDefaults": { "2576299196": "引数", @@ -9345,7 +9345,7 @@ "copy": "コピー", "copied": "コピーされました", "recipes": "レシピ", - "recipesHelp": "agent が orca.yaml に追加したレシピがここに表示され、ワー​​クスペースを起動できるようになります。", + "recipesHelp": "orca.yaml と有効なプラグインのレシピがここに表示され、ワークスペースを起動できます。", "refresh": "一時的な VM レシピを更新する", "checking": "レシピを確認中...", "none": "レシピはまだ見つかりません。" @@ -9574,7 +9574,7 @@ "eventsSubscribe": "ワークツリーが作成または削除され、エージェントステータスが変更されたときに通知を受ける", "settingsOwn": "プラグインの独自設定を読み取りおよび変更" }, - "decisionFailed": "Could not save the permission decision. Try again." + "decisionFailed": "権限の選択を保存できませんでした。再試行してください。" }, "PluginConsentProvenance": { "official": "Official", @@ -9588,23 +9588,23 @@ "indexCommit": "Index commit" }, "PluginDevelopmentSection": { - "saveFailed": "Could not save development plugin paths.", - "pathRequired": "Enter a plugin folder path.", - "title": "Development", - "help": "Load plugins directly from folders on this computer while you develop them. Dev plugins still require permission review. Workers run on this desktop host; SSH workspace actions route through Orca, so paths here are desktop paths.", - "remove": "Remove", - "pathLabel": "Development plugin folder path", + "saveFailed": "開発用プラグインのパスを保存できませんでした。", + "pathRequired": "プラグインフォルダーのパスを入力してください。", + "title": "開発", + "help": "開発中のプラグインを、このコンピューター上のフォルダーから直接読み込みます。開発用プラグインにも権限の確認が必要です。ワーカーはこのデスクトップホストで実行され、SSH ワークスペースの操作は Orca 経由で処理されるため、ここにはデスクトップ側のパスを指定します。", + "remove": "削除", + "pathLabel": "開発用プラグインのフォルダーパス", "placeholder": "/Users/you/plugins/my-plugin or C:\\Users\\you\\plugins\\my-plugin", - "add": "Add path" + "add": "パスを追加" }, "PluginInstallDialog": { - "localRequired": "Enter the plugin folder path.", - "gitUrlRequired": "Enter a repository URL.", - "gitUrlInvalid": "Use an HTTPS or SSH Git URL. Executable Git helper protocols are not allowed.", - "gitRefRequired": "Add an explicit #ref (tag or commit) so the install is pinned — for example #v0.1.0.", - "title": "Install plugin", - "description": "Installing copies the plugin into Orca and shows its permissions for review. No plugin code runs until you enable it.", - "source": "Install source", + "localRequired": "プラグインフォルダーのパスを入力してください。", + "gitUrlRequired": "リポジトリ URL を入力してください。", + "gitUrlInvalid": "HTTPS または SSH の Git URL を使用してください。実行可能な Git ヘルパープロトコルは使用できません。", + "gitRefRequired": "インストールを固定するため、明示的な #ref(タグまたはコミット)を追加してください。例: #v0.1.0。", + "title": "プラグインをインストール", + "description": "インストールするとプラグインが Orca にコピーされ、確認する権限が表示されます。有効にするまでプラグインのコードは実行されません。", + "source": "インストール元", "localTab": "ローカルフォルダー", "gitTab": "Git URL", "localLabel": "プラグインフォルダーパス", @@ -11068,13 +11068,13 @@ } }, "PluginPanel": { - "unavailable": "This plugin panel is no longer available.", - "loading": "Loading plugin panel...", - "unresponsive": "This plugin panel stopped responding and was suspended.", - "loadFailed": "The plugin panel could not be loaded." + "unavailable": "このプラグインパネルは利用できなくなりました。", + "loading": "プラグインパネルを読み込み中...", + "unresponsive": "プラグインパネルが応答しなくなったため停止しました。", + "loadFailed": "プラグインパネルを読み込めませんでした。" }, "activityBar": { - "error": "Error" + "error": "エラー" } } }, @@ -13072,8 +13072,8 @@ } }, "pluginQuickActions": { - "description": "{{value0}} plugin command", - "keyword": "plugin command" + "description": "{{value0}} プラグインのコマンド", + "keyword": "プラグインのコマンド" } } }, @@ -13701,9 +13701,9 @@ } }, "pluginPanelBridgeHost": { - "actionsUnavailable": "Plugin actions are not available in this client.", - "messageTooLarge": "Message exceeds the size limit.", - "tooManyRequests": "Too many requests." + "actionsUnavailable": "このクライアントではプラグイン操作を利用できません。", + "messageTooLarge": "メッセージがサイズ制限を超えています。", + "tooManyRequests": "リクエストが多すぎます。" } }, "link": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 2941dbf95..92dcecb1d 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -133,7 +133,7 @@ "064bd07810": "돌아가기", "ce37cf5279": "사이드바 표시/숨기기({{value0}})", "e4b9e7dff7": "사이드바 표시/숨기기", - "pluginCommandFailed": "Could not run the plugin command." + "pluginCommandFailed": "플러그인 명령을 실행할 수 없습니다." }, "web": { "WebConnect": { @@ -761,8 +761,8 @@ "projectHostsSummary": "호스트 {{value0}}개", "linearTitle": "Linear", "linearDescription": "Give agents the skill to read and update your linked Linear tickets.", - "pluginsTitle": "Plugins", - "pluginsDescription": "Install and manage experimental Orca plugins." + "pluginsTitle": "플러그인", + "pluginsDescription": "실험적인 Orca 플러그인을 설치하고 관리합니다." }, "useAppMenuPaste": { "pasteTooLarge": "붙여넣기 내용이 너무 큽니다." @@ -1951,7 +1951,7 @@ "projectsGroupsHeader": "프로젝트 및 그룹", "projectBadge": "프로젝트", "repoGroupBadge": "리포지토리 그룹", - "pluginCommandFailed": "Could not run the plugin command." + "pluginCommandFailed": "플러그인 명령을 실행할 수 없습니다." }, "github": { "pr": { @@ -4174,7 +4174,7 @@ "ee240a39eb": "필터 편집", "automationCreated": "자동화로 생성된 워크스페이스 숨기기", "cliCreated": "Hide CLI-created", - "detachedHead": "Hide detached HEAD" + "detachedHead": "분리된 HEAD 숨기기" }, "SidebarHeader": { "92154beb7e": "새로운 워크스페이스", @@ -4239,7 +4239,7 @@ "82594419ba": "필터", "automationCreated": "자동화로 생성된 워크스페이스 숨기기", "cliCreated": "Hide CLI-created", - "detachedHead": "Hide detached HEAD" + "detachedHead": "분리된 HEAD 숨기기" }, "SidebarWorkspaceOptionsMenu": { "95c9754653": "Agent 활동 레이아웃", @@ -6795,7 +6795,7 @@ "781cb74d22": "terminal 패널에서 실행됩니다.", "cb02e00202": "터미널", "d8c988dab4": "~/.orca/keybindings.json", - "shortcutUnavailable": "Shortcut is no longer available." + "shortcutUnavailable": "단축키를 더 이상 사용할 수 없습니다." }, "SourceControlAiActionRecipeDefaults": { "2576299196": "인수", @@ -9345,7 +9345,7 @@ "copy": "복사", "copied": "복사됨", "recipes": "조리법", - "recipesHelp": "agent가 orca.yaml에 추가하는 레시피가 여기에 표시되어 워크스페이스를 시작할 준비가 됩니다.", + "recipesHelp": "orca.yaml과 활성화된 플러그인의 레시피가 여기에 표시되어 워크스페이스를 시작할 수 있습니다.", "refresh": "임시 VM 레시피 새로 고침", "checking": "레시피 확인 중...", "none": "아직 레시피를 찾을 수 없습니다." @@ -11068,13 +11068,13 @@ } }, "PluginPanel": { - "unavailable": "This plugin panel is no longer available.", - "loading": "Loading plugin panel...", - "unresponsive": "This plugin panel stopped responding and was suspended.", - "loadFailed": "The plugin panel could not be loaded." + "unavailable": "이 플러그인 패널은 더 이상 사용할 수 없습니다.", + "loading": "플러그인 패널 불러오는 중...", + "unresponsive": "플러그인 패널이 응답하지 않아 일시 중단되었습니다.", + "loadFailed": "플러그인 패널을 불러올 수 없습니다." }, "activityBar": { - "error": "Error" + "error": "오류" } } }, @@ -13072,8 +13072,8 @@ } }, "pluginQuickActions": { - "description": "{{value0}} plugin command", - "keyword": "plugin command" + "description": "{{value0}} 플러그인 명령", + "keyword": "플러그인 명령" } } }, @@ -13701,9 +13701,9 @@ } }, "pluginPanelBridgeHost": { - "actionsUnavailable": "Plugin actions are not available in this client.", - "messageTooLarge": "Message exceeds the size limit.", - "tooManyRequests": "Too many requests." + "actionsUnavailable": "이 클라이언트에서는 플러그인 작업을 사용할 수 없습니다.", + "messageTooLarge": "메시지가 크기 제한을 초과합니다.", + "tooManyRequests": "요청이 너무 많습니다." } }, "link": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index f50268ab4..f73a87631 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -133,7 +133,7 @@ "064bd07810": "返回", "ce37cf5279": "切换侧边栏 ({{value0}})", "e4b9e7dff7": "切换侧边栏", - "pluginCommandFailed": "Could not run the plugin command." + "pluginCommandFailed": "无法运行插件命令。" }, "web": { "WebConnect": { @@ -761,8 +761,8 @@ "projectHostsSummary": "{{value0}} 个主机", "linearTitle": "Linear", "linearDescription": "Give agents the skill to read and update your linked Linear tickets.", - "pluginsTitle": "Plugins", - "pluginsDescription": "Install and manage experimental Orca plugins." + "pluginsTitle": "插件", + "pluginsDescription": "安装和管理实验性 Orca 插件。" }, "useAppMenuPaste": { "pasteTooLarge": "粘贴内容过大。" @@ -1951,7 +1951,7 @@ "projectsGroupsHeader": "项目和分组", "projectBadge": "项目", "repoGroupBadge": "仓库组", - "pluginCommandFailed": "Could not run the plugin command." + "pluginCommandFailed": "无法运行插件命令。" }, "github": { "pr": { @@ -3385,8 +3385,8 @@ "resource": { "memory": { "metric": { - "workingSetDescription": "Summed working set (WS). Shared pages can appear in more than one process.", - "rssDescription": "Summed resident set size (RSS). Shared or aliased pages can appear in more than one process." + "workingSetDescription": "工作集 (WS) 的总和。共享页可能会出现在多个进程中。", + "rssDescription": "驻留集大小 (RSS) 的总和。共享页或别名映射页可能会出现在多个进程中。" } } }, @@ -4174,7 +4174,7 @@ "ee240a39eb": "编辑筛选条件", "automationCreated": "隐藏自动化创建的工作区", "cliCreated": "Hide CLI-created", - "detachedHead": "Hide detached HEAD" + "detachedHead": "隐藏分离 HEAD" }, "SidebarHeader": { "92154beb7e": "新工作区", @@ -4239,7 +4239,7 @@ "82594419ba": "筛选条件", "automationCreated": "隐藏自动化创建的工作区", "cliCreated": "Hide CLI-created", - "detachedHead": "Hide detached HEAD" + "detachedHead": "隐藏分离 HEAD" }, "SidebarWorkspaceOptionsMenu": { "95c9754653": "智能体活动布局", @@ -6795,7 +6795,7 @@ "781cb74d22": "从终端窗格运行。", "cb02e00202": "终端", "d8c988dab4": "~/.orca/keybindings.json", - "shortcutUnavailable": "Shortcut is no longer available." + "shortcutUnavailable": "此快捷键已不再可用。" }, "SourceControlAiActionRecipeDefaults": { "2576299196": "参数", @@ -9345,7 +9345,7 @@ "copy": "复制", "copied": "已复制", "recipes": "环境模板", - "recipesHelp": "智能体添加到 orca.yaml 的环境模板会显示在这里,可用于启动工作区。", + "recipesHelp": "orca.yaml 和已启用插件中的配方会显示在这里,可用于启动工作区。", "refresh": "刷新临时 VM 环境模板", "checking": "正在检查环境模板...", "none": "还没有找到环境模板。" @@ -9588,50 +9588,50 @@ "indexCommit": "Index commit" }, "PluginDevelopmentSection": { - "saveFailed": "Could not save development plugin paths.", - "pathRequired": "Enter a plugin folder path.", - "title": "Development", - "help": "Load plugins directly from folders on this computer while you develop them. Dev plugins still require permission review. Workers run on this desktop host; SSH workspace actions route through Orca, so paths here are desktop paths.", - "remove": "Remove", - "pathLabel": "Development plugin folder path", + "saveFailed": "无法保存开发插件路径。", + "pathRequired": "请输入插件文件夹路径。", + "title": "开发", + "help": "开发插件时,可直接从此计算机上的文件夹加载。开发插件仍需检查权限。工作进程在此桌面主机上运行;SSH 工作区操作通过 Orca 路由,因此这里填写桌面端路径。", + "remove": "移除", + "pathLabel": "开发插件文件夹路径", "placeholder": "/Users/you/plugins/my-plugin or C:\\Users\\you\\plugins\\my-plugin", - "add": "Add path" + "add": "添加路径" }, "PluginInstallDialog": { - "localRequired": "Enter the plugin folder path.", - "gitUrlRequired": "Enter a repository URL.", - "gitUrlInvalid": "Use an HTTPS or SSH Git URL. Executable Git helper protocols are not allowed.", - "gitRefRequired": "Add an explicit #ref (tag or commit) so the install is pinned — for example #v0.1.0.", - "title": "Install plugin", - "description": "Installing copies the plugin into Orca and shows its permissions for review. No plugin code runs until you enable it.", - "source": "Install source", + "localRequired": "请输入插件文件夹路径。", + "gitUrlRequired": "请输入仓库 URL。", + "gitUrlInvalid": "请使用 HTTPS 或 SSH Git URL。不允许使用可执行的 Git 辅助协议。", + "gitRefRequired": "请添加明确的 #ref(标签或提交)以固定安装版本,例如 #v0.1.0。", + "title": "安装插件", + "description": "安装会将插件复制到 Orca,并显示其权限供你检查。启用插件之前,不会运行任何插件代码。", + "source": "安装来源", "localTab": "本地文件夹", "gitTab": "Git URL", "localLabel": "插件文件夹路径", "localPlaceholder": "/Users/you/plugins/my-plugin or C:\\Users\\you\\plugins\\my-plugin", - "localHelp": "Full path to a folder containing orca-plugin.json on this computer. The path is used exactly as entered.", - "gitLabel": "Repository URL with #ref", + "localHelp": "此计算机上包含 orca-plugin.json 的文件夹完整路径。系统会完全按输入内容使用该路径。", + "gitLabel": "带 #ref 的仓库 URL", "gitPlaceholder": "https://git.example/acme/orca-notes#v0.1.0", - "gitHelp": "Append an explicit #ref — a tag or commit — so the install is pinned. Works with GitHub, GitLab, and any git host.", - "cancel": "Cancel", - "installing": "Installing…", - "install": "Install", - "installFailed": "Plugin installation failed. Check the source and try again." + "gitHelp": "附加明确的 #ref(标签或提交)以固定安装版本。适用于 GitHub、GitLab 和任何 Git 主机。", + "cancel": "取消", + "installing": "正在安装…", + "install": "安装", + "installFailed": "插件安装失败。请检查来源后重试。" }, "PluginKeybindingConsentPreview": { - "heading": "Keyboard shortcuts", - "worktree": "Runs only while a workspace is active.", - "global": "Runs in the app without requiring an active workspace.", - "shadows": "Replaces: {{value0}}" + "heading": "键盘快捷键", + "worktree": "仅在有活动工作区时运行。", + "global": "无需活动工作区即可在应用中运行。", + "shadows": "替代:{{value0}}" }, "PluginMarketplaceBrowser": { - "loadFailed": "Could not load marketplace plugins.", - "refreshFailed": "Could not refresh marketplaces. Cached listings remain available.", - "previewFailed": "Could not prepare this plugin for review. Refresh the marketplace and try again.", - "installFailed": "Could not install this plugin. The reviewed source may have changed.", - "manageSources": "Manage sources", - "refreshing": "Refreshing…", - "refresh": "Refresh", + "loadFailed": "无法加载插件市场中的插件。", + "refreshFailed": "无法刷新插件市场。仍可使用缓存的列表。", + "previewFailed": "无法准备此插件以供审核。请刷新插件市场后重试。", + "installFailed": "无法安装此插件。审核过的源可能已更改。", + "manageSources": "管理源", + "refreshing": "正在刷新…", + "refresh": "刷新", "noInstalledTitle": "没有已安装的插件", "noInstalled": "您安装的插件将显示在此处。", "loading": "正在加载市场插件…", @@ -9646,11 +9646,11 @@ "empty": "已配置的市场中没有任何插件。" }, "PluginMarketplaceListingRow": { - "official": "Official", - "installed": "Installed", + "official": "官方", + "installed": "已安装", "noDescription": "No description provided.", - "blocked": "Blocked by Orca's safety list: {{value0}}", - "blockedAction": "Blocked", + "blocked": "已被 Orca 安全列表阻止:{{value0}}", + "blockedAction": "已阻止", "checkUpdate": "检查更新", "install": "Install" }, @@ -11068,13 +11068,13 @@ } }, "PluginPanel": { - "unavailable": "This plugin panel is no longer available.", - "loading": "Loading plugin panel...", - "unresponsive": "This plugin panel stopped responding and was suspended.", - "loadFailed": "The plugin panel could not be loaded." + "unavailable": "此插件面板已不可用。", + "loading": "正在加载插件面板...", + "unresponsive": "插件面板停止响应,已被暂停。", + "loadFailed": "无法加载插件面板。" }, "activityBar": { - "error": "Error" + "error": "错误" } } }, @@ -13072,8 +13072,8 @@ } }, "pluginQuickActions": { - "description": "{{value0}} plugin command", - "keyword": "plugin command" + "description": "{{value0}} 插件命令", + "keyword": "插件命令" } } }, @@ -13701,9 +13701,9 @@ } }, "pluginPanelBridgeHost": { - "actionsUnavailable": "Plugin actions are not available in this client.", - "messageTooLarge": "Message exceeds the size limit.", - "tooManyRequests": "Too many requests." + "actionsUnavailable": "此客户端不支持插件操作。", + "messageTooLarge": "消息超过大小限制。", + "tooManyRequests": "请求过多。" } }, "link": { diff --git a/src/renderer/src/lib/launch-agent-background-session-remote.test.ts b/src/renderer/src/lib/launch-agent-background-session-remote.test.ts index 6ae4071ba..6cf8e9b71 100644 --- a/src/renderer/src/lib/launch-agent-background-session-remote.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session-remote.test.ts @@ -230,6 +230,34 @@ describe('launchAgentBackgroundSession remote runtime and SSH startup delivery', worktreeId: 'wt-1', prompt: 'run the automation' }) + // Why the longer wait: a shell that has emitted nothing is still booting, + // so the fallback holds off rather than pasting before readline arms. + vi.advanceTimersByTime(1_550) + expect(mockWrite).not.toHaveBeenCalled() + vi.advanceTimersByTime(15_050) + + expect(mockWrite).toHaveBeenCalledWith( + 'pty-1', + "codex '--dangerously-bypass-approvals-and-sandbox' 'run the automation'\r" + ) + } finally { + vi.useRealTimers() + } + }) + + it('keeps the short fallback for an SSH shell that talks but cannot emit the marker', async () => { + vi.useFakeTimers() + try { + state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await launchAgentBackgroundSession({ + agent: 'codex', + worktreeId: 'wt-1', + prompt: 'run the automation' + }) + const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void + dataSidecar('user@remote repo % ') vi.advanceTimersByTime(1_550) expect(mockWrite).toHaveBeenCalledWith( diff --git a/src/renderer/src/lib/ssh-background-startup-delivery.test.ts b/src/renderer/src/lib/ssh-background-startup-delivery.test.ts new file mode 100644 index 000000000..ee5248cf2 --- /dev/null +++ b/src/renderer/src/lib/ssh-background-startup-delivery.test.ts @@ -0,0 +1,92 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createSshBackgroundStartupDelivery } from './ssh-background-startup-delivery' + +const SHELL_READY = '\x1b]777;orca-shell-ready\x07' + +function createDelivery(): { + delivery: ReturnType + write: ReturnType +} { + const write = vi.fn() + return { + delivery: createSshBackgroundStartupDelivery({ + command: 'codex "run the automation"', + waitForShellReady: true, + write + }), + write + } +} + +beforeEach(() => { + vi.useFakeTimers() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('createSshBackgroundStartupDelivery shell-ready fallback', () => { + it('does not force delivery at the short deadline while the remote shell is still silent', () => { + const { delivery, write } = createDelivery() + + // Armed at spawn, before any byte arrives (launch-agent-background-session). + delivery.armFallback('pty-1') + // A cold host sourcing /etc/profile plus nvm/pyenv has not prompted yet. + vi.advanceTimersByTime(3_000) + + expect(write).not.toHaveBeenCalled() + + // The prompt finally lands with the marker; delivery follows normally. + delivery.handleData(`${SHELL_READY}user@remote repo % `) + vi.advanceTimersByTime(50) + + expect(write).toHaveBeenCalledTimes(1) + expect(write.mock.calls[0]?.[1]).toContain('codex "run the automation"') + }) + + it('still delivers eventually when a shell can never emit the marker, and not before 15s', () => { + const { delivery, write } = createDelivery() + + delivery.armFallback('pty-1') + // Pin the boundary: asserting only eventual delivery would let the budget + // silently shrink back toward the short deadline this fix moved off. + vi.advanceTimersByTime(14_999) + + expect(write).not.toHaveBeenCalled() + + vi.advanceTimersByTime(1) + vi.advanceTimersByTime(50) + + expect(write).toHaveBeenCalledTimes(1) + }) + + it('keeps the short post-output deadline once the shell has started talking', () => { + const { delivery, write } = createDelivery() + + delivery.armFallback('pty-1') + // Output without the marker: the shell is alive but cannot emit it. + delivery.handleData('user@remote repo % ') + vi.advanceTimersByTime(1_550) + vi.advanceTimersByTime(50) + + expect(write).toHaveBeenCalledTimes(1) + }) + + // The long budget exists to protect the bracketed paste from landing before + // readline arms it. 'fast' delivery waits for no marker and pastes nothing + // prompt-sensitive, so stretching it there is latency with nothing bought. + it('keeps the short deadline for fast delivery, which waits for no marker', () => { + const write = vi.fn() + const delivery = createSshBackgroundStartupDelivery({ + command: 'codex "run the automation"', + waitForShellReady: false, + write + }) + + delivery.armFallback('pty-1') + vi.advanceTimersByTime(1_550) + + expect(write).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/lib/ssh-background-startup-delivery.ts b/src/renderer/src/lib/ssh-background-startup-delivery.ts index f117ca447..1c11254d6 100644 --- a/src/renderer/src/lib/ssh-background-startup-delivery.ts +++ b/src/renderer/src/lib/ssh-background-startup-delivery.ts @@ -5,6 +5,12 @@ import { import { buildStartupCommandSubmission } from '../../../shared/startup-command-submission' const SSH_SHELL_READY_STARTUP_FALLBACK_MS = 1500 +// Why: a remote shell that has not emitted a single byte is still booting — +// /etc/profile plus nvm/conda/pyenv over a cold link routinely needs more than +// the post-output deadline. Force-delivering there writes the bracketed-paste +// command before readline arms it, so a silent-since-spawn shell gets a longer +// budget; the short deadline applies once output proves the shell is talking. +const SSH_SHELL_READY_NO_OUTPUT_FALLBACK_MS = 15_000 type SshBackgroundStartupDeliveryOptions = { command: string | null @@ -28,6 +34,7 @@ export function createSshBackgroundStartupDelivery( const markerScan = options.waitForShellReady ? createShellReadyMarkerScanState() : null let injectTimer: ReturnType | null = null let fallbackTimer: ReturnType | null = null + let sawOutput = false const clearInjectTimer = (): void => { if (injectTimer !== null) { @@ -57,11 +64,19 @@ export function createSshBackgroundStartupDelivery( if (!pendingCommand || fallbackTimer !== null) { return } - fallbackTimer = setTimeout(() => { - fallbackTimer = null - startupShellReady = true - schedule(ptyId) - }, SSH_SHELL_READY_STARTUP_FALLBACK_MS) + // The long budget only buys time for the shell-ready marker; the fast path + // pastes nothing prompt-sensitive, so delaying it there is pure latency. + const waitingForSilentShell = options.waitForShellReady && !sawOutput + fallbackTimer = setTimeout( + () => { + fallbackTimer = null + startupShellReady = true + schedule(ptyId) + }, + waitingForSilentShell + ? SSH_SHELL_READY_NO_OUTPUT_FALLBACK_MS + : SSH_SHELL_READY_STARTUP_FALLBACK_MS + ) } const schedule = (ptyId: string): void => { @@ -100,6 +115,15 @@ export function createSshBackgroundStartupDelivery( return { handleData(data) { + // First byte proves the shell is talking, so the spawn-time long budget + // collapses back to the original post-output deadline. + if (!sawOutput && data.length > 0) { + sawOutput = true + if (fallbackTimer !== null && !startupShellReady && lastPtyId) { + clearFallbackTimer() + armFallback(lastPtyId) + } + } if (!markerScan) { return data } diff --git a/src/shared/worktree-card-properties.ts b/src/shared/worktree-card-properties.ts index 372f444a6..2067e8917 100644 --- a/src/shared/worktree-card-properties.ts +++ b/src/shared/worktree-card-properties.ts @@ -39,7 +39,9 @@ const LEGACY_NORMALIZED_COMPACT_WORKTREE_CARD_PROPERTIES_WITH_AUTOMATION: Worktr 'automation' ] -const WORKTREE_CARD_PROPERTY_ORDER: WorktreeCardProperty[] = [ +/** Every card property, in canonical render order. Client schemas derive their + * accepted value domain from this so a new property cannot drift out of them. */ +export const WORKTREE_CARD_PROPERTIES = [ 'status', 'unread', 'ci', @@ -52,14 +54,14 @@ const WORKTREE_CARD_PROPERTY_ORDER: WorktreeCardProperty[] = [ 'comment', 'ports', 'inline-agents' -] +] as const satisfies readonly WorktreeCardProperty[] export function normalizeWorktreeCardProperties( properties: readonly unknown[] | null | undefined ): WorktreeCardProperty[] { const normalized: WorktreeCardProperty[] = [...FIXED_WORKTREE_CARD_PROPERTIES] const source = properties ?? DEFAULT_WORKTREE_CARD_PROPERTIES - for (const property of WORKTREE_CARD_PROPERTY_ORDER) { + for (const property of WORKTREE_CARD_PROPERTIES) { if (source.includes(property) && !normalized.includes(property)) { normalized.push(property) }