mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -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={
|
||||
|
||||
+4
@@ -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()
|
||||
|
||||
+13
-24
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user