feat: optimize chat page scroll area

This commit is contained in:
07akioni
2026-07-29 20:25:22 +08:00
parent a0534b7b8a
commit a4602b959e
22 changed files with 358 additions and 76 deletions

View File

@@ -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

View File

@@ -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: <active id>`), 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.

View File

@@ -2,9 +2,9 @@
[English](README.md) | 中文
会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、统计行、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、输入区 dock队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约api-contracts v3 §7 加 slot 终端设计store seatprops share
会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、编辑器 dock与输入区一同 sticky 的会话统计行)、输入区 dock队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约api-contracts v3 §7 加 slot 终端设计store seatprops 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'` 列表 slotSession scope并将其列在 `children` 表中ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: <active id>`);视图标签页从环账本的注册选项(`id``order``label`投影而来。聊天视图是该包自身的环配置项其他插件ui-trajectory通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView``ViewEntry``ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。

View File

@@ -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

View File

@@ -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 {

View File

@@ -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 (
<div className={css.root}>
<div ref={listRef} className={css.scroll} onScroll={onScroll}>
<div ref={listRef} className={css.scroll}>
<div className={css.column}>
{openState === 'loading' && <div className={css.hint}></div>}
{openState === 'error' && <div className={css.openError}>{openErrorMessage}</div>}
@@ -388,22 +417,23 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
wait, tool execution, streaming) so it never flickers per step. */}
{running && <TurnDots />}
</div>
{!atBottom && (
<div className={css.toBottomSlot}>
<button
type="button"
className={css.toBottom}
aria-label="回到底部"
onClick={() => {
const local = listRef.current
/* v8 ignore next -- ref-null guard: the button only renders alongside the mounted list. */
if (local !== null) toBottom(scrollerOf(local))
}}
>
<IconChevronDownOutline14 />
</button>
</div>
)}
</div>
<StatsLine useSession={useSession} />
{!atBottom && (
<button
type="button"
className={css.toBottom}
aria-label="回到底部"
onClick={() => {
const el = listRef.current
/* v8 ignore next -- ref-null guard: the button only renders alongside the mounted list. */
if (el !== null) toBottom(el)
}}
>
<IconChevronDownOutline14 />
</button>
)}
</div>
)
}

View File

@@ -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<ConversationSnapshot> }
export const StatsLine = memo(function StatsLine({ useSession }: StatsLineProps) {

View File

@@ -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
}
/**

View File

@@ -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

View File

@@ -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 && <HeroGlow className={css.heroGlow} />}
{hero && <HeroShell />}
{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}
</div>
)
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 => (
<div className={css.scrollBody} data-conversation-scroll="">
{view}
{composer}
</div>
)
return (
<div className={css.root} data-phase={settling ? 'settling' : hero ? 'hero' : 'active'}>
<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. */}
{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}
</div>
)
}

View File

@@ -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 = (
<div className={css.viewArea}>
{active !== undefined && renderSlot('conversation.view', {}, { only: active.id })}
</div>
)
return (
<>
<header className={css.header}>
@@ -72,24 +78,22 @@ export function ConversationSession({
</div>
{tabs.length > 1 && (
<div className={css.tabs} role="tablist">
{tabs.map(view => (
{tabs.map(viewTab => (
<button
key={view.id}
key={viewTab.id}
type="button"
role="tab"
aria-selected={view.id === active?.id}
className={clsx(css.tab, view.id === active?.id && css.tabActive)}
onClick={() => { actions.setView(view.id) }}
aria-selected={viewTab.id === active?.id}
className={clsx(css.tab, viewTab.id === active?.id && css.tabActive)}
onClick={() => { actions.setView(viewTab.id) }}
>
{view.label}
{viewTab.label}
</button>
))}
</div>
)}
</header>
<div className={css.viewArea}>
{active !== undefined && renderSlot('conversation.view', {}, { only: active.id })}
</div>
{wrapActiveBody !== undefined ? wrapActiveBody(view) : view}
</>
)
}

View File

@@ -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<HTMLTextAreaElement>): void => {
// Shift+Enter is the native newline UNCONDITIONALLY — decided before the
// IME guard so a composition-closing Shift+Enter still breaks the line.

View File

@@ -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()
})

View File

@@ -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).

View File

@@ -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(<h.ChatView {...h.props} />, { 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(<h.ChatView {...h.props} />)

View File

@@ -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')

View File

@@ -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()
})

View File

@@ -74,6 +74,10 @@
white-space: pre-wrap;
word-break: break-all;
background: var(--dsw-alias-markdown-code-block);
/* Bottom radii live on <pre>: 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. */

View File

@@ -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);