mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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(() => {
|
||||
|
||||
@@ -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), {
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))!
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user