feat(floating-workspace): render the floating panel through the shared workspace surface (#22302)

* docs(floating-workspace): plan structured native chat in the floating panel

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

* docs(floating-workspace): plan the unification onto the shared workspace surface

* chore(floating-workspace): keep the unification plan out of the branch

* test(floating-workspace): pin the structured route on capability, not workspace kind

The host half removed the 'floating-workspace' blocker; this pin still asserted it and went red
on the merge. Floating now routes like any workspace, so with capabilities un-negotiated the
resolver answers 'runtime-capability-unknown'.

* feat(floating-workspace): resolve the floating workspace's renderer row without a worktree record

The renderer had no Worktree row for the floating id: markdown creation, tab-bar path entry,
native-chat file links, attachments and model discovery all resolve a workspace through
getKnownWorktreeById and silently fail for it. The host already answers with a synthetic row
minted from the resolved floating directory; this gives the renderer the same single seam.

- The repo slice stores the host-resolved floating directory (floatingWorkspacePath); the panel's
  existing cwd resolution now writes it there instead of panel-local state, so there is one copy.
- findKnownWorktreeById mints the shared floatingWorkspaceToWorktree row from that path, local
  host only, cached while the directory is unchanged — mirroring the folder-workspace branch it
  sits beside.
- No consumer changes needed: every row-dependent path already goes through this resolver.

* feat(tabs): scope editor and browser activation to the owning workspace

Shared editor/browser activation always wrote global selection (activeFileId,
activeBrowserTabId, activeTabType). The floating panel is an overlay above the active
workspace, and its active tab must never become global selection — the invariant
documented in agent-auto-ack-targets.ts. Group-scoped activation is a prerequisite for
rendering the floating workspace through the shared tab-group surface.

- setActiveFile and setActiveBrowserTab take an optional target workspace, mirroring
  setActiveTabType: global selection moves only when the target IS the globally active
  workspace; per-workspace state always updates. Omitted keeps legacy behavior.
- The shared tab-group activation/creation commands pass their workspace, which is a
  no-op for the main surface (hidden surfaces are inert, so commands only fire on the
  active workspace) and confines floating activation to floating state.
- Terminal setActiveTab was already owner-scoped; now pinned by test.

* refactor(tab-group): separate the workspace drag scope and group tree from main-window chrome

Preparation for hosting the floating workspace on the shared surface, whose shell owns its own
titlebar tab strip and cannot mount the main chrome:

- WorkspaceTabDragLayer: the one dnd-kit scope for a workspace's strips and tree, extracted from
  TabGroupSplitLayout as a render-prop layer so a host can put shell-owned chrome (a titlebar
  strip) inside the same drag scope as the tree.
- TabGroupSplitNodeTree: the chrome-free recursive group tree, with an explicit tabStrip
  composition point on TabGroupPanel ('attached' keeps today's per-group strip; 'external' hosts
  render no per-group strip at all — a hidden one would register duplicate drag sortables).
- WorkspacePaneOverlayLayers: the retained pane hosts extracted from WorktreeSplitSurface so any
  tree host mounts the same pane lifecycle stack.
- TerminalPaneOverlayLayer gains ownsNativeChatToggleShortcut, following the existing rule that
  only one live workspace registers the chord.

TabGroupSplitLayout and WorktreeSplitSurface recompose these parts; the main surface's DOM is
unchanged.

* feat(floating-workspace): decide the panel's emptiness and layout atomically

An empty floating workspace has no tab-group layout at all (the slice starts empty and
hydration skips tab-less workspaces), while the shared group tree requires one. This model
resolves the two together: the panel shows its empty state exactly until the first tab exists,
and mounts the tree only with a layout plus a live focused group (stored focus, else the group
with an active tab, else the first group — matching the shell chrome's focused-group policy).

* feat(floating-workspace): render the floating panel through the shared workspace surface

The panel previously projected exactly one tab group and hand-rendered its panes, so any tab
that left that group — 'Move Tab to Split' creates a second group — stayed alive in state but
vanished from the panel. The body now mounts the shared group tree and retained pane overlay
stack for the floating workspace id, so every group renders, splits work, and structured chat
finally has a floating surface.

- One drag scope: WorkspaceTabDragLayer wraps the titlebar strip AND the body tree, so exactly
  one DndContext owns floating tab drag; strip drops reorder, body-edge drops split. The
  floating-only FloatingWorkspaceTabDragContext is deleted.
- The titlebar keeps the panel's single TabBar, now fed by the shared per-group workspace model
  of the focused group (tabStrip='external' keeps per-group strips out of the shell), with
  workspace-scoped activation commands so global selection never moves.
- Panes render through WorkspacePaneOverlayLayers: terminals gate on the resolved cwd and a
  settled viewport exactly as before; browser guests ride the shared retention layer; agent
  session tabs appear in the strip and render through the structured overlay.
- The empty state renders exactly until the first resolvable tab exists (stale unified entries
  still show it), per the atomic layout/emptiness model.
- Deleted: the single-group projection, the hand-written pane maps, FloatingBrowserSlot, and
  the floating pane-handle registry.

Known behavior deltas, both narrowing floating onto main-workspace semantics: Cmd/Ctrl+W with
panel (not terminal) focus closes the active tab rather than one pane of a multi-pane tab, and
a pane-owned close of the last tab no longer arms the panel focus reclaim (strip and shortcut
closes still do).

* test: re-register moved pane-ownership sites with the extraction

The retained overlay stack moved into WorkspacePaneOverlayLayers and the floating chrome model
became a guest-paint consumer; the structured-pane layering pin and the browser-guest retention
census both name files, so they follow the seam.

* fix(floating-workspace): mount the shared group tree in a flex frame so panes own their height

The tree's nodes size themselves as flex items, and the floating shell mounted
them in a block container, so every group body — and every pane anchored to
one via anchor-size() — measured 0px tall. Wrap the tree and overlay layers in
the same 'absolute inset-0 flex' frame the worktree split surface uses, record
the host contract on the tree, pin the frame in the panel's element-tree test,
and add a real-renderer e2e spec that measures group-body and pane boxes for
the single-group and split layouts (jsdom computes no layout, which is how the
0px regression passed 50k tests).

* fix(runtime): import the floating worktree minting the structured create path calls

floatingWorkspaceToResolvedWorktree called floatingWorkspaceToWorktree without
importing it. The module is @ts-nocheck (mechanically split runtime), so tsc
never saw the missing import and it only failed at call time — the structured
createSupport probe for the floating workspace threw a ReferenceError. Add the
import and a test that runs the real method, which fails on the missing import
where the typechecker cannot.

* fix(browser-pane): resolve floating and folder workspace paths in notebook navigation

navigateBrowserPageToUrl looked the workspace up in allWorktrees(), which has
no row for folder or floating workspaces, so their notebook navigations ran
with an undefined workspace path. Use getKnownWorktreeById, the same resolver
every other consumer was moved to.

* fix(tabs): keep a workspace's tab selection out of the main window's global selection

* refactor(floating-workspace): launch and close panes like every other workspace

* refactor(tab-group): let the surface that mounts tab groups describe its own chrome

* refactor(floating-workspace): render the floating panel through the shared workspace surface

* fix(floating-workspace): resolve the floating directory once on the host, and keep the controls' inset

* test(floating-workspace): pin the unified floating panel against the shared surface

* fix(floating-workspace): read the floating directory's paths through the app environment

The runtime now resolves the floating directory itself, and the headless
runtime has no Electron app, so the Electron path lookup threw there.

* fix(floating-workspace): keep the main window's frame and shortcuts out of the floating panel

The shared layout drew the main window's 4px titlebar band and sidebar seam
inside the floating panel, and the chat-view toggle fired in every workspace
on screen, so one key press flipped the main window's tab and the panel's.

* fix(floating-workspace): close floating tabs without taking over the main window

An unsaved floating note's save prompt made the floating panel the main
window's active worktree, and never appeared on a fresh launch because the
workbench that owns the prompt was not mounted. The prompt now shows a
floating note in the panel, and the workbench mounts while the panel holds
tabs.

Every close path now reads the same captured emptied-workspace reaction, so a
floating pane closing itself, its shell exiting, a group close, and an unsaved
note close all keep the panel's keyboard ownership, as a tab close already did.

* test(tab-group): pin that a dirty editor close carries its emptied reaction

* refactor(tabs): one rule for when a selection moves the main window's selection

Opening a file kept a floating-only exception while every other writer checked
whether the tab's workspace is the active one. Every writer now asks the same
question, so opening a file in a background worktree no longer points the main
window's editor selection into that worktree.

* refactor(floating-workspace): drop what the shared surface left unused

The terminal pane's imperative close handle lost its last consumer when the
floating panel stopped keeping its own pane registry, and the floating and
folder workspaces built the same lineage-free resolved worktree twice.

* test(floating-workspace): type test fixtures without casts and drop the removed pane ref

* fix(floating-workspace): keep panel focus when a close lands after the panel emptied

Saving the last floating note closes it after an awaited write, so the panel
could render empty before the close armed its keyboard reclaim, leaving the
intent armed with nothing left to consume it. The panel now re-checks whenever
the intent arms as well as when its tab count changes.

A pinned tab's confirmation took focus before the close read panel ownership;
the dispatcher now captures it before any prompt.

* refactor(floating-workspace): finish what the shared surface left behind

- The floating panel no longer accepts AI vault session drops: a session
  cannot resume into it, and its window-level drop handler fired beside the
  main window's.
- The last global-selection writers that spelled the active-workspace check
  inline now use the shared rule.
- Remove the save-dialog hook and item counter the old floating body used, and
  correct comments that said the workbench only mounts once a workspace exists.

* fix(floating-workspace): keep a floating leaf detach from changing the main window's tab type

* fix(floating-workspace): give floating chats their workspace identity and directory

* fix(floating-workspace): read one visibility fact for chat attention and auto-ack

* docs(floating-workspace): correct comments the shared surface made stale

* fix(attention): one on-screen rule for every tab's unread marker, main window and floating panel

* fix(agent-session): pin the folder a session launched in; floating chats resume there or refuse

* fix(native-chat): resolve a floating chat's file links, images and skills in its pinned folder

* fix(agent-session): open a floating chat's terminal handoff in its pinned folder

* fix(agent-session): surface refusal text on chat open, require the launch-directory rule, and keep bookkeeping from gating a launch

* fix(floating-workspace): wait for a floating chat's pinned folder and read panel visibility from the store

* test(floating-workspace): assert the toggle event without a cast

* fix(native-chat): show why a chat could not be opened instead of dropping the host's refusal

* fix(floating-workspace): close chat tabs through shared commands

* fix(i18n): remove obsolete floating editor loading key

* fix(floating-workspace): preserve tab ownership on activation and browser close

* fix(floating-workspace): keep terminal activation and focus reclaim in sync

* fix(readme): point translations at tracked media

* fix(floating-workspace): retain split surface with an empty focused group

* docs(tabs): describe workspace-owned activation accurately

* fix(floating-workspace): finish the main merge and name the pin a launch directory

Port what the merge left on main's newer shapes:
- The floating chat's "folder is gone" refusal becomes a typed reason,
  launchFolderMissing, worded like every other start refusal (copy, desktop
  words, five translations). Older clients drop the unknown reason and show
  the code's generic words.
- The PR's launch-folder tests run on main's journal-database record store;
  the attach-level case is a row in the pre-spawn refusal table.
- Tests main added since the PR's base learn the PR's close-queue settling,
  workspace-scoped notebook lookup and tab-stamped chat host.
- Move the store's visible-tab-index reads into the tab table, so the record
  store stays under the line cap with the pin writer in it.
- Drop a duplicate Worktree import the merge left in a @ts-nocheck file.

Rename the persisted field, the status summary field and the renderer slice
from workspacePath to launchDirectory: it records the folder a session first
launched in, not its workspace's path, and it has not shipped yet.

* Fix floating chat visibility, closes, and directory pinning

* fix(i18n): drop the floating save dialog's strings with the dialog

The floating panel now closes through the shared unsaved-changes prompt, so
its own dialog's six strings had no caller and failed the runtime-catalog
check.

* fix(native-chat): pin floating founders and retain pinned editors

* test: align auto-ack fixtures with unified tab visibility

* test: give the forget-status attach fixture a location

Attach now reads params.location to decide whether a new record is a floating founder, so the partial fixture needs the location real attach params always carry.

* test: type the forget-status attach fixture instead of casting it

The cast tripped the changed-code type-assertion gate.

* fix(native-chat): read a floating chat's model defaults in the folder it was created in

The model picker read the floating setting's current folder, so a chat
pinned to another folder could show that folder's project default.

* test(cross-version): give the stub host the record store the catalog read now checks

* test(claude): give main's thinking-display launch stub the store shape the resolver takes
This commit is contained in:
Brennan Benson
2026-10-06 11:37:18 -07:00
committed by GitHub
parent e63e4f80da
commit defd85a9eb
249 changed files with 6252 additions and 2883 deletions
+2
View File
@@ -30,6 +30,8 @@ export const AGENT_SESSION_FAILURE_COPY = {
accountSwitchInProgress: 'A Claude account switch is in progress. Try again after it finishes.',
managedAccountUnsupported:
'While a Claude account is added in WSL, Claude chats need a Windows Claude account.',
launchFolderMissing:
'The folder this chat ran in no longer exists. Restore it to continue this chat.',
chooseClaudeAccount: 'Choose or add one in Claude Accounts settings.',
chooseClaudeAccountThenRunCommand:
'Choose or add one in Claude Accounts settings, then run /{{command}} again.',
@@ -209,6 +209,7 @@ const FAILURE_SENTENCES = {
joinSentences([say('historyTooLarge'), say('startNewChat')]),
managedAccountEnvOverride: (_context, _fact, _surface, say) => say('managedAccountEnvOverride'),
accountSwitchInProgress: (_context, _fact, _surface, say) => say('accountSwitchInProgress'),
launchFolderMissing: (_context, _fact, _surface, say) => say('launchFolderMissing'),
managedAccountUnsupported: (context, _fact, _surface, say) =>
joinSentences([
say('managedAccountUnsupported'),
+1
View File
@@ -23,6 +23,7 @@ export const AGENT_SESSION_FAILURE_KINDS = [
'managedAccountEnvOverride',
'accountSwitchInProgress',
'managedAccountUnsupported',
'launchFolderMissing',
'providerExited',
'restartFailed',
'providerRejected',
+5
View File
@@ -137,6 +137,9 @@ export type AgentSessionRecord = {
provider: string
providerHandleChain: AgentSessionProviderHandleLink[]
accountHome: AgentSessionAccountHome
/** The directory the provider first launched in, in the execution host's path syntax. Floating
* sessions resume here; worktree and folder ids still resolve by id to their durable place. */
launchDirectory?: string
/** Provider options the user chose, replayed whenever a new owner starts the session. */
options?: Record<string, string>
rewind?: AgentSessionRewindRecord
@@ -354,6 +357,8 @@ export function isPersistedAgentSessionRecord(
isAgentSessionExecutionLocation(record.location) &&
isStructuredAgentId(record.provider) &&
isAgentSessionAccountHome(record.accountHome) &&
(record.launchDirectory === undefined ||
isBoundedString(record.launchDirectory, MAX_PATH_LENGTH)) &&
(record.options === undefined || isAgentSessionOptions(record.options)) &&
(record.rewind === undefined || isAgentSessionRewindRecord(record.rewind)) &&
(record.conversationCommand === undefined ||
@@ -51,6 +51,8 @@ export const AGENT_SESSION_REFUSAL_REASONS = {
'accountSwitchInProgress',
/** A Claude account is added in WSL and no Windows one is selected, which a chat can't run under. */
'managedAccountUnsupported',
/** A floating chat resumes only in the folder it ran in, and that folder is gone. */
'launchFolderMissing',
/** The agent started, then Orca could not open the chat's conversation for it. */
'attachFailed'
],
@@ -96,6 +96,7 @@ const REASON_WORDS = {
managedAccountEnvOverride: { fact: 'managedAccountEnvOverride', action: 'actFirst' },
accountSwitchInProgress: { fact: 'accountSwitchInProgress', action: 'wait' },
managedAccountUnsupported: { fact: 'managedAccountUnsupported', action: 'actFirst' },
launchFolderMissing: { fact: 'launchFolderMissing', action: 'actFirst' },
attachFailed: codeWords('retry')
},
agent_session_ownership_unknown: {
+3
View File
@@ -266,6 +266,9 @@ export type AgentSessionStatusSummary = {
* the background-task channel. */
children?: AgentChildWorkView[]
providerSession?: AgentProviderSessionMetadata
/** Host-path directory the session is held to regardless of its workspace's current directory
* (a floating chat's pinned folder). Absent means resolve the workspace id; older hosts omit it. */
launchDirectory?: string
updatedAt: number
/** When the session's own agent entered `status`, dated by its own lifecycle edges and never by
* row activity: `updatedAt` also moves for a subagent's rows. Absent from older hosts, and when
+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'
}
}
@@ -90,6 +90,7 @@ const KIND_CATEGORY = {
managedAccountEnvOverride: 'startFailed',
accountSwitchInProgress: 'startFailed',
managedAccountUnsupported: 'startFailed',
launchFolderMissing: 'startFailed',
restartFailed: 'startFailed',
providerRejected: 'content',
attachmentInvalid: 'content',
@@ -115,6 +116,7 @@ const KIND_VERDICT = {
managedAccountEnvOverride: 'failure',
accountSwitchInProgress: 'failure',
managedAccountUnsupported: 'failure',
launchFolderMissing: 'failure',
restartFailed: 'failure',
providerRejected: 'failure',
attachmentInvalid: 'failure',
@@ -63,7 +63,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 start directory', { startsOutsideWorkspaceRoot: true }, 'custom-start-directory'],
['an SSH host', { executionHostId: 'ssh:host-a' }, 'remote-execution-host'],
['a missing capability', { hostCapabilities: [] }, 'runtime-capability'],
@@ -106,9 +105,14 @@ 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 })
}
)
})
describe('agents beyond Claude and Codex', () => {
@@ -29,7 +29,6 @@ export type NativeChatDefaultSettings = Pick<
export type StructuredNativeChatBlocker =
| 'reused-terminal'
| 'agent-without-structured-session'
| 'floating-workspace'
/** The launch names a start directory outside its workspace, which only a PTY can apply. The
* configured launch command and arguments are not read: they apply to terminal launches only. */
| 'custom-start-directory'
@@ -106,9 +105,6 @@ export function resolveStructuredNativeChatSupport(
if (!builtInAgent && !input.hostStructuredAgents?.includes(input.agent)) {
return { supported: false, blocker: 'agent-without-structured-session' }
}
if (input.workspaceKind === 'floating') {
return { supported: false, blocker: 'floating-workspace' }
}
if (input.startsOutsideWorkspaceRoot === true) {
return { supported: false, blocker: 'custom-start-directory' }
}
+3 -3
View File
@@ -1,9 +1,9 @@
/**
* Which kind of workspace a launch lands in, read from the workspace's own id.
*
* The three kinds are not interchangeable to a launch: only a git worktree and a folder workspace
* have somewhere a structured session can live, and the floating terminal — a sentinel with no
* backing repo, worktree or folder row — can host a PTY and nothing else.
* The three kinds are not interchangeable to a launch: the floating workspace is a sentinel with no
* backing repo, worktree or folder row, so its directory comes from the floating directory setting
* and it always runs on the local host.
*
* It lives in `shared` because both sides of the launch ask the same question: the renderer when a
* user opens an agent tab, and the host when it resolves an `agent.launch` target. A host must