Merge pull request #489 from omdsh-dev/feat/sidebar-perf

perf: 侧边栏性能优化(核心包 -45%)+ 修复底栏拖拽引发的原生左栏突变
This commit is contained in:
Atlas Zeng
2026-09-02 21:33:49 +08:00
committed by GitHub
27 changed files with 972 additions and 155 deletions
+1 -1
View File
@@ -752,7 +752,7 @@ ctx.effect(() =>
- **面板表面**:右/底面板背景 = `var(--dsw-alias-bg-layer-1)`。**绝不消费 `--dsw-specific-sidebar-fill`**(宿主左导航专属,皮肤按左导航语义覆盖它,面板消费会失去填充)。换面板表面 = 覆写 `--dsw-alias-bg-layer-1`。
- **终端/编辑器表面**:`effectiveTokenValue` 读 `--dsw-alias-bg-base`——`transparent` 与 alpha < 0.9 的半透明值回退不透明底色(文字不叠背景画,issue #90);≥ 0.9 放行。
- **根锚点**:宿主 div 带 `data-dsh-better-sidebar`(append 到 body);其内**面板宿主层** `[data-dsh-panel-host]`(`fixed; inset:0; z-25; pointer-events:none`,v0.13.1+),面板/开关簇 absolute 定位,免疫中间层 transform 劫持;页面级 transform 触发 `data-dsh-panel-host-degraded` 降级。皮肤作用域覆盖限定在 `[data-dsh-better-sidebar]` 内。
- **布局变量**(`<html>` 上,面板打开时有效):`--dsh-sidebar-width` / `--dsh-sidebar-height`。宿主推挤 = `#root` margin/width calc + centerCol `margin-bottom`;锚点用**复合选择器双保险**(禁止 `nth-child`):`#root [data-dsh-frame] > [data-pane="conversation"]` 与 `#root :has(> [data-slot="conversation"])`(两代命名同元素,`drag-layout.e2e.ts` 断言)。
- **布局变量**(`<html>` 上,面板打开时有效):`--dsh-sidebar-width` / `--dsh-sidebar-height`。宿主推挤 = `#root` margin/width calc + centerCol `margin-bottom`;锚点 = **JS 标注**(禁止 `nth-child`):侧栏 shell 的定位器给测得的 centerCol 节点打 `[data-dsh-center-col]` 标签(`Sidebar.tsx` locate,节点更换/HMR 时随 ref 迁移),`layout.css` 用 `#root [data-dsh-center-col]` 选中(`drag-layout.e2e.ts` 断言恰一节点且为 `[data-slot="conversation"]` 的父级)。
- **桌面信号与标题栏**(v0.14.1+ 四方案模型 `SidebarPrefs.titleBarScheme`,唯一决策点 `src/client/titlebar-strip.ts` 纯函数):
- 壳信号(只读,不自动触发修改):URL `dsh-desktop-mode` / `dsh-desktop-platform` / 可选 `dsh-desktop-titlebar-inset`(0–120 clamp)。
- **strip 取值链**:⓪ `web` 方案强制 0;① `navigator.windowControlsOverlay` 真实几何(`wco.ts` 订阅 `geometrychange`,**为 0 也权威**,`visible=false` 幽灵 API 视为缺失);② URL inset;③ 壳预设 `stripFor`(仅 `preset`);④ 手动 `titleBarStripPx`(仅 `custom`);⑤ 0。驱动 `body[data-dsh-title-bar-compat]` + `--dsh-title-bar-strip`。
@@ -0,0 +1,87 @@
# 侧边栏性能优化 + 底栏拖拽布局突变修复
**日期**:2026-08-31(2026-09-01 rebase 到 main c199212 并在 alpha.3 上复测)
**状态**:已实施(分支 `feat/sidebar-perf`,单 PR 多 commit)
**目标宿主**:DSH v0.1.2-alpha.2 起开发,宿主参考 `/Users/menghuan/Code/deepseek-harness`(只读);main 已切 alpha.3(v0.18.1-alpha.0),rebase 后在 alpha.3 真机全量复测(§3.1)
**测量环境**:macOS arm64 本机,真实 `dsh web`(npx 钉版)+ scratch profile 挂载 tarball,Playwright Chromium 无头,每指标 3 次取中位数(`tests/e2e/perf.e2e.ts` 输出 `PERF_JSON`)。
## 1. 修复的 bug:底栏拖拽引发原生左侧边栏突变
**症状**:右侧面板关闭时拖动底栏 resizer,DSH 原生左侧边栏突然折叠/跳动;松手后弹回。插件自己的右侧边栏不受影响。
**根因**(源码级定位,`Sidebar.tsx`):底栏拖拽的 `applyDrag` 把「持久化的右栏宽度偏好」(如 400px)无条件写进 `--dsh-sidebar-width`,不看 `panelOpen`;而静止时的 push effect 走 `layoutPushSize` 门控(面板关闭 → 0)。于是拖拽首帧该变量 0 → ~400px:
1. `layout.css` 的 `#root { margin-right; width: calc(100% - …) }` 瞬间挤掉宿主 400px(拖拽期间 transition 被 `body[data-dsh-sidebar-dragging]` 关闭——瞬跳而非动画);
2. 宿主 `AppFrame` 的 ResizeObserver → `setViewport` → viewport 跌破 1024px 断点 → `computeColumns` 把原生左栏折叠成 56px rail(0.3s grid 轨道动画);
3. 松手 `commitDrag` → store 提交 → push effect 又写回 0 → `#root` 弹回 → 左栏再展开。
插件右栏在 `#root` 之外的 `document.body` fixed 层(`[data-dsh-panel-host]`),几何上碰不到——这正是「左栏突变、右栏无恙」的不对称来源。底栏是**唯一**在 `panelOpen === false` 时可达的拖拽(宽度/角点拖拽都要求面板开着),所以只有它触发。`abortDrag` 路径此前已修过(走 `layoutPushSize` 门控),move/up 路径漏网。
**修复**:`applyDrag` 里 `writeGeometry` 的 width 实参加上与 push effect 一致的门控(`!narrow && panelOpen ? min(width, vw) : 0`);面板 DOM 写入(`panelRef`/`bottomRef`/`lastDragSize`)保持原始 width(底栏 `right` 定位派生自它)。
**验证**:e2e 回归(`drag-layout.e2e.ts`)——右面板关闭 + 底栏打开拖 strip,逐帧断言 pushed width 恒 0、宿主 grid 模板 / 左栏渲染宽度 / `#root` 右边缘全程不变。旧代码上实测泄漏 504px(红),修复后通过(绿)。
## 2. 性能改动清单
| # | 改动 | 类别 | commit |
|---|---|---|---|
| 1 | boot 路径 `loadPrefs` + `loadExternalDisable` 合并为一次 `settingsGet`(原来同文档串行拉两次,第二个无超时——settings RPC 挂起则侧边栏永不出现) | 启动 | perf: cut the boot and poll overhead |
| 2 | GitLens 2s silent 轮询不再每 tick 重列 worktrees(每 tick 少 spawn 1 个 git 进程;每 15 tick ≈30s 才重列+自动选择一次) | 运行时开销 | 同上 |
| 3 | ChangesTab 的 `extractFileOps` fold 从「每 poll 两遍(badge 一遍 + render 一遍)」改为每 poll 一遍、结果进 ref 共享(4000 条事件 × 每条 JSON.parse) | CPU | 同上 |
| 4 | SideChat 转录行引用复用:`transcriptRows` 接受上次结果,位置对齐内容相同的行复用旧对象——2s 轮询只重渲染变化的尾部(原来全部行连同 markdown 重解析每轮重渲染) | 渲染 | perf: stabilize renders |
| 5 | FileTree `expanded`/`revealed` 数组 → Set(原来每行 `includes()` 线性扫描,整树 O(rows×expanded)) | 渲染 | 同上 |
| 6 | `buildNewTabOptions` 从两处 JSX 内联调用改为共享 memo(原来每次 render 两个新数组,击穿 LeafView 的 props 比较) | 渲染 | 同上 |
| 7 | DiffPane 高度拖拽、TerminalView ResizeObserver 走每帧批处理(原来每 pointer/resize 事件一次 setState / 一次含字形度量的 fit());SessionLens 的 `new Blob().size`(整段编辑内容的 UTF-8 编码)从每行每 render 移到每变化一次的 memo | 渲染 | 同上 |
| 8 | 19 个非 zh/en 字典移入 lazy `locale` chunk(`lib/client-locale.js`,仅安装 better-locale 时按需拉取;核心包只留 zh/en——`t()` 本来也只查 zh/en + override store) | 启动体积 | perf: move the 19 dictionaries |
| 9 | 底栏推挤锚点从 `:has(> [data-slot=conversation])` 换成 shell 定位器打的 `[data-dsh-center-col]` 标注(`:has()` 匹配缓存随流式聊天的 #root 子树变动不断失效,每次布局推挤帧全文档重估) | 拖拽帧成本 | perf: tag the center column |
| 10 | TextEditor 不再每键 `doc.toString()` 全文字符串化进 React state(O(docLength)/键);listener 只翻 dirty,进预览/换文件时从 live view 快照一次;保存本就现读 | 编辑 | 同上 |
## 3. Before / After 对比
测量说明:`mount-sweep` = 冷导航 → 插件挂载 → 展开面板 → 依次打开全部 6 个内置 tab;`bottom-drag` = 面板关闭 + 底栏打开时拖 12 步的 rAF 帧间隔。同机同夜连续跑,数字用于相对比较而非绝对承诺。
| 指标 | before(main d70db8f) | after(本分支) | 变化 |
|---|---|---|---|
| 挂载时延(navigationStart → host attach,中位) | 376.9 ms | 272.9 ms | **-28%** |
| 挂载+扫 tab 期间 longtask 数 / 总时长 / 最长 | 2 / 161 ms / 96 ms | 2 / 146 ms / 84 ms | 总时长 -9%,最长 -13% |
| 核心 client.js 体积(未压缩) | 1.46 MB | 0.80 MB | **-45%** |
| 宿主聚合 client bundle 传输量(含全部插件,gzip) | 1,436,478 B | 1,233,778 B | **-203 KB / -14%** |
| 宿主聚合 client bundle 解码体积 | 5,136,894 B | 4,497,363 B | -640 KB(即字典总量,吻合) |
| 侧栏 shape 的资源请求数 / 其中 settings.get | 20 / ×4 | 18 / ×2 | boot 合并生效 |
| 底栏拖拽帧间隔 p50 / p95 / max | 8 / 10 / 11 ms | 8 / 10 / 11 ms | 持平(本就不掉帧;修复的是布局突变) |
| 底栏拖拽 pushed width 泄漏(右面板关闭时) | **504 px**(bug) | **0 px** | 修复 |
### 结构性收益(不随单次测量波动)
- **核心 bundle**:19 个字典(源码 ~640KB)出核心包,`client.js` 1.46MB → 822KB(构建产物实测)。绝大多数会话(未装 better-locale)永远不拉 `client-locale.js`(gzip 171KB);装了的用户按需拉一次(HTTP 缓存 + ETag 复用)。
- **boot RPC**:settings 文档从 2 次串行 → 1 次;`loadExternalDisable` 有了 2s 超时兜底。
- **GitLens 轮询的 git 进程 spawn**:每分钟 60 个(2 个/tick × 30 tick)→ ~34 个(1 个/tick + 每 15 tick 多 1 个),-43%。
- **ChangesTab fold**:每 2.5s poll 的 fold 次数 2 → 1(每次 fold 对至多 4000 条事件逐条处理、tool/call 还要 `JSON.parse`)。
- **SideChat 转录**:内容未变的行不再重建对象 → 下游 React reconcile + markdown 解析 + DOMPurify 全部跳过(引用稳定性由 `sidechat-transcript.spec.ts` 契约钉住)。
- **TextEditor**:每次按键的 O(文档长度) 字符串化 + shell 重渲染 → 0(仅 dirty 布尔)。
### 3.1 alpha.3 复测(rebase 到 main c199212 之后)
main 在本分支开发期间合入了 alpha.3 适配(peer `^0.1.2-alpha.3`,v0.18.1-alpha.0)与 sidechat 渲染升级(新增 `turnSummary` 行 kind 与 tool `card`——`rowsEqual` 已补齐两者,card 只在 tool/result 落地时变化、被 `executing`/`failed`/`resultText` 的比较覆盖,无需值比较)。rebase 后在 alpha.3 真机重测(同法 3 次中位,before 侧 = main c199212 代码 + 本分支的测量工具):
| 指标 | before(main c199212) | after(本分支) | 变化 |
|---|---|---|---|
| 挂载时延(中位) | 372.3 ms | 316.5 ms | **-15%** |
| longtask 总时长 / 最长 | 170 / 100 ms | 165 / 96 ms | -3% / -4% |
| 宿主聚合 bundle 传输 | 1,456,668 B | 1,246,595 B | **-210 KB / -14%** |
| boot settings.get | ×4 | ×2 | 合并生效 |
| 底栏拖拽 pushed width 泄漏 | **504 px**(复现) | **0 px** | 修复 |
全部收益在新 main 基线上成立;完整 mount lane(17 测试)在 alpha.3 真机全绿。§3 主表为 alpha.2 上的原始测量记录(两端同宿主,对比自洽),保留作历史基线。
## 4. 守护
- e2e:`drag-layout.e2e.ts` 新增底栏回归(宿主布局全程静止)与 center-col 标注断言(恰一节点、为 conversation slot 的父级);`perf.e2e.ts` 的 width-leak 断言在每次测量 lane 里复检。
- 单测:`prefs.spec.ts`(boot 决策单次 fetch)、`sidechat-transcript.spec.ts`(行引用复用契约)、`locales.spec.ts`(chunk 内全部字典 key-set 与 zh 一致)、`layout-css.spec.ts`(锚点选择器契约,禁 `:has` 回潮)、`bundle-route.spec.ts`(chunk 清单含 locale)。
- 完整 mount lane(17 测试)在本分支全绿(alpha.2 与 alpha.3 真机各跑一轮)。
## 5. 明确不做(取舍)
- `pinnedEntries` 的 store 版本号改造、连续 `store.reduce` 合并:侵入 store API,收益不明确,KISS 取舍。
- FileTree / SideChat 列表虚拟化:Set 化 + 引用复用后若真实负载仍不达标再立项。
- `e2e-mount.sh` 的 `command -v "$DSH_CMD"` 对带空格命令失效(回退未钉版本的 npx)是**宿主侧脚本的既有行为**,本地以 `DSH_CMD=/tmp/dsh-alpha2.sh` 包装脚本绕过,不改脚本(CI 全局安装钉版本,不受影响)。
+1 -1
View File
@@ -19,7 +19,7 @@ import { fileURLToPath } from 'node:url'
import type { Context, SidebarHttpRequest, SidebarHttpResponse } from './context-types.ts'
/** The chunk names the client may request (mirror of src/client/chunk-loader.ts). */
export const CHUNK_NAMES = ['terminal', 'editor', 'mermaid'] as const
export const CHUNK_NAMES = ['terminal', 'editor', 'mermaid', 'locale'] as const
export type ChunkName = (typeof CHUNK_NAMES)[number]
/** Directory of this host-half module (lib/ — the chunk scripts live next to it). */
+12 -6
View File
@@ -20,7 +20,7 @@
* (VSCode semantics — a drop on a file row targets its parent directory),
* and `busy` gates new drags while one upload is in flight.
*/
import { useCallback, useEffect, useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import clsx from 'clsx'
import {
@@ -441,6 +441,12 @@ export function FileTree(props: {
const root = cwd
// Membership is tested per rendered row (deep trees run this thousands of
// times per render): includes() made every row O(expanded), the whole tree
// O(rows × expanded). One Set per render keeps it O(1) per row.
const expandedSet = useMemo(() => new Set(expanded), [expanded])
const revealedSet = useMemo(() => new Set(revealed), [revealed])
const renderLevel = (dir: string, depth: number): ReactNode => {
const level = data[dir]
if (level === undefined) {
@@ -466,7 +472,7 @@ export function FileTree(props: {
const entries = level.entries ?? []
return entries.map(entry => {
if (entry.isDir) {
const isOpen = expanded.includes(entry.path)
const isOpen = expandedSet.has(entry.path)
return (
<div key={entry.path}>
<div
@@ -475,9 +481,9 @@ export function FileTree(props: {
className={clsx(
css.explorerRow, css.explorerDir, entry.hidden && css.explorerHidden,
dropTarget === entry.path && css.explorerRowDropTarget,
revealed.includes(entry.path) && css.explorerRowRevealed,
revealedSet.has(entry.path) && css.explorerRowRevealed,
)}
data-dsh-revealed={revealed.includes(entry.path) ? 'true' : undefined}
data-dsh-revealed={revealedSet.has(entry.path) ? 'true' : undefined}
style={{ paddingLeft: depth * 22 + 6 }}
onClick={() => { onToggle(entry.path) }}
onKeyDown={(event) => {
@@ -507,9 +513,9 @@ export function FileTree(props: {
className={clsx(
css.explorerRow, entry.hidden && css.explorerHidden, entry.broken && css.explorerBroken,
dropTarget === parentOf(entry.path) && css.explorerRowDropTarget,
revealed.includes(entry.path) && css.explorerRowRevealed,
revealedSet.has(entry.path) && css.explorerRowRevealed,
)}
data-dsh-revealed={revealed.includes(entry.path) ? 'true' : undefined}
data-dsh-revealed={revealedSet.has(entry.path) ? 'true' : undefined}
style={{ paddingLeft: depth * 22 + 6 }}
title={entry.broken ? `${entry.path} — ${t('brokenSymlink')}` : entry.path}
onClick={() => { onOpenFile(entry.path) }}
+11 -2
View File
@@ -383,6 +383,8 @@ export function SideChatView(props: {
const [menuOpen, setMenuOpen] = useState(false)
const cacheRef = useRef<ThreadCache>({ entries: [] })
// The previous poll's rows (see the mapping's reuse pass below).
const prevRowsRef = useRef<SidechatTranscriptRow[]>([])
const controllerRef = useRef<AbortController | null>(null)
const scrollRef = useRef<HTMLDivElement | null>(null)
const composerRef = useRef<HTMLTextAreaElement | null>(null)
@@ -480,6 +482,7 @@ export function SideChatView(props: {
// the composer — it owns the first message of a fresh thread.
useEffect(() => {
cacheRef.current = { entries: [] }
prevRowsRef.current = []
controllerRef.current?.abort()
setError(null)
setSaved(false)
@@ -517,8 +520,14 @@ export function SideChatView(props: {
}
}, [connectionState, threadId, fetchThread, fetchInfo])
const rows = useMemo(
() => (threadId === undefined ? [] : transcriptRows(cacheRef.current.entries)),
// The previous poll's rows ride into the mapping so unchanged rows keep
// their object identity (see reuseRows): the 2s poll re-renders only the
// changed tail instead of re-parsing markdown for the whole transcript.
const rows = useMemo(() => {
const next = threadId === undefined ? [] : transcriptRows(cacheRef.current.entries, prevRowsRef.current)
prevRowsRef.current = next
return next
},
// The cache is a ref; revision bumps on every successful pull.
// eslint-disable-next-line react-hooks/exhaustive-deps
[threadId, revision],
+32 -4
View File
@@ -407,6 +407,20 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
}, [sessionId, summaryCwd])
const cwd = summaryCwd ?? fetchedCwd
// The + menu options ride a memo so the two Workbenches share ONE array
// identity across renders that did not change the store (drag state,
// viewport resize): fresh arrays per render re-rendered every LeafView's
// + affordance whether or not anything tab-related moved.
const newTabOptions = useMemo(
() => (state === undefined || sessionId === undefined ? [] : buildNewTabOptions(state, ctx, { sessionId, cwd })),
// state is the whole session state — every field it wraps is fair game
// for the descriptors' available() callbacks. (The render's own guard
// sits below every hook; this memo must handle the no-session case
// itself.)
// eslint-disable-next-line react-hooks/exhaustive-deps
[state, ctx, sessionId, cwd],
)
/**
* Agent terminals push: subscribe to the host's live list of agent-owned
* terminals for this session (created by the model through the
@@ -742,6 +756,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
?.parentElement as HTMLElement | undefined
if (col === undefined || !col.isConnected) {
if (centerColRef.current !== null) {
centerColRef.current.removeAttribute('data-dsh-center-col')
centerColRef.current = null
observer?.disconnect()
observer = undefined
@@ -754,8 +769,13 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// measure it once. Same-node size changes are the ResizeObserver's
// job — no forced measurement here, because a forced
// getBoundingClientRect per mutation would reflow the shell at
// mutation cadence.
// mutation cadence. The tag retargets with the ref: layout.css's
// bottom-push rule anchors on [data-dsh-center-col], so exactly the
// measured node carries it (a stale tag on a swapped-out node would
// leave the push rule anchorless or doubled).
centerColRef.current?.removeAttribute('data-dsh-center-col')
centerColRef.current = col
col.setAttribute('data-dsh-center-col', '')
observer?.disconnect()
observer = new ResizeObserver(measureCenter)
observer.observe(col)
@@ -808,6 +828,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
observer?.disconnect()
watcher.disconnect()
htmlStyleWatcher.disconnect()
centerColRef.current?.removeAttribute('data-dsh-center-col')
centerColRef.current = null
}
// Opening the bottom panel re-runs the whole locate/measure chain: a
@@ -993,7 +1014,14 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// drag write-only (no React re-render mid-drag).
bottomRef.current?.style.setProperty('right', `${(window.innerWidth - centerRectRef.current.right) + (width - (state?.width ?? 0))}px`)
const bottomPush = !narrow && state?.bottomOpen === true ? height + keyboardInset : 0
writeGeometry(width, bottomPush)
// The pushed width must ride the same gate as the committed push effect
// (layoutPushSize): a collapsed right panel pushes 0. The bottom strip is
// the only drag reachable with the panel closed — writing the panel's
// persisted width preference here squeezed #root mid-drag, dropped the
// host viewport across its 1024px auto-collapse breakpoint, and the
// native left sidebar snapped to its 56px rail (and back on release).
const pushWidth = !narrow && state?.panelOpen === true ? Math.min(width, window.innerWidth) : 0
writeGeometry(pushWidth, bottomPush)
}
// Drags write at most once per frame: pointer events fire several times
@@ -1574,7 +1602,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
<Workbench
state={state}
tree={augmentedTree}
newTabOptions={buildNewTabOptions(state, ctx, { sessionId, cwd })}
newTabOptions={newTabOptions}
actions={wrappedActions}
onNewTab={onNewTab}
renderTab={renderTab}
@@ -1729,7 +1757,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
<Workbench
state={state}
tree={state.bottomSplits}
newTabOptions={buildNewTabOptions(state, ctx, { sessionId, cwd })}
newTabOptions={newTabOptions}
actions={actions}
onNewTab={onNewTab}
renderTab={(tab, active, paneId) => renderTab(tab, active, paneId, 'bottom')}
+17 -6
View File
@@ -268,13 +268,23 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
const inputSub = term.onData((data) => {
if (socket !== null && socket.readyState === WebSocket.OPEN) socket.send(data)
})
// Resize streams fire per layout frame during panel open/close
// animations; fit() measures glyphs, so coalesce to one fit per
// animation frame (same pattern as the core's frame-batcher, kept
// local — this view lives in the lazy terminal chunk and does not
// import core-bundle modules).
let resizeFrame: number | null = null
const observer = new ResizeObserver(() => {
try {
fit.fit()
sendResize()
} catch {
// The terminal may be mid-dispose; ignore.
}
if (resizeFrame !== null) return
resizeFrame = requestAnimationFrame(() => {
resizeFrame = null
try {
fit.fit()
sendResize()
} catch {
// The terminal may be mid-dispose; ignore.
}
})
})
observer.observe(host)
@@ -323,6 +333,7 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
cancelOpen()
window.clearTimeout(retry)
observer.disconnect()
if (resizeFrame !== null) cancelAnimationFrame(resizeFrame)
fontSub()
schemeSub()
inputSub.dispose()
+18 -6
View File
@@ -114,11 +114,13 @@ export function TextEditor(props: FileViewerProps) {
}, [content])
// Create the CodeMirror editor once the content is loaded. The view owns
// the document; React only tracks dirty/draft state through the update
// listener. For markdown the view stays mounted while previewing (hidden),
// so unsaved edits survive the preview/edit toggle. The theme + syntax
// colors live in a compartment so a scheme flip reconfigures only that
// part — the document, undo history and scroll position survive.
// the document; React only tracks dirty state through the update listener
// (the draft — the preview's text — is snapshotted from the live view on
// entering preview, not re-stringified per keystroke). For markdown the
// view stays mounted while previewing (hidden), so unsaved edits survive
// the preview/edit toggle. The theme + syntax colors live in a compartment
// so a scheme flip reconfigures only that part — the document, undo
// history and scroll position survive.
useEffect(() => {
if (content === undefined) return
const host = hostRef.current
@@ -139,7 +141,6 @@ export function TextEditor(props: FileViewerProps) {
...(language !== null ? [language] : []),
CodeMirrorView.updateListener.of((update) => {
if (update.docChanged) {
setDraft(update.state.doc.toString())
setDirty(true)
}
}),
@@ -226,6 +227,17 @@ export function TextEditor(props: FileViewerProps) {
if (mode === 'edit') viewRef.current?.requestMeasure()
}, [mode])
// Snapshot the live document into the draft whenever the preview needs
// it: entering preview (the markdown preview renders `draft ?? content`)
// and a content swap (the view was just re-created above, so the read is
// the new document — matching the reset-to-null of a clean tab). The
// updateListener used to re-stringify the WHOLE document on every
// keystroke (O(docLength) per key) for a draft only preview reads.
useEffect(() => {
const view = viewRef.current
setDraft(view === null ? null : view.state.doc.toString())
}, [mode, content])
const save = (): void => {
const view = viewRef.current
if (view === null || savingRef.current) return
+11 -2
View File
@@ -63,6 +63,12 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
// up). The cursor is the last delivered seq, so each poll ships only
// what the accumulator lacks. ────────────────────────────────────────
const eventsRef = useRef<readonly SidebarSessionEvent[]>([])
// The fold of eventsRef as of the last poll. extractFileOps parses every
// accumulated tool/call (up to EVENTS_CAP events); running it once per
// poll and REUSING the result across renders (the render used to re-fold
// the whole window, twice per tick, and the fresh array defeated the
// downstream memo on every poll) keeps the 2.5s tick at one fold.
const opsRef = useRef<readonly FileOp[]>([])
const seqRef = useRef(0)
const pollGen = useRef(0)
const [opsError, setOpsError] = useState(false)
@@ -77,7 +83,9 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
eventsRef.current = merged.length > EVENTS_CAP ? merged.slice(merged.length - EVENTS_CAP) : merged
}
if (lastSeq > seqRef.current) seqRef.current = lastSeq
opCounts.set(scope.sessionId, extractFileOps(eventsRef.current).length)
const folded = extractFileOps(eventsRef.current)
opsRef.current = folded
opCounts.set(scope.sessionId, folded.length)
setOpsError(false)
setTick(value => value + 1)
} catch {
@@ -89,6 +97,7 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
useEffect(() => {
pollGen.current += 1
eventsRef.current = []
opsRef.current = []
seqRef.current = 0
setOpsError(false)
}, [scope.sessionId])
@@ -100,7 +109,7 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
}, [visible, pull])
// tick only forces the re-render; the fold reads the ref directly.
void tick
const ops = extractFileOps(eventsRef.current)
const ops = opsRef.current
/** Persist a meta patch onto the tab (lens choice, pane height). */
const patchMeta = (patch: ChangesMeta): void => {
+8 -1
View File
@@ -21,6 +21,7 @@ import { DiffFiles } from '../diff/DiffFiles.tsx'
import { langOfPath } from '../diff/highlight.ts'
import { buildDiffSegments, diffLines, diffStats, parseUnifiedDiff, unifiedSegments, type DiffRow } from '../diff/rows.ts'
import { parseReadLines, type FileOp } from './ops.ts'
import { createFrameBatcher } from '../frame-batcher.ts'
import css from './changes.module.css'
import diffCss from '../diff/diff.module.css'
@@ -176,17 +177,23 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
const paneHeight = dragHeight ?? height
const clamp = (value: number): number => Math.min(Math.max(value, HEIGHT_MIN), Math.round(window.innerHeight * 0.7))
const dragOrigin = useRef<{ y: number; h: number } | null>(null)
// Pointer streams fire several times per frame; one setState per event
// re-rendered the whole pane at event cadence (see frame-batcher).
const dragBatcher = useRef(createFrameBatcher()).current
useEffect(() => () => dragBatcher.dispose(), [dragBatcher])
const onHandleDown = (event: ReactPointerEvent<HTMLDivElement>): void => {
event.preventDefault()
dragOrigin.current = { y: event.clientY, h: paneHeight }
const onMove = (ev: PointerEvent): void => {
if (dragOrigin.current === null) return
setDragHeight(clamp(dragOrigin.current.h + (dragOrigin.current.y - ev.clientY)))
const next = clamp(dragOrigin.current.h + (dragOrigin.current.y - ev.clientY))
dragBatcher.schedule(() => { setDragHeight(next) })
}
const onUp = (): void => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
dragOrigin.current = null
dragBatcher.flushNow()
setDragHeight(current => {
if (current !== null) onHeightCommit(current)
return null
+19
View File
@@ -76,6 +76,13 @@ interface ConfirmState {
* floods the panel at once (the end of the log is reached by paging). */
const LOG_BATCH = 20
/** Every Nth silent poll re-lists worktrees (and re-runs auto-selection): the
* 2s tick only needs the selected checkout's STATUS, and re-listing spawned
* a second git process per tick for a list that almost never changes — a
* linked checkout the agent creates mid-session is picked up within ~30s
* instead of 2s. */
const WORKTREE_RECHECK_TICKS = 15
export interface GitLensProps {
scope: SessionScope
/** The sidebar store: reads the `workspaceFence` pref (see the open guard below). */
@@ -123,6 +130,8 @@ export function GitLens(props: GitLensProps) {
* callback and re-trigger the mount effect — an N→N+1 fetch loop). */
const chosenPathRef = useRef<string | undefined>(undefined)
useEffect(() => { chosenPathRef.current = selectedWorktree }, [selectedWorktree])
/** Silent polls since the last worktree re-list (see WORKTREE_RECHECK_TICKS). */
const silentTickCount = useRef(0)
const gitScope: SessionScope = repoRoot === undefined ? scope : { ...scope, repoRoot }
@@ -160,6 +169,15 @@ export function GitLens(props: GitLensProps) {
refreshInFlight.current = true
let generation = refreshGeneration.current
try {
// Fast path for the ordinary silent tick: the selected checkout's
// STATUS is all that changes between worktree re-lists (see
// WORKTREE_RECHECK_TICKS) — one git process instead of two.
if (silent && chosenPathRef.current !== undefined && (silentTickCount.current += 1) % WORKTREE_RECHECK_TICKS !== 0) {
const statusResult = await api.gitStatus(gitScope, chosenPathRef.current)
if (generation === refreshGeneration.current) setStatus(statusResult)
return
}
silentTickCount.current = 0
const listed = await api.gitWorktrees(scope)
if (generation !== refreshGeneration.current) return
setWorktrees(listed)
@@ -214,6 +232,7 @@ export function GitLens(props: GitLensProps) {
refreshInFlight.current = false
worktreeChosenByUser.current = false
chosenPathRef.current = undefined
silentTickCount.current = 0
setSelectedWorktree(undefined)
}, [scope.sessionId, scope.cwd])
useEffect(() => { void refresh() }, [refresh])
+16 -2
View File
@@ -54,6 +54,20 @@ export function SessionLens({ ops, loadError, onPreview, selectedCallId }: Sessi
</button>
)
// Sizing a row's content constructs a Blob (a UTF-8 encode of the whole
// edit) — that was per row PER RENDER, re-encoding every field on every
// poll tick. ops keeps its identity between unchanged polls (see the
// tab's opsRef), so one pass per changed fold covers every render.
const opSizes = useMemo(() => {
const sizes = new Map<string, string>()
for (const op of ops) {
if (op.kind !== 'read' && !op.isError) {
sizes.set(op.callId, formatBytes(new Blob([op.edit?.newString ?? op.content ?? '']).size))
}
}
return sizes
}, [ops])
return (
<div className={css.session}>
<div className={css.filterRow} role="group" aria-label={t('changesSessionLens')}>
@@ -85,9 +99,9 @@ export function SessionLens({ ops, loadError, onPreview, selectedCallId }: Sessi
{op.running && <span className={css.opFlag}>{t('changesRunning')}</span>}
{op.isError && <span className={css.opFlagError}>{t('changesError')}</span>}
<span className={css.opMeta}>
{op.kind !== 'read' && !op.isError && (
{opSizes.get(op.callId) !== undefined && (
<span className={css.opSize}>
{formatBytes(new Blob([op.edit?.newString ?? op.content ?? '']).size)}
{opSizes.get(op.callId)}
</span>
)}
<span className={css.opTime}>{relativeTime(new Date(op.time).toISOString())}</span>
+1 -1
View File
@@ -49,7 +49,7 @@
* client.js); an edit that does land while a core HMR happens is caught by
* the ETag comparison on the next activation.
*/
export type ChunkName = 'terminal' | 'editor' | 'mermaid'
export type ChunkName = 'terminal' | 'editor' | 'mermaid' | 'locale'
/** The module exports a chunk factory provides (namespace-ish record). */
export type ChunkExports = Record<string, unknown>
+60
View File
@@ -0,0 +1,60 @@
/**
* Lazy chunk entry: the 19 non-zh/en dictionaries (ja/de/fr/…/zh-HK/MO/TW —
* ~640KB of source). The sidebar's own `t()` only ever consults zh/en plus
* the better-locale override store, so these dicts are needed ONLY when
* @huanlin/dsh-plugin-better-locale is installed: they register into its
* override store from there (see the client apply's better-locale
* integration). Built as `lib/client-locale.js` and fetched from the
* plugin's /sidebar/bundle route on first need — never import this module
* from the core bundle: it would drag every language back into the startup
* path (see docs/plans/2026-08-31-perf-optimization.md).
*/
import { ja } from '../locales-ja.ts'
import { de } from '../locales-de.ts'
import { fr } from '../locales-fr.ts'
import { pt } from '../locales-pt.ts'
import { ko } from '../locales-ko.ts'
import { ar } from '../locales-ar.ts'
import { hi } from '../locales-hi.ts'
import { id } from '../locales-id.ts'
import { tr } from '../locales-tr.ts'
import { vi } from '../locales-vi.ts'
import { th } from '../locales-th.ts'
import { ru } from '../locales-ru.ts'
import { it } from '../locales-it.ts'
import { nl } from '../locales-nl.ts'
import { sv } from '../locales-sv.ts'
import { pl } from '../locales-pl.ts'
import { zhHK } from '../locales-zh-HK.ts'
import { zhTW } from '../locales-zh-TW.ts'
import { zhMO } from '../locales-zh-MO.ts'
import type { CopyKey } from '../locales.ts'
/** Key-set check against zh (type-only import — erased before bundling, so
* this adds no runtime tie to the core bundle): a dictionary missing or
* adding a key fails the build instead of silently falling back to en. */
const checked = (dict: Record<CopyKey, string>): Record<string, string> => dict
/** Every non-zh/en dictionary keyed by its override id, ready for the
* better-locale store's `register(ns, dicts)`. */
export const localeDicts: Record<string, Record<string, string>> = {
ja: checked(ja),
de: checked(de),
fr: checked(fr),
pt: checked(pt),
ko: checked(ko),
ar: checked(ar),
hi: checked(hi),
id: checked(id),
tr: checked(tr),
vi: checked(vi),
th: checked(th),
ru: checked(ru),
it: checked(it),
nl: checked(nl),
sv: checked(sv),
pl: checked(pl),
'zh-HK': checked(zhHK),
'zh-TW': checked(zhTW),
'zh-MO': checked(zhMO),
}
+37 -24
View File
@@ -21,13 +21,11 @@ import { registerOpenPathInterception, registerTurnTailInterception } from './in
import { registerLinkInterception } from './link-intercept.ts'
import { registerImeGuard } from './ime-guard.ts'
import { registerSettingsNavIcon } from './settings-nav-icon.ts'
import { loadExternalDisable, loadPrefs } from './prefs.ts'
import { loadBootDecision } from './prefs.ts'
import { SideCardSection } from './SideCardSection.tsx'
import { api } from './api.ts'
import { LOCALE_NS, attachLocale, attachBetterLocale, t, zh, en,
ja, de, fr, pt, ko, ar, hi, id, tr, vi, th, ru, it, nl, sv, pl,
zhHK, zhTW, zhMO,
} from './locales.ts'
import { LOCALE_NS, attachLocale, attachBetterLocale, t, zh, en } from './locales.ts'
import { loadChunk } from './chunk-loader.ts'
import css from './sidebar.module.css'
import './layout.css'
@@ -82,7 +80,12 @@ export function apply(ctx: Context): void {
// the ja dict once the store becomes available.
ctx.effect(() => {
let dispose: (() => void) | undefined
// Guards the async chunk registration below: a sync() re-run (or fiber
// disposal) that lands while the chunk is still in flight must render
// that registration moot.
let generation = 0
const sync = (): void => {
generation += 1
dispose?.()
dispose = undefined
const store = ctx.get('betterLocale') as
@@ -96,10 +99,17 @@ export function apply(ctx: Context): void {
| undefined
attachBetterLocale(store)
if (store !== undefined) {
dispose = store.register(LOCALE_NS, {
ja, de, fr, pt, ko, ar, hi, id, tr, vi, th, ru, it, nl, sv, pl,
'zh-HK': zhHK, 'zh-TW': zhTW, 'zh-MO': zhMO,
})
// The 19 override dictionaries ride the lazy `locale` chunk: until
// it lands, the store has no betterSidebar entries and t() keeps
// the zh/en chain; the store's own revision bump on register
// re-renders the chrome once the dicts arrive.
const myGeneration = generation
void loadChunk('locale')
.then(mod => {
if (myGeneration !== generation) return
dispose = store.register(LOCALE_NS, mod.localeDicts as Record<string, Record<string, string>>)
})
.catch(() => { /* the dicts stay unregistered; the zh/en chain runs */ })
}
}
// Initial check (picks up the store if better-locale activated first).
@@ -108,6 +118,7 @@ export function apply(ctx: Context): void {
// activates with a persisted override, and when the user switches).
const unsubscribe = ctx.locale.subscribe(sync)
return () => {
generation += 1
unsubscribe()
dispose?.()
attachBetterLocale(undefined)
@@ -275,24 +286,26 @@ export function apply(ctx: Context): void {
}
const sync = async (): Promise<void> => {
if (disposed) return
// Resolve the user's side card prefs BEFORE the first session seeds,
// so a brand-new conversation opens (or stays closed) at the chosen
// width from first paint. A settings route failure falls back to the
// schema defaults; the sidebar still mounts (a stalled wire gives up
// after the timeout and mounts on the defaults).
const prefs = await Promise.race([
loadPrefs(api),
// Resolve the user's side card prefs and the external-disable flag
// from ONE settings fetch BEFORE the first session seeds, so a
// brand-new conversation opens (or stays closed) at the chosen width
// from first paint. A settings route failure falls back to the schema
// defaults; the sidebar still mounts (a stalled wire gives up after
// the timeout and mounts on the defaults — the external-disable check
// rides the same fetch, so one round trip covers both decisions).
const decision = await Promise.race([
loadBootDecision(api),
new Promise<null>(resolve => { const timer = window.setTimeout(() => resolve(null), 2000) }),
])
if (prefs !== null) sidebarStore.setPrefs(prefs)
if (disposed) return
// Mutual exclusion with the dsh-web-ui family right panel: while the
// aionui-panel provider is selected, the sidebar must not mount at
// all. Re-evaluated on every settings-document update (live switch).
const suspended = await loadExternalDisable(api)
if (disposed) return
sidebarStore.setSuspended(suspended)
if (suspended) unmount()
if (decision !== null) {
sidebarStore.setPrefs(decision.prefs)
// Mutual exclusion with the dsh-web-ui family right panel: while the
// aionui-panel provider is selected, the sidebar must not mount at
// all. Re-evaluated on every settings-document update (live switch).
sidebarStore.setSuspended(decision.suspended)
}
if (decision?.suspended) unmount()
else mount()
}
void sync()
+12 -12
View File
@@ -35,14 +35,16 @@
width var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
/* The AppFrame's center column, anchored by data attributes (see above).
Composite selector: DSH 0.1.x versions name the center grid item
`[data-pane="conversation"]`, while rc.8-era shells put a
`[data-slot="conversation"]` child inside it — both selectors resolve to
the SAME element on live pages (verified), and keeping both future-proofs
the rule against a host rename without touching shell-specific markup. */
#root [data-dsh-frame] > [data-pane="conversation"],
#root :has(> [data-slot="conversation"]) {
/* The AppFrame's center column. The Sidebar shell's locator (Sidebar.tsx
locate/measureCenter) tags the measured column node with
[data-dsh-center-col] — one attribute write per node lifetime. The rule
used to anchor on a structural has()-selector over the conversation slot
wrapper, whose match cache invalidates on every #root subtree mutation
(the streaming chat mutates it constantly), re-evaluating the selector
against the whole document; the tag is evaluated once per node instead.
A stretched grid item shrinks by its margins, so the conversation
content (output + input bar) lifts without touching the sidebars. */
#root [data-dsh-center-col] {
margin-bottom: var(--dsh-sidebar-height, 0px);
/* Grid/flex items default to min-height:auto. A long unbreakable token
(OAuth URL) then grows this column past the viewport and clips the
@@ -68,8 +70,7 @@ body[data-dsh-sidebar-collapsed] [data-slot="conversation.session.header"] > hea
}
body[data-dsh-sidebar-dragging] #root,
body[data-dsh-sidebar-dragging] #root [data-dsh-frame] > [data-pane="conversation"],
body[data-dsh-sidebar-dragging] #root :has(> [data-slot="conversation"]) {
body[data-dsh-sidebar-dragging] #root [data-dsh-center-col] {
transition: none;
}
@@ -94,8 +95,7 @@ body[data-dsh-sidebar-dragging] #root :has(> [data-slot="conversation"]) {
@media (prefers-reduced-motion: reduce) {
#root,
#root [data-dsh-frame] > [data-pane="conversation"],
#root :has(> [data-slot="conversation"]) {
#root [data-dsh-center-col] {
transition: none;
}
}
+7 -43
View File
@@ -845,49 +845,13 @@ export const en: Record<keyof typeof zh, string> = {
*/
export const LOCALE_NS = 'betterSidebar'
// The ja dictionary lives in a sibling file (326 keys) so this module
// stays readable. Type-checked against the zh key set: a missing or extra
// ja key is a compile error.
import { ja as jaDict } from './locales-ja.ts'
import { de as deDict } from './locales-de.ts'
import { fr as frDict } from './locales-fr.ts'
import { pt as ptDict } from './locales-pt.ts'
import { ko as koDict } from './locales-ko.ts'
import { ar as arDict } from './locales-ar.ts'
import { hi as hiDict } from './locales-hi.ts'
import { id as idDict } from './locales-id.ts'
import { tr as trDict } from './locales-tr.ts'
import { vi as viDict } from './locales-vi.ts'
import { th as thDict } from './locales-th.ts'
import { ru as ruDict } from './locales-ru.ts'
import { it as itDict } from './locales-it.ts'
import { nl as nlDict } from './locales-nl.ts'
import { sv as svDict } from './locales-sv.ts'
import { pl as plDict } from './locales-pl.ts'
import { zhHK as zhHKDict } from './locales-zh-HK.ts'
import { zhTW as zhTWDict } from './locales-zh-TW.ts'
import { zhMO as zhMODict } from './locales-zh-MO.ts'
/** The ja dictionary (key-set-equal to zh, enforced by the type annotation). */
export const ja: Record<keyof typeof zh, string> = jaDict as Record<keyof typeof zh, string>
export const de: Record<keyof typeof zh, string> = deDict as Record<keyof typeof zh, string>
export const fr: Record<keyof typeof zh, string> = frDict as Record<keyof typeof zh, string>
export const pt: Record<keyof typeof zh, string> = ptDict as Record<keyof typeof zh, string>
export const ko: Record<keyof typeof zh, string> = koDict as Record<keyof typeof zh, string>
export const ar: Record<keyof typeof zh, string> = arDict as Record<keyof typeof zh, string>
export const hi: Record<keyof typeof zh, string> = hiDict as Record<keyof typeof zh, string>
export const id: Record<keyof typeof zh, string> = idDict as Record<keyof typeof zh, string>
export const tr: Record<keyof typeof zh, string> = trDict as Record<keyof typeof zh, string>
export const vi: Record<keyof typeof zh, string> = viDict as Record<keyof typeof zh, string>
export const th: Record<keyof typeof zh, string> = thDict as Record<keyof typeof zh, string>
export const ru: Record<keyof typeof zh, string> = ruDict as Record<keyof typeof zh, string>
export const it: Record<keyof typeof zh, string> = itDict as Record<keyof typeof zh, string>
export const nl: Record<keyof typeof zh, string> = nlDict as Record<keyof typeof zh, string>
export const sv: Record<keyof typeof zh, string> = svDict as Record<keyof typeof zh, string>
export const pl: Record<keyof typeof zh, string> = plDict as Record<keyof typeof zh, string>
export const zhHK: Record<keyof typeof zh, string> = zhHKDict as Record<keyof typeof zh, string>
export const zhTW: Record<keyof typeof zh, string> = zhTWDict as Record<keyof typeof zh, string>
export const zhMO: Record<keyof typeof zh, string> = zhMODict as Record<keyof typeof zh, string>
// The 19 non-zh/en dictionaries live in the LAZY `locale` chunk
// (src/client/chunks/locale.tsx → lib/client-locale.js): `t()` only ever
// consults zh/en plus the better-locale override store, so those dicts are
// needed only when better-locale is installed — the client apply fetches the
// chunk there and registers its `localeDicts` into the override store. They
// used to sit statically in the core bundle (≈ half its weight) for a code
// path most sessions never enter.
/** The DSH locale service attached by the client apply (absent → browser detection). */
let localeService: { getSnapshot(): { active: string } } | undefined
+19
View File
@@ -217,3 +217,22 @@ export async function loadExternalDisable(settings: SidebarSettingsClient): Prom
return false
}
}
/** The boot decision both the prefs and the external-disable flag need:
* ONE settings fetch answers both (the boot path used to await
* {@link loadPrefs} and {@link loadExternalDisable} serially — two round
* trips of the same document before the first paint, and the second had no
* timeout, so a stalled wire could keep the sidebar unmounted forever). */
export interface BootDecision {
prefs: SidebarPrefs
suspended: boolean
}
export async function loadBootDecision(settings: SidebarSettingsClient): Promise<BootDecision> {
try {
const view = await settings.settingsGet()
return { prefs: parsePrefs(view.value), suspended: view.externalDisable === true }
} catch {
return { prefs: { ...SIDEBAR_PREFS_DEFAULTS }, suspended: false }
}
}
+55 -1
View File
@@ -307,7 +307,7 @@ function resultCard(
* @param entries - history rows (event + host-computed view) in seq order.
* @returns display rows in log order.
*/
export function transcriptRows(entries: readonly SidebarHistoryEntry[]): SidechatTranscriptRow[] {
export function transcriptRows(entries: readonly SidebarHistoryEntry[], prev?: readonly SidechatTranscriptRow[]): SidechatTranscriptRow[] {
const events = entries.map(entry => entry.event)
const seedEnd = lastSeedEnd(events)
const rows: SidechatTranscriptRow[] = []
@@ -484,5 +484,59 @@ export function transcriptRows(entries: readonly SidebarHistoryEntry[]): Sidecha
}
}
}
return reuseRows(rows, prev)
}
/** Whether two rows carry identical display content (identity fields plus
* every rendered field of their kind). */
function rowsEqual(a: SidechatTranscriptRow, b: SidechatTranscriptRow): boolean {
if (a.kind !== b.kind || a.seq !== b.seq) return false
switch (a.kind) {
case 'user':
case 'injection': {
const other = b as typeof a
return a.text === other.text
}
case 'assistant':
case 'reasoning': {
const other = b as typeof a
return a.text === other.text && a.settled === other.settled
}
case 'tool': {
// The card is NOT compared by value: its content is a pure function of
// the call-time args and the tool/result landing, and every transition
// (output/exit arriving) flips `executing`/`failed`/`resultText` —
// compared here — so a card whose content changed never slips through
// an equal compare, and an equal compare re-adopting the OLD card
// object (identical content, stale reference) only helps React skip.
const other = b as typeof a
return a.name === other.name && a.failed === other.failed && a.args === other.args
&& a.resultText === other.resultText && a.executing === other.executing
}
case 'turnSummary': {
const other = b as typeof a
return a.inputTokens === other.inputTokens && a.outputTokens === other.outputTokens
&& a.durationMs === other.durationMs
}
}
}
/** Re-adopt the PREVIOUS poll's row objects wherever the content is
* unchanged (position-aligned, append-mostly): settled rows keep their
* object identity across the 2s polls, so React's reconciler and the
* markdown/DOMPurify caches downstream skip them instead of re-rendering
* the whole transcript every poll. Comparing the strings is far cheaper
* than what a fresh reference costs the row below. Rows past the first
* mismatch (an inserted/superseded streaming row) rebuild as usual — that
* is exactly the changed tail. */
function reuseRows(rows: SidechatTranscriptRow[], prev: readonly SidechatTranscriptRow[] | undefined): SidechatTranscriptRow[] {
if (prev === undefined) return rows
const shared = Math.min(rows.length, prev.length)
for (let index = 0; index < shared; index++) {
const before = prev[index]
const after = rows[index]
if (before === undefined || after === undefined) continue
if (rowsEqual(before, after)) rows[index] = before
}
return rows
}
+1 -1
View File
@@ -145,6 +145,6 @@ describe('/sidebar/bundle route', () => {
})
it('exports the chunk allowlist (mirror of src/client/chunk-loader.ts)', () => {
expect([...CHUNK_NAMES]).toEqual(['terminal', 'editor', 'mermaid'])
expect([...CHUNK_NAMES]).toEqual(['terminal', 'editor', 'mermaid', 'locale'])
})
})
+152 -22
View File
@@ -612,33 +612,163 @@ test('bottom panel never flashes full-width after a width drag release (issue #2
}
})
test('the bottom-push anchor resolves through the composite selectors (at least one; same element when both)', async ({ page }) => {
// layout.css pushes the bottom panel via the center column. The selector
// is COMPOSITE on purpose: `[data-pane="conversation"]` (0.1.x naming)
// and `:has(> [data-slot="conversation"])` (rc.8-era naming) — HOST
// VERSIONS MAY RENAME THE ATTRIBUTE (issue #208 comment / PR #226), so
// the contract is "at least one resolves", and when both resolve they
// must hit the SAME element (otherwise the push would land twice).
test('the bottom-push anchor resolves through the shell-written center-column tag', async ({ page }) => {
// layout.css pushes the bottom panel via the center column, anchored on
// the [data-dsh-center-col] tag the Sidebar shell's locator writes onto
// the measured column node (Sidebar.tsx locate — the slot wrapper
// [data-slot="conversation"]'s parent). The tag must sit on exactly ONE
// node: a stale tag on a swapped-out node (boot swap / HMR) would leave
// the push rule anchorless or doubled.
await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' })
await expect(page.locator('[data-dsh-better-sidebar]')).toBeAttached({ timeout: 90_000 })
await expect
.poll(async () => page.evaluate(() => document.querySelectorAll('#root [data-dsh-center-col]').length), { timeout: 90_000 })
.toBe(1)
const anchors = await page.evaluate(() => {
const a = document.querySelector('#root [data-dsh-frame] > [data-pane="conversation"]')
const b = document.querySelector('#root :has(> [data-slot="conversation"])')
const frame = document.querySelector('#root [data-dsh-frame]')
const tagged = document.querySelector('#root [data-dsh-center-col]')
const slotParent = document.querySelector('#root [data-slot="conversation"]')?.parentElement ?? null
return {
a: a !== null,
b: b !== null,
same: a !== null && b !== null && a === b,
frameChildren: frame !== null
? [...frame.children].map(el => `${el.tagName}[${[...el.attributes].map(attr => attr.name).filter(name => name.startsWith('data-')).join(',')}]`)
: [],
tagged: tagged !== null,
same: tagged !== null && slotParent !== null && tagged === slotParent,
}
})
expect(
anchors.a || anchors.b,
`at least one bottom-push anchor must resolve on this host (frame children: ${anchors.frameChildren.join(' / ') || 'no [data-dsh-frame]'})`,
).toBe(true)
if (anchors.a && anchors.b) {
expect(anchors.same, 'both selectors must hit the SAME center-column element').toBe(true)
expect(anchors.tagged, 'the tagged center column must resolve').toBe(true)
expect(anchors.same, 'the tag must sit on the conversation slot wrapper\'s parent (the measured column)').toBe(true)
})
/* ── bottom-strip drag with the right panel CLOSED must not touch the host
layout — the regression test for the width-gate fix ───────────────── */
test('dragging the bottom strip while the right panel is closed keeps the host layout still', async ({ page }) => {
// The bottom drag used to write the panel's persisted width preference into
// `--dsh-sidebar-width` regardless of `panelOpen` (only the idle push
// effect gated it): with the panel closed the variable jumped 0 → ~400px on the
// first drag frame, #root lost 400px instantly, the host frame's viewport
// dropped across its 1024px auto-collapse breakpoint, and the NATIVE left
// sidebar snapped to its 56px rail — then snapped back on release. The
// panel-side DOM writes keep the raw width (the bottom panel's `right`
// derives from it); only the PUSHED width must ride the panelOpen gate.
await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' })
await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 })
const sidebar = page.locator('[data-dsh-better-sidebar]')
await expect(sidebar).toBeAttached({ timeout: 90_000 })
await dismissOnboarding(page)
// The right panel stays CLOSED (openByDefault off): the width variable
// must read as the collapsed push before anything happens.
const readPushWidth = (): Promise<number> => page.evaluate(() => {
const value = parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
return Number.isNaN(value) ? 0 : value
})
await expect
.poll(async () => {
const height = await page.evaluate(() => parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-height')) || 0)
return height
}, { timeout: 90_000 })
.toBe(0)
const bottomExpand = sidebar.getByRole('button', { name: 'Expand bottom panel' })
await expect(bottomExpand, 'the toggle cluster must offer the bottom-panel expand button').toHaveCount(1)
await bottomExpand.click()
await expect
.poll(async () => {
const value = await page.evaluate(() => parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-height')) || 0)
return value
}, { timeout: 90_000 })
.toBeGreaterThan(0)
expect(await readPushWidth(), 'the closed right panel must push 0 width').toBe(0)
// Per-frame sampler on the HOST side: the pushed width variable, the host
// frame's inline grid template (its first track is the native left
// sidebar's width), the rendered left-column width (the grid transition
// animates mid-states, so this catches even a transient snap), and #root's
// right edge (where the width push lands).
await page.evaluate(() => {
const samples: Array<{ t: number; varW: number; gridCols: string; leftColW: number; rootRight: number; dragging: boolean }> = []
const center = document.querySelector('#root [data-slot="conversation"]')?.parentElement ?? null
const frame = center?.parentElement ?? null
const leftCol = frame?.firstElementChild ?? null
const root = document.querySelector('#root') as HTMLElement
const loop = (): void => {
const varW = parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
samples.push({
t: performance.now(),
varW: Number.isNaN(varW) ? 0 : varW,
gridCols: frame instanceof HTMLElement ? frame.style.gridTemplateColumns : '',
leftColW: leftCol?.getBoundingClientRect().width ?? -1,
rootRight: root.getBoundingClientRect().right,
dragging: document.body.hasAttribute('data-dsh-sidebar-dragging'),
})
requestAnimationFrame(loop)
}
requestAnimationFrame(loop)
;(window as unknown as { __bottomDragSamples: typeof samples }).__bottomDragSamples = samples
})
// The bottom strip is the panel's top-edge row-resize hit strip. The panel
// SLIDES DOWN into place on expand: a box read mid-animation sits above the
// settled geometry and the pointerdown lands off-strip (the drag never
// starts). Wait until the strip's center sits at innerHeight - the pushed
// height (the panel's final top edge).
await expect
.poll(async () => {
const probe = await page.evaluate(() => {
const bottom = document.querySelector('[data-dsh-better-sidebar] [data-dsh-bottom-panel]')
if (bottom === null) return null
const strip = [...bottom.querySelectorAll<HTMLElement>('*')].find(el => getComputedStyle(el).cursor === 'row-resize')
if (strip === undefined) return null
const r = strip.getBoundingClientRect()
const heightVar = parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-height')) || 0
return Math.abs((r.y + r.height / 2) - (window.innerHeight - heightVar))
})
return probe === null ? Number.NaN : probe
}, { timeout: 30_000 })
.toBeLessThanOrEqual(8)
const stripBox = await page.evaluate(() => {
const bottom = document.querySelector('[data-dsh-better-sidebar] [data-dsh-bottom-panel]')
if (bottom === null) return null
const strip = [...bottom.querySelectorAll<HTMLElement>('*')].find(el => getComputedStyle(el).cursor === 'row-resize')
if (strip === undefined) return null
const r = strip.getBoundingClientRect()
return { x: r.x + r.width / 2, y: r.y + r.height / 2 }
})
expect(stripBox, 'the bottom drag strip must be present (cursor: row-resize)').not.toBeNull()
// Drag the strip UP (grow the bottom panel) in steps, like a real resize.
await page.mouse.move(stripBox!.x, stripBox!.y)
await page.mouse.down()
for (let i = 1; i <= 12; i++) {
await page.mouse.move(stripBox!.x, stripBox!.y - i * 8, { steps: 2 })
await page.waitForTimeout(40)
}
await page.mouse.up()
await page.waitForTimeout(500)
const samples = await page.evaluate(
() => (window as unknown as { __bottomDragSamples: Array<{ t: number; varW: number; gridCols: string; leftColW: number; rootRight: number; dragging: boolean }> }).__bottomDragSamples,
)
expect(samples.length, 'the frame sampler must have collected frames').toBeGreaterThan(20)
const dragging = samples.filter(s => s.dragging)
expect(dragging.length, 'the dragging attribute must appear during the drag').toBeGreaterThan(5)
// The drag must actually have resized the bottom panel (sanity: the height
// push committed to a bigger value than where it started).
const heightAfter = await page.evaluate(() => parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-height')) || 0)
expect(heightAfter).toBeGreaterThan(0)
// Contract: the pushed width stays 0 for EVERY frame — mid-drag AND after
// release. The old code let the panel's persisted preference (~400px)
// through on the first drag frame and the release commit.
for (const s of samples) {
expect(s.varW, `pushed width leaked during the bottom drag at t=${Math.round(s.t)} (got ${s.varW}px)`).toBe(0)
}
// Contract: the host layout never moves — same inline grid template, same
// rendered left-column width (the native sidebar would snap to its 56px
// rail when #root loses 400px on a 1280px viewport), same #root right edge.
const first = samples[0]!
for (const s of samples) {
expect(s.gridCols, `host grid template changed at t=${Math.round(s.t)}: ${first.gridCols} -> ${s.gridCols}`).toBe(first.gridCols)
expect(Math.abs(s.leftColW - first.leftColW), `native left column width changed at t=${Math.round(s.t)}: ${first.leftColW} -> ${s.leftColW}`).toBeLessThanOrEqual(1)
expect(Math.abs(s.rootRight - first.rootRight), `#root right edge changed at t=${Math.round(s.t)}: ${first.rootRight} -> ${s.rootRight}`).toBeLessThanOrEqual(1)
}
})
+278
View File
@@ -0,0 +1,278 @@
/**
* Perf lane: a MEASUREMENT harness, not a pass/fail gate (beyond the mount
* sanity it shares with the other lanes).
*
* It exists to put NUMBERS on the sidebar's client-side cost in a real
* mounted `dsh web` (the same scratch-profile boot as scripts/e2e-mount.sh):
*
* 1. mount latency — navigationStart → `[data-dsh-better-sidebar]`
* attached, sampled by an init-script probe (rAF loop, so it catches
* the attach even before Playwright's first poll);
* 2. longtasks — every >50ms main-thread task from first paint
* through the full built-in tab sweep (the heaviest mount surface);
* 3. bundle cost — per-resource transferSize/decodedBodySize/duration
* for everything sidebar-shaped the page fetched;
* 4. bottom-drag frames — rAF frame-interval p95/max while dragging the
* bottom strip (the layout-push hot path), plus the width-leak guard
* from the drag lane (a closed right panel must push 0).
*
* Results print as single-line `PERF_JSON` records (plus a `PERF_SUMMARY`
* aggregate) so a shell run can `tee` the log and grep them out; the
* before/after comparison lives in docs/plans/2026-08-31-perf-optimization.md.
* Run it 3× and take the median of each metric — single-run numbers on a
* developer machine are noise, not baselines.
*/
import { mkdirSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test, expect, type APIRequestContext, type Page } from '@playwright/test'
import { PAGE_URL, createHostApi, hostRpc } from './host'
/** This lane's own workspace (lanes run serially against one server — never
* share seed paths with mount/drag). */
const WORKSPACE_PATH = process.env.DSH_E2E_PERF_WORKSPACE ?? join(tmpdir(), 'dsh-e2e-perf-workspace')
/** Built-in tab titles the sweep drives (en-US copy; follows DSH locale). */
const BUILTIN_TABS = ['Files', 'Changes', 'Tasks', 'Side Chat (beta)', 'Terminal', 'Browser']
let api: APIRequestContext
async function seedSession(): Promise<void> {
mkdirSync(WORKSPACE_PATH, { recursive: true })
writeFileSync(join(WORKSPACE_PATH, 'seed.txt'), 'perf lane\n')
const workspace = await hostRpc<{ workspace: { workspaceId: string } }>(api, 'workspace.create', { path: WORKSPACE_PATH })
await hostRpc(api, 'session.create', { workspaceId: workspace.value.workspace.workspaceId })
}
test.beforeAll(async () => {
api = await createHostApi()
await seedSession()
})
test.afterAll(async () => {
await api?.dispose()
})
/** The longtask collector + mount-attach probe, injected before ANY page
* script runs (longtasks observed late are gone forever). */
const PERF_PROBE = `(() => {
const perf = { mountAt: null, longtasks: [] }
window.__perfProbe = perf
new PerformanceObserver(list => {
for (const entry of list.getEntries()) perf.longtasks.push({ start: entry.startTime, duration: entry.duration })
}).observe({ entryTypes: ['longtask'] })
const check = () => {
if (document.querySelector('[data-dsh-better-sidebar]') !== null) { perf.mountAt = performance.now(); return }
requestAnimationFrame(check)
}
requestAnimationFrame(check)
})()`
function percentile(values: number[], p: number): number {
if (values.length === 0) return 0
const sorted = [...values].sort((a, b) => a - b)
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1))
return sorted[index]!
}
interface ResourceStat {
name: string
transferSize: number
decodedBodySize: number
duration: number
}
/** Dismiss whatever onboarding takeover is present (same dance as the other
* lanes) so the pointer can reach the sidebar's controls. */
async function dismissOnboarding(page: Page): Promise<void> {
try {
await expect
.poll(() => page.getByRole('button', { name: /^(Continue|Configure later)$/ }).count(), { timeout: 60_000 })
.toBeGreaterThan(0)
} catch {
console.warn('[e2e-perf] no onboarding takeover appeared; proceeding')
}
for (let round = 0; round < 8; round++) {
let dismissed = false
for (const name of ['Continue', 'Configure later']) {
const button = page.getByRole('button', { name, exact: true }).first()
if ((await button.count()) === 0) continue
try {
await button.click({ timeout: 4_000 })
dismissed = true
await page.waitForTimeout(1_000)
} catch {
// Masked by a takeover stacked above; retry in the next round.
}
}
if (!dismissed) break
}
}
test('measure: mount latency, longtasks and bundle cost through a full tab sweep', async ({ page }) => {
await page.addInitScript(PERF_PROBE)
await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' })
await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 })
const sidebar = page.locator('[data-dsh-better-sidebar]')
await expect(sidebar).toBeAttached({ timeout: 90_000 })
const mountLatency = await page.evaluate(() =>
(window as unknown as { __perfProbe: { mountAt: number | null } }).__perfProbe.mountAt)
expect(mountLatency, 'the mount probe must have caught the attach').not.toBeNull()
await dismissOnboarding(page)
// Open the panel, then sweep every built-in tab through the "+" menu — the
// same crash-sweep surface as mount.e2e.ts, reused here as the workload.
const expandButton = sidebar.getByRole('button', { name: 'Expand sidebar' })
await expect(expandButton).toHaveCount(1)
await expandButton.click()
await expect
.poll(async () => {
const value = await page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
return value !== '' && value !== '0px'
}, { timeout: 90_000 })
.toBe(true)
const newTabButton = sidebar.getByRole('button', { name: 'New tab' }).first()
for (const title of BUILTIN_TABS) {
await newTabButton.click()
const item = page.getByRole('menuitem', { name: title }).first()
await expect(item, `built-in tab "${title}" must be offered by the + menu`).toHaveCount(1)
await item.click()
await page.waitForTimeout(1_500)
}
// Let the last tab settle (lazy chunks + first render) before reading.
await page.waitForTimeout(2_000)
const probe = await page.evaluate(() => (window as unknown as { __perfProbe: { mountAt: number | null; longtasks: Array<{ start: number; duration: number }> } }).__perfProbe)
const resources = await page.evaluate(() =>
performance.getEntriesByType('resource')
.filter(entry => /sidebar/.test(entry.name))
.map(entry => {
const resource = entry as PerformanceResourceTiming
return {
name: entry.name.replace(/^https?:\/\/[^/]+/, ''),
transferSize: resource.transferSize,
decodedBodySize: resource.decodedBodySize,
duration: entry.duration,
}
}) as ResourceStat[],
)
const longtasks = probe.longtasks ?? []
const record = {
metric: 'mount-sweep',
mountLatencyMs: mountLatency,
longtaskCount: longtasks.length,
longtaskTotalMs: Math.round(longtasks.reduce((sum, task) => sum + task.duration, 0)),
longtaskMaxMs: longtasks.reduce((max, task) => Math.max(max, task.duration), 0),
resourceCount: resources.length,
resourceTransferTotal: resources.reduce((sum, r) => sum + r.transferSize, 0),
resourceDecodedTotal: resources.reduce((sum, r) => sum + r.decodedBodySize, 0),
resources,
}
console.log(`PERF_JSON ${JSON.stringify(record)}`)
console.log(
`PERF_SUMMARY mount=${Math.round(mountLatency!)}ms longtasks=${record.longtaskCount}/${record.longtaskTotalMs}ms `
+ `resources=${record.resourceCount}/${(record.resourceTransferTotal / 1024).toFixed(0)}KiB`,
)
})
test('measure: bottom-strip drag frame pacing', async ({ page }) => {
await page.addInitScript(PERF_PROBE)
await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' })
await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 })
const sidebar = page.locator('[data-dsh-better-sidebar]')
await expect(sidebar).toBeAttached({ timeout: 90_000 })
await dismissOnboarding(page)
// The right panel stays CLOSED on purpose: the bottom drag must not push
// the host layout in that pose (the width-leak regression the drag lane
// locks; recorded here as a number-adjacent guard for the perf story).
const bottomExpand = sidebar.getByRole('button', { name: 'Expand bottom panel' })
await expect(bottomExpand).toHaveCount(1)
await bottomExpand.click()
await expect
.poll(async () => {
const value = await page.evaluate(() => parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-height')) || 0)
return value
}, { timeout: 90_000 })
.toBeGreaterThan(0)
// Frame-pacing sampler: rAF timestamps during the drag only.
await page.evaluate(() => {
const frames: number[] = []
const pushWidths: number[] = []
const loop = (): void => {
frames.push(performance.now())
const width = parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
pushWidths.push(Number.isNaN(width) ? 0 : width)
requestAnimationFrame(loop)
}
requestAnimationFrame(loop)
;(window as unknown as { __dragFrames: number[]; __dragPushWidths: number[] }).__dragFrames = frames
;(window as unknown as { __dragPushWidths: number[] }).__dragPushWidths = pushWidths
})
// The panel slides down into place on expand: read the strip only after it
// settles at innerHeight - the pushed height (a mid-animation box makes the
// pointerdown land off-strip and the drag never starts).
await expect
.poll(async () => {
const probe = await page.evaluate(() => {
const bottom = document.querySelector('[data-dsh-better-sidebar] [data-dsh-bottom-panel]')
if (bottom === null) return null
const strip = [...bottom.querySelectorAll<HTMLElement>('*')].find(el => getComputedStyle(el).cursor === 'row-resize')
if (strip === undefined) return null
const r = strip.getBoundingClientRect()
const heightVar = parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-height')) || 0
return Math.abs((r.y + r.height / 2) - (window.innerHeight - heightVar))
})
return probe === null ? Number.NaN : probe
}, { timeout: 30_000 })
.toBeLessThanOrEqual(8)
const stripPoint = await page.evaluate(() => {
const bottom = document.querySelector('[data-dsh-better-sidebar] [data-dsh-bottom-panel]')
if (bottom === null) return null
const strip = [...bottom.querySelectorAll<HTMLElement>('*')].find(el => getComputedStyle(el).cursor === 'row-resize')
if (strip === undefined) return null
const r = strip.getBoundingClientRect()
return { x: r.x + r.width / 2, y: r.y + r.height / 2 }
})
expect(stripPoint, 'the bottom drag strip must be present').not.toBeNull()
const t0 = await page.evaluate(() => performance.now())
await page.mouse.move(stripPoint!.x, stripPoint!.y)
await page.mouse.down()
for (let i = 1; i <= 12; i++) {
await page.mouse.move(stripPoint!.x, stripPoint!.y - i * 8, { steps: 2 })
await page.waitForTimeout(40)
}
await page.mouse.up()
const t1 = await page.evaluate(() => performance.now())
await page.waitForTimeout(300)
const { frames, widths } = await page.evaluate(() => ({
frames: (window as unknown as { __dragFrames: number[] }).__dragFrames,
widths: (window as unknown as { __dragPushWidths: number[] }).__dragPushWidths,
}))
const dragFrames = frames.filter(t => t >= t0 && t <= t1)
const intervals: number[] = []
for (let i = 1; i < dragFrames.length; i++) intervals.push(dragFrames[i]! - dragFrames[i - 1]!)
const dragWidths = widths.slice(Math.max(0, frames.indexOf(dragFrames[0] ?? frames[0]!)))
const record = {
metric: 'bottom-drag',
frames: dragFrames.length,
intervalMedianMs: Math.round(percentile(intervals, 50)),
intervalP95Ms: Math.round(percentile(intervals, 95)),
intervalMaxMs: Math.round(Math.max(0, ...intervals)),
pushWidthLeakMax: Math.max(0, ...dragWidths),
}
console.log(`PERF_JSON ${JSON.stringify(record)}`)
console.log(
`PERF_SUMMARY drag-frames=${record.frames} p50=${record.intervalMedianMs}ms p95=${record.intervalP95Ms}ms `
+ `max=${record.intervalMaxMs}ms widthLeak=${record.pushWidthLeakMax}px`,
)
expect(record.pushWidthLeakMax, 'the closed right panel must push 0 width during the bottom drag').toBe(0)
})
+8 -4
View File
@@ -9,9 +9,13 @@ import { describe, expect, it } from 'vitest'
const css = readFileSync('src/client/layout.css', 'utf8')
describe('layout.css conversation column', () => {
it('targets the AppFrame center column', () => {
expect(css).toContain('[data-pane="conversation"]')
expect(css).toContain('[data-slot="conversation"]')
it('targets the AppFrame center column through the shell-written tag', () => {
// The bottom-push rule anchors on the [data-dsh-center-col] tag the
// Sidebar shell's locator writes onto the measured column node (see
// Sidebar.tsx locate) — not on a `:has()` selector, whose cache
// invalidates on every #root subtree mutation (the streaming chat).
expect(css).toContain('[data-dsh-center-col]')
expect(css).not.toContain(':has(')
})
it('allows the center column to shrink and wrap long tokens', () => {
@@ -21,7 +25,7 @@ describe('layout.css conversation column', () => {
it('leaves overflow ownership to the host conversation descendants', () => {
const conversationRule = css.match(
/#root \[data-dsh-frame\][\s\S]*?\{([\s\S]*?)\n\}/,
/#root \[data-dsh-center-col\][\s\S]*?\{([\s\S]*?)\n\}/,
)?.[1]
expect(conversationRule).toBeDefined()
expect(conversationRule).not.toMatch(/(?:^|[;\s])overflow\s*:/)
+10 -14
View File
@@ -6,7 +6,8 @@
* interpolation.
*/
import { afterEach, describe, expect, it } from 'vitest'
import { LOCALE_NS, attachBetterLocale, attachLocale, en, isZh, ja, relativeTime, t, zh, de, fr, pt, ko, ar, hi, id as idDict, tr, vi, th, ru, it as itDict, nl, sv, pl, zhHK, zhTW, zhMO } from '../src/client/locales.ts'
import { LOCALE_NS, attachBetterLocale, attachLocale, en, isZh, relativeTime, t, zh } from '../src/client/locales.ts'
import { localeDicts } from '../src/client/chunks/locale.tsx'
/** Minimal structural fake of the DSH LocaleService face the sidebar uses. */
class FakeLocale {
@@ -108,20 +109,15 @@ describe('locales (DSH i18n following)', () => {
expect(Object.keys(en).sort()).toEqual(Object.keys(zh).sort())
})
it('keeps the ja dictionary key-set-equal to zh', () => {
expect(Object.keys(ja).sort()).toEqual(Object.keys(zh).sort())
})
it('keeps every shipped third-language dictionary key-set-equal to zh', () => {
// The third-language dicts are typed `Record<keyof typeof zh, string>` via
// an `as` cast (unchecked by tsc). This test catches missing/extra keys
// introduced when a zh key is added without updating every language.
const thirdLangs = {
de, fr, pt, ko, ar, hi, idDict, tr, vi, th, ru, itDict, nl, sv, pl, zhHK, zhTW, zhMO,
}
for (const [lang, dict] of Object.entries(thirdLangs)) {
expect(Object.keys(dict as Record<string, string>).sort(), lang).toEqual(Object.keys(zh).sort())
// The third-language dicts live in the lazy locale chunk typed as plain
// records (the chunk boundary has no key-set type tie to zh). This test
// catches missing/extra keys introduced when a zh key is added without
// updating every language — a missing key silently falls back to en.
for (const [lang, dict] of Object.entries(localeDicts)) {
expect(Object.keys(dict).sort(), lang).toEqual(Object.keys(zh).sort())
}
expect(Object.keys(localeDicts), 'every shipped third language rides the locale chunk').toContain('ja')
})
})
@@ -247,7 +243,7 @@ describe('locales (better-locale override)', () => {
locale.switchTo('en')
attachLocale(locale)
const betterLocale = new FakeBetterLocale({ [LOCALE_NS]: { ja } })
const betterLocale = new FakeBetterLocale({ [LOCALE_NS]: { ja: localeDicts.ja! } })
betterLocale.active = 'ja'
attachBetterLocale(betterLocale)
+26 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { loadExternalDisable, loadPrefs, type SidebarSettingsClient } from '../src/client/prefs.ts'
import { loadBootDecision, loadExternalDisable, loadPrefs, type SidebarSettingsClient } from '../src/client/prefs.ts'
import { allLeaves, createSidebarStore, defaultWidthFor, makeDefaultState, setWidth } from '../src/client/state.ts'
import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts'
@@ -416,3 +416,28 @@ describe('external disable (aionui-panel provider choice)', () => {
expect(await loadExternalDisable(rejecting())).toBe(false)
})
})
describe('boot decision (one fetch for prefs + external disable)', () => {
it('answers both decisions from a single settingsGet call', async () => {
let calls = 0
const counting = (): SidebarSettingsClient => ({
settingsGet: async () => { calls += 1; return { value: { openByDefault: true, defaultWidthPercent: 40 }, revision: 1, externalDisable: true } },
settingsUpdate: async () => ({ value: {}, revision: 2 }),
})
const decision = await loadBootDecision(counting())
expect(calls, 'the boot path must fetch the settings document exactly once').toBe(1)
expect(decision.suspended).toBe(true)
expect(decision.prefs.openByDefault).toBe(true)
})
it('falls back to the defaults + not suspended on any failure', async () => {
const decision = await loadBootDecision(rejecting())
expect(decision).toEqual({ prefs: SIDEBAR_PREFS_DEFAULTS, suspended: false })
})
it('reads suspended false when the flag is absent', async () => {
const decision = await loadBootDecision(wire({ defaultWidthPercent: 50 }))
expect(decision.suspended).toBe(false)
expect(decision.prefs.defaultWidthPercent).toBe(50)
})
})
+72
View File
@@ -371,3 +371,75 @@ describe('toolArgsSummary', () => {
expect(toolArgsSummary('{"command":" "}')).toBe('{"command":" "}')
})
})
describe('transcriptRows row reuse (the poll-to-poll identity pass)', () => {
it('re-adopts the previous poll\'s row objects wherever content is unchanged', () => {
const entries = [
entry(ev('session/end-seed', 0)),
entry(ev('user/message', 1, { content: textBlocks('the side question'), source: { kind: 'user' } })),
entry(ev('assistant/message', 2, { message: { content: textBlocks('the answer') } })),
]
const first = transcriptRows(entries)
const second = transcriptRows(entries, first)
expect(second).toEqual(first)
// Settled rows keep their object identity (fresh references defeat the
// downstream memo and re-render the whole transcript every 2s poll).
expect(second[1]).toBe(first[1])
expect(second[2]).toBe(first[2])
})
it('rebuilds changed rows and everything past the change', () => {
const before = transcriptRows([
entry(ev('session/end-seed', 0)),
entry(ev('user/message', 1, { content: textBlocks('q'), source: { kind: 'user' } })),
])
const after = transcriptRows([
entry(ev('session/end-seed', 0)),
entry(ev('user/message', 1, { content: textBlocks('q'), source: { kind: 'user' } })),
entry(ev('user/message', 2, { content: textBlocks('q2'), source: { kind: 'user' } })),
], before)
expect(after).toHaveLength(2)
expect(after[0]).toBe(before[0])
expect(after[1]).not.toBe(before[1])
expect(after[1]).toMatchObject({ kind: 'user', seq: 2, text: 'q2' })
})
it('never re-adopts a row whose rendered fields changed (tool result lands)', () => {
const call = entry(ev('tool/call', 1, { callId: 'c1', name: 'bash', arguments: '{"command":"ls"}' }))
const before = transcriptRows([entry(ev('session/end-seed', 0)), call])
expect(before[0]).toMatchObject({ kind: 'tool', executing: true })
const result = entry(ev('tool/result', 2, {
message: { source: { callId: 'c1' }, content: [{ type: 'tool-result', content: [{ type: 'text', text: 'out' }] }] },
}))
const after = transcriptRows([entry(ev('session/end-seed', 0)), call, result], before)
expect(after[0]).not.toBe(before[0])
expect(after[0]).toMatchObject({ kind: 'tool', executing: false, resultText: 'out' })
})
})
describe('transcriptRows row reuse (newer row kinds)', () => {
it('re-adopts settled turnSummary rows (token usage tail)', () => {
const entries = [
entry(ev('session/end-seed', 0)),
entry(ev('turn/start', 1, { turn: 1 })),
entry(ev('turn/end', 2, { turn: 1, reason: 'completed' })),
]
const first = transcriptRows(entries)
const summary = first.find(row => row.kind === 'turnSummary')
expect(summary).toBeDefined()
const second = transcriptRows(entries, first)
expect(second[second.length - 1]).toBe(summary)
})
it('re-adopts a tool row whose card landed (executing flip forces the rebuild)', () => {
const call = entry(ev('tool/call', 1, { callId: 'c1', name: 'bash', arguments: '{"command":"ls"}' }))
const before = transcriptRows([entry(ev('session/end-seed', 0)), call])
expect(before[0]).toMatchObject({ kind: 'tool', executing: true })
const result = entry(ev('tool/result', 2, {
message: { source: { callId: 'c1' }, content: [{ type: 'tool-result', content: [{ type: 'text', text: 'out\n[exit 0]' }] }] },
}))
const after = transcriptRows([entry(ev('session/end-seed', 0)), call, result], before)
expect(after[0]).not.toBe(before[0])
expect(after[0]).toMatchObject({ kind: 'tool', executing: false })
})
})
+1 -1
View File
@@ -324,7 +324,7 @@ function makeCssPlugin(pluginId: string): BuildPlugin {
}
/** The lazy chunk names (keep in sync with src/bundle-route.ts CHUNK_NAMES). */
const CHUNKS = ['terminal', 'editor', 'mermaid']
const CHUNKS = ['terminal', 'editor', 'mermaid', 'locale']
export default [
{