mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 17:47:40 +00:00
fix(terminals): automation runs, CLI tabs and folder-workspace browser tabs follow the owner (#27246)
* fix(terminals): automation runs, CLI tabs and folder-workspace browser tabs follow the owner - An automation run on a paired server opens its mirrored pane, or asks the server to focus it when the mirror has not loaded it yet, instead of reporting "Run terminal is unavailable." while the server still runs it. - This desktop's CLI browser tab create/close/profile requests are refused only for a workspace a paired server owns, not whenever a paired server is the Active Server. - browser.tabCreate resolves folder workspaces for server-hosted pages, as it already did for client-hosted ones. * fix(terminals): refuse ambiguous browser owners and name a failed run reveal --------- Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* A server-hosted browser tab for a folder workspace (#21353). Only the client-hosted branch used
|
||||
* the folder-aware resolver, so `id:folder:…` failed with selector_not_found on the server path.
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RuntimeBrowserCommandHost } from './orca-runtime-browser'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: { on: vi.fn(), removeListener: vi.fn() },
|
||||
webContents: { fromId: vi.fn() }
|
||||
}))
|
||||
|
||||
vi.mock('../ipc/browser-tab-registration-wait', () => ({
|
||||
waitForTabRegistration: vi.fn(),
|
||||
waitForWorktreeTabRegistration: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../browser/browser-session-registry', () => ({
|
||||
browserSessionRegistry: {
|
||||
getDefaultProfile: () => ({ id: 'default', partition: 'persist:orca-browser' }),
|
||||
getProfile: () => ({ id: 'default', partition: 'persist:orca-browser' }),
|
||||
resolveKnownPartition: () => 'persist:orca-browser'
|
||||
}
|
||||
}))
|
||||
|
||||
const FOLDER = 'folder:folder-1'
|
||||
|
||||
function createHost(overrides: Record<string, unknown>): RuntimeBrowserCommandHost {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: test host implements only the members browserTabCreate reads.
|
||||
return {
|
||||
resolveWorktreeSelector: vi.fn(async () => {
|
||||
throw new Error('selector_not_found')
|
||||
}),
|
||||
resolveBrowserWorkspace: vi.fn(async () => ({ id: FOLDER })),
|
||||
getAgentBrowserBridge: () => null,
|
||||
getAvailableAuthoritativeWindow: vi.fn(() => null),
|
||||
...overrides
|
||||
} as unknown as RuntimeBrowserCommandHost
|
||||
}
|
||||
|
||||
describe('browser.tabCreate on a folder workspace', () => {
|
||||
it('creates a server-hosted page for the folder workspace', async () => {
|
||||
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
|
||||
const createTab = vi.fn(async () => ({ browserPageId: 'page-folder' }))
|
||||
const host = createHost({
|
||||
getOffscreenBrowserBackend: vi.fn(() => ({ createTab }))
|
||||
})
|
||||
|
||||
await expect(
|
||||
new RuntimeBrowserCommands(host).browserTabCreate(
|
||||
{ worktree: `id:${FOLDER}`, url: 'about:blank' },
|
||||
{ pairedDeviceId: 'device-a', clientKind: 'runtime' }
|
||||
)
|
||||
).resolves.toEqual({ browserPageId: 'page-folder' })
|
||||
|
||||
expect(host.resolveBrowserWorkspace).toHaveBeenCalledWith(`id:${FOLDER}`)
|
||||
expect(createTab).toHaveBeenCalledWith(expect.objectContaining({ worktreeId: FOLDER }))
|
||||
})
|
||||
})
|
||||
@@ -39,10 +39,9 @@ export class RuntimeBrowserCommandsWithBrowserTabCreate extends RuntimeBrowserCo
|
||||
navigation: params.navigation,
|
||||
clientKind: caller?.clientKind
|
||||
})
|
||||
// Why: every placement must accept folder workspaces, not only client-hosted pages (#21353).
|
||||
const worktree = params.worktree
|
||||
? params.placement?.kind === 'client'
|
||||
? await this.host.resolveBrowserWorkspace(params.worktree)
|
||||
: await this.host.resolveWorktreeSelector(params.worktree)
|
||||
? await this.host.resolveBrowserWorkspace(params.worktree)
|
||||
: undefined
|
||||
const worktreeId = worktree?.id
|
||||
const sessionPartition = browserSessionRegistry.resolveKnownPartition(params.profileId)
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { AutomationRun } from '../../../../shared/automations-types'
|
||||
import {
|
||||
automationRunMatchesPaneKey,
|
||||
buildAutomationRunOpenLayout,
|
||||
canOpenAutomationRunOpenTarget,
|
||||
resolveAutomationRunOpenTarget
|
||||
} from './automation-run-open-target'
|
||||
|
||||
@@ -17,6 +16,8 @@ const runLeafLayout = {
|
||||
expandedLeafId: null
|
||||
}
|
||||
const livePtyIds = ['pty-run']
|
||||
const canOpen = (args: Parameters<typeof resolveAutomationRunOpenTarget>[0]): boolean =>
|
||||
resolveAutomationRunOpenTarget(args) !== null
|
||||
|
||||
function run(overrides: Partial<AutomationRun> = {}): AutomationRun {
|
||||
return {
|
||||
@@ -55,7 +56,7 @@ describe('automation run open target', () => {
|
||||
|
||||
it('requires exact pane identity before treating a run terminal as openable', () => {
|
||||
expect(
|
||||
canOpenAutomationRunOpenTarget({
|
||||
canOpen({
|
||||
run: run({ terminalPaneKey: null, terminalPtyId: null }),
|
||||
terminalTabExists: true,
|
||||
currentLayout: runLeafLayout,
|
||||
@@ -63,7 +64,7 @@ describe('automation run open target', () => {
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
canOpenAutomationRunOpenTarget({
|
||||
canOpen({
|
||||
run: run({ terminalPaneKey: `other-tab:${leafId}` }),
|
||||
terminalTabExists: true,
|
||||
currentLayout: runLeafLayout,
|
||||
@@ -71,7 +72,7 @@ describe('automation run open target', () => {
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
canOpenAutomationRunOpenTarget({
|
||||
canOpen({
|
||||
run: run(),
|
||||
terminalTabExists: true,
|
||||
currentLayout: runLeafLayout,
|
||||
@@ -82,7 +83,7 @@ describe('automation run open target', () => {
|
||||
|
||||
it('requires the run PTY to be live for View run', () => {
|
||||
expect(
|
||||
canOpenAutomationRunOpenTarget({
|
||||
canOpen({
|
||||
run: run(),
|
||||
terminalTabExists: true,
|
||||
currentLayout: runLeafLayout,
|
||||
@@ -90,7 +91,7 @@ describe('automation run open target', () => {
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
canOpenAutomationRunOpenTarget({
|
||||
canOpen({
|
||||
run: run(),
|
||||
terminalTabExists: true,
|
||||
currentLayout: runLeafLayout,
|
||||
@@ -115,7 +116,7 @@ describe('automation run open target', () => {
|
||||
|
||||
it('rejects a run without an exact PTY identity', () => {
|
||||
expect(
|
||||
canOpenAutomationRunOpenTarget({
|
||||
canOpen({
|
||||
run: run({ terminalPtyId: null }),
|
||||
terminalTabExists: true,
|
||||
currentLayout: runLeafLayout,
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import type { AutomationRun } from '../../../../shared/automations-types'
|
||||
import { parsePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import { makePaneKey, parsePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import { parseRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id'
|
||||
import {
|
||||
isWebTerminalSurfaceTabId,
|
||||
toWebTerminalSurfaceTabId
|
||||
} from '../../../../shared/terminal-surface-id'
|
||||
import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types'
|
||||
import { terminalLayoutContainsLeaf } from '../../../../shared/workspace-session-pane-ownership'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { resolveOwner } from '@/lib/resolve-owner'
|
||||
import type { WorktreeOperationRouteState } from '@/lib/worktree-operation-route'
|
||||
|
||||
export type AutomationRunPaneTarget = {
|
||||
tabId: string
|
||||
@@ -10,9 +18,7 @@ export type AutomationRunPaneTarget = {
|
||||
ptyId: string
|
||||
}
|
||||
|
||||
export function getAutomationRunOpenTabId(
|
||||
run: Pick<AutomationRun, 'terminalPaneKey'>
|
||||
): string | null {
|
||||
function getAutomationRunOpenTabId(run: Pick<AutomationRun, 'terminalPaneKey'>): string | null {
|
||||
return parsePaneKey(run.terminalPaneKey ?? '')?.tabId ?? null
|
||||
}
|
||||
|
||||
@@ -56,13 +62,56 @@ export function resolveAutomationRunOpenTarget({
|
||||
}
|
||||
}
|
||||
|
||||
export function canOpenAutomationRunOpenTarget(args: {
|
||||
run: AutomationRun
|
||||
terminalTabExists: boolean
|
||||
currentLayout: TerminalLayoutSnapshot | null | undefined
|
||||
livePtyIds: readonly string[]
|
||||
}): boolean {
|
||||
return resolveAutomationRunOpenTarget(args) !== null
|
||||
export type AutomationRunTerminalState = {
|
||||
hasTerminalTab: (tabId: string) => boolean
|
||||
terminalLayoutsByTabId: Readonly<Record<string, TerminalLayoutSnapshot | undefined>>
|
||||
ptyIdsByTabId: Readonly<Record<string, readonly string[] | undefined>>
|
||||
}
|
||||
|
||||
/**
|
||||
* The run's pane in this client. A run on a paired server names the server's tab, pane and PTY;
|
||||
* this client shows that pane as a mirrored tab whose PTY id is the server's terminal handle, so
|
||||
* the server's PTY id cannot be compared and the mirrored pane must carry a PTY from that server.
|
||||
*/
|
||||
export function resolveAutomationRunTerminalTarget(
|
||||
run: AutomationRun,
|
||||
terminals: AutomationRunTerminalState,
|
||||
ownerEnvironmentId: string | null
|
||||
): AutomationRunPaneTarget | null {
|
||||
const tabId = getAutomationRunOpenTabId(run)
|
||||
if (!tabId) {
|
||||
return null
|
||||
}
|
||||
const local = resolveAutomationRunOpenTarget({
|
||||
run,
|
||||
terminalTabExists: terminals.hasTerminalTab(tabId),
|
||||
currentLayout: terminals.terminalLayoutsByTabId[tabId],
|
||||
livePtyIds: terminals.ptyIdsByTabId[tabId] ?? []
|
||||
})
|
||||
const parsed = parsePaneKey(run.terminalPaneKey ?? '')
|
||||
if (local || !ownerEnvironmentId || !parsed || isWebTerminalSurfaceTabId(tabId)) {
|
||||
return local
|
||||
}
|
||||
const mirroredTabId = toWebTerminalSurfaceTabId(tabId)
|
||||
const layout = terminals.terminalLayoutsByTabId[mirroredTabId]
|
||||
const mirroredPtyId = layout?.ptyIdsByLeafId?.[parsed.leafId]
|
||||
if (
|
||||
!run.terminalPtyId ||
|
||||
!terminals.hasTerminalTab(mirroredTabId) ||
|
||||
!layout?.root ||
|
||||
!mirroredPtyId ||
|
||||
!terminalLayoutContainsLeaf(layout.root, parsed.leafId) ||
|
||||
parseRemoteRuntimePtyId(mirroredPtyId)?.environmentId !== ownerEnvironmentId ||
|
||||
!(terminals.ptyIdsByTabId[mirroredTabId] ?? []).includes(mirroredPtyId)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
tabId: mirroredTabId,
|
||||
paneKey: makePaneKey(mirroredTabId, parsed.leafId),
|
||||
leafId: parsed.leafId,
|
||||
ptyId: mirroredPtyId
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAutomationRunOpenLayout({
|
||||
@@ -82,3 +131,18 @@ export function buildAutomationRunOpenLayout({
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The paired server that owns the run's workspace, or null when this client runs it itself. */
|
||||
export function getAutomationRunOwnerEnvironmentId(
|
||||
state: WorktreeOperationRouteState,
|
||||
workspaceId: string | null | undefined,
|
||||
hostId?: ExecutionHostId
|
||||
): string | null {
|
||||
if (!workspaceId) {
|
||||
return null
|
||||
}
|
||||
const owner = resolveOwner(state, { workspaceId, ...(hostId ? { hostId } : {}) })
|
||||
return owner.kind === 'resolved' && owner.owner.endpoint.kind === 'environment'
|
||||
? owner.owner.endpoint.environmentId
|
||||
: null
|
||||
}
|
||||
|
||||
@@ -51,11 +51,14 @@ export function canRerunAutomationRun({
|
||||
export function getAutomationRunViewState({
|
||||
run,
|
||||
workspaceExists,
|
||||
terminalTargetExists
|
||||
terminalTargetExists,
|
||||
terminalOnPairedServer = false
|
||||
}: {
|
||||
run: AutomationRun
|
||||
workspaceExists: boolean
|
||||
terminalTargetExists: boolean
|
||||
/** The workspace's paired server holds the terminal; only it can say whether it still lives. */
|
||||
terminalOnPairedServer?: boolean
|
||||
}): AutomationRunViewState {
|
||||
const hasTerminalIdentity = Boolean(run.terminalPaneKey && run.terminalPtyId)
|
||||
if (run.workspaceId && workspaceExists && terminalTargetExists) {
|
||||
@@ -67,6 +70,15 @@ export function getAutomationRunViewState({
|
||||
}
|
||||
}
|
||||
|
||||
if (run.workspaceId && workspaceExists && hasTerminalIdentity && terminalOnPairedServer) {
|
||||
return {
|
||||
availability: 'terminal',
|
||||
actionLabel: 'View run',
|
||||
statusLabel: 'Run terminal is on the paired server.',
|
||||
canOpen: true
|
||||
}
|
||||
}
|
||||
|
||||
if (run.workspaceId && workspaceExists && hasTerminalIdentity) {
|
||||
return {
|
||||
availability: 'terminal',
|
||||
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* A run that executed on a paired server (#21213) names the server's tab, pane and PTY. This client
|
||||
* shows that pane as a mirrored tab keyed by the server's terminal handle, so matching the server's
|
||||
* PTY id against local state always failed and the run read "Run terminal is unavailable.".
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AutomationRun } from '../../../../shared/automations-types'
|
||||
import { toRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id'
|
||||
import { toWebTerminalSurfaceTabId } from '../../../../shared/terminal-surface-id'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const state: Record<string, unknown> = {}
|
||||
return {
|
||||
state,
|
||||
toastError: vi.fn(),
|
||||
toastMessage: vi.fn(),
|
||||
activateAndRevealWorktree: vi.fn(() => true),
|
||||
activateWebRuntimeSessionTab: vi.fn(async () => true),
|
||||
activateTerminalTabOnOwner: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { error: mocks.toastError, message: mocks.toastMessage } }))
|
||||
vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.state } }))
|
||||
vi.mock('@/lib/worktree-activation', () => ({
|
||||
activateAndRevealWorktree: mocks.activateAndRevealWorktree
|
||||
}))
|
||||
vi.mock('@/runtime/web-runtime-session', () => ({
|
||||
activateWebRuntimeSessionTab: mocks.activateWebRuntimeSessionTab
|
||||
}))
|
||||
vi.mock('@/lib/terminal-tab-owner-activation', () => ({
|
||||
activateTerminalTabOnOwner: mocks.activateTerminalTabOnOwner
|
||||
}))
|
||||
|
||||
import { createAutomationRunWorkspaceAction } from './automation-run-workspace-action'
|
||||
import type { AutomationsPageActionContext } from './automations-page-action-context'
|
||||
|
||||
const ENV = 'env-a'
|
||||
const WT = 'repo-a::/srv/app'
|
||||
const LEAF = '11111111-1111-4111-8111-111111111111'
|
||||
const HOST_TAB = 'host-tab-1'
|
||||
const MIRRORED_TAB = toWebTerminalSurfaceTabId(HOST_TAB)
|
||||
const MIRRORED_PTY = toRemoteRuntimePtyId('term-handle-1', ENV)
|
||||
const worktree = { id: WT, repoId: 'repo-a', hostId: `runtime:${ENV}` as const }
|
||||
|
||||
const run: AutomationRun = {
|
||||
id: 'run-1',
|
||||
automationId: 'automation-1',
|
||||
title: 'Run 1',
|
||||
scheduledFor: 1,
|
||||
status: 'completed',
|
||||
trigger: 'manual',
|
||||
workspaceId: WT,
|
||||
workspaceDisplayName: 'app',
|
||||
sessionKind: 'terminal',
|
||||
chatSessionId: null,
|
||||
terminalSessionId: HOST_TAB,
|
||||
terminalPaneKey: `${HOST_TAB}:${LEAF}`,
|
||||
// The server's own PTY id, which this client never sees.
|
||||
terminalPtyId: 'pty-on-server-7',
|
||||
outputSnapshot: null,
|
||||
precheckResult: null,
|
||||
usage: null,
|
||||
error: null,
|
||||
startedAt: 1,
|
||||
dispatchedAt: 1,
|
||||
createdAt: 1
|
||||
}
|
||||
|
||||
function storeState(mirrored: boolean): Record<string, unknown> {
|
||||
return {
|
||||
// Focus is on this computer: owner routing must not depend on it.
|
||||
settings: { activeRuntimeEnvironmentId: null },
|
||||
activeWorktreeId: null,
|
||||
repos: [{ id: 'repo-a', executionHostId: `runtime:${ENV}` }],
|
||||
worktreesByRepo: { 'repo-a': [{ ...worktree, runtimeOwnerEnvironmentId: ENV }] },
|
||||
getTab: (tabId: string) => (mirrored && tabId === MIRRORED_TAB ? { id: tabId } : undefined),
|
||||
terminalLayoutsByTabId: mirrored
|
||||
? {
|
||||
[MIRRORED_TAB]: {
|
||||
root: { type: 'leaf', leafId: LEAF },
|
||||
activeLeafId: LEAF,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF]: MIRRORED_PTY }
|
||||
}
|
||||
}
|
||||
: {},
|
||||
ptyIdsByTabId: mirrored ? { [MIRRORED_TAB]: [MIRRORED_PTY] } : {},
|
||||
unifiedTabsByWorktree: {},
|
||||
groupsByWorktree: {},
|
||||
activeGroupIdByWorktree: {},
|
||||
setTabLayout: vi.fn(),
|
||||
setActiveTab: vi.fn(),
|
||||
setActiveTabType: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
function openRun(): void {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the action reads only these members.
|
||||
const context = {
|
||||
store: { repoForRow: () => undefined, worktreeForRow: () => worktree },
|
||||
list: { selectedRow: { key: 'row' } }
|
||||
} as unknown as AutomationsPageActionContext
|
||||
createAutomationRunWorkspaceAction(context)(run)
|
||||
}
|
||||
|
||||
describe('opening a paired-server automation run', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('opens the mirrored pane of the run', () => {
|
||||
mocks.state = storeState(true)
|
||||
|
||||
openRun()
|
||||
|
||||
expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
expect(mocks.state.setActiveTab).toHaveBeenCalledWith(MIRRORED_TAB)
|
||||
expect(mocks.activateTerminalTabOnOwner).toHaveBeenCalledWith(WT, MIRRORED_TAB, LEAF)
|
||||
})
|
||||
|
||||
it('asks the server to open the pane when the mirror has not loaded it yet', async () => {
|
||||
mocks.state = storeState(false)
|
||||
|
||||
openRun()
|
||||
await vi.waitFor(() => expect(mocks.activateWebRuntimeSessionTab).toHaveBeenCalled())
|
||||
|
||||
expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith(WT)
|
||||
expect(mocks.activateWebRuntimeSessionTab).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ worktreeId: WT, tabId: HOST_TAB, environmentId: ENV, leafId: LEAF })
|
||||
)
|
||||
expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('says the terminal is unavailable only when the server has no such pane', async () => {
|
||||
mocks.state = storeState(false)
|
||||
mocks.activateWebRuntimeSessionTab.mockResolvedValueOnce(false)
|
||||
|
||||
openRun()
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('Run terminal is unavailable.')
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -3,10 +3,14 @@ import { toast } from 'sonner'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { useAppStore } from '@/store'
|
||||
import { activateTerminalTabOnOwner } from '@/lib/terminal-tab-owner-activation'
|
||||
import { activateWebRuntimeSessionTab } from '@/runtime/web-runtime-session'
|
||||
import { resolveWebSessionVisibleTabId } from '@/runtime/web-session-focus-intent'
|
||||
import { parsePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import {
|
||||
buildAutomationRunOpenLayout,
|
||||
getAutomationRunOpenTabId,
|
||||
resolveAutomationRunOpenTarget
|
||||
getAutomationRunOwnerEnvironmentId,
|
||||
resolveAutomationRunTerminalTarget
|
||||
} from './automation-run-open-target'
|
||||
import { getAutomationRunViewState } from './automation-run-view-state'
|
||||
import type { AutomationsPageActionContext } from './automations-page-action-context'
|
||||
@@ -21,27 +25,43 @@ export function createAutomationRunWorkspaceAction({ store, list }: AutomationsP
|
||||
? (worktreeForRow(selectedRow, repoForRow(selectedRow), run.workspaceId) ?? null)
|
||||
: null
|
||||
const appStore = useAppStore.getState()
|
||||
const openTabId = getAutomationRunOpenTabId(run)
|
||||
const terminalTabExists = openTabId ? Boolean(appStore.getTab(openTabId)) : false
|
||||
const currentLayout = openTabId ? appStore.terminalLayoutsByTabId[openTabId] : null
|
||||
const livePtyIds = openTabId ? (appStore.ptyIdsByTabId[openTabId] ?? []) : []
|
||||
const terminalTarget = resolveAutomationRunOpenTarget({
|
||||
const ownerEnvironmentId = runWorktree
|
||||
? getAutomationRunOwnerEnvironmentId(appStore, run.workspaceId, runWorktree.hostId)
|
||||
: null
|
||||
const terminalTarget = resolveAutomationRunTerminalTarget(
|
||||
run,
|
||||
terminalTabExists,
|
||||
currentLayout,
|
||||
livePtyIds
|
||||
})
|
||||
{
|
||||
hasTerminalTab: (tabId) => Boolean(appStore.getTab(tabId)),
|
||||
terminalLayoutsByTabId: appStore.terminalLayoutsByTabId,
|
||||
ptyIdsByTabId: appStore.ptyIdsByTabId
|
||||
},
|
||||
ownerEnvironmentId
|
||||
)
|
||||
const currentLayout = terminalTarget
|
||||
? appStore.terminalLayoutsByTabId[terminalTarget.tabId]
|
||||
: null
|
||||
const runViewState = getAutomationRunViewState({
|
||||
run,
|
||||
workspaceExists: Boolean(runWorktree),
|
||||
terminalTargetExists: terminalTarget !== null
|
||||
terminalTargetExists: terminalTarget !== null,
|
||||
terminalOnPairedServer: ownerEnvironmentId !== null
|
||||
})
|
||||
if (!run.workspaceId || !runWorktree || !runViewState.canOpen) {
|
||||
toast.error(runViewState.statusLabel)
|
||||
return
|
||||
}
|
||||
const paneRef = parsePaneKey(run.terminalPaneKey ?? '')
|
||||
if (runViewState.availability === 'terminal' && !terminalTarget) {
|
||||
toast.error(runViewState.statusLabel)
|
||||
if (!ownerEnvironmentId || !paneRef) {
|
||||
toast.error(runViewState.statusLabel)
|
||||
return
|
||||
}
|
||||
if (!activateAndRevealWorktree(run.workspaceId)) {
|
||||
toast.error(workspaceUnavailableMessage())
|
||||
return
|
||||
}
|
||||
// Why: the mirror may not hold the pane yet; the server focuses it when it arrives.
|
||||
void openRunTerminalOnOwner(run.workspaceId, ownerEnvironmentId, paneRef)
|
||||
return
|
||||
}
|
||||
if (terminalTarget && currentLayout) {
|
||||
@@ -52,20 +72,42 @@ export function createAutomationRunWorkspaceAction({ store, list }: AutomationsP
|
||||
if (activateAndRevealWorktree(run.workspaceId)) {
|
||||
appStore.setActiveTab(terminalTarget.tabId)
|
||||
appStore.setActiveTabType('terminal', run.workspaceId)
|
||||
activateTerminalTabOnOwner(run.workspaceId, terminalTarget.tabId, terminalTarget.leafId)
|
||||
return
|
||||
}
|
||||
}
|
||||
if (!activateAndRevealWorktree(run.workspaceId)) {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.automations.AutomationsPage.e1bf9b1512',
|
||||
'Workspace is not available.'
|
||||
)
|
||||
)
|
||||
toast.error(workspaceUnavailableMessage())
|
||||
return
|
||||
}
|
||||
toast.message(runViewState.statusLabel)
|
||||
}
|
||||
}
|
||||
|
||||
function workspaceUnavailableMessage(): string {
|
||||
return translate(
|
||||
'auto.components.automations.AutomationsPage.e1bf9b1512',
|
||||
'Workspace is not available.'
|
||||
)
|
||||
}
|
||||
|
||||
async function openRunTerminalOnOwner(
|
||||
worktreeId: string,
|
||||
environmentId: string,
|
||||
paneRef: { tabId: string; leafId: string }
|
||||
): Promise<void> {
|
||||
const opened = await activateWebRuntimeSessionTab({
|
||||
worktreeId,
|
||||
tabId: paneRef.tabId,
|
||||
environmentId,
|
||||
leafId: paneRef.leafId,
|
||||
expectedCurrentLocalTabId: resolveWebSessionVisibleTabId(useAppStore.getState(), worktreeId)
|
||||
})
|
||||
if (!opened) {
|
||||
toast.error(
|
||||
translate('components.automations.runTerminalUnavailable', 'Run terminal is unavailable.')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export type AutomationRunWorkspaceAction = ReturnType<typeof createAutomationRunWorkspaceAction>
|
||||
|
||||
@@ -2,9 +2,10 @@ import { useEffect, useMemo } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { getAutomationHostTargetKey, getAutomationTargetFromHostId } from './automation-host-client'
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
canOpenAutomationRunOpenTarget,
|
||||
getAutomationRunOpenTabId
|
||||
getAutomationRunOwnerEnvironmentId,
|
||||
resolveAutomationRunTerminalTarget
|
||||
} from './automation-run-open-target'
|
||||
import { canRerunAutomationRun, getAutomationRunViewState } from './automation-run-view-state'
|
||||
import { getAutomationRunWorkspaceDisplay } from './automation-run-workspace-display'
|
||||
@@ -213,25 +214,30 @@ export function useAutomationRunPageState({
|
||||
worktree: selectedAutomationRunPageWorktree
|
||||
})
|
||||
: null
|
||||
const selectedAutomationRunPageOpenTabId = selectedAutomationRunPage
|
||||
? getAutomationRunOpenTabId(selectedAutomationRunPage)
|
||||
: null
|
||||
const selectedAutomationRunPageOwnerEnvironmentId = useAppStore((state) =>
|
||||
selectedAutomationRunPageWorktree
|
||||
? getAutomationRunOwnerEnvironmentId(
|
||||
state,
|
||||
selectedAutomationRunPage?.workspaceId,
|
||||
selectedAutomationRunPageWorktree.hostId
|
||||
)
|
||||
: null
|
||||
)
|
||||
const selectedAutomationRunPageViewState = selectedAutomationRunPage
|
||||
? getAutomationRunViewState({
|
||||
run: selectedAutomationRunPage,
|
||||
workspaceExists: Boolean(selectedAutomationRunPageWorktree),
|
||||
terminalTargetExists: canOpenAutomationRunOpenTarget({
|
||||
run: selectedAutomationRunPage,
|
||||
terminalTabExists: selectedAutomationRunPageOpenTabId
|
||||
? activeTerminalTabIds.has(selectedAutomationRunPageOpenTabId)
|
||||
: false,
|
||||
currentLayout: selectedAutomationRunPageOpenTabId
|
||||
? terminalLayoutsByTabId[selectedAutomationRunPageOpenTabId]
|
||||
: null,
|
||||
livePtyIds: selectedAutomationRunPageOpenTabId
|
||||
? (ptyIdsByTabId[selectedAutomationRunPageOpenTabId] ?? [])
|
||||
: []
|
||||
})
|
||||
terminalTargetExists:
|
||||
resolveAutomationRunTerminalTarget(
|
||||
selectedAutomationRunPage,
|
||||
{
|
||||
hasTerminalTab: (tabId) => activeTerminalTabIds.has(tabId),
|
||||
terminalLayoutsByTabId,
|
||||
ptyIdsByTabId
|
||||
},
|
||||
selectedAutomationRunPageOwnerEnvironmentId
|
||||
) !== null,
|
||||
terminalOnPairedServer: selectedAutomationRunPageOwnerEnvironmentId !== null
|
||||
})
|
||||
: null
|
||||
const canRerunSelectedAutomationRunPage =
|
||||
|
||||
@@ -126,7 +126,7 @@ export function installAppLifetimeIpcEvents(
|
||||
registerUpdaterStatusIpcBridge(unsubs)
|
||||
registerBrowserStateIpcBridge(unsubs, isRuntimeEnvironmentActive)
|
||||
registerContentCreationIpcBridge(unsubs, isRuntimeEnvironmentActive)
|
||||
registerBrowserRequestIpcBridge(unsubs, isRuntimeEnvironmentActive)
|
||||
registerBrowserRequestIpcBridge(unsubs)
|
||||
registerTabLifecycleIpcBridge(unsubs)
|
||||
registerRateLimitIpcBridge(unsubs)
|
||||
registerDirectSshStateIpcBridge(unsubs, directSshRuntime)
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type CreateRequest = { requestId: string; worktreeId?: string; url: string }
|
||||
type CloseRequest = { requestId: string; tabId?: string | null; worktreeId?: string }
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const listeners: {
|
||||
createListener: ((data: CreateRequest) => void) | null
|
||||
closeListener: ((data: CloseRequest) => void) | null
|
||||
} = { createListener: null, closeListener: null }
|
||||
return {
|
||||
...listeners,
|
||||
getState: vi.fn(),
|
||||
replyTabCreate: vi.fn(),
|
||||
replyTabClose: vi.fn(),
|
||||
createBrowserTab: vi.fn(),
|
||||
closeBrowserTab: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/browser-pane/host-guest/webview-registry', () => ({
|
||||
destroyPersistentWebview: vi.fn()
|
||||
}))
|
||||
vi.mock('../../store', () => ({ useAppStore: { getState: mocks.getState } }))
|
||||
vi.mock('./browser-automation-bootstrap-lease', () => ({
|
||||
acquireBrowserAutomationBootstrapLease: vi.fn()
|
||||
}))
|
||||
vi.mock('../../store/pinned-tab-close-guard', () => ({
|
||||
guardPinnedTabClose: ({ onClose }: { onClose: () => void }) => onClose(),
|
||||
isUnifiedTabPinned: vi.fn(() => false),
|
||||
resolvePinnedTabLabel: vi.fn()
|
||||
}))
|
||||
|
||||
import { registerBrowserRequestIpcBridge } from './browser-request-ipc-bridge'
|
||||
|
||||
const LOCAL_WT = 'repo-local::/Users/me/app'
|
||||
const REMOTE_WT = 'repo-remote::/srv/app'
|
||||
|
||||
function storeState(activeWorktreeId: string) {
|
||||
return {
|
||||
// The Active Server is a paired server; neither request below may read it.
|
||||
settings: { activeRuntimeEnvironmentId: 'env-remote' },
|
||||
activeWorktreeId,
|
||||
repos: [
|
||||
{ id: 'repo-local', executionHostId: 'local' },
|
||||
{ id: 'repo-remote', executionHostId: 'runtime:env-remote' }
|
||||
],
|
||||
worktreesByRepo: {
|
||||
'repo-local': [{ id: LOCAL_WT, repoId: 'repo-local', hostId: 'local' }],
|
||||
'repo-remote': [
|
||||
{
|
||||
id: REMOTE_WT,
|
||||
repoId: 'repo-remote',
|
||||
hostId: 'runtime:env-remote',
|
||||
runtimeOwnerEnvironmentId: 'env-remote'
|
||||
}
|
||||
]
|
||||
},
|
||||
browserTabsByWorktree: {
|
||||
[LOCAL_WT]: [{ id: 'ws-local' }],
|
||||
[REMOTE_WT]: [{ id: 'ws-remote' }]
|
||||
},
|
||||
browserPagesByWorkspace: {
|
||||
'ws-local': [{ id: 'page-local' }],
|
||||
'ws-remote': [{ id: 'page-remote' }]
|
||||
},
|
||||
activeBrowserTabIdByWorktree: {},
|
||||
unifiedTabsByWorktree: {},
|
||||
createBrowserTab: mocks.createBrowserTab.mockImplementation(() => ({ id: 'ws-new' })),
|
||||
closeBrowserTab: mocks.closeBrowserTab,
|
||||
closeBrowserPage: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
describe('browser requests from this desktop follow the workspace owner (#22275)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: {
|
||||
ui: {
|
||||
onRequestTabCreate: (listener: typeof mocks.createListener) => {
|
||||
mocks.createListener = listener
|
||||
return () => {}
|
||||
},
|
||||
replyTabCreate: mocks.replyTabCreate,
|
||||
onRequestTabSetProfile: () => () => {},
|
||||
replyTabSetProfile: vi.fn(),
|
||||
onRequestTabClose: (listener: typeof mocks.closeListener) => {
|
||||
mocks.closeListener = listener
|
||||
return () => {}
|
||||
},
|
||||
replyTabClose: mocks.replyTabClose
|
||||
}
|
||||
}
|
||||
})
|
||||
registerBrowserRequestIpcBridge([])
|
||||
})
|
||||
|
||||
it('creates a tab for an explicit local worktree while a paired server is the Active Server', () => {
|
||||
mocks.getState.mockReturnValue(storeState(REMOTE_WT))
|
||||
|
||||
mocks.createListener?.({ requestId: 'r1', worktreeId: LOCAL_WT, url: 'about:blank' })
|
||||
|
||||
expect(mocks.createBrowserTab).toHaveBeenCalledWith(LOCAL_WT, 'about:blank', expect.anything())
|
||||
expect(mocks.replyTabCreate).toHaveBeenCalledWith({ requestId: 'r1', browserPageId: 'ws-new' })
|
||||
})
|
||||
|
||||
it("refuses to fall back to a paired server's active workspace", () => {
|
||||
mocks.getState.mockReturnValue(storeState(REMOTE_WT))
|
||||
|
||||
mocks.createListener?.({ requestId: 'r2', url: 'about:blank' })
|
||||
|
||||
expect(mocks.createBrowserTab).not.toHaveBeenCalled()
|
||||
expect(mocks.replyTabCreate).toHaveBeenCalledWith({
|
||||
requestId: 'r2',
|
||||
error: expect.stringContaining('paired server')
|
||||
})
|
||||
})
|
||||
|
||||
it("closes a local page but refuses a paired server's page", () => {
|
||||
mocks.getState.mockReturnValue(storeState(LOCAL_WT))
|
||||
|
||||
mocks.closeListener?.({ requestId: 'c1', tabId: 'ws-local', worktreeId: LOCAL_WT })
|
||||
mocks.closeListener?.({ requestId: 'c2', tabId: 'page-remote' })
|
||||
|
||||
expect(mocks.closeBrowserTab).toHaveBeenCalledExactlyOnceWith('ws-local')
|
||||
expect(mocks.replyTabClose).toHaveBeenCalledWith({ requestId: 'c1' })
|
||||
expect(mocks.replyTabClose).toHaveBeenCalledWith({
|
||||
requestId: 'c2',
|
||||
error: expect.stringContaining('paired server')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -75,7 +75,7 @@ describe('browser profile request teardown', () => {
|
||||
})
|
||||
|
||||
it('keeps document-preview guests while rebuilding URL siblings for a profile change', () => {
|
||||
registerBrowserRequestIpcBridge([], () => false)
|
||||
registerBrowserRequestIpcBridge([])
|
||||
|
||||
mocks.profileListener?.({
|
||||
requestId: 'request-1',
|
||||
|
||||
@@ -7,25 +7,24 @@ import {
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useAppStore } from '../../store'
|
||||
import { acquireBrowserAutomationBootstrapLease } from './browser-automation-bootstrap-lease'
|
||||
import {
|
||||
findBrowserTabWorktreeId,
|
||||
isBrowserWorkspaceOwnedByPairedServer
|
||||
} from './browser-request-workspace-owner'
|
||||
|
||||
export function registerBrowserRequestIpcBridge(
|
||||
unsubs: (() => void)[],
|
||||
isRuntimeEnvironmentActive: () => boolean
|
||||
): void {
|
||||
function pairedServerWorkspaceError(): string {
|
||||
return translate(
|
||||
'browser.request.pairedServerWorkspace',
|
||||
"This workspace runs on a paired server. Use that server's browser tabs."
|
||||
)
|
||||
}
|
||||
|
||||
// Why owner, not the Active Server setting: these requests come from this desktop's own runtime,
|
||||
// which only names workspaces it runs, so focus on a paired server must not refuse them (#22275).
|
||||
export function registerBrowserRequestIpcBridge(unsubs: (() => void)[]): void {
|
||||
unsubs.push(
|
||||
window.api.ui.onRequestTabCreate((data) => {
|
||||
try {
|
||||
if (isRuntimeEnvironmentActive()) {
|
||||
// Why: browser automation targets client-local Electron webviews that runtime agents can't see or control.
|
||||
window.api.ui.replyTabCreate({
|
||||
requestId: data.requestId,
|
||||
error: translate(
|
||||
'auto.hooks.useIpcEvents.291c8ed902',
|
||||
'Browser tabs are unavailable while a remote runtime is active'
|
||||
)
|
||||
})
|
||||
return
|
||||
}
|
||||
const store = useAppStore.getState()
|
||||
const worktreeId = data.worktreeId ?? store.activeWorktreeId
|
||||
if (!worktreeId) {
|
||||
@@ -35,6 +34,14 @@ export function registerBrowserRequestIpcBridge(
|
||||
})
|
||||
return
|
||||
}
|
||||
// Why only the fallback: main resolved an explicit id against its own catalog already.
|
||||
if (!data.worktreeId && isBrowserWorkspaceOwnedByPairedServer(store, worktreeId)) {
|
||||
window.api.ui.replyTabCreate({
|
||||
requestId: data.requestId,
|
||||
error: pairedServerWorkspaceError()
|
||||
})
|
||||
return
|
||||
}
|
||||
// Why: CLI-created tabs should land in the active browser tab's group, not the terminal's UI-active group.
|
||||
const activeBrowserTabId = store.activeBrowserTabIdByWorktree[worktreeId]
|
||||
const activeBrowserUnifiedTab = activeBrowserTabId
|
||||
@@ -72,17 +79,15 @@ export function registerBrowserRequestIpcBridge(
|
||||
unsubs.push(
|
||||
window.api.ui.onRequestTabSetProfile((data) => {
|
||||
try {
|
||||
if (isRuntimeEnvironmentActive()) {
|
||||
const store = useAppStore.getState()
|
||||
const pageWorktreeId = findBrowserTabWorktreeId(store, data.browserPageId)
|
||||
if (pageWorktreeId && isBrowserWorkspaceOwnedByPairedServer(store, pageWorktreeId)) {
|
||||
window.api.ui.replyTabSetProfile({
|
||||
requestId: data.requestId,
|
||||
error: translate(
|
||||
'auto.hooks.useIpcEvents.f45fa2b03c',
|
||||
'Browser profiles are unavailable while a remote runtime is active'
|
||||
)
|
||||
error: pairedServerWorkspaceError()
|
||||
})
|
||||
return
|
||||
}
|
||||
const store = useAppStore.getState()
|
||||
const owningWorkspace = Object.values(store.browserTabsByWorktree)
|
||||
.flat()
|
||||
.find((workspace) => {
|
||||
@@ -129,16 +134,6 @@ export function registerBrowserRequestIpcBridge(
|
||||
unsubs.push(
|
||||
window.api.ui.onRequestTabClose((data) => {
|
||||
try {
|
||||
if (isRuntimeEnvironmentActive()) {
|
||||
window.api.ui.replyTabClose({
|
||||
requestId: data.requestId,
|
||||
error: translate(
|
||||
'auto.hooks.useIpcEvents.291c8ed902',
|
||||
'Browser tabs are unavailable while a remote runtime is active'
|
||||
)
|
||||
})
|
||||
return
|
||||
}
|
||||
const store = useAppStore.getState()
|
||||
const explicitTargetId = data.tabId ?? null
|
||||
const replyBrowserTabNotFound = (tabId: string): void => {
|
||||
@@ -186,6 +181,14 @@ export function registerBrowserRequestIpcBridge(
|
||||
})
|
||||
return
|
||||
}
|
||||
const closeWorktreeId = findBrowserTabWorktreeId(store, tabToClose)
|
||||
if (closeWorktreeId && isBrowserWorkspaceOwnedByPairedServer(store, closeWorktreeId)) {
|
||||
window.api.ui.replyTabClose({
|
||||
requestId: data.requestId,
|
||||
error: pairedServerWorkspaceError()
|
||||
})
|
||||
return
|
||||
}
|
||||
// Why: the bridge keys tabs by browserPageId, but closeBrowserTab expects a workspace id.
|
||||
// Per the CLI's `tab close --page` contract, close only that page unless it is the last in its workspace.
|
||||
const isWorkspaceId = Object.values(store.browserTabsByWorktree)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { resolveOwner } from '@/lib/resolve-owner'
|
||||
import type { WorktreeOperationRouteState } from '@/lib/worktree-operation-route'
|
||||
import type { AppState } from '../../store/types'
|
||||
|
||||
/**
|
||||
* Whether a paired server owns (or may own) `worktreeId`. This desktop's runtime must not create or close browser
|
||||
* tabs there: that server's own page registry and snapshot are authoritative for them.
|
||||
*/
|
||||
export function isBrowserWorkspaceOwnedByPairedServer(
|
||||
state: WorktreeOperationRouteState,
|
||||
worktreeId: string
|
||||
): boolean {
|
||||
const owner = resolveOwner(state, { workspaceId: worktreeId })
|
||||
// Why ambiguous refuses: rows on several hosts cannot prove this desktop owns the tab.
|
||||
return (
|
||||
owner.kind === 'ambiguous' ||
|
||||
(owner.kind === 'resolved' && owner.owner.endpoint.kind === 'environment')
|
||||
)
|
||||
}
|
||||
|
||||
/** The workspace holding a browser tab, addressed by its tab (workspace) id or one of its page ids. */
|
||||
export function findBrowserTabWorktreeId(
|
||||
state: Pick<AppState, 'browserTabsByWorktree' | 'browserPagesByWorkspace'>,
|
||||
tabOrPageId: string
|
||||
): string | null {
|
||||
for (const [worktreeId, tabs] of Object.entries(state.browserTabsByWorktree)) {
|
||||
for (const tab of tabs) {
|
||||
if (
|
||||
tab.id === tabOrPageId ||
|
||||
(state.browserPagesByWorkspace[tab.id] ?? []).some((page) => page.id === tabOrPageId)
|
||||
) {
|
||||
return worktreeId
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -154,6 +154,9 @@
|
||||
"description": "Per-profile user-agent settings were removed. Choose Cleaned or Native in Browser settings; changes take effect after a restart.",
|
||||
"title": "Browser identity is now app-wide",
|
||||
"openSettings": "Open Settings"
|
||||
},
|
||||
"request": {
|
||||
"pairedServerWorkspace": "This workspace runs on a paired server. Use that server's browser tabs."
|
||||
}
|
||||
},
|
||||
"editor": {
|
||||
@@ -1205,8 +1208,6 @@
|
||||
"0e3cf53060": "Browser tab {{value0}} not found",
|
||||
"2f6637fe6c": "Browser tab {{value0}} is pinned",
|
||||
"a8d2bf8e9e": "No active browser tab to close",
|
||||
"291c8ed902": "Browser tabs are unavailable while a remote runtime is active",
|
||||
"f45fa2b03c": "Browser profiles are unavailable while a remote runtime is active",
|
||||
"f000b2ff76": "No active worktree",
|
||||
"56d3ec4203": "Failed to create untitled markdown file.",
|
||||
"f6300deb8b": "New Browser Tab",
|
||||
@@ -19079,6 +19080,9 @@
|
||||
"important": "Important",
|
||||
"warning": "Warning",
|
||||
"caution": "Caution"
|
||||
},
|
||||
"automations": {
|
||||
"runTerminalUnavailable": "Run terminal is unavailable."
|
||||
}
|
||||
},
|
||||
"dashboardPopout": {
|
||||
|
||||
@@ -1037,8 +1037,6 @@
|
||||
"0e3cf53060": "Pestaña del navegador {{value0}} no encontrada",
|
||||
"2f6637fe6c": "La pestaña del navegador {{value0}} está fijada",
|
||||
"a8d2bf8e9e": "No hay ninguna pestaña activa del navegador para cerrar",
|
||||
"291c8ed902": "Las pestañas del navegador no están disponibles mientras haya un host remoto activo",
|
||||
"f45fa2b03c": "Los perfiles del navegador no están disponibles mientras haya un host remoto activo",
|
||||
"f000b2ff76": "No hay ningún worktree activo",
|
||||
"56d3ec4203": "No se pudo crear el archivo Markdown sin título.",
|
||||
"f6300deb8b": "Nueva pestaña del navegador",
|
||||
|
||||
@@ -1138,8 +1138,6 @@
|
||||
"0e3cf53060": "Onglet de navigateur {{value0}} introuvable",
|
||||
"2f6637fe6c": "L'onglet de navigateur {{value0}} est épinglé",
|
||||
"a8d2bf8e9e": "Aucun onglet de navigateur actif à fermer",
|
||||
"291c8ed902": "Les onglets de navigateur sont indisponibles tant qu'un runtime distant est actif",
|
||||
"f45fa2b03c": "Les profils de navigateur sont indisponibles tant qu'un runtime distant est actif",
|
||||
"f000b2ff76": "Aucun worktree actif",
|
||||
"56d3ec4203": "Échec de la création du fichier markdown sans titre.",
|
||||
"f6300deb8b": "Nouvel onglet de navigateur",
|
||||
|
||||
@@ -1037,8 +1037,6 @@
|
||||
"0e3cf53060": "ブラウザタブ {{value0}} が見つかりません",
|
||||
"2f6637fe6c": "ブラウザタブ {{value0}} はピン留めされています",
|
||||
"a8d2bf8e9e": "閉じるためのアクティブなブラウザタブがありません",
|
||||
"291c8ed902": "リモートランタイムがアクティブな間はブラウザタブは使用できません",
|
||||
"f45fa2b03c": "リモートランタイムがアクティブな間はブラウザプロファイルを使用できません",
|
||||
"f000b2ff76": "アクティブなワークツリーがありません",
|
||||
"56d3ec4203": "無題の markdown ファイルの作成に失敗しました。",
|
||||
"f6300deb8b": "新規ブラウザタブ",
|
||||
|
||||
@@ -1037,8 +1037,6 @@
|
||||
"0e3cf53060": "브라우저 탭 {{value0}}을(를) 찾을 수 없습니다",
|
||||
"2f6637fe6c": "브라우저 탭 {{value0}}이(가) 고정되어 있습니다",
|
||||
"a8d2bf8e9e": "닫을 활성 브라우저 탭이 없습니다.",
|
||||
"291c8ed902": "원격 런타임이 활성화된 동안에는 브라우저 탭을 사용할 수 없습니다.",
|
||||
"f45fa2b03c": "원격 런타임이 활성화된 동안에는 브라우저 프로필을 사용할 수 없습니다.",
|
||||
"f000b2ff76": "활성 작업 트리 없음",
|
||||
"56d3ec4203": "제목 없는 markdown 파일을 생성하지 못했습니다.",
|
||||
"f6300deb8b": "새 브라우저 탭",
|
||||
|
||||
@@ -1037,8 +1037,6 @@
|
||||
"0e3cf53060": "未找到浏览器选项卡 {{value0}}",
|
||||
"2f6637fe6c": "浏览器选项卡 {{value0}} 已固定",
|
||||
"a8d2bf8e9e": "没有可关闭的活动浏览器选项卡",
|
||||
"291c8ed902": "当远程运行时处于活动状态时,浏览器选项卡不可用",
|
||||
"f45fa2b03c": "当远程运行时处于活动状态时,浏览器配置文件不可用",
|
||||
"f000b2ff76": "没有活动的工作树",
|
||||
"56d3ec4203": "无法创建无标题 Markdown 文件。",
|
||||
"f6300deb8b": "新浏览器选项卡",
|
||||
|
||||
Reference in New Issue
Block a user