Merge remote-tracking branch 'origin/master' into feat/web-message-feedback-ui

Keep both Remote contributions master and this branch add: the mount loop
now carries commandsRemote, goalsRemote, pluginInventoryRemote, and
messageFeedbackRemote, with both new tsconfig references retained.
This commit is contained in:
Chinesezjc
2026-08-12 16:36:10 +08:00
339 changed files with 7766 additions and 2302 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

@@ -0,0 +1,128 @@
// Keyless browser e2e: a user who configures some OTHER provider is not asked
// for the official DeepSeek key again, and the first-run setup card is a card
// they can close. The shipped DeepSeek adapter stays mounted without a
// credential throughout, so the only thing that ends onboarding here is the
// pi-ai route the user configures through the real wire. Zero model calls:
// configuration is pure settings/credentials/llm-domain traffic.
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import {
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/onboarding-usable-provider', import.meta.url))
const DISMISSED_EXPECTED = join(SNAPSHOT_DIR, 'dismissed.expected.md')
const MODE = webSnapshotMode()
const CREDENTIAL_STEP = '添加一个 API Key 开始使用'
describe.skipIf(MODE === 'record')('web e2e: another usable provider ends first-run onboarding', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({ deepSeekMissingCredential: true })
browser = await chromium.launch()
// The scenario asserts the shipped Chinese copy, so the browser asks for it.
page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: ZH_BROWSER_LOCALE })
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('closes the setup card without discarding the add card beside it', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-setup-card-cancel'))
const credentialStep = page.getByRole('region', { name: CREDENTIAL_STEP })
await credentialStep.waitFor({ timeout: 15_000 })
await credentialStep.getByRole('button', { name: '前往配置' }).click()
await credentialStep.waitFor({ state: 'detached', timeout: 15_000 })
const settings = page.getByRole('dialog', { name: '设置' })
await settings.waitFor({ timeout: 10_000 })
// Nothing is reachable yet, so DeepSeek presents itself as its open card.
const setupKey = settings.getByRole('textbox', { name: 'API 密钥', exact: true })
await setupKey.waitFor({ timeout: 10_000 })
const add = settings.getByRole('button', { name: '添加提供方' })
await expect.poll(async () => add.isEnabled(), { timeout: 10_000 }).toBe(true)
await add.click()
const pick = settings.getByLabel('提供方')
await pick.waitFor({ timeout: 10_000 })
await pick.selectOption('minimax-cn')
await expect.poll(
async () => settings.getByRole('textbox', { name: 'API 密钥', exact: true }).count(),
{ timeout: 10_000 },
).toBe(2)
// Cancelling the setup card is the regression: it used to leave itself open
// and close the add card, discarding that draft.
await settings.getByRole('button', { name: '取消', exact: true }).first().click()
expect(await settings.getByLabel('提供方').count()).toBe(1)
await expect.poll(
async () => settings.getByRole('textbox', { name: 'API 密钥', exact: true }).count(),
{ timeout: 10_000 },
).toBe(1)
// DeepSeek is now an ordinary row: a missing-key dot and an Edit button.
await settings.getByRole('button', { name: '编辑 DeepSeek (deepseek-official)' }).waitFor({ timeout: 10_000 })
const dismissed = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(DISMISSED_EXPECTED, dismissed, MODE)
expect(tripwire.warnings).toEqual([])
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('stops prompting for DeepSeek once the other provider can serve requests', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-other-provider'))
const settings = page.getByRole('dialog', { name: '设置' })
await settings.getByRole('textbox', { name: 'API 密钥', exact: true }).fill('sk-e2e-minimax')
await settings.getByRole('button', { name: '保存', exact: true }).click()
await settings.getByText('已保存 minimax-cn。', { exact: true }).waitFor({ timeout: 15_000 })
// Only minimax-cn is reachable; DeepSeek still holds no credential.
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY')
const credentials = await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8')
expect(credentials).toContain('MINIMAX_CN_API_KEY: sk-e2e-minimax')
expect(credentials).not.toContain('DEEPSEEK_API_KEY')
const warningsBefore = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
acknowledgeReloadConnectionLoss(tripwire, warningsBefore)
await page.waitForSelector('[class*="frame"]', { timeout: 15_000 })
// The regression: the step read only the official route's credential, so a
// fully configured user was taken over on every blank session.
await expect.poll(
async () => page.getByRole('region', { name: CREDENTIAL_STEP }).count(),
{ timeout: 10_000 },
).toBe(0)
expect(await page.locator('[class*="onboardingStage"]').count()).toBe(0)
expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(false)
// The Models page agrees: DeepSeek stays a row rather than reopening its
// setup card over a user who already has somewhere to send a request.
await page.getByRole('button', { name: '设置', exact: true }).click()
await settings.waitFor({ timeout: 10_000 })
await settings.getByRole('button', { name: '模型' }).click()
await settings.getByRole('button', { name: '编辑 DeepSeek (deepseek-official)' }).waitFor({ timeout: 10_000 })
expect(await settings.getByRole('textbox', { name: 'API 密钥', exact: true }).count()).toBe(0)
expect((await page.content()).includes('sk-e2e-minimax')).toBe(false)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['dismissed.expected.md'])
})
})

