Merge latest master into codex/migrate-to-oxlint

This commit is contained in:
Tianyi Cui
2026-07-29 22:43:10 +08:00
23 changed files with 534 additions and 117 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: 275c8097ebbc2a0fd1356c77e027064d3454f8d6
README.zh.md: 13f54215d21f72b17bf08313d67e5f3a95cd1c33
README.md: 8a8002ef75299579d7df1eb562a7e8bfaf2aeb2a
README.zh.md: 27e6ab94d4d1cee7fcb7943e0f07c4de2a2f7503

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 chains: the capped draft scrolls locally until its edge, then forwards to that host.
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 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。
视图环本身就是 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

@@ -17,6 +17,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'
@@ -238,6 +239,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). */
@@ -244,26 +253,34 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
const firstSeqRef = useRef<number | null>(null)
const openedRef = useRef(false)
const lastKeyRef = useRef<string | null>(null)
/** Flow tip signature — follow-scroll only when this moves, never on a
* scroll-driven at-bottom chrome re-render (that was snapping inertial
* scrolls the rest of the way to the floor). */
const followSigRef = useRef<string | null>(null)
const firstSeq = nodes[0]?.seq ?? null
const lastItem = items[items.length - 1]
const lastKey = lastItem?.key ?? null
const followSig = `${openState}:${firstSeq}:${lastKey}:${nodes.length}:${running ? 1 : 0}:${runningCalls.length}`
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
toBottom(el)
firstSeqRef.current = firstSeq
lastKeyRef.current = lastItem?.key ?? null
lastKeyRef.current = lastKey
followSigRef.current = followSig
return
}
// Prepend (head seq decreased): compensate by the height delta.
@@ -272,42 +289,65 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
anchorRef.current = null
firstSeqRef.current = firstSeq
/* v8 ignore next -- ?? arm: a prepend adds nodes, so the flow list here is never empty. */
lastKeyRef.current = lastItem?.key ?? null
lastKeyRef.current = lastKey
followSigRef.current = followSig
return
}
firstSeqRef.current = firstSeq
// Own words must be visible: a new trailing user node force-scrolls
// (send lives in the composer, so arrival is detected here, not armed there).
const lastKey = lastItem?.key ?? null
const appendedUser = lastKey !== lastKeyRef.current
&& lastItem !== undefined && lastItem.kind === 'node' && lastItem.node.kind === 'user'
const tipMoved = followSigRef.current !== followSig
lastKeyRef.current = lastKey
if (appendedUser || atBottomRef.current) toBottom(el)
followSigRef.current = followSig
// Follow new flow content while pinned; do NOT re-pin on every render
// merely because atBottomRef is true (scroll threshold → setState → snap).
if (appendedUser || (tipMoved && 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 +390,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 +428,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,18 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
/** Owner share of the strict session content seat. */
export interface ConversationSessionOwnerProps {
/**
* Wrap the view ring in the transcript scrollport that also hosts the
* sticky composer seat (whole `'conversation.composer'` chain output).
* 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 seat 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 seat.
*/
wrapActiveBody?: (view: ReactNode) => ReactNode
}
/**

View File

@@ -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;
@@ -127,6 +133,46 @@
flex-direction: column;
}
/* Common seat for the composer chain (fallback + elected overlay siblings). */
.composerSeat {
display: flex;
flex: none;
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 seat
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'] .composerSeat {
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;
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
@@ -165,12 +211,15 @@
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
/* Settling (session replaying, hero/docked unknown): keep the composer seat
mounted but invisible so no wrong layout flashes before the phase lands. */
.root[data-phase='settling'] .composerStack {
.root[data-phase='settling'] .composerSeat {
visibility: hidden;
}

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,53 @@ 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 },
)
// Sticky wraps the whole chain output (fallback + elected overlay), not
// only `.composerStack`: overlay:true renders those as siblings, and sticky
// on the fallback alone would leave Question/Approval panels at the content
// end off-screen when the user is not pinned to the floor.
const composerSeat = (
<div className={css.composerSeat} data-composer-seat="">
{composer}
</div>
)
// Header stays column chrome above this scrollport; the sticky composer
// seat lives inside it with the transcript. Always wrap while a session
// exists (hero/settling/active) so the composer keeps one tree seat across
// the blank → active flip — relocating it only in active remounted the textarea.
const wrapActiveBody = (view: ReactNode): ReactNode => (
<div className={css.scrollBody} data-conversation-scroll="">
{view}
{composerSeat}
</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 },
keeps a chrome-hidden shell while blank and owns the draft-
persistence mirror bind — unmounting it in the hero would lose
pre-first-send text on a refresh or scope rebuild. */}
{sessionId !== undefined && renderSlot(
'conversation.session',
{ wrapActiveBody },
)}
{sessionId === undefined ? composerSeat : 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()
@@ -44,52 +44,67 @@ export function ConversationSession({
// the machine mirror, not this seed effect.
}, [inputActions])
if (blank && composerPhase === 'blank') return null
// Blank hero/settling: keep the same header + body tree shape so a
// wrapActiveBody-hosted composer keeps its DOM identity across the first
// send (hero → active). Chrome is hidden; the draft-persistence mirror
// still runs because this component stays mounted.
const hideChrome = blank && composerPhase === 'blank'
const view: ReactNode = hideChrome ? null : (
<div className={css.viewArea}>
{active !== undefined && renderSlot('conversation.view', {}, { only: active.id })}
</div>
)
return (
<>
<header className={css.header}>
<div className={css.crumbRow}>
<nav className={css.crumbs} aria-label="Session hierarchy">
{ancestry.map((summary, index) => {
const last = index === ancestry.length - 1
return (
<span key={summary.id} className={css.crumbSeg}>
{index > 0 && <span className={css.crumbSep}>/</span>}
<header
className={clsx(css.header, hideChrome && css.headerHidden)}
aria-hidden={hideChrome || undefined}
>
{!hideChrome && (
<>
<div className={css.crumbRow}>
<nav className={css.crumbs} aria-label="Session hierarchy">
{ancestry.map((summary, index) => {
const last = index === ancestry.length - 1
return (
<span key={summary.id} className={css.crumbSeg}>
{index > 0 && <span className={css.crumbSep}>/</span>}
<button
type="button"
className={clsx(css.crumb, last && css.crumbCurrent)}
disabled={last}
onClick={() => { open(summary.id) }}
>
{summary.displayTitle}
</button>
</span>
)
})}
{ancestry.length === 0 && <span className={css.crumbCurrent}>{sessionId}</span>}
</nav>
</div>
{tabs.length > 1 && (
<div className={css.tabs} role="tablist">
{tabs.map(viewTab => (
<button
key={viewTab.id}
type="button"
className={clsx(css.crumb, last && css.crumbCurrent)}
disabled={last}
onClick={() => { open(summary.id) }}
role="tab"
aria-selected={viewTab.id === active?.id}
className={clsx(css.tab, viewTab.id === active?.id && css.tabActive)}
onClick={() => { actions.setView(viewTab.id) }}
>
{summary.displayTitle}
{viewTab.label}
</button>
</span>
)
})}
{ancestry.length === 0 && <span className={css.crumbCurrent}>{sessionId}</span>}
</nav>
</div>
{tabs.length > 1 && (
<div className={css.tabs} role="tablist">
{tabs.map(view => (
<button
key={view.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) }}
>
{view.label}
</button>
))}
</div>
))}
</div>
)}
</>
)}
</header>
<div className={css.viewArea}>
{active !== undefined && renderSlot('conversation.view', {}, { only: active.id })}
</div>
{wrapActiveBody !== undefined ? wrapActiveBody(view) : view}
</>
)
}

View File

@@ -115,9 +115,9 @@ export function HeroShell({ children }: HeroShellProps) {
Let&apos;s start building
</div>
<div className={css.body}>
{/* The resident composer (rendered by ConversationRoot at its stable
tree position; the workspace row rides its accessory hole) is
CSS-positioned into this gap during the hero phase — see
{/* The resident composer (ConversationRoot wrapActiveBody seat; the
workspace row rides the stack above the card) is CSS-centered in
the session scroll body during hero — see
ConversationRoot.module.css [data-phase='hero']. */}
</div>
</div>

View File

@@ -79,6 +79,27 @@ export function InputBar({
if (!locked) inputRef.current?.focus()
}, [locked])
// Active conversation scrollport: chain the wheel. While the textarea (capped
// at 14 lines with overflow-y:auto) can still move in this direction, keep
// the native scroll; only at its own edge forward delta to the host so a
// short draft never traps the gesture and a long draft stays scrollable.
// Hero mounts have no host and keep 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) || e.deltaY === 0) return
const atTop = el.scrollTop <= 0
const atEnd = el.scrollTop + el.clientHeight >= el.scrollHeight - 1
if ((e.deltaY < 0 && !atTop) || (e.deltaY > 0 && !atEnd)) 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

@@ -86,6 +86,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,42 @@ describe('ChatView', () => {
expect(view.queryByLabelText('回到底部')).toBeNull()
})
it('entering the at-bottom threshold does not snap the remaining scroll distance', () => {
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
const view = render(<h.ChatView {...h.props} />)
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true })
Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
// Inside FOLLOW_THRESHOLD (24) but not flush with the floor — the chrome
// re-render from setAtBottom must not force scrollTop to scrollHeight.
scroller.scrollTop = 690 // distance-to-bottom = 10
fireEvent.scroll(scroller)
expect(view.queryByLabelText('回到底部')).toBeNull()
expect(scroller.scrollTop).toBe(690)
})
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

