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
@@ -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 () => {
+2 -5
View File
@@ -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.
@@ -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 }),
+5 -1
View File
@@ -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<AgentSessionSlashCommand[]> {
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')]),
@@ -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<typeof createClaudeStructuredLaunchResolver> {
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 }),
@@ -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<boolean> = 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/<id>` — 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 }),
@@ -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<AgentSessionRecordStore, 'getRecord' | 'pinLaunchDirectory'>
resolveWorkspacePath: (workspaceId: string) => Promise<string>
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({
@@ -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<string, string> = { 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
})
@@ -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<AgentSessionRecordStore, 'getRecord' | 'pinLaunchDirectory'>
/** 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<string>
@@ -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<string, string> } : {}),
// An empty chain is a session that has never proved a thread, so it
@@ -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}`)
})
})
+5 -3
View File
@@ -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<string> {
@@ -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')
@@ -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. */
@@ -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<AgentSessionMutationResult<AgentSessionAttachResult>> {
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,
@@ -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
@@ -26,7 +26,8 @@ const TYPED_START_REFUSALS = [
'historyTooLarge',
'managedAccountEnvOverride',
'accountSwitchInProgress',
'managedAccountUnsupported'
'managedAccountUnsupported',
'launchFolderMissing'
] as const satisfies readonly (AgentSessionFailureKind &
AgentSessionRefusalReason<'agent_session_operation_invalid'>)[]
@@ -201,9 +201,19 @@ function attachContext(
} as unknown as StructuredAgentSessionAttachContext
}
const attachParams = {
envelope: { sessionId: SESSION, clientOperationId: 'op-1' }
} as unknown as Parameters<typeof attachStructuredAgentSession>[2]
const attachParams: Parameters<typeof attachStructuredAgentSession>[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 () => {
@@ -133,6 +133,8 @@ export type StructuredAgentSessionHostDeps = {
resolveLaunchEnv?: (
provider: AgentSessionRecord['provider']
) => Promise<Record<string, string> | undefined> | Record<string, string> | undefined
/** Execution-host path for a newly founded floating session. */
resolveWorkspacePath?: (workspaceId: string) => Promise<string>
now?: () => number
/** The idle sweep's period and window. Tests drive these; production takes the defaults. */
idleSweep?: { intervalMs?: number; idleMs?: number }
@@ -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 })
@@ -252,9 +252,10 @@ export class StructuredAgentSessionHost {
attach(
caller: StructuredAgentSessionCaller,
params: AgentSessionAttachParams
params: AgentSessionAttachParams,
options?: Parameters<typeof attachStructuredAgentSession>[3]
): Promise<SessionWire.AgentSessionMutationResult<SessionWire.AgentSessionAttachResult>> {
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. */
@@ -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',
@@ -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)
})
})
@@ -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')
})
})
@@ -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)
@@ -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()
}
}
@@ -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) &&
@@ -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<typeof NodeFsPromises>()
return {
...actual,
stat: async (...args: Parameters<typeof actual.stat>) => {
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<string> {
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)
})
})
@@ -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<AgentSessionRecordStore, 'pinLaunchDirectory'>
/** Absolute path of a workspace on this host, by the workspace's current directory policy. */
resolveWorkspacePath: (workspaceId: string) => Promise<string>
}
async function isDirectory(path: string): Promise<boolean> {
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<string> {
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
}
@@ -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
}
@@ -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
}
+16 -15
View File
@@ -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<AgentSessionRecord> =>
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)
@@ -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', () => {
@@ -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
@@ -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,
@@ -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<AgentSessionAttachParams> {
}): Promise<AgentSessionAttachParams & { hostLaunchDirectory?: string }> {
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
}
/**
@@ -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
@@ -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<string> {
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<ResolvedTerminalWorkspaceLaunchTarget> {
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
@@ -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<string, unknown>) {
function createCodexIntentRuntime(
settings: Record<string, unknown>,
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<string, unknown>) {
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({})
@@ -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
})
@@ -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' }
})
}
})
})
})
@@ -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<AgentSessionMutationResult<AgentSessionAttachResult>> {
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
}
@@ -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(() => {
@@ -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)
}
@@ -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. */
@@ -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']) =>
+1 -5
View File
@@ -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)
@@ -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({
@@ -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()
@@ -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<typeof useFloatingWorkspacePanel>
/**
* 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<void>
@@ -110,7 +97,7 @@ export function useFloatingWorkspacePanel() {
persistFloatingTerminalPanelOpen(resolvedOpen)
}
},
[enabled, open, rememberReturnFocus, restoreReturnFocus]
[enabled, open, setOpen, rememberReturnFocus, restoreReturnFocus]
)
const openMaximized = useCallback((): void => {
@@ -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}
/>
<TerminalPaneOverlayLayer
<WorkspacePaneOverlayLayers
worktreeId={worktreeId}
worktreePath={worktreePath}
isWorktreeActive={isVisible}
coldParkTerminalPanes={shouldColdParkTerminalPanes}
isForceParked={isForceParked}
isVisible={isVisible}
shouldMeasureHiddenWorktree={shouldMeasureHiddenWorktree}
shouldColdParkTerminalPanes={shouldColdParkTerminalPanes}
isForceParked={isForceParked}
activityTerminalPortals={activityTerminalPortals}
backgroundMountTabIds={backgroundMountTabIds}
activationDeferredMountTabIds={activationDeferredMountTabIds}
/>
<RetainedBrowserPaneOverlayLayer
worktreeId={worktreeId}
isWorktreeActive={isVisible}
mountEligible={shouldMountRetainedBrowserOverlay({
mountRetainedBrowserOverlay={shouldMountRetainedBrowserOverlay({
isWorktreeVisible: isVisible,
hasDeferredBackgroundMounts: backgroundMountTabIds !== null,
needsBrowserGuestPaint
})}
mountEmulatorOverlay={isVisible || backgroundMountTabIds === null}
/>
{isVisible || backgroundMountTabIds === null ? (
<EmulatorPaneOverlayLayer worktreeId={worktreeId} isWorktreeActive={isVisible} />
) : null}
<StructuredAgentSessionPaneOverlayLayer
worktreeId={worktreeId}
isWorktreeActive={isVisible}
/>
<AiVaultSessionDropLayer worktreeId={worktreeId} enabled={isVisible} />
</div>
)
})
@@ -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<string> | null
activationDeferredMountTabIds: ReadonlySet<string> | 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 (
<>
<TerminalPaneOverlayLayer
worktreeId={worktreeId}
worktreePath={worktreePath}
isWorktreeActive={isVisible}
coldParkTerminalPanes={shouldColdParkTerminalPanes}
isForceParked={isForceParked}
shouldMeasureHiddenWorktree={shouldMeasureHiddenWorktree}
activityTerminalPortals={activityTerminalPortals}
backgroundMountTabIds={backgroundMountTabIds}
activationDeferredMountTabIds={activationDeferredMountTabIds}
ownsNativeChatToggleShortcut={ownsNativeChatToggleShortcut}
/>
<RetainedBrowserPaneOverlayLayer
worktreeId={worktreeId}
isWorktreeActive={isVisible}
mountEligible={mountRetainedBrowserOverlay}
/>
{mountEmulatorOverlay ? (
<EmulatorPaneOverlayLayer worktreeId={worktreeId} isWorktreeActive={isVisible} />
) : null}
<StructuredAgentSessionPaneOverlayLayer
worktreeId={worktreeId}
isWorktreeActive={isVisible}
/>
<AiVaultSessionDropLayer worktreeId={worktreeId} enabled={isVisible} />
</>
)
}
@@ -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
]
)
@@ -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)
@@ -26,7 +26,7 @@ function getActivityThreadExecutionHostId(
type ActivityThreadWorkspaceCatalog = Pick<
AppState,
'worktreesByRepo' | 'detectedWorktreesByRepo' | 'folderWorkspaces'
'worktreesByRepo' | 'detectedWorktreesByRepo' | 'folderWorkspaces' | 'floatingWorkspacePath'
> & { defaultHostId: ExecutionHostId }
function readActivityThreadWorkspaceCatalog(): ActivityThreadWorkspaceCatalog {
@@ -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),
@@ -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<string, readonly BrowserTabState[]>
@@ -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(
<BrowserPaneOverlayLayer worktreeId={floating} isWorktreeActive />
)
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 = {
@@ -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'
@@ -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
@@ -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,
@@ -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<EditorRequestFileCloseDetail, 'onClosed'>
): void {
window.dispatchEvent(
new CustomEvent<EditorRequestFileCloseDetail>(ORCA_EDITOR_REQUEST_FILE_CLOSE_EVENT, {
detail: { fileId }
detail: { fileId, ...options }
})
)
}
@@ -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 }
}
@@ -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 <webview> 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(
<FloatingBrowserSlot browserTab={makeBrowserTab('floating-browser-1')} isActive />
)
})
// 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(
<FloatingBrowserSlot browserTab={makeBrowserTab('floating-browser-2')} isActive />
)
})
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(<FloatingBrowserSlot browserTab={makeBrowserTab('floating-3')} isActive />)
})
expect(paneScopes.at(-1)).toBe('owned-target')
expect(container.querySelector('[data-browser-overlay-tab-id="floating-3"]')).not.toBeNull()
act(() => {
root!.render(
<FloatingBrowserSlot browserTab={makeBrowserTab('floating-3')} isActive={false} />
)
})
expect(paneScopes.at(-1)).toBe('inactive')
})
})
@@ -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 <webview> 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 (
<div
className="relative flex min-h-0 flex-1 flex-col"
data-browser-overlay-tab-id={browserTab.id}
>
<div ref={setSlotViewportRef} className="absolute inset-0 flex min-h-0 flex-col" />
<BrowserPane
browserTab={browserTab}
isActive={isActive}
// Why: floating chrome chords must not also fire the focused split's browser, nor it ours.
chromeShortcutScope={isActive ? 'owned-target' : 'inactive'}
/>
</div>
)
}
@@ -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<typeof import('react')>('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
const { createReactHookOverrides } = await import('./floating-terminal-panel-test-module-mocks')
@@ -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<typeof import('react')>('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)
@@ -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<typeof import('react')>('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)
@@ -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<typeof import('react')>('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')
})
})
@@ -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<typeof import('react')>('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,
@@ -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<typeof import('react')>('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)
})
})
@@ -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<typeof useFloatingTerminalPanelController>): 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}
>
<div className="relative flex h-full w-full min-h-0 flex-col overflow-hidden rounded-lg border border-black/14 bg-card dark:border-white/14">
<div
className="flex h-9 shrink-0 cursor-grab items-center border-b border-border bg-[var(--bg-titlebar,var(--card))] active:cursor-grabbing"
data-floating-terminal-shortcut-surface
onPointerDown={handleDragStart}
onPointerMove={handleDragMove}
onPointerUp={handleDragEnd}
onPointerCancel={handleDragEnd}
onDoubleClick={handleTitlebarDoubleClick}
>
<FloatingWorkspaceTabDragContext enabled={open}>
<TabBar
tabs={terminalItems}
activeTabId={activeTerminalId}
worktreeId={FLOATING_TERMINAL_WORKTREE_ID}
expandedPaneByTabId={expandedPaneByTabId}
onActivate={activateFloatingItem}
onClose={closeFloatingItemConfirmed}
onCloseOthers={closeOthers}
onCloseToRight={closeToRight}
onCloseToLeft={closeToLeft}
onNewTerminalTab={() => 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"
/>
</FloatingWorkspaceTabDragContext>
<FloatingTerminalWindowControls
maximized={maximized}
onToggleMaximized={toggleMaximized}
onMinimize={() => onOpenChange(false)}
/>
</div>
<div
className="relative min-h-0 flex-1 overflow-hidden bg-background"
data-contextual-tour-target={
hasVisibleFloatingTabs ? 'floating-workspace-surface' : undefined
}
>
{/* 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 (
<div
key={`${tab.id}-${tab.generation ?? 0}`}
className={isActive ? 'absolute inset-0' : 'absolute inset-0 hidden'}
aria-hidden={!isActive}
>
<TerminalPane
ref={terminalPaneRegistry.getRefCallback(tab.id)}
tabId={tab.id}
worktreeId={FLOATING_TERMINAL_WORKTREE_ID}
cwd={cwd}
isActive={isActive}
// Why: the closed panel is only CSS-hidden, so gate
// visibility on `open` too. This routes the floating
// terminal through the standard hidden-terminal
// suspend/resume path: no live WebGL context (or glyph
// atlas to corrupt) while hidden, and the resume on
// reopen rebuilds the renderer from scratch.
isVisible={isActive && open}
onPtyExit={(ptyId, exitCode) => {
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)}
/>
</div>
)
})
: null}
{browserTabs.map((tab) => {
const isActive = tab.id === activeBrowserTab?.id
return (
<div
key={tab.id}
className={isActive ? 'absolute inset-0 flex' : 'absolute inset-0 hidden'}
aria-hidden={!isActive}
>
<FloatingBrowserSlot browserTab={tab} isActive={open && isActive} />
</div>
)
})}
{simulatorItems.map((tab) => {
const isActive = tab.id === activeTab?.id
return (
<div
key={tab.id}
className={isActive ? 'absolute inset-0 flex' : 'absolute inset-0 hidden'}
aria-hidden={!isActive}
>
<EmulatorPane tab={tab} worktreeId={tab.worktreeId} isActive={open && isActive} />
</div>
)
})}
{activeEditorFile ? (
<div className="absolute inset-0 flex min-h-0 min-w-0">
<Suspense
fallback={
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
{translate(
'auto.components.floating.terminal.FloatingTerminalPanel.d6b563ae24',
'Loading editor...'
)}
</div>
}
>
{/* Why: floating workspace markdown is scratch/local context,
not a repo review surface that should expose agent notes. */}
<EditorPanel
activeFileId={activeEditorFile.id}
activeViewStateId={activeEditorUnifiedId}
isVisible={open}
markdownAnnotationsEnabled={false}
{/* Why one drag layer around titlebar AND body: the titlebar strip's sortables and the
body tree's split/pane-body drop targets must live in the same dnd-kit scope, so a
floating tab drag can reorder in the strip or split against the tree — exactly one
DndContext owns floating tab drag. */}
<WorkspaceTabDragLayer worktreeId={FLOATING_TERMINAL_WORKTREE_ID} enabled={open}>
{({ isTabDragActive, hoveredTabInsertion, setDragRootNode }) => (
<div
ref={setDragRootNode}
className="relative flex h-full w-full min-h-0 flex-col overflow-hidden rounded-lg border border-black/14 bg-card dark:border-white/14"
>
<div
className="flex h-9 shrink-0 cursor-grab items-center border-b border-border bg-[var(--bg-titlebar,var(--card))] active:cursor-grabbing"
data-floating-terminal-shortcut-surface
onPointerDown={handleDragStart}
onPointerMove={handleDragMove}
onPointerUp={handleDragEnd}
onPointerCancel={handleDragEnd}
onDoubleClick={handleTitlebarDoubleClick}
>
<div className="flex h-full min-w-0 flex-1">
<TabBar
tabs={terminalItems}
activeTabId={
activeTab?.contentType === 'agent-session' ? activeTab.id : activeTerminalId
}
groupId={activeGroup?.id}
worktreeId={FLOATING_TERMINAL_WORKTREE_ID}
expandedPaneByTabId={expandedPaneByTabId}
onActivate={model.commands.activateTerminal}
onClose={closeFloatingItemConfirmed}
onCloseOthers={closeOthers}
onCloseToRight={closeToRight}
onCloseToLeft={closeToLeft}
onNewTerminalTab={() => 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"
/>
</Suspense>
</div>
<FloatingTerminalWindowControls
maximized={maximized}
onToggleMaximized={toggleMaximized}
onMinimize={() => onOpenChange(false)}
/>
</div>
) : null}
{!hasVisibleFloatingTabs ? (
<FloatingTerminalEmptyState
onNewTerminal={() => 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}
</div>
</div>
<div
className="relative min-h-0 flex-1 overflow-hidden bg-background"
data-contextual-tour-target={
hasVisibleFloatingTabs ? 'floating-workspace-surface' : undefined
}
>
{/* 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.
<div className="absolute inset-0 flex" data-floating-workspace-surface-frame>
<TabGroupSplitNodeTree
layout={surface.layout}
worktreeId={FLOATING_TERMINAL_WORKTREE_ID}
focusedGroupId={surface.focusedGroupId}
isWorktreeActive={open}
isTabDragActive={isTabDragActive}
hoveredTabInsertion={hoveredTabInsertion}
// Why external: the titlebar strip above is the panel's one tab row.
tabStrip="external"
// Why: floating workspace markdown is scratch/local context,
// not a repo review surface that should expose agent notes.
markdownAnnotationsEnabled={false}
rootTouchesBottomEdge={true}
/>
{/* 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. */}
<WorkspacePaneOverlayLayers
worktreeId={FLOATING_TERMINAL_WORKTREE_ID}
worktreePath={cwd && panelViewportSettled ? cwd : ''}
isVisible={open}
shouldMeasureHiddenWorktree={false}
shouldColdParkTerminalPanes={false}
isForceParked={false}
activityTerminalPortals={NO_ACTIVITY_TERMINAL_PORTALS}
backgroundMountTabIds={null}
activationDeferredMountTabIds={null}
mountRetainedBrowserOverlay={true}
mountEmulatorOverlay={true}
// Why: the active workspace's listener owns the chord while the panel overlays it.
ownsNativeChatToggleShortcut={false}
/>
</div>
) : (
<FloatingTerminalEmptyState
onNewTerminal={() => createFloatingTerminalTab()}
onNewMarkdown={createFloatingMarkdownTab}
onOpenMarkdown={openFloatingMarkdownTab}
onNewBrowser={createFloatingBrowserTab}
showNewBrowser={managedBrowserCreationEnabled}
onClose={() => onOpenChange(false)}
onFocusPanel={focusPanelForShortcuts}
newTerminalShortcut={newTerminalShortcut}
newBrowserShortcut={newBrowserShortcut}
newMarkdownShortcut={newMarkdownShortcut}
openMarkdownShortcut={openMarkdownShortcut}
closeShortcut={closeShortcut}
/>
)}
</div>
</div>
)}
</WorkspaceTabDragLayer>
{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
})}
</div>
)
}
@@ -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<typeof useFloatingTerminalPanelController>,
| 'saveDialogFileId'
| 'saveDialogFile'
| 'handleFloatingSaveDialogCancel'
| 'handleFloatingSaveDialogDiscard'
| 'handleFloatingSaveDialogSave'
>
export function renderFloatingTerminalSaveDialog({
saveDialogFileId,
saveDialogFile,
handleFloatingSaveDialogCancel,
handleFloatingSaveDialogDiscard,
handleFloatingSaveDialogSave
}: FloatingTerminalSaveDialogProps): React.JSX.Element {
return (
<Dialog
open={saveDialogFileId !== null}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
handleFloatingSaveDialogCancel()
}
}}
>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="text-sm">
{translate(
'auto.components.floating.terminal.FloatingTerminalPanel.690b6fb98a',
'Unsaved Changes'
)}
</DialogTitle>
<DialogDescription className="text-xs">
{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.'
)}
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleFloatingSaveDialogCancel}
>
{translate(
'auto.components.floating.terminal.FloatingTerminalPanel.e7bf09d4d4',
'Cancel'
)}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleFloatingSaveDialogDiscard}
>
{translate(
'auto.components.floating.terminal.FloatingTerminalPanel.918c2139f3',
"Don't Save"
)}
</Button>
<Button type="button" size="sm" onClick={handleFloatingSaveDialogSave}>
{translate(
'auto.components.floating.terminal.FloatingTerminalPanel.da508bd7f5',
'Save'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -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' })
})
})
@@ -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 (
<div className="flex items-center gap-1 px-2" data-floating-terminal-no-drag>
@@ -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 (
<TabDragProvider
isTabDragActive={dragSplit.activeDrag !== null}
isTabDragActiveRef={dragSplit.isTabDragActiveRef}
>
<DndContext
sensors={dragSplit.sensors}
collisionDetection={dragSplit.collisionDetection}
onDragStart={dragSplit.onDragStart}
onDragMove={dragSplit.onDragMove}
onDragOver={dragSplit.onDragOver}
onDragEnd={dragSplit.onDragEnd}
onDragCancel={dragSplit.onDragCancel}
// Why: same feedback loop the workspace strip hits — autoscroll shifts tabs under a still cursor and `over` re-resolves.
autoScroll={false}
>
<div ref={dragSplit.setDragRootNode} className="flex h-full min-w-0 flex-1">
{/* 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 })}
</div>
{/* Why: the source tab stays anchored in the overflow-hidden strip; this ghost is what follows the cursor. */}
<DragOverlay dropAnimation={null}>
{dragSplit.activeDrag ? <TabDragPreview drag={dragSplit.activeDrag} /> : null}
</DragOverlay>
</DndContext>
</TabDragProvider>
)
}
@@ -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 {
@@ -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<string, string | null>
activeTabIdByWorktree: Record<string, string | null>
layoutByWorktree: Record<string, TabGroupLayoutNode>
expandedPaneByTabId: Record<string, boolean>
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<void>
openNewMarkdownInActiveWorkspace: (groupId?: string) => Promise<void>
openNewTerminalTabInActiveWorkspace: (groupId?: string) => Promise<void>
createTab: (
worktreeId: string,
groupId?: string,
@@ -50,6 +60,8 @@ export type FloatingPanelStoreState = {
browserDefaultUrl: string
keybindings?: KeybindingOverrides
tabBarOrderByWorktree: Record<string, string[]>
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
}
@@ -60,6 +60,21 @@ export type FloatingTerminalPanelMocks = {
pinFile: Mock<FloatingPanelStoreState['pinFile']>
setFloatingFocus: Mock<(state: { panelFocused: boolean; terminalFocused: boolean }) => void>
setActiveTab: Mock<FloatingPanelStoreState['setActiveTab']>
setActiveTabType: Mock<FloatingPanelStoreState['setActiveTabType']>
setActiveFile: Mock<FloatingPanelStoreState['setActiveFile']>
setActiveBrowserTab: Mock<FloatingPanelStoreState['setActiveBrowserTab']>
setActiveWorktree: Mock<FloatingPanelStoreState['setActiveWorktree']>
focusGroup: Mock<FloatingPanelStoreState['focusGroup']>
createEmptySplitGroup: Mock<FloatingPanelStoreState['createEmptySplitGroup']>
openNewBrowserTabInActiveWorkspace: Mock<
FloatingPanelStoreState['openNewBrowserTabInActiveWorkspace']
>
openNewMarkdownInActiveWorkspace: Mock<
FloatingPanelStoreState['openNewMarkdownInActiveWorkspace']
>
openNewTerminalTabInActiveWorkspace: Mock<
FloatingPanelStoreState['openNewTerminalTabInActiveWorkspace']
>
setTabColor: Mock<FloatingPanelStoreState['setTabColor']>
setTabCustomTitle: Mock<FloatingPanelStoreState['setTabCustomTitle']>
setTabPaneExpanded: Mock<FloatingPanelStoreState['setTabPaneExpanded']>
@@ -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
@@ -13,6 +13,8 @@ import { storeBox, type FloatingPanelStoreState } from './floating-terminal-pane
export function createReactHookOverrides() {
return {
useCallback: <T>(callback: T) => callback,
// Why: subscription hooks in the shared model read module stores; the probe only needs the snapshot.
useSyncExternalStore: <T>(_subscribe: unknown, getSnapshot: () => T) => getSnapshot(),
useEffect: (effect: EffectCallback) => {
hookRuntime.effects.push(effect)
},
@@ -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> = {}): 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<typeof resolveFloatingWorkspaceSurfaceModel>[0] {
const unifiedTabsByWorktree: Record<string, Tab[]> = {}
const groupsByWorktree: Record<string, TabGroup[]> = {}
const layoutByWorktree: Record<string, TabGroupLayoutNode> = {}
const activeGroupIdByWorktree: Record<string, string> = {}
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' })
})
})
@@ -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 }
}
@@ -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<FakeHandle>()
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<FakeHandle>()
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<FakeHandle>()
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<FakeHandle>()
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<FakeHandle>()
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<FakeHandle>()
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()
})
})
@@ -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<THandle> = {
getHandle: (tabId: string) => THandle | null
getRefCallback: (tabId: string) => (handle: THandle | null) => void
retainOnly: (liveTabIds: Iterable<string>) => void
}
export function createTerminalPaneHandleRegistry<THandle>(): TerminalPaneHandleRegistry<THandle> {
const handles = new Map<string, THandle>()
const refCallbacks = new Map<string, (handle: THandle | null) => 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)
}
}
}
}
}
@@ -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<ReturnType<typeof useAppStore.getState>['closeFile']>()
const closeUnifiedTab = vi.fn<ReturnType<typeof useAppStore.getState>['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()
})
})
@@ -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<FloatingTerminalPanelItems, 'activeGroup' | 'groupTabs'> &
Pick<FloatingTerminalPanelLocalState, 'pendingReclaimArmByFileIdRef'> &
Pick<FloatingTerminalEditorCloseQueue, 'queueEditorCloseRequests'>
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
}
}
@@ -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<typeof import('react')>('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
return { ...actual, useCallback: <T>(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)
})
})
@@ -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<FloatingTerminalPanelItems, 'activeGroup' | 'groupTabs'> &
Pick<FloatingWorkspaceChromeModel, 'activeGroup' | 'groupTabs'> &
Pick<FloatingTerminalPanelLocalState, 'markdownCwd'>
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')) {
@@ -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
>
@@ -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<FloatingTerminalPanelItems, 'activeTerminalId'> &
Pick<
FloatingTerminalPanelLocalState,
'pendingReclaimArmByFileIdRef' | 'panelRef' | 'reclaimTerminalInputOnWindowFocusRef'
> & { open: boolean }
type FloatingTerminalFocusLifecycleInput = Pick<FloatingWorkspaceChromeModel, 'activeTerminalId'> &
Pick<FloatingTerminalPanelLocalState, 'panelRef' | 'reclaimTerminalInputOnWindowFocusRef'> & {
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') {
@@ -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<FloatingTerminalPanelLocalState, 'panelRef'> & { open: boolean }
@@ -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,
@@ -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<FloatingTerminalPanelItems, 'visibleFloatingItemCount'> &
Pick<FloatingTerminalPanelStoreState, 'floatingFiles'>
Pick<FloatingWorkspaceChromeModel, 'hasVisibleFloatingTabs'>
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 }
}
@@ -21,10 +21,9 @@ type FloatingTerminalPanelGeometryInput = Pick<
| 'maximized'
| 'stagedBoundsRef'
| 'lastPersistedBoundsRef'
| 'setCwd'
| 'setMarkdownCwd'
> &
Pick<FloatingTerminalPanelStoreState, 'floatingTerminalCwd'>
Pick<FloatingTerminalPanelStoreState, 'floatingTerminalCwd' | 'setFloatingWorkspacePath'>
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
@@ -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<string, { groupId: string; isActiveInGroup: boolean }>()
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<typeof useFloatingTerminalPanelItems>
@@ -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<string | null>(null)
const [markdownCwd, setMarkdownCwd] = useState<string | null>(null)
const initialBoundsStateRef = useRef<FloatingTerminalPanelBoundsState | null>(null)
if (initialBoundsStateRef.current === null) {
@@ -45,13 +42,7 @@ export function useFloatingTerminalPanelLocalState() {
const lastPersistedBoundsRef = useRef<FloatingTerminalPanelCommittedBounds | null>(
initialBoundsState.source === 'user' ? initialBoundsState.committedBounds : null
)
const pendingEditorCloseQueueRef = useRef<string[]>([])
const pendingReclaimArmByFileIdRef = useRef<Map<string, () => void>>(new Map())
const saveDialogFileIdRef = useRef<string | null>(null)
const panelRef = useRef<HTMLDivElement | null>(null)
const [terminalPaneRegistry] = useState(() =>
createTerminalPaneHandleRegistry<TerminalPaneHandle>()
)
const doubleTapDetectorRef = useRef<ModifierDoubleTapDetector | null>(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,
@@ -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<FloatingTerminalPanelStoreState, 'tabs'> & { 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 }
}
@@ -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<FloatingTerminalPanelLocalState, 'terminalPaneRegistry' | 'panelRef'> &
Pick<FloatingTerminalPanelLocalState, 'panelRef'> &
Pick<FloatingTerminalCloseActions, 'closeFloatingItemConfirmed'> &
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 => {
@@ -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
}
@@ -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<typeof useFloatingWorkspaceChromeModel>
@@ -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
@@ -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>(): T | null {
return null
}
function widened<T>(value: T): T {
return value
}
function absent<T>(): T | undefined {
return undefined
}
@@ -99,6 +104,12 @@ type StructuredSessionMessageListProps = {
deliveryNotices?: ReadonlyMap<string, NativeChatDeliveryNotice>
}
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<StructuredAgentSessionLaunchLifecycle>(),
ownerWorktreeId: widened<string | null>('wt-1'),
fileLinkContext: widened<NativeChatFileLinkContext | null>(DEFAULT_FILE_LINK_CONTEXT),
lifecycleLookup: vi.fn<(worktreeId: string, sessionId: string) => void>(),
launchFailure: nullable<AgentSessionWriteRefusal>(),
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'

Some files were not shown because too many files have changed in this diff Show More