diff --git a/docs/external-plugin-guide.md b/docs/external-plugin-guide.md index ca4c5ee..7a9903d 100644 --- a/docs/external-plugin-guide.md +++ b/docs/external-plugin-guide.md @@ -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]` 内。 -- **布局变量**(`` 上,面板打开时有效):`--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` 断言)。 +- **布局变量**(`` 上,面板打开时有效):`--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`。 diff --git a/docs/plans/2026-08-31-perf-optimization.md b/docs/plans/2026-08-31-perf-optimization.md new file mode 100644 index 0000000..bb61520 --- /dev/null +++ b/docs/plans/2026-08-31-perf-optimization.md @@ -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 全局安装钉版本,不受影响)。 diff --git a/src/bundle-route.ts b/src/bundle-route.ts index 3c404f3..4f098ec 100644 --- a/src/bundle-route.ts +++ b/src/bundle-route.ts @@ -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). */ diff --git a/src/client/FileTree.tsx b/src/client/FileTree.tsx index 942a06f..684ceb4 100644 --- a/src/client/FileTree.tsx +++ b/src/client/FileTree.tsx @@ -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 (
{ 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) }} diff --git a/src/client/SideChatView.tsx b/src/client/SideChatView.tsx index e409c1a..f6ed825 100644 --- a/src/client/SideChatView.tsx +++ b/src/client/SideChatView.tsx @@ -383,6 +383,8 @@ export function SideChatView(props: { const [menuOpen, setMenuOpen] = useState(false) const cacheRef = useRef({ entries: [] }) + // The previous poll's rows (see the mapping's reuse pass below). + const prevRowsRef = useRef([]) const controllerRef = useRef(null) const scrollRef = useRef(null) const composerRef = useRef(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], diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index 8897571..4b6c538 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -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 }) { renderTab(tab, active, paneId, 'bottom')} diff --git a/src/client/TerminalView.tsx b/src/client/TerminalView.tsx index eba3d93..ffaee8a 100644 --- a/src/client/TerminalView.tsx +++ b/src/client/TerminalView.tsx @@ -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() diff --git a/src/client/TextEditor.tsx b/src/client/TextEditor.tsx index 25114c9..66b0fd7 100644 --- a/src/client/TextEditor.tsx +++ b/src/client/TextEditor.tsx @@ -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 diff --git a/src/client/changes/ChangesTab.tsx b/src/client/changes/ChangesTab.tsx index ae7aa22..7726065 100644 --- a/src/client/changes/ChangesTab.tsx +++ b/src/client/changes/ChangesTab.tsx @@ -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([]) + // 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([]) 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 => { diff --git a/src/client/changes/DiffPane.tsx b/src/client/changes/DiffPane.tsx index c8ff841..436cb53 100644 --- a/src/client/changes/DiffPane.tsx +++ b/src/client/changes/DiffPane.tsx @@ -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): 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 diff --git a/src/client/changes/GitLens.tsx b/src/client/changes/GitLens.tsx index afec62b..71b7324 100644 --- a/src/client/changes/GitLens.tsx +++ b/src/client/changes/GitLens.tsx @@ -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(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]) diff --git a/src/client/changes/SessionLens.tsx b/src/client/changes/SessionLens.tsx index 8b8e6a1..8e33319 100644 --- a/src/client/changes/SessionLens.tsx +++ b/src/client/changes/SessionLens.tsx @@ -54,6 +54,20 @@ export function SessionLens({ ops, loadError, onPreview, selectedCallId }: Sessi ) + // 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() + 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 (
@@ -85,9 +99,9 @@ export function SessionLens({ ops, loadError, onPreview, selectedCallId }: Sessi {op.running && {t('changesRunning')}} {op.isError && {t('changesError')}} - {op.kind !== 'read' && !op.isError && ( + {opSizes.get(op.callId) !== undefined && ( - {formatBytes(new Blob([op.edit?.newString ?? op.content ?? '']).size)} + {opSizes.get(op.callId)} )} {relativeTime(new Date(op.time).toISOString())} diff --git a/src/client/chunk-loader.ts b/src/client/chunk-loader.ts index 76c90e1..169da7c 100644 --- a/src/client/chunk-loader.ts +++ b/src/client/chunk-loader.ts @@ -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 diff --git a/src/client/chunks/locale.tsx b/src/client/chunks/locale.tsx new file mode 100644 index 0000000..b2a439e --- /dev/null +++ b/src/client/chunks/locale.tsx @@ -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): Record => 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> = { + 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), +} diff --git a/src/client/index.tsx b/src/client/index.tsx index 30033f9..ff25c8a 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -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>) + }) + .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 => { 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(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() diff --git a/src/client/layout.css b/src/client/layout.css index 17075c6..c31a94a 100644 --- a/src/client/layout.css +++ b/src/client/layout.css @@ -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; } } diff --git a/src/client/locales.ts b/src/client/locales.ts index 584a7b7..4177b6b 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -845,49 +845,13 @@ export const en: Record = { */ 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 = jaDict as Record -export const de: Record = deDict as Record -export const fr: Record = frDict as Record -export const pt: Record = ptDict as Record -export const ko: Record = koDict as Record -export const ar: Record = arDict as Record -export const hi: Record = hiDict as Record -export const id: Record = idDict as Record -export const tr: Record = trDict as Record -export const vi: Record = viDict as Record -export const th: Record = thDict as Record -export const ru: Record = ruDict as Record -export const it: Record = itDict as Record -export const nl: Record = nlDict as Record -export const sv: Record = svDict as Record -export const pl: Record = plDict as Record -export const zhHK: Record = zhHKDict as Record -export const zhTW: Record = zhTWDict as Record -export const zhMO: Record = zhMODict as Record +// 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 diff --git a/src/client/prefs.ts b/src/client/prefs.ts index be4d482..082523a 100644 --- a/src/client/prefs.ts +++ b/src/client/prefs.ts @@ -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 { + try { + const view = await settings.settingsGet() + return { prefs: parsePrefs(view.value), suspended: view.externalDisable === true } + } catch { + return { prefs: { ...SIDEBAR_PREFS_DEFAULTS }, suspended: false } + } +} diff --git a/src/client/sidechat-transcript.ts b/src/client/sidechat-transcript.ts index 61dde7a..0c9e400 100644 --- a/src/client/sidechat-transcript.ts +++ b/src/client/sidechat-transcript.ts @@ -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 } diff --git a/tests/bundle-route.spec.ts b/tests/bundle-route.spec.ts index faf4400..1585cd5 100644 --- a/tests/bundle-route.spec.ts +++ b/tests/bundle-route.spec.ts @@ -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']) }) }) diff --git a/tests/e2e/drag-layout.e2e.ts b/tests/e2e/drag-layout.e2e.ts index a3ce7fb..9f131d7 100644 --- a/tests/e2e/drag-layout.e2e.ts +++ b/tests/e2e/drag-layout.e2e.ts @@ -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 => 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('*')].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('*')].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) } }) diff --git a/tests/e2e/perf.e2e.ts b/tests/e2e/perf.e2e.ts new file mode 100644 index 0000000..0e90493 --- /dev/null +++ b/tests/e2e/perf.e2e.ts @@ -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 { + 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 { + 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('*')].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('*')].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) +}) diff --git a/tests/layout-css.spec.ts b/tests/layout-css.spec.ts index c90d889..6613607 100644 --- a/tests/layout-css.spec.ts +++ b/tests/layout-css.spec.ts @@ -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*:/) diff --git a/tests/locales.spec.ts b/tests/locales.spec.ts index 4e12ffe..e2e6cab 100644 --- a/tests/locales.spec.ts +++ b/tests/locales.spec.ts @@ -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` 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).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) diff --git a/tests/prefs.spec.ts b/tests/prefs.spec.ts index 449f55b..fe48adb 100644 --- a/tests/prefs.spec.ts +++ b/tests/prefs.spec.ts @@ -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) + }) +}) diff --git a/tests/sidechat-transcript.spec.ts b/tests/sidechat-transcript.spec.ts index fbe5a6a..6c8e532 100644 --- a/tests/sidechat-transcript.spec.ts +++ b/tests/sidechat-transcript.spec.ts @@ -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 }) + }) +}) diff --git a/tsdown.config.ts b/tsdown.config.ts index 2183fd9..881a45d 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -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 [ {