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 new file mode 100644 index 0000000000..39556c379a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 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 new file mode 100644 index 0000000000..f245e7ca04 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md @@ -0,0 +1,29 @@ +# Agent Note: Fixed header, sticky composer inside the transcript scrollport + +Status: implemented + +English | [中文](2026-07-29-sticky-composer-conversation-scroll.zh.md) + +## Problem + +The active conversation column split scrolling: the chat (and trajectory) view owned `overflow-y: auto`, while the composer stack sat as a sibling below that scrollport. A wheel gesture over the stats line or input therefore hit a non-scrolling region and did nothing — the transcript only moved when the pointer was over the message list. Long drafts made it worse: the textarea is itself a scrollport, so wheel over the composer could be trapped there. The session header must occupy the top of the column as ordinary chrome (not `position: sticky` inside the scrollport), while the composer must stick to the bottom of the same scrollport as the transcript so wheel over the footer moves the flow. + +## 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]')`. + +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. + +## Alternatives considered + +**Sticky header and sticky composer inside one column scrollport.** Rejected for the header: it must occupy the top as fixed layout chrome, not participate in the scrollport's sticky layer. + +**Fixed flex-none composer below the scrollport with wheel forwarding.** Rejected: the product requires the composer to stick inside the transcript scrollport so the footer is part of that scroll hit-testing surface, not a sibling that only forwards deltas. + +**Portal the composer into ChatView's scroller.** Rejected: the composer is shared across view tabs; the wrap target is the Session body owned by the resident shell. + +**Keep StatsLine inside ChatView below the message column.** Rejected: outside the sticky composer it would scroll away while the input stayed pinned. + +## 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. 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 new file mode 100644 index 0000000000..7e4d5fb523 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 固定标题栏,sticky 编辑器位于 transcript 滚动容器内 + +Status: implemented + +[English](2026-07-29-sticky-composer-conversation-scroll.md) | 中文 + +## Problem + +活跃会话列把滚动拆成两段:聊天(以及 trajectory)视图自有 `overflow-y: auto`,编辑器栈则作为该滚动容器的兄弟节点坐在下方。指针落在统计行或输入区上时,滚轮打在不可滚动区域上因而毫无效果——只有指针在消息列表上时 transcript 才会移动。草稿变长时更糟:textarea 本身也是滚动容器,编辑器上的滚轮可能被截在那里。会话标题栏必须以普通 chrome 占据列顶(不能在滚动容器内 `position: sticky`),而编辑器必须与 transcript 贴在同一滚动容器底部,使页脚上的滚轮能带动内容流动。 + +## 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]')` 解析贴底跟随与前置锚定。 + +会话统计挂在 `'conversation.composer.dock'`(位于 `'conversation.input.dock'` 之上)。InputBar 的 textarea 在宿主内以 `{ passive: false }` 监听 `wheel`,调用 `preventDefault`,并将 `deltaY` 施加到宿主——hero 挂载没有宿主,保留 textarea 原生滚轮行为。hero → active 翻转时编辑器进入 Session 滚动容器可能重挂载 textarea;跨该翻转的耐久载体是 InputHub 草稿。 + +## Alternatives considered + +**标题栏与编辑器都在同一列滚动容器内 sticky。** 标题栏否决:它必须作为固定布局 chrome 占据顶部,而不是参与滚动容器的 sticky 层。 + +**滚动容器下方 flex-none 固定编辑器并转发滚轮。** 否决:产品要求编辑器 sticky 在 transcript 滚动容器内,使页脚成为该滚动命中面的一部分,而不是仅转发增量的兄弟节点。 + +**把编辑器 portal 进 ChatView 的 scroller。** 否决:编辑器跨视图标签共享;包装目标是常驻壳拥有的 Session 主体。 + +**把 StatsLine 留在 ChatView 消息列下方。** 否决:落在 sticky 编辑器之外会随内容滚走,而输入区仍钉在底部。 + +## Consequences + +在页脚上滚轮会滚动 transcript;可见布局是固定标题栏、可滚动 transcript 与 sticky 底部编辑器。统计出现在每一个活跃视图标签上。宿主下的嵌套视图 scroller 被抑制,因而 Trajectory 的 sticky Turn 标题贴在列宿主上。hero → active 断言经 InputHub 的草稿存续,而非 textarea DOM 身份。 diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 680faa2c6e..fa6b3c6b30 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.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 packages/client/ui-conversation/README.md -README.md: 5a1f9f1ad5cac6601e8686af7206bb436e40e91f -README.zh.md: ccbf1918ae3d40fd42ff7454f7f983d6261ba28f +README.md: 1d4dad5c342b9d83275eb2c1ef5a1b4b667def6b +README.zh.md: f881d4dbeb12d7e58ccc5be5caeaed0ad1b89882 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 5a1f9f1ad5..1d4dad5c34 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -2,9 +2,9 @@ English | [中文](README.zh.md) -Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, stats line, per-tool row slot with a bash sample registrant and the todo row), input dock (queue rows plus the todo plan strip), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares). +Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, per-tool row slot with a bash sample registrant and the todo row), composer dock (session stats sticky with the input), input dock (queue rows plus the todo plan strip), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares). -The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. +The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header occupies the top as ordinary column chrome; beneath it a scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). Wheel over the textarea forwards to that host so nested textarea scrolling never traps the gesture. The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: `), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index ccbf1918ae..f881d4dbeb 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、统计行、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、输入区 dock(队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约:api-contracts v3 §7 加 slot 终端设计(store seat/props share)。 +会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、编辑器 dock(与输入区一同 sticky 的会话统计行)、输入区 dock(队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约:api-contracts v3 §7 加 slot 终端设计(store seat/props share)。 -常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。 +常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段会话标题栏以普通列 chrome 占据顶部;其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。textarea 上的滚轮会转交给该宿主,避免嵌套 textarea 滚动截获手势。 视图环本身就是 slot:会话注册声明 `'conversation.view'` 列表 slot(Session scope),并将其列在 `children` 表中;ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: `);视图标签页从环账本的注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包自身的环配置项;其他插件(ui-trajectory)通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView`/`ViewEntry`/`ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 2b845e6c83..26e644e809 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -15,6 +15,7 @@ import type { IConversation } from './service.ts' import { InputHub } from './input/hub.ts' import { InputBar } from './skeleton/InputBar.tsx' import { ChatView } from './chat/ChatView.tsx' +import { StatsLine } from './chat/StatsLine.tsx' import { bashToolviewSample } from './toolviews/bash-sample.tsx' import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx' import { todoToolview } from './toolviews/todo-row.tsx' @@ -208,6 +209,9 @@ export function apply(ctx: Context): void { }, }, ChatView) + // Session stats stick with the composer (composer.dock = stats-line family). + slots.register({ name: 'conversation.composer.dock', id: 'stats', order: 0 }, StatsLine) + // Class-plugin mount (packages/AGENTS.md service form): the service // registers itself as `conversation` and lives on its own child fiber. // Mounted AFTER the chat entry register above — construction guarantee for diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.module.css b/packages/client/ui-conversation/src/client/chat/ChatView.module.css index fae96e5f6c..80e461b518 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.module.css +++ b/packages/client/ui-conversation/src/client/chat/ChatView.module.css @@ -1,6 +1,8 @@ /* Chat flow: one 16px rhythm everywhere — between blocks (prose <-> tool runs) via the column gap and between consecutive tool rows via the group - gap. Input padding cap rides the skeleton. */ + gap. Input padding cap rides the skeleton. Under + `[data-conversation-scroll]` the column host owns overflow and this view + is ordinary flow (see ConversationRoot active-phase rules). */ .root { position: relative; @@ -17,6 +19,18 @@ padding: 16px 24px; } +:global([data-conversation-scroll]) .root { + flex: 0 0 auto; + min-height: auto; + height: auto; +} + +:global([data-conversation-scroll]) .scroll { + overflow: visible; + flex: 0 0 auto; + min-height: auto; +} + /* Message column: 736px fixed width, centered on the same axis as the input box; the scroller itself stays full-bleed. */ .column { @@ -113,16 +127,34 @@ opacity: 0.6; } -/* Back-to-bottom: 34px circular icon button at the column's right edge. */ -.toBottom { - position: absolute; - right: max(24px, calc((100% - 736px) / 2)); +/* Back-to-bottom: zero-height sticky slot so the control does not extend + scrollHeight; the button translates up into the viewport. Under the + conversation host, clearance sits above the sticky composer stack. */ +.toBottomSlot { + position: sticky; bottom: 16px; - width: 34px; - height: 34px; + /* Above the sticky composer (z-index 7) so the control stays clickable and + visible over the input card. */ + z-index: 8; + height: 0; + display: flex; + justify-content: flex-end; + padding-right: max(0px, calc((100% - 736px) / 2)); + pointer-events: none; +} + +:global([data-conversation-scroll]) .toBottomSlot { + /* Clears the sticky composer stack (stats + docks + input card). */ + bottom: 168px; +} + +.toBottom { display: flex; align-items: center; justify-content: center; + width: 34px; + height: 34px; + margin-top: -34px; padding: 0; border: 1px solid var(--dsw-alias-border-l2); border-radius: 100px; @@ -130,6 +162,7 @@ background: var(--dsw-alias-button-floating-fill); box-shadow: var(--dsw-shadow-lv2); cursor: pointer; + pointer-events: auto; } .toBottom:hover { diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 0f6b3dc5ae..e281c0e11d 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -1,11 +1,16 @@ // ChatView: the default conversation view — message flow with user bubbles, // assistant narration, tool summary rows grouped into step runs, pending -// cards, paging, bottom-follow, and the session stats line under the flow -// (chrome dissolved into the view: the footer is part of what a chat view -// IS, not registration metadata). Pure component registered directly; its -// registration declares the keyed 'conversation.chat.toolview' hole, so tool -// rows render through the props renderSlot share (entryKey = tool name, -// GenericToolCard as the render-site fallback). +// cards, paging, and bottom-follow. Session stats live on +// 'conversation.composer.dock' (sticky with the composer). Pure component +// registered directly; its registration declares the keyed +// 'conversation.chat.toolview' hole, so tool rows render through the props +// renderSlot share (entryKey = tool name, GenericToolCard as the render-site +// fallback). +// +// Scroll: when nested under `[data-conversation-scroll]` (active conversation +// column), that host is the scrollport and this view is flow content; when +// mounted alone (unit tests), `.scroll` owns overflow. Bottom-follow and +// prepend anchoring always target the resolved scrollport. // // Render economics (architecture RFC performance model): the list parent // subscribes to snapshot segments that do NOT change per streaming chunk @@ -17,7 +22,7 @@ // memoized rows never churns them. import { - memo, useLayoutEffect, useMemo, useRef, useState, type ReactNode, + memo, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, } from 'react' import type { CodeSubCall, CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, ToolResultNode, @@ -30,11 +35,15 @@ import { AssistantMarkdown } from './AssistantMarkdown.tsx' import { GenericCommandCard } from './GenericCommandCard.tsx' import { GenericToolCard } from './GenericToolCard.tsx' import { MessageItem } from './MessageItem.tsx' -import { StatsLine } from './StatsLine.tsx' import css from './ChatView.module.css' const FOLLOW_THRESHOLD = 24 +/** Active column host when present; otherwise the view-local scroller. */ +function scrollerOf(from: HTMLElement): HTMLElement { + return (from.closest('[data-conversation-scroll]')) ?? from +} + type OpenFile = (path: string) => void /** The declared toolview hole's render share (stable framework binding, passed through memoized rows). */ @@ -248,16 +257,17 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio const firstSeq = nodes[0]?.seq ?? null const lastItem = items[items.length - 1] - const toBottom = (el: HTMLDivElement): void => { + const toBottom = (el: HTMLElement): void => { el.scrollTop = el.scrollHeight atBottomRef.current = true setAtBottom(true) } useLayoutEffect(() => { - const el = listRef.current + const local = listRef.current /* v8 ignore next -- ref-null guard: React attaches the ref before layout effects run. */ - if (el === null) return + if (local === null) return + const el = scrollerOf(local) // Open completed: jump to the bottom once. if (openState === 'open' && !openedRef.current) { openedRef.current = true @@ -285,29 +295,48 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio if (appendedUser || atBottomRef.current) toBottom(el) }) - const onScroll = (): void => { - const el = listRef.current - /* v8 ignore next -- ref-null guard: the handler only fires on the mounted element. */ - if (el === null) return + const onScrollRef = useRef(() => {}) + onScrollRef.current = () => { + const local = listRef.current + /* v8 ignore next -- ref-null guard: the handler only fires while mounted. */ + if (local === null) return + const el = scrollerOf(local) const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1 atBottomRef.current = isAtBottom setAtBottom(isAtBottom) } + // Bind scroll to the resolved scrollport (host or local) once per mount. + useEffect(() => { + const local = listRef.current + /* v8 ignore next -- ref-null guard: effect runs after the list node commits. */ + if (local === null) return + const el = scrollerOf(local) + const onScroll = (): void => { onScrollRef.current() } + el.addEventListener('scroll', onScroll, { passive: true }) + return () => { el.removeEventListener('scroll', onScroll) } + }, []) + // Follow streaming growth the parent never re-renders for (stable ref). // The ref starts null and is assigned every render, so the placeholder // initializer a function initial value would need never exists. const followRef = useRef<(() => void) | null>(null) followRef.current = () => { - const el = listRef.current - if (el !== null && atBottomRef.current) el.scrollTop = el.scrollHeight + const local = listRef.current + if (local !== null && atBottomRef.current) { + const el = scrollerOf(local) + el.scrollTop = el.scrollHeight + } } const onGrow = useRef(() => followRef.current?.()).current const loadOlderAnchored = (): void => { - const el = listRef.current + const local = listRef.current /* v8 ignore next -- ref-null guard: the paging button renders inside the list tree. */ - if (el !== null) anchorRef.current = { h: el.scrollHeight, t: el.scrollTop } + if (local !== null) { + const el = scrollerOf(local) + anchorRef.current = { h: el.scrollHeight, t: el.scrollTop } + } loadOlder() } @@ -350,7 +379,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio return (
-
+
{openState === 'loading' &&
载入历史…
} {openState === 'error' &&
历史加载失败:{openErrorMessage}
} @@ -388,22 +417,23 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio wait, tool execution, streaming) so it never flickers per step. */} {running && }
+ {!atBottom && ( +
+ +
+ )}
- - {!atBottom && ( - - )}
) } diff --git a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx index 45b783f19b..7db5695030 100644 --- a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx +++ b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx @@ -1,4 +1,6 @@ // Settled-node identity prevents stream-delta updates from rerendering this row. +// Mounted on 'conversation.composer.dock' so it sticks with the composer in the +// active conversation scrollport (see ConversationRoot data-conversation-scroll). import { memo, useMemo } from 'react' import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client' @@ -49,7 +51,7 @@ export function deriveStats(nodes: ConversationSnapshot['nodes']): UsageTotals { } } -/** Props: the conversation-snapshot selector hook (handed down by ChatView). */ +/** Props: the conversation-snapshot selector (dock registration or unit mount). */ export interface StatsLineProps { useSession: SnapshotSelectorHook } export const StatsLine = memo(function StatsLine({ useSession }: StatsLineProps) { diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 9f3f3edf02..5781d2dc15 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -117,6 +117,15 @@ 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. + * @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 de974da86c..18278e34a0 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -127,6 +127,40 @@ flex-direction: column; } +/* Active phase: header is ordinary column chrome above the scrollport (not + sticky). The scroll body holds the transcript and the sticky composer so + wheel over the footer moves the flow. */ +.root[data-phase='active'] { + overflow: hidden; +} + +.root[data-phase='active'] .header { + flex: none; +} + +.scrollBody { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; + overflow-y: auto; +} + +.root[data-phase='active'] .viewArea { + flex: 1 0 auto; + min-height: auto; +} + +.root[data-phase='active'] .composerStack { + position: sticky; + bottom: 0; + /* Above markdown CodeBlock sticky banners (z-index 6) so the footer never + paints under a sticking code header while scrolling. */ + z-index: 7; + flex: none; + background: var(--dsw-alias-bg-base); +} + /* Hero phase: the composer stack (hero chrome + workspace row + card) is flex-centered in the column; composer phase docks it at the bottom. Flex, NOT absolute+transform: a transform would make this box the containing diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index b110860685..7f62598a49 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -2,7 +2,7 @@ // chain stay mounted across no-session/session transitions. Only the inert // input body swaps for the strict session InputBar. -import { useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState, type ReactNode } from 'react' import clsx from 'clsx' import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSlotProps, InputZone } from '../contract/slots.ts' @@ -113,24 +113,42 @@ export function ConversationRoot({ {hero && } {hero && } {hero && heroWorkspaceRow} - {!hero && zone !== undefined && renderSlot('conversation.input.dock', zone)} + {/* Stats band above the input-dock strips so the prior ChatView footer + order (stats → todo/queue → card) is preserved under the sticky stack. */} {!hero && zone !== undefined && renderSlot('conversation.composer.dock', zone)} + {!hero && zone !== undefined && renderSlot('conversation.input.dock', zone)} {inputBar}
) + const phase = settling ? 'settling' : hero ? 'hero' : 'active' + const composer = renderSlotChain( + 'conversation.composer', + { interactions: pending }, + { 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. + const wrapActiveBody = (view: ReactNode): ReactNode => ( +
+ {view} + {composer} +
+ ) + return ( -
+
{/* 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. */} - {sessionId !== undefined && renderSlot('conversation.session', {})} - {renderSlotChain( - 'conversation.composer', - { interactions: pending }, - { fallback: composerBar, overlay: true }, + {sessionId !== undefined && renderSlot( + 'conversation.session', + phase === 'active' ? { wrapActiveBody } : {}, )} + {phase !== 'active' ? composer : null}
) } diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index 0c1addcba7..f36874cfc2 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -1,6 +1,6 @@ /** Strict per-session conversation content: header, view ring, and chat store bindings. */ -import { useEffect, useSyncExternalStore } from 'react' +import { useEffect, useSyncExternalStore, type ReactNode } from 'react' import clsx from 'clsx' import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client' @@ -24,7 +24,7 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session export function ConversationSession({ sessionId, useSession, useSessions, useInput, inputActions, useStore, actions, - renderSlot, views, bindDraftMirror, open, + renderSlot, views, bindDraftMirror, open, wrapActiveBody, }: ConversationSessionProps) { useSyncExternalStore(views.subscribe, views.version) const tabs = views.list() @@ -46,6 +46,12 @@ export function ConversationSession({ if (blank && composerPhase === 'blank') return null + const view: ReactNode = ( +
+ {active !== undefined && renderSlot('conversation.view', {}, { only: active.id })} +
+ ) + return ( <>
@@ -72,24 +78,22 @@ export function ConversationSession({
{tabs.length > 1 && (
- {tabs.map(view => ( + {tabs.map(viewTab => ( ))}
)} -
- {active !== undefined && renderSlot('conversation.view', {}, { only: active.id })} -
+ {wrapActiveBody !== undefined ? wrapActiveBody(view) : view} ) } diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index a98ee09114..9586a04cdc 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -75,6 +75,22 @@ export function InputBar({ if (!locked) inputRef.current?.focus() }, [locked]) + // Active conversation scrollport: never let the textarea become a nested + // wheel target; forward delta to `[data-conversation-scroll]` instead. + // Hero mounts have no host, so the textarea keeps native wheel scrolling. + useEffect(() => { + const el = inputRef.current + if (el === null) return + const onWheel = (e: WheelEvent): void => { + const host = el.closest('[data-conversation-scroll]') + if (!(host instanceof HTMLElement)) return + e.preventDefault() + host.scrollTop += e.deltaY + } + el.addEventListener('wheel', onWheel, { passive: false }) + return () => { el.removeEventListener('wheel', onWheel) } + }, []) + const onKeyDown = (e: KeyboardEvent): void => { // Shift+Enter is the native newline UNCONDITIONALLY — decided before the // IME guard so a composition-closing Shift+Enter still breaks the line. diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index 4d6dc99f4a..b66c45aa8a 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -84,6 +84,8 @@ describe('apply wiring', () => { // service being present implies the chat entry declared the hole first. const entries = b.slots.entries('conversation.chat.toolview') expect(entries.map(e => e.options.key)).toEqual(['bash', 'todo_write']) + // Stats stick with the composer (not inside ChatView). + expect(b.slots.entries('conversation.composer.dock').map(e => e.options.id)).toEqual(['stats']) await b.runtime.dispose() }) diff --git a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx index 6985991074..38edc48cca 100644 --- a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx @@ -1,5 +1,5 @@ // @vitest-environment jsdom -// StatsLine (rendered inside the chat view body): totals derivation + the RFC +// StatsLine (composer.dock entry): totals derivation + the RFC // hard acceptance — zero renders during streaming. Bash sample row: the // canonical sub-agent differential decided INSIDE the component off the // standard useSessions kit (no registry predicates — tool ring dissolved). diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 328ba38340..7254add4ca 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -371,6 +371,28 @@ describe('ChatView', () => { expect(view.queryByLabelText('回到底部')).toBeNull() }) + it('under data-conversation-scroll, bottom-follow targets the host scrollport', () => { + const host = document.createElement('div') + host.setAttribute('data-conversation-scroll', '') + Object.defineProperty(host, 'scrollHeight', { value: 2000, writable: true, configurable: true }) + Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true }) + Object.defineProperty(host, 'scrollTop', { value: 0, writable: true, configurable: true }) + document.body.appendChild(host) + try { + const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] }) + const view = render(, { container: host }) + // Open jump uses the host, not the local .scroll node. + expect(host.scrollTop).toBe(2000) + host.scrollTop = 100 + fireEvent.scroll(host) + expect(view.getByLabelText('回到底部')).toBeTruthy() + fireEvent.click(view.getByLabelText('回到底部')) + expect(host.scrollTop).toBe(2000) + } finally { + host.remove() + } + }) + it('paging button loads older and shows its busy label', () => { const h = makeHarness({ nodes: [user(5, 'later')], hasMore: true }) const view = render() diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 23d853dd1a..08d99ed260 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -227,6 +227,22 @@ describe('running and lock semantics (queue cut 1)', () => { expect((textarea).value).toBe('typed') }) + it('wheel over the textarea scrolls the conversation host, not a nested textarea port', () => { + const host = document.createElement('div') + host.setAttribute('data-conversation-scroll', '') + Object.defineProperty(host, 'scrollTop', { value: 40, writable: true, configurable: true }) + const { view, textarea } = bench() + host.appendChild(view.container) + document.body.appendChild(host) + try { + const wheeled = fireEvent.wheel(textarea, { deltaY: 30 }) + expect(wheeled).toBe(false) // preventDefault + expect(host.scrollTop).toBe(70) + } finally { + host.remove() + } + }) + it('disabled state shows the unavailable placeholder; custom placeholder wins', () => { const { textarea } = bench({ disabled: true }) expect(textarea.placeholder).toBe('Session unavailable') diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 0d32e2edea..8e9377e73f 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -102,6 +102,7 @@ function mount( views={{ list: () => [{ id: 'chat', label: 'Chat' }], subscribe: () => () => {}, version: () => 1 }} bindDraftMirror={write => wiring.bindMirror(write)} open={open} + {...owner} /> ) } @@ -166,6 +167,18 @@ describe('ConversationRoot resident composer', () => { expect(b.open).toHaveBeenCalledWith(sid('root')) }) + it('active phase: fixed header outside the scrollport; sticky composer inside it', () => { + const b = mount(conversationSnapshot()) + const host = b.view.container.querySelector('[data-conversation-scroll]') + const header = b.view.container.querySelector('header') + const textarea = b.view.container.querySelector('textarea') + expect(host).not.toBeNull() + expect(header).not.toBeNull() + // Header is column chrome above the scrollport; composer sticks inside it. + expect(host?.contains(header)).toBe(false) + expect(host?.contains(textarea)).toBe(true) + }) + it('hero phase: same textarea, hero chrome, no header, picker switches the workspace', () => { const b = mount( conversationSnapshot({ composerPhase: 'blank', blank: true }), @@ -174,7 +187,8 @@ describe('ConversationRoot resident composer', () => { { ...workspace('second'), title: 'Selected Folder' }, ], ) - // Hero chrome present, view ring absent. + // Hero chrome present, view ring absent; scroll host is active-phase only. + expect(b.view.container.querySelector('[data-conversation-scroll]')).toBeNull() 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 @@ -193,16 +207,19 @@ describe('ConversationRoot resident composer', () => { expect(b.view.getByText('Selected Folder')).toBeTruthy() }) - it('textarea DOM identity survives the hero → active flip', () => { + it('machine draft survives the hero → active flip into the sticky scrollport composer', () => { 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. + // 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. b.session.set(conversationSnapshot({ composerPhase: 'active', blank: false })) b.rerender() - const after = b.view.getByRole('textbox') - expect(after).toBe(before) - expect((after as HTMLTextAreaElement).value).toBe('kept across flip') + const after = b.view.getByRole('textbox') as HTMLTextAreaElement + 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) expect(b.view.queryByText("Let's start building")).toBeNull() expect(b.view.getByTestId('view-chat')).toBeTruthy() }) diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.module.css b/packages/client/ui-primitives/src/markdown/CodeBlock.module.css index 7222c3df44..4f78a49935 100644 --- a/packages/client/ui-primitives/src/markdown/CodeBlock.module.css +++ b/packages/client/ui-primitives/src/markdown/CodeBlock.module.css @@ -74,6 +74,10 @@ white-space: pre-wrap; word-break: break-all; background: var(--dsw-alias-markdown-code-block); + /* Bottom radii live on
: overflow:hidden on .block would kill the
+     sticky banner, and this opaque fill otherwise squares off the wrapper. */
+  border-bottom-left-radius: var(--dsl-code-block-border-radius);
+  border-bottom-right-radius: var(--dsl-code-block-border-radius);
 }
 
 /* Shiki inlines its theme background var; route it to the repo token. */
diff --git a/packages/client/ui-trajectory/src/client/views.module.css b/packages/client/ui-trajectory/src/client/views.module.css
index 16a853c441..6873315cd7 100644
--- a/packages/client/ui-trajectory/src/client/views.module.css
+++ b/packages/client/ui-trajectory/src/client/views.module.css
@@ -1,5 +1,7 @@
 /* Full-bleed scroll host so Turn sticky bars can paint edge-to-edge;
- * cell content width is capped on the turn body (max 880). */
+ * cell content width is capped on the turn body (max 880). Under the
+ * active conversation column (`[data-conversation-scroll]`) the parent
+ * owns overflow so the sticky composer stays in the same scrollport. */
 .root {
   overflow-y: auto;
   height: 100%;
@@ -10,6 +12,11 @@
   background: var(--dsw-specific-sidebar-fill);
 }
 
+:global([data-conversation-scroll]) .root {
+  overflow: visible;
+  height: auto;
+}
+
 .empty {
   padding: 16px;
   color: var(--dsw-alias-label-tertiary);