mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
Fix Resource Manager tab sizing and sort PTYs by workspace activity (#33226)
* Keep PTY resource tab mounted and sort workspaces by activity * Fix Resource Manager tab sizing and verify PTY list scaling
This commit is contained in:
@@ -6,6 +6,8 @@ import { ResourcePtyList } from './ResourcePtyList'
|
||||
import type { useResourceSessionInventory } from './use-resource-session-inventory'
|
||||
import type { useResourcePtyActions } from './use-resource-pty-actions'
|
||||
|
||||
const EMPTY_PTY_ROWS: ReturnType<typeof buildResourcePtyRows> = []
|
||||
|
||||
type Props = Omit<Parameters<typeof buildResourcePtyRows>[0], 'sessions'> & {
|
||||
active: boolean
|
||||
inventory: ReturnType<typeof useResourceSessionInventory>
|
||||
@@ -37,14 +39,17 @@ export function ResourcePtyPanel({
|
||||
runtimePaneTitlesByTabId,
|
||||
sessions
|
||||
})
|
||||
: [],
|
||||
: EMPTY_PTY_ROWS,
|
||||
[active, sessions, bindings, worktrees, repos, projectGroups, runtimePaneTitlesByTabId]
|
||||
)
|
||||
const groups = useMemo(() => groupResourcePtyRows(rows), [rows])
|
||||
const unbound = rows.filter((row) => row.binding === 'unbound').length
|
||||
const unbound = useMemo(
|
||||
() => rows.reduce((count, row) => count + Number(row.binding === 'unbound'), 0),
|
||||
[rows]
|
||||
)
|
||||
return (
|
||||
<div className="flex h-[420px] flex-col">
|
||||
<div className="space-y-2 border-b border-border px-3 py-2">
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="shrink-0 space-y-2 border-b border-border px-3 py-2">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{inventory.observedAt === null
|
||||
? translate('resourceManager.pty.thisComputer', 'This computer')
|
||||
@@ -75,7 +80,7 @@ export function ResourcePtyPanel({
|
||||
)}
|
||||
</div>
|
||||
{inventory.sessionsError && (
|
||||
<div role="alert" className="px-3 py-2 text-xs text-destructive">
|
||||
<div role="alert" className="shrink-0 px-3 py-2 text-xs text-destructive">
|
||||
{translate(
|
||||
'resourceManager.pty.failed',
|
||||
'Unverifiable: local terminals could not be refreshed. Last-observed terminals are shown.'
|
||||
|
||||
@@ -106,7 +106,7 @@ export function ResourceUsageStatusSegment({
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
{...STATUS_BAR_CONTEXT_MENU_EXEMPT_PROPS}
|
||||
className="w-[26rem] max-w-[calc(100vw-2rem)] p-0"
|
||||
className="flex h-[min(40rem,var(--radix-popover-content-available-height))] w-[26rem] max-w-[calc(100vw-2rem)] flex-col"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
// Why: activating a tab focuses xterm's DOM node; Radix would read that as focus-outside and close. Outside-click and Escape still close.
|
||||
onFocusOutside={(event) => event.preventDefault()}
|
||||
@@ -123,8 +123,12 @@ export function ResourceUsageStatusSegment({
|
||||
? () => void controller.localPtyInventory.refreshSessions()
|
||||
: controller.refreshResources
|
||||
})}
|
||||
<Tabs value={controller.selectedTab} onValueChange={controller.setSelectedTab}>
|
||||
<div className="px-3 pt-1 pb-1">
|
||||
<Tabs
|
||||
value={controller.selectedTab}
|
||||
onValueChange={controller.setSelectedTab}
|
||||
className="min-h-0 flex-1"
|
||||
>
|
||||
<div className="shrink-0 px-3 pt-1 pb-1">
|
||||
<TabsList variant="line" size="sm">
|
||||
<TabsTrigger value="resources">
|
||||
<Activity aria-hidden="true" className="size-3" />
|
||||
@@ -141,7 +145,10 @@ export function ResourceUsageStatusSegment({
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<TabsContent value="resources">
|
||||
<TabsContent
|
||||
value="resources"
|
||||
className="flex min-h-0 flex-col data-[state=inactive]:hidden"
|
||||
>
|
||||
{controller.actionError && (
|
||||
<p role="alert" className="px-3 py-2 text-xs text-destructive">
|
||||
{controller.actionError}
|
||||
@@ -159,7 +166,6 @@ export function ResourceUsageStatusSegment({
|
||||
commitToneClass,
|
||||
orphanCount
|
||||
})}
|
||||
{/* Why: fixed 420px height so the popover doesn't jump as worktrees expand/collapse or sessions change; inner tree owns its scroll. */}
|
||||
{renderResourceUsagePopoverBody({
|
||||
setPopoverBodyNode,
|
||||
unifiedRepos,
|
||||
@@ -188,7 +194,7 @@ export function ResourceUsageStatusSegment({
|
||||
})}
|
||||
<WorkspaceSpaceCompactPanel onOpenFullPage={openSpaceResults} />
|
||||
</TabsContent>
|
||||
<TabsContent value="ptys">
|
||||
<TabsContent value="ptys" className="min-h-0 data-[state=inactive]:hidden">
|
||||
<ResourcePtyPanel
|
||||
active={controller.ptyViewActive}
|
||||
inventory={controller.localPtyInventory}
|
||||
|
||||
@@ -34,7 +34,7 @@ export function WorkspaceSpaceCompactPanel({
|
||||
}, [cancelWorkspaceSpaceScan])
|
||||
|
||||
return (
|
||||
<div className="border-t border-border/50 bg-muted/15 px-3 py-2">
|
||||
<div className="shrink-0 border-t border-border/50 bg-muted/15 px-3 py-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<HardDrive className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { buildResourcePtyRows, groupResourcePtyRows } from './resource-pty-view-model'
|
||||
import type { PtyListedSession } from '../../../../shared/pty-listed-session'
|
||||
import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree'
|
||||
@@ -220,16 +220,100 @@ describe('local PTY view model', () => {
|
||||
expect(order(rows.filter((row) => row.identity !== '3'))).toEqual(expected)
|
||||
})
|
||||
|
||||
it('joins and groups 1,000 sessions in memory', () => {
|
||||
const start = performance.now()
|
||||
it('sorts projects and workspaces by latest activity, including folder workspaces', () => {
|
||||
const folder = folderWorkspaceToWorktree({ ...makeFolderWorkspace(), lastActivityAt: 300 })
|
||||
const rows = buildResourcePtyRows({
|
||||
...base(),
|
||||
sessions: Array.from({ length: 1_000 }, (_, index) =>
|
||||
session(`pty-${index}`, { title: `Terminal ${index}` })
|
||||
)
|
||||
worktrees: [
|
||||
{ ...makeWorktree('older', 'Alpha'), lastActivityAt: 100 },
|
||||
{ ...makeWorktree('recent', 'Zeta'), lastActivityAt: 200 },
|
||||
folder
|
||||
],
|
||||
projectGroups: [makeProjectGroup()],
|
||||
sessions: [
|
||||
session('old', { worktreeId: 'older' }),
|
||||
session('recent', { worktreeId: 'recent' }),
|
||||
session('folder', { worktreeId: folder.id }),
|
||||
session('unknown')
|
||||
]
|
||||
})
|
||||
const groups = groupResourcePtyRows(rows)
|
||||
expect(groups[0]?.workspaces[0]?.rows).toHaveLength(1_000)
|
||||
console.info(`PTY model 1,000 rows: ${(performance.now() - start).toFixed(2)}ms`)
|
||||
const order = (input: typeof rows) =>
|
||||
groupResourcePtyRows(input).map((group) => ({
|
||||
id: group.id,
|
||||
workspaces: group.workspaces.map((workspace) => workspace.id)
|
||||
}))
|
||||
const expected = [
|
||||
{ id: folder.repoId, workspaces: [folder.id] },
|
||||
{ id: 'repo-1', workspaces: ['recent', 'older'] },
|
||||
{ id: '', workspaces: [''] }
|
||||
]
|
||||
expect(order(rows)).toEqual(expected)
|
||||
expect(order(rows.toReversed())).toEqual(expected)
|
||||
expect(rows.map((row) => row.lastActivityAt)).toEqual([100, 200, 300, 0])
|
||||
})
|
||||
|
||||
it('falls back to stable name ordering for missing or invalid activity timestamps', () => {
|
||||
const inputs = base()
|
||||
const rows = buildResourcePtyRows({
|
||||
...inputs,
|
||||
worktrees: [
|
||||
{ ...makeWorktree('a', 'Alpha'), lastActivityAt: Number.NaN },
|
||||
{ ...makeWorktree('z', 'Zeta'), lastActivityAt: Number.POSITIVE_INFINITY }
|
||||
],
|
||||
sessions: [session('z', { worktreeId: 'z' }), session('a', { worktreeId: 'a' })]
|
||||
})
|
||||
expect(rows.every((row) => row.lastActivityAt === 0)).toBe(true)
|
||||
expect(groupResourcePtyRows(rows)[0]?.workspaces.map((workspace) => workspace.id)).toEqual([
|
||||
'a',
|
||||
'z'
|
||||
])
|
||||
})
|
||||
|
||||
it.each([1_000, 10_000])('joins and sorts %i sessions with bounded comparison work', (count) => {
|
||||
const workspaceCount = count / 10
|
||||
const inputs = {
|
||||
...base(),
|
||||
repos: Array.from({ length: 20 }, (_, index) => ({
|
||||
...makeRepo(),
|
||||
id: `repo-${index}`,
|
||||
displayName: `Project ${index}`
|
||||
})),
|
||||
worktrees: Array.from({ length: workspaceCount }, (_, index) =>
|
||||
makeWorktree(`workspace-${index}`, `Workspace ${index}`, {
|
||||
repoId: `repo-${index % 20}`,
|
||||
lastActivityAt: index % 7
|
||||
})
|
||||
),
|
||||
sessions: Array.from({ length: count }, (_, index) =>
|
||||
session(`pty-${(index * 7919) % count}`, {
|
||||
worktreeId: `workspace-${index % workspaceCount}`
|
||||
})
|
||||
)
|
||||
}
|
||||
const start = performance.now()
|
||||
const rows = buildResourcePtyRows(inputs)
|
||||
const elapsedJoin = performance.now() - start
|
||||
const comparisons = vi.spyOn(String.prototype, 'localeCompare')
|
||||
const normalizations = vi.spyOn(String.prototype, 'toLocaleLowerCase')
|
||||
try {
|
||||
const groups = groupResourcePtyRows(rows)
|
||||
const elapsedSort = performance.now() - start - elapsedJoin
|
||||
expect(normalizations).toHaveBeenCalledTimes(workspaceCount + 20)
|
||||
expect(comparisons.mock.calls.length).toBeLessThan(2 * count * Math.ceil(Math.log2(count)))
|
||||
expect(
|
||||
groups.flatMap((group) => group.workspaces.flatMap((workspace) => workspace.rows))
|
||||
).toHaveLength(count)
|
||||
for (const group of groups) {
|
||||
expect(group.workspaces.map((workspace) => workspace.lastActivityAt)).toEqual(
|
||||
group.workspaces.map((workspace) => workspace.lastActivityAt).toSorted((a, b) => b - a)
|
||||
)
|
||||
}
|
||||
console.info(
|
||||
`PTY ${count}: join ${elapsedJoin.toFixed(2)}ms, sort ${elapsedSort.toFixed(2)}ms`
|
||||
)
|
||||
} finally {
|
||||
comparisons.mockRestore()
|
||||
normalizations.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '../../../../shared/execution-host'
|
||||
import { projectGroupIdFromRepoId } from '../../../../shared/folder-workspace-worktree'
|
||||
import { makePaneKey, isTerminalLeafId } from '../../../../shared/stable-pane-id'
|
||||
import { recentStamp } from '../../../../shared/sidebar-list/stamp-order'
|
||||
import {
|
||||
buildResourceSessionBindingIndex,
|
||||
type ResourceSessionBindingInputs
|
||||
@@ -22,6 +23,7 @@ export type ResourcePtyRow = {
|
||||
identity: string
|
||||
label: string
|
||||
sortName: string
|
||||
lastActivityAt: number
|
||||
workspace: string | null
|
||||
projectId: string
|
||||
project: string | null
|
||||
@@ -34,7 +36,15 @@ export type ResourcePtyRow = {
|
||||
export type ResourcePtyGroup = {
|
||||
id: string
|
||||
name: string | null
|
||||
workspaces: { id: string; name: string | null; rows: ResourcePtyRow[] }[]
|
||||
sortName: string
|
||||
lastActivityAt: number
|
||||
workspaces: {
|
||||
id: string
|
||||
name: string | null
|
||||
sortName: string
|
||||
lastActivityAt: number
|
||||
rows: ResourcePtyRow[]
|
||||
}[]
|
||||
}
|
||||
|
||||
export function buildResourcePtyRows(args: {
|
||||
@@ -152,6 +162,7 @@ export function buildResourcePtyRows(args: {
|
||||
identity: ptySessionIdentity(session),
|
||||
label,
|
||||
sortName: tab?.customTitle?.trim() || tab?.defaultTitle?.trim() || session.id,
|
||||
lastActivityAt: recentStamp(workspace ?? {}),
|
||||
workspace: workspaceName,
|
||||
workspaceId: workspace?.id ?? null,
|
||||
projectId: project ? (workspace?.repoId ?? '') : '',
|
||||
@@ -177,7 +188,13 @@ export function groupResourcePtyRows(rows: readonly ResourcePtyRow[]): ResourceP
|
||||
let project = projects.get(row.projectId)
|
||||
if (!project) {
|
||||
project = {
|
||||
group: { id: row.projectId, name: row.project, workspaces: [] },
|
||||
group: {
|
||||
id: row.projectId,
|
||||
name: row.project,
|
||||
sortName: row.project?.trim().toLocaleLowerCase() ?? '\uffff',
|
||||
lastActivityAt: 0,
|
||||
workspaces: []
|
||||
},
|
||||
workspaces: new Map()
|
||||
}
|
||||
projects.set(row.projectId, project)
|
||||
@@ -185,19 +202,27 @@ export function groupResourcePtyRows(rows: readonly ResourcePtyRow[]): ResourceP
|
||||
const workspaceId = row.workspaceId ?? ''
|
||||
let workspace = project.workspaces.get(workspaceId)
|
||||
if (!workspace) {
|
||||
workspace = { id: workspaceId, name: row.workspace, rows: [] }
|
||||
workspace = {
|
||||
id: workspaceId,
|
||||
name: row.workspace,
|
||||
sortName: row.workspace?.trim().toLocaleLowerCase() ?? '\uffff',
|
||||
lastActivityAt: 0,
|
||||
rows: []
|
||||
}
|
||||
project.workspaces.set(workspaceId, workspace)
|
||||
project.group.workspaces.push(workspace)
|
||||
}
|
||||
workspace.rows.push(row)
|
||||
workspace.lastActivityAt = Math.max(workspace.lastActivityAt, row.lastActivityAt)
|
||||
project.group.lastActivityAt = Math.max(project.group.lastActivityAt, row.lastActivityAt)
|
||||
}
|
||||
const groups = [...projects.values()].map(({ group }) => group)
|
||||
for (const group of groups) {
|
||||
group.workspaces.sort(
|
||||
(a, b) =>
|
||||
(a.name?.trim().toLocaleLowerCase() ?? '\uffff').localeCompare(
|
||||
b.name?.trim().toLocaleLowerCase() ?? '\uffff'
|
||||
) || a.id.localeCompare(b.id)
|
||||
b.lastActivityAt - a.lastActivityAt ||
|
||||
a.sortName.localeCompare(b.sortName) ||
|
||||
a.id.localeCompare(b.id)
|
||||
)
|
||||
for (const workspace of group.workspaces) {
|
||||
workspace.rows.sort(
|
||||
@@ -207,8 +232,8 @@ export function groupResourcePtyRows(rows: readonly ResourcePtyRow[]): ResourceP
|
||||
}
|
||||
return groups.sort(
|
||||
(a, b) =>
|
||||
(a.name?.trim().toLocaleLowerCase() ?? '\uffff').localeCompare(
|
||||
b.name?.trim().toLocaleLowerCase() ?? '\uffff'
|
||||
) || a.id.localeCompare(b.id)
|
||||
b.lastActivityAt - a.lastActivityAt ||
|
||||
a.sortName.localeCompare(b.sortName) ||
|
||||
a.id.localeCompare(b.id)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import type { ComponentProps } from 'react'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { ResourcePtyPanel } from './ResourcePtyPanel'
|
||||
import * as model from './resource-pty-view-model'
|
||||
import { makeRepo, makeWorktree } from '../worktree-jump-palette-test-fixtures'
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (_key: string, fallback: string, values?: Record<string, string | number>) =>
|
||||
Object.entries(values ?? {}).reduce(
|
||||
(text, [key, value]) => text.replace(`{{${key}}}`, String(value)),
|
||||
fallback
|
||||
)
|
||||
}))
|
||||
|
||||
const VIEWPORT_HEIGHT = 360
|
||||
const ROW_HEIGHT = 40
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
)
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function (
|
||||
this: HTMLElement
|
||||
) {
|
||||
return this.hasAttribute('data-resource-pty-body') ? VIEWPORT_HEIGHT : ROW_HEIGHT
|
||||
})
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
|
||||
return new DOMRect(
|
||||
0,
|
||||
0,
|
||||
400,
|
||||
this.hasAttribute('data-resource-pty-body') ? VIEWPORT_HEIGHT : ROW_HEIGHT
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function panelProps(count: number): ComponentProps<typeof ResourcePtyPanel> {
|
||||
return {
|
||||
active: true,
|
||||
inventory: {
|
||||
sessionInventory: {
|
||||
sessions: Array.from({ length: count }, (_, index) => ({
|
||||
id: `pty-${String(index).padStart(5, '0')}`,
|
||||
incarnationId: 'A',
|
||||
fencedTermination: true,
|
||||
cwd: '/notes',
|
||||
title: `Terminal ${index}`,
|
||||
worktreeId: 'wt-1',
|
||||
agentOwnership: 'absent' as const
|
||||
})),
|
||||
count
|
||||
},
|
||||
observedAt: 1,
|
||||
sessionsError: false,
|
||||
loading: false,
|
||||
refreshSessions: async () => {},
|
||||
clearSessionsError: () => {},
|
||||
invalidate: () => {},
|
||||
removeSession: () => {},
|
||||
removeSessions: () => {}
|
||||
},
|
||||
actions: {
|
||||
confirmation: null,
|
||||
setConfirmation: () => {},
|
||||
ending: new Set(),
|
||||
errors: new Map(),
|
||||
confirm: () => {},
|
||||
endConfirmed: async () => {}
|
||||
},
|
||||
onNavigate: () => {},
|
||||
bindings: { tabsByWorktree: {}, ptyIdsByTabId: {}, workspaceSessionReady: true },
|
||||
worktrees: [makeWorktree('wt-1', 'Notes', { lastActivityAt: 100 })],
|
||||
repos: [makeRepo()],
|
||||
projectGroups: [],
|
||||
runtimePaneTitlesByTabId: {}
|
||||
}
|
||||
}
|
||||
|
||||
it.each([1_000, 10_000])(
|
||||
'bounds mounted PTY rows for %i sessions and reaches the last row',
|
||||
(count) => {
|
||||
const props = panelProps(count)
|
||||
const view = render(
|
||||
<TooltipProvider>
|
||||
<ResourcePtyPanel {...props} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
const scroller = view.container.querySelector<HTMLElement>('[data-resource-pty-body]')
|
||||
if (!scroller) {
|
||||
throw new Error('PTY scroller missing')
|
||||
}
|
||||
const mountedRows = () => view.container.querySelectorAll('[data-resource-pty-row]')
|
||||
expect(mountedRows().length).toBeGreaterThan(0)
|
||||
expect(mountedRows().length).toBeLessThan(50)
|
||||
expect(mountedRows()[0]?.getAttribute('data-resource-pty-row')).toContain('pty-00000')
|
||||
expect(view.container.querySelector('[role="listitem"]')?.getAttribute('aria-setsize')).toBe(
|
||||
String(count + 2)
|
||||
)
|
||||
act(() => {
|
||||
scroller.scrollTop = (count + 2) * ROW_HEIGHT - VIEWPORT_HEIGHT
|
||||
fireEvent.scroll(scroller)
|
||||
})
|
||||
expect(mountedRows().length).toBeGreaterThan(0)
|
||||
expect(mountedRows().length).toBeLessThan(50)
|
||||
expect(
|
||||
mountedRows()[mountedRows().length - 1]?.getAttribute('data-resource-pty-row')
|
||||
).toContain(`pty-${String(count - 1).padStart(5, '0')}`)
|
||||
}
|
||||
)
|
||||
|
||||
it('skips joins and sorting on scrolling or action updates, and does no model work when inactive', () => {
|
||||
const join = vi.spyOn(model, 'buildResourcePtyRows')
|
||||
const sort = vi.spyOn(model, 'groupResourcePtyRows')
|
||||
const props = panelProps(1_000)
|
||||
const panel = (value: typeof props) => (
|
||||
<TooltipProvider>
|
||||
<ResourcePtyPanel {...value} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
const view = render(panel(props))
|
||||
const joined = join.mock.calls.length
|
||||
const sorted = sort.mock.calls.length
|
||||
const scroller = view.container.querySelector<HTMLElement>('[data-resource-pty-body]')
|
||||
if (!scroller) {
|
||||
throw new Error('PTY scroller missing')
|
||||
}
|
||||
act(() => {
|
||||
scroller.scrollTop = ROW_HEIGHT * 20
|
||||
fireEvent.scroll(scroller)
|
||||
})
|
||||
view.rerender(panel({ ...props, actions: { ...props.actions, errors: new Map() } }))
|
||||
expect(join).toHaveBeenCalledTimes(joined)
|
||||
expect(sort).toHaveBeenCalledTimes(sorted)
|
||||
view.rerender(panel({ ...props, active: false }))
|
||||
const inactiveSorts = sort.mock.calls.length
|
||||
view.rerender(panel({ ...props, active: false, worktrees: [...props.worktrees] }))
|
||||
expect(join).toHaveBeenCalledTimes(joined)
|
||||
expect(sort).toHaveBeenCalledTimes(inactiveSorts)
|
||||
expect(view.container.querySelectorAll('[data-resource-pty-row]')).toHaveLength(0)
|
||||
})
|
||||
@@ -57,7 +57,11 @@ export function renderResourceUsagePopoverBody({
|
||||
runtimeHostNotices: readonly string[]
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div ref={setPopoverBodyNode} tabIndex={-1} className="flex h-[420px] flex-col outline-none">
|
||||
<div
|
||||
ref={setPopoverBodyNode}
|
||||
tabIndex={-1}
|
||||
className="flex min-h-0 flex-1 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
|
||||
@@ -111,7 +115,7 @@ export function renderResourceUsagePopoverBody({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto scrollbar-sleek">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-sleek">
|
||||
{runtimeHostNotices.map((notice) => (
|
||||
<div
|
||||
key={notice}
|
||||
|
||||
@@ -66,22 +66,27 @@ test('a real hidden window performs no Resource Manager diagnostics and vetoes f
|
||||
await expect(orcaPage.getByText(/Unverifiable: local terminals/)).toHaveCount(0)
|
||||
})
|
||||
|
||||
for (const theme of ['dark', 'light'] as const) {
|
||||
test(`local PTY review and checked confirmation with 1,000 sessions (${theme})`, async ({
|
||||
for (const { theme, sessionCount, viewportHeight } of [
|
||||
{ theme: 'dark', sessionCount: 1_000, viewportHeight: 900 },
|
||||
{ theme: 'light', sessionCount: 1_000, viewportHeight: 900 },
|
||||
{ theme: 'dark', sessionCount: 20, viewportHeight: 500 },
|
||||
{ theme: 'light', sessionCount: 10_000, viewportHeight: 500 }
|
||||
] as const) {
|
||||
test(`local PTY review and checked confirmation with ${sessionCount} sessions (${theme}, ${viewportHeight}px)`, async ({
|
||||
electronApp,
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await orcaPage.setViewportSize({ width: 1200, height: 900 })
|
||||
await electronApp.evaluate(({ ipcMain }) => {
|
||||
const rows = Array.from({ length: 1_000 }, (_, index) => ({
|
||||
await orcaPage.setViewportSize({ width: 1200, height: viewportHeight })
|
||||
await electronApp.evaluate(({ ipcMain }, sessionCount) => {
|
||||
const rows = Array.from({ length: sessionCount }, (_, index) => ({
|
||||
id: `local-pty-${String(index).padStart(4, '0')}`,
|
||||
incarnationId: `incarnation-${index}`,
|
||||
title: `Terminal ${index}`,
|
||||
...(index === 999 ? { foregroundProcess: 'node' } : {}),
|
||||
...(index === sessionCount - 1 ? { foregroundProcess: 'node' } : {}),
|
||||
cwd: `/workspace/folder-${index}`,
|
||||
agentOwnership: index === 0 ? 'unknown' : 'absent',
|
||||
fencedTermination: index !== 998
|
||||
fencedTermination: index !== sessionCount - 2
|
||||
}))
|
||||
const counts: Required<PtyViewCounts> = {
|
||||
listing: 0,
|
||||
@@ -113,18 +118,23 @@ for (const theme of ['dark', 'light'] as const) {
|
||||
counts.kills.push(request)
|
||||
return { outcome: 'refused', reason: 'attached' }
|
||||
})
|
||||
})
|
||||
}, sessionCount)
|
||||
await makeResourceManagerFixtureVisible(electronApp, orcaPage)
|
||||
await orcaPage.evaluate(async (theme) => {
|
||||
await window.__store!.getState().updateSettingsOrThrow({ theme })
|
||||
}, theme)
|
||||
await orcaPage.getByRole('button', { name: /^Resource Manager,/ }).click()
|
||||
const popover = orcaPage.locator('[data-slot="popover-content"]').filter({
|
||||
has: orcaPage.getByRole('tab', { name: 'Resources', exact: true })
|
||||
})
|
||||
const resourcesHeight = await popover.evaluate((element) => element.clientHeight)
|
||||
const listStarted = performance.now()
|
||||
await orcaPage.getByRole('tab', { name: 'PTYs', exact: true }).click()
|
||||
await expect(
|
||||
orcaPage.getByText('1000 Orca PTYs on this computer · 1000 without a tab', {
|
||||
exact: true
|
||||
})
|
||||
orcaPage.getByText(
|
||||
`${sessionCount} Orca PTYs on this computer · ${sessionCount} without a tab`,
|
||||
{ exact: true }
|
||||
)
|
||||
).toBeVisible()
|
||||
const listMs = performance.now() - listStarted
|
||||
const before = await electronApp.evaluate(() => {
|
||||
@@ -137,11 +147,17 @@ for (const theme of ['dark', 'light'] as const) {
|
||||
await expect(panel.getByRole('searchbox')).toHaveCount(0)
|
||||
await expect(panel.getByRole('combobox')).toHaveCount(0)
|
||||
const body = orcaPage.locator('[data-resource-pty-body]')
|
||||
await expect
|
||||
.poll(() => popover.evaluate((element) => element.clientHeight))
|
||||
.toBe(resourcesHeight)
|
||||
await expect
|
||||
.poll(() => body.evaluate((element) => element.scrollHeight > element.clientHeight))
|
||||
.toBe(true)
|
||||
await body.evaluate((element) => {
|
||||
element.scrollTop = element.scrollHeight
|
||||
})
|
||||
const unsupportedEnd = panel.getByRole('button', {
|
||||
name: 'End Terminal 998',
|
||||
name: `End Terminal ${sessionCount - 2}`,
|
||||
exact: true
|
||||
})
|
||||
await expect(unsupportedEnd).toBeDisabled()
|
||||
@@ -149,17 +165,21 @@ for (const theme of ['dark', 'light'] as const) {
|
||||
await expect(orcaPage.getByRole('tooltip', { name: /^End is unavailable/ })).toContainText(
|
||||
'End is unavailable for this terminal. Its host does not support the required safety checks.'
|
||||
)
|
||||
const details = panel.getByRole('button', { name: 'Details for Terminal 999', exact: true })
|
||||
const details = panel.getByRole('button', {
|
||||
name: `Details for Terminal ${sessionCount - 1}`,
|
||||
exact: true
|
||||
})
|
||||
await details.focus()
|
||||
await orcaPage.keyboard.press('Enter')
|
||||
await expect(details).toHaveAttribute('aria-expanded', 'true')
|
||||
await expect(panel.locator('dl').getByText('local-pty-0999', { exact: true })).toBeVisible()
|
||||
await expect(panel.locator('dl').getByText('incarnation-999', { exact: true })).toBeVisible()
|
||||
const lastSessionId = `local-pty-${String(sessionCount - 1).padStart(4, '0')}`
|
||||
await expect(panel.locator('dl').getByText(lastSessionId, { exact: true })).toBeVisible()
|
||||
await expect(
|
||||
panel.locator('dl').getByText(`incarnation-${sessionCount - 1}`, { exact: true })
|
||||
).toBeVisible()
|
||||
await expect(panel.locator('dl').getByText('Unavailable', { exact: true })).toHaveCount(2)
|
||||
await panel.getByRole('button', { name: 'Copy Orca session ID', exact: true }).click()
|
||||
expect(await electronApp.evaluate(({ clipboard }) => clipboard.readText())).toBe(
|
||||
'local-pty-0999'
|
||||
)
|
||||
expect(await electronApp.evaluate(({ clipboard }) => clipboard.readText())).toBe(lastSessionId)
|
||||
await orcaPage.screenshot({
|
||||
path: testInfo.outputPath(`local-pty-details-${theme}.png`),
|
||||
animations: 'disabled'
|
||||
@@ -172,7 +192,7 @@ for (const theme of ['dark', 'light'] as const) {
|
||||
await expect(firstTerminal).toBeVisible()
|
||||
expect(await orcaPage.getByRole('button', { name: /^End Terminal/ }).count()).toBeLessThan(50)
|
||||
await firstTerminal.focus()
|
||||
for (let index = 1; index <= 30; index++) {
|
||||
for (let index = 1; index <= Math.min(30, sessionCount - 3); index++) {
|
||||
await orcaPage.keyboard.press('Tab')
|
||||
await orcaPage.keyboard.press('Tab')
|
||||
const terminal = orcaPage.getByRole('button', { name: `End Terminal ${index}`, exact: true })
|
||||
@@ -182,11 +202,17 @@ for (const theme of ['dark', 'light'] as const) {
|
||||
await body.evaluate((element) => {
|
||||
element.scrollTop = element.scrollHeight
|
||||
})
|
||||
const lastTerminal = body.getByRole('button', { name: 'End Terminal 999', exact: true })
|
||||
await expect(lastTerminal).toBeVisible()
|
||||
console.info(JSON.stringify({ theme, rows: 1_000, listMs }))
|
||||
await expect(body.getByText('Last seen process: node', { exact: false })).toBeVisible()
|
||||
await expect(body.getByText('/workspace/folder-999', { exact: false })).toBeVisible()
|
||||
const lastTerminal = body.getByRole('button', {
|
||||
name: `End Terminal ${sessionCount - 1}`,
|
||||
exact: true
|
||||
})
|
||||
await expect(lastTerminal).toBeInViewport()
|
||||
console.info(JSON.stringify({ theme, rows: sessionCount, listMs }))
|
||||
await expect(
|
||||
body.getByText(`Last seen process: node · /workspace/folder-${sessionCount - 1}`, {
|
||||
exact: true
|
||||
})
|
||||
).toBeVisible()
|
||||
const after = await electronApp.evaluate(() => {
|
||||
if (!globalThis.__ptyViewCounts) {
|
||||
throw new Error('PTY view counters missing')
|
||||
@@ -196,11 +222,13 @@ for (const theme of ['dark', 'light'] as const) {
|
||||
expect(after.listing).toBe(before.listing)
|
||||
expect(after.metrics).toBe(before.metrics)
|
||||
await lastTerminal.click()
|
||||
await expect(orcaPage.getByRole('heading', { name: 'End Terminal 999?' })).toBeVisible()
|
||||
await expect(
|
||||
orcaPage.getByRole('heading', { name: `End Terminal ${sessionCount - 1}?` })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
orcaPage
|
||||
.getByRole('dialog', { name: 'End Terminal 999?' })
|
||||
.getByText('/workspace/folder-999', { exact: true })
|
||||
.getByRole('dialog', { name: `End Terminal ${sessionCount - 1}?` })
|
||||
.getByText(`/workspace/folder-${sessionCount - 1}`, { exact: true })
|
||||
).toBeVisible()
|
||||
expect(
|
||||
(
|
||||
@@ -230,8 +258,8 @@ for (const theme of ['dark', 'light'] as const) {
|
||||
).kills
|
||||
).toEqual([
|
||||
{
|
||||
id: 'local-pty-0999',
|
||||
expectedIncarnationId: 'incarnation-999',
|
||||
id: lastSessionId,
|
||||
expectedIncarnationId: `incarnation-${sessionCount - 1}`,
|
||||
requireNoAgentOwner: true,
|
||||
requireUnattached: true
|
||||
}
|
||||
@@ -246,7 +274,7 @@ for (const theme of ['dark', 'light'] as const) {
|
||||
})
|
||||
await testInfo.attach('interaction-measurement', {
|
||||
body: JSON.stringify({
|
||||
rows: 1_000,
|
||||
rows: sessionCount,
|
||||
listingCalls: before.listing,
|
||||
metricCalls: before.metrics
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user