View File

@@ -56,9 +56,9 @@ describe('web e2e: plugin configuration section', () => {
await page.getByRole('button', { name: '设置', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '设置' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: '插件' }).click()
await dialog.getByRole('button', { name: '插件配置', exact: true }).click()
await expect
.poll(() => dialog.getByRole('button', { name: '插件' }).getAttribute('aria-current'), { timeout: 5_000 })
.poll(() => dialog.getByRole('button', { name: '插件配置', exact: true }).getAttribute('aria-current'), { timeout: 5_000 })
.toBe('true')
return dialog
}

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

@@ -23,6 +23,8 @@ import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/settings-chrome', import.meta.url))
const DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'dialog.expected.md')
const PLUGINS_EXPECTED = join(SNAPSHOT_DIR, 'plugins.expected.md')
const PLUGIN_ROW_SELECTOR = '[data-plugin-entry$="ui-settings"]'
const MODE = webSnapshotMode()
describe('web e2e: settings modal and General preferences', () => {
@@ -92,6 +94,28 @@ describe('web e2e: settings modal and General preferences', () => {
await dialog.getByRole('button', { name: '模型' }).click()
await expect.poll(() => dialog.getByRole('button', { name: '模型' }).getAttribute('aria-current'), { timeout: 5_000 }).toBe('true')
expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBeNull()
// Plugins is a read-only projection of the same assembled Loader tree.
// Capture one stable shipped row rather than the whole inventory so adding
// an unrelated plugin does not rewrite this surface's golden.
await dialog.getByRole('button', { name: '插件', exact: true }).click()
await dialog.getByRole('heading', { name: '插件', exact: true }).waitFor({ timeout: 10_000 })
const pluginRow = dialog.locator(PLUGIN_ROW_SELECTOR)
await pluginRow.waitFor({ timeout: 10_000 })
const expectedPluginCount = [...scaffold.ctx.loader.entries()]
.filter(entry => !entry.options.group)
.length
expect(await dialog.getByRole('searchbox', { name: '搜索插件' }).count()).toBe(1)
expect(await dialog.locator('[data-plugin-entry]').count()).toBe(expectedPluginCount)
expect(await dialog.locator('[data-plugin-count]').getAttribute('data-plugin-count'))
.toBe(String(expectedPluginCount))
expect(await dialog.getByRole('button', { name: '插件', exact: true }).getAttribute('aria-current')).toBe('true')
expect(await dialog.getByRole('button', { name: '模型' }).getAttribute('aria-current')).toBeNull()
const pluginsSnapshot = await captureStableAria(
page,
PLUGIN_ROW_SELECTOR,
scaffold.workspaceCwd,
)
await compareOrRefreshGolden(PLUGINS_EXPECTED, pluginsSnapshot, MODE)
// Close path 1: Escape.
await page.keyboard.press('Escape')
await expect.poll(() => page.getByRole('dialog', { name: '设置' }).count(), { timeout: 5_000 }).toBe(0)
@@ -454,6 +478,6 @@ describe('web e2e: settings modal and General preferences', () => {
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['dialog.expected.md'])
await assertFixtureInventory(SNAPSHOT_DIR, ['dialog.expected.md', 'plugins.expected.md'])
})
})

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

@@ -7,6 +7,9 @@
- button "模型":
- img
- text: 模型
- button "插件":
- img
- text: 插件
- button "Agent 预设":
- img
- text: Agent 预设

View File

@@ -7,6 +7,9 @@
- button "模型":
- img
- text: 模型
- button "插件":
- img
- text: 插件
- button "Agent 预设":
- img
- text: Agent 预设

View File

@@ -7,6 +7,9 @@
- button "模型":
- img
- text: 模型
- button "插件":
- img
- text: 插件
- button "Agent 预设":
- img
- text: Agent 预设

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

@@ -7,6 +7,9 @@
- button "模型":
- img
- text: 模型
- button "插件":
- img
- text: 插件
- button "Agent 预设":
- img
- text: Agent 预设

View File

@@ -7,6 +7,9 @@
- button "模型":
- img
- text: 模型
- button "插件":
- img
- text: 插件
- button "Agent 预设":
- img
- text: Agent 预设

View File

@@ -7,6 +7,9 @@
- button "模型":
- img
- text: 模型
- button "插件":
- img
- text: 插件
- button "Agent 预设":
- img
- text: Agent 预设

View File

@@ -7,6 +7,9 @@
- button "模型":
- img
- text: 模型
- button "插件":
- img
- text: 插件
- button "Agent 预设":
- img
- text: Agent 预设

View File

@@ -7,6 +7,9 @@
- button "模型":
- img
- text: 模型
- button "插件":
- img
- text: 插件
- button "Agent 预设":
- img
- text: Agent 预设

View File

@@ -0,0 +1,74 @@
- dialog "设置":
- navigation:
- text: 设置
- button "通用设置":
- img
- text: 通用设置
- button "模型":
- img
- text: 模型
- button "插件":
- img
- text: 插件
- button "Agent 预设":
- img
- text: Agent 预设
- button "插件配置":
- img
- text: 插件配置
- button "打开配置文件"
- button "关闭":
- img
- text: 关闭
- heading "模型" [level=2]
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
- list:
- listitem:
- text: DeepSeek
- img "API 密钥缺失"
- button "编辑 DeepSeek (deepseek-official)": 编辑
- text: 提供方
- combobox "提供方":
- option "amazon-bedrock"
- option "ant-ling"
- option "anthropic"
- option "azure-openai-responses"
- option "cerebras"
- option "cloudflare-ai-gateway"
- option "cloudflare-workers-ai"
- option "deepseek"
- option "fireworks"
- option "github-copilot"
- option "google"
- option "google-vertex"
- option "groq"
- option "huggingface"
- option "kimi-coding"
- option "minimax"
- option "minimax-cn" [selected]
- option "mistral"
- option "moonshotai"
- option "moonshotai-cn"
- option "nvidia"
- option "openai"
- option "openai-codex"
- option "opencode"
- option "opencode-go"
- option "openrouter"
- option "qwen-token-plan"
- option "qwen-token-plan-cn"
- option "together"
- option "vercel-ai-gateway"
- option "xai"
- option "xiaomi"
- option "xiaomi-token-plan-ams"
- option "xiaomi-token-plan-cn"
- option "xiaomi-token-plan-sgp"
- option "zai"
- option "zai-coding-cn"
- text: API 密钥
- textbox "API 密钥":
- /placeholder: 输入 API 密钥,或留空使用环境认证
- group: 自定义设置
- button "取消"
- button "保存"

View File

@@ -7,6 +7,9 @@
- button "模型":
- img
- text: 模型
- button "插件":
- img
- text: 插件
- button "Agent 预设":
- img
- text: Agent 预设

View File

@@ -7,6 +7,9 @@
- button "模型":
- img
- text: 模型
- button "插件":
- img
- text: 插件
- button "Agent 预设":
- img
- text: Agent 预设

View File

@@ -0,0 +1,6 @@
- listitem:
- button "ui-settings, 已挂载, 已启用":
- strong: ui-settings
- img "已挂载"
- text: 已启用
- img

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

@@ -68,6 +68,13 @@ describe('web e2e: startup auto-selection', () => {
it('keeps the resident Hero and composer nodes when the first Workspace session appears', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-first-workspace-stable-tree'))
await page.locator(`${ROOT_PHASE}[data-phase="hero"]`).waitFor({ timeout: 15_000 })
const headline = page.getByText('Into the Unknown', { exact: true })
const fish = headline.locator('xpath=preceding-sibling::span[1]/*[name()="svg"]')
const fishHitbox = fish.locator('..')
expect(await fish.evaluate(node => getComputedStyle(node).color))
.toBe(await headline.evaluate(node => getComputedStyle(node).color))
await fishHitbox.hover()
expect(await fish.evaluate(node => getComputedStyle(node).animationName)).not.toBe('none')
await page.evaluate(() => {
const refs = {
root: document.querySelector('div[data-phase="hero"]'),

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

@@ -74,12 +74,16 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () =
await input.fill(prompt)
await input.press('Enter')
const workflow = page.getByRole('button', { name: /^snapshot-flow/ })
const workflow = page.locator('[data-workflow-run][data-run-status="running"]')
await workflow.waitFor({ timeout: 30_000 })
expect(await workflow.getAttribute('aria-expanded')).toBe('true')
const phase = page.getByRole('button', { name: /^Run/ })
await phase.waitFor({ timeout: 15_000 })
await phase.click()
const disclosures = workflow.locator('[data-disclosure-row]')
await disclosures.nth(1).waitFor({ timeout: 15_000 })
expect(await disclosures.nth(0).getAttribute('role')).toBeNull()
expect(await disclosures.nth(0).getAttribute('aria-expanded')).toBeNull()
expect(await disclosures.nth(1).getAttribute('role')).toBeNull()
expect(await disclosures.nth(1).getAttribute('aria-expanded')).toBeNull()
expect(await disclosures.nth(0).evaluate(element => getComputedStyle(element).cursor)).not.toBe('pointer')
expect(await disclosures.nth(1).evaluate(element => getComputedStyle(element).cursor)).not.toBe('pointer')
const member = page.getByRole('button', { name: /^Open Reply with exactly the word/ })
await member.waitFor({ timeout: 15_000 })
await member.focus()
@@ -139,15 +143,20 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () =
const sessions = page.getByRole('tree', { name: 'Sessions' })
await sessions.getByRole('treeitem', { name: /Use the workflow tool exactly/ }).click()
await settled
await page.locator('[data-workflow-run][data-run-status="completed"]').waitFor()
expect(await page.locator('[data-chat-flow-kind="tool-call"]').count()).toBeGreaterThanOrEqual(1)
expect(await page.locator('[data-chat-flow-kind="workflow-run"]').count()).toBe(1)
const terminalWorkflow = page.getByRole('button', { name: /^snapshot-flow/ })
await terminalWorkflow.waitFor()
if (await terminalWorkflow.getAttribute('aria-expanded') !== 'true') await terminalWorkflow.click()
expect(await terminalWorkflow.getAttribute('aria-expanded')).toBe('false')
expect(await terminalWorkflow.evaluate(element => getComputedStyle(element).cursor)).toBe('pointer')
await terminalWorkflow.click()
const terminalPhase = page.getByRole('button', { name: /^Run/ })
await terminalPhase.waitFor()
if (await terminalPhase.getAttribute('aria-expanded') !== 'true') await terminalPhase.click()
expect(await terminalPhase.getAttribute('aria-expanded')).toBe('false')
expect(await terminalPhase.evaluate(element => getComputedStyle(element).cursor)).toBe('pointer')
await terminalPhase.click()
await page.getByText(CHILD_PROMPT, { exact: false }).waitFor()
await expect.poll(
() => page.getByRole('button', { name: /^Open Reply with exactly the word/ }).count(),
@@ -165,6 +174,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () =
await workflow.click()
const phase = page.getByRole('button', { name: /^Run/ })
await phase.waitFor()
expect(await phase.getAttribute('aria-expanded')).toBe('false')
await phase.click()
await page.getByText(CHILD_PROMPT, { exact: false }).waitFor()
expect(await page.getByRole('button', { name: /^Open Reply with exactly the word/ }).count()).toBe(0)

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([])