diff --git a/src/main/agent-launch/agent-launch-executor.test.ts b/src/main/agent-launch/agent-launch-executor.test.ts index 58a41c3a49d..ab6891073e7 100644 --- a/src/main/agent-launch/agent-launch-executor.test.ts +++ b/src/main/agent-launch/agent-launch-executor.test.ts @@ -398,20 +398,17 @@ describe('delivering a launch prompt to a terminal agent', () => { * decided here rather than offered to a host probe that cannot answer for it. */ 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 99711cc1293..45f43e2b12f 100644 --- a/src/main/agent-launch/agent-launch-mode.ts +++ b/src/main/agent-launch/agent-launch-mode.ts @@ -96,7 +96,6 @@ const BLOCKER_REASON: Record< > = { 'reused-terminal': 'reused_terminal', 'agent-without-structured-session': 'agent_without_structured_session', - 'floating-workspace': 'structured_unsupported_on_host', 'tui-launch-command': 'tui_launch_command', 'remote-execution-host': 'remote_execution_host', 'project-runtime': 'wsl_execution_runtime', 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..789be7b0ea8 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,8 @@ 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' +import { resolveFloatingTerminalCwd } from '../ipc/floating-workspace-directory' export class OrcaRuntimeWithPersistHeadlessTerminalTitle extends OrcaRuntimeWithMoveHeadlessMobileSessionTab { // Persist a manual terminal rename so a headless rebuild keeps the title @@ -197,6 +199,17 @@ 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)) { + const store = this.requireStore() + return { + worktree: this.floatingWorkspaceToResolvedWorktree( + await resolveFloatingTerminalCwd(store, { path: store.getSettings().floatingTerminalCwd }) + ), + 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 decc28aa45b..79312041b09 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 @@ -63,6 +63,23 @@ export class OrcaRuntimeWithResolveBrowserNetworkExecutionHostForWorktree extend : this.resolveEmulatorWorkspaceId(selector) } + protected floatingWorkspaceToResolvedWorktree(path: string): ResolvedWorktree { + const worktree = floatingWorkspaceToWorktree(path) + return { + ...worktree, + parentWorktreeId: null, + childWorktreeIds: [], + lineage: null, + git: { + path: worktree.path, + head: worktree.head, + branch: worktree.branch, + isBare: worktree.isBare, + isMainWorktree: worktree.isMainWorktree + } + } + } + protected folderWorkspaceToResolvedWorktree(folderWorkspace: FolderWorkspace): ResolvedWorktree { const worktree = folderWorkspaceToWorktree(folderWorkspace) return { diff --git a/src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts b/src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts index 6448cc3911d..50f5db7e4bc 100644 --- a/src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts +++ b/src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts @@ -23,6 +23,7 @@ import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { homedir } from 'node:os' import { join } from 'node:path' import { parseWslUncPath } from '../../shared/wsl-paths' +import { isFloatingWorkspaceId } from '../../shared/floating-workspace-worktree' import { parseWorkspaceKey } from '../../shared/workspace-scope' export class OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript extends OrcaRuntimeWithStopStructuredSessionProcess { @@ -98,7 +99,10 @@ export class OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript extends Orca 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. 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 5551c4de354..b2611f9dfec 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 @@ -34,7 +34,7 @@ describe('agent.launch with the real floating workspace resolver', () => { }) 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. @@ -60,20 +60,23 @@ describe('agent.launch with the real floating workspace resolver', () => { ) expect(scope).toHaveBeenCalledExactlyOnceWith(selector) - expect(createSupport).not.toHaveBeenCalled() - expect(structuredHost).not.toHaveBeenCalled() - expect(createTerminal).toHaveBeenCalledExactlyOnceWith( - `id:${FLOATING_TERMINAL_WORKTREE_ID}`, - { startupAgent: 'claude' } - ) - 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) { + // Why this changed: the floating workspace resolves to its configured directory, so the + // structured path is consulted for it like any other workspace. Kind no longer refuses. + expect(createSupport).toHaveBeenCalledWith(`id:${FLOATING_TERMINAL_WORKTREE_ID}`, 'claude') + } else { + expect(createSupport).not.toHaveBeenCalled() + expect(structuredHost).not.toHaveBeenCalled() + expect(createTerminal).toHaveBeenCalledExactlyOnceWith( + `id:${FLOATING_TERMINAL_WORKTREE_ID}`, + { startupAgent: 'claude' } + ) + expect(result).toMatchObject({ + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + outcome: { kind: 'terminal', handle: 'term_floating' }, + receipt: { mode: 'terminal', reason: 'user_default' } + }) + } }) }) }) diff --git a/src/renderer/src/lib/agent-launch-route-input.test.ts b/src/renderer/src/lib/agent-launch-route-input.test.ts index 2ea4c568243..752c53be8e7 100644 --- a/src/renderer/src/lib/agent-launch-route-input.test.ts +++ b/src/renderer/src/lib/agent-launch-route-input.test.ts @@ -216,14 +216,17 @@ describe('buildAgentLaunchRouteInput', () => { workspace: { kind: 'floating', worktreeId: FLOATING_TERMINAL_WORKTREE_ID } }) expect(input.workspaceKind).toBe('floating') + // Still skipped: floating has no project row, so there is no local runtime preference to read. expect(input.projectRuntime).toBeUndefined() expect(mocks.getLocalProjectExecutionRuntimeContext).not.toHaveBeenCalled() + // Feasible now: the workspace resolves to its configured directory, so a session can be + // filed under it. Skipping the project runtime is about the missing repo row, not a refusal. expect( structuredFeasibleFor(store(), { agent: 'codex', workspace: { kind: 'floating', worktreeId: FLOATING_TERMINAL_WORKTREE_ID } }) - ).toBe(false) + ).toBe(true) }) it('passes a draft prompt through and never turns it into a blocker', () => { diff --git a/src/renderer/src/lib/agent-launch-routing.test.ts b/src/renderer/src/lib/agent-launch-routing.test.ts index e3f7a65938d..8d88155dec9 100644 --- a/src/renderer/src/lib/agent-launch-routing.test.ts +++ b/src/renderer/src/lib/agent-launch-routing.test.ts @@ -104,19 +104,16 @@ describe('resolveAgentLaunchRoute', () => { expect(route({ executionHostId })).toBe('legacy-native-chat') }) - it.each(['git-worktree', 'folder'] as const)( - 'supports a local %s without widening floating-terminal scope', + // Floating joined this list: its configured directory resolves like any other workspace, so a + // session can be filed under it. Workspace kind no longer downgrades a launch on its own. + it.each(['git-worktree', 'folder', 'floating'] as const)( + 'resolves a structured session for a local %s', (workspaceKind) => { expect(route({ workspaceKind })).toBe('structured-native-chat') } ) - // Why floating is here and not with the structured kinds: it has no workspace a session can - // be filed under, but the chat view is a pane-level rendering the panel already hosts, so the - // chat default still applies — terminal-backed, not structured. - it('keeps floating, WSL, and repair-required launches terminal-backed', () => { - expect(route({ workspaceKind: 'floating' })).toBe('legacy-native-chat') - expect(route({ agent: 'claude', workspaceKind: 'floating' })).toBe('legacy-native-chat') + it('keeps WSL and repair-required launches terminal-backed', () => { expect( route({ projectRuntime: { diff --git a/src/shared/floating-workspace-worktree.ts b/src/shared/floating-workspace-worktree.ts new file mode 100644 index 00000000000..c087d7c83fa --- /dev/null +++ b/src/shared/floating-workspace-worktree.ts @@ -0,0 +1,62 @@ +import { FLOATING_TERMINAL_WORKTREE_ID } from './constants' +import type { Worktree } from './worktree/types' + +/** + * The floating workspace is a directory the user points Orca at, with no repo, worktree or folder + * row behind it. Anything that resolves a workspace to a filesystem location needs an answer for + * it, so this mints the same synthetic `Worktree` shape folder workspaces use. + * + * Its id stays the sentinel constant: the session journal and every status subject are keyed by + * workspace id, so inventing a second identity here would split one workspace into two. + * + * Minting and recognising it live together so the two cannot drift. + */ +export function isFloatingWorkspaceId(worktreeId: string | null | undefined): boolean { + return worktreeId === FLOATING_TERMINAL_WORKTREE_ID +} + +/** Accepts the bare sentinel or the `id:` selector the runtime resolves targets with. */ +export function isFloatingWorkspaceSelector(selector: string | null | undefined): boolean { + return ( + selector === FLOATING_TERMINAL_WORKTREE_ID || selector === `id:${FLOATING_TERMINAL_WORKTREE_ID}` + ) +} + +/** `path` is the resolved floating directory; the caller owns resolving it from settings. */ +export function floatingWorkspaceToWorktree(path: string): Worktree { + return { + id: FLOATING_TERMINAL_WORKTREE_ID, + // Why: no project group stands behind it, unlike a folder workspace. Readers that resolve a + // display name must test the id, not this slot. + repoId: FLOATING_TERMINAL_WORKTREE_ID, + displayName: 'Floating workspace', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + linkedBitbucketPR: null, + linkedAzureDevOpsPR: null, + linkedGiteaPR: null, + linkedWorkItem: null, + linkedTaskSourceContext: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + manualOrder: 0, + lastActivityAt: 0, + createdAt: 0, + pendingFirstAgentMessageRename: false, + + diffComments: [], + path, + head: '', + branch: '', + isBare: false, + isSparse: false, + isMainWorktree: false, + hostId: 'local' + } +} diff --git a/src/shared/structured-native-chat-launch-route.test.ts b/src/shared/structured-native-chat-launch-route.test.ts index 21b52a83b6e..d8e7ac089fe 100644 --- a/src/shared/structured-native-chat-launch-route.test.ts +++ b/src/shared/structured-native-chat-launch-route.test.ts @@ -60,7 +60,6 @@ describe('per-launch structured feasibility', () => { ['a reused PTY agent', { reusesTerminal: true }, 'reused-terminal'], ['grok', { agent: 'grok' }, 'agent-without-structured-session'], ['openclaude', { agent: 'openclaude' }, 'agent-without-structured-session'], - ['a floating workspace', { workspaceKind: 'floating' }, 'floating-workspace'], ['a custom TUI launch command', { requiresTuiLaunchCommand: true }, 'tui-launch-command'], ['an SSH host', { executionHostId: 'ssh:host-a' }, 'remote-execution-host'], ['a missing capability', { hostCapabilities: [] }, 'runtime-capability'], @@ -112,7 +111,12 @@ describe('per-launch structured feasibility', () => { ).toEqual({ supported: false, blocker: 'project-runtime' }) }) - it('supports a folder workspace without widening floating scope', () => { - expect(support({ workspaceKind: 'folder' })).toEqual({ supported: true }) - }) + // Why floating is supported: its configured directory resolves like any other workspace, so a + // session can be filed under it. Workspace kind no longer refuses anything on its own. + it.each(['folder', 'floating', 'git-worktree'] as const)( + 'supports a local %s workspace', + (workspaceKind) => { + expect(support({ workspaceKind })).toEqual({ supported: true }) + } + ) }) diff --git a/src/shared/structured-native-chat-launch-route.ts b/src/shared/structured-native-chat-launch-route.ts index 5ae855ea47e..884d70b3fe2 100644 --- a/src/shared/structured-native-chat-launch-route.ts +++ b/src/shared/structured-native-chat-launch-route.ts @@ -24,7 +24,6 @@ export type NativeChatDefaultSettings = Pick< export type StructuredNativeChatBlocker = | 'reused-terminal' | 'agent-without-structured-session' - | 'floating-workspace' /** The agent's launch command is overridden, or the launch names its own working directory: * a process shape only a PTY can produce. The configured *arguments* are not read here — * they are a terminal concern the structured transports do not share a vocabulary with. */ @@ -83,9 +82,6 @@ export function resolveStructuredNativeChatSupport( if (!isAgentSessionHandleProvider(input.agent)) { return { supported: false, blocker: 'agent-without-structured-session' } } - if (input.workspaceKind === 'floating') { - return { supported: false, blocker: 'floating-workspace' } - } if (input.requiresTuiLaunchCommand === true) { return { supported: false, blocker: 'tui-launch-command' } }