mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
#14397 split `shared/types.ts` into 46 per-domain modules but kept the path as a re-export barrel so the import sites did not have to change. This removes the barrel: every consumer now imports from the module that actually declares the type, and `src/shared/types.ts` is deleted. Barrels hide where a type lives, make every consumer look like it depends on the whole domain, and let an unrelated edit invalidate a module that ~2,000 files transitively import. 2,323 import declarations across 2,321 files. Rewritten mechanically: each specifier was resolved to an absolute path via the TypeScript AST and recomputed, rather than string-substituted, so alias forms (`@/../../shared/ types`) and per-specifier `type` modifiers survive. Four cases the mechanical pass had to handle, each found by a gate rather than by reading the diff: - Modules inside `src/shared` import the barrel as `./types`, not `shared/types`. A pre-filter on the latter string skipped 176 of them and left imports dangling at a deleted file, which surfaced as confusing `Property 'x' is optional in type 'Repo' but required in Pick<Repo, ...>` errors rather than "module not found". - The barrel RENAMED one type on the way through (`WorkspaceSource as WorkspaceCreateTelemetrySource`), so the original name in the owning module has to be re-aliased at each consumer. - Three test files put `;(globalThis as ...)` on the line after the import. TypeScript parses that `;` as the import statement's terminator, so replacing through `statement.getEnd()` deletes it and breaks ASI. The rewrite now stops at the module specifier. - A file that already imported directly from a module got a SECOND import from it, because the barrel re-exported those same names — which trips `import/no-duplicates` under `--deny-warnings`. A post-pass merges declarations sharing a specifier and type-only-ness; the `import type` plus `import` pair from one module is left alone, since that form is allowed. Splitting one barrel import into several genuinely adds lines, which pushed `terminal-layout-pty-ownership.ts` to 301 counted lines: its 107-character import must wrap, and neither local type collapses onto one line (101 and 116 characters). Rather than contort a type declaration to fit a line budget, `collectLeafIds` and `pruneLeaves` move to `terminal-pane-layout-tree.ts` — they are pure structural operations on the layout tree and independent of PTY ownership. `visible-worktrees.ts` similarly loses its own mini-barrel re-export of `isDefaultBranchWorkspace`, with the four real consumers repointed at the declaring module. No `max-lines` bypass added. Verified: cold `tsc --noEmit` green on node, cli, and web (buildinfo deleted first — these projects are `composite: true` and reuse stale caches); the full `pnpm lint` green, not just bare oxlint — the narrower local check is what let the duplicate imports reach CI; max-lines ratchet OK at 344.
250 lines
8.4 KiB
TypeScript
250 lines
8.4 KiB
TypeScript
import { test, expect } from './helpers/orca-app'
|
|
import type { Page } from '@stablyai/playwright-test'
|
|
import type { TerminalPaneLayoutNode } from '../../src/shared/terminal-tab-types'
|
|
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
|
import { worktreeRow } from './worktree-row-locators'
|
|
|
|
type SmartSortScenario = {
|
|
blockedId: string
|
|
doneId: string
|
|
blockedTabId: string
|
|
doneTabId: string
|
|
blockedPaneKey: string
|
|
donePaneKey: string
|
|
}
|
|
|
|
async function getVisibleWorktreeIdsByTop(page: Page): Promise<string[]> {
|
|
return page
|
|
.locator('[data-worktree-sidebar] [role="option"][data-worktree-id]')
|
|
.evaluateAll((elements) =>
|
|
elements
|
|
.map((element) => ({
|
|
id: element.dataset.worktreeId ?? '',
|
|
top: element.getBoundingClientRect().top
|
|
}))
|
|
.filter((row) => row.id.length > 0)
|
|
.sort((a, b) => a.top - b.top)
|
|
.map((row) => row.id)
|
|
)
|
|
}
|
|
|
|
async function seedSmartSortScenario(page: Page): Promise<SmartSortScenario> {
|
|
return page.evaluate(() => {
|
|
const store = window.__store
|
|
if (!store) {
|
|
throw new Error('window.__store is not available')
|
|
}
|
|
|
|
const state = store.getState()
|
|
state.setActiveView('terminal')
|
|
state.setSidebarOpen(true)
|
|
state.setGroupBy('none')
|
|
state.setSortBy('smart')
|
|
|
|
const worktrees = Object.values(state.worktreesByRepo)
|
|
.flat()
|
|
.filter((worktree) => !worktree.isArchived)
|
|
if (worktrees.length < 2) {
|
|
throw new Error('Smart sort E2E needs at least two worktrees')
|
|
}
|
|
|
|
const [blocked, done] = worktrees
|
|
const now = Date.now()
|
|
|
|
store.setState((current) => ({
|
|
worktreesByRepo: Object.fromEntries(
|
|
Object.entries(current.worktreesByRepo).map(([repoId, repoWorktrees]) => [
|
|
repoId,
|
|
repoWorktrees.map((worktree) => {
|
|
if (worktree.id === blocked.id) {
|
|
return {
|
|
...worktree,
|
|
displayName: 'Z smart-sort blocked',
|
|
lastActivityAt: now - 5 * 60_000,
|
|
sortOrder: 0
|
|
}
|
|
}
|
|
if (worktree.id === done.id) {
|
|
return {
|
|
...worktree,
|
|
displayName: 'A smart-sort done',
|
|
lastActivityAt: now,
|
|
sortOrder: 10
|
|
}
|
|
}
|
|
return worktree
|
|
})
|
|
])
|
|
)
|
|
}))
|
|
|
|
for (const worktree of [blocked, done]) {
|
|
const currentState = store.getState()
|
|
if ((currentState.tabsByWorktree[worktree.id] ?? []).length === 0) {
|
|
currentState.createTab(worktree.id)
|
|
}
|
|
}
|
|
|
|
const stateWithTabs = store.getState()
|
|
const blockedTab = stateWithTabs.tabsByWorktree[blocked.id]?.[0]
|
|
const doneTab = stateWithTabs.tabsByWorktree[done.id]?.[0]
|
|
if (!blockedTab || !doneTab) {
|
|
throw new Error('Smart sort E2E failed to create terminal tabs')
|
|
}
|
|
|
|
const blockedPtyId = stateWithTabs.ptyIdsByTabId[blockedTab.id]?.[0] ?? `e2e-${blockedTab.id}`
|
|
const donePtyId = stateWithTabs.ptyIdsByTabId[doneTab.id]?.[0] ?? `e2e-${doneTab.id}`
|
|
const firstLayoutLeafId = (node: TerminalPaneLayoutNode | null | undefined): string | null => {
|
|
if (!node) {
|
|
return null
|
|
}
|
|
return node.type === 'leaf'
|
|
? node.leafId
|
|
: (firstLayoutLeafId(node.first) ?? firstLayoutLeafId(node.second))
|
|
}
|
|
let blockedLeafId = ''
|
|
let doneLeafId = ''
|
|
|
|
// Why: WorktreeList intentionally holds cold-start ordering until a live
|
|
// PTY exists. E2E hidden windows can create tabs before panes mount, so
|
|
// seed the live-PTY and stable-layout maps explicitly and let agent-status
|
|
// writes drive the same sortEpoch path that hook events use in the app.
|
|
store.setState((current) => {
|
|
const blockedLayout = current.terminalLayoutsByTabId[blockedTab.id]
|
|
const doneLayout = current.terminalLayoutsByTabId[doneTab.id]
|
|
blockedLeafId = firstLayoutLeafId(blockedLayout?.root) ?? crypto.randomUUID()
|
|
doneLeafId = firstLayoutLeafId(doneLayout?.root) ?? crypto.randomUUID()
|
|
|
|
return {
|
|
ptyIdsByTabId: {
|
|
...current.ptyIdsByTabId,
|
|
[blockedTab.id]: current.ptyIdsByTabId[blockedTab.id]?.length
|
|
? current.ptyIdsByTabId[blockedTab.id]
|
|
: [blockedPtyId],
|
|
[doneTab.id]: current.ptyIdsByTabId[doneTab.id]?.length
|
|
? current.ptyIdsByTabId[doneTab.id]
|
|
: [donePtyId]
|
|
},
|
|
terminalLayoutsByTabId: {
|
|
...current.terminalLayoutsByTabId,
|
|
[blockedTab.id]: {
|
|
root: blockedLayout?.root ?? { type: 'leaf', leafId: blockedLeafId },
|
|
activeLeafId: blockedLayout?.activeLeafId ?? blockedLeafId,
|
|
expandedLeafId: blockedLayout?.expandedLeafId ?? null,
|
|
ptyIdsByLeafId: {
|
|
...blockedLayout?.ptyIdsByLeafId,
|
|
[blockedLeafId]: blockedPtyId
|
|
}
|
|
},
|
|
[doneTab.id]: {
|
|
root: doneLayout?.root ?? { type: 'leaf', leafId: doneLeafId },
|
|
activeLeafId: doneLayout?.activeLeafId ?? doneLeafId,
|
|
expandedLeafId: doneLayout?.expandedLeafId ?? null,
|
|
ptyIdsByLeafId: {
|
|
...doneLayout?.ptyIdsByLeafId,
|
|
[doneLeafId]: donePtyId
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
const actions = store.getState()
|
|
actions.setAgentStatus(
|
|
`${doneTab.id}:${doneLeafId}`,
|
|
{ state: 'done', prompt: 'Finished', agentType: 'codex' },
|
|
'codex',
|
|
{ updatedAt: now, stateStartedAt: now - 1_000 }
|
|
)
|
|
actions.setAgentStatus(
|
|
`${blockedTab.id}:${blockedLeafId}`,
|
|
{ state: 'blocked', prompt: 'Needs approval', agentType: 'codex' },
|
|
'codex',
|
|
{ updatedAt: now, stateStartedAt: now - 60_000 }
|
|
)
|
|
|
|
return {
|
|
blockedId: blocked.id,
|
|
doneId: done.id,
|
|
blockedTabId: blockedTab.id,
|
|
doneTabId: doneTab.id,
|
|
blockedPaneKey: `${blockedTab.id}:${blockedLeafId}`,
|
|
donePaneKey: `${doneTab.id}:${doneLeafId}`
|
|
}
|
|
})
|
|
}
|
|
|
|
async function getSmartSortScenarioReadiness(
|
|
page: Page,
|
|
scenario: SmartSortScenario
|
|
): Promise<{
|
|
blockedHasLivePty: boolean
|
|
doneHasLivePty: boolean
|
|
blockedState: string | null
|
|
doneState: string | null
|
|
fallbackOrder: string[]
|
|
}> {
|
|
return page.evaluate((scenario) => {
|
|
const state = window.__store?.getState()
|
|
if (!state) {
|
|
return {
|
|
blockedHasLivePty: false,
|
|
doneHasLivePty: false,
|
|
blockedState: null,
|
|
doneState: null,
|
|
fallbackOrder: []
|
|
}
|
|
}
|
|
const scenarioWorktrees = Object.values(state.worktreesByRepo)
|
|
.flat()
|
|
.filter((worktree) => worktree.id === scenario.blockedId || worktree.id === scenario.doneId)
|
|
return {
|
|
blockedHasLivePty: (state.ptyIdsByTabId[scenario.blockedTabId]?.length ?? 0) > 0,
|
|
doneHasLivePty: (state.ptyIdsByTabId[scenario.doneTabId]?.length ?? 0) > 0,
|
|
blockedState: state.agentStatusByPaneKey[scenario.blockedPaneKey]?.state ?? null,
|
|
doneState: state.agentStatusByPaneKey[scenario.donePaneKey]?.state ?? null,
|
|
fallbackOrder: scenarioWorktrees
|
|
.sort((a, b) => b.sortOrder - a.sortOrder || a.displayName.localeCompare(b.displayName))
|
|
.map((worktree) => worktree.id)
|
|
}
|
|
}, scenario)
|
|
}
|
|
|
|
test.describe('Worktree Smart Sort', () => {
|
|
test.beforeEach(async ({ orcaPage }) => {
|
|
await waitForSessionReady(orcaPage)
|
|
await waitForActiveWorktree(orcaPage)
|
|
await ensureTerminalVisible(orcaPage)
|
|
})
|
|
|
|
test('renders attention-needed worktrees above finished agents in Smart mode', async ({
|
|
orcaPage
|
|
}) => {
|
|
const scenario = await seedSmartSortScenario(orcaPage)
|
|
const { blockedId, doneId } = scenario
|
|
|
|
await expect
|
|
.poll(() => getSmartSortScenarioReadiness(orcaPage, scenario), {
|
|
timeout: 8_000,
|
|
message: 'Smart sort scenario did not seed live PTYs and fresh agent statuses'
|
|
})
|
|
.toEqual({
|
|
blockedHasLivePty: true,
|
|
doneHasLivePty: true,
|
|
blockedState: 'blocked',
|
|
doneState: 'done',
|
|
fallbackOrder: [doneId, blockedId]
|
|
})
|
|
|
|
await expect
|
|
.poll(async () => (await getVisibleWorktreeIdsByTop(orcaPage)).slice(0, 2), {
|
|
timeout: 12_000,
|
|
message: 'Smart sort did not promote the blocked worktree in the visible sidebar'
|
|
})
|
|
.toEqual([blockedId, doneId])
|
|
|
|
await expect(worktreeRow(orcaPage, blockedId)).toBeVisible()
|
|
await expect(worktreeRow(orcaPage, doneId)).toBeVisible()
|
|
})
|
|
})
|