diff --git a/src/main/agent-launch/agent-launch-executor.test.ts b/src/main/agent-launch/agent-launch-executor.test.ts index b2c50e82037..0e9c350c5ec 100644 --- a/src/main/agent-launch/agent-launch-executor.test.ts +++ b/src/main/agent-launch/agent-launch-executor.test.ts @@ -433,24 +433,21 @@ describe('delivering a launch prompt to a terminal agent', () => { }) /** - * The kind is read off the resolved workspace id, so a workspace with nowhere to keep a session is - * decided here rather than offered to a host probe that cannot answer for it. + * The kind is read off the resolved workspace id. Every kind, the floating workspace included, now + * has a directory a session can run in, so none downgrades a launch on its own. */ describe('a launch into an existing workspace, by workspace kind', () => { - it('runs the floating workspace as a terminal, never a structured session', async () => { + it('opens a structured session in the floating workspace', async () => { const h = harness({}) const result = await h.run({ agent: 'claude', target: { kind: 'existing', worktree: FLOATING_TERMINAL_WORKTREE_ID } }) - // The invariant, not the call order: the floating sentinel has no session store to open into. - expect(h.createStructuredSession).not.toHaveBeenCalled() - expect(result.outcome).toEqual({ kind: 'terminal', handle: 'term_1' }) - expect(result.receipt).toMatchObject({ - mode: 'terminal', - reason: 'structured_unsupported_on_host' - }) + // Why this changed: the floating workspace resolves to its configured directory, so a session + // has somewhere to run and be filed under. Kind alone no longer downgrades a launch. + expect(h.createStructuredSession).toHaveBeenCalled() + expect(result.outcome).toMatchObject({ kind: 'structured' }) }) it('still opens a structured session in a folder workspace', async () => { diff --git a/src/main/agent-launch/agent-launch-mode.ts b/src/main/agent-launch/agent-launch-mode.ts index f9a9eef7846..bd69aba3f1a 100644 --- a/src/main/agent-launch/agent-launch-mode.ts +++ b/src/main/agent-launch/agent-launch-mode.ts @@ -97,7 +97,6 @@ const BLOCKER_REASON: Record< > = { 'reused-terminal': 'reused_terminal', 'agent-without-structured-session': 'agent_without_structured_session', - 'floating-workspace': 'structured_unsupported_on_host', 'custom-start-directory': 'tui_launch_command', 'remote-execution-host': 'remote_execution_host', 'project-runtime': 'wsl_execution_runtime', @@ -147,10 +146,8 @@ export function decideAgentLaunchMode(args: { executionHostId: 'local', reusesTerminal: Boolean(placement.terminal), hostCapabilities: RUNTIME_CAPABILITIES, - // The floating workspace has nowhere to keep a session, so it is decided here rather than left - // to the host probe below, which cannot answer for a workspace with no record. WSL still is: - // the create-support probe reads the resolved workspace rather than guessing from a - // client-side project runtime. + // The host resolves the floating workspace to its configured directory; create-support still + // answers for the resolved workspace, including whether it uses WSL. ...(placement.workspaceKind ? { workspaceKind: placement.workspaceKind } : {}), // Mirrors the renderer's own route input (`agent-launch-route-input.ts`): a cwd is terminal-only // when it names somewhere other than the workspace root, by the same shared rule. diff --git a/src/main/claude/claude-agent-sdk-contract-pins.test.ts b/src/main/claude/claude-agent-sdk-contract-pins.test.ts index 341284f16e6..e16c1420ef1 100644 --- a/src/main/claude/claude-agent-sdk-contract-pins.test.ts +++ b/src/main/claude/claude-agent-sdk-contract-pins.test.ts @@ -14,7 +14,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { spawnProcess } from '../../shared/child-process/run-process' import type { AgentSessionRecord } from '../../shared/agent-session-record' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' -import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' import { claudeQuerySettingsReader } from './claude-agent-sdk-control-requests' import { createClaudeStructuredLaunchResolver } from './claude-structured-launch-resolution' @@ -147,7 +146,7 @@ function resolvedLaunch(permissionMode: PermissionMode, launchArgs: string[] = [ launchArgs } as unknown as AgentSessionRecord return createClaudeStructuredLaunchResolver({ - store: { getRecord: () => record } as unknown as AgentSessionRecordStore, + store: { getRecord: () => record, pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async () => '/repos/workspace-1', resolveCommand: () => FAKE_CLI, resolveAuthPolicy: () => ({ stripAuthEnv: true }), diff --git a/src/main/claude/claude-at-rest-commands.ts b/src/main/claude/claude-at-rest-commands.ts index 7eaa9c143ed..02bf2fc17a0 100644 --- a/src/main/claude/claude-at-rest-commands.ts +++ b/src/main/claude/claude-at-rest-commands.ts @@ -5,6 +5,7 @@ import { structuredSlashCommands } from '../../shared/structured-agent-session-c import { discoverSkills } from '../skills/discovery' import { scanClaudeCommandFolders } from './claude-command-folder-scan' import { supportsClaudeStructuredLocation } from './claude-structured-location-support' +import { agentSessionPinnedLaunchDirectory } from '../runtime/agent-session-record-launch-directory' /** How long one scan answers for a workspace and account before the next read scans again. */ export const CLAUDE_AT_REST_COMMANDS_TTL_MS = 10_000 @@ -110,7 +111,10 @@ export class ClaudeAtRestCommandCatalog { } private async scan(record: AgentSessionRecord): Promise { - const cwd = await this.deps.resolveWorkspacePath(record.location.workspaceId) + // The folder Claude will launch in: a floating chat's pin, not the floating setting's current one. + const cwd = + agentSessionPinnedLaunchDirectory(record) ?? + (await this.deps.resolveWorkspacePath(record.location.workspaceId)) const account = record.accountHome.path const [custom, discovered] = await Promise.all([ scanClaudeCommandFolders([join(cwd, '.claude', 'commands'), join(account, 'commands')]), diff --git a/src/main/claude/claude-structured-auth-parity.test.ts b/src/main/claude/claude-structured-auth-parity.test.ts index aa535a12a32..c445e4837f2 100644 --- a/src/main/claude/claude-structured-auth-parity.test.ts +++ b/src/main/claude/claude-structured-auth-parity.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../shared/agent-session-record' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { beginClaudeAuthSwitch, endClaudeAuthSwitch } from '../claude-accounts/live-pty-gate' @@ -6,7 +6,6 @@ import { CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE } from '../claude-accounts/environment' -import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' import { createClaudeStructuredLaunchResolver } from './claude-structured-launch-resolution' import { ClaudeStructuredSessionAdapter } from './claude-structured-session-adapter' import { @@ -43,7 +42,7 @@ function resolverFor(options: { authSwitchSettleTimeoutMs?: number }): ReturnType { return createClaudeStructuredLaunchResolver({ - store: { getRecord: () => record() } as unknown as AgentSessionRecordStore, + store: { getRecord: () => record(), pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', resolveAuthPolicy: () => ({ stripAuthEnv: options.stripAuthEnv }), @@ -68,7 +67,7 @@ function realResolverAdapter( } as unknown as AgentSessionRecord return new ClaudeStructuredSessionAdapter({ resolveLaunch: createClaudeStructuredLaunchResolver({ - store: { getRecord: () => resumable } as unknown as AgentSessionRecordStore, + store: { getRecord: () => resumable, pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', resolveAuthPolicy: () => ({ stripAuthEnv: false }), diff --git a/src/main/claude/claude-structured-launch-resolution.test.ts b/src/main/claude/claude-structured-launch-resolution.test.ts index ef75a6ba2b9..c280553cfe5 100644 --- a/src/main/claude/claude-structured-launch-resolution.test.ts +++ b/src/main/claude/claude-structured-launch-resolution.test.ts @@ -4,7 +4,7 @@ import { delimiter, dirname, join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../shared/agent-session-record' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' -import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { claudeStructuredAuthPolicyForSettings } from '../claude-accounts/claude-structured-auth-policy' import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' @@ -65,7 +65,7 @@ function resolverFor( hasTranscript: () => Promise = async () => true ) { return createClaudeStructuredLaunchResolver({ - store: { getRecord: () => value } as unknown as AgentSessionRecordStore, + store: { getRecord: () => value, pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', resolveAuthPolicy: () => ({ stripAuthEnv }), @@ -110,6 +110,19 @@ const RESUMABLE = record({ }) describe('claude structured launch resolution', () => { + it('resumes a floating session in its pinned folder, not the current floating setting', async () => { + const pinned = mkdtempSync(join(tmpdir(), 'orca-claude-floating-')) + const floating = record({ + location: { ...record().location, workspaceId: FLOATING_TERMINAL_WORKTREE_ID }, + launchDirectory: pinned + }) + + const launch = await resolverFor(floating)({ identity: IDENTITY }) + + // resolverFor answers `/repos/` — the current setting — which a pinned resume must ignore. + expect(launch.cwd).toBe(pinned) + }) + it('pre-mints a stable provider id and pins interactive setting sources', async () => { const first = await resolverFor(record())({ identity: IDENTITY }) const second = await resolverFor(record())({ identity: IDENTITY }) @@ -354,7 +367,7 @@ describe('claude structured launch resolution', () => { it('builds on the supplied inherited env instead of Orca process env', async () => { const launch = await createClaudeStructuredLaunchResolver({ - store: { getRecord: () => record() } as unknown as AgentSessionRecordStore, + store: { getRecord: () => record(), pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', resolveAuthPolicy: () => ({ stripAuthEnv: false }), @@ -366,7 +379,7 @@ describe('claude structured launch resolution', () => { it('drops an inherited CLAUDE_CONFIG_DIR so the record stays the only Claude home the pin sees', async () => { const launch = await createClaudeStructuredLaunchResolver({ - store: { getRecord: () => record() } as unknown as AgentSessionRecordStore, + store: { getRecord: () => record(), pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', resolveAuthPolicy: () => ({ stripAuthEnv: false }), @@ -384,7 +397,7 @@ describe('claude structured launch resolution', () => { it('keeps a configured overlay CLAUDE_CONFIG_DIR over the dropped inherited one', async () => { const launch = await createClaudeStructuredLaunchResolver({ - store: { getRecord: () => record() } as unknown as AgentSessionRecordStore, + store: { getRecord: () => record(), pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', resolveAuthPolicy: () => ({ stripAuthEnv: false }), @@ -397,7 +410,7 @@ describe('claude structured launch resolution', () => { it('still strips an inherited auth key under a managed account', async () => { const launch = await createClaudeStructuredLaunchResolver({ - store: { getRecord: () => record() } as unknown as AgentSessionRecordStore, + store: { getRecord: () => record(), pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', resolveAuthPolicy: () => ({ stripAuthEnv: true }), @@ -434,7 +447,7 @@ describe('claude structured launch resolution', () => { makeExecutable(nodeCommand) const launch = await createClaudeStructuredLaunchResolver({ - store: { getRecord: () => record() } as unknown as AgentSessionRecordStore, + store: { getRecord: () => record(), pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => claudeCommand, resolveAuthPolicy: () => ({ stripAuthEnv: false }), @@ -476,7 +489,7 @@ describe('claude structured launch resolution', () => { describe('managed-account gate on every acquisition', () => { function resolverWithGate(read: () => ClaudeManagedAccountGateSettings | null) { return createClaudeStructuredLaunchResolver({ - store: { getRecord: () => RESUMABLE } as unknown as AgentSessionRecordStore, + store: { getRecord: () => RESUMABLE, pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', // Derived, not a literal: the gate and the policy must read the SAME account state, so a @@ -531,8 +544,7 @@ describe('readable Claude thinking', () => { command = '/usr/local/bin/claude' ) => createClaudeStructuredLaunchResolver({ - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: launch resolution reads only getRecord. - store: { getRecord: () => record() } as unknown as AgentSessionRecordStore, + store: { getRecord: () => record(), pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => command, resolveAuthPolicy: () => ({ stripAuthEnv: false }), diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index 5f1edeb017a..dc33d652e4d 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -35,6 +35,7 @@ import { resolveSessionFilePath } from '../native-chat/session-file-resolver' import { withoutInheritedClaudeConfigDir } from './claude-config-dir-pin' import type { ClaudeThinkingDisplaySupport } from './claude-thinking-display-support' import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' +import { resolveAgentSessionLaunchDirectory } from '../runtime/agent-session-launch-directory' import { CLAUDE_STRUCTURED_AGENT } from './claude-structured-agent-definition' export const CLAUDE_DEFAULT_SETTING_SOURCES = ['user', 'project', 'local'] as const @@ -112,7 +113,7 @@ export type ClaudeStructuredLaunch = { } export type ClaudeStructuredLaunchResolverDeps = { - store: AgentSessionRecordStore + store: Pick resolveWorkspacePath: (workspaceId: string) => Promise resolveCommand?: () => string resolveEnv?: () => @@ -325,7 +326,7 @@ export function createClaudeStructuredLaunchResolver( ? head.nativeId : claudeSessionIdForOrcaSession(identity.sessionId) const continuesChain = head !== null - const cwd = await deps.resolveWorkspacePath(record.location.workspaceId) + const cwd = await resolveAgentSessionLaunchDirectory(deps, record) const sources = await resolveClaudeChildEnvSources(deps) // Asked as soon as the spawn's cwd and PATH are known, so it overlaps what is left to resolve. const thinkingDisplay = deps.thinkingDisplay?.argsFor({ diff --git a/src/main/codex/codex-structured-launch-resolution.test.ts b/src/main/codex/codex-structured-launch-resolution.test.ts index 1e6a6b4805e..9b62398ed87 100644 --- a/src/main/codex/codex-structured-launch-resolution.test.ts +++ b/src/main/codex/codex-structured-launch-resolution.test.ts @@ -1,8 +1,11 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' import type { AgentSessionRecord } from '../../shared/agent-session-record' import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' -import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' import { createCodexStructuredLaunchResolver } from './codex-structured-launch-resolution' import { codexStructuredPermissionPolicyForSettings } from './codex-structured-permission-policy' import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' @@ -45,7 +48,7 @@ function resolverFor( agentDefaultArgs: Record = { codex: '' } ) { return createCodexStructuredLaunchResolver({ - store: { getRecord: () => value } as unknown as AgentSessionRecordStore, + store: { getRecord: () => value, pinLaunchDirectory: vi.fn() }, resolveWorkspacePath, resolveCommand: () => '/usr/local/bin/codex', resolveRollout, @@ -55,6 +58,41 @@ function resolverFor( } describe('codex structured launch resolution', () => { + it('resumes a floating session in its pinned folder, not the current floating setting', async () => { + const pinned = mkdtempSync(join(tmpdir(), 'orca-codex-floating-')) + const resolveWorkspacePath = vi.fn(async () => '/floating/current-setting') + const floating = record({ + location: { ...record().location, workspaceId: FLOATING_TERMINAL_WORKTREE_ID }, + launchDirectory: pinned + }) + + const launch = await resolverFor(floating, resolveWorkspacePath)({ identity: IDENTITY }) + + expect(launch.cwd).toBe(pinned) + expect(resolveWorkspacePath).not.toHaveBeenCalled() + }) + + it('repairs the first launch directory of an unpinned legacy floating session', async () => { + const pinLaunchDirectory = vi.fn() + const resolveLaunch = createCodexStructuredLaunchResolver({ + store: { + getRecord: () => + record({ + location: { ...record().location, workspaceId: FLOATING_TERMINAL_WORKTREE_ID } + }), + pinLaunchDirectory + }, + resolveWorkspacePath: async () => '/floating/start-folder', + resolveCommand: () => '/usr/local/bin/codex', + isWindowsProcessStartTimeAvailable: () => true + }) + + await expect(resolveLaunch({ identity: IDENTITY })).resolves.toMatchObject({ + cwd: '/floating/start-folder' + }) + expect(pinLaunchDirectory).toHaveBeenCalledExactlyOnceWith(SESSION_ID, '/floating/start-folder') + }) + it('launches the app server in the workspace and account home the record pinned', async () => { const launch = await resolverFor(record())({ identity: IDENTITY }) @@ -74,7 +112,7 @@ describe('codex structured launch resolution', () => { await withPlatform('win32', async () => { const resolveLaunch = createCodexStructuredLaunchResolver({ - store: { getRecord: () => record() } as unknown as AgentSessionRecordStore, + store: { getRecord: () => record(), pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async () => String.raw`C:\workspaces\orca`, resolveCommand: () => command, isWindowsProcessStartTimeAvailable: () => true @@ -91,7 +129,7 @@ describe('codex structured launch resolution', () => { await withPlatform('win32', async () => { const resolveWorkspacePath = vi.fn(async () => String.raw`C:\workspaces\orca`) const resolveLaunch = createCodexStructuredLaunchResolver({ - store: { getRecord: () => record() } as unknown as AgentSessionRecordStore, + store: { getRecord: () => record(), pinLaunchDirectory: vi.fn() }, resolveWorkspacePath, isWindowsProcessStartTimeAvailable: () => false }) diff --git a/src/main/codex/codex-structured-launch-resolution.ts b/src/main/codex/codex-structured-launch-resolution.ts index 18db5def05d..0f5d21b41d4 100644 --- a/src/main/codex/codex-structured-launch-resolution.ts +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -11,6 +11,7 @@ import { agentSessionProviderHandleChainHead } from '../../shared/agent-session- import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { resolveCodexCommand } from '../codex-cli/command' import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' +import { resolveAgentSessionLaunchDirectory } from '../runtime/agent-session-launch-directory' import type { CodexStructuredLaunch } from './codex-structured-session-adapter' import type { CodexStructuredPermissionPolicy } from './codex-structured-permission-policy' import { resolvePinnedCodexRolloutProof } from './codex-pinned-rollout-proof' @@ -18,7 +19,7 @@ import { isWindowsProcessStartTimeAvailable } from '../windows/windows-process-t import { CODEX_STRUCTURED_AGENT } from './codex-structured-agent-definition' export type CodexStructuredLaunchResolverDeps = { - store: AgentSessionRecordStore + store: Pick /** Absolute path of a workspace on this host. Rejects when the workspace no * longer resolves, which is the case a stale mobile client hits. */ resolveWorkspacePath: (workspaceId: string) => Promise @@ -102,7 +103,7 @@ export function createCodexStructuredLaunchResolver( return { command, args: ['app-server'], - cwd: await deps.resolveWorkspacePath(location.workspaceId), + cwd: await resolveAgentSessionLaunchDirectory(deps, record), codexHome: accountHome.path, ...(environment ? { env: { ...environment } as Record } : {}), // An empty chain is a session that has never proved a thread, so it diff --git a/src/main/ipc/floating-workspace-directory.test.ts b/src/main/ipc/floating-workspace-directory.test.ts index 55438efe587..f10f16efdd6 100644 --- a/src/main/ipc/floating-workspace-directory.test.ts +++ b/src/main/ipc/floating-workspace-directory.test.ts @@ -1,19 +1,10 @@ import { mkdtemp, mkdir, realpath, rm, symlink, unlink } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' import type { GlobalSettings } from '../../shared/global-settings-types' -const { appGetPathMock } = vi.hoisted(() => ({ - appGetPathMock: vi.fn() -})) - -vi.mock('electron', () => ({ - app: { - getPath: appGetPathMock - } -})) - import { ensureDefaultFloatingWorkspacePath, trustFloatingWorkspaceDirectory, @@ -53,14 +44,16 @@ describe('floating workspace directory', () => { homeDir = path.join(tempRoot, 'home') userDataDir = path.join(tempRoot, 'user-data') await mkdir(homeDir) - appGetPathMock.mockImplementation((name: string) => { - if (name === 'home') { - return homeDir + installFakeAppEnvironment({ + getPath: (name) => { + if (name === 'home') { + return homeDir + } + if (name === 'userData') { + return userDataDir + } + throw new Error(`unexpected app path: ${name}`) } - if (name === 'userData') { - return userDataDir - } - throw new Error(`unexpected app path: ${name}`) }) }) diff --git a/src/main/ipc/floating-workspace-directory.ts b/src/main/ipc/floating-workspace-directory.ts index 7c7a70cdd34..02142248117 100644 --- a/src/main/ipc/floating-workspace-directory.ts +++ b/src/main/ipc/floating-workspace-directory.ts @@ -1,7 +1,7 @@ import { constants as fsConstants } from 'node:fs' import { access, mkdir, realpath, stat } from 'node:fs/promises' import path from 'node:path' -import { app } from 'electron' +import { getAppEnvironment } from '../../shared/app-environment' import type { GlobalSettings } from '../../shared/global-settings-types' import type { FloatingTerminalCwdRequest } from '../../shared/ui-chrome-types' import type { Store } from '../persistence' @@ -22,7 +22,8 @@ function expandHomePath(input: string, home: string): string { } function resolveFloatingWorkspaceInput(input: string): string { - const home = app.getPath('home') + // Why the environment port: the headless runtime resolves this too, and it has no Electron app. + const home = getAppEnvironment().getPath('home') const expanded = expandHomePath(input, home) return path.isAbsolute(expanded) ? path.resolve(expanded) : path.resolve(home, expanded) } @@ -69,7 +70,8 @@ function isTrustedFloatingWorkspaceDirectory( /** The app-owned folder floating markdown documents are created in; a desktop-only root. */ export function getDefaultFloatingWorkspacePath(): string { - return path.join(app.getPath('userData'), FLOATING_WORKSPACE_DIRNAME) + // Why the environment port: the headless runtime resolves this too, and it has no Electron app. + return path.join(getAppEnvironment().getPath('userData'), FLOATING_WORKSPACE_DIRNAME) } export async function ensureDefaultFloatingWorkspacePath(): Promise { diff --git a/src/main/ipc/local-file-access-resolution.test.ts b/src/main/ipc/local-file-access-resolution.test.ts index e659a516c96..23f53aec7e3 100644 --- a/src/main/ipc/local-file-access-resolution.test.ts +++ b/src/main/ipc/local-file-access-resolution.test.ts @@ -2,6 +2,7 @@ import { mkdir, mkdtemp, realpath, rm, stat, symlink, writeFile } from 'node:fs/ import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' import type { Store } from '../persistence' import type * as RepoWorktrees from '../repo-worktrees' import { resolveAuthorizedPath } from './filesystem-auth' @@ -67,6 +68,8 @@ let project: string let outside: string beforeEach(async () => { + // The floating folder resolves through the app environment, as the headless runtime does too. + installFakeAppEnvironment({ getPath: () => userData.path }) invalidateAuthorizedRootsCache() base = await mkdtemp(join(await realpath(tmpdir()), 'orca-file-access-')) project = join(base, 'project') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index bf47e0f068a..62546e4a88e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -128,7 +128,10 @@ export type AgentSessionAcquisition = { /** A refusal before spawn that a person can act on; the site that refused names it. */ export type AgentSessionPreSpawnReason = Extract< AgentSessionRefusalReason<'agent_session_operation_invalid'>, - 'managedAccountEnvOverride' | 'accountSwitchInProgress' | 'managedAccountUnsupported' + | 'managedAccountEnvOverride' + | 'accountSwitchInProgress' + | 'managedAccountUnsupported' + | 'launchFolderMissing' > /** Acquisition failed with first-hand proof that no provider process existed. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index 85dabccda7f..3a13e142420 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -8,6 +8,7 @@ import { recoverStructuredRewind } from './structured-rewind-recovery' // state; this owns the ordering between them. import { randomUUID } from 'node:crypto' +import { isFloatingWorkspaceId } from '../../../shared/floating-workspace-worktree' import type { AgentSessionAttachResult, AgentSessionMutationResult, @@ -43,6 +44,7 @@ import { } from '../../observability/agent-session-instrumentation' export type StructuredAgentSessionAttachOptions = { + hostLaunchDirectory?: string recordPhase?: AgentSessionCreatePhaseRecorder onAcquisitionFailed?: AttachFlowInput['onAcquisitionFailed'] /** The queued message a start is for; see `StructuredAgentSessionProviderChild.startedFor`. */ @@ -69,14 +71,17 @@ export function attachStructuredAgentSessionUnderSerialize( export function attachStructuredAgentSession( context: StructuredAgentSessionAttachContext, callerKey: string, - params: AgentSessionAttachParams + params: AgentSessionAttachParams, + options: StructuredAgentSessionAttachOptions = {} ): Promise> { const sessionId = params.envelope.sessionId // Tracked from enqueue, not from its turn on the queue: a quit drains a queued attach before it // evicts, so no child is spawned behind the eviction and orphaned. const run = (recordPhase?: AgentSessionCreatePhaseRecorder) => context.tasks.trackAttach( - context.serialize(sessionId, () => runAttach(context, callerKey, params, { recordPhase })) + context.serialize(sessionId, () => + runAttach(context, callerKey, params, { ...options, recordPhase }) + ) ) if (params.envelope.expectedRuntimeFence !== null) { return run() @@ -119,14 +124,22 @@ async function runAttach( const probe = await withAgentSessionCreatePhase('probe_owner', recordPhase, () => context.runtimeState.probeOwner(sessionId) ) - // A child this attach spawns writes through a sink this attempt owns. Only a successful - // attach makes the child and its sink the session's; any other exit closes the sink with - // whatever the child queued, and leaves the conversation's child as it was. - const attemptSink = context.runtimeState.mintEventSink(sessionId) // Read before the reserve clears it: how the previous generation ended decides how whatever it // left running is settled. const priorRecord = context.deps.store.getRecord(sessionId) const priorDeathEvidence = priorRecord?.lease.deathEvidence ?? null + const launchDirectory = + !priorRecord && isFloatingWorkspaceId(params.location.workspaceId) + ? (options.hostLaunchDirectory ?? + (await context.deps.resolveWorkspacePath?.(params.location.workspaceId))) + : undefined + if (!priorRecord && isFloatingWorkspaceId(params.location.workspaceId) && !launchDirectory) { + throw new Error('floating_agent_session_launch_directory_unavailable') + } + // A child this attach spawns writes through a sink this attempt owns. Only a successful + // attach makes the child and its sink the session's; any other exit closes the sink with + // whatever the child queued, and leaves the conversation's child as it was. + const attemptSink = context.runtimeState.mintEventSink(sessionId) const attempt: { candidate: AttachCandidate | null; committed: boolean } = { candidate: null, committed: false @@ -146,6 +159,7 @@ async function runAttach( } }, authority: { + ...(launchDirectory ? { launchDirectory } : {}), spawnToken: () => context.deps.mintSpawnToken?.() ?? randomUUID(), claimKeyId: context.deps.claimKeyId, handoffOperationId: params.envelope.clientOperationId, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts index d6780082433..a4e5c0cfdd4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts @@ -87,6 +87,7 @@ export type AgentSessionAttachParams = { /** Host-supplied half of the reservation. */ export type AgentSessionAttachAuthority = { + launchDirectory?: string spawnToken: string | (() => string) claimKeyId: string handoffOperationId: string | null @@ -319,6 +320,7 @@ export function reserveRequestFor(input: { : {}), ...(authority.launchArgs ? { launchArgs: authority.launchArgs } : {}), ...(authority.launchEnv ? { launchEnv: authority.launchEnv } : {}), + ...(authority.launchDirectory ? { launchDirectory: authority.launchDirectory } : {}), ...(params.adopt ? { // Fence 1 is a new record's first, and the owner probe requires the head link to carry diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts index c0c2172230d..6805ffd5ead 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts @@ -26,7 +26,8 @@ const TYPED_START_REFUSALS = [ 'historyTooLarge', 'managedAccountEnvOverride', 'accountSwitchInProgress', - 'managedAccountUnsupported' + 'managedAccountUnsupported', + 'launchFolderMissing' ] as const satisfies readonly (AgentSessionFailureKind & AgentSessionRefusalReason<'agent_session_operation_invalid'>)[] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts index f91ab9b822b..93c86fc16df 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts @@ -201,9 +201,19 @@ function attachContext( } as unknown as StructuredAgentSessionAttachContext } -const attachParams = { - envelope: { sessionId: SESSION, clientOperationId: 'op-1' } -} as unknown as Parameters[2] +const attachParams: Parameters[2] = { + envelope: { + sessionId: SESSION, + clientOperationId: 'op-1', + expectedRuntimeFence: 1, + payloadFingerprint: 'fixture-payload' + }, + location: ownerRecord().location, + provider: 'codex', + agent: 'codex', + accountHome: ownerRecord().accountHome, + runtimeKind: 'native' +} describe('a session that leaves the host without an explicit close', () => { it('forgets the retained exact subject after the record and live session are deleted first', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 243e2d8bea2..51fd59cc8ed 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -133,6 +133,8 @@ export type StructuredAgentSessionHostDeps = { resolveLaunchEnv?: ( provider: AgentSessionRecord['provider'] ) => Promise | undefined> | Record | undefined + /** Execution-host path for a newly founded floating session. */ + resolveWorkspacePath?: (workspaceId: string) => Promise now?: () => number /** The idle sweep's period and window. Tests drive these; production takes the defaults. */ idleSweep?: { intervalMs?: number; idleMs?: number } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts index 0da2e682ec5..e92b33aac1c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -30,6 +30,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' let root: string @@ -57,6 +58,27 @@ beforeEach(() => { }) describe('attach', () => { + it('founds a client-location floating chat with the host folder and keeps it on replay', async () => { + const resolveWorkspacePath = vi.fn(async () => '/host/original-folder') + host.deps.resolveWorkspacePath = resolveWorkspacePath + const params = attachParams({ + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: FLOATING_TERMINAL_WORKTREE_ID, + workspaceKind: 'folder' + } + }) + + expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) + expect(store.getRecord(SESSION)?.launchDirectory).toBe('/host/original-folder') + resolveWorkspacePath.mockResolvedValue('/host/changed-folder') + + expect(await host.attach(CALLER, params)).toMatchObject({ ok: true, replayed: true }) + expect(store.getRecord(SESSION)?.launchDirectory).toBe('/host/original-folder') + expect(resolveWorkspacePath).toHaveBeenCalledTimes(1) + }) + it('reserves the lease, spawns through the adapter, and opens the journal', async () => { const result = await host.attach(CALLER, attachParams()) expect(result).toMatchObject({ ok: true, replayed: false }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index b82ece68435..863ab9edd68 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -252,9 +252,10 @@ export class StructuredAgentSessionHost { attach( caller: StructuredAgentSessionCaller, - params: AgentSessionAttachParams + params: AgentSessionAttachParams, + options?: Parameters[3] ): Promise> { - return attachStructuredAgentSession(this.attachContext(), caller.callerKey, params) + return attachStructuredAgentSession(this.attachContext(), caller.callerKey, params, options) } /** Test barrier: every write has landed by its call's return, so no production path needs it. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts index b69e90d3e77..104933eecb7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts @@ -130,6 +130,12 @@ describe('a create that fails before any process spawns', () => { 'managedAccountUnsupported', 'While a Claude account is added in WSL, Claude chats need a Windows Claude account. Choose or add one in Claude Accounts settings, then send your message again.' ], + [ + 'a floating chat whose launch folder is gone', + 'the folder this chat ran in no longer exists: /gone/floating', + 'launchFolderMissing', + 'The folder this chat ran in no longer exists. Restore it to continue this chat.' + ], [ "Orca's own reason", 'claude sessions pin CLAUDE_CONFIG_DIR, not CODEX_HOME', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-reservation-directory.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-reservation-directory.test.ts new file mode 100644 index 00000000000..e1fa90508f4 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-reservation-directory.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +import { reserveRequestFor, type AgentSessionAttachParams } from './structured-agent-session-attach' + +const params: AgentSessionAttachParams = { + envelope: { + sessionId: 'chat-one', + clientOperationId: 'op-one', + expectedRuntimeFence: null, + payloadFingerprint: 'fingerprint' + }, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: FLOATING_TERMINAL_WORKTREE_ID, + workspaceKind: 'folder' + }, + provider: 'claude', + agent: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/host/account' }, + runtimeKind: 'native' +} + +describe('host-resolved directory in the attach reservation', () => { + it('carries the folder into the same required reservation as the record', () => { + const request = reserveRequestFor({ + sessionId: params.envelope.sessionId, + params, + authority: { + spawnToken: 'spawn-one', + claimKeyId: 'claim-one', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + launchDirectory: '/host/floating-folder' + }, + callerKey: 'client-one', + fingerprint: 'fingerprint', + now: 123 + }) + + expect(request.launchDirectory).toBe('/host/floating-folder') + expect(request.location.workspaceId).toBe(FLOATING_TERMINAL_WORKTREE_ID) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-launch-directory.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-launch-directory.test.ts new file mode 100644 index 00000000000..7e98972c55d --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-launch-directory.test.ts @@ -0,0 +1,127 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' +import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' + +const SESSION = 'session-alpha' +const NOW = 1_800_000_000_000 + +const FLOATING: AgentSessionExecutionLocation = { + executionHostId: 'local', + wslDistro: null, + workspaceId: FLOATING_TERMINAL_WORKTREE_ID, + workspaceKind: 'folder' +} +const WORKTREE: AgentSessionExecutionLocation = { + ...FLOATING, + workspaceId: 'repo-1::/repos/one', + workspaceKind: 'git-worktree' +} + +let root: string +let store: AgentSessionRecordStore +const journals = createTrackedJournalOpener() + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-status-feed-workspace-path-')) + store = await openTestAgentSessionRecordStore(root) +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +async function feedFor(location: AgentSessionExecutionLocation) { + await store.reserveOwner({ + sessionId: SESSION, + location, + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, + expectedFence: null, + spawnToken: 'spawn-a', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { + callerKey: 'client-1', + operationId: `${NOW}-${'1'.padStart(32, '0')}`, + fingerprint: 'fp-1' + }, + now: NOW + }) + const journal = await journals.open({ + identity: { + sessionId: SESSION, + workspaceId: location.workspaceId, + hostId: 'local', + agent: 'codex', + providerHandle: codexProviderHandle('thread-1') + }, + stateDirectory: root + }) + const feed = new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), + sessions: new Map([[SESSION, { journal, params: { location, provider: 'codex' } }]]), + getRecord: (sessionId) => store.getRecord(sessionId), + now: () => NOW + }) + const events: AgentSessionStatusEvent[] = [] + feed.subscribe({ id: 'renderer', emit: (event) => events.push(event) }) + return { feed, events } +} + +function lastSummary(events: readonly AgentSessionStatusEvent[]) { + const event = events.at(-1) + if (event?.type === 'status') { + return event.session + } + if (event?.type === 'snapshot') { + return event.sessions.find((session) => session.sessionId === SESSION) + } + throw new Error('no status publication') +} + +describe('status feed pinned launch directory', () => { + it('publishes a floating session its pinned folder once the launch pins it', async () => { + const { feed, events } = await feedFor(FLOATING) + expect(lastSummary(events)).toMatchObject({ sessionId: SESSION }) + expect(lastSummary(events)).not.toHaveProperty('launchDirectory') + + await store.pinLaunchDirectory(SESSION, '/home/me/original-floating') + feed.publish(SESSION) + + expect(events.at(-1)).toMatchObject({ + type: 'status', + session: { sessionId: SESSION, launchDirectory: '/home/me/original-floating' } + }) + }) + + it('carries the pin in the snapshot a later subscriber opens with, as after a restart', async () => { + const { feed } = await feedFor(FLOATING) + await store.pinLaunchDirectory(SESSION, '/home/me/original-floating') + + const late: AgentSessionStatusEvent[] = [] + feed.subscribe({ id: 'reloaded-renderer', emit: (event) => late.push(event) }) + + expect(lastSummary(late)).toMatchObject({ launchDirectory: '/home/me/original-floating' }) + }) + + it('omits the pin for a worktree session, whose id is where it launches', async () => { + const { feed, events } = await feedFor(WORKTREE) + await store.pinLaunchDirectory(SESSION, '/repos/one') + feed.publish(SESSION) + + expect(lastSummary(events)).toMatchObject({ sessionId: SESSION }) + expect(lastSummary(events)).not.toHaveProperty('launchDirectory') + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts index 828063da409..9fa62fd170e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts @@ -361,6 +361,7 @@ describe('StructuredAgentSessionStatusFeed', () => { it('carries the record model and the running tool line the sidebar row shows', async () => { const journal = await openJournal() const { feed, events } = feedFor(new Map([[SESSION, { journal }]]), { + location: indexed({ journal }).params.location, options: { model: 'gpt-5-codex' }, providerHandleChain: [] }) @@ -674,7 +675,11 @@ describe('StructuredAgentSessionStatusFeed', () => { { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) - const record = { options: { model: 'first-model' }, providerHandleChain: [] } + const record = { + location: indexed({ journal }).params.location, + options: { model: 'first-model' }, + providerHandleChain: [] + } const { feed, events } = feedFor(new Map([[SESSION, { journal }]]), record) record.options.model = 'second-model' feed.publish(SESSION) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts index 24581c8fca2..de27ebbe8c6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -22,6 +22,7 @@ import type { AgentChildWorkView } from '../../../shared/agent-status-child-work import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionProviderChild } from './structured-agent-session-host-types' import { structuredAgentSessionProviderSessionMetadata } from './structured-agent-session-history-result' +import { agentSessionPinnedLaunchDirectory } from '../../runtime/agent-session-record-launch-directory' import { structuredStatusChildWork } from './structured-agent-session-status-child-work' import { StructuredAgentSessionJournalProjections, @@ -264,6 +265,7 @@ export class StructuredAgentSessionStatusFeed { // The journal has no model: the record's acknowledged options are where a mid-session // switch lands, so the row follows whichever is in force. const model = normalizeOptionalField(record?.options?.model, AGENT_MODEL_MAX_LENGTH) + const launchDirectory = record ? agentSessionPinnedLaunchDirectory(record) : undefined return { sessionId, workspaceId: session.params.location.workspaceId, @@ -281,6 +283,7 @@ export class StructuredAgentSessionStatusFeed { ...(model ? { model } : {}), ...this.childWorkFields(sessionId, session.params.provider), ...(providerSession ? { providerSession } : {}), + ...(launchDirectory ? { launchDirectory } : {}), updatedAt: journal.lastActivityAt() || this.deps.now() } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts index 129d6e104d4..68f8e06a40c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts @@ -33,6 +33,7 @@ export function structuredStatusSummariesEqual( a.toolName === b.toolName && a.toolInput === b.toolInput && a.lastAssistantMessage === b.lastAssistantMessage && + a.launchDirectory === b.launchDirectory && a.turnOutcome === b.turnOutcome && agentSessionBackgroundTasksEqual(a.backgroundTasks, b.backgroundTasks) && structuredStatusChildrenEqual(a.children, b.children) && diff --git a/src/main/runtime/agent-session-launch-directory.test.ts b/src/main/runtime/agent-session-launch-directory.test.ts new file mode 100644 index 00000000000..099ec84ba98 --- /dev/null +++ b/src/main/runtime/agent-session-launch-directory.test.ts @@ -0,0 +1,257 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import type * as NodeFsPromises from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' +import { resolveAgentSessionLaunchDirectory } from './agent-session-launch-directory' +import { foundAgentSessionRecord } from './agent-session-record-founding' +import type { AgentSessionRecordStore } from './agent-session-record-store' +import { openTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' +import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' + +const statFault = vi.hoisted(() => { + const fault: { error: Error | null } = { error: null } + return fault +}) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + stat: async (...args: Parameters) => { + if (statFault.error) { + throw statFault.error + } + return actual.stat(...args) + } + } +}) + +const NOW = 1_800_000_000_000 +const SESSION = 'session-alpha' + +const FLOATING: AgentSessionExecutionLocation = { + executionHostId: 'local', + wslDistro: null, + workspaceId: FLOATING_TERMINAL_WORKTREE_ID, + workspaceKind: 'folder' +} +const WORKTREE: AgentSessionExecutionLocation = { + ...FLOATING, + workspaceId: 'repo-1::/repos/one', + workspaceKind: 'git-worktree' +} +const FOLDER: AgentSessionExecutionLocation = { + ...FLOATING, + workspaceId: 'folder:folder-1', + workspaceKind: 'folder' +} + +let root: string +let store: AgentSessionRecordStore + +const LAUNCH_FOLDER_MISSING = { name: 'AgentSessionPreSpawnError', reason: 'launchFolderMissing' } + +function reserveRequest(location: AgentSessionExecutionLocation): AgentSessionReserveRequest { + return { + sessionId: SESSION, + location, + provider: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, + expectedFence: null, + spawnToken: 'spawn-a', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { + callerKey: 'client-1', + operationId: `${NOW}-${'1'.padStart(32, '0')}`, + fingerprint: 'fp-1' + }, + now: NOW + } +} + +/** A session reserved at `location`, pinned to `launchDirectory` when one is given. */ +async function reserve(location: AgentSessionExecutionLocation, launchDirectory?: string) { + const { record } = await store.reserveOwner({ + ...reserveRequest(location), + ...(launchDirectory ? { launchDirectory } : {}) + }) + return record +} + +async function directory(name: string): Promise { + const path = join(root, name) + await mkdir(path, { recursive: true }) + return path +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-launch-directory-')) + store = await openTestAgentSessionRecordStore(root) +}) + +afterEach(async () => { + statFault.error = null + vi.restoreAllMocks() + await rm(root, { recursive: true, force: true }) +}) + +describe('agent session launch directory', () => { + it('pins a new floating session to the directory its first launch used', async () => { + const configured = await directory('floating-a') + const record = await reserve(FLOATING, configured) + const resolveWorkspacePath = vi.fn(() => directory('changed-setting')) + + const cwd = await resolveAgentSessionLaunchDirectory({ store, resolveWorkspacePath }, record) + + expect(cwd).toBe(configured) + expect(resolveWorkspacePath).not.toHaveBeenCalled() + const reopened = await openTestAgentSessionRecordStore(root) + expect(reopened.getRecord(SESSION)?.launchDirectory).toBe(configured) + }) + + it('resolves a worktree session by id without storing a second path', async () => { + const record = await reserve(WORKTREE) + + await expect( + resolveAgentSessionLaunchDirectory( + { store, resolveWorkspacePath: async (id) => `/resolved/${id}` }, + record + ) + ).resolves.toBe(`/resolved/${WORKTREE.workspaceId}`) + expect(store.getRecord(SESSION)?.launchDirectory).toBeUndefined() + }) + + it('resumes a floating session in its pinned folder after the floating setting changed', async () => { + const original = await directory('floating-a') + const changed = await directory('floating-b') + const record = await reserve(FLOATING, original) + const resolveWorkspacePath = vi.fn(async () => changed) + + await expect( + resolveAgentSessionLaunchDirectory({ store, resolveWorkspacePath }, record) + ).resolves.toBe(original) + expect(resolveWorkspacePath).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)?.launchDirectory).toBe(original) + }) + + it('refuses a floating resume whose pinned folder is gone instead of substituting one', async () => { + const gone = join(root, 'deleted-floating') + const record = await reserve(FLOATING, gone) + const fallback = await directory('app-owned-floating') + const resolveWorkspacePath = vi.fn(async () => fallback) + + const failure = resolveAgentSessionLaunchDirectory({ store, resolveWorkspacePath }, record) + + await expect(failure).rejects.toMatchObject(LAUNCH_FOLDER_MISSING) + expect(resolveWorkspacePath).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)?.launchDirectory).toBe(gone) + }) + + it('refuses a floating resume whose pinned path is now a file', async () => { + const file = join(root, 'not-a-folder') + await writeFile(file, '') + const record = await reserve(FLOATING, file) + + await expect( + resolveAgentSessionLaunchDirectory({ store, resolveWorkspacePath: async () => root }, record) + ).rejects.toMatchObject(LAUNCH_FOLDER_MISSING) + }) + + it('reports a pinned folder it cannot read as that failure, not as a missing folder', async () => { + const pinned = await directory('floating-locked') + const record = await reserve(FLOATING, pinned) + const denied = Object.assign(new Error(`EACCES: permission denied, stat '${pinned}'`), { + code: 'EACCES' + }) + statFault.error = denied + const resolveWorkspacePath = vi.fn(async () => root) + + const failure = resolveAgentSessionLaunchDirectory({ store, resolveWorkspacePath }, record) + + await expect(failure).rejects.toBe(denied) + expect(resolveWorkspacePath).not.toHaveBeenCalled() + }) + + it('launches in the resolved directory when writing its pin fails', async () => { + const configured = await directory('floating-a') + const record = await reserve(FLOATING) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const pinFailure = new Error('disk full') + const pinLaunchDirectory = vi.fn(async () => { + throw pinFailure + }) + + await expect( + resolveAgentSessionLaunchDirectory( + { store: { pinLaunchDirectory }, resolveWorkspacePath: async () => configured }, + record + ) + ).resolves.toBe(configured) + expect(pinLaunchDirectory).toHaveBeenCalledExactlyOnceWith(SESSION, configured) + expect(warn).toHaveBeenCalledWith( + '[agent-session] launch directory pin failed', + SESSION, + pinFailure + ) + }) + + it.each([ + ['git worktree', WORKTREE], + ['folder', FOLDER] + ])('keeps resolving a %s resume by id, not by its pin', async (_kind, location) => { + const record = await reserve(location, '/where/it/first/ran') + + await expect( + resolveAgentSessionLaunchDirectory( + { store, resolveWorkspacePath: async (id) => `/resolved/${id}` }, + record + ) + ).resolves.toBe(`/resolved/${location.workspaceId}`) + expect(store.getRecord(SESSION)?.launchDirectory).toBe('/where/it/first/ran') + }) + + it('founds a /clear replacement already pinned to the folder it inherits', async () => { + const inherited = await directory('floating-cleared') + const source = await reserve(FLOATING, inherited) + const replacement = foundAgentSessionRecord( + { ...source, sessionId: 'clear-replacement' }, + { claimKeyId: 'key-1', now: NOW } + ) + expect(replacement.launchDirectory).toBe(inherited) + + await expect( + resolveAgentSessionLaunchDirectory( + { store, resolveWorkspacePath: async () => '/floating/current-setting' }, + replacement + ) + ).resolves.toBe(inherited) + }) + + it('pins a legacy floating record once, so a later setting change cannot move it', async () => { + const current = await directory('floating-current') + const later = await directory('floating-later') + const legacy = await reserve(FLOATING) + expect(legacy.launchDirectory).toBeUndefined() + + await expect( + resolveAgentSessionLaunchDirectory( + { store, resolveWorkspacePath: async () => current }, + legacy + ) + ).resolves.toBe(current) + const pinned = store.getRecord(SESSION) + expect(pinned?.launchDirectory).toBe(current) + if (!pinned) { + throw new Error('the pinned record disappeared') + } + + await expect( + resolveAgentSessionLaunchDirectory({ store, resolveWorkspacePath: async () => later }, pinned) + ).resolves.toBe(current) + }) +}) diff --git a/src/main/runtime/agent-session-launch-directory.ts b/src/main/runtime/agent-session-launch-directory.ts new file mode 100644 index 00000000000..3616db4d68f --- /dev/null +++ b/src/main/runtime/agent-session-launch-directory.ts @@ -0,0 +1,66 @@ +import { stat } from 'node:fs/promises' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' +import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { AgentSessionRecordStore } from './agent-session-record-store' +import { agentSessionPinnedLaunchDirectory } from './agent-session-record-launch-directory' +import { isFloatingWorkspaceId } from '../../shared/floating-workspace-worktree' + +/** The floating folder a session ran in is gone; resuming anywhere else would be a different chat. */ +export function agentSessionLaunchFolderMissing( + launchDirectory: string +): AgentSessionPreSpawnError { + return new AgentSessionPreSpawnError( + new Error(`the folder this chat ran in no longer exists: ${launchDirectory}`), + { reason: 'launchFolderMissing' } + ) +} + +export type AgentSessionLaunchDirectoryDeps = { + store: Pick + /** Absolute path of a workspace on this host, by the workspace's current directory policy. */ + resolveWorkspacePath: (workspaceId: string) => Promise +} + +async function isDirectory(path: string): Promise { + try { + return (await stat(path)).isDirectory() + } catch (error) { + // Only absence means the folder is gone; a permission or I/O failure is reported as itself. + if (isDefinitiveAbsence(error)) { + return false + } + throw error + } +} + +/** + * The one answer to "which directory does this acquisition launch the provider in". + * + * The floating workspace id names a setting, not a place, so a floating session resumes in the + * folder it was pinned to and refuses when that folder is gone. Worktree and folder ids name a + * durable place and keep resolving by id. Old floating records without a pin are repaired on + * launch; new floating records receive the directory in their founding reservation. + */ +export async function resolveAgentSessionLaunchDirectory( + deps: AgentSessionLaunchDirectoryDeps, + record: AgentSessionRecord +): Promise { + const pinned = agentSessionPinnedLaunchDirectory(record) + if (pinned !== undefined) { + if (!(await isDirectory(pinned))) { + throw agentSessionLaunchFolderMissing(pinned) + } + return pinned + } + const resolved = await deps.resolveWorkspacePath(record.location.workspaceId) + if (isFloatingWorkspaceId(record.location.workspaceId) && record.launchDirectory === undefined) { + try { + await deps.store.pinLaunchDirectory(record.sessionId, resolved) + } catch (error) { + // Bookkeeping must not gate the launch; an unpinned record is pinned by its next launch. + console.warn('[agent-session] launch directory pin failed', record.sessionId, error) + } + } + return resolved +} diff --git a/src/main/runtime/agent-session-record-founding.ts b/src/main/runtime/agent-session-record-founding.ts index d759a2461e0..464b144e5ce 100644 --- a/src/main/runtime/agent-session-record-founding.ts +++ b/src/main/runtime/agent-session-record-founding.ts @@ -6,7 +6,13 @@ import { /** Who a conversation's agent is and how it launches: the same whether it is created or founded. */ export type AgentSessionRecordIdentity = Pick< AgentSessionRecord, - 'sessionId' | 'location' | 'provider' | 'accountHome' | 'options' | 'launchArgs' + | 'sessionId' + | 'location' + | 'provider' + | 'accountHome' + | 'options' + | 'launchArgs' + | 'launchDirectory' > export function agentSessionRecordIdentityFields( @@ -21,6 +27,8 @@ export function agentSessionRecordIdentityFields( accountHome: identity.accountHome, ...(identity.options ? { options: { ...identity.options } } : {}), ...(identity.launchArgs ? { launchArgs: [...identity.launchArgs] } : {}), + // A /clear continues in the same tab, so it stays in the folder the chat ran in. + ...(identity.launchDirectory ? { launchDirectory: identity.launchDirectory } : {}), createdAt: now, updatedAt: now } diff --git a/src/main/runtime/agent-session-record-launch-directory.ts b/src/main/runtime/agent-session-record-launch-directory.ts new file mode 100644 index 00000000000..e00759d8377 --- /dev/null +++ b/src/main/runtime/agent-session-record-launch-directory.ts @@ -0,0 +1,27 @@ +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { isFloatingWorkspaceId } from '../../shared/floating-workspace-worktree' + +/** + * Pin the directory a session's provider was launched in. + * + * First writer wins: the pin records where the session ran, so no later launch may move it — a + * move would be an explicit act, not a side effect of resolving a workspace again. + */ +export function pinAgentSessionRecordLaunchDirectory( + record: AgentSessionRecord, + launchDirectory: string, + now: number +): AgentSessionRecord { + if (record.launchDirectory !== undefined) { + return record + } + return { ...record, launchDirectory, updatedAt: now } +} + +/** + * The pinned directory a launch is held to, or undefined when the workspace id decides. Only the + * floating id names a setting rather than a place, so only a floating session is held to its pin. + */ +export function agentSessionPinnedLaunchDirectory(record: AgentSessionRecord): string | undefined { + return isFloatingWorkspaceId(record.location.workspaceId) ? record.launchDirectory : undefined +} diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index eed772bcf30..bca7cf49cf1 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -8,7 +8,7 @@ import { type AgentSessionConversationClear } from './agent-session-conversation-command-record' import { setAgentSessionRecordConversationName } from './agent-session-record-conversation-name' - +import { pinAgentSessionRecordLaunchDirectory } from './agent-session-record-launch-directory' import { agentSessionOperationKey, type AgentSessionOperationClaim, @@ -67,7 +67,12 @@ import { type AgentSessionReserveResult } from './agent-session-reservation-admission' import type { AgentSessionStoreState } from './agent-session-record-store-file' -import { setAgentSessionTabVisibility, showAgentSessionTabs } from './agent-session-tab-table' +import { + agentSessionVisibleTabIndex, + listVisibleAgentSessionIds, + setAgentSessionTabVisibility, + showAgentSessionTabs +} from './agent-session-tab-table' import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' import type { JournalOperationReceipt } from '../native-chat/agent-session-journal/journal-row-writer' import { loadAgentSessionStoreRows } from './agent-session-record-rows' @@ -114,20 +119,10 @@ export class AgentSessionRecordStore { /** Whether this host has recorded a chat, readable or not. Nothing removes a record row. */ holdsRecords = (): boolean => this.state.records.size > 0 || this.state.unreadableRecords.size > 0 - listVisibleSessionIds = (): string[] => - (this.state.sessionTabs?.sessionIds() ?? []).filter((sessionId) => - this.state.records.has(sessionId) - ) + listVisibleSessionIds = (): string[] => listVisibleAgentSessionIds(this.state) - /** Unrecorded, `sessionIds` are the tab rows a chat opened while the import was owed left. */ - getVisibleSessionTabIndex = (): { present: boolean; sessionIds: string[] } => ({ - present: this.state.sessionTabs !== null, - sessionIds: this.state.sessionTabs - ? this.listVisibleSessionIds() - : (this.state.unrecordedSessionTabs?.sessionIds() ?? []).filter((sessionId) => - this.state.records.has(sessionId) - ) - }) + getVisibleSessionTabIndex = (): { present: boolean; sessionIds: string[] } => + agentSessionVisibleTabIndex(this.state) /** The id of the chat tab showing this conversation, if one does. */ getSessionTabId = (sessionId: string): string | null => @@ -173,6 +168,12 @@ export class AgentSessionRecordStore { setAgentSessionRecordConversationName(record, name, Date.now()) ) + /** Unfenced like the name: it records where a launch ran and never contends with the lease. */ + pinLaunchDirectory = (sessionId: string, launchDirectory: string): Promise => + this.mutate(sessionId, (record) => + pinAgentSessionRecordLaunchDirectory(record, launchDirectory, Date.now()) + ) + /** A record this build cannot validate: readable as present, never grantable as a writer. */ isSessionUnreadable(sessionId: string): boolean { return this.state.unreadableRecords.has(sessionId) diff --git a/src/main/runtime/agent-session-reservation-admission.test.ts b/src/main/runtime/agent-session-reservation-admission.test.ts index 0729ebfc35b..7ae309560b0 100644 --- a/src/main/runtime/agent-session-reservation-admission.test.ts +++ b/src/main/runtime/agent-session-reservation-admission.test.ts @@ -99,6 +99,21 @@ describe('adopted handle chain seeding', () => { }) }) +describe('floating launch directory reservation', () => { + it('stores the host-selected folder when the record is first reserved', () => { + const request = reserveRequest({ launchDirectory: '/host/first-folder' }) + const { record, disposition } = applyAgentSessionReservation( + storeState(), + request, + LEASE_TTL_MS + ) + + expect(disposition).toBe('created') + expect(record.launchDirectory).toBe('/host/first-folder') + expect(record.lease.claimStatus).toBe('reserved') + }) +}) + describe('adopted conversation ownership', () => { it('refuses when another record already holds the same conversation root', () => { // The held link names a leaf; the adoption names none. Same root is the whole test: keying on @@ -149,6 +164,7 @@ describe('adopted conversation ownership', () => { ), location: LOCATION, accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, + launchDirectory: '/original-folder', providerHandleChain: [link] } @@ -156,6 +172,7 @@ describe('adopted conversation ownership', () => { storeState([committed]), reserveRequest({ adoptedHandleLink: link, + launchDirectory: '/changed-folder', expectedFence: 1, handoffOperationId: 'handoff-1' }), @@ -164,6 +181,7 @@ describe('adopted conversation ownership', () => { expect(disposition).toBe('retry-reservation') expect(record.providerHandleChain).toEqual([link]) + expect(record.launchDirectory).toBe('/original-folder') }) it('refuses a Codex adoption another record already holds', () => { diff --git a/src/main/runtime/agent-session-reservation-admission.ts b/src/main/runtime/agent-session-reservation-admission.ts index d36d87b0e99..1344cc128d0 100644 --- a/src/main/runtime/agent-session-reservation-admission.ts +++ b/src/main/runtime/agent-session-reservation-admission.ts @@ -49,6 +49,8 @@ import type { AgentSessionStoreState } from './agent-session-record-store-file' import { agentSessionRecordIdentityFields } from './agent-session-record-founding' export type AgentSessionReserveRequest = { + /** Host-resolved floating directory committed with the first owner reservation. */ + launchDirectory?: string sessionId: string location: AgentSessionExecutionLocation provider: StructuredAgentId diff --git a/src/main/runtime/agent-session-tab-table.ts b/src/main/runtime/agent-session-tab-table.ts index 15ace0af761..e95e80d9d1e 100644 --- a/src/main/runtime/agent-session-tab-table.ts +++ b/src/main/runtime/agent-session-tab-table.ts @@ -114,6 +114,26 @@ function reopenedTabId(sessionId: string, held: (tabId: string) => boolean): str return tabId } +/** The sessions whose chat tab is shown, in tab order, skipping any without a record. */ +export function listVisibleAgentSessionIds(state: AgentSessionStoreState): string[] { + return (state.sessionTabs?.sessionIds() ?? []).filter((sessionId) => state.records.has(sessionId)) +} + +/** Unrecorded, `sessionIds` are the tab rows a chat opened while the import was owed left. */ +export function agentSessionVisibleTabIndex(state: AgentSessionStoreState): { + present: boolean + sessionIds: string[] +} { + return { + present: state.sessionTabs !== null, + sessionIds: state.sessionTabs + ? listVisibleAgentSessionIds(state) + : (state.unrecordedSessionTabs?.sessionIds() ?? []).filter((sessionId) => + state.records.has(sessionId) + ) + } +} + export function setAgentSessionTabVisibility( state: AgentSessionStoreState, sessionId: string, diff --git a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts index e51f2c26ae2..0fcdaae9532 100644 --- a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts +++ b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts @@ -17,6 +17,10 @@ import { hasPersistedStructuredAgentSessionStore as hasPersistedStructuredAgentS import { ensureStructuredAgentSessionHostUnlessRefused } from './structured-agent-session-host-refusal' import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { parseWslUncPath } from '../../shared/wsl-paths' +import { + isFloatingWorkspaceId, + isFloatingWorkspaceSelector +} from '../../shared/floating-workspace-worktree' import { parseWorkspaceKey } from '../../shared/workspace-scope' import { agentSessionAccountHome, @@ -53,7 +57,8 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR protected structuredAgentAccountHomePathResolver( agent: StructuredAgentId, worktree: string, - purpose: 'launch' | 'read' + purpose: 'launch' | 'read', + hostLaunchDirectory?: string ) { const registration = structuredAgentRuntimeRegistration(agent) if (!registration) { @@ -73,7 +78,9 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR purpose, workspacePath: purpose === 'launch' - ? async () => (await this.resolveRuntimeFileTarget(worktree)).worktree.path + ? async () => + hostLaunchDirectory ?? + (await this.resolveRuntimeFileTarget(worktree)).worktree.path : null }, services @@ -107,7 +114,10 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR const target = await this.resolveRuntimeFileTarget(worktreeSelector) const repo = this.store?.getRepo(target.worktree.repoId) const folderScope = parseWorkspaceKey(target.worktree.id) - const folderWorkspace = folderScope?.type === 'folder' + // The floating workspace is a plain directory with no repo git options, which is exactly what + // `folder` denotes here — it describes how Orca manages the place, not whether git is in it. + const folderWorkspace = + folderScope?.type === 'folder' || isFloatingWorkspaceId(target.worktree.id) // WSL routing describes *this* machine; no remote or runtime host may inherit // it. Both branches key on executionHostId: the target no longer carries a // connectionId, which used to spell remote, unresolved and local alike. @@ -146,18 +156,23 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR agent: StructuredAgentId callerKey?: string resumeFrom?: { providerSessionId: string } - }): Promise { + }): Promise { + const hostLaunchDirectory = isFloatingWorkspaceSelector(input.worktree) + ? (await this.resolveRuntimeFileTarget(input.worktree)).worktree.path + : undefined const resolveAccountHomePath = this.structuredAgentAccountHomePathResolver( input.agent, input.worktree, - 'launch' + 'launch', + hostLaunchDirectory ) if (!resolveAccountHomePath) { throw agentSessionRefusalError('structured_agent_session_unsupported', { reason: 'hostUnsupported' }) } - return this.resolveStructuredAgentSessionIntent(input, resolveAccountHomePath) + const resolved = await this.resolveStructuredAgentSessionIntent(input, resolveAccountHomePath) + return hostLaunchDirectory ? { ...resolved, hostLaunchDirectory } : resolved } /** diff --git a/src/main/runtime/orca-runtime-persist-headless-terminal-title.ts b/src/main/runtime/orca-runtime-persist-headless-terminal-title.ts index 1869ac70819..932d6075cd5 100644 --- a/src/main/runtime/orca-runtime-persist-headless-terminal-title.ts +++ b/src/main/runtime/orca-runtime-persist-headless-terminal-title.ts @@ -15,6 +15,7 @@ import { } from '../../shared/execution-host' import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import { resolveWorktreeHostRouting } from './worktree-launch-host-repo' +import { isFloatingWorkspaceSelector } from '../../shared/floating-workspace-worktree' export class OrcaRuntimeWithPersistHeadlessTerminalTitle extends OrcaRuntimeWithMoveHeadlessMobileSessionTab { // Persist a manual terminal rename so a headless rebuild keeps the title @@ -197,6 +198,16 @@ export class OrcaRuntimeWithPersistHeadlessTerminalTitle extends OrcaRuntimeWith worktree: ResolvedWorktree executionHostId: ExecutionHostId }> { + // The floating workspace has no row to resolve. Answering here rather than at each caller + // keeps one resolver authoritative for "where does this workspace live". + if (isFloatingWorkspaceSelector(worktreeSelector)) { + return { + worktree: this.floatingWorkspaceToResolvedWorktree( + await this.resolveFloatingWorkspacePath() + ), + executionHostId: LOCAL_EXECUTION_HOST_ID + } + } const folderScope = await this.resolveFolderWorkspaceLaunchScope(worktreeSelector) if (folderScope?.folderWorkspace) { // A folder workspace has no repo row to disagree with; its own inference already threw on an diff --git a/src/main/runtime/orca-runtime-resolve-browser-network-execution-host-for-worktree.ts b/src/main/runtime/orca-runtime-resolve-browser-network-execution-host-for-worktree.ts index 370ed4da51b..b3a33f356f1 100644 --- a/src/main/runtime/orca-runtime-resolve-browser-network-execution-host-for-worktree.ts +++ b/src/main/runtime/orca-runtime-resolve-browser-network-execution-host-for-worktree.ts @@ -1,6 +1,11 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. import { OrcaRuntimeWithTransitionGraphReloadToTerminalState } from './orca-runtime-transition-graph-reload-to-terminal-state' import type { ExecutionHostId } from '../../shared/execution-host' +import { + floatingWorkspaceToWorktree, + isFloatingWorkspaceSelector +} from '../../shared/floating-workspace-worktree' +import { resolveFloatingTerminalCwd } from '../ipc/floating-workspace-directory' import type { BrowserNetworkExecutionHost } from '../../shared/browser-client-host-protocol' import { LOCAL_EXECUTION_HOST_ID, @@ -14,6 +19,7 @@ import { resolveWorktreeLaunchHost } from './worktree-launch-host-repo' import { folderWorkspaceKey, parseWorkspaceKey } from '../../shared/workspace-scope' import type { FolderWorkspace } from '../../shared/folder-workspace-types' import type { ResolvedWorktree } from './runtime-worktree-path-identity' +import type { Worktree } from '../../shared/worktree/types' import { folderWorkspaceToWorktree } from '../../shared/folder-workspace-worktree' import type { TerminalWorkspaceLaunchScope } from './runtime-legacy-worker-terminal-recovery-types' import { resolveTerminalStartupCwd } from '../../shared/terminal-startup-cwd' @@ -26,9 +32,25 @@ import { getExplicitWorktreeIdSelector } from './runtime-worktree-selection' import { WORKTREE_ID_SEPARATOR } from '../../shared/worktree/id' import { WorktreeIdRequiresFullPathError } from './runtime-worktree-lineage-resolution' import { triggerTerminalSpawnPushTargetMaterialization } from './runtime-terminal-spawn-push-target-materialization' -import type { Worktree } from '../../shared/worktree/types' import { resolveCreatedWorktreeTerminalTarget } from './runtime-created-worktree-terminal-target' +// Why: folder and floating workspaces have no worktree row, so no parent, children or lineage. +function withoutWorktreeLineage(worktree: Worktree): ResolvedWorktree { + return { + ...worktree, + parentWorktreeId: null, + childWorktreeIds: [], + lineage: null, + git: { + path: worktree.path, + head: worktree.head, + branch: worktree.branch, + isBare: worktree.isBare, + isMainWorktree: worktree.isMainWorktree + } + } +} + export class OrcaRuntimeWithResolveBrowserNetworkExecutionHostForWorktree extends OrcaRuntimeWithTransitionGraphReloadToTerminalState { protected resolveBrowserNetworkExecutionHostForWorktree(worktree?: { id: string @@ -66,21 +88,21 @@ export class OrcaRuntimeWithResolveBrowserNetworkExecutionHostForWorktree extend : this.resolveEmulatorWorkspaceId(selector) } + // Why one resolver: host-launched floating terminals and floating chat sessions run in the same + // directory the panel's own terminals use — the user's floating-workspace setting. + protected async resolveFloatingWorkspacePath(): Promise { + const store = this.store + return store + ? resolveFloatingTerminalCwd(store, { path: store.getSettings().floatingTerminalCwd }) + : homedir() + } + + protected floatingWorkspaceToResolvedWorktree(path: string): ResolvedWorktree { + return withoutWorktreeLineage(floatingWorkspaceToWorktree(path)) + } + protected folderWorkspaceToResolvedWorktree(folderWorkspace: FolderWorkspace): ResolvedWorktree { - const worktree = folderWorkspaceToWorktree(folderWorkspace) - return { - ...worktree, - parentWorktreeId: null, - childWorktreeIds: [], - lineage: null, - git: { - path: worktree.path, - head: worktree.head, - branch: worktree.branch, - isBare: worktree.isBare, - isMainWorktree: worktree.isMainWorktree - } - } + return withoutWorktreeLineage(folderWorkspaceToWorktree(folderWorkspace)) } protected resolveWorkspaceTerminalStartupCwd( @@ -101,15 +123,12 @@ export class OrcaRuntimeWithResolveBrowserNetworkExecutionHostForWorktree extend selector: string, createdWorktree?: Worktree ): Promise { - const floatingTerminalSelector = - selector === FLOATING_TERMINAL_WORKTREE_ID || - selector === `id:${FLOATING_TERMINAL_WORKTREE_ID}` - if (floatingTerminalSelector) { - // Why: the floating sentinel is terminal-only — no backing repo/worktree record for other workspace APIs. + if (isFloatingWorkspaceSelector(selector)) { + // Why no managed worktree: the floating sentinel has no repo or worktree record behind it. return { scope: { id: FLOATING_TERMINAL_WORKTREE_ID, - path: homedir(), + path: await this.resolveFloatingWorkspacePath(), connectionId: null, repo: null, folderWorkspace: null diff --git a/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts b/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts index edff20ee99c..c6e75cd5a23 100644 --- a/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts +++ b/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts @@ -4,12 +4,18 @@ const applyAgentWorkspaceTrust = vi.hoisted(() => vi.fn(async () => ({}))) vi.mock('../agent-workspace-trust', () => ({ applyAgentWorkspaceTrust })) import { OrcaRuntimeService } from './orca-runtime' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' +import { attachFingerprintFields } from '../native-chat/agent-session-wire/structured-agent-session-attach' beforeEach(() => { applyAgentWorkspaceTrust.mockClear() }) -function createCodexIntentRuntime(settings: Record) { +function createCodexIntentRuntime( + settings: Record, + workspaceId = 'workspace-1', + workspacePath = '/repos/workspace-1' +) { const prepareCodexStructuredLaunch = vi.fn(() => '/accounts/selected/home') const runtime = new OrcaRuntimeService( // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: create intent only reads getSettings from the store. @@ -24,21 +30,39 @@ function createCodexIntentRuntime(settings: Record) { resolveStructuredAgentSessionLocation: vi.fn(async () => ({ executionHostId: 'local', wslDistro: null, - workspaceId: 'workspace-1', + workspaceId, workspaceKind: 'git-worktree' as const })), - resolveRuntimeFileTarget: vi.fn(async () => ({ worktree: { path: '/repos/workspace-1' } })) + resolveRuntimeFileTarget: vi.fn(async () => ({ worktree: { path: workspacePath } })) }) const createIntent = () => runtime.resolveStructuredAgentSessionCreateIntent({ envelope: { sessionId: 'session-1', clientOperationId: 'operation-1' }, - worktree: 'id:workspace-1', + worktree: `id:${workspaceId}`, agent: 'codex' }) return { prepareCodexStructuredLaunch, createIntent } } describe('structured Codex folder trust', () => { + it('takes a floating chat directory from the host-resolved workspace', async () => { + const { createIntent } = createCodexIntentRuntime( + {}, + FLOATING_TERMINAL_WORKTREE_ID, + '/host/floating-folder' + ) + + const intent = await createIntent() + + expect(intent.hostLaunchDirectory).toBe('/host/floating-folder') + expect(applyAgentWorkspaceTrust).toHaveBeenCalledWith( + 'codex', + '/host/floating-folder', + expect.any(Object) + ) + expect(attachFingerprintFields(intent)).not.toHaveProperty('hostLaunchDirectory') + }) + it('pre-trusts the chat folder before launch preparation, as a Codex terminal launch does', async () => { const { prepareCodexStructuredLaunch, createIntent } = createCodexIntentRuntime({}) diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-03.spec.ts index de21af9f013..d2b1c957cf9 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-03.spec.ts @@ -1,3 +1,4 @@ +import { realpath } from 'node:fs/promises' import { describe, expect, it, vi } from 'vitest' import { detectAgentCommandsOnHost } from '../../preflight/agent-detection' import { @@ -5,7 +6,6 @@ import { OrcaRuntimeService, SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV, electronMocks, - homedir, ipcMain, join, mkdtemp, @@ -366,7 +366,12 @@ describe('OrcaRuntimeService', () => { } ])('creates background terminal sessions for a $label', async ({ selector }) => { const spawn = vi.fn().mockResolvedValue({ id: 'pty-floating' }) - const runtime = new OrcaRuntimeService(store) + const floatingDir = await realpath(await mkdtemp(join(tmpdir(), 'orca-floating-'))) + const runtimeStore: typeof store = { + ...store, + getSettings: () => ({ ...store.getSettings(), floatingTerminalCwd: floatingDir }) + } + const runtime = new OrcaRuntimeService(runtimeStore) runtime.setPtyController({ spawn, write: () => true, @@ -393,8 +398,9 @@ describe('OrcaRuntimeService', () => { worktreeId?: string } | undefined + // Why the configured folder: host-launched floating terminals start where the panel's own do. expect(spawnCall).toMatchObject({ - cwd: homedir(), + cwd: floatingDir, connectionId: null, worktreeId: FLOATING_TERMINAL_WORKTREE_ID }) diff --git a/src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts b/src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts index 1df2c1f7433..4802152fa62 100644 --- a/src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts @@ -2,9 +2,21 @@ import { homedir } from 'node:os' import { afterEach, describe, expect, it, vi } from 'vitest' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { OrcaRuntimeService } from '../../orca-runtime' +import type { ResolvedWorktree } from '../../runtime-worktree-path-identity' import { AGENT_LAUNCH_METHODS } from './agent-launch' import { CAPABLE_CLIENT, methodNamed, STRUCTURED_PREFERENCE } from './agent-launch.test-fixture' +const createStructuredSession = vi.hoisted(() => + vi.fn(async (args: { envelope: { sessionId: string } }) => ({ + ok: true as const, + value: { sessionId: args.envelope.sessionId, fence: 1 } + })) +) + +vi.mock('./structured-agent-session-create', () => ({ + createStructuredAgentSessionForWorktree: createStructuredSession +})) + vi.mock('electron', () => ({ BrowserWindow: { fromId: vi.fn(() => null) }, webContents: { fromId: vi.fn(() => null) }, @@ -15,7 +27,34 @@ vi.mock('electron', () => ({ const launch = methodNamed(AGENT_LAUNCH_METHODS, 'agent.launch') const selectors = [FLOATING_TERMINAL_WORKTREE_ID, `id:${FLOATING_TERMINAL_WORKTREE_ID}`] -afterEach(() => vi.restoreAllMocks()) +afterEach(() => { + vi.restoreAllMocks() + createStructuredSession.mockClear() +}) + +describe('floating workspace resolved-worktree minting', () => { + // Regression: this mixin file is @ts-nocheck, so a missing module import there is invisible to + // `pnpm tc` and only fails at call time (shipped once as a ReferenceError from the structured + // createSupport path). Calling the real method pins the import wiring. + it('mints the synthetic floating row through the shared module', () => { + class FloatingResolverProbe extends OrcaRuntimeService { + mintFloatingResolvedWorktree(path: string): ResolvedWorktree { + return this.floatingWorkspaceToResolvedWorktree(path) + } + } + + const resolved = new FloatingResolverProbe().mintFloatingResolvedWorktree('/tmp/floating-qa') + + expect(resolved).toMatchObject({ + id: FLOATING_TERMINAL_WORKTREE_ID, + path: '/tmp/floating-qa', + parentWorktreeId: null, + childWorktreeIds: [], + lineage: null, + git: { path: '/tmp/floating-qa', head: '', branch: '', isBare: false, isMainWorktree: false } + }) + }) +}) describe('agent.launch with the real floating workspace resolver', () => { it.each(selectors)('resolves %s without a managed worktree record', async (selector) => { @@ -33,8 +72,20 @@ describe('agent.launch with the real floating workspace resolver', () => { }) }) + // Why here: the runtime resolvers this crosses are @ts-nocheck, so only a call that runs them + // proves the floating workspace resolves to a location a structured session can be filed under. + it.each(selectors)('supports a structured session for %s on the local host', async (selector) => { + const runtime = new OrcaRuntimeService() + + await expect( + runtime.getStructuredAgentSessionCreateSupport(selector, 'codex') + ).resolves.toEqual({ + supported: true + }) + }) + describe.each([true, false])('structured preference %s', (structuredPreference) => { - it.each(selectors)('launches a terminal through %s', async (selector) => { + it.each(selectors)('routes %s by preference, not by workspace kind', async (selector) => { const runtime = new OrcaRuntimeService() vi.spyOn(runtime, 'getClientSettings').mockReturnValue( // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch reads only these preferences and optional agentCmdOverrides; no other settings consumer runs because terminal creation is stubbed. @@ -55,25 +106,36 @@ describe('agent.launch with the real floating workspace resolver', () => { }) const result = await launch.handler( - launch.params.parse({ agent: 'claude', target: { kind: 'existing', worktree: selector } }), + launch.params.parse({ agent: 'codex', target: { kind: 'existing', worktree: selector } }), { runtime, ...CAPABLE_CLIENT } ) expect(scope).toHaveBeenCalledExactlyOnceWith(selector) - expect(createSupport).not.toHaveBeenCalled() - expect(structuredHost).not.toHaveBeenCalled() - expect(createTerminal).toHaveBeenCalledExactlyOnceWith( - `id:${FLOATING_TERMINAL_WORKTREE_ID}`, - { startupAgent: 'claude', onPtySpawnDispatched: expect.any(Function) } - ) - expect(result).toMatchObject({ - worktreeId: FLOATING_TERMINAL_WORKTREE_ID, - outcome: { kind: 'terminal', handle: 'term_floating' }, - receipt: { - mode: 'terminal', - reason: structuredPreference ? 'structured_unsupported_on_host' : 'user_default' - } - }) + if (structuredPreference) { + expect(createSupport).toHaveBeenCalledWith(`id:${FLOATING_TERMINAL_WORKTREE_ID}`, 'codex') + expect(createStructuredSession).toHaveBeenCalledWith( + expect.objectContaining({ worktree: `id:${FLOATING_TERMINAL_WORKTREE_ID}` }) + ) + expect(createTerminal).not.toHaveBeenCalled() + expect(result).toMatchObject({ + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + outcome: { kind: 'structured', sessionId: expect.any(String) }, + receipt: { mode: 'structured' } + }) + } else { + expect(createStructuredSession).not.toHaveBeenCalled() + expect(createSupport).not.toHaveBeenCalled() + expect(structuredHost).not.toHaveBeenCalled() + expect(createTerminal).toHaveBeenCalledExactlyOnceWith( + `id:${FLOATING_TERMINAL_WORKTREE_ID}`, + { startupAgent: 'codex', onPtySpawnDispatched: expect.any(Function) } + ) + expect(result).toMatchObject({ + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + outcome: { kind: 'terminal', handle: 'term_floating' }, + receipt: { mode: 'terminal', reason: 'user_default' } + }) + } }) }) }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create.ts b/src/main/runtime/rpc/methods/structured-agent-session-create.ts index af82880543f..d1857652f2d 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create.ts @@ -36,6 +36,7 @@ import { export type PreparedStructuredAgentSessionCreate = { host: StructuredAgentSessionHost attachParams: AgentSessionAttachParams + hostLaunchDirectory?: string /** Null when the caller supplied its own location; only a resolved worktree publishes a tab. */ tab: { workspaceId: string; agent: StructuredAgentId } | null } @@ -100,9 +101,15 @@ export async function prepareStructuredAgentSessionCreateForWorktree(args: { fields: attachFingerprintFields({ ...resolved, envelope: args.envelope }) }) host ??= await args.ensureHost() - const { agent: _resolvedAgent, provider: _resolvedProvider, ...resolvedAttach } = resolved + const { + agent: _resolvedAgent, + provider: _resolvedProvider, + hostLaunchDirectory, + ...resolvedAttach + } = resolved return { host, + ...(hostLaunchDirectory ? { hostLaunchDirectory } : {}), attachParams: { ...resolvedAttach, // After the fingerprint, deliberately: `attachFingerprintFields` excludes options because @@ -129,7 +136,11 @@ export async function commitStructuredAgentSessionCreate(args: { activate: boolean }): Promise> { const { prepared } = args - const result = await prepared.host.attach(args.caller, prepared.attachParams) + const result = prepared.hostLaunchDirectory + ? await prepared.host.attach(args.caller, prepared.attachParams, { + hostLaunchDirectory: prepared.hostLaunchDirectory + }) + : await prepared.host.attach(args.caller, prepared.attachParams) if (!result.ok || !prepared.tab) { return result } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts index 459ae5c75a4..d3bb69a6e00 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts @@ -2,6 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import type { AgentSessionRecord } from '../../../../shared/agent-session-record' +import { agentSessionRecordFixture } from '../../../../shared/agent-session-record.test-fixture' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { call, clearStructuredHostStub, @@ -16,10 +19,39 @@ afterEach(() => { describe('agentSession.modelCatalog', () => { const read = vi.fn(async () => ({ origin: 'unknown' as const })) + let record: AgentSessionRecord | null = null beforeEach(() => { read.mockClear() - setStructuredAgentSessionHost(Object.assign(hostStub(), { deps: { modelCatalog: { read } } })) + record = null + setStructuredAgentSessionHost( + Object.assign(hostStub(), { + deps: { modelCatalog: { read }, store: { getRecord: () => record } } + }) + ) + }) + + it("reads a floating chat's catalog in the folder it was created in", async () => { + const fixture = agentSessionRecordFixture() + record = { + ...fixture, + location: { ...fixture.location, workspaceId: FLOATING_TERMINAL_WORKTREE_ID }, + launchDirectory: '/home/me/floating-a' + } + // The floating setting has since moved to another folder. + const resolveStructuredAgentSessionLocalWorkspacePath = vi.fn(async () => '/home/me/floating-b') + await call( + 'agentSession.modelCatalog', + { agent: 'codex', sessionId: SESSION, worktree: `id:${FLOATING_TERMINAL_WORKTREE_ID}` }, + STRUCTURED_CLIENT, + { resolveStructuredAgentSessionLocalWorkspacePath } + ) + expect(resolveStructuredAgentSessionLocalWorkspacePath).not.toHaveBeenCalled() + expect(read).toHaveBeenCalledWith({ + agent: 'codex', + sessionId: SESSION, + workspacePath: '/home/me/floating-a' + }) }) it('reads the catalog for the directory the named worktree runs in on this host', async () => { @@ -74,7 +106,11 @@ describe('agentSession.modelCatalog before anything built the host', () => { fetchedAt: 1 })) const installHost = vi.fn(async () => { - setStructuredAgentSessionHost(Object.assign(hostStub(), { deps: { modelCatalog: { read } } })) + setStructuredAgentSessionHost( + Object.assign(hostStub(), { + deps: { modelCatalog: { read }, store: { getRecord: () => null } } + }) + ) }) beforeEach(() => { diff --git a/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts b/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts index 3cd86c876ad..98d94439b1f 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts @@ -14,6 +14,7 @@ import { requireStructuredHost } from './structured-agent-session-gate' import { ModelCatalogParams, OptionsParams } from './structured-agent-session-schemas' +import { agentSessionPinnedLaunchDirectory } from '../../agent-session-record-launch-directory' export const STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS = [ defineMethod({ @@ -36,6 +37,13 @@ export const STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS = [ if (!catalog) { return { origin: 'unknown' as const } } + // A floating chat runs in the folder it was created in, not the floating setting's current one. + const record = + params.sessionId === undefined ? null : host.deps.store.getRecord(params.sessionId) + const launchDirectory = record ? agentSessionPinnedLaunchDirectory(record) : undefined + if (launchDirectory) { + return catalog.read({ ...params, workspacePath: launchDirectory }) + } if (worktree === undefined) { return catalog.read(params) } diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index 82437bb6d81..d7244bbd79b 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -16,6 +16,7 @@ import { } from '../../../../shared/protocol-version' import { STRUCTURED_AGENT_SESSION_START_WAIT_MS } from '../../../native-chat/agent-session-wire/structured-agent-session-send-settlement' import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { ALL_RPC_METHODS } from './index' import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' import { CLEANUP_METHODS } from './structured-agent-session-gate-classification.test-fixture' @@ -425,6 +426,46 @@ describe('method routing', () => { ) }) + it('keeps the host-resolved floating folder outside a worktree create payload', async () => { + const worktree = `id:${FLOATING_TERMINAL_WORKTREE_ID}` + const params = { + envelope: envelope({ + expectedRuntimeFence: null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.create', + sessionId: SESSION, + fields: { worktree, agent: 'codex' } + }) + }), + worktree, + agent: 'codex' + } + + expect( + await call('agentSession.create', params, STRUCTURED_CLIENT, { + resolveStructuredAgentSessionCreateIntent: async () => ({ + envelope: params.envelope, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: FLOATING_TERMINAL_WORKTREE_ID, + workspaceKind: 'folder' + }, + provider: 'codex', + agent: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/host/account' }, + runtimeKind: 'native', + hostLaunchDirectory: '/host/floating-folder' + }) + }) + ).toMatchObject({ ok: true, result: { ok: true } }) + expect(hostCalls.attach).toHaveBeenCalledWith( + expect.anything(), + expect.not.objectContaining({ hostLaunchDirectory: expect.anything() }), + { hostLaunchDirectory: '/host/floating-folder' } + ) + }) + it('refuses a create whose declared fingerprint omits the tab it reserved', async () => { // The tab id is part of the intent fingerprint, so a payload whose declared digest omits it // is refused rather than admitted as the blank create it looks like. @@ -590,6 +631,41 @@ describe('method routing', () => { expect(ensured).toMatchObject({ ok: true }) }) + it.each(['agentSession.create', 'agentSession.ensure'] as const)( + 'routes a new floating client location through host attach for %s', + async (method) => { + const params = attachParams({ + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: FLOATING_TERMINAL_WORKTREE_ID, + workspaceKind: 'folder' + } + }) + + expect(await call(method, params, STRUCTURED_CLIENT)).toMatchObject({ ok: true }) + expect(hostCalls.attach).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ location: params.location }) + ) + expect(hostCalls.attach.mock.calls[0]?.[1]).not.toHaveProperty('launchDirectory') + } + ) + + it.each(['agentSession.create', 'agentSession.ensure'] as const)( + 'rejects a client-selected floating launch directory for %s', + async (method) => { + const response = await call( + method, + { ...attachParams(), launchDirectory: '/client/selected-folder' }, + STRUCTURED_CLIENT + ) + + expect(response).toMatchObject({ ok: false }) + expect(hostCalls.attach).not.toHaveBeenCalled() + } + ) + /** A client-supplied location skips the worktree-resolving support check, so both attach-shaped * entries must ask the executing host directly or a host that cannot fence a provider child * would create one anyway. */ diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index c347be27270..6913d3abc7c 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -283,6 +283,7 @@ async function installOnJournal( claimKeyId: deps.claimKeyId, probeOwner: createStructuredAgentSessionOwnerProbe(deps.hostId), probeOwners: createStructuredAgentSessionOwnerProbes(deps.hostId), + resolveWorkspacePath: deps.resolveWorkspacePath, ...(deps.resolveLaunchArgs ? { resolveLaunchArgs: async (provider: AgentSessionRecord['provider']) => diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index b51cd836df2..892c445d100 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -39,11 +39,7 @@ function App(): React.JSX.Element { const onboardingGate = useOnboardingAndFeatureTips() const clearUnreadDockBadge = useUnreadDockBadge(floatingWorkspace.open) - // Why enabled && open: the overlay only renders while the feature is on, and its panel is - // aria-hidden while closed — so that pair is what "on screen" means for the floating workspace. - useAppShellServices({ - floatingPanelVisible: floatingWorkspace.enabled && floatingWorkspace.open - }) + useAppShellServices() // Why before the startup chain: its effect runs first, and no startup step can skip the load. useEffect(startNativeChatDraftLoad, []) useAppStartupHydration(onboardingGate.applyStartupOnboardingState) diff --git a/src/renderer/src/app-shell/use-app-chrome-layout.ts b/src/renderer/src/app-shell/use-app-chrome-layout.ts index f675ae7b79e..e49ecd66948 100644 --- a/src/renderer/src/app-shell/use-app-chrome-layout.ts +++ b/src/renderer/src/app-shell/use-app-chrome-layout.ts @@ -6,6 +6,7 @@ import { resolveLeftSidebarStyleVariables } from '@/lib/left-sidebar-appearance' import { resolveLeftTitlebarChromeLayout } from '@/lib/titlebar-left-chrome' import { shouldShowWorktreeCreationSurface } from '@/lib/worktree-creation-surface' import { useAppStore } from '../store' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { selectActiveTerminalChromeState } from '../store/active-terminal-chrome-selector' import { useSystemPrefersDark } from '../components/terminal-pane/use-system-prefers-dark' import { @@ -53,7 +54,13 @@ export function useAppChromeLayout() { [settings, systemPrefersDark] ) as React.CSSProperties | undefined - const canMountTerminalWorkbenchNow = activeWorktreeId !== null || backgroundTerminalMountRequested + // Why floating tabs count: the workbench owns every tab's unsaved-close and quit protection, and + // floating tabs exist without an active worktree. + const floatingWorkspaceHasTabs = useAppStore( + (s) => (s.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID]?.length ?? 0) > 0 + ) + const canMountTerminalWorkbenchNow = + activeWorktreeId !== null || backgroundTerminalMountRequested || floatingWorkspaceHasTabs // Why a latch in state, not a ref: the write has to be visible to the next render, and a // render-phase ref write would also survive a render React discards. Setting state during // render is the supported way to derive it, and the `||` below keeps this render correct. @@ -61,7 +68,7 @@ export function useAppChromeLayout() { if (canMountTerminalWorkbenchNow && !hasMountedTerminalWorkbench) { setHasMountedTerminalWorkbench(true) } - // Why: skip the terminal bundle on the landing path, but once mounted keep hidden panes alive through sleep/shutdown when activeWorktreeId briefly goes null. + // Why: skip the terminal bundle on a landing path with no tabs, but once mounted keep hidden panes alive through sleep/shutdown when activeWorktreeId briefly goes null. const shouldMountTerminalWorkbench = canMountTerminalWorkbenchNow || hasMountedTerminalWorkbench // Why: visible worktree creation owns its faux tab strip start to finish; keep the previous workspace mounted for retention without real chrome. const creationLayoutActive = shouldShowWorktreeCreationSurface({ diff --git a/src/renderer/src/app-shell/use-app-shell-services.ts b/src/renderer/src/app-shell/use-app-shell-services.ts index f36401ff32e..3bb29d17afc 100644 --- a/src/renderer/src/app-shell/use-app-shell-services.ts +++ b/src/renderer/src/app-shell/use-app-shell-services.ts @@ -30,7 +30,7 @@ import { useVisibleHostedReviewRefresh } from './use-visible-hosted-review-refre * the component that consumes its result unmounts (right sidebar, explorer, terminal) or is * absent entirely on the landing path. */ -export function useAppShellServices(options: { floatingPanelVisible: boolean }): void { +export function useAppShellServices(): void { const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady) const persistedUIReady = useAppStore((s) => s.persistedUIReady) const primarySelectionMiddleClickPaste = useAppStore((s) => @@ -53,7 +53,7 @@ export function useAppShellServices(options: { floatingPanelVisible: boolean }): // Why: wire file-change watching at App level so the editor keeps hearing FS changes when Explorer unmounts (right-sidebar switches to Source Control/Checks). useEditorExternalWatch() useGlobalFileDrop() - useAutoAckViewedAgent(options.floatingPanelVisible) + useAutoAckViewedAgent() useAppMenuPaste() useAppMenuSelectionActions() useLargeTextControlPaste() diff --git a/src/renderer/src/app-shell/use-floating-workspace-panel.ts b/src/renderer/src/app-shell/use-floating-workspace-panel.ts index 7cd3763e401..c2fb65c8bfc 100644 --- a/src/renderer/src/app-shell/use-floating-workspace-panel.ts +++ b/src/renderer/src/app-shell/use-floating-workspace-panel.ts @@ -1,35 +1,22 @@ -import { - useCallback, - useEffect, - useLayoutEffect, - useRef, - useState, - type SetStateAction -} from 'react' +import { useCallback, useEffect, useLayoutEffect, useRef, type SetStateAction } from 'react' import { TOGGLE_FLOATING_TERMINAL_EVENT, requestFloatingTerminalOpenMaximized } from '@/lib/floating-terminal' import { createFloatingWorkspaceTourInteractionSnapshot } from '@/lib/floating-workspace-tour-interaction-snapshot' -import { - persistFloatingTerminalPanelOpen, - readPersistedFloatingTerminalPanelViewState -} from '../components/floating-terminal/floating-terminal-panel-view-state' +import { persistFloatingTerminalPanelOpen } from '../components/floating-terminal/floating-terminal-panel-view-state' import { useAppStore } from '../store' import { selectFloatingVisibleTabCount } from '../store/selectors' export type FloatingWorkspacePanelState = ReturnType /** - * Owns the floating workspace overlay's open state: persistence, the focus it hands back on - * close, and the toggle/disable paths that can flip it from outside React. + * Drives the floating workspace overlay's open state (held in the store): persistence, the focus + * it hands back on close, and the toggle/disable paths that can flip it from outside React. */ export function useFloatingWorkspacePanel() { - // Why restored: leaving the panel closed forces the user to reopen and re-maximize it, - // and that size jump reflows a live TUI's buffer (see floating-terminal-panel-view-state). - const [open, setOpen] = useState( - () => readPersistedFloatingTerminalPanelViewState()?.open === true - ) + const open = useAppStore((s) => s.floatingWorkspacePanelOpen) + const setOpen = useAppStore((s) => s.setFloatingWorkspacePanelOpen) const tourInteractionSnapshotRef = useRef<{ wasPreviouslyInteracted?: boolean persisted?: Promise @@ -110,7 +97,7 @@ export function useFloatingWorkspacePanel() { persistFloatingTerminalPanelOpen(resolvedOpen) } }, - [enabled, open, rememberReturnFocus, restoreReturnFocus] + [enabled, open, setOpen, rememberReturnFocus, restoreReturnFocus] ) const openMaximized = useCallback((): void => { diff --git a/src/renderer/src/components/TerminalWorktreeSplitSurface.tsx b/src/renderer/src/components/TerminalWorktreeSplitSurface.tsx index e41b1d04091..03a3a472703 100644 --- a/src/renderer/src/components/TerminalWorktreeSplitSurface.tsx +++ b/src/renderer/src/components/TerminalWorktreeSplitSurface.tsx @@ -10,11 +10,7 @@ import { shouldMountRetainedBrowserOverlay } from './browser-pane/host-guest/browser-worktree-surface-paintability' import TabGroupSplitLayout from './tab-group/TabGroupSplitLayout' -import TerminalPaneOverlayLayer from './terminal-pane/TerminalPaneOverlayLayer' -import { RetainedBrowserPaneOverlayLayer } from './browser-pane/assemble-chrome/BrowserPaneOverlayLayer' -import EmulatorPaneOverlayLayer from './emulator-pane/EmulatorPaneOverlayLayer' -import StructuredAgentSessionPaneOverlayLayer from './native-chat/StructuredAgentSessionPaneOverlayLayer' -import AiVaultSessionDropLayer from './tab-group/AiVaultSessionDropLayer' +import { WorkspacePaneOverlayLayers } from './WorkspacePaneOverlayLayers' export const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({ worktreeId, @@ -66,34 +62,23 @@ export const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({ focusedGroupId={focusedGroupId} isWorktreeActive={isVisible} /> - - - {isVisible || backgroundMountTabIds === null ? ( - - ) : null} - - ) }) diff --git a/src/renderer/src/components/WorkspacePaneOverlayLayers.tsx b/src/renderer/src/components/WorkspacePaneOverlayLayers.tsx new file mode 100644 index 00000000000..f8fa5236339 --- /dev/null +++ b/src/renderer/src/components/WorkspacePaneOverlayLayers.tsx @@ -0,0 +1,71 @@ +import type { ActivityTerminalPortalTarget } from './activity/activity-terminal-portal' +import TerminalPaneOverlayLayer from './terminal-pane/TerminalPaneOverlayLayer' +import { RetainedBrowserPaneOverlayLayer } from './browser-pane/assemble-chrome/BrowserPaneOverlayLayer' +import EmulatorPaneOverlayLayer from './emulator-pane/EmulatorPaneOverlayLayer' +import StructuredAgentSessionPaneOverlayLayer from './native-chat/StructuredAgentSessionPaneOverlayLayer' +import AiVaultSessionDropLayer from './tab-group/AiVaultSessionDropLayer' + +/** + * The retained pane hosts for one workspace: terminal, browser, emulator and structured-chat + * panes anchored onto the group tree's pane bodies, plus the vault drop layer. Shared by every + * host of the group tree so pane lifecycle (retention, parking, guest paint) has one owner. + */ +export function WorkspacePaneOverlayLayers({ + worktreeId, + worktreePath, + isVisible, + shouldMeasureHiddenWorktree, + shouldColdParkTerminalPanes, + isForceParked, + activityTerminalPortals, + backgroundMountTabIds, + activationDeferredMountTabIds, + mountRetainedBrowserOverlay, + mountEmulatorOverlay, + ownsNativeChatToggleShortcut = true +}: { + worktreeId: string + worktreePath: string + isVisible: boolean + shouldMeasureHiddenWorktree: boolean + shouldColdParkTerminalPanes: boolean + isForceParked: boolean + activityTerminalPortals: ActivityTerminalPortalTarget[] + backgroundMountTabIds: ReadonlySet | null + activationDeferredMountTabIds: ReadonlySet | null + mountRetainedBrowserOverlay: boolean + mountEmulatorOverlay: boolean + /** False for overlay hosts (the floating panel): the active workspace's listener already owns + * the chord, and two live listeners would both toggle. */ + ownsNativeChatToggleShortcut?: boolean +}): React.JSX.Element { + return ( + <> + + + {mountEmulatorOverlay ? ( + + ) : null} + + + + ) +} diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.tsx b/src/renderer/src/components/activity/ActivityPrototypePage.tsx index 90716732e62..4db73d25275 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.tsx +++ b/src/renderer/src/components/activity/ActivityPrototypePage.tsx @@ -265,12 +265,14 @@ export default function ActivityPrototypePage(): React.JSX.Element { worktreesByRepo: storeData.worktreesByRepo, detectedWorktreesByRepo: storeData.detectedWorktreesByRepo, folderWorkspaces: storeData.folderWorkspaces, + floatingWorkspacePath: storeData.floatingWorkspacePath, defaultHostId: storeData.defaultHostId }), [ storeData.worktreesByRepo, storeData.detectedWorktreesByRepo, storeData.folderWorkspaces, + storeData.floatingWorkspacePath, storeData.defaultHostId ] ) diff --git a/src/renderer/src/components/activity/activity-thread-actions.test.ts b/src/renderer/src/components/activity/activity-thread-actions.test.ts index fd20d2ca18d..9f46fbd90f3 100644 --- a/src/renderer/src/components/activity/activity-thread-actions.test.ts +++ b/src/renderer/src/components/activity/activity-thread-actions.test.ts @@ -9,7 +9,6 @@ const mocks = vi.hoisted(() => ({ activateTabAndFocusPane: vi.fn(), activateStructuredAgentSessionTab: vi.fn(), activateAndRevealWorkspace: vi.fn(), - isFloatingWorkspacePanelVisible: vi.fn(), dispatchEvent: vi.fn() })) @@ -23,9 +22,6 @@ vi.mock('@/lib/structured-agent-session-tab-activation', () => ({ vi.mock('@/lib/worktree-activation', () => ({ activateAndRevealWorkspace: mocks.activateAndRevealWorkspace })) -vi.mock('@/lib/floating-workspace-terminal-actions', () => ({ - isFloatingWorkspacePanelVisible: mocks.isFloatingWorkspacePanelVisible -})) import { createActivityThreadActions, hasActivityThreadWorkspace } from './activity-thread-actions' @@ -70,7 +66,6 @@ describe('activity thread host routing', () => { beforeEach(() => { vi.clearAllMocks() vi.stubGlobal('window', { dispatchEvent: mocks.dispatchEvent }) - mocks.isFloatingWorkspacePanelVisible.mockReturnValue(false) mocks.activateStructuredAgentSessionTab.mockReturnValue(false) mocks.activateAndRevealWorkspace.mockReturnValue({ primaryTabId: null }) getKnownWorktreeById.mockReturnValue(thread.worktree) @@ -119,7 +114,7 @@ describe('activity thread host routing', () => { state.settings = { floatingTerminalEnabled: true } state.tabsByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: [floatingThread.tab] } mocks.activateAndRevealWorkspace.mockReturnValue(false) - mocks.isFloatingWorkspacePanelVisible.mockReturnValue(open) + state.floatingWorkspacePanelOpen = open makeActions().selectThread(floatingThread) diff --git a/src/renderer/src/components/activity/activity-thread-actions.ts b/src/renderer/src/components/activity/activity-thread-actions.ts index 1193467fea0..1b35bf3f341 100644 --- a/src/renderer/src/components/activity/activity-thread-actions.ts +++ b/src/renderer/src/components/activity/activity-thread-actions.ts @@ -26,7 +26,7 @@ function getActivityThreadExecutionHostId( type ActivityThreadWorkspaceCatalog = Pick< AppState, - 'worktreesByRepo' | 'detectedWorktreesByRepo' | 'folderWorkspaces' + 'worktreesByRepo' | 'detectedWorktreesByRepo' | 'folderWorkspaces' | 'floatingWorkspacePath' > & { defaultHostId: ExecutionHostId } function readActivityThreadWorkspaceCatalog(): ActivityThreadWorkspaceCatalog { diff --git a/src/renderer/src/components/activity/use-agent-pane-threads.ts b/src/renderer/src/components/activity/use-agent-pane-threads.ts index 44fecebb5e6..5f3cb64d09f 100644 --- a/src/renderer/src/components/activity/use-agent-pane-threads.ts +++ b/src/renderer/src/components/activity/use-agent-pane-threads.ts @@ -40,6 +40,7 @@ export type AgentPaneThreadsStoreData = Pick< | 'repos' | 'worktreesByRepo' | 'folderWorkspaces' + | 'floatingWorkspacePath' | 'detectedWorktreesByRepo' | 'getKnownWorktreeById' | 'acknowledgedAgentsByPaneKey' @@ -125,6 +126,7 @@ export function useAgentPaneThreads(args: { repos: s.repos, worktreesByRepo: s.worktreesByRepo, folderWorkspaces: s.folderWorkspaces, + floatingWorkspacePath: s.floatingWorkspacePath, detectedWorktreesByRepo: s.detectedWorktreesByRepo, getKnownWorktreeById: s.getKnownWorktreeById, worktreeMap: getWorktreeMapFromState(s), diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.test.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.test.tsx index 8ab52d952b8..c6fee18e367 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.test.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.test.tsx @@ -5,6 +5,7 @@ import { renderToStaticMarkup } from 'react-dom/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { BrowserTab as BrowserTabState } from '../../../../../shared/browser-workspace-types' import type { Tab, TabGroup } from '../../../../../shared/tab-types' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../../shared/constants' type MockAppState = { browserTabsByWorktree: Record @@ -271,6 +272,26 @@ describe('BrowserPaneOverlayLayer', () => { expect(view.container.querySelectorAll('[data-browser-overlay-tab-id]')).toHaveLength(1000) }) + it('keeps a floating browser to chords from its own overlay even when its group holds focus', () => { + const floating = FLOATING_TERMINAL_WORKTREE_ID + const state = createState() + mocks.state = { + ...state, + browserTabsByWorktree: { [floating]: state.browserTabsByWorktree['wt-1'] }, + unifiedTabsByWorktree: { [floating]: state.unifiedTabsByWorktree['wt-1'] }, + groupsByWorktree: { [floating]: state.groupsByWorktree['wt-1'] }, + activeGroupIdByWorktree: { [floating]: 'group-1' } + } + + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain( + 'data-browser-pane-id="browser-a" data-browser-pane-active="true" data-browser-find-shortcut-scope="owned-target"' + ) + }) + it('keeps an active browser pane unfocused when another split holds focus (#11348)', () => { mocks.state = createState() mocks.state.groupsByWorktree = { diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.tsx index b15c8420ff0..b9c80d82eeb 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.tsx @@ -8,6 +8,7 @@ import BrowserPane from './browser-workspace-pane' import { DeferredBrowserContent } from './DeferredBrowserContent' import type { BrowserChromeShortcutScope } from '../describe-page/browser-page-types' import { tabGroupBodyAnchorName } from '../../tab-group/tab-group-body-anchor' +import { isFloatingWorkspaceId } from '../../../../../shared/floating-workspace-worktree' import { useBrowserGuestPaintRetention } from '../host-guest/browser-guest-paint-retention' import { isClientHostedBrowserRowSelectionLive, @@ -135,6 +136,7 @@ const BrowserPaneOverlayLayer = memo(function BrowserPaneOverlayLayer({ })) ) const focusGroup = useAppStore((state) => state.focusGroup) + const isFloatingWorkspace = isFloatingWorkspaceId(worktreeId) const knownFocusedGroupId = useMemo( () => focusedGroupId !== undefined && groups.some((group) => group.id === focusedGroupId) @@ -180,7 +182,8 @@ const BrowserPaneOverlayLayer = memo(function BrowserPaneOverlayLayer({ const isActive = Boolean(isWorktreeActive && assignment && assignment.isActiveInGroup) const chromeShortcutScope: BrowserChromeShortcutScope = !isActive ? 'inactive' - : knownFocusedGroupId === undefined + : // Why: 'focused' means the main window's focused split, so a floating browser answers only its own chords. + isFloatingWorkspace || knownFocusedGroupId === undefined ? 'owned-target' : assignment?.groupId === knownFocusedGroupId ? 'focused' diff --git a/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-notebook-link.test.ts b/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-notebook-link.test.ts index 38a0326840d..fd42af12c7c 100644 --- a/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-notebook-link.test.ts +++ b/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-notebook-link.test.ts @@ -8,7 +8,7 @@ vi.mock('@/store', () => ({ useAppStore: { getState: () => ({ settings: {}, - allWorktrees: () => [{ id: 'wt-1', path: '/repo' }], + getKnownWorktreeById: (id: string) => (id === 'wt-1' ? { id, path: '/repo' } : undefined), setActiveTabType: vi.fn(), ensureWorktreeRootGroup: () => 'group-1', openFile: mocks.openFile diff --git a/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-url.ts b/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-url.ts index 2f33e434982..a3ba85c5a35 100644 --- a/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-url.ts +++ b/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-url.ts @@ -97,7 +97,8 @@ export function navigateBrowserPageToUrl({ } try { - const activeWorktree = store.allWorktrees().find((w) => w.id === worktreeId) + // Why getKnownWorktreeById: folder and floating workspaces are absent from allWorktrees(). + const activeWorktree = store.getKnownWorktreeById(worktreeId) const fileContext: RuntimeFileOperationArgs = { settings: store.settings, worktreeId, diff --git a/src/renderer/src/components/editor/editor-autosave.ts b/src/renderer/src/components/editor/editor-autosave.ts index e2f7a304e4a..fb78c95df84 100644 --- a/src/renderer/src/components/editor/editor-autosave.ts +++ b/src/renderer/src/components/editor/editor-autosave.ts @@ -53,6 +53,8 @@ export type EditorFileSavedDetail = { export type EditorRequestFileCloseDetail = { fileId: string + /** Runs once the file actually closes — after save or discard, never on cancel. */ + onClosed?: () => void } export type EditorRequestCmdSaveDetail = { @@ -212,10 +214,13 @@ export async function requestEditorFileSave(target: EditorSaveFileTarget): Promi }) } -export function requestEditorFileClose(fileId: string): void { +export function requestEditorFileClose( + fileId: string, + options?: Pick +): void { window.dispatchEvent( new CustomEvent(ORCA_EDITOR_REQUEST_FILE_CLOSE_EVENT, { - detail: { fileId } + detail: { fileId, ...options } }) ) } diff --git a/src/renderer/src/components/editor/editor-untitled-dont-save-dialogs.test.tsx b/src/renderer/src/components/editor/editor-untitled-dont-save-dialogs.test.tsx index 4d55dda3d55..ece6409c8eb 100644 --- a/src/renderer/src/components/editor/editor-untitled-dont-save-dialogs.test.tsx +++ b/src/renderer/src/components/editor/editor-untitled-dont-save-dialogs.test.tsx @@ -30,15 +30,22 @@ vi.mock('@/lib/connection-context', () => ({ getConnectionIdForFile: vi.fn() })) const FILE_ID = '/repo/untitled.md' function mainWindowDialogController(fileId: string): TerminalEditorCloseDialogActionsInput { + const pendingEditorCloseQueueRef = { current: [fileId] } return { advanceEditorCloseQueue: vi.fn(), + closedReactionsRef: { current: new Map() }, inFlightSaveFileIdRef: { current: null }, isClosingRef: { current: false }, - pendingEditorCloseQueueRef: { current: [fileId] }, + pendingEditorCloseQueueRef, queueEditorCloseRequests: vi.fn(), releaseCloseDialogGuardAfterDebounce: vi.fn(), saveDialogFileId: fileId, setSaveDialogFileId: vi.fn(), + settleQueuedClose: vi.fn((settled: string) => { + pendingEditorCloseQueueRef.current = pendingEditorCloseQueueRef.current.filter( + (queued) => queued !== settled + ) + }), waitForFileClosed: vi.fn(async () => true), windowCloseAfterDirtyRef: { current: null } } diff --git a/src/renderer/src/components/floating-terminal/FloatingBrowserSlot.test.tsx b/src/renderer/src/components/floating-terminal/FloatingBrowserSlot.test.tsx deleted file mode 100644 index 638a9b4b12c..00000000000 --- a/src/renderer/src/components/floating-terminal/FloatingBrowserSlot.test.tsx +++ /dev/null @@ -1,109 +0,0 @@ -// @vitest-environment happy-dom - -import { act } from 'react' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { FloatingBrowserSlot } from './FloatingBrowserSlot' -import { getBrowserOverlaySlotViewport } from '@/components/browser-pane/host-guest/browser-page-viewport' -import type { BrowserChromeShortcutScope } from '@/components/browser-pane/BrowserPane' -import type { BrowserTab } from '../../../../shared/browser-workspace-types' - -const paneScopes = vi.hoisted((): (BrowserChromeShortcutScope | undefined)[] => []) - -// Why: BrowserPane mounts a real Electron we can't run in jsdom; stub -// it so the test isolates the slot-root registration that BrowserPane depends on. -vi.mock('@/components/browser-pane/BrowserPane', () => ({ - default: ({ chromeShortcutScope }: { chromeShortcutScope?: BrowserChromeShortcutScope }) => { - paneScopes.push(chromeShortcutScope) - return null - } -})) - -function makeBrowserTab(id: string): BrowserTab { - return { - id, - worktreeId: 'global-floating-terminal', - activePageId: id, - pageIds: [id], - url: 'https://google.com', - title: 'Google', - loading: true, - faviconUrl: null, - canGoBack: false, - canGoForward: false, - loadError: null, - createdAt: 0 - } -} - -describe('FloatingBrowserSlot', () => { - let container: HTMLDivElement | null = null - let root: Root | null = null - - afterEach(() => { - act(() => { - root?.unmount() - }) - container?.remove() - root = null - container = null - paneScopes.length = 0 - }) - - it('registers a browser overlay slot viewport keyed by the tab id so BrowserPane can mount its webview', () => { - container = document.createElement('div') - document.body.appendChild(container) - root = createRoot(container) - - expect(getBrowserOverlaySlotViewport('floating-browser-1')).toBeNull() - - act(() => { - root!.render( - - ) - }) - - // Without this registration ensureBrowserPageViewport returns null, the - // webview is never created, and the page spins on "loading" forever. - expect(getBrowserOverlaySlotViewport('floating-browser-1')).not.toBeNull() - }) - - it('unregisters the slot viewport when the tab unmounts', () => { - container = document.createElement('div') - document.body.appendChild(container) - root = createRoot(container) - - act(() => { - root!.render( - - ) - }) - expect(getBrowserOverlaySlotViewport('floating-browser-2')).not.toBeNull() - - act(() => { - root!.unmount() - }) - root = null - - expect(getBrowserOverlaySlotViewport('floating-browser-2')).toBeNull() - }) - - it('answers chrome chords only from inside its own overlay, never as the focused split', () => { - container = document.createElement('div') - document.body.appendChild(container) - root = createRoot(container) - - act(() => { - root!.render() - }) - expect(paneScopes.at(-1)).toBe('owned-target') - expect(container.querySelector('[data-browser-overlay-tab-id="floating-3"]')).not.toBeNull() - - act(() => { - root!.render( - - ) - }) - expect(paneScopes.at(-1)).toBe('inactive') - }) -}) diff --git a/src/renderer/src/components/floating-terminal/FloatingBrowserSlot.tsx b/src/renderer/src/components/floating-terminal/FloatingBrowserSlot.tsx deleted file mode 100644 index 85b883d6b86..00000000000 --- a/src/renderer/src/components/floating-terminal/FloatingBrowserSlot.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { useCallback } from 'react' -import BrowserPane from '@/components/browser-pane/BrowserPane' -import { registerBrowserOverlaySlotViewport } from '@/components/browser-pane/host-guest/browser-page-viewport' -import type { BrowserTab as BrowserTabState } from '../../../../shared/browser-workspace-types' - -// Why: BrowserPane mounts its persistent Electron into a slot viewport -// root keyed by the browser tab id. The main workspace registers that root via -// BrowserPaneOverlayLayer, but the floating panel renders BrowserPane directly -// and has no overlay layer — so without registering a root here, -// ensureBrowserPageViewport returns null, the webview is never created, and the -// page spins on "loading" forever. Mirror BrowserOverlaySlot's slot-root div so -// the guest mounts and survives tab switches (the root must not be reparented). -export function FloatingBrowserSlot({ - browserTab, - isActive -}: { - browserTab: BrowserTabState - isActive: boolean -}): React.JSX.Element { - const setSlotViewportRef = useCallback( - (node: HTMLDivElement | null): void => { - registerBrowserOverlaySlotViewport(browserTab.id, node) - }, - [browserTab.id] - ) - - return ( -
-
- -
- ) -} diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.bounds.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.bounds.test.tsx index 64f7a6bc56a..f17ff0a7f63 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.bounds.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.bounds.test.tsx @@ -23,6 +23,11 @@ import { setViewport } from './floating-terminal-panel-render-probe' +vi.mock('zustand/react/shallow', () => ({ + // Why: zustand resolves the real react (unmocked in node_modules); the memo wrapper is inert here. + useShallow: (selector: unknown) => selector +})) + vi.mock('react', async () => { const actual = await vi.importActual('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() const { createReactHookOverrides } = await import('./floating-terminal-panel-test-module-mocks') diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.empty-state.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.empty-state.test.tsx index 61b17584733..4c249d473f7 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.empty-state.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.empty-state.test.tsx @@ -16,6 +16,11 @@ import { type ReactElementLike } from './floating-terminal-panel-render-probe' +vi.mock('zustand/react/shallow', () => ({ + // Why: zustand resolves the real react (unmocked in node_modules); the memo wrapper is inert here. + useShallow: (selector: unknown) => selector +})) + vi.mock('react', async () => { const actual = await vi.importActual('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() const { createReactHookOverrides } = await import('./floating-terminal-panel-test-module-mocks') @@ -273,6 +278,33 @@ describe('FloatingTerminalPanel close behavior', () => { ) }) + it('keeps the split surface mounted when its focused group has no tabs', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' })]) + const state = storeBox.state as FloatingPanelStoreState + state.groupsByWorktree[FLOATING_TERMINAL_WORKTREE_ID].push({ + id: 'empty-group', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + activeTabId: null, + tabOrder: [] + }) + state.activeGroupIdByWorktree[FLOATING_TERMINAL_WORKTREE_ID] = 'empty-group' + state.layoutByWorktree[FLOATING_TERMINAL_WORKTREE_ID] = { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: 'floating-group' }, + second: { type: 'leaf', groupId: 'empty-group' } + } + + const element = await renderPanel(true) + + expect(() => findByTypeName(element, 'FloatingTerminalEmptyState')).toThrow( + 'FloatingTerminalEmptyState not found' + ) + expect(findByTypeName(element, 'TabGroupSplitNodeTree').props.focusedGroupId).toBe( + 'empty-group' + ) + }) + it('minimizes the empty floating workspace from the empty state', async () => { const onOpenChange = vi.fn() const element = await renderPanel(true, onOpenChange) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.focus.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.focus.test.tsx index c4dd87eecff..cd414c13804 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.focus.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.focus.test.tsx @@ -1,16 +1,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { consumeFloatingPanelReclaimIntent } from '@/lib/floating-workspace-focus-reclaim' +import { + armFloatingPanelReclaimIntent, + consumeFloatingPanelReclaimIntent +} from '@/lib/floating-workspace-focus-reclaim' +import { ORCA_EDITOR_REQUEST_FILE_CLOSE_EVENT } from '@/components/editor/editor-autosave' import { makeFile, makeTab, setFloatingEditorTabs, setFloatingTabs } from './floating-terminal-panel-test-fixtures' -import { - mocks, - saveDialogBox, - setupFloatingTerminalPanelTest -} from './floating-terminal-panel-test-harness' +import { mocks, setupFloatingTerminalPanelTest } from './floating-terminal-panel-test-harness' import { attachRef, findByProp, @@ -19,6 +19,30 @@ import { runEffects } from './floating-terminal-panel-render-probe' +function requestedEditorCloseReaction(): () => void { + const event = vi + .mocked(window.dispatchEvent) + .mock.calls.map(([candidate]) => candidate) + .find((candidate) => candidate.type === ORCA_EDITOR_REQUEST_FILE_CLOSE_EVENT) + if (!(event instanceof CustomEvent)) { + throw new Error('the editor close was not requested') + } + const detail: unknown = event.detail + if (!detail || typeof detail !== 'object' || !('onClosed' in detail)) { + throw new Error('the close request has no settlement reaction') + } + const onClosed = detail.onClosed + if (typeof onClosed !== 'function') { + throw new Error('the close reaction is not callable') + } + return () => onClosed() +} + +vi.mock('zustand/react/shallow', () => ({ + // Why: zustand resolves the real react (unmocked in node_modules); the memo wrapper is inert here. + useShallow: (selector: unknown) => selector +})) + vi.mock('react', async () => { const actual = await vi.importActual('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() const { createReactHookOverrides } = await import('./floating-terminal-panel-test-module-mocks') @@ -333,9 +357,9 @@ describe('FloatingTerminalPanel close behavior', () => { attachRef(findByProp(element, 'data-floating-terminal-panel').props.ref, panelElement) runEffects() - // The last-pane close authority (L3 → onCloseTab) closes the tab while the panel owns focus. - const terminalPane = findByTypeName(element, 'TerminalPane') - ;(terminalPane.props.onCloseTab as () => void)() + // The last-tab close authority (strip close → confirmed close) runs while the panel owns focus. + const tabBar = findByTypeName(element, 'TabBar') + ;(tabBar.props.onClose as (tabId: string) => void)('tab-1') expect(mocks.closeTerminalTab).toHaveBeenCalledWith( 'tab-1', expect.objectContaining({ onClosed: expect.any(Function) }) @@ -370,8 +394,8 @@ describe('FloatingTerminalPanel close behavior', () => { attachRef(findByProp(element, 'data-floating-terminal-panel').props.ref, panelElement) runEffects() - const terminalPane = findByTypeName(element, 'TerminalPane') - ;(terminalPane.props.onCloseTab as () => void)() + const tabBar = findByTypeName(element, 'TabBar') + ;(tabBar.props.onClose as (tabId: string) => void)('tab-1') // Emptying schedules the reclaim frame (id 42, callback not yet run); unmounting cancels it. setFloatingTabs([]) @@ -445,9 +469,7 @@ describe('FloatingTerminalPanel close behavior', () => { expect(consumeFloatingPanelReclaimIntent()).toBe(false) }) - // A dirty editor's close is deferred to the save dialog, so its arm check must survive the queue - // and fire when the file actually leaves the panel — otherwise this one content type would empty - // the panel with no intent armed and the next Cmd/Ctrl+T would miss the floating panel. + // The central save queue runs the captured reaction only after a dirty file actually closes. it('reclaims panel keyboard focus after a deferred dirty-editor close empties the panel', async () => { setFloatingEditorTabs([makeFile({ id: 'file-a', isDirty: true })]) const panelElement = { contains: vi.fn().mockReturnValue(true), focus: vi.fn() } @@ -466,24 +488,54 @@ describe('FloatingTerminalPanel close behavior', () => { ;(tabBar.props.onClose as (tabId: string) => void)('tab-file-a') // The close is parked on the save dialog: nothing closed yet, so nothing reclaims focus yet. - expect(saveDialogBox.fileId).toBe('file-a') + const onClosed = requestedEditorCloseReaction() expect(mocks.closeFile).not.toHaveBeenCalledWith('file-a') expect(window.requestAnimationFrame).not.toHaveBeenCalled() - // The dialog resolves (save or discard) and the file leaves the panel: the parked arm runs on + // The central queue resolves and the file leaves the panel. Its callback arms reclaim on // the now-empty count and the count→0 effect consumes it. The deferred reclaim frame is the // signal here — the empty panel's own open-focus effect focuses synchronously, without a frame. - saveDialogBox.fileId = null setFloatingEditorTabs([]) + onClosed() const emptyElement = await renderPanel(true) attachRef(findByProp(emptyElement, 'data-floating-terminal-panel').props.ref, panelElement) runEffects() + // The probe reads external-store snapshots on render; React re-renders on the arm notification. + const afterArm = await renderPanel(true) + attachRef(findByProp(afterArm, 'data-floating-terminal-panel').props.ref, panelElement) + runEffects() + expect(window.requestAnimationFrame).toHaveBeenCalled() expect(panelElement.focus).toHaveBeenCalledWith({ preventScroll: true }) }) - it('drops the deferred dirty-editor arm when the save dialog is cancelled', async () => { + it('reclaims focus when intent arms after the empty panel has rendered', async () => { + setFloatingTabs([]) + const panelElement = { contains: vi.fn().mockReturnValue(true), focus: vi.fn() } + const activeElement = { closest: vi.fn().mockReturnValue(panelElement) } + Object.setPrototypeOf(activeElement, HTMLElement.prototype) + vi.stubGlobal('document', { + activeElement, + addEventListener: vi.fn(), + removeEventListener: vi.fn() + }) + + const emptyElement = await renderPanel(true) + attachRef(findByProp(emptyElement, 'data-floating-terminal-panel').props.ref, panelElement) + runEffects() + vi.mocked(window.requestAnimationFrame).mockClear() + + armFloatingPanelReclaimIntent() + const afterArm = await renderPanel(true) + attachRef(findByProp(afterArm, 'data-floating-terminal-panel').props.ref, panelElement) + runEffects() + + expect(window.requestAnimationFrame).toHaveBeenCalled() + expect(consumeFloatingPanelReclaimIntent()).toBe(false) + }) + + it('does not reclaim after a cancelled dirty-editor close', async () => { setFloatingEditorTabs([makeFile({ id: 'file-a', isDirty: true })]) const panelElement = { contains: vi.fn().mockReturnValue(true), focus: vi.fn() } const activeElement = { closest: vi.fn().mockReturnValue(panelElement) } @@ -499,10 +551,9 @@ describe('FloatingTerminalPanel close behavior', () => { const tabBar = findByTypeName(element, 'TabBar') ;(tabBar.props.onClose as (tabId: string) => void)('tab-file-a') - ;(findByTypeName(element, 'Dialog').props.onOpenChange as (open: boolean) => void)(false) + requestedEditorCloseReaction() - // Cancel keeps the file open; a later close of that file (from some other path) must not - // resurrect this cancelled close's arm. + // Cancel never invokes onClosed; a later removal from another path must not reclaim focus. setFloatingEditorTabs([]) const emptyElement = await renderPanel(true) attachRef(findByProp(emptyElement, 'data-floating-terminal-panel').props.ref, panelElement) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.markdown-editor.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.markdown-editor.test.tsx index 7f28f85285c..ad65720a025 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.markdown-editor.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.markdown-editor.test.tsx @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { createUntitledMarkdownFileWithTemplateSelection } from '@/lib/create-untitled-markdown' +import { ORCA_EDITOR_REQUEST_FILE_CLOSE_EVENT } from '@/components/editor/editor-autosave' import { makeFile, makeTab, @@ -10,17 +11,30 @@ import { import { hookRuntime, mocks, - saveDialogBox, setupFloatingTerminalPanelTest } from './floating-terminal-panel-test-harness' import { - findByProp, findByTypeName, flushAsyncWork, renderPanel, runEffects } from './floating-terminal-panel-render-probe' +function requestedEditorCloses(): string[] { + return vi + .mocked(window.dispatchEvent) + .mock.calls.flatMap(([event]) => + event.type === ORCA_EDITOR_REQUEST_FILE_CLOSE_EVENT && event instanceof CustomEvent + ? [event.detail.fileId] + : [] + ) +} + +vi.mock('zustand/react/shallow', () => ({ + // Why: zustand resolves the real react (unmocked in node_modules); the memo wrapper is inert here. + useShallow: (selector: unknown) => selector +})) + vi.mock('react', async () => { const actual = await vi.importActual('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() const { createReactHookOverrides } = await import('./floating-terminal-panel-test-module-mocks') @@ -224,23 +238,23 @@ describe('FloatingTerminalPanel close behavior', () => { setFloatingEditorTabs([makeFile({ id: 'notes' })]) const element = await renderPanel(true) - const editorPanel = findByProp(element, 'activeFileId') + // The editor renders inside the shared group tree; the panel's contract is the policy + // it passes down (scratch markdown exposes no agent annotations). + const tree = findByTypeName(element, 'TabGroupSplitNodeTree') - expect(editorPanel.props.markdownAnnotationsEnabled).toBe(false) - expect(editorPanel.props.activeFileId).toBe('notes') - expect(editorPanel.props.isVisible).toBe(true) + expect(tree.props.markdownAnnotationsEnabled).toBe(false) + expect(tree.props.isWorktreeActive).toBe(true) }) it('marks the retained floating editor hidden when the panel is closed', async () => { setFloatingEditorTabs([makeFile({ id: 'notes' })]) const element = await renderPanel(false) - const editorPanel = findByProp(element, 'activeFileId') - expect(editorPanel.props.isVisible).toBe(false) + expect(findByTypeName(element, 'TabGroupSplitNodeTree').props.isWorktreeActive).toBe(false) }) - it('queues dirty editor closes from close-all-files instead of overwriting the dialog id', async () => { + it('routes every dirty editor from close-all-files to the central save queue', async () => { setFloatingEditorTabs([ makeFile({ id: 'file-a', isDirty: true }), makeFile({ id: 'file-b', isDirty: true }) @@ -250,12 +264,12 @@ describe('FloatingTerminalPanel close behavior', () => { const tabBar = findByTypeName(element, 'TabBar') ;(tabBar.props.onCloseAllFiles as () => void)() - expect(saveDialogBox.fileId).toBe('file-a') + expect(requestedEditorCloses()).toEqual(['file-a', 'file-b']) expect(mocks.closeFile).not.toHaveBeenCalledWith('file-a') expect(mocks.closeFile).not.toHaveBeenCalledWith('file-b') }) - it('queues dirty editor closes from close-others and close-to-right one file at a time', async () => { + it('routes close-others and close-to-right through the central save queue', async () => { setFloatingEditorTabs([ makeFile({ id: 'file-a', isDirty: true }), makeFile({ id: 'file-b', isDirty: true }), @@ -265,15 +279,15 @@ describe('FloatingTerminalPanel close behavior', () => { const element = await renderPanel(true) const tabBar = findByTypeName(element, 'TabBar') ;(tabBar.props.onCloseOthers as (tabId: string) => void)('tab-file-b') - expect(saveDialogBox.fileId).toBe('file-a') + expect(requestedEditorCloses()).toEqual(['file-a', 'file-c']) - saveDialogBox.fileId = null + vi.mocked(window.dispatchEvent).mockClear() mocks.closeFile.mockClear() hookRuntime.values = [] const nextElement = await renderPanel(true) const nextTabBar = findByTypeName(nextElement, 'TabBar') ;(nextTabBar.props.onCloseToRight as (tabId: string) => void)('tab-file-a') - expect(saveDialogBox.fileId).toBe('file-b') + expect(requestedEditorCloses()).toEqual(['file-b', 'file-c']) expect(mocks.closeFile).not.toHaveBeenCalledWith('file-c') }) }) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.shortcuts.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.shortcuts.test.tsx index 708699286fb..79412ab9869 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.shortcuts.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.shortcuts.test.tsx @@ -27,6 +27,11 @@ import { runEffects } from './floating-terminal-panel-render-probe' +vi.mock('zustand/react/shallow', () => ({ + // Why: zustand resolves the real react (unmocked in node_modules); the memo wrapper is inert here. + useShallow: (selector: unknown) => selector +})) + vi.mock('react', async () => { const actual = await vi.importActual('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() const { createReactHookOverrides } = await import('./floating-terminal-panel-test-module-mocks') @@ -204,7 +209,6 @@ describe('FloatingTerminalPanel close behavior', () => { 'floating-group', undefined ) - // Why: createTab itself activates the new tab within the floating group. expect(mocks.activateTab).not.toHaveBeenCalled() }) @@ -301,7 +305,6 @@ describe('FloatingTerminalPanel close behavior', () => { 'floating-group', undefined ) - // Why: createTab itself activates the new tab within the floating group. expect(mocks.activateTab).not.toHaveBeenCalled() }) @@ -549,6 +552,8 @@ describe('FloatingTerminalPanel close behavior', () => { } state.activeGroupIdByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: groupId } state.activeTabIdByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: terminalTab.id } + // Real state keeps tabs and layout together; the surface mounts only with both. + state.layoutByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: { type: 'leaf', groupId } } state.tabBarOrderByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: [ terminalUnifiedTab.id, diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tab-lifecycle.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tab-lifecycle.test.tsx index 1d8624ba435..6a2fb7600c5 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tab-lifecycle.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tab-lifecycle.test.tsx @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import type { Tab } from '../../../../shared/tab-types' import { + attachFloatingBrowserUnifiedTab, makeFile, makeTab, setFloatingSimulatorTab, @@ -9,19 +10,27 @@ import { storeBox, type FloatingPanelStoreState } from './floating-terminal-panel-test-fixtures' +import { mocks, setupFloatingTerminalPanelTest } from './floating-terminal-panel-test-harness' import { - mocks, - parkingBox, - setupFloatingTerminalPanelTest -} from './floating-terminal-panel-test-harness' -import { - findAllByTypeName, + findByProp, findByTypeName, flushAsyncWork, renderPanel, runEffects } from './floating-terminal-panel-render-probe' +const closeWorkspaceBrowserTabMock = vi.hoisted(() => + vi.fn(() => ({ closesLocally: true, localCloseReason: 'user' })) +) +vi.mock('@/lib/workspace-browser-tab-close', () => ({ + closeWorkspaceBrowserTab: closeWorkspaceBrowserTabMock +})) + +vi.mock('zustand/react/shallow', () => ({ + // Why: zustand resolves the real react (unmocked in node_modules); the memo wrapper is inert here. + useShallow: (selector: unknown) => selector +})) + vi.mock('react', async () => { const actual = await vi.importActual('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() const { createReactHookOverrides } = await import('./floating-terminal-panel-test-module-mocks') @@ -164,7 +173,7 @@ describe('FloatingTerminalPanel close behavior', () => { afterEach(() => { vi.unstubAllGlobals() }) - it('creates new floating terminal tabs active in the floating group', async () => { + it('creates new floating terminal tabs with workspace-owned activation', async () => { setFloatingTabs([makeTab({ id: 'tab-1' })]) const element = await renderPanel(true) @@ -177,7 +186,6 @@ describe('FloatingTerminalPanel close behavior', () => { 'floating-group', undefined ) - // Why: createTab itself activates the new tab within the floating group. expect(mocks.activateTab).not.toHaveBeenCalled() expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('created-tab') }) @@ -185,6 +193,7 @@ describe('FloatingTerminalPanel close behavior', () => { it('keeps floating browser create and duplicate local during active web runtime sessions', async () => { setFloatingTabs([makeTab({ id: 'tab-1' })]) ;(storeBox.state as FloatingPanelStoreState).settings.activeRuntimeEnvironmentId = 'runtime-1' + attachFloatingBrowserUnifiedTab('browser-1') ;(storeBox.state as FloatingPanelStoreState).browserTabsByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: [ { @@ -241,37 +250,47 @@ describe('FloatingTerminalPanel close behavior', () => { ) }) - it('hides the active terminal pane from the renderer while the panel is closed', async () => { + it('keeps the shared pane overlay mounted but hidden while the panel is closed', async () => { setFloatingTabs([makeTab({ id: 'tab-1' })]) - // Why: the closed panel stays mounted but CSS-hidden; gating isVisible on - // `open` routes the terminal through the standard hidden-terminal WebGL - // suspend/resume path so no live glyph atlas can corrupt while hidden. + // Why: the closed panel stays mounted but CSS-hidden; passing isVisible=open routes every + // retained pane through the standard hidden-terminal suspend/resume path (pinned on the + // shared TerminalOverlaySlot), so no live glyph atlas can corrupt while hidden. await renderPanel(false) runEffects() await Promise.resolve() const closedElement = await renderPanel(false) - const closedPane = findByTypeName(closedElement, 'TerminalPane') - expect(closedPane.props.isActive).toBe(true) - expect(closedPane.props.isVisible).toBe(false) + const closedLayers = findByTypeName(closedElement, 'WorkspacePaneOverlayLayers') + expect(closedLayers.props.isVisible).toBe(false) const openElement = await renderPanel(true) - const openPane = findByTypeName(openElement, 'TerminalPane') - expect(openPane.props.isVisible).toBe(true) + const openLayers = findByTypeName(openElement, 'WorkspacePaneOverlayLayers') + expect(openLayers.props.isVisible).toBe(true) + // Terminal panes mount against the host-resolved floating cwd once the viewport settles. + expect(openLayers.props.worktreePath).toBe('/tmp/orca') }) - it('does not mount floating terminal panes selected for cold parking', async () => { + it('wires the shared pane overlay with the floating parking and shortcut policy', async () => { setFloatingTabs([makeTab({ id: 'tab-1' }), makeTab({ id: 'tab-2' })]) - parkingBox.parkedTabIds = new Set(['tab-2']) await renderPanel(true) runEffects() await Promise.resolve() const element = await renderPanel(true) - expect(findAllByTypeName(element, 'TerminalPane').map((pane) => pane.props.tabId)).toEqual([ - 'tab-1' - ]) + // Cold-park selection itself is owned by the shared overlay layer; the floating panel's + // contract is the policy it feeds in. + expect(findByTypeName(element, 'WorkspacePaneOverlayLayers').props).toEqual( + expect.objectContaining({ + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + shouldColdParkTerminalPanes: false, + isForceParked: false, + shouldMeasureHiddenWorktree: false, + backgroundMountTabIds: null, + activationDeferredMountTabIds: null, + ownsNativeChatToggleShortcut: false + }) + ) }) it('keeps the panel open when the explicit close action removes the last tab', async () => { @@ -309,68 +328,73 @@ describe('FloatingTerminalPanel close behavior', () => { expect(onOpenChange).not.toHaveBeenCalled() }) - it('keeps PTY exit separate from explicit terminal pane close', async () => { - const onOpenChange = vi.fn() - setFloatingTabs([makeTab({ id: 'tab-1' })]) - - await renderPanel(true, onOpenChange) - runEffects() - await Promise.resolve() - const element = await renderPanel(true, onOpenChange) - const terminalPane = findByTypeName(element, 'TerminalPane') - - ;(terminalPane.props.onPtyExit as (ptyId: string) => void)('pty-1') - expect(mocks.shouldDeferParkedPtyExitTabClose).toHaveBeenCalledWith('tab-1', 'pty-1') - expect(mocks.closeTerminalTab).toHaveBeenCalledWith('tab-1', { - lifecyclePtyId: 'pty-1', - reason: 'pty-exit' - }) - expect(mocks.closeTab).not.toHaveBeenCalled() - expect(onOpenChange).not.toHaveBeenCalled() - - mocks.closeTerminalTab.mockClear() - ;(terminalPane.props.onCloseTab as () => void)() - // Explicit pane close routes through the confirmed-close authority, not a raw pty-exit prune. - expect(mocks.closeTerminalTab).toHaveBeenCalledWith( - 'tab-1', - expect.objectContaining({ onClosed: expect.any(Function) }) - ) - expect(mocks.closeTab).not.toHaveBeenCalled() - expect(onOpenChange).not.toHaveBeenCalled() - }) - - it('preserves split siblings when a parked PTY exits during reveal', async () => { - setFloatingTabs([makeTab({ id: 'tab-1' })]) - mocks.shouldDeferParkedPtyExitTabClose.mockReturnValueOnce(true) - - await renderPanel(true) - runEffects() - await Promise.resolve() - const element = await renderPanel(true) - const terminalPane = findByTypeName(element, 'TerminalPane') - - ;(terminalPane.props.onPtyExit as (ptyId: string) => void)('split-pty') - - expect(mocks.shouldDeferParkedPtyExitTabClose).toHaveBeenCalledWith('tab-1', 'split-pty') - expect(mocks.closeTerminalTab).not.toHaveBeenCalled() - expect(mocks.closeTab).not.toHaveBeenCalled() - }) - it('renders and closes simulator tabs in the floating workspace', async () => { const tab = setFloatingSimulatorTab() const element = await renderPanel(true) const tabBar = findByTypeName(element, 'TabBar') - const emulatorPane = findByTypeName(element, 'EmulatorPane') ;(tabBar.props.onCloseFile as (tabId: string) => void)(tab.id) expect(tabBar.props.activeTabType).toBe('simulator') expect(tabBar.props.activeSimulatorTabId).toBe(tab.id) - expect(emulatorPane.props.tab).toBe(tab) + // The emulator pane itself renders through the shared overlay stack. + expect(findByTypeName(element, 'WorkspacePaneOverlayLayers').props.mountEmulatorOverlay).toBe( + true + ) expect(mocks.closeUnifiedTab).toHaveBeenCalledWith(tab.id) expect(mocks.closeFile).not.toHaveBeenCalledWith(tab.id) }) + it('closes a structured chat through the shared workspace command', async () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: beforeEach installs the typed floating panel store fixture. + const state = storeBox.state as FloatingPanelStoreState + const baseTab = setFloatingSimulatorTab() + const tab: Tab = { ...baseTab, contentType: 'agent-session', entityId: 'session-1' } + state.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] = [tab] + + const element = await renderPanel(true) + const tabBar = findByTypeName(element, 'TabBar') + ;(tabBar.props.onCloseFile as (tabId: string) => void)(tab.id) + + expect(mocks.closeUnifiedTab).toHaveBeenCalledWith(tab.id) + expect(mocks.closeFile).not.toHaveBeenCalledWith(tab.entityId) + }) + + it('closes a browser through the shared host-aware command', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' })]) + attachFloatingBrowserUnifiedTab('browser-1') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: beforeEach installs the typed floating panel store fixture. + const state = storeBox.state as FloatingPanelStoreState + state.browserTabsByWorktree = { + [FLOATING_TERMINAL_WORKTREE_ID]: [ + { + id: 'browser-1', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + url: 'https://example.com', + title: 'Example', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } + ] + } + + const element = await renderPanel(true) + const tabBar = findByTypeName(element, 'TabBar') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the rendered TabBar stub supplies this close callback with a tab id. + ;(tabBar.props.onCloseBrowserTab as (tabId: string) => void)('tab-browser-1') + + expect(closeWorkspaceBrowserTabMock).toHaveBeenCalledExactlyOnceWith( + FLOATING_TERMINAL_WORKTREE_ID, + 'browser-1', + 'tab-browser-1' + ) + expect(mocks.closeBrowserTab).not.toHaveBeenCalled() + }) + it('keeps simulator tabs open when closing all files', async () => { const state = storeBox.state as FloatingPanelStoreState const groupId = 'floating-group' @@ -415,6 +439,9 @@ describe('FloatingTerminalPanel close behavior', () => { ] } state.activeGroupIdByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: groupId } + state.layoutByWorktree = { + [FLOATING_TERMINAL_WORKTREE_ID]: { type: 'leaf', groupId } + } state.tabBarOrderByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: [editorTab.id, simulatorTab.id] } @@ -445,7 +472,6 @@ describe('FloatingTerminalPanel close behavior', () => { 'floating-group', undefined ) - // Why: createTab itself activates the new tab within the floating group. expect(mocks.activateTab).not.toHaveBeenCalled() expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('created-tab') @@ -458,27 +484,47 @@ describe('FloatingTerminalPanel close behavior', () => { expect(onOpenChange).not.toHaveBeenCalled() }) - it('reads the current tab list for bulk close actions', async () => { + it('uses the current tab list for bulk close actions after rerender', async () => { setFloatingTabs([makeTab({ id: 'old-left' }), makeTab({ id: 'old-keep' })]) - const element = await renderPanel(true) - const tabBar = findByTypeName(element, 'TabBar') + await renderPanel(true) setFloatingTabs([ makeTab({ id: 'new-left', sortOrder: 0 }), makeTab({ id: 'new-keep', sortOrder: 1 }), makeTab({ id: 'new-right', sortOrder: 2 }) ]) + const refreshed = await renderPanel(true) + const tabBar = findByTypeName(refreshed, 'TabBar') ;(tabBar.props.onCloseOthers as (tabId: string) => void)('new-keep') - expect(mocks.closeTab).toHaveBeenCalledWith('new-left', { reason: 'cleanup' }) - expect(mocks.closeTab).toHaveBeenCalledWith('new-right', { reason: 'cleanup' }) - expect(mocks.closeTab).not.toHaveBeenCalledWith('old-left') + expect(mocks.closeTerminalTab).toHaveBeenCalledWith( + 'new-left', + expect.objectContaining({ skipRunningProcessConfirm: true }) + ) + expect(mocks.closeTerminalTab).toHaveBeenCalledWith( + 'new-right', + expect.objectContaining({ skipRunningProcessConfirm: true }) + ) + expect(mocks.closeTerminalTab).not.toHaveBeenCalledWith('old-left', expect.anything()) - mocks.closeTab.mockClear() - ;(tabBar.props.onCloseToRight as (tabId: string) => void)('new-left') - expect(mocks.closeTab).toHaveBeenCalledWith('new-keep', { reason: 'cleanup' }) - expect(mocks.closeTab).toHaveBeenCalledWith('new-right', { reason: 'cleanup' }) - expect(mocks.closeTab).not.toHaveBeenCalledWith('old-keep') + mocks.closeTerminalTab.mockClear() + setFloatingTabs([ + makeTab({ id: 'new-left', sortOrder: 0 }), + makeTab({ id: 'new-keep', sortOrder: 1 }), + makeTab({ id: 'new-right', sortOrder: 2 }) + ]) + const next = await renderPanel(true) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the rendered TabBar stub supplies this close callback with a tab id. + ;(findByTypeName(next, 'TabBar').props.onCloseToRight as (tabId: string) => void)('new-left') + expect(mocks.closeTerminalTab).toHaveBeenCalledWith( + 'new-keep', + expect.objectContaining({ skipRunningProcessConfirm: true }) + ) + expect(mocks.closeTerminalTab).toHaveBeenCalledWith( + 'new-right', + expect.objectContaining({ skipRunningProcessConfirm: true }) + ) + expect(mocks.closeTerminalTab).not.toHaveBeenCalledWith('old-keep', expect.anything()) }) it('closes tabs to the right using visible tab order', async () => { @@ -498,9 +544,15 @@ describe('FloatingTerminalPanel close behavior', () => { const tabBar = findByTypeName(element, 'TabBar') ;(tabBar.props.onCloseToRight as (tabId: string) => void)('tab-c') - expect(mocks.closeTab).toHaveBeenCalledWith('tab-a', { reason: 'cleanup' }) - expect(mocks.closeTab).toHaveBeenCalledWith('tab-b', { reason: 'cleanup' }) - expect(mocks.closeTab).not.toHaveBeenCalledWith('tab-c') + expect(mocks.closeTerminalTab).toHaveBeenCalledWith( + 'tab-a', + expect.objectContaining({ skipRunningProcessConfirm: true }) + ) + expect(mocks.closeTerminalTab).toHaveBeenCalledWith( + 'tab-b', + expect.objectContaining({ skipRunningProcessConfirm: true }) + ) + expect(mocks.closeTerminalTab).not.toHaveBeenCalledWith('tab-c', expect.anything()) }) }) @@ -511,21 +563,42 @@ describe('FloatingTerminalPanel tab drag wiring', () => { vi.unstubAllGlobals() }) - it('hosts the tab strip in a drag context so floating tabs can be reordered', async () => { + it('hosts the tab strip and body tree in one shared drag scope', async () => { setFloatingTabs([makeTab({ id: 'tab-1' }), makeTab({ id: 'tab-2' })]) const element = await renderPanel(true) - const dragContext = findByTypeName(element, 'FloatingWorkspaceTabDragContext') + const dragLayer = findByTypeName(element, 'WorkspaceTabDragLayer') - expect(dragContext.props.enabled).toBe(true) - expect(findByTypeName(dragContext.props.children, 'TabBar')).toBeDefined() + expect(dragLayer.props.worktreeId).toBe(FLOATING_TERMINAL_WORKTREE_ID) + expect(dragLayer.props.enabled).toBe(true) + // The one strip and the group tree live inside the same scope, so a drag can + // reorder in the strip or split against the tree. + expect(findByTypeName(element, 'TabBar')).toBeDefined() + expect(findByTypeName(element, 'TabGroupSplitNodeTree')).toBeDefined() }) - it('leaves the drag context inactive while the closed panel stays mounted', async () => { + it('mounts the tree and pane overlays inside the absolute flex surface frame', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' })]) + + const element = await renderPanel(true) + const frame = findByProp(element, 'data-floating-workspace-surface-frame') + + // Regression (0px panes): the tree's nodes size themselves as flex items, so their host + // must be a flex container that owns the body rect — TabGroupSplitNodeTree's host + // contract. Without it every group body, and every pane anchored to one, measures 0px + // tall. jsdom computes no layout, so pin the class contract; the e2e spec measures boxes. + expect(frame.props.className).toBe('absolute inset-0 flex') + expect(findByTypeName(frame, 'TabGroupSplitNodeTree')).toBeDefined() + // The overlays share the frame so anchor()/fallback geometry resolves in the same + // containing block as the group bodies they cover — as in WorktreeSplitSurface. + expect(findByTypeName(frame, 'WorkspacePaneOverlayLayers')).toBeDefined() + }) + + it('leaves the drag scope inactive while the closed panel stays mounted', async () => { setFloatingTabs([makeTab({ id: 'tab-1' })]) const element = await renderPanel(false) - expect(findByTypeName(element, 'FloatingWorkspaceTabDragContext').props.enabled).toBe(false) + expect(findByTypeName(element, 'WorkspaceTabDragLayer').props.enabled).toBe(false) }) }) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanelSurface.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanelSurface.tsx index bd2311e7ada..eca08e6d2ca 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanelSurface.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanelSurface.tsx @@ -1,27 +1,18 @@ -import { Suspense } from 'react' -import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' -import EmulatorPane from '@/components/emulator-pane/EmulatorPane' import TabBar from '@/components/tab-bar/TabBar' -import TerminalPane from '@/components/terminal-pane/TerminalPane' -import { shouldDeferParkedPtyExitTabClose } from '@/components/terminal-pane/terminal-parked-tab-watchers' -import { closeTerminalTab } from '@/components/terminal/terminal-tab-actions' +import { TabGroupSplitNodeTree } from '@/components/tab-group/TabGroupSplitNodeTree' +import { WorkspaceTabDragLayer } from '@/components/tab-group/WorkspaceTabDragLayer' +import { WorkspacePaneOverlayLayers } from '@/components/WorkspacePaneOverlayLayers' import { isTerminalImeInputContextRefreshing } from '@/components/terminal-pane/terminal-ime-input-context-refresh' import { buildDuplicatedBrowserTabOptions } from '@/lib/duplicate-browser-tab-options' -import { translate } from '@/i18n/i18n' -import { useAppStore } from '@/store' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' -import { isProvenProcessExit } from '../../../../shared/terminal-exit-cause' -import { FloatingBrowserSlot } from './FloatingBrowserSlot' import { FloatingTerminalEmptyState } from './FloatingTerminalEmptyState' import { renderFloatingTerminalOrchestrationCard } from './FloatingTerminalOrchestrationCard' import { FloatingTerminalOrchestrationDialog } from './FloatingTerminalOrchestrationDialog' import { FloatingTerminalResizeHandles } from './FloatingTerminalResizeHandles' -import { renderFloatingTerminalSaveDialog } from './FloatingTerminalSaveDialog' import { FloatingTerminalWindowControls } from './FloatingTerminalWindowControls' -import { FloatingWorkspaceTabDragContext } from './FloatingWorkspaceTabDragContext' import type { useFloatingTerminalPanelController } from './use-floating-terminal-panel-controller' -const EditorPanel = lazy(() => import('@/components/editor/EditorPanel')) +const NO_ACTIVITY_TERMINAL_PORTALS: [] = [] export function renderFloatingTerminalPanelSurface({ open, @@ -37,10 +28,16 @@ export function renderFloatingTerminalPanelSurface({ handleDragMove, handleDragEnd, handleTitlebarDoubleClick, + surface, + model, terminalItems, + activeGroup, activeTerminalId, + activeEditorUnifiedId, + activeBrowserId, + activeTab, + activeTabType, expandedPaneByTabId, - activateFloatingItem, closeFloatingItemConfirmed, closeOthers, closeToRight, @@ -52,29 +49,15 @@ export function renderFloatingTerminalPanelSurface({ setTabCustomTitle, setTabColor, setTabPaneExpanded, - editorItems, - browserItems, - activeEditorUnifiedId, - activeBrowserId, - activeTab, - activeTabType, - browserTabs, createBrowserTab, closeAllFiles, makePreviewFilePermanent, pinFile, - tabBarOrder, toggleMaximized, hasVisibleFloatingTabs, managedBrowserCreationEnabled, cwd, panelViewportSettled, - tabs, - parkedTerminalTabIds, - terminalPaneRegistry, - activeBrowserTab, - simulatorItems, - activeEditorFile, focusPanelForShortcuts, newTerminalShortcut, newBrowserShortcut, @@ -86,12 +69,7 @@ export function renderFloatingTerminalPanelSurface({ setOrchestrationDialogOpen, previewUserBounds, orchestrationDialogOpen, - refreshOrchestrationSetupVisibility, - saveDialogFileId, - saveDialogFile, - handleFloatingSaveDialogCancel, - handleFloatingSaveDialogDiscard, - handleFloatingSaveDialogSave + refreshOrchestrationSetupVisibility }: ReturnType): React.JSX.Element { return ( // Why: sit above the z-40 notification cards so the floating workspace is @@ -134,191 +112,159 @@ export function renderFloatingTerminalPanelSurface({ }} onKeyDownCapture={handleShortcutSurfaceKeyDown} > -
-
- - createFloatingTerminalTab()} - onNewTerminalWithShell={createFloatingTerminalTab} - onNewBrowserTab={createFloatingBrowserTab} - onNewFileTab={createFloatingMarkdownTab} - onOpenFileTab={openFloatingMarkdownTab} - newTabMenuOrder="markdown-first" - onSetCustomTitle={setTabCustomTitle} - onSetTabColor={setTabColor} - onTogglePaneExpand={(tabId) => - setTabPaneExpanded(tabId, expandedPaneByTabId[tabId] !== true) - } - editorFiles={editorItems} - browserTabs={browserItems} - activeFileId={activeEditorUnifiedId} - activeBrowserTabId={activeBrowserId} - activeSimulatorTabId={activeTab?.contentType === 'simulator' ? activeTab.id : null} - activeTabType={activeTabType} - onActivateFile={activateFloatingItem} - onCloseFile={closeFloatingItemConfirmed} - onActivateBrowserTab={activateFloatingItem} - onCloseBrowserTab={closeFloatingItemConfirmed} - onDuplicateBrowserTab={(browserTabId, sourceUnifiedTabId) => { - const source = browserTabs.find((tab) => tab.id === browserTabId) - if (!source) { - return - } - createBrowserTab(FLOATING_TERMINAL_WORKTREE_ID, source.url, { - ...buildDuplicatedBrowserTabOptions(source), - afterTabId: sourceUnifiedTabId, - browserRuntimeEnvironmentId: null - }) - }} - onCloseAllFiles={closeAllFiles} - onMakePreviewFilePermanent={makePreviewFilePermanent} - onPinFile={pinFile} - tabBarOrder={tabBarOrder} - tabStripChrome="floating-panel" - /> - - onOpenChange(false)} - /> -
- -
- {/* Why also gated on a settled viewport: a restored-maximized panel derives its - rect from the live viewport, so mounting terminals before the window finishes - maximizing fits them to a grid it is about to leave, and the correcting fit - reflows the buffer under a live TUI. */} - {cwd && panelViewportSettled - ? tabs - .filter((tab) => !parkedTerminalTabIds.has(tab.id)) - .map((tab) => { - const isActive = tab.id === activeTerminalId - return ( -
- { - if (exitCode !== undefined && !isProvenProcessExit(exitCode)) { - useAppStore.getState().markUnverifiedPtyLoss(tab.id) - return - } - if (shouldDeferParkedPtyExitTabClose(tab.id, ptyId)) { - return - } - closeTerminalTab(tab.id, { - reason: 'pty-exit', - lifecyclePtyId: ptyId - }) - }} - onCloseTab={() => closeFloatingItemConfirmed(tab.id)} - /> -
- ) - }) - : null} - {browserTabs.map((tab) => { - const isActive = tab.id === activeBrowserTab?.id - return ( -
- -
- ) - })} - {simulatorItems.map((tab) => { - const isActive = tab.id === activeTab?.id - return ( -
- -
- ) - })} - {activeEditorFile ? ( -
- - {translate( - 'auto.components.floating.terminal.FloatingTerminalPanel.d6b563ae24', - 'Loading editor...' - )} -
- } - > - {/* Why: floating workspace markdown is scratch/local context, - not a repo review surface that should expose agent notes. */} - + {({ isTabDragActive, hoveredTabInsertion, setDragRootNode }) => ( +
+
+
+ createFloatingTerminalTab()} + onNewTerminalWithShell={createFloatingTerminalTab} + onNewBrowserTab={createFloatingBrowserTab} + onNewFileTab={createFloatingMarkdownTab} + onOpenFileTab={openFloatingMarkdownTab} + newTabMenuOrder="markdown-first" + onSetCustomTitle={setTabCustomTitle} + onSetTabColor={setTabColor} + onTogglePaneExpand={(tabId) => + setTabPaneExpanded(tabId, expandedPaneByTabId[tabId] !== true) + } + editorFiles={model.editorItems} + browserTabs={model.browserItems} + agentSessionTabs={model.agentSessionItems} + groupActiveTabId={activeTab?.id ?? null} + activeFileId={activeEditorUnifiedId} + activeBrowserTabId={activeBrowserId} + activeSimulatorTabId={ + activeTab?.contentType === 'simulator' ? activeTab.id : null + } + activeTabType={activeTabType} + onActivateFile={model.commands.activateEditor} + onCloseFile={closeFloatingItemConfirmed} + onActivateBrowserTab={model.commands.activateBrowser} + onActivateAgentSession={model.commands.activateAgentSession} + onCloseBrowserTab={closeFloatingItemConfirmed} + onDuplicateBrowserTab={(browserTabId, sourceUnifiedTabId) => { + const source = model.browserItems.find((tab) => tab.id === browserTabId) + if (!source) { + return + } + createBrowserTab(FLOATING_TERMINAL_WORKTREE_ID, source.url, { + ...buildDuplicatedBrowserTabOptions(source), + afterTabId: sourceUnifiedTabId, + browserRuntimeEnvironmentId: null + }) + }} + onCloseAllFiles={closeAllFiles} + onMakePreviewFilePermanent={makePreviewFilePermanent} + onPinFile={pinFile} + tabBarOrder={model.tabBarOrder} + hoveredTabInsertion={hoveredTabInsertion} + tabStripChrome="floating-panel" /> - +
+ onOpenChange(false)} + />
- ) : null} - {!hasVisibleFloatingTabs ? ( - createFloatingTerminalTab()} - onNewMarkdown={createFloatingMarkdownTab} - onOpenMarkdown={openFloatingMarkdownTab} - onNewBrowser={createFloatingBrowserTab} - showNewBrowser={managedBrowserCreationEnabled} - onClose={() => onOpenChange(false)} - onFocusPanel={focusPanelForShortcuts} - newTerminalShortcut={newTerminalShortcut} - newBrowserShortcut={newBrowserShortcut} - newMarkdownShortcut={newMarkdownShortcut} - openMarkdownShortcut={openMarkdownShortcut} - closeShortcut={closeShortcut} - /> - ) : null} -
-
+ +
+ {/* Why also gated on resolvable items: stale unified tabs whose entities are gone + must show the empty state (with its CTAs), not a blank group body. */} + {surface.kind === 'workspace' && hasVisibleFloatingTabs ? ( + // Why the flex frame: the tree's nodes size themselves as flex items (flex-1), so + // the host must own their rect with a flex container — same contract as + // WorktreeSplitSurface. In a block parent every pane collapses to 0px. +
+ + {/* Why also gated on a settled viewport: a restored-maximized panel derives its + rect from the live viewport, so mounting terminals before the window finishes + maximizing fits them to a grid it is about to leave, and the correcting fit + reflows the buffer under a live TUI. An empty worktreePath mounts no + terminal panes; browser/editor/chat panes are not viewport-fitted. */} + +
+ ) : ( + createFloatingTerminalTab()} + onNewMarkdown={createFloatingMarkdownTab} + onOpenMarkdown={openFloatingMarkdownTab} + onNewBrowser={createFloatingBrowserTab} + showNewBrowser={managedBrowserCreationEnabled} + onClose={() => onOpenChange(false)} + onFocusPanel={focusPanelForShortcuts} + newTerminalShortcut={newTerminalShortcut} + newBrowserShortcut={newBrowserShortcut} + newMarkdownShortcut={newMarkdownShortcut} + openMarkdownShortcut={openMarkdownShortcut} + closeShortcut={closeShortcut} + /> + )} +
+
+ )} + {renderFloatingTerminalOrchestrationCard({ visible: showOrchestrationSetup && activeTabType === 'terminal', onDismiss: dismissOrchestrationSetup, @@ -336,13 +282,6 @@ export function renderFloatingTerminalPanelSurface({ onOpenChange={setOrchestrationDialogOpen} onSetupStateChange={() => void refreshOrchestrationSetupVisibility()} /> - {renderFloatingTerminalSaveDialog({ - saveDialogFileId, - saveDialogFile, - handleFloatingSaveDialogCancel, - handleFloatingSaveDialogDiscard, - handleFloatingSaveDialogSave - })}
) } diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalSaveDialog.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalSaveDialog.tsx deleted file mode 100644 index f285e6ce63f..00000000000 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalSaveDialog.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { Button } from '@/components/ui/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' -import { translate } from '@/i18n/i18n' -import type { useFloatingTerminalPanelController } from './use-floating-terminal-panel-controller' - -type FloatingTerminalSaveDialogProps = Pick< - ReturnType, - | 'saveDialogFileId' - | 'saveDialogFile' - | 'handleFloatingSaveDialogCancel' - | 'handleFloatingSaveDialogDiscard' - | 'handleFloatingSaveDialogSave' -> - -export function renderFloatingTerminalSaveDialog({ - saveDialogFileId, - saveDialogFile, - handleFloatingSaveDialogCancel, - handleFloatingSaveDialogDiscard, - handleFloatingSaveDialogSave -}: FloatingTerminalSaveDialogProps): React.JSX.Element { - return ( - { - if (!nextOpen) { - handleFloatingSaveDialogCancel() - } - }} - > - - - - {translate( - 'auto.components.floating.terminal.FloatingTerminalPanel.690b6fb98a', - 'Unsaved Changes' - )} - - - {saveDialogFile - ? translate( - 'auto.components.floating.terminal.FloatingTerminalPanel.5ddc688c52', - '"{{value0}}" has unsaved changes. Do you want to save before closing?', - { value0: saveDialogFile.relativePath.split('/').pop() } - ) - : translate( - 'auto.components.floating.terminal.FloatingTerminalPanel.b085fb58b5', - 'This file has unsaved changes.' - )} - - - - - - - - - - ) -} diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.test.tsx index da9710b965d..ac4bb5eed80 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.test.tsx @@ -15,7 +15,9 @@ const storeBox = vi.hoisted(() => ({ })) const mocks = vi.hoisted(() => ({ + activateTab: vi.fn(), createTab: vi.fn(), + setActiveTabForWorktree: vi.fn(), setTabBarOrder: vi.fn(), queueTabStartupCommand: vi.fn(), focusTerminalTabSurface: vi.fn(), @@ -149,6 +151,8 @@ beforeEach(() => { agentDefaultEnv: {} }, createTab: mocks.createTab, + activateTab: mocks.activateTab, + setActiveTabForWorktree: mocks.setActiveTabForWorktree, setTabBarOrder: mocks.setTabBarOrder, queueTabStartupCommand: mocks.queueTabStartupCommand, tabsByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: [{ id: EXISTING_TAB_ID }] }, @@ -177,7 +181,8 @@ describe('FloatingTerminalWindowControls default-agent launch', () => { requestId: expect.any(String), agent: 'claude', worktreeId: FLOATING_TERMINAL_WORKTREE_ID, - launchSource: 'shortcut' + launchSource: 'shortcut', + activate: false }) // Why: the whole point of the migration. The shared launcher owns the startup plan and the // tab it lands in, so this button must not reach past it into the tab store. @@ -186,9 +191,18 @@ describe('FloatingTerminalWindowControls default-agent launch', () => { expect(mocks.setTabBarOrder).not.toHaveBeenCalled() }) - it('focuses the launched terminal tab', () => { + it('activates the launched terminal tab so the floating panel selects and focuses it', () => { clickLaunch() + // Why: the floating panel renders its visible tab from the unified group's + // activeTabId, which only activateTab writes. setActiveTabForWorktree updates + // the complementary legacy per-worktree map. Without activateTab the new agent + // tab would be appended but never selected/focused. + expect(mocks.setActiveTabForWorktree).toHaveBeenCalledWith( + FLOATING_TERMINAL_WORKTREE_ID, + NEW_AGENT_TAB_ID + ) + expect(mocks.activateTab).toHaveBeenCalledWith(NEW_AGENT_TAB_ID) expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith(NEW_AGENT_TAB_ID) }) @@ -198,14 +212,12 @@ describe('FloatingTerminalWindowControls default-agent launch', () => { clickLaunch() expect(toast.error).toHaveBeenCalledWith('Could not build launch command for Claude.') - expect(mocks.focusTerminalTabSurface).not.toHaveBeenCalled() + expect(mocks.activateTab).not.toHaveBeenCalled() }) - // Why: a floating window has nowhere to keep a structured session, so the launch must resolve a - // terminal. Pinned against the shared resolver the launcher routes on, not a restatement here. - it('keeps the floating workspace off the structured route', () => { - // `claude` is a structured-session provider on a local host, so `floating-workspace` is the - // only blocker that can produce this result — any other answer means the kind stopped deciding. + // Why: workspace kind no longer refuses the structured route — the floating session files under + // its resolved directory like any workspace. Only capability negotiation can still say no here. + it('routes the floating workspace by capability, not by workspace kind', () => { expect( resolveStructuredNativeChatSupport({ agent: 'claude', @@ -213,6 +225,6 @@ describe('FloatingTerminalWindowControls default-agent launch', () => { hostCapabilities: null, workspaceKind: 'floating' }) - ).toEqual({ supported: false, blocker: 'floating-workspace' }) + ).toEqual({ supported: false, blocker: 'runtime-capability-unknown' }) }) }) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx index 28fa197c050..8536d4e5912 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx @@ -39,6 +39,8 @@ export function FloatingTerminalWindowControls({ onMinimize }: FloatingTerminalWindowControlsProps): React.JSX.Element { const defaultTuiAgent = useAppStore((s) => s.settings?.defaultTuiAgent ?? null) + const setActiveTabForWorktree = useAppStore((s) => s.setActiveTabForWorktree) + const activateTab = useAppStore((s) => s.activateTab) const maximizeShortcutLabel = useOptionalShortcutLabel('floatingWorkspace.maximize') const minimizeShortcutLabel = useOptionalShortcutLabel('floatingWorkspace.minimize') @@ -71,7 +73,11 @@ export function FloatingTerminalWindowControls({ requestId: newAgentLaunchRequestId(), agent: defaultAgent, worktreeId: FLOATING_TERMINAL_WORKTREE_ID, - launchSource: 'shortcut' + launchSource: 'shortcut', + // Why: `agent-auto-ack-targets` relies on the floating panel's active tab never becoming the + // global `activeTabId`; activating here would also flip the main view off an open editor. + // This selects within the floating group below instead. + activate: false }) if (!result) { toast.error( @@ -86,8 +92,14 @@ export function FloatingTerminalWindowControls({ if (result.surface.kind !== 'local-terminal') { return } + // Why: the floating panel renders its visible tab from the unified group's + // activeTabId. setActiveTabForWorktree only writes activeTabIdByWorktree, so + // the new agent tab would be appended but never selected/focused. activateTab + // selects it within the group, matching the empty-state tab creators. + setActiveTabForWorktree(FLOATING_TERMINAL_WORKTREE_ID, result.surface.tabId) + activateTab(result.surface.tabId) focusTerminalTabSurface(result.surface.tabId) - }, [defaultAgent, defaultAgentLabel]) + }, [activateTab, defaultAgent, defaultAgentLabel, setActiveTabForWorktree]) return (
diff --git a/src/renderer/src/components/floating-terminal/FloatingWorkspaceTabDragContext.tsx b/src/renderer/src/components/floating-terminal/FloatingWorkspaceTabDragContext.tsx deleted file mode 100644 index b331375d128..00000000000 --- a/src/renderer/src/components/floating-terminal/FloatingWorkspaceTabDragContext.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { cloneElement } from 'react' -import { DndContext, DragOverlay } from '@dnd-kit/core' -import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' -import TabDragPreview from '../tab-bar/TabDragPreview' -import { TabDragProvider } from '../tab-group/tab-drag-context' -import { useTabDragSplit, type HoveredTabInsertion } from '../tab-group/useTabDragSplit' - -/** dnd-kit host for the floating workspace tab strip, so its tabs reorder with - * the same gesture and insertion bar the main workspace tab bar uses. The panel - * renders one group and no split layout, so a drop only ever resolves to a - * same-group reorder — the pane-column split branches find no panel geometry. */ -export function FloatingWorkspaceTabDragContext({ - enabled, - children -}: { - /** False while the panel is hidden: a closed panel must not register sensors that compete with the workspace DndContext. */ - enabled: boolean - children: React.ReactElement<{ hoveredTabInsertion?: HoveredTabInsertion | null }> -}): React.JSX.Element { - const dragSplit = useTabDragSplit({ worktreeId: FLOATING_TERMINAL_WORKTREE_ID, enabled }) - - return ( - - -
- {/* Why cloneElement over a render prop: the tab bar stays a plain child element, which keeps the panel's tree shallow-walkable. */} - {cloneElement(children, { hoveredTabInsertion: dragSplit.hoveredTabInsertion })} -
- {/* Why: the source tab stays anchored in the overflow-hidden strip; this ghost is what follows the cursor. */} - - {dragSplit.activeDrag ? : null} - -
-
- ) -} diff --git a/src/renderer/src/components/floating-terminal/floating-terminal-panel-render-probe.ts b/src/renderer/src/components/floating-terminal/floating-terminal-panel-render-probe.ts index c6cc9676e81..135e68c5ee7 100644 --- a/src/renderer/src/components/floating-terminal/floating-terminal-panel-render-probe.ts +++ b/src/renderer/src/components/floating-terminal/floating-terminal-panel-render-probe.ts @@ -20,7 +20,21 @@ function visit(node: unknown, cb: (node: ReactElementLike) => void): void { return } cb(element) - visit(element.props.children, cb) + // Why: WorkspaceTabDragLayer takes a render prop; walk its children with an inert drag state + // so the titlebar strip and body tree stay reachable without invoking dnd-kit. + const children = element.props.children + if (typeof children === 'function') { + visit( + children({ + isTabDragActive: false, + hoveredTabInsertion: null, + setDragRootNode: () => {} + }), + cb + ) + return + } + visit(children, cb) } export function findByTypeName(node: unknown, typeName: string): ReactElementLike { diff --git a/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-fixtures.ts b/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-fixtures.ts index 9fc535e1bbd..cd061b5ccf0 100644 --- a/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-fixtures.ts +++ b/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-fixtures.ts @@ -1,7 +1,7 @@ import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import type { KeybindingOverrides, TerminalShortcutPolicy } from '../../../../shared/keybindings' import type { BrowserTab } from '../../../../shared/browser-workspace-types' -import type { Tab, TabGroup } from '../../../../shared/tab-types' +import type { Tab, TabGroup, TabGroupLayoutNode } from '../../../../shared/tab-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { OpenFile } from '@/store/slices/editor' @@ -14,7 +14,17 @@ export type FloatingPanelStoreState = { openFiles: OpenFile[] activeGroupIdByWorktree: Record activeTabIdByWorktree: Record + layoutByWorktree: Record expandedPaneByTabId: Record + focusGroup: (worktreeId: string, groupId: string) => void + setActiveTabType: (type: string, worktreeId?: string) => void + setActiveFile: (fileId: string, worktreeId?: string) => void + setActiveBrowserTab: (tabId: string, worktreeId?: string) => void + setActiveWorktree: (worktreeId: string | null) => void + createEmptySplitGroup: (worktreeId: string, groupId: string, direction: string) => string | null + openNewBrowserTabInActiveWorkspace: (groupId?: string) => Promise + openNewMarkdownInActiveWorkspace: (groupId?: string) => Promise + openNewTerminalTabInActiveWorkspace: (groupId?: string) => Promise createTab: ( worktreeId: string, groupId?: string, @@ -50,6 +60,8 @@ export type FloatingPanelStoreState = { browserDefaultUrl: string keybindings?: KeybindingOverrides tabBarOrderByWorktree: Record + floatingWorkspacePath: string | null + setFloatingWorkspacePath: (path: string) => void settings: { activeRuntimeEnvironmentId?: string | null floatingTerminalCwd?: string @@ -131,6 +143,32 @@ export function setFloatingTabs(tabs: TerminalTab[]): void { state.activeGroupIdByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: groupId } state.activeTabIdByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: tabs[0]?.id ?? null } state.tabBarOrderByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: tabs.map((tab) => tab.id) } + state.layoutByWorktree = + tabs.length > 0 ? { [FLOATING_TERMINAL_WORKTREE_ID]: { type: 'leaf', groupId } } : {} +} + +/** Appends the unified strip entry a real createBrowserTab always writes beside the entity. */ +export function attachFloatingBrowserUnifiedTab(browserTabId: string): void { + const state = storeBox.state as FloatingPanelStoreState + const groupId = 'floating-group' + const unified = state.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? [] + const tab: Tab = { + id: `tab-${browserTabId}`, + entityId: browserTabId, + groupId, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + contentType: 'browser', + label: browserTabId, + customLabel: null, + color: null, + sortOrder: unified.length, + createdAt: unified.length + } + state.unifiedTabsByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: [...unified, tab] } + const group = state.groupsByWorktree[FLOATING_TERMINAL_WORKTREE_ID]?.[0] + if (group) { + group.tabOrder = [...group.tabOrder, tab.id] + } } export function setFloatingEditorTabs(files: OpenFile[]): void { @@ -162,6 +200,8 @@ export function setFloatingEditorTabs(files: OpenFile[]): void { ] } state.activeGroupIdByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: groupId } + state.layoutByWorktree = + files.length > 0 ? { [FLOATING_TERMINAL_WORKTREE_ID]: { type: 'leaf', groupId } } : {} } export function setFloatingSimulatorTab(): Tab { @@ -193,5 +233,6 @@ export function setFloatingSimulatorTab(): Tab { } state.activeGroupIdByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: groupId } state.tabBarOrderByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: [tab.id] } + state.layoutByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: { type: 'leaf', groupId } } return tab } diff --git a/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-harness.ts b/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-harness.ts index d8bb0848c67..5de72fd51f9 100644 --- a/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-harness.ts +++ b/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-harness.ts @@ -60,6 +60,21 @@ export type FloatingTerminalPanelMocks = { pinFile: Mock setFloatingFocus: Mock<(state: { panelFocused: boolean; terminalFocused: boolean }) => void> setActiveTab: Mock + setActiveTabType: Mock + setActiveFile: Mock + setActiveBrowserTab: Mock + setActiveWorktree: Mock + focusGroup: Mock + createEmptySplitGroup: Mock + openNewBrowserTabInActiveWorkspace: Mock< + FloatingPanelStoreState['openNewBrowserTabInActiveWorkspace'] + > + openNewMarkdownInActiveWorkspace: Mock< + FloatingPanelStoreState['openNewMarkdownInActiveWorkspace'] + > + openNewTerminalTabInActiveWorkspace: Mock< + FloatingPanelStoreState['openNewTerminalTabInActiveWorkspace'] + > setTabColor: Mock setTabCustomTitle: Mock setTabPaneExpanded: Mock @@ -107,6 +122,15 @@ export const mocks: FloatingTerminalPanelMocks = { pinFile: vi.fn(), setFloatingFocus: vi.fn(), setActiveTab: vi.fn(), + setActiveTabType: vi.fn(), + setActiveFile: vi.fn(), + setActiveBrowserTab: vi.fn(), + setActiveWorktree: vi.fn(), + focusGroup: vi.fn(), + createEmptySplitGroup: vi.fn(), + openNewBrowserTabInActiveWorkspace: vi.fn(), + openNewMarkdownInActiveWorkspace: vi.fn(), + openNewTerminalTabInActiveWorkspace: vi.fn(), setTabColor: vi.fn(), setTabCustomTitle: vi.fn(), setTabPaneExpanded: vi.fn(), @@ -123,7 +147,7 @@ export const parkingBox = { } function resetStore(tabs: TerminalTab[] = []): void { - storeBox.state = { + const state: FloatingPanelStoreState = { tabsByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: tabs }, browserTabsByWorktree: {}, browserPagesByWorkspace: {}, @@ -132,7 +156,17 @@ function resetStore(tabs: TerminalTab[] = []): void { openFiles: [], activeGroupIdByWorktree: {}, activeTabIdByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: tabs[0]?.id ?? null }, + layoutByWorktree: {}, expandedPaneByTabId: {}, + focusGroup: mocks.focusGroup, + setActiveTabType: mocks.setActiveTabType, + setActiveFile: mocks.setActiveFile, + setActiveBrowserTab: mocks.setActiveBrowserTab, + setActiveWorktree: mocks.setActiveWorktree, + createEmptySplitGroup: mocks.createEmptySplitGroup, + openNewBrowserTabInActiveWorkspace: mocks.openNewBrowserTabInActiveWorkspace, + openNewMarkdownInActiveWorkspace: mocks.openNewMarkdownInActiveWorkspace, + openNewTerminalTabInActiveWorkspace: mocks.openNewTerminalTabInActiveWorkspace, activateTab: mocks.activateTab, closeBrowserTab: mocks.closeBrowserTab, clearEditorDraft: mocks.clearEditorDraft, @@ -152,8 +186,14 @@ function resetStore(tabs: TerminalTab[] = []): void { browserDefaultUrl: 'about:blank', keybindings: {}, tabBarOrderByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: tabs.map((tab) => tab.id) }, + floatingWorkspacePath: null, + // The real slice stores the host-resolved cwd; mirror the write so panes see it next render. + setFloatingWorkspacePath: (path: string) => { + state.floatingWorkspacePath = path + }, settings: { floatingTerminalCwd: '' } - } satisfies FloatingPanelStoreState + } + storeBox.state = state } // Why: the panel's per-test environment (hook runtime, store, stubbed globals and module diff --git a/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-module-mocks.ts b/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-module-mocks.ts index 8ab51749d38..04c37ecfc9d 100644 --- a/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-module-mocks.ts +++ b/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-module-mocks.ts @@ -13,6 +13,8 @@ import { storeBox, type FloatingPanelStoreState } from './floating-terminal-pane export function createReactHookOverrides() { return { useCallback: (callback: T) => callback, + // Why: subscription hooks in the shared model read module stores; the probe only needs the snapshot. + useSyncExternalStore: (_subscribe: unknown, getSnapshot: () => T) => getSnapshot(), useEffect: (effect: EffectCallback) => { hookRuntime.effects.push(effect) }, diff --git a/src/renderer/src/components/floating-terminal/floating-workspace-surface-model.test.ts b/src/renderer/src/components/floating-terminal/floating-workspace-surface-model.test.ts new file mode 100644 index 00000000000..4b6d994b42f --- /dev/null +++ b/src/renderer/src/components/floating-terminal/floating-workspace-surface-model.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from 'vitest' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import type { Tab, TabGroup, TabGroupLayoutNode } from '../../../../shared/tab-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import { resolveFloatingWorkspaceSurfaceModel } from './floating-workspace-surface-model' + +const FLOATING = FLOATING_TERMINAL_WORKTREE_ID + +function makeTab(id: string, groupId: string): Tab { + return { + id, + entityId: `entity-${id}`, + contentType: 'terminal', + worktreeId: FLOATING, + groupId, + label: id, + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + } +} + +function makeGroup(id: string, overrides: Partial = {}): TabGroup { + return { id, worktreeId: FLOATING, activeTabId: null, tabOrder: [], ...overrides } +} + +const LEAF: TabGroupLayoutNode = { type: 'leaf', groupId: 'group-1' } + +function makeState(overrides: { + tabs?: Tab[] + groups?: TabGroup[] + layout?: TabGroupLayoutNode + focusedGroupId?: string + terminalTabs?: TerminalTab[] +}): Parameters[0] { + const unifiedTabsByWorktree: Record = {} + const groupsByWorktree: Record = {} + const layoutByWorktree: Record = {} + const activeGroupIdByWorktree: Record = {} + if (overrides.tabs) { + unifiedTabsByWorktree[FLOATING] = overrides.tabs + } + if (overrides.groups) { + groupsByWorktree[FLOATING] = overrides.groups + } + if (overrides.layout) { + layoutByWorktree[FLOATING] = overrides.layout + } + if (overrides.focusedGroupId) { + activeGroupIdByWorktree[FLOATING] = overrides.focusedGroupId + } + const terminalTabs = + overrides.terminalTabs ?? + (overrides.tabs ?? []) + .filter((tab) => tab.contentType === 'terminal') + .map((tab) => ({ + id: tab.entityId, + ptyId: null, + worktreeId: FLOATING, + title: tab.label, + customTitle: null, + color: null, + sortOrder: tab.sortOrder, + createdAt: tab.createdAt + })) + return { + unifiedTabsByWorktree, + groupsByWorktree, + layoutByWorktree, + activeGroupIdByWorktree, + tabsByWorktree: { [FLOATING]: terminalTabs }, + browserTabsByWorktree: {}, + openFiles: [] + } +} + +describe('resolveFloatingWorkspaceSurfaceModel', () => { + it('is empty until the first tab exists', () => { + expect(resolveFloatingWorkspaceSurfaceModel(makeState({}))).toEqual({ kind: 'empty' }) + }) + + it('is empty when tabs exist but no layout does (defensive: creation writes both)', () => { + expect( + resolveFloatingWorkspaceSurfaceModel( + makeState({ tabs: [makeTab('t1', 'group-1')], groups: [makeGroup('group-1')] }) + ) + ).toEqual({ kind: 'empty' }) + }) + + it('mounts the workspace with the stored focused group', () => { + expect( + resolveFloatingWorkspaceSurfaceModel( + makeState({ + tabs: [makeTab('t1', 'group-1')], + groups: [makeGroup('group-1'), makeGroup('group-2')], + layout: LEAF, + focusedGroupId: 'group-2' + }) + ) + ).toEqual({ kind: 'workspace', layout: LEAF, focusedGroupId: 'group-2' }) + }) + + it('keeps the workspace mounted when the focused split is empty', () => { + const layout: TabGroupLayoutNode = { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: 'group-1' }, + second: { type: 'leaf', groupId: 'group-2' } + } + expect( + resolveFloatingWorkspaceSurfaceModel( + makeState({ + tabs: [makeTab('t1', 'group-1')], + groups: [ + makeGroup('group-1', { activeTabId: 't1', tabOrder: ['t1'] }), + makeGroup('group-2') + ], + layout, + focusedGroupId: 'group-2' + }) + ) + ).toEqual({ kind: 'workspace', layout, focusedGroupId: 'group-2' }) + }) + + it('shows the empty state when every unified tab has lost its backing entity', () => { + expect( + resolveFloatingWorkspaceSurfaceModel( + makeState({ + tabs: [makeTab('t1', 'group-1')], + groups: [makeGroup('group-1', { activeTabId: 't1', tabOrder: ['t1'] })], + layout: LEAF, + terminalTabs: [] + }) + ) + ).toEqual({ kind: 'empty' }) + }) + + it('falls back from a stale focused id to the group with an active tab', () => { + expect( + resolveFloatingWorkspaceSurfaceModel( + makeState({ + tabs: [makeTab('t1', 'group-2')], + groups: [makeGroup('group-1'), makeGroup('group-2', { activeTabId: 't1' })], + layout: LEAF, + focusedGroupId: 'group-gone' + }) + ) + ).toEqual({ kind: 'workspace', layout: LEAF, focusedGroupId: 'group-2' }) + }) + + it('falls back to the first group when none records an active tab', () => { + expect( + resolveFloatingWorkspaceSurfaceModel( + makeState({ + tabs: [makeTab('t1', 'group-1')], + groups: [makeGroup('group-1'), makeGroup('group-2')], + layout: LEAF + }) + ) + ).toEqual({ kind: 'workspace', layout: LEAF, focusedGroupId: 'group-1' }) + }) +}) diff --git a/src/renderer/src/components/floating-terminal/floating-workspace-surface-model.ts b/src/renderer/src/components/floating-terminal/floating-workspace-surface-model.ts new file mode 100644 index 00000000000..fc595c09e2e --- /dev/null +++ b/src/renderer/src/components/floating-terminal/floating-workspace-surface-model.ts @@ -0,0 +1,68 @@ +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import type { TabGroupLayoutNode } from '../../../../shared/tab-types' +import type { AppState } from '@/store/types' + +export type FloatingWorkspaceSurfaceModel = + | { kind: 'empty' } + | { kind: 'workspace'; layout: TabGroupLayoutNode; focusedGroupId: string } + +type FloatingWorkspaceSurfaceState = Pick< + AppState, + | 'unifiedTabsByWorktree' + | 'groupsByWorktree' + | 'layoutByWorktree' + | 'activeGroupIdByWorktree' + | 'tabsByWorktree' + | 'browserTabsByWorktree' + | 'openFiles' +> + +/** + * Decides emptiness and layout for the floating panel body atomically. + * + * An empty floating workspace has no layout at all: `layoutByWorktree` starts empty and + * hydration skips workspaces without tabs — while the shared group tree requires a layout. + * So the panel renders its empty state exactly until the first tab exists; tab creation and + * hydration both write a layout with the tab, which flips this to a mountable workspace. + */ +export function resolveFloatingWorkspaceSurfaceModel( + state: FloatingWorkspaceSurfaceState +): FloatingWorkspaceSurfaceModel { + const tabs = state.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] + const layout = state.layoutByWorktree[FLOATING_TERMINAL_WORKTREE_ID] + if (!tabs || tabs.length === 0 || !layout) { + return { kind: 'empty' } + } + const terminalIds = new Set( + (state.tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []).map((tab) => tab.id) + ) + const browserIds = new Set( + (state.browserTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []).map((tab) => tab.id) + ) + const fileIds = new Set(state.openFiles.map((file) => file.id)) + const hasVisibleTab = tabs.some((tab) => { + if (tab.contentType === 'terminal') { + return terminalIds.has(tab.entityId) + } + if (tab.contentType === 'browser') { + return browserIds.has(tab.entityId) + } + if (tab.contentType === 'agent-session' || tab.contentType === 'simulator') { + return true + } + return fileIds.has(tab.entityId) + }) + if (!hasVisibleTab) { + return { kind: 'empty' } + } + const groups = state.groupsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? [] + const storedFocusId = state.activeGroupIdByWorktree[FLOATING_TERMINAL_WORKTREE_ID] + const focusedGroupId = + (storedFocusId && groups.some((group) => group.id === storedFocusId) ? storedFocusId : null) ?? + groups.find((group) => group.activeTabId != null)?.id ?? + groups[0]?.id + if (!focusedGroupId) { + return { kind: 'empty' } + } + return { kind: 'workspace', layout, focusedGroupId } +} diff --git a/src/renderer/src/components/floating-terminal/terminal-pane-handle-registry.test.ts b/src/renderer/src/components/floating-terminal/terminal-pane-handle-registry.test.ts deleted file mode 100644 index 90b50de4fa1..00000000000 --- a/src/renderer/src/components/floating-terminal/terminal-pane-handle-registry.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { createTerminalPaneHandleRegistry } from './terminal-pane-handle-registry' - -type FakeHandle = { closeActivePane: () => void } - -function createHandle(): FakeHandle { - return { closeActivePane: () => {} } -} - -describe('createTerminalPaneHandleRegistry', () => { - it('returns the same ref callback for a tab id across renders', () => { - const registry = createTerminalPaneHandleRegistry() - expect(registry.getRefCallback('tab-1')).toBe(registry.getRefCallback('tab-1')) - expect(registry.getRefCallback('tab-2')).not.toBe(registry.getRefCallback('tab-1')) - }) - - it('exposes the attached handle and clears it on detach', () => { - const registry = createTerminalPaneHandleRegistry() - const handle = createHandle() - const register = registry.getRefCallback('tab-1') - - register(handle) - expect(registry.getHandle('tab-1')).toBe(handle) - - register(null) - expect(registry.getHandle('tab-1')).toBeNull() - }) - - it('keeps callback identity stable through a same-id remount', () => { - const registry = createTerminalPaneHandleRegistry() - const first = registry.getRefCallback('tab-1') - first(createHandle()) - - // Generation bump: React detaches the old element, then attaches the new one. - first(null) - const remounted = registry.getRefCallback('tab-1') - const handle = createHandle() - remounted(handle) - - expect(remounted).toBe(first) - expect(registry.getRefCallback('tab-1')).toBe(first) - expect(registry.getHandle('tab-1')).toBe(handle) - }) - - it('does not re-attach on parent renders that follow a remount', () => { - const registry = createTerminalPaneHandleRegistry() - let attachedCallback: ((handle: FakeHandle | null) => void) | null = null - let handle: FakeHandle | null = null - let attachCount = 0 - - // One render + commit of the pane: React only touches the ref when its identity changed. - const commit = (): void => { - const next = registry.getRefCallback('tab-1') - if (next === attachedCallback) { - return - } - attachedCallback?.(null) - handle = createHandle() - next(handle) - attachedCallback = next - attachCount += 1 - } - - // Generation bump: the render reads the cache first, then the commit swaps the two elements. - const remount = (): void => { - const next = registry.getRefCallback('tab-1') - attachedCallback?.(null) - handle = createHandle() - next(handle) - attachedCallback = next - attachCount += 1 - } - - commit() - remount() - commit() - commit() - commit() - - expect(attachCount).toBe(2) - expect(registry.getHandle('tab-1')).toBe(handle) - }) - - it('re-arms the cache when a pruned tab attaches again', () => { - const registry = createTerminalPaneHandleRegistry() - const register = registry.getRefCallback('tab-1') - - registry.retainOnly([]) - // The pruned callback is still live in React's committed tree until it detaches. - register(createHandle()) - - expect(registry.getRefCallback('tab-1')).toBe(register) - }) - - it('prunes entries for tabs that left the panel', () => { - const registry = createTerminalPaneHandleRegistry() - registry.getRefCallback('tab-1')(createHandle()) - const survivor = registry.getRefCallback('tab-2') - survivor(createHandle()) - - registry.retainOnly(['tab-2']) - - expect(registry.getHandle('tab-1')).toBeNull() - expect(registry.getRefCallback('tab-1')).not.toBe(survivor) - expect(registry.getRefCallback('tab-2')).toBe(survivor) - expect(registry.getHandle('tab-2')).not.toBeNull() - }) -}) diff --git a/src/renderer/src/components/floating-terminal/terminal-pane-handle-registry.ts b/src/renderer/src/components/floating-terminal/terminal-pane-handle-registry.ts deleted file mode 100644 index c0d69db2993..00000000000 --- a/src/renderer/src/components/floating-terminal/terminal-pane-handle-registry.ts +++ /dev/null @@ -1,53 +0,0 @@ -// Per-tab imperative handles for the floating panel's terminal panes, plus the stable ref callback -// that fills them. Generic over the handle so this stays a plain unit-testable module instead of -// pulling in TerminalPane. -export type TerminalPaneHandleRegistry = { - getHandle: (tabId: string) => THandle | null - getRefCallback: (tabId: string) => (handle: THandle | null) => void - retainOnly: (liveTabIds: Iterable) => void -} - -export function createTerminalPaneHandleRegistry(): TerminalPaneHandleRegistry { - const handles = new Map() - const refCallbacks = new Map void>() - - const getRefCallback = (tabId: string): ((handle: THandle | null) => void) => { - const cached = refCallbacks.get(tabId) - if (cached) { - return cached - } - const register = (handle: THandle | null): void => { - if (handle) { - handles.set(tabId, handle) - // Re-arm: a remount (generation bump / StrictMode) can attach through a callback that - // retainOnly already pruned, and the next render must reuse it rather than mint a new one. - refCallbacks.set(tabId, register) - return - } - // Detach drops the handle only. Dropping the callback here would delete the entry the current - // render just wrote, so every later render would mint a fresh identity and force React to - // detach/re-attach the pane — the exact churn this cache exists to prevent. - handles.delete(tabId) - } - refCallbacks.set(tabId, register) - return register - } - - return { - getHandle: (tabId) => handles.get(tabId) ?? null, - getRefCallback, - retainOnly: (liveTabIds) => { - const live = new Set(liveTabIds) - for (const tabId of refCallbacks.keys()) { - if (!live.has(tabId)) { - refCallbacks.delete(tabId) - } - } - for (const tabId of handles.keys()) { - if (!live.has(tabId)) { - handles.delete(tabId) - } - } - } - } -} diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-close-actions.test.tsx b/src/renderer/src/components/floating-terminal/use-floating-terminal-close-actions.test.tsx new file mode 100644 index 00000000000..3907c88c020 --- /dev/null +++ b/src/renderer/src/components/floating-terminal/use-floating-terminal-close-actions.test.tsx @@ -0,0 +1,145 @@ +// @vitest-environment happy-dom +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultSettings } from '../../../../shared/constants' +import { useAppStore } from '@/store' +import { makeOpenFile, makeTabGroup, makeUnifiedTab } from '@/store/slices/store-test-helpers' +import { useFloatingTerminalCloseActions } from './use-floating-terminal-close-actions' + +const requestEditorFileClose = vi.hoisted(() => vi.fn()) +vi.mock('@/components/editor/editor-autosave', () => ({ requestEditorFileClose })) + +const worktreeId = FLOATING_TERMINAL_WORKTREE_ID +const groupId = 'floating-group' +const sharedFileId = 'shared-file' +const editor = makeUnifiedTab({ + id: 'editor-one', + worktreeId, + groupId, + contentType: 'editor', + entityId: sharedFileId +}) +const secondReference = makeUnifiedTab({ + id: 'editor-two', + worktreeId, + groupId: 'other-group', + contentType: 'editor', + entityId: sharedFileId +}) +const chat = makeUnifiedTab({ + id: 'chat-one', + worktreeId, + groupId, + contentType: 'agent-session', + entityId: 'chat-session' +}) +const pinnedEditor = makeUnifiedTab({ + id: 'pinned-editor', + worktreeId, + groupId, + contentType: 'editor', + entityId: 'pinned-file', + isPinned: true +}) +const group = makeTabGroup({ + id: groupId, + worktreeId, + activeTabId: editor.id, + tabOrder: [editor.id, chat.id] +}) + +const closeFile = vi.fn['closeFile']>() +const closeUnifiedTab = vi.fn['closeUnifiedTab']>( + () => null +) +const requestPinnedTabCloseConfirm = vi.fn() + +beforeEach(() => { + vi.clearAllMocks() + useAppStore.setState({ + closeFile, + closeUnifiedTab, + requestPinnedTabCloseConfirm, + unifiedTabsByWorktree: { [worktreeId]: [editor, chat, secondReference] }, + groupsByWorktree: { [worktreeId]: [group] }, + activeGroupIdByWorktree: { [worktreeId]: groupId }, + openFiles: [makeOpenFile({ id: sharedFileId, worktreeId })] + }) +}) + +function actions(groupTabs = [editor, chat]) { + return renderHook(() => useFloatingTerminalCloseActions({ activeGroup: group, groupTabs })).result + .current +} + +function setPinnedConfirmation(confirmClosePinnedTab: boolean) { + const settings = useAppStore.getState().settings ?? getDefaultSettings(process.cwd()) + useAppStore.setState({ settings: { ...settings, confirmClosePinnedTab } }) +} + +describe('floating titlebar close actions', () => { + it('closes one editor reference while keeping its shared open file', () => { + actions().closeFloatingItemConfirmed(editor.id) + + expect(closeFile).not.toHaveBeenCalled() + expect(closeUnifiedTab).toHaveBeenCalledWith(editor.id) + }) + + it('closes editor tabs without closing structured chats from Close All Editor Tabs', () => { + actions().closeAllFiles() + + expect(closeUnifiedTab).toHaveBeenCalledWith(editor.id) + expect(closeUnifiedTab).not.toHaveBeenCalledWith(chat.id) + expect(closeFile).not.toHaveBeenCalled() + }) + + it.each([true, false])( + 'retains pinned editors without prompting during Close All Editor Tabs (confirmation %s)', + (confirmClosePinnedTab) => { + setPinnedConfirmation(confirmClosePinnedTab) + useAppStore.setState({ + unifiedTabsByWorktree: { [worktreeId]: [editor, pinnedEditor, chat] }, + openFiles: [ + makeOpenFile({ id: sharedFileId, worktreeId }), + makeOpenFile({ id: 'pinned-file', worktreeId }) + ] + }) + + actions([editor, pinnedEditor, chat]).closeAllFiles() + + expect(closeUnifiedTab).toHaveBeenCalledWith(editor.id) + expect(closeUnifiedTab).not.toHaveBeenCalledWith(pinnedEditor.id) + expect(closeUnifiedTab).not.toHaveBeenCalledWith(chat.id) + expect(requestPinnedTabCloseConfirm).not.toHaveBeenCalled() + } + ) + + it('keeps the confirmation policy for an explicit pinned editor close', () => { + setPinnedConfirmation(true) + useAppStore.setState({ + unifiedTabsByWorktree: { [worktreeId]: [pinnedEditor] }, + openFiles: [makeOpenFile({ id: 'pinned-file', worktreeId })] + }) + + actions([pinnedEditor]).closeFloatingItemConfirmed(pinnedEditor.id) + + expect(requestPinnedTabCloseConfirm).toHaveBeenCalledOnce() + expect(closeUnifiedTab).not.toHaveBeenCalled() + requestPinnedTabCloseConfirm.mock.calls[0]?.[0].onConfirm() + expect(closeUnifiedTab).toHaveBeenCalledWith(pinnedEditor.id) + }) + + it('routes a dirty last reference through the shared save confirmation', () => { + useAppStore.setState({ + unifiedTabsByWorktree: { [worktreeId]: [editor] }, + openFiles: [makeOpenFile({ id: sharedFileId, worktreeId, isDirty: true })] + }) + + actions().closeFloatingItemConfirmed(editor.id) + + expect(requestEditorFileClose).toHaveBeenCalledWith(sharedFileId, { + onClosed: expect.any(Function) + }) + expect(closeUnifiedTab).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-close-actions.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-close-actions.ts index ef08b9ee581..f3e3abc9149 100644 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-close-actions.ts +++ b/src/renderer/src/components/floating-terminal/use-floating-terminal-close-actions.ts @@ -1,223 +1,82 @@ import { useCallback } from 'react' import { resolveGroupTabFromVisibleId } from '@/components/tab-group/tab-group-visible-id' -import { closeTerminalTab } from '@/components/terminal/terminal-tab-actions' -import { - countVisibleFloatingWorkspaceItems, - isFloatingWorkspacePanelFocused -} from '@/lib/floating-workspace-terminal-actions' -import { armFloatingPanelReclaimIntent } from '@/lib/floating-workspace-focus-reclaim' -import { useAppStore } from '@/store' -import { destroyWorkspaceWebviews } from '@/store/slices/browser-webview-cleanup' -import { guardPinnedTabClose, resolvePinnedTabLabel } from '@/store/pinned-tab-close-guard' -import type { Tab } from '../../../../shared/tab-types' +import { useTabGroupCloseScopeCommands } from '@/components/tab-group/useTabGroupCloseScopeCommands' +import { useTabGroupTabCloseCommands } from '@/components/tab-group/useTabGroupTabCloseCommands' +import { dispatchWorkspaceTabCommand } from '@/lib/workspace-tab-commands' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' -import type { FloatingTerminalEditorCloseQueue } from './use-floating-terminal-editor-close-queue' -import type { FloatingTerminalPanelItems } from './use-floating-terminal-panel-items' -import type { FloatingTerminalPanelLocalState } from './use-floating-terminal-panel-local-state' -import type { FloatingTerminalPanelStoreState } from './use-floating-terminal-panel-store-state' +import type { FloatingWorkspaceChromeModel } from './use-floating-workspace-chrome-model' type FloatingTerminalCloseActionsInput = Pick< - FloatingTerminalPanelStoreState, - 'closeTab' | 'closeBrowserTab' | 'closeFile' | 'closeUnifiedTab' -> & - Pick & - Pick & - Pick + FloatingWorkspaceChromeModel, + 'activeGroup' | 'groupTabs' +> export function useFloatingTerminalCloseActions({ - closeTab, - closeBrowserTab, - closeFile, - closeUnifiedTab, activeGroup, - groupTabs, - pendingReclaimArmByFileIdRef, - queueEditorCloseRequests + groupTabs }: FloatingTerminalCloseActionsInput) { - const closeFloatingItems = useCallback( - (visibleIds: string[]) => { - const state = useAppStore.getState() - const currentGroupTabs = activeGroup - ? (state.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []).filter( - (tab) => tab.groupId === activeGroup.id - ) - : (state.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []) - const items = visibleIds - .map((visibleId) => resolveGroupTabFromVisibleId(currentGroupTabs, visibleId)) - .filter((item): item is Tab => item !== null && !item.isPinned) - if (items.length === 0) { - return - } - const dirtyEditorFileIds: string[] = [] - for (const item of items) { - if (item.contentType === 'terminal') { - closeTab(item.entityId, { reason: 'cleanup' }) - } else if (item.contentType === 'browser') { - destroyWorkspaceWebviews(state.browserPagesByWorkspace, item.entityId) - closeBrowserTab(item.entityId) - } else if (item.contentType === 'simulator') { - closeUnifiedTab(item.id) - } else { - const file = state.openFiles.find((candidate) => candidate.id === item.entityId) - if (file?.isDirty) { - dirtyEditorFileIds.push(item.entityId) - continue - } - closeFile(item.entityId) - } - } - if (dirtyEditorFileIds.length > 0) { - queueEditorCloseRequests(dirtyEditorFileIds) - } - }, - [activeGroup, closeBrowserTab, closeFile, closeTab, closeUnifiedTab, queueEditorCloseRequests] + const worktreeId = FLOATING_TERMINAL_WORKTREE_ID + const { closeItem, closeMany } = useTabGroupTabCloseCommands({ worktreeId }) + const scope = useTabGroupCloseScopeCommands({ + groupId: activeGroup?.id ?? '', + worktreeId, + group: activeGroup, + groupTabs, + closeItem, + closeMany + }) + const resolveItemId = useCallback( + (visibleId: string) => resolveGroupTabFromVisibleId(groupTabs, visibleId)?.id ?? null, + [groupTabs] ) const closeFloatingItemConfirmed = useCallback( (visibleId: string, options?: { guestOwned?: boolean }) => { - const item = resolveGroupTabFromVisibleId(groupTabs, visibleId) - if (!item) { - return + const tabId = resolveItemId(visibleId) + if (tabId) { + dispatchWorkspaceTabCommand({ + type: 'close', + target: { kind: 'tab', worktreeId, tabId }, + floatingPanelGuestOwned: options?.guestOwned === true + }) } - const panelOwnedNow = options?.guestOwned === true || isFloatingWorkspacePanelFocused() - const itemCountBeforeClose = countVisibleFloatingWorkspaceItems(useAppStore.getState()) - const armIfEmptying = (): void => { - if (!panelOwnedNow) { - return - } - const itemCountAfterClose = countVisibleFloatingWorkspaceItems(useAppStore.getState()) - if (itemCountAfterClose === 0 && itemCountAfterClose < itemCountBeforeClose) { - armFloatingPanelReclaimIntent() - } - } - if (item.contentType === 'terminal') { - closeTerminalTab(item.entityId, { onClosed: armIfEmptying }) - return - } - const state = useAppStore.getState() - guardPinnedTabClose({ - isPinned: item.isPinned === true, - tabLabel: resolvePinnedTabLabel(state, FLOATING_TERMINAL_WORKTREE_ID, visibleId), - onClose: () => { - const latest = useAppStore.getState() - if (item.contentType === 'browser') { - destroyWorkspaceWebviews(latest.browserPagesByWorkspace, item.entityId) - closeBrowserTab(item.entityId) - } else if (item.contentType === 'simulator') { - closeUnifiedTab(item.id) - } else { - const file = latest.openFiles.find((candidate) => candidate.id === item.entityId) - if (file?.isDirty) { - pendingReclaimArmByFileIdRef.current.set(item.entityId, armIfEmptying) - queueEditorCloseRequests([item.entityId]) - return - } - closeFile(item.entityId) - } - armIfEmptying() - } - }) }, - [ - closeBrowserTab, - closeFile, - closeUnifiedTab, - groupTabs, - pendingReclaimArmByFileIdRef, - queueEditorCloseRequests - ] + [resolveItemId, worktreeId] ) - const closeOthers = useCallback( (visibleId: string) => { - const state = useAppStore.getState() - const currentGroupTabs = activeGroup - ? (state.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []).filter( - (tab) => tab.groupId === activeGroup.id - ) - : (state.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []) - const item = resolveGroupTabFromVisibleId(currentGroupTabs, visibleId) - if (!item) { - return + const tabId = resolveItemId(visibleId) + if (tabId) { + scope.closeOthers(tabId) } - closeFloatingItems( - currentGroupTabs.filter((tab) => tab.id !== item.id && !tab.isPinned).map((tab) => tab.id) - ) }, - [activeGroup, closeFloatingItems] + [resolveItemId, scope] ) - - const closeToSide = useCallback( - (visibleId: string, side: 'left' | 'right') => { - const state = useAppStore.getState() - const currentGroup = activeGroup - ? state.groupsByWorktree[FLOATING_TERMINAL_WORKTREE_ID]?.find( - (group) => group.id === activeGroup.id - ) - : null - const currentGroupTabs = currentGroup - ? (state.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []).filter( - (tab) => tab.groupId === currentGroup.id - ) - : (state.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []) - const item = resolveGroupTabFromVisibleId(currentGroupTabs, visibleId) - if (!item || !currentGroup) { - return - } - const index = currentGroup.tabOrder.indexOf(item.id) - if (index === -1) { - return - } - const sideIds = - side === 'right' - ? currentGroup.tabOrder.slice(index + 1) - : currentGroup.tabOrder.slice(0, index) - const tabById = new Map(currentGroupTabs.map((tab) => [tab.id, tab])) - closeFloatingItems( - sideIds.filter((tabId) => { - const tab = tabById.get(tabId) - return tab ? !tab.isPinned : false - }) - ) - }, - [activeGroup, closeFloatingItems] - ) - const closeToRight = useCallback( - (visibleId: string) => closeToSide(visibleId, 'right'), - [closeToSide] + (visibleId: string) => { + const tabId = resolveItemId(visibleId) + if (tabId) { + scope.closeToRight(tabId) + } + }, + [resolveItemId, scope] ) const closeToLeft = useCallback( - (visibleId: string) => closeToSide(visibleId, 'left'), - [closeToSide] + (visibleId: string) => { + const tabId = resolveItemId(visibleId) + if (tabId) { + scope.closeToLeft(tabId) + } + }, + [resolveItemId, scope] ) - const closeAllFiles = useCallback(() => { - const state = useAppStore.getState() - const currentGroupTabs = activeGroup - ? (state.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []).filter( - (tab) => tab.groupId === activeGroup.id - ) - : (state.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []) - closeFloatingItems( - currentGroupTabs - .filter( - (tab) => - tab.contentType !== 'terminal' && - tab.contentType !== 'browser' && - tab.contentType !== 'simulator' && - !tab.isPinned - ) - .map((tab) => tab.id) - ) - }, [activeGroup, closeFloatingItems]) - return { closeFloatingItemConfirmed, closeOthers, closeToRight, closeToLeft, - closeAllFiles + closeAllFiles: scope.closeAllEditorTabsInGroup } } diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.test.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.test.ts deleted file mode 100644 index 3a1d598d4cb..00000000000 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -// Real-store coverage: the floating New Terminal ("+" menu and Cmd+T) selects its tab in the floating panel only. - -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultSettings } from '../../../../shared/constants' -import { createTestStore, makeWorktree, seedStore } from '../../store/slices/store-test-helpers' -import { createStoreCascadesMockApi } from '../../store/slices/store-cascades-test-harness' - -const storeBox = vi.hoisted(() => { - const box: { store: unknown } = { store: null } - return box -}) -const focusTerminalTabSurface = vi.hoisted(() => vi.fn()) - -vi.mock('react', async () => { - const actual = await vi.importActual('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() - return { ...actual, useCallback: (callback: T) => callback } -}) - -vi.mock('sonner', () => ({ - toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn(), message: vi.fn() } -})) - -vi.mock('@/store', () => ({ - get useAppStore() { - return storeBox.store - } -})) - -vi.mock('@/lib/focus-terminal-tab-surface', () => ({ focusTerminalTabSurface })) - -createStoreCascadesMockApi() - -const MAIN_WORKTREE_ID = 'repo1::/path/wt1' - -describe('floating "+" New Terminal', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('selects the new tab in the floating panel without moving the main window', async () => { - const store = createTestStore() - storeBox.store = store - seedStore(store, { - settings: getDefaultSettings('/tmp'), - worktreesByRepo: { - repo1: [makeWorktree({ id: MAIN_WORKTREE_ID, repoId: 'repo1', path: '/path/wt1' })] - }, - activeWorktreeId: MAIN_WORKTREE_ID - }) - const mainTerminal = store.getState().createTab(MAIN_WORKTREE_ID) - store.getState().setActiveTabType('editor', MAIN_WORKTREE_ID) - const previousFloatingTab = store.getState().createTab(FLOATING_TERMINAL_WORKTREE_ID) - const { useFloatingTerminalCreateActions } = - await import('./use-floating-terminal-create-actions') - const seeded = store.getState() - const floatingGroup = seeded.groupsByWorktree[FLOATING_TERMINAL_WORKTREE_ID]?.[0] - expect(floatingGroup?.activeTabId).toBe(previousFloatingTab.id) - - const { createFloatingTerminalTab } = useFloatingTerminalCreateActions({ - activateTab: seeded.activateTab, - setActiveTab: seeded.setActiveTab, - createBrowserTab: seeded.createBrowserTab, - browserDefaultUrl: seeded.browserDefaultUrl, - openFile: seeded.openFile, - activeGroup: floatingGroup, - groupTabs: seeded.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? [], - markdownCwd: null - }) - createFloatingTerminalTab() - - const state = store.getState() - const newTab = state.tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID]?.find( - (tab) => tab.id !== previousFloatingTab.id - ) - expect(newTab).toBeDefined() - expect(state.activeTabId).toBe(mainTerminal.id) - expect(state.activeTabType).toBe('editor') - expect(state.activeTabTypeByWorktree[MAIN_WORKTREE_ID]).toBe('editor') - expect(state.groupsByWorktree[FLOATING_TERMINAL_WORKTREE_ID]?.[0]?.activeTabId).toBe(newTab?.id) - // Why: auto-acknowledge reads this map; it must follow the tab the floating panel shows. - expect(state.activeTabIdByWorktree[FLOATING_TERMINAL_WORKTREE_ID]).toBe(newTab?.id) - expect(focusTerminalTabSurface).toHaveBeenCalledExactlyOnceWith(newTab?.id) - }) -}) diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.ts index 65b29800796..9a29a47e95e 100644 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.ts +++ b/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.ts @@ -7,11 +7,10 @@ import { ensureClientCreationActionAllowed } from '@/lib/client-creation-action- import { openDocumentInFloatingWorkspace } from '@/lib/open-document-in-floating-workspace' import { extractIpcErrorMessage } from '@/lib/ipc-error' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' -import { createFloatingWorkspaceTerminalTab } from '@/lib/floating-workspace-tab-creation' import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' -import type { FloatingTerminalPanelItems } from './use-floating-terminal-panel-items' +import type { FloatingWorkspaceChromeModel } from './use-floating-workspace-chrome-model' import type { FloatingTerminalPanelLocalState } from './use-floating-terminal-panel-local-state' import type { FloatingTerminalPanelStoreState } from './use-floating-terminal-panel-store-state' @@ -19,14 +18,20 @@ const LOCAL_RUNTIME_SETTINGS = { activeRuntimeEnvironmentId: null } as const type FloatingTerminalCreateActionsInput = Pick< FloatingTerminalPanelStoreState, - 'activateTab' | 'setActiveTab' | 'createBrowserTab' | 'browserDefaultUrl' | 'openFile' + | 'activateTab' + | 'setActiveTab' + | 'createTab' + | 'createBrowserTab' + | 'browserDefaultUrl' + | 'openFile' > & - Pick & + Pick & Pick export function useFloatingTerminalCreateActions({ activateTab, setActiveTab, + createTab, createBrowserTab, browserDefaultUrl, openFile, @@ -58,9 +63,13 @@ export function useFloatingTerminalCreateActions({ [activateTab, groupTabs, setActiveTab] ) - const createFloatingTerminalTab = useCallback((shellOverride?: string) => { - void createFloatingWorkspaceTerminalTab(useAppStore.getState(), shellOverride) - }, []) + const createFloatingTerminalTab = useCallback( + (shellOverride?: string) => { + const tab = createTab(FLOATING_TERMINAL_WORKTREE_ID, activeGroup?.id, shellOverride) + focusTerminalTabSurface(tab.id) + }, + [activeGroup, createTab] + ) const createFloatingBrowserTab = useCallback(() => { if (!ensureClientCreationActionAllowed(FLOATING_TERMINAL_WORKTREE_ID, 'managed-browser')) { diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-editor-close-queue.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-editor-close-queue.ts deleted file mode 100644 index 8f82053348f..00000000000 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-editor-close-queue.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { useCallback, useEffect } from 'react' -import { useTerminalSaveDialog } from '@/components/terminal/useTerminalSaveDialog' -import { appendUniqueOpenFileIds } from '@/components/terminal/unsaved-close-queue' -import { useAppStore } from '@/store' -import type { FloatingTerminalPanelLocalState } from './use-floating-terminal-panel-local-state' -import type { FloatingTerminalPanelStoreState } from './use-floating-terminal-panel-store-state' - -type FloatingTerminalEditorCloseQueueInput = Pick< - FloatingTerminalPanelStoreState, - 'floatingFiles' | 'closeFile' -> & - Pick< - FloatingTerminalPanelLocalState, - 'pendingEditorCloseQueueRef' | 'pendingReclaimArmByFileIdRef' | 'saveDialogFileIdRef' - > - -export function useFloatingTerminalEditorCloseQueue({ - floatingFiles, - closeFile, - pendingEditorCloseQueueRef, - pendingReclaimArmByFileIdRef, - saveDialogFileIdRef -}: FloatingTerminalEditorCloseQueueInput) { - const { - saveDialogFileId, - saveDialogFile, - requestCloseFile, - handleSaveDialogSave, - handleSaveDialogDiscard, - handleSaveDialogCancel - } = useTerminalSaveDialog({ openFiles: floatingFiles, closeFile }) - - const getNextQueuedEditorClose = useCallback((): string | null => { - while (pendingEditorCloseQueueRef.current.length > 0) { - const fileId = pendingEditorCloseQueueRef.current[0] - const file = useAppStore.getState().openFiles.find((candidate) => candidate.id === fileId) - if (!file) { - pendingEditorCloseQueueRef.current.shift() - continue - } - if (!file.isDirty) { - closeFile(fileId) - pendingEditorCloseQueueRef.current.shift() - continue - } - return fileId - } - return null - }, [closeFile, pendingEditorCloseQueueRef]) - - const advanceEditorCloseQueue = useCallback(() => { - if (saveDialogFileIdRef.current !== null) { - return - } - const nextFileId = getNextQueuedEditorClose() - if (!nextFileId) { - return - } - saveDialogFileIdRef.current = nextFileId - requestCloseFile(nextFileId) - }, [getNextQueuedEditorClose, requestCloseFile, saveDialogFileIdRef]) - - const queueEditorCloseRequests = useCallback( - (fileIds: string[]) => { - pendingEditorCloseQueueRef.current = appendUniqueOpenFileIds( - pendingEditorCloseQueueRef.current, - fileIds, - new Set(useAppStore.getState().openFiles.map((file) => file.id)) - ) - advanceEditorCloseQueue() - }, - [advanceEditorCloseQueue, pendingEditorCloseQueueRef] - ) - - useEffect(() => { - saveDialogFileIdRef.current = saveDialogFileId - if (saveDialogFileId === null) { - advanceEditorCloseQueue() - } - }, [advanceEditorCloseQueue, saveDialogFileId, saveDialogFileIdRef]) - - const handleFloatingSaveDialogSave = useCallback(() => { - const fileId = saveDialogFileIdRef.current - if (fileId) { - pendingEditorCloseQueueRef.current = pendingEditorCloseQueueRef.current.filter( - (queuedId) => queuedId !== fileId - ) - } - handleSaveDialogSave() - }, [handleSaveDialogSave, pendingEditorCloseQueueRef, saveDialogFileIdRef]) - - const handleFloatingSaveDialogDiscard = useCallback(() => { - const fileId = saveDialogFileIdRef.current - if (fileId) { - pendingEditorCloseQueueRef.current = pendingEditorCloseQueueRef.current.filter( - (queuedId) => queuedId !== fileId - ) - } - void Promise.resolve(handleSaveDialogDiscard()) - }, [handleSaveDialogDiscard, pendingEditorCloseQueueRef, saveDialogFileIdRef]) - - const handleFloatingSaveDialogCancel = useCallback(() => { - pendingEditorCloseQueueRef.current = [] - pendingReclaimArmByFileIdRef.current.clear() - handleSaveDialogCancel() - }, [handleSaveDialogCancel, pendingEditorCloseQueueRef, pendingReclaimArmByFileIdRef]) - - return { - saveDialogFileId, - saveDialogFile, - queueEditorCloseRequests, - handleFloatingSaveDialogSave, - handleFloatingSaveDialogDiscard, - handleFloatingSaveDialogCancel - } -} - -export type FloatingTerminalEditorCloseQueue = ReturnType< - typeof useFloatingTerminalEditorCloseQueue -> diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-focus-lifecycle.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-focus-lifecycle.ts index 4487d9fa187..b3091e66d6c 100644 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-focus-lifecycle.ts +++ b/src/renderer/src/components/floating-terminal/use-floating-terminal-focus-lifecycle.ts @@ -3,35 +3,30 @@ import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { clearFloatingPanelReclaimIntent } from '@/lib/floating-workspace-focus-reclaim' import { isFloatingWorkspaceTerminalInputTarget } from '@/lib/floating-workspace-terminal-actions' import { reportFloatingFocus } from './floating-terminal-focus-reporting' -import type { FloatingTerminalPanelItems } from './use-floating-terminal-panel-items' +import type { FloatingWorkspaceChromeModel } from './use-floating-workspace-chrome-model' import type { FloatingTerminalPanelLocalState } from './use-floating-terminal-panel-local-state' -type FloatingTerminalFocusLifecycleInput = Pick & - Pick< - FloatingTerminalPanelLocalState, - 'pendingReclaimArmByFileIdRef' | 'panelRef' | 'reclaimTerminalInputOnWindowFocusRef' - > & { open: boolean } +type FloatingTerminalFocusLifecycleInput = Pick & + Pick & { + open: boolean + } export function useFloatingTerminalFocusLifecycle({ activeTerminalId, - pendingReclaimArmByFileIdRef, panelRef, reclaimTerminalInputOnWindowFocusRef, open }: FloatingTerminalFocusLifecycleInput): void { useEffect(() => { - const pendingReclaimArms = pendingReclaimArmByFileIdRef.current if (!open) { reportFloatingFocus(null, true) clearFloatingPanelReclaimIntent() - pendingReclaimArms.clear() } return () => { reportFloatingFocus(null, true) clearFloatingPanelReclaimIntent() - pendingReclaimArms.clear() } - }, [open, pendingReclaimArmByFileIdRef]) + }, [open]) useEffect(() => { if (!open || typeof document === 'undefined') { diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-initial-focus-effects.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-initial-focus-effects.ts index aa50e7fa2f1..9ac3490fc30 100644 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-initial-focus-effects.ts +++ b/src/renderer/src/components/floating-terminal/use-floating-terminal-initial-focus-effects.ts @@ -1,11 +1,11 @@ import { useEffect } from 'react' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { reportFloatingFocus } from './floating-terminal-focus-reporting' -import type { FloatingTerminalPanelItems } from './use-floating-terminal-panel-items' +import type { FloatingWorkspaceChromeModel } from './use-floating-workspace-chrome-model' import type { FloatingTerminalPanelLocalState } from './use-floating-terminal-panel-local-state' type FloatingTerminalInitialFocusInput = Pick< - FloatingTerminalPanelItems, + FloatingWorkspaceChromeModel, 'activeTerminalId' | 'hasVisibleFloatingTabs' > & Pick & { open: boolean } diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-controller.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-controller.ts index fb8bc43051d..19dea616132 100644 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-controller.ts +++ b/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-controller.ts @@ -3,7 +3,6 @@ import { createFloatingTerminalPanelDragActions } from './floating-terminal-pane import type { FloatingTerminalPanelProps } from './floating-terminal-panel-types' import { useFloatingTerminalCloseActions } from './use-floating-terminal-close-actions' import { useFloatingTerminalCreateActions } from './use-floating-terminal-create-actions' -import { useFloatingTerminalEditorCloseQueue } from './use-floating-terminal-editor-close-queue' import { useFloatingTerminalFocusLifecycle } from './use-floating-terminal-focus-lifecycle' import { useFloatingTerminalGlobalShortcutListeners } from './use-floating-terminal-global-shortcut-listeners' import { useFloatingTerminalGuestBridge } from './use-floating-terminal-guest-bridge' @@ -12,7 +11,7 @@ import { useFloatingTerminalOrchestrationDismissal } from './use-floating-termin import { useFloatingTerminalOrchestrationVisibility } from './use-floating-terminal-orchestration-visibility' import { useFloatingTerminalPanelFocusReclaim } from './use-floating-terminal-panel-focus-reclaim' import { useFloatingTerminalPanelGeometry } from './use-floating-terminal-panel-geometry' -import { useFloatingTerminalPanelItems } from './use-floating-terminal-panel-items' +import { useFloatingWorkspaceChromeModel } from './use-floating-workspace-chrome-model' import { useFloatingTerminalPanelLocalState } from './use-floating-terminal-panel-local-state' import { useFloatingTerminalPanelMaximize } from './use-floating-terminal-panel-maximize' import { useFloatingTerminalPanelShortcuts } from './use-floating-terminal-panel-shortcuts' @@ -27,7 +26,7 @@ export function useFloatingTerminalPanelController({ const storeState = useFloatingTerminalPanelStoreState() const shortcutDetails = useFloatingTerminalShortcutDetails() const localState = useFloatingTerminalPanelLocalState() - const items = useFloatingTerminalPanelItems({ ...storeState, open }) + const items = useFloatingWorkspaceChromeModel() useContextualTour('floating-workspace', open, 'floating_workspace_visible', { recordFeatureInteraction: tourInteractionSnapshot?.recordFeatureInteractionForTour ?? false, @@ -35,7 +34,6 @@ export function useFloatingTerminalPanelController({ wasFeaturePreviouslyInteracted: tourInteractionSnapshot?.wasPreviouslyInteracted }) - const editorCloseQueue = useFloatingTerminalEditorCloseQueue({ ...storeState, ...localState }) const geometry = useFloatingTerminalPanelGeometry({ ...storeState, ...localState }) useFloatingTerminalInitialFocusEffects({ ...items, ...localState, open }) const orchestrationVisibility = useFloatingTerminalOrchestrationVisibility({ @@ -48,10 +46,7 @@ export function useFloatingTerminalPanelController({ ...items }) const closeActions = useFloatingTerminalCloseActions({ - ...storeState, - ...localState, - ...items, - ...editorCloseQueue + ...items }) const focusReclaim = useFloatingTerminalPanelFocusReclaim({ ...storeState, @@ -86,7 +81,6 @@ export function useFloatingTerminalPanelController({ ...shortcutDetails, ...localState, ...items, - ...editorCloseQueue, ...geometry, ...orchestrationVisibility, ...createActions, diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-focus-reclaim.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-focus-reclaim.ts index 7a677794ac7..4680223b512 100644 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-focus-reclaim.ts +++ b/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-focus-reclaim.ts @@ -1,28 +1,31 @@ -import { useCallback, useEffect } from 'react' +import { useCallback, useEffect, useSyncExternalStore } from 'react' import { clearFloatingPanelReclaimIntent, - consumeFloatingPanelReclaimIntent + consumeFloatingPanelReclaimIntent, + isFloatingPanelReclaimIntentArmed, + subscribeFloatingPanelReclaimIntent } from '@/lib/floating-workspace-focus-reclaim' import { reportFloatingFocus } from './floating-terminal-focus-reporting' -import type { FloatingTerminalPanelItems } from './use-floating-terminal-panel-items' +import type { FloatingWorkspaceChromeModel } from './use-floating-workspace-chrome-model' import type { FloatingTerminalPanelLocalState } from './use-floating-terminal-panel-local-state' -import type { FloatingTerminalPanelStoreState } from './use-floating-terminal-panel-store-state' type FloatingTerminalPanelFocusReclaimInput = Pick< FloatingTerminalPanelLocalState, - 'panelRef' | 'shortcutFocusFrameRef' | 'shortcutFocusTimeoutRef' | 'pendingReclaimArmByFileIdRef' + 'panelRef' | 'shortcutFocusFrameRef' | 'shortcutFocusTimeoutRef' > & - Pick & - Pick + Pick export function useFloatingTerminalPanelFocusReclaim({ panelRef, shortcutFocusFrameRef, shortcutFocusTimeoutRef, - pendingReclaimArmByFileIdRef, - visibleFloatingItemCount, - floatingFiles + hasVisibleFloatingTabs }: FloatingTerminalPanelFocusReclaimInput) { + const reclaimIntentArmed = useSyncExternalStore( + subscribeFloatingPanelReclaimIntent, + isFloatingPanelReclaimIntentArmed, + () => false + ) const focusPanelForShortcuts = useCallback( (preserveExistingPanelFocus = true) => { const active = document.activeElement @@ -86,27 +89,14 @@ export function useFloatingTerminalPanelFocusReclaim({ }, []) useEffect(() => { - const pending = pendingReclaimArmByFileIdRef.current - if (pending.size === 0) { - return - } - for (const [fileId, armIfEmptying] of pending) { - if (!floatingFiles.some((file) => file.id === fileId)) { - pending.delete(fileId) - armIfEmptying() - } - } - }, [floatingFiles, pendingReclaimArmByFileIdRef]) - - useEffect(() => { - if (visibleFloatingItemCount > 0) { + if (hasVisibleFloatingTabs) { clearFloatingPanelReclaimIntent() return } - if (consumeFloatingPanelReclaimIntent()) { + if (reclaimIntentArmed && consumeFloatingPanelReclaimIntent()) { focusPanelForShortcutsAfterClose() } - }, [focusPanelForShortcutsAfterClose, visibleFloatingItemCount]) + }, [focusPanelForShortcutsAfterClose, hasVisibleFloatingTabs, reclaimIntentArmed]) return { focusPanelForShortcuts, setPanelNode, reportFloatingFocusFromTarget } } diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-geometry.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-geometry.ts index b24890503ca..d72077e5c7e 100644 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-geometry.ts +++ b/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-geometry.ts @@ -21,10 +21,9 @@ type FloatingTerminalPanelGeometryInput = Pick< | 'maximized' | 'stagedBoundsRef' | 'lastPersistedBoundsRef' - | 'setCwd' | 'setMarkdownCwd' > & - Pick + Pick export function useFloatingTerminalPanelGeometry({ boundsSourceRef, @@ -33,7 +32,7 @@ export function useFloatingTerminalPanelGeometry({ maximized, stagedBoundsRef, lastPersistedBoundsRef, - setCwd, + setFloatingWorkspacePath, setMarkdownCwd, floatingTerminalCwd }: FloatingTerminalPanelGeometryInput) { @@ -106,13 +105,15 @@ export function useFloatingTerminalPanelGeometry({ let cancelled = false void window.api.app.getFloatingTerminalCwd({ path: floatingTerminalCwd }).then((nextCwd) => { if (!cancelled) { - setCwd(nextCwd) + // Why the store: every renderer consumer that must answer "where does the floating + // workspace live" (workspace row minting included) reads this one resolved copy. + setFloatingWorkspacePath(nextCwd) } }) return () => { cancelled = true } - }, [floatingTerminalCwd, setCwd]) + }, [floatingTerminalCwd, setFloatingWorkspacePath]) useEffect(() => { let cancelled = false diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-items.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-items.ts deleted file mode 100644 index 392317f2d37..00000000000 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-items.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { useMemo } from 'react' -import { resolveGroupTabFromVisibleId } from '@/components/tab-group/tab-group-visible-id' -import { useTerminalTabColdParking } from '@/components/terminal-pane/use-terminal-tab-cold-parking' -import type { OpenFile } from '@/store/slices/editor' -import type { BrowserTab as BrowserTabState } from '../../../../shared/browser-workspace-types' -import type { TerminalTab } from '../../../../shared/terminal-tab-types' -import { resolveUnifiedTabLabel } from '../../../../shared/tab-title-resolution' -import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' -import type { FloatingTerminalPanelStoreState } from './use-floating-terminal-panel-store-state' - -const NO_ACTIVITY_TERMINAL_PORTALS = [] - -type FloatingTerminalPanelItemsInput = Pick< - FloatingTerminalPanelStoreState, - 'tabs' | 'browserTabs' | 'groups' | 'unifiedTabs' | 'floatingFiles' | 'generatedTabTitlesEnabled' -> & { open: boolean } - -export function useFloatingTerminalPanelItems({ - tabs, - browserTabs, - groups, - unifiedTabs, - floatingFiles, - generatedTabTitlesEnabled, - open -}: FloatingTerminalPanelItemsInput) { - const activeGroup = useMemo( - () => - groups.find((group) => group.activeTabId != null) ?? - (unifiedTabs[0] - ? (groups.find((group) => group.id === unifiedTabs[0].groupId) ?? null) - : null), - [groups, unifiedTabs] - ) - const groupTabs = useMemo( - () => (activeGroup ? unifiedTabs.filter((tab) => tab.groupId === activeGroup.id) : unifiedTabs), - [activeGroup, unifiedTabs] - ) - const activeTab = useMemo( - () => - (activeGroup?.activeTabId - ? groupTabs.find((tab) => tab.id === activeGroup.activeTabId) - : null) ?? - groupTabs[0] ?? - null, - [activeGroup, groupTabs] - ) - const activeTerminalId = activeTab?.contentType === 'terminal' ? activeTab.entityId : null - const activeBrowserId = activeTab?.contentType === 'browser' ? activeTab.entityId : null - const activeEditorUnifiedId = - activeTab && - activeTab.contentType !== 'terminal' && - activeTab.contentType !== 'browser' && - activeTab.contentType !== 'simulator' - ? activeTab.id - : null - const activeEditorFileId = - activeTab && - activeTab.contentType !== 'terminal' && - activeTab.contentType !== 'browser' && - activeTab.contentType !== 'simulator' - ? activeTab.entityId - : null - const terminalTabById = useMemo(() => new Map(tabs.map((tab) => [tab.id, tab])), [tabs]) - const terminalAssignments = useMemo(() => { - const assignments = new Map() - for (const tab of unifiedTabs) { - if (tab.contentType === 'terminal') { - assignments.set(tab.entityId, { - groupId: tab.groupId, - isActiveInGroup: tab.entityId === activeTerminalId - }) - } - } - return assignments - }, [activeTerminalId, unifiedTabs]) - const parkedTerminalTabIds = useTerminalTabColdParking({ - worktreeId: FLOATING_TERMINAL_WORKTREE_ID, - terminalTabs: tabs, - assignments: terminalAssignments, - activeTerminalTabId: activeTerminalId, - isWorktreeActive: open, - coldParkTerminalPanes: false, - shouldMeasureHiddenWorktree: false, - activityTerminalPortals: NO_ACTIVITY_TERMINAL_PORTALS - }) - const terminalItems = useMemo<(TerminalTab & { unifiedTabId: string })[]>( - () => - groupTabs - .filter((tab) => tab.contentType === 'terminal') - .flatMap((tab): (TerminalTab & { unifiedTabId: string })[] => { - const terminalTab = terminalTabById.get(tab.entityId) - if (!terminalTab) { - return [] - } - return [ - { - ...terminalTab, - unifiedTabId: tab.id, - title: resolveUnifiedTabLabel( - { - ...tab, - quickCommandLabel: tab.quickCommandLabel ?? terminalTab.quickCommandLabel, - generatedLabel: tab.generatedLabel ?? terminalTab.generatedTitle - }, - generatedTabTitlesEnabled, - tab.label - ), - generatedTitle: terminalTab.generatedTitle ?? tab.generatedLabel ?? null, - quickCommandLabel: terminalTab.quickCommandLabel ?? tab.quickCommandLabel ?? null, - customTitle: tab.customLabel ?? terminalTab.customTitle, - color: tab.color ?? terminalTab.color - } - ] - }), - [generatedTabTitlesEnabled, groupTabs, terminalTabById] - ) - const browserItems = useMemo( - () => - groupTabs - .filter((tab) => tab.contentType === 'browser') - .map((tab) => { - const browserTab = browserTabs.find((candidate) => candidate.id === tab.entityId) - return browserTab ? { ...browserTab, tabId: tab.id } : null - }) - .filter((tab): tab is BrowserTabState & { tabId: string } => tab !== null), - [browserTabs, groupTabs] - ) - const editorItems = useMemo( - () => - groupTabs - .filter( - (tab) => - tab.contentType !== 'terminal' && - tab.contentType !== 'browser' && - tab.contentType !== 'simulator' - ) - .map((tab) => { - const file = floatingFiles.find((candidate) => candidate.id === tab.entityId) - return file ? { ...file, tabId: tab.id } : null - }) - .filter((file): file is OpenFile & { tabId: string } => file !== null), - [floatingFiles, groupTabs] - ) - const simulatorItems = useMemo( - () => groupTabs.filter((tab) => tab.contentType === 'simulator'), - [groupTabs] - ) - const hasVisibleFloatingTabs = - terminalItems.length > 0 || - browserItems.length > 0 || - editorItems.length > 0 || - simulatorItems.length > 0 - const visibleFloatingItemCount = - terminalItems.length + browserItems.length + editorItems.length + simulatorItems.length - const activeClosableTab = hasVisibleFloatingTabs ? activeTab : null - const tabBarOrder = useMemo( - () => - (activeGroup?.tabOrder ?? []).map((tabId) => { - const tab = groupTabs.find((candidate) => candidate.id === tabId) - return tab?.contentType === 'terminal' || tab?.contentType === 'browser' - ? tab.entityId - : tabId - }), - [activeGroup, groupTabs] - ) - const visibleFloatingTabOrder = useMemo( - () => - tabBarOrder.filter((visibleId) => { - const tab = resolveGroupTabFromVisibleId(groupTabs, visibleId) - if (!tab) { - return false - } - if (tab.contentType === 'terminal') { - return terminalItems.some((item) => item.unifiedTabId === tab.id) - } - if (tab.contentType === 'browser') { - return browserItems.some((item) => item.tabId === tab.id) - } - if (tab.contentType === 'simulator') { - return simulatorItems.some((item) => item.id === tab.id) - } - return editorItems.some((item) => item.tabId === tab.id) - }), - [browserItems, editorItems, groupTabs, simulatorItems, tabBarOrder, terminalItems] - ) - const activeBrowserTab = activeBrowserId - ? (browserTabs.find((tab) => tab.id === activeBrowserId) ?? null) - : null - const activeEditorFile = activeEditorFileId - ? (floatingFiles.find((file) => file.id === activeEditorFileId) ?? null) - : null - const activeTabType: 'browser' | 'terminal' | 'simulator' | 'editor' = - activeTab?.contentType === 'browser' - ? 'browser' - : activeTab?.contentType === 'terminal' - ? 'terminal' - : activeTab?.contentType === 'simulator' - ? 'simulator' - : 'editor' - - return { - activeGroup, - groupTabs, - activeTab, - activeTerminalId, - activeBrowserId, - activeEditorUnifiedId, - parkedTerminalTabIds, - terminalItems, - browserItems, - editorItems, - simulatorItems, - hasVisibleFloatingTabs, - visibleFloatingItemCount, - activeClosableTab, - tabBarOrder, - visibleFloatingTabOrder, - activeBrowserTab, - activeEditorFile, - activeTabType - } -} - -export type FloatingTerminalPanelItems = ReturnType diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-local-state.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-local-state.ts index f910b6432f2..237d4db1b77 100644 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-local-state.ts +++ b/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-local-state.ts @@ -1,5 +1,4 @@ import { useRef, useState } from 'react' -import type { TerminalPaneHandle } from '@/components/terminal-pane/TerminalPane' import { useMountedRef } from '@/hooks/useMountedRef' import { ModifierDoubleTapDetector } from '../../../../shared/modifier-double-tap-detector' import type { @@ -12,7 +11,6 @@ import { hasOrchestrationSetupMarker, isOrchestrationSetupDismissed } from '@/lib/orchestration-setup-state' -import { createTerminalPaneHandleRegistry } from './terminal-pane-handle-registry' import { readInitialPanelBounds, type FloatingTerminalPanelBoundsState @@ -20,7 +18,6 @@ import { import { useSettledPanelViewport } from './use-settled-panel-viewport' export function useFloatingTerminalPanelLocalState() { - const [cwd, setCwd] = useState(null) const [markdownCwd, setMarkdownCwd] = useState(null) const initialBoundsStateRef = useRef(null) if (initialBoundsStateRef.current === null) { @@ -45,13 +42,7 @@ export function useFloatingTerminalPanelLocalState() { const lastPersistedBoundsRef = useRef( initialBoundsState.source === 'user' ? initialBoundsState.committedBounds : null ) - const pendingEditorCloseQueueRef = useRef([]) - const pendingReclaimArmByFileIdRef = useRef void>>(new Map()) - const saveDialogFileIdRef = useRef(null) const panelRef = useRef(null) - const [terminalPaneRegistry] = useState(() => - createTerminalPaneHandleRegistry() - ) const doubleTapDetectorRef = useRef(null) if (!doubleTapDetectorRef.current) { // The detector must exist before event handlers are published. @@ -74,8 +65,6 @@ export function useFloatingTerminalPanelLocalState() { } | null>(null) return { - cwd, - setCwd, markdownCwd, setMarkdownCwd, boundsSourceRef, @@ -92,11 +81,7 @@ export function useFloatingTerminalPanelLocalState() { restoreBoundsRef, stagedBoundsRef, lastPersistedBoundsRef, - pendingEditorCloseQueueRef, - pendingReclaimArmByFileIdRef, - saveDialogFileIdRef, panelRef, - terminalPaneRegistry, doubleTapDetectorRef, shortcutFocusFrameRef, shortcutFocusTimeoutRef, diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-maximize.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-maximize.ts index 58463b46fb3..4349b3ced50 100644 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-maximize.ts +++ b/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-maximize.ts @@ -8,7 +8,6 @@ import { } from './floating-terminal-panel-bounds' import { persistFloatingTerminalPanelMaximized } from './floating-terminal-panel-view-state' import type { FloatingTerminalPanelLocalState } from './use-floating-terminal-panel-local-state' -import type { FloatingTerminalPanelStoreState } from './use-floating-terminal-panel-store-state' type FloatingTerminalPanelMaximizeInput = Pick< FloatingTerminalPanelLocalState, @@ -20,9 +19,7 @@ type FloatingTerminalPanelMaximizeInput = Pick< | 'committedBoundsRef' | 'boundsSourceRef' | 'stagedBoundsRef' - | 'terminalPaneRegistry' -> & - Pick & { open: boolean } +> & { open: boolean } export function useFloatingTerminalPanelMaximize({ bounds, @@ -33,8 +30,6 @@ export function useFloatingTerminalPanelMaximize({ committedBoundsRef, boundsSourceRef, stagedBoundsRef, - terminalPaneRegistry, - tabs, open }: FloatingTerminalPanelMaximizeInput) { const toggleMaximized = useCallback(() => { @@ -106,10 +101,6 @@ export function useFloatingTerminalPanelMaximize({ } }, [open, maximizePanel]) - useEffect(() => { - terminalPaneRegistry.retainOnly(tabs.map((tab) => tab.id)) - }, [tabs, terminalPaneRegistry]) - return { toggleMaximized } } diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-shortcuts.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-shortcuts.ts index ee1979451cf..52babee1750 100644 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-shortcuts.ts +++ b/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-shortcuts.ts @@ -18,17 +18,17 @@ import type { } from './floating-terminal-panel-types' import type { FloatingTerminalCloseActions } from './use-floating-terminal-close-actions' import type { FloatingTerminalCreateActions } from './use-floating-terminal-create-actions' -import type { FloatingTerminalPanelItems } from './use-floating-terminal-panel-items' +import type { FloatingWorkspaceChromeModel } from './use-floating-workspace-chrome-model' import type { FloatingTerminalPanelLocalState } from './use-floating-terminal-panel-local-state' import type { FloatingTerminalPanelMaximize } from './use-floating-terminal-panel-maximize' const FLOATING_TERMINAL_SHORTCUT_SURFACE_SELECTOR = '[data-floating-terminal-shortcut-surface]' type FloatingTerminalPanelShortcutsInput = Pick< - FloatingTerminalPanelItems, - 'activeTab' | 'activeTerminalId' | 'activeClosableTab' | 'visibleFloatingTabOrder' + FloatingWorkspaceChromeModel, + 'activeTab' | 'activeClosableTab' | 'visibleFloatingTabOrder' > & - Pick & + Pick & Pick & FloatingTerminalCreateActions & FloatingTerminalPanelMaximize & { @@ -38,10 +38,8 @@ type FloatingTerminalPanelShortcutsInput = Pick< export function useFloatingTerminalPanelShortcuts({ activeTab, - activeTerminalId, activeClosableTab, visibleFloatingTabOrder, - terminalPaneRegistry, panelRef, closeFloatingItemConfirmed, activateFloatingItem, @@ -53,16 +51,14 @@ export function useFloatingTerminalPanelShortcuts({ open, onOpenChange }: FloatingTerminalPanelShortcutsInput) { + // Why the tab (not a pane): with an xterm focused the capture policy defers to the terminal's + // own dispatch, which closes the active pane; outside terminal focus the close targets the tab, + // matching the main workspace's strip-close semantics. const closeActiveFloatingTerminalPane = useCallback(() => { - const handle = activeTerminalId ? terminalPaneRegistry.getHandle(activeTerminalId) : null - if (handle) { - handle.closeActivePane() - return - } if (activeClosableTab) { closeFloatingItemConfirmed(activeClosableTab.id) } - }, [activeClosableTab, activeTerminalId, closeFloatingItemConfirmed, terminalPaneRegistry]) + }, [activeClosableTab, closeFloatingItemConfirmed]) const resolveFloatingPanelShortcut = useCallback( (input: FloatingPanelShortcutInput): FloatingPanelShortcutResolution | null => { diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-store-state.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-store-state.ts index 8dda34ef129..a167b0414fb 100644 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-store-state.ts +++ b/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-store-state.ts @@ -6,9 +6,9 @@ import { selectFloatingTerminalPanelInputs } from './floating-terminal-panel-inp export function useFloatingTerminalPanelStoreState() { const { tabs, browserTabs, groups, unifiedTabs, floatingFiles, expandedPaneByTabId } = useAppStore(selectFloatingTerminalPanelInputs) + const createTab = useAppStore((state) => state.createTab) const createBrowserTab = useAppStore((state) => state.createBrowserTab) const closeTab = useAppStore((state) => state.closeTab) - const closeBrowserTab = useAppStore((state) => state.closeBrowserTab) const closeFile = useAppStore((state) => state.closeFile) const closeUnifiedTab = useAppStore((state) => state.closeUnifiedTab) const activateTab = useAppStore((state) => state.activateTab) @@ -21,6 +21,9 @@ export function useFloatingTerminalPanelStoreState() { const openFile = useAppStore((state) => state.openFile) const browserDefaultUrl = useAppStore((state) => state.browserDefaultUrl) const floatingTerminalCwd = useAppStore((state) => state.settings?.floatingTerminalCwd ?? '') + // The host-resolved floating directory; the geometry hook keeps it fresh. + const cwd = useAppStore((state) => state.floatingWorkspacePath) + const setFloatingWorkspacePath = useAppStore((state) => state.setFloatingWorkspacePath) const generatedTabTitlesEnabled = useAppStore( (state) => state.settings?.tabAutoGenerateTitle === true ) @@ -37,9 +40,9 @@ export function useFloatingTerminalPanelStoreState() { unifiedTabs, floatingFiles, expandedPaneByTabId, + createTab, createBrowserTab, closeTab, - closeBrowserTab, closeFile, closeUnifiedTab, activateTab, @@ -52,6 +55,8 @@ export function useFloatingTerminalPanelStoreState() { openFile, browserDefaultUrl, floatingTerminalCwd, + cwd, + setFloatingWorkspacePath, generatedTabTitlesEnabled, managedBrowserCreationEnabled } diff --git a/src/renderer/src/components/floating-terminal/use-floating-workspace-chrome-model.ts b/src/renderer/src/components/floating-terminal/use-floating-workspace-chrome-model.ts new file mode 100644 index 00000000000..935d8796cb7 --- /dev/null +++ b/src/renderer/src/components/floating-terminal/use-floating-workspace-chrome-model.ts @@ -0,0 +1,116 @@ +import { useMemo } from 'react' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import { useShallow } from 'zustand/react/shallow' +import { useAppStore } from '@/store' +import { useTabGroupWorkspaceModel } from '@/components/tab-group/useTabGroupWorkspaceModel' +import { resolveGroupTabFromVisibleId } from '@/components/tab-group/tab-group-visible-id' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { resolveFloatingWorkspaceSurfaceModel } from './floating-workspace-surface-model' + +const EMPTY_TERMINAL_TABS: readonly TerminalTab[] = [] + +/** + * Shell chrome model for the floating panel: the shared per-group workspace model of the + * FOCUSED floating group, plus the derived fields the panel's titlebar strip, shortcuts, + * close policy and focus lifecycle read. The panel body renders every group through the + * shared tree; only the shell's one titlebar strip needs a focused-group projection. + */ +export function useFloatingWorkspaceChromeModel() { + const surface = useAppStore(useShallow(resolveFloatingWorkspaceSurfaceModel)) + const focusedGroupId = surface.kind === 'workspace' ? surface.focusedGroupId : '' + const model = useTabGroupWorkspaceModel({ + groupId: focusedGroupId, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID + }) + const { activeTab, group, groupTabs } = model + // Why filtered: the strip and the emptiness decision only count tabs whose terminal entity + // still exists — a stale unified entry must fall back to the empty state, not a blank pane. + const terminalEntities = useAppStore( + (state) => state.tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? EMPTY_TERMINAL_TABS + ) + const terminalItems = useMemo(() => { + const entityIds = new Set(terminalEntities.map((tab) => tab.id)) + return model.terminalTabs.filter((item) => entityIds.has(item.id)) + }, [model.terminalTabs, terminalEntities]) + + const activeTerminalId = activeTab?.contentType === 'terminal' ? activeTab.entityId : null + const activeBrowserId = activeTab?.contentType === 'browser' ? activeTab.entityId : null + const activeEditorUnifiedId = + activeTab && + activeTab.contentType !== 'terminal' && + activeTab.contentType !== 'browser' && + activeTab.contentType !== 'simulator' && + activeTab.contentType !== 'agent-session' + ? activeTab.id + : null + const activeBrowserTab = activeBrowserId + ? (model.browserItems.find((item) => item.id === activeBrowserId) ?? null) + : null + const activeEditorFile = activeEditorUnifiedId + ? (model.editorItems.find((item) => item.tabId === activeEditorUnifiedId) ?? null) + : null + const activeTabType: 'browser' | 'terminal' | 'simulator' | 'editor' | 'agent-session' = + activeTab?.contentType === 'browser' + ? 'browser' + : activeTab?.contentType === 'terminal' + ? 'terminal' + : activeTab?.contentType === 'agent-session' + ? 'agent-session' + : activeTab?.contentType === 'simulator' + ? 'simulator' + : 'editor' + + const hasVisibleFloatingTabs = surface.kind === 'workspace' + + // Visible-id order for tab-cycling shortcuts: strip order restricted to entries whose entity + // still resolves, so a shortcut never lands on a tab the strip is not showing. + const visibleFloatingTabOrder = useMemo( + () => + model.tabBarOrder.filter((visibleId) => { + const tab = resolveGroupTabFromVisibleId(groupTabs, visibleId) + if (!tab) { + return false + } + if (tab.contentType === 'terminal') { + return terminalItems.some((item) => item.unifiedTabId === tab.id) + } + if (tab.contentType === 'browser') { + return model.browserItems.some((item) => item.tabId === tab.id) + } + if (tab.contentType === 'simulator' || tab.contentType === 'agent-session') { + return true + } + return model.editorItems.some((item) => item.tabId === tab.id) + }), + [groupTabs, model.browserItems, model.editorItems, model.tabBarOrder, terminalItems] + ) + const activeClosableTab = + activeTab && + visibleFloatingTabOrder.includes( + activeTab.contentType === 'terminal' || activeTab.contentType === 'browser' + ? activeTab.entityId + : activeTab.id + ) + ? activeTab + : null + + return { + surface, + model, + terminalItems, + activeGroup: group, + groupTabs, + activeTab, + activeTerminalId, + activeBrowserId, + activeEditorUnifiedId, + activeBrowserTab, + activeEditorFile, + activeTabType, + activeClosableTab, + hasVisibleFloatingTabs, + visibleFloatingTabOrder + } +} + +export type FloatingWorkspaceChromeModel = ReturnType diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx index 2b93f01e59b..c1369c966cd 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx @@ -2,6 +2,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () => (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks() @@ -16,6 +17,7 @@ vi.mock('@/runtime/structured-agent-session-client', () => vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession()) vi.mock('./use-native-chat-font-size', () => moduleFactories.useNativeChatFontSize()) vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext()) +vi.mock('./use-native-chat-tab-owner', () => moduleFactories.useNativeChatTabOwner()) vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick()) vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList()) vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer()) @@ -107,6 +109,17 @@ describe('NativeChatStructuredSession launch lifecycle', () => { expect(mocks.retryLaunch).toHaveBeenCalledWith('wt-1', 'session-1') }) + it('keys a floating chat launch by its owner even before any path context exists', () => { + mocks.ownerWorktreeId = FLOATING_TERMINAL_WORKTREE_ID + mocks.fileLinkContext = null + mocks.launchLifecycle = 'failed' + render(sessionView()) + + expect(mocks.lifecycleLookup).toHaveBeenCalledWith(FLOATING_TERMINAL_WORKTREE_ID, 'session-1') + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + expect(mocks.retryLaunch).toHaveBeenCalledWith(FLOATING_TERMINAL_WORKTREE_ID, 'session-1') + }) + it('words why a failed launch failed beside Retry from the refusal, never its code', () => { mocks.launchLifecycle = 'failed' mocks.launchFailure = NOT_SIGNED_IN diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx index 8c7363c4115..cf990808189 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -10,6 +10,7 @@ import type { NativeChatApprovalCardProps } from './NativeChatApprovalCard' import type { NativeChatDeliveryNotice } from './NativeChatMessageRow' import type { NativeChatQuestionCardProps } from './NativeChatQuestionCard' import type { NativeChatLaunchSeed } from './native-chat-composer-types' +import type { NativeChatFileLinkContext } from './native-chat-file-link' import type { NativeChatOlderPageResult } from './native-chat-pagination' import type { StructuredAgentSessionThreadGoal } from './use-structured-agent-session-thread-goal' import type { StructuredAgentSessionLaunchLifecycle } from '@/lib/structured-agent-session-launch' @@ -24,6 +25,10 @@ function nullable(): T | null { return null } +function widened(value: T): T { + return value +} + function absent(): T | undefined { return undefined } @@ -99,6 +104,12 @@ type StructuredSessionMessageListProps = { deliveryNotices?: ReadonlyMap } +const DEFAULT_FILE_LINK_CONTEXT: NativeChatFileLinkContext = { + worktreeId: 'wt-1', + worktreePath: '/repo', + runtimeEnvironmentId: null +} + const initialMessageListProps: StructuredSessionMessageListProps | null = null const initialApprovalCardProps: NativeChatApprovalCardProps | null = null @@ -112,6 +123,9 @@ export function createStructuredSessionMocks() { call: vi.fn<(...args: never[]) => unknown>(), fileLinkClick: vi.fn<(...args: never[]) => unknown>(), launchLifecycle: nullable(), + ownerWorktreeId: widened('wt-1'), + fileLinkContext: widened(DEFAULT_FILE_LINK_CONTEXT), + lifecycleLookup: vi.fn<(worktreeId: string, sessionId: string) => void>(), launchFailure: nullable(), launchResumes: false, retryLaunch: vi.fn<(worktreeId: string, sessionId: string) => unknown>(), @@ -283,18 +297,20 @@ export function createStructuredSessionMocks() { getStructuredAgentSessionLaunchLifecycle: () => mocks.launchLifecycle, getStructuredAgentSessionLaunchResumes: () => mocks.launchResumes, useStructuredAgentSessionLaunchSelection: () => null, - useStructuredAgentSessionLaunchLifecycle: () => mocks.launchLifecycle, + useStructuredAgentSessionLaunchLifecycle: (worktreeId: string, sessionId: string) => { + mocks.lifecycleLookup(worktreeId, sessionId) + return mocks.launchLifecycle + }, useStructuredAgentSessionLaunchFailure: () => mocks.launchFailure }), useNativeChatFontSize: () => ({ useNativeChatFontSize: () => undefined }), useNativeChatFileLinkContext: () => ({ - useNativeChatFileLinkContext: () => ({ - worktreeId: 'wt-1', - worktreePath: '/repo', - runtimeEnvironmentId: null - }) + useNativeChatFileLinkContext: () => mocks.fileLinkContext + }), + useNativeChatTabOwner: () => ({ + useNativeChatTabOwnerWorktreeId: () => mocks.ownerWorktreeId }), useNativeChatFileLinkClick: () => ({ useNativeChatFileLinkClick: (context: unknown) => (context ? mocks.fileLinkClick : undefined) @@ -341,9 +357,12 @@ export function createStructuredSessionMocks() { const resetStructuredSessionMocks = (): void => { mocks.call.mockReset() mocks.launchLifecycle = null + mocks.ownerWorktreeId = 'wt-1' + mocks.fileLinkContext = DEFAULT_FILE_LINK_CONTEXT mocks.launchFailure = null mocks.launchResumes = false mocks.retryLaunch.mockReset() + mocks.lifecycleLookup.mockReset() mocks.controllerProps = null mocks.mode = 'static' mocks.status = 'ready' diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx index 4d1b94ba882..b4d8ee5e87c 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx @@ -20,6 +20,7 @@ vi.mock('@/runtime/structured-agent-session-client', () => vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession()) vi.mock('./use-native-chat-font-size', () => moduleFactories.useNativeChatFontSize()) vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext()) +vi.mock('./use-native-chat-tab-owner', () => moduleFactories.useNativeChatTabOwner()) vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick()) vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList()) vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer()) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index b3102c5639b..1c4108f4e61 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -21,6 +21,7 @@ import { useNativeChatFontSize } from './use-native-chat-font-size' import { LinkActionPopover } from '@/components/link-actions/LinkActionPopover' import { useNativeChatLinkActions } from './use-native-chat-link-actions' import { useNativeChatFileLinkContext } from './use-native-chat-file-link-context' +import { useNativeChatTabOwnerWorktreeId } from './use-native-chat-tab-owner' import { useStructuredAgentSession } from './use-structured-agent-session' import { useNativeChatImageRuntimeContext } from './native-chat-image-runtime-context' import { useStructuredNativeChatPaneCommands } from './use-structured-native-chat-pane-commands' @@ -43,11 +44,9 @@ import { pendingPromptsAllUnanswerableHere } from '../../../../shared/agent-sess export function NativeChatStructuredSession( props: Omit ): React.JSX.Element { + const ownerWorktreeId = useNativeChatTabOwnerWorktreeId(props.tabId) const fileLinkContext = useNativeChatFileLinkContext(props.tabId) - const provisionalLaunch = useNativeChatProvisionalLaunch( - fileLinkContext?.worktreeId, - props.sessionId - ) + const provisionalLaunch = useNativeChatProvisionalLaunch(ownerWorktreeId, props.sessionId) const { sendThroughRelaunch } = provisionalLaunch // The host's own word on whether the provider child has answered startup yet. const startupPhase = useStructuredAgentSessionHostExecutionPhase(props.sessionId, props.target) @@ -229,7 +228,7 @@ export function NativeChatStructuredSession( optionPickerRequest, sessionCommands: controller.sessionCommands, contextUsage: controller.contextUsage, - worktreeId: fileLinkContext?.worktreeId, + worktreeId: ownerWorktreeId ?? undefined, onError: setComposerError, runtime: (props.target.kind === 'local' ? 'local' : 'remote') as 'local' | 'remote', sessionId: props.sessionId, @@ -238,8 +237,8 @@ export function NativeChatStructuredSession( } }, [ controller, - fileLinkContext?.worktreeId, optionPickerRequest, + ownerWorktreeId, props.agent, props.sessionId, props.target, diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.probe.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.probe.test.tsx index d3cd38b97ee..b0e51894dcf 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.probe.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.probe.test.tsx @@ -17,6 +17,7 @@ vi.mock('@/runtime/structured-agent-session-client', () => vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession()) vi.mock('./use-native-chat-font-size', () => moduleFactories.useNativeChatFontSize()) vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext()) +vi.mock('./use-native-chat-tab-owner', () => moduleFactories.useNativeChatTabOwner()) vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick()) vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList()) vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer()) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx index 5eea775bee7..d36cc874b53 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx @@ -143,6 +143,10 @@ vi.mock('./use-native-chat-file-link-context', () => ({ }) })) +vi.mock('./use-native-chat-tab-owner', () => ({ + useNativeChatTabOwnerWorktreeId: () => 'wt-1' +})) + vi.mock('./use-native-chat-file-link-click', () => ({ useNativeChatFileLinkClick: (context: unknown) => (context ? mocks.fileLinkClick : undefined) })) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.launch-directory.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.launch-directory.test.tsx new file mode 100644 index 00000000000..0fae5234637 --- /dev/null +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.launch-directory.test.tsx @@ -0,0 +1,159 @@ +// @vitest-environment happy-dom + +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + AgentSessionStatusEvent, + AgentSessionStatusSummary +} from '../../../../shared/agent-session-wire' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { + LOCAL_EXECUTION_HOST_ID, + toRuntimeExecutionHostId +} from '../../../../shared/execution-host' +import type { Tab } from '../../../../shared/tab-types' +import type { AppState } from '@/store/types' +import type * as RuntimeRpcClientModule from '@/runtime/runtime-rpc-client' + +type TestStore = { + getState: () => AppState + setState: (state: Partial) => void +} + +const mocks = vi.hoisted(() => { + const hoisted: { + store: TestStore | null + subscribeStatus: ReturnType + supportsCapability: ReturnType + unsubscribe: ReturnType + } = { + store: null, + subscribeStatus: vi.fn(), + supportsCapability: vi.fn(), + unsubscribe: vi.fn() + } + return hoisted +}) + +vi.mock('@/store', async () => { + const { createTestStore } = await import('@/store/slices/store-test-helpers') + const useAppStore = createTestStore() + mocks.store = useAppStore + return { useAppStore } +}) + +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => ({ + ...(await importOriginal()), + runtimeEnvironmentSupportsCapability: mocks.supportsCapability +})) + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: vi.fn(), + subscribeStructuredAgentSession: vi.fn(), + subscribeStructuredAgentSessionStatus: mocks.subscribeStatus +})) + +import { StructuredAgentSessionStatusBridge } from './StructuredAgentSessionStatusBridge' +import { resetStructuredAgentSessionStatusFeedsForTests } from '@/runtime/structured-agent-session-status-feed' + +const floatingTab = { + id: 'floating-chat-1', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + groupId: 'floating-group', + contentType: 'agent-session', + entityId: 'session-1', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0, + isPinned: false, + agentSessionAgent: 'codex', + executionHostId: LOCAL_EXECUTION_HOST_ID +} satisfies Tab + +function summary(overrides: Partial = {}): AgentSessionStatusSummary { + return { + sessionId: 'session-1', + workspaceId: FLOATING_TERMINAL_WORKTREE_ID, + agent: 'codex', + status: 'idle', + latestPrompt: 'hello', + updatedAt: 1, + ...overrides + } +} + +function emit(event: AgentSessionStatusEvent): void { + const call = mocks.subscribeStatus.mock.calls.at(-1) + if (!call) { + throw new Error('status feed not subscribed') + } + const send: (event: AgentSessionStatusEvent) => void = call[1] + act(() => send(event)) +} + +function pins(): AppState['structuredSessionLaunchDirectoryByTabId'] { + return mocks.store?.getState().structuredSessionLaunchDirectoryByTabId ?? {} +} + +describe('StructuredAgentSessionStatusBridge pinned launch directory', () => { + beforeEach(() => { + vi.clearAllMocks() + resetStructuredAgentSessionStatusFeedsForTests() + mocks.subscribeStatus.mockResolvedValue({ unsubscribe: mocks.unsubscribe }) + mocks.supportsCapability.mockResolvedValue(true) + mocks.store?.setState({ + structuredSessionLaunchDirectoryByTabId: {}, + unifiedTabsByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: [floatingTab] } + }) + }) + + afterEach(() => { + cleanup() + resetStructuredAgentSessionStatusFeedsForTests() + }) + + it('keeps the host-published pinned folder for the tab and drops it when the tab goes', async () => { + const view = render() + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + + emit({ type: 'snapshot', sessions: [summary({ launchDirectory: '/home/me/original' })] }) + + expect(pins()).toEqual({ + [floatingTab.id]: { sessionId: 'session-1', launchDirectory: '/home/me/original' } + }) + + act(() => mocks.store?.setState({ unifiedTabsByWorktree: {} })) + view.rerender() + expect(pins()).toEqual({}) + }) + + it('records nothing when an older host omits the field', async () => { + render() + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + + emit({ type: 'snapshot', sessions: [summary()] }) + + expect(pins()).toEqual({}) + }) + + it('never records a path published by a remote runtime', async () => { + // The tab records the paired host that owns the chat. + mocks.store?.setState({ + unifiedTabsByWorktree: { + [FLOATING_TERMINAL_WORKTREE_ID]: [ + { ...floatingTab, executionHostId: toRuntimeExecutionHostId('env-1') } + ] + } + }) + render() + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + + emit({ type: 'snapshot', sessions: [summary({ launchDirectory: '/srv/remote/floating' })] }) + + // The summary itself landed; only its path was withheld. + expect(Object.keys(mocks.store?.getState().agentStatusByPaneKey ?? {})).toHaveLength(1) + expect(pins()).toEqual({}) + }) +}) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx index 43e484c6f13..f711fff8044 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx @@ -289,6 +289,21 @@ function StructuredAgentSessionOwnedStatusProjection({ useEffect(() => { projectStatus(tab, summary, observation, launchFailedAt) }, [summary, observation, tab, launchFailedAt]) + const launchDirectory = summary?.launchDirectory + useEffect(() => { + // Why local only: a remote host's path is in its syntax, and floating chats only run locally. + useAppStore + .getState() + .setStructuredSessionLaunchDirectory( + tab.id, + tab.entityId, + target.kind === 'local' ? launchDirectory : undefined + ) + }, [launchDirectory, target.kind, tab.id, tab.entityId]) + useEffect( + () => () => useAppStore.getState().clearStructuredSessionLaunchDirectory(tab.id, tab.entityId), + [tab.entityId, tab.id] + ) useEffect( () => () => useAppStore.getState().removeAgentStatus(structuredAgentSessionPaneKey(tab.id, tab.entityId)), diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts index 59c4947c8b4..3475db76333 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts @@ -78,6 +78,11 @@ const PIECES: Record + translate( + 'components.native-chat.failureWords.launchFolderMissing', + COPY.launchFolderMissing + ), chooseClaudeAccount: () => translate( 'components.native-chat.failureWords.chooseClaudeAccount', diff --git a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts index b7564ba7d9c..a77a0e207f8 100644 --- a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts +++ b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts @@ -46,7 +46,7 @@ type NativeChatAttachmentOwnerState = Pick< | 'sshConnectionStates' | 'tabsByWorktree' | 'worktreesByRepo' -> +> & { floatingWorkspacePath?: AppState['floatingWorkspacePath'] } /** Resolve who owns the composer's backing worktree at attach time. Mirrors the * terminal drop resolver's order: runtime owner first, then SSH vs local. */ diff --git a/src/renderer/src/components/native-chat/native-chat-file-link.test.ts b/src/renderer/src/components/native-chat/native-chat-file-link.test.ts index eaf63279fe4..8d911033097 100644 --- a/src/renderer/src/components/native-chat/native-chat-file-link.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-file-link.test.ts @@ -3,7 +3,9 @@ import type { Tab } from '../../../../shared/tab-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { AppState } from '@/store/types' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultSettings } from '../../../../shared/constants' import { + findNativeChatTabOwnerWorktreeId, resolveNativeChatFileLink, resolveNativeChatFileLinkContext, type NativeChatFileLinkContext @@ -135,6 +137,63 @@ describe('resolveNativeChatFileLinkContext', () => { }) }) +describe('floating workspace native chat', () => { + const floatingTab = { + id: 'floating-chat-1', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + groupId: 'floating-group', + contentType: 'agent-session', + entityId: 'session-1', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0, + isPinned: false, + agentSessionAgent: 'codex' + } satisfies Tab + + function floatingState(floatingWorkspacePath: string | null): AppState { + return state({ + tabsByWorktree: {}, + unifiedTabsByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: [floatingTab] }, + getKnownWorktreeById: () => undefined, + worktreesByRepo: {}, + // Why a focused runtime: floating must stay local even when one is selected. + settings: { ...getDefaultSettings('/home/me'), activeRuntimeEnvironmentId: 'env-1' }, + floatingWorkspacePath + }) + } + + it('finds the owning floating workspace before its directory is known', () => { + const pending = floatingState(null) + expect(findNativeChatTabOwnerWorktreeId(pending, floatingTab.id)).toBe( + FLOATING_TERMINAL_WORKTREE_ID + ) + expect(resolveNativeChatFileLinkContext(pending, floatingTab.id)).toBeNull() + }) + + it('resolves file links against the pinned folder after the floating setting moved', () => { + const pinned = { + ...floatingState('/home/me/changed-setting'), + structuredSessionLaunchDirectoryByTabId: { + [floatingTab.id]: { sessionId: floatingTab.entityId, launchDirectory: '/home/me/pinned' } + } + } + expect(resolveNativeChatFileLinkContext(pinned, floatingTab.id)).toEqual({ + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + worktreePath: '/home/me/pinned', + runtimeEnvironmentId: null + }) + }) + + it('resolves no file links until the pin arrives, instead of trusting the current setting', () => { + expect( + resolveNativeChatFileLinkContext(floatingState('/home/me/changed-setting'), floatingTab.id) + ).toBeNull() + }) +}) + describe('resolveNativeChatFileLink', () => { it('resolves repo-relative file links against the chat worktree', () => { expect(resolveNativeChatFileLink('docs/guide.md', context)).toEqual({ diff --git a/src/renderer/src/components/native-chat/native-chat-file-link.ts b/src/renderer/src/components/native-chat/native-chat-file-link.ts index 431dc9a556e..043bb9b78c6 100644 --- a/src/renderer/src/components/native-chat/native-chat-file-link.ts +++ b/src/renderer/src/components/native-chat/native-chat-file-link.ts @@ -1,12 +1,11 @@ import { routeNativeChatHref } from '../../../../shared/native-chat-href-routing' -import type { Worktree } from '../../../../shared/worktree/types' import { parseExplicitFileLinkTarget, resolveExplicitFileLinkTarget } from '@/lib/explicit-file-link-target' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import type { AppState } from '@/store/types' -import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { resolveNativeChatTabDirectory } from './native-chat-tab-directory' export type NativeChatFileLinkContext = { worktreeId: string @@ -31,6 +30,8 @@ type NativeChatFileLinkState = Pick< | 'worktreesByRepo' > & { unifiedTabsByWorktree?: AppState['unifiedTabsByWorktree'] + floatingWorkspacePath?: AppState['floatingWorkspacePath'] + structuredSessionLaunchDirectoryByTabId?: AppState['structuredSessionLaunchDirectoryByTabId'] } export function findTerminalTabWorktreeId( @@ -59,42 +60,26 @@ function findStructuredTabWorktreeId( return null } -function findWorktreeFallback( - worktreesByRepo: NativeChatFileLinkState['worktreesByRepo'], - worktreeId: string -): Pick | null { - for (const worktrees of Object.values(worktreesByRepo)) { - const worktree = worktrees.find((entry) => entry.id === worktreeId) - if (worktree) { - return worktree - } - } - return null +/** The workspace that owns a native chat tab, independent of whether its directory is known. */ +export function findNativeChatTabOwnerWorktreeId( + state: Pick, + tabId: string +): string | null { + return ( + findTerminalTabWorktreeId(state.tabsByWorktree, tabId) ?? + findStructuredTabWorktreeId(state.unifiedTabsByWorktree, tabId) + ) } export function resolveNativeChatFileLinkContext( state: NativeChatFileLinkState, terminalTabId: string ): NativeChatFileLinkContext | null { - const worktreeId = - findTerminalTabWorktreeId(state.tabsByWorktree, terminalTabId) ?? - findStructuredTabWorktreeId(state.unifiedTabsByWorktree, terminalTabId) + const worktreeId = findNativeChatTabOwnerWorktreeId(state, terminalTabId) if (!worktreeId) { return null } - - const knownWorktree = state.getKnownWorktreeById(worktreeId) - const worktree = knownWorktree?.path - ? knownWorktree - : findWorktreeFallback(state.worktreesByRepo, worktreeId) - const workspaceScope = parseWorkspaceKey(worktreeId) - const worktreePath = - worktree?.path ?? - (workspaceScope?.type === 'folder' - ? (state.folderWorkspaces.find( - (workspace) => workspace.id === workspaceScope.folderWorkspaceId - )?.folderPath ?? null) - : null) + const worktreePath = resolveNativeChatTabDirectory(state, terminalTabId, worktreeId) if (!worktreePath) { return null } diff --git a/src/renderer/src/components/native-chat/native-chat-image-runtime-context.test.ts b/src/renderer/src/components/native-chat/native-chat-image-runtime-context.test.ts index cfda20998fa..6264b437c03 100644 --- a/src/renderer/src/components/native-chat/native-chat-image-runtime-context.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-image-runtime-context.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest' import { shallow } from 'zustand/shallow' import type { AppState } from '@/store/types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { Tab } from '../../../../shared/tab-types' +import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultSettings } from '../../../../shared/constants' import { resolveNativeChatImageRuntimeContext, selectNativeChatImageOwnerState @@ -93,4 +95,49 @@ describe('resolveNativeChatImageRuntimeContext', () => { settings: { activeRuntimeEnvironmentId: 'owner-a' } }) }) + + it('resolves a floating chat to its pinned folder on the local host, and nothing before the pin', () => { + const floatingTab: Tab = { + id: 'floating-chat-1', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + groupId: 'floating-group', + contentType: 'agent-session', + entityId: 'session-1', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0, + isPinned: false, + agentSessionAgent: 'codex' + } + const floatingState: AppState = { + ...state(), + tabsByWorktree: {}, + unifiedTabsByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: [floatingTab] }, + getKnownWorktreeById: () => undefined, + worktreesByRepo: {}, + // Why a focused runtime: floating must stay local even when one is selected. + settings: { ...getDefaultSettings('/home/me'), activeRuntimeEnvironmentId: 'env-1' }, + floatingWorkspacePath: '/home/me/changed-setting' + } + + expect(resolveNativeChatImageRuntimeContext(floatingState, 'floating-chat-1')).toBeNull() + expect( + resolveNativeChatImageRuntimeContext( + { + ...floatingState, + structuredSessionLaunchDirectoryByTabId: { + 'floating-chat-1': { sessionId: 'session-1', launchDirectory: '/home/me/pinned' } + } + }, + 'floating-chat-1' + ) + ).toMatchObject({ + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + worktreePath: '/home/me/pinned', + expectedExecutionHostId: 'local', + settings: { activeRuntimeEnvironmentId: null } + }) + }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-image-runtime-context.ts b/src/renderer/src/components/native-chat/native-chat-image-runtime-context.ts index 600c5b63c1d..30ec92ac7b1 100644 --- a/src/renderer/src/components/native-chat/native-chat-image-runtime-context.ts +++ b/src/renderer/src/components/native-chat/native-chat-image-runtime-context.ts @@ -3,16 +3,14 @@ import type { AppState } from '@/store/types' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' import { settingsForWorktreeOperationRoute, - resolveWorktreeOperationRouteResult + resolveWorktreeOperationRouteResult, + type WorktreeOperationRouteResolution } from '@/lib/worktree-operation-route' import { resolveNativeChatFileLinkContext } from './native-chat-file-link' import { captureDirectSshMutationExpectation } from '@/lib/ssh-mutation-expectation' -import { - parseExecutionHostId, - toRuntimeExecutionHostId, - type ExecutionHostId -} from '../../../../shared/execution-host' -import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { parseExecutionHostId, toRuntimeExecutionHostId } from '../../../../shared/execution-host' +import { isFloatingWorkspaceId } from '../../../../shared/floating-workspace-worktree' +import { resolveNativeChatTabDirectory } from './native-chat-tab-directory' import { useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' @@ -26,6 +24,8 @@ type OwnerState = Pick< | 'worktreesByRepo' | 'detectedWorktreesByRepo' | 'folderWorkspaces' + | 'floatingWorkspacePath' + | 'structuredSessionLaunchDirectoryByTabId' | 'projectGroups' | 'runtimeEnvironments' | 'runtimeEnvironmentCatalogHydrated' @@ -49,6 +49,8 @@ export function selectNativeChatImageOwnerState(state: AppState): OwnerState { worktreesByRepo: state.worktreesByRepo, detectedWorktreesByRepo: state.detectedWorktreesByRepo, folderWorkspaces: state.folderWorkspaces, + floatingWorkspacePath: state.floatingWorkspacePath, + structuredSessionLaunchDirectoryByTabId: state.structuredSessionLaunchDirectoryByTabId, projectGroups: state.projectGroups, runtimeEnvironments: state.runtimeEnvironments, runtimeEnvironmentCatalogHydrated: state.runtimeEnvironmentCatalogHydrated, @@ -64,6 +66,12 @@ export function selectNativeChatImageOwnerState(state: AppState): OwnerState { } } +// Why: floating has no catalog row for the route resolver to find, and it always runs locally. +const FLOATING_WORKSPACE_ROUTE: WorktreeOperationRouteResolution = { + kind: 'resolved', + route: { executionHostId: 'local', runtimeEnvironmentId: null } +} + // Route settings are cloned for the runtime operation contract. Reuse that // clone while the store's source settings and selected runtime are unchanged so // consumers do not treat an unrelated store update as a new image owner. @@ -98,33 +106,6 @@ function stableSettingsForRoute( return resolved } -function resolvePath( - state: OwnerState, - worktreeId: string, - hostId: ExecutionHostId | null -): string | null { - const known = state.getKnownWorktreeById(worktreeId, hostId ?? undefined) - if (known?.path) { - return known.path - } - const workspace = parseWorkspaceKey(worktreeId) - if (workspace?.type === 'folder') { - return ( - state.folderWorkspaces.find((entry) => entry.id === workspace.folderWorkspaceId) - ?.folderPath ?? null - ) - } - for (const worktrees of Object.values(state.worktreesByRepo ?? {})) { - const match = worktrees.find( - (entry) => entry.id === worktreeId && (!hostId || entry.hostId === hostId) - ) - if (match?.path) { - return match.path - } - } - return null -} - export function resolveNativeChatImageRuntimeContext( state: OwnerState, tabId: string @@ -133,7 +114,9 @@ export function resolveNativeChatImageRuntimeContext( if (!linkContext) { return null } - const routeResolution = resolveWorktreeOperationRouteResult(state, linkContext.worktreeId) + const routeResolution = isFloatingWorkspaceId(linkContext.worktreeId) + ? FLOATING_WORKSPACE_ROUTE + : resolveWorktreeOperationRouteResult(state, linkContext.worktreeId) if (routeResolution.kind !== 'resolved') { return null } @@ -144,7 +127,12 @@ export function resolveNativeChatImageRuntimeContext( if (!executionHostId) { return null } - const worktreePath = resolvePath(state, linkContext.worktreeId, executionHostId) + const worktreePath = resolveNativeChatTabDirectory( + state, + tabId, + linkContext.worktreeId, + executionHostId + ) if (!worktreePath) { return null } diff --git a/src/renderer/src/components/native-chat/native-chat-skill-discovery-context.ts b/src/renderer/src/components/native-chat/native-chat-skill-discovery-context.ts index b0f59a8a8a1..fc1e47ee3c1 100644 --- a/src/renderer/src/components/native-chat/native-chat-skill-discovery-context.ts +++ b/src/renderer/src/components/native-chat/native-chat-skill-discovery-context.ts @@ -7,18 +7,24 @@ import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' -import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { + resolveNativeChatTabDirectory, + resolveNativeChatTabDirectoryResolution, + type NativeChatTabDirectoryState +} from './native-chat-tab-directory' export type NativeChatSkillStateInputs = Pick< AppState, | 'activeRepoId' | 'activeWorktreeId' + | 'floatingWorkspacePath' | 'folderWorkspaces' | 'projectGroups' | 'projects' | 'repos' | 'restoredRuntimeHostIdByWorkspaceSessionKey' | 'settings' + | 'structuredSessionLaunchDirectoryByTabId' | 'tabsByWorktree' | 'unifiedTabsByWorktree' | 'worktreesByRepo' @@ -26,10 +32,8 @@ export type NativeChatSkillStateInputs = Pick< type NativeChatSkillTab = { id: string; startupCwd?: string } -type NativeChatSkillWorktreeState = { +type NativeChatSkillWorktreeState = NativeChatTabDirectoryState & { tabsByWorktree: Record - unifiedTabsByWorktree?: Record - worktreesByRepo: Record } export type NativeChatSkillDiscoveryContext = { @@ -44,12 +48,14 @@ export function selectNativeChatSkillStateInputs(state: AppState): NativeChatSki return { activeRepoId: state.activeRepoId, activeWorktreeId: state.activeWorktreeId, + floatingWorkspacePath: state.floatingWorkspacePath, folderWorkspaces: state.folderWorkspaces, projectGroups: state.projectGroups, projects: state.projects, repos: state.repos, restoredRuntimeHostIdByWorkspaceSessionKey: state.restoredRuntimeHostIdByWorkspaceSessionKey, settings: state.settings, + structuredSessionLaunchDirectoryByTabId: state.structuredSessionLaunchDirectoryByTabId, tabsByWorktree: state.tabsByWorktree, unifiedTabsByWorktree: state.unifiedTabsByWorktree, worktreesByRepo: state.worktreesByRepo @@ -70,13 +76,22 @@ export function resolveNativeChatSkillDiscoveryCwd( if (startupCwd) { return startupCwd } - for (const worktrees of Object.values(state.worktreesByRepo)) { - const worktree = worktrees.find((entry) => entry.id === found.worktreeId) - if (worktree) { - return worktree.path - } + return resolveNativeChatTabDirectory(state, terminalTabId, found.worktreeId) +} + +/** A missing context that is not a failure: the chat's folder is known soon, when its pin arrives. */ +export function isNativeChatSkillDiscoveryAwaitingDirectory( + state: NativeChatSkillWorktreeState, + terminalTabId: string +): boolean { + const found = findNativeChatTab(state, terminalTabId) + if (!found || found.tab.startupCwd?.trim()) { + return false } - return null + return ( + resolveNativeChatTabDirectoryResolution(state, terminalTabId, found.worktreeId).status === + 'awaiting-pin' + ) } export function resolveNativeChatSkillDiscoveryContext( @@ -87,14 +102,7 @@ export function resolveNativeChatSkillDiscoveryContext( if (!worktreeId) { return null } - const workspaceScope = parseWorkspaceKey(worktreeId) - const cwd = - resolveNativeChatSkillDiscoveryCwd(state, terminalTabId) ?? - (workspaceScope?.type === 'folder' - ? state.folderWorkspaces.find( - (workspace) => workspace.id === workspaceScope.folderWorkspaceId - )?.folderPath - : null) + const cwd = resolveNativeChatSkillDiscoveryCwd(state, terminalTabId) if (!cwd) { return null } diff --git a/src/renderer/src/components/native-chat/native-chat-tab-directory.test.ts b/src/renderer/src/components/native-chat/native-chat-tab-directory.test.ts new file mode 100644 index 00000000000..04b87261f26 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-tab-directory.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { + resolveNativeChatTabDirectory, + resolveNativeChatTabDirectoryResolution, + type NativeChatTabDirectoryState +} from './native-chat-tab-directory' + +const FLOATING_TAB = { + id: 'floating-chat-1', + entityId: 'session-1', + contentType: 'agent-session', + agentSessionAgent: 'codex' +} +const FLOATING_TERMINAL_TAB = { + id: 'floating-terminal-1', + entityId: 'pty-1', + contentType: 'terminal' +} +const WORKTREE_TAB = { id: 'worktree-chat-1', entityId: 'session-2' } +const SSH_HOST = 'ssh:box-1' + +function state(overrides: Partial = {}): NativeChatTabDirectoryState { + return { + floatingWorkspacePath: '/home/me/changed-setting', + worktreesByRepo: { + repo: [ + { id: 'wt-1', path: '/repo/worktree', hostId: 'local' }, + { id: 'wt-ssh', path: '/srv/remote/worktree', hostId: SSH_HOST } + ] + }, + unifiedTabsByWorktree: { + [FLOATING_TERMINAL_WORKTREE_ID]: [FLOATING_TAB, FLOATING_TERMINAL_TAB], + 'wt-1': [WORKTREE_TAB], + 'wt-ssh': [{ id: 'ssh-chat-1', entityId: 'session-3' }] + }, + structuredSessionLaunchDirectoryByTabId: { + [FLOATING_TAB.id]: { sessionId: 'session-1', launchDirectory: '/home/me/pinned' } + }, + ...overrides + } +} + +describe('resolveNativeChatTabDirectory', () => { + it('answers a floating chat with its pinned folder after the floating setting moved', () => { + expect( + resolveNativeChatTabDirectory(state(), FLOATING_TAB.id, FLOATING_TERMINAL_WORKTREE_ID) + ).toBe('/home/me/pinned') + expect( + resolveNativeChatTabDirectory( + state(), + FLOATING_TAB.id, + FLOATING_TERMINAL_WORKTREE_ID, + 'local' + ) + ).toBe('/home/me/pinned') + }) + + it('has no directory until the pin arrives, instead of the current floating setting', () => { + const unpinned = state({ structuredSessionLaunchDirectoryByTabId: {} }) + expect( + resolveNativeChatTabDirectory(unpinned, FLOATING_TAB.id, FLOATING_TERMINAL_WORKTREE_ID) + ).toBeNull() + expect( + resolveNativeChatTabDirectoryResolution( + unpinned, + FLOATING_TAB.id, + FLOATING_TERMINAL_WORKTREE_ID + ) + ).toEqual({ status: 'awaiting-pin' }) + }) + + it('ignores a pin left from a session the tab no longer shows', () => { + const rebound = state({ + unifiedTabsByWorktree: { + [FLOATING_TERMINAL_WORKTREE_ID]: [{ ...FLOATING_TAB, entityId: 'session-new' }] + } + }) + expect( + resolveNativeChatTabDirectoryResolution( + rebound, + FLOATING_TAB.id, + FLOATING_TERMINAL_WORKTREE_ID + ) + ).toEqual({ status: 'awaiting-pin' }) + }) + + it('resolves a floating tab no session pin is published for by the floating setting', () => { + expect( + resolveNativeChatTabDirectory( + state(), + FLOATING_TERMINAL_TAB.id, + FLOATING_TERMINAL_WORKTREE_ID + ) + ).toBe('/home/me/changed-setting') + }) + + it('never answers a floating chat off the local host, pinned or not', () => { + expect( + resolveNativeChatTabDirectory( + state(), + FLOATING_TAB.id, + FLOATING_TERMINAL_WORKTREE_ID, + SSH_HOST + ) + ).toBeNull() + }) + + it('resolves worktree chats by id even when a pin is recorded for the tab', () => { + const pinned = state({ + structuredSessionLaunchDirectoryByTabId: { + [WORKTREE_TAB.id]: { sessionId: 'session-2', launchDirectory: '/somewhere/else' }, + 'ssh-chat-1': { sessionId: 'session-3', launchDirectory: '/somewhere/else' } + } + }) + expect(resolveNativeChatTabDirectory(pinned, WORKTREE_TAB.id, 'wt-1')).toBe('/repo/worktree') + expect(resolveNativeChatTabDirectory(pinned, 'ssh-chat-1', 'wt-ssh', SSH_HOST)).toBe( + '/srv/remote/worktree' + ) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-tab-directory.ts b/src/renderer/src/components/native-chat/native-chat-tab-directory.ts new file mode 100644 index 00000000000..fb700e67840 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-tab-directory.ts @@ -0,0 +1,75 @@ +import type { ExecutionHostId } from '../../../../shared/execution-host' +import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' +import { isFloatingWorkspaceId } from '../../../../shared/floating-workspace-worktree' +import { resolveWorkspaceDirectory, type WorkspaceDirectoryState } from '@/lib/workspace-directory' +import type { StructuredSessionLaunchDirectory } from '@/store/slices/structured-session-launch-directories' + +type NativeChatDirectoryTab = { + id: string + entityId?: string + contentType?: string + agentSessionAgent?: unknown +} + +export type NativeChatTabDirectoryState = WorkspaceDirectoryState & { + unifiedTabsByWorktree?: Record + structuredSessionLaunchDirectoryByTabId?: Record +} + +export type NativeChatTabDirectoryResolution = + | { status: 'resolved'; directory: string } + /** A floating structured chat whose pinned folder the status feed has not delivered yet. */ + | { status: 'awaiting-pin' } + | { status: 'unavailable' } + +/** + * The directory a native chat tab's agent runs in. A floating structured chat is held to the folder + * its session was pinned to, because the floating setting can move after launch, so it has no + * directory until the host publishes that pin. Every other tab resolves by workspace id exactly as + * the host does. + */ +export function resolveNativeChatTabDirectoryResolution( + state: NativeChatTabDirectoryState, + tabId: string, + worktreeId: string, + executionHostId?: ExecutionHostId | null +): NativeChatTabDirectoryResolution { + const structuredTab = isFloatingWorkspaceId(worktreeId) + ? // Same predicate as the status bridge that mirrors pins, so no tab waits on one never sent. + state.unifiedTabsByWorktree?.[worktreeId]?.find( + (tab) => + tab.id === tabId && + tab.contentType === 'agent-session' && + isAgentSessionHandleProvider(tab.agentSessionAgent) + ) + : undefined + if (!structuredTab) { + const directory = resolveWorkspaceDirectory(state, worktreeId, executionHostId) + return directory ? { status: 'resolved', directory } : { status: 'unavailable' } + } + // Why: a floating chat only runs on the local host, so no other host holds its folder. + if (executionHostId && executionHostId !== 'local') { + return { status: 'unavailable' } + } + const pinned = state.structuredSessionLaunchDirectoryByTabId?.[tabId] + // Why no setting fallback: absence cannot tell "not delivered yet" from "none", and the setting + // may name a folder this session never ran in. + return pinned && pinned.sessionId === structuredTab.entityId + ? { status: 'resolved', directory: pinned.launchDirectory } + : { status: 'awaiting-pin' } +} + +export function resolveNativeChatTabDirectory( + state: NativeChatTabDirectoryState, + tabId: string, + worktreeId: string, + executionHostId?: ExecutionHostId | null +): string | null { + const resolution = resolveNativeChatTabDirectoryResolution( + state, + tabId, + worktreeId, + executionHostId + ) + return resolution.status === 'resolved' ? resolution.directory : null +} diff --git a/src/renderer/src/components/native-chat/structured-attention-dispatch.test.ts b/src/renderer/src/components/native-chat/structured-attention-dispatch.test.ts index 5869e2eabfa..0203bbc28bb 100644 --- a/src/renderer/src/components/native-chat/structured-attention-dispatch.test.ts +++ b/src/renderer/src/components/native-chat/structured-attention-dispatch.test.ts @@ -21,6 +21,7 @@ import { } from '@/store/slices/store-test-helpers' import type { NotificationDispatchRequest } from '../../../../shared/notification-settings-types' import { isStructuredTab, type StructuredTab } from './structured-agent-session-tabs' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' vi.mock('@/store', () => ({ useAppStore: { getState: () => store.getState() } })) // The two client-side follow-ups of a delivery are leaf side effects with their own tests; this @@ -47,9 +48,12 @@ const CHAT_TAB = 'chat-tab' const SESSION = 'session-1' function settingsWith(groupAttention: boolean): GlobalSettings { - // The dispatcher reads exactly one settings field and GlobalSettings has no test factory. - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only field read. - return { experimentalTerminalAttention: groupAttention } as GlobalSettings + // The dispatcher reads only these settings fields and GlobalSettings has no test factory. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only fields read. + return { + experimentalTerminalAttention: groupAttention, + floatingTerminalEnabled: true + } as GlobalSettings } function completion(overrides?: Partial): AgentSessionTurnCompletion { @@ -79,6 +83,7 @@ function seed(overrides?: { workspaceId?: string folderWorkspaces?: FolderWorkspace[] tabs?: boolean + floatingPanelOpen?: boolean }): void { const workspaceId = overrides?.workspaceId ?? WORKSPACE store.setState({ @@ -111,6 +116,7 @@ function seed(overrides?: { ] }, activeGroupIdByWorktree: { [workspaceId]: GROUP }, + activeView: 'terminal', // Default: the user is looking somewhere else, which is when unread is owed. activeWorktreeId: overrides?.activeWorktreeId === undefined ? 'other-workspace' : overrides.activeWorktreeId, @@ -118,6 +124,7 @@ function seed(overrides?: { unreadTerminalPanes: {}, unreadAgentCompletionPanes: {}, settings: settingsWith(overrides?.groupAttention ?? true), + floatingWorkspacePanelOpen: overrides?.floatingPanelOpen ?? false, // Persistence is not what this test is about; the folder path would otherwise call out to IPC. updateFolderWorkspace: async () => true }) @@ -254,6 +261,33 @@ describe('dispatchStructuredTurnCompletionAttention', () => { expect(indicators().paneDot).toBe('agent-completion') }) + it('earns no unread for a floating chat selected in the open floating panel', () => { + // The main window is on another workspace; the floating panel is what the user is reading. + seed({ workspaceId: FLOATING_TERMINAL_WORKTREE_ID, floatingPanelOpen: true }) + dispatchStructuredTurnCompletionAttention( + structuredTab(FLOATING_TERMINAL_WORKTREE_ID), + completion() + ) + expect(store.getState().unreadAgentCompletionPanes).toEqual({}) + expect(store.getState().unreadTerminalTabs).toEqual({}) + }) + + it('earns unread for a floating chat while the floating panel is closed', () => { + seed({ workspaceId: FLOATING_TERMINAL_WORKTREE_ID }) + dispatchStructuredTurnCompletionAttention( + structuredTab(FLOATING_TERMINAL_WORKTREE_ID), + completion() + ) + expect(indicators(FLOATING_TERMINAL_WORKTREE_ID).paneDot).toBe('agent-completion') + }) + + it('earns unread when the workspace chat is behind another top-level view', () => { + seed({ activeWorktreeId: WORKSPACE }) + store.setState({ activeView: 'tasks' }) + dispatchStructuredTurnCompletionAttention(structuredTab(), completion()) + expect(indicators().paneDot).toBe('agent-completion') + }) + it('hands main the host verdict, which picks finished, failed or stopped', () => { dispatchStructuredTurnCompletionAttention(structuredTab(), completion()) // 'done' is the host's report that the turn settled, not a reading of the status row: main diff --git a/src/renderer/src/components/native-chat/structured-attention-surface.ts b/src/renderer/src/components/native-chat/structured-attention-surface.ts index 3abbff8b245..344b120d4e6 100644 --- a/src/renderer/src/components/native-chat/structured-attention-surface.ts +++ b/src/renderer/src/components/native-chat/structured-attention-surface.ts @@ -17,6 +17,7 @@ import type { import { parsePaneKey } from '../../../../shared/stable-pane-id' import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' import { isOrcaWindowForegroundFocused } from '../terminal-pane/terminal-notification-pane-visibility' +import { isTabOnVisibleSurface } from '@/hooks/agent-auto-ack-targets' import { isStructuredTab, type StructuredTab } from './structured-agent-session-tabs' type StoreSnapshot = ReturnType @@ -66,20 +67,15 @@ function admitStructuredSession( : { admitted: false, cause: 'superseded-surface' } } -/** The tab the workspace's focused group is currently showing, if it is this structured tab. */ function isViewedStructuredTab( state: StoreSnapshot, workspaceId: string, tab: StructuredTab ): boolean { - if (!isOrcaWindowForegroundFocused() || state.activeWorktreeId !== workspaceId) { - return false - } - const activeGroupId = state.activeGroupIdByWorktree[workspaceId] - const group = (state.groupsByWorktree[workspaceId] ?? []).find( - (candidate) => candidate.id === activeGroupId + return ( + isOrcaWindowForegroundFocused() && + isTabOnVisibleSurface(state, workspaceId, tab.id, 'structured') ) - return group?.activeTabId === tab.id } function collectStructuredAttentionRemainder( diff --git a/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx index e05cbb7a870..4e5e35d1cd8 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx @@ -4,6 +4,7 @@ import { act, cleanup, render, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { NativeChatSkillDiscovery } from './use-native-chat-skills' import { getNativeChatAgentProfile } from '../../../../shared/native-chat-agent-profiles' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { isSkillPickerTriggered } from './native-chat-composer-state' const mocks = vi.hoisted(() => ({ @@ -176,6 +177,40 @@ describe('useNativeChatSkills', () => { expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() }) + it('reports a floating chat awaiting its pinned folder as loading, then scans the pin', () => { + const floatingTab = { + id: 'tab-1', + contentType: 'agent-session', + entityId: 'session-1', + agentSessionAgent: 'codex' + } + mocks.state = { + ...stateForHost('local'), + tabsByWorktree: {}, + floatingWorkspacePath: '/home/me/changed-setting', + structuredSessionLaunchDirectoryByTabId: {}, + unifiedTabsByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: [floatingTab] } + } + const view = render() + expect(mocks.snapshots.at(-1)?.status).toBe('loading') + expect(mocks.snapshots.at(-1)?.error).toBeNull() + expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() + + mocks.state = { + ...mocks.state, + structuredSessionLaunchDirectoryByTabId: { + 'tab-1': { sessionId: 'session-1', launchDirectory: '/home/me/pinned' } + } + } + view.rerender() + expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'local' }, + 'skills.discover', + { cwd: '/home/me/pinned', worktreeId: FLOATING_TERMINAL_WORKTREE_ID }, + { timeoutMs: 10_000 } + ) + }) + it('shares one in-flight request between sibling panes', async () => { render( <> diff --git a/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts b/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts index 9baf747673a..bee675d0ffd 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts @@ -1,7 +1,14 @@ import { describe, expect, it } from 'vitest' import type { DiscoveredSkill, SkillDiscoveryResult } from '../../../../shared/skills' +import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultSettings } from '../../../../shared/constants' +import type { Tab } from '../../../../shared/tab-types' +import { + isNativeChatSkillDiscoveryAwaitingDirectory, + type NativeChatSkillStateInputs +} from './native-chat-skill-discovery-context' import { isNativeChatSkillForAgent, + resolveNativeChatSkillDiscoveryContext, resolveNativeChatSkillDiscoveryCwd } from './use-native-chat-skills' @@ -174,3 +181,70 @@ describe('resolveNativeChatSkillDiscoveryCwd', () => { ).toBe('/repo/worktree/packages/app') }) }) + +describe('floating workspace skill discovery', () => { + const floatingTab: Tab = { + id: 'floating-chat-1', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + groupId: 'floating-group', + contentType: 'agent-session', + entityId: 'session-1', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0, + isPinned: false, + agentSessionAgent: 'codex' + } + const floatingInputs: NativeChatSkillStateInputs = { + activeRepoId: null, + activeWorktreeId: null, + floatingWorkspacePath: '/home/me/scratch', + folderWorkspaces: [], + projectGroups: [], + projects: [], + repos: [], + restoredRuntimeHostIdByWorkspaceSessionKey: {}, + // Why a focused runtime: floating must stay local even when one is selected. + settings: { ...getDefaultSettings('/home/me'), activeRuntimeEnvironmentId: 'env-1' }, + structuredSessionLaunchDirectoryByTabId: {}, + tabsByWorktree: {}, + unifiedTabsByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: [floatingTab] }, + worktreesByRepo: {} + } + + it('scans nothing and awaits the pin rather than scanning the current setting', () => { + expect(resolveNativeChatSkillDiscoveryCwd(floatingInputs, 'floating-chat-1')).toBeNull() + expect(resolveNativeChatSkillDiscoveryContext(floatingInputs, 'floating-chat-1')).toBeNull() + expect(isNativeChatSkillDiscoveryAwaitingDirectory(floatingInputs, 'floating-chat-1')).toBe( + true + ) + }) + + it('scans the pinned folder after the floating setting moved', () => { + const pinned: NativeChatSkillStateInputs = { + ...floatingInputs, + floatingWorkspacePath: '/home/me/changed-setting', + structuredSessionLaunchDirectoryByTabId: { + 'floating-chat-1': { sessionId: 'session-1', launchDirectory: '/home/me/pinned' } + } + } + expect(isNativeChatSkillDiscoveryAwaitingDirectory(pinned, 'floating-chat-1')).toBe(false) + expect(resolveNativeChatSkillDiscoveryContext(pinned, 'floating-chat-1')).toMatchObject({ + cwd: '/home/me/pinned', + executionHostKind: 'local', + runtimeTarget: { kind: 'local' }, + discoveryTarget: { cwd: '/home/me/pinned', worktreeId: FLOATING_TERMINAL_WORKTREE_ID } + }) + }) + + it('stays not-ready until the floating directory resolves', () => { + expect( + resolveNativeChatSkillDiscoveryContext( + { ...floatingInputs, floatingWorkspacePath: null }, + 'floating-chat-1' + ) + ).toBeNull() + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-skills.ts b/src/renderer/src/components/native-chat/use-native-chat-skills.ts index 42adc911656..f045766178f 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-skills.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-skills.ts @@ -7,6 +7,7 @@ import { getNativeChatAgentProfile } from '../../../../shared/native-chat-agent- import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { emitNativeChatSkillDiscovery } from '@/lib/native-chat-telemetry' import { + isNativeChatSkillDiscoveryAwaitingDirectory, resolveNativeChatSkillDiscoveryContext, selectNativeChatSkillStateInputs, type NativeChatSkillDiscoveryContext @@ -43,6 +44,13 @@ const IDLE_STATE: StoredDiscoveryState = { error: null, contextKey: null } +// No context yet, but one is on its way: reads as loading rather than as a failed scan. +const AWAITING_CONTEXT_STATE: StoredDiscoveryState = { + status: 'loading', + skills: [], + error: null, + contextKey: null +} const MISSING_CONTEXT_STATE: StoredDiscoveryState = { status: 'error', skills: [], @@ -87,6 +95,10 @@ export function useNativeChatSkills( () => resolveNativeChatSkillDiscoveryContext(inputs, terminalTabId), [inputs, terminalTabId] ) + const awaitingContext = useMemo( + () => !context && isNativeChatSkillDiscoveryAwaitingDirectory(inputs, terminalTabId), + [context, inputs, terminalTabId] + ) const [state, setState] = useState(IDLE_STATE) const [retryGeneration, setRetryGeneration] = useState(0) const paneDiscoveryCache = useRef(new Map()) @@ -185,11 +197,13 @@ export function useNativeChatSkills( !profile || !enabled ? IDLE_STATE : !context - ? MISSING_CONTEXT_STATE + ? awaitingContext + ? AWAITING_CONTEXT_STATE + : MISSING_CONTEXT_STATE : state.contextKey === context.key ? state : { status: 'loading' as const, skills: [], error: null, contextKey: context.key }, - [context, enabled, profile, state] + [awaitingContext, context, enabled, profile, state] ) const visibleSkills = useMemo(() => { if (!profile || effectiveState.status !== 'ready') { diff --git a/src/renderer/src/components/native-chat/use-native-chat-tab-owner.ts b/src/renderer/src/components/native-chat/use-native-chat-tab-owner.ts new file mode 100644 index 00000000000..8ec04deac07 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-tab-owner.ts @@ -0,0 +1,7 @@ +import { useAppStore } from '@/store' +import { findNativeChatTabOwnerWorktreeId } from './native-chat-file-link' + +/** Owning workspace of a chat tab; launch identity must not wait on its directory resolving. */ +export function useNativeChatTabOwnerWorktreeId(tabId: string): string | null { + return useAppStore((state) => findNativeChatTabOwnerWorktreeId(state, tabId)) +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.surface-owner.test.ts b/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.surface-owner.test.ts new file mode 100644 index 00000000000..7132afa51d4 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.surface-owner.test.ts @@ -0,0 +1,102 @@ +// @vitest-environment happy-dom + +import { cleanup, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' + +const toggleTabViewMode = vi.hoisted(() => vi.fn()) + +function workspaceWithTerminal(worktreeId: string) { + const tabId = `${worktreeId}-tab` + return { + groups: [{ id: `${worktreeId}-group`, activeTabId: tabId }], + tabs: [{ id: tabId, entityId: `${worktreeId}-terminal`, contentType: 'terminal' }], + terminals: [{ id: `${worktreeId}-terminal`, title: 'zsh' }] + } +} + +const state = vi.hoisted(() => ({ activeWorktreeId: 'wt-main' })) +vi.mock('../../store', () => { + const main = workspaceWithTerminal('wt-main') + const floating = workspaceWithTerminal(FLOATING_TERMINAL_WORKTREE_ID) + return { + useAppStore: { + getState: () => ({ + activeWorktreeId: state.activeWorktreeId, + activeGroupIdByWorktree: { + 'wt-main': main.groups[0].id, + [FLOATING_TERMINAL_WORKTREE_ID]: floating.groups[0].id + }, + groupsByWorktree: { + 'wt-main': main.groups, + [FLOATING_TERMINAL_WORKTREE_ID]: floating.groups + }, + unifiedTabsByWorktree: { + 'wt-main': main.tabs, + [FLOATING_TERMINAL_WORKTREE_ID]: floating.tabs + }, + tabsByWorktree: { + 'wt-main': main.terminals, + [FLOATING_TERMINAL_WORKTREE_ID]: floating.terminals + }, + terminalLayoutsByTabId: {}, + agentStatusByPaneKey: {}, + settings: { experimentalNativeChat: true }, + toggleTabViewMode + }) + } + } +}) +vi.mock('./native-chat-shortcut', () => ({ + isMacPlatform: () => true, + matchesNativeChatToggleShortcut: () => true +})) +vi.mock('./native-chat-availability', () => ({ canToggleNativeChat: () => true })) +vi.mock('@/lib/native-chat-transcript-readability', () => ({ + isNativeChatTranscriptLocalReadable: () => true +})) +vi.mock('@/lib/connection-context', () => ({ getConnectionIdFromState: () => null })) + +import { useNativeChatToggleShortcut } from './use-native-chat-toggle-shortcut' + +function pressToggleFrom(target: HTMLElement): void { + target.dispatchEvent(new KeyboardEvent('keydown', { key: 'j', bubbles: true })) +} + +// The floating panel and the main window mount the same surface side by side; one key press +// must toggle only the surface it came from. +describe('native chat toggle with the floating panel on screen', () => { + afterEach(() => { + cleanup() + toggleTabViewMode.mockClear() + document.body.replaceChildren() + }) + + function mountBothSurfaces(): { panelButton: HTMLElement; mainButton: HTMLElement } { + renderHook(() => useNativeChatToggleShortcut('wt-main', true)) + renderHook(() => useNativeChatToggleShortcut(FLOATING_TERMINAL_WORKTREE_ID, true)) + const panel = document.createElement('div') + panel.setAttribute('data-floating-terminal-panel', '') + const panelButton = document.createElement('button') + panel.append(panelButton) + const mainButton = document.createElement('button') + document.body.append(panel, mainButton) + return { panelButton, mainButton } + } + + it('toggles only the floating tab for a key press inside the panel', () => { + const { panelButton } = mountBothSurfaces() + + pressToggleFrom(panelButton) + + expect(toggleTabViewMode.mock.calls).toEqual([[`${FLOATING_TERMINAL_WORKTREE_ID}-tab`]]) + }) + + it('toggles only the main window tab for a key press outside the panel', () => { + const { mainButton } = mountBothSurfaces() + + pressToggleFrom(mainButton) + + expect(toggleTabViewMode.mock.calls).toEqual([['wt-main-tab']]) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts b/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts index 085bb240ef6..6cc94609361 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts @@ -7,6 +7,7 @@ import { canToggleNativeChat } from './native-chat-availability' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { isMacPlatform, matchesNativeChatToggleShortcut } from './native-chat-shortcut' import { getConnectionIdFromState } from '@/lib/connection-context' +import { resolveKeyboardWorkspaceId } from '@/lib/floating-workspace-terminal-actions' import { isNativeChatTabWideFallbackSafe, resolveNativeChatActiveLayoutLeafId @@ -35,10 +36,10 @@ export function resolveNativeChatToggleShortcutDetectedAgent({ ) } -/** Toggles the active worktree's focused agent-terminal tab between the terminal - * and native chat views via the keyboard. Gated to the active worktree so only - * one listener acts at a time, and to agent terminals so the chord is inert on - * plain shells / non-terminal surfaces. */ +/** Toggles a workspace's focused agent-terminal tab between the terminal and native + * chat views via the keyboard. Gated to the surface the key press came from (main + * window or floating panel) so only one listener acts, and to agent terminals so + * the chord is inert on plain shells / non-terminal surfaces. */ export function useNativeChatToggleShortcut(worktreeId: string, isWorktreeActive: boolean): void { useEffect(() => { if (!isWorktreeActive) { @@ -50,6 +51,11 @@ export function useNativeChatToggleShortcut(worktreeId: string, isWorktreeActive return } const state = useAppStore.getState() + // Why: the floating panel and the main window can both be on screen; only the one the key + // press came from toggles. + if (resolveKeyboardWorkspaceId(e.target, state.activeWorktreeId) !== worktreeId) { + return + } const activeGroupId = state.activeGroupIdByWorktree[worktreeId] const group = (state.groupsByWorktree[worktreeId] ?? []).find((g) => g.id === activeGroupId) if (!group?.activeTabId) { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx index fd4445b68b6..257ca019eef 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx @@ -6,7 +6,7 @@ import type { StructuredAgentSessionState } from '../../../../shared/structured- const mocks = vi.hoisted(() => ({ call: vi.fn<(target: unknown, method: string, params: unknown) => Promise>(), - hold: vi.fn<(args: { enabled?: boolean }) => void>(), + hold: vi.fn((_args: { enabled?: boolean }) => ({ error: null })), read: vi.fn<(args: { isVisible?: boolean }) => void>(), outbox: vi.fn<(args: { fence: number | null; submissions: readonly unknown[] }) => void>(), send: vi.fn<(text: string) => boolean>(), diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx index d3f8fd9dc0f..d9956651626 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx @@ -393,8 +393,11 @@ describe('useStructuredAgentSession options', () => { const pending = new Promise((_resolve, rejectPromise) => { reject = rejectPromise }) + // Only the option write fails; a refused hold would surface on `error` for its own reason. mocks.call.mockImplementation((_target, method) => - method === 'agentSession.options' ? Promise.resolve(OPTIONS) : pending + method === 'agentSession.options' || method === 'agentSession.hold' + ? Promise.resolve(method === 'agentSession.options' ? OPTIONS : null) + : pending ) const { result, rerender } = renderHook(() => useStructuredAgentSession({ diff --git a/src/renderer/src/components/settings/KeybindingsFileActions.tsx b/src/renderer/src/components/settings/KeybindingsFileActions.tsx index f9b1288a73e..e19464fc45f 100644 --- a/src/renderer/src/components/settings/KeybindingsFileActions.tsx +++ b/src/renderer/src/components/settings/KeybindingsFileActions.tsx @@ -3,8 +3,7 @@ import { ChevronDown, Code2, ExternalLink, FileText, FolderOpen, RefreshCw } fro import { toast } from 'sonner' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { useAppStore } from '../../store' -import { TOGGLE_FLOATING_TERMINAL_EVENT } from '../../lib/floating-terminal' -import { isFloatingWorkspacePanelVisible } from '../../lib/floating-workspace-terminal-actions' +import { revealFloatingWorkspacePanel } from '../../lib/floating-workspace-panel-reveal' import { detectLanguage } from '../../lib/language-detect' import { Button } from '../ui/button' import { @@ -104,9 +103,7 @@ export function KeybindingsFileActions(): React.JSX.Element { cancelFloatingTerminalToggleFrame() floatingTerminalToggleFrameRef.current = requestAnimationFrame(() => { floatingTerminalToggleFrameRef.current = null - if (!isFloatingWorkspacePanelVisible()) { - window.dispatchEvent(new CustomEvent(TOGGLE_FLOATING_TERMINAL_EVENT)) - } + revealFloatingWorkspacePanel(useAppStore.getState()) }) } catch (error) { toast.error( diff --git a/src/renderer/src/components/sidebar/SidebarAgentsList.tsx b/src/renderer/src/components/sidebar/SidebarAgentsList.tsx index eab6379936c..22114f5b150 100644 --- a/src/renderer/src/components/sidebar/SidebarAgentsList.tsx +++ b/src/renderer/src/components/sidebar/SidebarAgentsList.tsx @@ -102,12 +102,14 @@ export default function SidebarAgentsList({ worktreesByRepo: storeData.worktreesByRepo, detectedWorktreesByRepo: storeData.detectedWorktreesByRepo, folderWorkspaces: storeData.folderWorkspaces, + floatingWorkspacePath: storeData.floatingWorkspacePath, defaultHostId: storeData.defaultHostId }), [ storeData.worktreesByRepo, storeData.detectedWorktreesByRepo, storeData.folderWorkspaces, + storeData.floatingWorkspacePath, storeData.defaultHostId ] ) diff --git a/src/renderer/src/components/tab-group/TabGroupPanel.tsx b/src/renderer/src/components/tab-group/TabGroupPanel.tsx index f6ad4b04d89..b7476c499ab 100644 --- a/src/renderer/src/components/tab-group/TabGroupPanel.tsx +++ b/src/renderer/src/components/tab-group/TabGroupPanel.tsx @@ -29,12 +29,18 @@ const EditorPanel = lazy(() => import('../editor/EditorPanel')) const EMPTY_GROUPS: readonly TabGroup[] = [] const EMPTY_CLIENT_HOSTED_ROWS: readonly ClientHostedBrowserRow[] = [] +/** Who renders this group's tab strip: the panel itself ('attached', the main surface), or the + * host shell ('external', the floating panel's titlebar strip). */ +export type TabGroupPanelTabStrip = 'attached' | 'external' + export default function TabGroupPanel({ groupId, worktreeId, isVisible, isFocused, hasSplitGroups, + tabStrip = 'attached', + markdownAnnotationsEnabled = true, touchesRightEdge, touchesLeftEdge, touchesBottomEdge = false, @@ -51,6 +57,9 @@ export default function TabGroupPanel({ isVisible: boolean isFocused: boolean hasSplitGroups: boolean + tabStrip?: TabGroupPanelTabStrip + /** Off for scratch workspaces (the floating panel) whose markdown is not a review surface. */ + markdownAnnotationsEnabled?: boolean touchesRightEdge: boolean touchesLeftEdge: boolean touchesBottomEdge?: boolean @@ -260,93 +269,97 @@ export default function TabGroupPanel({ > {/* Why: each split group needs its own tab row because multiple groups can show at once but the titlebar has only one shared center slot. */} {/* Why: macOS hiddenInset titleBarStyle makes -webkit-app-region: drag the only way to move the window from this tab row. */} -
-
- {/* Why: Electron drag hit-test respects no-drag only on DOM descendants, not z-index siblings, so this no-drag spacer keeps the collapsed left-sidebar's floating toggle clickable. */} - {reserveCollapsedSidebarHeaderSpace && !sidebarOpen ? ( + {/* Why absent (not hidden) for an 'external' host: the floating panel's titlebar owns the one + strip, and a mounted second strip would register duplicate sortables in the drag scope. */} + {tabStrip !== 'attached' ? null : ( +
+
+ {/* Why: Electron drag hit-test respects no-drag only on DOM descendants, not z-index siblings, so this no-drag spacer keeps the collapsed left-sidebar's floating toggle clickable. */} + {reserveCollapsedSidebarHeaderSpace && !sidebarOpen ? ( +
+ ) : null} +
{tabBar}
- ) : null} -
{tabBar}
-
-
- {isFocused ? ( - - ) : null} - {isFocused && hasSplitGroups ? ( - - - - - + + + + { + commands.closeGroup() }} - className={menuButtonClassName} > - - - - - - { - commands.closeGroup() - }} - > - - {translate( - 'auto.components.tab.group.TabGroupPanel.closePaneColumn', - 'Close split pane' - )} - - - - - {translate( - 'auto.components.tab.group.TabGroupPanel.9acaf92093', - 'Pane Actions' - )} - - - ) : null} + + {translate( + 'auto.components.tab.group.TabGroupPanel.closePaneColumn', + 'Close split pane' + )} + + + + + {translate( + 'auto.components.tab.group.TabGroupPanel.9acaf92093', + 'Pane Actions' + )} + + + ) : null} +
+ {/* Why: Electron drag hit-test respects no-drag only on DOM descendants, not z-index siblings, so this no-drag spacer keeps the floating right-sidebar toggle + window controls clickable. */} + {reserveClosedExplorerToggleSpace && !rightSidebarOpen ? ( +
+ ) : null}
- {/* Why: Electron drag hit-test respects no-drag only on DOM descendants, not z-index siblings, so this no-drag spacer keeps the floating right-sidebar toggle + window controls clickable. */} - {reserveClosedExplorerToggleSpace && !rightSidebarOpen ? ( -
- ) : null}
-
+ )}
diff --git a/src/renderer/src/components/tab-group/TabGroupSplitLayout.test.ts b/src/renderer/src/components/tab-group/TabGroupSplitLayout.test.ts index b0cadf8c4b0..22b04485b64 100644 --- a/src/renderer/src/components/tab-group/TabGroupSplitLayout.test.ts +++ b/src/renderer/src/components/tab-group/TabGroupSplitLayout.test.ts @@ -73,7 +73,10 @@ describe('TabGroupSplitLayout', () => { }) function getLayoutWrapper(element: ReturnType) { - const dndContext = asElement(element.props.children) + // The layout composes WorkspaceTabDragLayer (a render prop) around the chrome wrapper: + // invoke it to reach TabDragProvider > DndContext > [wrapper, DragOverlay, split overlay]. + const providerElement = asElement(invokeComponent(asElement(element))) + const dndContext = asElement(providerElement.props.children) return React.Children.toArray(dndContext.props.children as React.ReactNode)[0] } @@ -82,9 +85,12 @@ describe('TabGroupSplitLayout', () => { asElement(getLayoutWrapper(element)).props.children as React.ReactNode ) const splitBody = layoutWrapperChildren[1] - const splitNodeElement = React.Children.only( + const treeElement = React.Children.only( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the wrapper's split body renders exactly the tree element; props are untyped in this shallow-walk rig. asElement(splitBody).props.children as React.ReactNode ) + // TabGroupSplitNodeTree renders the recursive SplitNode; invoke both to reach the leaf/split. + const splitNodeElement = invokeComponent(asElement(treeElement)) return invokeComponent(asElement(splitNodeElement)) } diff --git a/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx b/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx index 77f04fb3023..2f0a182de25 100644 --- a/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx +++ b/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx @@ -1,267 +1,9 @@ -import { useCallback, useEffect, useRef, useState } from 'react' -import { DndContext, DragOverlay } from '@dnd-kit/core' import type { TabGroupLayoutNode } from '../../../../shared/tab-types' -import { useAppStore } from '../../store' -import TabGroupPanel from './TabGroupPanel' -import TabDragPreview from '../tab-bar/TabDragPreview' -import { TabDragProvider } from './tab-drag-context' -import TabPaneColumnSplitDragOverlay from './TabPaneColumnSplitDragOverlay' -import { type HoveredTabInsertion, useTabDragSplit } from './useTabDragSplit' - -const MIN_RATIO = 0.15 -const MAX_RATIO = 0.85 - -function ResizeHandle({ - direction, - onResizeStart, - onRatioChange -}: { - direction: 'horizontal' | 'vertical' - onResizeStart: () => void - onRatioChange: (ratio: number) => void -}): React.JSX.Element { - const isHorizontal = direction === 'horizontal' - const [dragging, setDragging] = useState(false) - const activeResizeCleanupRef = useRef<((updateDragging?: boolean) => void) | null>(null) - - useEffect( - () => () => { - activeResizeCleanupRef.current?.(false) - }, - [] - ) - - const onPointerDown = useCallback( - (event: React.PointerEvent) => { - event.preventDefault() - // Why: a second pointer must not steal or finalize the active gesture. - if (activeResizeCleanupRef.current) { - return - } - const handle = event.currentTarget - const container = handle.parentElement - if (!container) { - return - } - const firstPane = handle.previousElementSibling as HTMLElement | null - const secondPane = handle.nextElementSibling as HTMLElement | null - if (!firstPane || !secondPane) { - return - } - onResizeStart() - setDragging(true) - handle.setPointerCapture(event.pointerId) - // Why: measure outside pointermove so pane writes never force a readback. - let rect = container.getBoundingClientRect() - const resizeObserver = new ResizeObserver(() => { - rect = container.getBoundingClientRect() - }) - resizeObserver.observe(container) - let draggedRatio: number | null = null - - const onPointerMove = (moveEvent: PointerEvent): void => { - if (moveEvent.pointerId !== event.pointerId || !handle.hasPointerCapture(event.pointerId)) { - return - } - const ratio = isHorizontal - ? (moveEvent.clientX - rect.left) / rect.width - : (moveEvent.clientY - rect.top) / rect.height - const clamped = Math.min(MAX_RATIO, Math.max(MIN_RATIO, ratio)) - draggedRatio = clamped - // Why: direct style writes keep the drag off the store — a commit per - // pointermove published 60-120 global store updates/s against every - // subscriber (STA-3328). React re-applies identical flex on commit. - firstPane.style.flex = `${clamped} 1 0%` - secondPane.style.flex = `${1 - clamped} 1 0%` - } - - let cleaned = false - const cleanup = (updateDragging = true): void => { - if (cleaned) { - return - } - cleaned = true - resizeObserver.disconnect() - if (draggedRatio !== null) { - onRatioChange(draggedRatio) - } - if (updateDragging) { - setDragging(false) - } - try { - if (handle.hasPointerCapture(event.pointerId)) { - handle.releasePointerCapture(event.pointerId) - } - } catch { - // Best effort: unmount cleanup can run after Chromium has already dropped capture. - } - handle.removeEventListener('pointermove', onPointerMove) - handle.removeEventListener('pointerup', onPointerUp) - handle.removeEventListener('pointercancel', onPointerCancel) - handle.removeEventListener('lostpointercapture', onLostPointerCapture) - if (activeResizeCleanupRef.current === cleanup) { - activeResizeCleanupRef.current = null - } - } - - const onPointerUp = (upEvent: PointerEvent): void => { - if (upEvent.pointerId === event.pointerId) { - cleanup() - } - } - - const onPointerCancel = (cancelEvent: PointerEvent): void => { - if (cancelEvent.pointerId === event.pointerId) { - cleanup() - } - } - - const onLostPointerCapture = (lostEvent: PointerEvent): void => { - if (lostEvent.pointerId === event.pointerId) { - cleanup() - } - } - - handle.addEventListener('pointermove', onPointerMove) - handle.addEventListener('pointerup', onPointerUp) - handle.addEventListener('pointercancel', onPointerCancel) - handle.addEventListener('lostpointercapture', onLostPointerCapture) - activeResizeCleanupRef.current = cleanup - }, - [isHorizontal, onRatioChange, onResizeStart] - ) - - return ( -
- ) -} - -function SplitNode({ - node, - nodePath, - worktreeId, - focusedGroupId, - isWorktreeActive, - hasSplitGroups, - touchesTopEdge, - touchesRightEdge, - touchesLeftEdge, - touchesBottomEdge, - suppressLeftBorder, - suppressRightBorder, - suppressBottomBorder, - isTabDragActive, - hoveredTabInsertion -}: { - node: TabGroupLayoutNode - nodePath: string - worktreeId: string - focusedGroupId?: string - isWorktreeActive: boolean - hasSplitGroups: boolean - touchesTopEdge: boolean - touchesRightEdge: boolean - touchesLeftEdge: boolean - touchesBottomEdge: boolean - suppressLeftBorder: boolean - suppressRightBorder: boolean - suppressBottomBorder: boolean - isTabDragActive: boolean - hoveredTabInsertion: HoveredTabInsertion | null -}): React.JSX.Element { - const setTabGroupSplitRatio = useAppStore((state) => state.setTabGroupSplitRatio) - const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction) - - if (node.type === 'leaf') { - return ( -