fix(renderer): restore behavior the UI split dropped

The oversized-UI-surfaces split was cut from a stale branch and reverted merged
work. getClientCreationActionPolicy entered Terminal.tsx in #13909 and left in
the split, taking six call sites with it, so every action-time creation gate in
the terminal and floating surfaces was gone. Restores those and the other
behavior the split dropped, each ported from the pre-split reference:

- Cmd/Ctrl+S dispatched a bare Event with no detail, so the only listener always
  bailed on detail?.fileId and the chord never saved. Its resolver had been left
  orphaned, imported by nothing but its own test.
- Terminal and floating create actions lost their availability gates, their
  toasts, and their catch handlers; one path throws on unavailable, so it was a
  silent unhandled rejection.
- Both outermost workbench wrappers lost the browser guest paint retention
  branch, and the census entry covering them was deleted in the same commit.
- The Space Analyzer header counted omitted items the list no longer rendered,
  and a worktree whose items were all omitted showed the empty state.
- The terminal root lost its tab topology projection, so every tab-title update
  re-rendered it.
- The titlebar tab bar stopped being passed clientHostedBrowserRows, leaving
  client-hosted pages uncloseable before a worktree has a layout.
- Parking diagnostics lost their exempt-route counts and crash breadcrumb.
- A suppressed inherited-terminal frame began buying a freshness scan the
  pre-split early return skipped.

Adds regression tests for each, all verified to fail against the pre-fix code.
Restores three deleted assertions whose invariants are still live, and replaces
a concatenated source-boundary fixture with per-module pinning so a symbol is
again asserted against the module that must own it.

