mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
feat(floating-workspace): resolve the floating workspace so a structured session can run there
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user