Merge remote-tracking branch 'origin/main' into brennanb2025/publish-submission-positions

This commit is contained in:
Brennan Benson
2026-10-04 14:24:13 -07:00
708 changed files with 37385 additions and 4451 deletions
@@ -0,0 +1,84 @@
// A current client's `agentSession.send`, built by the outbox clients send from, against a real
// published release. The release's schema must take it and its admission must re-derive the
// fingerprint the client declared: a send it rejects, or digests differently and refuses as an
// operation conflict, loses the user's message.
import { beforeAll, describe, expect, it } from 'vitest'
import { resolveBaselineReleaseRef } from './release-checkout'
import { installableHost, structuredHostStub } from './structured-agent-session-host-fixture'
import {
resetOperationIds,
sendParams,
SESSION,
WORKSPACE
} from './structured-agent-session-surface-manifest'
import {
loadAgentSessionWireBuild,
WORKING_TREE,
type AgentSessionWireBuild,
type RpcReply,
type SentMessage
} from './versioned-agent-session-wire'
// Why: a cold CI run extracts the baseline checkout before the first pairing.
const SUITE_TIMEOUT_MS = 180_000
let current: AgentSessionWireBuild
let baseline: AgentSessionWireBuild
beforeAll(async () => {
current = await loadAgentSessionWireBuild(WORKING_TREE)
baseline = await loadAgentSessionWireBuild(resolveBaselineReleaseRef())
}, SUITE_TIMEOUT_MS)
async function send(build: AgentSessionWireBuild, params: unknown): Promise<RpcReply[]> {
const replies: RpcReply[] = []
await build
// Releases before the setting gate was dropped admit structured calls only with it on.
.createDispatcher({
getRuntimeId: () => 'runtime-1',
getClientSettings: () => ({ experimentalStructuredNativeChat: true })
})
.dispatchStreaming(
{ id: 'request-send', authToken: 'cross-version-token', method: 'agentSession.send', params },
(raw) => replies.push(JSON.parse(raw)),
{ clientKind: 'runtime', clientCapabilities: current.capabilities }
)
return replies
}
describe('a current client sending a message', () => {
it.each([
['a plain message', undefined],
// `delivery` joins the operation fingerprint, so a builder that drops it is refused by a host
// that digests it.
['a message held as a draft while a turn runs', 'queue-if-active' as const]
])('is accepted for %s, under the fingerprint each build re-derives', async (_case, delivery) => {
// Anti-vacuous: the release must have the method, or there is no older host to send to.
expect(baseline.methodNames).toContain('agentSession.send')
for (const build of [current, baseline]) {
resetOperationIds()
const hostCalls = structuredHostStub(SESSION, WORKSPACE)
await build.installStructuredHost(installableHost(hostCalls))
try {
const replies = await send(build, sendParams('hi', 1, delivery))
expect(replies, `${build.label}: ${JSON.stringify(replies)}`).toMatchObject([{ ok: true }])
expect(hostCalls.send, `${build.label}: the send reached the host`).toHaveBeenCalledTimes(1)
const sent: SentMessage = hostCalls.send.mock.calls[0]?.[1]
expect(sent.delivery, `${build.label}: delivery reached the host`).toBe(delivery)
// Past the fingerprint check, admission stops at the journal this harness never opens.
expect(await build.admitSend(sent), `${build.label}: send admission`).toMatchObject({
ok: false,
refusal: { details: { reason: 'sessionNotAttached' } }
})
// Negative control: proves the fingerprint check ran before admission reached the journal.
const wrong = { ...sent.envelope, payloadFingerprint: '0'.repeat(64) }
expect(await build.admitSend({ ...sent, envelope: wrong }), build.label).toMatchObject({
refusal: { details: { reason: 'fingerprintMismatch' } }
})
} finally {
await build.installStructuredHost(null)
}
}
})
})
@@ -180,7 +180,7 @@ test.each(PRE_QODER_HOSTS)(
expect(
await client.searchSessions({ query: 'proof', filters: { agents: ['codex'] }, within })
).toMatchObject({ hits: [{ agent: 'codex' }] })
expect(call).toHaveBeenCalledTimes(1)
expect(call).toHaveBeenCalledTimes(2)
}
)
@@ -0,0 +1,205 @@
import { createSessionSearchService } from '../../../src/main/ai-vault-search/session-search-service'
import {
addSyntheticSession,
openSessionSearchHarness
} from '../../../src/main/ai-vault-search/session-search-engine-test-fixture'
import { afterEach, expect, test, vi } from 'vitest'
import {
createSessionSearchClient,
unavailableSessionSearchStatus
} from '../../../src/shared/ai-vault-search-client'
import { AI_VAULT_AGENTS } from '../../../src/shared/ai-vault-types'
import {
searchHit,
searchResults,
fakeSearchService
} from '../../../src/shared/ai-vault-search-test-fixture'
import {
searchSessionService,
sessionSearchServiceStatus,
setSessionSearchService
} from '../../../src/main/ai-vault-search/session-search-service-registry'
import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout'
const LEGACY_REF = 'b49abdb1f4da6b3d62dfa9ccf3c74dc9e74d291c'
afterEach(() => setSessionSearchService(null))
test('the actual pre-Jcode client reads current host pages while current peers retain every provider', async () => {
const baseline = await importReleaseCheckoutModule(
await materializeReleaseCheckout(LEGACY_REF),
'src/shared/ai-vault-search-contract.ts'
)
const oldResponse = baseline.AiVaultSearchResponseSchema
if (
!oldResponse ||
typeof oldResponse !== 'object' ||
!('safeParse' in oldResponse) ||
typeof oldResponse.safeParse !== 'function'
) {
throw new Error('Pinned release has no response parser')
}
const hits = (['codex', 'qoder', 'jcode'] as const).map((agent) => ({ ...searchHit(), agent }))
expect(oldResponse.safeParse({ ...searchResults(), hits: [hits[2]] })).toHaveProperty(
'success',
false
)
const service = fakeSearchService()
service.search.mockImplementation(async (request) => ({
...searchResults(),
hits: hits.filter(
(hit) => !request.filters?.agents?.length || request.filters.agents.includes(hit.agent)
)
}))
setSessionSearchService(service)
const legacy = await searchSessionService({ query: 'proof' }, 'relay')
expect(oldResponse.safeParse(legacy)).toHaveProperty('success', true)
expect(legacy).toMatchObject({ hits: [{ agent: 'codex' }] })
const client = createSessionSearchClient(
(method, params) =>
method === 'aiVault.searchStatus'
? sessionSearchServiceStatus(params, 'relay')
: searchSessionService(params, 'relay'),
'relay'
)
expect(await client.searchSessions({ query: 'proof' })).toMatchObject({
hits: [{ agent: 'codex' }, { agent: 'qoder' }, { agent: 'jcode' }]
})
expect(await sessionSearchServiceStatus({}, 'relay')).toMatchObject({
supportedAgents: [...AI_VAULT_AGENTS]
})
})
test.each(['runtime', 'relay'] as const)(
'a new %s client refuses sole unsupported Jcode without widening the query',
async (transport) => {
const call = vi.fn(async () => unavailableSessionSearchStatus())
expect(
await createSessionSearchClient(call, transport).searchSessions({
query: 'proof',
filters: { agents: ['jcode'] }
})
).toEqual({ kind: 'unavailable', reason: 'unsupported-agent' })
expect(call).toHaveBeenCalledExactlyOnceWith('aiVault.searchStatus', {})
}
)
test('agent-list negotiation takes precedence over the old flag and tolerates future advertised names', async () => {
const within = { kind: 'workspace' as const, worktreeId: 'folder:/task-owned/folder' }
const call = vi.fn(async (method: string) =>
method === 'aiVault.searchStatus'
? {
...unavailableSessionSearchStatus(),
supportsQoderHistory: true,
supportedAgents: ['codex', 'jcode', 'future-agent']
}
: searchResults()
)
await createSessionSearchClient(call, 'runtime').searchSessions({
query: 'proof',
cursor: 'page-2',
within,
filters: { agents: ['codex', 'qoder', 'jcode'], since: '2026-08-01T00:00:00Z', sort: 'newest' }
})
expect(call).toHaveBeenLastCalledWith(
'aiVault.searchSessions',
expect.objectContaining({
cursor: 'page-2',
within,
filters: { agents: ['codex', 'jcode'], since: '2026-08-01T00:00:00Z', sort: 'newest' },
supportedAgents: [...AI_VAULT_AGENTS]
})
)
})
test('a current host honors a negotiated client subset before index search and keeps scope identity', async () => {
const service = fakeSearchService()
setSessionSearchService(service)
await searchSessionService(
{
query: 'proof',
supportsQoderHistory: true,
supportedAgents: ['codex', 'future-agent'],
filters: { agents: ['codex', 'qoder', 'jcode'], scopePaths: ['/task-owned'] }
},
'runtime'
)
expect(service.search).toHaveBeenLastCalledWith(
{ query: 'proof', limit: 20, filters: { agents: ['codex'], scopePaths: ['/task-owned'] } },
undefined
)
})
test.each([{ supportedAgents: [] }, { supportedAgents: ['future-agent'] }])(
'a client with no shared advertised agents returns no indexed rows for %j',
async ({ supportedAgents }) => {
const harness = await openSessionSearchHarness('ss-empty-peer-agents')
try {
addSyntheticSession(harness.db, { id: 1, agent: 'codex' })
addSyntheticSession(harness.db, { id: 2, agent: 'jcode' })
const retrieve = vi.spyOn(harness.engine, 'search')
const service = createSessionSearchService({
engine: harness.engine,
indexer: { status: unavailableSessionSearchStatus, reconcile: async () => {} }
})
setSessionSearchService(service)
const reply = await searchSessionService({ query: 'needle', supportedAgents }, 'runtime')
expect(reply).toMatchObject({ hits: [], page: { cursor: null, hasMore: false } })
expect(retrieve).toHaveBeenCalledExactlyOnceWith({
query: 'needle',
limit: 20,
filters: { scopePaths: [''] }
})
expect(retrieve.mock.results[0]).toMatchObject({
type: 'return',
value: { hits: [], page: { cursor: null, hasMore: false } }
})
} finally {
setSessionSearchService(null)
await harness.close()
}
}
)
test.each(['v1.4.211', LEGACY_REF])(
'a missing status method still searches through the actual %s request parser',
async (ref) => {
const baseline = await importReleaseCheckoutModule(
await materializeReleaseCheckout(ref),
'src/shared/ai-vault-search-contract.ts'
)
const parser = baseline.AiVaultSearchRequestSchema
if (
!parser ||
typeof parser !== 'object' ||
!('parse' in parser) ||
typeof parser.parse !== 'function'
) {
throw new Error('Pinned release has no request parser')
}
const call = vi.fn(async (method: string, request: Record<string, unknown>) => {
if (method === 'aiVault.searchStatus') {
throw { code: 'method_not_found' }
}
parser.parse(request)
expect(request).toMatchObject({
filters: {
agents: AI_VAULT_AGENTS.filter(
(agent) => !['codebuddy', 'zcode', 'qoder', 'jcode'].includes(agent)
),
scopePaths: ['/execution-host/folder']
}
})
return searchResults()
})
expect(
await createSessionSearchClient(call, 'relay').searchSessions({
query: 'needle',
filters: { agents: [...AI_VAULT_AGENTS], scopePaths: ['/execution-host/folder'] }
})
).toMatchObject({ kind: 'results' })
expect(call.mock.calls.map(([method]) => method)).toEqual([
'aiVault.searchStatus',
'aiVault.searchSessions'
])
}
)
@@ -11,6 +11,10 @@
import { attachFingerprintFields } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-attach'
import type { AgentSessionAttachParams } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-attach'
import { computeAgentSessionPayloadFingerprint } from '../../../src/shared/agent-session-mutation-envelope'
import {
createStructuredAgentSessionOutboxEntry,
structuredAgentSessionSendRequest
} from '../../../src/shared/structured-agent-session-outbox'
export const SESSION = 'session-alpha'
export const WORKSPACE = 'workspace-1'
@@ -237,9 +241,21 @@ export function createIntentParams(): Record<string, unknown> {
return { envelope: envelope({ method: 'agentSession.create', fields, fence: null }), ...fields }
}
export function sendParams(text: string, fence: number): Record<string, unknown> {
const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] }
return { envelope: envelope({ method: 'agentSession.send', fields: { body }, fence }), body }
/** Built by the outbox clients send from, so an older host is handed exactly what a current
* client puts on the wire, fingerprint included. */
export function sendParams(
text: string,
fence: number,
sentDelivery?: 'queue-if-active'
): Record<string, unknown> {
const entry = createStructuredAgentSessionOutboxEntry({
clientMessageId: operationId(),
sessionId: SESSION,
text,
attachments: [],
queuedAt: NOW
})
return structuredAgentSessionSendRequest({ ...entry, sentDelivery }, fence)
}
/** Schema-valid params per method; values only need to survive validation. */
@@ -1,3 +1,4 @@
import type { sendPlan } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans'
import {
importReleaseCheckoutModule,
materializeReleaseCheckout,
@@ -17,6 +18,10 @@ export const WORKING_TREE = 'working-tree' as const
* in current source is invisible to a release checkout's dispatcher. */
const STRUCTURED_HOST_REGISTRY =
'/src/main/native-chat/agent-session-wire/structured-agent-session-registry.ts'
const MUTATION_PLANS =
'/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts'
const MUTATION_ADMISSION =
'/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts'
export type RpcReply = {
id: string
@@ -60,6 +65,10 @@ export type AgentSessionWireBuild = {
* the surface stays loadable, and throws rather than no-opping so a build with
* no slot cannot read as a surface that answered. */
installStructuredHost: (host: unknown) => Promise<void>
/** This build's own admission of the `agentSession.send` params its host was handed. With no
* journal it stops after the fingerprint check: a fingerprint it derives differently refuses
* as `fingerprintMismatch`, one it agrees with as `sessionNotAttached`. */
admitSend: (sent: SentMessage) => Promise<unknown>
}
type DispatcherModule = {
@@ -86,6 +95,32 @@ function applyStructuredHost(module: Record<string, unknown>, label: string, hos
;(install as (next: unknown) => void)(host)
}
/** The `agentSession.send` params a host is handed. */
export type SentMessage = Parameters<typeof sendPlan>[0]
type SendAdmissionModules = {
sendPlan: (sent: SentMessage) => unknown
admitAndRunAgentSessionMutation: (request: {
plan: unknown
envelope: SentMessage['envelope']
journal: () => undefined
}) => Promise<unknown>
}
async function admitSend(
plans: Record<string, unknown>,
admission: Record<string, unknown>,
sent: SentMessage
): Promise<unknown> {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the build's own send plan and admission; with no `prepareSession` it reads only plan, envelope and journal. A drifted export or shape fails this check.
const build = { ...plans, ...admission } as unknown as SendAdmissionModules
return build.admitAndRunAgentSessionMutation({
plan: build.sendPlan(sent),
envelope: sent.envelope,
journal: () => undefined
})
}
function capabilityStrings(module: Record<string, unknown>): readonly string[] {
const declared = module.RUNTIME_CAPABILITIES
if (!Array.isArray(declared) || declared.length === 0) {
@@ -117,6 +152,13 @@ async function loadWorkingTreeBuild(): Promise<AgentSessionWireBuild> {
const registry =
await import('../../../src/main/native-chat/agent-session-wire/structured-agent-session-registry')
applyStructuredHost(registry as unknown as Record<string, unknown>, WORKING_TREE, host)
},
admitSend: async (sent) => {
const [plans, admission] = await Promise.all([
import('../../../src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans'),
import('../../../src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission')
])
return admitSend(plans, admission, sent)
}
}
}
@@ -146,6 +188,13 @@ async function loadReleaseBuild(checkout: ReleaseCheckout): Promise<AgentSession
checkout.ref,
host
)
},
admitSend: async (sent) => {
const [plans, admission] = await Promise.all([
importReleaseCheckoutModule(checkout, MUTATION_PLANS),
importReleaseCheckoutModule(checkout, MUTATION_ADMISSION)
])
return admitSend(plans, admission, sent)
}
}
}
@@ -0,0 +1,63 @@
import { test, expect } from './helpers/orca-app'
import type { Editor } from '@tiptap/core'
import {
cleanupMarkdownFixture,
createMarkdownFixture,
getActiveWorktreeContext,
openMarkdownFixture,
waitForRichMarkdownEditor
} from './helpers/markdown-editor-fixture'
import { waitForSessionReady, waitForActiveWorktree } from './helpers/store'
type PageRichMarkdownLinkEditorElement = HTMLElement & {
editor?: Editor
}
test('collapses a selection beside a document link without rewriting the link', async ({
orcaPage,
registerPostElectronShutdownCleanup
}, testInfo) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
const context = await getActiveWorktreeContext(orcaPage)
const filePath = await createMarkdownFixture(
context,
'.orca-e2e-markdown-links',
'selection',
testInfo.workerIndex,
'[[Guide]]after\n\nSelecting text beside a document link should keep the link intact.'
)
registerPostElectronShutdownCleanup(() => cleanupMarkdownFixture(filePath))
await openMarkdownFixture(orcaPage, context, filePath)
const editor = await waitForRichMarkdownEditor(orcaPage)
await expect(editor.locator('[data-doc-link-target="Guide"]')).toHaveCount(1)
await orcaPage.evaluate(() => {
const editorElement =
document.querySelector<PageRichMarkdownLinkEditorElement>('.rich-markdown-editor')
const instance = editorElement?.editor
if (!editorElement || !instance) {
throw new Error('Editor unavailable')
}
editorElement.focus()
if (!instance.commands.setTextSelection({ from: 2, to: 7 })) {
throw new Error('Selection unavailable')
}
})
await expect.poll(() => orcaPage.evaluate(() => window.getSelection()?.toString())).toBe('after')
await expect
.poll(() =>
orcaPage.evaluate(() => {
const selection =
document.querySelector<PageRichMarkdownLinkEditorElement>('.rich-markdown-editor')?.editor
?.state.selection
return selection ? { from: selection.from, to: selection.to, empty: selection.empty } : null
})
)
.toEqual({ from: 2, to: 7, empty: false })
await expect(editor).toBeFocused()
await orcaPage.keyboard.press('ArrowLeft')
await expect(editor.locator('[data-doc-link-target="Guide"]')).toHaveCount(1)
await expect(editor.locator('p').first()).toHaveText('Guideafter')
await expect.poll(() => orcaPage.evaluate(() => window.getSelection()?.toString())).toBe('')
await orcaPage.screenshot({ path: testInfo.outputPath('link-after-collapse.png') })
})
+307
View File
@@ -0,0 +1,307 @@
import type { Locator, Page } from '@stablyai/playwright-test'
import { expect, test } from './helpers/orca-app'
import { pressShortcut } from './helpers/shortcuts'
import {
cleanupMarkdownFixture,
createMarkdownFixture,
getActiveWorktreeContext,
openMarkdownFixture,
waitForRichMarkdownEditor
} from './helpers/markdown-editor-fixture'
const FIRST = 'First alpha paragraph for pointer selection.'
const SECOND = 'Second beta paragraph for pointer selection.'
const SOURCE = [
'# Adversarial Find interactions',
'Needle first match.',
...Array.from({ length: 65 }, (_, index) => `Spacer ${index} keeps the search match away.`),
FIRST,
SECOND,
'| Left | Right |\n| --- | --- |\n| Cell alpha | Cell beta |\n| Cell gamma | Cell delta |',
'- [ ] Task marker',
'<details open>\n<summary>Details marker</summary>\n\nDetails body marker\n\n</details>',
'```javascript\nconst codeMarker = "clean";\n```',
'Late target paragraph for a pending query.',
...Array.from({ length: 30 }, (_, index) => `Tail spacer ${index} keeps the second match away.`),
'Needle final match.'
].join('\n\n')
async function textPoint(target: Locator, offset: number) {
return target.evaluate((element, offset) => {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT)
let remaining = offset
let node = walker.nextNode()
while (node) {
if (node instanceof Text && remaining <= node.length) {
const range = document.createRange()
range.setStart(node, remaining)
range.collapse(true)
const bounds = range.getBoundingClientRect()
return { x: bounds.left, y: bounds.top + bounds.height / 2 }
}
remaining -= node.textContent?.length ?? 0
node = walker.nextNode()
}
throw new Error('Text offset was not found')
}, offset)
}
async function selectedText(page: Page) {
return page.evaluate(() => window.getSelection()?.toString() ?? '')
}
async function centered(target: Locator) {
await target.evaluate((element) => element.scrollIntoView({ block: 'center' }))
}
async function find(page: Page, query = 'Needle', status = '1/2') {
await pressShortcut(page, 'f')
const input = page.getByRole('textbox', { name: 'Find in rich markdown editor' })
await expect(input).toBeFocused()
await input.fill(query)
await expect(page.locator('.rich-markdown-search-status')).toHaveText(status)
return input
}
async function drag(page: Page, start: Locator, end: Locator, from: number, to: number) {
const firstPoint = await textPoint(start, from)
const lastPoint = await textPoint(end, to)
await page.mouse.move(firstPoint.x, firstPoint.y)
await page.mouse.down()
await page.mouse.move(lastPoint.x, lastPoint.y, { steps: 15 })
await page.mouse.up()
}
test.beforeEach(async ({ orcaPage, registerPostElectronShutdownCleanup }, testInfo) => {
const context = await getActiveWorktreeContext(orcaPage)
const filePath = await createMarkdownFixture(
context,
'.orca-e2e-markdown-adversarial',
'find-interactions',
testInfo.workerIndex,
SOURCE
)
registerPostElectronShutdownCleanup(() => cleanupMarkdownFixture(filePath))
await openMarkdownFixture(orcaPage, context, filePath)
const editor = await waitForRichMarkdownEditor(orcaPage)
await editor.locator('p').first().click()
})
test('Find preserves Shift-click, double-click, and multi-paragraph copy selection', async ({
orcaPage
}, testInfo) => {
const editor = orcaPage.locator('.rich-markdown-editor')
const first = editor.getByText(FIRST, { exact: true })
const second = editor.getByText(SECOND, { exact: true })
const search = await find(orcaPage)
await centered(first)
const viewport = orcaPage.locator('.rich-markdown-editor-shell .overflow-auto')
const scroll = await viewport.evaluate((element) => element.scrollTop)
const start = await textPoint(first, 6)
const end = await textPoint(second, 11)
await orcaPage.mouse.click(start.x, start.y)
await orcaPage.keyboard.down('Shift')
await orcaPage.mouse.click(end.x, end.y)
await orcaPage.keyboard.up('Shift')
expect(await selectedText(orcaPage)).toBe(`${FIRST.slice(6)}\n\n${SECOND.slice(0, 11)}`)
await expect(editor).toBeFocused()
await expect(
orcaPage
.locator('[data-tab-id]')
.filter({ hasText: 'find-interactions' })
.last()
.locator('span.rounded-full')
).toHaveCount(0)
await orcaPage.mouse.click(start.x, start.y)
await search.focus()
await orcaPage.keyboard.down('Shift')
await orcaPage.mouse.click(end.x, end.y)
await orcaPage.keyboard.up('Shift')
expect(await selectedText(orcaPage)).toBe(`${FIRST.slice(6)}\n\n${SECOND.slice(0, 11)}`)
const copied = await editor.evaluate((element) => {
const clipboard = new DataTransfer()
element.dispatchEvent(
new ClipboardEvent('copy', { bubbles: true, cancelable: true, clipboardData: clipboard })
)
return { plain: clipboard.getData('text/plain'), html: clipboard.getData('text/html') }
})
expect(copied.plain).toContain(FIRST.slice(6))
expect(copied.plain).toContain(SECOND.slice(0, 11))
expect(copied.html).toContain('<p>')
await expect(editor.getByText('Needle first match.', { exact: true })).toBeVisible()
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBeCloseTo(scroll, 0)
await orcaPage.screenshot({ path: testInfo.outputPath('shift-click-copy-selection.png') })
await search.focus()
const word = await textPoint(first, 9)
await orcaPage.mouse.dblclick(word.x, word.y)
expect(await selectedText(orcaPage)).toBe('alpha')
await orcaPage.keyboard.type('xy', { delay: 100 })
await expect(
editor.getByText('First xy paragraph for pointer selection.', { exact: true })
).toBeVisible()
await expect(search).toHaveValue('Needle')
await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/2')
})
test('Replace input returns to a multi-paragraph drag and explicit next-match navigation', async ({
orcaPage
}, testInfo) => {
const editor = orcaPage.locator('.rich-markdown-editor')
const search = await find(orcaPage)
await orcaPage.getByRole('button', { name: 'Toggle replace', exact: true }).click()
const replace = orcaPage.getByRole('textbox', { name: 'Replace in rich markdown editor' })
await replace.fill('Thread')
const first = editor.getByText(FIRST, { exact: true })
const second = editor.getByText(SECOND, { exact: true })
await centered(first)
await drag(orcaPage, first, second, 6, 11)
expect(await selectedText(orcaPage)).toBe(`${FIRST.slice(6)}\n\n${SECOND.slice(0, 11)}`)
await orcaPage.keyboard.type('xy', { delay: 100 })
await expect(
editor.getByText(`${FIRST.slice(0, 6)}xy${SECOND.slice(11)}`, { exact: true })
).toBeVisible()
await orcaPage.getByRole('button', { name: 'Next match', exact: true }).click()
await expect.poll(() => selectedText(orcaPage)).toBe('Needle')
await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('2/2')
await replace.focus()
await orcaPage.getByRole('button', { name: 'Replace', exact: true }).click()
await expect(editor.getByText('Thread final match.', { exact: true })).toBeVisible()
await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/1')
await expect(replace).toBeFocused()
await orcaPage.keyboard.press('Escape')
await expect(search).toHaveCount(0)
await pressShortcut(orcaPage, 'f')
await expect(search).toBeFocused()
await expect(search).toHaveValue('')
await orcaPage.screenshot({ path: testInfo.outputPath('replace-next-reopen.png') })
})
test('Find returns focus and copies the intended cells after Shift-clicking a table', async ({
orcaPage
}, testInfo) => {
const editor = orcaPage.locator('.rich-markdown-editor')
const search = await find(orcaPage, 'Cell alpha', '1/1')
const firstCell = editor.getByText('Cell alpha', { exact: true })
const lastCell = editor.getByText('Cell delta', { exact: true })
await centered(firstCell)
const viewport = orcaPage.locator('.rich-markdown-editor-shell .overflow-auto')
const scroll = await viewport.evaluate((element) => element.scrollTop)
const point = await textPoint(lastCell, 6)
await orcaPage.keyboard.down('Shift')
await orcaPage.mouse.move(point.x, point.y)
await orcaPage.mouse.down()
await orcaPage.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
})
)
await orcaPage.mouse.up()
await orcaPage.keyboard.up('Shift')
await expect(editor.locator('td.selectedCell')).toHaveCount(4)
await orcaPage.screenshot({ path: testInfo.outputPath('shift-cell-selection.png') })
await expect(editor).toBeFocused()
await expect(search).toHaveValue('Cell alpha')
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBeCloseTo(scroll, 0)
const tab = orcaPage.locator('[data-tab-id]').filter({ hasText: 'find-interactions' }).last()
await expect(tab.locator('span.rounded-full')).toHaveCount(0)
const copied = await editor.evaluate((element) => {
const clipboard = new DataTransfer()
element.dispatchEvent(
new ClipboardEvent('copy', {
bubbles: true,
cancelable: true,
clipboardData: clipboard
})
)
return clipboard.getData('text/plain')
})
expect(copied).toBe('Cell alpha\n\nCell beta\n\nCell gamma\n\nCell delta')
})
test('Find preserves embedded task, details, and code editing', async ({ orcaPage }, testInfo) => {
const editor = orcaPage.locator('.rich-markdown-editor')
const search = await find(orcaPage)
await centered(editor.getByRole('checkbox'))
await search.focus()
const checkbox = editor.getByRole('checkbox')
await checkbox.check()
await expect(checkbox).toBeChecked()
await expect(search).toHaveValue('Needle')
await search.focus()
const details = editor.locator('[data-type="details"]')
await details.getByRole('button').click()
await expect(details.locator('[data-type="detailsContent"]')).toBeHidden()
await search.focus()
await details.getByRole('button').click()
await expect(details.locator('[data-type="detailsContent"]')).toBeVisible()
const body = editor.getByText('Details body marker', { exact: true })
const bodyPoint = await textPoint(body, 7)
await search.focus()
await orcaPage.mouse.click(bodyPoint.x, bodyPoint.y)
await orcaPage.keyboard.type('xy', { delay: 100 })
await expect(editor.getByText('Detailsxy body marker', { exact: true })).toBeVisible()
const code = editor.getByText('const codeMarker = "clean";', { exact: true })
await centered(code)
const codePoint = await textPoint(code, 6)
await search.focus()
await orcaPage.mouse.click(codePoint.x, codePoint.y)
await orcaPage.keyboard.type('xy', { delay: 100 })
await expect(editor.getByText('const xycodeMarker = "clean";', { exact: true })).toBeVisible()
await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/2')
await orcaPage.screenshot({ path: testInfo.outputPath('embedded-control-editing.png') })
})
test('a pending Find query cannot claim focus after document and checkbox interaction', async ({
orcaPage
}, testInfo) => {
const editor = orcaPage.locator('.rich-markdown-editor')
const search = await find(orcaPage)
const target = editor.getByText('Late target paragraph for a pending query.', { exact: true })
await centered(target)
const point = await textPoint(target, 5)
const viewport = orcaPage.locator('.rich-markdown-editor-shell .overflow-auto')
const scroll = await viewport.evaluate((element) => element.scrollTop)
const checkbox = editor.getByRole('checkbox')
const checkboxPoint = await checkbox.evaluate((element) => {
const bounds = element.getBoundingClientRect()
return { x: bounds.left + bounds.width / 2, y: bounds.top + bounds.height / 2 }
})
await search.fill('Cell alpha')
await orcaPage.mouse.click(point.x, point.y)
await expect(editor).toBeFocused()
await orcaPage.mouse.click(checkboxPoint.x, checkboxPoint.y)
await expect(checkbox).toBeChecked()
await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/1')
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBeCloseTo(scroll, 0)
await expect(target).toHaveText('Late target paragraph for a pending query.')
await orcaPage.mouse.click(point.x, point.y)
await orcaPage.keyboard.type('xy', { delay: 100 })
await expect(
editor.getByText('Late xytarget paragraph for a pending query.', { exact: true })
).toBeVisible()
await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/1')
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBeCloseTo(scroll, 0)
await expect(search).toHaveValue('Cell alpha')
await orcaPage.screenshot({ path: testInfo.outputPath('pending-query-editor-caret.png') })
})
test('Replace advances when its replacement still contains the search query', async ({
orcaPage
}, testInfo) => {
const editor = orcaPage.locator('.rich-markdown-editor')
await find(orcaPage)
await orcaPage.getByRole('button', { name: 'Toggle replace', exact: true }).click()
await orcaPage.getByRole('textbox', { name: 'Replace in rich markdown editor' }).fill('NeedleX')
await orcaPage.getByRole('button', { name: 'Replace', exact: true }).click()
await expect(editor.getByText('NeedleX first match.', { exact: true })).toBeVisible()
await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('2/2')
await expect(
editor.getByText('Needle final match.', { exact: true }).locator('[data-active="true"]')
).toHaveText('Needle')
await orcaPage.getByRole('button', { name: 'Replace', exact: true }).click()
await expect(editor.getByText('NeedleX final match.', { exact: true })).toBeVisible()
await expect(editor.getByText('NeedleX first match.', { exact: true })).toBeVisible()
await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/2')
await orcaPage.screenshot({ path: testInfo.outputPath('replacement-retains-query.png') })
})
+215
View File
@@ -0,0 +1,215 @@
import type { Locator, Page } from '@stablyai/playwright-test'
import path from 'node:path'
import { test, expect } from './helpers/orca-app'
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import { pressShortcut } from './helpers/shortcuts'
import {
cleanupMarkdownFixture,
createMarkdownFixture,
getActiveWorktreeContext,
openMarkdownFixture,
waitForRichMarkdownEditor
} from './helpers/markdown-editor-fixture'
const TARGET_TEXT = 'Editing target paragraph keeps the pointer position and viewport.'
const MARKDOWN = [
'# Find and pointer editing',
'Needle remains at the beginning.',
...Array.from({ length: 100 }, (_, index) =>
index === 70 ? TARGET_TEXT : `Paragraph ${index} provides room to scroll through the document.`
),
'Needle remains at the end.'
].join('\n\n')
const TABLE_MARKDOWN = MARKDOWN.replace(
TARGET_TEXT,
`| Name | Value |\n| --- | --- |\n| Target | ${TARGET_TEXT} |`
)
async function textPoint(paragraph: Locator, offset: number) {
return paragraph.evaluate((element, offset) => {
const text = element.firstChild
if (!(text instanceof Text)) {
throw new Error('Expected a plain-text paragraph')
}
const range = document.createRange()
range.setStart(text, offset)
range.collapse(true)
const bounds = range.getBoundingClientRect()
return { x: bounds.left, y: bounds.top + bounds.height / 2 }
}, offset)
}
async function readParagraphSelection(paragraph: Locator) {
return paragraph.evaluate((element) => {
const selection = window.getSelection()
if (!selection?.anchorNode || !selection.focusNode) {
return null
}
if (!element.contains(selection.anchorNode) || !element.contains(selection.focusNode)) {
return null
}
const start = document.createRange()
start.selectNodeContents(element)
start.setEnd(selection.anchorNode, selection.anchorOffset)
const end = document.createRange()
end.selectNodeContents(element)
end.setEnd(selection.focusNode, selection.focusOffset)
return {
from: Math.min(start.toString().length, end.toString().length),
to: Math.max(start.toString().length, end.toString().length),
text: selection.toString()
}
})
}
async function centerParagraph(paragraph: Locator): Promise<void> {
await paragraph.evaluate((element) => element.scrollIntoView({ block: 'center' }))
}
async function pointAtParagraph(page: Page, paragraph: Locator, drag: boolean) {
const start = await textPoint(paragraph, 8)
await page.mouse.move(start.x, start.y)
await page.mouse.down()
if (drag) {
const end = await textPoint(paragraph, 24)
await page.mouse.move(end.x, end.y, { steps: 12 })
}
// Tiptap focus commands can schedule a stale-selection scroll for the next frame.
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
})
)
await page.mouse.up()
const selection = await readParagraphSelection(paragraph)
expect(selection).not.toBeNull()
if (!selection) {
throw new Error('Pointer selection left the intended paragraph')
}
if (drag) {
expect(selection.text).toBe('target paragraph')
} else {
expect(selection.from).toBe(selection.to)
}
return selection
}
test.beforeEach(async ({ orcaPage }) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
})
for (const interaction of ['click', 'drag'] as const) {
test(`keeps Find open while a document ${interaction} edits at the pointer position`, async ({
orcaPage,
registerPostElectronShutdownCleanup
}, testInfo) => {
const context = await getActiveWorktreeContext(orcaPage)
const filePath = await createMarkdownFixture(
context,
'.orca-e2e-markdown-find-editing',
`find-${interaction}`,
testInfo.workerIndex,
MARKDOWN
)
registerPostElectronShutdownCleanup(() => cleanupMarkdownFixture(filePath))
await openMarkdownFixture(orcaPage, context, filePath)
const editor = await waitForRichMarkdownEditor(orcaPage)
const paragraph = editor.locator('p').filter({ hasText: 'Editing' })
const viewport = orcaPage.locator('.rich-markdown-editor-shell .overflow-auto')
await editor.locator('p').first().click()
await pressShortcut(orcaPage, 'f')
const search = orcaPage.getByRole('textbox', { name: 'Find in rich markdown editor' })
await expect(search).toBeFocused()
await search.fill('Needle')
await orcaPage.getByRole('button', { name: 'Match case', exact: true }).click()
await orcaPage.getByRole('button', { name: 'Match whole word', exact: true }).click()
await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/2')
await centerParagraph(paragraph)
const originalScroll = await viewport.evaluate((element) => element.scrollTop)
const selection = await pointAtParagraph(orcaPage, paragraph, interaction === 'drag')
// Separate key events allow document updates to expose a caret reset between characters.
await orcaPage.keyboard.type('xy', { delay: 100 })
await orcaPage.screenshot({ path: testInfo.outputPath('find-document-edit.png') })
await expect(paragraph).toHaveText(
`${TARGET_TEXT.slice(0, selection.from)}xy${TARGET_TEXT.slice(selection.to)}`
)
await expect(editor.locator('p').first()).toHaveText('Needle remains at the beginning.')
await expect
.poll(() => readParagraphSelection(paragraph))
.toEqual({
from: selection.from + 2,
to: selection.from + 2,
text: ''
})
await expect
.poll(() => viewport.evaluate((element) => element.scrollTop))
.toBeCloseTo(originalScroll, 0)
await expect(search).toBeVisible()
await expect(search).toHaveValue('Needle')
await expect(orcaPage.getByRole('button', { name: 'Match case', exact: true })).toHaveAttribute(
'aria-pressed',
'true'
)
await expect(
orcaPage.getByRole('button', { name: 'Match whole word', exact: true })
).toHaveAttribute('aria-pressed', 'true')
await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/2')
})
test(`keeps the scrolled table and ${interaction} selection after switching Source to Rich`, async ({
orcaPage,
registerPostElectronShutdownCleanup
}, testInfo) => {
const context = await getActiveWorktreeContext(orcaPage)
const filePath = await createMarkdownFixture(
context,
'.orca-e2e-markdown-find-editing',
`source-${interaction}`,
testInfo.workerIndex,
TABLE_MARKDOWN
)
registerPostElectronShutdownCleanup(() => cleanupMarkdownFixture(filePath))
await openMarkdownFixture(orcaPage, context, filePath)
await waitForRichMarkdownEditor(orcaPage)
await orcaPage.getByRole('radio', { name: 'Source', exact: true }).click()
await expect(orcaPage.locator('.monaco-editor')).toBeVisible()
await orcaPage.getByRole('radio', { name: 'Rich Editor', exact: true }).click()
const editor = await waitForRichMarkdownEditor(orcaPage)
await expect(editor).not.toBeFocused()
const paragraph = editor.locator('td p').filter({ hasText: 'Editing' })
const viewport = orcaPage.locator('.rich-markdown-editor-shell .overflow-auto')
await centerParagraph(paragraph)
const originalScroll = await viewport.evaluate((element) => element.scrollTop)
const selection = await pointAtParagraph(orcaPage, paragraph, interaction === 'drag')
await expect(editor).toBeFocused()
await expect(paragraph).toHaveText(TARGET_TEXT)
await expect
.poll(() => viewport.evaluate((element) => element.scrollTop))
.toBeCloseTo(originalScroll, 0)
const tab = orcaPage
.locator('[data-tab-id]')
.filter({ hasText: path.basename(filePath) })
.last()
await expect(tab.locator('span.rounded-full')).toHaveCount(0)
await expect(tab.getByRole('button', { name: 'Close tab' })).toBeVisible()
await orcaPage.screenshot({ path: testInfo.outputPath('source-rich-table-selection.png') })
await orcaPage.keyboard.type('xy', { delay: 100 })
await orcaPage.screenshot({ path: testInfo.outputPath('source-rich-pointer-edit.png') })
await expect(paragraph).toHaveText(
`${TARGET_TEXT.slice(0, selection.from)}xy${TARGET_TEXT.slice(selection.to)}`
)
await expect
.poll(() => readParagraphSelection(paragraph))
.toEqual({
from: selection.from + 2,
to: selection.from + 2,
text: ''
})
await expect
.poll(() => viewport.evaluate((element) => element.scrollTop))
.toBeCloseTo(originalScroll, 0)
})
}
@@ -0,0 +1,209 @@
import type { Locator, Page } from '@stablyai/playwright-test'
import type { Editor, JSONContent } from '@tiptap/core'
import type { MarkdownManager } from '@tiptap/markdown'
import { expect, test } from './helpers/orca-app'
import {
cleanupMarkdownFixture,
createMarkdownFixture,
getActiveWorktreeContext,
openMarkdownFixture,
waitForRichMarkdownEditor
} from './helpers/markdown-editor-fixture'
const PARAGRAPH_COUNT = Number(process.env.ORCA_MARKDOWN_REVIEW_PERF_PARAGRAPHS ?? '500')
const targetIndex = Math.floor(PARAGRAPH_COUNT / 2)
const paragraph = (index: number) =>
`Paragraph ${index}. Ordinary editing and selection of plain prose.`
const SOURCE = Array.from({ length: PARAGRAPH_COUNT }, (_, index) => paragraph(index)).join('\n\n')
type ReviewSelectionMetrics = {
calls: number
jsonCalls: number
serializeMs: number
jsonMs: number
frameGaps: number[]
}
type ReviewSelectionProbe = {
snapshot: () => ReviewSelectionMetrics
reset: () => void
restore: () => void
}
type PageRichMarkdownReviewEditorElement = HTMLElement & {
editor?: Editor & { markdown?: MarkdownManager }
__reviewSelectionProbe?: ReviewSelectionProbe
}
async function frames(page: Page) {
await page.evaluate(
() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
)
)
}
async function measure(editor: Locator, reset = false): Promise<ReviewSelectionMetrics> {
return editor.evaluate((element, reset) => {
const editorElement =
element.closest<PageRichMarkdownReviewEditorElement>('.rich-markdown-editor')
const probe = editorElement?.__reviewSelectionProbe
if (!probe) {
throw new Error('Review selection probe missing')
}
if (reset) {
probe.reset()
}
return probe.snapshot()
}, reset)
}
test('selection and scrolling reuse Markdown review source lines', async ({
orcaPage,
registerPostElectronShutdownCleanup
}, testInfo) => {
test.setTimeout(180_000)
expect(Buffer.byteLength(SOURCE)).toBeLessThan(600 * 1024)
const context = await getActiveWorktreeContext(orcaPage)
const filePath = await createMarkdownFixture(
context,
'.orca-e2e-markdown-review-perf',
'review-selection',
testInfo.workerIndex,
SOURCE
)
registerPostElectronShutdownCleanup(() => cleanupMarkdownFixture(filePath))
await openMarkdownFixture(orcaPage, context, filePath)
const editor = await waitForRichMarkdownEditor(orcaPage)
await expect(editor.locator('p')).toHaveCount(PARAGRAPH_COUNT, { timeout: 60_000 })
await editor.evaluate((element) => {
const editorElement =
element.closest<PageRichMarkdownReviewEditorElement>('.rich-markdown-editor')
const instance = editorElement?.editor
if (!editorElement || !instance) {
throw new Error('Editor unavailable')
}
const markdown = instance.markdown
if (!markdown) {
throw new Error('Markdown manager missing')
}
const serialize = markdown.serialize
const getJSON = instance.getJSON
let calls = 0
let jsonCalls = 0
let serializeMs = 0
let jsonMs = 0
let frameGaps: number[] = []
let previousFrame: number | null = null
let frameId = 0
const tick = (now: number) => {
if (previousFrame !== null) {
frameGaps.push(now - previousFrame)
}
previousFrame = now
frameId = requestAnimationFrame(tick)
}
frameId = requestAnimationFrame(tick)
markdown.serialize = (content: JSONContent): string => {
const start = performance.now()
try {
calls++
return serialize.call(markdown, content)
} finally {
serializeMs += performance.now() - start
}
}
instance.getJSON = (): ReturnType<Editor['getJSON']> => {
const start = performance.now()
try {
jsonCalls++
return getJSON.call(instance)
} finally {
jsonMs += performance.now() - start
}
}
editorElement.__reviewSelectionProbe = {
snapshot: () => ({ calls, jsonCalls, serializeMs, jsonMs, frameGaps }),
reset: () => {
calls = 0
jsonCalls = 0
serializeMs = 0
jsonMs = 0
frameGaps = []
previousFrame = null
},
restore: () => {
cancelAnimationFrame(frameId)
markdown.serialize = serialize
instance.getJSON = getJSON
}
}
})
try {
const target = editor.getByText(paragraph(targetIndex), { exact: true })
await target.evaluate((element) => element.scrollIntoView({ block: 'center' }))
const point = await target.evaluate((element) => {
const text = element.firstChild
if (!(text instanceof Text)) {
throw new Error('Paragraph text missing')
}
const range = document.createRange()
range.setStart(text, 0)
range.collapse(true)
const rect = range.getBoundingClientRect()
return { x: rect.left, y: rect.top + rect.height / 2 }
})
await orcaPage.mouse.click(point.x, point.y)
await orcaPage.keyboard.press('Shift+ArrowRight')
await expect(
orcaPage.getByRole('button', { name: 'Add review note', exact: true })
).toBeVisible()
await frames(orcaPage)
const cold = await measure(editor)
await measure(editor, true)
for (let index = 0; index < 20; index++) {
await orcaPage.keyboard.press('Shift+ArrowRight')
await frames(orcaPage)
}
const selection = await measure(editor)
expect(await orcaPage.evaluate(() => window.getSelection()?.toString())).toBe(
paragraph(targetIndex).slice(0, 21)
)
const viewport = orcaPage.locator('.rich-markdown-editor-shell .overflow-auto')
await measure(editor, true)
for (let index = 0; index < 10; index++) {
await viewport.evaluate((element, index) => {
element.scrollTop += index % 2 ? -8 : 8
}, index)
await frames(orcaPage)
}
const scroll = await measure(editor)
const result = {
paragraphs: PARAGRAPH_COUNT,
sourceBytes: Buffer.byteLength(SOURCE),
cold,
selection,
scroll
}
await testInfo.attach('review-selection-metrics', {
body: JSON.stringify(result, null, 2),
contentType: 'application/json'
})
process.stdout.write(`${JSON.stringify(result)}\n`)
await expect(editor).toBeFocused()
const tab = orcaPage.locator('[data-tab-id]').filter({ hasText: 'review-selection' }).last()
await expect(tab.locator('span.rounded-full')).toHaveCount(0)
await orcaPage.screenshot({ path: testInfo.outputPath('review-selection.png') })
expect(selection.calls).toBe(0)
expect(selection.jsonCalls).toBe(0)
expect(scroll.calls).toBe(0)
expect(scroll.jsonCalls).toBe(0)
} finally {
await editor.evaluate((element) => {
const editorElement =
element.closest<PageRichMarkdownReviewEditorElement>('.rich-markdown-editor')
editorElement?.__reviewSelectionProbe?.restore()
})
}
})
@@ -0,0 +1,106 @@
import { randomUUID } from 'node:crypto'
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import path from 'node:path'
import type { Page } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import { getActiveWorktreeContext } from './helpers/markdown-editor-fixture'
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import { parseIpynb } from '../../src/renderer/src/components/editor/ipynb-parse'
const FIRST = { id: 'first', source: 'print("original")' }
const SECOND = { id: 'second', source: 'print("other")' }
function notebookContent(cells: { id: string; source: string }[]): string {
return JSON.stringify({
nbformat: 4,
nbformat_minor: 5,
metadata: { language_info: { name: 'python' } },
cells: cells.map(({ id, source }) => ({
id,
cell_type: 'code',
metadata: {},
execution_count: null,
outputs: [],
source: [source]
}))
})
}
async function openNotebook(page: Page, filePath: string): Promise<void> {
const context = await getActiveWorktreeContext(page)
await page.evaluate(
({ filePath, worktreeId, relativePath }) => {
window.__store?.getState().openFile({
filePath,
relativePath,
worktreeId,
language: 'json',
mode: 'edit'
})
},
{
filePath,
worktreeId: context.worktreeId,
relativePath: path.relative(context.rootPath, filePath)
}
)
await expect(page.locator('.ipynb-code-surface')).toHaveCount(2)
}
async function fileIsDirty(page: Page): Promise<boolean | null> {
return page.evaluate(() => {
const state = window.__store?.getState()
return state?.openFiles.find((file) => file.id === state.activeFileId)?.isDirty ?? null
})
}
test.beforeEach(async ({ orcaPage }) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
})
for (const reload of ['source', 'reorder'] as const) {
test(`keeps the active cell current after an external notebook ${reload} reload`, async ({
orcaPage,
registerPostElectronShutdownCleanup
}, testInfo) => {
const context = await getActiveWorktreeContext(orcaPage)
const directory = path.join(context.rootPath, '.orca-e2e-notebook-external-sync')
await mkdir(directory, { recursive: true })
const filePath = path.join(directory, `${reload}-${testInfo.workerIndex}-${randomUUID()}.ipynb`)
registerPostElectronShutdownCleanup(() => rm(filePath, { force: true }))
await writeFile(filePath, notebookContent([FIRST, SECOND]), 'utf8')
await openNotebook(orcaPage, filePath)
const surfaces = orcaPage.locator('.ipynb-code-surface')
await surfaces.first().getByRole('button').click()
await expect(orcaPage.locator('.ipynb-code-surface .monaco-editor')).toBeVisible()
await expect.poll(() => fileIsDirty(orcaPage)).toBe(false)
const updatedFirst = reload === 'source' ? { ...FIRST, source: 'print("updated")' } : FIRST
const updatedSecond = { ...SECOND, source: 'print("reload marker")' }
const externalCells =
reload === 'source' ? [updatedFirst, updatedSecond] : [updatedSecond, updatedFirst]
await writeFile(filePath, notebookContent(externalCells), 'utf8')
const preview = surfaces.nth(reload === 'source' ? 1 : 0)
await expect(preview).toHaveText(updatedSecond.source, { timeout: 25_000 })
await expect.poll(() => fileIsDirty(orcaPage)).toBe(false)
await orcaPage.screenshot({ path: testInfo.outputPath('external-reload-clean.png') })
const activeCell = surfaces.nth(reload === 'source' ? 0 : 1)
await expect.soft(activeCell.locator('.view-lines')).toHaveText(updatedFirst.source)
await orcaPage.keyboard.press('End')
await orcaPage.keyboard.type('!', { delay: 100 })
await expect.poll(() => fileIsDirty(orcaPage)).toBe(true)
await orcaPage.screenshot({ path: testInfo.outputPath('external-reload-cell-edit.png') })
await orcaPage.getByRole('button', { name: 'Save notebook', exact: true }).click()
const editedFirst = { ...updatedFirst, source: `${updatedFirst.source}!` }
await expect
.poll(async () => {
const saved = parseIpynb(await readFile(filePath, 'utf8'))
return saved.cells.map(({ id, source }) => ({ id, source }))
})
.toEqual(reload === 'source' ? [editedFirst, updatedSecond] : [updatedSecond, editedFirst])
await expect.poll(() => fileIsDirty(orcaPage)).toBe(false)
await orcaPage.screenshot({ path: testInfo.outputPath('external-reload-cell-saved.png') })
})
}
@@ -0,0 +1,142 @@
import type { Page } from '@stablyai/playwright-test'
// Freeze the oracle only after the fixture's reveal and measured rows have settled.
export async function waitForLineageScrollFixtureReady(
page: Page,
parentId: string
): Promise<void> {
await page.evaluate(
(parentId) =>
new Promise<void>((resolve, reject) => {
const sidebar = document.querySelector<HTMLElement>('[data-worktree-sidebar]')
if (!sidebar) {
reject(new Error('Missing sidebar'))
return
}
const quietSpanMs = 500
const started = performance.now()
let lastScrollAt = started
let unchangedSince = started
let signature = ''
let frameId: number | null = null
let frames = 0
let intervalId: ReturnType<typeof setInterval> | undefined
let timeoutId: ReturnType<typeof setTimeout> | undefined
const cleanup = (): void => {
sidebar.removeEventListener('scroll', onScroll)
window.removeEventListener('pagehide', onPageHide)
clearInterval(intervalId)
clearTimeout(timeoutId)
if (frameId !== null) {
cancelAnimationFrame(frameId)
}
}
const finish = (error?: Error): void => {
cleanup()
if (error) {
reject(error)
} else {
resolve()
}
}
const onScroll = (): void => {
lastScrollAt = performance.now()
}
const onPageHide = (): void => finish(new Error('Renderer closed before fixture settled'))
const readSignature = (): string => {
const parent = sidebar.querySelector<HTMLElement>(
`[role="option"][data-worktree-id=${JSON.stringify(parentId)}]`
)
const wrapper = parent?.closest<HTMLElement>('[data-worktree-virtual-row]')
if (
!parent?.isConnected ||
!wrapper ||
document.fonts.status !== 'loaded' ||
sidebar.scrollTop <= 0
) {
return ''
}
const mounted = Array.from(
sidebar.querySelectorAll<HTMLElement>('[data-worktree-virtual-row]')
)
if (mounted.length > 100) {
throw new Error('Unbounded lineage fixture row measurement')
}
const parentRect = parent.getBoundingClientRect()
const sidebarRect = sidebar.getBoundingClientRect()
return JSON.stringify({
id: parent.dataset.worktreeId,
key: wrapper.dataset.worktreeVirtualRowKey,
index: wrapper.dataset.index,
start: wrapper.dataset.worktreeVirtualRowStart,
transform: wrapper.style.transform,
parentTop: parentRect.top,
parentHeight: parentRect.height,
scrollTop: sidebar.scrollTop,
scrollHeight: sidebar.scrollHeight,
sidebarTop: sidebarRect.top,
sidebarHeight: sidebarRect.height,
canvasHeight: wrapper.parentElement?.style.height,
rows: mounted.map((row) => ({
key: row.dataset.worktreeVirtualRowKey,
index: row.dataset.index,
start: row.dataset.worktreeVirtualRowStart,
transform: row.style.transform,
height: row.getBoundingClientRect().height
}))
})
}
const inspect = (): boolean => {
const next = readSignature()
if (!next || next !== signature) {
signature = next
unchangedSince = performance.now()
frames = 0
if (frameId !== null) {
cancelAnimationFrame(frameId)
}
frameId = null
return false
}
const now = performance.now()
const ready = now - unchangedSince >= quietSpanMs && now - lastScrollAt >= quietSpanMs
if (!ready) {
frames = 0
}
return ready
}
const onFrame = (): void => {
frameId = null
try {
if (!inspect()) {
return
}
frames += 1
if (frames === 2) {
finish()
return
}
frameId = requestAnimationFrame(onFrame)
} catch (error) {
finish(error instanceof Error ? error : new Error(String(error)))
}
}
sidebar.addEventListener('scroll', onScroll, { passive: true })
window.addEventListener('pagehide', onPageHide, { once: true })
intervalId = setInterval(() => {
try {
if (inspect() && frameId === null) {
frameId = requestAnimationFrame(onFrame)
}
} catch (error) {
finish(error instanceof Error ? error : new Error(String(error)))
}
}, 50)
timeoutId = setTimeout(
() => finish(new Error(`Lineage fixture did not settle: ${signature}`)),
10000
)
}),
parentId
)
}
+5 -3
View File
@@ -7,9 +7,9 @@ export type LineageScenario = {
export async function seedLineageScenario(
page: Page,
options: { inlineOnly?: boolean } = {}
options: { inlineOnly?: boolean; preserveGrouping?: boolean } = {}
): Promise<LineageScenario> {
return page.evaluate(({ inlineOnly }) => {
return page.evaluate(({ inlineOnly, preserveGrouping }) => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available')
@@ -18,7 +18,9 @@ export async function seedLineageScenario(
const state = store.getState()
state.setActiveView('terminal')
state.setSidebarOpen(true)
state.setGroupBy('none')
if (!preserveGrouping) {
state.setGroupBy('none')
}
state.setSortBy('recent')
// Why: these specs assert lineage structure, not the user's persisted
// sidebar filters. Make the seeded child render even when it has no live PTY.
@@ -0,0 +1,84 @@
import { test, expect } from './helpers/orca-app'
import { waitForSessionReady, waitForActiveWorktree } from './helpers/store'
import { seedLineageScenario } from './worktree-lineage-state'
test.use({ launchEnv: { ORCA_BACKGROUND_LAUNCH: '1' } })
test('locally controlled project-group modal keeps child-toggle input', async ({
orcaPage,
electronApp
}, testInfo) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
expect(
await electronApp.evaluate(({ BrowserWindow }) =>
BrowserWindow.getAllWindows().every((window) => !window.isVisible())
)
).toBe(true)
const family = await seedLineageScenario(orcaPage)
const groupId = await orcaPage.evaluate(async ({ parentId }) => {
const store = window.__store
if (!store) {
throw new Error('Missing store')
}
const parent = Object.values(store.getState().worktreesByRepo)
.flat()
.find((row) => row.id === parentId)
if (!parent) {
throw new Error('Missing parent')
}
const group = await store.getState().createProjectGroup('Shortcut dialog group')
if (!group) {
throw new Error('Missing project group')
}
await store.getState().moveProjectToGroup(parent.repoId, group.id)
store.getState().setGroupBy('repo')
await store.getState().setKeybindingOverride('sidebar.childWorkspaces.toggle', ['Mod+Alt+H'])
window.addEventListener('keydown', (event) => {
if (event.code === 'KeyH') {
document.body.dataset.childToggleClaimed = String(event.defaultPrevented)
}
})
return group.id
}, family)
const group = orcaPage.locator(`[data-project-group-header-id="${groupId}"]`)
await expect(
orcaPage
.locator(
`[data-worktree-sidebar] [role="option"][data-worktree-id=${JSON.stringify(family.parentId)}]`
)
.first()
.getByRole('button', { name: 'Hide 1 child workspace' })
).toBeVisible()
await group.hover()
await group.getByRole('button', { name: /Group actions/i }).click()
// The menu is not a modal input owner.
await orcaPage.keyboard.press('ControlOrMeta+Alt+KeyH')
expect(await orcaPage.evaluate(() => window.__store?.getState().collapsedGroups.size)).toBe(1)
await orcaPage.keyboard.press('ControlOrMeta+Alt+KeyH')
expect(await orcaPage.evaluate(() => window.__store?.getState().collapsedGroups.size)).toBe(0)
await orcaPage.getByRole('menuitem', { name: /Delete/i }).click()
const dialog = orcaPage.getByRole('dialog', { name: 'Delete Project Group' })
await expect(dialog).toBeVisible()
await expect(dialog.getByRole('button', { name: 'Delete Group', exact: true })).toBeFocused()
expect(await orcaPage.evaluate(() => window.__store?.getState().activeModal)).toBe('none')
const before = await orcaPage.evaluate(() =>
[...(window.__store?.getState().collapsedGroups ?? [])].sort()
)
await orcaPage.keyboard.press('ControlOrMeta+Alt+KeyH')
const proof = testInfo.outputPath('local-modal-guard.png')
await orcaPage.screenshot({ path: proof })
await testInfo.attach('local-modal-guard', { path: proof, contentType: 'image/png' })
expect(
await orcaPage.evaluate(() => [...(window.__store?.getState().collapsedGroups ?? [])].sort())
).toEqual(before)
await expect(orcaPage.locator('body')).toHaveAttribute('data-child-toggle-claimed', 'false')
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click()
await expect(
orcaPage
.locator(
`[data-worktree-sidebar] [role="option"][data-worktree-id=${JSON.stringify(family.childId)}]`
)
.first()
).toBeVisible()
})
@@ -0,0 +1,103 @@
import { test, expect } from './helpers/orca-app'
import { waitForSessionReady, waitForActiveWorktree } from './helpers/store'
import { seedLineageScenario } from './worktree-lineage-state'
import { waitForLineageScrollFixtureReady } from './worktree-lineage-scroll-readiness'
test.use({ launchEnv: { ORCA_BACKGROUND_LAUNCH: '1' } })
test('keyboard and chip preserve the scrolled parent, and unfolding opens the sidebar', async ({
orcaPage,
electronApp
}, testInfo) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
expect(
await electronApp.evaluate(({ BrowserWindow }) =>
BrowserWindow.getAllWindows().every((window) => !window.isVisible())
)
).toBe(true)
await orcaPage.emulateMedia({ reducedMotion: 'reduce' })
await orcaPage.setViewportSize({ width: 1200, height: 800 })
const family = await seedLineageScenario(orcaPage)
await orcaPage.evaluate(async ({ parentId, childId }) => {
const store = window.__store
if (!store) {
throw new Error('Missing store')
}
const state = store.getState()
const parent = Object.values(state.worktreesByRepo)
.flat()
.find((row) => row.id === parentId)
if (!parent) {
throw new Error('Missing parent')
}
const surrounding = Array.from({ length: 40 }, (_, index) => ({
...parent,
id: `scroll-card-${index}`,
instanceId: `scroll-instance-${index}`,
displayName: `Surrounding workspace ${index}`,
isMainWorktree: false,
isPinned: false,
parentWorktreeId: null,
childWorktreeIds: [],
lineage: null,
sortOrder: 40 - index
}))
store.setState({
sortBy: 'manual',
worktreesByRepo: {
...state.worktreesByRepo,
[parent.repoId]: [
...state.worktreesByRepo[parent.repoId].map((row) => ({
...row,
sortOrder: row.id === parentId ? 16.5 : row.id === childId ? 16.4 : row.sortOrder
})),
...surrounding
]
}
})
await store.getState().setKeybindingOverride('sidebar.childWorkspaces.toggle', ['Mod+Alt+H'])
}, family)
const sidebar = orcaPage.locator('[data-worktree-sidebar]')
const parentRow = sidebar
.locator(`[role="option"][data-worktree-id=${JSON.stringify(family.parentId)}]`)
.first()
const childRow = sidebar
.locator(`[role="option"][data-worktree-id=${JSON.stringify(family.childId)}]`)
.first()
await orcaPage.getByRole('button', { name: 'Reveal active workspace' }).click()
await expect(parentRow).toBeVisible()
await parentRow.evaluate((row) => row.scrollIntoView({ block: 'center' }))
await expect.poll(() => sidebar.evaluate((element) => element.scrollTop)).toBeGreaterThan(0)
const geometry = () =>
parentRow.evaluate((row) => ({
top: row.getBoundingClientRect().top,
scrollTop: row.closest('[data-worktree-sidebar]')?.scrollTop ?? 0
}))
await waitForLineageScrollFixtureReady(orcaPage, family.parentId)
const before = await geometry()
await orcaPage.mouse.move(1150, 400)
await orcaPage.keyboard.press('ControlOrMeta+Alt+KeyH')
await expect(childRow).toBeHidden()
await expect.poll(async () => Math.abs((await geometry()).top - before.top)).toBeLessThan(2)
expect((await geometry()).scrollTop).toBeGreaterThan(0)
const collapsedProof = testInfo.outputPath('keyboard-collapsed-parent-anchor.png')
await sidebar.screenshot({ path: collapsedProof })
await testInfo.attach('keyboard-collapsed-parent-anchor', {
path: collapsedProof,
contentType: 'image/png'
})
await parentRow.getByRole('button', { name: 'Show 1 child workspace' }).click()
await expect(childRow).toBeVisible()
await expect.poll(async () => Math.abs((await geometry()).top - before.top)).toBeLessThan(2)
await orcaPage.mouse.move(1150, 400)
await orcaPage.keyboard.press('ControlOrMeta+Alt+KeyH')
await expect(childRow).toBeHidden()
await orcaPage.evaluate(() => window.__store?.getState().setSidebarOpen(false))
await expect(sidebar).toBeHidden()
await orcaPage.keyboard.press('ControlOrMeta+Alt+KeyH')
await expect(sidebar).toBeVisible()
await expect(childRow).toBeVisible()
})
@@ -0,0 +1,485 @@
import type { Page } from '@stablyai/playwright-test'
import { mkdirSync } from 'node:fs'
import { join } from 'node:path'
import { test, expect } from './helpers/orca-app'
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart'
import { seedLineageScenario, seedWorkspaceLiveTerminal } from './worktree-lineage-state'
test.use({ launchEnv: { ORCA_BACKGROUND_LAUNCH: '1' } })
const ACTION_ID = 'sidebar.childWorkspaces.toggle' as const
const CHORD = 'ControlOrMeta+Alt+KeyH'
function sidebarWorktreeRow(page: Page, worktreeId: string) {
return page
.locator(
`[data-worktree-sidebar] [role="option"][data-worktree-id=${JSON.stringify(worktreeId)}]`
)
.first()
}
async function waitForLineageWorktrees(page: Page): Promise<void> {
await expect
.poll(
() =>
page.evaluate(async () => {
const store = window.__store
if (!store) {
return 0
}
for (const repo of store.getState().repos) {
await store.getState().fetchWorktrees(repo.id)
}
return Object.values(store.getState().worktreesByRepo)
.flat()
.filter((worktree) => !worktree.isArchived).length
}),
{ timeout: 30_000 }
)
.toBeGreaterThanOrEqual(2)
}
async function captureEvidence(page: Page, name: string): Promise<void> {
if (process.env.ORCA_CAPTURE_EVIDENCE !== '1') {
return
}
const directory = join(process.cwd(), 'pr-evidence')
mkdirSync(directory, { recursive: true })
await page.screenshot({ path: join(directory, name) })
}
async function setToggleBinding(page: Page, bindings: string[] | null): Promise<void> {
await page.evaluate(
async ({ actionId, bindings }) => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available')
}
const state = store.getState()
await (bindings
? state.setKeybindingOverride(actionId, bindings)
: state.resetKeybindingOverride(actionId))
},
{ actionId: ACTION_ID, bindings }
)
}
async function movePointerOffSidebar(page: Page): Promise<void> {
const viewport =
page.viewportSize() ??
(await page.evaluate(() => ({
width: window.innerWidth,
height: window.innerHeight
})))
await page.mouse.move(viewport.width - 20, viewport.height / 2)
}
async function observeShortcutClaim(page: Page): Promise<void> {
await page.evaluate(() => {
window.addEventListener('keydown', (event) => {
if (event.code === 'KeyH') {
document.body.dataset.childToggleClaimed = String(event.defaultPrevented)
}
})
})
}
test.describe('Toggle Child Workspaces shortcut', () => {
test.beforeEach(async ({ orcaPage, electronApp }) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
expect(
await electronApp.evaluate(({ BrowserWindow }) =>
BrowserWindow.getAllWindows().every((window) => !window.isVisible())
)
).toBe(true)
})
test.afterEach(async ({ orcaPage }) => {
await setToggleBinding(orcaPage, null)
})
test('Settings exposes the unbound action and records an assigned shortcut', async ({
orcaPage
}) => {
await orcaPage.evaluate(async () => {
await window.__store?.getState().updateSettings({ uiLanguage: 'en' })
window.__store?.getState().openSettingsPage()
})
await orcaPage.getByPlaceholder('Search settings').fill('shortcuts')
const search = orcaPage.getByPlaceholder('Search command or keys')
await expect(search).toBeVisible()
await search.fill('Toggle Child Workspaces')
await expect(orcaPage.getByText('Toggle Child Workspaces', { exact: true })).toBeVisible()
const add = orcaPage.getByRole('button', { name: 'Add shortcut for Toggle Child Workspaces' })
await expect(add).toBeVisible()
await captureEvidence(orcaPage, 'child-toggle-settings-unbound.png')
await add.click()
const recorder = orcaPage.locator('[data-shortcut-recorder-active]')
await expect(recorder).toBeVisible()
await recorder.press(CHORD)
await expect(
orcaPage.getByRole('button', { name: 'Change shortcut for Toggle Child Workspaces' })
).toBeVisible()
await captureEvidence(orcaPage, 'child-toggle-settings-assigned.png')
})
test('hides and shows children of the active or hovered workspace', async ({ orcaPage }) => {
const { parentId, childId } = await seedLineageScenario(orcaPage)
await setToggleBinding(orcaPage, ['Mod+Alt+H'])
const parentRow = sidebarWorktreeRow(orcaPage, parentId)
const childRow = sidebarWorktreeRow(orcaPage, childId)
await parentRow.click()
await expect(parentRow).toHaveAttribute('aria-current', 'page')
await expect(childRow).toBeVisible()
await captureEvidence(orcaPage, 'child-toggle-sidebar-expanded.png')
// No hovered card: the active parent is the target.
await movePointerOffSidebar(orcaPage)
await orcaPage.keyboard.press(CHORD)
await expect(childRow).toBeHidden()
await expect(parentRow.getByRole('button', { name: 'Show 1 child workspace' })).toBeVisible()
await captureEvidence(orcaPage, 'child-toggle-sidebar-collapsed.png')
await orcaPage.keyboard.press(CHORD)
await expect(childRow).toBeVisible()
await expect(parentRow.getByRole('button', { name: 'Hide 1 child workspace' })).toBeVisible()
await childRow.click()
await expect(childRow).toHaveAttribute('aria-current', 'page')
await movePointerOffSidebar(orcaPage)
await orcaPage.keyboard.press(CHORD)
await expect(childRow).toBeHidden()
await orcaPage.keyboard.press(CHORD)
await expect(childRow).toBeVisible()
await parentRow.click()
// A hovered leaf child folds its parent.
await childRow.hover()
await orcaPage.keyboard.press(CHORD)
await expect(childRow).toBeHidden()
await expect(parentRow.getByRole('button', { name: 'Show 1 child workspace' })).toBeVisible()
// A hovered parent unfolds its own children.
await parentRow.hover()
await orcaPage.keyboard.press(CHORD)
await expect(childRow).toBeVisible()
})
test('a modal keeps its shortcut input while the parent chip is visible', async ({
orcaPage
}) => {
const { parentId, childId } = await seedLineageScenario(orcaPage)
await setToggleBinding(orcaPage, ['Mod+Alt+H'])
await orcaPage.evaluate(() => window.__store?.getState().openModal('worktree-palette'))
await expect(orcaPage.getByRole('dialog')).toBeVisible()
await orcaPage.getByRole('dialog').evaluate((dialog) => {
dialog.setAttribute('tabindex', '-1')
dialog.focus()
})
await orcaPage.keyboard.press(CHORD)
await expect(
sidebarWorktreeRow(orcaPage, parentId).getByRole('button', {
name: 'Hide 1 child workspace',
includeHidden: true
})
).toBeVisible()
await expect(sidebarWorktreeRow(orcaPage, childId)).toBeVisible()
await orcaPage.keyboard.press('Escape')
})
test('a hovered parent takes precedence over another active family', async ({ orcaPage }) => {
const { parentId, childId } = await seedLineageScenario(orcaPage)
await setToggleBinding(orcaPage, ['Mod+Alt+H'])
const other = await orcaPage.evaluate(
({ parentId, childId }) => {
const store = window.__store
if (!store) {
throw new Error('Missing store')
}
const state = store.getState()
const worktrees = Object.values(state.worktreesByRepo).flat()
const parent = worktrees.find((worktree) => worktree.id === parentId)
const child = worktrees.find((worktree) => worktree.id === childId)
const edge = state.worktreeLineageById[childId]
if (!parent || !child || !edge) {
throw new Error('Missing seeded family')
}
const otherParent = {
...parent,
id: `${parentId}-other`,
instanceId: 'other-parent',
displayName: 'Another parent'
}
const otherChild = {
...child,
id: `${childId}-other`,
instanceId: 'other-child',
displayName: 'Another child'
}
store.setState({
worktreesByRepo: {
...state.worktreesByRepo,
[parent.repoId]: [...state.worktreesByRepo[parent.repoId], otherParent, otherChild]
},
worktreeLineageById: {
...state.worktreeLineageById,
[otherChild.id]: {
...edge,
worktreeId: otherChild.id,
worktreeInstanceId: otherChild.instanceId,
parentWorktreeId: otherParent.id,
parentWorktreeInstanceId: otherParent.instanceId
}
}
})
return { parentId: otherParent.id, childId: otherChild.id }
},
{ parentId, childId }
)
await sidebarWorktreeRow(orcaPage, other.parentId).hover()
await orcaPage.keyboard.press(CHORD)
await expect(sidebarWorktreeRow(orcaPage, other.childId)).toBeHidden()
await expect(sidebarWorktreeRow(orcaPage, childId)).toBeVisible()
await captureEvidence(orcaPage, 'child-toggle-hover-precedence.png')
})
test('a focused floating workspace keeps the shortcut input', async ({ orcaPage }) => {
const { parentId, childId } = await seedLineageScenario(orcaPage)
await setToggleBinding(orcaPage, ['Mod+Alt+H'])
await orcaPage.getByRole('button', { name: 'Show floating workspace', exact: true }).click()
const panel = orcaPage.locator('[data-floating-terminal-panel][aria-hidden="false"]')
await expect(panel).toBeVisible()
await panel.locator('[data-floating-terminal-shortcut-surface]').first().focus()
await orcaPage.keyboard.press(CHORD)
await expect(
sidebarWorktreeRow(orcaPage, parentId).getByRole('button', { name: 'Hide 1 child workspace' })
).toBeVisible()
await expect(sidebarWorktreeRow(orcaPage, childId)).toBeVisible()
await captureEvidence(orcaPage, 'child-toggle-floating-guard.png')
})
test('a stale child instance leaves no chip and does not claim the chord', async ({
orcaPage
}) => {
const { parentId, childId } = await seedLineageScenario(orcaPage)
await setToggleBinding(orcaPage, ['Mod+Alt+H'])
await orcaPage.evaluate((childId) => {
window.__store?.setState((state) => ({
worktreesByRepo: Object.fromEntries(
Object.entries(state.worktreesByRepo).map(([repoId, rows]) => [
repoId,
rows.map((row) =>
row.id === childId ? { ...row, instanceId: 'recreated-child' } : row
)
])
)
}))
}, childId)
const parentRow = sidebarWorktreeRow(orcaPage, parentId)
await expect(parentRow.getByRole('button', { name: /child workspace/ })).toHaveCount(0)
await parentRow.click()
await orcaPage.locator('[data-worktree-sidebar]').focus()
await movePointerOffSidebar(orcaPage)
await observeShortcutClaim(orcaPage)
await orcaPage.keyboard.press(CHORD)
await expect(orcaPage.locator('body')).toHaveAttribute('data-child-toggle-claimed', 'false')
await expect(sidebarWorktreeRow(orcaPage, childId)).toBeVisible()
await captureEvidence(orcaPage, 'child-toggle-stale-no-chip.png')
})
test('folder cards pass the chord through while another family has a chip', async ({
orcaPage
}) => {
const { parentId, childId } = await seedLineageScenario(orcaPage)
await setToggleBinding(orcaPage, ['Mod+Alt+H'])
const folderKey = 'folder:child-toggle-folder'
await orcaPage.evaluate(() => {
const store = window.__store
const repo = store?.getState().repos[0]
if (!store || !repo) {
throw new Error('Missing seeded project')
}
store.setState({
projectGroups: [
{
id: 'child-toggle-group',
name: 'Folder project',
parentPath: repo.path,
parentGroupId: null,
createdFrom: 'manual',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
],
folderWorkspaces: [
{
id: 'child-toggle-folder',
projectGroupId: 'child-toggle-group',
name: 'Folder without a child chip',
folderPath: repo.path,
executionHostId: 'local',
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 1,
createdAt: 1,
updatedAt: 1
}
]
})
})
const folder = sidebarWorktreeRow(orcaPage, folderKey)
await expect(folder).toBeVisible()
await expect(folder.getByRole('button', { name: /child workspace/ })).toHaveCount(0)
await sidebarWorktreeRow(orcaPage, parentId).click()
await orcaPage.locator('[data-worktree-sidebar]').focus()
await folder.hover()
await observeShortcutClaim(orcaPage)
await orcaPage.keyboard.press(CHORD)
await expect(orcaPage.locator('body')).toHaveAttribute('data-child-toggle-claimed', 'false')
await expect(sidebarWorktreeRow(orcaPage, childId)).toBeVisible()
await folder.click()
await expect(folder).toHaveAttribute('aria-current', 'page')
await orcaPage.locator('[data-worktree-sidebar]').focus()
await movePointerOffSidebar(orcaPage)
await orcaPage.keyboard.press(CHORD)
await expect(orcaPage.locator('body')).toHaveAttribute('data-child-toggle-claimed', 'false')
await expect(sidebarWorktreeRow(orcaPage, childId)).toBeVisible()
await captureEvidence(orcaPage, 'child-toggle-folder-pass-through.png')
})
test('pinned duplicates share the chip state and record the sidebar anchor', async ({
orcaPage
}) => {
const { parentId, childId } = await seedLineageScenario(orcaPage)
await setToggleBinding(orcaPage, ['Mod+Alt+H'])
await orcaPage.evaluate(async (parentId) => {
await window.__store?.getState().updateSettings({ showPinnedWorktreesInGroups: true })
window.__store?.setState((state) => ({
worktreesByRepo: Object.fromEntries(
Object.entries(state.worktreesByRepo).map(([repoId, rows]) => [
repoId,
rows.map((row) => (row.id === parentId ? { ...row, isPinned: true } : row))
])
)
}))
const sidebar = document.querySelector<HTMLElement>('[data-worktree-sidebar]')
sidebar?.addEventListener('orca-record-virtualized-scroll-anchor', () => {
sidebar.dataset.anchorRecords = String(Number(sidebar.dataset.anchorRecords ?? 0) + 1)
})
}, parentId)
const chips = orcaPage.getByRole('button', { name: 'Hide 1 child workspace', exact: true })
await expect(chips).toHaveCount(2)
const natural = orcaPage
.locator('[data-worktree-section-key="all"]')
.filter({ has: chips })
.first()
await natural.hover()
await orcaPage.keyboard.press(CHORD)
await expect(
orcaPage.getByRole('button', { name: 'Show 1 child workspace', exact: true })
).toHaveCount(2)
await expect(orcaPage.locator('[data-worktree-sidebar]')).toHaveAttribute(
'data-anchor-records',
'1'
)
await expect(sidebarWorktreeRow(orcaPage, childId)).toBeHidden()
await orcaPage
.getByRole('button', { name: 'Show 1 child workspace', exact: true })
.first()
.click()
await expect(chips).toHaveCount(2)
await expect(orcaPage.locator('[data-worktree-sidebar]')).toHaveAttribute(
'data-anchor-records',
'2'
)
await captureEvidence(orcaPage, 'child-toggle-pinned-chip-parity.png')
})
test('does nothing while a sidebar filter hides every child', async ({ orcaPage }) => {
const { parentId, childId } = await seedLineageScenario(orcaPage)
await setToggleBinding(orcaPage, ['Mod+Alt+H'])
await seedWorkspaceLiveTerminal(orcaPage, parentId)
const parentRow = sidebarWorktreeRow(orcaPage, parentId)
const childRow = sidebarWorktreeRow(orcaPage, childId)
await parentRow.click()
await expect(parentRow).toHaveAttribute('aria-current', 'page')
const readCollapsedGroups = (): Promise<string[]> =>
orcaPage.evaluate(() => [...(window.__store?.getState().collapsedGroups ?? [])].sort())
const setShowSleeping = (show: boolean): Promise<void> =>
orcaPage.evaluate((show) => window.__store?.getState().setShowSleepingWorkspaces(show), show)
// The child has no live terminal, so hiding sleeping workspaces removes it and its chip.
await setShowSleeping(false)
try {
await expect(childRow).toBeHidden()
await expect(parentRow.getByRole('button', { name: /child workspace/ })).toHaveCount(0)
const before = await readCollapsedGroups()
// Checked after each press: two toggles of one key would cancel out.
await movePointerOffSidebar(orcaPage)
await orcaPage.keyboard.press(CHORD)
expect(await readCollapsedGroups()).toEqual(before)
await parentRow.hover()
await orcaPage.keyboard.press(CHORD)
expect(await readCollapsedGroups()).toEqual(before)
} finally {
await setShowSleeping(true)
}
await expect(childRow).toBeVisible()
})
})
test('child-workspace shortcut and chip collapse survive an app restart', async ({
testRepoPath
}, testInfo) => {
const session = createRestartSession(testInfo)
let app: Awaited<ReturnType<typeof session.launch>>['app'] | undefined
try {
const first = await session.launch()
app = first.app
await attachRepoAndOpenTerminal(first.page, testRepoPath)
await waitForLineageWorktrees(first.page)
const family = await seedLineageScenario(first.page)
await setToggleBinding(first.page, ['Mod+Alt+H'])
await sidebarWorktreeRow(first.page, family.parentId).click()
await movePointerOffSidebar(first.page)
await first.page.keyboard.press(CHORD)
await expect(sidebarWorktreeRow(first.page, family.childId)).toBeHidden()
await session.close(app)
app = undefined
const second = await session.launch()
app = second.app
await attachRepoAndOpenTerminal(second.page, testRepoPath)
await waitForLineageWorktrees(second.page)
// Resetting the grouping mode also clears the persisted collapsed groups.
await seedLineageScenario(second.page, { preserveGrouping: true })
await expect(
sidebarWorktreeRow(second.page, family.parentId).getByRole('button', {
name: 'Show 1 child workspace'
})
).toBeVisible()
await expect(sidebarWorktreeRow(second.page, family.childId)).toBeHidden()
await movePointerOffSidebar(second.page)
await second.page.keyboard.press(CHORD)
await expect(sidebarWorktreeRow(second.page, family.childId)).toBeVisible()
await captureEvidence(second.page, 'child-toggle-restart-persistence.png')
} finally {
if (app) {
await session.close(app)
}
await session.dispose()
}
})