Sidebar: land the switch-on fixes from the flip branch on main, switch still off (#31466)

* fix(sidebar): land flip-a's switch-on fixes on main, still dark (no flip)

The fixes found while the sidebar ran from main's feed, without turning
that on: ORCA_SIDEBAR_INPUTS_IS_MAIN stays false, and none of flip-a's
stop-gaps come along.

- Main stops counting a closed tab's terminal once it was asked to stop
  and no pane shows it; a failed or refused stop takes the request back,
  and a host out of contact stays unverifiable.
- Main fetches a paired server's rows with the transport's normal bound,
  so a server slower than 5 s still lists.
- A deferred unread raise flips the folder row it was decided for, and a
  read while the owner check is out cancels it.
- Phone: no worktree.ps flash before the feed; a host's last installed
  feed stands in while a fresh read is out; it highlights its own
  last-opened workspace and a fold never scrolls; projects file under
  their project group with the desktop's indent; the collapse spinner
  shows only after 500 ms; "Reopen to update" keeps the rows on screen.
- e2e helpers and journeys for the switch-on suite.

* fix(sidebar): keep an owed relay stop marked, clear a thrown stop, and prune pending unread raises

- A stop-and-wait that throws takes its stop request back, like one that fails.
- A final stop the legacy relay still owes stays marked, since the relay retries it.
- Pending unread raises are tracked only while one waits, so the map no longer grows.
- The paired-server fallback poll test gives its third fetch a reply and asserts live.
This commit is contained in:
Jinwoo Hong
2026-10-21 12:24:27 -04:00
committed by GitHub
parent 50b07c0e8f
commit 92bbb380a6
53 changed files with 2404 additions and 299 deletions
+17 -3
View File
@@ -21,6 +21,8 @@ import {
type WorkspaceViewSettings
} from '../../../mobile/src/worktree/workspace-view-settings'
import type { RpcResponse } from '../../../mobile/src/transport/types'
import { NODE_PLATFORM_NAMES } from '../../../mobile/src/transport/mobile-runtime-host-platform'
import type { LastVisitedWorktreeOnHost } from '../../../mobile/src/worktree/host-list-highlight'
import type { Section } from '../../../mobile/src/worktree/workspace-list-types'
import type { PairedMobileClient, PairedMobileSocket } from './paired-mobile-client'
@@ -90,7 +92,11 @@ function socketEventClient(socket: PairedMobileSocket) {
* refresh lifecycle, feed reader, the shared builder and its section mapping. Reads happen only
* when that lifecycle makes them; an assertion reads what the page last installed.
*/
export async function openPhoneFeedList(phone: PairedMobileClient) {
export async function openPhoneFeedList(
phone: PairedMobileClient,
/** The workspace this phone last opened on the host: its list's only highlight. */
lastVisited: LastVisitedWorktreeOnHost | null = null
) {
const socket = await phone.openSocket()
const port = socketPort(socket)
let read: HostSidebarFeedRead = { installed: null, unavailable: false }
@@ -118,7 +124,10 @@ export async function openPhoneFeedList(phone: PairedMobileClient) {
await port.sendRequest('host.platform'),
hostPlatformRead.interpret
)
const hostPlatform = platformRead.kind === 'answered' ? platformRead.value : null
const answered = platformRead.kind === 'answered' ? platformRead.value : null
// Why matched, not read: this helper and the page may resolve different zod copies, so the
// reply's inferred type does not carry across.
const hostPlatform = NODE_PLATFORM_NAMES.find((name) => name === answered) ?? null
await syncViewSettings()
const stop = startHostWorktreeRefresh({
client: socketEventClient(socket),
@@ -156,7 +165,7 @@ export async function openPhoneFeedList(phone: PairedMobileClient) {
)
return buildHostFeedSections({
rows: built.sectionRows,
activeRowKeys: built.activeRowKeys,
lastVisited,
liveRows: [],
repoHostIdByRepoId: new Map(),
search: ''
@@ -186,6 +195,11 @@ export async function openPhoneFeedList(phone: PairedMobileClient) {
}
}
/** An outline without its highlight marks: the phone highlights its own last-opened workspace. */
export function outlineRows(outline: ListOutline): ListOutline {
return outline.map((line) => line.replace(/ ACTIVE$/, ''))
}
/** The rows the desktop sidebar has rendered, in order, in the outline's terms. */
export async function desktopSidebarOutline(page: Page): Promise<ListOutline> {
return page.evaluate(() => {
+29
View File
@@ -0,0 +1,29 @@
import type { Page } from '@stablyai/playwright-test'
import { expect } from '@stablyai/playwright-test'
import { getRepoExecutionHostId } from '../../../src/shared/execution-host'
import { getRepoHostIdentityForParts } from '../../../src/shared/repo-host-identity'
/**
* Dismisses the setup-script prompt for every loaded repo, as its Dismiss button does. The seeded
* repo's detected setup command shows the card over the sidebar's lower rows, and it re-inspects
* and reappears whenever the active workspace changes, so a visibility check can miss it.
*/
export async function dismissSetupScriptPrompts(page: Page): Promise<void> {
const repos = await page.evaluate(() =>
(window.__store?.getState().repos ?? []).map((repo) => ({
id: repo.id,
connectionId: repo.connectionId ?? null,
executionHostId: repo.executionHostId ?? null
}))
)
const identities = repos.map((repo) =>
getRepoHostIdentityForParts(repo.id, getRepoExecutionHostId(repo))
)
await page.evaluate((keys) => {
const state = window.__store?.getState()
for (const key of keys) {
state?.dismissSetupScriptPrompt(key)
}
}, identities)
await expect(page.locator('[data-setup-script-prompt-layer]')).toHaveCount(0)
}
@@ -7,6 +7,7 @@ import type { Locator, Page } from '@stablyai/playwright-test'
import { expect } from '@stablyai/playwright-test'
import { ensureTerminalVisible } from './store'
import { waitForActivePanePtyId, waitForActiveTerminalManager } from './terminal'
import { dismissSetupScriptPrompts } from './sidebar-setup-prompt'
export type WorkspaceSample = {
livePtyCount: number
@@ -111,6 +112,7 @@ export async function sleepWorkspaceViaSidebar(page: Page, worktreeId: string):
const row = rowLocator(page, worktreeId)
await expect(row).toBeVisible()
await row.scrollIntoViewIfNeeded()
await dismissSetupScriptPrompts(page)
const scope = row.locator('[data-worktree-context-menu-scope="worktree"]').first()
const target = (await scope.count()) > 0 ? scope : row
await target.click({ button: 'right' })
@@ -123,6 +125,7 @@ export async function activateWorkspaceByClick(page: Page, worktreeId: string):
const row = rowLocator(page, worktreeId)
await expect(row).toBeVisible()
await row.scrollIntoViewIfNeeded()
await dismissSetupScriptPrompts(page)
await row.click()
await expect
.poll(() => page.evaluate(() => window.__store?.getState().activeWorktreeId ?? null), {
+138 -72
View File
@@ -1,6 +1,7 @@
/**
* The desktop sidebar drawn from main's feed (ORCA_SIDEBAR_INPUTS=main): it follows main, not the
* window's own store, and a drag, create, open or collapse still lands in the frame it is made.
* window's own store, and a drag, create, open or collapse still shows at once from the window's
* own state.
*/
import type { Page } from '@stablyai/playwright-test'
import { expect, test } from './helpers/orca-app'
@@ -25,45 +26,85 @@ function renderedOrder(page: Page): Promise<string[]> {
)
}
/** `sinceInput`: ms since the first pointerup or click after the log started. */
type Frame = { ids: string[]; shows: boolean; sinceInput: number | null }
/** One committed sidebar DOM state. `afterInput`: committed once the first input began.
* `inInputTask`: committed within the task of an input, before any later task could run. */
type DomState = {
ids: string[]
shows: boolean
current: string | null
afterInput: boolean
inInputTask: boolean
}
type FrameLog = { stop: () => Promise<Frame[]> }
type DomLog = { stop: () => Promise<{ states: DomState[]; inputs: string[] }> }
/** Records, every animation frame, the rendered row ids and whether the sidebar shows `text`. */
async function startFrameLog(page: Page, text = ''): Promise<FrameLog> {
/**
* Records every committed state of the sidebar's DOM (rows top to bottom, whether it shows `text`,
* the current row) and whether it landed in the task of an input. Why that bound: the window
* applies its own state in the input's task, while main's answer needs an IPC reply, which is a
* later task, so it holds on any runner load. No paints: a hidden window may never paint.
*/
async function startDomLog(page: Page, text = ''): Promise<DomLog> {
const handle = await page.evaluateHandle(
({ selector, wanted }) => {
const frames: Frame[] = []
let running = true
let inputAt: number | null = null
const onInput = (): void => {
inputAt ??= performance.now()
}
window.addEventListener('pointerup', onInput, true)
window.addEventListener('mouseup', onInput, true)
window.addEventListener('click', onInput, true)
const sample = (): void => {
if (!running) {
return
const states: (Omit<DomState, 'afterInput' | 'inInputTask'> & { at: number })[] = []
const inputs: { type: string; at: number; nextTaskAt: number }[] = []
// Why the event's own time: the app's handlers can commit, and this log record the state,
// before this listener runs. A posted message runs in the next task, which ends the window.
const onInput = (event: Event): void => {
const input = { type: event.type, at: event.timeStamp, nextTaskAt: Infinity }
inputs.push(input)
const channel = new MessageChannel()
channel.port1.onmessage = () => {
input.nextTaskAt = performance.now()
channel.port1.close()
}
const sidebar = document.querySelector('[data-worktree-sidebar]')
frames.push({
ids: [...document.querySelectorAll<HTMLElement>(selector)]
.sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top)
.map((row) => row.dataset.worktreeId ?? ''),
shows: wanted !== '' && (sidebar?.textContent ?? '').includes(wanted),
sinceInput: inputAt === null ? null : performance.now() - inputAt
})
requestAnimationFrame(sample)
channel.port2.postMessage(null)
}
requestAnimationFrame(sample)
// Why drop and dragend: a native drag ends in them, with no mouseup on Linux.
const events = ['pointerup', 'mouseup', 'click', 'drop', 'dragend']
for (const type of events) {
window.addEventListener(type, onInput, true)
}
const sidebar = document.querySelector('[data-worktree-sidebar]')
const record = (): void => {
const rows = [...document.querySelectorAll<HTMLElement>(selector)].sort(
(a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top
)
states.push({
ids: rows.map((row) => row.dataset.worktreeId ?? ''),
shows: wanted !== '' && (sidebar?.textContent ?? '').includes(wanted),
current:
rows.find((row) => row.getAttribute('aria-current') === 'page')?.dataset.worktreeId ??
null,
at: performance.now()
})
}
// A mutation callback runs after each commit, so it sees every state React commits.
const observer = new MutationObserver(record)
if (sidebar) {
observer.observe(sidebar, {
subtree: true,
childList: true,
attributes: true,
characterData: true
})
}
record()
return () => {
running = false
window.removeEventListener('pointerup', onInput, true)
window.removeEventListener('mouseup', onInput, true)
window.removeEventListener('click', onInput, true)
return frames
observer.disconnect()
for (const type of events) {
window.removeEventListener(type, onInput, true)
}
const firstInputAt = inputs[0]?.at ?? Infinity
return {
states: states.map(({ at, ...state }) => ({
...state,
afterInput: at >= firstInputAt,
inInputTask: inputs.some((input) => input.at <= at && at <= input.nextTaskAt)
})),
inputs: inputs.map((input) => input.type)
}
}
},
{ selector: ROWS, wanted: text }
@@ -71,6 +112,11 @@ async function startFrameLog(page: Page, text = ''): Promise<FrameLog> {
return { stop: () => handle.evaluate((stop) => stop()) }
}
/** The index of the first state after the input that satisfies `reached`, or -1. */
function firstAfterInput(states: DomState[], reached: (state: DomState) => boolean): number {
return states.findIndex((state) => state.afterInput && reached(state))
}
async function dragBefore(page: Page, sourceId: string, targetId: string): Promise<void> {
const source = page.locator(`${ROWS}[data-worktree-id=${JSON.stringify(sourceId)}]`).first()
const target = page.locator(`${ROWS}[data-worktree-id=${JSON.stringify(targetId)}]`).first()
@@ -109,42 +155,53 @@ test('the sidebar lists main’s inputs and keeps drag, create, open and collaps
await expect.poll(async () => (await ours()).length, { timeout: 30_000 }).toBe(3)
await orcaPage.screenshot({ path: testInfo.outputPath(`list.png`) })
// Empty the window's own catalog for one frame: the rows still listed come from main. No
// refetch can land within the frame.
const listedInEmptiedFrame = await orcaPage.evaluate(async (selector) => {
// Empty the window's own catalog for one task: the rows still listed come from main. No
// refetch can answer before the next task.
const listedWhileEmptied = await orcaPage.evaluate(async (selector) => {
window.__store!.setState({ worktreesByRepo: {} })
await new Promise((resolve) => requestAnimationFrame(resolve))
await new Promise((resolve) => setTimeout(resolve, 0))
return document.querySelectorAll(selector).length
}, ROWS)
expect(listedInEmptiedFrame).toBeGreaterThan(0)
expect(listedWhileEmptied).toBeGreaterThan(0)
await orcaPage.evaluate(() => window.__store!.getState().fetchAllWorktrees())
await expect.poll(async () => (await ours()).length).toBe(3)
// Drag: every frame after the drop shows the new order; none snaps back.
// Drag: the new order shows in the drop's own task, and no later state snaps back.
const before = await ours()
const source = before[2]!
const dragLog = await startFrameLog(orcaPage)
const dragLog = await startDomLog(orcaPage)
await dragBefore(orcaPage, source, before[0]!)
const expected = [source, before[0]!, before[1]!]
await expect.poll(ours).toEqual(expected)
await orcaPage.waitForTimeout(400)
// From the first frame painted after the drop, every frame shows the new order.
const afterDrop = (await dragLog.stop())
.filter(({ sinceInput }) => sinceInput !== null)
.map(({ ids }) => ids.filter((id) => created.includes(id)).join())
expect(afterDrop.length).toBeGreaterThan(0)
expect(new Set(afterDrop)).toEqual(new Set([expected.join()]))
const drag = await dragLog.stop()
const oursIn = (state: DomState): string =>
state.ids.filter((id) => created.includes(id)).join()
const dropShown = firstAfterInput(drag.states, (state) => oursIn(state) === expected.join())
expect(dropShown, `inputs seen: ${drag.inputs.join() || 'none'}`).toBeGreaterThanOrEqual(0)
expect(drag.states[dropShown]!.inInputTask).toBe(true)
expect(
drag.states
.slice(dropShown)
.map(oursIn)
.filter((order) => order !== expected.join())
).toEqual([])
await orcaPage.screenshot({ path: testInfo.outputPath(`after-drag.png`) })
// Open: the clicked row is highlighted in the frame of the click.
// Open: the clicked row is highlighted in the click's own task.
const openId = expected[1]!
const openLog = await startDomLog(orcaPage)
await orcaPage
.locator(`${ROWS}[data-worktree-id=${JSON.stringify(openId)}]`)
.first()
.click()
await expect(
orcaPage.locator(`${ROWS}[data-worktree-id=${JSON.stringify(openId)}][aria-current="page"]`)
).toHaveCount(1, { timeout: 1_000 })
).toHaveCount(1)
const openStates = (await openLog.stop()).states
const opened = firstAfterInput(openStates, (state) => state.current === openId)
expect(opened).toBeGreaterThanOrEqual(0)
expect(openStates[opened]!.inInputTask).toBe(true)
// Cmd/Ctrl+1–9 number the rendered rows.
const rendered = await renderedOrder(orcaPage)
@@ -154,29 +211,27 @@ test('the sidebar lists main’s inputs and keeps drag, create, open and collaps
.poll(() => orcaPage.evaluate(() => window.__store!.getState().activeWorktreeId))
.toBe(rendered[1])
// Collapse: the section's rows leave in the next frame, without waiting for main.
// Collapse: the section's rows leave in the click's own task, without waiting for main.
await orcaPage.evaluate(() => window.__store!.getState().setGroupBy('repo'))
await expect.poll(async () => (await ours()).length).toBe(3)
const header = orcaPage.locator('[data-worktree-sidebar] [data-repo-header-id][aria-expanded]')
const collapseLog = await startFrameLog(orcaPage)
const collapseLog = await startDomLog(orcaPage)
await header.first().click()
await expect.poll(async () => (await ours()).length).toBe(0)
// A slow runner can settle the poll before any frame after the click is painted.
await orcaPage.evaluate(() => new Promise((resolve) => requestAnimationFrame(resolve)))
const collapseFrames = await collapseLog.stop()
// The first frame painted after the click already has no rows.
expect(
collapseFrames
.find(({ sinceInput }) => sinceInput !== null)
?.ids.some((id) => created.includes(id))
).toBe(false)
const collapseStates = (await collapseLog.stop()).states
const collapsed = firstAfterInput(
collapseStates,
(state) => !state.ids.some((id) => created.includes(id))
)
expect(collapsed).toBeGreaterThanOrEqual(0)
expect(collapseStates[collapsed]!.inInputTask).toBe(true)
await header.first().click()
await expect.poll(async () => (await ours()).length).toBe(3)
// Create: the pending row hands over to the new row with no frame showing neither.
// Create: the pending row hands over to the new row with no state showing neither.
await orcaPage.evaluate(() => window.__store!.getState().setGroupBy('none'))
const name = `reads-main-new-${Date.now()}`
const createLog = await startFrameLog(orcaPage, name)
const createLog = await startDomLog(orcaPage, name)
await openSidebarWorkspaceComposer(orcaPage)
const dialog = orcaPage.getByRole('dialog', { name: /Create (Workspace|Worktree)/i })
await dialog.getByPlaceholder(/Type a name/i).fill(name)
@@ -195,10 +250,14 @@ test('the sidebar lists main’s inputs and keeps drag, create, open and collaps
created.push(newId)
await expect.poll(async () => (await renderedOrder(orcaPage)).includes(newId)).toBe(true)
await orcaPage.waitForTimeout(300)
const createFrames = await createLog.stop()
const pendingShown = createFrames.findIndex(({ shows }) => shows)
const createStates = (await createLog.stop()).states
const pendingShown = createStates.findIndex(({ shows }) => shows)
expect(pendingShown).toBeGreaterThanOrEqual(0)
const handover = createFrames.slice(pendingShown)
// The name shows as the pending row before the new row exists.
expect(createStates[pendingShown]!.ids).not.toContain(newId)
// The create click's own task lists the pending row, without waiting for main.
expect(createStates[pendingShown]!.inInputTask).toBe(true)
const handover = createStates.slice(pendingShown)
expect(handover.filter(({ ids, shows }) => !shows && !ids.includes(newId))).toEqual([])
const appeared = handover.findIndex(({ ids }) => ids.includes(newId))
expect(handover.slice(appeared).every(({ ids }) => ids.includes(newId))).toBe(true)
@@ -237,17 +296,24 @@ test('the sidebar lists main’s inputs and keeps drag, create, open and collaps
)
.toBe(true)
await scroller.evaluate((element) => element.scrollTo({ top: element.scrollHeight }))
const lastFullyShown = (): Promise<string> =>
// The list's last row, once painted at the bottom: rows render after the scroll, so until
// then the last mounted row sits mid-viewport, within overscan of the top.
const lastRowAtBottom = (): Promise<string> =>
scroller.evaluate((element, selector) => {
const box = element.getBoundingClientRect()
const shown = [...element.querySelectorAll<HTMLElement>(selector)].filter((row) => {
const rect = row.getBoundingClientRect()
return rect.top >= box.top && rect.bottom <= box.bottom + 1
})
return shown.at(-1)?.dataset.worktreeId ?? ''
const last = [...element.querySelectorAll<HTMLElement>(selector)]
.map((row) => ({ row, rect: row.getBoundingClientRect() }))
.sort((left, right) => left.rect.top - right.rect.top)
.at(-1)
if (!last || last.rect.top < box.top || last.rect.bottom > box.bottom + 1) {
return ''
}
return box.bottom - last.rect.bottom < last.rect.height
? (last.row.dataset.worktreeId ?? '')
: ''
}, ROWS)
await expect.poll(lastFullyShown).not.toBe('')
const bottomId = await lastFullyShown()
await expect.poll(lastRowAtBottom).not.toBe('')
const bottomId = await lastRowAtBottom()
const bottomRow = orcaPage.locator(`${ROWS}[data-worktree-id=${JSON.stringify(bottomId)}]`)
await expect(bottomRow).toBeInViewport()
await scroller.evaluate((element) => element.scrollTo({ top: 0 }))
+142 -13
View File
@@ -3,7 +3,8 @@
* matches the desktop sidebar row for row: headers, order, nesting, the single highlight and
* collapse. Desktop, then a desktop that is a paired server's client.
*/
import { mkdirSync, writeFileSync } from 'node:fs'
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import os from 'node:os'
import { execFileSync } from 'node:child_process'
import path from 'node:path'
import type { Page } from '@stablyai/playwright-test'
@@ -16,6 +17,7 @@ import { pairMobileClient } from './helpers/paired-mobile-client'
import { launchPhoneMirrorTopology } from './helpers/phone-mirror-topology'
import {
desktopSidebarOutline,
outlineRows,
openPhoneFeedList,
type ListOutline
} from './helpers/phone-feed-list'
@@ -25,7 +27,12 @@ type PhoneList = Awaited<ReturnType<typeof openPhoneFeedList>>
const AddedRepoSchema = z.object({ repo: z.object({ id: z.string() }) })
/** Both sides inside one poll: either may still be catching up with the last change. */
async function expectPhoneMatchesDesktop(page: Page, phone: PhoneList): Promise<ListOutline> {
/** Waits until both sides agree on a list that already shows the change (`showsChange`). */
async function expectPhoneMatchesDesktop(
page: Page,
phone: PhoneList,
showsChange: (outline: ListOutline) => boolean = () => true
): Promise<ListOutline> {
let desktop: ListOutline = []
let shown: ListOutline = []
await expect
@@ -33,12 +40,15 @@ async function expectPhoneMatchesDesktop(page: Page, phone: PhoneList): Promise<
async () => {
desktop = await desktopSidebarOutline(page)
shown = await phone.outline()
return shown.join('\n') === desktop.join('\n')
// Why the change too: the phone hears a change later, so both still agree on the old list.
return (
outlineRows(shown).join('\n') === outlineRows(desktop).join('\n') && showsChange(shown)
)
},
{ timeout: 30_000 }
)
.toBe(true)
.catch(() => expect(shown).toEqual(desktop))
.catch(() => expect(outlineRows(shown)).toEqual(outlineRows(desktop)))
expect(shown.some((line) => line.startsWith('ITEM '))).toBe(true)
return shown
}
@@ -77,22 +87,31 @@ test.describe('switch on', () => {
setupDecision: 'skip'
})
}
const list = await openPhoneFeedList(phone)
// The phone last opened the desktop's workspace, so it highlights that row.
const list = await openPhoneFeedList(phone, { worktreeId: activeId })
try {
const first = await expectPhoneMatchesDesktop(orcaPage, list)
expect(first.filter((line) => line.endsWith(' ACTIVE'))).toHaveLength(1)
// A pin on the desktop reaches the phone through a notice, with a Pinned section.
await runtimeCall(orcaPage, 'worktree.set', { worktree: `id:${activeId}`, isPinned: true })
const pinned = await expectPhoneMatchesDesktop(orcaPage, list)
expect(pinned.some((line) => line.startsWith('HEADER Pinned'))).toBe(true)
const hasPinned = (outline: ListOutline): boolean =>
outline.some((line) => line.startsWith('HEADER Pinned'))
const pinned = await expectPhoneMatchesDesktop(orcaPage, list, hasPinned)
expect(hasPinned(pinned)).toBe(true)
// Pinned rows also in their groups: the open workspace highlights at both.
await orcaPage.evaluate(() =>
window.__store!.getState().updateSettings({ showPinnedWorktreesInGroups: true })
)
const duplicated = await expectPhoneMatchesDesktop(orcaPage, list)
expect(duplicated.filter((line) => line.endsWith(' ACTIVE'))).toHaveLength(2)
const activeCount = (outline: ListOutline): number =>
outline.filter((line) => line.endsWith(' ACTIVE')).length
const duplicated = await expectPhoneMatchesDesktop(
orcaPage,
list,
(outline) => activeCount(outline) === 2
)
expect(activeCount(duplicated)).toBe(2)
// The phone collapses one section with ui.setCollapsed; the desktop and the phone both fold it.
const project = (await list.sections())?.find((section) => section.repo)
@@ -107,7 +126,11 @@ test.describe('switch on', () => {
await expect
.poll(() => orcaPage.evaluate(() => [...window.__store!.getState().collapsedGroups]))
.toEqual([...before, project!.key])
const folded = await expectPhoneMatchesDesktop(orcaPage, list)
const folded = await expectPhoneMatchesDesktop(
orcaPage,
list,
(outline) => outline.length < duplicated.length
)
const foldedSection = (await list.sections())?.find((section) => section.key === project!.key)
expect(foldedSection?.data).toEqual([])
expect(foldedSection?.count).toBeGreaterThan(0)
@@ -121,14 +144,120 @@ test.describe('switch on', () => {
opId: { writer: 'e2e-phone', seq: 2 }
})
).toMatchObject({ ok: true })
await expectPhoneMatchesDesktop(orcaPage, list)
await expectPhoneMatchesDesktop(
orcaPage,
list,
(outline) => outline.length === duplicated.length
)
const unfolded = (await list.sections())?.find((section) => section.key === project!.key)
expect(unfolded?.data.length).toBeGreaterThan(0)
// Unpinned on the desktop: the Pinned section goes and one highlight is left.
await runtimeCall(orcaPage, 'worktree.set', { worktree: `id:${activeId}`, isPinned: false })
const unpinned = await expectPhoneMatchesDesktop(orcaPage, list)
expect(unpinned.filter((line) => line.endsWith(' ACTIVE'))).toHaveLength(1)
const unpinned = await expectPhoneMatchesDesktop(
orcaPage,
list,
(outline) => !hasPinned(outline)
)
expect(activeCount(unpinned)).toBe(1)
} finally {
list.close()
phone.dispose()
}
})
test('the phone files grouped projects under the desktop’s project group, and folds it with the desktop', async ({
orcaPage,
registerPostElectronShutdownCleanup
}) => {
test.setTimeout(180_000)
await waitForSessionReady(orcaPage)
const activeId = await waitForActiveWorktree(orcaPage)
const repoId = activeId.split('::')[0]
const folderPath = realpathSync(mkdtempSync(path.join(os.tmpdir(), 'orca-phone-group-')))
registerPostElectronShutdownCleanup(async () =>
rmSync(folderPath, { recursive: true, force: true })
)
const folder = AddedRepoSchema.parse(
await runtimeCall(orcaPage, 'repo.add', { path: folderPath, kind: 'folder' })
)
await expect
.poll(() =>
orcaPage.evaluate(
(id) => window.__store!.getState().repos.some((entry) => entry.id === id),
folder.repo.id
)
)
.toBe(true)
// A group holding a repo project and a folder project, the folder project first.
const moved = await orcaPage.evaluate(
async ({ repoId, folderId }) => {
const state = () => window.__store!.getState()
const group = await state().createProjectGroup('school', 'local')
if (!group) {
throw new Error('Expected a project group')
}
const repoById = (id: string) => {
const found = state().repos.find((entry) => entry.id === id)
if (!found) {
throw new Error(`Expected repo ${id}`)
}
return found
}
return [
await state().moveProjectToGroup(repoById(folderId), group.id, 0),
await state().moveProjectToGroup(repoById(repoId), group.id, 1)
]
},
{ repoId, folderId: folder.repo.id }
)
expect(moved).toEqual([true, true])
const phone = pairMobileClient(await createDesktopMobilePairingOffer(orcaPage))
const list = await openPhoneFeedList(phone)
try {
// The group, then the folder project, then the repo project's rows: the desktop's order.
const inGroup = (outline: ListOutline): boolean => {
const at = [
outline.indexOf('HEADER school'),
outline.indexOf(`HEADER ${path.basename(folderPath)}`),
outline.findIndex((line) => line.startsWith('ITEM ') && line.includes(repoId))
]
return at[0] !== -1 && at[0] < at[1] && at[1] < at[2]
}
const shown = await expectPhoneMatchesDesktop(orcaPage, list, inGroup)
expect(inGroup(shown)).toBe(true)
const sections = (await list.sections()) ?? []
const groupAt = sections.findIndex((section) => section.title === 'school')
expect(sections[groupAt]).toMatchObject({ isProjectGroup: true })
// The folder project, then the repo project, both one level in.
expect(
sections
.slice(groupAt + 1, groupAt + 3)
.map((section) => [section.repo?.id, section.projectGroupDepth])
).toEqual([
[folder.repo.id, 1],
[repoId, 1]
])
// The phone folds the group; the desktop folds it too, and both hide its projects.
const key = sections[groupAt]!.key
expect(
await list.request('ui.setCollapsed', {
key,
collapsed: true,
opId: { writer: 'e2e-phone-group', seq: 1 }
})
).toMatchObject({ ok: true })
// Every project sits in the group, so both lists fold down to its header alone.
await expect
.poll(async () => [await list.outline(), await desktopSidebarOutline(orcaPage)], {
timeout: 30_000
})
.toEqual([['HEADER school'], ['HEADER school']])
// The desktop unfolds it; the phone follows.
await orcaPage.evaluate((key) => window.__store!.getState().toggleCollapsedGroup(key), key)
await expectPhoneMatchesDesktop(orcaPage, list, (outline) => outline.length === shown.length)
} finally {
list.close()
phone.dispose()
+15 -1
View File
@@ -3,7 +3,13 @@ import { test, expect } from './helpers/orca-app'
import { postClaudeHook, readHookEndpoint } from './helpers/agent-hook-endpoint'
import { SIDEBAR_INPUTS_BUILD } from './helpers/sidebar-inputs-build'
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import { waitForActivePaneHookDescriptor, waitForActiveTerminalManager } from './helpers/terminal'
import {
execInTerminal,
waitForActivePaneHookDescriptor,
waitForActivePanePtyId,
waitForActiveTerminalManager,
waitForTerminalOutput
} from './helpers/terminal'
import { RECENT_STAMP_THROTTLE_MS } from '../../src/shared/sidebar-list/sort-stamps'
import {
WorkspaceRowsSnapshotSchema,
@@ -36,6 +42,14 @@ test('the host stamps a workspace as its agent is prompted, finishes and asks fo
await waitForActiveTerminalManager(orcaPage, 30_000)
const endpoint = await readHookEndpoint(electronApp)
const pane = await waitForActivePaneHookDescriptor(orcaPage)
// A terminal spawning in the pane clears what an earlier agent there reported, so hooks posted
// before it spawns are lost; wait for it, and hold its foreground as the agent would.
await execInTerminal(
orcaPage,
await waitForActivePanePtyId(orcaPage),
"printf 'agent-%s\\n' up; sleep 600"
)
await waitForTerminalOutput(orcaPage, 'agent-up', 20_000)
const rowKey = `local|${pane.worktreeId}`
await expect.poll(async () => (await readMainRow(orcaPage, rowKey))?.kind).toBe('git')
const before = (await readMainRow(orcaPage, rowKey))!
+10 -4
View File
@@ -8,6 +8,7 @@
*/
import type { Page } from '@stablyai/playwright-test'
import { expect, test } from './helpers/orca-app'
import { toRuntimeExecutionHostId } from '../../src/shared/execution-host'
import {
launchManagedWorkspace,
skipUnlessManagedDockerHost,
@@ -17,6 +18,7 @@ import { createDesktopMobilePairingOffer } from './helpers/paired-electron-clien
import { pairMobileClient } from './helpers/paired-mobile-client'
import {
desktopSidebarOutline,
outlineRows,
openPhoneFeedList,
type ListOutline
} from './helpers/phone-feed-list'
@@ -39,12 +41,12 @@ async function expectPhoneMatchesDesktop(page: Page, phone: PhoneList): Promise<
async () => {
desktop = await desktopSidebarOutline(page)
shown = await phone.outline()
return shown.join('\n') === desktop.join('\n')
return outlineRows(shown).join('\n') === outlineRows(desktop).join('\n')
},
{ timeout: 60_000 }
)
.toBe(true)
.catch(() => expect(shown).toEqual(desktop))
.catch(() => expect(outlineRows(shown)).toEqual(outlineRows(desktop)))
return shown
}
@@ -52,7 +54,7 @@ test("the phone lists the desktop's managed SSH workspace as the desktop sidebar
{}, testInfo) => {
test.setTimeout(12 * 60_000)
await withManagedHost(testInfo, async (fixture) => {
const { page, seeded } = await launchManagedWorkspace(fixture)
const { page, seeded, environmentId } = await launchManagedWorkspace(fixture)
const sshRow = (outline: ListOutline): string[] =>
outline.filter((line) => line.startsWith('ITEM ') && line.includes(seeded.repoPath))
// The desktop draws the SSH workspace before the phone is asked to match it.
@@ -61,7 +63,11 @@ test("the phone lists the desktop's managed SSH workspace as the desktop sidebar
.toHaveLength(1)
const phone = pairMobileClient(await createDesktopMobilePairingOffer(page))
const list = await openPhoneFeedList(phone)
// The phone last opened the seeded workspace, on its managed server, so it highlights that row.
const list = await openPhoneFeedList(phone, {
worktreeId: seeded.worktreeId,
executionHost: toRuntimeExecutionHostId(environmentId)
})
try {
const shown = await expectPhoneMatchesDesktop(page, list)
expect(sshRow(shown)).toEqual([expect.stringMatching(/ ACTIVE$/)])
@@ -10,6 +10,7 @@ import {
linkWorkspaces,
writeWorkspaceMeta
} from './helpers/repo-with-workspaces'
import { dismissSetupScriptPrompts } from './helpers/sidebar-setup-prompt'
import { seedLineageScenario, seedWorkspaceLiveTerminal } from './worktree-lineage-state'
test.use({ launchEnv: { ORCA_BACKGROUND_LAUNCH: '1' } })
@@ -286,6 +287,7 @@ test.describe('Toggle Child Workspaces shortcut', () => {
const folder = sidebarWorktreeRow(orcaPage, folderKey)
await expect(folder).toBeVisible()
await expect(folder.getByRole('button', { name: /child workspace/ })).toHaveCount(0)
await dismissSetupScriptPrompts(orcaPage)
await sidebarWorktreeRow(orcaPage, parentId).click()
await orcaPage.locator('[data-worktree-sidebar]').focus()
await folder.hover()