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

perf: 侧边栏性能优化(核心包 -45%)+ 修复底栏拖拽引发的原生左栏突变
This commit is contained in:
Atlas Zeng
2026-09-02 21:33:49 +08:00
committed by GitHub
27 changed files with 972 additions and 155 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ import { fileURLToPath } from 'node:url'
import type { Context, SidebarHttpRequest, SidebarHttpResponse } from './context-types.ts'
/** The chunk names the client may request (mirror of src/client/chunk-loader.ts). */
export const CHUNK_NAMES = ['terminal', 'editor', 'mermaid'] as const
export const CHUNK_NAMES = ['terminal', 'editor', 'mermaid', 'locale'] as const
export type ChunkName = (typeof CHUNK_NAMES)[number]
/** Directory of this host-half module (lib/ — the chunk scripts live next to it). */
+12 -6
View File
@@ -20,7 +20,7 @@
* (VSCode semantics — a drop on a file row targets its parent directory),
* and `busy` gates new drags while one upload is in flight.
*/
import { useCallback, useEffect, useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import clsx from 'clsx'
import {
@@ -441,6 +441,12 @@ export function FileTree(props: {
const root = cwd
// Membership is tested per rendered row (deep trees run this thousands of
// times per render): includes() made every row O(expanded), the whole tree
// O(rows × expanded). One Set per render keeps it O(1) per row.
const expandedSet = useMemo(() => new Set(expanded), [expanded])
const revealedSet = useMemo(() => new Set(revealed), [revealed])
const renderLevel = (dir: string, depth: number): ReactNode => {
const level = data[dir]
if (level === undefined) {
@@ -466,7 +472,7 @@ export function FileTree(props: {
const entries = level.entries ?? []
return entries.map(entry => {
if (entry.isDir) {
const isOpen = expanded.includes(entry.path)
const isOpen = expandedSet.has(entry.path)
return (
<div key={entry.path}>
<div
@@ -475,9 +481,9 @@ export function FileTree(props: {
className={clsx(
css.explorerRow, css.explorerDir, entry.hidden && css.explorerHidden,
dropTarget === entry.path && css.explorerRowDropTarget,
revealed.includes(entry.path) && css.explorerRowRevealed,
revealedSet.has(entry.path) && css.explorerRowRevealed,
)}
data-dsh-revealed={revealed.includes(entry.path) ? 'true' : undefined}
data-dsh-revealed={revealedSet.has(entry.path) ? 'true' : undefined}
style={{ paddingLeft: depth * 22 + 6 }}
onClick={() => { onToggle(entry.path) }}
onKeyDown={(event) => {
@@ -507,9 +513,9 @@ export function FileTree(props: {
className={clsx(
css.explorerRow, entry.hidden && css.explorerHidden, entry.broken && css.explorerBroken,
dropTarget === parentOf(entry.path) && css.explorerRowDropTarget,
revealed.includes(entry.path) && css.explorerRowRevealed,
revealedSet.has(entry.path) && css.explorerRowRevealed,
)}
data-dsh-revealed={revealed.includes(entry.path) ? 'true' : undefined}
data-dsh-revealed={revealedSet.has(entry.path) ? 'true' : undefined}
style={{ paddingLeft: depth * 22 + 6 }}
title={entry.broken ? `${entry.path} — ${t('brokenSymlink')}` : entry.path}
onClick={() => { onOpenFile(entry.path) }}
+11 -2
View File
@@ -383,6 +383,8 @@ export function SideChatView(props: {
const [menuOpen, setMenuOpen] = useState(false)
const cacheRef = useRef<ThreadCache>({ entries: [] })
// The previous poll's rows (see the mapping's reuse pass below).
const prevRowsRef = useRef<SidechatTranscriptRow[]>([])
const controllerRef = useRef<AbortController | null>(null)
const scrollRef = useRef<HTMLDivElement | null>(null)
const composerRef = useRef<HTMLTextAreaElement | null>(null)
@@ -480,6 +482,7 @@ export function SideChatView(props: {
// the composer — it owns the first message of a fresh thread.
useEffect(() => {
cacheRef.current = { entries: [] }
prevRowsRef.current = []
controllerRef.current?.abort()
setError(null)
setSaved(false)
@@ -517,8 +520,14 @@ export function SideChatView(props: {
}
}, [connectionState, threadId, fetchThread, fetchInfo])
const rows = useMemo(
() => (threadId === undefined ? [] : transcriptRows(cacheRef.current.entries)),
// The previous poll's rows ride into the mapping so unchanged rows keep
// their object identity (see reuseRows): the 2s poll re-renders only the
// changed tail instead of re-parsing markdown for the whole transcript.
const rows = useMemo(() => {
const next = threadId === undefined ? [] : transcriptRows(cacheRef.current.entries, prevRowsRef.current)
prevRowsRef.current = next
return next
},
// The cache is a ref; revision bumps on every successful pull.
// eslint-disable-next-line react-hooks/exhaustive-deps
[threadId, revision],
+32 -4
View File
@@ -407,6 +407,20 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
}, [sessionId, summaryCwd])
const cwd = summaryCwd ?? fetchedCwd
// The + menu options ride a memo so the two Workbenches share ONE array
// identity across renders that did not change the store (drag state,
// viewport resize): fresh arrays per render re-rendered every LeafView's
// + affordance whether or not anything tab-related moved.
const newTabOptions = useMemo(
() => (state === undefined || sessionId === undefined ? [] : buildNewTabOptions(state, ctx, { sessionId, cwd })),
// state is the whole session state — every field it wraps is fair game
// for the descriptors' available() callbacks. (The render's own guard
// sits below every hook; this memo must handle the no-session case
// itself.)
// eslint-disable-next-line react-hooks/exhaustive-deps
[state, ctx, sessionId, cwd],
)
/**
* Agent terminals push: subscribe to the host's live list of agent-owned
* terminals for this session (created by the model through the
@@ -742,6 +756,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
?.parentElement as HTMLElement | undefined
if (col === undefined || !col.isConnected) {
if (centerColRef.current !== null) {
centerColRef.current.removeAttribute('data-dsh-center-col')
centerColRef.current = null
observer?.disconnect()
observer = undefined
@@ -754,8 +769,13 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// measure it once. Same-node size changes are the ResizeObserver's
// job — no forced measurement here, because a forced
// getBoundingClientRect per mutation would reflow the shell at
// mutation cadence.
// mutation cadence. The tag retargets with the ref: layout.css's
// bottom-push rule anchors on [data-dsh-center-col], so exactly the
// measured node carries it (a stale tag on a swapped-out node would
// leave the push rule anchorless or doubled).
centerColRef.current?.removeAttribute('data-dsh-center-col')
centerColRef.current = col
col.setAttribute('data-dsh-center-col', '')
observer?.disconnect()
observer = new ResizeObserver(measureCenter)
observer.observe(col)
@@ -808,6 +828,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
observer?.disconnect()
watcher.disconnect()
htmlStyleWatcher.disconnect()
centerColRef.current?.removeAttribute('data-dsh-center-col')
centerColRef.current = null
}
// Opening the bottom panel re-runs the whole locate/measure chain: a
@@ -993,7 +1014,14 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// drag write-only (no React re-render mid-drag).
bottomRef.current?.style.setProperty('right', `${(window.innerWidth - centerRectRef.current.right) + (width - (state?.width ?? 0))}px`)
const bottomPush = !narrow && state?.bottomOpen === true ? height + keyboardInset : 0
writeGeometry(width, bottomPush)
// The pushed width must ride the same gate as the committed push effect
// (layoutPushSize): a collapsed right panel pushes 0. The bottom strip is
// the only drag reachable with the panel closed — writing the panel's
// persisted width preference here squeezed #root mid-drag, dropped the
// host viewport across its 1024px auto-collapse breakpoint, and the
// native left sidebar snapped to its 56px rail (and back on release).
const pushWidth = !narrow && state?.panelOpen === true ? Math.min(width, window.innerWidth) : 0
writeGeometry(pushWidth, bottomPush)
}
// Drags write at most once per frame: pointer events fire several times
@@ -1574,7 +1602,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
<Workbench
state={state}
tree={augmentedTree}
newTabOptions={buildNewTabOptions(state, ctx, { sessionId, cwd })}
newTabOptions={newTabOptions}
actions={wrappedActions}
onNewTab={onNewTab}
renderTab={renderTab}
@@ -1729,7 +1757,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
<Workbench
state={state}
tree={state.bottomSplits}
newTabOptions={buildNewTabOptions(state, ctx, { sessionId, cwd })}
newTabOptions={newTabOptions}
actions={actions}
onNewTab={onNewTab}
renderTab={(tab, active, paneId) => renderTab(tab, active, paneId, 'bottom')}
+17 -6
View File
@@ -268,13 +268,23 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
const inputSub = term.onData((data) => {
if (socket !== null && socket.readyState === WebSocket.OPEN) socket.send(data)
})
// Resize streams fire per layout frame during panel open/close
// animations; fit() measures glyphs, so coalesce to one fit per
// animation frame (same pattern as the core's frame-batcher, kept
// local — this view lives in the lazy terminal chunk and does not
// import core-bundle modules).
let resizeFrame: number | null = null
const observer = new ResizeObserver(() => {
try {
fit.fit()
sendResize()
} catch {
// The terminal may be mid-dispose; ignore.
}
if (resizeFrame !== null) return
resizeFrame = requestAnimationFrame(() => {
resizeFrame = null
try {
fit.fit()
sendResize()
} catch {
// The terminal may be mid-dispose; ignore.
}
})
})
observer.observe(host)
@@ -323,6 +333,7 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
cancelOpen()
window.clearTimeout(retry)
observer.disconnect()
if (resizeFrame !== null) cancelAnimationFrame(resizeFrame)
fontSub()
schemeSub()
inputSub.dispose()
+18 -6
View File
@@ -114,11 +114,13 @@ export function TextEditor(props: FileViewerProps) {
}, [content])
// Create the CodeMirror editor once the content is loaded. The view owns
// the document; React only tracks dirty/draft state through the update
// listener. For markdown the view stays mounted while previewing (hidden),
// so unsaved edits survive the preview/edit toggle. The theme + syntax
// colors live in a compartment so a scheme flip reconfigures only that
// part — the document, undo history and scroll position survive.
// the document; React only tracks dirty state through the update listener
// (the draft — the preview's text — is snapshotted from the live view on
// entering preview, not re-stringified per keystroke). For markdown the
// view stays mounted while previewing (hidden), so unsaved edits survive
// the preview/edit toggle. The theme + syntax colors live in a compartment
// so a scheme flip reconfigures only that part — the document, undo
// history and scroll position survive.
useEffect(() => {
if (content === undefined) return
const host = hostRef.current
@@ -139,7 +141,6 @@ export function TextEditor(props: FileViewerProps) {
...(language !== null ? [language] : []),
CodeMirrorView.updateListener.of((update) => {
if (update.docChanged) {
setDraft(update.state.doc.toString())
setDirty(true)
}
}),
@@ -226,6 +227,17 @@ export function TextEditor(props: FileViewerProps) {
if (mode === 'edit') viewRef.current?.requestMeasure()
}, [mode])
// Snapshot the live document into the draft whenever the preview needs
// it: entering preview (the markdown preview renders `draft ?? content`)
// and a content swap (the view was just re-created above, so the read is
// the new document — matching the reset-to-null of a clean tab). The
// updateListener used to re-stringify the WHOLE document on every
// keystroke (O(docLength) per key) for a draft only preview reads.
useEffect(() => {
const view = viewRef.current
setDraft(view === null ? null : view.state.doc.toString())
}, [mode, content])
const save = (): void => {
const view = viewRef.current
if (view === null || savingRef.current) return
+11 -2
View File
@@ -63,6 +63,12 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
// up). The cursor is the last delivered seq, so each poll ships only
// what the accumulator lacks. ────────────────────────────────────────
const eventsRef = useRef<readonly SidebarSessionEvent[]>([])
// The fold of eventsRef as of the last poll. extractFileOps parses every
// accumulated tool/call (up to EVENTS_CAP events); running it once per
// poll and REUSING the result across renders (the render used to re-fold
// the whole window, twice per tick, and the fresh array defeated the
// downstream memo on every poll) keeps the 2.5s tick at one fold.
const opsRef = useRef<readonly FileOp[]>([])
const seqRef = useRef(0)
const pollGen = useRef(0)
const [opsError, setOpsError] = useState(false)
@@ -77,7 +83,9 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
eventsRef.current = merged.length > EVENTS_CAP ? merged.slice(merged.length - EVENTS_CAP) : merged
}
if (lastSeq > seqRef.current) seqRef.current = lastSeq
opCounts.set(scope.sessionId, extractFileOps(eventsRef.current).length)
const folded = extractFileOps(eventsRef.current)
opsRef.current = folded
opCounts.set(scope.sessionId, folded.length)
setOpsError(false)
setTick(value => value + 1)
} catch {
@@ -89,6 +97,7 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
useEffect(() => {
pollGen.current += 1
eventsRef.current = []
opsRef.current = []
seqRef.current = 0
setOpsError(false)
}, [scope.sessionId])
@@ -100,7 +109,7 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
}, [visible, pull])
// tick only forces the re-render; the fold reads the ref directly.
void tick
const ops = extractFileOps(eventsRef.current)
const ops = opsRef.current
/** Persist a meta patch onto the tab (lens choice, pane height). */
const patchMeta = (patch: ChangesMeta): void => {
+8 -1
View File
@@ -21,6 +21,7 @@ import { DiffFiles } from '../diff/DiffFiles.tsx'
import { langOfPath } from '../diff/highlight.ts'
import { buildDiffSegments, diffLines, diffStats, parseUnifiedDiff, unifiedSegments, type DiffRow } from '../diff/rows.ts'
import { parseReadLines, type FileOp } from './ops.ts'
import { createFrameBatcher } from '../frame-batcher.ts'
import css from './changes.module.css'
import diffCss from '../diff/diff.module.css'
@@ -176,17 +177,23 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
const paneHeight = dragHeight ?? height
const clamp = (value: number): number => Math.min(Math.max(value, HEIGHT_MIN), Math.round(window.innerHeight * 0.7))
const dragOrigin = useRef<{ y: number; h: number } | null>(null)
// Pointer streams fire several times per frame; one setState per event
// re-rendered the whole pane at event cadence (see frame-batcher).
const dragBatcher = useRef(createFrameBatcher()).current
useEffect(() => () => dragBatcher.dispose(), [dragBatcher])
const onHandleDown = (event: ReactPointerEvent<HTMLDivElement>): void => {
event.preventDefault()
dragOrigin.current = { y: event.clientY, h: paneHeight }
const onMove = (ev: PointerEvent): void => {
if (dragOrigin.current === null) return
setDragHeight(clamp(dragOrigin.current.h + (dragOrigin.current.y - ev.clientY)))
const next = clamp(dragOrigin.current.h + (dragOrigin.current.y - ev.clientY))
dragBatcher.schedule(() => { setDragHeight(next) })
}
const onUp = (): void => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
dragOrigin.current = null
dragBatcher.flushNow()
setDragHeight(current => {
if (current !== null) onHeightCommit(current)
return null
+19
View File
@@ -76,6 +76,13 @@ interface ConfirmState {
* floods the panel at once (the end of the log is reached by paging). */
const LOG_BATCH = 20
/** Every Nth silent poll re-lists worktrees (and re-runs auto-selection): the
* 2s tick only needs the selected checkout's STATUS, and re-listing spawned
* a second git process per tick for a list that almost never changes — a
* linked checkout the agent creates mid-session is picked up within ~30s
* instead of 2s. */
const WORKTREE_RECHECK_TICKS = 15
export interface GitLensProps {
scope: SessionScope
/** The sidebar store: reads the `workspaceFence` pref (see the open guard below). */
@@ -123,6 +130,8 @@ export function GitLens(props: GitLensProps) {
* callback and re-trigger the mount effect — an N→N+1 fetch loop). */
const chosenPathRef = useRef<string | undefined>(undefined)
useEffect(() => { chosenPathRef.current = selectedWorktree }, [selectedWorktree])
/** Silent polls since the last worktree re-list (see WORKTREE_RECHECK_TICKS). */
const silentTickCount = useRef(0)
const gitScope: SessionScope = repoRoot === undefined ? scope : { ...scope, repoRoot }
@@ -160,6 +169,15 @@ export function GitLens(props: GitLensProps) {
refreshInFlight.current = true
let generation = refreshGeneration.current
try {
// Fast path for the ordinary silent tick: the selected checkout's
// STATUS is all that changes between worktree re-lists (see
// WORKTREE_RECHECK_TICKS) — one git process instead of two.
if (silent && chosenPathRef.current !== undefined && (silentTickCount.current += 1) % WORKTREE_RECHECK_TICKS !== 0) {
const statusResult = await api.gitStatus(gitScope, chosenPathRef.current)
if (generation === refreshGeneration.current) setStatus(statusResult)
return
}
silentTickCount.current = 0
const listed = await api.gitWorktrees(scope)
if (generation !== refreshGeneration.current) return
setWorktrees(listed)
@@ -214,6 +232,7 @@ export function GitLens(props: GitLensProps) {
refreshInFlight.current = false
worktreeChosenByUser.current = false
chosenPathRef.current = undefined
silentTickCount.current = 0
setSelectedWorktree(undefined)
}, [scope.sessionId, scope.cwd])
useEffect(() => { void refresh() }, [refresh])
+16 -2
View File
@@ -54,6 +54,20 @@ export function SessionLens({ ops, loadError, onPreview, selectedCallId }: Sessi
</button>
)
// Sizing a row's content constructs a Blob (a UTF-8 encode of the whole
// edit) — that was per row PER RENDER, re-encoding every field on every
// poll tick. ops keeps its identity between unchanged polls (see the
// tab's opsRef), so one pass per changed fold covers every render.
const opSizes = useMemo(() => {
const sizes = new Map<string, string>()
for (const op of ops) {
if (op.kind !== 'read' && !op.isError) {
sizes.set(op.callId, formatBytes(new Blob([op.edit?.newString ?? op.content ?? '']).size))
}
}
return sizes
}, [ops])
return (
<div className={css.session}>
<div className={css.filterRow} role="group" aria-label={t('changesSessionLens')}>
@@ -85,9 +99,9 @@ export function SessionLens({ ops, loadError, onPreview, selectedCallId }: Sessi
{op.running && <span className={css.opFlag}>{t('changesRunning')}</span>}
{op.isError && <span className={css.opFlagError}>{t('changesError')}</span>}
<span className={css.opMeta}>
{op.kind !== 'read' && !op.isError && (
{opSizes.get(op.callId) !== undefined && (
<span className={css.opSize}>
{formatBytes(new Blob([op.edit?.newString ?? op.content ?? '']).size)}
{opSizes.get(op.callId)}
</span>
)}
<span className={css.opTime}>{relativeTime(new Date(op.time).toISOString())}</span>
+1 -1
View File
@@ -49,7 +49,7 @@
* client.js); an edit that does land while a core HMR happens is caught by
* the ETag comparison on the next activation.
*/
export type ChunkName = 'terminal' | 'editor' | 'mermaid'
export type ChunkName = 'terminal' | 'editor' | 'mermaid' | 'locale'
/** The module exports a chunk factory provides (namespace-ish record). */
export type ChunkExports = Record<string, unknown>
+60
View File
@@ -0,0 +1,60 @@
/**
* Lazy chunk entry: the 19 non-zh/en dictionaries (ja/de/fr/…/zh-HK/MO/TW —
* ~640KB of source). The sidebar's own `t()` only ever consults zh/en plus
* the better-locale override store, so these dicts are needed ONLY when
* @huanlin/dsh-plugin-better-locale is installed: they register into its
* override store from there (see the client apply's better-locale
* integration). Built as `lib/client-locale.js` and fetched from the
* plugin's /sidebar/bundle route on first need — never import this module
* from the core bundle: it would drag every language back into the startup
* path (see docs/plans/2026-08-31-perf-optimization.md).
*/
import { ja } from '../locales-ja.ts'
import { de } from '../locales-de.ts'
import { fr } from '../locales-fr.ts'
import { pt } from '../locales-pt.ts'
import { ko } from '../locales-ko.ts'
import { ar } from '../locales-ar.ts'
import { hi } from '../locales-hi.ts'
import { id } from '../locales-id.ts'
import { tr } from '../locales-tr.ts'
import { vi } from '../locales-vi.ts'
import { th } from '../locales-th.ts'
import { ru } from '../locales-ru.ts'
import { it } from '../locales-it.ts'
import { nl } from '../locales-nl.ts'
import { sv } from '../locales-sv.ts'
import { pl } from '../locales-pl.ts'
import { zhHK } from '../locales-zh-HK.ts'
import { zhTW } from '../locales-zh-TW.ts'
import { zhMO } from '../locales-zh-MO.ts'
import type { CopyKey } from '../locales.ts'
/** Key-set check against zh (type-only import — erased before bundling, so
* this adds no runtime tie to the core bundle): a dictionary missing or
* adding a key fails the build instead of silently falling back to en. */
const checked = (dict: Record<CopyKey, string>): Record<string, string> => dict
/** Every non-zh/en dictionary keyed by its override id, ready for the
* better-locale store's `register(ns, dicts)`. */
export const localeDicts: Record<string, Record<string, string>> = {
ja: checked(ja),
de: checked(de),
fr: checked(fr),
pt: checked(pt),
ko: checked(ko),
ar: checked(ar),
hi: checked(hi),
id: checked(id),
tr: checked(tr),
vi: checked(vi),
th: checked(th),
ru: checked(ru),
it: checked(it),
nl: checked(nl),
sv: checked(sv),
pl: checked(pl),
'zh-HK': checked(zhHK),
'zh-TW': checked(zhTW),
'zh-MO': checked(zhMO),
}
+37 -24
View File
@@ -21,13 +21,11 @@ import { registerOpenPathInterception, registerTurnTailInterception } from './in
import { registerLinkInterception } from './link-intercept.ts'
import { registerImeGuard } from './ime-guard.ts'
import { registerSettingsNavIcon } from './settings-nav-icon.ts'
import { loadExternalDisable, loadPrefs } from './prefs.ts'
import { loadBootDecision } from './prefs.ts'
import { SideCardSection } from './SideCardSection.tsx'
import { api } from './api.ts'
import { LOCALE_NS, attachLocale, attachBetterLocale, t, zh, en,
ja, de, fr, pt, ko, ar, hi, id, tr, vi, th, ru, it, nl, sv, pl,
zhHK, zhTW, zhMO,
} from './locales.ts'
import { LOCALE_NS, attachLocale, attachBetterLocale, t, zh, en } from './locales.ts'
import { loadChunk } from './chunk-loader.ts'
import css from './sidebar.module.css'
import './layout.css'
@@ -82,7 +80,12 @@ export function apply(ctx: Context): void {
// the ja dict once the store becomes available.
ctx.effect(() => {
let dispose: (() => void) | undefined
// Guards the async chunk registration below: a sync() re-run (or fiber
// disposal) that lands while the chunk is still in flight must render
// that registration moot.
let generation = 0
const sync = (): void => {
generation += 1
dispose?.()
dispose = undefined
const store = ctx.get('betterLocale') as
@@ -96,10 +99,17 @@ export function apply(ctx: Context): void {
| undefined
attachBetterLocale(store)
if (store !== undefined) {
dispose = store.register(LOCALE_NS, {
ja, de, fr, pt, ko, ar, hi, id, tr, vi, th, ru, it, nl, sv, pl,
'zh-HK': zhHK, 'zh-TW': zhTW, 'zh-MO': zhMO,
})
// The 19 override dictionaries ride the lazy `locale` chunk: until
// it lands, the store has no betterSidebar entries and t() keeps
// the zh/en chain; the store's own revision bump on register
// re-renders the chrome once the dicts arrive.
const myGeneration = generation
void loadChunk('locale')
.then(mod => {
if (myGeneration !== generation) return
dispose = store.register(LOCALE_NS, mod.localeDicts as Record<string, Record<string, string>>)
})
.catch(() => { /* the dicts stay unregistered; the zh/en chain runs */ })
}
}
// Initial check (picks up the store if better-locale activated first).
@@ -108,6 +118,7 @@ export function apply(ctx: Context): void {
// activates with a persisted override, and when the user switches).
const unsubscribe = ctx.locale.subscribe(sync)
return () => {
generation += 1
unsubscribe()
dispose?.()
attachBetterLocale(undefined)
@@ -275,24 +286,26 @@ export function apply(ctx: Context): void {
}
const sync = async (): Promise<void> => {
if (disposed) return
// Resolve the user's side card prefs BEFORE the first session seeds,
// so a brand-new conversation opens (or stays closed) at the chosen
// width from first paint. A settings route failure falls back to the
// schema defaults; the sidebar still mounts (a stalled wire gives up
// after the timeout and mounts on the defaults).
const prefs = await Promise.race([
loadPrefs(api),
// Resolve the user's side card prefs and the external-disable flag
// from ONE settings fetch BEFORE the first session seeds, so a
// brand-new conversation opens (or stays closed) at the chosen width
// from first paint. A settings route failure falls back to the schema
// defaults; the sidebar still mounts (a stalled wire gives up after
// the timeout and mounts on the defaults — the external-disable check
// rides the same fetch, so one round trip covers both decisions).
const decision = await Promise.race([
loadBootDecision(api),
new Promise<null>(resolve => { const timer = window.setTimeout(() => resolve(null), 2000) }),
])
if (prefs !== null) sidebarStore.setPrefs(prefs)
if (disposed) return
// Mutual exclusion with the dsh-web-ui family right panel: while the
// aionui-panel provider is selected, the sidebar must not mount at
// all. Re-evaluated on every settings-document update (live switch).
const suspended = await loadExternalDisable(api)
if (disposed) return
sidebarStore.setSuspended(suspended)
if (suspended) unmount()
if (decision !== null) {
sidebarStore.setPrefs(decision.prefs)
// Mutual exclusion with the dsh-web-ui family right panel: while the
// aionui-panel provider is selected, the sidebar must not mount at
// all. Re-evaluated on every settings-document update (live switch).
sidebarStore.setSuspended(decision.suspended)
}
if (decision?.suspended) unmount()
else mount()
}
void sync()
+12 -12
View File
@@ -35,14 +35,16 @@
width var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
/* The AppFrame's center column, anchored by data attributes (see above).
Composite selector: DSH 0.1.x versions name the center grid item
`[data-pane="conversation"]`, while rc.8-era shells put a
`[data-slot="conversation"]` child inside it — both selectors resolve to
the SAME element on live pages (verified), and keeping both future-proofs
the rule against a host rename without touching shell-specific markup. */
#root [data-dsh-frame] > [data-pane="conversation"],
#root :has(> [data-slot="conversation"]) {
/* The AppFrame's center column. The Sidebar shell's locator (Sidebar.tsx
locate/measureCenter) tags the measured column node with
[data-dsh-center-col] — one attribute write per node lifetime. The rule
used to anchor on a structural has()-selector over the conversation slot
wrapper, whose match cache invalidates on every #root subtree mutation
(the streaming chat mutates it constantly), re-evaluating the selector
against the whole document; the tag is evaluated once per node instead.
A stretched grid item shrinks by its margins, so the conversation
content (output + input bar) lifts without touching the sidebars. */
#root [data-dsh-center-col] {
margin-bottom: var(--dsh-sidebar-height, 0px);
/* Grid/flex items default to min-height:auto. A long unbreakable token
(OAuth URL) then grows this column past the viewport and clips the
@@ -68,8 +70,7 @@ body[data-dsh-sidebar-collapsed] [data-slot="conversation.session.header"] > hea
}
body[data-dsh-sidebar-dragging] #root,
body[data-dsh-sidebar-dragging] #root [data-dsh-frame] > [data-pane="conversation"],
body[data-dsh-sidebar-dragging] #root :has(> [data-slot="conversation"]) {
body[data-dsh-sidebar-dragging] #root [data-dsh-center-col] {
transition: none;
}
@@ -94,8 +95,7 @@ body[data-dsh-sidebar-dragging] #root :has(> [data-slot="conversation"]) {
@media (prefers-reduced-motion: reduce) {
#root,
#root [data-dsh-frame] > [data-pane="conversation"],
#root :has(> [data-slot="conversation"]) {
#root [data-dsh-center-col] {
transition: none;
}
}
+7 -43
View File
@@ -845,49 +845,13 @@ export const en: Record<keyof typeof zh, string> = {
*/
export const LOCALE_NS = 'betterSidebar'
// The ja dictionary lives in a sibling file (326 keys) so this module
// stays readable. Type-checked against the zh key set: a missing or extra
// ja key is a compile error.
import { ja as jaDict } from './locales-ja.ts'
import { de as deDict } from './locales-de.ts'
import { fr as frDict } from './locales-fr.ts'
import { pt as ptDict } from './locales-pt.ts'
import { ko as koDict } from './locales-ko.ts'
import { ar as arDict } from './locales-ar.ts'
import { hi as hiDict } from './locales-hi.ts'
import { id as idDict } from './locales-id.ts'
import { tr as trDict } from './locales-tr.ts'
import { vi as viDict } from './locales-vi.ts'
import { th as thDict } from './locales-th.ts'
import { ru as ruDict } from './locales-ru.ts'
import { it as itDict } from './locales-it.ts'
import { nl as nlDict } from './locales-nl.ts'
import { sv as svDict } from './locales-sv.ts'
import { pl as plDict } from './locales-pl.ts'
import { zhHK as zhHKDict } from './locales-zh-HK.ts'
import { zhTW as zhTWDict } from './locales-zh-TW.ts'
import { zhMO as zhMODict } from './locales-zh-MO.ts'
/** The ja dictionary (key-set-equal to zh, enforced by the type annotation). */
export const ja: Record<keyof typeof zh, string> = jaDict as Record<keyof typeof zh, string>
export const de: Record<keyof typeof zh, string> = deDict as Record<keyof typeof zh, string>
export const fr: Record<keyof typeof zh, string> = frDict as Record<keyof typeof zh, string>
export const pt: Record<keyof typeof zh, string> = ptDict as Record<keyof typeof zh, string>
export const ko: Record<keyof typeof zh, string> = koDict as Record<keyof typeof zh, string>
export const ar: Record<keyof typeof zh, string> = arDict as Record<keyof typeof zh, string>
export const hi: Record<keyof typeof zh, string> = hiDict as Record<keyof typeof zh, string>
export const id: Record<keyof typeof zh, string> = idDict as Record<keyof typeof zh, string>
export const tr: Record<keyof typeof zh, string> = trDict as Record<keyof typeof zh, string>
export const vi: Record<keyof typeof zh, string> = viDict as Record<keyof typeof zh, string>
export const th: Record<keyof typeof zh, string> = thDict as Record<keyof typeof zh, string>
export const ru: Record<keyof typeof zh, string> = ruDict as Record<keyof typeof zh, string>
export const it: Record<keyof typeof zh, string> = itDict as Record<keyof typeof zh, string>
export const nl: Record<keyof typeof zh, string> = nlDict as Record<keyof typeof zh, string>
export const sv: Record<keyof typeof zh, string> = svDict as Record<keyof typeof zh, string>
export const pl: Record<keyof typeof zh, string> = plDict as Record<keyof typeof zh, string>
export const zhHK: Record<keyof typeof zh, string> = zhHKDict as Record<keyof typeof zh, string>
export const zhTW: Record<keyof typeof zh, string> = zhTWDict as Record<keyof typeof zh, string>
export const zhMO: Record<keyof typeof zh, string> = zhMODict as Record<keyof typeof zh, string>
// The 19 non-zh/en dictionaries live in the LAZY `locale` chunk
// (src/client/chunks/locale.tsx → lib/client-locale.js): `t()` only ever
// consults zh/en plus the better-locale override store, so those dicts are
// needed only when better-locale is installed — the client apply fetches the
// chunk there and registers its `localeDicts` into the override store. They
// used to sit statically in the core bundle (≈ half its weight) for a code
// path most sessions never enter.
/** The DSH locale service attached by the client apply (absent → browser detection). */
let localeService: { getSnapshot(): { active: string } } | undefined
+19
View File
@@ -217,3 +217,22 @@ export async function loadExternalDisable(settings: SidebarSettingsClient): Prom
return false
}
}
/** The boot decision both the prefs and the external-disable flag need:
* ONE settings fetch answers both (the boot path used to await
* {@link loadPrefs} and {@link loadExternalDisable} serially — two round
* trips of the same document before the first paint, and the second had no
* timeout, so a stalled wire could keep the sidebar unmounted forever). */
export interface BootDecision {
prefs: SidebarPrefs
suspended: boolean
}
export async function loadBootDecision(settings: SidebarSettingsClient): Promise<BootDecision> {
try {
const view = await settings.settingsGet()
return { prefs: parsePrefs(view.value), suspended: view.externalDisable === true }
} catch {
return { prefs: { ...SIDEBAR_PREFS_DEFAULTS }, suspended: false }
}
}
+55 -1
View File
@@ -307,7 +307,7 @@ function resultCard(
* @param entries - history rows (event + host-computed view) in seq order.
* @returns display rows in log order.
*/
export function transcriptRows(entries: readonly SidebarHistoryEntry[]): SidechatTranscriptRow[] {
export function transcriptRows(entries: readonly SidebarHistoryEntry[], prev?: readonly SidechatTranscriptRow[]): SidechatTranscriptRow[] {
const events = entries.map(entry => entry.event)
const seedEnd = lastSeedEnd(events)
const rows: SidechatTranscriptRow[] = []
@@ -484,5 +484,59 @@ export function transcriptRows(entries: readonly SidebarHistoryEntry[]): Sidecha
}
}
}
return reuseRows(rows, prev)
}
/** Whether two rows carry identical display content (identity fields plus
* every rendered field of their kind). */
function rowsEqual(a: SidechatTranscriptRow, b: SidechatTranscriptRow): boolean {
if (a.kind !== b.kind || a.seq !== b.seq) return false
switch (a.kind) {
case 'user':
case 'injection': {
const other = b as typeof a
return a.text === other.text
}
case 'assistant':
case 'reasoning': {
const other = b as typeof a
return a.text === other.text && a.settled === other.settled
}
case 'tool': {
// The card is NOT compared by value: its content is a pure function of
// the call-time args and the tool/result landing, and every transition
// (output/exit arriving) flips `executing`/`failed`/`resultText` —
// compared here — so a card whose content changed never slips through
// an equal compare, and an equal compare re-adopting the OLD card
// object (identical content, stale reference) only helps React skip.
const other = b as typeof a
return a.name === other.name && a.failed === other.failed && a.args === other.args
&& a.resultText === other.resultText && a.executing === other.executing
}
case 'turnSummary': {
const other = b as typeof a
return a.inputTokens === other.inputTokens && a.outputTokens === other.outputTokens
&& a.durationMs === other.durationMs
}
}
}
/** Re-adopt the PREVIOUS poll's row objects wherever the content is
* unchanged (position-aligned, append-mostly): settled rows keep their
* object identity across the 2s polls, so React's reconciler and the
* markdown/DOMPurify caches downstream skip them instead of re-rendering
* the whole transcript every poll. Comparing the strings is far cheaper
* than what a fresh reference costs the row below. Rows past the first
* mismatch (an inserted/superseded streaming row) rebuild as usual — that
* is exactly the changed tail. */
function reuseRows(rows: SidechatTranscriptRow[], prev: readonly SidechatTranscriptRow[] | undefined): SidechatTranscriptRow[] {
if (prev === undefined) return rows
const shared = Math.min(rows.length, prev.length)
for (let index = 0; index < shared; index++) {
const before = prev[index]
const after = rows[index]
if (before === undefined || after === undefined) continue
if (rowsEqual(before, after)) rows[index] = before
}
return rows
}