Merge remote-tracking branch 'origin/master' into worktree/code-diff-card

This commit is contained in:
creatixchu committed 2026-09-16 19:22:17 +08:00
commit 5991ea36ae
13 files changed
+133 -36

No files matched your search

@@ -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/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md
2026-07-25-web-client-session-scope-and-provide-channel.md: 620102f9f5e7fd76f38bf0031b856b26c2a7840d
2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 3f00658c672908ba92627416ba5d5db381c0d9cd
2026-07-25-web-client-session-scope-and-provide-channel.md: 0f003edc4a53e7a04ea4f5613abe42c2d3926566
2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 1bae16cfaa1c54eebc6c8709ac6db5323b68d222
@@ -95,6 +95,8 @@ Slot scope is the closed set `root | session-maybe | session`:
`conversation` is the resident `session-maybe` shell: `ConversationRoot`, HeroShell, the Workspace picker, the root-owned scrollport and composer stack, and the overlay chain's fallback frame retain their React instances across the no-session → blank-session switch. Two strict entries fill fixed regions without reparenting that tree: `conversation.session.header` carries breadcrumb/tabs/actions above the scrollport, while `conversation.session` carries the view ring and draft mirror inside it; both share the same session-scoped chat store. The composer bar (`conversation.composer.bar`) is itself `session-maybe`: with no session its machine faces and message actions are inert, while the whole dashed card opens the existing Workspace picker by pointer and its read-only textarea does the same through Enter or Space. The same instance — textarea included — goes live when a session appears; the remaining input slots stay strict `session` and dispatch nothing until then. The blank → engaging/active transition never rebuilds the InputBar on a phase flip.
Blank Sessions retain the header's leading and corner slots so navigation controls, including the right-sidebar opener, are available before the first message. Title, actions, utilities, and View tabs remain hidden in the blank phase. The header still requires a selected Session; the Files and Terminal entries use that Session's workspace and execution services without requiring a recorded Turn.
- The runtime's first built-in entry: the `'session'` hook — `useSession` itself rides the same mechanism, no special-casing.
- Concurrent discipline: the render plane reads only from the hooks compartment (uSES consistency guarantee); props-compartment callbacks are used only in event-handler space; descriptor resolution is render-safe (idempotent caching, with prune reaping residue from abandoned renders).
- Third-party components take zero value dependencies; types are a one-line type-only import (declaration merging into `SessionStandardProps` / `SessionMaybeStandardProps`).
@@ -95,6 +95,8 @@ slot scope 是闭集 `root | session-maybe | session`:
`conversation` 是 `session-maybe` 的常驻外壳:`ConversationRoot`、HeroShell、Workspace picker、root 持有的 scrollport 与 composer stack,以及 overlay chain 的 fallback 外框,在无会话 → blank 会话的切换中保持 React 实例。两个严格 session entry 只填入固定区域,不改变该树的父级:`conversation.session.header` 在 scrollport 上方承载 breadcrumb/tab/action,`conversation.session` 在其内部承载 view ring 与 draft mirror;二者共享同一个 session scope chat store。composer bar(`conversation.composer.bar`)本身即为 `session-maybe`:无 session 时,其 machine faces 和消息动作保持惰性,整张虚线卡片可经指针打开现有 Workspace picker,只读 textarea 也可通过 Enter 或 Space 打开。session 出现后同一实例(含 textarea)转为 live;其余输入 slot 保持严格 `session`,在此之前不派发任何内容。blank → engaging/active 的 InputBar 不因 phase 翻转而重建。
blank Session 保留 header 的 leading 与 corner slot,让右侧栏展开入口等导航控件在首条消息之前即可使用。标题、actions、utilities 和 View tabs 在 blank phase 中继续隐藏。header 仍要求已选中的 Session;Files 与 Terminal 入口使用该 Session 的工作区和执行服务,无需已有 Turn 记录。
- 运行时内建第一条:`'session'` 钩子——`useSession` 本身走同一机制,无特判。
- Concurrent 纪律:渲染平面只从 hooks 格读(uSES 一致性保证);props 格回调只在事件 handler 空间用;描述符解析 render-safe(幂等缓存、废弃渲染残留由 prune 收尸)。
- 第三方组件值零依赖,类型一行 type-only import(declaration merging 进 `SessionStandardProps` / `SessionMaybeStandardProps`)。
@@ -1,5 +1,5 @@
// Recorded-session Sidebar geometry and per-Session view state through the shipped browser composition.
import { mkdir, readFile } from 'node:fs/promises'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Locator, Page } from 'playwright'
@@ -15,6 +15,7 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/details-session-lifecycle', import.meta.url))
const HANDLES_EXPECTED = join(SNAPSHOT_DIR, 'handles.expected.md')
const SIDEBAR_EXPECTED = join(SNAPSHOT_DIR, 'sidebar.expected.md')
const BLANK_EXPECTED = join(SNAPSHOT_DIR, 'blank-session.expected.md')
const SHOT_DIR = fileURLToPath(new URL('../../../.artifacts/screenshots/0907-sidebar-rules', import.meta.url))
const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome/session.v3.jsonl', import.meta.url))
const SEED_FIXTURE = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url))
@@ -127,17 +128,20 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
const sessionEvents: string[] = []
beforeAll(async () => {
const fixture = await readFile(FIXTURE, 'utf8')
expect(fixtureUserPrompts(fixture)).toEqual([PROMPT])
scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 5, compareReplaySession: false })
await seedSession(scaffold, await readFile(SEED_FIXTURE, 'utf8'), 'details-session-lifecycle-seed')
scaffold.ctx.on('session/event', (_session, event) => { sessionEvents.push(event.type) })
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await appFrame(page).waitFor({ timeout: 30_000 })
expect(await page.locator('[data-sidebar-right-expand]').count()).toBe(0)
await connectFreshWorkspace(page, scaffold.workspaceCwd)
}, 120_000)
@@ -154,12 +158,77 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
await mkdir(SHOT_DIR, { recursive: true })
await saveFailureShot(page, `screenshots/0907-sidebar-rules/details-session-lifecycle-${MODE}-${process.pid}`)
})
const blankColumn = page.locator('[data-rightbar-col]')
const workspace = join(scaffold.workspaceCwd, 'workspace')
await writeFile(join(workspace, 'before-chat.md'), '# Before the first message\n\nWorkspace preview is available.\n')
await page.locator('[data-sidebar-right-expand]').waitFor({ state: 'visible' })
await page.screenshot({ path: join(SHOT_DIR, `blank-collapsed-${MODE}-${process.pid}.png`), fullPage: true })
await page.locator('[data-sidebar-right-expand]').click()
await blankColumn.locator('[data-sidebar-right-guide-entry="files"]').click()
await blankColumn.locator('[data-files-entry="file"]').getByRole('button', { name: 'before-chat.md', exact: true }).click()
await blankColumn.getByText('Workspace preview is available.', { exact: true }).waitFor()
await page.getByText('Into the Unknown', { exact: false }).waitFor()
const blankPanes = await paneSnapshot(page)
expect(blankPanes.map(pane => pane.tabs.map(tab => tab.title))).toEqual([['Files', 'before-chat.md']])
await page.screenshot({ path: join(SHOT_DIR, `blank-preview-${MODE}-${process.pid}.png`), fullPage: true })
const blankViewport = page.viewportSize()!
try {
await blankColumn.locator('[data-sidebar-right-toggle]').click()
await page.setViewportSize({ width: 767, height: blankViewport.height })
await page.locator('[data-sidebar-right-expand]').click()
await expect.poll(() => blankColumn.locator('[data-sidebar-right-panel]').boundingBox())
.toEqual({ x: 0, y: 0, width: 767, height: blankViewport.height })
await blankColumn.getByText('Workspace preview is available.', { exact: true }).waitFor()
await blankColumn.locator('[data-sidebar-right-toggle]').click()
} finally {
await page.setViewportSize(blankViewport)
}
await page.locator('[data-sidebar-right-expand]').click()
await blankColumn.locator('[data-dockkit-add-tab]').click()
await blankColumn.locator('[data-sidebar-right-guide-entry="terminal"]')
.getByRole('button', { name: /^New terminal/u }).click()
const agent = scaffold.ctx.agents.list().find(agent => agent.session.header.cwd === workspace)
if (agent === undefined) throw new Error('Blank Session has no workspace Agent')
await expect.poll(() => scaffold.ctx.terminalController.list(agent.id).map(terminal => terminal.state)).toEqual(['running'])
await page.locator('.xterm-helper-textarea:visible').click()
await page.keyboard.insertText('node -e "require(\'fs\').writeFileSync(\'before-chat-terminal.txt\',\'READY\')"')
await page.keyboard.press('Enter')
await expect.poll(() => readFile(join(workspace, 'before-chat-terminal.txt'), 'utf8')).toBe('READY')
expect(sessionEvents).not.toContain('turn/start')
expect(sessionEvents).not.toContain('user/message')
await blankColumn.locator('[data-dockkit-tab][aria-selected="true"]').hover()
// The tab disappears before the Host finishes process cleanup; close responds after quiescence.
const [closed] = await Promise.all([
page.waitForResponse('**/api/terminal/close'),
blankColumn.locator('[data-dockkit-tab][aria-selected="true"] [data-dockkit-tab-close]').click(),
])
expect(await closed.json()).toMatchObject({ result: { ok: true } })
expect(scaffold.ctx.terminalController.list(agent.id)).toEqual([])
await blankColumn.locator('[data-dockkit-tab]').filter({ hasText: 'before-chat.md' }).click()
await compareOrRefreshGolden(BLANK_EXPECTED, [
'# Blank Session workspace sidebar', '',
'- No selected Session: expand control absent',
'- Selected workspace before first message: expand control visible',
'- Files: before-chat.md opens as a Markdown preview',
'- Narrow viewport: reopened preview fills the viewport',
'- Terminal: writes a file in the selected workspace before any user message or turn', '',
`\`\`\`json\n${JSON.stringify(await paneSnapshot(page), null, 2)}\n\`\`\``,
].join('\n'), MODE)
const settled = scaffold.whenTurnSettled()
const input = page.locator('[data-composer-input]').first()
await input.fill(PROMPT)
await input.press('Enter')
await settled
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 })
expect(await paneSnapshot(page)).toEqual(blankPanes)
await blankColumn.getByText('Workspace preview is available.', { exact: true }).waitFor()
for (const title of ['before-chat.md', 'Files']) {
const tab = blankColumn.locator('[data-dockkit-tab]').filter({ hasText: title })
await tab.hover()
await tab.locator('[data-dockkit-tab-close]').click()
}
await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0)
expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false)
@@ -356,6 +425,6 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
await compareOrRefreshGolden(SIDEBAR_EXPECTED, checkpoints.join('\n\n'), MODE)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['handles.expected.md', 'sidebar.expected.md'])
await assertFixtureInventory(SNAPSHOT_DIR, ['handles.expected.md', 'sidebar.expected.md', 'blank-session.expected.md'])
})
})
@@ -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 packages/client/ui-conversation/README.md
README.md: 802ba3b8d853457e00cab3180c251ffec5291f19
README.zh.md: 83199417289770640c9c038d84ee3e0fa6ac7866
README.md: e07c2f66e55d0bfe55b869d5b395914670278be3
README.zh.md: 81e5300e5bac3d6d54e83ead165598cb2c378a99
@@ -46,6 +46,8 @@ Workspace selection uses `uiWorkspace.openWorkspace` to prepare the target and c
The package occupies the root-scoped `main` key `conversation`, whose wrapper declares the optional-Session `main.conversation` shell. It registers strict Session header/body entries, View list, composer chain and bar, input regions, Hero regions, queue dock, draft persistence, and phase calculation. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop.
A blank Session retains the header's leading and corner controls, including the right-sidebar opener, while hiding its title, actions, utilities, and View tabs. Selecting a Workspace creates the Session needed by these controls; the first message is not required. Without a selected Session, the strict header is absent. Sidebar entries retain their own data and execution prerequisites.
View selection is deterministic: a registered persisted selection wins, otherwise registered `chat` wins, otherwise no View renders. It never chooses the first registered View. Shell phase combines Session lifecycle with the active-target set; no target-specific snapshot is read by the shell.
The shell reads the persisted View preference before rendering when a Session first binds or a cached Session becomes current, activates the registered preferred View or Chat fallback, and activates later tab or focus selections before committing them to the store. A blank Session still omits the `conversation.view` slot; no unselected target is activated.
@@ -46,6 +46,8 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map,
本包占据 root 作用域 `main` 中的 `conversation` key,其包装层声明 optional-Session `main.conversation` shell。本包注册 strict Session header/body、View list、composer chain 与 bar、输入区域、Hero 区域、queue dock、草稿持久化和 phase 计算。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。
blank Session 保留 header 的 leading 与 corner 控件,包括右侧栏展开入口,同时隐藏标题、actions、utilities 和 View tabs。选择 Workspace 会创建这些控件所需的 Session,无需先发送消息。没有选中 Session 时,strict header 不挂载。侧栏各入口仍遵循自身的数据与执行环境要求。
View 选择规则固定:有效且已注册的持久化选择优先,其次是已注册的 `chat`,否则不渲染 View;绝不选择第一个已注册 View。Shell phase 只组合 Session lifecycle 与 active-target set,不读取任何 target-specific 快照。
Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染前读取持久化 View 偏好,激活已注册的偏好 View 或 Chat fallback,并在后续 tab 或 focus 选择写入 store 前先激活对应 target。blank Session 仍不渲染 `conversation.view` slot;未选中的 target 不会激活。
@@ -46,10 +46,14 @@
border-bottom: 0.5px solid var(--dsw-alias-border-l3);
}
/* Blank hero/settling: keep the strict Session header mounted without taking
column space; the root-owned scrollport and composer remain below it. */
.headerHidden {
display: none;
/* Blank Sessions retain navigation controls without conversation title or tabs. */
.headerBlank {
min-height: 0;
border-bottom: none;
}
.headerBlank .headerCorner {
margin-left: auto;
}
/* macOS hiddenInset titlebar: the title row doubles as the window drag
@@ -67,15 +71,6 @@
-webkit-app-region: no-drag;
}
/* Blank state on macOS desktop: the chrome rows are gone but the leading seat
(the hidden sidebar's reopen control) stays on screen, so the header keeps
its layout without the rule. */
:global([data-platform='darwin']) .headerHidden {
display: block;
min-height: 0;
border-bottom: none;
}
.titleRow {
display: flex;
align-items: center;
@@ -1,7 +1,6 @@
/** Strict per-session header/body content inserted into the resident conversation layout. */
import clsx from 'clsx'
import { isDarwinDesktop } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SessionListState, SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type {
@@ -55,8 +54,7 @@ function equalBreadcrumbs(left: readonly Breadcrumb[], right: readonly Breadcrum
/**
* Renders Session header chrome above the resident conversation scrollport.
* @param props - Strict Session store, view ledger, navigation, render, and locale shares.
* @returns the header: title and tabs when visible, and on macOS desktop the
* always-mounted leading seat even while blank-session chrome hides.
* @returns Session navigation controls, with title and tabs after conversation starts.
*/
export function ConversationSessionHeader({
sessionId, useSession, useSessions, useConversation, useConversationViews, useStore,
@@ -69,16 +67,8 @@ export function ConversationSessionHeader({
const session = useSession(s => s)
const conversation = useConversation(s => s)
const hideChrome = session.blank && conversationPhase(session, conversation) === 'blank'
// macOS desktop keeps the leading seat mounted through the blank state (the
// hidden sidebar's reopen control lives there), so the header may not leave
// the layout or the accessibility tree while its chrome hides.
const darwinDesktop = isDarwinDesktop()
return (
<header
className={clsx(css.header, hideChrome && css.headerHidden)}
aria-hidden={(hideChrome && !darwinDesktop) || undefined}
>
<header className={clsx(css.header, hideChrome && css.headerBlank)}>
<div className={css.titleRow}>
<div className={css.headerLeading} data-conversation-header-leading="">
{renderSlot('conversation.session.header.leading', {})}
@@ -142,11 +132,11 @@ export function ConversationSessionHeader({
<div className={css.headerUtilities}>
{renderSlot('conversation.session.header.utilities', {})}
</div>
<div className={css.headerCorner} data-conversation-header-corner="">
{renderSlot('conversation.session.header.corner', {})}
</div>
</>
)}
<div className={css.headerCorner} data-conversation-header-corner="">
{renderSlot('conversation.session.header.corner', {})}
</div>
</div>
{!hideChrome && tabs.length > 1 && (
<div className={css.tabs} role="tablist">
@@ -462,7 +462,7 @@ describe('ConversationRoot resident composer', () => {
expect(seat?.contains(fallback)).toBe(true)
})
it('hero phase: same textarea, hero chrome, no header, picker switches the workspace', () => {
it('hero phase: keeps sidebar controls accessible while hiding conversation chrome', () => {
const b = mount(
sessionSnapshotOf({ blank: true }),
[
@@ -474,7 +474,11 @@ describe('ConversationRoot resident composer', () => {
const host = b.view.container.querySelector('[data-conversation-scroll]')
const header = b.view.container.querySelector('header')
expect(host).not.toBeNull()
expect(header?.getAttribute('aria-hidden')).toBe('true')
expect(header?.getAttribute('aria-hidden')).not.toBe('true')
expect(b.view.getByTestId('view-conversation.session.header.corner')).toBeTruthy()
expect(b.view.queryByRole('tablist')).toBeNull()
expect(b.slotCalls).not.toContain('conversation.session.header.utilities')
expect(b.slotCalls).not.toContain('conversation.session.header.actions')
expect(b.view.getByText('探索未至之境')).toBeTruthy()
expect(b.view.getByText('预览版')).toBeTruthy()
expect(b.view.queryByTestId('view-chat')).toBeNull()
@@ -0,0 +1,25 @@
# Blank Session workspace sidebar
- No selected Session: expand control absent
- Selected workspace before first message: expand control visible
- Files: before-chat.md opens as a Markdown preview
- Narrow viewport: reopened preview fills the viewport
- Terminal: writes a file in the selected workspace before any user message or turn
```json
[
{
"active": true,
"tabs": [
{
"title": "Files",
"selected": false
},
{
"title": "before-chat.md",
"selected": true
}
]
}
]
```
@@ -22,6 +22,9 @@
- button "Settings":
- img
- text: Settings
- banner:
- button "Open right sidebar":
- img
- text: Into the Unknown Preview
- button "Choose workspace":
- img
@@ -22,6 +22,9 @@
- button "Settings":
- img
- text: Settings
- banner:
- button "Open right sidebar":
- img
- text: Into the Unknown Preview
- button "Choose workspace":
- img