fix(ui-conversation): keep the docked composer seat stable (#7591)

* fix(ui-conversation): keep the docked composer seat stable

The active composer seat now keeps one stacking rank (z-index 9) in every
state, a direct compositing reason (will-change: transform), the scroll
floor without relying on a View to fill the column (margin-top: auto), and
no scroll-anchoring role. Composer tooltips portal to the body because the
seat is the containing block for fixed descendants. The width handle keeps
z-index 0 while dragging.

The docked statistics row reserves one reading's height before its
projections arrive, and the header without a Session keeps the blank
Session's 40px row on every platform so both Hero states center the input
card at the same height.

Fixes #7590

* docs(ui-conversation): state the composer fixed-overlay contract

Composer-hosted slots now document that fixed overlays must portal to the
document body, because the docked composer is their containing block; the
client slot catalog is regenerated from that JSDoc. The README also records
that gutter tables cover a captured width-handle drag and that the
composer's backdrop blur is verified only in Chromium. Local comments note
the grayscale text antialiasing of the permanent composer layer, why the
statistics row uses a minimum height, why the sessionless header keeps an
empty title row, and that the Hero offset test assumes the default plugins.

Refs #7590

* docs(ui-conversation): state the back-to-bottom offset as geometry

The composer and back-to-bottom stacking comments now state the resting
16px gap instead of claiming the control never overlaps the input card.

Refs #7590
This commit is contained in:
imccyu
2026-10-30 12:36:21 +08:00
committed by GitHub
18 changed files with 409 additions and 127 deletions
+132 -15
View File
@@ -1,6 +1,7 @@
// Browser geometry for the input card across Chat and Trajectory. The browser
// must expose layout-consuming scrollbars, and an uncompensated control keeps
// equal rectangles from passing vacuously.
// Browser geometry for the input card across Chat and Trajectory, plus the
// docked seat's stacking rank, fixed overlays, floor position, and statistics
// row. The browser must expose layout-consuming scrollbars, and an
// uncompensated control keeps equal rectangles from passing vacuously.
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
@@ -11,7 +12,7 @@ import {
assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole,
webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
import { newEnglishPage, saveFailureShot, writeComposerDraft } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/composer-tab-geometry', import.meta.url))
/** Records platform-neutral distances between the two tabs' card rectangles. */
@@ -158,6 +159,28 @@ async function compareTabs(page: Page): Promise<TabComparison> {
}
}
/**
* Run one measurement with an extra stylesheet in the page, then remove it.
* @param page - the page under test.
* @param id - the stylesheet element id, unique per rule set.
* @param css - rules applied only for the measurement.
* @param measure - the measurement taken while the rules apply.
* @returns the measurement's result.
*/
async function withPageStyle<T>(page: Page, id: string, css: string, measure: () => Promise<T>): Promise<T> {
await page.evaluate(({ styleId, text }) => {
const style = document.createElement('style')
style.id = styleId
style.textContent = text
document.head.append(style)
}, { styleId: id, text: css })
try {
return await measure()
} finally {
await page.evaluate((styleId) => { document.getElementById(styleId)?.remove() }, id)
}
}
/**
* Run the uncompensated cascade in the page for one measurement, then lift it:
* the overlay seat's `right` compensation dropped to 0, so it measures the
@@ -166,17 +189,7 @@ async function compareTabs(page: Page): Promise<TabComparison> {
* @returns the comparison as the column lays out without the compensation.
*/
async function compareTabsWithoutCompensation(page: Page): Promise<TabComparison> {
await page.evaluate(({ id, css }) => {
const style = document.createElement('style')
style.id = id
style.textContent = css
document.head.append(style)
}, { id: CONTROL_STYLE_ID, css: CONTROL_CSS })
try {
return await compareTabs(page)
} finally {
await page.evaluate((id) => { document.getElementById(id)?.remove() }, CONTROL_STYLE_ID)
}
return await withPageStyle(page, CONTROL_STYLE_ID, CONTROL_CSS, () => compareTabs(page))
}
/**
@@ -345,6 +358,110 @@ describe('web e2e: input card position across view tabs', () => {
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('keeps the docked composer at one stacking rank while its command menu opens', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-seat-rank'))
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
const host = page.locator('[data-conversation-scroll]')
const seat = page.locator('[data-conversation-scroll] > [data-composer-seat]')
const rank = (): Promise<number> => seat.evaluate(element => Number(getComputedStyle(element).zIndex))
// Scroll anchoring never selects composer content.
expect(await seat.evaluate(element => getComputedStyle(element).overflowAnchor)).toBe('none')
await host.evaluate((element) => { element.scrollTop = 0 })
const backToBottom = page.getByRole('button', { name: 'Back to bottom', exact: true })
await backToBottom.waitFor({ timeout: 10_000 })
const controlRank = await backToBottom.evaluate((element) => {
for (let node: Element | null = element; node !== null; node = node.parentElement) {
const zIndex = getComputedStyle(node).zIndex
if (zIndex !== 'auto') return Number(zIndex)
}
throw new Error('back-to-bottom control has no stacking rank')
})
const resting = await rank()
expect(resting).toBeGreaterThan(controlRank)
const input = page.locator('[data-composer-input]').last()
await writeComposerDraft(page, input, '/')
await page.locator('[data-trigger-menu]').waitFor({ timeout: 10_000 })
expect(await rank()).toBe(resting)
await page.keyboard.press('Escape')
await writeComposerDraft(page, input, '')
await page.locator('[data-trigger-menu]').waitFor({ state: 'detached', timeout: 10_000 })
expect(await rank()).toBe(resting)
await host.evaluate((element) => { element.scrollTop = element.scrollHeight })
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('keeps fixed-position overlays out of the docked composer', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-seat-fixed-overlays'))
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
const seat = page.locator('[data-conversation-scroll] > [data-composer-seat]')
// The transform hint makes the seat the containing block for fixed descendants.
expect(await seat.evaluate(element => getComputedStyle(element).willChange)).toBe('transform')
const fixedInside = (): Promise<number> => seat.evaluate(element => [...element.querySelectorAll('*')]
.filter(node => getComputedStyle(node).position === 'fixed').length)
const commands = seat.getByRole('button', { name: 'Add files or run commands', exact: true })
await commands.hover()
const tooltip = page.getByRole('tooltip').filter({ hasText: 'Add files or run commands' })
await tooltip.waitFor({ timeout: 10_000 })
expect(await tooltip.evaluate(node => node.closest('[data-composer-seat]') === null)).toBe(true)
expect(await fixedInside()).toBe(0)
// Viewport coordinates still place the bubble above its button.
const [bubbleBox, buttonBox] = await Promise.all([tooltip.boundingBox(), commands.boundingBox()])
expect(bubbleBox).not.toBeNull()
expect(buttonBox).not.toBeNull()
if (bubbleBox !== null && buttonBox !== null) {
expect(bubbleBox.y + bubbleBox.height).toBeLessThanOrEqual(buttonBox.y + 0.5)
expect(bubbleBox.x).toBeLessThan(buttonBox.x + buttonBox.width)
expect(bubbleBox.x + bubbleBox.width).toBeGreaterThan(buttonBox.x)
}
await page.mouse.move(0, 0)
const input = page.locator('[data-composer-input]').last()
await writeComposerDraft(page, input, '/')
await page.locator('[data-trigger-menu]').waitFor({ timeout: 10_000 })
expect(await fixedInside()).toBe(0)
await page.keyboard.press('Escape')
await writeComposerDraft(page, input, '')
await page.locator('[data-trigger-menu]').waitFor({ state: 'detached', timeout: 10_000 })
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('rests the docked composer at the floor when no View fills the column', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-seat-floor'))
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
// Hides the View subtree, which normally stretches to push the seat down.
const gap = await withPageStyle(
page,
'composer-seat-floor-probe',
'[data-conversation-scroll] > :not([data-composer-seat]) { display: none !important; }',
() => page.locator('[data-conversation-scroll]').evaluate((element) => {
const seat = element.querySelector(':scope > [data-composer-seat]')
if (seat === null) throw new Error('no composer seat in the scroll body')
const box = element.getBoundingClientRect()
return box.top + element.clientTop + element.clientHeight - seat.getBoundingClientRect().bottom
}),
)
expect(Math.abs(gap)).toBeLessThanOrEqual(0.5)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('keeps the statistics row height whether or not its readings are present', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-seat-stats-row'))
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
const dock = page.locator('[data-conversation-scroll] > [data-composer-seat] [data-composer-dock]')
// Vacuity guard: the seeded usage must produce at least one reading.
await expect.poll(() => dock.locator('[data-composer-stat], button[aria-haspopup="dialog"]').count(), { timeout: 10_000 })
.toBeGreaterThan(0)
const height = (): Promise<number> => dock.evaluate(element => element.getBoundingClientRect().height)
const withReadings = await height()
const withoutReadings = await withPageStyle(
page,
'composer-seat-stats-row-probe',
'[data-composer-dock] > * { display: none !important; }',
height,
)
expect(Math.abs(withReadings - withoutReadings)).toBeLessThanOrEqual(0.5)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('matches the committed tab geometry golden', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-golden'))
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
+5 -3
View File
@@ -19,7 +19,9 @@ afterAll(async () => {
})
describe('navigation without a selected Session', () => {
it.each(['web', 'win32', 'linux', 'darwin'] as const)('%s preserves its empty-header geometry and sidebar access', async (platform) => {
// The resident header keeps the blank-Session row height on every platform,
// so the Hero centers below the same header before and after a Workspace is bound.
it.each(['web', 'win32', 'linux', 'darwin'] as const)('%s keeps the blank-Session header height and sidebar access', async (platform) => {
const page = await newEnglishPage(browser)
try {
if (platform !== 'web') {
@@ -41,14 +43,14 @@ describe('navigation without a selected Session', () => {
const sessionHeader = page.locator('[data-slot="conversation.session.header"]')
expect(await sessionHeader.count()).toBe(0)
const header = page.locator('[data-slot="conversation.header"] > header')
expect(await header.evaluate(element => element.getBoundingClientRect().height)).toBe(platform === 'darwin' ? 40 : 0)
expect(await header.evaluate(element => element.getBoundingClientRect().height)).toBe(40)
await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).click()
const reopen = platform === 'darwin'
? page.locator('[data-shell-leading]').getByRole('button', { name: 'Open sidebar', exact: true })
: page.getByRole('button', { name: 'Open sidebar', exact: true })
await reopen.waitFor({ state: 'visible' })
expect(await sessionHeader.count()).toBe(0)
expect(await header.evaluate(element => element.getBoundingClientRect().height)).toBe(platform === 'darwin' ? 40 : 0)
expect(await header.evaluate(element => element.getBoundingClientRect().height)).toBe(40)
if (platform === 'darwin') {
expect(await page.locator('[data-shell-leading]').getByRole('button', { name: 'New session', exact: true }).isVisible()).toBe(true)
}
@@ -61,10 +61,36 @@ describe('web e2e: startup auto-selection', () => {
if (Object.values(refs).some(node => node === null)) throw new Error('incomplete initial Hero tree')
;(window as unknown as { __heroTree: typeof refs }).__heroTree = refs
})
// The input card's distance from the conversation top, once it holds for three frames.
const heroCardOffset = (): Promise<number> => page.evaluate(async () => {
const read = (): number => {
const root = document.querySelector('div[data-phase="hero"]')
const card = document.querySelector('[data-composer-card]')
if (root === null || card === null) throw new Error('incomplete Hero composer')
return card.getBoundingClientRect().top - root.getBoundingClientRect().top
}
const deadline = performance.now() + 5_000
let previous = read()
let stableFrames = 0
while (performance.now() < deadline) {
await new Promise<void>((resolve) => { requestAnimationFrame(() => { resolve() }) })
const current = read()
stableFrames = Math.abs(current - previous) < 0.01 ? stableFrames + 1 : 0
if (stableFrames >= 3) return current
previous = current
}
throw new Error('Hero input card did not settle')
})
const sessionlessOffset = await heroCardOffset()
// A registered Workspace is the precondition for the reload case below;
// this first connection is also the no-Workspace → Workspace path.
await connectFreshWorkspace(page, scaffold.workspaceCwd, 'startup-auto-selection')
await page.locator('[data-slot="conversation.session.header"]').waitFor({ timeout: 15_000 })
// Binding the Workspace keeps the input card at the same height in the Hero.
// This holds for the default plugin set: `conversation.hero.options` entries
// render only with a Session and can change the Hero's height.
expect(Math.abs(await heroCardOffset() - sessionlessOffset)).toBeLessThanOrEqual(0.5)
expect(await page.evaluate(() => {
const before = (window as unknown as { __heroTree: Record<string, Element> }).__heroTree
@@ -344,10 +344,10 @@
right: 0;
bottom: 16px;
left: 0;
/* Above the sticky composer (z-index 7) so the control stays clickable and
visible over the input card. Exception: while an @/slash menu is open the
seat lifts to 9 (ui-conversation ConversationRoot) so the menu is never
covered by this control. */
/* Above transcript sticky chrome (CodeBlock banners 6, the pinned compaction
header and turn rail 7) and below the composer seat (9, ui-conversation
ConversationRoot), so menus the composer opens upward cover this control.
At rest the control sits 16px above the input card. */
z-index: 8;
height: 0;
display: flex;
@@ -21,8 +21,8 @@
en: cc4dafd1aae02468
zh: be6b67724b7ac4a3
/deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props:
en: 9b596b668979544a
zh: 159f24dfc8715c92
en: 99ec447f0a6bda48
zh: 922cf64666676530
/deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries:
en: 59cd63057dfb6eeb
zh: b2e2c86e810b7bb8
@@ -33,8 +33,8 @@
en: 9f4e0da7c8addf88
zh: 1a33d9981af75643
/deepseek-ai-dsh-client-ui-conversation/known-limitations-and-deferred-work:
en: 38d243630d0e4c38
zh: a58cdc35b664020b
en: 1413851e9d622bd3
zh: 84502501f8ce599b
/deepseek-ai-dsh-client-ui-conversation/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: e88aeb617ffb74ea
+4 -3
View File
@@ -80,7 +80,7 @@ The context meter follows the [statistics-control radius](../../../docs/ui-radiu
The composer uses the shared [text-input focus policy](../ui-primitives/README.md#use-this-package) on mount, Session switching, and unlock. Identified phones and tablets require tapping the input; desktop devices retain automatic focus. On these mobile devices, a submission that empties the focused draft moves focus to the non-text composer card, dismissing the keyboard while retaining its navigation position. Rejected submissions, retained newer draft text, and another control already holding focus preserve focus. The [Lexical patch](../../../patches/lexical@0.49.0.patch) keeps caret scrolling in the same coordinate system as DOM geometry when a mobile keyboard pans the page.
The context-occupancy button shows a ring and percentage below the input card, after the Session statistics. Clicking it opens the token breakdown in a panel kept inside the viewport, including when no statistics are shown; the button stays hidden until context usage and capacity are available.
The context-occupancy button shows a ring and percentage below the input card, after the Session statistics. Clicking it opens the token breakdown in a panel kept inside the viewport, including when no statistics are shown; the button stays hidden until context usage and capacity are available. Outside the Hero, that row keeps one reading's height while it is empty, so statistics and context usage arriving later do not change the composer's height; the Hero shows no empty row.
With Coding Tools off, the shell hides only Trajectory; other plugin-contributed Views remain available. The View tab bar is hidden when fewer than two Views are available. Turning Coding Tools off while Trajectory is active returns to Chat; stored View preferences and recorded Session data remain intact. Turning it on makes Trajectory available again. View owners receive the available roster so their navigation actions can follow the same visibility.
@@ -100,13 +100,13 @@ Workspace selection uses `uiWorkspace.openWorkspace` to prepare the target and c
The package occupies the root-scoped `main` key `conversation`. Its `main.conversation` shell keeps the resident `conversation.header` outside the optional-Session `conversation.content` Component Factory. The header hosts root-scoped navigation without a selected Session; titles, actions, and View tabs remain in its strict Session child. The Factory owns the shared body and Composer, reads the current Session through its standard Hook, and exposes strict-Session `views` plus root-scoped `widthControls` local positions. Its default adapter renders the existing `conversation.session` entry, while the main occurrence selects the width handles; an embedded occurrence can replace `views` and omit those handles without rendering the main Header. The shared body and Composer register the queue and Todo docks. The Todo dock uses the shared panel elevation above the composer; its rows use the shared idle, ongoing, and done markers for pending, in-progress, and completed items. `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; the resident header keeps a 40px window strip on macOS desktop that it owns as its drag row, and no empty vertical space on Web, Windows, or Linux. Sidebar entries retain their own data and execution prerequisites. A started Session header uses one row when fewer than two Views are available; the tab row reserves space only while rendered. When the title and actions do not fit, the title area clips them and fades its trailing edge before the utilities, which stay fully visible and clickable; overlays in the title and actions slots portal to stay outside that clip.
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; the resident header keeps the blank-Session 40px row on every platform, so the Hero input card sits at the same height before and after a Workspace is bound; on macOS desktop that row is also the window drag row. Sidebar entries retain their own data and execution prerequisites. A started Session header uses one row when fewer than two Views are available; the tab row reserves space only while rendered. When the title and actions do not fit, the title area clips them and fades its trailing edge before the utilities, which stay fully visible and clickable; overlays in the title and actions slots portal to stay outside that clip.
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.
Active transcripts in the main occurrence expose content-width drag handles in their uncovered side gutters; embedded occurrences omit them. A View that paints into a gutter raises only its concrete painted element above the handle; transparent full-width wrappers stay below so they do not claim empty gutter. This requires the path between that element and the Conversation body to remain outside an intermediate stacking context; the shipped Chromium behavior is pinned by the browser scenario. Chat applies the rule to table elements, while its column-bounded tool cards need no raise. Wheel motion over a handle still scrolls the transcript, while Ctrl+wheel remains a browser zoom gesture. The sticky composer intentionally owns its full footer band, which is not a resize target; an already-captured drag lifts its indicator until release ([handle styles](src/client/skeleton/ConversationRoot.module.css)).
Active transcripts in the main occurrence expose content-width drag handles in their uncovered side gutters; embedded occurrences omit them. A View that paints into a gutter raises only its concrete painted element above the handle; transparent full-width wrappers stay below so they do not claim empty gutter. This requires the path between that element and the Conversation body to remain outside an intermediate stacking context; the shipped Chromium behavior is pinned by the browser scenario. Chat applies the rule to table elements, while its column-bounded tool cards need no raise. Wheel motion over a handle still scrolls the transcript, while Ctrl+wheel remains a browser zoom gesture. The sticky composer owns its full footer band, which is not a resize target; a captured drag continues under the band and under gutter tables, which cover its indicator. In an active transcript the composer rests at the scrollport floor even when no View fills the column, keeps one stacking rank in every state, above transcript sticky headers and the back-to-bottom control, so menus it opens upward cover that control, and never serves as a scroll-anchoring target. It keeps its own compositor layer whatever lies beneath it, which makes it the containing block for fixed-position descendants: contributions to `conversation.composer`, `conversation.composer.bar`, `conversation.composer.dock`, `conversation.input.dock`, and the other `conversation.input.*` slots must portal fixed overlays to the document body, for example with `Tooltip` or `Menu` `portal` or `createPortal` ([handle and composer styles](src/client/skeleton/ConversationRoot.module.css)).
The width-handle indicator follows the pointer only during a captured drag; ordinary hover leaves its position unchanged.
@@ -201,6 +201,7 @@ None; Conversation assembly and browser input state do not alter provider-side p
- **Only registered targets can render** — the shell deliberately has no implicit fallback target beyond the registered `chat` preference.
- **Factory occurrences inherit their render-position Session** — `conversation.content` does not accept an independently addressed Session; that requires a separate Session-provider capability.
- **Composer backdrop blur is verified only in Chromium** — with `will-change: transform` on the docked composer, descendant `backdrop-filter` still samples the transcript in Chromium; WebKit behavior has not been verified.
<a id="dev-note"></a>
+4 -3
View File
@@ -80,7 +80,7 @@ Queue 预览启用空白折叠和文档边缘 trim,保留 emoji,并使用 CS
composer 在挂载、切换 Session 和解除锁定时使用共享的[文字输入框聚焦策略](../ui-primitives/README.zh.md#use-this-package)。识别出的手机和平板需要点击输入框;桌面设备保留自动聚焦。在这些移动设备上,提交清空正在聚焦的草稿后,焦点移到不接受文字输入的 composer 卡片,收起键盘并保留键盘导航位置。被拒绝的提交、保留的新草稿文字,以及已经聚焦的其他控件均保留原有焦点。移动端键盘平移页面时,[Lexical 补丁](../../../patches/lexical@0.49.0.patch)让光标滚动计算与 DOM 几何使用同一坐标系。
上下文占用按钮在输入卡片下方、会话统计右侧显示圆环和百分比。点击按钮可在视口内的面板查看 token 构成,没有统计项时面板也不会越界;上下文用量和容量尚不可用时,按钮保持隐藏。
上下文占用按钮在输入卡片下方、会话统计右侧显示圆环和百分比。点击按钮可在视口内的面板查看 token 构成,没有统计项时面板也不会越界;上下文用量和容量尚不可用时,按钮保持隐藏。Hero 之外,这一行为空时仍保留一项读数的高度,因此会话统计和上下文用量稍后到达不会改变 composer 的高度;Hero 不显示空行。
关闭代码工作工具时,外壳仅隐藏 Trajectory;其他插件贡献的 View 仍然可用。可用 View 少于两个时隐藏 View 标签栏。在 Trajectory 激活时关闭代码工作工具会返回 Chat;已保存的 View 偏好和 Session 记录保持不变。开启后,Trajectory 恢复可用。View 所有者接收可用列表,使导航操作跟随相同的可见性。
@@ -100,13 +100,13 @@ root 作用域的 `conversation.session.header.views.actions` list 有注册项
本包占据 root 作用域 `main` 中的 `conversation` key。其 `main.conversation` 外壳将常驻的 `conversation.header` 放在可选 Session 的 `conversation.content` Component Factory 外。未选中 Session 时,头部仍承载根作用域导航;标题、操作和 View 标签保留在严格 Session 子组件中。Factory 拥有共享正文与 Composer,通过其标准 Hook 读取当前 Session,并公开 strict-Session `views` 与 root-scoped `widthControls` 两个局部位置。默认 adapter 渲染现有 `conversation.session` entry,主 occurrence 选择宽度拖拽条;嵌入式 occurrence 可以替换 `views`、省略拖拽条,且不渲染主 Header。共享正文与 Composer 注册 queue dock 和 Todo dock。Todo dock 在 composer 上方使用共享面板 elevation;其中的行分别以共享 idle、ongoing 与 done 标记表示待处理、进行中与已完成。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。
blank Session 保留 header 的 leading 与 corner 控件,包括右侧栏展开入口,同时隐藏标题、actions、utilities 和 View tabs。选择 Workspace 会创建这些控件所需的 Session,无需先发送消息。没有选中 Session 时,strict header 不挂载;常驻 header 在 macOS 桌面保留 40px 的窗口顶条,由它自己作为拖拽行持有,在 Web、Windows 或 Linux 上不预留空白高度。侧栏各入口仍遵循自身的数据与执行环境要求。 已开始的 Session 在可用 View 少于两个时使用单行标题栏,仅在渲染标签行时保留其高度。标题与 actions 放不下时,标题区域会裁剪它们,并在 utilities 之前渐隐其末端;utilities 始终完整可见且可点击;标题与 actions slot 中的浮层通过 portal 渲染,不受该裁剪影响。
blank Session 保留 header 的 leading 与 corner 控件,包括右侧栏展开入口,同时隐藏标题、actions、utilities 和 View tabs。选择 Workspace 会创建这些控件所需的 Session,无需先发送消息。没有选中 Session 时,strict header 不挂载;常驻 header 在所有平台都保留与 blank Session 相同的 40px 行,因此绑定 Workspace 前后 Hero 输入卡片的高度位置不变;在 macOS 桌面上,这一行同时是窗口拖拽行。侧栏各入口仍遵循自身的数据与执行环境要求。 已开始的 Session 在可用 View 少于两个时使用单行标题栏,仅在渲染标签行时保留其高度。标题与 actions 放不下时,标题区域会裁剪它们,并在 utilities 之前渐隐其末端;utilities 始终完整可见且可点击;标题与 actions slot 中的浮层通过 portal 渲染,不受该裁剪影响。
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 不会激活。
主 occurrence 的活跃 transcript 只在未被内容覆盖的两侧沟槽中提供正文宽度拖拽条;嵌入式 occurrence 省略这些拖拽条。View 如果绘制进沟槽,只将具体的可见元素提到拖拽条上方;透明的全宽包装层保持在下方,不会占用空白沟槽。该规则要求此元素与 Conversation body 之间不能引入中间堆叠上下文;浏览器场景固定了交付 Chromium 的行为。Chat 将该规则用于表格元素,其限定在阅读列内的工具卡片无需提高层级。指针位于拖拽条上时,滚轮仍会滚动 transcript,Ctrl+滚轮则保留为浏览器缩放手势。粘滞 composer 刻意拥有完整的底部区带,该区域不是宽度调整目标;已捕获的拖拽会将指示线提高到松开为止([拖动手柄样式](src/client/skeleton/ConversationRoot.module.css))。
主 occurrence 的活跃 transcript 只在未被内容覆盖的两侧沟槽中提供正文宽度拖拽条;嵌入式 occurrence 省略这些拖拽条。View 如果绘制进沟槽,只将具体的可见元素提到拖拽条上方;透明的全宽包装层保持在下方,不会占用空白沟槽。该规则要求此元素与 Conversation body 之间不能引入中间堆叠上下文;浏览器场景固定了交付 Chromium 的行为。Chat 将该规则用于表格元素,其限定在阅读列内的工具卡片无需提高层级。指针位于拖拽条上时,滚轮仍会滚动 transcript,Ctrl+滚轮则保留为浏览器缩放手势。粘滞 composer 拥有完整的底部区带,该区域不是宽度调整目标;已捕获的拖拽会在该区带和沟槽中的表格下方继续,此时指示线被它们遮住。在活跃 transcript 中,即使没有 View 撑满整列,composer 也停在滚动区底部;它在所有状态下保持同一层级,高于 transcript 的吸顶标题与“回到底部”按钮,因此它向上打开的菜单会盖住该按钮;它也从不作为滚动锚定目标。无论下方是什么内容,它都保持自己的合成层,因此成为 fixed 定位后代的包含块:向 `conversation.composer`、`conversation.composer.bar`、`conversation.composer.dock`、`conversation.input.dock` 以及其他 `conversation.input.*` slot 贡献的内容,必须把 fixed 浮层 portal 到 document body,例如使用 `Tooltip` 或 `Menu` 的 `portal`,或 `createPortal`([拖动手柄与 composer 样式](src/client/skeleton/ConversationRoot.module.css))。
宽度拖拽条的指示线只在已捕获指针的拖拽期间跟随指针,普通悬停不改变其位置。
@@ -201,6 +201,7 @@ selector 必须是 owner currency 的纯函数。非 null 返回值作为 `match
- **只有已注册 target 可以渲染**——除已注册的 `chat` 偏好外,shell 刻意不提供隐式 fallback target。
- **Factory occurrence 继承渲染位置的 Session**——`conversation.content` 不接受独立寻址的 Session;该能力需要单独的 Session provider。
- **composer 内的毛玻璃只在 Chromium 中验证过**——docked composer 设置 `will-change: transform` 后,Chromium 中后代的 `backdrop-filter` 仍会采样背后的 transcript;WebKit 中的表现尚未验证。
<a id="dev-note"></a>
@@ -276,7 +276,10 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
'conversation.session.header.views.actions': { kind: 'list'; scope: 'root'; owner: ConversationViewActionsOwnerProps }
/** Registered Conversation target Views, rendered one at a time. */
'conversation.view': { kind: 'list'; scope: 'session'; owner: ConvViewOwnerProps }
/** Selector-routed replacements for the current Session's resident composer. */
/**
* Selector-routed replacements for the current Session's resident composer; fixed overlays must
* portal to the document body because the docked composer contains them.
*/
'conversation.composer': { kind: 'chain'; scope: 'session'; owner: ComposerChainProps }
/** Workspace picker shown by the blank-session Hero. */
'conversation.hero.workspace': { kind: 'single'; scope: 'root'; owner: EmptyWorkspaceOwnerProps }
@@ -286,34 +289,66 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
'conversation.hero.agentPreset': { kind: 'single'; scope: 'session-maybe'; owner: HeroAgentPresetOwnerProps }
/** Compact options right-aligned in the New Session row after the workspace and preset controls; rendered only with a Session. */
'conversation.hero.options': { kind: 'list'; scope: 'session' }
/** Full-width entries above the composer card. */
/**
* Full-width entries above the composer card; fixed overlays must portal to the document body
* because the docked composer contains them.
*/
'conversation.input.dock': { kind: 'list'; scope: 'session'; owner: InputZone }
/** Floating entries rendered inside the resident composer card. */
/**
* Floating entries rendered inside the resident composer card; fixed overlays must portal to
* the document body because the docked composer contains them.
*/
'conversation.input.overlay': { kind: 'list'; scope: 'session' }
/** Ambient entries below the composer card. */
/**
* Ambient entries below the composer card; fixed overlays must portal to the document body
* because the docked composer contains them.
*/
'conversation.composer.dock': { kind: 'list'; scope: 'session' }
/** Compact controls at the left of the composer tool row. */
/**
* Compact controls at the left of the composer tool row; fixed overlays must portal to the
* document body because the docked composer contains them.
*/
'conversation.input.left': { kind: 'list'; scope: 'session' }
/** Compact controls before the composer submit action. */
/**
* Compact controls before the composer submit action; fixed overlays must portal to the
* document body because the docked composer contains them.
*/
'conversation.input.right': { kind: 'list'; scope: 'session' }
/** Compact action after the model selector; it can expand across the toolbar while retaining the editor and submit action. */
/**
* Compact action after the model selector; it can expand across the toolbar while retaining the
* editor and submit action, and fixed overlays must portal to the document body because the
* docked composer contains them.
*/
'conversation.input.activity': { kind: 'single'; scope: 'session'; owner: InputActivityOwnerProps }
/** Resident composer body, including the no-Session inert state. */
/**
* Resident composer body, including the no-Session inert state; fixed overlays must portal to
* the document body because the docked composer contains them.
*/
'conversation.composer.bar': { kind: 'single'; scope: 'session-maybe'; owner: ComposerBarOwnerProps }
/** Optional draft-attachment rail and drop target. */
/**
* Optional draft-attachment rail and drop target; fixed overlays must portal to the document
* body because the docked composer contains them.
*/
'conversation.input.attachments': {
kind: 'single'
scope: 'session-maybe'
owner: ComposerAttachmentsOwnerProps
}
/** Plan control inside the composer tool row. */
/**
* Plan control inside the composer tool row; fixed overlays must portal to the document body
* because the docked composer contains them.
*/
'conversation.input.plan': { kind: 'single'; scope: 'session'; owner: InputControlOwnerProps }
/** Current-session permission control inside the composer tool row. */
/**
* Current-session permission control inside the composer tool row; fixed overlays must portal
* to the document body because the docked composer contains them.
*/
'conversation.input.permission': { kind: 'single'; scope: 'session'; owner: InputControlOwnerProps }
/**
* Model selector inside the composer tool row. When expanded controls cannot
* share a line, the row sets --dsh-composer-model-text-display to none and
* --dsh-composer-model-icon-display to block for an occupant's compact display.
* Fixed overlays must portal to the document body because the docked composer contains them.
*/
'conversation.input.model': { kind: 'single'; scope: 'session'; owner: InputControlOwnerProps }
}
@@ -107,7 +107,7 @@ export function ContextMeter({ useProjection, t }: ContextMeterProps) {
return (
<span ref={rootRef} className={css.root}>
<Tooltip label={t('context.aria', { percent: reading })} side="top" delayMs={200} disabled={open}>
<Tooltip label={t('context.aria', { percent: reading })} side="top" delayMs={200} disabled={open} portal>
<button
type="button"
className={css.trigger}
@@ -15,11 +15,13 @@ export function ConversationHeader({ sessionId, useSession, useConversation, ren
const blank = session === undefined || conversation === undefined
|| (session.blank && conversationPhase(session, conversation) === 'blank')
return (
<header className={clsx(css.header, blank && css.headerBlank, sessionId === undefined && css.headerSessionless)} data-window-drag>
<header className={clsx(css.header, blank && css.headerBlank)} data-window-drag>
<div className={css.headerLeading} data-conversation-header-leading="">
{renderSlot('conversation.header.leading', {})}
</div>
{sessionId === undefined
// The empty title row keeps the blank-Session header height, so the
// Hero centers its input card at the same offset before a Session exists.
? <div className={css.titleRow} />
: renderSlot('conversation.session.header', { hideChrome: blank })}
</header>
@@ -28,7 +28,9 @@
padding-bottom: 10px;
}
/* Blank Sessions retain navigation controls without conversation title or tabs. */
/* Blank Sessions retain navigation controls without conversation title or tabs.
The same 40px row (10px inset + 30px title row) applies before a Session
exists, so the Hero centers below one header height in both states. */
.headerBlank {
min-height: 0;
padding-bottom: 0;
@@ -39,15 +41,6 @@
margin-left: auto;
}
/* Only the macOS titlebar needs reserved drag space before a Session exists. */
:global(html:not([data-platform='darwin'])) .headerSessionless {
padding-top: 0;
}
:global(html:not([data-platform='darwin'])) .headerSessionless .titleRow {
min-height: 0;
}
/* macOS hiddenInset titlebar: the header block owns the window drag over its
own box — title row plus the view tab strip, so the draggable geometry is the
header's geometry and no frame band has to match its height. The header marks
@@ -332,8 +325,9 @@
position: absolute;
top: 0;
bottom: 0;
/* Concrete overflow content establishes z-index 1, while the sticky
composer remains above both layers. */
/* Fixed in every state: concrete overflow content (z-index 1) and the
composer seat (z-index 9) stay above the strip, so the footer band is
never a resize target. Pointer capture keeps a drag active under it. */
z-index: 0;
width: min(
10px,
@@ -394,13 +388,6 @@
}
}
/* A drag starts above the composer band. Pointer capture keeps it active when
crossing the footer; lift only the active handle so its indicator remains
visible without making the resting footer gutter draggable. */
.widthHandle[data-dragging] {
z-index: 8;
}
/* Views that elect a composer overlay (trajectory etc.) own their full-bleed
scrollers; the strips would sit over their content and steal clicks. */
.root:has([data-conversation-composer-overlay]) .widthHandle {
@@ -508,14 +495,27 @@
.embeddedBody[data-content-phase='active'] .composerSeat {
position: sticky;
bottom: 0;
/* Above markdown CodeBlock sticky banners (z-index 6) so the footer never
paints under a sticking code header while scrolling. The pinned compaction
header (MessageItem.module.css, ui-chat) and the turn-navigation rail slot
(TurnNavigator.module.css, ui-chat) also take 7 to clear the same banners.
Among elements of equal rank the later one in DOM order wins, so the
composer stays above the pinned header on the extreme small-viewport
overlap and the pinned header stays above the rail. */
z-index: 7;
/* Takes the scroll body's free space whenever the transcript is shorter than
the scrollport, so the seat rests at the floor even when no View fills the
column. A sticky `bottom` only lifts a seat that would sit below the floor. */
margin-top: auto;
/* One fixed rank that no state changes: above markdown CodeBlock banners (6),
the pinned compaction header (7, MessageItem.module.css) and turn rail (7,
TurnNavigator.module.css) in ui-chat, and the back-to-bottom control (8,
ChatView.module.css), so menus the composer opens upward cover that
control. At rest the control sits 16px above the input card. */
z-index: 9;
/* A direct compositing reason: without it the seat is composited only while
it overlaps other composited content, so its layer could come and go with
the transcript beneath it. A transform hint keeps descendant backdrop blur
sampling the transcript, but makes the seat the containing block for
fixed-position descendants, so every fixed overlay inside the composer
(tooltips, menus, panels) must portal to the document body. The permanent
layer has a translucent top, so Chromium renders the seat's text with
grayscale antialiasing where it would otherwise use LCD text. */
will-change: transform;
/* Scroll anchoring never selects composer content as its anchor. */
overflow-anchor: none;
/* Input mask (figma 1205:27463): transcript fades out under a FIXED 36px
band at the seat's top (the figma 24% of the resting ~150px composer),
solid below — px stops, not %, so a growing draft only widens the solid
@@ -529,15 +529,6 @@
);
}
/* An open @/slash menu (data-trigger-menu, ui-input-trigger) renders inside
the input card, so the seat's stacking context caps its z-index; lift the
seat above the back-to-bottom control (z-index 8, ui-chat ChatView) while
the menu is open. The steady state stays 7 so the control keeps painting
over the card. */
.root[data-phase='active'] .composerSeat:has([data-trigger-menu]) {
z-index: 9;
}
/* Views may opt into a composer overlay while ConversationRoot retains
ownership of the seat geometry and its active-phase precedence. */
.scrollBody:has([data-conversation-composer-overlay]) {
@@ -19,6 +19,15 @@
padding-top: 4px;
}
/* The docked composer reserves one statistics row before its readings
arrive, so the projections landing never change the composer height. The
row height matches the readings: a 20px secondary line plus 1px block
padding (ui-chat StatsPills `.pill`, ContextMeter `.trigger`). A minimum,
not a fixed height, so taller readings still size the row. */
.root:not(.hero) > .dock {
min-height: calc(22px + var(--dsh-content-font-delta-secondary, 0px));
}
.hero .dock:empty {
display: none;
}
@@ -483,7 +483,7 @@ export const InputBar = memo(function InputBar({
/>
<div ref={rowRef} className={css.row}>
<div className={css.tools} hidden={activity}>
<Tooltip label={t('input.commands')} side="top" delayMs={500}>
<Tooltip label={t('input.commands')} side="top" delayMs={500} portal>
<button
type="button"
className={css.add}
@@ -524,7 +524,7 @@ export const InputBar = memo(function InputBar({
{renderSlot('conversation.input.activity', { locked, onActiveChange: setActivity })}
</div>}
{interruptible && !cancelDenied && (
<Tooltip label={t('input.stop')} shortcutKeys={stopKeys} side="top" delayMs={500} disabled={stopAction === undefined}>
<Tooltip label={t('input.stop')} shortcutKeys={stopKeys} side="top" delayMs={500} disabled={stopAction === undefined} portal>
<button
type="button"
className={css.primary}
@@ -540,7 +540,7 @@ export const InputBar = memo(function InputBar({
</Tooltip>
)}
{(primaryStops ? cancelDenied : promptDenied) ? null : (
<Tooltip label={primaryStops ? t('input.stop') : primaryLabel} shortcutKeys={primaryStops ? stopKeys : undefined} side="top" delayMs={500} disabled={primaryDisabled}>
<Tooltip label={primaryStops ? t('input.stop') : primaryLabel} shortcutKeys={primaryStops ? stopKeys : undefined} side="top" delayMs={500} disabled={primaryDisabled} portal>
<button
type="button"
className={css.primary}
@@ -0,0 +1,58 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
function readStyles(path: string): string {
return readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
}
const conversationCss = readStyles('../src/client/skeleton/ConversationRoot.module.css')
const inputBarCss = readStyles('../src/client/skeleton/InputBar.module.css')
const contextMeterCss = readStyles('../src/client/skeleton/ContextMeter.module.css')
/** Return the body of the rule whose selector list starts with `selector`. */
function rule(css: string, selector: string): string {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const match = new RegExp(`(?:^|\\n)${escaped}[^{]*\\{([^}]*)\\}`).exec(css)
expect(match, `missing ${selector} rule`).not.toBeNull()
return match?.[1] ?? ''
}
describe('docked composer seat styles', () => {
const seat = rule(conversationCss, ".root[data-phase='active'] .composerSeat,")
it('keeps the seat at one stacking rank in every state', () => {
expect(seat).toMatch(/position:\s*sticky;/)
expect(seat).toMatch(/z-index:\s*9;/)
// No other rule ranks the seat, so opening a menu inside it changes nothing.
const ranked = [...conversationCss.matchAll(/([^{}]*\.composerSeat[^{}]*)\{([^}]*)\}/g)]
.filter(([, , body]) => body !== undefined && /z-index/.test(body))
expect(ranked).toHaveLength(1)
})
it('rests the seat at the floor without depending on a View to fill the column', () => {
expect(seat).toMatch(/margin-top:\s*auto;/)
})
it('gives the seat a compositing reason that does not depend on overlap', () => {
expect(seat).toMatch(/will-change:\s*transform;/)
})
it('keeps scroll anchoring off composer content', () => {
expect(seat).toMatch(/overflow-anchor:\s*none;/)
})
})
describe('composer statistics row styles', () => {
it('reserves one reading row in the docked composer only', () => {
expect(rule(inputBarCss, '.root:not(.hero) > .dock'))
.toContain('min-height: calc(22px + var(--dsh-content-font-delta-secondary, 0px))')
expect(rule(inputBarCss, '.hero .dock:empty')).toMatch(/display:\s*none;/)
})
it('sizes the reservation to one reading: a 20px secondary line plus 1px block padding', () => {
const trigger = rule(contextMeterCss, '.trigger')
expect(trigger).toContain('line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px))')
expect(trigger).toMatch(/padding:\s*1px 8px;/)
})
})
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { en as commonEn, zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/index.ts'
import { ContextMeter, type ContextMeterProps } from '../src/client/skeleton/ContextMeter.tsx'
@@ -67,6 +67,20 @@ describe('ContextMeter', () => {
expect(view.queryByRole('dialog')).toBeNull()
})
it('renders its reading tooltip outside the composer subtree', () => {
vi.useFakeTimers()
try {
const view = meter({ contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 } })
fireEvent.pointerEnter(view.getByRole('button', { name: '上下文已用 25%' }))
act(() => { vi.advanceTimersByTime(200) })
const tooltip = view.getByRole('tooltip')
expect(view.container.contains(tooltip)).toBe(false)
expect(document.body.contains(tooltip)).toBe(true)
} finally {
vi.useRealTimers()
}
})
it('lets each locale own the headline word order around the reading', () => {
const values = {
contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 },
@@ -1072,6 +1072,31 @@ describe('running and lock semantics', () => {
}
})
// The docked seat is the containing block for fixed descendants, so control
// tooltips render under the document body rather than inside the composer.
it('renders every control tooltip outside the composer subtree', () => {
vi.useFakeTimers()
try {
const { view } = bench({ running: true })
const shown: string[] = []
for (const control of view.container.querySelectorAll('button')) {
fireEvent.pointerEnter(control)
act(() => { vi.advanceTimersByTime(500) })
const tooltip = view.queryByRole('tooltip')
if (tooltip !== null) {
shown.push(control.getAttribute('aria-label') ?? '')
expect(view.container.contains(tooltip)).toBe(false)
expect(document.body.contains(tooltip)).toBe(true)
}
fireEvent.pointerLeave(control)
act(() => { vi.advanceTimersByTime(500) })
}
expect(shown).toEqual(expect.arrayContaining(['添加文件或调用指令', '停止生成']))
} finally {
vi.useRealTimers()
}
})
it('dismisses the Stop tooltip when an empty composer becomes idle', () => {
vi.useFakeTimers()
try {
@@ -29,7 +29,8 @@ describe('conversation width handle styles', () => {
expect(indicator).toContain('var(--dsh-width-handle-pointer-y, 50%) + 36px')
})
it('keeps the indicator above the composer after drag capture begins', () => {
expect(conversationCss).toMatch(/\.widthHandle\[data-dragging\]\s*\{[^}]*z-index:\s*8/s)
it('keeps one stacking rank below the composer in every drag state', () => {
expect(rule(conversationCss, '.widthHandle')).toMatch(/z-index:\s*0;/)
expect(conversationCss).not.toMatch(/\.widthHandle\[[^\]]*\][^{]*\{[^}]*z-index/s)
})
})
@@ -757,8 +757,8 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
key: 'conversation.composer',
kind: 'chain',
scope: 'session',
summary: 'Selector-routed replacements for the current Session\'s resident composer.',
doc: 'Selector-routed replacements for the current Session\'s resident composer.',
summary: 'Selector-routed replacements for the current Session\'s resident composer; fixed overlays must portal to the document body because the docked composer contains them.',
doc: 'Selector-routed replacements for the current Session\'s resident composer; fixed overlays must\nportal to the document body because the docked composer contains them.',
registerOptions: [
{
name: 'select',
@@ -808,14 +808,14 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer\', () => ctx.slots.register(\n { name: \'conversation.composer\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:280',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:283',
},
{
key: 'conversation.composer.bar',
kind: 'single',
scope: 'session-maybe',
summary: 'Resident composer body, including the no-Session inert state.',
doc: 'Resident composer body, including the no-Session inert state.',
summary: 'Resident composer body, including the no-Session inert state; fixed overlays must portal to the document body because the docked composer contains them.',
doc: 'Resident composer body, including the no-Session inert state; fixed overlays must portal to\nthe document body because the docked composer contains them.',
registerOptions: [],
ownerProps: [
'/** Owner share of the resident composer bar. */\nexport interface ComposerBarOwnerProps {\n /** Hero uses centered placement; composer uses the active bottom placement. */\n variant: \'hero\' | \'composer\'\n /** A feature-owned reason that makes message input inert while leaving model selection live. */\n blocked?: { readonly reason: string }\n /** Lock all message actions while preserving the resident composer surface. */\n disabled?: boolean\n /** Whether the shared Workspace picker is expanded. */\n workspacePickerOpen?: boolean\n /** Open the Workspace picker from the inert composer surface. */\n onRequestWorkspace?: () => void\n placeholder?: string\n /** Optional content rendered above the composer surface. */\n accessory?: ReactNode\n}',
@@ -851,14 +851,14 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.bar\', () => ctx.slots.register(\n { name: \'conversation.composer.bar\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:302',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:327',
},
{
key: 'conversation.composer.dock',
kind: 'list',
scope: 'session',
summary: 'Ambient entries below the composer card.',
doc: 'Ambient entries below the composer card.',
summary: 'Ambient entries below the composer card; fixed overlays must portal to the document body because the docked composer contains them.',
doc: 'Ambient entries below the composer card; fixed overlays must portal to the document body\nbecause the docked composer contains them.',
registerOptions: [
{
name: 'id',
@@ -912,7 +912,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.dock\', () => ctx.slots.register(\n { name: \'conversation.composer.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:294',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:306',
},
{
key: 'conversation.header',
@@ -1024,7 +1024,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.agentPreset\', () => ctx.slots.register(\n { name: \'conversation.hero.agentPreset\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:286',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:289',
},
{
key: 'conversation.hero.brand.mark',
@@ -1056,7 +1056,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
occupants: [],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.brand.mark\', () => ctx.slots.register(\n { name: \'conversation.hero.brand.mark\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:284',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:287',
},
{
key: 'conversation.hero.options',
@@ -1116,7 +1116,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.options\', () => ctx.slots.register(\n { name: \'conversation.hero.options\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:288',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:291',
},
{
key: 'conversation.hero.workspace',
@@ -1152,7 +1152,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.workspace\', () => ctx.slots.register(\n { name: \'conversation.hero.workspace\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:282',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:285',
},
{
key: 'conversation.hero.workspace.directoryFlow',
@@ -1193,8 +1193,8 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
key: 'conversation.input.activity',
kind: 'single',
scope: 'session',
summary: 'Compact action after the model selector; it can expand across the toolbar while retaining the editor and submit action.',
doc: 'Compact action after the model selector; it can expand across the toolbar while retaining the editor and submit action.',
summary: 'Compact action after the model selector; it can expand across the toolbar while retaining the editor and submit action, and fixed overlays must portal to the document body because the docked composer contains them.',
doc: 'Compact action after the model selector; it can expand across the toolbar while retaining the\neditor and submit action, and fixed overlays must portal to the document body because the\ndocked composer contains them.',
registerOptions: [],
ownerProps: [
'/** A toolbar activity hides ordinary accessory controls while expanded; its occupant must release expansion on unmount. */\nexport interface InputActivityOwnerProps extends InputControlOwnerProps {\n /** @param active - whether the occupant needs the toolbar width before the submit action. */\n onActiveChange: (active: boolean) => void\n}',
@@ -1232,14 +1232,14 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.activity\', () => ctx.slots.register(\n { name: \'conversation.input.activity\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:300',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:322',
},
{
key: 'conversation.input.attachments',
kind: 'single',
scope: 'session-maybe',
summary: 'Optional draft-attachment rail and drop target.',
doc: 'Optional draft-attachment rail and drop target.',
summary: 'Optional draft-attachment rail and drop target; fixed overlays must portal to the document body because the docked composer contains them.',
doc: 'Optional draft-attachment rail and drop target; fixed overlays must portal to the document\nbody because the docked composer contains them.',
registerOptions: [],
ownerProps: [
'/** Input state handed to the optional attachment presentation plugin. */\nexport interface ComposerAttachmentsOwnerProps {\n /** Browser-owned draft attachments in input order. */\n attachments: readonly ComposerAttachment[]\n /** Whether a document-level file drop may add attachments now. */\n canAcceptDrop: boolean\n /**\n * Add one dropped batch through the composer\'s validation path.\n * @param files - dropped, pasted, or picked browser files in source order.\n * @param directories - members of `files` the drop source identified as directories.\n */\n onAddFiles: (files: readonly File[], directories?: ReadonlySet<File>) => void\n /** Remove one draft attachment through the Conversation service. */\n onRemoveAttachment: (id: DraftAttachmentId) => void\n /** Current per-draft upload states for file-kind attachments. */\n uploads: DraftFileUploads\n /** Restart one failed file upload. */\n onRetryFile: (id: DraftAttachmentId) => void\n /**\n * Explain one upload the Host refused; such a draft cannot be retried and\n * stays until it is removed.\n * @param reason - the refusal `reason` the failed upload carries.\n * @returns localized text naming the limit or the way out.\n /* …truncated — full shape in source */',
@@ -1277,14 +1277,14 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.attachments\', () => ctx.slots.register(\n { name: \'conversation.input.attachments\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:304',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:332',
},
{
key: 'conversation.input.dock',
kind: 'list',
scope: 'session',
summary: 'Full-width entries above the composer card.',
doc: 'Full-width entries above the composer card.',
summary: 'Full-width entries above the composer card; fixed overlays must portal to the document body because the docked composer contains them.',
doc: 'Full-width entries above the composer card; fixed overlays must portal to the document body\nbecause the docked composer contains them.',
registerOptions: [
{
name: 'id',
@@ -1346,14 +1346,14 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.dock\', () => ctx.slots.register(\n { name: \'conversation.input.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:290',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:296',
},
{
key: 'conversation.input.left',
kind: 'list',
scope: 'session',
summary: 'Compact controls at the left of the composer tool row.',
doc: 'Compact controls at the left of the composer tool row.',
summary: 'Compact controls at the left of the composer tool row; fixed overlays must portal to the document body because the docked composer contains them.',
doc: 'Compact controls at the left of the composer tool row; fixed overlays must portal to the\ndocument body because the docked composer contains them.',
registerOptions: [
{
name: 'id',
@@ -1404,14 +1404,14 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
occupants: [],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.left\', () => ctx.slots.register(\n { name: \'conversation.input.left\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:296',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:311',
},
{
key: 'conversation.input.model',
kind: 'single',
scope: 'session',
summary: 'Model selector inside the composer tool row.',
doc: 'Model selector inside the composer tool row. When expanded controls cannot\nshare a line, the row sets --dsh-composer-model-text-display to none and\n--dsh-composer-model-icon-display to block for an occupant\'s compact display.',
doc: 'Model selector inside the composer tool row. When expanded controls cannot\nshare a line, the row sets --dsh-composer-model-text-display to none and\n--dsh-composer-model-icon-display to block for an occupant\'s compact display.\nFixed overlays must portal to the document body because the docked composer contains them.',
registerOptions: [],
ownerProps: [
'/** Owner share of the named plan, permission, and model controls. */\nexport interface InputControlOwnerProps {\n /** Whether the composer currently refuses interaction. */\n locked: boolean\n}',
@@ -1448,7 +1448,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.model\', () => ctx.slots.register(\n { name: \'conversation.input.model\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:318',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:353',
},
{
key: 'conversation.input.model.label',
@@ -1498,8 +1498,8 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
key: 'conversation.input.overlay',
kind: 'list',
scope: 'session',
summary: 'Floating entries rendered inside the resident composer card.',
doc: 'Floating entries rendered inside the resident composer card.',
summary: 'Floating entries rendered inside the resident composer card; fixed overlays must portal to the document body because the docked composer contains them.',
doc: 'Floating entries rendered inside the resident composer card; fixed overlays must portal to\nthe document body because the docked composer contains them.',
registerOptions: [
{
name: 'id',
@@ -1554,14 +1554,14 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.overlay\', () => ctx.slots.register(\n { name: \'conversation.input.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:292',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:301',
},
{
key: 'conversation.input.permission',
kind: 'single',
scope: 'session',
summary: 'Current-session permission control inside the composer tool row.',
doc: 'Current-session permission control inside the composer tool row.',
summary: 'Current-session permission control inside the composer tool row; fixed overlays must portal to the document body because the docked composer contains them.',
doc: 'Current-session permission control inside the composer tool row; fixed overlays must portal\nto the document body because the docked composer contains them.',
registerOptions: [],
ownerProps: [
'/** Owner share of the named plan, permission, and model controls. */\nexport interface InputControlOwnerProps {\n /** Whether the composer currently refuses interaction. */\n locked: boolean\n}',
@@ -1598,14 +1598,14 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.permission\', () => ctx.slots.register(\n { name: \'conversation.input.permission\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:312',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:346',
},
{
key: 'conversation.input.plan',
kind: 'single',
scope: 'session',
summary: 'Plan control inside the composer tool row.',
doc: 'Plan control inside the composer tool row.',
summary: 'Plan control inside the composer tool row; fixed overlays must portal to the document body because the docked composer contains them.',
doc: 'Plan control inside the composer tool row; fixed overlays must portal to the document body\nbecause the docked composer contains them.',
registerOptions: [],
ownerProps: [
'/** Owner share of the named plan, permission, and model controls. */\nexport interface InputControlOwnerProps {\n /** Whether the composer currently refuses interaction. */\n locked: boolean\n}',
@@ -1641,14 +1641,14 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.plan\', () => ctx.slots.register(\n { name: \'conversation.input.plan\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:310',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:341',
},
{
key: 'conversation.input.right',
kind: 'list',
scope: 'session',
summary: 'Compact controls before the composer submit action.',
doc: 'Compact controls before the composer submit action.',
summary: 'Compact controls before the composer submit action; fixed overlays must portal to the document body because the docked composer contains them.',
doc: 'Compact controls before the composer submit action; fixed overlays must portal to the\ndocument body because the docked composer contains them.',
registerOptions: [
{
name: 'id',
@@ -1699,7 +1699,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
occupants: [],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.right\', () => ctx.slots.register(\n { name: \'conversation.input.right\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:298',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:316',
},
{
key: 'conversation.plan-review.actions',