@@ -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 [
{