@@ -233,6 +233,56 @@ describe('running and lock semantics (queue cut 1)', () => {
expect((textarea).value).toBe('typed')
})
it('wheel over a non-overflowing textarea forwards to the conversation host', () => {
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('wheel chains: long drafts scroll inside the textarea until each edge, then the host', () => {
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)
Object.defineProperty(textarea, 'clientHeight', { value: 100, configurable: true })
Object.defineProperty(textarea, 'scrollHeight', { value: 400, configurable: true })
let scrollTop = 150
Object.defineProperty(textarea, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value: number) => { scrollTop = value },
})
try {
// Mid-draft: both directions stay local — host must not move.
expect(fireEvent.wheel(textarea, { deltaY: 30 })).toBe(true)
expect(fireEvent.wheel(textarea, { deltaY: -30 })).toBe(true)
expect(host.scrollTop).toBe(40)
// At the bottom edge, further down-scroll forwards to the host.
scrollTop = 300
expect(fireEvent.wheel(textarea, { deltaY: 30 })).toBe(false)
expect(host.scrollTop).toBe(70)
// At the top edge, further up-scroll forwards to the host.
scrollTop = 0
host.scrollTop = 70
expect(fireEvent.wheel(textarea, { deltaY: -20 })).toBe(false)
expect(host.scrollTop).toBe(50)
} 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

@@ -61,6 +61,8 @@ function mount(
snapshot: ConversationSnapshot,
workspaceRows: WorkspaceView[] = [{ ...workspace('one'), sessionIds: [SID] }],
retargetWorkspace = vi.fn(async (_workspaceId: WorkspaceId) => {}),
/** When true, mimic overlay:true chain siblings (hidden fallback + takeover). */
overlayTakeover = false,
) {
const root = sid('root')
const sessions = createSnapshotStore<SessionListState>({
@@ -111,6 +113,7 @@ function mount(
}}
bindDraftMirror={write => wiring.bindMirror(write)}
open={open}
{...owner}
/>
)
}
@@ -141,7 +144,18 @@ function mount(
}
return <div data-testid={`view-${opts?.only ?? key}`} />
}) as ConversationRootProps['renderSlot']
const renderSlotChain = ((_key, _owner, opts) => opts?.fallback ?? null) as ConversationRootProps['renderSlotChain']
const renderSlotChain = ((_key, _owner, opts) => (
overlayTakeover
? (
<>
<div data-chain-overlay-fallback="conversation.composer" style={{ display: 'none' }}>
{opts?.fallback ?? null}
</div>
<div data-testid="composer-takeover">TAKEOVER</div>
</>
)
: (opts?.fallback ?? null)
)) as ConversationRootProps['renderSlotChain']
const props: ConversationRootProps = {
sessionId: SID,
SessionProvider: ({ children }) => children(SID),
@@ -176,6 +190,30 @@ describe('ConversationRoot resident composer', () => {
expect(b.open).toHaveBeenCalledWith(sid('root'))
})
it('active phase: fixed header outside the scrollport; sticky composer seat inside it', () => {
const b = mount(conversationSnapshot())
const host = b.view.container.querySelector('[data-conversation-scroll]')
const seat = b.view.container.querySelector('[data-composer-seat]')
const header = b.view.container.querySelector('header')
const textarea = b.view.container.querySelector('textarea')
expect(host).not.toBeNull()
expect(seat).not.toBeNull()
expect(header).not.toBeNull()
// Header is column chrome above the scrollport; the seat sticks inside it.
expect(host?.contains(header)).toBe(false)
expect(host?.contains(seat)).toBe(true)
expect(seat?.contains(textarea)).toBe(true)
})
it('sticky composer seat wraps the whole overlay chain, not only the fallback stack', () => {
const b = mount(conversationSnapshot(), undefined, undefined, true)
const seat = b.view.container.querySelector('[data-composer-seat]')
const takeover = b.view.getByTestId('composer-takeover')
const fallback = b.view.container.querySelector('[data-chain-overlay-fallback="conversation.composer"]')
expect(seat?.contains(takeover)).toBe(true)
expect(seat?.contains(fallback)).toBe(true)
})
it('hero phase: same textarea, hero chrome, no header, picker switches the workspace', () => {
const b = mount(
conversationSnapshot({ composerPhase: 'blank', blank: true }),
@@ -184,13 +222,19 @@ describe('ConversationRoot resident composer', () => {
{ ...workspace('second'), title: 'Selected Folder' },
],
)
// Hero chrome present, view ring absent.
// Hero chrome present, view ring absent; scroll host already wraps the
// resident composer so the blank → active flip does not remount it.
const host = b.view.container.querySelector('[data-conversation-scroll]')
const header = b.view.container.querySelector('header')
expect(host).not.toBeNull()
expect(header?.getAttribute('aria-hidden')).toBe('true')
expect(b.view.getByText("Let's start building")).toBeTruthy()
expect(b.view.queryByTestId('view-chat')).toBeNull()
// The same machine-backed textarea is live in the hero, and the
// persistence mirror stays bound (ConversationSession mounts chrome-less
// persistence mirror stays bound (ConversationSession mounts chrome-hidden
// for blank sessions): hero typing reaches the chat store.
const box = b.view.getByRole('textbox')
expect(host?.contains(box)).toBe(true)
fireEvent.change(box, { target: { value: 'draft in hero' } })
expect(b.chat.store.getSnapshot().draft).toBe('draft in hero')
// Picker: open through the chip; a pick switches to the other
@@ -203,16 +247,20 @@ describe('ConversationRoot resident composer', () => {
expect(b.view.getByText('Selected Folder')).toBeTruthy()
})
it('textarea DOM identity survives the hero → active flip', () => {
it('same textarea DOM node survives the hero → active flip into the sticky scrollport', () => {
const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true }))
const before = b.view.getByRole('textbox')
fireEvent.change(before, { target: { value: 'kept across flip' } })
// First message landed: content exists, phase leaves blank.
// First message landed: content exists, phase leaves blank. Composer
// already sat in the Session scrollport during hero, so the textarea
// node and InputHub draft both survive.
b.session.set(conversationSnapshot({ composerPhase: 'active', blank: false }))
b.rerender()
const after = b.view.getByRole('textbox')
const after = b.view.getByRole('textbox') as HTMLTextAreaElement
expect(after).toBe(before)
expect((after as HTMLTextAreaElement).value).toBe('kept across flip')
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

@@ -13,6 +13,13 @@
background: var(--dsw-alias-bg-layer-1);
}
/* Under the active conversation column (`[data-conversation-scroll]`) the
* parent owns overflow so the sticky composer stays in the same scrollport. */
:global([data-conversation-scroll]) .root {
overflow: visible;
height: auto;
}
.ledger {
display: flex;
flex: 1;