diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.i18n.yaml index 39556c379a..718e1dde26 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-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 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md index f245e7ca04..803033613c 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md @@ -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. diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.zh.md b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.zh.md index 7e4d5fb523..0c3e83ce13 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.zh.md @@ -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 草稿。 diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 5781d2dc15..372effcea6 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -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 diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index 18278e34a0..4984de4048 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -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 diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index 7f62598a49..41e88bf7a0 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -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 => (