Merge remote-tracking branch 'origin/master' into worktree/attachment-alignment-2

This commit is contained in:
creatixchu
2026-08-12 13:02:39 +08:00
201 changed files with 3681 additions and 1720 deletions

View File

@@ -21,7 +21,12 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
// The sidebar renders from the boot graph: every inject layer activated.
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
await within(tree).findByText('4 sessions')
// The compact layout dropped group session counts; the fixture workspace
// group row renders immediately with its sessions beneath it.
const fixtureGroup = (await within(tree).findAllByText('fixture'))
.map(el => el.closest<HTMLElement>('[role="treeitem"]'))
.find(el => el?.getAttribute('aria-expanded') !== null)
if (fixtureGroup === undefined) throw new Error('fixture Workspace group missing')
// The resident fixture has both a question and an approval; composer routing
// exposes the question first, and the assembled workspace plugin mirrors that

View File

@@ -77,8 +77,13 @@ async function nextPaint(page: Page): Promise<void> {
}
async function openSeed(page: Page): Promise<void> {
await page.getByText(/^\d+ sessions?$/, { exact: true }).waitFor({ timeout: 30_000 })
const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
// The compact layout dropped group session counts; the seeded baseline is
// the Ungrouped bucket once cold summaries load.
await page.getByText('Ungrouped', { exact: true }).waitFor({ timeout: 30_000 })
// Search collapsed into a header action; expand it before filling.
const searchButton = page.getByRole('button', { name: 'Search sessions' })
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
const search = page.getByRole('textbox', { name: 'Search sessions...', exact: true })
await search.fill(FIXTURE.markers.user(1))
const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
await results.first().waitFor({ timeout: 60_000 })

View File

@@ -168,8 +168,10 @@ async function launchScrollWorld(options: ScrollWorldOptions): Promise<ScrollWor
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
// Session-list bootstrap can replace the controlled search state. Wait
// for the seeded baseline before openSeed starts the lazy content query.
await page.getByText(/^\d+ sessions?$/, { exact: true }).waitFor({ timeout: 30_000 })
// for the seeded baseline before openSeed starts the lazy content query
// (the compact layout dropped group session counts; the Ungrouped bucket
// row is the barrier).
await page.getByText('Ungrouped', { exact: true }).waitFor({ timeout: 30_000 })
return {
events,
page,
@@ -258,7 +260,10 @@ async function conversationTurns(page: Page): Promise<number> {
}
async function openSeed(page: Page, fixture: ChatScrollFixture, tailMarker?: string): Promise<void> {
const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
// Search collapsed into a header action; expand it before filling.
const searchButton = page.getByRole('button', { name: 'Search sessions' })
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
const search = page.getByRole('textbox', { name: 'Search sessions...', exact: true })
// Cold summaries initially show the temporary workspace basename, so the
// persisted first-message marker is the stable user-facing identity. The
// query itself triggers lazy content-index reconciliation; no transient

View File

@@ -249,7 +249,10 @@ async function compareTabsWithoutReservation(page: Page): Promise<TabComparison>
* @param page - the page under test.
*/
async function openSeededSession(page: Page): Promise<void> {
const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
// Search collapsed into a header action; expand it before filling.
const searchButton = page.getByRole('button', { name: 'Search sessions' })
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
const search = page.getByRole('textbox', { name: 'Search sessions...', exact: true })
await search.fill(FIXTURE.markers.user(1))
const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
const deadline = Date.now() + 60_000

View File

@@ -197,8 +197,13 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
it.skipIf(MODE === 'record')('materialized a real Workspace and Session over the wire', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-materialize'))
// Browser: the sidebar tree now carries the auto-created workspace group
// with its one session, and the opened session is the selected row.
await expect.poll(() => page.getByText('1 session', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
// with its one session, and the opened session is the selected row. The
// compact layout dropped group session counts, so the group row itself is
// the barrier.
await expect.poll(
() => page.locator('[role="treeitem"][aria-expanded]').filter({ hasText: 'workspace' }).count(),
{ timeout: 15_000 },
).toBeGreaterThanOrEqual(1)
await expect.poll(() => page.locator('[role="treeitem"][aria-selected="true"]').count(), { timeout: 10_000 }).toBe(1)
await expect.poll(() => page.getByText('LIGHTHOUSE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
// Host: the session's durable header cwd is the folder the workspace

View File

@@ -53,7 +53,10 @@ async function assertBaselineSucceeded(response: Response, method: string): Prom
async function ensureSeedOpen(page: Page): Promise<void> {
const chat = page.getByRole('tab', { name: 'Chat', exact: true })
const search = page.getByPlaceholder('Search name, keywords', { exact: false })
// Search is a collapsed header action; expand it so the input is actionable.
const searchButton = page.getByRole('button', { name: 'Search sessions' })
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
const search = page.getByPlaceholder('Search sessions', { exact: false })
if (await chat.count() === 0) {
await search.fill('WATERFALL')
const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
@@ -119,8 +122,9 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
// The frame mounts before the asynchronous session-list baseline lands.
// Search must target the settled seeded row, not the startup input that
// the ready projection replaces.
await page.getByText('1 session', { exact: true }).waitFor({ timeout: 30_000 })
// the ready projection replaces (the compact layout dropped group session
// counts; the Ungrouped bucket row is the barrier).
await page.getByText('Ungrouped', { exact: true }).waitFor({ timeout: 30_000 })
}, 120_000)
afterEach(async () => {
@@ -176,9 +180,13 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
it.skipIf(MODE === 'record')('finds an unopened seeded session by message content and opens it', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-search'))
// The API baselines can settle before React commits their projection. The
// seeded count is the final user-visible barrier before editing search.
await page.getByText('1 session', { exact: true }).waitFor({ timeout: 30_000 })
const search = page.getByPlaceholder('Search name, keywords', { exact: false })
// seeded Ungrouped bucket row is the final user-visible barrier before
// editing search (the compact layout dropped group session counts).
await page.getByText('Ungrouped', { exact: true }).waitFor({ timeout: 30_000 })
// Search is a collapsed header action; expand it so the input is actionable.
const searchButton = page.getByRole('button', { name: 'Search sessions' })
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
const search = page.getByPlaceholder('Search sessions', { exact: false })
// The cold row has not been opened, so only the persisted log can satisfy
// this query. First search lazily reconciles the SQLite content index.
await search.fill('zzzqx-no-such-session')

View File

@@ -68,8 +68,11 @@ describe.skipIf(MODE === 'record' || !HAS_PWSH)('web e2e: pwsh calls use the bas
onTestFailed(() => saveFailureShot(page, 'web-e2e-pwsh-terminal'))
// Open the seeded session through content search: the sidebar groups
// sessions by workspace and its row order is world-dependent, while the
// search index covers the seeded log deterministically.
const search = page.getByPlaceholder('Search name, keywords', { exact: false })
// search index covers the seeded log deterministically. Search is a
// collapsed header action; expand it so the input is actionable.
const searchButton = page.getByRole('button', { name: 'Search sessions' })
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
const search = page.getByPlaceholder('Search sessions', { exact: false })
await search.fill('Run a PowerShell command')
const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
await expect.poll(() => result.count(), { timeout: 15_000 }).toBe(1)

View File

@@ -385,7 +385,11 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
// able to change a golden.
{
id: 'agent-presets',
config: { default: 'standard', roots: [{ path: SHIPPED_PRESET_DIR, trust: 'system' }] },
config: {
default: 'standard',
roots: [{ path: SHIPPED_PRESET_DIR, trust: 'system' }],
includeUserRoot: false,
},
},
{ id: 'session-persistence-jsonl', config: { root: persistenceRoot } },
{ id: 'session-query-sqlite', config: { path: ':memory:', openAt: 'first-search' } },
@@ -445,7 +449,9 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
] },
...options.agentPresets === undefined
? []
: [{ id: 'agent-presets', config: options.agentPresets }],
// Never the derived harness-home root: a developer's own presets must not
// be able to change a golden, whatever roots a scenario asks for.
: [{ id: 'agent-presets', config: { ...options.agentPresets, includeUserRoot: false } }],
...options.toolsMode === undefined ? [] : [{ id: 'tools', config: { mode: options.toolsMode } }],
...options.cordisTools === true
? [{ insert: [{ id: 'tool-cordis', name: 'cordis:tool-cordis' }] }]

View File

@@ -349,9 +349,9 @@ async function pointAt(page: Page, where: 'list' | 'away'): Promise<void> {
/**
* Reveal the seeded rows: every seeded session is unattached, so they all sit
* in the collapsed Ungrouped bucket. Converges on expanded rather than
* clicking once — startup auto-selection can expand the bucket first, and a
* second click would collapse it again. Hand-rolled polling because
* in the collapsed Ungrouped bucket. Open the bucket, then use its transient
* Show-more control because an open group intentionally renders only five
* rows by default. Hand-rolled polling because
* `expect.poll` is test-scoped and this runs in `beforeAll`.
* @param page - the page under test.
*/
@@ -364,6 +364,12 @@ async function expandSeededSessions(page: Page): Promise<void> {
if (await bucket.getAttribute('aria-expanded') !== 'true') {
await page.getByText('Ungrouped', { exact: true }).click()
}
const showMore = page.getByRole('button', { name: /Show \d+ more sessions/ })
if (await bucket.getAttribute('aria-expanded') === 'true'
&& await rows.count() <= SEED_COUNT / 2
&& await showMore.count() > 0) {
await showMore.click()
}
if (await bucket.getAttribute('aria-expanded') === 'true' && await rows.count() > SEED_COUNT / 2) return
if (Date.now() > deadline) {
throw new Error(`Ungrouped bucket never revealed more than ${SEED_COUNT / 2} rows`)

View File

@@ -5,17 +5,17 @@
- img
- text: New Session
- text: Workspaces
- button "Group by":
- button "Search sessions":
- img
- textbox "Search sessions..."
- button "View options":
- img
- button "Add workspace":
- img
- button "Search sessions":
- img
- textbox "Search name, keywords..."
- tree "Sessions":
- treeitem "workspace 1 session" [expanded]:
- treeitem "workspace" [expanded]:
- img
- text: workspace 1 session
- text: workspace
- treeitem "New Session" [selected]
- button "Settings":
- img

View File

@@ -5,17 +5,17 @@
- img
- text: New Session
- text: Workspaces
- button "Group by":
- button "Search sessions":
- img
- textbox "Search sessions..."
- button "View options":
- img
- button "Add workspace":
- img
- button "Search sessions":
- img
- textbox "Search name, keywords..."
- tree "Sessions":
- treeitem "workspace 1 session" [expanded]:
- treeitem "workspace" [expanded]:
- img
- text: workspace 1 session
- text: workspace
- treeitem "New Session" [selected]
- button "Settings":
- img

View File

@@ -1,7 +1,7 @@
- tree "Sessions":
- treeitem "Ungrouped 3 sessions" [expanded]:
- treeitem "Ungrouped" [expanded]:
- img
- text: Ungrouped 3 sessions
- treeitem "Use the read tool twice (2) now" [selected]
- treeitem "Use the read tool twice (1) now"
- text: Ungrouped
- treeitem "Use the read tool twice 1min"
- treeitem "Use the read tool twice (1) now"
- treeitem "Use the read tool twice (2) now" [selected]

View File

@@ -1,6 +1,6 @@
- tree "Sessions":
- treeitem "workspace 2 sessions" [expanded]:
- treeitem "workspace" [expanded]:
- img
- text: workspace 2 sessions
- treeitem "1 subagent running Delegate a background task. now"
- text: workspace
- treeitem "New Session" [selected]
- treeitem "1 subagent running Delegate a background task. now"

View File

@@ -1,6 +1,6 @@
- tree "Sessions":
- treeitem "workspace 2 sessions" [expanded]:
- treeitem "workspace" [expanded]:
- img
- text: workspace 2 sessions
- treeitem "Explain event sourcing in one (1) now" [selected]
- text: workspace
- treeitem "Ask a research subagent to now"
- treeitem "Explain event sourcing in one (1) now" [selected]

View File

@@ -1,5 +1,5 @@
- tree "Sessions":
- treeitem "workspace 1 session" [expanded]:
- treeitem "workspace" [expanded]:
- img
- text: workspace 1 session
- text: workspace
- treeitem "Ask a research subagent to now"

View File

@@ -31,6 +31,8 @@ const ONE_SHOT_LABEL = 'event-sourcing reviewer'
const NESTED_LABEL = 'example editor'
const PARENT_PROMPT = 'Ask a research subagent to explain event sourcing.'
const INITIAL_PROMPT = 'Explain event sourcing in one sentence.'
/** The grandchild's own first message; its arrival is what says its history finished loading. */
const NESTED_PROMPT = 'Give one concrete event sourcing example.'
const FOLLOWUP = 'Now give the same explanation to a human reader.'
const POST_FORK_FOLLOWUP = 'Continue the original conversation after the fork.'
@@ -176,7 +178,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
seq: 1,
time: authoredAt + 1,
data: {
content: [{ type: 'text', text: 'Give one concrete event sourcing example.' }],
content: [{ type: 'text', text: NESTED_PROMPT }],
source: { kind: 'user' },
},
surfaceOp: 'append',
@@ -404,6 +406,11 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
)
await nestedRow.click()
await page.getByText('The parent session is offline; reopen it to continue sending messages.').waitFor()
// The offline banner renders from the descriptor alone, so it says nothing
// about the transcript below it. The golden pins that transcript, and
// `captureStableAria` calls two identical polls stable — including two of
// "Loading history…". Wait for the message the golden asserts.
await page.getByText(NESTED_PROMPT).waitFor()
const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' })
const crumbs = await hierarchy.getByRole('button').allTextContents()
expect(crumbs.slice(-2)).toEqual([LABEL, NESTED_LABEL])

View File

@@ -59,7 +59,10 @@ interface RowAnchor {
}
async function openSeed(page: Page): Promise<void> {
const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
// Search collapsed into a header action; expand it before filling.
const searchButton = page.getByRole('button', { name: 'Search sessions' })
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
const search = page.getByRole('textbox', { name: 'Search sessions...', exact: true })
await search.fill(FIXTURE.markers.user(1))
const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
await expect.poll(() => result.count(), { timeout: 60_000 }).toBe(1)
@@ -182,7 +185,9 @@ describe('web e2e: Trajectory virtualization over tail-paged history', () => {
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await page.getByText('1 session', { exact: true }).waitFor({ timeout: 30_000 })
// The compact layout dropped group session counts; the seeded baseline is
// the Ungrouped bucket once cold summaries load.
await page.getByText('Ungrouped', { exact: true }).waitFor({ timeout: 30_000 })
}, 120_000)
afterAll(async () => {

View File

@@ -372,21 +372,22 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
// Grouped default: workspace group rows render (the seeded session sits
// under Ungrouped; the created workspaces are empty groups).
await expect.poll(() => page.getByText('Workspaces', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
await page.getByRole('button', { name: 'Group by' }).click()
// Grouping and ordering moved into the View options menu.
await page.getByRole('button', { name: 'View options' }).click()
await page.getByRole('menuitem', { name: 'In one list' }).click()
// Flat mode: the section label flips and the seeded session is a
// top-level row with no group headers above it.
await expect.poll(() => page.getByText('Sessions', { exact: true }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 5_000 }).toBe(0)
await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
expect(await page.evaluate(() => localStorage.getItem('dsh.workspace.view'))).toContain('flat')
expect(await page.evaluate(() => localStorage.getItem('dsh.workspace.view.v4'))).toContain('flat')
// Persisted across reload; then restore grouped for inter-spec hygiene.
const warningStart = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
acknowledgeReloadConnectionLoss(tripwire, warningStart)
await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 15_000 }).toBe(0)
await page.getByRole('button', { name: 'Group by' }).click()
await page.getByRole('button', { name: 'View options' }).click()
await page.getByRole('menuitem', { name: 'WorkSpace' }).click()
await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
expect(tripwire.pageErrors).toEqual([])