Merge branch 'master' into fix/web-turn-error-surface
This commit is contained in:
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.md
|
||||
2026-07-31-hero-visible-while-blank-session-opens.md: b39963beffa403ef6fa44735aa88395a99139751
|
||||
2026-07-31-hero-visible-while-blank-session-opens.zh.md: b451d7c00ec7eb8d736134e738d72e5e07fd1b04
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
# Agent Note: Hero stays visible while a blank session opens
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-31-hero-visible-while-blank-session-opens.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The conversation root has a `settling` phase for a session that is still opening while its composer reads `blank`: the hero-versus-docked outcome is unknowable until history arrives, so the composer seat is hidden (`visibility:hidden`) rather than flashing the centered hero and snapping to the docked bar. Startup auto-selection turned that guard into the defect it was meant to prevent. From the no-workspace hero, `WorkspacesService.startInitialSelection` connects the most recent workspace and opens its blank session; `openState` flips to `loading` the moment `open()` lands, so the center column went blank for the whole history round-trip and then repainted, which reads as a full-page refresh on every launch.
|
||||
|
||||
## Decision
|
||||
|
||||
`ConversationRoot` reads the session list summary's `blank` flag alongside the conversation snapshot and exempts summary-proven blank sessions from settling: `settling` additionally requires `summaryBlank !== true`, and `hero` accepts a blank composer whenever the summary proves the session blank, in every open state rather than only `loading`. A session the list already reports as blank can only land on the hero, so hiding buys nothing and costs the visible flash; the same proof holds before the open starts (`cold`) and after one fails (`error`), where the previous conditions fell through to the active phase and rendered a docked bare composer under chrome `ConversationSession` hides for blank sessions. Whenever the summary does not prove the session blank — a row reporting `blank: false`, or no row at all because the list has not caught up — `summaryBlank` is not `true` and the conservative settling hide is unchanged.
|
||||
|
||||
The summary flag and the snapshot's own `blank` are distinct sources: the snapshot describes the session being opened, the summary is the list row that already exists before the open resolves. Only the latter is available early enough to decide the phase.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Drop the settling phase entirely.** Rejected because it still earns its keep for a session with no summary row: without a prior claim about emptiness, hero-versus-docked is genuinely unknowable and the flash it prevents is the worse one.
|
||||
|
||||
**Delay the `loading` flip until history returns.** Rejected because `openState` is authoritative about the open operation; deferring it to suppress a presentation artifact would misreport the data state to every other consumer.
|
||||
|
||||
**Cross-fade or otherwise animate the settling hide.** Rejected because the column has nothing to show during the round-trip either way — the fix is to not hide content whose outcome is already known, not to decorate the hiding.
|
||||
|
||||
## Deferred
|
||||
|
||||
The no-session→session tree relocation in `ConversationRoot` (the hero/composer subtree moves into the `conversation.session` outlet) still rebuilds the composer DOM on the same transition; removing it means moving `conversation.session` to `session-maybe` scope, a slot-contract change that needs its own proposal.
|
||||
|
||||
Object-layer reference churn found while diagnosing this — no-op projections minting fresh snapshots, the create path projecting twice, `select()` using `notifyNow` from async continuations — is real but independent of the visible flash.
|
||||
|
||||
## Consequences
|
||||
|
||||
Startup auto-selection renders the hero immediately and keeps the composer seat and header visible through the history round-trip, so launching into a recent workspace no longer looks like a page reload. Sessions whose summary does not prove them blank keep the previous settling behavior, so the guard still covers the case it was written for. Skeleton tests pin all three summary shapes: a row reporting `blank: false` settles, an absent row settles, and a summary-proven blank session opening under `loading` renders hero chrome with a live textarea.
|
||||
|
||||
The assembled coverage is `apps/web/tests/startup-auto-selection.e2e.ts` (keyless web browser lane): it registers a workspace, holds the `session.history` response open at the browser's network boundary, and asserts the visible frame while the auto-selected open is in flight — hero phase, hero title, painted composer — plus a recorded phase timeline of exactly `['hero']` for the whole load. Holding the round-trip is what makes it a regression test rather than a race: against a loopback host the open settles too fast to sample, and with the exemption reverted the held window is precisely when the root reports `settling`.
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
# Agent Note: 空白会话打开期间保持 hero 可见
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-31-hero-visible-while-blank-session-opens.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
会话根节点为"正在打开且 composer 处于 `blank`"的会话保留了一个 `settling` 阶段:在历史记录返回之前,hero 与 docked 的归属不可知,因此宁可隐藏 composer 座位(`visibility:hidden`),也不要先闪出居中的 hero 再跳到底部输入条。启动时的自动选择把这道防护变成了它本要防止的缺陷。从无工作区的 hero 进入时,`WorkspacesService.startInitialSelection` 会连接最近的工作区并打开其空白会话;`open()` 一落地 `openState` 立即翻为 `loading`,中间栏因此在整个历史往返期间保持空白,随后重绘一次——每次启动看起来都像整页刷新。
|
||||
|
||||
## 决策
|
||||
|
||||
`ConversationRoot` 在读取会话快照的同时读取会话列表摘要的 `blank` 标志,并让"摘要已证明为空白"的会话豁免 settling:`settling` 额外要求 `summaryBlank !== true`,而 `hero` 在摘要证明会话为空白时接受处于 blank 的 composer——覆盖全部 open state,而非仅 `loading`。列表已报告为空白的会话只可能落到 hero,因此隐藏毫无收益,只换来一次可见闪烁;同一份证明在打开开始之前(`cold`)与打开失败之后(`error`)同样成立,而此前的条件会在这两种状态下落到 active 阶段,在 `ConversationSession` 为空白会话隐藏的外壳之下渲染出一条停靠的裸 composer。只要摘要没有证明会话为空白——无论是报告 `blank: false` 的行,还是列表尚未跟上因而根本没有该行——`summaryBlank` 都不为 `true`,保守的 settling 隐藏行为保持不变。
|
||||
|
||||
摘要标志与快照自身的 `blank` 是两个不同来源:快照描述正在打开的这个会话,摘要则是在打开操作完成之前就已存在的列表行。只有后者足够早,可用于决定阶段。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**彻底移除 settling 阶段。** 否决,因为对没有摘要行的会话它仍有价值:在缺少任何关于"是否为空"的先验断言时,hero 与 docked 的归属确实不可知,而它所防止的那种闪烁更糟糕。
|
||||
|
||||
**推迟 `loading` 的翻转,直到历史返回。** 否决,因为 `openState` 是打开操作的权威状态;为了压制一个呈现层瑕疵而推迟它,会向其他所有消费者误报数据状态。
|
||||
|
||||
**为 settling 的隐藏加交叉淡入或其他动画。** 否决,因为无论如何该栏在往返期间都没有内容可展示——正确的修复是不隐藏结局已知的内容,而不是把隐藏装饰得好看些。
|
||||
|
||||
## 推迟事项
|
||||
|
||||
`ConversationRoot` 中"无会话→有会话"的树位置迁移(hero/composer 子树移入 `conversation.session` 出口)仍会在同一次转换中重建 composer 的 DOM;消除它意味着把 `conversation.session` 移到 `session-maybe` 作用域,这是一次插槽契约变更,需要单独立项。
|
||||
|
||||
诊断期间发现的对象层引用抖动——空操作投影铸造出新的快照、创建路径重复投影一次、`select()` 在异步续体中使用 `notifyNow`——确实存在,但与这次可见闪烁相互独立。
|
||||
|
||||
## 影响
|
||||
|
||||
启动自动选择会立即渲染 hero,并在整个历史往返期间保持 composer 座位与 header 可见,因此启动进入最近工作区不再像页面重载。摘要未证明为空白的会话保持原有的 settling 行为,这道防护仍覆盖它当初针对的场景。骨架测试固定了摘要的三种形态:报告 `blank: false` 的行进入 settling;根本没有该行同样进入 settling;摘要已证明为空白的会话在 `loading` 期间渲染 hero 外壳与可用的文本框。
|
||||
|
||||
组装级覆盖是 `apps/web/tests/startup-auto-selection.e2e.ts`(无密钥的 Web 浏览器泳道):它注册一个工作区,在浏览器网络边界上扣住 `session.history` 的响应,并在自动选择的打开仍在飞行途中断言可见画面——hero 阶段、hero 标题、已绘制的 composer——外加整次加载记录到的阶段时间线恰好为 `['hero']`。扣住这次往返正是它成为回归测试而非竞态的原因:对着回环主机,打开会快到无从采样;而一旦回退这条豁免,被扣住的这段窗口恰恰就是根节点报告 `settling` 的时刻。
|
||||
@@ -0,0 +1,119 @@
|
||||
// Web e2e scenario: startup auto-selection keeps the hero on screen.
|
||||
//
|
||||
// A page load with a workspace already registered runs
|
||||
// `WorkspacesService.startInitialSelection`: it connects the most recent
|
||||
// workspace and opens its blank session. `openState` flips to `loading` the
|
||||
// moment `open()` lands, which used to drive `data-phase=settling` on the
|
||||
// conversation root — `visibility:hidden` over the composer seat and the
|
||||
// header for the whole `session.history` round-trip, so the center column went
|
||||
// blank and repainted, reading as a full-page refresh on every launch.
|
||||
//
|
||||
// The unit spec pins the phase condition over hand-built stores. What only the
|
||||
// assembled application can show is that the path a user actually takes
|
||||
// reaches it: the real selection service, the real client session opening over
|
||||
// the real /api transport, and a real browser deciding what is painted.
|
||||
//
|
||||
// The round-trip against a loopback host is far too fast to observe, so this
|
||||
// scenario HOLDS the `session.history` response open at the browser's network
|
||||
// boundary and asserts the visible frame while it is in flight. That gate is
|
||||
// what makes the assertions non-vacuous: with the exemption reverted the held
|
||||
// window is exactly when `settling` is painted and the composer is hidden.
|
||||
//
|
||||
// Zero model calls: registering a workspace and opening its blank session are
|
||||
// host RPCs with no model involvement. A stray stream would fail loud with
|
||||
// NO_ADAPTER.
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { acknowledgeReloadConnectionLoss, launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
/** Wire path of the history round-trip the conversation root waits out (POST /api/session.history). */
|
||||
const HISTORY_ROUTE = '**/api/session.history'
|
||||
|
||||
/**
|
||||
* The conversation root's own phase attribute. `div` disambiguates it from the
|
||||
* composer textarea, which carries an unrelated `data-phase` of its own.
|
||||
*/
|
||||
const ROOT_PHASE = 'div[data-phase]'
|
||||
|
||||
/** Every distinct `data-phase` the conversation root shows, in order, across one page load. */
|
||||
function recordedPhases(page: Page): Promise<string[]> {
|
||||
return page.evaluate(() => (window as unknown as { __conversationPhases: string[] }).__conversationPhases)
|
||||
}
|
||||
|
||||
describe('web e2e: startup auto-selection', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
// A registered workspace is the precondition for auto-selection: the first
|
||||
// load has nothing to select, so the reload below is the path under test.
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd, 'startup-auto-selection')
|
||||
}, 180_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('keeps the hero and the composer on screen while the auto-selected blank session opens', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-startup-auto-selection'))
|
||||
// Runs before any page script on the reload below, so the first phase the
|
||||
// root ever renders is recorded, not just the ones after a listener attaches.
|
||||
await page.addInitScript(() => {
|
||||
const phases: string[] = []
|
||||
;(window as unknown as { __conversationPhases: string[] }).__conversationPhases = phases
|
||||
setInterval(() => {
|
||||
const phase = document.querySelector('div[data-phase]')?.getAttribute('data-phase')
|
||||
if (phase === null || phase === undefined) return
|
||||
if (phases[phases.length - 1] !== phase) phases.push(phase)
|
||||
}, 8)
|
||||
})
|
||||
|
||||
let releaseHistory = (): void => {}
|
||||
const historyHeld = new Promise<void>((resolve) => { releaseHistory = resolve })
|
||||
let historyRequested = (): void => {}
|
||||
const historyInFlight = new Promise<void>((resolve) => { historyRequested = resolve })
|
||||
let gated = false
|
||||
await page.route(HISTORY_ROUTE, async (route) => {
|
||||
// Only the auto-selection's own round-trip is held; later pages must not
|
||||
// deadlock behind a gate this test has already released.
|
||||
if (gated) { await route.continue(); return }
|
||||
gated = true
|
||||
historyRequested()
|
||||
await historyHeld
|
||||
await route.continue()
|
||||
})
|
||||
|
||||
const warningsBefore = tripwire.warnings.length
|
||||
await page.reload({ waitUntil: 'commit' })
|
||||
await historyInFlight
|
||||
|
||||
// The frame a user sees while the session is still opening: hero phase, the
|
||||
// hero title, and a composer that is actually painted (`settling` hides the
|
||||
// seat with `visibility:hidden`, which Playwright reports as not visible).
|
||||
await page.waitForSelector(ROOT_PHASE, { timeout: 15_000 })
|
||||
expect(await page.locator(ROOT_PHASE).first().getAttribute('data-phase')).toBe('hero')
|
||||
expect(await page.getByText("Let's start building").isVisible()).toBe(true)
|
||||
expect(await page.locator('textarea').first().isVisible()).toBe(true)
|
||||
|
||||
releaseHistory()
|
||||
await page.locator('textarea:enabled[placeholder="Describe what you want to build"]')
|
||||
.waitFor({ timeout: 15_000 })
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningsBefore)
|
||||
|
||||
// Settling is not merely absent from the frame sampled above: the root
|
||||
// never entered it at any point of the load.
|
||||
expect(await recordedPhases(page)).toEqual(['hero'])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 120_000)
|
||||
})
|
||||
@@ -48,7 +48,8 @@
|
||||
"tests/queue-actions.e2e.ts",
|
||||
"tests/skill-invocation-policy.e2e.ts",
|
||||
"tests/permission-policy-context.e2e.ts",
|
||||
"tests/access-confirmation.e2e.ts"
|
||||
"tests/access-confirmation.e2e.ts",
|
||||
"tests/startup-auto-selection.e2e.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
|
||||
@@ -22,6 +22,7 @@ export function ConversationRoot({
|
||||
const session = useSession(s => s)
|
||||
const inputState = useInput(s => s)
|
||||
const cwd = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.cwd)
|
||||
const summaryBlank = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.blank)
|
||||
const workspaces = useWorkspaces(s => s)
|
||||
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
@@ -65,8 +66,16 @@ export function ConversationRoot({
|
||||
// While a session is still replaying (loading + blank) the hero/docked
|
||||
// choice is unknowable — render the composer hidden instead of flashing
|
||||
// the centered hero and snapping to the docked bar (or vice versa).
|
||||
// Exemption: a session the list summary already proves blank can only
|
||||
// land on the hero, so hiding would blank the column for the whole
|
||||
// history round-trip (the startup auto-selection flash) for nothing.
|
||||
// The exemption is deliberately open-state-wide, not loading-only: a
|
||||
// summary-blank session is the hero before its open starts (`cold`) and
|
||||
// after one fails (`error`) for the same reason — there is no history.
|
||||
const settling = sessionId !== undefined && composerPhase === 'blank' && openState === 'loading'
|
||||
const hero = sessionId === undefined || (composerPhase === 'blank' && openState === 'open')
|
||||
&& summaryBlank !== true
|
||||
const hero = sessionId === undefined
|
||||
|| (composerPhase === 'blank' && (openState === 'open' || summaryBlank === true))
|
||||
const zone: InputZone | undefined =
|
||||
session === undefined || inputState === undefined ? undefined : { session, input: inputState }
|
||||
|
||||
|
||||
@@ -80,16 +80,25 @@ function mount(
|
||||
snapshot: ConversationSnapshot,
|
||||
workspaceRows: WorkspaceView[] = [{ ...workspace('one'), sessionIds: [SID] }],
|
||||
retargetWorkspace = vi.fn(async (_workspaceId: WorkspaceId) => {}),
|
||||
/** When true, mimic overlay:true chain siblings (hidden fallback + takeover). */
|
||||
overlayTakeover = false,
|
||||
options: {
|
||||
/** When true, mimic overlay:true chain siblings (hidden fallback + takeover). */
|
||||
overlayTakeover?: boolean
|
||||
/** The session list summary's `blank` flag — independent of the snapshot's. */
|
||||
summaryBlank?: boolean
|
||||
/** Drop the session's summary row entirely (a session the list has not caught up with). */
|
||||
omitSummaryRow?: boolean
|
||||
} = {},
|
||||
) {
|
||||
const root = sid('root')
|
||||
const rootRow = { id: root, displayTitle: 'Root', running: false, waitingApproval: false, blank: false, updatedAt: 1 }
|
||||
const childRow = {
|
||||
id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one',
|
||||
running: false, waitingApproval: false, blank: options.summaryBlank ?? false, updatedAt: 2,
|
||||
}
|
||||
const listed = options.omitSummaryRow !== true
|
||||
const sessions = createSnapshotStore<SessionListState>({
|
||||
ids: [root, SID],
|
||||
byId: {
|
||||
[root]: { id: root, displayTitle: 'Root', running: false, waitingApproval: false, blank: false, updatedAt: 1 },
|
||||
[SID]: { id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one', running: false, waitingApproval: false, blank: false, updatedAt: 2 },
|
||||
},
|
||||
ids: listed ? [root, SID] : [root],
|
||||
byId: { [root]: rootRow, ...listed && { [SID]: childRow } },
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
})
|
||||
@@ -167,7 +176,7 @@ function mount(
|
||||
return <div data-testid={`view-${opts?.only ?? key}`} />
|
||||
}) as ConversationRootProps['renderSlot']
|
||||
const renderSlotChain = ((_key, _owner, opts) => (
|
||||
overlayTakeover
|
||||
options.overlayTakeover === true
|
||||
? (
|
||||
<>
|
||||
<div data-chain-overlay-fallback="conversation.composer" style={{ display: 'none' }}>
|
||||
@@ -229,7 +238,7 @@ describe('ConversationRoot resident composer', () => {
|
||||
})
|
||||
|
||||
it('sticky composer seat wraps the whole overlay chain, not only the fallback stack', () => {
|
||||
const b = mount(conversationSnapshot(), undefined, undefined, true)
|
||||
const b = mount(conversationSnapshot(), undefined, undefined, { overlayTakeover: true })
|
||||
const seat = b.view.container.querySelector('[data-composer-seat]')
|
||||
const takeover = b.view.getByTestId('composer-takeover')
|
||||
const fallback = b.view.container.querySelector('[data-chain-overlay-fallback="conversation.composer"]')
|
||||
@@ -270,6 +279,39 @@ describe('ConversationRoot resident composer', () => {
|
||||
expect(b.view.getByText('Selected Folder')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('settling phase: a summary that does not prove the session blank hides the composer while it opens', () => {
|
||||
const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true, openState: 'loading' }))
|
||||
const root = b.view.container.querySelector('[data-phase]')
|
||||
expect(root?.getAttribute('data-phase')).toBe('settling')
|
||||
expect(b.view.queryByText('开始构建吧')).toBeNull()
|
||||
})
|
||||
|
||||
it('settling phase: a session the list has no row for settles conservatively', () => {
|
||||
const b = mount(
|
||||
conversationSnapshot({ composerPhase: 'blank', blank: true, openState: 'loading' }),
|
||||
undefined,
|
||||
undefined,
|
||||
{ omitSummaryRow: true },
|
||||
)
|
||||
const root = b.view.container.querySelector('[data-phase]')
|
||||
expect(root?.getAttribute('data-phase')).toBe('settling')
|
||||
})
|
||||
|
||||
it('startup auto-selection: a summary-proven blank session opens straight into the hero', () => {
|
||||
const b = mount(
|
||||
conversationSnapshot({ composerPhase: 'blank', blank: true, openState: 'loading' }),
|
||||
undefined,
|
||||
undefined,
|
||||
{ summaryBlank: true },
|
||||
)
|
||||
// The summary already proves the outcome, so the settling hide would only
|
||||
// blank the column for the history round-trip.
|
||||
const root = b.view.container.querySelector('[data-phase]')
|
||||
expect(root?.getAttribute('data-phase')).toBe('hero')
|
||||
expect(b.view.getByText('开始构建吧')).toBeTruthy()
|
||||
expect(b.view.getByRole('textbox')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('same textarea DOM node survives the hero → active flip into the sticky scrollport', () => {
|
||||
const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true }))
|
||||
const before = b.view.getByRole('textbox')
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"apps/web/tests/skill-invocation-policy.e2e.ts",
|
||||
"apps/web/tests/permission-policy-context.e2e.ts",
|
||||
"apps/web/tests/access-confirmation.e2e.ts",
|
||||
"apps/web/tests/startup-auto-selection.e2e.ts",
|
||||
"apps/cli/tests/**/*.ts",
|
||||
"examples/*/src/**/*.ts",
|
||||
"examples/*/start.ts",
|
||||
|
||||
Reference in New Issue
Block a user