From d38ff541504ae1bac53bf12dfa260e83b52076ef Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 25 Aug 2026 14:02:57 +0800 Subject: [PATCH 1/4] feat(web): navigate loaded Chat Turns from a compact rail ChatView derives one navigation mark per currently loaded Turn, keyed by Turn number and anchored on that Turn's first loaded user node. The rail sits against the scrollport's right edge, centered in the band the sticky composer leaves visible; hover and keyboard focus preview the Turn's prompt and response, and activating a mark moves the shared scrollport and records the resulting restoration anchor. ConversationRoot publishes --dsh-conversation-viewport-height beside the composer height it already measures on the scrollport, so floating View chrome can center in that band without assuming a Session header height. --- ...8-25-loaded-turn-chat-navigation.i18n.yaml | 6 + .../2026-08-25-loaded-turn-chat-navigation.md | 41 ++++ ...26-08-25-loaded-turn-chat-navigation.zh.md | 41 ++++ apps/web/tests/chat-long-interactions.e2e.ts | 44 ++++- packages/client/ui-chat/README.i18n.yaml | 4 +- packages/client/ui-chat/README.md | 2 +- packages/client/ui-chat/README.zh.md | 2 +- .../ui-chat/src/client/chat/ChatNodeSeat.tsx | 5 + .../ui-chat/src/client/chat/ChatView.tsx | 81 ++++++++ .../src/client/chat/TurnNavigator.module.css | 176 ++++++++++++++++++ .../ui-chat/src/client/chat/TurnNavigator.tsx | 118 ++++++++++++ .../src/client/chat/turn-navigation.ts | 49 +++++ packages/client/ui-chat/src/client/locale.ts | 6 + .../ui-chat/tests/chat-view.client.spec.tsx | 69 +++++++ .../src/client/skeleton/ConversationRoot.tsx | 16 +- .../goal-multi-turn-actions/ui.expected.md | 3 + 16 files changed, 654 insertions(+), 9 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.md create mode 100644 .agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.zh.md create mode 100644 packages/client/ui-chat/src/client/chat/TurnNavigator.module.css create mode 100644 packages/client/ui-chat/src/client/chat/TurnNavigator.tsx create mode 100644 packages/client/ui-chat/src/client/chat/turn-navigation.ts diff --git a/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.i18n.yaml b/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.i18n.yaml new file mode 100644 index 0000000000..bcc02e9b68 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.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/feature/2026-08-25-loaded-turn-chat-navigation.md +2026-08-25-loaded-turn-chat-navigation.md: 92e92323115c5da51ced09092d9ecd4e376f874f +2026-08-25-loaded-turn-chat-navigation.zh.md: b0cba5e78fad8b2ff3559d6cbfc12fb2160a2dc8 diff --git a/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.md b/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.md new file mode 100644 index 0000000000..92e9232311 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.md @@ -0,0 +1,41 @@ +# Agent Note: Loaded-Turn chat navigation + +Status: implemented + +English | [中文](2026-08-25-loaded-turn-chat-navigation.zh.md) + +## Problem + +Long Chat transcripts require repeated scrolling to revisit an earlier Turn. Session history is paged, so the browser may hold only a suffix of the conversation and the first loaded Turn may begin after its user message. A navigator that implies knowledge of unloaded Turns, or keys marks by their current array position, becomes misleading or unstable when Session Controller prepends the preceding event page. + +## Decision + +ChatView derives one navigation item for every currently loaded Turn that has a visible transcript node. Each item uses the Turn number as its stable React key and the first loaded user node, falling back to the Turn's first loaded node, as its scroll anchor. This is a pure projection of the Chat snapshot: the feature adds no Session event, persisted index, or pagination request. + +The rail renders the complete loaded Turn set with a 10px natural interval and never renders an ellipsis or unloaded-history placeholder. Its height shrink-wraps small sets; when the loaded set exceeds the available height, percentage positions compress every mark into the capped rail. When an earlier page arrives, existing Turn keys and DOM elements remain stable while their resolved positions change; CSS transitions animate that redistribution. A Turn split by the page boundary initially previews its Turn number and loaded assistant response, then gains the user prompt when the preceding page supplies it. + +The rail sits against the scrollport's right edge and centers on the band the sticky composer leaves visible. That band is the scrollport's own height minus the seat's, so ConversationRoot publishes `--dsh-conversation-viewport-height` beside the `--dsh-composer-height` it already measures on the same element, and the rail centers on their difference instead of a viewport height that ignores the Session header. + +The active mark follows a reading line near the top of the shared Chat scrollport. Scroll updates are coalesced with `requestAnimationFrame`; reaching the bottom selects the final loaded Turn. Activating a mark computes the target node's position in the existing scroll coordinate system, moves that same scrollport, and records the resulting Chat scroll-restoration anchor. + +Every Turn remains an accessible button even when dense marks visually overlap. The rail maps pointer height to the nearest loaded Turn, while keyboard focus and activation operate the individual buttons. Hover and focus show a compact prompt-and-response preview, the active mark is longer and darker, the rail is hidden when the Chat container is at most 900px wide, and reduced-motion preferences disable redistribution and mark-entry animation. + +## Alternatives considered + +**Persist a complete Turn index separately from the loaded Session page.** Rejected: the current client cannot navigate to an unloaded transcript anchor without first materializing that history, and a second index would duplicate Session projection state. + +**Show an ellipsis for unloaded history.** Rejected: pagination exposes only `hasMore`, not the number or distribution of earlier Turns, so an ellipsis would add no actionable destination. Loading a page and redistributing the actual loaded set communicates the available navigation precisely. + +**Always spread marks across the available height.** Rejected: a small loaded set produces visually unrelated marks separated by large empty regions. A fixed natural interval preserves a compact index while percentage compression still admits dense histories. + +**Key marks by loaded-array position.** Rejected: prepending a page would reuse each DOM element for a different Turn, lose focus and preview identity, and prevent the existing marks from animating to their new positions. + +**Call `scrollIntoView` on the Turn row.** Rejected: Chat owns a shared scroller, bottom-follow state, paging anchors, and persisted restoration coordinates. An opaque browser scroll would bypass those state updates. + +## Consequences + +Desktop-width Chat views can jump among all currently loaded Turns and inspect a short preview without expanding transcript content. Pagination prepends new destinations without presenting fabricated coverage or remounting existing marks. The first loaded mark can temporarily lack a prompt when the page boundary cuts through its Turn; its Turn label remains usable until the earlier page fills that data. If a future transcript virtualizer unmounts loaded anchors, navigation will need an explicit materialization operation before scrolling rather than changing this loaded-Turn projection. + +## Testing + +Component tests pin Turn derivation, accessible previews, scroll-coordinate jumps, DOM identity, and percentage redistribution after prepend. The long-interaction Chromium scenario pins the real paginated boundary, prompt completion after `Load earlier`, stable-mark movement, keyboard activation, and active-state update. The multi-Turn recorded Web snapshot includes the navigation landmark and buttons. diff --git a/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.zh.md b/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.zh.md new file mode 100644 index 0000000000..b0cba5e78f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.zh.md @@ -0,0 +1,41 @@ +# Agent Note:已加载 Turn 的聊天导航 + +Status: implemented + +[English](2026-08-25-loaded-turn-chat-navigation.md) | 中文 + +## 问题 + +较长的 Chat transcript 需要反复滚动才能回看更早的 Turn。Session 历史采用分页加载,因此浏览器可能只持有会话后缀,首个已加载 Turn 也可能从用户消息之后开始。如果导航暗示自己知道未加载的 Turn,或者按当前数组位置给刻度设置 key,Session Controller 前插上一页 event 后,导航就会产生误导或变得不稳定。 + +## 决定 + +ChatView 为当前已加载且含可见 transcript node 的每个 Turn 派生一项导航。每项使用 Turn 编号作为稳定的 React key,并以首个已加载用户 node 为滚动锚点;没有用户 node 时回退到该 Turn 的首个已加载 node。这只是 Chat snapshot 的纯投影:本功能不新增 Session event、持久化索引或分页请求。 + +导航轨道以 10px 自然间距渲染完整的已加载 Turn 集合,永不显示省略号或未加载历史占位。集合较小时轨道随内容收缩;已加载集合超过可用高度后,百分比位置会把所有刻度压缩到设定上限内。更早一页到达后,已有 Turn 的 key 和 DOM 元素保持不变,最终位置随之变化;CSS transition 为这次重排添加动画。如果一个 Turn 被分页边界截断,预览最初显示其 Turn 编号与已加载的助手回复,上一页补齐后再显示用户问题。 + +轨道紧贴滚动视口右缘,并在粘性输入区之外的可见区间内垂直居中。该区间等于滚动视口自身高度减去输入区高度,因此 ConversationRoot 在同一元素上除已有的 `--dsh-composer-height` 外再发布 `--dsh-conversation-viewport-height`,轨道按两者之差居中,而不是按忽略 Session 头部的视口高度居中。 + +活跃刻度跟随共享 Chat 滚动区顶部附近的阅读线。滚动更新由 `requestAnimationFrame` 合并;到达底部时选择最后一个已加载 Turn。激活刻度会在现有滚动坐标系中计算目标 node 的位置,移动同一个滚动区,并记录由此产生的 Chat 滚动恢复锚点。 + +即使密集刻度在视觉上重叠,每个 Turn 仍是可访问的按钮。轨道把指针高度映射到最近的已加载 Turn,键盘聚焦和激活则作用于各个按钮。悬停或聚焦显示紧凑的问题与回复预览,活跃刻度更长、更深;Chat 容器宽度不超过 900px 时隐藏轨道,用户偏好减少动态效果时关闭重排和刻度入场动画。 + +## 曾考虑的替代方案 + +**在已加载 Session 页之外持久化完整 Turn 索引。**否决:当前客户端必须先物化历史记录,才能导航到未加载的 transcript 锚点;第二套索引还会重复 Session 投影状态。 + +**为未加载历史显示省略号。**否决:分页只暴露 `hasMore`,不提供更早 Turn 的数量或分布,因此省略号不是可操作的目的地。加载一页并重排真实的已加载集合,才能精确表达当前可导航范围。 + +**始终把刻度铺满可用高度。**否决:已加载集合较小时,各刻度会被大片空白隔开,在视觉上失去关联。固定自然间距保持紧凑索引,百分比压缩仍能容纳密集历史。 + +**按已加载数组位置给刻度设置 key。**否决:前插一页会让每个 DOM 元素改为代表另一个 Turn,丢失焦点与预览身份,也无法让已有刻度移动到新位置。 + +**对 Turn 行调用 `scrollIntoView`。**否决:Chat 拥有共享滚动区、底部跟随状态、分页锚点与持久化恢复坐标。浏览器的黑盒滚动会绕过这些状态更新。 + +## 后果 + +桌面宽度的 Chat 视图可以在当前所有已加载 Turn 之间跳转,并在不展开 transcript 内容的情况下查看短预览。分页会前插新目的地,但不会虚构覆盖范围或重新挂载已有刻度。分页边界截断 Turn 时,首个已加载刻度可能暂时缺少问题;在上一页补齐数据前,Turn 标签仍可用于导航。如果未来 transcript 虚拟列表会卸载已加载锚点,导航需要在滚动前执行显式物化操作,而不应改变这里的已加载 Turn 投影。 + +## 测试 + +组件测试固定 Turn 派生、可访问预览、滚动坐标跳转、DOM 身份以及前插后的百分比重排。长交互 Chromium 场景固定真实分页边界、`加载更早` 后补齐问题、稳定刻度移动、键盘激活与活跃状态更新。多 Turn 的 Web 录制快照包含导航 landmark 和按钮。 diff --git a/apps/web/tests/chat-long-interactions.e2e.ts b/apps/web/tests/chat-long-interactions.e2e.ts index 03198a23a8..4525cf8390 100644 --- a/apps/web/tests/chat-long-interactions.e2e.ts +++ b/apps/web/tests/chat-long-interactions.e2e.ts @@ -153,7 +153,7 @@ describe('web e2e: long Chat interaction contract', () => { }) await seedSession(scaffold, FIXTURE.log, SESSION_ID) browser = await chromium.launch() - page = await newEnglishPage(browser, 900) + page = await newEnglishPage(browser, 1_280) tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) @@ -193,6 +193,48 @@ describe('web e2e: long Chat interaction contract', () => { if (boundary === undefined) throw new Error(`turn ${String(BRANCH_TURN)} has no turn/end event`) const expectedUserText = textContent(branchUserEvent.data.content) + const turnNavigation = page.getByRole('navigation', { name: 'Turn navigation' }) + await turnNavigation.waitFor({ state: 'visible', timeout: 15_000 }) + const initialTurnButtons = turnNavigation.getByRole('button') + const initialTurnCount = await initialTurnButtons.count() + expect(initialTurnCount).toBeGreaterThan(1) + expect(await initialTurnButtons.last().getAttribute('aria-current')).toBe('true') + const firstTurnButton = initialTurnButtons.first() + const firstTurnLabel = await firstTurnButton.getAttribute('aria-label') + if (firstTurnLabel === null) throw new Error('first Turn navigation mark has no accessible label') + const firstTurn = Number(firstTurnLabel.match(/^Jump to turn (\d+)$/)?.[1]) + expect(Number.isSafeInteger(firstTurn)).toBe(true) + await firstTurnButton.focus() + const preview = page.getByRole('tooltip') + await preview.waitFor({ state: 'visible', timeout: 5_000 }) + // The first loaded Turn may begin mid-Turn at a page boundary. Its mark is + // still useful with the loaded response and gains the prompt after prepend. + expect(await preview.textContent()).toContain(`Turn ${String(firstTurn)}`) + expect(await preview.textContent()).toContain(FIXTURE.markers.assistant(firstTurn)) + const firstTurnPosition = await firstTurnButton.evaluate(button => ( + button.parentElement?.style.getPropertyValue('--turn-position') ?? '' + )) + expect(firstTurnPosition).toBe('0%') + + const loadEarlier = page.getByRole('button', { name: 'Load earlier', exact: true }) + await loadEarlier.click() + await expect.poll(() => turnNavigation.getByRole('button').count(), { timeout: 15_000 }) + .toBeGreaterThan(initialTurnCount) + const stableFirstTurnButton = turnNavigation.getByRole('button', { name: firstTurnLabel }) + expect(await stableFirstTurnButton.evaluate(button => ( + button.parentElement?.style.getPropertyValue('--turn-position') ?? '' + ))).not.toBe(firstTurnPosition) + await stableFirstTurnButton.focus() + await expect.poll(() => preview.textContent(), { timeout: 5_000 }) + .toContain(FIXTURE.markers.user(firstTurn)) + expect(await preview.textContent()).toContain(FIXTURE.markers.assistant(firstTurn)) + await stableFirstTurnButton.press('Enter') + await expect.poll(() => stableFirstTurnButton.getAttribute('aria-current'), { timeout: 5_000 }).toBe('true') + await expect.poll( + () => page.locator(`[data-chat-turn="${String(firstTurn)}"][data-chat-flow-kind="user"]`).count(), + { timeout: 5_000 }, + ).toBe(1) + await wheelUntilMounted(page, `[data-chat-call-id="${TARGET_CALL_2}"]`, -1_100) const toolUserKey = messageKey(toolUserEvent) const toolAssistantKey = assistantKey(toolAssistantEvent) diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 4859153e08..c63e44730a 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/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-chat/README.md -README.md: 5253cb95b0e5c0b89c32646e2ae2915936d35288 -README.zh.md: 8cd2d0d581a0493892aed23f42ebc0c229a0bc17 +README.md: eca746a7598d57325a0972b25b684e7fbc7c1632 +README.zh.md: df690ec2d03c27edbdf5802bbe02380960527d4c diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 5253cb95b0..eca746a759 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -14,4 +14,4 @@ None; Chat presentation does not assemble or mutate provider requests. ## Known Limitations and Deferred Work -- **The view reflects the loaded Session window** — older transcript nodes become available only after Session Controller loads the preceding event page. +- **The view reflects the loaded Session window** — older transcript nodes become available only after Session Controller loads the preceding event page. Turn navigation likewise represents only loaded Turns; loading an earlier page preserves existing Turn marks and redistributes the complete loaded set in a compact rail without an unloaded-history placeholder. Marks stay 10px apart until the loaded set exceeds the available height, then compress to fit. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 8cd2d0d581..df690ec2d0 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -14,4 +14,4 @@ Conversation 组装的浏览器 Chat target。本包注册 Chat event definition ## 已知限制与暂缓事项 -- **视图只反映已加载的 Session 窗口**——只有 Session Controller 加载前一页 event 后,更早的 transcript node 才会出现。 +- **视图只反映已加载的 Session 窗口**——只有 Session Controller 加载前一页 event 后,更早的 transcript node 才会出现。轮次导航同样只表示已加载的 Turn;加载更早一页时,已有 Turn 刻度保持身份不变,完整的已加载集合在紧凑轨道中重新排布,不显示未加载历史占位。刻度默认相隔 10px,仅在已加载集合超过可用高度时压缩间距。 diff --git a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx index 22a6d0dc35..7568f767ff 100644 --- a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx @@ -36,6 +36,10 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ node, selectedCallId, cwd, openFile, inspectCall, forkAt, renderMessageImages, fileMentions, ]) if (routedNode === undefined || owner === null) return null + const location = routedNode.location + const turn = location.kind === 'turn' || location.kind === 'step' + ? location.turn.turn + : undefined // Runtime dispatch owns the correlation: every Node's discriminant is the // keyed-slot entry passed alongside that same Node. TypeScript does not // distribute an object containing a union into a union of objects itself. @@ -46,6 +50,7 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ data-chat-anchor-key={routedNode.key} data-chat-flow-key={routedNode.key} data-chat-flow-kind={routedNode.kind} + data-chat-turn={turn} > {renderSlot('conversation.chat.node', routedOwner, { entryKey: routedNode.kind, diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index a647ad5c18..a82841bf53 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -9,6 +9,8 @@ import { Button, IconChevronDownOutline14, Modal } from '@deepseek-ai/dsh-client import type { ChatViewSlotProps } from '../contract/slots.ts' import { PendingSteeringBubble } from './MessageItem.tsx' import { ChatNodeSeat } from './ChatNodeSeat.tsx' +import { TurnNavigator } from './TurnNavigator.tsx' +import { deriveTurnNavigationItems, type TurnNavigationItem } from './turn-navigation.ts' import { formatRunDuration } from './message-chrome.ts' import css from './ChatView.module.css' @@ -151,6 +153,7 @@ export function ChatView({ }: ChatViewSlotProps) { const order = useChat(s => s.order) const nodeStore = useChat(s => s.nodes) + const locations = useChat(s => s.locations) const timeline = useChat(s => s.timeline) const inbox = useSession(s => s.queue) // Workspace root off the session list row: path summaries display relative to it. @@ -208,11 +211,18 @@ export function ChatView({ [loadImage, renderSlot], ) const runningTurnStart = useMemo(() => runningTurnStartTime(timeline), [timeline]) + const turnNavigationItems = useMemo( + () => deriveTurnNavigationItems({ timeline, locations, nodes: nodeStore }), + [locations, nodeStore, order, timeline], + ) const listRef = useRef(null) const columnRef = useRef(null) const atBottomRef = useRef(true) const [atBottom, setAtBottom] = useState(true) + const [activeTurn, setActiveTurn] = useState( + () => turnNavigationItems.at(-1)?.turn ?? null, + ) /** Last position delivered or written on the main thread. */ const observedTopRef = useRef(0) /** Paging anchor: semantic row/position at click, updated by reader scrolls @@ -234,6 +244,51 @@ export function ChatView({ const lastSteeringId = pendingSteering[pendingSteering.length - 1]?.id ?? null const followSig = `${openState}:${firstSeq}:${lastKey}:${order.length}:${running ? 1 : 0}:${lastSteeringId ?? ''}` + const syncActiveTurn = useCallback((): void => { + const local = listRef.current + const first = turnNavigationItems[0] + if (local === null || first === undefined) { + setActiveTurn(null) + return + } + const el = scrollerOf(local) + const scrollport = el.getBoundingClientRect() + const readingLine = scrollport.top + Math.min(96, el.clientHeight * 0.2) + let next = first.turn + for (const item of turnNavigationItems) { + const row = anchorElement(local, item.anchorKey) + if (row === null || row.getBoundingClientRect().top > readingLine) break + next = item.turn + } + if (el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1) { + next = turnNavigationItems.at(-1)?.turn ?? next + } + setActiveTurn(current => current === next ? current : next) + }, [turnNavigationItems]) + + const activeFrameRef = useRef(null) + const scheduleActiveTurn = useCallback((): void => { + if (activeFrameRef.current !== null) return + if (typeof requestAnimationFrame === 'undefined') { + syncActiveTurn() + return + } + activeFrameRef.current = requestAnimationFrame(() => { + activeFrameRef.current = null + syncActiveTurn() + }) + }, [syncActiveTurn]) + + useEffect(() => () => { + if (activeFrameRef.current !== null && typeof cancelAnimationFrame !== 'undefined') { + cancelAnimationFrame(activeFrameRef.current) + } + }, []) + + useLayoutEffect(() => { + scheduleActiveTurn() + }, [scheduleActiveTurn]) + const toBottom = (el: HTMLElement): void => { anchorRef.current = null el.scrollTop = el.scrollHeight @@ -241,6 +296,7 @@ export function ChatView({ atBottomRef.current = true setAtBottom(true) chatScroll.save(null) + setActiveTurn(turnNavigationItems.at(-1)?.turn ?? null) } useLayoutEffect(() => { @@ -339,6 +395,7 @@ export function ChatView({ if (isAtBottom) chatScroll.save(null) else if (position !== null) chatScroll.save(position) observedTopRef.current = el.scrollTop + scheduleActiveTurn() } // Bind the scroll listener on the resolved scrollport once per mount; @@ -405,9 +462,33 @@ export function ChatView({ loadOlder() } + const navigateToTurn = (item: TurnNavigationItem): void => { + const local = listRef.current + if (local === null) return + const row = anchorElement(local, item.anchorKey) + if (row === null) return + anchorRef.current = null + const el = scrollerOf(local) + el.scrollTop += flowTop(row, el) - 24 + observedTopRef.current = el.scrollTop + const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1 + atBottomRef.current = isAtBottom + setAtBottom(isAtBottom) + setActiveTurn(item.turn) + const position = isAtBottom ? null : scrollPosition(local, el) + if (isAtBottom) chatScroll.save(null) + else if (position !== null) chatScroll.save(position) + } + return (
+
{openState === 'loading' &&
{t('chat.loadingHistory')}
} {openState === 'error' && openError !== null && ( diff --git a/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css b/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css new file mode 100644 index 0000000000..8a874897de --- /dev/null +++ b/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css @@ -0,0 +1,176 @@ +/* Zero-height sticky slot, like the back-to-bottom control: the rail floats + over the transcript's right gutter without extending scrollHeight. */ +.slot { + position: sticky; + top: 0; + z-index: 6; + height: 0; + pointer-events: none; +} + +.rail { + /* The band a reader actually sees: the scrollport minus the sticky composer + stack covering its floor. ConversationRoot publishes both measurements on + the scrollport; the fallbacks carry the first paint before its observer + fires. */ + --turn-rail-band: calc( + var(--dsh-conversation-viewport-height, 100dvh) - var(--dsh-composer-height, 152px) + ); + --turn-preview-height: 100px; + + position: absolute; + top: calc(var(--turn-rail-band) / 2); + /* Flush with the scrollport edge: the slot sits inside the transcript's side + padding, so the rail gives that inset back and keeps 12px of its own. */ + right: calc(12px - (var(--dsh-composer-side-clearance) + 16px)); + width: 28px; + height: min( + var(--turn-natural-height), + clamp(120px, calc(var(--turn-rail-band) - 64px), 420px) + ); + cursor: pointer; + pointer-events: auto; + transform: translateY(-50%); + transition: height 220ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.marks { + position: absolute; + inset: var(--turn-rail-inset) 0; +} + +.markPosition { + position: absolute; + top: min(var(--turn-natural-position), var(--turn-position)); + right: 0; + left: 0; + height: 10px; + transform: translateY(-50%); + transition: top 220ms cubic-bezier(0.2, 0.8, 0.2, 1); + animation: dsh-turn-mark-enter 150ms ease-out; +} + +/* The rail owns pointer input for the whole column, so a mark is a keyboard + destination that paints one tick — never a mouse target of its own. */ +.mark { + position: absolute; + /* As wide as the longest tick, right-aligned with it: the focus ring below + then wraps the tick instead of the rail's full pointer column. */ + inset: 0 0 0 auto; + width: 20px; + padding: 0; + border: 0; + border-radius: 8px; + background: transparent; + cursor: pointer; + pointer-events: none; +} + +.mark::before { + position: absolute; + top: 50%; + right: 0; + width: 12px; + height: 2px; + border-radius: 2px; + background: var(--dsw-alias-border-l4); + content: ''; + transform: translateY(-50%); + transition: width 140ms ease, background-color 140ms ease; +} + +.markPreview::before { + width: 18px; + background: var(--dsw-alias-label-tertiary); +} + +.markActive::before { + width: 20px; + background: var(--dsw-alias-label-primary); +} + +/* Keyboard focus outranks both resting states: the tick takes the brand color + the rest of the rail never uses, and a hairline ring keeps it legible + against a busy transcript. */ +.mark:focus-visible::before { + width: 20px; + background: var(--dsw-alias-state-business-primary); +} + +.mark:focus-visible { + outline: 1px solid var(--dsw-alias-state-business-primary); + outline-offset: 2px; +} + +.preview { + position: absolute; + /* Centered on its mark (mark positions are measured inside the rail inset), + then held clear of both rail ends. */ + top: clamp( + 0px, + calc( + min(var(--turn-natural-position), var(--turn-position)) + + var(--turn-rail-inset) - var(--turn-preview-height) / 2 + ), + calc(100% - var(--turn-preview-height)) + ); + right: calc(100% + 10px); + box-sizing: border-box; + width: min(300px, calc(100cqw - 120px)); + max-height: var(--turn-preview-height); + overflow: hidden; + padding: 10px 12px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 10px; + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-bg-layer-1); + box-shadow: var(--dsw-shadow-lv2); + pointer-events: none; + animation: dsh-turn-preview-enter 120ms ease-out; + transition: top 140ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.previewPrompt, +.previewResponse { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; +} + +.previewPrompt { + font: var(--dsw-font-xs-strong-13); + -webkit-line-clamp: 2; +} + +.previewResponse { + margin-top: 4px; + color: var(--dsw-alias-label-caption); + font: var(--dsw-font-xxs-12); + -webkit-line-clamp: 2; +} + +@keyframes dsh-turn-mark-enter { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes dsh-turn-preview-enter { + from { opacity: 0; transform: translateX(4px); } + to { opacity: 1; transform: translateX(0); } +} + +@container (max-width: 900px) { + .slot { + display: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .rail, + .markPosition, + .mark::before, + .preview { + transition: none; + animation: none; + } +} diff --git a/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx b/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx new file mode 100644 index 0000000000..79029a3e52 --- /dev/null +++ b/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx @@ -0,0 +1,118 @@ +import { + useId, useState, type CSSProperties, type MouseEvent, type PointerEvent, +} from 'react' +import type { ChatViewSlotProps } from '../contract/slots.ts' +import type { TurnNavigationItem } from './turn-navigation.ts' +import css from './TurnNavigator.module.css' + +interface TurnNavigatorProps { + readonly items: readonly TurnNavigationItem[] + readonly activeTurn: number | null + readonly onNavigate: (item: TurnNavigationItem) => void + readonly t: ChatViewSlotProps['t'] +} + +/** Resting gap between neighbouring marks before the rail compresses to fit. */ +const TURN_SPACING_PX = 10 +/** Rail padding above the first mark and below the last one, per end. */ +const RAIL_INSET_PX = 6 + +type TurnPositionStyle = CSSProperties & { + readonly '--turn-natural-position': string + readonly '--turn-position': string +} + +type TurnRailStyle = CSSProperties & { + readonly '--turn-natural-height': string + readonly '--turn-rail-inset': string +} + +function itemPosition(index: number, count: number): TurnPositionStyle { + const ratio = count <= 1 ? 0 : index / (count - 1) + return { + '--turn-natural-position': `${String(index * TURN_SPACING_PX)}px`, + '--turn-position': `${String(ratio * 100)}%`, + } +} + +function railSize(count: number): TurnRailStyle { + return { + '--turn-natural-height': `${String((count - 1) * TURN_SPACING_PX + 2 * RAIL_INSET_PX)}px`, + '--turn-rail-inset': `${String(RAIL_INSET_PX)}px`, + } +} + +function itemAtPointer( + items: readonly TurnNavigationItem[], + rail: HTMLElement, + clientY: number, +): TurnNavigationItem | undefined { + const rect = rail.getBoundingClientRect() + const usableHeight = Math.max(1, rect.height - 2 * RAIL_INSET_PX) + const ratio = Math.max(0, Math.min(1, (clientY - rect.top - RAIL_INSET_PX) / usableHeight)) + return items[Math.round(ratio * (items.length - 1))] +} + +/** Compact rail of the currently loaded Turns with hover and focus previews. */ +export function TurnNavigator({ items, activeTurn, onNavigate, t }: TurnNavigatorProps) { + const [previewTurn, setPreviewTurn] = useState(null) + const previewId = useId() + if (items.length < 2) return null + const previewIndex = items.findIndex(item => item.turn === previewTurn) + const preview = previewIndex < 0 ? undefined : items[previewIndex] + const previewPosition = previewIndex < 0 ? undefined : itemPosition(previewIndex, items.length) + const previewAtPointer = (event: PointerEvent): void => { + setPreviewTurn(itemAtPointer(items, event.currentTarget, event.clientY)?.turn ?? null) + } + const navigateAtPointer = (event: MouseEvent): void => { + const item = itemAtPointer(items, event.currentTarget, event.clientY) + if (item !== undefined) onNavigate(item) + } + return ( +
+ +
+ ) +} diff --git a/packages/client/ui-chat/src/client/chat/turn-navigation.ts b/packages/client/ui-chat/src/client/chat/turn-navigation.ts new file mode 100644 index 0000000000..5ce5305dfd --- /dev/null +++ b/packages/client/ui-chat/src/client/chat/turn-navigation.ts @@ -0,0 +1,49 @@ +import type { ChatNode } from '../contract/chat-nodes.ts' +import type { ChatSnapshot } from '../contract/snapshot.ts' + +/** One loaded Turn projected into the compact Chat navigation rail. */ +export interface TurnNavigationItem { + readonly turn: number + readonly anchorKey: string + readonly prompt: string + readonly response: string +} + +function compactText(parts: readonly string[]): string { + return parts.join(' ').replace(/\s+/g, ' ').trim() +} + +function promptText(node: ChatNode): string { + if (node.kind !== 'user') return '' + return compactText(node.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])) +} + +function responseText(node: ChatNode): string { + if (node.kind !== 'assistant-step') return '' + return compactText(node.data.blocks.flatMap(block => block.kind === 'text' ? [block.text] : [])) +} + +/** + * Project the currently loaded Chat window into stable Turn navigation items. + * @param snapshot - current incremental Chat snapshot. + * @returns loaded Turns that have at least one visible rendered anchor. + */ +export function deriveTurnNavigationItems( + snapshot: Pick, +): readonly TurnNavigationItem[] { + return snapshot.timeline.turnOrder.flatMap((turn): TurnNavigationItem[] => { + const nodes = snapshot.locations.getTurn(turn) + .map(key => snapshot.nodes.get(key)) + .filter((node): node is ChatNode => node !== undefined && node.visibility === 'visible') + const user = nodes.find(node => node.kind === 'user') + const anchor = user ?? nodes[0] + if (anchor === undefined) return [] + const response = nodes.findLast(node => responseText(node) !== '') + return [{ + turn, + anchorKey: anchor.key, + prompt: user === undefined ? '' : promptText(user), + response: response === undefined ? '' : responseText(response), + }] + }) +} diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index be8a3521ac..6ffbb125d4 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -28,6 +28,9 @@ export const zh = { 'chat.loadOlder': '加载更早', 'chat.toBottom': '回到底部', 'chat.deepDiving': '深度求索中...', + 'chat.turnNavigation.label': '轮次导航', + 'chat.turnNavigation.jump': '跳转到第 {turn} 轮', + 'chat.turnNavigation.turn': '第 {turn} 轮', 'fileOpen.title': '无法打开文件', 'fileOpen.unknown': '无法打开此文件', 'fileOpen.folderTitle': '无法打开文件夹', @@ -114,6 +117,9 @@ export const en = { 'chat.loadOlder': 'Load earlier', 'chat.toBottom': 'Back to bottom', 'chat.deepDiving': 'Deep diving...', + 'chat.turnNavigation.label': 'Turn navigation', + 'chat.turnNavigation.jump': 'Jump to turn {turn}', + 'chat.turnNavigation.turn': 'Turn {turn}', 'fileOpen.title': 'Couldn’t open file', 'fileOpen.unknown': 'Couldn’t open this file', 'fileOpen.folderTitle': 'Couldn’t open folder', diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index d112bd779c..de703920fc 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -32,6 +32,7 @@ import { } from '../src/client/chat/MessageItem.tsx' import { TurnTailNodeView } from '../src/client/chat/TurnTailNodeView.tsx' import { formatRunDuration } from '../src/client/chat/message-chrome.ts' +import { deriveTurnNavigationItems } from '../src/client/chat/turn-navigation.ts' import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts' afterEach(() => { @@ -114,6 +115,12 @@ const user = (seq: number, text: string): UserMessageNode => ({ content: [{ type: 'text', text }] as never, source: null, }) +const userInTurn = (seq: number, text: string, turn: number): ConversationNode => ({ + ...user(seq, text), + // The production Location index owns this association. The legacy fixture + // accepts the extra coordinate so component tests can build the same view. + turn, +} as unknown as ConversationNode) const assistant = (seq: number, text: string, turn = 1): AssistantMessageNode => ({ kind: 'assistant', seq, time: seq * 1_000, turn, step: 1, blocks: [{ kind: 'text', text }], }) @@ -406,6 +413,68 @@ describe('Chat node rendering', () => { }) describe('ChatView', () => { + it('projects loaded turns into prompt and response navigation previews', () => { + const snapshot = chatSnapshotFixture({ + nodes: [ + userInTurn(1, 'first prompt', 1), + assistant(2, 'first response', 1), + userInTurn(4, 'second prompt', 2), + assistant(5, 'second response', 2), + ], + turnEnds: new Map([[1, 3], [2, 6]]), + }) + expect(deriveTurnNavigationItems(snapshot)).toEqual([ + { turn: 1, anchorKey: 'fixture:user:1', prompt: 'first prompt', response: 'first response' }, + { turn: 2, anchorKey: 'fixture:user:4', prompt: 'second prompt', response: 'second response' }, + ]) + const h = makeHarness({}, {}, snapshot) + const view = render() + const navigation = view.getByRole('navigation', { name: '轮次导航' }) + expect(navigation.style.getPropertyValue('--turn-natural-height')).toBe('22px') + const first = view.getByRole('button', { name: '跳转到第 1 轮' }) + const second = view.getByRole('button', { name: '跳转到第 2 轮' }) + expect(first.parentElement?.style.getPropertyValue('--turn-natural-position')).toBe('0px') + expect(second.parentElement?.style.getPropertyValue('--turn-natural-position')).toBe('10px') + expect(second.getAttribute('aria-current')).toBe('true') + fireEvent.focus(first) + const preview = view.getByRole('tooltip') + expect(preview.textContent).toContain('first prompt') + expect(preview.textContent).toContain('first response') + }) + + it('jumps to a turn anchor and reflows stable marks after an older page arrives', () => { + const later = [ + userInTurn(4, 'second prompt', 2), assistant(5, 'second response', 2), + userInTurn(7, 'third prompt', 3), assistant(8, 'third response', 3), + ] + const h = makeHarness({ nodes: later }, { hasMore: true }) + const view = render() + const second = view.getByRole('button', { name: '跳转到第 2 轮' }) + const secondPosition = second.parentElement as HTMLElement + expect(secondPosition.style.getPropertyValue('--turn-position')).toBe('0%') + + const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement + const metrics = installScrollMetrics(scroller, 1_000, 300) + metrics.setLayout(1_000, 700) + vi.spyOn(scroller, 'getBoundingClientRect').mockReturnValue({ top: 0, bottom: 300 } as DOMRect) + const secondRow = view.container.querySelector('[data-chat-flow-key="fixture:user:4"]') as HTMLElement + vi.spyOn(secondRow, 'getBoundingClientRect').mockReturnValue({ top: -500, bottom: -440 } as DOMRect) + fireEvent.click(second) + expect(scroller.scrollTop).toBe(176) + expect(second.getAttribute('aria-current')).toBe('true') + + act(() => { + h.setChat({ + nodes: [userInTurn(1, 'first prompt', 1), assistant(2, 'first response', 1), ...later], + turnTimings: new Map([[1, { startTime: 1_000 }], [2, { startTime: 4_000 }], [3, { startTime: 7_000 }]]), + }) + }) + const movedSecond = view.getByRole('button', { name: '跳转到第 2 轮' }) + expect(movedSecond.parentElement).toBe(secondPosition) + expect(secondPosition.style.getPropertyValue('--turn-natural-position')).toBe('10px') + expect(secondPosition.style.getPropertyValue('--turn-position')).toBe('50%') + }) + it('hands a windowless tool result to the Tool seat with an empty tool name', () => { const h = makeHarness({ nodes: [{ ...toolResult(3, 'w1'), call: null }], diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index a3a5942da7..d172b261cb 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -38,10 +38,13 @@ export function ConversationRoot({ const [pendingWorkspaceId, setPendingWorkspaceId] = useState() const pickerAnchor = useRef(null) - // Publishes the seat's live height as --dsh-composer-height on the scroll - // body so floating View controls clear the composer as - // it grows. Callback ref, not an effect; stable identity prevents observer - // churn while the first blank session fills the resident body outlet. + // Publishes the two live measurements floating View chrome reads off the + // scroll body: the seat's height as --dsh-composer-height, so controls clear + // the composer as it grows, and the scrollport's own height as + // --dsh-conversation-viewport-height, so a control can sit in the band the + // seat leaves visible. Callback ref, not an effect; stable identity prevents + // observer churn while the first blank session fills the resident body + // outlet. const seatObserver = useRef(null) const seatResizeRef = useCallback((seat: HTMLDivElement | null): void => { seatObserver.current?.disconnect() @@ -50,8 +53,13 @@ export function ConversationRoot({ if (seat === null || scroller === null) return seatObserver.current = new ResizeObserver(() => { scroller.style.setProperty('--dsh-composer-height', `${seat.offsetHeight}px`) + scroller.style.setProperty( + '--dsh-conversation-viewport-height', + `${scroller.clientHeight}px`, + ) }) seatObserver.current.observe(seat) + seatObserver.current.observe(scroller) }, []) const sessionWorkspace = sessionId === undefined diff --git a/snapshots/web/goal-multi-turn-actions/ui.expected.md b/snapshots/web/goal-multi-turn-actions/ui.expected.md index 80733e2719..a835b3352e 100644 --- a/snapshots/web/goal-multi-turn-actions/ui.expected.md +++ b/snapshots/web/goal-multi-turn-actions/ui.expected.md @@ -9,6 +9,9 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" +- navigation "Turn navigation": + - button "Jump to turn 1" + - button "Jump to turn 2" - group "Command input": /goal 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的 - 'button "goal Goal created Status: active Objective: 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的 Rounds: 0/256 Activation: armed Commands: /goal edit , /goal pause, /goal clear"': - img From ba84299c989a2f00a7966b7c8bf8eea119841e7f Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 25 Aug 2026 14:26:10 +0800 Subject: [PATCH 2/4] test(web): record the Turn rail in every affected aria golden The rail is a landmark on every Chat wide enough to show it, so each recorded conversation with at least two loaded Turns now carries the navigation node and its marks. --- .../stats-paged-history/ui.expected.md | 29 +++++++++++++++++++ .../web/cordis-tool-round/ui.expected.md | 4 +++ snapshots/web/message-actions/ui.expected.md | 3 ++ .../seeded-history/command-row.expected.md | 3 ++ .../seeded-history/feedback-row.expected.md | 3 ++ snapshots/web/seeded-history/ui.expected.md | 3 ++ .../web/subagent-conversation/ui.expected.md | 3 ++ 7 files changed, 48 insertions(+) diff --git a/apps/web/tests/expected/stats-paged-history/ui.expected.md b/apps/web/tests/expected/stats-paged-history/ui.expected.md index 78d175af5d..1722b90b13 100644 --- a/apps/web/tests/expected/stats-paged-history/ui.expected.md +++ b/apps/web/tests/expected/stats-paged-history/ui.expected.md @@ -7,6 +7,35 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" +- navigation "Turn navigation": + - button "Jump to turn 1" + - button "Jump to turn 2" + - button "Jump to turn 3" + - button "Jump to turn 4" + - button "Jump to turn 5" + - button "Jump to turn 6" + - button "Jump to turn 7" + - button "Jump to turn 8" + - button "Jump to turn 9" + - button "Jump to turn 10" + - button "Jump to turn 11" + - button "Jump to turn 12" + - button "Jump to turn 13" + - button "Jump to turn 14" + - button "Jump to turn 15" + - button "Jump to turn 16" + - button "Jump to turn 17" + - button "Jump to turn 18" + - button "Jump to turn 19" + - button "Jump to turn 20" + - button "Jump to turn 21" + - button "Jump to turn 22" + - button "Jump to turn 23" + - button "Jump to turn 24" + - button "Jump to turn 25" + - button "Jump to turn 26" + - button "Jump to turn 27" + - button "Jump to turn 28" - text: m1 7/25 {{clock}} - button "Copy": - img diff --git a/snapshots/web/cordis-tool-round/ui.expected.md b/snapshots/web/cordis-tool-round/ui.expected.md index cc05c3aed9..2ec7d45c4a 100644 --- a/snapshots/web/cordis-tool-round/ui.expected.md +++ b/snapshots/web/cordis-tool-round/ui.expected.md @@ -9,6 +9,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" +- navigation "Turn navigation": + - button "Jump to turn 1" + - button "Jump to turn 2" + - button "Jump to turn 3" - text: "Use only Cordis tools. First call cordis_inspect_self with no arguments. Then call cordis_define with plugin kind \"new\", idPrefix \"snap\", name \"snapshot noop\", purpose \"does nothing, for the snapshot\", code.host exactly \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\" and code.client exactly \"return { inject: [\\\"slots\\\"], apply(ctx) { ctx.slots.register({ name: \\\"shell.overlay\\\", id: \\\"snapshot-probe\\\" }, () => React.createElement(\\\"div\\\", { \\\"data-snapshot-probe\\\": \\\"loaded\\\" })) } }\". Read its returned pluginId and packageId, then call cordis_run with those exact IDs and mode \"run\". After the run request returns, reply exactly CORDIS_UI_READY and stop. {{clock}}" - button "Copy": - img diff --git a/snapshots/web/message-actions/ui.expected.md b/snapshots/web/message-actions/ui.expected.md index 0419f0f1b1..794bd71758 100644 --- a/snapshots/web/message-actions/ui.expected.md +++ b/snapshots/web/message-actions/ui.expected.md @@ -7,6 +7,9 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" +- navigation "Turn navigation": + - button "Jump to turn 1" + - button "Jump to turn 2" - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. 7/25 {{clock}}" - button "Copy": - img diff --git a/snapshots/web/seeded-history/command-row.expected.md b/snapshots/web/seeded-history/command-row.expected.md index 4402a0c69b..01234b74da 100644 --- a/snapshots/web/seeded-history/command-row.expected.md +++ b/snapshots/web/seeded-history/command-row.expected.md @@ -7,6 +7,9 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" +- navigation "Turn navigation": + - button "Jump to turn 1" + - button "Jump to turn 2" - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. 7/25 {{clock}}" - button "Copy": - img diff --git a/snapshots/web/seeded-history/feedback-row.expected.md b/snapshots/web/seeded-history/feedback-row.expected.md index 3f7148828e..3b362a9252 100644 --- a/snapshots/web/seeded-history/feedback-row.expected.md +++ b/snapshots/web/seeded-history/feedback-row.expected.md @@ -7,6 +7,9 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" +- navigation "Turn navigation": + - button "Jump to turn 1" + - button "Jump to turn 2" - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. 7/25 {{clock}}" - button "Copy": - img diff --git a/snapshots/web/seeded-history/ui.expected.md b/snapshots/web/seeded-history/ui.expected.md index 3ca7fba7ca..1f72791a3c 100644 --- a/snapshots/web/seeded-history/ui.expected.md +++ b/snapshots/web/seeded-history/ui.expected.md @@ -7,6 +7,9 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" +- navigation "Turn navigation": + - button "Jump to turn 1" + - button "Jump to turn 2" - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. 7/25 {{clock}}" - button "Copy": - img diff --git a/snapshots/web/subagent-conversation/ui.expected.md b/snapshots/web/subagent-conversation/ui.expected.md index dc26ca3e97..68eb4d1082 100644 --- a/snapshots/web/subagent-conversation/ui.expected.md +++ b/snapshots/web/subagent-conversation/ui.expected.md @@ -14,6 +14,9 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" +- navigation "Turn navigation": + - button "Jump to turn 1" + - button "Jump to turn 2" - text: Explain event sourcing in one sentence. {{clock}} - button "Copy": - img From 1272c7d0dfcafc533c2a4e1a2258efbc9c63822d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 25 Aug 2026 14:46:38 +0800 Subject: [PATCH 3/4] perf(web): accumulate the Turn rail instead of scanning the loaded window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rail's items now ride the Chat snapshot: a structural upsert re-derives the loaded Turn set, a content-only upsert re-derives only the Turns whose nodes changed, and each preview is capped so navigation state never holds a copy of the transcript. The published array keeps its identity until an item changes, so ChatView selects it as both data and change signal — and a streaming reply's preview follows the in-place node update instead of the last structural publication. A scroll frame resolves the active mark with one hit test at the reading line, falling back to a single row scan, rather than a DOM query per mark. Flow-height changes resync through the existing column observer, navigating during a pending page keeps the paging anchor, and the rail height no longer holds a floor taller than the band it centers in. --- ...8-25-loaded-turn-chat-navigation.i18n.yaml | 4 +- .../2026-08-25-loaded-turn-chat-navigation.md | 10 ++- ...26-08-25-loaded-turn-chat-navigation.zh.md | 10 ++- apps/web/tests/chat-long-interactions.e2e.ts | 8 +- .../ui-chat/src/client/chat/ChatView.tsx | 70 ++++++++++++---- .../src/client/chat/TurnNavigator.module.css | 6 +- .../ui-chat/src/client/chat/TurnNavigator.tsx | 2 +- .../src/client/chat/turn-navigation.ts | 49 ----------- .../ui-chat/src/client/contract/snapshot.ts | 26 ++++++ .../chat-snapshot-builder.ts | 83 ++++++++++++++++++- .../conversation-nodes/turn-navigation.ts | 71 ++++++++++++++++ packages/client/ui-chat/src/client/index.ts | 9 +- .../tests/chat-snapshot-fixture.client.ts | 14 +++- .../ui-chat/tests/chat-view.client.spec.tsx | 3 +- ...nversation-node-definitions.client.spec.ts | 40 +++++++++ .../tests/tool-details-render.client.tsx | 1 + .../tests/plan-review-panel.client.spec.tsx | 1 + .../user-questions-composer.client.spec.tsx | 1 + 18 files changed, 325 insertions(+), 83 deletions(-) delete mode 100644 packages/client/ui-chat/src/client/chat/turn-navigation.ts create mode 100644 packages/client/ui-chat/src/client/conversation-nodes/turn-navigation.ts diff --git a/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.i18n.yaml b/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.i18n.yaml index bcc02e9b68..0ba01f1eeb 100644 --- a/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.md -2026-08-25-loaded-turn-chat-navigation.md: 92e92323115c5da51ced09092d9ecd4e376f874f -2026-08-25-loaded-turn-chat-navigation.zh.md: b0cba5e78fad8b2ff3559d6cbfc12fb2160a2dc8 +2026-08-25-loaded-turn-chat-navigation.md: 5d9d93b07f7a8c527bf7376bf111c6a709afa4d1 +2026-08-25-loaded-turn-chat-navigation.zh.md: 21dba024710b306848fee9bc4fe1f16913c475b3 diff --git a/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.md b/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.md index 92e9232311..5d9d93b07f 100644 --- a/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.md +++ b/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.md @@ -10,13 +10,15 @@ Long Chat transcripts require repeated scrolling to revisit an earlier Turn. Ses ## Decision -ChatView derives one navigation item for every currently loaded Turn that has a visible transcript node. Each item uses the Turn number as its stable React key and the first loaded user node, falling back to the Turn's first loaded node, as its scroll anchor. This is a pure projection of the Chat snapshot: the feature adds no Session event, persisted index, or pagination request. +The Chat snapshot builder accumulates one navigation item for every currently loaded Turn that has a visible transcript node. Each item uses the Turn number as its stable React key and the first loaded user node, falling back to the Turn's first loaded node, as its scroll anchor. This is a pure projection of loaded Chat state: the feature adds no Session event, persisted index, or pagination request. + +Accumulation, not a render-time scan: a structural upsert re-derives the loaded Turn set, a content-only upsert re-derives only the Turns whose nodes changed, and each preview is capped at 160 characters so navigation state never holds a copy of the transcript. The published array keeps its identity until an item changes, so ChatView selects it as both the rail's data and its change signal — the renderer never walks the loaded window, and a streaming reply's preview follows the in-place node update instead of the last structural publication. The rail renders the complete loaded Turn set with a 10px natural interval and never renders an ellipsis or unloaded-history placeholder. Its height shrink-wraps small sets; when the loaded set exceeds the available height, percentage positions compress every mark into the capped rail. When an earlier page arrives, existing Turn keys and DOM elements remain stable while their resolved positions change; CSS transitions animate that redistribution. A Turn split by the page boundary initially previews its Turn number and loaded assistant response, then gains the user prompt when the preceding page supplies it. The rail sits against the scrollport's right edge and centers on the band the sticky composer leaves visible. That band is the scrollport's own height minus the seat's, so ConversationRoot publishes `--dsh-conversation-viewport-height` beside the `--dsh-composer-height` it already measures on the same element, and the rail centers on their difference instead of a viewport height that ignores the Session header. -The active mark follows a reading line near the top of the shared Chat scrollport. Scroll updates are coalesced with `requestAnimationFrame`; reaching the bottom selects the final loaded Turn. Activating a mark computes the target node's position in the existing scroll coordinate system, moves that same scrollport, and records the resulting Chat scroll-restoration anchor. +The active mark follows a reading line near the top of the shared Chat scrollport. A scroll frame resolves the owning Turn with one hit test at that line, falling back to a single row scan where layout cannot answer, so cost does not grow with the number of marks. Flow-height changes that move rows across the line without a scroll event resync through the existing column observer. Scroll updates are coalesced with `requestAnimationFrame`; reaching the bottom selects the final loaded Turn. Activating a mark computes the target node's position in the existing scroll coordinate system, moves that same scrollport, and records the resulting Chat scroll-restoration anchor. Every Turn remains an accessible button even when dense marks visually overlap. The rail maps pointer height to the nearest loaded Turn, while keyboard focus and activation operate the individual buttons. Hover and focus show a compact prompt-and-response preview, the active mark is longer and darker, the rail is hidden when the Chat container is at most 900px wide, and reduced-motion preferences disable redistribution and mark-entry animation. @@ -28,6 +30,8 @@ Every Turn remains an accessible button even when dense marks visually overlap. **Always spread marks across the available height.** Rejected: a small loaded set produces visually unrelated marks separated by large empty regions. A fixed natural interval preserves a compact index while percentage compression still admits dense histories. +**Derive the rail in the renderer from the Chat snapshot.** Rejected: renderers do not scan the loaded Chat Nodes ([client discipline](../../../../packages/client/AGENTS.md)). A render-time projection also re-copied every Turn's prompt and reply text on each structural publication, and could not see the in-place node updates a streaming reply produces, so previews froze at the first chunk. + **Key marks by loaded-array position.** Rejected: prepending a page would reuse each DOM element for a different Turn, lose focus and preview identity, and prevent the existing marks from animating to their new positions. **Call `scrollIntoView` on the Turn row.** Rejected: Chat owns a shared scroller, bottom-follow state, paging anchors, and persisted restoration coordinates. An opaque browser scroll would bypass those state updates. @@ -38,4 +42,4 @@ Desktop-width Chat views can jump among all currently loaded Turns and inspect a ## Testing -Component tests pin Turn derivation, accessible previews, scroll-coordinate jumps, DOM identity, and percentage redistribution after prepend. The long-interaction Chromium scenario pins the real paginated boundary, prompt completion after `Load earlier`, stable-mark movement, keyboard activation, and active-state update. The multi-Turn recorded Web snapshot includes the navigation landmark and buttons. +Builder tests pin the accumulated projection, the bounded preview, and preview freshness under an in-place chunk update. Component tests pin the published items, accessible previews, scroll-coordinate jumps, DOM identity, and percentage redistribution after prepend. The long-interaction Chromium scenario pins the real paginated boundary, prompt completion after `Load earlier`, stable-mark movement, keyboard activation, active-state update, and the narrow-container hide. The multi-Turn recorded Web snapshot includes the navigation landmark and buttons. diff --git a/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.zh.md b/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.zh.md index b0cba5e78f..21dba02471 100644 --- a/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.zh.md +++ b/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.zh.md @@ -10,13 +10,15 @@ Status: implemented ## 决定 -ChatView 为当前已加载且含可见 transcript node 的每个 Turn 派生一项导航。每项使用 Turn 编号作为稳定的 React key,并以首个已加载用户 node 为滚动锚点;没有用户 node 时回退到该 Turn 的首个已加载 node。这只是 Chat snapshot 的纯投影:本功能不新增 Session event、持久化索引或分页请求。 +Chat snapshot 构建层为当前已加载且含可见 transcript node 的每个 Turn 累积一项导航。每项使用 Turn 编号作为稳定的 React key,并以首个已加载用户 node 为滚动锚点;没有用户 node 时回退到该 Turn 的首个已加载 node。这只是已加载 Chat 状态的纯投影:本功能不新增 Session event、持久化索引或分页请求。 + +累积而非渲染期扫描:结构性 upsert 重算已加载 Turn 集合,仅内容变化的 upsert 只重算受影响 Turn,每条预览截断到 160 字符,导航状态因此不会持有 transcript 副本。发布的数组在条目未变时保持引用不变,ChatView 直接选取它,既作为轨道数据也作为变化信号——渲染层不遍历已加载窗口,流式回复的预览也跟随节点原地更新,而不是停在上一次结构性发布。 导航轨道以 10px 自然间距渲染完整的已加载 Turn 集合,永不显示省略号或未加载历史占位。集合较小时轨道随内容收缩;已加载集合超过可用高度后,百分比位置会把所有刻度压缩到设定上限内。更早一页到达后,已有 Turn 的 key 和 DOM 元素保持不变,最终位置随之变化;CSS transition 为这次重排添加动画。如果一个 Turn 被分页边界截断,预览最初显示其 Turn 编号与已加载的助手回复,上一页补齐后再显示用户问题。 轨道紧贴滚动视口右缘,并在粘性输入区之外的可见区间内垂直居中。该区间等于滚动视口自身高度减去输入区高度,因此 ConversationRoot 在同一元素上除已有的 `--dsh-composer-height` 外再发布 `--dsh-conversation-viewport-height`,轨道按两者之差居中,而不是按忽略 Session 头部的视口高度居中。 -活跃刻度跟随共享 Chat 滚动区顶部附近的阅读线。滚动更新由 `requestAnimationFrame` 合并;到达底部时选择最后一个已加载 Turn。激活刻度会在现有滚动坐标系中计算目标 node 的位置,移动同一个滚动区,并记录由此产生的 Chat 滚动恢复锚点。 +活跃刻度跟随共享 Chat 滚动区顶部附近的阅读线。每个滚动帧用一次命中测试解析该行所属 Turn,布局无法作答时退化为一次行扫描,成本不随刻度数量增长。图片加载、工具卡展开等不产生滚动事件的高度变化,通过既有的 column observer 重新同步。滚动更新由 `requestAnimationFrame` 合并;到达底部时选择最后一个已加载 Turn。激活刻度会在现有滚动坐标系中计算目标 node 的位置,移动同一个滚动区,并记录由此产生的 Chat 滚动恢复锚点。 即使密集刻度在视觉上重叠,每个 Turn 仍是可访问的按钮。轨道把指针高度映射到最近的已加载 Turn,键盘聚焦和激活则作用于各个按钮。悬停或聚焦显示紧凑的问题与回复预览,活跃刻度更长、更深;Chat 容器宽度不超过 900px 时隐藏轨道,用户偏好减少动态效果时关闭重排和刻度入场动画。 @@ -28,6 +30,8 @@ ChatView 为当前已加载且含可见 transcript node 的每个 Turn 派生一 **始终把刻度铺满可用高度。**否决:已加载集合较小时,各刻度会被大片空白隔开,在视觉上失去关联。固定自然间距保持紧凑索引,百分比压缩仍能容纳密集历史。 +**在渲染层从 Chat snapshot 派生轨道。**否决:渲染层不扫描已加载的 Chat Nodes(见 [client 纪律](../../../../packages/client/AGENTS.md))。渲染期投影还会在每次结构性发布时重新复制每个 Turn 的问题与回复文本,且看不到流式回复的节点原地更新,预览会停在首个 chunk。 + **按已加载数组位置给刻度设置 key。**否决:前插一页会让每个 DOM 元素改为代表另一个 Turn,丢失焦点与预览身份,也无法让已有刻度移动到新位置。 **对 Turn 行调用 `scrollIntoView`。**否决:Chat 拥有共享滚动区、底部跟随状态、分页锚点与持久化恢复坐标。浏览器的黑盒滚动会绕过这些状态更新。 @@ -38,4 +42,4 @@ ChatView 为当前已加载且含可见 transcript node 的每个 Turn 派生一 ## 测试 -组件测试固定 Turn 派生、可访问预览、滚动坐标跳转、DOM 身份以及前插后的百分比重排。长交互 Chromium 场景固定真实分页边界、`加载更早` 后补齐问题、稳定刻度移动、键盘激活与活跃状态更新。多 Turn 的 Web 录制快照包含导航 landmark 和按钮。 +构建层测试固定累积投影、预览截断,以及原地 chunk 更新后的预览新鲜度。组件测试固定已发布条目、可访问预览、滚动坐标跳转、DOM 身份以及前插后的百分比重排。长交互 Chromium 场景固定真实分页边界、`加载更早` 后补齐问题、稳定刻度移动、键盘激活、活跃状态更新与窄容器隐藏。多 Turn 的 Web 录制快照包含导航 landmark 和按钮。 diff --git a/apps/web/tests/chat-long-interactions.e2e.ts b/apps/web/tests/chat-long-interactions.e2e.ts index 4525cf8390..da9243a7cd 100644 --- a/apps/web/tests/chat-long-interactions.e2e.ts +++ b/apps/web/tests/chat-long-interactions.e2e.ts @@ -153,7 +153,7 @@ describe('web e2e: long Chat interaction contract', () => { }) await seedSession(scaffold, FIXTURE.log, SESSION_ID) browser = await chromium.launch() - page = await newEnglishPage(browser, 1_280) + page = await newEnglishPage(browser, 900) tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) @@ -235,6 +235,12 @@ describe('web e2e: long Chat interaction contract', () => { { timeout: 5_000 }, ).toBe(1) + // Desktop-only affordance: a narrow Chat container hides the rail outright. + await page.setViewportSize({ width: 800, height: 900 }) + await turnNavigation.waitFor({ state: 'hidden', timeout: 5_000 }) + await page.setViewportSize({ width: 1_680, height: 900 }) + await turnNavigation.waitFor({ state: 'visible', timeout: 5_000 }) + await wheelUntilMounted(page, `[data-chat-call-id="${TARGET_CALL_2}"]`, -1_100) const toolUserKey = messageKey(toolUserEvent) const toolAssistantKey = assistantKey(toolAssistantEvent) diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index a82841bf53..298d417d75 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -7,10 +7,10 @@ import type { } from '@deepseek-ai/dsh-client-ui-conversation/client' import { Button, IconChevronDownOutline14, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' +import type { TurnNavigationItem } from '../contract/snapshot.ts' import { PendingSteeringBubble } from './MessageItem.tsx' import { ChatNodeSeat } from './ChatNodeSeat.tsx' import { TurnNavigator } from './TurnNavigator.tsx' -import { deriveTurnNavigationItems, type TurnNavigationItem } from './turn-navigation.ts' import { formatRunDuration } from './message-chrome.ts' import css from './ChatView.module.css' @@ -36,6 +36,32 @@ function anchorElement(list: HTMLElement, key: string): HTMLElement | null { return null } +/** + * Turn owning the row at a scrollport line. Scroll frames are hot, so this + * hit-tests the line first and falls back to one row scan when layout cannot + * answer (jsdom, pre-paint); neither path queries per navigation item. + * @param list - the ChatView list element. + * @param line - viewport y of the reading line. + * @returns the Turn number, or null when no loaded row covers the line. + */ +function turnAtLine(list: HTMLElement, line: number): number | null { + const content = list.getBoundingClientRect() + if (typeof document.elementsFromPoint === 'function' && content.width > 0) { + for (const element of document.elementsFromPoint(content.left + content.width / 2, line)) { + const row = element instanceof HTMLElement ? element.closest('[data-chat-turn]') : null + const turn = Number(row?.dataset.chatTurn) + if (row !== null && list.contains(row) && Number.isSafeInteger(turn)) return turn + } + } + let found: number | null = null + for (const row of list.querySelectorAll('[data-chat-turn]')) { + if (row.getBoundingClientRect().top > line) break + const turn = Number(row.dataset.chatTurn) + if (Number.isSafeInteger(turn)) found = turn + } + return found +} + /** Row position in scrollport coordinates (viewport-independent). */ function flowTop(row: HTMLElement, scrollport: HTMLElement): number { return row.getBoundingClientRect().top - scrollport.getBoundingClientRect().top @@ -153,7 +179,10 @@ export function ChatView({ }: ChatViewSlotProps) { const order = useChat(s => s.order) const nodeStore = useChat(s => s.nodes) - const locations = useChat(s => s.locations) + // The rail's items are accumulated in the Chat snapshot, so this selector is + // both the data and its change signal: the array identity moves only when a + // Turn enters, leaves, or changes its preview. + const turnNavigationItems = useChat(s => s.navigation.items()) const timeline = useChat(s => s.timeline) const inbox = useSession(s => s.queue) // Workspace root off the session list row: path summaries display relative to it. @@ -211,10 +240,6 @@ export function ChatView({ [loadImage, renderSlot], ) const runningTurnStart = useMemo(() => runningTurnStartTime(timeline), [timeline]) - const turnNavigationItems = useMemo( - () => deriveTurnNavigationItems({ timeline, locations, nodes: nodeStore }), - [locations, nodeStore, order, timeline], - ) const listRef = useRef(null) const columnRef = useRef(null) @@ -252,13 +277,17 @@ export function ChatView({ return } const el = scrollerOf(local) - const scrollport = el.getBoundingClientRect() - const readingLine = scrollport.top + Math.min(96, el.clientHeight * 0.2) + const readingLine = el.getBoundingClientRect().top + Math.min(96, el.clientHeight * 0.2) + const reading = turnAtLine(local, readingLine) + // No row reaches the line yet: the flow head still owns the mark. Otherwise + // the row's Turn may be one the rail does not offer (all its nodes hidden), + // so the newest offered Turn at or above it owns the mark. let next = first.turn - for (const item of turnNavigationItems) { - const row = anchorElement(local, item.anchorKey) - if (row === null || row.getBoundingClientRect().top > readingLine) break - next = item.turn + if (reading !== null) { + for (const item of turnNavigationItems) { + if (item.turn > reading) break + next = item.turn + } } if (el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1) { next = turnNavigationItems.at(-1)?.turn ?? next @@ -266,6 +295,7 @@ export function ChatView({ setActiveTurn(current => current === next ? current : next) }, [turnNavigationItems]) + const activeTurnRef = useRef<(() => void) | null>(null) const activeFrameRef = useRef(null) const scheduleActiveTurn = useCallback((): void => { if (activeFrameRef.current !== null) return @@ -285,6 +315,8 @@ export function ChatView({ } }, []) + activeTurnRef.current = scheduleActiveTurn + useLayoutEffect(() => { scheduleActiveTurn() }, [scheduleActiveTurn]) @@ -434,7 +466,12 @@ export function ChatView({ if (column === null || local === null || typeof ResizeObserver === 'undefined') return const scrollport = scrollerOf(local) const composer = scrollport.querySelector('[data-composer-seat]') - const observer = new ResizeObserver(() => { followRef.current?.() }) + // Flow-height changes (image loads, tool disclosures) move rows across the + // reading line without a scroll event, so the active mark resyncs here too. + const observer = new ResizeObserver(() => { + followRef.current?.() + activeTurnRef.current?.() + }) observer.observe(column) if (composer !== null) observer.observe(composer) return () => { observer.disconnect() } @@ -467,10 +504,15 @@ export function ChatView({ if (local === null) return const row = anchorElement(local, item.anchorKey) if (row === null) return - anchorRef.current = null const el = scrollerOf(local) el.scrollTop += flowTop(row, el) - 24 observedTopRef.current = el.scrollTop + // A pending older page still has to compensate the prepended height, so + // navigation moves that anchor to the new position instead of dropping it. + const landed = loadingOlder ? pagingAnchor(local, el) : null + anchorRef.current = landed === null || landed.dataset.chatAnchorKey === undefined + ? null + : { key: landed.dataset.chatAnchorKey, top: flowTop(landed, el) } const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1 atBottomRef.current = isAtBottom setAtBottom(isAtBottom) diff --git a/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css b/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css index 8a874897de..76a7b73e9c 100644 --- a/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css +++ b/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css @@ -24,9 +24,13 @@ padding, so the rail gives that inset back and keeps 12px of its own. */ right: calc(12px - (var(--dsh-composer-side-clearance) + 16px)); width: 28px; + /* Never taller than the band it centers in: a short window (a tall composer, + a low viewport) shrinks the rail instead of pushing marks under the + composer or above the scrollport. */ height: min( var(--turn-natural-height), - clamp(120px, calc(var(--turn-rail-band) - 64px), 420px) + max(0px, calc(var(--turn-rail-band) - 64px)), + 420px ); cursor: pointer; pointer-events: auto; diff --git a/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx b/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx index 79029a3e52..b2533e533c 100644 --- a/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx +++ b/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx @@ -2,7 +2,7 @@ import { useId, useState, type CSSProperties, type MouseEvent, type PointerEvent, } from 'react' import type { ChatViewSlotProps } from '../contract/slots.ts' -import type { TurnNavigationItem } from './turn-navigation.ts' +import type { TurnNavigationItem } from '../contract/snapshot.ts' import css from './TurnNavigator.module.css' interface TurnNavigatorProps { diff --git a/packages/client/ui-chat/src/client/chat/turn-navigation.ts b/packages/client/ui-chat/src/client/chat/turn-navigation.ts deleted file mode 100644 index 5ce5305dfd..0000000000 --- a/packages/client/ui-chat/src/client/chat/turn-navigation.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { ChatNode } from '../contract/chat-nodes.ts' -import type { ChatSnapshot } from '../contract/snapshot.ts' - -/** One loaded Turn projected into the compact Chat navigation rail. */ -export interface TurnNavigationItem { - readonly turn: number - readonly anchorKey: string - readonly prompt: string - readonly response: string -} - -function compactText(parts: readonly string[]): string { - return parts.join(' ').replace(/\s+/g, ' ').trim() -} - -function promptText(node: ChatNode): string { - if (node.kind !== 'user') return '' - return compactText(node.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])) -} - -function responseText(node: ChatNode): string { - if (node.kind !== 'assistant-step') return '' - return compactText(node.data.blocks.flatMap(block => block.kind === 'text' ? [block.text] : [])) -} - -/** - * Project the currently loaded Chat window into stable Turn navigation items. - * @param snapshot - current incremental Chat snapshot. - * @returns loaded Turns that have at least one visible rendered anchor. - */ -export function deriveTurnNavigationItems( - snapshot: Pick, -): readonly TurnNavigationItem[] { - return snapshot.timeline.turnOrder.flatMap((turn): TurnNavigationItem[] => { - const nodes = snapshot.locations.getTurn(turn) - .map(key => snapshot.nodes.get(key)) - .filter((node): node is ChatNode => node !== undefined && node.visibility === 'visible') - const user = nodes.find(node => node.kind === 'user') - const anchor = user ?? nodes[0] - if (anchor === undefined) return [] - const response = nodes.findLast(node => responseText(node) !== '') - return [{ - turn, - anchorKey: anchor.key, - prompt: user === undefined ? '' : promptText(user), - response: response === undefined ? '' : responseText(response), - }] - }) -} diff --git a/packages/client/ui-chat/src/client/contract/snapshot.ts b/packages/client/ui-chat/src/client/contract/snapshot.ts index 35b1562c5b..4a3deb7410 100644 --- a/packages/client/ui-chat/src/client/contract/snapshot.ts +++ b/packages/client/ui-chat/src/client/contract/snapshot.ts @@ -18,6 +18,28 @@ export interface ChatNodeStore { values(): readonly ChatConversationViewNode[] } +/** One loaded Turn projected into the compact Chat navigation rail. */ +export interface TurnNavigationItem { + readonly turn: number + /** Stable Conversation Context key the rail scrolls to. */ + readonly anchorKey: string + /** Bounded prompt preview; empty when the loaded window starts mid-Turn. */ + readonly prompt: string + /** Bounded assistant-response preview; empty until the Turn answers. */ + readonly response: string +} + +/** Stable live navigation projection of the loaded Turns. */ +export interface ChatTurnNavigationIndex { + /** + * Loaded Turns that have a visible anchor, in timeline order. The array + * identity changes exactly when a Turn enters, leaves, or changes preview, + * so a renderer can select it directly as its change signal. + * @returns current navigation items. + */ + items(): readonly TurnNavigationItem[] +} + /** Stable live Location index for Chat nodes. */ export interface ChatLocationNodeIndex { /** @param turn - owning turn. @returns ordered Chat Node keys in the turn. */ @@ -40,6 +62,7 @@ export interface ChatSnapshot { readonly order: readonly string[] readonly nodes: ChatNodeStore readonly locations: ChatLocationNodeIndex + readonly navigation: ChatTurnNavigationIndex readonly timeline: ConversationTimelineSnapshot readonly legacy: LegacyConversationSlice } @@ -64,6 +87,9 @@ export const EMPTY_CHAT_SNAPSHOT: ChatSnapshot = { getTurn: () => EMPTY_LIST, getStep: () => EMPTY_LIST, }, + navigation: { + items: () => EMPTY_LIST, + }, timeline: EMPTY_TIMELINE, legacy: { nodes: EMPTY_LIST, diff --git a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts index f70e657014..0f8c37d1a1 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts @@ -6,13 +6,15 @@ import type { import type { ChatConversationViewNode, ChatNode } from '../contract/chat-nodes.ts' import { isRunningTool } from '../contract/chat-nodes.ts' import type { - ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, ConversationNode, - LegacyConversationSlice, PartialAssistant, RunningToolCall, + ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, ChatTurnNavigationIndex, ConversationNode, + LegacyConversationSlice, PartialAssistant, RunningToolCall, TurnNavigationItem, } from '../contract/snapshot.ts' import { sessionRecallLabels } from './event-projection.ts' +import { sameTurnNavigationItem, turnNavigationItem } from './turn-navigation.ts' const EMPTY_KEYS: readonly string[] = [] const EMPTY_TURNS: readonly number[] = [] +const EMPTY_ITEMS: readonly TurnNavigationItem[] = [] const EMPTY_LIST: readonly never[] = [] function sameReferences(left: readonly T[], right: readonly T[]): boolean { @@ -125,6 +127,61 @@ function updateIndex( return next } +/** + * Loaded-Turn rail projection accumulated alongside the node store: a + * structural change re-derives the Turn set, a content-only upsert re-derives + * only the Turns whose nodes moved, and the published array keeps its identity + * until an item actually changes. Renderers therefore consume final Turn data + * instead of scanning the loaded window per frame. + */ +class MutableTurnNavigationIndex implements ChatTurnNavigationIndex { + private current: readonly TurnNavigationItem[] = EMPTY_ITEMS + private byTurn = new Map() + + items(): readonly TurnNavigationItem[] { + return this.current + } + + /** Re-derive the whole Turn set; runs only when the loaded structure moves. */ + rebuild( + timeline: ConversationTimelineSnapshot, + locations: ChatLocationNodeIndex, + nodes: ChatNodeStore, + ): void { + const next: TurnNavigationItem[] = [] + const byTurn = new Map() + for (const turn of timeline.turnOrder) { + const derived = turnNavigationItem(turn, locations, nodes) + if (derived === undefined) continue + const previous = this.byTurn.get(turn) + const item = previous !== undefined && sameTurnNavigationItem(previous, derived) ? previous : derived + next.push(item) + byTurn.set(turn, item) + } + this.byTurn = byTurn + const unchanged = next.length === this.current.length + && next.every((item, index) => item === this.current[index]) + if (!unchanged) this.current = next + } + + /** Re-derive only the Turns a content-only upsert touched. */ + touch( + turns: ReadonlySet, + locations: ChatLocationNodeIndex, + nodes: ChatNodeStore, + ): void { + if (turns.size === 0) return + const next = this.current.map((item) => { + if (!turns.has(item.turn)) return item + const derived = turnNavigationItem(item.turn, locations, nodes) + if (derived === undefined || sameTurnNavigationItem(item, derived)) return item + this.byTurn.set(item.turn, derived) + return derived + }) + if (next.some((item, index) => item !== this.current[index])) this.current = next + } +} + function stepKey(turn: number, step: number): string { return `${turn}:${step}` } @@ -479,9 +536,12 @@ function partialContributionChanged( export class ChatSnapshotBuilder implements ConversationViewBuilder { private readonly store = new MutableChatNodeStore() private readonly locations = new MutableChatLocationIndex() + private readonly navigation = new MutableTurnNavigationIndex() private readonly legacy = new LegacySliceBuilder() private readonly referenceLabels = new ReferenceLabelProjector() private order: readonly string[] = EMPTY_KEYS + /** Last published timeline: a Turn boundary can land without a new node. */ + private timeline: ConversationTimelineSnapshot | null = null readonly empty: ChatSnapshot constructor() { @@ -496,6 +556,8 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder node.key) this.locations.rebuild(this.order, this.store) + this.navigation.rebuild(input.timeline, this.locations, this.store) + this.timeline = input.timeline return this.snapshot(input.timeline, this.legacy.replace(nodes, input.timeline)) } @@ -522,6 +584,12 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder { + const turns = new Set() + for (const node of nodes) { + const turn = locationCoordinates(node.location).turn + if (turn !== undefined) turns.add(turn) + } + return turns +} + function locationIdentity(location: ConversationLocation): string { const coordinates = locationCoordinates(location) return `${location.kind}:${coordinates.turn ?? ''}:${coordinates.step ?? ''}` diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-navigation.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-navigation.ts new file mode 100644 index 0000000000..9d54e058c1 --- /dev/null +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-navigation.ts @@ -0,0 +1,71 @@ +import type { ChatNode } from '../contract/chat-nodes.ts' +import type { ChatLocationNodeIndex, ChatNodeStore, TurnNavigationItem } from '../contract/snapshot.ts' + +/** + * Preview budget per field. The rail clamps two short lines, so anything past + * this is invisible; copying whole transcripts into navigation state would + * otherwise grow with the loaded window on every structural update. + */ +const PREVIEW_LIMIT = 160 + +/** Join rendered text until the preview budget is met, then stop reading. */ +function preview(parts: Iterable): string { + let text = '' + for (const part of parts) { + text += text === '' ? part : ` ${part}` + if (text.length >= PREVIEW_LIMIT) break + } + return text.replace(/\s+/g, ' ').trim().slice(0, PREVIEW_LIMIT) +} + +function promptText(node: ChatNode): string { + if (node.kind !== 'user') return '' + return preview(node.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])) +} + +function responseText(node: ChatNode): string { + if (node.kind !== 'assistant-step') return '' + return preview(node.data.blocks.flatMap(block => block.kind === 'text' ? [block.text] : [])) +} + +/** + * Whether two items carry the same rail state, so the reader can keep its array. + * @param left - previously published item, when the Turn had one. + * @param right - freshly derived item, when the Turn still has one. + * @returns whether both sides describe the same mark. + */ +export function sameTurnNavigationItem( + left: TurnNavigationItem | undefined, + right: TurnNavigationItem | undefined, +): boolean { + if (left === undefined || right === undefined) return left === right + return left.turn === right.turn && left.anchorKey === right.anchorKey + && left.prompt === right.prompt && left.response === right.response +} + +/** + * Project one loaded Turn into its rail item. + * @param turn - Turn number the item addresses. + * @param locations - live Location index supplying the Turn's node keys. + * @param nodes - live Chat node store. + * @returns the item, or undefined when the Turn has no visible loaded node. + */ +export function turnNavigationItem( + turn: number, + locations: ChatLocationNodeIndex, + nodes: ChatNodeStore, +): TurnNavigationItem | undefined { + const loaded = locations.getTurn(turn) + .map(key => nodes.get(key)) + .filter((node): node is ChatNode => node !== undefined && node.visibility === 'visible') + const user = loaded.find(node => node.kind === 'user') + const anchor = user ?? loaded[0] + if (anchor === undefined) return undefined + const response = loaded.findLast(node => responseText(node) !== '') + return { + turn, + anchorKey: anchor.key, + prompt: user === undefined ? '' : promptText(user), + response: response === undefined ? '' : responseText(response), + } +} diff --git a/packages/client/ui-chat/src/client/index.ts b/packages/client/ui-chat/src/client/index.ts index c04c2c588e..60165ddd39 100644 --- a/packages/client/ui-chat/src/client/index.ts +++ b/packages/client/ui-chat/src/client/index.ts @@ -13,10 +13,11 @@ export type {} from './conversation-nodes/turn-tail.ts' export type { AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig, - AssistantTiming, ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, CommandNode, - CompactionSummaryNode, ContextMessageNode, ConversationNode, LegacyConversationSlice, - ModelRetryNode, PartialAssistant, RunningToolCall, SteeringMessageNode, ToolCallBlock, - ToolResultNode, TurnErrorNode, TurnMaxTokensNode, UnknownSurfaceNode, UserMessageNode, + AssistantTiming, ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, ChatTurnNavigationIndex, + CommandNode, CompactionSummaryNode, ContextMessageNode, ConversationNode, + LegacyConversationSlice, ModelRetryNode, PartialAssistant, RunningToolCall, + SteeringMessageNode, ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode, + TurnNavigationItem, UnknownSurfaceNode, UserMessageNode, } from './contract/snapshot.ts' export type { AssistantChatData, ChatConversationViewNode, ChatNode, ChatNodeKind, diff --git a/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts b/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts index a6033c4c8c..dce67d27ec 100644 --- a/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts +++ b/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts @@ -1,12 +1,15 @@ import type { AssistantMessageNode, ChatConversationViewNode, ChatSnapshot, ConversationNode, ChatLocationNodeIndex, ChatNodeStore, CompactionSummaryNode, LegacyConversationSlice, - PartialAssistant, RunningToolCall, ToolCallBlock, + PartialAssistant, RunningToolCall, ToolCallBlock, TurnNavigationItem, } from '@deepseek-ai/dsh-client-ui-chat/client' import type { ConversationLocationDataStore, ConversationTurnDataMap, TurnLocation, } from '@deepseek-ai/dsh-client-ui-conversation/client' import { deriveTurnMetrics } from '../src/client/contract/turn-metrics.ts' +import { + sameTurnNavigationItem, turnNavigationItem, +} from '../src/client/conversation-nodes/turn-navigation.ts' const EMPTY: readonly never[] = [] @@ -290,10 +293,19 @@ export function chatSnapshotFixture(input: { && previous.legacy.turnEnds === legacy.turnEnds ? previous.timeline : { turnOrder: [...turns.keys()], turns } + const derived = timeline.turnOrder + .map(turn => turnNavigationItem(turn, locations, store)) + .filter((item): item is TurnNavigationItem => item !== undefined) + const kept = previous?.navigation.items() ?? [] + const items = kept.length === derived.length + && derived.every((item, index) => sameTurnNavigationItem(kept[index], item)) + ? kept + : derived return { order, nodes: store, locations, + navigation: { items: () => items }, timeline, legacy, } diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index de703920fc..1f04ad37f5 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -32,7 +32,6 @@ import { } from '../src/client/chat/MessageItem.tsx' import { TurnTailNodeView } from '../src/client/chat/TurnTailNodeView.tsx' import { formatRunDuration } from '../src/client/chat/message-chrome.ts' -import { deriveTurnNavigationItems } from '../src/client/chat/turn-navigation.ts' import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts' afterEach(() => { @@ -423,7 +422,7 @@ describe('ChatView', () => { ], turnEnds: new Map([[1, 3], [2, 6]]), }) - expect(deriveTurnNavigationItems(snapshot)).toEqual([ + expect(snapshot.navigation.items()).toEqual([ { turn: 1, anchorKey: 'fixture:user:1', prompt: 'first prompt', response: 'first response' }, { turn: 2, anchorKey: 'fixture:user:4', prompt: 'second prompt', response: 'second response' }, ]) diff --git a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts index 88b103ae66..f720f4a8bc 100644 --- a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts +++ b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts @@ -140,6 +140,46 @@ describe('built-in conversation node Definitions', () => { expect(chatViewDefinition.isActive?.(current)).toBe(false) }) + it('keeps the Turn rail projection current when a chunk updates one node in place', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'user/message', textMessage('user-1', 'navigate here'), { surfaceOp: 'append' }), + at(3, 'step/start', { turn: 1, step: 1 }), + at(4, 'assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'text-delta', index: 0, text: 'first' }, + }), + ]) + const opening = snapshot(value).navigation.items() + expect(opening).toHaveLength(1) + expect(opening[0]?.turn).toBe(1) + expect(opening[0]?.prompt).toBe('navigate here') + expect(opening[0]?.response).toBe('first') + + // Content-only upsert: the node keeps its key, so the rail's preview has to + // follow the in-place update rather than the last structural publication. + value.append(at(5, 'assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'text-delta', index: 0, text: ' and more' }, + })) + value.flush() + const streamed = snapshot(value).navigation.items() + expect(streamed[0]?.response).toBe('first and more') + expect(streamed).not.toBe(opening) + }) + + it('bounds each rail preview instead of copying the whole transcript', () => { + const long = 'x'.repeat(400) + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'user/message', textMessage('user-1', long), { surfaceOp: 'append' }), + ]) + const items = snapshot(value).navigation.items() + expect(items[0]?.prompt.length).toBe(160) + }) + it('keeps one keyed Assistant node while streaming settles and materializes interruption from Location', () => { const value = assembler([ at(1, 'turn/start', { turn: 1 }), diff --git a/packages/client/ui-tool/tests/tool-details-render.client.tsx b/packages/client/ui-tool/tests/tool-details-render.client.tsx index b915b39de9..7021928ee3 100644 --- a/packages/client/ui-tool/tests/tool-details-render.client.tsx +++ b/packages/client/ui-tool/tests/tool-details-render.client.tsx @@ -56,6 +56,7 @@ export function toolChatSnapshot( getTurn: () => empty, getStep: () => empty, }, + navigation: { items: () => [] }, timeline: { turnOrder: [], turns: new Map() }, legacy: { nodes: settled, diff --git a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx index 4faba04c00..faff18b4da 100644 --- a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx @@ -66,6 +66,7 @@ const chatState: ChatState = { order: emptyKeys, nodes: { get: () => undefined, values: () => [] }, locations: { getTurn: () => emptyKeys, getStep: () => emptyKeys }, + navigation: { items: () => [] }, timeline: { turnOrder: [], turns: new Map() }, legacy: { nodes: [], diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx index a650dd6a5b..e6cf481fcc 100644 --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx @@ -64,6 +64,7 @@ const chatState: ChatState = { order: emptyKeys, nodes: { get: () => undefined, values: () => [] }, locations: { getTurn: () => emptyKeys, getStep: () => emptyKeys }, + navigation: { items: () => [] }, timeline: { turnOrder: [], turns: new Map() }, legacy: { nodes: [], From 87ac9f5beae1ee596310b9646cca996bbcecd7d6 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 26 Aug 2026 10:21:49 +0800 Subject: [PATCH 4/4] fix(notices): restore the SDK version the lockfile installs The generator names the first matching virtual-store directory, so a local store still holding an older SDK payload alongside the locked one renders that older version into the notices. --- THIRD_PARTY_NOTICES.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index e079fa8c57..03c01e88af 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -113,18 +113,18 @@ pnpm applies local patches to the following packages at install time, so shipped The project owner authorizes distribution of every version of the official `@anthropic-ai/claude-agent-sdk` package and the official Claude Code CLI/platform payloads that each version declares through `optionalDependencies`. This identity-scoped authorization does not classify their declared terms as permissive and does not cover any unrelated runtime package; version, declared-license, and payload-set changes still require the ordinary dependency, lockfile, compatibility, terms, and notices review. -The installed SDK 0.3.220 declares the following optional platform packages. Each carries the official Claude Code 2.1.220 executable; the package identities and versions come from the SDK manifest, while the declared license field is verified against the platform payload installed for the current host. +The installed SDK 0.3.241 declares the following optional platform packages. Each carries the official Claude Code 2.1.241 executable; the package identities and versions come from the SDK manifest, while the declared license field is verified against the platform payload installed for the current host. | Optional platform package | Version | Declared license | | --- | --- | --- | -| [`@anthropic-ai/claude-agent-sdk-darwin-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-arm64) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-darwin-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-x64) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-linux-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-linux-arm64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64-musl) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-linux-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-linux-x64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64-musl) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-win32-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-arm64) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-win32-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-x64) | 0.3.220 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-darwin-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-arm64) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-darwin-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-x64) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-arm64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64-musl) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-x64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64-musl) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-win32-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-arm64) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-win32-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-x64) | 0.3.241 | SEE LICENSE IN LICENSE.md | ## Development-only npm dependencies