Merge pull request #489 from omdsh-dev/feat/sidebar-perf
perf: 侧边栏性能优化(核心包 -45%)+ 修复底栏拖拽引发的原生左栏突变
This commit is contained in:
@@ -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
@@ -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
@@ -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) }}
|
||||
|
||||
@@ -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
@@ -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')}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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 [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user