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 -}