mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
Merge remote-tracking branch 'origin/main' into ota-c7-10-c2-editor-mount
This commit is contained in:
@@ -1,21 +1,61 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { availableParallelism } from 'node:os'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { availableParallelism, totalmem } from 'node:os'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
|
||||
// These projects overlap heavily in src/shared but have no build dependency on
|
||||
// each other, so tsc can check them concurrently instead of in a `&&` chain.
|
||||
const projects = [
|
||||
'tsconfig.node.json',
|
||||
'tsconfig.tc.cli.json',
|
||||
'tsconfig.tc.web.json',
|
||||
'tsconfig.mobile-web.json'
|
||||
const BYTES_PER_GIB = 1024 ** 3
|
||||
|
||||
// Peak heap per project, read from `tsc --extendedDiagnostics` and rounded up. node and
|
||||
// web are the expensive pair: run together they exceed a 16 GB CI runner, and an
|
||||
// out-of-memory runner is killed mid-check, so the job reports a lost runner instead of a
|
||||
// type error. Admission is therefore by memory, not by core count alone.
|
||||
export const TYPECHECK_PROJECTS = [
|
||||
{ config: 'tsconfig.node.json', heapGib: 7 },
|
||||
{ config: 'tsconfig.tc.web.json', heapGib: 6 },
|
||||
{ config: 'tsconfig.tc.cli.json', heapGib: 2 },
|
||||
{ config: 'tsconfig.mobile-web.json', heapGib: 1 }
|
||||
]
|
||||
|
||||
// The OS, node itself, and the runner agent need their share; the rest is what tsc may hold.
|
||||
export function admissibleHeapGib(totalBytes) {
|
||||
return Math.max(1, (totalBytes / BYTES_PER_GIB) * 0.75)
|
||||
}
|
||||
|
||||
/**
|
||||
* Heaviest first, admitting another project only while it fits both the memory budget and
|
||||
* the core count. A project larger than the whole budget still runs, alone, so a small
|
||||
* machine makes progress rather than producing an empty batch forever.
|
||||
*/
|
||||
export function planTypecheckBatches(projects, { budgetGib, parallelism }) {
|
||||
const pending = [...projects].sort((left, right) => right.heapGib - left.heapGib)
|
||||
const batches = []
|
||||
|
||||
while (pending.length > 0) {
|
||||
const batch = []
|
||||
let claimed = 0
|
||||
|
||||
for (let index = 0; index < pending.length;) {
|
||||
const project = pending[index]
|
||||
const admit =
|
||||
batch.length === 0 || (batch.length < parallelism && claimed + project.heapGib <= budgetGib)
|
||||
|
||||
if (admit) {
|
||||
batch.push(project)
|
||||
claimed += project.heapGib
|
||||
pending.splice(index, 1)
|
||||
} else {
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
|
||||
batches.push(batch)
|
||||
}
|
||||
|
||||
return batches
|
||||
}
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('../..', import.meta.url))
|
||||
const tsc = fileURLToPath(new URL('../../node_modules/typescript/bin/tsc', import.meta.url))
|
||||
|
||||
// Why serialize on a single-core runner: three tsc processes there thrash rather than overlap.
|
||||
const concurrent = availableParallelism() > 1
|
||||
|
||||
function checkProject(project) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [tsc, '--noEmit', '-p', `config/${project}`], {
|
||||
@@ -36,23 +76,32 @@ function checkProject(project) {
|
||||
})
|
||||
}
|
||||
|
||||
let failures = []
|
||||
if (concurrent) {
|
||||
const results = await Promise.allSettled(projects.map(checkProject))
|
||||
failures = results.filter((result) => result.status === 'rejected').map((result) => result.reason)
|
||||
} else {
|
||||
for (const project of projects) {
|
||||
try {
|
||||
await checkProject(project)
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
async function runTypecheckProjects() {
|
||||
const batches = planTypecheckBatches(TYPECHECK_PROJECTS, {
|
||||
budgetGib: admissibleHeapGib(totalmem()),
|
||||
parallelism: availableParallelism()
|
||||
})
|
||||
|
||||
// Every batch runs even after one fails, so a single broken project still reports the rest.
|
||||
const failures = []
|
||||
for (const batch of batches) {
|
||||
const results = await Promise.allSettled(batch.map((project) => checkProject(project.config)))
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') {
|
||||
failures.push(result.reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return failures
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
for (const failure of failures) {
|
||||
console.error(failure.message ?? failure)
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const failures = await runTypecheckProjects()
|
||||
if (failures.length > 0) {
|
||||
for (const failure of failures) {
|
||||
console.error(failure.message ?? failure)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
TYPECHECK_PROJECTS,
|
||||
admissibleHeapGib,
|
||||
planTypecheckBatches
|
||||
} from './run-typecheck-projects-in-parallel.mjs'
|
||||
|
||||
const CI_RUNNER = { totalBytes: 16 * 1024 ** 3, parallelism: 4 }
|
||||
const DEV_LAPTOP = { totalBytes: 64 * 1024 ** 3, parallelism: 18 }
|
||||
|
||||
function planFor({ totalBytes, parallelism }, projects = TYPECHECK_PROJECTS) {
|
||||
return planTypecheckBatches(projects, {
|
||||
budgetGib: admissibleHeapGib(totalBytes),
|
||||
parallelism
|
||||
})
|
||||
}
|
||||
|
||||
function batchOf(batches, config) {
|
||||
return batches.findIndex((batch) => batch.some((project) => project.config === config))
|
||||
}
|
||||
|
||||
describe('typecheck project admission', () => {
|
||||
it('keeps the two expensive projects off the same CI runner', () => {
|
||||
const batches = planFor(CI_RUNNER)
|
||||
expect(batchOf(batches, 'tsconfig.node.json')).not.toBe(
|
||||
batchOf(batches, 'tsconfig.tc.web.json')
|
||||
)
|
||||
})
|
||||
|
||||
it('holds every CI batch inside the memory budget', () => {
|
||||
const budget = admissibleHeapGib(CI_RUNNER.totalBytes)
|
||||
for (const batch of planFor(CI_RUNNER)) {
|
||||
const claimed = batch.reduce((total, project) => total + project.heapGib, 0)
|
||||
expect(claimed).toBeLessThanOrEqual(budget)
|
||||
}
|
||||
})
|
||||
|
||||
it('still fills a CI batch with the cheap projects rather than serializing everything', () => {
|
||||
// Why: strict serialization measured ~60% slower than pairing the cheap work alongside.
|
||||
expect(planFor(CI_RUNNER).length).toBeLessThan(TYPECHECK_PROJECTS.length)
|
||||
})
|
||||
|
||||
it('leaves a roomy machine fully parallel', () => {
|
||||
expect(planFor(DEV_LAPTOP)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('serializes on a single core', () => {
|
||||
const batches = planFor({ totalBytes: 64 * 1024 ** 3, parallelism: 1 })
|
||||
expect(batches).toHaveLength(TYPECHECK_PROJECTS.length)
|
||||
expect(batches.every((batch) => batch.length === 1)).toBe(true)
|
||||
})
|
||||
|
||||
it('runs a project that alone exceeds the budget instead of stalling', () => {
|
||||
const batches = planTypecheckBatches([{ config: 'huge.json', heapGib: 512 }], {
|
||||
budgetGib: admissibleHeapGib(2 * 1024 ** 3),
|
||||
parallelism: 4
|
||||
})
|
||||
expect(batches).toEqual([[{ config: 'huge.json', heapGib: 512 }]])
|
||||
})
|
||||
|
||||
it('schedules every project exactly once', () => {
|
||||
const scheduled = planFor(CI_RUNNER)
|
||||
.flat()
|
||||
.map((project) => project.config)
|
||||
.sort()
|
||||
expect(scheduled).toEqual(TYPECHECK_PROJECTS.map((project) => project.config).sort())
|
||||
})
|
||||
|
||||
it('never lets one project outgrow a CI runner on its own', () => {
|
||||
// A single project over budget runs anyway, so this is the ceiling the pool cannot rescue.
|
||||
const heaviest = Math.max(...TYPECHECK_PROJECTS.map((project) => project.heapGib))
|
||||
expect(heaviest).toBeLessThanOrEqual(admissibleHeapGib(CI_RUNNER.totalBytes))
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useLocalSearchParams } from 'expo-router'
|
||||
import { MobileAgentSessionHistoryPanel } from '../../../../src/agent-history/MobileAgentSessionHistoryPanel'
|
||||
import { MobileWebShellScreen } from '../../../../src/mobile-web-shell/MobileWebShellScreen'
|
||||
import { ShellSwitchPendingScreen } from '../../../../src/mobile-web-shell/ShellSwitchPendingScreen'
|
||||
import { shellScreenRoute } from '../../../../src/mobile-web-shell/shell-screen-route'
|
||||
import { useMobileWebShellEnabled } from '../../../../src/mobile-web-shell/use-mobile-web-shell-enabled'
|
||||
import { useShellSwitchDecision } from '../../../../src/mobile-web-shell/shell-switch-decision'
|
||||
import { firstParam } from '../../../../src/navigation/route-param-reader'
|
||||
|
||||
/**
|
||||
@@ -10,8 +11,7 @@ import { firstParam } from '../../../../src/navigation/route-param-reader'
|
||||
*
|
||||
* The switch is `index.tsx`'s, for its reasons: the shell renders the page only for a route the
|
||||
* bundle lists with grants this app implements, `fallback` is what a negotiation that said no
|
||||
* falls back to, and a settling flag read renders the native panel because a store build never
|
||||
* reaches storage at all.
|
||||
* falls back to, and a flag read still settling paints neither renderer.
|
||||
*
|
||||
* Two dynamic segments rather than one, so both are encoded: `useLocalSearchParams` answers the
|
||||
* decoded value, and a worktree id or a deep-linked host id carrying `/`, `?`, `#` or whitespace
|
||||
@@ -38,21 +38,27 @@ export default function MobileAgentSessionHistoryScreen() {
|
||||
const hostId = firstParam(params.hostId)
|
||||
const worktreeId = firstParam(params.worktreeId)
|
||||
const name = firstParam(params.name)
|
||||
const enabled = useMobileWebShellEnabled()
|
||||
const panel = (
|
||||
<MobileAgentSessionHistoryPanel hostId={hostId} worktreeId={worktreeId} name={name} />
|
||||
)
|
||||
|
||||
if (enabled !== true || !hostId || !worktreeId) {
|
||||
return panel
|
||||
// Built before the decision rather than after it, as every switch does now: the decision needs
|
||||
// to know whether the shell is a possible outcome before it can say a neutral frame is owed.
|
||||
const route =
|
||||
hostId && worktreeId
|
||||
? shellScreenRoute({
|
||||
pathname: `/h/${encodeURIComponent(hostId)}/agent-history/${encodeURIComponent(worktreeId)}`,
|
||||
// Omitted rather than empty: the page reads the label off the search half, and a `name=`
|
||||
// with nothing after it is a label, where an absent one lets the panel derive its own.
|
||||
...(name === '' ? {} : { params: { name } })
|
||||
})
|
||||
: null
|
||||
const decision = useShellSwitchDecision(route)
|
||||
|
||||
if (decision.kind === 'pending') {
|
||||
return <ShellSwitchPendingScreen />
|
||||
}
|
||||
const route = shellScreenRoute({
|
||||
pathname: `/h/${encodeURIComponent(hostId)}/agent-history/${encodeURIComponent(worktreeId)}`,
|
||||
// Omitted rather than empty: the page reads the label off the search half, and a `name=`
|
||||
// with nothing after it is a label, where an absent one lets the panel derive its own.
|
||||
...(name === '' ? {} : { params: { name } })
|
||||
})
|
||||
if (route === null) {
|
||||
if (decision.kind === 'native') {
|
||||
return panel
|
||||
}
|
||||
// Keyed on the route: a host captures the grants its session was opened with, so a screen
|
||||
@@ -60,6 +66,11 @@ export default function MobileAgentSessionHistoryScreen() {
|
||||
// page has left. The key is what makes the change a remount, which disposes that bridge in the
|
||||
// commit, and the new session starts with no grants until its own `init`.
|
||||
return (
|
||||
<MobileWebShellScreen key={route.pathname} hostId={hostId} route={route} fallback={panel} />
|
||||
<MobileWebShellScreen
|
||||
key={decision.route.pathname}
|
||||
hostId={hostId}
|
||||
route={decision.route}
|
||||
fallback={panel}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,15 +6,16 @@ import {
|
||||
shellScreenRouteKey
|
||||
} from '../../../../src/mobile-web-shell/shell-screen-route'
|
||||
import { MobileWebShellScreen } from '../../../../src/mobile-web-shell/MobileWebShellScreen'
|
||||
import { useMobileWebShellEnabled } from '../../../../src/mobile-web-shell/use-mobile-web-shell-enabled'
|
||||
import { ShellSwitchPendingScreen } from '../../../../src/mobile-web-shell/ShellSwitchPendingScreen'
|
||||
import { useShellSwitchDecision } from '../../../../src/mobile-web-shell/shell-switch-decision'
|
||||
|
||||
/**
|
||||
* The file explorer, from the desktop's bundle or from this app.
|
||||
*
|
||||
* The shell decides, not this switch: it renders the page only for a route the bundle lists with
|
||||
* grants this app implements, and answers `native-route` otherwise, which is what `fallback` is.
|
||||
* `enabled === null` is the flag read still settling and renders the native screen, which is the
|
||||
* only frame a store build ever paints here.
|
||||
* A flag read still settling is a third answer and paints neither renderer; see
|
||||
* `shell-switch-decision.ts`.
|
||||
*
|
||||
* Encoded, not interpolated raw, for the reason `web.tsx` states: an id carrying `?`, `#` or
|
||||
* whitespace would build a pathname the page refuses and mount nothing.
|
||||
@@ -32,7 +33,6 @@ export default function MobileFileExplorerScreen() {
|
||||
const hostId = firstParam(params.hostId)
|
||||
const worktreeId = firstParam(params.worktreeId)
|
||||
const name = firstParam(params.name)
|
||||
const enabled = useMobileWebShellEnabled()
|
||||
const native = (
|
||||
<MobileFileExplorerPanel hostId={hostId} worktreeId={worktreeId} name={name} embedded={false} />
|
||||
)
|
||||
@@ -47,7 +47,12 @@ export default function MobileFileExplorerScreen() {
|
||||
})
|
||||
: null
|
||||
|
||||
if (enabled !== true || !hostId || route === null) {
|
||||
const decision = useShellSwitchDecision(route)
|
||||
|
||||
if (decision.kind === 'pending') {
|
||||
return <ShellSwitchPendingScreen />
|
||||
}
|
||||
if (decision.kind === 'native') {
|
||||
return native
|
||||
}
|
||||
// Keyed on the route: a host captures the grants its session was opened with, so a screen reused
|
||||
@@ -55,9 +60,9 @@ export default function MobileFileExplorerScreen() {
|
||||
// left. The key is what makes the change a remount, which disposes that bridge in the commit.
|
||||
return (
|
||||
<MobileWebShellScreen
|
||||
key={shellScreenRouteKey(route)}
|
||||
key={shellScreenRouteKey(decision.route)}
|
||||
hostId={hostId}
|
||||
route={route}
|
||||
route={decision.route}
|
||||
fallback={native}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
shellScreenRouteKey
|
||||
} from '../../../../../src/mobile-web-shell/shell-screen-route'
|
||||
import { MobileWebShellScreen } from '../../../../../src/mobile-web-shell/MobileWebShellScreen'
|
||||
import { useMobileWebShellEnabled } from '../../../../../src/mobile-web-shell/use-mobile-web-shell-enabled'
|
||||
import { ShellSwitchPendingScreen } from '../../../../../src/mobile-web-shell/ShellSwitchPendingScreen'
|
||||
import { useShellSwitchDecision } from '../../../../../src/mobile-web-shell/shell-switch-decision'
|
||||
|
||||
/**
|
||||
* The file preview, from the desktop's bundle or from this app.
|
||||
@@ -41,7 +42,6 @@ export default function MobileFilePreviewRoute() {
|
||||
worktreeName?: string | string[]
|
||||
}>()
|
||||
const route = normalizeMobileFilePreviewRouteParams(params)
|
||||
const enabled = useMobileWebShellEnabled()
|
||||
const native = <MobileFilePreviewScreen route={route} />
|
||||
|
||||
const shellRoute = route.ok
|
||||
@@ -53,7 +53,14 @@ export default function MobileFilePreviewRoute() {
|
||||
})
|
||||
: null
|
||||
|
||||
if (enabled !== true || !route.ok || shellRoute === null) {
|
||||
const decision = useShellSwitchDecision(shellRoute)
|
||||
|
||||
if (decision.kind === 'pending') {
|
||||
return <ShellSwitchPendingScreen />
|
||||
}
|
||||
// `route.ok` again for the compiler: `shellRoute` is built only on the ok branch, so a `shell`
|
||||
// decision already implies it.
|
||||
if (decision.kind === 'native' || !route.ok) {
|
||||
return native
|
||||
}
|
||||
// Keyed on the whole route, params included, for two reasons. A host captures the grants its
|
||||
@@ -63,9 +70,9 @@ export default function MobileFilePreviewRoute() {
|
||||
// was opened on, with nothing to tell it otherwise.
|
||||
return (
|
||||
<MobileWebShellScreen
|
||||
key={shellScreenRouteKey(shellRoute)}
|
||||
key={shellScreenRouteKey(decision.route)}
|
||||
hostId={route.params.hostId}
|
||||
route={shellRoute}
|
||||
route={decision.route}
|
||||
fallback={native}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -4,8 +4,9 @@ import { firstParam } from '../../../src/navigation/route-param-reader'
|
||||
import { HostScreen } from '../../../src/host-screen/HostScreen'
|
||||
import { useResponsiveLayout } from '../../../src/layout/responsive-layout'
|
||||
import { MobileWebShellScreen } from '../../../src/mobile-web-shell/MobileWebShellScreen'
|
||||
import { ShellSwitchPendingScreen } from '../../../src/mobile-web-shell/ShellSwitchPendingScreen'
|
||||
import { shellScreenRoute } from '../../../src/mobile-web-shell/shell-screen-route'
|
||||
import { useMobileWebShellEnabled } from '../../../src/mobile-web-shell/use-mobile-web-shell-enabled'
|
||||
import { useShellSwitchDecision } from '../../../src/mobile-web-shell/shell-switch-decision'
|
||||
|
||||
/**
|
||||
* The worktree list, from the desktop's bundle or from this app.
|
||||
@@ -15,8 +16,8 @@ import { useMobileWebShellEnabled } from '../../../src/mobile-web-shell/use-mobi
|
||||
* So the two ways to stay native are a flag that is off and a negotiation that said no, and the
|
||||
* second one covers every host whose desktop is older than the page.
|
||||
*
|
||||
* `enabled === null` is the flag read still settling, and it renders the native screen: a store
|
||||
* build never reaches storage at all, so that is the only frame it ever paints here.
|
||||
* The flag read settling is a third answer, not a fourth spelling of native: see
|
||||
* `shell-switch-decision.ts`.
|
||||
*
|
||||
* Encoded, not interpolated raw, for the reason `web.tsx` states: a deep-linked host id carrying
|
||||
* `?`, `#` or whitespace would build a pathname the page refuses, and a refusal here is a failure
|
||||
@@ -30,14 +31,17 @@ function HostListScreen() {
|
||||
// truthy, so a bare read also builds `/h/` and hands that over; this answers `''` and stays.
|
||||
const params = useLocalSearchParams<{ hostId?: string | string[] }>()
|
||||
const hostId = firstParam(params.hostId)
|
||||
const enabled = useMobileWebShellEnabled()
|
||||
|
||||
// Asked here as every switch asks it: encoding does not save a `.` or `..` host id, which fails
|
||||
// the bridge's segment rule, and handing that over paints the page's failure screen over the
|
||||
// native list this route already has.
|
||||
const route = shellScreenRoute({ pathname: `/h/${encodeURIComponent(hostId)}` })
|
||||
const decision = useShellSwitchDecision(hostId === '' ? null : route)
|
||||
|
||||
if (enabled !== true || !hostId || route === null) {
|
||||
if (decision.kind === 'pending') {
|
||||
return <ShellSwitchPendingScreen />
|
||||
}
|
||||
if (decision.kind === 'native') {
|
||||
return <HostScreen />
|
||||
}
|
||||
return (
|
||||
@@ -46,7 +50,7 @@ function HostListScreen() {
|
||||
// so a host id change must be a remount rather than a prop update.
|
||||
key={hostId}
|
||||
hostId={hostId}
|
||||
route={route}
|
||||
route={decision.route}
|
||||
fallback={<HostScreen />}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
shellScreenRouteKey
|
||||
} from '../../../../src/mobile-web-shell/shell-screen-route'
|
||||
import { MobileWebShellScreen } from '../../../../src/mobile-web-shell/MobileWebShellScreen'
|
||||
import { useMobileWebShellEnabled } from '../../../../src/mobile-web-shell/use-mobile-web-shell-enabled'
|
||||
import { ShellSwitchPendingScreen } from '../../../../src/mobile-web-shell/ShellSwitchPendingScreen'
|
||||
import { useShellSwitchDecision } from '../../../../src/mobile-web-shell/shell-switch-decision'
|
||||
|
||||
/**
|
||||
* Diff review, from the desktop's bundle or from this app.
|
||||
@@ -33,7 +34,6 @@ export default function MobileDiffReviewScreen() {
|
||||
}>()
|
||||
const hostId = firstReviewParam(params.hostId)
|
||||
const worktreeId = firstReviewParam(params.worktreeId)
|
||||
const enabled = useMobileWebShellEnabled()
|
||||
const native = <MobileDiffReviewRouteScreen />
|
||||
|
||||
// The four query params are read by the screen itself, so they are carried across whole rather
|
||||
@@ -52,7 +52,12 @@ export default function MobileDiffReviewScreen() {
|
||||
})
|
||||
: null
|
||||
|
||||
if (enabled !== true || !hostId || route === null) {
|
||||
const decision = useShellSwitchDecision(route)
|
||||
|
||||
if (decision.kind === 'pending') {
|
||||
return <ShellSwitchPendingScreen />
|
||||
}
|
||||
if (decision.kind === 'native') {
|
||||
return native
|
||||
}
|
||||
// Keyed on the route: a host captures the grants its session was opened with, so a screen reused
|
||||
@@ -60,9 +65,9 @@ export default function MobileDiffReviewScreen() {
|
||||
// left. A `file` change is the common case here and is a param change, not a path change.
|
||||
return (
|
||||
<MobileWebShellScreen
|
||||
key={shellScreenRouteKey(route)}
|
||||
key={shellScreenRouteKey(decision.route)}
|
||||
hostId={hostId}
|
||||
route={route}
|
||||
route={decision.route}
|
||||
fallback={native}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
shellScreenRouteKey
|
||||
} from '../../../../src/mobile-web-shell/shell-screen-route'
|
||||
import { MobileWebShellScreen } from '../../../../src/mobile-web-shell/MobileWebShellScreen'
|
||||
import { useMobileWebShellEnabled } from '../../../../src/mobile-web-shell/use-mobile-web-shell-enabled'
|
||||
import { ShellSwitchPendingScreen } from '../../../../src/mobile-web-shell/ShellSwitchPendingScreen'
|
||||
import { useShellSwitchDecision } from '../../../../src/mobile-web-shell/shell-switch-decision'
|
||||
|
||||
/**
|
||||
* The session screen — terminal and chat — from the desktop's bundle or from this app.
|
||||
@@ -40,7 +41,6 @@ export default function MobileSessionScreen() {
|
||||
}>()
|
||||
const hostId = firstParam(params.hostId)
|
||||
const worktreeId = firstParam(params.worktreeId)
|
||||
const enabled = useMobileWebShellEnabled()
|
||||
const router = useRouter()
|
||||
const native = <MobileSessionRouteScreen />
|
||||
const paneKey = firstParam(params.paneKey) ?? ''
|
||||
@@ -77,7 +77,12 @@ export default function MobileSessionScreen() {
|
||||
})
|
||||
: null
|
||||
|
||||
if (enabled !== true || !hostId || route === null) {
|
||||
const decision = useShellSwitchDecision(route)
|
||||
|
||||
if (decision.kind === 'pending') {
|
||||
return <ShellSwitchPendingScreen />
|
||||
}
|
||||
if (decision.kind === 'native') {
|
||||
return native
|
||||
}
|
||||
// Keyed on the route minus `paneKey`: a host captures the grants its session was opened with, so
|
||||
@@ -90,9 +95,9 @@ export default function MobileSessionScreen() {
|
||||
const { paneKey: _paneKey, ...identity } = routeParams
|
||||
return (
|
||||
<MobileWebShellScreen
|
||||
key={shellScreenRouteKey({ pathname: route.pathname, params: identity })}
|
||||
key={shellScreenRouteKey({ pathname: decision.route.pathname, params: identity })}
|
||||
hostId={hostId}
|
||||
route={route}
|
||||
route={decision.route}
|
||||
fallback={native}
|
||||
onRouteParamClear={erasePaneKey}
|
||||
/>
|
||||
|
||||
@@ -7,14 +7,16 @@ import {
|
||||
shellScreenRouteKey
|
||||
} from '../../../../src/mobile-web-shell/shell-screen-route'
|
||||
import { MobileWebShellScreen } from '../../../../src/mobile-web-shell/MobileWebShellScreen'
|
||||
import { useMobileWebShellEnabled } from '../../../../src/mobile-web-shell/use-mobile-web-shell-enabled'
|
||||
import { ShellSwitchPendingScreen } from '../../../../src/mobile-web-shell/ShellSwitchPendingScreen'
|
||||
import { useShellSwitchDecision } from '../../../../src/mobile-web-shell/shell-switch-decision'
|
||||
|
||||
/**
|
||||
* The source-control hub, from the desktop's bundle or from this app.
|
||||
*
|
||||
* The files switch's shape, for its reasons: the shell answers `native-route` for a route the
|
||||
* bundle does not list or this app's grants do not cover, and `fallback` is what that renders.
|
||||
* `enabled === null` is the flag read still settling, which is the only frame a store build paints.
|
||||
* A flag read still settling is a third answer and paints neither renderer; see
|
||||
* `shell-switch-decision.ts`.
|
||||
*
|
||||
* `pr` and `history` are not switched and never will be. Both are `Redirect`s into this route, and
|
||||
* a redirect inside the page would leave the session bound to a pathname the page has left; left
|
||||
@@ -37,7 +39,6 @@ export default function MobileSourceControlScreen() {
|
||||
const name = firstParam(params.name)
|
||||
const origin = firstParam(params.origin)
|
||||
const tab = firstParam(params.tab)
|
||||
const enabled = useMobileWebShellEnabled()
|
||||
const native = (
|
||||
<MobileSourceControlPanel
|
||||
hostId={hostId}
|
||||
@@ -65,7 +66,12 @@ export default function MobileSourceControlScreen() {
|
||||
})
|
||||
: null
|
||||
|
||||
if (enabled !== true || !hostId || route === null) {
|
||||
const decision = useShellSwitchDecision(route)
|
||||
|
||||
if (decision.kind === 'pending') {
|
||||
return <ShellSwitchPendingScreen />
|
||||
}
|
||||
if (decision.kind === 'native') {
|
||||
return native
|
||||
}
|
||||
// Keyed on the route: a host captures the grants its session was opened with, so a screen reused
|
||||
@@ -73,9 +79,9 @@ export default function MobileSourceControlScreen() {
|
||||
// left. The key is what makes the change a remount, which disposes that bridge in the commit.
|
||||
return (
|
||||
<MobileWebShellScreen
|
||||
key={shellScreenRouteKey(route)}
|
||||
key={shellScreenRouteKey(decision.route)}
|
||||
hostId={hostId}
|
||||
route={route}
|
||||
route={decision.route}
|
||||
fallback={native}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useLocalSearchParams } from 'expo-router'
|
||||
import { MobileWebShellScreen } from '../../../src/mobile-web-shell/MobileWebShellScreen'
|
||||
import { ShellSwitchPendingScreen } from '../../../src/mobile-web-shell/ShellSwitchPendingScreen'
|
||||
import { shellScreenRoute } from '../../../src/mobile-web-shell/shell-screen-route'
|
||||
import { useMobileWebShellEnabled } from '../../../src/mobile-web-shell/use-mobile-web-shell-enabled'
|
||||
import { useShellSwitchDecision } from '../../../src/mobile-web-shell/shell-switch-decision'
|
||||
import { firstParam } from '../../../src/navigation/route-param-reader'
|
||||
import { MobileTasksScreen } from '../../../src/tasks/MobileTasksScreen'
|
||||
|
||||
@@ -21,19 +22,24 @@ export default function MobileTasksRoute() {
|
||||
}>()
|
||||
const hostId = firstParam(params.hostId)
|
||||
const taskSource = firstParam(params.taskSource)
|
||||
const enabled = useMobileWebShellEnabled()
|
||||
const native = <MobileTasksScreen />
|
||||
|
||||
if (enabled !== true || !hostId) {
|
||||
return native
|
||||
// Built before the decision rather than after it, as every switch does now: the decision needs
|
||||
// to know whether the shell is a possible outcome before it can say a neutral frame is owed.
|
||||
const route = hostId
|
||||
? shellScreenRoute({
|
||||
pathname: `/h/${encodeURIComponent(hostId)}/tasks`,
|
||||
// Omitted rather than empty: an absent provider lets the page pick its own default, where
|
||||
// `taskSource=` is a provider named nothing.
|
||||
...(taskSource === '' ? {} : { params: { taskSource } })
|
||||
})
|
||||
: null
|
||||
const decision = useShellSwitchDecision(route)
|
||||
|
||||
if (decision.kind === 'pending') {
|
||||
return <ShellSwitchPendingScreen />
|
||||
}
|
||||
const route = shellScreenRoute({
|
||||
pathname: `/h/${encodeURIComponent(hostId)}/tasks`,
|
||||
// Omitted rather than empty: an absent provider lets the page pick its own default, where
|
||||
// `taskSource=` is a provider named nothing.
|
||||
...(taskSource === '' ? {} : { params: { taskSource } })
|
||||
})
|
||||
if (route === null) {
|
||||
if (decision.kind === 'native') {
|
||||
return native
|
||||
}
|
||||
return (
|
||||
@@ -42,7 +48,7 @@ export default function MobileTasksRoute() {
|
||||
// with, so a host id change must be a remount rather than a prop update.
|
||||
key={hostId}
|
||||
hostId={hostId}
|
||||
route={route}
|
||||
route={decision.route}
|
||||
fallback={native}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
import { ActivityIndicator, StyleSheet, View } from 'react-native'
|
||||
import { Redirect, useLocalSearchParams } from 'expo-router'
|
||||
import { MobileWebShellScreen } from '../../../src/mobile-web-shell/MobileWebShellScreen'
|
||||
import { useMobileWebShellEnabled } from '../../../src/mobile-web-shell/use-mobile-web-shell-enabled'
|
||||
import { colors } from '../../../src/theme/mobile-theme'
|
||||
import { ShellSwitchPendingScreen } from '../../../src/mobile-web-shell/ShellSwitchPendingScreen'
|
||||
import { useShellSwitchDecision } from '../../../src/mobile-web-shell/shell-switch-decision'
|
||||
|
||||
/**
|
||||
* The hybrid shell route, dark behind a development-only flag.
|
||||
*
|
||||
* One of the two callers of `useMobileWebShellEnabled`. With the flag off — which is every store build,
|
||||
* since the only writer is the `__DEV__` Troubleshoot toggle — this redirects and the screen is
|
||||
* never constructed, so nothing is fetched, written or swept. It sits under `app/h/[hostId]` so
|
||||
* `HostProtocolGate` in that group's layout still owns the `desktop-too-old` wall above it.
|
||||
* With the flag off — which is every store build, since the only writer is the `__DEV__`
|
||||
* Troubleshoot toggle — this redirects and the screen is never constructed, so nothing is fetched,
|
||||
* written or swept. It sits under `app/h/[hostId]` so `HostProtocolGate` in that group's layout
|
||||
* still owns the `desktop-too-old` wall above it.
|
||||
*
|
||||
* Reachable by deep link and from the developer row only; no screen links here.
|
||||
*/
|
||||
export default function MobileWebShellRoute() {
|
||||
const { hostId } = useLocalSearchParams<{ hostId: string }>()
|
||||
const enabled = useMobileWebShellEnabled()
|
||||
// Encoded here rather than in the JSX below so the decision holds the route the shell is handed:
|
||||
// `hostId` arrives decoded from the URL, so one carrying `?`, `#` or whitespace would build a
|
||||
// pathname the page refuses and never mount anything.
|
||||
const decision = useShellSwitchDecision(
|
||||
hostId ? { pathname: `/h/${encodeURIComponent(hostId)}` } : null
|
||||
)
|
||||
|
||||
if (enabled === null) {
|
||||
if (decision.kind === 'pending') {
|
||||
// A redirect fired before the read settles would bounce a flag that is on, and a screen mounted
|
||||
// before it settles would fetch on a flag that is off. Neither, until it is known.
|
||||
return (
|
||||
<View style={styles.pending}>
|
||||
<ActivityIndicator color={colors.textSecondary} accessibilityLabel="Checking host" />
|
||||
</View>
|
||||
)
|
||||
return <ShellSwitchPendingScreen />
|
||||
}
|
||||
if (!enabled || !hostId) {
|
||||
if (decision.kind === 'native') {
|
||||
return <Redirect href={`/h/${hostId ?? ''}`} />
|
||||
}
|
||||
// The screen the page stands in for. The document is served at `/`, which matches no route in
|
||||
@@ -36,27 +36,14 @@ export default function MobileWebShellRoute() {
|
||||
// The fallback is a redirect rather than the native screen: this route exists only to open the
|
||||
// page deliberately, so a bundle that does not list the worktree list has nothing to show here
|
||||
// and the host route is where the list actually lives.
|
||||
//
|
||||
// Encoded, not interpolated raw: `hostId` arrives decoded from the URL, so one carrying `?`, `#`
|
||||
// or whitespace would build a pathname the page refuses and never mount anything. The page
|
||||
// decodes it back when it matches `[hostId]`, so the screen it opens is the same one.
|
||||
return (
|
||||
<MobileWebShellScreen
|
||||
// Same reason as the agent-history route: a host holds the grants its session opened with,
|
||||
// so a host id change must be a remount rather than a prop update.
|
||||
key={hostId}
|
||||
hostId={hostId}
|
||||
route={{ pathname: `/h/${encodeURIComponent(hostId)}` }}
|
||||
route={decision.route}
|
||||
fallback={<Redirect href={`/h/${hostId}`} />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
pending: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.bgBase
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ActivityIndicator, StyleSheet, View } from 'react-native'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
|
||||
/**
|
||||
* What a route switch paints while the hybrid shell flag is still being read, which is a
|
||||
* development build only: a store build cannot have the flag on and never reaches this.
|
||||
*
|
||||
* The base background and nothing else placed on it, so the frame before the decision looks like
|
||||
* the frame after it whichever way the decision goes. Lifted out of the `web` route, which is
|
||||
* where this exact view already was, rather than written again: it is `HostProtocolGate`'s pending
|
||||
* state to the pixel, which is the surface directly above every switch that uses this one.
|
||||
*/
|
||||
export function ShellSwitchPendingScreen() {
|
||||
return (
|
||||
<View style={styles.pending}>
|
||||
<ActivityIndicator color={colors.textSecondary} accessibilityLabel="Loading" />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
pending: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.bgBase
|
||||
}
|
||||
})
|
||||
@@ -33,6 +33,12 @@ vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
StyleSheet: { create: (styles: unknown) => styles },
|
||||
View: 'View'
|
||||
}))
|
||||
|
||||
vi.mock('expo-router', () => ({ useLocalSearchParams: () => dependencies.params }))
|
||||
|
||||
vi.mock('./PageRouteUnavailableScreen', () => ({
|
||||
|
||||
@@ -3,7 +3,8 @@ import { firstParam } from '../navigation/route-param-reader'
|
||||
import { shellScreenRoute, shellScreenRouteKey } from './shell-screen-route'
|
||||
import { MobileWebShellScreen } from './MobileWebShellScreen'
|
||||
import { PageRouteUnavailableScreen } from './PageRouteUnavailableScreen'
|
||||
import { useMobileWebShellEnabled } from './use-mobile-web-shell-enabled'
|
||||
import { ShellSwitchPendingScreen } from './ShellSwitchPendingScreen'
|
||||
import { useShellSwitchDecision } from './shell-switch-decision'
|
||||
|
||||
/**
|
||||
* Any host-scoped pathname this app has no route file for, handed to the shell.
|
||||
@@ -32,7 +33,6 @@ export default function MobileWebPageCatchAllScreen() {
|
||||
}>()
|
||||
const hostId = firstParam(params.hostId)
|
||||
const segments = Array.isArray(params.page) ? params.page : params.page ? [params.page] : []
|
||||
const enabled = useMobileWebShellEnabled()
|
||||
const refusal = <PageRouteUnavailableScreen hostId={hostId} />
|
||||
|
||||
const route =
|
||||
@@ -42,7 +42,12 @@ export default function MobileWebPageCatchAllScreen() {
|
||||
})
|
||||
: null
|
||||
|
||||
if (enabled !== true || !hostId || route === null) {
|
||||
const decision = useShellSwitchDecision(route)
|
||||
|
||||
if (decision.kind === 'pending') {
|
||||
return <ShellSwitchPendingScreen />
|
||||
}
|
||||
if (decision.kind === 'native') {
|
||||
return refusal
|
||||
}
|
||||
// Keyed on the route, as the other switches are: a host captures the grants its session opened
|
||||
@@ -57,9 +62,9 @@ export default function MobileWebPageCatchAllScreen() {
|
||||
// connection that the screen does not exist. `catch-all-page-route-states.test.tsx` drives them.
|
||||
return (
|
||||
<MobileWebShellScreen
|
||||
key={shellScreenRouteKey(route)}
|
||||
key={shellScreenRouteKey(decision.route)}
|
||||
hostId={hostId}
|
||||
route={route}
|
||||
route={decision.route}
|
||||
fallback={refusal}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -29,6 +29,12 @@ vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
StyleSheet: { create: (styles: unknown) => styles },
|
||||
View: 'View'
|
||||
}))
|
||||
|
||||
vi.mock('expo-router', () => ({ useLocalSearchParams: () => dependencies.params }))
|
||||
|
||||
vi.mock('../agent-history/MobileAgentSessionHistoryPanel', () => ({
|
||||
@@ -92,16 +98,16 @@ describe('the native agent-history route that hands off to the shell', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('renders the native panel while the flag read is still settling', async () => {
|
||||
// `index.tsx`'s frame, for its reason: the read is async and a store build never reaches
|
||||
// storage at all, so the native screen is the only thing this route may paint first.
|
||||
await renderRoute()
|
||||
expect(dependencies.panels[0]).toEqual({
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
name: 'my worktree'
|
||||
it('renders neither panel nor shell while the flag read is still settling', async () => {
|
||||
// `index.tsx`'s frame, for its reason: the read is async, so the native panel used to mount
|
||||
// here and be replaced by the page the moment a flag-on read landed. No `await` inside `act`,
|
||||
// which leaves the read's promise pending and catches the route in that window.
|
||||
act(() => {
|
||||
create(createElement(MobileAgentSessionHistoryScreen))
|
||||
})
|
||||
expect(dependencies.panels).toHaveLength(1)
|
||||
expect(dependencies.panels).toEqual([])
|
||||
expect(dependencies.routes).toEqual([])
|
||||
await act(async () => {})
|
||||
})
|
||||
|
||||
it('names no params when the caller named no worktree', async () => {
|
||||
|
||||
@@ -31,6 +31,12 @@ vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
StyleSheet: { create: (styles: unknown) => styles },
|
||||
View: 'View'
|
||||
}))
|
||||
|
||||
vi.mock('expo-router', () => ({ useLocalSearchParams: () => dependencies.params }))
|
||||
|
||||
vi.mock('../files/MobileFileExplorerPanel', () => ({
|
||||
@@ -105,14 +111,15 @@ describe('the native file explorer route that hands off to the shell', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('renders the native panel while the flag read is still settling', async () => {
|
||||
await renderExplorer()
|
||||
expect(dependencies.panels[0]).toEqual({
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
name: 'my worktree',
|
||||
embedded: false
|
||||
it('renders neither panel nor shell while the flag read is still settling', async () => {
|
||||
// No `await` inside `act`, which leaves the read's promise pending: the native panel used to
|
||||
// mount in this window and be replaced by the page the moment a flag-on read landed.
|
||||
act(() => {
|
||||
create(createElement(MobileFileExplorerScreen))
|
||||
})
|
||||
expect(dependencies.panels).toEqual([])
|
||||
expect(dependencies.routes).toEqual([])
|
||||
await act(async () => {})
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,8 +12,10 @@ import { censusSourceFiles } from '../test-support/census-source-files'
|
||||
const MOBILE_ROOT = join(import.meta.dirname, '..', '..')
|
||||
const FLAG_KEY = 'orca:mobileWebShellEnabled'
|
||||
const DEFINITION = 'src/storage/preferences.ts'
|
||||
/** The one product reader. Every route asks it, so the list below stays the whole census. */
|
||||
/** The one product reader, which only the shared switch decision asks. */
|
||||
const FLAG_HOOK = 'src/mobile-web-shell/use-mobile-web-shell-enabled.ts'
|
||||
/** The one caller of that hook: every route asks this instead, so its list is the whole census. */
|
||||
const DECISION = 'src/mobile-web-shell/shell-switch-decision.ts'
|
||||
const ROUTE = 'app/h/[hostId]/web.tsx'
|
||||
const HOST_ROUTE = 'app/h/[hostId]/index.tsx'
|
||||
const AGENT_HISTORY_ROUTE = 'app/h/[hostId]/agent-history/[worktreeId].tsx'
|
||||
@@ -25,6 +27,8 @@ const REVIEW_ROUTE = 'app/h/[hostId]/review/[worktreeId].tsx'
|
||||
const SESSION_ROUTE = 'app/h/[hostId]/session/[worktreeId].tsx'
|
||||
/** The one switch with no native screen behind it; its route file only re-exports this body. */
|
||||
const CATCH_ALL_ROUTE = 'src/mobile-web-shell/catch-all-page-route.tsx'
|
||||
/** What a switch paints while the decision is `pending`, and the third thing every switch names. */
|
||||
const PENDING_SCREEN = 'src/mobile-web-shell/ShellSwitchPendingScreen.tsx'
|
||||
/** One entry per screen the flag can switch to the page, which is what a review reads. */
|
||||
const SWITCHED_ROUTES = [
|
||||
HOST_ROUTE,
|
||||
@@ -62,11 +66,35 @@ function filesContaining(needle: string): string[] {
|
||||
.sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* The same matches with the line each was read off, as the failure message for the rules below.
|
||||
*
|
||||
* A census that answers only with paths tells a reader which file is wrong and nothing about what
|
||||
* in it is: the needles here are identifiers, and a file can name one in an import, a call or a
|
||||
* comment. The snippet is what turns "this list moved" into the edit that moved it.
|
||||
*/
|
||||
function matchesOf(needle: string): string {
|
||||
// Sorted by path then by line number, not as text: `:59:` sorts before `:4:` as a string, which
|
||||
// reads as a file whose matches are out of order.
|
||||
return [...SOURCES]
|
||||
.sort((left, right) => left.path.localeCompare(right.path))
|
||||
.flatMap((file) =>
|
||||
file.text
|
||||
.split('\n')
|
||||
.flatMap((line, index) =>
|
||||
line.includes(needle) ? [`${file.path}:${index + 1}: ${line.trim()}`] : []
|
||||
)
|
||||
)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
describe('who touches the hybrid shell flag', () => {
|
||||
it('reaches every shipped tree, so the absence assertions below cannot pass vacuously', () => {
|
||||
const paths = SOURCES.map((file) => file.path)
|
||||
expect(paths).toContain(DEFINITION)
|
||||
expect(paths).toContain(FLAG_HOOK)
|
||||
expect(paths).toContain(DECISION)
|
||||
expect(paths).toContain(PENDING_SCREEN)
|
||||
expect(paths).toContain(ROUTE)
|
||||
for (const route of SWITCHED_ROUTES) {
|
||||
expect(paths).toContain(route)
|
||||
@@ -90,17 +118,51 @@ describe('who touches the hybrid shell flag', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('reaches the switched routes through that hook and no others', () => {
|
||||
it('is read by the shared switch decision and by nothing else', () => {
|
||||
// The narrowest this has ever been, and the reason the rule below is total: a route cannot
|
||||
// hold a private opinion about the flag — including about the window where it is still `null`
|
||||
// — without reading it, and this is the only place that reads it.
|
||||
expect(
|
||||
filesContaining('useMobileWebShellEnabled'),
|
||||
matchesOf('useMobileWebShellEnabled')
|
||||
).toEqual([DECISION, FLAG_HOOK].sort())
|
||||
})
|
||||
|
||||
it('reaches the switched routes through that decision and no others', () => {
|
||||
// Each switched route is a screen the flag decides the renderer of, and one more is one more
|
||||
// place a dark feature could turn itself on. The list grows once per domain series, in the PR
|
||||
// that switches the route file to MobileWebShellScreen, and never as a side effect of anything
|
||||
// else. A switched route is inert until MOBILE_WEB_PAGE_ROUTES lists it as well, so an entry
|
||||
// here can land a PR ahead of that one.
|
||||
expect(filesContaining('useMobileWebShellEnabled')).toEqual(
|
||||
[FLAG_HOOK, ROUTE, ...SWITCHED_ROUTES].sort()
|
||||
expect(filesContaining('useShellSwitchDecision'), matchesOf('useShellSwitchDecision')).toEqual(
|
||||
[DECISION, ROUTE, ...SWITCHED_ROUTES].sort()
|
||||
)
|
||||
})
|
||||
|
||||
it('gives every one of them the same neutral state to paint while the flag is unresolved', () => {
|
||||
// The rule a sixth switch would otherwise regress past. Reading the flag through the decision
|
||||
// is not on its own enough: a switch that ignored `pending` and fell through to its native
|
||||
// screen would satisfy the rule above and still flash native in front of a flag-on user. This
|
||||
// one says every switch names the neutral screen, which is existence rather than shape — where
|
||||
// it names it is the route test's business, and `shell-switch-null-flag.test.tsx` drives all
|
||||
// nine through the states themselves.
|
||||
expect(
|
||||
filesContaining('ShellSwitchPendingScreen'),
|
||||
matchesOf('ShellSwitchPendingScreen')
|
||||
).toEqual([PENDING_SCREEN, ROUTE, ...SWITCHED_ROUTES].sort())
|
||||
})
|
||||
|
||||
it('fences the build kind in one place, which both the read and the hook ask', () => {
|
||||
// The `__DEV__` test that makes a store build unable to turn the flag on. The hook starts its
|
||||
// state on it so a release build never reaches the neutral state, which is the same answer
|
||||
// `loadMobileWebShellEnabled` gives one render later — and two spellings of one build-kind
|
||||
// test are two things to keep true, where this feature's darkness rests on exactly one.
|
||||
expect(
|
||||
filesContaining('mobileWebShellFlagCanBeOn'),
|
||||
matchesOf('mobileWebShellFlagCanBeOn')
|
||||
).toEqual([DEFINITION, FLAG_HOOK].sort())
|
||||
})
|
||||
|
||||
it('is written only by the developer row', () => {
|
||||
expect(filesContaining('saveMobileWebShellEnabled')).toEqual([DEFINITION, DEVELOPER_ROW].sort())
|
||||
})
|
||||
|
||||
@@ -25,6 +25,12 @@ vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
StyleSheet: { create: (styles: unknown) => styles },
|
||||
View: 'View'
|
||||
}))
|
||||
|
||||
vi.mock('expo-router', () => ({
|
||||
useLocalSearchParams: () => ({ hostId: dependencies.hostId })
|
||||
}))
|
||||
|
||||
@@ -150,6 +150,9 @@ describe('the hybrid shell route', () => {
|
||||
const tree = rendered.tree
|
||||
expect(tree === null ? [] : byName(tree, 'Redirect')).toEqual([])
|
||||
expect(dependencies.mounted).toEqual([])
|
||||
// The neutral state itself, rendered for real here: `shell-switch-null-flag.test.tsx` mocks it
|
||||
// to count mounts across all nine switches, so this is where its shape stays pinned.
|
||||
expect(tree === null ? [] : byName(tree, 'ActivityIndicator')).toHaveLength(1)
|
||||
await act(async () => {})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -32,6 +32,12 @@ vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
StyleSheet: { create: (styles: unknown) => styles },
|
||||
View: 'View'
|
||||
}))
|
||||
|
||||
vi.mock('expo-router', () => ({
|
||||
useLocalSearchParams: () => dependencies.params,
|
||||
useRouter: () => ({
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { shellSwitchDecision } from './shell-switch-decision'
|
||||
|
||||
const ROUTE = { pathname: '/h/host-1/files/wt-1' }
|
||||
|
||||
/**
|
||||
* The flag is read asynchronously, so every switch has a window where it holds `null` — the read
|
||||
* has not settled and neither answer is known yet. Treating that as "off" is what made a flag-on
|
||||
* user watch the native screen mount and then be replaced by the page.
|
||||
*/
|
||||
describe('the hybrid shell switch decision', () => {
|
||||
it('waits while the flag is unresolved rather than answering native', () => {
|
||||
expect(shellSwitchDecision(null, ROUTE)).toEqual({ kind: 'pending' })
|
||||
})
|
||||
|
||||
it('answers native with the flag off', () => {
|
||||
expect(shellSwitchDecision(false, ROUTE)).toEqual({ kind: 'native' })
|
||||
})
|
||||
|
||||
it('answers the shell, carrying the route, with the flag on', () => {
|
||||
expect(shellSwitchDecision(true, ROUTE)).toEqual({ kind: 'shell', route: ROUTE })
|
||||
})
|
||||
|
||||
it('answers native for a route the shell could never open, without waiting on the flag', () => {
|
||||
// No neutral frame is owed when the shell is not a possible outcome: a switch whose params
|
||||
// build no route the bridge would accept has one renderer, and holding it back would paint a
|
||||
// spinner over a screen that was always going to be the native one.
|
||||
expect(shellSwitchDecision(null, null)).toEqual({ kind: 'native' })
|
||||
expect(shellSwitchDecision(true, null)).toEqual({ kind: 'native' })
|
||||
expect(shellSwitchDecision(false, null)).toEqual({ kind: 'native' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { BridgeInitRoute } from './bridge/bridge-init-route'
|
||||
import { useMobileWebShellEnabled } from './use-mobile-web-shell-enabled'
|
||||
|
||||
/**
|
||||
* Which renderer a hybrid-shell route switch mounts, once that is knowable.
|
||||
*
|
||||
* `pending` is the third answer every switch was missing. Where the flag can be on it is read from
|
||||
* storage after the first render, so `null` is a window every switch passes through, and each one
|
||||
* used to spend it on the native screen: a flag-on user watched the native screen mount, subscribe
|
||||
* and paint, then be torn down and replaced by the page. One decision here so a switch cannot hold
|
||||
* a private opinion about `null`, and so the flag keeps exactly one reader — which is what makes
|
||||
* the census beside this file total rather than a list somebody remembers to extend.
|
||||
*
|
||||
* `pending` is unreachable on a build that cannot have the flag on: the hook starts at `false`
|
||||
* there, so a store build commits its native renderer on frame one and pays nothing for a neutral
|
||||
* state it could never have used.
|
||||
*
|
||||
* A route the shell could never open is answered `native` without waiting: the flag cannot change
|
||||
* that outcome, and a neutral frame in front of a decided one is a flash this file exists to remove.
|
||||
*/
|
||||
export type ShellSwitchDecision =
|
||||
| { readonly kind: 'pending' }
|
||||
| { readonly kind: 'native' }
|
||||
| { readonly kind: 'shell'; readonly route: BridgeInitRoute }
|
||||
|
||||
export function shellSwitchDecision(
|
||||
enabled: boolean | null,
|
||||
route: BridgeInitRoute | null
|
||||
): ShellSwitchDecision {
|
||||
if (route === null) {
|
||||
return { kind: 'native' }
|
||||
}
|
||||
if (enabled === null) {
|
||||
return { kind: 'pending' }
|
||||
}
|
||||
return enabled ? { kind: 'shell', route } : { kind: 'native' }
|
||||
}
|
||||
|
||||
/** The route is `null` when this switch's params name no screen the shell could open. */
|
||||
export function useShellSwitchDecision(route: BridgeInitRoute | null): ShellSwitchDecision {
|
||||
return shellSwitchDecision(useMobileWebShellEnabled(), route)
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
import { createElement, type ComponentType } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type SwitchDependencies = {
|
||||
storage: Map<string, string>
|
||||
/** What bounds the neutral window on a released phone; see the last case in this file. */
|
||||
reads: number
|
||||
/** Committed mounts, not renders: React may discard a render, and what this file is about is
|
||||
* what the user was shown. */
|
||||
natives: string[]
|
||||
shells: string[]
|
||||
/** Committed mounts of the neutral screen, which is what "never paints a neutral frame" needs:
|
||||
* a frame committed and replaced inside one `act` leaves nothing in the final tree. */
|
||||
neutrals: number
|
||||
params: Record<string, string | string[] | undefined>
|
||||
}
|
||||
|
||||
const dependencies = vi.hoisted((): SwitchDependencies => ({
|
||||
storage: new Map(),
|
||||
reads: 0,
|
||||
natives: [],
|
||||
shells: [],
|
||||
neutrals: 0,
|
||||
params: {}
|
||||
}))
|
||||
|
||||
const nativeScreen = vi.hoisted(
|
||||
() =>
|
||||
async (name: string): Promise<ComponentType<Record<string, unknown>>> => {
|
||||
const React = await import('react')
|
||||
return function NativeScreen() {
|
||||
React.useEffect(() => {
|
||||
dependencies.natives.push(name)
|
||||
}, [])
|
||||
return null
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
default: {
|
||||
getItem: async (key: string) => {
|
||||
dependencies.reads += 1
|
||||
return dependencies.storage.get(key) ?? null
|
||||
},
|
||||
setItem: async (key: string, value: string) => {
|
||||
dependencies.storage.set(key, value)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
StyleSheet: { create: (styles: unknown) => styles },
|
||||
View: 'View'
|
||||
}))
|
||||
|
||||
vi.mock('expo-router', () => ({
|
||||
Redirect: 'Redirect',
|
||||
useLocalSearchParams: () => dependencies.params,
|
||||
useRouter: () => ({ setParams: () => {} })
|
||||
}))
|
||||
|
||||
vi.mock('./MobileWebShellScreen', async () => {
|
||||
const React = await import('react')
|
||||
return {
|
||||
MobileWebShellScreen: (props: { route: { pathname: string } }) => {
|
||||
const pathname = React.useRef(props.route.pathname)
|
||||
React.useEffect(() => {
|
||||
dependencies.shells.push(pathname.current)
|
||||
}, [])
|
||||
return null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./ShellSwitchPendingScreen', async () => {
|
||||
const React = await import('react')
|
||||
return {
|
||||
ShellSwitchPendingScreen: () => {
|
||||
React.useEffect(() => {
|
||||
dependencies.neutrals += 1
|
||||
}, [])
|
||||
return null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../host-screen/HostScreen', async () => ({ HostScreen: await nativeScreen('host-list') }))
|
||||
vi.mock('../components/WorkspaceDetailPlaceholder', async () => ({
|
||||
WorkspaceDetailPlaceholder: await nativeScreen('workspace-detail-placeholder')
|
||||
}))
|
||||
vi.mock('../layout/responsive-layout', () => ({
|
||||
useResponsiveLayout: () => ({ isWideLayout: false })
|
||||
}))
|
||||
vi.mock('../tasks/MobileTasksScreen', async () => ({
|
||||
MobileTasksScreen: await nativeScreen('tasks')
|
||||
}))
|
||||
vi.mock('../agent-history/MobileAgentSessionHistoryPanel', async () => ({
|
||||
MobileAgentSessionHistoryPanel: await nativeScreen('agent-history')
|
||||
}))
|
||||
vi.mock('../files/MobileFileExplorerPanel', async () => ({
|
||||
MobileFileExplorerPanel: await nativeScreen('files')
|
||||
}))
|
||||
vi.mock('../files/MobileFilePreviewScreen', async () => ({
|
||||
MobileFilePreviewScreen: await nativeScreen('files-preview')
|
||||
}))
|
||||
vi.mock('../source-control/MobileSourceControlPanel', async () => ({
|
||||
MobileSourceControlPanel: await nativeScreen('source-control')
|
||||
}))
|
||||
vi.mock('../session/MobileDiffReviewRouteScreen', async () => ({
|
||||
MobileDiffReviewRouteScreen: await nativeScreen('review')
|
||||
}))
|
||||
vi.mock('../session/MobileSessionRouteScreen', async () => ({
|
||||
MobileSessionRouteScreen: await nativeScreen('session')
|
||||
}))
|
||||
vi.mock('./PageRouteUnavailableScreen', async () => ({
|
||||
PageRouteUnavailableScreen: await nativeScreen('catch-all')
|
||||
}))
|
||||
|
||||
import HostListRoute from '../../app/h/[hostId]/index'
|
||||
import TasksRoute from '../../app/h/[hostId]/tasks'
|
||||
import AgentHistoryRoute from '../../app/h/[hostId]/agent-history/[worktreeId]'
|
||||
import FilesRoute from '../../app/h/[hostId]/files/[worktreeId]'
|
||||
import FilesPreviewRoute from '../../app/h/[hostId]/files/preview/[worktreeId]'
|
||||
import SourceControlRoute from '../../app/h/[hostId]/source-control/[worktreeId]'
|
||||
import ReviewRoute from '../../app/h/[hostId]/review/[worktreeId]'
|
||||
import SessionRoute from '../../app/h/[hostId]/session/[worktreeId]'
|
||||
import CatchAllRoute from './catch-all-page-route'
|
||||
|
||||
const FLAG_KEY = 'orca:mobileWebShellEnabled'
|
||||
|
||||
/**
|
||||
* Every switch the hybrid shell flag decides, with the params each needs to name a route the shell
|
||||
* could open. `native` is what that switch renders when the flag is off — a panel for most of them
|
||||
* and the refusal screen for the catch-all, which has no native screen behind it.
|
||||
*/
|
||||
type SwitchCase = {
|
||||
readonly name: string
|
||||
readonly Route: ComponentType
|
||||
readonly params: Record<string, string | string[]>
|
||||
/** What the mocked native screen pushes when it mounts. */
|
||||
readonly native: string
|
||||
readonly pathname: string
|
||||
}
|
||||
|
||||
const SWITCHES: readonly SwitchCase[] = [
|
||||
{
|
||||
name: 'host list',
|
||||
Route: HostListRoute,
|
||||
params: { hostId: 'host-1' },
|
||||
native: 'host-list',
|
||||
pathname: '/h/host-1'
|
||||
},
|
||||
{
|
||||
name: 'tasks',
|
||||
Route: TasksRoute,
|
||||
params: { hostId: 'host-1' },
|
||||
native: 'tasks',
|
||||
pathname: '/h/host-1/tasks'
|
||||
},
|
||||
{
|
||||
name: 'agent history',
|
||||
Route: AgentHistoryRoute,
|
||||
params: { hostId: 'host-1', worktreeId: 'wt-1' },
|
||||
native: 'agent-history',
|
||||
pathname: '/h/host-1/agent-history/wt-1'
|
||||
},
|
||||
{
|
||||
name: 'files',
|
||||
Route: FilesRoute,
|
||||
params: { hostId: 'host-1', worktreeId: 'wt-1' },
|
||||
native: 'files',
|
||||
pathname: '/h/host-1/files/wt-1'
|
||||
},
|
||||
{
|
||||
name: 'file preview',
|
||||
Route: FilesPreviewRoute,
|
||||
params: { hostId: 'host-1', worktreeId: 'wt-1', relativePath: 'src/index.ts' },
|
||||
native: 'files-preview',
|
||||
pathname: '/h/host-1/files/preview/wt-1'
|
||||
},
|
||||
{
|
||||
name: 'source control',
|
||||
Route: SourceControlRoute,
|
||||
params: { hostId: 'host-1', worktreeId: 'wt-1' },
|
||||
native: 'source-control',
|
||||
pathname: '/h/host-1/source-control/wt-1'
|
||||
},
|
||||
{
|
||||
name: 'review',
|
||||
Route: ReviewRoute,
|
||||
params: { hostId: 'host-1', worktreeId: 'wt-1' },
|
||||
native: 'review',
|
||||
pathname: '/h/host-1/review/wt-1'
|
||||
},
|
||||
{
|
||||
name: 'session',
|
||||
Route: SessionRoute,
|
||||
params: { hostId: 'host-1', worktreeId: 'wt-1' },
|
||||
native: 'session',
|
||||
pathname: '/h/host-1/session/wt-1'
|
||||
},
|
||||
{
|
||||
name: 'catch-all',
|
||||
Route: CatchAllRoute,
|
||||
params: { hostId: 'host-1', page: ['settings'] },
|
||||
native: 'catch-all',
|
||||
pathname: '/h/host-1/settings'
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* `__DEV__` is a React Native global with no value under this runner, so every case pins it rather
|
||||
* than inheriting one: assigned onto `globalThis` for a build kind that has it and deleted for a
|
||||
* store build, which is what the app sees when the bundler defined nothing. Which one is in force
|
||||
* decides whether the neutral state is reachable at all, so an unpinned case would be measuring
|
||||
* the runner.
|
||||
*/
|
||||
function setDevelopmentBuild(isDevelopmentBuild: boolean | undefined): void {
|
||||
if (isDevelopmentBuild === undefined) {
|
||||
Reflect.deleteProperty(globalThis, '__DEV__')
|
||||
return
|
||||
}
|
||||
Object.assign(globalThis, { __DEV__: isDevelopmentBuild })
|
||||
}
|
||||
|
||||
/** Renders without settling the flag read: no `await` inside `act`, so the effect's promise is
|
||||
* deliberately left pending and the switch is caught in its unresolved window. */
|
||||
function renderUnsettled(Route: ComponentType): ReactTestRenderer {
|
||||
const rendered: { tree: ReactTestRenderer | null } = { tree: null }
|
||||
act(() => {
|
||||
rendered.tree = create(createElement(Route))
|
||||
})
|
||||
if (rendered.tree === null) {
|
||||
throw new Error('the switch did not render')
|
||||
}
|
||||
return rendered.tree
|
||||
}
|
||||
|
||||
describe.each(SWITCHES)('the $name switch on a development build', (entry) => {
|
||||
beforeEach(() => {
|
||||
dependencies.storage.clear()
|
||||
dependencies.reads = 0
|
||||
dependencies.natives.length = 0
|
||||
dependencies.shells.length = 0
|
||||
dependencies.neutrals = 0
|
||||
dependencies.params = { ...entry.params }
|
||||
setDevelopmentBuild(true)
|
||||
})
|
||||
|
||||
it('mounts neither renderer while the flag is unresolved, and paints the neutral state', async () => {
|
||||
dependencies.storage.set(FLAG_KEY, 'true')
|
||||
renderUnsettled(entry.Route)
|
||||
expect(dependencies.natives).toEqual([])
|
||||
expect(dependencies.shells).toEqual([])
|
||||
expect(dependencies.neutrals).toBe(1)
|
||||
await act(async () => {})
|
||||
})
|
||||
|
||||
it('mounts the shell once when the read resolves on, having never mounted native', async () => {
|
||||
dependencies.storage.set(FLAG_KEY, 'true')
|
||||
renderUnsettled(entry.Route)
|
||||
await act(async () => {})
|
||||
expect(dependencies.natives).toEqual([])
|
||||
expect(dependencies.shells).toEqual([entry.pathname])
|
||||
})
|
||||
|
||||
it('mounts native once when the read resolves off, and nothing else', async () => {
|
||||
renderUnsettled(entry.Route)
|
||||
await act(async () => {})
|
||||
expect(dependencies.natives).toEqual([entry.native])
|
||||
expect(dependencies.shells).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The build every store user is on, where the flag cannot be turned on at all.
|
||||
*
|
||||
* A neutral frame is worth a native mount only when the flag could resolve on. Outside `__DEV__`
|
||||
* it cannot, so the hook holds `false` from its first render and the `pending` branch is
|
||||
* unreachable here: the switch commits the native renderer on frame one and never revises it.
|
||||
*/
|
||||
describe.each(SWITCHES)('the $name switch on a release build', (entry) => {
|
||||
beforeEach(() => {
|
||||
dependencies.storage.clear()
|
||||
dependencies.reads = 0
|
||||
dependencies.natives.length = 0
|
||||
dependencies.shells.length = 0
|
||||
dependencies.neutrals = 0
|
||||
dependencies.params = { ...entry.params }
|
||||
setDevelopmentBuild(false)
|
||||
})
|
||||
|
||||
it('commits native on its first frame and never mounts the neutral screen', async () => {
|
||||
// A flag a development build left in the container, which a store build shares a bundle id
|
||||
// with: still unreachable, and still no neutral frame in front of it.
|
||||
dependencies.storage.set(FLAG_KEY, 'true')
|
||||
renderUnsettled(entry.Route)
|
||||
expect(dependencies.neutrals).toBe(0)
|
||||
expect(dependencies.natives).toEqual([entry.native])
|
||||
expect(dependencies.shells).toEqual([])
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(dependencies.neutrals).toBe(0)
|
||||
expect(dependencies.natives).toEqual([entry.native])
|
||||
expect(dependencies.shells).toEqual([])
|
||||
})
|
||||
|
||||
it('reaches no storage at all, which is what makes the first frame decidable', async () => {
|
||||
// The same fact the initialiser rests on: `loadMobileWebShellEnabled` answers `false` outside
|
||||
// `__DEV__` before it looks at the key, so there is nothing to wait for and nothing to read.
|
||||
dependencies.storage.set(FLAG_KEY, 'true')
|
||||
renderUnsettled(entry.Route)
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(dependencies.reads).toBe(0)
|
||||
})
|
||||
|
||||
it('does the same when the bundler defined no `__DEV__` at all', async () => {
|
||||
setDevelopmentBuild(undefined)
|
||||
dependencies.storage.set(FLAG_KEY, 'true')
|
||||
renderUnsettled(entry.Route)
|
||||
expect(dependencies.neutrals).toBe(0)
|
||||
expect(dependencies.natives).toEqual([entry.native])
|
||||
await act(async () => {})
|
||||
})
|
||||
})
|
||||
@@ -1,19 +1,23 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { loadMobileWebShellEnabled } from '../storage/preferences'
|
||||
import { loadMobileWebShellEnabled, mobileWebShellFlagCanBeOn } from '../storage/preferences'
|
||||
|
||||
/**
|
||||
* The hybrid shell flag, read once per mount.
|
||||
*
|
||||
* The one product reader of `loadMobileWebShellEnabled`, which is what keeps the flag census
|
||||
* meaningful: the routes ask this and nothing asks the storage key twice. `null` is the read still
|
||||
* settling, and every caller treats it as off — a route that guessed on would fetch on a flag that
|
||||
* is off, which is the one thing the flag exists to prevent.
|
||||
* settling, which `shell-switch-decision.ts` turns into a neutral frame rather than a guess.
|
||||
*
|
||||
* A release build never reaches storage at all; `loadMobileWebShellEnabled` answers false outside
|
||||
* `__DEV__` before it looks.
|
||||
* That frame is worth a native mount only where the flag could resolve on, so a build that cannot
|
||||
* have it on starts at `false` rather than `null`: `mobileWebShellFlagCanBeOn` is the same fact
|
||||
* `loadMobileWebShellEnabled` would answer with, one render earlier, and it makes `null`
|
||||
* unreachable on every store build. The effect still runs there and still answers `false`, because
|
||||
* the initialiser is a starting point and the read is what decides.
|
||||
*/
|
||||
export function useMobileWebShellEnabled(): boolean | null {
|
||||
const [enabled, setEnabled] = useState<boolean | null>(null)
|
||||
const [enabled, setEnabled] = useState<boolean | null>(() =>
|
||||
mobileWebShellFlagCanBeOn() ? null : false
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let stale = false
|
||||
|
||||
@@ -123,11 +123,23 @@ const MOBILE_WEB_SHELL_KEY = 'orca:mobileWebShellEnabled'
|
||||
// Why: the hybrid shell route is dark. Default-off means a store build never fetches, writes or
|
||||
// sweeps a bundle cache, and the only writer is the __DEV__ Troubleshoot toggle — anything but
|
||||
// `'true'`, including an unreadable store, is off.
|
||||
/**
|
||||
* Whether this build can have the flag on at all.
|
||||
*
|
||||
* A release build never reads the key: it shares its bundle id with the development build and the
|
||||
* iOS data container survives an install-over, so a flag a developer left on would otherwise
|
||||
* follow the store build in and mount the shell on a deep link.
|
||||
*
|
||||
* Named rather than spelled twice. The hook beside the reader starts its state on this answer so
|
||||
* a store build is decided on its first render rather than after an effect, and two spellings of
|
||||
* one `__DEV__` test would be two things to keep true.
|
||||
*/
|
||||
export function mobileWebShellFlagCanBeOn(): boolean {
|
||||
return typeof __DEV__ !== 'undefined' && __DEV__
|
||||
}
|
||||
|
||||
export async function loadMobileWebShellEnabled(): Promise<boolean> {
|
||||
// A release build never reads the key at all: it shares its bundle id with the development build
|
||||
// and the iOS data container survives an install-over, so a flag a developer left on would
|
||||
// otherwise follow the store build in and mount the shell on a deep link.
|
||||
if (typeof __DEV__ === 'undefined' || !__DEV__) {
|
||||
if (!mobileWebShellFlagCanBeOn()) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -77,6 +77,14 @@ export const MODEL_PRICING: Record<string, CodexModelPricing> = {
|
||||
inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 2 }],
|
||||
cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 0.2 }],
|
||||
outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 9 }]
|
||||
},
|
||||
'gpt-6-astra': {
|
||||
input: 10,
|
||||
cachedInput: 1,
|
||||
output: 50,
|
||||
inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 20 }],
|
||||
cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 2 }],
|
||||
outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 75 }]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +179,9 @@ export function normalizeModelForPricing(model: string | null): string | null {
|
||||
if (normalized === 'gpt-5.6-luna' || normalized.startsWith('gpt-5.6-luna-')) {
|
||||
return 'gpt-5.6-luna'
|
||||
}
|
||||
if (normalized === 'gpt-6-astra' || normalized.startsWith('gpt-6-astra-')) {
|
||||
return 'gpt-6-astra'
|
||||
}
|
||||
// Why: OpenAI routes the bare `gpt-5.6` alias to Sol. Match it exactly — a
|
||||
// `gpt-5.6-` prefix match would swallow the tier IDs above and any future
|
||||
// cheaper variant.
|
||||
|
||||
@@ -31,6 +31,7 @@ export function buildSummary(
|
||||
let events = 0
|
||||
let estimatedCostUsd = 0
|
||||
let hasAnyBillableCost = false
|
||||
let hasUnpricedModels = false
|
||||
const byModel = new Map<string, number>()
|
||||
const byProject = new Map<string, number>()
|
||||
|
||||
@@ -55,6 +56,9 @@ export function buildSummary(
|
||||
if (cost !== null) {
|
||||
hasAnyBillableCost = true
|
||||
estimatedCostUsd += cost
|
||||
} else if (row.model !== null) {
|
||||
// A named model with no pricing entry: its tokens silently leave the total.
|
||||
hasUnpricedModels = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +76,7 @@ export function buildSummary(
|
||||
reasoningOutputTokens,
|
||||
totalTokens,
|
||||
estimatedCostUsd: hasAnyBillableCost ? estimatedCostUsd : null,
|
||||
hasUnpricedModels,
|
||||
topModel,
|
||||
topProject,
|
||||
hasAnyCodexData: filteredSessions.length > 0 || filteredDaily.length > 0
|
||||
|
||||
@@ -212,6 +212,145 @@ describe('CodexUsageStore', () => {
|
||||
expect(breakdown.find((row) => row.key === 'gpt-5.6-luna')?.estimatedCostUsd).toBeCloseTo(0.205)
|
||||
})
|
||||
|
||||
it('prices GPT-6 Astra with current OpenAI rates', async () => {
|
||||
const store = createStoreWithState({
|
||||
dailyAggregates: [
|
||||
{
|
||||
day: '2026-04-09',
|
||||
model: 'gpt-6-astra',
|
||||
projectKey: 'worktree:repo-1::/workspace/repo',
|
||||
projectLabel: 'Repo',
|
||||
repoId: 'repo-1',
|
||||
worktreeId: 'repo-1::/workspace/repo',
|
||||
eventCount: 1,
|
||||
inputTokens: 2_000_000,
|
||||
cachedInputTokens: 1_000_000,
|
||||
outputTokens: 1_000_000,
|
||||
reasoningOutputTokens: 100_000,
|
||||
totalTokens: 3_000_000,
|
||||
hasInferredPricing: false
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const breakdown = await store.getBreakdown('orca', '30d', 'model')
|
||||
|
||||
expect(breakdown.find((row) => row.key === 'gpt-6-astra')?.estimatedCostUsd).toBeCloseTo(87.208)
|
||||
})
|
||||
|
||||
it('normalizes GPT-6 Astra reasoning suffixes and snapshot IDs before pricing', async () => {
|
||||
const store = createStoreWithState({
|
||||
dailyAggregates: ['gpt-6-astra-high', 'gpt-6-astra-2026-09-01'].map((model) => ({
|
||||
day: '2026-04-09',
|
||||
model,
|
||||
projectKey: 'worktree:repo-1::/workspace/repo',
|
||||
projectLabel: 'Repo',
|
||||
repoId: 'repo-1',
|
||||
worktreeId: 'repo-1::/workspace/repo',
|
||||
eventCount: 1,
|
||||
inputTokens: 100_000,
|
||||
cachedInputTokens: 50_000,
|
||||
outputTokens: 25_000,
|
||||
reasoningOutputTokens: 5_000,
|
||||
totalTokens: 125_000,
|
||||
hasInferredPricing: false
|
||||
}))
|
||||
})
|
||||
|
||||
const breakdown = await store.getBreakdown('orca', '30d', 'model')
|
||||
|
||||
expect(breakdown.find((row) => row.key === 'gpt-6-astra-high')?.estimatedCostUsd).toBeCloseTo(
|
||||
1.8
|
||||
)
|
||||
expect(
|
||||
breakdown.find((row) => row.key === 'gpt-6-astra-2026-09-01')?.estimatedCostUsd
|
||||
).toBeCloseTo(1.8)
|
||||
})
|
||||
|
||||
it('flags a named model with no pricing entry so its missing tokens are declared', async () => {
|
||||
const store = createStoreWithState({
|
||||
dailyAggregates: [
|
||||
{
|
||||
day: '2026-04-09',
|
||||
model: 'gpt-5',
|
||||
projectKey: 'worktree:repo-1::/workspace/repo',
|
||||
projectLabel: 'Repo',
|
||||
repoId: 'repo-1',
|
||||
worktreeId: 'repo-1::/workspace/repo',
|
||||
eventCount: 1,
|
||||
inputTokens: 1000,
|
||||
cachedInputTokens: 400,
|
||||
outputTokens: 250,
|
||||
reasoningOutputTokens: 100,
|
||||
totalTokens: 1250,
|
||||
hasInferredPricing: false
|
||||
},
|
||||
{
|
||||
day: '2026-04-09',
|
||||
model: 'gpt-7-unreleased',
|
||||
projectKey: 'worktree:repo-1::/workspace/repo',
|
||||
projectLabel: 'Repo',
|
||||
repoId: 'repo-1',
|
||||
worktreeId: 'repo-1::/workspace/repo',
|
||||
eventCount: 1,
|
||||
inputTokens: 5_000_000,
|
||||
cachedInputTokens: 0,
|
||||
outputTokens: 5_000_000,
|
||||
reasoningOutputTokens: 0,
|
||||
totalTokens: 10_000_000,
|
||||
hasInferredPricing: false
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const summary = await store.getSummary('orca', '30d')
|
||||
|
||||
expect(summary.hasUnpricedModels).toBe(true)
|
||||
// The unpriced row's ten million tokens are absent from the total it sits beside.
|
||||
expect(summary.estimatedCostUsd).toBeCloseTo(0.0033, 6)
|
||||
})
|
||||
|
||||
it('keeps the unpriced flag off for priced rows and for rows with no model name', async () => {
|
||||
const store = createStoreWithState({
|
||||
dailyAggregates: [
|
||||
{
|
||||
day: '2026-04-09',
|
||||
model: 'gpt-6-astra',
|
||||
projectKey: 'worktree:repo-1::/workspace/repo',
|
||||
projectLabel: 'Repo',
|
||||
repoId: 'repo-1',
|
||||
worktreeId: 'repo-1::/workspace/repo',
|
||||
eventCount: 1,
|
||||
inputTokens: 1000,
|
||||
cachedInputTokens: 400,
|
||||
outputTokens: 250,
|
||||
reasoningOutputTokens: 100,
|
||||
totalTokens: 1250,
|
||||
hasInferredPricing: false
|
||||
},
|
||||
{
|
||||
day: '2026-04-09',
|
||||
model: null,
|
||||
projectKey: 'worktree:repo-1::/workspace/repo',
|
||||
projectLabel: 'Repo',
|
||||
repoId: 'repo-1',
|
||||
worktreeId: 'repo-1::/workspace/repo',
|
||||
eventCount: 1,
|
||||
inputTokens: 1000,
|
||||
cachedInputTokens: 0,
|
||||
outputTokens: 500,
|
||||
reasoningOutputTokens: 0,
|
||||
totalTokens: 1500,
|
||||
hasInferredPricing: true
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const summary = await store.getSummary('orca', '30d')
|
||||
|
||||
expect(summary.hasUnpricedModels).toBe(false)
|
||||
})
|
||||
|
||||
it('normalizes Codex model variants and reasoning suffixes before pricing', async () => {
|
||||
const store = createStoreWithState({
|
||||
dailyAggregates: [
|
||||
|
||||
@@ -70,6 +70,7 @@ describe('addOrcaWslInteropEnv', () => {
|
||||
ORCA_TAB_ID: 'tab-1',
|
||||
ORCA_WORKTREE_ID: 'repo::\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo',
|
||||
ORCA_AGENT_LAUNCH_TOKEN: 'launch-secret',
|
||||
ORCA_OPENCODE_AGENT: 'opencode2',
|
||||
ORCA_AGENT_HOOK_PORT: '4567',
|
||||
ORCA_AGENT_HOOK_TOKEN: 'token',
|
||||
ORCA_AGENT_HOOK_ENV: 'dev',
|
||||
@@ -94,6 +95,7 @@ describe('addOrcaWslInteropEnv', () => {
|
||||
expect(env.WSLENV).toContain('ORCA_TAB_ID/u')
|
||||
expect(env.WSLENV).toContain('ORCA_WORKTREE_ID/u')
|
||||
expect(env.WSLENV).toContain('ORCA_AGENT_LAUNCH_TOKEN/u')
|
||||
expect(env.WSLENV).toContain('ORCA_OPENCODE_AGENT/u')
|
||||
expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_PORT/u')
|
||||
expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_TOKEN/u')
|
||||
expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_ENV/u')
|
||||
|
||||
@@ -53,6 +53,7 @@ function worktreeSetupWslenvEntries(env: Record<string, string | undefined>): st
|
||||
]
|
||||
}
|
||||
|
||||
/** Adds the host environment values required by a WSL PTY and its guest relay. */
|
||||
export function addOrcaWslInteropEnv(env: Record<string, string>): void {
|
||||
// Why set here: every WSL spawn path funnels through this helper, and the
|
||||
// in-guest login script needs the resolved wrapper root. Windows/WSL wrappers
|
||||
@@ -83,6 +84,9 @@ export function addOrcaWslInteropEnv(env: Record<string, string>): void {
|
||||
'ORCA_TAB_ID/u',
|
||||
'ORCA_WORKTREE_ID/u',
|
||||
'ORCA_AGENT_LAUNCH_TOKEN/u',
|
||||
// The guest plugin uses this marker to select the OpenCode variant that
|
||||
// owns the pane when both native and WSL installations are present.
|
||||
'ORCA_OPENCODE_AGENT/u',
|
||||
`${SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV}/u`,
|
||||
`${SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV}/u`,
|
||||
'ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND/u',
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { cleanup, render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useState } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ArtifactListItem } from '../../../../shared/artifacts'
|
||||
|
||||
@@ -37,12 +38,41 @@ function artifact(slug: string, title: string): ArtifactListItem {
|
||||
}
|
||||
}
|
||||
|
||||
/** Selection held in state, so whether a click selected is readable off the row's own wash. */
|
||||
function SelectingCollection({ items }: { items: ArtifactListItem[] }): React.JSX.Element {
|
||||
const [selectedSlug, setSelectedSlug] = useState<string | null>(null)
|
||||
return (
|
||||
<ArtifactCollection
|
||||
artifacts={items}
|
||||
deletingId={null}
|
||||
selectedSlug={selectedSlug}
|
||||
selectArtifact={setSelectedSlug}
|
||||
deleteArtifact={vi.fn()}
|
||||
hasMore={false}
|
||||
loadingMore={false}
|
||||
loadMore={vi.fn()}
|
||||
onRefresh={vi.fn()}
|
||||
isRefreshing={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// `hidden`, because a modal Radix menu marks the rest of the tree aria-hidden while it is open —
|
||||
// and the row behind that menu is exactly what these assertions have to read.
|
||||
function rowFor(title: string): HTMLElement {
|
||||
return screen.getByRole('button', { name: new RegExp(title), hidden: true })
|
||||
}
|
||||
|
||||
describe('ArtifactCollection', () => {
|
||||
afterEach(cleanup)
|
||||
// Why: the viewport stub spies on HTMLElement.prototype, so an unrestored one fabricates
|
||||
// layout for every later test in the run.
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
function renderCollection(
|
||||
items: ArtifactListItem[],
|
||||
selectArtifact = vi.fn()
|
||||
selectArtifact = vi.fn(),
|
||||
hasMore = false
|
||||
): { container: HTMLElement; selectArtifact: ReturnType<typeof vi.fn> } {
|
||||
const { container } = render(
|
||||
<TooltipProvider>
|
||||
@@ -52,7 +82,7 @@ describe('ArtifactCollection', () => {
|
||||
selectedSlug={items[0]?.artifact.slug ?? null}
|
||||
selectArtifact={selectArtifact}
|
||||
deleteArtifact={vi.fn()}
|
||||
hasMore={false}
|
||||
hasMore={hasMore}
|
||||
loadingMore={false}
|
||||
loadMore={vi.fn()}
|
||||
onRefresh={vi.fn()}
|
||||
@@ -111,6 +141,84 @@ describe('ArtifactCollection', () => {
|
||||
expect(screen.getByText('No matches')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
/**
|
||||
* Both of the row's menu escapes at once. The actions trigger is a DOM descendant of the row, and
|
||||
* Radix portals the open menu out of it but React still bubbles its clicks back through the row —
|
||||
* so without either guard the detail drawer opens behind every menu interaction.
|
||||
*/
|
||||
it('runs a row menu action without selecting the artifact behind the menu', async () => {
|
||||
const user = userEvent.setup()
|
||||
const items = [artifact('first', 'First artifact'), artifact('second', 'Second artifact')]
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<SelectingCollection items={items} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
|
||||
// Positive control: a click on the row body does select, so the negatives below are not vacuous.
|
||||
await user.click(rowFor('First artifact'))
|
||||
expect(rowFor('First artifact')).toHaveAttribute('data-current', 'true')
|
||||
|
||||
const trigger = within(rowFor('Second artifact')).getByRole('button', {
|
||||
name: 'Artifact actions'
|
||||
})
|
||||
await user.click(trigger)
|
||||
// Radix mounts menu content only while the menu is open, so a match here is the menu opening.
|
||||
expect(screen.getByRole('menuitem', { name: 'Copy link' })).toBeInTheDocument()
|
||||
expect(rowFor('Second artifact')).not.toHaveAttribute('data-current')
|
||||
|
||||
await user.click(screen.getByRole('menuitem', { name: 'Copy link' }))
|
||||
expect(rowFor('Second artifact')).not.toHaveAttribute('data-current')
|
||||
expect(rowFor('First artifact')).toHaveAttribute('data-current', 'true')
|
||||
})
|
||||
|
||||
// Why past 50: below the windowing threshold every row is in the DOM, so nothing has to be
|
||||
// announced — the set size only has to be right once the rows are windowed.
|
||||
const PAGED_ITEM_COUNT = 60
|
||||
|
||||
function pagedItems(): ArtifactListItem[] {
|
||||
return Array.from({ length: PAGED_ITEM_COUNT }, (_, index) =>
|
||||
artifact(`slug-${index}`, `Artifact ${index}`)
|
||||
)
|
||||
}
|
||||
|
||||
// Why: happy-dom has no layout, and the virtualizer sizes its window from the scroller's
|
||||
// offsetHeight — left at 0 it mounts no rows at all and the assertions below would be vacuous.
|
||||
function stubScrollerViewport(): void {
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(
|
||||
function (this: HTMLElement) {
|
||||
return this.classList.contains('overflow-auto') ? 600 : 53
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function announcedSetSizes(container: HTMLElement): string[] {
|
||||
const wrappers = Array.from(container.querySelectorAll('[data-index]'))
|
||||
expect(wrappers.length).toBeGreaterThan(0)
|
||||
// The window is a strict subset, so a set size read off the DOM could not reach the real total.
|
||||
expect(wrappers.length).toBeLessThan(PAGED_ITEM_COUNT)
|
||||
return wrappers.map((wrapper) => wrapper.getAttribute('aria-setsize') ?? 'missing')
|
||||
}
|
||||
|
||||
it('announces an unknown set size while a further page is loadable', () => {
|
||||
stubScrollerViewport()
|
||||
const { container } = renderCollection(pagedItems(), vi.fn(), true)
|
||||
|
||||
// The button is the contradiction: a concrete set size here would claim these are all of them.
|
||||
expect(screen.getByRole('button', { name: /Load more/ })).toBeInTheDocument()
|
||||
const sizes = announcedSetSizes(container)
|
||||
expect(sizes).toEqual(sizes.map(() => '-1'))
|
||||
})
|
||||
|
||||
it('announces the real row count once the cursor is exhausted', () => {
|
||||
stubScrollerViewport()
|
||||
const { container } = renderCollection(pagedItems())
|
||||
|
||||
expect(screen.queryByRole('button', { name: /Load more/ })).not.toBeInTheDocument()
|
||||
const sizes = announcedSetSizes(container)
|
||||
expect(sizes).toEqual(sizes.map(() => String(PAGED_ITEM_COUNT)))
|
||||
})
|
||||
|
||||
it('shows compact type, size, and expiry in the row', () => {
|
||||
const items = [artifact('first', 'First artifact')]
|
||||
renderCollection(items)
|
||||
|
||||
@@ -37,6 +37,8 @@ export function ArtifactCollection({
|
||||
// Why: clamp on the way in so a multi-MB paste never reaches state or filtering.
|
||||
const onQueryChange = (next: string): void => setQuery(clampArtifactListSearchQuery(next))
|
||||
const matches = useMemo(() => filterArtifactsBySearchQuery(artifacts, query), [artifacts, query])
|
||||
// Why: state, not a ref — the windowed rows need the scroller on their own mount pass.
|
||||
const [scrollElement, setScrollElement] = useState<HTMLDivElement | null>(null)
|
||||
|
||||
return (
|
||||
<section className="flex min-h-0 flex-1 flex-col overflow-hidden px-3 pb-4 md:px-5">
|
||||
@@ -48,19 +50,22 @@ export function ArtifactCollection({
|
||||
isRefreshing={isRefreshing}
|
||||
/>
|
||||
<div
|
||||
ref={setScrollElement}
|
||||
className={cn('scrollbar-sleek min-h-0 flex-1 overflow-auto', LIST_TABLE_CONTAINER_CLASS)}
|
||||
>
|
||||
<ArtifactListTableHeader />
|
||||
{/* Why no width wrapper around the rows: a `w-fit` band sizes to the full scroll width,
|
||||
painting every row's hover and selection wash past the viewport edge (376px -> 468px). */}
|
||||
{matches.length > 0 ? (
|
||||
<div className="divide-y divide-border/50">
|
||||
<ArtifactListRows
|
||||
artifacts={matches}
|
||||
deletingId={deletingId}
|
||||
selectedSlug={selectedSlug}
|
||||
selectArtifact={selectArtifact}
|
||||
deleteArtifact={deleteArtifact}
|
||||
/>
|
||||
</div>
|
||||
<ArtifactListRows
|
||||
artifacts={matches}
|
||||
deletingId={deletingId}
|
||||
selectedSlug={selectedSlug}
|
||||
scrollElement={scrollElement}
|
||||
hasMore={hasMore}
|
||||
selectArtifact={selectArtifact}
|
||||
deleteArtifact={deleteArtifact}
|
||||
/>
|
||||
) : (
|
||||
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
{translate('auto.components.artifacts.ArtifactCollection.noMatches', 'No matches')}
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { Fragment } from 'react'
|
||||
import { Copy, ExternalLink, MoreHorizontal, Trash2 } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { ArtifactListItem } from '../../../../shared/artifacts'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger
|
||||
} from '@/components/ui/context-menu'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { isPortaledRowMenuClick, isRowActivationKey } from '@/lib/list-row-interaction'
|
||||
import {
|
||||
artifactName,
|
||||
artifactTypeLabel,
|
||||
formatArtifactExpiryCompact,
|
||||
formatArtifactUpdatedCompact,
|
||||
formatByteSize
|
||||
} from './artifact-display-labels'
|
||||
import { copyArtifactLink, openArtifactInBrowser } from './artifact-link-actions'
|
||||
import { ARTIFACTS_TABLE_GRID_CLASS } from './artifacts-table-layout'
|
||||
import {
|
||||
LIST_TABLE_ROW_CLASS,
|
||||
LIST_TABLE_ROW_DIVIDER_CLASS,
|
||||
LIST_TABLE_ROW_SELECTED_CLASS
|
||||
} from '@/lib/list-table-layout'
|
||||
|
||||
type ArtifactRowAction = {
|
||||
key: string
|
||||
label: string
|
||||
icon: LucideIcon
|
||||
onSelect: () => void
|
||||
/** Rendered after a separator, styled as destructive. */
|
||||
destructive?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
// Why: the row dropdown and the row context menu must offer the same actions; one source keeps them from drifting.
|
||||
function artifactRowActions(
|
||||
item: ArtifactListItem,
|
||||
deleting: boolean,
|
||||
deleteArtifact: (item: ArtifactListItem) => void
|
||||
): readonly ArtifactRowAction[] {
|
||||
return [
|
||||
{
|
||||
key: 'copy',
|
||||
label: translate('auto.components.artifacts.copyLink', 'Copy link'),
|
||||
icon: Copy,
|
||||
onSelect: () => void copyArtifactLink(item.shareUrl)
|
||||
},
|
||||
{
|
||||
key: 'open',
|
||||
label: translate('auto.components.artifacts.openInBrowser', 'Open in browser'),
|
||||
icon: ExternalLink,
|
||||
onSelect: () => openArtifactInBrowser(item.shareUrl)
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: translate('auto.components.artifacts.ArtifactsPage.deleteArtifact', 'Delete artifact'),
|
||||
icon: Trash2,
|
||||
onSelect: () => deleteArtifact(item),
|
||||
destructive: true,
|
||||
disabled: deleting
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export function ArtifactListRow({
|
||||
item,
|
||||
deleting,
|
||||
isSelected,
|
||||
showDivider,
|
||||
selectArtifact,
|
||||
deleteArtifact
|
||||
}: {
|
||||
item: ArtifactListItem
|
||||
deleting: boolean
|
||||
isSelected: boolean
|
||||
/** False on the list-final row only: its bottom edge is the Load more block's top rule, or the
|
||||
* table container's own border. */
|
||||
showDivider: boolean
|
||||
selectArtifact: (slug: string) => void
|
||||
deleteArtifact: (item: ArtifactListItem) => void
|
||||
}): React.JSX.Element {
|
||||
const name = artifactName(item)
|
||||
const typeLabel = artifactTypeLabel(item)
|
||||
const updatedLabel = formatArtifactUpdatedCompact(item.artifact.updatedAt)
|
||||
const expiryLabel = formatArtifactExpiryCompact(item.artifact.expiresAt)
|
||||
const sizeLabel = formatByteSize(item.artifact.byteSize)
|
||||
const rowActions = artifactRowActions(item, deleting, deleteArtifact)
|
||||
|
||||
return (
|
||||
// Non-modal, so scrolling is never blocked: windowing unmounting an open menu mid-scroll reads as dismissal.
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-current={isSelected ? 'true' : undefined}
|
||||
onClick={(event) => {
|
||||
if (isPortaledRowMenuClick(event)) {
|
||||
return
|
||||
}
|
||||
selectArtifact(item.artifact.slug)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (!isRowActivationKey(event)) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
selectArtifact(item.artifact.slug)
|
||||
}}
|
||||
className={cn(
|
||||
ARTIFACTS_TABLE_GRID_CLASS,
|
||||
LIST_TABLE_ROW_CLASS,
|
||||
showDivider && LIST_TABLE_ROW_DIVIDER_CLASS,
|
||||
isSelected && LIST_TABLE_ROW_SELECTED_CLASS
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 truncate font-medium" title={name}>
|
||||
{name}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-muted-foreground" title={typeLabel}>
|
||||
{typeLabel}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-muted-foreground" title={sizeLabel}>
|
||||
{sizeLabel}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-muted-foreground" title={updatedLabel}>
|
||||
{updatedLabel}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-muted-foreground" title={expiryLabel}>
|
||||
{expiryLabel}
|
||||
</span>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="size-7 text-muted-foreground"
|
||||
aria-label={translate('auto.components.artifacts.actions', 'Artifact actions')}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{rowActions.map(({ key, label, icon: Icon, onSelect, destructive, disabled }) => (
|
||||
<Fragment key={key}>
|
||||
{destructive ? <DropdownMenuSeparator /> : null}
|
||||
<DropdownMenuItem
|
||||
variant={destructive ? 'destructive' : 'default'}
|
||||
disabled={disabled}
|
||||
onSelect={onSelect}
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
{label}
|
||||
</DropdownMenuItem>
|
||||
</Fragment>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-48">
|
||||
{rowActions.map(({ key, label, icon: Icon, onSelect, destructive, disabled }) => (
|
||||
<Fragment key={key}>
|
||||
{destructive ? <ContextMenuSeparator /> : null}
|
||||
<ContextMenuItem
|
||||
variant={destructive ? 'destructive' : 'default'}
|
||||
disabled={disabled}
|
||||
onSelect={onSelect}
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
{label}
|
||||
</ContextMenuItem>
|
||||
</Fragment>
|
||||
))}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
@@ -1,196 +1,58 @@
|
||||
import { Fragment } from 'react'
|
||||
import { Copy, ExternalLink, MoreHorizontal, Trash2 } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { VirtualizedList } from '@/components/virtualized-list'
|
||||
import type { ArtifactListItem } from '../../../../shared/artifacts'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger
|
||||
} from '@/components/ui/context-menu'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { isPortaledRowMenuClick, isRowActivationKey } from '@/lib/list-row-interaction'
|
||||
import {
|
||||
artifactName,
|
||||
artifactTypeLabel,
|
||||
formatArtifactExpiryCompact,
|
||||
formatArtifactUpdatedCompact,
|
||||
formatByteSize
|
||||
} from './artifact-display-labels'
|
||||
import { copyArtifactLink, openArtifactInBrowser } from './artifact-link-actions'
|
||||
import { ARTIFACTS_TABLE_GRID_CLASS } from './artifacts-table-layout'
|
||||
import { LIST_TABLE_ROW_CLASS, LIST_TABLE_ROW_SELECTED_CLASS } from '@/lib/list-table-layout'
|
||||
|
||||
type ArtifactRowAction = {
|
||||
key: string
|
||||
label: string
|
||||
icon: LucideIcon
|
||||
onSelect: () => void
|
||||
/** Rendered after a separator, styled as destructive. */
|
||||
destructive?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
// Why: the row dropdown and the row context menu must offer the same actions; one source keeps them from drifting.
|
||||
function artifactRowActions(
|
||||
item: ArtifactListItem,
|
||||
deleting: boolean,
|
||||
deleteArtifact: (item: ArtifactListItem) => void
|
||||
): readonly ArtifactRowAction[] {
|
||||
return [
|
||||
{
|
||||
key: 'copy',
|
||||
label: translate('auto.components.artifacts.copyLink', 'Copy link'),
|
||||
icon: Copy,
|
||||
onSelect: () => void copyArtifactLink(item.shareUrl)
|
||||
},
|
||||
{
|
||||
key: 'open',
|
||||
label: translate('auto.components.artifacts.openInBrowser', 'Open in browser'),
|
||||
icon: ExternalLink,
|
||||
onSelect: () => openArtifactInBrowser(item.shareUrl)
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: translate('auto.components.artifacts.ArtifactsPage.deleteArtifact', 'Delete artifact'),
|
||||
icon: Trash2,
|
||||
onSelect: () => deleteArtifact(item),
|
||||
destructive: true,
|
||||
disabled: deleting
|
||||
}
|
||||
]
|
||||
}
|
||||
import { ArtifactListRow } from './ArtifactListRow'
|
||||
import { ARTIFACTS_TABLE_ROW_HEIGHT_PX } from './artifacts-table-layout'
|
||||
|
||||
/**
|
||||
* The artifacts table body, windowed inside the collection's scroller. Load more only appends, so
|
||||
* between refreshes the list grows without bound; below the virtualize threshold rows stay in
|
||||
* natural flow.
|
||||
*/
|
||||
export function ArtifactListRows({
|
||||
artifacts,
|
||||
deletingId,
|
||||
selectedSlug,
|
||||
scrollElement,
|
||||
hasMore,
|
||||
selectArtifact,
|
||||
deleteArtifact
|
||||
}: {
|
||||
artifacts: readonly ArtifactListItem[]
|
||||
deletingId: string | null
|
||||
selectedSlug: string | null
|
||||
scrollElement: HTMLDivElement | null
|
||||
// Why it has to reach the rows: the cursor is what makes the loaded count not the real total, so
|
||||
// without it every row would announce a set size the Load more button next to it contradicts.
|
||||
hasMore: boolean
|
||||
selectArtifact: (slug: string) => void
|
||||
deleteArtifact: (item: ArtifactListItem) => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{artifacts.map((item) => {
|
||||
const name = artifactName(item)
|
||||
const typeLabel = artifactTypeLabel(item)
|
||||
const updatedLabel = formatArtifactUpdatedCompact(item.artifact.updatedAt)
|
||||
const expiryLabel = formatArtifactExpiryCompact(item.artifact.expiresAt)
|
||||
const sizeLabel = formatByteSize(item.artifact.byteSize)
|
||||
const isSelected = selectedSlug === item.artifact.slug
|
||||
const deleting = deletingId === item.artifact.slug
|
||||
const rowActions = artifactRowActions(item, deleting, deleteArtifact)
|
||||
// Why: appended pages are deduped against the slugs already loaded, and the first page's are
|
||||
// unique per the server, so a slug identifies the last row without an index — which `renderRow`
|
||||
// does not supply.
|
||||
const lastSlug = artifacts.at(-1)?.artifact.slug
|
||||
|
||||
return (
|
||||
<ContextMenu key={item.artifact.slug}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-current={isSelected ? 'true' : undefined}
|
||||
onClick={(event) => {
|
||||
if (isPortaledRowMenuClick(event)) {
|
||||
return
|
||||
}
|
||||
selectArtifact(item.artifact.slug)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (!isRowActivationKey(event)) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
selectArtifact(item.artifact.slug)
|
||||
}}
|
||||
className={cn(
|
||||
ARTIFACTS_TABLE_GRID_CLASS,
|
||||
LIST_TABLE_ROW_CLASS,
|
||||
isSelected && LIST_TABLE_ROW_SELECTED_CLASS
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 truncate font-medium" title={name}>
|
||||
{name}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-muted-foreground" title={typeLabel}>
|
||||
{typeLabel}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-muted-foreground" title={sizeLabel}>
|
||||
{sizeLabel}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-muted-foreground" title={updatedLabel}>
|
||||
{updatedLabel}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-muted-foreground" title={expiryLabel}>
|
||||
{expiryLabel}
|
||||
</span>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="size-7 text-muted-foreground"
|
||||
aria-label={translate(
|
||||
'auto.components.artifacts.actions',
|
||||
'Artifact actions'
|
||||
)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{rowActions.map(
|
||||
({ key, label, icon: Icon, onSelect, destructive, disabled }) => (
|
||||
<Fragment key={key}>
|
||||
{destructive ? <DropdownMenuSeparator /> : null}
|
||||
<DropdownMenuItem
|
||||
variant={destructive ? 'destructive' : 'default'}
|
||||
disabled={disabled}
|
||||
onSelect={onSelect}
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
{label}
|
||||
</DropdownMenuItem>
|
||||
</Fragment>
|
||||
)
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-48">
|
||||
{rowActions.map(({ key, label, icon: Icon, onSelect, destructive, disabled }) => (
|
||||
<Fragment key={key}>
|
||||
{destructive ? <ContextMenuSeparator /> : null}
|
||||
<ContextMenuItem
|
||||
variant={destructive ? 'destructive' : 'default'}
|
||||
disabled={disabled}
|
||||
onSelect={onSelect}
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
{label}
|
||||
</ContextMenuItem>
|
||||
</Fragment>
|
||||
))}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
// Accepted: rows are transform-positioned, so an insert above the viewport slides the list with
|
||||
// no layout shift for scroll anchoring to correct.
|
||||
return (
|
||||
<VirtualizedList
|
||||
rows={artifacts}
|
||||
scrollElement={scrollElement}
|
||||
estimateRowHeightPx={ARTIFACTS_TABLE_ROW_HEIGHT_PX}
|
||||
announceListPosition
|
||||
hasUnloadedRows={hasMore}
|
||||
getRowKey={(item) => item.artifact.slug}
|
||||
renderRow={(item) => (
|
||||
<ArtifactListRow
|
||||
key={item.artifact.slug}
|
||||
item={item}
|
||||
deleting={deletingId === item.artifact.slug}
|
||||
isSelected={selectedSlug === item.artifact.slug}
|
||||
showDivider={item.artifact.slug !== lastSlug}
|
||||
selectArtifact={selectArtifact}
|
||||
deleteArtifact={deleteArtifact}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,3 +2,8 @@
|
||||
// Name | Type | Size | Updated | Expires | Actions
|
||||
export const ARTIFACTS_TABLE_GRID_CLASS =
|
||||
'grid grid-cols-[minmax(0,1.6fr)_minmax(4.5rem,6.5rem)_minmax(4rem,5.5rem)_minmax(6.5rem,9rem)_minmax(6.5rem,9rem)_2.5rem]'
|
||||
|
||||
// Why: an `items-center px-3 py-3 text-sm` row whose tallest cell is the `size-7` actions button
|
||||
// (24px padding + 28px button), plus its own 1px divider. measureElement still corrects, but a
|
||||
// wrong estimate makes the virtualized list's scrollbar jump on first paint.
|
||||
export const ARTIFACTS_TABLE_ROW_HEIGHT_PX = 53
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
flattenSourceControlTree,
|
||||
type SourceControlTreeNode
|
||||
} from '@/components/right-sidebar/source-control-tree'
|
||||
import { SourceControlVirtualFileList } from '@/components/right-sidebar/source-control/listing/virtual-file-list'
|
||||
import { VirtualizedList } from '@/components/virtualized-list'
|
||||
import type { ConflictReviewEntry } from '@/store/slices/editor'
|
||||
import type { GitStatusEntry } from '../../../../shared/git-status-types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
@@ -103,7 +103,7 @@ export function ConflictReviewFileTree({
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<SourceControlVirtualFileList
|
||||
<VirtualizedList
|
||||
rows={rows}
|
||||
scrollElement={listScrollElement}
|
||||
estimateRowHeightPx={CONFLICT_REVIEW_ROW_HEIGHT_PX}
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import { SourceControlVirtualFileList } from '@/components/right-sidebar/source-control/listing/virtual-file-list'
|
||||
import { VirtualizedList } from '@/components/virtualized-list'
|
||||
import type {
|
||||
CombinedDiffFileTreeEntry,
|
||||
CombinedDiffFileTreeMode
|
||||
@@ -41,7 +41,7 @@ export function CombinedDiffFileTreeRows({
|
||||
onNavigate: (entry: CombinedDiffFileTreeEntry) => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<SourceControlVirtualFileList
|
||||
<VirtualizedList
|
||||
rows={rows}
|
||||
scrollElement={scrollElement}
|
||||
estimateRowHeightPx={COMBINED_DIFF_TREE_ROW_HEIGHT_PX}
|
||||
|
||||
+5
-5
@@ -3,7 +3,7 @@
|
||||
import React, { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SOURCE_CONTROL_VIRTUALIZE_MIN_ROWS } from '@/components/right-sidebar/source-control/listing/virtual-file-list'
|
||||
import { VIRTUALIZED_LIST_MIN_ROWS } from '@/components/virtualized-list'
|
||||
import type { GitBranchChangeEntry } from '../../../../../../shared/git-diff-compare-types'
|
||||
import type { CombinedDiffFileTreeRow as CombinedDiffFileTreeRowComponent } from './combined-diff-file-tree-row'
|
||||
|
||||
@@ -119,15 +119,15 @@ function renderTree(entries: readonly GitBranchChangeEntry[]): void {
|
||||
|
||||
describe('combined diff file tree row windowing', () => {
|
||||
it('mounts every row below the virtualize threshold', () => {
|
||||
const fileCount = SOURCE_CONTROL_VIRTUALIZE_MIN_ROWS - 10
|
||||
const fileCount = VIRTUALIZED_LIST_MIN_ROWS - 10
|
||||
const directoryCount = 4
|
||||
renderTree(buildEntries(fileCount, directoryCount))
|
||||
|
||||
// `src` plus one directory row per leaf directory, plus one row per file.
|
||||
const totalRows = 1 + directoryCount + fileCount
|
||||
expect(totalRows).toBeLessThan(SOURCE_CONTROL_VIRTUALIZE_MIN_ROWS)
|
||||
expect(totalRows).toBeLessThan(VIRTUALIZED_LIST_MIN_ROWS)
|
||||
expect(mountedRows.count).toBe(totalRows)
|
||||
expect(host.querySelector('[data-testid="source-control-virtual-list"]')).toBeNull()
|
||||
expect(host.querySelector('[data-testid="virtualized-list"]')).toBeNull()
|
||||
// Natural flow: no absolutely positioned wrappers, exactly the pre-virtualization markup.
|
||||
expect(host.querySelectorAll('[data-index]').length).toBe(0)
|
||||
})
|
||||
@@ -138,7 +138,7 @@ describe('combined diff file tree row windowing', () => {
|
||||
renderTree(buildEntries(fileCount, directoryCount))
|
||||
|
||||
const totalRows = 1 + directoryCount + fileCount
|
||||
expect(host.querySelector('[data-testid="source-control-virtual-list"]')).not.toBeNull()
|
||||
expect(host.querySelector('[data-testid="virtualized-list"]')).not.toBeNull()
|
||||
expect(mountedRows.count).toBeGreaterThan(0)
|
||||
// A 600px viewport plus overscan: bounded by the window, not by the review size.
|
||||
expect(mountedRows.count).toBeLessThan(totalRows / 10)
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
SOURCE_CONTROL_FILE_ROW_OVERSCAN,
|
||||
SOURCE_CONTROL_VIRTUALIZE_MIN_ROWS
|
||||
} from '@/components/right-sidebar/source-control/listing/virtual-file-list'
|
||||
import { VIRTUALIZED_LIST_MIN_ROWS, VIRTUALIZED_LIST_OVERSCAN } from '@/components/virtualized-list'
|
||||
import type {
|
||||
GitConflictKind,
|
||||
GitConflictResolutionStatus,
|
||||
@@ -25,7 +22,7 @@ const FILES_PER_DIRECTORY = MERGE_FILE_COUNT / MERGE_DIRECTORY_COUNT
|
||||
// happy-dom has no layout, so the viewport and every row height come from the `offsetHeight` and
|
||||
// `getBoundingClientRect` spies below; `offsetHeight` feeds both virtual-core's viewport rect and
|
||||
// `measureElement`, while `getBoundingClientRect` is read only by
|
||||
// `measureSourceControlScrollMargin`. The rect spy puts the scroller at `top: 0` and the virtual
|
||||
// `measureVirtualizedListScrollMargin`. The rect spy puts the scroller at `top: 0` and the virtual
|
||||
// list `MEASURED_SCROLL_MARGIN_PX` into its scrollable content, so the margin this consumer
|
||||
// measures is the real one rather than an accidental 0.
|
||||
const ROWS_PER_VIEWPORT = Math.floor(VIEWPORT_HEIGHT_PX / CONFLICT_REVIEW_ROW_HEIGHT_PX)
|
||||
@@ -80,11 +77,11 @@ afterEach(() => {
|
||||
/**
|
||||
* Synthetic layout top for the rect spy. Everything sits at 0 except the virtual list, which sits
|
||||
* `MEASURED_SCROLL_MARGIN_PX` into the scroller's content: the offset is taken against the
|
||||
* scroller's current `scrollTop`, which `measureSourceControlScrollMargin` adds back, so a
|
||||
* scroller's current `scrollTop`, which `measureVirtualizedListScrollMargin` adds back, so a
|
||||
* re-measure mid-scroll reports the same margin as the one at mount.
|
||||
*/
|
||||
function synthesizedTop(element: Element): number {
|
||||
if (!element.matches('[data-testid="source-control-virtual-list"]')) {
|
||||
if (!element.matches('[data-testid="virtualized-list"]')) {
|
||||
return 0
|
||||
}
|
||||
const scroller = element.closest('.overflow-auto')
|
||||
@@ -245,7 +242,7 @@ function expectWindowMatchesProjection(projection: readonly string[]): void {
|
||||
}
|
||||
|
||||
function getVirtualListContainer(): HTMLElement {
|
||||
const container = host.querySelector('[data-testid="source-control-virtual-list"]')
|
||||
const container = host.querySelector('[data-testid="virtualized-list"]')
|
||||
if (!(container instanceof HTMLElement)) {
|
||||
throw new Error('virtual list container not found')
|
||||
}
|
||||
@@ -255,7 +252,7 @@ function getVirtualListContainer(): HTMLElement {
|
||||
/**
|
||||
* The range virtual-core resolves: it ends at the first row whose end reaches or passes the
|
||||
* viewport bottom, so a partly visible last row is still in range; then
|
||||
* `SOURCE_CONTROL_FILE_ROW_OVERSCAN` extends both edges, clamped to the list. Two
|
||||
* `VIRTUALIZED_LIST_OVERSCAN` extends both edges, clamped to the list. Two
|
||||
* preconditions: the scroll offset must be row-aligned (a partially scrolled row makes the real
|
||||
* range one row longer), and `CONFLICT_REVIEW_ROW_HEIGHT_PX` must divide `VIEWPORT_HEIGHT_PX` — the
|
||||
* floor in `ROWS_PER_VIEWPORT` keeps the expectation a whole number, but a row height that leaves a
|
||||
@@ -265,10 +262,10 @@ function expectedWindow(
|
||||
startIndex: number,
|
||||
totalRows: number
|
||||
): { first: number; last: number; count: number } {
|
||||
const first = Math.max(0, startIndex - SOURCE_CONTROL_FILE_ROW_OVERSCAN)
|
||||
const first = Math.max(0, startIndex - VIRTUALIZED_LIST_OVERSCAN)
|
||||
const last = Math.min(
|
||||
totalRows - 1,
|
||||
startIndex + ROWS_PER_VIEWPORT - 1 + SOURCE_CONTROL_FILE_ROW_OVERSCAN
|
||||
startIndex + ROWS_PER_VIEWPORT - 1 + VIRTUALIZED_LIST_OVERSCAN
|
||||
)
|
||||
return { first, last, count: last - first + 1 }
|
||||
}
|
||||
@@ -322,9 +319,9 @@ describe('conflict review file tree row windowing', () => {
|
||||
|
||||
const projection = buildProjection(fileCount, directoryCount)
|
||||
expect(projection.length).toBe(1 + directoryCount + fileCount)
|
||||
expect(projection.length).toBeLessThan(SOURCE_CONTROL_VIRTUALIZE_MIN_ROWS)
|
||||
expect(projection.length).toBeLessThan(VIRTUALIZED_LIST_MIN_ROWS)
|
||||
expect(getMountedRows().map(getRowLabel)).toEqual(projection)
|
||||
expect(host.querySelector('[data-testid="source-control-virtual-list"]')).toBeNull()
|
||||
expect(host.querySelector('[data-testid="virtualized-list"]')).toBeNull()
|
||||
// Natural flow: no absolutely positioned wrappers, exactly the pre-virtualization markup.
|
||||
expect(host.querySelectorAll('[data-index]')).toHaveLength(0)
|
||||
})
|
||||
@@ -379,7 +376,7 @@ describe('conflict review file tree row windowing', () => {
|
||||
scroller.dispatchEvent(new Event('scroll'))
|
||||
})
|
||||
|
||||
expect(getMountedWindow()[0]?.index).toBe(250 - SOURCE_CONTROL_FILE_ROW_OVERSCAN - 1)
|
||||
expect(getMountedWindow()[0]?.index).toBe(250 - VIRTUALIZED_LIST_OVERSCAN - 1)
|
||||
expectWindowMatchesProjection(projection)
|
||||
})
|
||||
|
||||
@@ -476,7 +473,7 @@ describe('conflict review file tree row windowing', () => {
|
||||
const collapsed = buildProjection(MERGE_FILE_COUNT, MERGE_DIRECTORY_COUNT, new Set(['dir-04']))
|
||||
expect(collapsed.length).toBe(461)
|
||||
// Still far above the threshold, so the list cannot have fallen back to natural flow.
|
||||
expect(collapsed.length).toBeGreaterThanOrEqual(SOURCE_CONTROL_VIRTUALIZE_MIN_ROWS)
|
||||
expect(collapsed.length).toBeGreaterThanOrEqual(VIRTUALIZED_LIST_MIN_ROWS)
|
||||
expect(getVirtualListContainer().style.height).toBe(
|
||||
`${collapsed.length * CONFLICT_REVIEW_ROW_HEIGHT_PX}px`
|
||||
)
|
||||
@@ -594,10 +591,10 @@ describe('conflict review file tree row windowing', () => {
|
||||
|
||||
// Each duplicate is its own row, titled with its full path rather than the shared basename.
|
||||
const projection = ['src', directoryA, pathA, padA, directoryB, pathB, padB]
|
||||
expect(projection.length + 1).toBeLessThan(SOURCE_CONTROL_VIRTUALIZE_MIN_ROWS)
|
||||
expect(projection.length + 1).toBeLessThan(VIRTUALIZED_LIST_MIN_ROWS)
|
||||
expect(getMountedRows().map(getRowLabel)).toEqual(projection)
|
||||
// Natural flow has no keyed wrappers, so the row element's own key is all React reconciles by.
|
||||
expect(host.querySelector('[data-testid="source-control-virtual-list"]')).toBeNull()
|
||||
expect(host.querySelector('[data-testid="virtualized-list"]')).toBeNull()
|
||||
expect(host.querySelectorAll('[data-index]')).toHaveLength(0)
|
||||
|
||||
const beforeA = getRow(pathA)
|
||||
@@ -679,6 +676,6 @@ describe('conflict review file tree row windowing', () => {
|
||||
// guard above it the panel would be blank instead of explaining itself.
|
||||
expect(getScroller().textContent).toContain('No conflicts in this snapshot.')
|
||||
expect(getMountedRows()).toHaveLength(0)
|
||||
expect(host.querySelector('[data-testid="source-control-virtual-list"]')).toBeNull()
|
||||
expect(host.querySelector('[data-testid="virtualized-list"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
+14
-26
@@ -7,10 +7,10 @@ import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import type { GitStatusEntry } from '../../../../shared/git-status-types'
|
||||
import SourceControl from './SourceControl'
|
||||
import {
|
||||
SOURCE_CONTROL_FILE_ROW_HEIGHT_PX,
|
||||
SOURCE_CONTROL_FILE_ROW_OVERSCAN,
|
||||
SOURCE_CONTROL_VIRTUALIZE_MIN_ROWS
|
||||
} from './source-control/listing/virtual-file-list'
|
||||
VIRTUALIZED_LIST_MIN_ROWS,
|
||||
VIRTUALIZED_LIST_OVERSCAN,
|
||||
VIRTUALIZED_LIST_ROW_HEIGHT_PX
|
||||
} from '@/components/virtualized-list'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const activeRepo = {
|
||||
@@ -78,9 +78,7 @@ const VIEWPORT_HEIGHT_PX = 600
|
||||
// Rows the viewport can show plus overscan on both edges plus the partial
|
||||
// rows clipped at each edge of the window.
|
||||
const MAX_MOUNTED_ROWS =
|
||||
Math.ceil(VIEWPORT_HEIGHT_PX / SOURCE_CONTROL_FILE_ROW_HEIGHT_PX) +
|
||||
2 * SOURCE_CONTROL_FILE_ROW_OVERSCAN +
|
||||
2
|
||||
Math.ceil(VIEWPORT_HEIGHT_PX / VIRTUALIZED_LIST_ROW_HEIGHT_PX) + 2 * VIRTUALIZED_LIST_OVERSCAN + 2
|
||||
|
||||
function gitEntry(overrides: Partial<GitStatusEntry>): GitStatusEntry {
|
||||
return {
|
||||
@@ -209,7 +207,7 @@ beforeEach(() => {
|
||||
function (this: HTMLElement) {
|
||||
return this.classList.contains('overflow-auto')
|
||||
? VIEWPORT_HEIGHT_PX
|
||||
: SOURCE_CONTROL_FILE_ROW_HEIGHT_PX
|
||||
: VIRTUALIZED_LIST_ROW_HEIGHT_PX
|
||||
}
|
||||
)
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
|
||||
@@ -219,17 +217,7 @@ beforeEach(() => {
|
||||
const isScroller = this.classList.contains('overflow-auto')
|
||||
const scroller = isScroller ? null : this.closest('.overflow-auto')
|
||||
const top = isScroller ? 0 : -(scroller?.scrollTop ?? 0)
|
||||
return {
|
||||
top,
|
||||
bottom: top + SOURCE_CONTROL_FILE_ROW_HEIGHT_PX,
|
||||
height: SOURCE_CONTROL_FILE_ROW_HEIGHT_PX,
|
||||
left: 0,
|
||||
right: 240,
|
||||
width: 240,
|
||||
x: 0,
|
||||
y: top,
|
||||
toJSON: () => ({})
|
||||
} as DOMRect
|
||||
return new DOMRect(0, top, 240, VIRTUALIZED_LIST_ROW_HEIGHT_PX)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -285,7 +273,7 @@ function row(path: string): HTMLDivElement | null {
|
||||
}
|
||||
|
||||
function virtualList(): HTMLDivElement | null {
|
||||
return container.querySelector<HTMLDivElement>('[data-testid="source-control-virtual-list"]')
|
||||
return container.querySelector<HTMLDivElement>('[data-testid="virtualized-list"]')
|
||||
}
|
||||
|
||||
describe('SourceControl virtualized changed-files list', () => {
|
||||
@@ -310,7 +298,7 @@ describe('SourceControl virtualized changed-files list', () => {
|
||||
expect(row('src/file-000.ts')).toBeTruthy()
|
||||
expect(row('src/file-250.ts')).toBeNull()
|
||||
|
||||
scrollTo(250 * SOURCE_CONTROL_FILE_ROW_HEIGHT_PX)
|
||||
scrollTo(250 * VIRTUALIZED_LIST_ROW_HEIGHT_PX)
|
||||
|
||||
expect(row('src/file-250.ts')).toBeTruthy()
|
||||
expect(row('src/file-000.ts')).toBeNull()
|
||||
@@ -332,7 +320,7 @@ describe('SourceControl virtualized changed-files list', () => {
|
||||
expect(container.textContent).toContain('1 selected')
|
||||
|
||||
// The selected row scrolls out of the mounted window but stays selected.
|
||||
scrollTo(240 * SOURCE_CONTROL_FILE_ROW_HEIGHT_PX)
|
||||
scrollTo(240 * VIRTUALIZED_LIST_ROW_HEIGHT_PX)
|
||||
expect(row('src/file-000.ts')).toBeNull()
|
||||
expect(container.textContent).toContain('1 selected')
|
||||
|
||||
@@ -371,7 +359,7 @@ describe('SourceControl virtualized changed-files list', () => {
|
||||
})
|
||||
renderSourceControl()
|
||||
|
||||
scrollTo(100 * SOURCE_CONTROL_FILE_ROW_HEIGHT_PX)
|
||||
scrollTo(100 * VIRTUALIZED_LIST_ROW_HEIGHT_PX)
|
||||
expect(row('src/file-100.ts')).toBeTruthy()
|
||||
const heightBefore = virtualList()?.style.height
|
||||
|
||||
@@ -384,7 +372,7 @@ describe('SourceControl virtualized changed-files list', () => {
|
||||
}
|
||||
renderSourceControl()
|
||||
|
||||
expect(scroller().scrollTop).toBe(100 * SOURCE_CONTROL_FILE_ROW_HEIGHT_PX)
|
||||
expect(scroller().scrollTop).toBe(100 * VIRTUALIZED_LIST_ROW_HEIGHT_PX)
|
||||
expect(row('src/file-100.ts')).toBeTruthy()
|
||||
expect(virtualList()?.style.height).toBe(heightBefore)
|
||||
})
|
||||
@@ -398,7 +386,7 @@ describe('SourceControl virtualized changed-files list', () => {
|
||||
})
|
||||
renderSourceControl()
|
||||
|
||||
expect(paths.length).toBeLessThan(SOURCE_CONTROL_VIRTUALIZE_MIN_ROWS)
|
||||
expect(paths.length).toBeLessThan(VIRTUALIZED_LIST_MIN_ROWS)
|
||||
expect(virtualList()).toBeNull()
|
||||
expect(mountedRows().length).toBe(paths.length)
|
||||
for (const path of paths) {
|
||||
@@ -423,7 +411,7 @@ describe('SourceControl virtualized changed-files list', () => {
|
||||
|
||||
expect(virtualList()).toBeTruthy()
|
||||
const mounted = container.querySelectorAll(
|
||||
'[data-testid="source-control-virtual-list"] [data-index]'
|
||||
'[data-testid="virtualized-list"] [data-index]'
|
||||
).length
|
||||
expect(mounted).toBeGreaterThan(0)
|
||||
expect(mounted).toBeLessThanOrEqual(MAX_MOUNTED_ROWS)
|
||||
|
||||
@@ -12,7 +12,7 @@ import { BranchEntryRow } from './branch-entry-row'
|
||||
import { SectionHeader } from './section-header'
|
||||
import { formatSourceControlRefLabel } from '../panel/branch-context-stats'
|
||||
import { SourceControlBranchTreeDirectoryRow } from './tree-directory-rows'
|
||||
import { SourceControlVirtualFileList } from './virtual-file-list'
|
||||
import { VirtualizedList } from '../../../virtualized-list'
|
||||
|
||||
export function SourceControlBranchSection({
|
||||
branchSummary,
|
||||
@@ -106,7 +106,7 @@ export function SourceControlBranchSection({
|
||||
/>
|
||||
{!collapsedSections.has('branch') &&
|
||||
(sourceControlViewMode === 'tree' ? (
|
||||
<SourceControlVirtualFileList
|
||||
<VirtualizedList
|
||||
rows={visibleBranchTreeRows}
|
||||
scrollElement={fileListScrollElement}
|
||||
getRowKey={(node) => node.key}
|
||||
@@ -138,7 +138,7 @@ export function SourceControlBranchSection({
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<SourceControlVirtualFileList
|
||||
<VirtualizedList
|
||||
rows={filteredBranchEntries}
|
||||
scrollElement={fileListScrollElement}
|
||||
getRowKey={(entry) => `branch:${entry.path}`}
|
||||
|
||||
+3
-3
@@ -13,7 +13,7 @@ import { getSourceControlDirectoryActionPaths } from './directory-action-paths'
|
||||
import { SourceControlTreeDirectoryRow } from './tree-directory-rows'
|
||||
import { SubmodulePlaceholderRow } from './submodule-placeholder-row'
|
||||
import { UncommittedEntryRow } from './uncommitted-entry-row'
|
||||
import { SourceControlVirtualFileList } from './virtual-file-list'
|
||||
import { VirtualizedList } from '../../../virtualized-list'
|
||||
|
||||
export function SourceControlSectionFileList({
|
||||
sourceControlViewMode,
|
||||
@@ -71,7 +71,7 @@ export function SourceControlSectionFileList({
|
||||
diffCommentCountByPath: Map<string, number>
|
||||
}): React.JSX.Element {
|
||||
return sourceControlViewMode === 'tree' ? (
|
||||
<SourceControlVirtualFileList
|
||||
<VirtualizedList
|
||||
rows={treeRows}
|
||||
scrollElement={fileListScrollElement}
|
||||
getRowKey={(node) => node.key}
|
||||
@@ -134,7 +134,7 @@ export function SourceControlSectionFileList({
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<SourceControlVirtualFileList
|
||||
<VirtualizedList
|
||||
rows={listRows}
|
||||
scrollElement={fileListScrollElement}
|
||||
getRowKey={(row) =>
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { CodexUsageSummary } from '../../../../shared/codex-usage-types'
|
||||
import type { AppState } from '../../store'
|
||||
import { CodexUsagePane } from './CodexUsagePane'
|
||||
|
||||
const noop = vi.fn()
|
||||
|
||||
let currentSummary: CodexUsageSummary | null = null
|
||||
|
||||
const mockStoreState = {
|
||||
codexUsageScanState: {
|
||||
enabled: true,
|
||||
isScanning: false,
|
||||
lastScanStartedAt: 1,
|
||||
lastScanCompletedAt: 2,
|
||||
lastScanError: null,
|
||||
hasAnyCodexData: true
|
||||
},
|
||||
get codexUsageSummary() {
|
||||
return currentSummary
|
||||
},
|
||||
codexUsageDaily: [],
|
||||
codexUsageModelBreakdown: [],
|
||||
codexUsageProjectBreakdown: [],
|
||||
codexUsageRecentSessions: [],
|
||||
codexUsageScope: 'orca',
|
||||
codexUsageRange: '30d',
|
||||
fetchCodexUsage: noop,
|
||||
setCodexUsageEnabled: noop,
|
||||
refreshCodexUsage: noop,
|
||||
setCodexUsageScope: noop,
|
||||
setCodexUsageRange: noop,
|
||||
recordFeatureInteraction: noop
|
||||
} satisfies Partial<AppState>
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
useAppStore: (selector: (state: Partial<AppState>) => unknown) => selector(mockStoreState)
|
||||
}))
|
||||
|
||||
vi.mock('./CodexUsageDetails', () => ({
|
||||
CodexUsageDetails: () => <div>details</div>
|
||||
}))
|
||||
|
||||
vi.mock('./ShareUsageButton', () => ({
|
||||
ShareUsageButton: () => <button type="button">Share</button>
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (_key: string, fallback: string) => fallback
|
||||
}))
|
||||
|
||||
function summaryWithUnpriced(
|
||||
hasUnpricedModels: boolean,
|
||||
estimatedCostUsd: number | null = 12.5
|
||||
): CodexUsageSummary {
|
||||
return {
|
||||
scope: 'orca',
|
||||
range: '30d',
|
||||
sessions: 1,
|
||||
events: 1,
|
||||
inputTokens: 1000,
|
||||
cachedInputTokens: 400,
|
||||
outputTokens: 250,
|
||||
reasoningOutputTokens: 100,
|
||||
totalTokens: 1250,
|
||||
estimatedCostUsd,
|
||||
hasUnpricedModels,
|
||||
topModel: 'gpt-6-astra',
|
||||
topProject: 'Repo',
|
||||
hasAnyCodexData: true
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
currentSummary = null
|
||||
cleanup()
|
||||
})
|
||||
|
||||
describe('CodexUsagePane estimated cost card', () => {
|
||||
it('qualifies the total when a named model has no pricing entry', () => {
|
||||
currentSummary = summaryWithUnpriced(true)
|
||||
|
||||
render(<CodexUsagePane />)
|
||||
|
||||
expect(
|
||||
screen.getByText('Est. API-equivalent cost • excludes unpriced models')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('$12.50')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('drops the caveat when no model was priced, since there is no remainder to exclude', () => {
|
||||
currentSummary = summaryWithUnpriced(true, null)
|
||||
|
||||
render(<CodexUsagePane />)
|
||||
|
||||
expect(screen.getByText('Est. API-equivalent cost')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/excludes unpriced models/)).not.toBeInTheDocument()
|
||||
expect(screen.getByText('n/a')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('leaves the total unqualified when every model is priced', () => {
|
||||
currentSummary = summaryWithUnpriced(false)
|
||||
|
||||
render(<CodexUsagePane />)
|
||||
|
||||
expect(screen.getByText('Est. API-equivalent cost')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/excludes unpriced models/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -97,6 +97,15 @@ export function CodexUsagePane(): React.JSX.Element {
|
||||
}
|
||||
|
||||
const hasAnyData = summary?.hasAnyCodexData ?? scanState.hasAnyCodexData
|
||||
const costLabel = translate(
|
||||
'auto.components.stats.CodexUsagePane.1a18fbd56b',
|
||||
'Est. API-equivalent cost'
|
||||
)
|
||||
// "Excludes" promises a remainder, so it only qualifies a total that exists.
|
||||
const costCardLabel =
|
||||
summary?.hasUnpricedModels && summary.estimatedCostUsd !== null
|
||||
? `${costLabel} ${translate('auto.components.stats.CodexUsagePane.costExcludesUnpricedModels', '• excludes unpriced models')}`
|
||||
: costLabel
|
||||
|
||||
return (
|
||||
<UsageTrackingPaneShell
|
||||
@@ -191,10 +200,7 @@ export function CodexUsagePane(): React.JSX.Element {
|
||||
icon={<FolderKanban className="size-4" />}
|
||||
/>
|
||||
<StatCard
|
||||
label={translate(
|
||||
'auto.components.stats.CodexUsagePane.1a18fbd56b',
|
||||
'Est. API-equivalent cost'
|
||||
)}
|
||||
label={costCardLabel}
|
||||
value={formatCost(summary?.estimatedCostUsd ?? null)}
|
||||
icon={<Coins className="size-4" />}
|
||||
/>
|
||||
|
||||
@@ -79,6 +79,7 @@ describe('usage overview model', () => {
|
||||
reasoningOutputTokens: 300,
|
||||
totalTokens: 3_200,
|
||||
estimatedCostUsd: 0.02,
|
||||
hasUnpricedModels: false,
|
||||
topModel: 'gpt-5.4',
|
||||
topProject: 'orca-secondary',
|
||||
hasAnyCodexData: true
|
||||
@@ -191,6 +192,37 @@ describe('usage overview model', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('marks the overview cost partial when Codex priced some models but not all', () => {
|
||||
function overviewWithUnpricedCodex(hasUnpricedModels: boolean) {
|
||||
const codexSummary: CodexUsageSummary = {
|
||||
scope: 'orca',
|
||||
range: '30d',
|
||||
sessions: 1,
|
||||
events: 3,
|
||||
inputTokens: 2_000,
|
||||
cachedInputTokens: 800,
|
||||
outputTokens: 1_200,
|
||||
reasoningOutputTokens: 300,
|
||||
totalTokens: 3_200,
|
||||
estimatedCostUsd: 0.02,
|
||||
hasUnpricedModels,
|
||||
topModel: 'gpt-6-astra',
|
||||
topProject: 'orca-secondary',
|
||||
hasAnyCodexData: true
|
||||
}
|
||||
return buildUsageOverview({
|
||||
claude: { scanState: null, summary: null, daily: [] },
|
||||
codex: { scanState: enabledCodexScanState(), summary: codexSummary, daily: [] },
|
||||
opencode: { scanState: null, summary: null, daily: [] }
|
||||
})
|
||||
}
|
||||
|
||||
// The provider's own total is a real number, so the null-cost path never fires for it.
|
||||
expect(overviewWithUnpricedCodex(true).estimatedCostUsd).toBeCloseTo(0.02)
|
||||
expect(overviewWithUnpricedCodex(true).hasPartialCost).toBe(true)
|
||||
expect(overviewWithUnpricedCodex(false).hasPartialCost).toBe(false)
|
||||
})
|
||||
|
||||
it('pads recent usage days with zero-token cells', () => {
|
||||
const recent = getRecentUsageDays(
|
||||
[
|
||||
|
||||
@@ -34,7 +34,8 @@ export function buildUsageOverview(input: UsageOverviewInput): UsageOverviewMode
|
||||
const knownCost = providers.reduce((sum, provider) => sum + (provider.estimatedCostUsd ?? 0), 0)
|
||||
const hasKnownCost = providers.some((provider) => provider.estimatedCostUsd !== null)
|
||||
const hasPartialCost = providers.some(
|
||||
(provider) => provider.hasData && provider.estimatedCostUsd === null
|
||||
(provider) =>
|
||||
provider.hasPartialCost || (provider.hasData && provider.estimatedCostUsd === null)
|
||||
)
|
||||
const lastUpdatedAt =
|
||||
providers.reduce<number | null>(
|
||||
|
||||
@@ -33,6 +33,8 @@ export type UsageProviderOverview = {
|
||||
cacheTokens: number
|
||||
reasoningTokens: number
|
||||
estimatedCostUsd: number | null
|
||||
/** The provider priced some of its tokens but not all, so `estimatedCostUsd` understates the bill. */
|
||||
hasPartialCost: boolean
|
||||
topModel: string | null
|
||||
topProject: string | null
|
||||
activeDays: number
|
||||
|
||||
@@ -45,6 +45,7 @@ export function createClaudeProvider(input: UsageOverviewInput['claude']): Usage
|
||||
cacheTokens: summary ? summary.cacheReadTokens + summary.cacheWriteTokens : 0,
|
||||
reasoningTokens: 0,
|
||||
estimatedCostUsd: summary?.estimatedCostUsd ?? null,
|
||||
hasPartialCost: false,
|
||||
topModel: summary?.topModel ?? null,
|
||||
topProject: summary?.topProject ?? null,
|
||||
activeDays: countActiveDays(dailyActiveDays)
|
||||
@@ -73,6 +74,7 @@ export function createCodexProvider(input: UsageOverviewInput['codex']): UsagePr
|
||||
cacheTokens: summary?.cachedInputTokens ?? 0,
|
||||
reasoningTokens: summary?.reasoningOutputTokens ?? 0,
|
||||
estimatedCostUsd: summary?.estimatedCostUsd ?? null,
|
||||
hasPartialCost: summary?.hasUnpricedModels ?? false,
|
||||
topModel: summary?.topModel ?? null,
|
||||
topProject: summary?.topProject ?? null,
|
||||
activeDays: countActiveDays(dailyActiveDays)
|
||||
@@ -103,6 +105,7 @@ export function createOpenCodeProvider(
|
||||
cacheTokens: summary?.cachedInputTokens ?? 0,
|
||||
reasoningTokens: summary?.reasoningOutputTokens ?? 0,
|
||||
estimatedCostUsd: summary?.estimatedCostUsd ?? null,
|
||||
hasPartialCost: false,
|
||||
topModel: summary?.topModel ?? null,
|
||||
topProject: summary?.topProject ?? null,
|
||||
activeDays: countActiveDays(dailyActiveDays)
|
||||
|
||||
+122
-50
@@ -4,12 +4,12 @@ import { act, useState, type ReactElement } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
measureSourceControlScrollMargin,
|
||||
observeSourceControlScrollMargin,
|
||||
SOURCE_CONTROL_FILE_ROW_HEIGHT_PX,
|
||||
SOURCE_CONTROL_VIRTUALIZE_MIN_ROWS,
|
||||
SourceControlVirtualFileList
|
||||
} from './source-control/listing/virtual-file-list'
|
||||
measureVirtualizedListScrollMargin,
|
||||
observeVirtualizedListScrollMargin,
|
||||
VIRTUALIZED_LIST_MIN_ROWS,
|
||||
VIRTUALIZED_LIST_ROW_HEIGHT_PX,
|
||||
VirtualizedList
|
||||
} from './virtualized-list'
|
||||
|
||||
const VIEWPORT_HEIGHT_PX = 600
|
||||
|
||||
@@ -18,12 +18,7 @@ type ResizeObserverBoxSize = {
|
||||
inlineSize: number
|
||||
}
|
||||
|
||||
type TrackedResizeObserver = {
|
||||
callback: ResizeObserverCallback
|
||||
elements: Set<Element>
|
||||
}
|
||||
|
||||
const activeResizeObservers = new Set<TrackedResizeObserver>()
|
||||
const activeResizeObservers = new Set<MockResizeObserver>()
|
||||
|
||||
class MockResizeObserver implements ResizeObserver {
|
||||
readonly elements = new Set<Element>()
|
||||
@@ -72,7 +67,7 @@ function fireResizeObservers(target?: Element): void {
|
||||
devicePixelContentBoxSize: [size]
|
||||
} satisfies ResizeObserverEntry
|
||||
})
|
||||
observer.callback(entries, observer as unknown as ResizeObserver)
|
||||
observer.callback(entries, observer)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,25 +93,15 @@ beforeEach(() => {
|
||||
function (this: HTMLElement) {
|
||||
return this.classList.contains('overflow-auto')
|
||||
? VIEWPORT_HEIGHT_PX
|
||||
: SOURCE_CONTROL_FILE_ROW_HEIGHT_PX
|
||||
: VIRTUALIZED_LIST_ROW_HEIGHT_PX
|
||||
}
|
||||
)
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
|
||||
const top = topsByElement.get(this) ?? 0
|
||||
const height = this.classList.contains('overflow-auto')
|
||||
? VIEWPORT_HEIGHT_PX
|
||||
: SOURCE_CONTROL_FILE_ROW_HEIGHT_PX
|
||||
return {
|
||||
top,
|
||||
bottom: top + height,
|
||||
height,
|
||||
left: 0,
|
||||
right: 240,
|
||||
width: 240,
|
||||
x: 0,
|
||||
y: top,
|
||||
toJSON: () => ({})
|
||||
} as DOMRect
|
||||
: VIRTUALIZED_LIST_ROW_HEIGHT_PX
|
||||
return new DOMRect(0, top, 240, height)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -135,11 +120,15 @@ function setTop(element: Element, top: number): void {
|
||||
function SharedScrollerHarness({
|
||||
aboveHeight,
|
||||
rows,
|
||||
rowTestId = 'virtual-row'
|
||||
rowTestId = 'virtual-row',
|
||||
announceListPosition,
|
||||
hasUnloadedRows
|
||||
}: {
|
||||
aboveHeight: number
|
||||
rows: readonly string[]
|
||||
rowTestId?: string
|
||||
announceListPosition?: boolean
|
||||
hasUnloadedRows?: boolean
|
||||
}): ReactElement {
|
||||
const [scroller, setScroller] = useState<HTMLDivElement | null>(null)
|
||||
|
||||
@@ -177,9 +166,11 @@ function SharedScrollerHarness({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SourceControlVirtualFileList
|
||||
<VirtualizedList
|
||||
rows={rows}
|
||||
scrollElement={scroller}
|
||||
announceListPosition={announceListPosition}
|
||||
hasUnloadedRows={hasUnloadedRows}
|
||||
getRowKey={(row) => row}
|
||||
renderRow={(row) => (
|
||||
<div key={row} data-testid={rowTestId}>
|
||||
@@ -217,7 +208,7 @@ function MultiSectionHarness({
|
||||
}}
|
||||
>
|
||||
<div data-testid="first-section">
|
||||
<SourceControlVirtualFileList
|
||||
<VirtualizedList
|
||||
rows={firstRows}
|
||||
scrollElement={scroller}
|
||||
getRowKey={(row) => row}
|
||||
@@ -225,7 +216,7 @@ function MultiSectionHarness({
|
||||
/>
|
||||
</div>
|
||||
<div data-testid="second-section">
|
||||
<SourceControlVirtualFileList
|
||||
<VirtualizedList
|
||||
rows={secondRows}
|
||||
scrollElement={scroller}
|
||||
getRowKey={(row) => row}
|
||||
@@ -237,7 +228,7 @@ function MultiSectionHarness({
|
||||
}
|
||||
|
||||
function syncListTop(aboveHeight: number): HTMLDivElement | null {
|
||||
const list = host.querySelector<HTMLDivElement>('[data-testid="source-control-virtual-list"]')
|
||||
const list = host.querySelector<HTMLDivElement>('[data-testid="virtualized-list"]')
|
||||
if (list) {
|
||||
setTop(list, aboveHeight)
|
||||
}
|
||||
@@ -249,16 +240,16 @@ function syncListTop(aboveHeight: number): HTMLDivElement | null {
|
||||
}
|
||||
|
||||
function syncMultiSectionListTops(firstRowCount: number): void {
|
||||
const lists = host.querySelectorAll<HTMLElement>('[data-testid="source-control-virtual-list"]')
|
||||
const lists = host.querySelectorAll<HTMLElement>('[data-testid="virtualized-list"]')
|
||||
if (lists[0]) {
|
||||
setTop(lists[0], 0)
|
||||
}
|
||||
if (lists[1]) {
|
||||
setTop(lists[1], firstRowCount * SOURCE_CONTROL_FILE_ROW_HEIGHT_PX)
|
||||
setTop(lists[1], firstRowCount * VIRTUALIZED_LIST_ROW_HEIGHT_PX)
|
||||
}
|
||||
}
|
||||
|
||||
describe('measureSourceControlScrollMargin', () => {
|
||||
describe('measureVirtualizedListScrollMargin', () => {
|
||||
it('returns the list offset inside the scroller independent of scrollTop', () => {
|
||||
const scroller = document.createElement('div')
|
||||
const list = document.createElement('div')
|
||||
@@ -266,16 +257,16 @@ describe('measureSourceControlScrollMargin', () => {
|
||||
setTop(list, 250)
|
||||
Object.defineProperty(scroller, 'scrollTop', { configurable: true, value: 40 })
|
||||
|
||||
expect(measureSourceControlScrollMargin(list, scroller)).toBe(190)
|
||||
expect(measureVirtualizedListScrollMargin(list, scroller)).toBe(190)
|
||||
|
||||
Object.defineProperty(scroller, 'scrollTop', { configurable: true, value: 120 })
|
||||
setTop(list, 170)
|
||||
// list.top dropped by the same amount scrollTop rose → margin unchanged.
|
||||
expect(measureSourceControlScrollMargin(list, scroller)).toBe(190)
|
||||
expect(measureVirtualizedListScrollMargin(list, scroller)).toBe(190)
|
||||
})
|
||||
})
|
||||
|
||||
describe('observeSourceControlScrollMargin', () => {
|
||||
describe('observeVirtualizedListScrollMargin', () => {
|
||||
it('notifies on resize and disconnects cleanly', () => {
|
||||
const scroller = document.createElement('div')
|
||||
const child = document.createElement('div')
|
||||
@@ -283,7 +274,7 @@ describe('observeSourceControlScrollMargin', () => {
|
||||
scroller.append(child, list)
|
||||
const onLayout = vi.fn()
|
||||
|
||||
const cleanup = observeSourceControlScrollMargin(list, scroller, onLayout)
|
||||
const cleanup = observeVirtualizedListScrollMargin(list, scroller, onLayout)
|
||||
expect(activeResizeObservers.size).toBe(1)
|
||||
|
||||
fireResizeObservers()
|
||||
@@ -304,7 +295,7 @@ describe('observeSourceControlScrollMargin', () => {
|
||||
scroller.append(removedSibling, list)
|
||||
const onLayout = vi.fn()
|
||||
|
||||
const cleanup = observeSourceControlScrollMargin(list, scroller, onLayout)
|
||||
const cleanup = observeVirtualizedListScrollMargin(list, scroller, onLayout)
|
||||
const observer = Array.from(activeResizeObservers)[0]
|
||||
expect(observer?.elements.has(removedSibling)).toBe(true)
|
||||
onLayout.mockClear()
|
||||
@@ -323,10 +314,10 @@ describe('observeSourceControlScrollMargin', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('SourceControlVirtualFileList scroll-margin lifecycle', () => {
|
||||
describe('VirtualizedList scroll-margin lifecycle', () => {
|
||||
it('does not read layout during ordinary re-renders after the initial measure', () => {
|
||||
const aboveHeight = 160
|
||||
const baseRows = manyRows(SOURCE_CONTROL_VIRTUALIZE_MIN_ROWS)
|
||||
const baseRows = manyRows(VIRTUALIZED_LIST_MIN_ROWS)
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
@@ -353,7 +344,7 @@ describe('SourceControlVirtualFileList scroll-margin lifecycle', () => {
|
||||
})
|
||||
|
||||
it('keeps multi-section windowing correct when content above resizes', () => {
|
||||
const rows = manyRows(SOURCE_CONTROL_VIRTUALIZE_MIN_ROWS + 20)
|
||||
const rows = manyRows(VIRTUALIZED_LIST_MIN_ROWS + 20)
|
||||
let aboveHeight = 200
|
||||
|
||||
act(() => {
|
||||
@@ -407,10 +398,8 @@ describe('SourceControlVirtualFileList scroll-margin lifecycle', () => {
|
||||
})
|
||||
|
||||
it('updates a later virtual section when an earlier virtual section resizes', () => {
|
||||
let firstRows = manyRows(SOURCE_CONTROL_VIRTUALIZE_MIN_ROWS + 10).map((row) => `first-${row}`)
|
||||
const secondRows = manyRows(SOURCE_CONTROL_VIRTUALIZE_MIN_ROWS + 20).map(
|
||||
(row) => `second-${row}`
|
||||
)
|
||||
let firstRows = manyRows(VIRTUALIZED_LIST_MIN_ROWS + 10).map((row) => `first-${row}`)
|
||||
const secondRows = manyRows(VIRTUALIZED_LIST_MIN_ROWS + 20).map((row) => `second-${row}`)
|
||||
|
||||
act(() => {
|
||||
root.render(<MultiSectionHarness firstRows={firstRows} secondRows={secondRows} />)
|
||||
@@ -418,7 +407,7 @@ describe('SourceControlVirtualFileList scroll-margin lifecycle', () => {
|
||||
syncMultiSectionListTops(firstRows.length)
|
||||
act(() => fireResizeObservers())
|
||||
|
||||
firstRows = manyRows(SOURCE_CONTROL_VIRTUALIZE_MIN_ROWS + 80).map((row) => `first-${row}`)
|
||||
firstRows = manyRows(VIRTUALIZED_LIST_MIN_ROWS + 80).map((row) => `first-${row}`)
|
||||
act(() => {
|
||||
root.render(<MultiSectionHarness firstRows={firstRows} secondRows={secondRows} />)
|
||||
})
|
||||
@@ -440,7 +429,7 @@ describe('SourceControlVirtualFileList scroll-margin lifecycle', () => {
|
||||
Object.defineProperty(scroller, 'scrollTop', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: firstRows.length * SOURCE_CONTROL_FILE_ROW_HEIGHT_PX
|
||||
value: firstRows.length * VIRTUALIZED_LIST_ROW_HEIGHT_PX
|
||||
})
|
||||
act(() => scroller.dispatchEvent(new Event('scroll')))
|
||||
|
||||
@@ -452,14 +441,97 @@ describe('SourceControlVirtualFileList scroll-margin lifecycle', () => {
|
||||
|
||||
it('renders small lists without the virtualization shell or observers', () => {
|
||||
const rows = ['a', 'b', 'c']
|
||||
expect(rows.length).toBeLessThan(SOURCE_CONTROL_VIRTUALIZE_MIN_ROWS)
|
||||
expect(rows.length).toBeLessThan(VIRTUALIZED_LIST_MIN_ROWS)
|
||||
|
||||
act(() => {
|
||||
root.render(<SharedScrollerHarness aboveHeight={80} rows={rows} rowTestId="plain" />)
|
||||
})
|
||||
|
||||
expect(host.querySelector('[data-testid="source-control-virtual-list"]')).toBeNull()
|
||||
expect(host.querySelector('[data-testid="virtualized-list"]')).toBeNull()
|
||||
expect(host.querySelectorAll('[data-testid="plain"]').length).toBe(3)
|
||||
expect(activeResizeObservers.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// Why not 500 and not a window's worth: a hard-coded set size, or one that happens to equal the
|
||||
// mounted count, would pass at those lengths.
|
||||
const ANNOUNCED_ROW_COUNT = VIRTUALIZED_LIST_MIN_ROWS + 23
|
||||
|
||||
describe('VirtualizedList list-position announcement', () => {
|
||||
function renderAnnouncing(options: { hasUnloadedRows?: boolean } = {}): HTMLElement[] {
|
||||
act(() => {
|
||||
root.render(
|
||||
<SharedScrollerHarness
|
||||
aboveHeight={0}
|
||||
rows={manyRows(ANNOUNCED_ROW_COUNT)}
|
||||
announceListPosition
|
||||
hasUnloadedRows={options.hasUnloadedRows}
|
||||
/>
|
||||
)
|
||||
})
|
||||
syncListTop(0)
|
||||
act(() => {
|
||||
fireResizeObservers()
|
||||
})
|
||||
return Array.from(host.querySelectorAll<HTMLElement>('[data-index]'))
|
||||
}
|
||||
|
||||
it('announces the full row count once every row is loaded', () => {
|
||||
const wrappers = renderAnnouncing()
|
||||
|
||||
expect(wrappers.length).toBeGreaterThan(0)
|
||||
// The window is a strict subset, so a set size read off the DOM could not reach the real total.
|
||||
expect(wrappers.length).toBeLessThan(ANNOUNCED_ROW_COUNT)
|
||||
expect(host.querySelector('[data-testid="virtualized-list"]')?.getAttribute('role')).toBe(
|
||||
'list'
|
||||
)
|
||||
expect(
|
||||
wrappers.map((wrapper) => [
|
||||
wrapper.getAttribute('role'),
|
||||
wrapper.getAttribute('aria-setsize'),
|
||||
wrapper.getAttribute('aria-posinset')
|
||||
])
|
||||
).toEqual(
|
||||
wrappers.map((wrapper) => [
|
||||
'listitem',
|
||||
String(ANNOUNCED_ROW_COUNT),
|
||||
String(Number(wrapper.dataset.index) + 1)
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('announces an unknown set size while more rows are still unloaded', () => {
|
||||
const wrappers = renderAnnouncing({ hasUnloadedRows: true })
|
||||
|
||||
expect(wrappers.length).toBeGreaterThan(0)
|
||||
// -1, never the loaded count: a "Load more" control exists precisely because more rows do.
|
||||
expect(wrappers.map((wrapper) => wrapper.getAttribute('aria-setsize'))).toEqual(
|
||||
wrappers.map(() => '-1')
|
||||
)
|
||||
// Position is still real — only the total is unknown.
|
||||
expect(wrappers.map((wrapper) => wrapper.getAttribute('aria-posinset'))).toEqual(
|
||||
wrappers.map((wrapper) => String(Number(wrapper.dataset.index) + 1))
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves the a11y tree untouched for callers that do not opt in', () => {
|
||||
act(() => {
|
||||
root.render(<SharedScrollerHarness aboveHeight={0} rows={manyRows(ANNOUNCED_ROW_COUNT)} />)
|
||||
})
|
||||
syncListTop(0)
|
||||
act(() => {
|
||||
fireResizeObservers()
|
||||
})
|
||||
|
||||
const wrappers = Array.from(host.querySelectorAll<HTMLElement>('[data-index]'))
|
||||
expect(wrappers.length).toBeGreaterThan(0)
|
||||
expect(host.querySelector('[data-testid="virtualized-list"]')?.hasAttribute('role')).toBe(false)
|
||||
for (const wrapper of wrappers) {
|
||||
expect([
|
||||
wrapper.hasAttribute('role'),
|
||||
wrapper.hasAttribute('aria-setsize'),
|
||||
wrapper.hasAttribute('aria-posinset')
|
||||
]).toEqual([false, false, false])
|
||||
}
|
||||
})
|
||||
})
|
||||
+43
-30
@@ -1,23 +1,16 @@
|
||||
import { useLayoutEffect, useRef, useState } from 'react'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
|
||||
// Why: below this count plain rows keep the DOM identical to the
|
||||
// pre-virtualization markup (natural flow, no absolute positioning), so small
|
||||
// changesets keep exact scrollbar and flicker-free behavior. `STA-351` /
|
||||
// `STA-1280` jank only appears with hundreds of rows.
|
||||
export const SOURCE_CONTROL_VIRTUALIZE_MIN_ROWS = 50
|
||||
// Why: rows are one py-1 text-xs line, except conflict/submodule rows which
|
||||
// add a second label line — so estimate the common height and let
|
||||
// measureElement correct the tall variants. Identical entries measure
|
||||
// identically, so a git-status refresh cannot move the scroll position.
|
||||
export const SOURCE_CONTROL_FILE_ROW_HEIGHT_PX = 24
|
||||
export const SOURCE_CONTROL_FILE_ROW_OVERSCAN = 10
|
||||
// Small lists stay in natural flow; windowing pays off only at larger sizes.
|
||||
export const VIRTUALIZED_LIST_MIN_ROWS = 50
|
||||
export const VIRTUALIZED_LIST_ROW_HEIGHT_PX = 24
|
||||
export const VIRTUALIZED_LIST_OVERSCAN = 10
|
||||
|
||||
/**
|
||||
* Offset of `container` from the start of `scrollElement`'s scrollable content.
|
||||
* Independent of current scrollTop (relative tops + scrollTop cancel out).
|
||||
*/
|
||||
export function measureSourceControlScrollMargin(
|
||||
export function measureVirtualizedListScrollMargin(
|
||||
container: HTMLElement,
|
||||
scrollElement: HTMLElement
|
||||
): number {
|
||||
@@ -34,7 +27,7 @@ export function measureSourceControlScrollMargin(
|
||||
* Does not observe subtree mutations — virtualized row mount/unmount would
|
||||
* thrash and never change scroll margin.
|
||||
*/
|
||||
export function observeSourceControlScrollMargin(
|
||||
export function observeVirtualizedListScrollMargin(
|
||||
container: HTMLElement,
|
||||
scrollElement: HTMLElement,
|
||||
onLayout: () => void
|
||||
@@ -46,7 +39,7 @@ export function observeSourceControlScrollMargin(
|
||||
|
||||
const observeScrollerChildren = (): void => {
|
||||
const currentChildren = new Set(scrollElement.children)
|
||||
// Why: child-list churn can detach previously observed sections; pruning
|
||||
// Why: child-list churn can detach previously observed siblings; pruning
|
||||
// targets prevents the long-lived virtual list from retaining stale DOM.
|
||||
for (const child of observedChildren) {
|
||||
if (!currentChildren.has(child) && child !== container) {
|
||||
@@ -60,7 +53,7 @@ export function observeSourceControlScrollMargin(
|
||||
}
|
||||
observeScrollerChildren()
|
||||
|
||||
// Why: sections mount/unmount as direct scroller children; re-observe so a
|
||||
// Why: list siblings mount/unmount as direct scroller children; re-observe so a
|
||||
// newly inserted sibling can still shift this list's margin when it resizes.
|
||||
const mutationObserver = new MutationObserver(() => {
|
||||
observeScrollerChildren()
|
||||
@@ -75,16 +68,17 @@ export function observeSourceControlScrollMargin(
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows one source-control section's rows inside the panel's shared
|
||||
* scroller. Sections below SOURCE_CONTROL_VIRTUALIZE_MIN_ROWS render plainly;
|
||||
* larger ones mount only viewport + overscan rows.
|
||||
* Renders rows in natural flow until the list is large enough to benefit from
|
||||
* windowing, then mounts only the viewport plus overscan rows.
|
||||
*/
|
||||
export function SourceControlVirtualFileList<TRow>({
|
||||
export function VirtualizedList<TRow>({
|
||||
rows,
|
||||
getRowKey,
|
||||
renderRow,
|
||||
scrollElement,
|
||||
estimateRowHeightPx = SOURCE_CONTROL_FILE_ROW_HEIGHT_PX
|
||||
estimateRowHeightPx = VIRTUALIZED_LIST_ROW_HEIGHT_PX,
|
||||
announceListPosition = false,
|
||||
hasUnloadedRows
|
||||
}: {
|
||||
rows: readonly TRow[]
|
||||
getRowKey: (row: TRow) => string
|
||||
@@ -96,16 +90,23 @@ export function SourceControlVirtualFileList<TRow>({
|
||||
// Why: callers outside source control have their own row paddings; measureElement
|
||||
// still corrects, but a wrong estimate makes the initial scrollbar jump.
|
||||
estimateRowHeightPx?: number
|
||||
// Why: windowing hides the real row count and each row's place in it from assistive tech, so
|
||||
// opt in to say both. Virtualized path only — below the threshold this returns a bare fragment,
|
||||
// with no container to carry the roles, so small lists announce no list at all. Off by default:
|
||||
// turning it on rewrites every other caller's a11y tree, and list/tree/grid is each surface's call.
|
||||
announceListPosition?: boolean
|
||||
// Why: a caller that pages in rows holds fewer than exist, and announcing the loaded count as the
|
||||
// total is the mis-statement aria-setsize exists to prevent — ARIA spells that unknown total -1.
|
||||
hasUnloadedRows?: boolean
|
||||
}): React.JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [scrollMargin, setScrollMargin] = useState(0)
|
||||
const virtualize = rows.length >= SOURCE_CONTROL_VIRTUALIZE_MIN_ROWS
|
||||
const virtualize = rows.length >= VIRTUALIZED_LIST_MIN_ROWS
|
||||
|
||||
// Why: the section shares the panel scroller with the commit area and
|
||||
// sibling sections, so the virtualizer needs this list's offset inside that
|
||||
// scroller. Measure on mount / scrollElement attach and when observers
|
||||
// report layout shifts — never during ordinary React renders (git-status
|
||||
// polls re-render often and must not force synchronous layout).
|
||||
// Why: the list shares its scroller with headers and sibling lists, so the
|
||||
// virtualizer needs this list's offset inside that scroller. Measure on mount
|
||||
// / scrollElement attach and when observers report layout shifts — never during
|
||||
// ordinary React renders.
|
||||
useLayoutEffect(() => {
|
||||
if (!virtualize) {
|
||||
return
|
||||
@@ -116,21 +117,26 @@ export function SourceControlVirtualFileList<TRow>({
|
||||
}
|
||||
|
||||
const updateMargin = (): void => {
|
||||
const nextMargin = measureSourceControlScrollMargin(container, scrollElement)
|
||||
const nextMargin = measureVirtualizedListScrollMargin(container, scrollElement)
|
||||
setScrollMargin((current) => (current === nextMargin ? current : nextMargin))
|
||||
}
|
||||
|
||||
updateMargin()
|
||||
return observeSourceControlScrollMargin(container, scrollElement, updateMargin)
|
||||
return observeVirtualizedListScrollMargin(container, scrollElement, updateMargin)
|
||||
}, [scrollElement, virtualize])
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
// Why the null half: disabled nulls scrollOffset, so initialOffset() cannot latch 0 pre-attach.
|
||||
enabled: virtualize && scrollElement !== null,
|
||||
getScrollElement: () => scrollElement,
|
||||
estimateSize: () => estimateRowHeightPx,
|
||||
overscan: SOURCE_CONTROL_FILE_ROW_OVERSCAN,
|
||||
overscan: VIRTUALIZED_LIST_OVERSCAN,
|
||||
scrollMargin,
|
||||
// Why: crossing the threshold attaches the scroller for the first time and the virtualizer
|
||||
// writes its start offset back — from 0, scrolling the shared scroller to the top under the
|
||||
// user, unless told where they already are.
|
||||
initialOffset: () => scrollElement?.scrollTop ?? 0,
|
||||
// Why: stable row keys let the virtualizer carry item identity across
|
||||
// status refreshes instead of remounting the window each poll.
|
||||
getItemKey: (index) => {
|
||||
@@ -139,6 +145,9 @@ export function SourceControlVirtualFileList<TRow>({
|
||||
}
|
||||
})
|
||||
|
||||
// Why not the window's count: the window is what AT must not be able to hear.
|
||||
const announcedSetSize = hasUnloadedRows ? -1 : rows.length
|
||||
|
||||
if (!virtualize) {
|
||||
return <>{rows.map((row) => renderRow(row))}</>
|
||||
}
|
||||
@@ -146,7 +155,8 @@ export function SourceControlVirtualFileList<TRow>({
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-testid="source-control-virtual-list"
|
||||
data-testid="virtualized-list"
|
||||
role={announceListPosition ? 'list' : undefined}
|
||||
className="relative w-full"
|
||||
style={{ height: virtualizer.getTotalSize() }}
|
||||
>
|
||||
@@ -160,6 +170,9 @@ export function SourceControlVirtualFileList<TRow>({
|
||||
key={item.key}
|
||||
ref={virtualizer.measureElement}
|
||||
data-index={item.index}
|
||||
role={announceListPosition ? 'listitem' : undefined}
|
||||
aria-setsize={announceListPosition ? announcedSetSize : undefined}
|
||||
aria-posinset={announceListPosition ? item.index + 1 : undefined}
|
||||
className="absolute top-0 left-0 w-full"
|
||||
// Why: item.start includes scrollMargin (offsets are scroller-wide),
|
||||
// but rows position inside this container, so subtract it back out.
|
||||
@@ -4184,7 +4184,8 @@
|
||||
"rangeLast7Days": "Last 7 days",
|
||||
"rangeLast30Days": "Last 30 days",
|
||||
"rangeLast90Days": "Last 90 days",
|
||||
"rangeAllTime": "All time"
|
||||
"rangeAllTime": "All time",
|
||||
"costExcludesUnpricedModels": "• excludes unpriced models"
|
||||
},
|
||||
"OpenCodeUsagePane": {
|
||||
"349f7c3f5c": "Total",
|
||||
|
||||
@@ -26,3 +26,9 @@ export const LIST_TABLE_STICKY_HEADER_CELL_CLASS = `${LIST_TABLE_STICKY_CELL_BAS
|
||||
|
||||
// Why: hover/selection ride variants, not props, so the frozen cell tracks the row's own wash.
|
||||
export const LIST_TABLE_STICKY_ROW_CELL_CLASS = `${LIST_TABLE_STICKY_CELL_BASE_CLASS} z-20 bg-[color-mix(in_srgb,var(--muted)_20%,var(--background))] transition-colors group-hover/list-table-row:bg-accent group-data-[current=true]/list-table-row:bg-accent`
|
||||
|
||||
// Why: virtualized rows are absolutely positioned, so a parent `divide-y` would only ever see the
|
||||
// single virtual container; each row draws its own separator instead. Bottom edge, like `divide-y`:
|
||||
// the semi-transparent rule composites over the row it belongs to, so a selected row keeps its
|
||||
// accent wash under its own hairline rather than tinting the one above it.
|
||||
export const LIST_TABLE_ROW_DIVIDER_CLASS = 'border-b border-border/50'
|
||||
|
||||
@@ -56,6 +56,7 @@ function createSummary(totalTokens: number): CodexUsageSummary {
|
||||
reasoningOutputTokens: 0,
|
||||
totalTokens,
|
||||
estimatedCostUsd: 1,
|
||||
hasUnpricedModels: false,
|
||||
topModel: 'gpt-5',
|
||||
topProject: 'orca',
|
||||
hasAnyCodexData: true
|
||||
|
||||
@@ -22,6 +22,8 @@ export type CodexUsageSummary = {
|
||||
reasoningOutputTokens: number
|
||||
totalTokens: number
|
||||
estimatedCostUsd: number | null
|
||||
/** A row carried a model name that `MODEL_PRICING` has no entry for, so its tokens are missing from `estimatedCostUsd`. */
|
||||
hasUnpricedModels: boolean
|
||||
topModel: string | null
|
||||
topProject: string | null
|
||||
hasAnyCodexData: boolean
|
||||
|
||||
Reference in New Issue
Block a user