This commit is contained in:
07akioni
2026-07-29 20:56:56 +08:00
parent 382884e237
commit 2a12057345
9 changed files with 96 additions and 67 deletions
@@ -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-29-sticky-composer-conversation-scroll.md
2026-07-29-sticky-composer-conversation-scroll.md: f245e7ca0404df4f644504ac9e1b101e659b6b68
2026-07-29-sticky-composer-conversation-scroll.zh.md: 7e4d5fb523f33f450c343e12e486e1d33784ee6c
2026-07-29-sticky-composer-conversation-scroll.md: 803033613c715a1b5c8d299ab0df985e6a02fa6b
2026-07-29-sticky-composer-conversation-scroll.zh.md: 0c3e83ce13f70e0e425e89f20ca133a55d9a663d
@@ -10,9 +10,9 @@ The active conversation column split scrolling: the chat (and trajectory) view o
## Decision
Active phase keeps the session header as `flex: none` column chrome above the scrollport. `ConversationRoot` supplies a `wrapActiveBody` owner callback that wraps the view ring in a `data-conversation-scroll` body and places the composer stack inside that body with `position: sticky; bottom: 0`. Hero and settling keep the composer as a Root child (centered hero card). ChatView and Trajectory/Waterfall keep a local scroller only when mounted outside that host (unit tests); under the host they set `overflow: visible` and resolve bottom-follow / prepend anchoring through `closest('[data-conversation-scroll]')`.
While a session exists, `ConversationRoot` always supplies a `wrapActiveBody` owner callback that wraps the view ring in a `data-conversation-scroll` body and places the composer stack inside that body. Active CSS sticks the composer with `position: sticky; bottom: 0`; hero CSS centers the same stack inside the scroll body. `ConversationSession` keeps a chrome-hidden header + body shell while blank so that tree seat does not change on the first send. The session header remains `flex: none` column chrome above the scrollport when visible. ChatView and Trajectory/Waterfall keep a local scroller only when mounted outside that host (unit tests); under the host they set `overflow: visible` and resolve bottom-follow / prepend anchoring through `closest('[data-conversation-scroll]')`.
Session stats live on `'conversation.composer.dock'` (above `'conversation.input.dock'`). The InputBar textarea, when inside the host, listens for `wheel` with `{ passive: false }`, calls `preventDefault`, and applies `deltaY` to the host — hero mounts have no host and keep native textarea wheel behavior. Moving the composer into the Session scrollport on the hero → active flip may remount the textarea; the InputHub draft is the durable carrier across that flip.
Session stats live on `'conversation.composer.dock'` (above `'conversation.input.dock'`). The InputBar textarea, when inside the host, listens for `wheel` with `{ passive: false }`, calls `preventDefault`, and applies `deltaY` to the host.
## Alternatives considered
@@ -26,4 +26,4 @@ Session stats live on `'conversation.composer.dock'` (above `'conversation.input
## Consequences
Wheel over the footer scrolls the transcript; the visible layout is a fixed header, scrolling transcript, and sticky bottom composer. Stats appear on every active view tab. Nested view scrollers under the host are suppressed so sticky Turn headers in Trajectory stick to the column host. Hero → active asserts draft survival through the InputHub, not textarea DOM identity.
Wheel over the footer scrolls the transcript; the visible layout is a fixed header, scrolling transcript, and sticky bottom composer. Stats appear on every active view tab. Nested view scrollers under the host are suppressed so sticky Turn headers in Trajectory stick to the column host. Hero → active keeps the same textarea DOM node (assembled slash-flow snapshot) and the InputHub draft.
@@ -10,9 +10,9 @@ Status: implemented
## Decision
活跃阶段会话标题栏保持为滚动容器之上的 `flex: none` 列 chrome。`ConversationRoot` 提供 `wrapActiveBody` owner 回调,将视图环包进 `data-conversation-scroll` 主体,并把编辑器栈以 `position: sticky; bottom: 0` 放进该主体。Hero/settling 仍把编辑器作为 Root 子节点(居中 hero 卡片)。ChatView 与 Trajectory/Waterfall 仅在宿主之外挂载时(单元测试)保留本地 scroller;位于宿主下时设为 `overflow: visible`,并通过 `closest('[data-conversation-scroll]')` 解析贴底跟随与前置锚定。
只要存在会话,`ConversationRoot` 就会始终提供 `wrapActiveBody` owner 回调,将视图环包进 `data-conversation-scroll` 主体,并把编辑器栈放进该主体。活跃阶段 CSS `position: sticky; bottom: 0` 钉住编辑器;hero CSS 在同一滚动主体内居中同一栈。`ConversationSession` 在 blank 时保留隐藏 chrome 的 header + body 壳,使首次发送时树座位不变。可见时会话标题栏仍是滚动容器之上的 `flex: none` 列 chrome。ChatView 与 Trajectory/Waterfall 仅在宿主之外挂载时(单元测试)保留本地 scroller;位于宿主下时设为 `overflow: visible`,并通过 `closest('[data-conversation-scroll]')` 解析贴底跟随与前置锚定。
会话统计挂在 `'conversation.composer.dock'`(位于 `'conversation.input.dock'` 之上)。InputBar 的 textarea 在宿主内以 `{ passive: false }` 监听 `wheel`,调用 `preventDefault`,并将 `deltaY` 施加到宿主——hero 挂载没有宿主,保留 textarea 原生滚轮行为。hero → active 翻转时编辑器进入 Session 滚动容器可能重挂载 textarea;跨该翻转的耐久载体是 InputHub 草稿
会话统计挂在 `'conversation.composer.dock'`(位于 `'conversation.input.dock'` 之上)。InputBar 的 textarea 在宿主内以 `{ passive: false }` 监听 `wheel`,调用 `preventDefault`,并将 `deltaY` 施加到宿主。
## Alternatives considered
@@ -26,4 +26,4 @@ Status: implemented
## Consequences
在页脚上滚轮会滚动 transcript;可见布局是固定标题栏、可滚动 transcript 与 sticky 底部编辑器。统计出现在每一个活跃视图标签上。宿主下的嵌套视图 scroller 被抑制,因而 Trajectory 的 sticky Turn 标题贴在列宿主上。hero → active 断言经 InputHub 的草稿存续,而非 textarea DOM 身份
在页脚上滚轮会滚动 transcript;可见布局是固定标题栏、可滚动 transcript 与 sticky 底部编辑器。统计出现在每一个活跃视图标签上。宿主下的嵌套视图 scroller 被抑制,因而 Trajectory 的 sticky Turn 标题贴在列宿主上。hero → active 保持同一 textarea DOM 节点(assembled slash-flow 快照)以及 InputHub 草稿
@@ -118,11 +118,13 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
/** Owner share of the strict session content seat. */
export interface ConversationSessionOwnerProps {
/**
* Active phase only: wrap the view ring in the transcript scrollport that
* also hosts the sticky composer. The header stays outside that wrapper as
* ordinary column chrome (`flex: none`), while the composer sticks to the
* bottom of the same scrollport so wheel over the footer scrolls the flow.
* @param view - the session view-ring content.
* Wrap the view ring in the transcript scrollport that also hosts the
* sticky composer. Supplied for every real session (hero/settling/active)
* so the composer keeps one tree seat across the blank → active flip; the
* header stays outside that wrapper as ordinary column chrome
* (`flex: none`), while active CSS sticks the composer to the bottom of
* the same scrollport so wheel over the footer scrolls the flow.
* @param view - the session view-ring content (null while blank chrome is hidden).
* @returns the scrollport containing `view` and the sticky composer.
*/
wrapActiveBody?: (view: ReactNode) => ReactNode
@@ -17,6 +17,12 @@
border-bottom: 1px solid var(--dsw-alias-border-l2);
}
/* Blank hero/settling: keep the header node mounted (stable Session tree for
the wrapActiveBody composer) without taking column space. */
.headerHidden {
display: none;
}
.crumbRow {
display: flex;
align-items: center;
@@ -199,8 +205,11 @@
padding-left: 8px;
}
.root[data-phase='hero'] {
/* Hero: the composer sits inside the session scroll body; center there so
the tree seat matches active (sticky footer) without a Root remount. */
.root[data-phase='hero'] .scrollBody {
justify-content: center;
overflow-y: auto;
}
/* Settling (session replaying, hero/docked unknown): keep the composer
@@ -128,9 +128,10 @@ export function ConversationRoot({
{ fallback: composerBar, overlay: true },
)
// Active: header is column chrome above the scrollport; the sticky composer
// lives inside the same scrollport as the transcript (wheel over the footer
// scrolls the flow). Hero/settling keep the composer as a Root child.
// Header stays column chrome above this scrollport; the sticky composer
// lives inside it with the transcript. Always wrap while a session exists
// (hero/settling/active) so the composer keeps one tree seat across the
// blank → active flip — relocating it only in active remounted the textarea.
const wrapActiveBody = (view: ReactNode): ReactNode => (
<div className={css.scrollBody} data-conversation-scroll="">
{view}
@@ -141,14 +142,14 @@ export function ConversationRoot({
return (
<div className={css.root} data-phase={phase}>
{/* Mounted for every real session, hero included: ConversationSession
renders no chrome while blank but owns the draft-persistence mirror
bind — unmounting it in the hero would lose pre-first-send text on
a refresh or scope rebuild. */}
keeps a chrome-hidden shell while blank and owns the draft-
persistence mirror bind — unmounting it in the hero would lose
pre-first-send text on a refresh or scope rebuild. */}
{sessionId !== undefined && renderSlot(
'conversation.session',
phase === 'active' ? { wrapActiveBody } : {},
{ wrapActiveBody },
)}
{phase !== 'active' ? composer : null}
{sessionId === undefined ? composer : null}
</div>
)
}
@@ -44,9 +44,13 @@ export function ConversationSession({
// the machine mirror, not this seed effect.
}, [inputActions])
if (blank && composerPhase === 'blank') return null
// Blank hero/settling: keep the same header + body tree shape so a
// wrapActiveBody-hosted composer keeps its DOM identity across the first
// send (hero → active). Chrome is hidden; the draft-persistence mirror
// still runs because this component stays mounted.
const hideChrome = blank && composerPhase === 'blank'
const view: ReactNode = (
const view: ReactNode = hideChrome ? null : (
<div className={css.viewArea}>
{active !== undefined && renderSlot('conversation.view', {}, { only: active.id })}
</div>
@@ -54,43 +58,50 @@ export function ConversationSession({
return (
<>
<header className={css.header}>
<div className={css.crumbRow}>
<nav className={css.crumbs} aria-label="Session hierarchy">
{ancestry.map((summary, index) => {
const last = index === ancestry.length - 1
return (
<span key={summary.id} className={css.crumbSeg}>
{index > 0 && <span className={css.crumbSep}>/</span>}
<header
className={clsx(css.header, hideChrome && css.headerHidden)}
aria-hidden={hideChrome || undefined}
>
{!hideChrome && (
<>
<div className={css.crumbRow}>
<nav className={css.crumbs} aria-label="Session hierarchy">
{ancestry.map((summary, index) => {
const last = index === ancestry.length - 1
return (
<span key={summary.id} className={css.crumbSeg}>
{index > 0 && <span className={css.crumbSep}>/</span>}
<button
type="button"
className={clsx(css.crumb, last && css.crumbCurrent)}
disabled={last}
onClick={() => { open(summary.id) }}
>
{summary.displayTitle}
</button>
</span>
)
})}
{ancestry.length === 0 && <span className={css.crumbCurrent}>{sessionId}</span>}
</nav>
</div>
{tabs.length > 1 && (
<div className={css.tabs} role="tablist">
{tabs.map(viewTab => (
<button
key={viewTab.id}
type="button"
className={clsx(css.crumb, last && css.crumbCurrent)}
disabled={last}
onClick={() => { open(summary.id) }}
role="tab"
aria-selected={viewTab.id === active?.id}
className={clsx(css.tab, viewTab.id === active?.id && css.tabActive)}
onClick={() => { actions.setView(viewTab.id) }}
>
{summary.displayTitle}
{viewTab.label}
</button>
</span>
)
})}
{ancestry.length === 0 && <span className={css.crumbCurrent}>{sessionId}</span>}
</nav>
</div>
{tabs.length > 1 && (
<div className={css.tabs} role="tablist">
{tabs.map(viewTab => (
<button
key={viewTab.id}
type="button"
role="tab"
aria-selected={viewTab.id === active?.id}
className={clsx(css.tab, viewTab.id === active?.id && css.tabActive)}
onClick={() => { actions.setView(viewTab.id) }}
>
{viewTab.label}
</button>
))}
</div>
))}
</div>
)}
</>
)}
</header>
{wrapActiveBody !== undefined ? wrapActiveBody(view) : view}
@@ -115,9 +115,9 @@ export function HeroShell({ children }: HeroShellProps) {
Let&apos;s start building
</div>
<div className={css.body}>
{/* The resident composer (rendered by ConversationRoot at its stable
tree position; the workspace row rides its accessory hole) is
CSS-positioned into this gap during the hero phase — see
{/* The resident composer (ConversationRoot wrapActiveBody seat; the
workspace row rides the stack above the card) is CSS-centered in
the session scroll body during hero — see
ConversationRoot.module.css [data-phase='hero']. */}
</div>
</div>
@@ -187,14 +187,19 @@ describe('ConversationRoot resident composer', () => {
{ ...workspace('second'), title: 'Selected Folder' },
],
)
// Hero chrome present, view ring absent; scroll host is active-phase only.
expect(b.view.container.querySelector('[data-conversation-scroll]')).toBeNull()
// Hero chrome present, view ring absent; scroll host already wraps the
// resident composer so the blank → active flip does not remount it.
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(b.view.getByText("Let's start building")).toBeTruthy()
expect(b.view.queryByTestId('view-chat')).toBeNull()
// The same machine-backed textarea is live in the hero, and the
// persistence mirror stays bound (ConversationSession mounts chrome-less
// persistence mirror stays bound (ConversationSession mounts chrome-hidden
// for blank sessions): hero typing reaches the chat store.
const box = b.view.getByRole('textbox')
expect(host?.contains(box)).toBe(true)
fireEvent.change(box, { target: { value: 'draft in hero' } })
expect(b.chat.store.getSnapshot().draft).toBe('draft in hero')
// Picker: open through the chip; a pick switches to the other
@@ -207,16 +212,17 @@ describe('ConversationRoot resident composer', () => {
expect(b.view.getByText('Selected Folder')).toBeTruthy()
})
it('machine draft survives the hero → active flip into the sticky scrollport composer', () => {
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')
fireEvent.change(before, { target: { value: 'kept across flip' } })
// First message landed: content exists, phase leaves blank. The active
// composer lives inside the Session scrollport (sticky footer), so the
// textarea may remount; the InputHub draft is the durable carrier.
// First message landed: content exists, phase leaves blank. Composer
// already sat in the Session scrollport during hero, so the textarea
// node and InputHub draft both survive.
b.session.set(conversationSnapshot({ composerPhase: 'active', blank: false }))
b.rerender()
const after = b.view.getByRole('textbox') as HTMLTextAreaElement
expect(after).toBe(before)
expect(after.value).toBe('kept across flip')
expect(b.chat.store.getSnapshot().draft).toBe('kept across flip')
expect(b.view.container.querySelector('[data-conversation-scroll]')?.contains(after)).toBe(true)