fix(ui): preference sync, picker arming, zoom, chat status, and reverted locales (#11241)
* fix(ui): preference sync, picker arming, zoom, chat status, and reverted locales 7.1 ui.set rejected whole preference payloads on enum drift. The new AssertNoMissingKeys guard is key-only, so it could not see that LegacyWorktreeCardProperty omitted 'cli' (in DEFAULT_WORKTREE_CARD_PROPERTIES) or that rightSidebarTab omitted 'workspaces'/'pr-checks' and every plugin tab. UiUpdate is .strict(), so one bad value failed the entire batch and silently dropped sidebarWidth/groupBy/sortBy/filterRepoIds riding the same debounced write. Both enums now derive from the shared unions, AssertNoMissingValues catches value drift by name, and UiUpdate drops an unknown value instead of rejecting the batch around it. Unknown KEYS still reject. 7.2 The SSH shell-ready fallback moved from first-output to spawn, so a remote shell needing >1.5s to prompt got the bracketed-paste startup command before readline armed it, with no recovery afterward. The short deadline now applies only once output proves the shell is talking; a silent-since-spawn shell gets a longer budget and still delivers eventually. 7.3 The project picker armed in rank order but rendered in section order, so with a folder group present the BOTTOM row was armed on open and Enter created the workspace in the wrong place. Row keys now derive from the same sections that render. The folders bucket also gains the recent-exclusion guard the projects bucket has; that duplicate was unreachable, so this is symmetry, not a live bug fix. 7.4 setBrowserPageZoomLevel now compares before writing, so a pane reasserting a level the host already holds no longer emits a redundant host-wide HostZoomMap write. The user-applied level also moved to a module-level map keyed by page id: the guest webview outlives its React pane, so the pane-local ref re-seeded from the shared Settings default on every remount and let a later default retroactively hijack an already-zoomed tab. See PR notes on the part of this finding that could not be fixed as prescribed. 7.5 A non-null sessionId short-circuited the live-work escape hatch, forcing 'loading' over hook 'working' and rendering an idle pane mid-turn: Send instead of Stop, no typing indicator, no streaming preview. Status stays 'working'; the empty-transcript loading SURFACE moves to selectNativeChatViewState, which keeps 7.6 #10770 merged from a base predating #8549, reverting 182-187 translated strings per locale to English (es 182, ja/ko/zh 187) plus en.json's recipesHelp. Restored by script, only where the English source is unchanged between the two shas, so later legitimate edits are preserved: 0 keys added or removed, every value sourced from97e4776dfe, and the four other English-source changes since 7.7 Match highlighting indexed by UTF-16 code unit but rendered by code point, so an emoji-named folder showed marks one glyph late. Cosmetic. Co-authored-by: Orca <help@stably.ai> * fix(ssh): keep fast startup delivery on the short fallback deadline The 15s no-output budget added for the shell-ready fallback was applied to every SSH launch, including 'fast' delivery. Fast delivery waits for no marker and pastes nothing prompt-sensitive, so it gained a 10x startup delay for nothing. Co-authored-by: Orca <help@stably.ai> * fix(rpc): generalize the ui.set value-parity guard to every shared key Naming worktreeCardProperties and rightSidebarTab left the next field to drift exactly as unguarded: dropping 'pr-status' from groupBy typechecked clean. Check the value domain of every shared key instead, against z.input (what a client may send) rather than z.infer (post-transform). Also move the pure mergeNativeChatLiveSession suite beside the module it covers; the hook's test file owns an IO harness and was at the max-lines cap. Co-authored-by: Orca <help@stably.ai> * fix(i18n): re-apply only the locale strings still reverted at HEAD #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 with nothing to signal the loss. Since that finding was written, #11205 and other upstream translation passes independently re-covered most of ja/ko/zh. Replaying #8549's catalogs wholesale would now overwrite that newer work, so this re-applies a key ONLY where all of the following hold at origin/main: it was translated at97e4776dfe, #10770 reverted it, its English source is unchanged since, and no upstream commit has touched it since the revert. es 182 ja 32 ko 17 zh 55 Everything else is left to upstream. Verified zero upstream translations reverted: every changed key still matches its #10770 value at main. Keys upstream deleted are not resurrected, and keys whose English source was edited since are skipped as legitimate source changes rather than reverts (this is what keeps zh CPU on #11205's deliberate "CPU" over #8549's "中央处理器"). Key count and order are unchanged in all five catalogs. en.json's own recipesHelp was reverted by the same stale base and no upstream commit has touched it since, so it is restored to match the live source at EphemeralVmsPane.tsx:252. * fix(rpc): keep null in the ui.set value-parity guard NonNullable stripped null as well as undefined, so dropping .nullable() from a `| null` field passed the guard while still rejecting the batch at runtime -- the exact drift class the guard exists to catch. Proven: making visibleWorkspaceHostIds non-nullable typechecked clean before, now errors by name. Also pins the 15s silent-shell budget so it cannot silently shrink back toward the short deadline. Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
a721125d06
commit
1d7e7656e3
|
|
@ -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<TaskProvider>(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:<publisher>.<id>/<panel>` keys, so the
|
||||
// schema validates their shape rather than enumerating them.
|
||||
const RightSidebarTabParam = z.custom<StaticRightSidebarTab | `plugin:${string}`>(
|
||||
(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<PersistedUIState, MainOwnedUIState>,
|
||||
z.infer<typeof UiUpdate>
|
||||
> = 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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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[] = [
|
||||
|
|
|
|||
|
|
@ -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<PersistedUIState, MainOwnedUIState>,
|
||||
z.infer<UiUpdateFieldsSchema>
|
||||
> = 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<PersistedUIState, MainOwnedUIState>,
|
||||
z.input<UiUpdateFieldsSchema>
|
||||
> = true
|
||||
void _uiUpdateValueParity
|
||||
|
|
@ -12,3 +12,29 @@ export type AssertNoMissingKeys<TType, TSchema extends Record<string, unknown>>
|
|||
Exclude<keyof TType, keyof TSchema> extends never
|
||||
? true
|
||||
: { missingFromSchema: Exclude<keyof TType, keyof TSchema> }
|
||||
|
||||
/**
|
||||
* 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<TType, TSchema> =
|
||||
MissingValueKeys<TType, TSchema> extends never
|
||||
? true
|
||||
: { valueDomainTooNarrowFor: MissingValueKeys<TType, TSchema> }
|
||||
|
||||
// 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<TType, TSchema> = {
|
||||
[K in Extract<keyof TType, keyof TSchema>]: Exclude<TType[K], undefined> extends Exclude<
|
||||
TSchema[K],
|
||||
undefined
|
||||
>
|
||||
? never
|
||||
: K
|
||||
}[Extract<keyof TType, keyof TSchema>]
|
||||
|
|
|
|||
|
|
@ -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<TShape extends z.ZodRawShape>(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<TValue extends Record<string, unknown>>(value: TValue): TValue {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).filter(([, entry]) => entry !== undefined)
|
||||
) as TValue
|
||||
}
|
||||
|
|
@ -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<string | null>(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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<string, number>()
|
||||
|
||||
/** 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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<ProjectCombobox options={sectionedOptions} value={null} onValueChange={vi.fn()} />
|
||||
)
|
||||
})
|
||||
openList()
|
||||
|
||||
const rows = Array.from(container.querySelectorAll<HTMLElement>('[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(
|
||||
<ProjectCombobox options={sectionedOptions} value={null} onValueChange={onValueChange} />
|
||||
)
|
||||
})
|
||||
act(() => {
|
||||
container
|
||||
.querySelector<HTMLElement>('[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(
|
||||
<ProjectCombobox options={sectionedOptions} value={null} onValueChange={vi.fn()} />
|
||||
)
|
||||
})
|
||||
openList()
|
||||
act(() => {
|
||||
field().dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
|
||||
})
|
||||
|
||||
const rows = Array.from(container.querySelectorAll<HTMLElement>('[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(
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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(<MatchedText text="orca" hits={[0, 1, 2, 3]} />)
|
||||
})
|
||||
|
||||
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(<MatchedText text={option.displayName} hits={match!.nameHits} />)
|
||||
})
|
||||
|
||||
expect(markedText()).toBe('orca')
|
||||
})
|
||||
|
||||
it('leaves text unmarked when there are no hits', () => {
|
||||
act(() => {
|
||||
root.render(<MatchedText text="🚀 orca" hits={[]} />)
|
||||
})
|
||||
|
||||
expect(container.querySelectorAll('mark')).toHaveLength(0)
|
||||
expect(container.textContent).toBe('🚀 orca')
|
||||
})
|
||||
})
|
||||
|
|
@ -33,10 +33,15 @@ export function MatchedText({
|
|||
if (marks.size === 0) {
|
||||
return <span className={cn('min-w-0 truncate', className)}>{text}</span>
|
||||
}
|
||||
// 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 (
|
||||
<span className={cn('min-w-0 truncate', className)}>
|
||||
{[...text].map((char, index) =>
|
||||
marks.has(index) ? (
|
||||
{[...text].map((char) => {
|
||||
const index = codeUnit
|
||||
codeUnit += char.length
|
||||
return marks.has(index) ? (
|
||||
<mark
|
||||
key={`${index}-${char}`}
|
||||
className="bg-transparent p-0 font-semibold text-foreground underline decoration-ring underline-offset-2"
|
||||
|
|
@ -46,7 +51,7 @@ export function MatchedText({
|
|||
) : (
|
||||
char
|
||||
)
|
||||
)}
|
||||
})}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<unknown>(
|
||||
(node, part) =>
|
||||
node && typeof node === 'object' ? (node as Record<string, unknown>)[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.'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<typeof createSshBackgroundStartupDelivery>
|
||||
write: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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<typeof setTimeout> | null = null
|
||||
let fallbackTimer: ReturnType<typeof setTimeout> | 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue