From f0989d9cd2e4916314669b6604739e0794f5c394 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:36:39 +0800 Subject: [PATCH 1/8] fix(ui-conversation): keep hero visible while a blank session opens A summary-proven blank session can only land back on the hero, so the settling phase (visibility:hidden composer seat) blanked the center column for the whole history round-trip during startup auto-selection. Exempt such sessions from settling and treat them as hero while loading. --- .../src/client/skeleton/ConversationRoot.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index c52ba0b6b3..a48ae13dd6 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -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,13 @@ 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. 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 } From 4afcc3810bdc483f8bd529a434035263c73882c6 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 31 Jul 2026 17:57:10 +0800 Subject: [PATCH 2/8] test(ui-conversation): pin the settling exemption for summary-blank sessions The mount helper's 4th positional argument becomes an options object so a test can set the session list row's blank flag independently of the conversation snapshot's; the two new cases cover both branches of the settling condition. --- .../ui-conversation/tests/skeleton.spec.tsx | 36 ++++++++++++++++--- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index eeb8404fd5..1d98cfd6fa 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -80,15 +80,19 @@ 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 + } = {}, ) { const root = sid('root') const sessions = createSnapshotStore({ 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 }, + [SID]: { id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one', running: false, waitingApproval: false, blank: options.summaryBlank ?? false, updatedAt: 2 }, }, current: SID, phase: 'ready', @@ -167,7 +171,7 @@ function mount( return
}) as ConversationRootProps['renderSlot'] const renderSlotChain = ((_key, _owner, opts) => ( - overlayTakeover + options.overlayTakeover === true ? ( <>
@@ -229,7 +233,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 +274,28 @@ describe('ConversationRoot resident composer', () => { expect(b.view.getByText('Selected Folder')).toBeTruthy() }) + it('settling phase: a blank session with no list summary 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('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') From be7bf9d4267871dda900c7ec4d5de794997c5cb5 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 31 Jul 2026 17:57:13 +0800 Subject: [PATCH 3/8] doc(agent-notes): record the blank-session settling exemption --- ...isible-while-blank-session-opens.i18n.yaml | 6 ++++ ...-hero-visible-while-blank-session-opens.md | 33 +++++++++++++++++++ ...ro-visible-while-blank-session-opens.zh.md | 33 +++++++++++++++++++ 3 files changed, 72 insertions(+) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.i18n.yaml new file mode 100644 index 0000000000..1221753cb7 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.i18n.yaml @@ -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: 10339c5e6540daa84e691c01edf335aaa2fdf8aa +2026-07-31-hero-visible-while-blank-session-opens.zh.md: 571962804489112dcccd66d18317835bab45e30e diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.md b/.agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.md new file mode 100644 index 0000000000..10339c5e65 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.md @@ -0,0 +1,33 @@ +# 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 while `openState === 'loading'` when the summary proves the session blank. A session the list already reports as blank can only land on the hero, so hiding buys nothing and costs the visible flash. When the summary row is absent — a session not yet listed — `summaryBlank` is `undefined` 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 with no list summary keep the previous settling behavior, so the guard still covers the case it was written for. Skeleton tests pin both branches: an unlisted blank session settles, a summary-proven blank session opening under `loading` renders hero chrome and a live textarea. diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.zh.md new file mode 100644 index 0000000000..5719628044 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.zh.md @@ -0,0 +1,33 @@ +# 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` 在摘要证明会话为空白时,接受 `openState === 'loading'` 期间处于 blank 的 composer。列表已报告为空白的会话只可能落到 hero,因此隐藏毫无收益,只换来一次可见闪烁。当摘要行缺失时——会话尚未出现在列表中——`summaryBlank` 为 `undefined`,保守的 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 行为,这道防护仍覆盖它当初针对的场景。骨架测试固定了两条分支:未列出的空白会话进入 settling;摘要已证明为空白的会话在 `loading` 期间渲染 hero 外壳与可用的文本框。 From 8acb924f8217bc33487ff63e628992b1f96c0bce Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 31 Jul 2026 18:09:31 +0800 Subject: [PATCH 4/8] doc(ui-conversation): note that the settling exemption spans every open state --- .../ui-conversation/src/client/skeleton/ConversationRoot.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index a48ae13dd6..5d8a1d36cf 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -69,6 +69,9 @@ export function ConversationRoot({ // 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' && summaryBlank !== true const hero = sessionId === undefined From 3b2ee5ce872f1fad4c58a4b81547cacb406e2991 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 31 Jul 2026 18:37:04 +0800 Subject: [PATCH 5/8] test(ui-conversation): pin all three list-summary shapes for the settling exemption The mount fixture always listed the session, so the case named "no list summary" actually exercised a row proving non-blank. An omitSummaryRow option drops the row, and the three cases now pin blank:false, an absent row, and the summary-proven blank open. --- .../ui-conversation/tests/skeleton.spec.tsx | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 1d98cfd6fa..ebc263b712 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -85,15 +85,20 @@ function mount( 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({ - 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: options.summaryBlank ?? false, updatedAt: 2 }, - }, + ids: listed ? [root, SID] : [root], + byId: { [root]: rootRow, ...listed && { [SID]: childRow } }, current: SID, phase: 'ready', }) @@ -274,13 +279,24 @@ describe('ConversationRoot resident composer', () => { expect(b.view.getByText('Selected Folder')).toBeTruthy() }) - it('settling phase: a blank session with no list summary hides the composer while it opens', () => { + 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' }), From da500aae67478576c865bb1194c41f49c9666437 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 31 Jul 2026 18:37:06 +0800 Subject: [PATCH 6/8] test(web): pin the startup auto-selection hero in the assembled app Holds the session.history response at the browser's network boundary so the auto-selected open is observable, then asserts the visible frame and the recorded phase timeline. Registered host-plane like the other scaffold-booting e2e files (host include + client project exclude). --- apps/web/tests/startup-auto-selection.e2e.ts | 119 +++++++++++++++++++ apps/web/tsconfig.json | 3 +- tsconfig.host.json | 1 + 3 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 apps/web/tests/startup-auto-selection.e2e.ts diff --git a/apps/web/tests/startup-auto-selection.e2e.ts b/apps/web/tests/startup-auto-selection.e2e.ts new file mode 100644 index 0000000000..6ffb6cbf83 --- /dev/null +++ b/apps/web/tests/startup-auto-selection.e2e.ts @@ -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 { + 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 + + 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, '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((resolve) => { releaseHistory = resolve }) + let historyRequested = (): void => {} + const historyInFlight = new Promise((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) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index a4cd2d9121..7e4e278f3a 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -47,7 +47,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": [ { diff --git a/tsconfig.host.json b/tsconfig.host.json index 0b2a16c7f8..f474693e7c 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -35,6 +35,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", From a4d88a98eeb72dbf1512748ab3a985d7781288f5 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 31 Jul 2026 18:37:08 +0800 Subject: [PATCH 7/8] doc(agent-notes): widen the settling-exemption note to the shipped condition --- ...6-07-31-hero-visible-while-blank-session-opens.i18n.yaml | 4 ++-- .../2026-07-31-hero-visible-while-blank-session-opens.md | 6 ++++-- .../2026-07-31-hero-visible-while-blank-session-opens.zh.md | 6 ++++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.i18n.yaml index 1221753cb7..12b3982d54 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.i18n.yaml @@ -2,5 +2,5 @@ # 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: 10339c5e6540daa84e691c01edf335aaa2fdf8aa -2026-07-31-hero-visible-while-blank-session-opens.zh.md: 571962804489112dcccd66d18317835bab45e30e +2026-07-31-hero-visible-while-blank-session-opens.md: b39963beffa403ef6fa44735aa88395a99139751 +2026-07-31-hero-visible-while-blank-session-opens.zh.md: b451d7c00ec7eb8d736134e738d72e5e07fd1b04 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.md b/.agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.md index 10339c5e65..b39963beff 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.md @@ -10,7 +10,7 @@ The conversation root has a `settling` phase for a session that is still opening ## 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 while `openState === 'loading'` when the summary proves the session blank. A session the list already reports as blank can only land on the hero, so hiding buys nothing and costs the visible flash. When the summary row is absent — a session not yet listed — `summaryBlank` is `undefined` and the conservative settling hide is unchanged. +`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. @@ -30,4 +30,6 @@ Object-layer reference churn found while diagnosing this — no-op projections m ## 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 with no list summary keep the previous settling behavior, so the guard still covers the case it was written for. Skeleton tests pin both branches: an unlisted blank session settles, a summary-proven blank session opening under `loading` renders hero chrome and a live textarea. +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`. diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.zh.md index 5719628044..b451d7c00e 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -`ConversationRoot` 在读取会话快照的同时读取会话列表摘要的 `blank` 标志,并让"摘要已证明为空白"的会话豁免 settling:`settling` 额外要求 `summaryBlank !== true`,而 `hero` 在摘要证明会话为空白时,接受 `openState === 'loading'` 期间处于 blank 的 composer。列表已报告为空白的会话只可能落到 hero,因此隐藏毫无收益,只换来一次可见闪烁。当摘要行缺失时——会话尚未出现在列表中——`summaryBlank` 为 `undefined`,保守的 settling 隐藏行为保持不变。 +`ConversationRoot` 在读取会话快照的同时读取会话列表摘要的 `blank` 标志,并让"摘要已证明为空白"的会话豁免 settling:`settling` 额外要求 `summaryBlank !== true`,而 `hero` 在摘要证明会话为空白时接受处于 blank 的 composer——覆盖全部 open state,而非仅 `loading`。列表已报告为空白的会话只可能落到 hero,因此隐藏毫无收益,只换来一次可见闪烁;同一份证明在打开开始之前(`cold`)与打开失败之后(`error`)同样成立,而此前的条件会在这两种状态下落到 active 阶段,在 `ConversationSession` 为空白会话隐藏的外壳之下渲染出一条停靠的裸 composer。只要摘要没有证明会话为空白——无论是报告 `blank: false` 的行,还是列表尚未跟上因而根本没有该行——`summaryBlank` 都不为 `true`,保守的 settling 隐藏行为保持不变。 摘要标志与快照自身的 `blank` 是两个不同来源:快照描述正在打开的这个会话,摘要则是在打开操作完成之前就已存在的列表行。只有后者足够早,可用于决定阶段。 @@ -30,4 +30,6 @@ Status: implemented ## 影响 -启动自动选择会立即渲染 hero,并在整个历史往返期间保持 composer 座位与 header 可见,因此启动进入最近工作区不再像页面重载。没有列表摘要的会话保持原有的 settling 行为,这道防护仍覆盖它当初针对的场景。骨架测试固定了两条分支:未列出的空白会话进入 settling;摘要已证明为空白的会话在 `loading` 期间渲染 hero 外壳与可用的文本框。 +启动自动选择会立即渲染 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` 的时刻。 From 2dde4e7f09f5f6659f305b2a9afe5d56e40da0ba Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 31 Jul 2026 18:46:22 +0800 Subject: [PATCH 8/8] test(web): follow connectFreshWorkspace's staged-directory signature --- apps/web/tests/startup-auto-selection.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/startup-auto-selection.e2e.ts b/apps/web/tests/startup-auto-selection.e2e.ts index 6ffb6cbf83..f3a953c1e6 100644 --- a/apps/web/tests/startup-auto-selection.e2e.ts +++ b/apps/web/tests/startup-auto-selection.e2e.ts @@ -57,7 +57,7 @@ describe('web e2e: startup auto-selection', () => { 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, 'startup-auto-selection') + await connectFreshWorkspace(page, scaffold.workspaceCwd, 'startup-auto-selection') }, 180_000) afterAll(async () => {