Deletes three orphaned trees the splits stranded: a duplicate ResourceUsage
surface, cmd-j-match-relevance, and an agent-session claim-key module whose
logic the record store already owns. Makes two non-recursive test walkers
recursive, one of which silently skipped every nested CLI handler group.
This commit is contained in:
Neil
2026-09-01 12:51:17 -07:00
parent 79990464d2
commit 8fef5820ff
40 changed files with 1005 additions and 1885 deletions
+54 -18
View File
@@ -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<string, unknown> {
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<string, unknown> = 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<string, unknown> = 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)
})
})
@@ -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'
)
}
@@ -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 <webview>, 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 (
<div
className={`relative flex flex-1 min-w-0 min-h-0 overflow-hidden${effectiveActiveLayout ? '' : ' hidden'}`}
className={`relative flex flex-1 min-w-0 min-h-0 overflow-hidden${
effectiveActiveLayout
? ''
: retainBrowserGuestPaint
? ' opacity-0 pointer-events-none'
: ' hidden'
}`}
>
{workspaceSurfaces
.filter((workspace) => mountedWorktreeIdsRef.current.has(workspace.id))
@@ -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 (
<div
// Why: already out of flow via the workbench container when hidden, so retention only
// has to drop `hidden` — it does not need to leave the flex column a second time.
className={`flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden${
renderedActiveWorktreeId ? '' : ' hidden'
renderedActiveWorktreeId
? ''
: retainBrowserGuestPaint
? ' opacity-0 pointer-events-none'
: ' hidden'
}`}
data-rendered-active-worktree-id={renderedActiveWorktreeId ?? undefined}
>
@@ -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<string, unknown>,
tabBarProps: [] as Record<string, unknown>[]
}))
vi.mock('../store', () => ({
useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector(mocks.state), {
getState: () => mocks.state
})
}))
vi.mock('./tab-bar/TabBar', () => ({
default: (props: Record<string, unknown>) => {
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(<TerminalTitlebarTabs controller={controller} />))
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([])
})
})
@@ -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<React.ComponentProps<typeof TabBar>, 'tabs'>
): React.JSX.Element {
const tabs = useAppStore((state) => state.tabsByWorktree[props.worktreeId] ?? EMPTY_TERMINAL_TABS)
return <TabBar {...props} tabs={tabs} />
}
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(
<TabBar
tabs={tabs}
<LiveTerminalTabBar
activeTabId={activeTabId}
worktreeId={renderedActiveWorktreeId}
onActivate={handleActivateTab}
@@ -71,6 +82,7 @@ export function TerminalTitlebarTabs({
onTogglePaneExpand={handleTogglePaneExpand}
editorFiles={worktreeFiles}
browserTabs={worktreeBrowserTabs}
clientHostedBrowserRows={worktreeClientHostedBrowserRows}
activeFileId={activeFileId}
activeBrowserTabId={activeBrowserTabId}
activeSimulatorTabId={
@@ -56,6 +56,10 @@ const RETENTION_HELPER_SYMBOLS = [
// Every place that decides whether a browser guest keeps painting, and the helper it must use.
const RETENTION_SITES = new Map<string, readonly string[]>([
['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',
@@ -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(
@@ -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()
@@ -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')
@@ -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 (
<div className="px-3 py-1.5 pl-6 flex items-center justify-between gap-2">
<span className="text-[11px] text-muted-foreground truncate">{label}</span>
<div className="flex items-center gap-2 shrink-0">
<ResourceUsageMetricPair cpu={values.cpu} memory={values.memory} size="small" />
<span className={ROW_TRAILING_GUTTER_CLS} aria-hidden />
</div>
</div>
)
}
export function ResourceUsageAppSection({
app,
isCollapsed,
onToggle
}: {
app: AppMemory
isCollapsed: boolean
onToggle: () => void
}): React.JSX.Element {
return (
<div className="border-t border-border/50">
<div className="flex items-center">
<button
type="button"
onClick={onToggle}
className="pl-2 py-2 pr-0.5 transition-colors hover:bg-muted/50"
aria-label={
isCollapsed
? translate(
'auto.components.status.bar.ResourceUsageStatusSegment.e419d27083',
'Expand Orca'
)
: translate(
'auto.components.status.bar.ResourceUsageStatusSegment.53dd5560ae',
'Collapse Orca'
)
}
aria-expanded={!isCollapsed}
>
{isCollapsed ? (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronDown className="h-3 w-3 text-muted-foreground" />
)}
</button>
<div className="flex-1 min-w-0 py-2 pr-3 flex items-center justify-between">
<span className="text-[11px] font-semibold uppercase tracking-wide truncate text-muted-foreground">
{translate('auto.components.status.bar.ResourceUsageStatusSegment.288a4dd177', 'Orca')}
</span>
<div className="flex items-center gap-2 shrink-0">
<ResourceUsageSparkline samples={app.history} />
<ResourceUsageMetricPair cpu={app.cpu} memory={app.memory} />
<span className={ROW_TRAILING_GUTTER_CLS} aria-hidden />
</div>
</div>
</div>
{!isCollapsed && (
<div className="border-t border-border/30">
<AppSubRow
label={translate(
'auto.components.status.bar.ResourceUsageStatusSegment.81cd37af99',
'Main'
)}
values={app.main}
/>
<AppSubRow
label={translate(
'auto.components.status.bar.ResourceUsageStatusSegment.d406915b78',
'Renderer'
)}
values={app.renderer}
/>
{(app.other.cpu > 0 || app.other.memory > 0) && (
<AppSubRow
label={translate(
'auto.components.status.bar.ResourceUsageStatusSegment.0f9e50eb07',
'Other'
)}
values={app.other}
/>
)}
</div>
)}
</div>
)
}
@@ -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 (
<Dialog
open={killConfirm !== null}
onOpenChange={(next) => {
if (next) {
return
}
if (killing) {
return
}
setKillConfirm(null)
}}
>
<DialogContent
className="max-w-md"
showCloseButton={!killing}
onPointerDownOutside={(event) => {
if (killing) {
event.preventDefault()
}
}}
onEscapeKeyDown={(event) => {
if (killing) {
event.preventDefault()
}
}}
>
<DialogHeader>
<DialogTitle className="text-sm">
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.e9a5d3c2b1f0',
'Kill {{value0}}?',
{
value0:
killConfirm?.label ??
translate(
'auto.components.status.bar.ResourceUsageStatusSegment.138b99bd80',
'this session'
)
}
)}
</DialogTitle>
<DialogDescription className="text-xs">
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.67c4ecda49',
"Force-quits this terminal. Any unsaved work in the pane is lost. This can't be undone."
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setKillConfirm(null)} disabled={killing}>
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.946d9f94d0',
'Cancel'
)}
</Button>
<Button variant="destructive" onClick={() => void runKillConfirmed()} disabled={killing}>
{killing ? <LoaderCircle className="size-4 animate-spin" /> : null}
{killing
? translate(
'auto.components.status.bar.ResourceUsageStatusSegment.41ae4fa725',
'Killing…'
)
: translate(
'auto.components.status.bar.ResourceUsageStatusSegment.b10695d6ce',
'Kill session'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -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 (
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
aria-hidden
preserveAspectRatio="none"
>
<polyline
points={points}
fill="none"
strokeWidth={1}
strokeLinecap="round"
strokeLinejoin="round"
className="stroke-muted-foreground/70"
/>
</svg>
)
}
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 (
<div
className={cn(
METRIC_COLUMNS_CLS,
textClassName,
muted ? 'text-muted-foreground/50' : 'text-muted-foreground'
)}
>
<span className={CPU_COLUMN_CLS}>{formatMetricCpu(cpu)}</span>
<span className={MEM_COLUMN_CLS}>{formatMetricMemory(memory)}</span>
</div>
)
}
@@ -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 (
<PopoverContent
side="top"
align="end"
sideOffset={8}
{...STATUS_BAR_CONTEXT_MENU_EXEMPT_PROPS}
className="w-[26rem] max-w-[calc(100vw-2rem)] p-0"
onOpenAutoFocus={(event) => event.preventDefault()}
// Why: xterm focus must not dismiss the resource manager after tab activation.
onFocusOutside={(event) => event.preventDefault()}
>
<div className="flex items-center justify-between gap-2 border-b border-border px-3 py-1.5">
<div className="flex min-w-0 items-center gap-1.5 text-[11px] font-medium text-foreground">
<MemoryStick className="size-3 shrink-0 text-muted-foreground" />
<span className="truncate">
{translate('auto.components.status.bar.StatusBar.d1e1a7a6bf', 'Resource Manager')}
</span>
</div>
<div className="flex items-center gap-0.5">
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => daemonActions.setPending('restart')}
disabled={daemonActions.isBusy}
aria-label={translate(
'auto.components.status.bar.ResourceUsageStatusSegment.c9382662bb',
'Restart daemon'
)}
className="inline-flex size-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
>
<RotateCw className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.c9382662bb',
'Restart daemon'
)}
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => daemonActions.setPending('killAll')}
disabled={daemonActions.isBusy}
aria-label={translate(
'auto.components.status.bar.ResourceUsageStatusSegment.bd19fd7a59',
'Kill all sessions'
)}
className="inline-flex size-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-40"
>
<Trash2 className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.bd19fd7a59',
'Kill all sessions'
)}
</TooltipContent>
</Tooltip>
</div>
</div>
{daemonUnreachable && (
<div className="flex items-start gap-2 border-b border-border bg-yellow-500/10 px-3 py-2 text-[11px] text-foreground">
<AlertTriangle className="mt-0.5 size-3 shrink-0 text-yellow-500" />
<div className="flex-1">
<div className="font-medium">
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.f8e0d794b4',
'Daemon is not responding'
)}
</div>
<div className="text-muted-foreground">
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.f85af9cda6',
'Resource snapshots and terminal sessions are unavailable.'
)}
</div>
</div>
<Button
variant="outline"
size="sm"
className="shrink-0"
onClick={() => daemonActions.setPending('restart')}
disabled={daemonActions.isBusy}
>
<RotateCw className="mr-1 size-3" />
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.93b0de3c21',
'Restart'
)}
</Button>
</div>
)}
{!daemonUnreachable && sessionsOnlyError && (
<div
className="flex items-center gap-2 border-b border-border bg-muted/40 px-3 py-1.5 text-[11px] text-muted-foreground"
role="status"
>
<AlertTriangle className="size-3 shrink-0 text-yellow-500" />
<span>
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.e7cf14ec78',
'Terminal sessions unavailable. The list may be stale.'
)}
</span>
</div>
)}
{resourceSnapshot && (
<div className="px-3 py-2 border-b border-border flex items-baseline justify-between gap-3 text-xs tabular-nums">
<div className="flex items-baseline gap-3 min-w-0">
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<span
tabIndex={0}
className="font-medium text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:rounded"
>
{formatCpu(totalCpu)}
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6} className="z-[70] max-w-xs">
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.1fedf94eae',
'Combined CPU load. Values above 100% mean more than one core is working at once.'
)}
</TooltipContent>
</Tooltip>
<span className="text-muted-foreground/50">·</span>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<span
tabIndex={0}
className="font-medium text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:rounded"
>
{formatMemory(totalMemory)}{' '}
<span className="font-normal text-muted-foreground">
{memoryMetricCopy.summaryLabel}
</span>
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6} className="z-[70] max-w-xs">
{memoryMetricCopy.description}
</TooltipContent>
</Tooltip>
</div>
{orphanCount > 0 && (
<span className="shrink-0 text-yellow-500" aria-live="polite">
{orphanCount === 1
? translate(
'auto.components.status.bar.ResourceUsageStatusSegment.30ff2c3c31',
'{{value0}} orphan',
{ value0: orphanCount }
)
: translate(
'auto.components.status.bar.ResourceUsageStatusSegment.b8f4a2c1d0e3',
'{{value0}} orphans',
{ value0: orphanCount }
)}
</span>
)}
</div>
)}
{/* Why: fixed height prevents list expansion and polling from moving the popover. */}
<div ref={setPopoverBodyNode} tabIndex={-1} className="flex h-[420px] flex-col outline-none">
{(unifiedRepos.length > 0 || resourceSnapshot) && (
<div className="flex items-center justify-between px-3 py-1 bg-muted/30 border-b border-border/50 text-[10px] uppercase tracking-wide shrink-0">
<button
type="button"
onClick={() => setSortOption('name')}
className={cn(
'hover:text-foreground transition-colors',
sortOption === 'name' ? 'font-semibold text-foreground' : 'text-muted-foreground/80'
)}
aria-pressed={sortOption === 'name'}
>
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.2aa2de6cb9',
'Name'
)}
</button>
<div className="flex items-center gap-2 shrink-0">
<div className={cn(METRIC_COLUMNS_CLS, 'text-[10px]')}>
<button
type="button"
onClick={() => setSortOption('cpu')}
className={cn(
CPU_COLUMN_CLS,
'hover:text-foreground transition-colors',
sortOption === 'cpu'
? 'font-semibold text-foreground'
: 'text-muted-foreground/80'
)}
aria-pressed={sortOption === 'cpu'}
>
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.298f4be7f2',
'CPU'
)}
</button>
<button
type="button"
onClick={() => setSortOption('memory')}
className={cn(
MEM_COLUMN_CLS,
'hover:text-foreground transition-colors',
sortOption === 'memory'
? 'font-semibold text-foreground'
: 'text-muted-foreground/80'
)}
aria-pressed={sortOption === 'memory'}
>
{memoryMetricCopy.columnLabel}
</button>
</div>
<span className={ROW_TRAILING_GUTTER_CLS} aria-hidden />
</div>
</div>
)}
<div className="flex-1 overflow-y-auto scrollbar-sleek">
{unifiedRepos.length > 0 && (
<ResourceUsageTree
repos={unifiedRepos}
sortOption={sortOption}
collapsedRepos={collapsedRepos}
toggleRepo={toggleRepo}
collapsedWorktrees={collapsedWorktrees}
activeWorktreeId={activeWorktreeId}
toggleWorktree={toggleWorktree}
navigateToWorktree={navigateToWorktree}
navigateToTab={navigateToTab}
onDelete={deleteWorktree}
onKillSession={handleKillSession}
/>
)}
{unifiedRepos.length === 0 && resourceSnapshot && (
<div className="px-3 py-4 text-center text-xs text-muted-foreground">
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.27a74f91f0',
'Nothing running right now'
)}
</div>
)}
{resourceSnapshot && (
<ResourceUsageAppSection
app={resourceSnapshot.app}
isCollapsed={appCollapsed}
onToggle={() => setAppCollapsed((value) => !value)}
/>
)}
{!resourceSnapshot && !daemonUnreachable && (
<div className="px-3 py-4 text-center text-xs text-muted-foreground">
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.888dad8c55',
'Loading…'
)}
</div>
)}
</div>
</div>
<div className="border-t border-border/50 px-3 py-2 shrink-0">
<button
type="button"
onClick={handleOpenWorkspaceCleanup}
className="relative inline-flex w-full items-center justify-center rounded-md border border-border/70 px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent/60"
>
<span className="min-w-0 truncate px-4 text-center">
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.92924a14e3',
'Clean up workspaces'
)}
</span>
<ChevronRight className="absolute right-2.5 size-3.5 text-muted-foreground" aria-hidden />
</button>
{orphanCount > 0 ? (
<button
type="button"
onClick={() => void handleKillOrphans()}
className="mt-2 inline-flex w-full items-center justify-center rounded-md border border-border/70 px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent/60"
>
{orphanCount === 1
? translate(
'auto.components.status.bar.ResourceUsageStatusSegment.c7e3b1a0d9f2',
'Kill {{value0}} orphan terminal',
{ value0: orphanCount }
)
: translate(
'auto.components.status.bar.ResourceUsageStatusSegment.d8f4c2b1e0a3',
'Kill {{value0}} orphan terminals',
{ value0: orphanCount }
)}
</button>
) : null}
</div>
<WorkspaceSpaceCompactPanel onOpenFullPage={openSpaceResults} />
</PopoverContent>
)
}
@@ -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<string>
toggleRepo: (repoId: string) => void
collapsedWorktrees: Set<string>
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 (
<ResourceUsageWorktreeRow
key={worktree.worktreeId}
worktree={worktree}
storeRecord={storeRecord}
activeWorktreeId={activeWorktreeId}
isCollapsed={collapsedWorktrees.has(worktree.worktreeId)}
onToggle={() => 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 (
<div key={group.repoId} className="border-b border-border/50 last:border-b-0">
<div className="flex items-center">
<button
type="button"
onClick={() => toggleRepo(group.repoId)}
className="pl-2 py-2 pr-0.5 transition-colors hover:bg-muted/50"
aria-label={
repoCollapsed
? translate(
'auto.components.status.bar.ResourceUsageStatusSegment.b12e31dfcb',
'Expand repo'
)
: translate(
'auto.components.status.bar.ResourceUsageStatusSegment.73a3fd68a9',
'Collapse repo'
)
}
>
{repoCollapsed ? (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronDown className="h-3 w-3 text-muted-foreground" />
)}
</button>
<div className="flex-1 min-w-0 py-2 pr-3 flex items-center justify-between gap-2">
<span className="flex items-center gap-1.5 min-w-0">
<span className="text-[11px] font-semibold uppercase tracking-wide truncate text-muted-foreground">
{group.repoName}
</span>
{group.hasRemoteChildren && (
<span className="shrink-0 text-[9px] uppercase tracking-wide text-muted-foreground/70">
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.21cacb16d1',
'· remote'
)}
</span>
)}
</span>
<div className="flex items-center gap-2 shrink-0">
<ResourceUsageMetricPair cpu={group.cpu} memory={group.memory} />
<span className={ROW_TRAILING_GUTTER_CLS} aria-hidden />
</div>
</div>
</div>
{!repoCollapsed && (
<div className="border-t border-border/30">{group.worktrees.map(renderWorktree)}</div>
)}
</div>
)
})}
</>
)
}
@@ -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 (
<Tooltip delayDuration={150}>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<button
type="button"
{...STATUS_BAR_CONTEXT_MENU_EXEMPT_PROPS}
className="relative inline-flex items-center gap-1.5 cursor-pointer rounded px-1 py-0.5 hover:bg-accent/70"
aria-label={
daemonUnreachable
? translate(
'auto.components.status.bar.ResourceUsageStatusSegment.59f178fe11',
'{{value0}}, daemon unreachable',
{ value0: resourceManagerAriaLabel }
)
: resourceManagerAriaLabel
}
>
{spaceScanReady ? (
<span
className="absolute -right-0.5 -top-0.5 size-1.5 rounded-full bg-primary"
aria-hidden="true"
/>
) : null}
<MemoryStick className="size-3 text-muted-foreground" />
{!iconOnly && (
<>
<span className="text-[11px] font-medium tabular-nums text-muted-foreground">
{memBadgeLabel}
</span>
<span className="text-muted-foreground/50">·</span>
<Terminal className="size-3 text-muted-foreground" />
<span className="text-[11px] tabular-nums text-muted-foreground">
{triggerSessionCount}
{orphanCount > 0 && (
<span className="text-yellow-500 ml-0.5">({orphanCount})</span>
)}
</span>
</>
)}
{iconOnly && triggerSessionCount > 0 && (
<span className="text-[11px] tabular-nums text-muted-foreground">
{triggerSessionCount}
</span>
)}
{daemonUnreachable && (
<AlertTriangle
className="size-3 text-yellow-500"
aria-label={translate(
'auto.components.status.bar.ResourceUsageStatusSegment.ca95d077db',
'Daemon unreachable'
)}
/>
)}
</button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>
<div className="space-y-0.5">
{resourceManagerTooltipLines.map((line) => (
<div key={line.id} className={line.emphasized ? 'text-primary' : ''}>
{line.text}
</div>
))}
</div>
</TooltipContent>
</Tooltip>
)
}
@@ -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 (
<div
className={cn(
'group/sessrow flex items-center gap-2 pl-10 pr-3 py-1.5',
clickable && 'cursor-pointer hover:bg-accent/40'
)}
onClick={clickable ? handleClick : undefined}
role={clickable ? 'button' : undefined}
tabIndex={clickable ? 0 : -1}
onKeyDown={
clickable
? (event) => {
if (isResourceSessionActivationKey(event.key)) {
event.preventDefault()
handleClick()
}
}
: undefined
}
data-worktree-id={worktreeId}
>
<span
className={cn(
'size-1.5 shrink-0 rounded-full',
session.bound ? 'bg-emerald-500' : 'bg-muted-foreground/40'
)}
/>
<span className="text-[11px] text-muted-foreground truncate min-w-0 flex-1">
{session.label}
</span>
<ResourceUsageMetricPair cpu={session.cpu} memory={session.memory} size="small" />
{/* Why: the shared gutter aligns columns while keeping orphan kills visible. */}
<span className={ROW_TRAILING_GUTTER_CLS}>
<button
type="button"
onClick={(event) => {
event.stopPropagation()
onKill(session)
}}
className={cn(
'rounded p-0.5 text-muted-foreground transition-opacity hover:bg-destructive/10 hover:text-destructive',
session.bound &&
'can-hover:opacity-0 group-hover/sessrow:opacity-100 group-focus-within/sessrow:opacity-100 focus-visible:opacity-100'
)}
aria-label={translate(
'auto.components.status.bar.ResourceUsageStatusSegment.fa6d36758d',
'Kill session {{value0}}',
{ value0: session.sessionId }
)}
>
<X className="size-3" />
</button>
</span>
</div>
)
}
function BrowserRow({ browser }: { browser: BrowserWorkspace }): React.JSX.Element {
const label = browser.title?.trim() || browser.label?.trim() || browser.url
return (
<div className="flex items-center gap-2 pl-10 pr-3 py-1.5">
<Globe className="size-3 shrink-0 text-muted-foreground" aria-hidden />
<span className="min-w-0 flex-1 truncate text-[11px] text-muted-foreground">{label}</span>
<ResourceUsageMetricPair cpu={null} memory={null} size="small" />
<span className={ROW_TRAILING_GUTTER_CLS} aria-hidden />
</div>
)
}
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 (
<div className="border-b border-border/20 last:border-b-0">
<div className="group/wtrow flex items-center ml-2 transition-colors hover:bg-muted/60">
{hasResources ? (
<button
type="button"
onClick={onToggle}
className="pl-2 py-2 pr-0.5 shrink-0"
aria-label={
isCollapsed
? translate(
'auto.components.status.bar.ResourceUsageStatusSegment.c4a8968bdd',
'Expand workspace'
)
: translate(
'auto.components.status.bar.ResourceUsageStatusSegment.bbcd9b7b85',
'Collapse workspace'
)
}
>
{isCollapsed ? (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronDown className="h-3 w-3 text-muted-foreground" />
)}
</button>
) : (
<span
className="pl-2 py-2 pr-0.5 shrink-0 w-[calc(0.5rem+0.75rem+0.125rem)]"
aria-hidden
/>
)}
<button
type="button"
onClick={onNavigate}
aria-label={translate(
'auto.components.status.bar.ResourceUsageStatusSegment.d659d71d2d',
'Resume workspace {{value0}}',
{ value0: rowLabel }
)}
className="flex-1 min-w-0 py-2 pr-2 pl-1 text-left flex items-center gap-1.5"
disabled={!isNavigable}
>
<span className="text-xs font-medium truncate">{rowLabel}</span>
{worktree.isRemote && (
<span className="shrink-0 text-[9px] uppercase tracking-wide text-muted-foreground/70">
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.21cacb16d1',
'· remote'
)}
</span>
)}
</button>
<div className="flex items-center gap-2 shrink-0 pr-3">
<div className="relative">
<span
className={cn(
'block transition-opacity',
showWorktreeActions &&
'group-hover/wtrow:opacity-0 group-hover/wtrow:pointer-events-none group-focus-within/wtrow:opacity-0 group-focus-within/wtrow:pointer-events-none [@media(hover:none)]:opacity-0 [@media(hover:none)]:pointer-events-none'
)}
aria-hidden={showWorktreeActions ? undefined : true}
>
<ResourceUsageSparkline samples={worktree.history} />
</span>
{showWorktreeActions && (
<div className="absolute inset-0 flex items-center justify-end gap-0.5 can-hover:opacity-0 can-hover:pointer-events-none transition-opacity group-hover/wtrow:opacity-100 group-hover/wtrow:pointer-events-auto group-focus-within/wtrow:opacity-100 group-focus-within/wtrow:pointer-events-auto">
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<button
type="button"
onClick={onDelete}
disabled={isMainWorktree}
aria-label={translate(
'auto.components.status.bar.ResourceUsageStatusSegment.16bc3c998a',
'Delete workspace {{value0}}',
{ value0: rowLabel }
)}
className={cn(
'p-0.5 rounded text-muted-foreground transition-colors',
isMainWorktree
? 'opacity-40 cursor-not-allowed'
: 'hover:bg-destructive/10 hover:text-destructive'
)}
>
<Trash2 className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent
side="top"
sideOffset={4}
className="z-[70] max-w-[200px] text-pretty"
>
{isMainWorktree
? translate(
'auto.components.status.bar.ResourceUsageStatusSegment.946724a70a',
'The main workspace cannot be deleted.'
)
: translate(
'auto.components.status.bar.ResourceUsageStatusSegment.a82253b458',
'Delete workspace.'
)}
</TooltipContent>
</Tooltip>
</div>
)}
</div>
<ResourceUsageMetricPair cpu={worktree.cpu} memory={worktree.memory} />
<span className={ROW_TRAILING_GUTTER_CLS} aria-hidden />
</div>
</div>
{!isCollapsed &&
worktree.sessions.map((session) => (
<ResourceUsageSessionRow
key={session.sessionId}
session={session}
worktreeId={worktree.worktreeId}
onNavigate={navigateToTab}
onKill={onKillSession}
/>
))}
{!isCollapsed &&
worktree.browsers.map((browser) => <BrowserRow key={browser.id} browser={browser} />)}
</div>
)
}
@@ -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
}
@@ -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<ResourceUsageSortOption>('memory')
const [collapsedRepos, setCollapsedRepos] = useState<Set<string>>(new Set())
const [collapsedWorktrees, setCollapsedWorktrees] = useState<Set<string>>(new Set())
const [appCollapsed, setAppCollapsed] = useState(true)
const {
sessionInventory,
sessionsError,
refreshSessions,
clearSessionsError,
removeSession,
removeSessions
} = useResourceSessionInventory(workspaceSessionReady)
const sessions = sessionInventory.sessions
const [killConfirm, setKillConfirm] = useState<UnifiedSessionRow | null>(null)
const [killing, setKilling] = useState(false)
const [spaceScanSnapshot, setSpaceScanSnapshot] = useState<ResourceUsageSpaceScanSnapshot>(
() => ({
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<ResourceSessionBindingInputs>(
() => ({
ptyIdsByTabId,
tabsByWorktree,
terminalLayoutsByTabId,
deferredSshSessionIdsByTabId,
workspaceSessionReady
}),
[
ptyIdsByTabId,
tabsByWorktree,
terminalLayoutsByTabId,
deferredSshSessionIdsByTabId,
workspaceSessionReady
]
)
const popoverBodyRef = useRef<HTMLDivElement | null>(null)
const popoverBodyFocusFrameRef = useRef<number | null>(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<typeof useResourceUsageFoundation>
@@ -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<string, string>()
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<string, string | null>()
for (const repo of repos) {
map.set(repo.id, repo.connectionId ?? null)
}
return map
}, [repos])
const repoRuntimeScopedById = useMemo(() => {
const map = new Map<string, boolean>()
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<typeof useResourceUsageProjection>
@@ -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>): 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(
<WorkspaceSpaceBreakdownList
isScanning={false}
worktree={worktree({
topLevelItems: [item('node_modules', 400), item('src', 100)],
omittedTopLevelItemCount: 7,
omittedTopLevelSizeBytes: 900
})}
/>
)
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(
<WorkspaceSpaceBreakdownList
isScanning={false}
worktree={worktree({
topLevelItems: [item('src', 250)],
omittedTopLevelItemCount: 3,
omittedTopLevelSizeBytes: 1000
})}
/>
)
const widths = Array.from(container.querySelectorAll<HTMLElement>('div[style]')).map(
(node) => node.style.width
)
expect(widths).toEqual(['25%', '100%'])
})
it('omits the aggregate row when nothing was omitted', () => {
const { container } = render(
<WorkspaceSpaceBreakdownList
isScanning={false}
worktree={worktree({ topLevelItems: [item('src', 250)] })}
/>
)
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(
<WorkspaceSpaceBreakdownList
isScanning={false}
worktree={worktree({ omittedTopLevelItemCount: 4, omittedTopLevelSizeBytes: 80 })}
/>
)
expect(container.textContent).not.toContain('No files found.')
expect(renderedRowNames(container)).toEqual(['Other top-level items (4)'])
})
})
@@ -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 (
<div className="min-h-72 rounded-lg border border-border/70 bg-background/35">
<div className="border-b border-border/60 px-4 py-3">
@@ -86,7 +102,7 @@ export function WorkspaceSpaceBreakdownList({
)}
</span>
</div>
) : worktree.topLevelItems.length === 0 ? (
) : topLevelItemCount === 0 ? (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
{translate(
'auto.components.status.bar.WorkspaceSpaceManagerPanel.16988df079',
@@ -99,6 +115,7 @@ export function WorkspaceSpaceBreakdownList({
{worktree.topLevelItems.slice(0, 12).map((item) => (
<BreakdownRow key={`${item.path}:${item.name}`} item={item} maxSize={maxChildSize} />
))}
{omittedItem ? <BreakdownRow item={omittedItem} maxSize={maxChildSize} /> : null}
</div>
</div>
)}
@@ -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' }),
@@ -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<EditorRequestCmdSaveDetail>(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
}
@@ -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<string, unknown>,
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<EditorRequestCmdSaveDetail>).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([])
})
})
@@ -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()
@@ -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<string, unknown>,
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')
})
})
@@ -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]
)
@@ -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 (
@@ -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<WorktreeTabBucketProjection<
TerminalTab,
TerminalTab
> | null>(null)
terminalTopologyProjectionRef.current ??= createTerminalWorktreeTopologyProjection()
const mountedWorktreeIdsRef = useRef(new Set<string>())
const browserGuestWorktreeRecencyRef = useRef<string[]>([])
const measurableBackgroundWorktreeIdsRef = useRef(new Set<string>())
@@ -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
@@ -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,
@@ -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)
@@ -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
}
@@ -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 }
])
}
@@ -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<AppState['browserTabsByWorktree'][string]>[number]
): boolean {
// Document previews are served through a desktop-only grant.
return !workspace.docLocation
}
@@ -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
)
}
@@ -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
@@ -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
@@ -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> = {}): 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> = {}): 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)
})
})
@@ -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<void> } | null {
return linearRequestState.inflightStatusRequest
}
export function setInflightStatusRequest(
request: { contextKey: string; promise: Promise<void> } | null
): void {
linearRequestState.inflightStatusRequest = request
}