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; # 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: # 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 # 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.md: 803033613c715a1b5c8d299ab0df985e6a02fa6b
2026-07-29-sticky-composer-conversation-scroll.zh.md: 7e4d5fb523f33f450c343e12e486e1d33784ee6c 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 ## 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 ## Alternatives considered
@@ -26,4 +26,4 @@ Session stats live on `'conversation.composer.dock'` (above `'conversation.input
## Consequences ## 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 ## 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 ## Alternatives considered
@@ -26,4 +26,4 @@ Status: implemented
## Consequences ## 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. */ /** Owner share of the strict session content seat. */
export interface ConversationSessionOwnerProps { export interface ConversationSessionOwnerProps {
/** /**
* Active phase only: wrap the view ring in the transcript scrollport that * Wrap the view ring in the transcript scrollport that also hosts the
* also hosts the sticky composer. The header stays outside that wrapper as * sticky composer. Supplied for every real session (hero/settling/active)
* ordinary column chrome (`flex: none`), while the composer sticks to the * so the composer keeps one tree seat across the blank → active flip; the
* bottom of the same scrollport so wheel over the footer scrolls the flow. * header stays outside that wrapper as ordinary column chrome
* @param view - the session view-ring content. * (`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. * @returns the scrollport containing `view` and the sticky composer.
*/ */
wrapActiveBody?: (view: ReactNode) => ReactNode wrapActiveBody?: (view: ReactNode) => ReactNode
@@ -17,6 +17,12 @@
border-bottom: 1px solid var(--dsw-alias-border-l2); 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 { .crumbRow {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -199,8 +205,11 @@
padding-left: 8px; 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; justify-content: center;
overflow-y: auto;
} }
/* Settling (session replaying, hero/docked unknown): keep the composer /* Settling (session replaying, hero/docked unknown): keep the composer
@@ -128,9 +128,10 @@ export function ConversationRoot({
{ fallback: composerBar, overlay: true }, { fallback: composerBar, overlay: true },
) )
// Active: header is column chrome above the scrollport; the sticky composer // Header stays column chrome above this scrollport; the sticky composer
// lives inside the same scrollport as the transcript (wheel over the footer // lives inside it with the transcript. Always wrap while a session exists
// scrolls the flow). Hero/settling keep the composer as a Root child. // (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 => ( const wrapActiveBody = (view: ReactNode): ReactNode => (
<div className={css.scrollBody} data-conversation-scroll=""> <div className={css.scrollBody} data-conversation-scroll="">
{view} {view}
@@ -141,14 +142,14 @@ export function ConversationRoot({
return ( return (
<div className={css.root} data-phase={phase}> <div className={css.root} data-phase={phase}>
{/* Mounted for every real session, hero included: ConversationSession {/* Mounted for every real session, hero included: ConversationSession
renders no chrome while blank but owns the draft-persistence mirror keeps a chrome-hidden shell while blank and owns the draft-
bind — unmounting it in the hero would lose pre-first-send text on persistence mirror bind — unmounting it in the hero would lose
a refresh or scope rebuild. */} pre-first-send text on a refresh or scope rebuild. */}
{sessionId !== undefined && renderSlot( {sessionId !== undefined && renderSlot(
'conversation.session', 'conversation.session',
phase === 'active' ? { wrapActiveBody } : {}, { wrapActiveBody },
)} )}
{phase !== 'active' ? composer : null} {sessionId === undefined ? composer : null}
</div> </div>
) )
} }
@@ -44,9 +44,13 @@ export function ConversationSession({
// the machine mirror, not this seed effect. // the machine mirror, not this seed effect.
}, [inputActions]) }, [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}> <div className={css.viewArea}>
{active !== undefined && renderSlot('conversation.view', {}, { only: active.id })} {active !== undefined && renderSlot('conversation.view', {}, { only: active.id })}
</div> </div>
@@ -54,43 +58,50 @@ export function ConversationSession({
return ( return (
<> <>
<header className={css.header}> <header
<div className={css.crumbRow}> className={clsx(css.header, hideChrome && css.headerHidden)}
<nav className={css.crumbs} aria-label="Session hierarchy"> aria-hidden={hideChrome || undefined}
{ancestry.map((summary, index) => { >
const last = index === ancestry.length - 1 {!hideChrome && (
return ( <>
<span key={summary.id} className={css.crumbSeg}> <div className={css.crumbRow}>
{index > 0 && <span className={css.crumbSep}>/</span>} <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 <button
key={viewTab.id}
type="button" type="button"
className={clsx(css.crumb, last && css.crumbCurrent)} role="tab"
disabled={last} aria-selected={viewTab.id === active?.id}
onClick={() => { open(summary.id) }} className={clsx(css.tab, viewTab.id === active?.id && css.tabActive)}
onClick={() => { actions.setView(viewTab.id) }}
> >
{summary.displayTitle} {viewTab.label}
</button> </button>
</span> ))}
) </div>
})} )}
{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>
)} )}
</header> </header>
{wrapActiveBody !== undefined ? wrapActiveBody(view) : view} {wrapActiveBody !== undefined ? wrapActiveBody(view) : view}
@@ -115,9 +115,9 @@ export function HeroShell({ children }: HeroShellProps) {
Let&apos;s start building Let&apos;s start building
</div> </div>
<div className={css.body}> <div className={css.body}>
{/* The resident composer (rendered by ConversationRoot at its stable {/* The resident composer (ConversationRoot wrapActiveBody seat; the
tree position; the workspace row rides its accessory hole) is workspace row rides the stack above the card) is CSS-centered in
CSS-positioned into this gap during the hero phase — see the session scroll body during hero — see
ConversationRoot.module.css [data-phase='hero']. */} ConversationRoot.module.css [data-phase='hero']. */}
</div> </div>
</div> </div>
@@ -187,14 +187,19 @@ describe('ConversationRoot resident composer', () => {
{ ...workspace('second'), title: 'Selected Folder' }, { ...workspace('second'), title: 'Selected Folder' },
], ],
) )
// Hero chrome present, view ring absent; scroll host is active-phase only. // Hero chrome present, view ring absent; scroll host already wraps the
expect(b.view.container.querySelector('[data-conversation-scroll]')).toBeNull() // 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.getByText("Let's start building")).toBeTruthy()
expect(b.view.queryByTestId('view-chat')).toBeNull() expect(b.view.queryByTestId('view-chat')).toBeNull()
// The same machine-backed textarea is live in the hero, and the // 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. // for blank sessions): hero typing reaches the chat store.
const box = b.view.getByRole('textbox') const box = b.view.getByRole('textbox')
expect(host?.contains(box)).toBe(true)
fireEvent.change(box, { target: { value: 'draft in hero' } }) fireEvent.change(box, { target: { value: 'draft in hero' } })
expect(b.chat.store.getSnapshot().draft).toBe('draft in hero') expect(b.chat.store.getSnapshot().draft).toBe('draft in hero')
// Picker: open through the chip; a pick switches to the other // 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() 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 b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true }))
const before = b.view.getByRole('textbox') const before = b.view.getByRole('textbox')
fireEvent.change(before, { target: { value: 'kept across flip' } }) fireEvent.change(before, { target: { value: 'kept across flip' } })
// First message landed: content exists, phase leaves blank. The active // First message landed: content exists, phase leaves blank. Composer
// composer lives inside the Session scrollport (sticky footer), so the // already sat in the Session scrollport during hero, so the textarea
// textarea may remount; the InputHub draft is the durable carrier. // node and InputHub draft both survive.
b.session.set(conversationSnapshot({ composerPhase: 'active', blank: false })) b.session.set(conversationSnapshot({ composerPhase: 'active', blank: false }))
b.rerender() b.rerender()
const after = b.view.getByRole('textbox') as HTMLTextAreaElement const after = b.view.getByRole('textbox') as HTMLTextAreaElement
expect(after).toBe(before)
expect(after.value).toBe('kept across flip') expect(after.value).toBe('kept across flip')
expect(b.chat.store.getSnapshot().draft).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) expect(b.view.container.querySelector('[data-conversation-scroll]')?.contains(after)).toBe(true)