mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 09:37:33 +00:00
* feat(sidebar): sort stamps reconcile stored tiers at startup against live hook rows and terminals (dark) * test(sidebar): a restart clears an unbacked local tier and keeps an SSH host's * fix(sidebar): run the sort tier reconcile once hook rows and PTYs settle, seeding hydrated rows * fix(sidebar): clear a stored tier only on proof its terminals died; a rest seed adds no clear * chore(sidebar): merge origin/main; locality helper moved to shared * feat(sidebar): sort Recent and Smart by host stamps behind the sidebar-inputs switch With ORCA_SIDEBAR_INPUTS=main, the window, main and the phone order Recent by lastActivityAt and Smart by smartTier then smartAt (Needs you, Done, Working, then the rest by Recent). The window holds its displayed order while the pointer is over the list or a drag runs. Switch off: today's sort path. Co-Authored-By: Claude <noreply@anthropic.com> * chore: restore files the merge commit's formatter rewrote Co-Authored-By: Claude <noreply@anthropic.com> * fix(sidebar): stamp-order review fixes behind the sidebar-inputs switch The window writes no Recent stamps with the switch on (terminal start and exit, unread marks, comment edits), the hover hold covers only Recent and Smart and ends with a drag, main follows only explicit metadata saves, and the phone picks its sort from the unfiltered rows. Co-Authored-By: Claude <noreply@anthropic.com> * test(sidebar): folder writers and a Manual drag under the stamp switch Co-Authored-By: Claude <noreply@anthropic.com> * test(sidebar): name the stamp type in the ps summaries test Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
253 lines
8.6 KiB
TypeScript
253 lines
8.6 KiB
TypeScript
import { test, expect } from './helpers/orca-app'
|
|
import type { Page } from '@stablyai/playwright-test'
|
|
import type { TerminalPaneLayoutNode } from '../../src/shared/terminal-tab-types'
|
|
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
|
import { worktreeRow } from './worktree-row-locators'
|
|
import { SIDEBAR_INPUTS_BUILD } from './helpers/sidebar-shadow'
|
|
|
|
type SmartSortScenario = {
|
|
blockedId: string
|
|
doneId: string
|
|
blockedTabId: string
|
|
doneTabId: string
|
|
blockedPaneKey: string
|
|
donePaneKey: string
|
|
}
|
|
|
|
async function getVisibleWorktreeIdsByTop(page: Page): Promise<string[]> {
|
|
return page
|
|
.locator('[data-worktree-sidebar] [role="option"][data-worktree-id]')
|
|
.evaluateAll((elements) =>
|
|
elements
|
|
.map((element) => ({
|
|
id: element.dataset.worktreeId ?? '',
|
|
top: element.getBoundingClientRect().top
|
|
}))
|
|
.filter((row) => row.id.length > 0)
|
|
.sort((a, b) => a.top - b.top)
|
|
.map((row) => row.id)
|
|
)
|
|
}
|
|
|
|
async function seedSmartSortScenario(page: Page): Promise<SmartSortScenario> {
|
|
return page.evaluate(() => {
|
|
const store = window.__store
|
|
if (!store) {
|
|
throw new Error('window.__store is not available')
|
|
}
|
|
|
|
const state = store.getState()
|
|
state.setActiveView('terminal')
|
|
state.setSidebarOpen(true)
|
|
state.setGroupBy('none')
|
|
state.setSortBy('smart')
|
|
|
|
const worktrees = Object.values(state.worktreesByRepo)
|
|
.flat()
|
|
.filter((worktree) => !worktree.isArchived)
|
|
if (worktrees.length < 2) {
|
|
throw new Error('Smart sort E2E needs at least two worktrees')
|
|
}
|
|
|
|
const [blocked, done] = worktrees
|
|
const now = Date.now()
|
|
|
|
store.setState((current) => ({
|
|
worktreesByRepo: Object.fromEntries(
|
|
Object.entries(current.worktreesByRepo).map(([repoId, repoWorktrees]) => [
|
|
repoId,
|
|
repoWorktrees.map((worktree) => {
|
|
if (worktree.id === blocked.id) {
|
|
return {
|
|
...worktree,
|
|
displayName: 'Z smart-sort blocked',
|
|
lastActivityAt: now - 5 * 60_000,
|
|
sortOrder: 0
|
|
}
|
|
}
|
|
if (worktree.id === done.id) {
|
|
return {
|
|
...worktree,
|
|
displayName: 'A smart-sort done',
|
|
lastActivityAt: now,
|
|
sortOrder: 10
|
|
}
|
|
}
|
|
return worktree
|
|
})
|
|
])
|
|
)
|
|
}))
|
|
|
|
for (const worktree of [blocked, done]) {
|
|
const currentState = store.getState()
|
|
if ((currentState.tabsByWorktree[worktree.id] ?? []).length === 0) {
|
|
currentState.createTab(worktree.id)
|
|
}
|
|
}
|
|
|
|
const stateWithTabs = store.getState()
|
|
const blockedTab = stateWithTabs.tabsByWorktree[blocked.id]?.[0]
|
|
const doneTab = stateWithTabs.tabsByWorktree[done.id]?.[0]
|
|
if (!blockedTab || !doneTab) {
|
|
throw new Error('Smart sort E2E failed to create terminal tabs')
|
|
}
|
|
|
|
const blockedPtyId = stateWithTabs.ptyIdsByTabId[blockedTab.id]?.[0] ?? `e2e-${blockedTab.id}`
|
|
const donePtyId = stateWithTabs.ptyIdsByTabId[doneTab.id]?.[0] ?? `e2e-${doneTab.id}`
|
|
const firstLayoutLeafId = (node: TerminalPaneLayoutNode | null | undefined): string | null => {
|
|
if (!node) {
|
|
return null
|
|
}
|
|
return node.type === 'leaf'
|
|
? node.leafId
|
|
: (firstLayoutLeafId(node.first) ?? firstLayoutLeafId(node.second))
|
|
}
|
|
let blockedLeafId = ''
|
|
let doneLeafId = ''
|
|
|
|
// Why: WorktreeList intentionally holds cold-start ordering until a live
|
|
// PTY exists. E2E hidden windows can create tabs before panes mount, so
|
|
// seed the live-PTY and stable-layout maps explicitly and let agent-status
|
|
// writes drive the same sortEpoch path that hook events use in the app.
|
|
store.setState((current) => {
|
|
const blockedLayout = current.terminalLayoutsByTabId[blockedTab.id]
|
|
const doneLayout = current.terminalLayoutsByTabId[doneTab.id]
|
|
blockedLeafId = firstLayoutLeafId(blockedLayout?.root) ?? crypto.randomUUID()
|
|
doneLeafId = firstLayoutLeafId(doneLayout?.root) ?? crypto.randomUUID()
|
|
|
|
return {
|
|
ptyIdsByTabId: {
|
|
...current.ptyIdsByTabId,
|
|
[blockedTab.id]: current.ptyIdsByTabId[blockedTab.id]?.length
|
|
? current.ptyIdsByTabId[blockedTab.id]
|
|
: [blockedPtyId],
|
|
[doneTab.id]: current.ptyIdsByTabId[doneTab.id]?.length
|
|
? current.ptyIdsByTabId[doneTab.id]
|
|
: [donePtyId]
|
|
},
|
|
terminalLayoutsByTabId: {
|
|
...current.terminalLayoutsByTabId,
|
|
[blockedTab.id]: {
|
|
root: blockedLayout?.root ?? { type: 'leaf', leafId: blockedLeafId },
|
|
activeLeafId: blockedLayout?.activeLeafId ?? blockedLeafId,
|
|
expandedLeafId: blockedLayout?.expandedLeafId ?? null,
|
|
ptyIdsByLeafId: {
|
|
...blockedLayout?.ptyIdsByLeafId,
|
|
[blockedLeafId]: blockedPtyId
|
|
}
|
|
},
|
|
[doneTab.id]: {
|
|
root: doneLayout?.root ?? { type: 'leaf', leafId: doneLeafId },
|
|
activeLeafId: doneLayout?.activeLeafId ?? doneLeafId,
|
|
expandedLeafId: doneLayout?.expandedLeafId ?? null,
|
|
ptyIdsByLeafId: {
|
|
...doneLayout?.ptyIdsByLeafId,
|
|
[doneLeafId]: donePtyId
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
const actions = store.getState()
|
|
actions.setAgentStatus(
|
|
`${doneTab.id}:${doneLeafId}`,
|
|
{ state: 'done', prompt: 'Finished', agentType: 'codex' },
|
|
'codex',
|
|
{ updatedAt: now, stateStartedAt: now - 1_000 }
|
|
)
|
|
actions.setAgentStatus(
|
|
`${blockedTab.id}:${blockedLeafId}`,
|
|
{ state: 'blocked', prompt: 'Needs approval', agentType: 'codex' },
|
|
'codex',
|
|
{ updatedAt: now, stateStartedAt: now - 60_000 }
|
|
)
|
|
|
|
return {
|
|
blockedId: blocked.id,
|
|
doneId: done.id,
|
|
blockedTabId: blockedTab.id,
|
|
doneTabId: doneTab.id,
|
|
blockedPaneKey: `${blockedTab.id}:${blockedLeafId}`,
|
|
donePaneKey: `${doneTab.id}:${doneLeafId}`
|
|
}
|
|
})
|
|
}
|
|
|
|
async function getSmartSortScenarioReadiness(
|
|
page: Page,
|
|
scenario: SmartSortScenario
|
|
): Promise<{
|
|
blockedHasLivePty: boolean
|
|
doneHasLivePty: boolean
|
|
blockedState: string | null
|
|
doneState: string | null
|
|
fallbackOrder: string[]
|
|
}> {
|
|
return page.evaluate((scenario) => {
|
|
const state = window.__store?.getState()
|
|
if (!state) {
|
|
return {
|
|
blockedHasLivePty: false,
|
|
doneHasLivePty: false,
|
|
blockedState: null,
|
|
doneState: null,
|
|
fallbackOrder: []
|
|
}
|
|
}
|
|
const scenarioWorktrees = Object.values(state.worktreesByRepo)
|
|
.flat()
|
|
.filter((worktree) => worktree.id === scenario.blockedId || worktree.id === scenario.doneId)
|
|
return {
|
|
blockedHasLivePty: (state.ptyIdsByTabId[scenario.blockedTabId]?.length ?? 0) > 0,
|
|
doneHasLivePty: (state.ptyIdsByTabId[scenario.doneTabId]?.length ?? 0) > 0,
|
|
blockedState: state.agentStatusByPaneKey[scenario.blockedPaneKey]?.state ?? null,
|
|
doneState: state.agentStatusByPaneKey[scenario.donePaneKey]?.state ?? null,
|
|
fallbackOrder: scenarioWorktrees
|
|
.sort((a, b) => b.sortOrder - a.sortOrder || a.displayName.localeCompare(b.displayName))
|
|
.map((worktree) => worktree.id)
|
|
}
|
|
}, scenario)
|
|
}
|
|
|
|
test.describe('Worktree Smart Sort', () => {
|
|
// The window-seeded statuses here never reach the host, which owns Smart in that build.
|
|
test.skip(SIDEBAR_INPUTS_BUILD, 'ORCA_SIDEBAR_INPUTS=main sorts by host stamps')
|
|
test.beforeEach(async ({ orcaPage }) => {
|
|
await waitForSessionReady(orcaPage)
|
|
await waitForActiveWorktree(orcaPage)
|
|
await ensureTerminalVisible(orcaPage)
|
|
})
|
|
|
|
test('renders attention-needed worktrees above finished agents in Smart mode', async ({
|
|
orcaPage
|
|
}) => {
|
|
const scenario = await seedSmartSortScenario(orcaPage)
|
|
const { blockedId, doneId } = scenario
|
|
|
|
await expect
|
|
.poll(() => getSmartSortScenarioReadiness(orcaPage, scenario), {
|
|
timeout: 8_000,
|
|
message: 'Smart sort scenario did not seed live PTYs and fresh agent statuses'
|
|
})
|
|
.toEqual({
|
|
blockedHasLivePty: true,
|
|
doneHasLivePty: true,
|
|
blockedState: 'blocked',
|
|
doneState: 'done',
|
|
fallbackOrder: [doneId, blockedId]
|
|
})
|
|
|
|
await expect
|
|
.poll(async () => (await getVisibleWorktreeIdsByTop(orcaPage)).slice(0, 2), {
|
|
timeout: 12_000,
|
|
message: 'Smart sort did not promote the blocked worktree in the visible sidebar'
|
|
})
|
|
.toEqual([blockedId, doneId])
|
|
|
|
await expect(worktreeRow(orcaPage, blockedId)).toBeVisible()
|
|
await expect(worktreeRow(orcaPage, doneId)).toBeVisible()
|
|
})
|
|
})
|