From 8fef5820ffac193c165e42e5307fc01f4459e97b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:41:42 -0700 Subject: [PATCH] fix(renderer): restore behavior the UI split dropped The oversized-UI-surfaces split was cut from a stale branch and reverted merged work. getClientCreationActionPolicy entered Terminal.tsx in #13909 and left in the split, taking six call sites with it, so every action-time creation gate in the terminal and floating surfaces was gone. Restores those and the other behavior the split dropped, each ported from the pre-split reference: - Cmd/Ctrl+S dispatched a bare Event with no detail, so the only listener always bailed on detail?.fileId and the chord never saved. Its resolver had been left orphaned, imported by nothing but its own test. - Terminal and floating create actions lost their availability gates, their toasts, and their catch handlers; one path throws on unavailable, so it was a silent unhandled rejection. - Both outermost workbench wrappers lost the browser guest paint retention branch, and the census entry covering them was deleted in the same commit. - The Space Analyzer header counted omitted items the list no longer rendered, and a worktree whose items were all omitted showed the empty state. - The terminal root lost its tab topology projection, so every tab-title update re-rendered it. - The titlebar tab bar stopped being passed clientHostedBrowserRows, leaving client-hosted pages uncloseable before a worktree has a layout. - Parking diagnostics lost their exempt-route counts and crash breadcrumb. - A suppressed inherited-terminal frame began buying a freshness scan the pre-split early return skipped. Adds regression tests for each, all verified to fail against the pre-fix code. Restores three deleted assertions whose invariants are still live, and replaces a concatenated source-boundary fixture with per-module pinning so a symbol is again asserted against the module that must own it. Deletes three orphaned trees the splits stranded: a duplicate ResourceUsage surface, cmd-j-match-relevance, and an agent-session claim-key module whose logic the record store already owns. Makes two non-recursive test walkers recursive, one of which silently skipped every nested CLI handler group. --- src/cli/handler-group-manifest.test.ts | 72 +++- .../runtime/agent-session-claim-key-state.ts | 46 --- .../TerminalSplitWorkspaceSurfaces.tsx | 13 +- .../src/components/TerminalSurface.tsx | 10 +- .../components/TerminalTitlebarTabs.test.tsx | 111 ++++++ .../src/components/TerminalTitlebarTabs.tsx | 18 +- ...rowser-guest-retention-site-census.test.ts | 4 + .../use-floating-terminal-create-actions.ts | 4 + .../use-floating-terminal-panel-shortcuts.ts | 7 + ...rkspaceNameField-source-boundaries.test.ts | 37 +- .../status-bar/ResourceUsageAppSection.tsx | 98 ----- .../status-bar/ResourceUsageKillDialog.tsx | 98 ----- .../status-bar/ResourceUsageMetrics.tsx | 130 ------ .../ResourceUsagePopoverContent.tsx | 376 ------------------ .../status-bar/ResourceUsageTree.tsx | 127 ------ .../status-bar/ResourceUsageTrigger.tsx | 95 ----- .../status-bar/ResourceUsageWorktreeRow.tsx | 260 ------------ .../status-bar/resource-usage-sort.ts | 47 --- .../use-resource-usage-foundation.ts | 213 ---------- .../use-resource-usage-projection.ts | 150 ------- .../workspace-space-breakdown-list.test.tsx | 111 ++++++ .../workspace-space-breakdown-list.tsx | 21 +- .../workspace-space-presentation.test.ts | 134 ++++++- .../terminal-workspace-editor-shortcuts.ts | 67 ++++ .../terminal-workspace-keydown.test.ts | 104 +++++ .../components/terminal-workspace-keydown.ts | 65 ++- .../use-terminal-create-actions.test.tsx | 119 ++++++ .../components/use-terminal-create-actions.ts | 46 ++- .../components/use-terminal-parking-pass.ts | 13 +- .../use-terminal-workspace-foundation.ts | 15 +- .../use-terminal-workspace-projection.ts | 5 + .../useSettingsNavigationMetadata.test.ts | 7 +- .../src/lib/client-creation-action-error.ts | 24 ++ src/renderer/src/lib/cmd-j-match-relevance.ts | 96 ----- .../mobile-session-surfaces.ts | 7 +- .../sync-runtime-graph/sync-projections.ts | 10 - .../store/slices/agent-status-live-actions.ts | 19 +- .../slices/agent-status-live-entry-builder.ts | 13 +- ...gent-status-live-freshness-request.test.ts | 70 ++++ .../store/slices/linear/linear-slice-scope.ts | 28 +- 40 files changed, 1005 insertions(+), 1885 deletions(-) delete mode 100644 src/main/runtime/agent-session-claim-key-state.ts create mode 100644 src/renderer/src/components/TerminalTitlebarTabs.test.tsx delete mode 100644 src/renderer/src/components/status-bar/ResourceUsageAppSection.tsx delete mode 100644 src/renderer/src/components/status-bar/ResourceUsageKillDialog.tsx delete mode 100644 src/renderer/src/components/status-bar/ResourceUsageMetrics.tsx delete mode 100644 src/renderer/src/components/status-bar/ResourceUsagePopoverContent.tsx delete mode 100644 src/renderer/src/components/status-bar/ResourceUsageTree.tsx delete mode 100644 src/renderer/src/components/status-bar/ResourceUsageTrigger.tsx delete mode 100644 src/renderer/src/components/status-bar/ResourceUsageWorktreeRow.tsx delete mode 100644 src/renderer/src/components/status-bar/resource-usage-sort.ts delete mode 100644 src/renderer/src/components/status-bar/use-resource-usage-foundation.ts delete mode 100644 src/renderer/src/components/status-bar/use-resource-usage-projection.ts create mode 100644 src/renderer/src/components/status-bar/workspace-space-breakdown-list.test.tsx create mode 100644 src/renderer/src/components/terminal-workspace-editor-shortcuts.ts create mode 100644 src/renderer/src/components/terminal-workspace-keydown.test.ts create mode 100644 src/renderer/src/components/use-terminal-create-actions.test.tsx create mode 100644 src/renderer/src/lib/client-creation-action-error.ts delete mode 100644 src/renderer/src/lib/cmd-j-match-relevance.ts create mode 100644 src/renderer/src/store/slices/agent-status-live-freshness-request.test.ts diff --git a/src/cli/handler-group-manifest.test.ts b/src/cli/handler-group-manifest.test.ts index d17c3446db1..3849f39a7c9 100644 --- a/src/cli/handler-group-manifest.test.ts +++ b/src/cli/handler-group-manifest.test.ts @@ -1,5 +1,5 @@ import { readdirSync } from 'node:fs' -import { join } from 'node:path' +import { join, relative, sep } from 'node:path' import { describe, expect, it } from 'vitest' import { buildHandlerRoutes, dispatch, type HandlerContext } from './dispatch' @@ -9,6 +9,32 @@ import { HANDLER_GROUPS, type HandlerGroup } from './handler-group-manifest' // group. These tests are the only thing standing between that trust and a // silently unreachable command, so they load every group for real. +// Why: __dirname works under both Vitest and the CommonJS tsc emit that +// build:cli type-checks this file against; import.meta.dirname does not. +const HANDLERS_DIR = join(__dirname, 'handlers') + +// Why: both the plural records and the single-command `*_HANDLER` ones that +// nested modules export get spread into a group, so both must route. +const HANDLER_RECORD_EXPORT = /_HANDLERS?$/ + +function listHandlerModules(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name) + if (entry.isDirectory()) { + return listHandlerModules(path) + } + return entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts') ? [path] : [] + }) +} + +function isHandlerRecord(value: unknown): value is Record { + return ( + typeof value === 'object' && + value !== null && + Object.values(value).every((entry) => typeof entry === 'function') + ) +} + describe('handler group manifest', () => { it('lists a loadable group for every entry', async () => { for (const group of HANDLER_GROUPS) { @@ -54,25 +80,35 @@ describe('handler group manifest', () => { }) // Why: dropping a group from the manifest silently unregisters its commands — - // scan the directory so a new or forgotten handler file fails here, not in prod. - it('registers every handler module that exports a handler group', async () => { - // Why: __dirname works under both Vitest and the CommonJS tsc emit that - // build:cli type-checks this file against; import.meta.dirname does not. - const dir = join(__dirname, 'handlers') - const modules = readdirSync(dir).filter( - (file) => file.endsWith('.ts') && !file.endsWith('.test.ts') - ) - const registered = new Set(HANDLER_GROUPS.map((group) => group.name)) - const missing: string[] = [] - for (const file of modules) { - const name = file.slice(0, -'.ts'.length) - const exports: Record = await import(join(dir, file)) - const exportsGroup = Object.keys(exports).some((key) => key.endsWith('_HANDLERS')) - if (exportsGroup && !registered.has(name)) { - missing.push(name) + // walk the tree so a new or forgotten handler file fails here, not in prod. + // Nested modules are spread into a parent group rather than registered under + // their own name, so routability, not file name, is the invariant that holds. + it('routes every command exported by a handler module', async () => { + const routes = buildHandlerRoutes(HANDLER_GROUPS) + const unroutable: string[] = [] + for (const file of listHandlerModules(HANDLERS_DIR)) { + const exports: Record = await import(file) + for (const [name, value] of Object.entries(exports)) { + if (!HANDLER_RECORD_EXPORT.test(name) || !isHandlerRecord(value)) { + continue + } + for (const key of Object.keys(value)) { + if (!routes.has(key)) { + unroutable.push(`${relative(HANDLERS_DIR, file)} ${name}: ${key}`) + } + } } } - expect(missing).toEqual([]) + expect(unroutable).toEqual([]) + }) + + it('finds the modules it is meant to guard', () => { + // Why: a walk that missed the tree would make the guard above vacuously pass. + const modules = listHandlerModules(HANDLERS_DIR) + expect(modules.length).toBeGreaterThanOrEqual(40) + expect( + modules.filter((file) => relative(HANDLERS_DIR, file).includes(sep)).length + ).toBeGreaterThanOrEqual(7) }) }) diff --git a/src/main/runtime/agent-session-claim-key-state.ts b/src/main/runtime/agent-session-claim-key-state.ts deleted file mode 100644 index f9fe77cbdc5..00000000000 --- a/src/main/runtime/agent-session-claim-key-state.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { classifyObservedAgentSessionSpawnToken } from '../../shared/agent-session-lease-adjudication' -import type { AgentSessionRecord } from '../../shared/agent-session-record' -import type { AgentSessionStoreState } from './agent-session-record-store-file' - -export function isVerifiable( - state: AgentSessionStoreState, - keyId: string, - now: number, - retentionMs: number -): boolean { - const retired = state.retiredClaimKeys.find((entry) => entry.keyId === keyId) - return !retired || now - retired.retiredAt <= retentionMs -} - -export function markConflicted(record: AgentSessionRecord, now: number): AgentSessionRecord { - return { - ...record, - updatedAt: now, - // A conflicted key must remain conflicted after its observing process exits. - lease: { ...record.lease, claimStatus: 'conflicted', handoffStage: 'manual-recovery' } - } -} - -export function retire( - state: AgentSessionStoreState, - keyId: string, - now: number, - retentionMs: number -): void { - if (!state.retiredClaimKeys.some((entry) => entry.keyId === keyId)) { - state.retiredClaimKeys.push({ keyId, retiredAt: now }) - } - state.retiredClaimKeys = state.retiredClaimKeys.filter( - (entry) => now - entry.retiredAt <= retentionMs - ) -} - -export function listOrphanSpawnTokens( - records: readonly AgentSessionRecord[], - observedTokens: readonly string[] -): string[] { - const leases = records.map((record) => record.lease) - return observedTokens.filter( - (spawnToken) => classifyObservedAgentSessionSpawnToken({ spawnToken, leases }) === 'orphan' - ) -} diff --git a/src/renderer/src/components/TerminalSplitWorkspaceSurfaces.tsx b/src/renderer/src/components/TerminalSplitWorkspaceSurfaces.tsx index b6842fd95fe..5c3145360f6 100644 --- a/src/renderer/src/components/TerminalSplitWorkspaceSurfaces.tsx +++ b/src/renderer/src/components/TerminalSplitWorkspaceSurfaces.tsx @@ -1,3 +1,4 @@ +import { useAnyBrowserGuestNeedsPaint } from './browser-pane/host-guest/browser-guest-paint-retention' import { WorktreeSplitSurface } from './TerminalWorktreeSplitSurface' import type { TerminalController } from './use-terminal-controller' @@ -22,12 +23,22 @@ export function TerminalSplitWorkspaceSurfaces({ renderedActiveWorktreeId, workspaceSurfaces } = controller + // Why: this and TerminalSurface are both strict ancestors of every browser , so a + // remote controller needs each to drop `hidden` — the per-worktree surface hatch below cannot + // override an ancestor that stopped compositing. + const retainBrowserGuestPaint = useAnyBrowserGuestNeedsPaint(!effectiveActiveLayout) if (!anyMountedWorktreeHasLayout) { return null } return (
{workspaceSurfaces .filter((workspace) => mountedWorktreeIdsRef.current.has(workspace.id)) diff --git a/src/renderer/src/components/TerminalSurface.tsx b/src/renderer/src/components/TerminalSurface.tsx index 66553037b42..023f19f7311 100644 --- a/src/renderer/src/components/TerminalSurface.tsx +++ b/src/renderer/src/components/TerminalSurface.tsx @@ -1,4 +1,5 @@ import EditorAutosaveController from './editor/EditorAutosaveController' +import { useAnyBrowserGuestNeedsPaint } from './browser-pane/host-guest/browser-guest-paint-retention' import { TerminalTitlebarTabs } from './TerminalTitlebarTabs' import { TerminalSplitWorkspaceSurfaces } from './TerminalSplitWorkspaceSurfaces' import { TerminalLegacyWorkspaceSurface } from './TerminalLegacyWorkspaceSurface' @@ -11,10 +12,17 @@ export function TerminalSurface({ controller: TerminalController }): React.JSX.Element { const { renderedActiveWorktreeId } = controller + const retainBrowserGuestPaint = useAnyBrowserGuestNeedsPaint(!renderedActiveWorktreeId) return (
diff --git a/src/renderer/src/components/TerminalTitlebarTabs.test.tsx b/src/renderer/src/components/TerminalTitlebarTabs.test.tsx new file mode 100644 index 00000000000..e4f4162c97a --- /dev/null +++ b/src/renderer/src/components/TerminalTitlebarTabs.test.tsx @@ -0,0 +1,111 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ClientHostedBrowserRow } from '../../../shared/client-hosted-browser-rows' +import { + applyClientHostedBrowserRows, + getClientHostedBrowserRows +} from '@/lib/pane-manager/client-hosted-browser-row-state' +import { TerminalTitlebarTabs } from './TerminalTitlebarTabs' +import type { TerminalController } from './use-terminal-controller' + +const mocks = vi.hoisted(() => ({ + state: {} as Record, + tabBarProps: [] as Record[] +})) + +vi.mock('../store', () => ({ + useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector(mocks.state), { + getState: () => mocks.state + }) +})) +vi.mock('./tab-bar/TabBar', () => ({ + default: (props: Record) => { + mocks.tabBarProps.push(props) + return null + } +})) + +const WORKTREE_ID = 'repo-1::/repo/worktree' +const ROW: ClientHostedBrowserRow = { + browserPageId: 'page-1', + title: 'Client page', + url: 'https://example.com' +} as ClientHostedBrowserRow + +let titlebarTarget: HTMLElement +let container: HTMLElement + +function renderTitlebarTabs(): void { + const controller = { + activeBrowserTabId: null, + activeFileId: null, + activeTabId: null, + activeTabType: 'terminal', + effectiveActiveLayout: null, + expandedPaneByTabId: {}, + handleActivateBrowserTab: vi.fn(), + handleActivateTab: vi.fn(), + handleCloseAllFiles: vi.fn(), + handleCloseBrowserTab: vi.fn(), + handleCloseFile: vi.fn(), + handleCloseOthers: vi.fn(), + handleCloseTab: vi.fn(), + handleCloseTabsToLeft: vi.fn(), + handleCloseTabsToRight: vi.fn(), + handleDuplicateBrowserTab: vi.fn(), + handleNewBrowserTab: vi.fn(), + handleNewFile: vi.fn(), + handleNewSimulatorTab: vi.fn(), + handleNewTab: vi.fn(), + handleOpenEntry: vi.fn(), + handleTogglePaneExpand: vi.fn(), + makePreviewFilePermanent: vi.fn(), + mobileEmulatorEnabled: false, + pinFile: vi.fn(), + renderedActiveWorktreeId: WORKTREE_ID, + setActiveFile: vi.fn(), + setActiveTab: vi.fn(), + setActiveTabType: vi.fn(), + setTabColor: vi.fn(), + setTabCustomTitle: vi.fn(), + tabBarOrder: [], + titlebarTabsTarget: titlebarTarget, + worktreeBrowserTabs: [], + // Mirrors the projection hook's derivation so the assertion follows the real row store. + worktreeClientHostedBrowserRows: getClientHostedBrowserRows(WORKTREE_ID), + worktreeFiles: [] + } as unknown as TerminalController + const root = createRoot(container) + act(() => root.render()) + act(() => root.unmount()) +} + +describe('TerminalTitlebarTabs', () => { + beforeEach(() => { + mocks.tabBarProps = [] + mocks.state = { tabsByWorktree: {}, unifiedTabsByWorktree: {}, getActiveTab: () => null } + titlebarTarget = document.createElement('div') + container = document.createElement('div') + document.body.append(titlebarTarget, container) + }) + + afterEach(() => { + applyClientHostedBrowserRows({ worktreeId: WORKTREE_ID, rows: [] }) + titlebarTarget.remove() + container.remove() + }) + + it('forwards client-hosted browser rows to the titlebar tab bar', () => { + applyClientHostedBrowserRows({ worktreeId: WORKTREE_ID, rows: [ROW] }) + renderTitlebarTabs() + expect(mocks.tabBarProps.at(-1)?.clientHostedBrowserRows).toEqual([ROW]) + }) + + it('passes no rows when the worktree has none', () => { + renderTitlebarTabs() + expect(mocks.tabBarProps.at(-1)?.clientHostedBrowserRows).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/TerminalTitlebarTabs.tsx b/src/renderer/src/components/TerminalTitlebarTabs.tsx index a275da9d801..c2a5d8218e4 100644 --- a/src/renderer/src/components/TerminalTitlebarTabs.tsx +++ b/src/renderer/src/components/TerminalTitlebarTabs.tsx @@ -1,8 +1,20 @@ import { createPortal } from 'react-dom' +import type { TerminalTab } from '../../../shared/terminal-tab-types' import { useAppStore } from '../store' import TabBar from './tab-bar/TabBar' import type { TerminalController } from './use-terminal-controller' +const EMPTY_TERMINAL_TABS: TerminalTab[] = [] + +// Why: keeps title-only tab updates a leaf subscription so the Terminal root, +// which reads the topology projection, does not re-render on every rename. +function LiveTerminalTabBar( + props: Omit, 'tabs'> +): React.JSX.Element { + const tabs = useAppStore((state) => state.tabsByWorktree[props.worktreeId] ?? EMPTY_TERMINAL_TABS) + return +} + export function TerminalTitlebarTabs({ controller }: { @@ -41,17 +53,16 @@ export function TerminalTitlebarTabs({ setTabColor, setTabCustomTitle, tabBarOrder, - tabs, titlebarTabsTarget, worktreeBrowserTabs, + worktreeClientHostedBrowserRows, worktreeFiles } = controller if (!renderedActiveWorktreeId || effectiveActiveLayout || !titlebarTabsTarget) { return null } return createPortal( - ([ ['components/TerminalWorkbenchContainer.tsx', ['useAnyBrowserGuestNeedsPaint']], + // The two outermost workbench wrappers: strict ancestors of every guest, so the per-worktree + // surface hatch below cannot rescue a guest either one parked with `hidden`. + ['components/TerminalSurface.tsx', ['useAnyBrowserGuestNeedsPaint']], + ['components/TerminalSplitWorkspaceSurfaces.tsx', ['useAnyBrowserGuestNeedsPaint']], ['components/TerminalWorktreeSplitSurface.tsx', ['useBrowserGuestPaintRetention']], [ 'components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.tsx', diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.ts index 781948111c1..a60cdc7d93e 100644 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.ts +++ b/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.ts @@ -3,6 +3,7 @@ import { toast } from 'sonner' import { resolveGroupTabFromVisibleId } from '@/components/tab-group/tab-group-visible-id' import { getConnectionId } from '@/lib/connection-context' import { createUntitledMarkdownFileWithTemplateSelection } from '@/lib/create-untitled-markdown' +import { ensureClientCreationActionAllowed } from '@/lib/client-creation-action-error' import { detectLanguage } from '@/lib/language-detect' import { extractIpcErrorMessage } from '@/lib/ipc-error' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' @@ -74,6 +75,9 @@ export function useFloatingTerminalCreateActions({ ) const createFloatingBrowserTab = useCallback(() => { + if (!ensureClientCreationActionAllowed(FLOATING_TERMINAL_WORKTREE_ID, 'managed-browser')) { + return + } const url = browserDefaultUrl ?? 'about:blank' createBrowserTab(FLOATING_TERMINAL_WORKTREE_ID, url, { title: translate( diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-shortcuts.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-shortcuts.ts index 67e0bc79db3..b597810c0fd 100644 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-shortcuts.ts +++ b/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-shortcuts.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, type KeyboardEvent as ReactKeyboardEvent } from 'react' import { isTerminalPaneCloseChord } from '@/components/terminal-pane/terminal-shortcut-policy' +import { ensureClientCreationActionAllowed } from '@/lib/client-creation-action-error' import { matchFloatingWorkspacePanelOwnedAction, matchFloatingWorkspacePanelShortcut @@ -7,6 +8,7 @@ import { import { isFloatingWorkspaceTerminalInputTarget } from '@/lib/floating-workspace-terminal-actions' import { getShortcutPlatform } from '@/lib/shortcut-platform' import { useAppStore } from '@/store' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import type { KeybindingContext, KeybindingMatchOptions } from '../../../../shared/keybindings' import type { FloatingPanelShortcutInput, @@ -123,6 +125,11 @@ export function useFloatingTerminalPanelShortcuts({ if (resolution.action === 'tab.newTerminal') { createFloatingTerminalTab() } else if (resolution.action === 'tab.newBrowser') { + if ( + !ensureClientCreationActionAllowed(FLOATING_TERMINAL_WORKTREE_ID, 'managed-browser') + ) { + return 'handled' + } createFloatingBrowserTab() } else if (resolution.action === 'tab.newMarkdown') { createFloatingMarkdownTab() diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField-source-boundaries.test.ts b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField-source-boundaries.test.ts index e1d58f70ded..4382cd1aeb0 100644 --- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField-source-boundaries.test.ts +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField-source-boundaries.test.ts @@ -21,23 +21,6 @@ const COPY_SOURCE = readSource('smart-workspace-name-field-copy.ts') const INPUT_SOURCE = readSource('smart-workspace-name-input-surface.tsx') const SURFACE_SOURCE = readSource('smart-workspace-name-field-surface.tsx') const DIALOG_SOURCE = readSource('smart-workspace-cross-repo-dialog.tsx') -const FIELD_SOURCES = [ - MODEL_SOURCE, - CONTROLLER_SOURCE, - FOUNDATION_SOURCE, - AVAILABILITY_SOURCE, - FOCUS_SOURCE, - STATE_SOURCE, - GITHUB_SOURCE, - GITLAB_SOURCE, - SECONDARY_SEARCH_SOURCE, - ACTIONS_SOURCE, - PRESENTATION_SOURCE, - COPY_SOURCE, - INPUT_SOURCE, - SURFACE_SOURCE, - DIALOG_SOURCE -].join('\n') function sourceBetween(source: string, startPattern: string, endPattern: string): string { const start = source.indexOf(startPattern) @@ -72,7 +55,7 @@ describe('SmartWorkspaceNameField repo-backed source boundaries', () => { expect(availableModesSection).toContain("item.id === 'jira'") expect(availableModesSection).toContain('return jiraSourceConnected') expect(availableModesSection).toContain('branchesEnabled && !repoBackedSourcesDisabled') - expect(FIELD_SOURCES).toContain('repoBackedSourcesDisabled') + expect(CONTROLLER_SOURCE).toContain('repoBackedSourcesDisabled') expect(CONTROLLER_SOURCE).toContain('foundation.gitlabSourceAvailable') const jiraLookupSection = sourceBetween( @@ -107,8 +90,13 @@ describe('SmartWorkspaceNameField repo-backed source boundaries', () => { }) it('searches repo-backed task sources through implicit repo targets instead of a menu', () => { - expect(FIELD_SOURCES).not.toContain('RepoBackedSourceMenu') - expect(FIELD_SOURCES).not.toContain('repoBackedSourceOptions') + // The menu declared a prop, derived a visibility flag, and rendered a control; implicit repo + // targets replaced all three, so each former host is pinned separately. + expect(MODEL_SOURCE).not.toContain('repoBackedSourceOptions') + expect(CONTROLLER_SOURCE).not.toContain('repoBackedSourceOptions') + expect(FOUNDATION_SOURCE).not.toContain('repoBackedSourceOptions') + expect(SURFACE_SOURCE).not.toContain('RepoBackedSourceMenu') + expect(INPUT_SOURCE).not.toContain('RepoBackedSourceMenu') expect(MODEL_SOURCE).toContain('repoBackedSearchRepos?: readonly RepoOption[]') const targetSection = sourceBetween( @@ -125,26 +113,27 @@ describe('SmartWorkspaceNameField repo-backed source boundaries', () => { expect(CONTROLLER_SOURCE).toContain('foundation.repoBackedSearchTargets.length > 0') expect(GITHUB_SOURCE).toContain('fetchWorkItemsAcrossRepos') expect(GITHUB_SOURCE).toContain('repoBackedSearchTargets.map') + expect(GITLAB_SOURCE).toContain('repoBackedSearchTargets.map') }) it('does not fan decisive Linear URLs out to unrelated providers', () => { const githubGate = sourceBetween( - FIELD_SOURCES, + CONTROLLER_SOURCE, 'const shouldQueryGithub =', 'const shouldQueryLinear =' ) const branchGate = sourceBetween( - FIELD_SOURCES, + SECONDARY_SEARCH_SOURCE, 'const branchSearchRequest = useMemo', 'useEffect(() => {\n if (!branchSearchRequest)' ) const gitlabGate = sourceBetween( - FIELD_SOURCES, + CONTROLLER_SOURCE, 'const shouldQueryGitlab =', 'useSmartWorkspaceGitlabSearch({' ) - expect(FIELD_SOURCES).toContain( + expect(PRESENTATION_SOURCE).toContain( "linearUrlIntent !== null && (mode === 'smart' || mode === 'linear')" ) expect(githubGate).toContain('!linearUrlIntentOwnsInput') diff --git a/src/renderer/src/components/status-bar/ResourceUsageAppSection.tsx b/src/renderer/src/components/status-bar/ResourceUsageAppSection.tsx deleted file mode 100644 index 6f74cba2d5e..00000000000 --- a/src/renderer/src/components/status-bar/ResourceUsageAppSection.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import React from 'react' -import { ChevronDown, ChevronRight } from 'lucide-react' -import type { AppMemory, UsageValues } from '../../../../shared/process-stats-types' -import { translate } from '@/i18n/i18n' -import { - ResourceUsageMetricPair, - ResourceUsageSparkline, - ROW_TRAILING_GUTTER_CLS -} from './ResourceUsageMetrics' - -function AppSubRow({ label, values }: { label: string; values: UsageValues }): React.JSX.Element { - return ( -
- {label} -
- - -
-
- ) -} - -export function ResourceUsageAppSection({ - app, - isCollapsed, - onToggle -}: { - app: AppMemory - isCollapsed: boolean - onToggle: () => void -}): React.JSX.Element { - return ( -
-
- -
- - {translate('auto.components.status.bar.ResourceUsageStatusSegment.288a4dd177', 'Orca')} - -
- - - -
-
-
- {!isCollapsed && ( -
- - - {(app.other.cpu > 0 || app.other.memory > 0) && ( - - )} -
- )} -
- ) -} diff --git a/src/renderer/src/components/status-bar/ResourceUsageKillDialog.tsx b/src/renderer/src/components/status-bar/ResourceUsageKillDialog.tsx deleted file mode 100644 index fcb6beb6f2e..00000000000 --- a/src/renderer/src/components/status-bar/ResourceUsageKillDialog.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import React from 'react' -import { LoaderCircle } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' -import { translate } from '@/i18n/i18n' -import type { ResourceUsageActions } from './use-resource-usage-actions' -import type { ResourceUsageFoundation } from './use-resource-usage-foundation' - -export function ResourceUsageKillDialog({ - foundation, - actions -}: { - foundation: ResourceUsageFoundation - actions: ResourceUsageActions -}): React.JSX.Element { - const { killConfirm, killing, setKillConfirm } = foundation - const { runKillConfirmed } = actions - - return ( - { - if (next) { - return - } - if (killing) { - return - } - setKillConfirm(null) - }} - > - { - if (killing) { - event.preventDefault() - } - }} - onEscapeKeyDown={(event) => { - if (killing) { - event.preventDefault() - } - }} - > - - - {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.e9a5d3c2b1f0', - 'Kill {{value0}}?', - { - value0: - killConfirm?.label ?? - translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.138b99bd80', - 'this session' - ) - } - )} - - - {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.67c4ecda49', - "Force-quits this terminal. Any unsaved work in the pane is lost. This can't be undone." - )} - - - - - - - - - ) -} diff --git a/src/renderer/src/components/status-bar/ResourceUsageMetrics.tsx b/src/renderer/src/components/status-bar/ResourceUsageMetrics.tsx deleted file mode 100644 index 6353c41b761..00000000000 --- a/src/renderer/src/components/status-bar/ResourceUsageMetrics.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import React, { memo, useMemo } from 'react' -import { cn } from '@/lib/utils' -import type { Metric } from './resource-usage-merge-types' - -export const METRIC_COLUMNS_CLS = 'flex items-center shrink-0 tabular-nums' -export const CPU_COLUMN_CLS = 'w-12 text-right' -export const MEM_COLUMN_CLS = 'w-16 text-right' -export const ROW_TRAILING_GUTTER_CLS = 'w-5 shrink-0 flex items-center justify-end' - -export function formatMemory(bytes: number): string { - if (bytes < 1024 * 1024) { - return `${Math.round(bytes / 1024)} KB` - } - if (bytes < 1024 * 1024 * 1024) { - return `${(bytes / (1024 * 1024)).toFixed(1)} MB` - } - return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB` -} - -export function formatCpu(percent: number): string { - return `${percent.toFixed(1)}%` -} - -function formatMetricCpu(value: Metric): string { - return value === null ? '—' : formatCpu(value) -} - -function formatMetricMemory(value: Metric): string { - return value === null ? '—' : formatMemory(value) -} - -type SparklineProps = { - samples: number[] - width?: number - height?: number -} - -function SparklineImpl({ samples, width = 48, height = 14 }: SparklineProps): React.JSX.Element { - const points = useMemo(() => { - const safe = Array.isArray(samples) ? samples : [] - if (safe.length < 2) { - const midY = (height / 2).toFixed(1) - return `0,${midY} ${width},${midY}` - } - - let min = safe[0] - let max = safe[0] - for (const value of safe) { - if (value < min) { - min = value - } - if (value > max) { - max = value - } - } - const range = max - min || 1 - const stepX = width / (safe.length - 1) - const out: string[] = [] - for (let index = 0; index < safe.length; index++) { - const x = (index * stepX).toFixed(1) - const y = (height - ((safe[index] - min) / range) * height).toFixed(1) - out.push(`${x},${y}`) - } - return out.join(' ') - }, [samples, width, height]) - - return ( - - - - ) -} - -export const ResourceUsageSparkline = memo(SparklineImpl, (left, right) => { - if (left.width !== right.width || left.height !== right.height) { - return false - } - const leftSamples = Array.isArray(left.samples) ? left.samples : [] - const rightSamples = Array.isArray(right.samples) ? right.samples : [] - if (leftSamples === rightSamples) { - return true - } - if (leftSamples.length !== rightSamples.length) { - return false - } - for (let index = 0; index < leftSamples.length; index++) { - if (leftSamples[index] !== rightSamples[index]) { - return false - } - } - return true -}) - -export function ResourceUsageMetricPair({ - cpu, - memory, - size = 'base' -}: { - cpu: Metric - memory: Metric - size?: 'base' | 'small' -}): React.JSX.Element { - const textClassName = size === 'small' ? 'text-[11px]' : 'text-xs' - const muted = cpu === null && memory === null - return ( -
- {formatMetricCpu(cpu)} - {formatMetricMemory(memory)} -
- ) -} diff --git a/src/renderer/src/components/status-bar/ResourceUsagePopoverContent.tsx b/src/renderer/src/components/status-bar/ResourceUsagePopoverContent.tsx deleted file mode 100644 index 64ca6049363..00000000000 --- a/src/renderer/src/components/status-bar/ResourceUsagePopoverContent.tsx +++ /dev/null @@ -1,376 +0,0 @@ -import React from 'react' -import { AlertTriangle, ChevronRight, MemoryStick, RotateCw, Trash2 } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { PopoverContent } from '@/components/ui/popover' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { translate } from '@/i18n/i18n' -import { cn } from '@/lib/utils' -import { WorkspaceSpaceCompactPanel } from './WorkspaceSpaceCompactPanel' -import { ResourceUsageAppSection } from './ResourceUsageAppSection' -import { - CPU_COLUMN_CLS, - formatCpu, - formatMemory, - MEM_COLUMN_CLS, - METRIC_COLUMNS_CLS, - ROW_TRAILING_GUTTER_CLS -} from './ResourceUsageMetrics' -import { ResourceUsageTree } from './ResourceUsageTree' -import { STATUS_BAR_CONTEXT_MENU_EXEMPT_PROPS } from './status-bar-context-menu-policy' -import type { ResourceUsageActions } from './use-resource-usage-actions' -import type { ResourceUsageFoundation } from './use-resource-usage-foundation' -import type { ResourceUsageProjection } from './use-resource-usage-projection' - -export function ResourceUsagePopoverContent({ - foundation, - projection, - actions -}: { - foundation: ResourceUsageFoundation - projection: ResourceUsageProjection - actions: ResourceUsageActions -}): React.JSX.Element { - const { - sortOption, - setSortOption, - daemonActions, - resourceSnapshot, - setPopoverBodyNode, - collapsedRepos, - collapsedWorktrees, - activeWorktreeId, - appCollapsed, - setAppCollapsed - } = foundation - const { - daemonUnreachable, - sessionsOnlyError, - totalCpu, - totalMemory, - memoryMetricCopy, - orphanCount, - unifiedRepos - } = projection - const { - toggleRepo, - toggleWorktree, - navigateToWorktree, - navigateToTab, - deleteWorktree, - handleKillSession, - handleOpenWorkspaceCleanup, - handleKillOrphans, - openSpaceResults - } = actions - - return ( - event.preventDefault()} - // Why: xterm focus must not dismiss the resource manager after tab activation. - onFocusOutside={(event) => event.preventDefault()} - > -
-
- - - {translate('auto.components.status.bar.StatusBar.d1e1a7a6bf', 'Resource Manager')} - -
- -
- - - - - - {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.c9382662bb', - 'Restart daemon' - )} - - - - - - - - {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.bd19fd7a59', - 'Kill all sessions' - )} - - -
-
- - {daemonUnreachable && ( -
- -
-
- {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.f8e0d794b4', - 'Daemon is not responding' - )} -
-
- {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.f85af9cda6', - 'Resource snapshots and terminal sessions are unavailable.' - )} -
-
- -
- )} - - {!daemonUnreachable && sessionsOnlyError && ( -
- - - {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.e7cf14ec78', - 'Terminal sessions unavailable. The list may be stale.' - )} - -
- )} - - {resourceSnapshot && ( -
-
- - - - {formatCpu(totalCpu)} - - - - {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.1fedf94eae', - 'Combined CPU load. Values above 100% mean more than one core is working at once.' - )} - - - · - - - - {formatMemory(totalMemory)}{' '} - - {memoryMetricCopy.summaryLabel} - - - - - {memoryMetricCopy.description} - - -
- {orphanCount > 0 && ( - - {orphanCount === 1 - ? translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.30ff2c3c31', - '{{value0}} orphan', - { value0: orphanCount } - ) - : translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.b8f4a2c1d0e3', - '{{value0}} orphans', - { value0: orphanCount } - )} - - )} -
- )} - - {/* Why: fixed height prevents list expansion and polling from moving the popover. */} -
- {(unifiedRepos.length > 0 || resourceSnapshot) && ( -
- -
-
- - -
- -
-
- )} - -
- {unifiedRepos.length > 0 && ( - - )} - - {unifiedRepos.length === 0 && resourceSnapshot && ( -
- {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.27a74f91f0', - 'Nothing running right now' - )} -
- )} - - {resourceSnapshot && ( - setAppCollapsed((value) => !value)} - /> - )} - - {!resourceSnapshot && !daemonUnreachable && ( -
- {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.888dad8c55', - 'Loading…' - )} -
- )} -
-
- -
- - {orphanCount > 0 ? ( - - ) : null} -
- - -
- ) -} diff --git a/src/renderer/src/components/status-bar/ResourceUsageTree.tsx b/src/renderer/src/components/status-bar/ResourceUsageTree.tsx deleted file mode 100644 index 3914bab48df..00000000000 --- a/src/renderer/src/components/status-bar/ResourceUsageTree.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import React, { useMemo } from 'react' -import { ChevronDown, ChevronRight } from 'lucide-react' -import { useWorktreeMap } from '../../store/selectors' -import { translate } from '@/i18n/i18n' -import type { - UnifiedProjectGroup, - UnifiedSessionRow, - UnifiedWorktreeRow -} from './resource-usage-merge-types' -import type { ResourceUsageSortOption } from './resource-usage-sort' -import { sortResourceUsageProjectGroups, sortResourceUsageWorktrees } from './resource-usage-sort' -import { ResourceUsageMetricPair, ROW_TRAILING_GUTTER_CLS } from './ResourceUsageMetrics' -import { ResourceUsageWorktreeRow } from './ResourceUsageWorktreeRow' - -export function ResourceUsageTree({ - repos, - sortOption, - collapsedRepos, - toggleRepo, - collapsedWorktrees, - activeWorktreeId, - toggleWorktree, - navigateToWorktree, - navigateToTab, - onDelete, - onKillSession -}: { - repos: UnifiedProjectGroup[] - sortOption: ResourceUsageSortOption - collapsedRepos: Set - toggleRepo: (repoId: string) => void - collapsedWorktrees: Set - activeWorktreeId: string | null - toggleWorktree: (worktreeId: string) => void - navigateToWorktree: (worktreeId: string) => void - navigateToTab: (tabId: string, paneKey: string | null) => void - onDelete: (worktreeId: string) => void - onKillSession: (session: UnifiedSessionRow) => void -}): React.JSX.Element { - const worktreeById = useWorktreeMap() - const sortedRepos = useMemo(() => { - const grouped = sortResourceUsageProjectGroups(repos, sortOption) - return grouped.map((repo) => ({ - ...repo, - worktrees: sortResourceUsageWorktrees(repo.worktrees, sortOption) - })) - }, [repos, sortOption]) - const renderWorktree = (worktree: UnifiedWorktreeRow): React.JSX.Element => { - const storeRecord = worktreeById.get(worktree.worktreeId) ?? null - return ( - toggleWorktree(worktree.worktreeId)} - onNavigate={() => navigateToWorktree(worktree.worktreeId)} - onDelete={() => onDelete(worktree.worktreeId)} - onKillSession={onKillSession} - navigateToTab={navigateToTab} - /> - ) - } - - if (sortedRepos.length === 1) { - return <>{sortedRepos[0].worktrees.map(renderWorktree)} - } - - return ( - <> - {sortedRepos.map((group) => { - const repoCollapsed = collapsedRepos.has(group.repoId) - return ( -
-
- -
- - - {group.repoName} - - {group.hasRemoteChildren && ( - - {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.21cacb16d1', - '· remote' - )} - - )} - -
- - -
-
-
- {!repoCollapsed && ( -
{group.worktrees.map(renderWorktree)}
- )} -
- ) - })} - - ) -} diff --git a/src/renderer/src/components/status-bar/ResourceUsageTrigger.tsx b/src/renderer/src/components/status-bar/ResourceUsageTrigger.tsx deleted file mode 100644 index 567be1d8a4b..00000000000 --- a/src/renderer/src/components/status-bar/ResourceUsageTrigger.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import React from 'react' -import { AlertTriangle, MemoryStick, Terminal } from 'lucide-react' -import { PopoverTrigger } from '@/components/ui/popover' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { translate } from '@/i18n/i18n' -import { STATUS_BAR_CONTEXT_MENU_EXEMPT_PROPS } from './status-bar-context-menu-policy' -import type { ResourceUsageProjection } from './use-resource-usage-projection' - -export function ResourceUsageTrigger({ - iconOnly, - spaceScanReady, - projection -}: { - iconOnly: boolean - spaceScanReady: boolean - projection: ResourceUsageProjection -}): React.JSX.Element { - const { - daemonUnreachable, - resourceManagerAriaLabel, - memBadgeLabel, - triggerSessionCount, - orphanCount, - resourceManagerTooltipLines - } = projection - - return ( - - - - - - - -
- {resourceManagerTooltipLines.map((line) => ( -
- {line.text} -
- ))} -
-
-
- ) -} diff --git a/src/renderer/src/components/status-bar/ResourceUsageWorktreeRow.tsx b/src/renderer/src/components/status-bar/ResourceUsageWorktreeRow.tsx deleted file mode 100644 index 741863d11f1..00000000000 --- a/src/renderer/src/components/status-bar/ResourceUsageWorktreeRow.tsx +++ /dev/null @@ -1,260 +0,0 @@ -import React from 'react' -import { ChevronDown, ChevronRight, Globe, Trash2, X } from 'lucide-react' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { cn } from '@/lib/utils' -import { translate } from '@/i18n/i18n' -import type { BrowserWorkspace } from '../../../../shared/browser-workspace-types' -import type { Worktree } from '../../../../shared/worktree/types' -import { ORPHAN_WORKTREE_ID } from '../../../../shared/constants' -import { UNATTRIBUTED_REPO_ID } from './mergeSnapshotAndSessions' -import type { UnifiedSessionRow, UnifiedWorktreeRow } from './resource-usage-merge-types' -import { isResourceSessionActivationKey } from './resource-session-navigation' -import { - ResourceUsageMetricPair, - ResourceUsageSparkline, - ROW_TRAILING_GUTTER_CLS -} from './ResourceUsageMetrics' - -export function ResourceUsageSessionRow({ - session, - worktreeId, - onNavigate, - onKill -}: { - session: UnifiedSessionRow - worktreeId: string - onNavigate: (tabId: string, paneKey: string | null) => void - onKill: (session: UnifiedSessionRow) => void -}): React.JSX.Element { - const clickable = session.tabId !== null && session.bound - const handleClick = (): void => { - if (clickable && session.tabId) { - onNavigate(session.tabId, session.paneKey) - } - } - - return ( -
{ - if (isResourceSessionActivationKey(event.key)) { - event.preventDefault() - handleClick() - } - } - : undefined - } - data-worktree-id={worktreeId} - > - - - {session.label} - - - {/* Why: the shared gutter aligns columns while keeping orphan kills visible. */} - - - -
- ) -} - -function BrowserRow({ browser }: { browser: BrowserWorkspace }): React.JSX.Element { - const label = browser.title?.trim() || browser.label?.trim() || browser.url - return ( -
- - {label} - - -
- ) -} - -export function ResourceUsageWorktreeRow({ - worktree, - storeRecord, - activeWorktreeId, - isCollapsed, - onToggle, - onNavigate, - onDelete, - onKillSession, - navigateToTab -}: { - worktree: UnifiedWorktreeRow - storeRecord: Worktree | null - activeWorktreeId: string | null - isCollapsed: boolean - onToggle: () => void - onNavigate: () => void - onDelete: () => void - onKillSession: (session: UnifiedSessionRow) => void - navigateToTab: (tabId: string, paneKey: string | null) => void -}): React.JSX.Element { - const hasResources = worktree.sessions.length > 0 || worktree.browsers.length > 0 - const isSynthetic = - worktree.worktreeId === ORPHAN_WORKTREE_ID || worktree.repoId === UNATTRIBUTED_REPO_ID - const isNavigable = !isSynthetic - const showWorktreeActions = - !isSynthetic && storeRecord !== null && worktree.worktreeId !== activeWorktreeId - const isMainWorktree = storeRecord?.isMainWorktree ?? false - const rowLabel = storeRecord?.displayName?.trim() || worktree.worktreeName - - return ( -
-
- {hasResources ? ( - - ) : ( - - )} - -
-
- - - - {showWorktreeActions && ( -
- - - - - - {isMainWorktree - ? translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.946724a70a', - 'The main workspace cannot be deleted.' - ) - : translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.a82253b458', - 'Delete workspace.' - )} - - -
- )} -
- - -
-
- {!isCollapsed && - worktree.sessions.map((session) => ( - - ))} - {!isCollapsed && - worktree.browsers.map((browser) => )} -
- ) -} diff --git a/src/renderer/src/components/status-bar/resource-usage-sort.ts b/src/renderer/src/components/status-bar/resource-usage-sort.ts deleted file mode 100644 index e66e5c8e458..00000000000 --- a/src/renderer/src/components/status-bar/resource-usage-sort.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { Metric, UnifiedProjectGroup, UnifiedWorktreeRow } from './resource-usage-merge-types' - -export type ResourceUsageSortOption = 'memory' | 'cpu' | 'name' - -function compareMetricDesc(left: Metric, right: Metric): number { - // Why: remote null metrics stay behind sampled rows for every sort direction. - if (left === null && right === null) { - return 0 - } - if (left === null) { - return 1 - } - if (right === null) { - return -1 - } - return right - left -} - -export function sortResourceUsageWorktrees( - list: UnifiedWorktreeRow[], - sort: ResourceUsageSortOption -): UnifiedWorktreeRow[] { - const copy = [...list] - if (sort === 'memory') { - copy.sort((left, right) => compareMetricDesc(left.memory, right.memory)) - } else if (sort === 'cpu') { - copy.sort((left, right) => compareMetricDesc(left.cpu, right.cpu)) - } else { - copy.sort((left, right) => left.worktreeName.localeCompare(right.worktreeName)) - } - return copy -} - -export function sortResourceUsageProjectGroups( - groups: UnifiedProjectGroup[], - sort: ResourceUsageSortOption -): UnifiedProjectGroup[] { - const copy = [...groups] - if (sort === 'memory') { - copy.sort((left, right) => compareMetricDesc(left.memory, right.memory)) - } else if (sort === 'cpu') { - copy.sort((left, right) => compareMetricDesc(left.cpu, right.cpu)) - } else { - copy.sort((left, right) => left.repoName.localeCompare(right.repoName)) - } - return copy -} diff --git a/src/renderer/src/components/status-bar/use-resource-usage-foundation.ts b/src/renderer/src/components/status-bar/use-resource-usage-foundation.ts deleted file mode 100644 index bfb0314cfca..00000000000 --- a/src/renderer/src/components/status-bar/use-resource-usage-foundation.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { useMountedRef } from '@/hooks/useMountedRef' -import { useAppStore } from '../../store' -import { useDaemonActions } from '../shared/useDaemonActions' -import type { UnifiedSessionRow } from './resource-usage-merge-types' -import type { ResourceUsageSortOption } from './resource-usage-sort' -import { - getResourceUsageAllWorktrees, - getResourceUsageBrowserTabsByWorktree, - getResourceUsageDeferredSshSessionIdsByTabId, - getResourceUsagePtyIdsByTabId, - getResourceUsageRepos, - getResourceUsageRuntimePaneTitlesByTabId, - getResourceUsageTerminalLayoutsByTabId, - getResourceUsageTabsByWorktree -} from './resource-usage-open-slices' -import { - resolveResourceUsageSpaceScanReady, - type ResourceUsageSpaceScanSnapshot -} from './resource-usage-space-scan-ready' -import type { ResourceSessionBindingInputs } from './resource-session-bindings' -import { useResourceSessionInventory } from './use-resource-session-inventory' - -const POLL_MS = 2_000 - -export function useResourceUsageFoundation() { - const snapshot = useAppStore((state) => state.memorySnapshot) - const memorySnapshotError = useAppStore((state) => state.memorySnapshotError) - const fetchSnapshot = useAppStore((state) => state.fetchMemorySnapshot) - const workspaceSessionReady = useAppStore((state) => state.workspaceSessionReady) - const setActiveView = useAppStore((state) => state.setActiveView) - const openModal = useAppStore((state) => state.openModal) - const openSpacePage = useAppStore((state) => state.openSpacePage) - const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction) - const activeView = useAppStore((state) => state.activeView) - const activeWorktreeId = useAppStore((state) => state.activeWorktreeId) - const workspaceSpaceScannedAt = useAppStore( - (state) => state.workspaceSpaceAnalysis?.scannedAt ?? null - ) - const workspaceSpaceScanning = useAppStore((state) => state.workspaceSpaceScanning) - const [open, setOpen] = useState(false) - const [sortOption, setSortOption] = useState('memory') - const [collapsedRepos, setCollapsedRepos] = useState>(new Set()) - const [collapsedWorktrees, setCollapsedWorktrees] = useState>(new Set()) - const [appCollapsed, setAppCollapsed] = useState(true) - const { - sessionInventory, - sessionsError, - refreshSessions, - clearSessionsError, - removeSession, - removeSessions - } = useResourceSessionInventory(workspaceSessionReady) - const sessions = sessionInventory.sessions - const [killConfirm, setKillConfirm] = useState(null) - const [killing, setKilling] = useState(false) - const [spaceScanSnapshot, setSpaceScanSnapshot] = useState( - () => ({ - ready: false, - previousScanning: workspaceSpaceScanning, - lastSeenScannedAt: workspaceSpaceScannedAt - }) - ) - // Why: title and binding maps churn; the closed trigger selects stable sentinels. - const runtimePaneTitlesByTabId = useAppStore((state) => - getResourceUsageRuntimePaneTitlesByTabId(state, open) - ) - const repos = useAppStore((state) => getResourceUsageRepos(state, open)) - const allWorktrees = useAppStore((state) => getResourceUsageAllWorktrees(state, open)) - const tabsByWorktree = useAppStore((state) => getResourceUsageTabsByWorktree(state, open)) - const browserTabsByWorktree = useAppStore((state) => - getResourceUsageBrowserTabsByWorktree(state, open) - ) - const ptyIdsByTabId = useAppStore((state) => getResourceUsagePtyIdsByTabId(state, open)) - const terminalLayoutsByTabId = useAppStore((state) => - getResourceUsageTerminalLayoutsByTabId(state, open) - ) - const deferredSshSessionIdsByTabId = useAppStore((state) => - getResourceUsageDeferredSshSessionIdsByTabId(state, open) - ) - const resourceSnapshot = snapshot - const resourceSessionBindings = useMemo( - () => ({ - ptyIdsByTabId, - tabsByWorktree, - terminalLayoutsByTabId, - deferredSshSessionIdsByTabId, - workspaceSessionReady - }), - [ - ptyIdsByTabId, - tabsByWorktree, - terminalLayoutsByTabId, - deferredSshSessionIdsByTabId, - workspaceSessionReady - ] - ) - const popoverBodyRef = useRef(null) - const popoverBodyFocusFrameRef = useRef(null) - const mountedRef = useMountedRef() - const cancelPopoverBodyFocusFrame = useCallback((): void => { - if (popoverBodyFocusFrameRef.current === null) { - return - } - cancelAnimationFrame(popoverBodyFocusFrameRef.current) - popoverBodyFocusFrameRef.current = null - }, []) - const setPopoverBodyNode = useCallback( - (node: HTMLDivElement | null): void => { - if (!node) { - cancelPopoverBodyFocusFrame() - } - popoverBodyRef.current = node - }, - [cancelPopoverBodyFocusFrame] - ) - const daemonActions = useDaemonActions({ - onRestartSettled: () => { - clearSessionsError() - void fetchSnapshot() - void refreshSessions() - } - }) - const nextSpaceScanSnapshot = resolveResourceUsageSpaceScanReady({ - snapshot: spaceScanSnapshot, - open, - activeView, - scannedAt: workspaceSpaceScannedAt, - scanning: workspaceSpaceScanning - }) - if ( - nextSpaceScanSnapshot.ready !== spaceScanSnapshot.ready || - nextSpaceScanSnapshot.previousScanning !== spaceScanSnapshot.previousScanning || - nextSpaceScanSnapshot.lastSeenScannedAt !== spaceScanSnapshot.lastSeenScannedAt - ) { - setSpaceScanSnapshot(nextSpaceScanSnapshot) - } - const spaceScanReady = nextSpaceScanSnapshot.ready - - // Why: seed RAM after session restore so the closed badge does not require a click. - useEffect(() => { - if (workspaceSessionReady) { - void fetchSnapshot() - } - }, [workspaceSessionReady, fetchSnapshot]) - - useEffect(() => { - if (!open) { - return - } - void fetchSnapshot() - void refreshSessions() - const memTimer = window.setInterval(() => { - void fetchSnapshot() - }, POLL_MS) - return () => { - window.clearInterval(memTimer) - } - }, [open, fetchSnapshot, refreshSessions]) - - useEffect(() => { - if (!open) { - clearSessionsError() - } - }, [open, clearSessionsError]) - - return { - snapshot, - memorySnapshotError, - workspaceSessionReady, - setActiveView, - openModal, - openSpacePage, - recordFeatureInteraction, - activeWorktreeId, - open, - setOpen, - sortOption, - setSortOption, - collapsedRepos, - setCollapsedRepos, - collapsedWorktrees, - setCollapsedWorktrees, - appCollapsed, - setAppCollapsed, - sessionInventory, - sessionsError, - refreshSessions, - removeSession, - removeSessions, - sessions, - killConfirm, - setKillConfirm, - killing, - setKilling, - runtimePaneTitlesByTabId, - repos, - allWorktrees, - tabsByWorktree, - browserTabsByWorktree, - resourceSnapshot, - resourceSessionBindings, - popoverBodyRef, - popoverBodyFocusFrameRef, - mountedRef, - cancelPopoverBodyFocusFrame, - setPopoverBodyNode, - daemonActions, - spaceScanReady - } -} - -export type ResourceUsageFoundation = ReturnType diff --git a/src/renderer/src/components/status-bar/use-resource-usage-projection.ts b/src/renderer/src/components/status-bar/use-resource-usage-projection.ts deleted file mode 100644 index 5b8145d58e0..00000000000 --- a/src/renderer/src/components/status-bar/use-resource-usage-projection.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { useEffect, useMemo, useState } from 'react' -import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' -import { countEstimatedInactiveWorkspaces } from '../workspace-cleanup/inactive-workspace-estimate' -import { mergeSnapshotAndSessions } from './mergeSnapshotAndSessions' -import { countUnboundDaemonSessions } from './resource-session-bindings' -import { - getResourceManagerAriaLabel, - getResourceManagerTooltipLines -} from './resource-manager-terminal-copy' -import { getResourceMemoryMetricCopy } from './resource-memory-metric-copy' -import { formatMemory } from './ResourceUsageMetrics' -import type { ResourceUsageFoundation } from './use-resource-usage-foundation' - -export function useResourceUsageProjection(foundation: ResourceUsageFoundation) { - const { - repos, - allWorktrees, - open, - resourceSnapshot, - sessions, - resourceSessionBindings, - runtimePaneTitlesByTabId, - browserTabsByWorktree, - workspaceSessionReady, - sessionInventory, - sessionsError, - memorySnapshotError, - snapshot, - spaceScanReady - } = foundation - - const repoDisplayNameById = useMemo(() => { - const map = new Map() - for (const repo of repos) { - const display = repo.displayName?.trim() - if (display) { - map.set(repo.id, display) - } - } - return map - }, [repos]) - - // Why: connectionId is the only honest signal that a repo runs over SSH. - const repoConnectionIdById = useMemo(() => { - const map = new Map() - for (const repo of repos) { - map.set(repo.id, repo.connectionId ?? null) - } - return map - }, [repos]) - - const repoRuntimeScopedById = useMemo(() => { - const map = new Map() - for (const repo of repos) { - const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) - map.set(repo.id, parsed?.kind === 'runtime') - } - return map - }, [repos]) - - const repoById = useMemo(() => new Map(repos.map((repo) => [repo.id, repo])), [repos]) - const worktreeById = useMemo( - () => new Map(allWorktrees.map((worktree) => [worktree.id, worktree])), - [allWorktrees] - ) - const [oldWorkspaceCount, setOldWorkspaceCount] = useState(0) - useEffect(() => { - setOldWorkspaceCount(countEstimatedInactiveWorkspaces(allWorktrees, repoById, Date.now())) - }, [allWorktrees, repoById]) - - // Why: the closed segment must not merge on keystroke-driven store updates. - const unifiedRepos = useMemo( - () => - open - ? mergeSnapshotAndSessions(resourceSnapshot, sessions, { - ...resourceSessionBindings, - runtimePaneTitlesByTabId, - repoDisplayNameById, - repoConnectionIdById, - repoRuntimeScopedById, - browserTabsByWorktree, - worktreeById - }) - : [], - [ - open, - resourceSnapshot, - sessions, - resourceSessionBindings, - runtimePaneTitlesByTabId, - repoDisplayNameById, - repoConnectionIdById, - repoRuntimeScopedById, - browserTabsByWorktree, - worktreeById - ] - ) - - const orphanCount = useMemo(() => { - if (!open || !workspaceSessionReady) { - return 0 - } - return countUnboundDaemonSessions(sessions, resourceSessionBindings) - }, [open, sessions, resourceSessionBindings, workspaceSessionReady]) - - const triggerSessionCount = sessionInventory.count - const memoryMetricCopy = getResourceMemoryMetricCopy( - resourceSnapshot?.processMemoryMetric ?? 'rss' - ) - const { totalMemory, totalCpu, memBadgeLabel } = useMemo(() => { - const memory = resourceSnapshot?.totalMemory ?? 0 - const cpu = resourceSnapshot?.totalCpu ?? 0 - return { - totalMemory: memory, - totalCpu: cpu, - memBadgeLabel: resourceSnapshot ? formatMemory(memory) : '—' - } - }, [resourceSnapshot]) - - const daemonUnreachable = sessionsError && (memorySnapshotError !== null || snapshot === null) - const sessionsOnlyError = sessionsError && memorySnapshotError === null - const resourceManagerTooltipLines = getResourceManagerTooltipLines({ - memoryLabel: resourceSnapshot - ? `${memBadgeLabel} · ${memoryMetricCopy.summaryLabel}` - : memBadgeLabel, - sessionCount: triggerSessionCount, - spaceScanReady - }) - const resourceManagerAriaLabel = getResourceManagerAriaLabel({ - sessionCount: triggerSessionCount, - spaceScanReady - }) - - return { - oldWorkspaceCount, - unifiedRepos, - orphanCount, - triggerSessionCount, - memoryMetricCopy, - totalMemory, - totalCpu, - memBadgeLabel, - daemonUnreachable, - sessionsOnlyError, - resourceManagerTooltipLines, - resourceManagerAriaLabel - } -} - -export type ResourceUsageProjection = ReturnType diff --git a/src/renderer/src/components/status-bar/workspace-space-breakdown-list.test.tsx b/src/renderer/src/components/status-bar/workspace-space-breakdown-list.test.tsx new file mode 100644 index 00000000000..52f1f167e50 --- /dev/null +++ b/src/renderer/src/components/status-bar/workspace-space-breakdown-list.test.tsx @@ -0,0 +1,111 @@ +// @vitest-environment happy-dom + +import { cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { WorkspaceSpaceBreakdownList } from './workspace-space-breakdown-list' +import type { + WorkspaceSpaceItem, + WorkspaceSpaceWorktree +} from '../../../../shared/workspace-space-types' + +function item(name: string, sizeBytes: number): WorkspaceSpaceItem { + return { name, path: `/workspace/${name}`, kind: 'directory', sizeBytes } +} + +function worktree(overrides: Partial): WorkspaceSpaceWorktree { + return { + worktreeId: 'wt', + repoId: 'repo', + repoDisplayName: 'repo', + repoPath: '/repo', + displayName: 'workspace', + path: '/workspace', + branch: 'refs/heads/main', + isMainWorktree: false, + isRemote: false, + isSparse: false, + canDelete: true, + lastActivityAt: 0, + status: 'ok', + error: null, + scannedAt: 0, + sizeBytes: 0, + reclaimableBytes: 0, + skippedEntryCount: 0, + topLevelItems: [], + omittedTopLevelItemCount: 0, + omittedTopLevelSizeBytes: 0, + ...overrides + } +} + +function renderedRowNames(container: HTMLElement): string[] { + return Array.from(container.querySelectorAll('span.font-medium')).map( + (node) => node.textContent ?? '' + ) +} + +afterEach(cleanup) + +describe('WorkspaceSpaceBreakdownList', () => { + it('renders one row per counted top-level item, including the omitted aggregate', () => { + const { container } = render( + + ) + + const names = renderedRowNames(container) + expect(names).toEqual(['node_modules', 'src', 'Other top-level items (7)']) + // The header count labels this list: the 7 omitted items are one aggregate row. + expect(container.textContent).toContain('9 top-level items') + expect(names.length - 1 + 7).toBe(9) + }) + + it('scales the size bars against the omitted aggregate when it is the largest item', () => { + const { container } = render( + + ) + + const widths = Array.from(container.querySelectorAll('div[style]')).map( + (node) => node.style.width + ) + expect(widths).toEqual(['25%', '100%']) + }) + + it('omits the aggregate row when nothing was omitted', () => { + const { container } = render( + + ) + + expect(renderedRowNames(container)).toEqual(['src']) + expect(container.textContent).toContain('1 top-level items') + }) + + it('shows the omitted aggregate rather than an empty state when every item was omitted', () => { + const { container } = render( + + ) + + expect(container.textContent).not.toContain('No files found.') + expect(renderedRowNames(container)).toEqual(['Other top-level items (4)']) + }) +}) diff --git a/src/renderer/src/components/status-bar/workspace-space-breakdown-list.tsx b/src/renderer/src/components/status-bar/workspace-space-breakdown-list.tsx index cda1a638c04..01530e17ba8 100644 --- a/src/renderer/src/components/status-bar/workspace-space-breakdown-list.tsx +++ b/src/renderer/src/components/status-bar/workspace-space-breakdown-list.tsx @@ -48,8 +48,24 @@ export function WorkspaceSpaceBreakdownList({ ) } - const maxChildSize = getLargestWorkspaceSpaceItemSize(worktree.topLevelItems) + const maxChildSize = Math.max( + getLargestWorkspaceSpaceItemSize(worktree.topLevelItems), + worktree.omittedTopLevelSizeBytes + ) const topLevelItemCount = worktree.topLevelItems.length + worktree.omittedTopLevelItemCount + const omittedItem: WorkspaceSpaceItem | null = + worktree.omittedTopLevelItemCount > 0 + ? { + name: translate( + 'components.status.bar.workspaceSpace.otherTopLevelItems', + 'Other top-level items ({{value0}})', + { value0: worktree.omittedTopLevelItemCount } + ), + path: '', + kind: 'other', + sizeBytes: worktree.omittedTopLevelSizeBytes + } + : null return (
@@ -86,7 +102,7 @@ export function WorkspaceSpaceBreakdownList({ )}
- ) : worktree.topLevelItems.length === 0 ? ( + ) : topLevelItemCount === 0 ? (
{translate( 'auto.components.status.bar.WorkspaceSpaceManagerPanel.16988df079', @@ -99,6 +115,7 @@ export function WorkspaceSpaceBreakdownList({ {worktree.topLevelItems.slice(0, 12).map((item) => ( ))} + {omittedItem ? : null}
)} diff --git a/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts b/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts index 666db63968f..08b4b4bc9d9 100644 --- a/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts +++ b/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts @@ -19,7 +19,7 @@ import { sortWorkspaceSpaceRows } from './workspace-space-presentation' import { getWorkspaceSpaceGitStatusRefreshCandidates } from './workspace-space-git-status-order' -import { getWorkspaceDecisionDetails } from './WorkspaceSpaceManagerPanel' +import { getWorkspaceDecisionDetails } from './workspace-space-decision-details' import { getWorkspaceSpaceDeleteState, getWorkspaceSpaceGitStatusForScan @@ -452,6 +452,117 @@ describe('workspace space presentation helpers', () => { expect(details.reviewLabel).toBeNull() }) + it('hides only a matching suppressed GitHub review from workspace decisions', () => { + const matching = getWorkspaceDecisionDetails( + row({ branch: 'refs/heads/feature/local' }), + decisionInputs({ + hostedReviewCache: { + 'local::repo::feature/local': { + data: { + provider: 'github', + number: 12, + state: 'open', + status: 'success', + title: 'Suppressed PR' + } + } + }, + worktreeMap: new Map([ + [ + 'wt', + worktreeRecord({ + branch: 'refs/heads/feature/local', + linkedPR: null, + suppressedGitHubPR: 12 + }) + ] + ]) + }) + ) + const different = getWorkspaceDecisionDetails( + row({ branch: 'refs/heads/feature/local' }), + decisionInputs({ + hostedReviewCache: { + 'local::repo::feature/local': { + data: { + provider: 'github', + number: 13, + state: 'open', + status: 'success', + title: 'Different PR' + } + } + }, + worktreeMap: new Map([ + [ + 'wt', + worktreeRecord({ + branch: 'refs/heads/feature/local', + linkedPR: null, + suppressedGitHubPR: 12 + }) + ] + ]) + }) + ) + + expect(matching.reviewLabel).toBeNull() + expect(different.reviewLabel).toBe('PR #13 Open, success') + }) + + it('preserves explicit links and non-GitHub reviews in workspace decisions', () => { + const cachedReview = { + number: 12, + state: 'open', + status: 'success', + title: 'Review' + } + const explicit = getWorkspaceDecisionDetails( + row({ branch: 'refs/heads/feature/local' }), + decisionInputs({ + hostedReviewCache: { + 'local::repo::feature/local': { + data: { ...cachedReview, provider: 'github' } + } + }, + worktreeMap: new Map([ + [ + 'wt', + worktreeRecord({ + branch: 'refs/heads/feature/local', + linkedPR: 12, + suppressedGitHubPR: 12 + }) + ] + ]) + }) + ) + const gitLab = getWorkspaceDecisionDetails( + row({ branch: 'refs/heads/feature/local' }), + decisionInputs({ + hostedReviewCache: { + 'local::repo::feature/local': { + data: { ...cachedReview, provider: 'gitlab' } + } + }, + worktreeMap: new Map([ + [ + 'wt', + worktreeRecord({ + branch: 'refs/heads/feature/local', + linkedPR: null, + suppressedGitHubPR: 12, + linkedGitLabMR: 12 + }) + ] + ]) + }) + ) + + expect(explicit.reviewLabel).toBe('PR #12 Open, success') + expect(gitLab.reviewLabel).toBe('PR #12 Open, success') + }) + it('counts migration-unsupported agent entries by worktree id', () => { const count = countWorkspaceSpaceActiveAgents({ worktreeId: 'wt', @@ -484,6 +595,27 @@ describe('workspace space presentation helpers', () => { ).toEqual(rows.map((item) => item.worktreeId)) }) + it('orders git-status refreshes active first, then visible, then the rest', () => { + const rows = [ + row({ worktreeId: 'rest-a', executionHostId: 'local' }), + row({ worktreeId: 'visible-a', executionHostId: 'local' }), + row({ worktreeId: 'active', executionHostId: 'ssh:builder' }), + row({ worktreeId: 'visible-b', executionHostId: 'local' }), + row({ worktreeId: 'rest-b', executionHostId: 'local' }) + ] + const visibleWorktreeIdentities = new Set( + [rows[1], rows[3]].map(getWorkspaceSpaceWorktreeIdentity) + ) + + expect( + getWorkspaceSpaceGitStatusRefreshCandidates(rows, { + activeWorktreeId: 'active', + activeExecutionHostId: 'ssh:builder', + visibleWorktreeIdentities + }).map((item) => item.worktreeId) + ).toEqual(['active', 'visible-a', 'visible-b', 'rest-a', 'rest-b']) + }) + it('resolves inspected worktree ids from the current scan rows', () => { const rows = [ row({ worktreeId: 'errored', status: 'error' }), diff --git a/src/renderer/src/components/terminal-workspace-editor-shortcuts.ts b/src/renderer/src/components/terminal-workspace-editor-shortcuts.ts new file mode 100644 index 00000000000..22f88cdbf20 --- /dev/null +++ b/src/renderer/src/components/terminal-workspace-editor-shortcuts.ts @@ -0,0 +1,67 @@ +import type { KeybindingActionId } from '../../../shared/keybindings' +import { useAppStore } from '../store' +import { + ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT, + type EditorRequestCmdSaveDetail +} from './editor/editor-autosave' +import { getEditorCmdSaveFileId } from './editor/editor-cmd-save-target' +import { isEventTargetInsideFloatingWorkspacePanel } from '@/lib/floating-workspace-terminal-actions' + +type EditorShortcutContext = { + event: KeyboardEvent + floatingWorkspaceFocused: boolean + matchShortcut: (actionId: KeybindingActionId) => boolean + notifyTerminalCapture: (actionId: KeybindingActionId) => void +} + +// Returns true only when the chord was consumed, so unclaimed editor chords still +// fall through to the remaining workspace shortcuts. +export function handleTerminalWorkspaceEditorShortcut({ + event, + floatingWorkspaceFocused, + matchShortcut, + notifyTerminalCapture +}: EditorShortcutContext): boolean { + // Save active editor file — fallback for when focus is outside the editor (tab bar/sidebar); editor-local handlers own save when the editor is focused. + if (!event.repeat && matchShortcut('editor.save')) { + const target = event.target as HTMLElement | null + const inEditor = + target?.closest('.monaco-editor, [contenteditable]') !== null || + target?.closest('textarea:not(.xterm-helper-textarea), input') !== null + if (!inEditor) { + const state = useAppStore.getState() + const floatingPanelOwnsEvent = + isEventTargetInsideFloatingWorkspacePanel(event.target) || floatingWorkspaceFocused + const requestedFileId = getEditorCmdSaveFileId(state, floatingPanelOwnsEvent) + if (requestedFileId) { + event.preventDefault() + notifyTerminalCapture('editor.save') + window.dispatchEvent( + new CustomEvent(ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT, { + detail: { fileId: requestedFileId } + }) + ) + return true + } + } + } + // Why: long/structured files need a discoverable unwrap path without Settings (#9974). + if (!event.repeat && matchShortcut('editor.toggleWordWrap')) { + const state = useAppStore.getState() + if (state.activeTabType === 'editor' && state.activeFileId) { + event.preventDefault() + notifyTerminalCapture('editor.toggleWordWrap') + // Why: diff surfaces use diffWordWrap; plain editors use editorWordWrap (#10086). + const activeFile = state.openFiles.find((file) => file.id === state.activeFileId) + if (activeFile?.mode === 'diff') { + const wrapOn = state.settings?.diffWordWrap === true + void state.updateSettings({ diffWordWrap: !wrapOn }) + } else { + const wrapOn = state.settings?.editorWordWrap !== false + void state.updateSettings({ editorWordWrap: !wrapOn }) + } + return true + } + } + return false +} diff --git a/src/renderer/src/components/terminal-workspace-keydown.test.ts b/src/renderer/src/components/terminal-workspace-keydown.test.ts new file mode 100644 index 00000000000..015b9708a11 --- /dev/null +++ b/src/renderer/src/components/terminal-workspace-keydown.test.ts @@ -0,0 +1,104 @@ +// @vitest-environment happy-dom + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +import { + ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT, + type EditorRequestCmdSaveDetail +} from './editor/editor-autosave' +import { handleTerminalWorkspaceKeyDown } from './terminal-workspace-keydown' +import type { TerminalActivationController } from './use-terminal-activation-actions' + +const mocks = vi.hoisted(() => ({ + state: {} as Record, + floatingFocused: false, + targetInsideFloatingPanel: false +})) + +vi.mock('../store', () => ({ useAppStore: { getState: () => mocks.state } })) +vi.mock('../hooks/ipc-tab-switch', () => ({ + handleSwitchRecentTab: vi.fn(), + handleSwitchTab: vi.fn(), + handleSwitchTabAcrossAllTypes: vi.fn(), + handleSwitchTerminalTab: vi.fn() +})) +vi.mock('@/lib/floating-workspace-terminal-actions', () => ({ + createFloatingWorkspaceBrowserTab: vi.fn(), + createFloatingWorkspaceMarkdownTab: vi.fn(), + createFloatingWorkspaceTerminalTab: vi.fn(), + handleEmptyFloatingWorkspacePanelCloseShortcut: () => false, + isEventTargetInsideFloatingWorkspacePanel: () => mocks.targetInsideFloatingPanel, + isFloatingWorkspacePanelFocused: () => mocks.floatingFocused, + switchFloatingWorkspaceTab: vi.fn() +})) +vi.mock('@/lib/terminal-shortcut-capture-notification', () => ({ + showTerminalShortcutCaptureNotification: vi.fn() +})) +vi.mock('./terminal-agent-tab-shortcut', () => ({ + resolveTerminalAgentTabShortcut: () => ({ actionId: null, agent: null }) +})) + +const controller = { + activeWorktreeId: 'repo-1::/repo/worktree', + handleCloseAllFiles: vi.fn(), + handleCloseBrowserTab: vi.fn(), + handleCloseFile: vi.fn(), + handleNewAgentTab: vi.fn(), + handleNewBrowserTab: vi.fn(), + handleNewFile: vi.fn(), + handleNewSimulatorTab: vi.fn(), + handleNewTab: vi.fn(), + keybindings: undefined, + mobileEmulatorEnabled: false, + terminalShortcutPolicy: 'orca-first' +} as unknown as TerminalActivationController + +function pressCmdS(): (EditorRequestCmdSaveDetail | undefined)[] { + const details: (EditorRequestCmdSaveDetail | undefined)[] = [] + const listener = (event: Event): void => { + details.push((event as CustomEvent).detail ?? undefined) + } + window.addEventListener(ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT, listener) + const target = document.createElement('div') + document.body.appendChild(target) + const event = new KeyboardEvent('keydown', { key: 's', metaKey: true, cancelable: true }) + Object.defineProperty(event, 'target', { value: target }) + try { + handleTerminalWorkspaceKeyDown(event, controller, 'darwin') + } finally { + window.removeEventListener(ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT, listener) + target.remove() + } + return details +} + +describe('handleTerminalWorkspaceKeyDown editor.save', () => { + beforeEach(() => { + mocks.floatingFocused = false + mocks.targetInsideFloatingPanel = false + mocks.state = { + activeView: 'terminal', + activeTabType: 'editor', + activeFileId: 'file-1', + getActiveTab: () => null + } + }) + + it('dispatches the save request with the resolved file id', () => { + expect(pressCmdS()).toEqual([{ fileId: 'file-1' }]) + }) + + it('resolves the floating panel editor when the panel owns the event', () => { + mocks.targetInsideFloatingPanel = true + mocks.state.getActiveTab = (worktreeId: string) => + worktreeId === FLOATING_TERMINAL_WORKTREE_ID + ? { contentType: 'editor', entityId: 'floating-file' } + : null + expect(pressCmdS()).toEqual([{ fileId: 'floating-file' }]) + }) + + it('does not swallow the chord outside the workspace view', () => { + mocks.state.activeView = 'tasks' + expect(pressCmdS()).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/terminal-workspace-keydown.ts b/src/renderer/src/components/terminal-workspace-keydown.ts index 522a203ffff..dd6e1e70878 100644 --- a/src/renderer/src/components/terminal-workspace-keydown.ts +++ b/src/renderer/src/components/terminal-workspace-keydown.ts @@ -3,7 +3,6 @@ import type { KeybindingActionId } from '../../../shared/keybindings' import { keybindingMatchesAction } from '../../../shared/keybindings' import { matchesRecentTabSwitcherChord } from '../../../shared/window-shortcut-policy' import { useAppStore } from '../store' -import { ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT } from './editor/editor-autosave' import { handleSwitchRecentTab, handleSwitchTab, @@ -20,9 +19,15 @@ import { switchFloatingWorkspaceTab } from '@/lib/floating-workspace-terminal-actions' import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut-capture-notification' +import { + ensureClientCreationActionAllowed, + showClientCreationActionError +} from '@/lib/client-creation-action-error' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { translate } from '@/i18n/i18n' import { getKeybindingContext } from './terminal-workspace-model' import { resolveTerminalAgentTabShortcut } from './terminal-agent-tab-shortcut' +import { handleTerminalWorkspaceEditorShortcut } from './terminal-workspace-editor-shortcuts' import type { TerminalActivationController } from './use-terminal-activation-actions' export function handleTerminalWorkspaceKeyDown( @@ -101,14 +106,26 @@ export function handleTerminalWorkspaceKeyDown( if (!event.repeat && matchShortcut('tab.reopenClosed')) { event.preventDefault() notifyTerminalCapture('tab.reopenClosed') - useAppStore.getState().reopenClosedTab(activeWorktreeId) + try { + useAppStore.getState().reopenClosedTab(activeWorktreeId) + } catch (error) { + showClientCreationActionError(error) + } return } if (!event.repeat && matchShortcut('tab.newBrowser')) { event.preventDefault() notifyTerminalCapture('tab.newBrowser') + const browserWorkspaceId = floatingWorkspaceFocused + ? FLOATING_TERMINAL_WORKTREE_ID + : activeWorktreeId + if (!ensureClientCreationActionAllowed(browserWorkspaceId, 'managed-browser')) { + return + } if (floatingWorkspaceFocused) { - void createFloatingWorkspaceBrowserTab(useAppStore.getState()) + void createFloatingWorkspaceBrowserTab(useAppStore.getState()).catch( + showClientCreationActionError + ) return } handleNewBrowserTab() @@ -117,41 +134,23 @@ export function handleTerminalWorkspaceKeyDown( if (!event.repeat && mobileEmulatorEnabled && matchShortcut('tab.newSimulator')) { event.preventDefault() notifyTerminalCapture('tab.newSimulator') + if (!ensureClientCreationActionAllowed(activeWorktreeId, 'mobile-emulator')) { + return + } if (!floatingWorkspaceFocused) { handleNewSimulatorTab() } return } - if (!event.repeat && matchShortcut('editor.save')) { - const target = event.target as HTMLElement | null - const inEditor = - target?.closest('.monaco-editor, [contenteditable]') !== null || - target?.closest('textarea:not(.xterm-helper-textarea), input') !== null - if (!inEditor) { - const state = useAppStore.getState() - if (state.activeTabType === 'editor' && state.activeFileId) { - event.preventDefault() - notifyTerminalCapture('editor.save') - window.dispatchEvent(new Event(ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT)) - return - } - } - } - if (!event.repeat && matchShortcut('editor.toggleWordWrap')) { - const state = useAppStore.getState() - if (state.activeTabType === 'editor' && state.activeFileId) { - event.preventDefault() - notifyTerminalCapture('editor.toggleWordWrap') - const activeFile = state.openFiles.find((file) => file.id === state.activeFileId) - if (activeFile?.mode === 'diff') { - const wrapOn = state.settings?.diffWordWrap === true - void state.updateSettings({ diffWordWrap: !wrapOn }) - } else { - const wrapOn = state.settings?.editorWordWrap !== false - void state.updateSettings({ editorWordWrap: !wrapOn }) - } - return - } + if ( + handleTerminalWorkspaceEditorShortcut({ + event, + floatingWorkspaceFocused, + matchShortcut, + notifyTerminalCapture + }) + ) { + return } if (!event.repeat && matchShortcut('tab.newMarkdown')) { event.preventDefault() diff --git a/src/renderer/src/components/use-terminal-create-actions.test.tsx b/src/renderer/src/components/use-terminal-create-actions.test.tsx new file mode 100644 index 00000000000..b12fd7730c6 --- /dev/null +++ b/src/renderer/src/components/use-terminal-create-actions.test.tsx @@ -0,0 +1,119 @@ +// @vitest-environment happy-dom + +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ClientCreationActionAvailability } from '@/lib/client-creation-action-policy' +import { useTerminalCreateActions } from './use-terminal-create-actions' +import type { TerminalColdActivationController } from './terminal-cold-activation' + +const mocks = vi.hoisted(() => ({ + browserAvailability: { + state: 'enabled', + provider: 'local-client' + } as ClientCreationActionAvailability, + simulatorAvailability: { + state: 'enabled', + provider: 'local-client' + } as ClientCreationActionAvailability, + state: {} as Record, + toastError: vi.fn(), + createBrowserTab: vi.fn(), + openNewBrowserTabInActiveWorkspace: vi.fn(), + openMobileEmulatorTab: vi.fn() +})) + +vi.mock('../store', () => ({ useAppStore: { getState: () => mocks.state } })) +vi.mock('sonner', () => ({ + toast: { error: (...args: unknown[]) => mocks.toastError(...args), message: vi.fn() } +})) +vi.mock('@/lib/client-creation-action-policy', () => ({ + getClientCreationActionPolicy: () => ({ + 'managed-browser': mocks.browserAvailability, + 'mobile-emulator': mocks.simulatorAvailability + }) +})) +vi.mock('@/lib/focus-terminal-tab-surface', () => ({ focusTerminalTabSurface: vi.fn() })) +vi.mock('@/runtime/web-runtime-session', () => ({ + createWebRuntimeSessionBrowserTab: vi.fn(), + createWebRuntimeSessionTerminal: vi.fn(), + isWebRuntimeSessionActive: () => false +})) +vi.mock('@/lib/open-mobile-emulator-tab', () => ({ + openMobileEmulatorTab: (...args: unknown[]) => mocks.openMobileEmulatorTab(...args) +})) +vi.mock('@/lib/launch-agent-in-new-tab', () => ({ launchAgentInNewTab: vi.fn() })) +vi.mock('@/lib/duplicate-browser-tab-options', () => ({ + buildDuplicatedBrowserTabOptions: () => ({}) +})) +vi.mock('@/runtime/remote-browser-tab-ownership', () => ({ + browserWorkspaceHasRemoteOwner: () => false +})) +vi.mock('./tab-bar/tab-create-entry-action', () => ({ openTabBarEntry: vi.fn() })) +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('./terminal-workspace-model', () => ({ + getActiveWorktreeRuntimeEnvironmentId: () => null +})) + +const WORKTREE_ID = 'repo-1::/repo/worktree' + +function renderActions() { + return renderHook(() => + useTerminalCreateActions({ + activeWorktreeId: WORKTREE_ID, + createBrowserTab: mocks.createBrowserTab, + createTab: vi.fn(), + openNewBrowserTabInActiveWorkspace: mocks.openNewBrowserTabInActiveWorkspace, + openNewMarkdownInActiveWorkspace: vi.fn(), + openNewTerminalTabInActiveWorkspace: vi.fn(), + setActiveTabType: vi.fn(), + setTabBarOrder: vi.fn() + } as unknown as TerminalColdActivationController) + ).result.current +} + +describe('useTerminalCreateActions creation gates', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.browserAvailability = { state: 'enabled', provider: 'local-client' } + mocks.simulatorAvailability = { state: 'enabled', provider: 'local-client' } + mocks.state = { + activeGroupIdByWorktree: {}, + groupsByWorktree: {}, + browserDefaultUrl: 'about:blank', + browserTabsByWorktree: { [WORKTREE_ID]: [{ id: 'browser-1', url: 'https://example.com' }] } + } + }) + + it('toasts instead of creating a browser tab when the provider forbids it', () => { + mocks.browserAvailability = { state: 'hidden', reason: 'no browser here' } + renderActions().handleNewBrowserTab() + expect(mocks.toastError).toHaveBeenCalledWith('no browser here') + expect(mocks.createBrowserTab).not.toHaveBeenCalled() + }) + + it('toasts instead of duplicating a browser tab when the provider forbids it', () => { + mocks.browserAvailability = { state: 'hidden', reason: 'no browser here' } + renderActions().handleDuplicateBrowserTab('browser-1') + expect(mocks.toastError).toHaveBeenCalledWith('no browser here') + expect(mocks.createBrowserTab).not.toHaveBeenCalled() + }) + + it('reports a rejected workspace browser open instead of leaving it unhandled', async () => { + mocks.state.activeGroupIdByWorktree = { [WORKTREE_ID]: 'group-1' } + mocks.openNewBrowserTabInActiveWorkspace.mockRejectedValue(new Error('runtime says no')) + const unhandled = vi.fn() + process.on('unhandledRejection', unhandled) + renderActions().handleNewBrowserTab() + await new Promise((resolve) => setTimeout(resolve, 0)) + process.off('unhandledRejection', unhandled) + expect(mocks.toastError).toHaveBeenCalledWith('runtime says no') + expect(unhandled).not.toHaveBeenCalled() + }) + + it('reports a rejected simulator open instead of leaving it unhandled', async () => { + mocks.openMobileEmulatorTab.mockRejectedValue(new Error('emulator says no')) + renderActions().handleNewSimulatorTab() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(mocks.toastError).toHaveBeenCalledWith('emulator says no') + }) +}) diff --git a/src/renderer/src/components/use-terminal-create-actions.ts b/src/renderer/src/components/use-terminal-create-actions.ts index 57bbdf2c1be..927e8e52b04 100644 --- a/src/renderer/src/components/use-terminal-create-actions.ts +++ b/src/renderer/src/components/use-terminal-create-actions.ts @@ -12,6 +12,8 @@ import { openMobileEmulatorTab } from '@/lib/open-mobile-emulator-tab' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' import { buildDuplicatedBrowserTabOptions } from '@/lib/duplicate-browser-tab-options' import { browserWorkspaceHasRemoteOwner } from '@/runtime/remote-browser-tab-ownership' +import { getClientCreationActionPolicy } from '@/lib/client-creation-action-policy' +import { showClientCreationActionError } from '@/lib/client-creation-action-error' import { openTabBarEntry, type TabCreateEntryArgs } from './tab-bar/tab-create-entry-action' import { translate } from '@/i18n/i18n' import { getActiveWorktreeRuntimeEnvironmentId } from './terminal-workspace-model' @@ -122,7 +124,7 @@ export function useTerminalCreateActions(controller: TerminalColdActivationContr void openMobileEmulatorTab(activeWorktreeId, { placement: 'rightSplit', targetGroupId: targetGroupId ?? undefined - }) + }).catch(showClientCreationActionError) }, [activeWorktreeId]) const handleNewBrowserTab = useCallback(() => { @@ -133,22 +135,31 @@ export function useTerminalCreateActions(controller: TerminalColdActivationContr useAppStore.getState().activeGroupIdByWorktree[activeWorktreeId] ?? useAppStore.getState().groupsByWorktree[activeWorktreeId]?.[0]?.id if (targetGroupId) { - void openNewBrowserTabInActiveWorkspace(targetGroupId) + void openNewBrowserTabInActiveWorkspace(targetGroupId).catch(showClientCreationActionError) return } - const defaultUrl = useAppStore.getState().browserDefaultUrl ?? 'about:blank' + const state = useAppStore.getState() + const browserAvailability = getClientCreationActionPolicy(state, activeWorktreeId)[ + 'managed-browser' + ] + if (browserAvailability.state !== 'enabled') { + toast.error(browserAvailability.reason) + return + } + const defaultUrl = state.browserDefaultUrl ?? 'about:blank' const runtimeEnvironmentId = getActiveWorktreeRuntimeEnvironmentId(activeWorktreeId) - if (isWebRuntimeSessionActive(runtimeEnvironmentId)) { + if (browserAvailability.provider === 'paired-runtime' && runtimeEnvironmentId) { void createWebRuntimeSessionBrowserTab({ worktreeId: activeWorktreeId, environmentId: runtimeEnvironmentId, url: defaultUrl - }) + }).catch(showClientCreationActionError) return } createBrowserTab(activeWorktreeId, defaultUrl, { title: translate('auto.components.Terminal.37da0d736f', 'New Browser Tab'), - focusAddressBar: true + focusAddressBar: true, + ...(runtimeEnvironmentId ? { browserRuntimeEnvironmentId: null } : {}) }) }, [activeWorktreeId, createBrowserTab, openNewBrowserTabInActiveWorkspace]) @@ -168,8 +179,16 @@ export function useTerminalCreateActions(controller: TerminalColdActivationContr return } const runtimeEnvironmentId = getActiveWorktreeRuntimeEnvironmentId(activeWorktreeId) + const browserAvailability = getClientCreationActionPolicy(state, activeWorktreeId)[ + 'managed-browser' + ] + if (browserAvailability.state !== 'enabled') { + toast.error(browserAvailability.reason) + return + } if ( - isWebRuntimeSessionActive(runtimeEnvironmentId) && + browserAvailability.provider === 'paired-runtime' && + runtimeEnvironmentId && browserWorkspaceHasRemoteOwner(state, source.id, runtimeEnvironmentId) ) { void createWebRuntimeSessionBrowserTab({ @@ -177,12 +196,17 @@ export function useTerminalCreateActions(controller: TerminalColdActivationContr environmentId: runtimeEnvironmentId, url: source.url, profileId: source.sessionProfileId - }) + }).catch(showClientCreationActionError) return } - createBrowserTab(activeWorktreeId, source.url, { - ...buildDuplicatedBrowserTabOptions(source) - }) + try { + createBrowserTab(activeWorktreeId, source.url, { + ...buildDuplicatedBrowserTabOptions(source), + ...(runtimeEnvironmentId ? { browserRuntimeEnvironmentId: null } : {}) + }) + } catch (error) { + showClientCreationActionError(error) + } }, [activeWorktreeId, createBrowserTab] ) diff --git a/src/renderer/src/components/use-terminal-parking-pass.ts b/src/renderer/src/components/use-terminal-parking-pass.ts index b5065c9355a..01733c3c09c 100644 --- a/src/renderer/src/components/use-terminal-parking-pass.ts +++ b/src/renderer/src/components/use-terminal-parking-pass.ts @@ -2,11 +2,14 @@ import { useEffect } from 'react' import { useAppStore } from '../store' import { TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS, + countEvictionExemptTabRoutes, + formatEvictionExemptRouteCounts, hasPendingRetentionSpawnWork, selectForceParkEvictableTabIds, selectRetentionForceParkedTerminalWorktrees, type TerminalWorktreeRetentionCandidate } from './terminal-pane/terminal-hidden-worktree-retention' +import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' import { selectEvictionExemptTerminalTabIds } from './terminal-pane/terminal-eviction-exempt-tabs' import { captureForceParkedWorktreeBuffers } from './terminal-pane/force-park-buffer-capture' import { warnTerminalLifecycleAnomaly } from './terminal-pane/terminal-lifecycle-diagnostics' @@ -91,10 +94,18 @@ export function useTerminalParkingPass(controller: TerminalParkingFoundation): v const evictableTabIds = selectForceParkEvictableTabIds(forceParkedTabs, (tab) => exemptTabIds.has(tab.id) ) + // Why routed + breadcrumbed: only per-route counts in a field bundle + // can say whether fail-open ids or unresolved snapshot capability + // dominates the degenerate all-exempt force-park (which frees no heap). if (evictableTabIds.length === 0 && forceParkedTabs.length > 0) { + const exemptRouteCounts = countEvictionExemptTabRoutes(forceParkedTabs, worktreeId) warnTerminalLifecycleAnomaly('retention force-park freed no panes', { worktreeId, - reason: `exemptTabs=${forceParkedTabs.length}` + reason: `exemptTabs=${forceParkedTabs.length} ${formatEvictionExemptRouteCounts(exemptRouteCounts)}` + }) + recordRendererCrashBreadcrumb('terminal_force_park_freed_no_panes', { + exemptTabs: forceParkedTabs.length, + ...exemptRouteCounts }) } if ( diff --git a/src/renderer/src/components/use-terminal-workspace-foundation.ts b/src/renderer/src/components/use-terminal-workspace-foundation.ts index 68ea6fcb8d8..9b365e9d885 100644 --- a/src/renderer/src/components/use-terminal-workspace-foundation.ts +++ b/src/renderer/src/components/use-terminal-workspace-foundation.ts @@ -1,13 +1,21 @@ import { useMemo, useRef } from 'react' import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import type { TerminalTab } from '../../../shared/terminal-tab-types' import { useAppStore } from '../store' import { useWorktreeMap } from '../store/selectors' import { getResolvedExecutionHostIdForWorktree } from '@/lib/resolved-worktree-execution-host' +import type { WorktreeTabBucketProjection } from '@/lib/worktree-tab-bucket-projection' import { projectWorkspaceSurfaces } from './workspace-surface-projection' import { selectPairedRuntimeParkingEnvironmentIds } from './terminal-pane/terminal-hidden-view-parking' +import { createTerminalWorktreeTopologyProjection } from './terminal-pane/terminal-hidden-worktree-retention' import { isMainTerminalSideEffectAuthorityForPty } from './terminal-pane/terminal-side-effect-facts-handler' export function useTerminalWorkspaceFoundation() { + const terminalTopologyProjectionRef = useRef | null>(null) + terminalTopologyProjectionRef.current ??= createTerminalWorktreeTopologyProjection() const mountedWorktreeIdsRef = useRef(new Set()) const browserGuestWorktreeRecencyRef = useRef([]) const measurableBackgroundWorktreeIdsRef = useRef(new Set()) @@ -39,7 +47,12 @@ export function useTerminalWorkspaceFoundation() { [worktreesById, folderWorkspaces, renderedActiveWorktreeId, activeFolderSurfaceHostId] ) const activeView = useAppStore((state) => state.activeView) - const tabsByWorktree = useAppStore((state) => state.tabsByWorktree) + // Why: terminal titles are leaf chrome. The root host only subscribes to + // mount/parking semantics; a real transition publishes fresh tab objects, + // while LiveTerminalTabBar reads title-only updates from the active bucket. + const tabsByWorktree = useAppStore((state) => + terminalTopologyProjectionRef.current!.project(state.tabsByWorktree) + ) const pendingStartupByTabId = useAppStore((state) => state.pendingStartupByTabId) const terminalParkingEnabled = useAppStore( (state) => state.settings?.terminalHiddenViewParking !== false diff --git a/src/renderer/src/components/use-terminal-workspace-projection.ts b/src/renderer/src/components/use-terminal-workspace-projection.ts index 50672658c8f..a42031e7b11 100644 --- a/src/renderer/src/components/use-terminal-workspace-projection.ts +++ b/src/renderer/src/components/use-terminal-workspace-projection.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo } from 'react' import { useAppStore } from '../store' import { hasFeatureInteraction } from '../../../shared/feature-interactions' import { setForegroundTerminalTabIds } from '@/lib/foreground-terminal-tabs' +import { useClientHostedBrowserRows } from '@/lib/pane-manager/client-hosted-browser-row-state' import { useTerminalProviderSnapshotCapability } from './terminal/use-terminal-provider-snapshot-capability' import { getEffectiveLayoutForWorktree as getEffectiveLayout } from './terminal/split-group-mount' import { useContextualTour } from './contextual-tours/use-contextual-tour' @@ -65,6 +66,9 @@ export function useTerminalWorkspaceProjection(controller: TerminalWorkspaceStor const worktreeBrowserTabs = renderedActiveWorktreeId ? (browserTabsByWorktree[renderedActiveWorktreeId] ?? []) : [] + // Why: this strip only renders before the worktree has a layout, which is exactly when a paired + // client can have opened a page the host never has. Without a row here it stays uncloseable. + const worktreeClientHostedBrowserRows = useClientHostedBrowserRows(renderedActiveWorktreeId ?? '') const getEffectiveLayoutForWorktree = useCallback( (worktreeId: string) => getEffectiveLayout(worktreeId, layoutByWorktree, groupsByWorktree, activeGroupIdByWorktree), @@ -100,6 +104,7 @@ export function useTerminalWorkspaceProjection(controller: TerminalWorkspaceStor titlebarTabsTarget, worktreeFiles, worktreeBrowserTabs, + worktreeClientHostedBrowserRows, getEffectiveLayoutForWorktree, effectiveActiveLayout, activeWorktreeBrowserTabIdsKey, diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts index bf12b2c0c28..12ca698f192 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts @@ -1,5 +1,5 @@ import { readdirSync, readFileSync } from 'node:fs' -import { resolve } from 'node:path' +import { basename, resolve } from 'node:path' import { describe, expect, it } from 'vitest' import { buildSettingsNavigationMetadata } from './useSettingsNavigationMetadata' import type { Repo } from '../../../shared/repo-types' @@ -410,10 +410,11 @@ describe('settings navigation metadata', () => { const testDir = import.meta.dirname // Why: the section tables live in sibling settings-navigation-* modules, so reading only the // hook would scan a file that no longer holds the imports this guard exists to police. + // Walk recursively so a later split that nests the modules cannot shrink this guard. const sourceFiles = [ 'useSettingsNavigationMetadata.ts', - ...readdirSync(testDir).filter( - (name) => name.startsWith('settings-navigation-') && name.endsWith('.ts') + ...readdirSync(testDir, { recursive: true, encoding: 'utf8' }).filter( + (name) => basename(name).startsWith('settings-navigation-') && name.endsWith('.ts') ) ] expect(sourceFiles.length).toBeGreaterThan(1) diff --git a/src/renderer/src/lib/client-creation-action-error.ts b/src/renderer/src/lib/client-creation-action-error.ts new file mode 100644 index 00000000000..e0c70afd22e --- /dev/null +++ b/src/renderer/src/lib/client-creation-action-error.ts @@ -0,0 +1,24 @@ +import { toast } from 'sonner' +import { useAppStore } from '@/store' +import { + getClientCreationActionPolicy, + type ClientCreationAction +} from './client-creation-action-policy' + +export function showClientCreationActionError(error: unknown): void { + toast.error(error instanceof Error ? error.message : String(error)) +} + +// Why: action paths must surface the policy's reason; the visibility gate alone +// leaves shortcut-driven creation failing silently. +export function ensureClientCreationActionAllowed( + worktreeId: string | null, + action: ClientCreationAction +): boolean { + const availability = getClientCreationActionPolicy(useAppStore.getState(), worktreeId)[action] + if (availability.state !== 'enabled') { + toast.error(availability.reason) + return false + } + return true +} diff --git a/src/renderer/src/lib/cmd-j-match-relevance.ts b/src/renderer/src/lib/cmd-j-match-relevance.ts deleted file mode 100644 index 79b830783a4..00000000000 --- a/src/renderer/src/lib/cmd-j-match-relevance.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { - resolveWorktreeBranchLabel, - resolveWorktreeDisplayName -} from './worktree-default-display-name' -import type { MatchRange, PaletteSearchResult } from './worktree-palette-search' -import type { Worktree } from '../../../shared/worktree/types' - -/** A missing match sorts after every concrete field match. */ -export const NO_MATCH_RELEVANCE = Number.MAX_SAFE_INTEGER - -export type PaletteRelevanceFieldTier = 0 | 1 | 2 - -export type PaletteRelevanceField = { - text: string - ranges: readonly MatchRange[] - tier: PaletteRelevanceFieldTier -} - -const NON_WORD_CHARACTER = /[^\p{L}\p{M}\p{N}]/u -const POSITION_RANKS = 4 - -function positionRank(text: string, range: MatchRange): number { - if (range.start === 0) { - return range.end >= text.trimEnd().length ? 0 : 1 - } - return NON_WORD_CHARACTER.test(text[range.start - 1] ?? '') ? 2 : 3 -} - -export function scorePaletteRelevance(fields: readonly PaletteRelevanceField[]): number { - let best = NO_MATCH_RELEVANCE - for (const field of fields) { - for (const range of field.ranges) { - best = Math.min(best, field.tier * POSITION_RANKS + positionRank(field.text, range)) - } - } - return best -} - -export function getWorktreeMatchRelevance( - match: PaletteSearchResult, - worktree: Worktree, - repoName: string -): number { - return scorePaletteRelevance([ - { - text: resolveWorktreeDisplayName(worktree), - ranges: match.displayNameRanges, - tier: 0 - }, - { - text: resolveWorktreeBranchLabel(worktree), - ranges: match.branchRanges, - tier: 1 - }, - { - text: match.supportingText?.text ?? '', - ranges: match.supportingText?.matchRanges ?? [], - tier: 2 - }, - { text: repoName, ranges: match.repoRanges, tier: 2 } - ]) -} - -/** Structural shape shared by browser, simulator, and workspace-tab results. */ -export type OpenTabRelevanceInput = { - title: string - titleRanges: readonly MatchRange[] - secondaryText: string - secondaryRanges: readonly MatchRange[] - worktreeName: string - worktreeRanges: readonly MatchRange[] - repoName: string - repoRanges: readonly MatchRange[] - workspaceLabel?: string | null - workspaceRanges?: readonly MatchRange[] - typeAliasMatch?: { text: string; ranges: readonly MatchRange[] } | null -} - -export function getOpenTabMatchRelevance(result: OpenTabRelevanceInput): number { - return scorePaletteRelevance([ - { text: result.title, ranges: result.titleRanges, tier: 0 }, - { text: result.secondaryText, ranges: result.secondaryRanges, tier: 1 }, - { - text: result.typeAliasMatch?.text ?? '', - ranges: result.typeAliasMatch?.ranges ?? [], - tier: 1 - }, - { - text: result.workspaceLabel ?? '', - ranges: result.workspaceRanges ?? [], - tier: 2 - }, - { text: result.worktreeName, ranges: result.worktreeRanges, tier: 2 }, - { text: result.repoName, ranges: result.repoRanges, tier: 2 } - ]) -} diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-surfaces.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-surfaces.ts index 9012a0fc0d1..216d2dd0448 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-surfaces.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-surfaces.ts @@ -91,10 +91,15 @@ export function isMobilePublishableOpenFile(file: AppState['openFiles'][number]) ) } +/** + * Why a workspace document is held back: it is served to one desktop guest through a grant no + * mobile client holds, so there is nothing on the other side that could render it — and the wire + * has no tab kind for it, so an old client would take it for an ordinary browser tab and offer + * navigation for a page that has no URL. Host and phone parity ships behind capability negotiation. + */ export function isMobilePublishableBrowserWorkspace( workspace: NonNullable[number] ): boolean { - // Document previews are served through a desktop-only grant. return !workspace.docLocation } diff --git a/src/renderer/src/runtime/sync-runtime-graph/sync-projections.ts b/src/renderer/src/runtime/sync-runtime-graph/sync-projections.ts index 911348fae64..0627bccee06 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/sync-projections.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/sync-projections.ts @@ -2,12 +2,10 @@ import type { AppState } from '@/store/types' import { resolveTerminalTabTitle } from '../../../../shared/tab-title-resolution' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { - EMPTY_AGENT_STATUS_BY_PANE_KEY, EMPTY_BROWSER_PAGES_BY_WORKSPACE, EMPTY_BROWSER_TABS_BY_WORKTREE, graphState } from './graph-state' -import { buildRuntimeMobileAgentStatusProjection } from './agent-status-projection' export function getBrowserTabsByWorktree(state: AppState): AppState['browserTabsByWorktree'] { // Some callers/tests build partial pre-browser states; treat missing slices as empty. @@ -147,11 +145,3 @@ export function stableHashString(value: string): string { } return `draft:${value.length}:${(hash >>> 0).toString(16)}` } - -export function buildRuntimeMobileAgentStatusProjectionForState( - agentStatusByPaneKey: AppState['agentStatusByPaneKey'] | undefined -): string { - return buildRuntimeMobileAgentStatusProjection( - agentStatusByPaneKey ?? EMPTY_AGENT_STATUS_BY_PANE_KEY - ) -} diff --git a/src/renderer/src/store/slices/agent-status-live-actions.ts b/src/renderer/src/store/slices/agent-status-live-actions.ts index d9b0ff2c3dc..a9407da5c13 100644 --- a/src/renderer/src/store/slices/agent-status-live-actions.ts +++ b/src/renderer/src/store/slices/agent-status-live-actions.ts @@ -9,7 +9,8 @@ import type { import { resolveAgentPaneAuthorityKey } from './agent-pane-authority' import { buildAgentStatusLiveEntry, - type AgentStatusLiveEntryBuild + type AgentStatusLiveEntryBuild, + type AgentStatusLiveEntryRejection } from './agent-status-live-entry-builder' import { reduceAgentStatusLiveUpdate } from './agent-status-live-reducer' import { @@ -49,7 +50,7 @@ export function createAgentStatusLiveActions( ) { return } - let built: AgentStatusLiveEntryBuild | null = null + let built: AgentStatusLiveEntryBuild | AgentStatusLiveEntryRejection | null = null set((state) => { built = buildAgentStatusLiveEntry({ state, @@ -61,14 +62,16 @@ export function createAgentStatusLiveActions( metadata, updatedAt }) - return built ? reduceAgentStatusLiveUpdate(state, built, updatedAt) : state + return built.entry ? reduceAgentStatusLiveUpdate(state, built, updatedAt) : state }) // Zustand's updater runs synchronously, but TypeScript cannot observe the closure assignment. - const builtResult = built as AgentStatusLiveEntryBuild | null - if (!builtResult) { - // Keep standalone calls' deferred freshness contract even when a stale - // event is rejected by the reducer. - requestFreshness(false) + const builtResult = built as AgentStatusLiveEntryBuild | AgentStatusLiveEntryRejection | null + if (!builtResult?.entry) { + // Keep standalone calls' deferred freshness contract when a stale event is rejected, but a + // suppressed inherited-terminal frame returns without buying the deferred O(entries) scan. + if (builtResult?.reason !== 'suppressed-inherited-terminal') { + requestFreshness(false) + } return } const { entry } = builtResult diff --git a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts index 2afd35430f3..3ef257edb6b 100644 --- a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts +++ b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts @@ -52,6 +52,11 @@ export type AgentStatusLiveEntryBuild = { boundaryResolved: boolean } +export type AgentStatusLiveEntryRejection = { + entry: null + reason: 'stale' | 'suppressed-inherited-terminal' +} + export type AgentStatusLiveEntryArgs = { state: AppState paneKey: string @@ -63,14 +68,14 @@ export type AgentStatusLiveEntryArgs = { updatedAt: number } -/** Build one accepted live row and the derived map-update facts. */ +/** Build one accepted live row and the derived map-update facts, or say why the frame was rejected. */ export function buildAgentStatusLiveEntry( args: AgentStatusLiveEntryArgs -): AgentStatusLiveEntryBuild | null { +): AgentStatusLiveEntryBuild | AgentStatusLiveEntryRejection { const { state, paneKey, payload, terminalTitle, timing, routing, metadata, updatedAt } = args const existing = state.agentStatusByPaneKey[paneKey] if (existing && updatedAt < existing.updatedAt) { - return null + return { entry: null, reason: 'stale' } } const effectiveTitle = terminalTitle ?? existing?.terminalTitle let history: AgentStateHistoryEntry[] = existing?.stateHistory ?? [] @@ -141,7 +146,7 @@ export function buildAgentStatusLiveEntry( incomingState: payload.state }) ) { - return null + return { entry: null, reason: 'suppressed-inherited-terminal' } } const runtimeOrchestration = state.runtimeAgentOrchestrationByPaneKey[paneKey] const runtimeMergedOrchestration = runtimeOrchestration diff --git a/src/renderer/src/store/slices/agent-status-live-freshness-request.test.ts b/src/renderer/src/store/slices/agent-status-live-freshness-request.test.ts new file mode 100644 index 00000000000..233a7c0121b --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-live-freshness-request.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AppState } from '../types' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { AgentStatusPayload } from './agent-status-contract' +import type { AgentStatusRuntime } from './agent-status-runtime' +import { createAgentStatusLiveActions } from './agent-status-live-actions' + +const NOW = new Date('2026-04-09T12:00:00.000Z').getTime() +const PANE_KEY = 'tab-1:11111111-1111-4111-8111-111111111111' + +function existingEntry(overrides: Partial = {}): AgentStatusEntry { + return { + paneKey: PANE_KEY, + state: 'working', + prompt: 'parent turn', + updatedAt: NOW, + stateStartedAt: NOW, + stateHistory: [], + agentType: 'claude', + ...overrides + } +} + +function setup(existing: AgentStatusEntry) { + const state = { + agentStatusByPaneKey: { [PANE_KEY]: existing }, + recentlyRetiredAgentStatusPaneKeys: {}, + recentlyClosedAgentStatusTabIds: {} + } as unknown as AppState + const requestFreshness = vi.fn() + const runtime = { + get: () => state, + set: vi.fn((update) => { + if (typeof update === 'function') { + update(state) + } + }), + applyGeneratedTabTitleUpdate: vi.fn(), + requestFreshness, + transactAgentStatuses: vi.fn() + } as unknown as AgentStatusRuntime + return { requestFreshness, actions: createAgentStatusLiveActions(runtime) } +} + +function payload(overrides: Partial = {}): AgentStatusPayload { + return { state: 'done', prompt: 'child hook', ...overrides } as AgentStatusPayload +} + +describe('setAgentStatus freshness requests on rejected frames', () => { + it('skips the deferred freshness scan when an inherited terminal status is suppressed', () => { + // A nested child hook inherits ORCA_PANE_KEY, so its `done` is dropped while the parent works. + const { requestFreshness, actions } = setup(existingEntry()) + + actions.setAgentStatus(PANE_KEY, payload({ agentType: 'codex' }), undefined, { + updatedAt: NOW + 1 + }) + + expect(requestFreshness).not.toHaveBeenCalled() + }) + + it('still requests deferred freshness when a stale frame is rejected', () => { + const { requestFreshness, actions } = setup(existingEntry()) + + actions.setAgentStatus(PANE_KEY, payload({ agentType: 'claude' }), undefined, { + updatedAt: NOW - 1 + }) + + expect(requestFreshness).toHaveBeenCalledWith(false) + }) +}) diff --git a/src/renderer/src/store/slices/linear/linear-slice-scope.ts b/src/renderer/src/store/slices/linear/linear-slice-scope.ts index 42c1aadac03..6a36b740c64 100644 --- a/src/renderer/src/store/slices/linear/linear-slice-scope.ts +++ b/src/renderer/src/store/slices/linear/linear-slice-scope.ts @@ -10,11 +10,7 @@ import { type LinearIssueAttributeFilter } from '../../../../../shared/linear/issue-attribute-filter' import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' -import { - getLinearCacheGeneration, - getLinearMutationGeneration, - linearRequestState -} from './linear-slice-request-state' +import { getLinearCacheGeneration, getLinearMutationGeneration } from './linear-slice-request-state' export function normalizeListAttributeFilter( attributeFilter?: LinearIssueAttributeFilter | null @@ -78,25 +74,3 @@ export function getLinearReadScope( export function scopedLinearCacheKey(scope: LinearReadScope, key: string): string { return scope.cachePrefix ? `${scope.cachePrefix}::${key}` : key } - -/** Read the current request generation without exposing mutable implementation details. */ -export function linearReadGenerationSnapshot(): { - cacheGeneration: number - mutationGeneration: number -} { - return { - cacheGeneration: getLinearCacheGeneration(), - mutationGeneration: getLinearMutationGeneration() - } -} - -/** Keep the status request handle private to the request-state module. */ -export function getInflightStatusRequest(): { contextKey: string; promise: Promise } | null { - return linearRequestState.inflightStatusRequest -} - -export function setInflightStatusRequest( - request: { contextKey: string; promise: Promise } | null -): void { - linearRequestState.inflightStatusRequest = request -}