feat(floating-workspace): resolve the floating workspace so a structured session can run there

This commit is contained in:
Brennan Benson
2026-09-21 16:27:14 -07:00
parent 410d5ece65
commit 3fc38da5c6
11 changed files with 137 additions and 42 deletions
@@ -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 () => {
@@ -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',
@@ -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
@@ -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 {
@@ -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.
@@ -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' }
})
}
})
})
})
@@ -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', () => {
@@ -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: {
+62
View File
@@ -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'
}
}
@@ -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 })
}
)
})
@@ -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' }
}