mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 00:02:10 +00:00
* fix(floating-workspace): keep agent launches from moving the main window's tab
Launching an agent from the floating workspace's "+" menu switched the main
window off whatever chat or editor tab it was showing and onto its terminals.
The main window's selection is supposed to move only for the worktree it is
showing: browser and editor tab creation, splits, moves and drops all check
`activeWorktreeId === worktreeId` before touching it. Two places did not:
- `launchAgentInNewTab` called `setActiveTabType('terminal')` without a
worktree, which targets the active worktree whatever worktree the launch
landed in.
- terminal `createTab` wrote the global `activeTabId` for a tab in any
worktree.
Both now follow the store rule. The launch still selects its tab within its
own worktree, which is what the floating panel renders.
The floating titlebar button had side-stepped this with an `activate: false`
opt-out plus manual selection. That opt-out had no other caller and is removed;
the button now launches and focuses like every other entry point.
* fix(tabs): scope the remaining launch surface writes to the launch's worktree
Three more launch paths create a terminal tab and then call
`setActiveTabType('terminal')` without a worktree, which targets whatever
worktree is active when the call runs rather than the one the tab landed in:
- the paired-host agent launch, after the host's asynchronous create
- Session History resume, which can target a worktree the user is not viewing
and activates it only afterwards
- sleeping-agent resume, which the activation gate runs after asynchronous
readiness checks, by which time the user may have moved to another worktree
Each now names its worktree, like the local agent launch. The new tab still
lands selected when the user switches to that worktree.
* test(tabs): pin the paired-host launch scope in its existing web-runtime test
* test(tabs): type the left-worktree resume fixture instead of casting it
* refactor(tabs): require the worktree that setActiveTabType applies to
`setActiveTabType(type, worktreeId?)` quietly fell back to the active
worktree when the caller left the worktree out. A caller acting on a tab in
another worktree (the floating workspace, a background launch, a reveal that
lands after an async step) therefore retyped whatever the main window was
showing. The launch paths fixed earlier in this branch were instances of that;
54 other callers still relied on the fallback.
The worktree is now a required argument (nullable only for the no-active-
worktree case), so every caller states which worktree it means and a new
unscoped call fails to compile. Each call site passes the worktree of the tab
it acts on; where that is by construction the active worktree (shortcuts,
palette, tab strip), the result is unchanged. `activateTabAndFocusPane`
resolves the tab's owning worktree the same way `setActiveTab` does.
End-to-end helpers that drive the store directly pass the active worktree,
which keeps their previous behaviour.
* fix(floating-workspace): let the floating New Terminal activate its own tab
The floating "+" New Terminal created its tab with `activate: false` and then
selected it with `activateTab`, because creating an active tab used to write
the main window's selected tab even for another worktree. `createTab` now
activates a tab only within its own worktree's group unless that worktree is
the one on screen, so the workaround is no longer needed.
Creating the tab active also moves the floating workspace's remembered tab to
the new one; before, it stayed on the previously selected floating tab, which
auto-acknowledge reads to decide which floating agent the user is looking at.
* refactor(floating-workspace): route every floating New Terminal through one creator
The floating "+" New Terminal had stopped deferring activation, but Cmd+T with the
floating panel focused still went through a separate creator that created the tab
inactive and activated it by hand, which left the floating workspace's remembered tab
on the previous tab. Both now call createFloatingWorkspaceTerminalTab, which creates
the tab active in its own group and focuses it.
* docs(tabs): say why an unowned tab id keeps the on-screen worktree scope
335 lines
11 KiB
TypeScript
335 lines
11 KiB
TypeScript
/**
|
|
* Zustand store inspection helpers for Orca E2E tests.
|
|
*
|
|
* Why: In dev mode, Orca exposes `window.__store` (the Zustand useAppStore).
|
|
* Reading store state gives tests reliable access to app state without
|
|
* fragile DOM scraping.
|
|
*/
|
|
|
|
import type { Page } from '@stablyai/playwright-test'
|
|
import { expect } from '@stablyai/playwright-test'
|
|
import type { BrowserTabSummary, ExplorerFileSummary, TerminalTabSummary } from './runtime-types'
|
|
|
|
/** Read a value from the Zustand store. Returns the raw JS value. */
|
|
export async function getStoreState<T>(page: Page, selector: string): Promise<T> {
|
|
return page.evaluate((selector) => {
|
|
const store = window.__store
|
|
if (!store) {
|
|
throw new Error('window.__store is not available — is the app in dev mode?')
|
|
}
|
|
|
|
const state = store.getState()
|
|
// Support dot-notation selectors like 'activeWorktreeId' or 'tabsByWorktree'
|
|
return selector.split('.').reduce<unknown>((value, key) => {
|
|
if (value && typeof value === 'object') {
|
|
return (value as Record<string, unknown>)[key]
|
|
}
|
|
|
|
return undefined
|
|
}, state) as T
|
|
}, selector)
|
|
}
|
|
|
|
/** Get the active worktree ID. */
|
|
export async function getActiveWorktreeId(page: Page): Promise<string | null> {
|
|
return getStoreState<string | null>(page, 'activeWorktreeId')
|
|
}
|
|
|
|
/** Get the active tab ID. */
|
|
export async function getActiveTabId(page: Page): Promise<string | null> {
|
|
return getStoreState<string | null>(page, 'activeTabId')
|
|
}
|
|
|
|
/** Get the active tab type ('terminal' | 'editor' | 'browser'). */
|
|
export async function getActiveTabType(page: Page): Promise<string | null> {
|
|
return getStoreState<string | null>(page, 'activeTabType')
|
|
}
|
|
|
|
/** Get all terminal tabs for a given worktree. */
|
|
export async function getWorktreeTabs(
|
|
page: Page,
|
|
worktreeId: string
|
|
): Promise<{ id: string; title?: string }[]> {
|
|
return page.evaluate((worktreeId) => {
|
|
const store = window.__store
|
|
if (!store) {
|
|
return []
|
|
}
|
|
|
|
const state = store.getState()
|
|
return (state.tabsByWorktree[worktreeId] ?? []).map((tab): TerminalTabSummary => ({
|
|
id: tab.id,
|
|
title: tab.customTitle || tab.title
|
|
}))
|
|
}, worktreeId)
|
|
}
|
|
|
|
/**
|
|
* Get the tab bar order for a worktree.
|
|
*
|
|
* Why: split groups manage tab order via group.tabOrder on each TabGroup,
|
|
* not the legacy tabBarOrderByWorktree field. Read from the active group's
|
|
* tabOrder so drag-reorder assertions work with the split-group model.
|
|
* Falls back to the legacy field for worktrees that haven't been absorbed
|
|
* into the split-group model yet.
|
|
*/
|
|
export async function getTabBarOrder(page: Page, worktreeId: string): Promise<string[]> {
|
|
return page.evaluate((worktreeId) => {
|
|
const store = window.__store
|
|
if (!store) {
|
|
return []
|
|
}
|
|
|
|
const state = store.getState()
|
|
const groups = state.groupsByWorktree?.[worktreeId] ?? []
|
|
const activeGroupId = state.activeGroupIdByWorktree?.[worktreeId]
|
|
const activeGroup = activeGroupId
|
|
? groups.find((g: { id: string }) => g.id === activeGroupId)
|
|
: groups[0]
|
|
if (activeGroup?.tabOrder?.length > 0) {
|
|
const unifiedTabs = state.unifiedTabsByWorktree?.[worktreeId] ?? []
|
|
return activeGroup.tabOrder.map((itemId: string) => {
|
|
const tab = unifiedTabs.find((t: { id: string }) => t.id === itemId)
|
|
if (!tab) {
|
|
return itemId
|
|
}
|
|
return tab.contentType === 'terminal' || tab.contentType === 'browser'
|
|
? tab.entityId
|
|
: tab.id
|
|
})
|
|
}
|
|
return state.tabBarOrderByWorktree[worktreeId] ?? []
|
|
}, worktreeId)
|
|
}
|
|
|
|
/** Get browser tabs for a given worktree. */
|
|
export async function getBrowserTabs(
|
|
page: Page,
|
|
worktreeId: string
|
|
): Promise<{ id: string; url?: string; title?: string }[]> {
|
|
return page.evaluate((worktreeId) => {
|
|
const store = window.__store
|
|
if (!store) {
|
|
return []
|
|
}
|
|
|
|
const state = store.getState()
|
|
return (state.browserTabsByWorktree[worktreeId] ?? []).map((tab): BrowserTabSummary => ({
|
|
id: tab.id,
|
|
url: tab.url,
|
|
title: tab.title
|
|
}))
|
|
}, worktreeId)
|
|
}
|
|
|
|
/** Get open editor files for a given worktree. */
|
|
export async function getOpenFiles(
|
|
page: Page,
|
|
worktreeId: string
|
|
): Promise<{ id: string; filePath: string; relativePath: string }[]> {
|
|
return page.evaluate((worktreeId) => {
|
|
const store = window.__store
|
|
if (!store) {
|
|
return []
|
|
}
|
|
|
|
const state = store.getState()
|
|
return state.openFiles
|
|
.filter((file) => file.worktreeId === worktreeId)
|
|
.map((file): ExplorerFileSummary => ({
|
|
id: file.id,
|
|
filePath: file.filePath,
|
|
relativePath: file.relativePath
|
|
}))
|
|
}, worktreeId)
|
|
}
|
|
|
|
/** Wait until the workspace session is ready. Uses expect.poll for proper Playwright waiting. */
|
|
export async function waitForSessionReady(page: Page, timeoutMs = 30_000): Promise<void> {
|
|
await expect
|
|
.poll(async () => getStoreState<boolean>(page, 'workspaceSessionReady'), {
|
|
timeout: timeoutMs,
|
|
message: 'workspaceSessionReady did not become true'
|
|
})
|
|
.toBe(true)
|
|
}
|
|
|
|
/**
|
|
* Wait until the deferred startup worktree scan has completed.
|
|
*
|
|
* Why: hydration fires an unawaited full catalog refresh after
|
|
* `workspaceSessionReady`; a fixture seeded before it lands is silently
|
|
* overwritten when it does.
|
|
*/
|
|
export async function waitForStartupWorktreeRefresh(page: Page, timeoutMs = 60_000): Promise<void> {
|
|
await expect
|
|
.poll(async () => getStoreState<boolean>(page, 'startupWorktreeRefreshCompleted'), {
|
|
timeout: timeoutMs,
|
|
message: 'startupWorktreeRefreshCompleted did not become true'
|
|
})
|
|
.toBe(true)
|
|
}
|
|
|
|
/** Wait until a worktree is active and return its ID. */
|
|
export async function waitForActiveWorktree(page: Page, timeoutMs = 30_000): Promise<string> {
|
|
let activeWorktreeId: string | null = null
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
activeWorktreeId = await page.evaluate(() => {
|
|
const store = window.__store
|
|
if (!store) {
|
|
return null
|
|
}
|
|
|
|
let state = store.getState()
|
|
if (state.activeWorktreeId) {
|
|
return state.activeWorktreeId
|
|
}
|
|
|
|
const firstWorktree = Object.values(state.worktreesByRepo).flat()[0]
|
|
if (!firstWorktree) {
|
|
return null
|
|
}
|
|
|
|
// Why: isolated E2E sessions can hydrate worktree rows without
|
|
// restoring a selection. Re-try store activation as worktrees load
|
|
// instead of relying on sidebar option click hit targets.
|
|
state.setActiveWorktree(firstWorktree.id)
|
|
state = store.getState()
|
|
return state.activeWorktreeId
|
|
})
|
|
return activeWorktreeId
|
|
},
|
|
{
|
|
timeout: timeoutMs,
|
|
message: 'activeWorktreeId did not become available'
|
|
}
|
|
)
|
|
.not.toBeNull()
|
|
|
|
return activeWorktreeId!
|
|
}
|
|
|
|
/** Get all worktree IDs across all repos. */
|
|
export async function getAllWorktreeIds(page: Page): Promise<string[]> {
|
|
return page.evaluate(() => {
|
|
const store = window.__store
|
|
if (!store) {
|
|
return []
|
|
}
|
|
|
|
const state = store.getState()
|
|
const allWorktrees = Object.values(state.worktreesByRepo).flat()
|
|
return allWorktrees.map((worktree) => worktree.id)
|
|
})
|
|
}
|
|
|
|
/** Switch to a different worktree via the store. Returns the new worktree ID or null. */
|
|
export async function switchToOtherWorktree(
|
|
page: Page,
|
|
currentWorktreeId: string
|
|
): Promise<string | null> {
|
|
return page.evaluate((currentId) => {
|
|
const store = window.__store
|
|
if (!store) {
|
|
return null
|
|
}
|
|
|
|
const state = store.getState()
|
|
const allWorktrees = Object.values(state.worktreesByRepo).flat()
|
|
const other = allWorktrees.find((worktree) => worktree.id !== currentId)
|
|
if (!other) {
|
|
return null
|
|
}
|
|
|
|
state.setActiveWorktree(other.id)
|
|
return other.id
|
|
}, currentWorktreeId)
|
|
}
|
|
|
|
/** Switch to a specific worktree via the store. */
|
|
export async function switchToWorktree(page: Page, worktreeId: string): Promise<void> {
|
|
await page.evaluate((id) => {
|
|
const store = window.__store
|
|
if (!store) {
|
|
return
|
|
}
|
|
|
|
store.getState().setActiveWorktree(id)
|
|
}, worktreeId)
|
|
}
|
|
|
|
/**
|
|
* Ensure the active tab is a terminal and that the first terminal tab exists.
|
|
*
|
|
* Why: the first terminal tab is created by a renderer effect after session
|
|
* hydration. Waiting on store state is more reliable than DOM visibility in
|
|
* hidden-window mode and avoids racing that initial auto-create step.
|
|
*/
|
|
export async function ensureTerminalVisible(page: Page, timeoutMs = 10_000): Promise<void> {
|
|
await expect
|
|
.poll(
|
|
async () =>
|
|
page.evaluate(() => {
|
|
const store = window.__store
|
|
if (!store) {
|
|
return false
|
|
}
|
|
let state = store.getState()
|
|
let worktreeId = state.activeWorktreeId
|
|
if (!worktreeId) {
|
|
const firstWorktree = Object.values(state.worktreesByRepo).flat()[0]
|
|
if (!firstWorktree) {
|
|
return false
|
|
}
|
|
// Why: reload-based specs can briefly clear the active worktree
|
|
// after session readiness while worktrees are already loaded.
|
|
state.setActiveWorktree(firstWorktree.id)
|
|
state = store.getState()
|
|
worktreeId = state.activeWorktreeId ?? firstWorktree.id
|
|
}
|
|
|
|
const tabs = state.tabsByWorktree[worktreeId] ?? []
|
|
const activeTab =
|
|
tabs.find((tab) => tab.id === state.activeTabIdByWorktree[worktreeId]) ??
|
|
tabs.find((tab) => tab.id === state.activeTabId) ??
|
|
tabs[0] ??
|
|
// Why: fresh isolated E2E profiles may not have finished the UI-driven
|
|
// auto-create effect yet. Use the same store action to create the first
|
|
// terminal tab so terminal-focused specs start from a stable baseline.
|
|
state.createTab(worktreeId)
|
|
state.setActiveTab(activeTab.id)
|
|
if (state.activeTabType !== 'terminal') {
|
|
state.setActiveTabType('terminal', window.__store?.getState().activeWorktreeId ?? null)
|
|
}
|
|
|
|
state = store.getState()
|
|
if (state.activeTabType !== 'terminal' || state.activeWorktreeId !== worktreeId) {
|
|
return false
|
|
}
|
|
return (state.tabsByWorktree[worktreeId] ?? []).some(
|
|
(tab) => tab.id === state.activeTabId
|
|
)
|
|
}),
|
|
{ timeout: timeoutMs, message: 'No active terminal tab found for current worktree' }
|
|
)
|
|
.toBe(true)
|
|
}
|
|
|
|
/** Check if a worktree exists in the store. */
|
|
export async function worktreeExists(page: Page, name: string): Promise<boolean> {
|
|
return page.evaluate((name) => {
|
|
const store = window.__store
|
|
if (!store) {
|
|
return false
|
|
}
|
|
|
|
const state = store.getState()
|
|
const allWorktrees = Object.values(state.worktreesByRepo).flat()
|
|
return allWorktrees.some(
|
|
(worktree) => worktree.displayName === name || worktree.path.endsWith(`/${name}`)
|
|
)
|
|
}, name)
|
|
}
|