From bc6f1e129d9a84d8bfaf2576a2df0d4855357c95 Mon Sep 17 00:00:00 2001 From: Lin Date: Fri, 21 Aug 2026 13:40:41 +0800 Subject: [PATCH 01/10] fix(markdown): preview local/relative images in .md files The shared MarkdownText only renders absolute http(s) images (relative links are disabled for chat security), so a local image referenced by a previewed markdown file - ![alt](./img.png), an absolute /cwd/img.png, or a reference definition - showed only its alt text. Rewrite such destinations into absolute /sidebar/file media URLs (with the GUI origin prefix) so MarkdownText accepts them and the host media route serves the bytes (still restricted to the session cwd). The rewriter is a dependency-free pure helper (markdown-images.ts) with a focused unit spec; TextEditor passes the rewritten preview text to both the plain MarkdownText and the mermaid-lazy preview. --- README.md | 1 + README_EN.md | 1 + src/client/TextEditor.tsx | 12 ++++- src/client/markdown-images.ts | 89 +++++++++++++++++++++++++++++++++++ tests/markdown-images.spec.ts | 73 ++++++++++++++++++++++++++++ 5 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 src/client/markdown-images.ts create mode 100644 tests/markdown-images.spec.ts diff --git a/README.md b/README.md index eb0cdab..19f4624 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ - 🔧 **xterm 依赖迁移**:弃用的 xterm 迁移至 `@xterm/xterm`(Closes [#122](https://github.com/omdsh-dev/DSH-better-sidebar/issues/122),[#128](https://github.com/omdsh-dev/DSH-better-sidebar/pull/128)) - 📝 **Markdown 编辑器**:选区转对话弹窗恢复可用([#24](https://github.com/omdsh-dev/DSH-better-sidebar/pull/24)) +- 🖼️ **Markdown 预览支持本地/相对路径图片**:预览 `.md` 时把指向本地文件的图片目标(相对/绝对路径、引用式 `[id]: url`)重写为 `/sidebar/file` 媒体 URL 并显示(此前仅绝对 http(s) 图片能渲染,相对路径只显示 alt 文本) - 🐛 **node-pty 加载失败不再拖垮 server**([#140](https://github.com/omdsh-dev/DSH-better-sidebar/issues/140)):宿主半改为懒加载 node-pty,缺失时插件照常挂载,终端以修复提示横幅(可复制命令 + 重试按钮)呈现,agent 终端工具自动跳过 - 🧪 测试工程:单元测试拆分(#141)+ smoke 偶发失败修复 diff --git a/README_EN.md b/README_EN.md index 2b2e3ee..60ad11f 100644 --- a/README_EN.md +++ b/README_EN.md @@ -109,6 +109,7 @@ - 🔧 **xterm migration**: deprecated xterm dependency migrated to `@xterm/xterm` (Closes [#122](https://github.com/omdsh-dev/DSH-better-sidebar/issues/122), [#128](https://github.com/omdsh-dev/DSH-better-sidebar/pull/128)) - 📝 **Markdown editor**: selection-to-conversation popup restored ([#24](https://github.com/omdsh-dev/DSH-better-sidebar/pull/24)) +- 🖼️ **Markdown preview renders local/relative images**: image destinations that point at local files (relative/absolute paths, reference-style `[id]: url`) are rewritten to `/sidebar/file` media URLs and displayed in the preview (previously only absolute http(s) images rendered; relative paths showed just the alt text) - 🐛 **node-pty load failure no longer crashes the server** ([#140](https://github.com/omdsh-dev/DSH-better-sidebar/issues/140)): the host half now lazy-loads node-pty — when it is missing the plugin still mounts, the terminal shows a repair banner (copyable command + Retry button), and agent terminal tools are skipped - 🧪 Test engineering: unit spec split (#141) + flaky smoke cleanup fix diff --git a/src/client/TextEditor.tsx b/src/client/TextEditor.tsx index bcb406f..57137b6 100644 --- a/src/client/TextEditor.tsx +++ b/src/client/TextEditor.tsx @@ -22,6 +22,7 @@ import { EditorView as CodeMirrorView, keymap, lineNumbers } from '@codemirror/v import { defaultKeymap, history, historyKeymap } from '@codemirror/commands' import { IconCheckOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' import { api, htmlUrl } from './api.ts' +import { rewriteLocalImageUrls } from './markdown-images.ts' import { languageForPath } from './lang.ts' import { cmSurfaceTheme, CmThemeCompartment } from './cm-themes.ts' import { isDarkScheme, subscribeColorScheme } from './theme.ts' @@ -252,6 +253,13 @@ export function TextEditor(props: FileViewerProps) { const html = viewerId === 'html' /** The markdown source the preview renders (draft wins over saved content). */ const mdText = draft ?? content ?? '' + /** The preview source: `mdText` with local image destinations rewritten to + * absolute media URLs (see {@link rewriteLocalImageUrls}); the raw + * `mdText` stays untouched for selection/line lookup and for mermaid-block + * detection, which are unaffected by image syntax. */ + const previewText = markdown + ? rewriteLocalImageUrls(mdText, scope, path, window.location.origin) + : mdText /** md/mermaid block split for the preview (mermaid fences lift out). Split * only in preview mode: edit-mode keystrokes must not re-scan the source. */ const mdBlocks = useMemo( @@ -392,8 +400,8 @@ export function TextEditor(props: FileViewerProps) { parse; cross-fence references/footnotes stay intact); files without one render exactly as before. */} {hasMermaid - ? - : } + ? + : } )} {html && mode === 'preview' && ( diff --git a/src/client/markdown-images.ts b/src/client/markdown-images.ts new file mode 100644 index 0000000..115303c --- /dev/null +++ b/src/client/markdown-images.ts @@ -0,0 +1,89 @@ +/** + * Markdown-preview local-image resolution. The shared `MarkdownText` (from + * @deepseek-ai/dsh-client-ui-primitives) only renders absolute http(s) image + * URLs — relative links are disabled for chat security — so a local image in + * a previewed `.md` (`![alt](./img.png)`, an absolute `/cwd/img.png`, or a + * reference definition) would otherwise fall back to its alt text. This + * dependency-free helper rewrites those destinations into absolute + * `/sidebar/file` media URLs (prefixed with the GUI's own origin) so + * `MarkdownText` accepts them; the host media route then serves the bytes, + * still restricted to files under the session cwd. + */ + +import type { SessionScope } from './api.ts' +import { isAbsolutePath } from './paths.ts' + +/** + * True for a destination that is a remote URL — an absolute `scheme:` URL + * that is not a Windows drive path (`C:\...`). http/https/data/mailto etc. + * all match here and are handed back to `MarkdownText` untouched. + */ +function isRemoteUrl(dest: string): boolean { + return /^[a-z][a-z0-9+.-]*:/i.test(dest) && !/^[A-Za-z]:[\\/]/.test(dest) +} + +/** + * Collapse `.`/`..` segments of an absolute local path, preserving its root + * (POSIX `/`), its Windows drive (`C:\`), or its UNC `\\server\share` + * prefix. The host's `requireAbsolute` (`path.resolve`) normalizes anyway, + * but producing a canonical path here keeps the `/sidebar/file` URL clean. + */ +function normalizeLocalPath(path: string): string { + const drive = /^([A-Za-z]:)[\\/]/.exec(path)?.[1] + const body = drive !== undefined ? path.slice(drive.length) : path + const parts = body.split(/[\\/]+/).filter((segment) => segment !== '' && segment !== '.') + const out: string[] = [] + for (const part of parts) { + if (part === '..') { out.pop(); continue } + out.push(part) + } + if (drive !== undefined) return `${drive}\\${out.join('\\')}` + const separator = path.startsWith('\\') ? '\\' : '/' + const root = path.startsWith('/') ? '/' : path.startsWith('\\') ? '\\\\' : '' + return `${root}${out.join(separator)}` +} + +/** + * Rewrite markdown image destinations that point at local files into + * absolute `/sidebar/file` media URLs. Relative destinations resolve against + * the opened file's directory (normalizing `.`/`..` segments); absolute + * local paths pass through. Remote (http/https/data/mailto) and `#`-anchor + * destinations are left untouched for `MarkdownText`. Reference-style images + * (`![x][id]` + `[id]: url`) are covered by rewriting their definition lines. + * @param text - The raw markdown source (inline + reference images). + * @param scope - The session scope (sessionId + cwd) for the media route. + * @param filePath - The absolute path of the opened `.md` file. + * @param origin - The GUI's own origin (`window.location.origin`); injected + * so the core rewrite stays pure and unit-testable. + * @returns The markdown with local image destinations rewritten in place. + */ +export function rewriteLocalImageUrls( + text: string, + scope: SessionScope, + filePath: string, + origin: string, +): string { + const slash = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\')) + const directory = slash === -1 ? '/' : filePath.slice(0, slash + 1) + const mediaUrl = (candidate: string): string => { + // Mirrors api.ts fileUrl/mediaUrl for the /sidebar/file media route, made + // absolute so the shared MarkdownText http(s) allowlist accepts it. + const params = new URLSearchParams({ sessionId: scope.sessionId, path: candidate }) + if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd) + return `${origin}/sidebar/file?${params.toString()}` + } + const resolve = (dest: string): string => { + const trimmed = dest.trim() + if (trimmed === '' || trimmed.startsWith('#')) return dest + if (isRemoteUrl(trimmed)) return dest + const candidate = isAbsolutePath(trimmed) ? trimmed : directory + trimmed + return mediaUrl(normalizeLocalPath(candidate)) + } + const inline = text.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, (_match, alt, dest) => { + return `![${alt}](${resolve(dest)})` + }) + // Reference-style images: rewrite their `[id]: ` definitions. + return inline.replace(/^(\s*\[[^\]]+\]:\s*)(<[^>]+>|[^\s]+)/gm, (_match, head, dest) => { + return `${head}${resolve(dest.replace(/^<|>$/g, ''))}` + }) +} diff --git a/tests/markdown-images.spec.ts b/tests/markdown-images.spec.ts new file mode 100644 index 0000000..d867ff5 --- /dev/null +++ b/tests/markdown-images.spec.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest' +import { rewriteLocalImageUrls } from '../src/client/markdown-images.ts' +import type { SessionScope } from '../src/client/api.ts' + +const ORIGIN = 'http://127.0.0.1:3080' +const scope: SessionScope = { sessionId: 'abc', cwd: '/repo' } + +describe('rewriteLocalImageUrls', () => { + it('resolves relative destinations against the opened file directory', () => { + const md = '![a](./img.png)\n![b](images/b.jpg)' + const out = rewriteLocalImageUrls(md, scope, '/repo/docs/readme.md', ORIGIN) + expect(out).toContain(`![a](${ORIGIN}/sidebar/file?sessionId=abc&path=%2Frepo%2Fdocs%2Fimg.png&cwd=%2Frepo)`) + expect(out).toContain(`![b](${ORIGIN}/sidebar/file?sessionId=abc&path=%2Frepo%2Fdocs%2Fimages%2Fb.jpg&cwd=%2Frepo)`) + }) + + it('expects the rewritten destination to be an absolute http URL MarkdownText accepts', () => { + const out = rewriteLocalImageUrls('![a](./img.png)', scope, '/repo/readme.md', ORIGIN) + const url = out.match(/\]\((http[^)]+)\)/)?.[1] + expect(url).toBeDefined() + expect(new URL(url as string).protocol).toBe('http:') + }) + + it('passes absolute local paths through unchanged', () => { + const md = '![a](/repo/assets/b.png)' + const out = rewriteLocalImageUrls(md, scope, '/repo/docs/readme.md', ORIGIN) + expect(out).toContain(`![a](${ORIGIN}/sidebar/file?sessionId=abc&path=%2Frepo%2Fassets%2Fb.png&cwd=%2Frepo)`) + }) + + it('keeps remote http(s)/data/mailto destinations untouched', () => { + const md = '![a](https://example.com/x.jpg)\n![b](http://e.com/y.png)\n![c](data:image/png;base64,AAA)' + const out = rewriteLocalImageUrls(md, scope, '/repo/readme.md', ORIGIN) + expect(out).toContain('![a](https://example.com/x.jpg)') + expect(out).toContain('![b](http://e.com/y.png)') + expect(out).toContain('![c](data:image/png;base64,AAA)') + }) + + it('leaves in-page anchors untouched', () => { + const md = '![a](#section)\n\n[link](#top)' + const out = rewriteLocalImageUrls(md, scope, '/repo/readme.md', ORIGIN) + expect(out).toContain('![a](#section)') + }) + + it('rewrites reference-style image definitions', () => { + const md = '![logo][logo]\n\n[logo]: ./logo.svg' + const out = rewriteLocalImageUrls(md, scope, '/repo/readme.md', ORIGIN) + expect(out).toContain(`[logo]: ${ORIGIN}/sidebar/file?sessionId=abc&path=%2Frepo%2Flogo.svg&cwd=%2Frepo`) + // The inline reference itself is left for MarkdownText to combine. + expect(out).toContain('![logo][logo]') + }) + + it('normalizes `.`/`..` segments in relative destinations', () => { + const md = '![a](./img.png)\n![b](../assets/b.png)' + const out = rewriteLocalImageUrls(md, scope, '/repo/docs/sub/readme.md', ORIGIN) + expect(out).toContain(`![a](${ORIGIN}/sidebar/file?sessionId=abc&path=%2Frepo%2Fdocs%2Fsub%2Fimg.png&cwd=%2Frepo)`) + expect(out).toContain(`![b](${ORIGIN}/sidebar/file?sessionId=abc&path=%2Frepo%2Fdocs%2Fassets%2Fb.png&cwd=%2Frepo)`) + expect(out).not.toContain('/.') + }) + + it('drops the cwd param when the scope has no cwd', () => { + const out = rewriteLocalImageUrls('![a](./img.png)', { sessionId: 'abc' }, '/repo/readme.md', ORIGIN) + expect(out).toContain(`sessionId=abc&path=%2Frepo%2Fimg.png`) + expect(out).not.toContain('cwd=') + }) + + it('handles windows absolute paths as local (not remote)', () => { + const md = '![a](C:\\repo\\img.png)' + const out = rewriteLocalImageUrls(md, scope, 'C:\\repo\\docs\\readme.md', ORIGIN) + // A Windows drive path is absolute (not a remote scheme) → rewritten. + const url = out.match(/\]\(([^)]+)\)/)?.[1] as string + expect(url).toContain('/sidebar/file?') + expect(decodeURIComponent(url)).toContain('C:\\repo\\img.png') + }) +}) From 8ceaefcfc771bf97406c4dfb6be102c30d440450 Mon Sep 17 00:00:00 2001 From: Zzy Date: Fri, 21 Aug 2026 23:32:54 +0800 Subject: [PATCH 02/10] fix: keep conversation composer in the viewport under layout push Long unbreakable tokens (OAuth URLs) grew the AppFrame center column past the viewport because the layout-push rule used the grid default min-height:auto. Cap --dsh-sidebar-height the same way setBottomHeight does (viewport minus PANEL_MIN) and let the column shrink and wrap. --- src/client/Sidebar.tsx | 31 +++++++++------- src/client/layout-push.ts | 32 +++++++++++++++++ src/client/layout.css | 8 +++++ tests/layout-css.spec.ts | 22 ++++++++++++ tests/layout-push.spec.ts | 75 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 156 insertions(+), 12 deletions(-) create mode 100644 src/client/layout-push.ts create mode 100644 tests/layout-css.spec.ts create mode 100644 tests/layout-push.spec.ts diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index 5684db7..eddb857 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -43,6 +43,7 @@ import { import { IconPanelBottomOutline16, IconPanelRightOutline16 } from './icons.tsx' import { Workbench, type WorkbenchActions } from './split-pane.tsx' import { useNarrowViewport } from './breakpoints.ts' +import { layoutPushSize } from './layout-push.ts' import { parseDesktopEnv } from './desktop-env.ts' import { getWcoSnapshot, subscribeWco } from './wco.ts' import { getShellPreset } from './shell-presets.ts' @@ -804,12 +805,15 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { dragCommitted.current = true stopDragScheduling() const last = lastDragSize.current - const adoptedWidth = !narrow && state?.panelOpen === true - ? Math.min(last?.width ?? state?.width ?? 0, window.innerWidth) - : 0 - const adoptedHeight = !narrow && state?.bottomOpen === true - ? Math.min(last?.height ?? state?.bottomHeight ?? 0, window.innerHeight) - : 0 + const { width: adoptedWidth, height: adoptedHeight } = layoutPushSize({ + narrow, + panelOpen: state?.panelOpen === true, + bottomOpen: state?.bottomOpen === true, + width: last?.width ?? state?.width ?? 0, + bottomHeight: last?.height ?? state?.bottomHeight ?? 0, + viewportWidth: window.innerWidth, + viewportHeight: window.innerHeight, + }) applyDrag(adoptedWidth, adoptedHeight) draggingRef.current = false measureCenter() @@ -826,12 +830,15 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { // On NARROW viewports the drawer FLOATS over the app shell — no push, the // conversation keeps the full width behind the drawer. useEffect(() => { - const width = !narrow && snapshot.state?.panelOpen === true - ? Math.min(snapshot.state.width, window.innerWidth) - : 0 - const height = !narrow && snapshot.state?.bottomOpen === true - ? Math.min(snapshot.state.bottomHeight, window.innerHeight) - : 0 + const { width, height } = layoutPushSize({ + narrow, + panelOpen: snapshot.state?.panelOpen === true, + bottomOpen: snapshot.state?.bottomOpen === true, + width: snapshot.state?.width ?? 0, + bottomHeight: snapshot.state?.bottomHeight ?? 0, + viewportWidth: window.innerWidth, + viewportHeight: window.innerHeight, + }) writeGeometry(width, height) }, [narrow, snapshot.state?.panelOpen, snapshot.state?.width, snapshot.state?.bottomOpen, snapshot.state?.bottomHeight]) // Unmount must release the push (issue #31): when the boundary swaps the diff --git a/src/client/layout-push.ts b/src/client/layout-push.ts new file mode 100644 index 0000000..cf235d6 --- /dev/null +++ b/src/client/layout-push.ts @@ -0,0 +1,32 @@ +/** + * Size written to `--dsh-sidebar-width` / `--dsh-sidebar-height`. + * The conversation column (output + composer) must keep at least + * {@link PANEL_MIN} of the viewport after the bottom panel claims height. + */ +import { BOTTOM_MIN, PANEL_MIN } from './state.ts' + +export interface LayoutPushInput { + narrow: boolean + panelOpen: boolean + bottomOpen: boolean + width: number + bottomHeight: number + viewportWidth: number + viewportHeight: number +} + +export interface LayoutPushSize { + width: number + height: number +} + +/** Compute the live layout-push size. Narrow drawers float and push 0. */ +export function layoutPushSize(input: LayoutPushInput): LayoutPushSize { + if (input.narrow) return { width: 0, height: 0 } + const maxWidth = Math.max(PANEL_MIN, input.viewportWidth) + const maxHeight = Math.max(BOTTOM_MIN, input.viewportHeight - PANEL_MIN) + return { + width: input.panelOpen ? Math.min(Math.max(0, input.width), maxWidth) : 0, + height: input.bottomOpen ? Math.min(Math.max(0, input.bottomHeight), maxHeight) : 0, + } +} diff --git a/src/client/layout.css b/src/client/layout.css index 333f9b0..e634d79 100644 --- a/src/client/layout.css +++ b/src/client/layout.css @@ -44,6 +44,14 @@ #root [data-dsh-frame] > [data-pane="conversation"], #root :has(> [data-slot="conversation"]) { 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 + composer + left-rail Settings row. min-height:0 lets the cell shrink; + overflow:hidden keeps paint inside the cell; overflow-wrap wraps the + token so it cannot force the cell's intrinsic size. */ + min-height: 0; + overflow: hidden; + overflow-wrap: anywhere; transition: margin-bottom var(--ds-transition-duration-slow) var(--ds-ease-in-out); } diff --git a/tests/layout-css.spec.ts b/tests/layout-css.spec.ts new file mode 100644 index 0000000..3b42b9a --- /dev/null +++ b/tests/layout-css.spec.ts @@ -0,0 +1,22 @@ +/** + * layout.css must let the DSH conversation column shrink below its content + * size. Without min-height:0 a long unbreakable URL grows the grid item + * past the viewport and clips the composer. + */ +import { readFileSync } from 'node:fs' +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('allows the center column to shrink and wrap long tokens', () => { + expect(css).toMatch(/min-height:\s*0/) + expect(css).toMatch(/overflow:\s*hidden/) + expect(css).toMatch(/overflow-wrap:\s*anywhere/) + }) +}) diff --git a/tests/layout-push.spec.ts b/tests/layout-push.spec.ts new file mode 100644 index 0000000..35b3fa9 --- /dev/null +++ b/tests/layout-push.spec.ts @@ -0,0 +1,75 @@ +/** + * Layout-push size contract: the conversation column (output + composer) + * must keep at least PANEL_MIN of the viewport after the bottom panel + * claims its height. A stale persisted bottomHeight (or a cap at 100vh) + * used to crush the composer off-screen. + */ +import { describe, expect, it } from 'vitest' +import { BOTTOM_MIN, PANEL_MIN } from '../src/client/state.ts' +import { layoutPushSize } from '../src/client/layout-push.ts' + +describe('layoutPushSize', () => { + it('pushes nothing on a narrow drawer viewport', () => { + expect(layoutPushSize({ + narrow: true, + panelOpen: true, + bottomOpen: true, + width: 400, + bottomHeight: 400, + viewportWidth: 390, + viewportHeight: 700, + })).toEqual({ width: 0, height: 0 }) + }) + + it('pushes nothing when both panels are collapsed', () => { + expect(layoutPushSize({ + narrow: false, + panelOpen: false, + bottomOpen: false, + width: 400, + bottomHeight: 400, + viewportWidth: 1280, + viewportHeight: 800, + })).toEqual({ width: 0, height: 0 }) + }) + + it('caps an oversize bottom panel so the conversation keeps PANEL_MIN', () => { + const viewportHeight = 800 + const size = layoutPushSize({ + narrow: false, + panelOpen: false, + bottomOpen: true, + width: 0, + bottomHeight: 10_000, + viewportWidth: 1280, + viewportHeight, + }) + expect(size.height).toBe(viewportHeight - PANEL_MIN) + expect(size.height).toBeGreaterThanOrEqual(BOTTOM_MIN) + expect(viewportHeight - size.height).toBeGreaterThanOrEqual(PANEL_MIN) + }) + + it('caps an oversize right panel at the viewport width', () => { + expect(layoutPushSize({ + narrow: false, + panelOpen: true, + bottomOpen: false, + width: 10_000, + bottomHeight: 0, + viewportWidth: 1280, + viewportHeight: 800, + })).toEqual({ width: 1280, height: 0 }) + }) + + it('passes through in-range open sizes', () => { + expect(layoutPushSize({ + narrow: false, + panelOpen: true, + bottomOpen: true, + width: 400, + bottomHeight: 220, + viewportWidth: 1280, + viewportHeight: 800, + })).toEqual({ width: 400, height: 220 }) + }) +}) From 965ec49c163ebb29df3cecb885f1fa0081d63ae7 Mon Sep 17 00:00:00 2001 From: Zzy Date: Fri, 21 Aug 2026 23:41:33 +0800 Subject: [PATCH 03/10] fix: harden layout push bounds --- src/client/layout-push.ts | 15 ++++++++++----- src/client/layout.css | 5 ++--- tests/layout-css.spec.ts | 9 ++++++++- tests/layout-push.spec.ts | 24 ++++++++++++++++++++++++ 4 files changed, 44 insertions(+), 9 deletions(-) diff --git a/src/client/layout-push.ts b/src/client/layout-push.ts index cf235d6..8b53cb2 100644 --- a/src/client/layout-push.ts +++ b/src/client/layout-push.ts @@ -3,7 +3,7 @@ * The conversation column (output + composer) must keep at least * {@link PANEL_MIN} of the viewport after the bottom panel claims height. */ -import { BOTTOM_MIN, PANEL_MIN } from './state.ts' +import { PANEL_MIN } from './state.ts' export interface LayoutPushInput { narrow: boolean @@ -20,13 +20,18 @@ export interface LayoutPushSize { height: number } +function finiteNonNegative(value: number): number { + return Number.isFinite(value) ? Math.max(0, value) : 0 +} + /** Compute the live layout-push size. Narrow drawers float and push 0. */ export function layoutPushSize(input: LayoutPushInput): LayoutPushSize { if (input.narrow) return { width: 0, height: 0 } - const maxWidth = Math.max(PANEL_MIN, input.viewportWidth) - const maxHeight = Math.max(BOTTOM_MIN, input.viewportHeight - PANEL_MIN) + const viewportWidth = finiteNonNegative(input.viewportWidth) + const viewportHeight = finiteNonNegative(input.viewportHeight) + const maxHeight = Math.max(0, viewportHeight - Math.min(PANEL_MIN, viewportHeight)) return { - width: input.panelOpen ? Math.min(Math.max(0, input.width), maxWidth) : 0, - height: input.bottomOpen ? Math.min(Math.max(0, input.bottomHeight), maxHeight) : 0, + width: input.panelOpen ? Math.min(finiteNonNegative(input.width), viewportWidth) : 0, + height: input.bottomOpen ? Math.min(finiteNonNegative(input.bottomHeight), maxHeight) : 0, } } diff --git a/src/client/layout.css b/src/client/layout.css index e634d79..17075c6 100644 --- a/src/client/layout.css +++ b/src/client/layout.css @@ -47,10 +47,9 @@ /* Grid/flex items default to min-height:auto. A long unbreakable token (OAuth URL) then grows this column past the viewport and clips the composer + left-rail Settings row. min-height:0 lets the cell shrink; - overflow:hidden keeps paint inside the cell; overflow-wrap wraps the - token so it cannot force the cell's intrinsic size. */ + overflow-wrap lets the token wrap instead of forcing its intrinsic + size. The host's own descendants remain responsible for scrolling. */ min-height: 0; - overflow: hidden; overflow-wrap: anywhere; transition: margin-bottom var(--ds-transition-duration-slow) var(--ds-ease-in-out); } diff --git a/tests/layout-css.spec.ts b/tests/layout-css.spec.ts index 3b42b9a..c90d889 100644 --- a/tests/layout-css.spec.ts +++ b/tests/layout-css.spec.ts @@ -16,7 +16,14 @@ describe('layout.css conversation column', () => { it('allows the center column to shrink and wrap long tokens', () => { expect(css).toMatch(/min-height:\s*0/) - expect(css).toMatch(/overflow:\s*hidden/) expect(css).toMatch(/overflow-wrap:\s*anywhere/) }) + + it('leaves overflow ownership to the host conversation descendants', () => { + const conversationRule = css.match( + /#root \[data-dsh-frame\][\s\S]*?\{([\s\S]*?)\n\}/, + )?.[1] + expect(conversationRule).toBeDefined() + expect(conversationRule).not.toMatch(/(?:^|[;\s])overflow\s*:/) + }) }) diff --git a/tests/layout-push.spec.ts b/tests/layout-push.spec.ts index 35b3fa9..b9052d2 100644 --- a/tests/layout-push.spec.ts +++ b/tests/layout-push.spec.ts @@ -72,4 +72,28 @@ describe('layoutPushSize', () => { viewportHeight: 800, })).toEqual({ width: 400, height: 220 }) }) + + it('never pushes beyond a viewport smaller than the normal panel minima', () => { + expect(layoutPushSize({ + narrow: false, + panelOpen: true, + bottomOpen: true, + width: 400, + bottomHeight: 220, + viewportWidth: 200, + viewportHeight: 200, + })).toEqual({ width: 200, height: 0 }) + }) + + it('turns non-finite geometry into a safe zero push', () => { + expect(layoutPushSize({ + narrow: false, + panelOpen: true, + bottomOpen: true, + width: Number.NaN, + bottomHeight: Number.POSITIVE_INFINITY, + viewportWidth: Number.NaN, + viewportHeight: Number.POSITIVE_INFINITY, + })).toEqual({ width: 0, height: 0 }) + }) }) From c426431e8bbdd8030dee522753f278251e28f164 Mon Sep 17 00:00:00 2001 From: Zzy Date: Fri, 21 Aug 2026 23:49:13 +0800 Subject: [PATCH 04/10] fix: recalculate panel geometry on resize --- src/client/Sidebar.tsx | 25 ++++++++++++++----- src/client/breakpoints.ts | 22 ++++++++++++----- tests/breakpoints-react.spec.tsx | 42 ++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 12 deletions(-) create mode 100644 tests/breakpoints-react.spec.tsx diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index eddb857..552dd62 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -42,7 +42,7 @@ import { } from './state.ts' import { IconPanelBottomOutline16, IconPanelRightOutline16 } from './icons.tsx' import { Workbench, type WorkbenchActions } from './split-pane.tsx' -import { useNarrowViewport } from './breakpoints.ts' +import { isNarrowWidth, useViewportSize } from './breakpoints.ts' import { layoutPushSize } from './layout-push.ts' import { parseDesktopEnv } from './desktop-env.ts' import { getWcoSnapshot, subscribeWco } from './wco.ts' @@ -184,7 +184,8 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { // — the merged display is the right sidebar alone, the bottom tabs thrown // into its strips. Widening never rewrites the migrated state: the tabs // keep living in the right tree. - const narrow = useNarrowViewport() + const viewport = useViewportSize() + const narrow = isNarrowWidth(viewport.width) // On-screen keyboard / visual-viewport inset (mobile, split-screen, …): // when the visual viewport shrinks below the layout viewport, bottom- @@ -836,11 +837,11 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { bottomOpen: snapshot.state?.bottomOpen === true, width: snapshot.state?.width ?? 0, bottomHeight: snapshot.state?.bottomHeight ?? 0, - viewportWidth: window.innerWidth, - viewportHeight: window.innerHeight, + viewportWidth: viewport.width, + viewportHeight: viewport.height, }) writeGeometry(width, height) - }, [narrow, snapshot.state?.panelOpen, snapshot.state?.width, snapshot.state?.bottomOpen, snapshot.state?.bottomHeight]) + }, [narrow, snapshot.state?.panelOpen, snapshot.state?.width, snapshot.state?.bottomOpen, snapshot.state?.bottomHeight, viewport.width, viewport.height]) // Unmount must release the push (issue #31): when the boundary swaps the // whole sidebar after a render crash (or the plugin fiber is disposed / // HMR), the CSS variables would otherwise stay on and layout.css @@ -955,6 +956,18 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { ) } + const bottomPanelHeight = layoutPushSize({ + narrow, + panelOpen: state.panelOpen, + // Keep the hidden panel's geometry ready for its slide-in transition; + // only the layout push itself becomes zero while it is closed. + bottomOpen: true, + width: state.width, + bottomHeight: state.bottomHeight, + viewportWidth: viewport.width, + viewportHeight: viewport.height, + }).height + const onNewTab = (optionId: string): void => { const service = ctx.betterSidebar const descriptor = service?.getTab(optionId) @@ -1205,7 +1218,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { data-dsh-panel data-dsh-bottom-panel style={{ - height: Math.min(state.bottomHeight, window.innerHeight), + height: bottomPanelHeight, left: centerRect.left, // Keep the panel above the on-screen keyboard when the visual // viewport shrinks (see the keyboardInset effect). diff --git a/src/client/breakpoints.ts b/src/client/breakpoints.ts index 3901cff..2535861 100644 --- a/src/client/breakpoints.ts +++ b/src/client/breakpoints.ts @@ -20,6 +20,11 @@ export function isNarrowWidth(width: number): boolean { return width < NARROW_MAX_WIDTH } +export interface ViewportSize { + width: number + height: number +} + /** * Live narrow-viewport flag for components. Reads `window.innerWidth` and * re-measures on resize (rAF-throttled, the repo's existing drag pattern). @@ -27,16 +32,17 @@ export function isNarrowWidth(width: number): boolean { * resize listener is equally exact for a breakpoint that never changes * while the page is open. */ -export function useNarrowViewport(): boolean { - const [narrow, setNarrow] = useState( - () => typeof window !== 'undefined' && isNarrowWidth(window.innerWidth), - ) +export function useViewportSize(): ViewportSize { + const [size, setSize] = useState(() => ({ + width: typeof window === 'undefined' ? 0 : window.innerWidth, + height: typeof window === 'undefined' ? 0 : window.innerHeight, + })) useEffect(() => { if (typeof window === 'undefined') return let frame: number | null = null const measure = (): void => { frame = null - setNarrow(isNarrowWidth(window.innerWidth)) + setSize({ width: window.innerWidth, height: window.innerHeight }) } const onResize = (): void => { if (frame === null) frame = requestAnimationFrame(measure) @@ -47,5 +53,9 @@ export function useNarrowViewport(): boolean { if (frame !== null) cancelAnimationFrame(frame) } }, []) - return narrow + return size +} + +export function useNarrowViewport(): boolean { + return isNarrowWidth(useViewportSize().width) } diff --git a/tests/breakpoints-react.spec.tsx b/tests/breakpoints-react.spec.tsx new file mode 100644 index 0000000..a9ce885 --- /dev/null +++ b/tests/breakpoints-react.spec.tsx @@ -0,0 +1,42 @@ +// @vitest-environment jsdom +import { createRoot } from 'react-dom/client' +import { act } from 'react-dom/test-utils' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useViewportSize } from '../src/client/breakpoints.ts' + +describe('useViewportSize', () => { + afterEach(() => { + document.body.replaceChildren() + vi.restoreAllMocks() + }) + + it('publishes height-only resizes without requiring a breakpoint change', async () => { + const frames: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frames.push(callback) + return frames.length + }) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 }) + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 800 }) + + const observed: string[] = [] + function Probe(): null { + const size = useViewportSize() + observed.push(`${size.width}x${size.height}`) + return null + } + + const host = document.createElement('div') + document.body.append(host) + const root = createRoot(host) + await act(async () => { root.render() }) + + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 600 }) + window.dispatchEvent(new Event('resize')) + await act(async () => { frames.shift()?.(0) }) + + expect(observed.at(-1)).toBe('1280x600') + await act(async () => { root.unmount() }) + }) +}) From 04686dcc7dd7f04bffa6face71b9d155f39195b8 Mon Sep 17 00:00:00 2001 From: Zzy Date: Fri, 21 Aug 2026 23:54:49 +0800 Subject: [PATCH 05/10] fix: preserve bottom cap during width drags --- src/client/Sidebar.tsx | 15 ++++++++++++--- tests/sidebar-layout-push.spec.ts | 11 +++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) create mode 100644 tests/sidebar-layout-push.spec.ts diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index 552dd62..914d4fb 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -235,6 +235,15 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { const state = snapshot.state const sessionId = snapshot.sessionId const summaryCwd = sessionId === undefined ? undefined : sessionList.byId[sessionId]?.cwd + const pushedBottomHeight = (bottomOpen: boolean, bottomHeight: number): number => layoutPushSize({ + narrow, + panelOpen: false, + bottomOpen, + width: 0, + bottomHeight, + viewportWidth: viewport.width, + viewportHeight: viewport.height, + }).height // The collapsed toggle cluster reclaims the top-right corner, so the DSH // session header's right-aligned utilities (the "Session log" download @@ -776,7 +785,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { // (clamped) instead of rolling back the flick. if (draggingWidth) { width = clampWidth(widthDrag.current.startWidth + (widthDrag.current.startX - event.clientX)) - height = state?.bottomOpen === true ? Math.min(state.bottomHeight, window.innerHeight) : 0 + height = pushedBottomHeight(state?.bottomOpen === true, state?.bottomHeight ?? 0) } else if (draggingBottom) { width = Math.min(state?.width ?? 0, window.innerWidth) height = clampHeight(bottomDrag.current.startHeight + (bottomDrag.current.startY - event.clientY)) @@ -1110,7 +1119,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { if (!event.currentTarget.hasPointerCapture(event.pointerId)) return const { startX, startWidth } = widthDrag.current const width = clampWidth(startWidth + (startX - event.clientX)) - const height = state.bottomOpen ? Math.min(state.bottomHeight, window.innerHeight) : 0 + const height = pushedBottomHeight(state.bottomOpen, state.bottomHeight) scheduleDrag(width, height) }} onPointerUp={(event) => { @@ -1124,7 +1133,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { // pointermove (the rAF pending value) can be stale. Commit // from the up position (v0.13.1 semantics; issue #247). const width = clampWidth(startWidth + (startX - event.clientX)) - const height = state.bottomOpen ? Math.min(state.bottomHeight, window.innerHeight) : 0 + const height = pushedBottomHeight(state.bottomOpen, state.bottomHeight) commitDrag(width, height, s => setWidth(s, width)) setDraggingWidth(false) }} diff --git a/tests/sidebar-layout-push.spec.ts b/tests/sidebar-layout-push.spec.ts new file mode 100644 index 0000000..8f355fb --- /dev/null +++ b/tests/sidebar-layout-push.spec.ts @@ -0,0 +1,11 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +const source = readFileSync('src/client/Sidebar.tsx', 'utf8') + +describe('Sidebar layout-push integration', () => { + it('does not bypass the shared bottom-height cap during width drags', () => { + expect(source).not.toContain('Math.min(state.bottomHeight, window.innerHeight)') + expect(source.match(/pushedBottomHeight\(/g)).toHaveLength(3) + }) +}) From 1c330ba44fdb6430d583ad425cd808cc29f3ebd7 Mon Sep 17 00:00:00 2001 From: Zzy Date: Sat, 22 Aug 2026 00:02:22 +0800 Subject: [PATCH 06/10] fix: cap bottom panel above virtual keyboard --- src/client/Sidebar.tsx | 16 ++++++++++------ tests/sidebar-layout-push.spec.ts | 6 ++++++ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index 914d4fb..ede7acd 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -217,6 +217,10 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { if (frame !== null) cancelAnimationFrame(frame) } }, []) + // The bottom panel is offset above the on-screen keyboard. Cap its height + // against that same visible area, not the taller layout viewport, so the + // conversation keeps PANEL_MIN even on wide touch devices. + const layoutViewportHeight = Math.max(0, viewport.height - keyboardInset) // Current conversation (the sessions list feed). const sessionList = useSyncExternalStore( @@ -242,7 +246,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { width: 0, bottomHeight, viewportWidth: viewport.width, - viewportHeight: viewport.height, + viewportHeight: layoutViewportHeight, }).height // The collapsed toggle cluster reclaims the top-right corner, so the DSH @@ -821,8 +825,8 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { bottomOpen: state?.bottomOpen === true, width: last?.width ?? state?.width ?? 0, bottomHeight: last?.height ?? state?.bottomHeight ?? 0, - viewportWidth: window.innerWidth, - viewportHeight: window.innerHeight, + viewportWidth: viewport.width, + viewportHeight: layoutViewportHeight, }) applyDrag(adoptedWidth, adoptedHeight) draggingRef.current = false @@ -847,10 +851,10 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { width: snapshot.state?.width ?? 0, bottomHeight: snapshot.state?.bottomHeight ?? 0, viewportWidth: viewport.width, - viewportHeight: viewport.height, + viewportHeight: layoutViewportHeight, }) writeGeometry(width, height) - }, [narrow, snapshot.state?.panelOpen, snapshot.state?.width, snapshot.state?.bottomOpen, snapshot.state?.bottomHeight, viewport.width, viewport.height]) + }, [narrow, snapshot.state?.panelOpen, snapshot.state?.width, snapshot.state?.bottomOpen, snapshot.state?.bottomHeight, viewport.width, layoutViewportHeight]) // Unmount must release the push (issue #31): when the boundary swaps the // whole sidebar after a render crash (or the plugin fiber is disposed / // HMR), the CSS variables would otherwise stay on and layout.css @@ -974,7 +978,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { width: state.width, bottomHeight: state.bottomHeight, viewportWidth: viewport.width, - viewportHeight: viewport.height, + viewportHeight: layoutViewportHeight, }).height const onNewTab = (optionId: string): void => { diff --git a/tests/sidebar-layout-push.spec.ts b/tests/sidebar-layout-push.spec.ts index 8f355fb..0574ba1 100644 --- a/tests/sidebar-layout-push.spec.ts +++ b/tests/sidebar-layout-push.spec.ts @@ -8,4 +8,10 @@ describe('Sidebar layout-push integration', () => { expect(source).not.toContain('Math.min(state.bottomHeight, window.innerHeight)') expect(source.match(/pushedBottomHeight\(/g)).toHaveLength(3) }) + + it('caps panel geometry against the viewport visible above the keyboard', () => { + expect(source).toContain('viewport.height - keyboardInset') + expect(source.match(/viewportHeight: layoutViewportHeight/g)).toHaveLength(4) + expect(source).toContain('viewport.width, layoutViewportHeight]') + }) }) From 27cd211bb3407777a6492ecc368eaeccd959bb40 Mon Sep 17 00:00:00 2001 From: Zzy Date: Sat, 22 Aug 2026 00:08:06 +0800 Subject: [PATCH 07/10] fix: cap drag height to visual viewport --- src/client/Sidebar.tsx | 16 +++++++++------- tests/sidebar-layout-push.spec.ts | 11 +++++++++-- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index ede7acd..cd58a9c 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -198,6 +198,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { // Guarded: browsers without visualViewport (older WebViews, jsdom) stay // at 0. rAF-throttled, same pattern as useNarrowViewport. const [keyboardInset, setKeyboardInset] = useState(0) + const [visualViewportHeight, setVisualViewportHeight] = useState(null) useEffect(() => { const vv = window.visualViewport if (vv === null || vv === undefined) return @@ -206,6 +207,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { frame = null const inset = Math.max(0, window.innerHeight - (vv.height + vv.offsetTop)) setKeyboardInset(inset > 1 ? Math.round(inset) : 0) + setVisualViewportHeight(Math.max(0, Math.round(vv.height))) } const onResize = (): void => { if (frame === null) frame = requestAnimationFrame(measure) } vv.addEventListener('resize', onResize) @@ -220,7 +222,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { // The bottom panel is offset above the on-screen keyboard. Cap its height // against that same visible area, not the taller layout viewport, so the // conversation keeps PANEL_MIN even on wide touch devices. - const layoutViewportHeight = Math.max(0, viewport.height - keyboardInset) + const layoutViewportHeight = visualViewportHeight ?? viewport.height // Current conversation (the sessions list feed). const sessionList = useSyncExternalStore( @@ -792,10 +794,10 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { height = pushedBottomHeight(state?.bottomOpen === true, state?.bottomHeight ?? 0) } else if (draggingBottom) { width = Math.min(state?.width ?? 0, window.innerWidth) - height = clampHeight(bottomDrag.current.startHeight + (bottomDrag.current.startY - event.clientY)) + height = pushedBottomHeight(true, clampHeight(bottomDrag.current.startHeight + (bottomDrag.current.startY - event.clientY))) } else if (draggingCorner) { width = clampWidth(cornerDrag.current.startWidth + (cornerDrag.current.startX - event.clientX)) - height = clampHeight(cornerDrag.current.startHeight + (cornerDrag.current.startY - event.clientY)) + height = pushedBottomHeight(true, clampHeight(cornerDrag.current.startHeight + (cornerDrag.current.startY - event.clientY))) } } if (width !== undefined && height !== undefined) { @@ -1187,7 +1189,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { if (!event.currentTarget.hasPointerCapture(event.pointerId)) return const { startX, startY, startWidth, startHeight } = cornerDrag.current const width = clampWidth(startWidth + (startX - event.clientX)) - const height = clampHeight(startHeight + (startY - event.clientY)) + const height = pushedBottomHeight(true, clampHeight(startHeight + (startY - event.clientY))) scheduleDrag(width, height) }} onPointerUp={(event) => { @@ -1199,7 +1201,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { // Up position wins over the rAF pending value (see the width // strip handler — issue #247). const width = clampWidth(startWidth + (startX - event.clientX)) - const height = clampHeight(startHeight + (startY - event.clientY)) + const height = pushedBottomHeight(true, clampHeight(startHeight + (startY - event.clientY))) commitDrag(width, height, s => setBottomHeight(setWidth(s, width), height)) setDraggingCorner(false) }} @@ -1266,7 +1268,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { onPointerMove={(event) => { if (!event.currentTarget.hasPointerCapture(event.pointerId)) return const { startY, startHeight } = bottomDrag.current - const height = clampHeight(startHeight + (startY - event.clientY)) + const height = pushedBottomHeight(true, clampHeight(startHeight + (startY - event.clientY))) scheduleDrag(Math.min(state.width, window.innerWidth), height) }} onPointerUp={(event) => { @@ -1277,7 +1279,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { const { startY, startHeight } = bottomDrag.current // Up position wins over the rAF pending value (see the width // strip handler — issue #247). - const height = clampHeight(startHeight + (startY - event.clientY)) + const height = pushedBottomHeight(true, clampHeight(startHeight + (startY - event.clientY))) commitDrag(Math.min(state.width, window.innerWidth), height, s => setBottomHeight(s, height)) setDraggingBottom(false) }} diff --git a/tests/sidebar-layout-push.spec.ts b/tests/sidebar-layout-push.spec.ts index 0574ba1..b94de12 100644 --- a/tests/sidebar-layout-push.spec.ts +++ b/tests/sidebar-layout-push.spec.ts @@ -6,12 +6,19 @@ const source = readFileSync('src/client/Sidebar.tsx', 'utf8') describe('Sidebar layout-push integration', () => { it('does not bypass the shared bottom-height cap during width drags', () => { expect(source).not.toContain('Math.min(state.bottomHeight, window.innerHeight)') - expect(source.match(/pushedBottomHeight\(/g)).toHaveLength(3) + expect(source.match(/pushedBottomHeight\(/g)).toHaveLength(9) }) it('caps panel geometry against the viewport visible above the keyboard', () => { - expect(source).toContain('viewport.height - keyboardInset') + expect(source).toContain('setVisualViewportHeight(Math.max(0, Math.round(vv.height)))') + expect(source).toContain('visualViewportHeight ?? viewport.height') expect(source.match(/viewportHeight: layoutViewportHeight/g)).toHaveLength(4) expect(source).toContain('viewport.width, layoutViewportHeight]') }) + + it('reapplies the visible-height cap to every vertical drag result', () => { + expect(source).not.toMatch(/const height = clampHeight\(/) + expect(source).not.toMatch(/height = clampHeight\(/) + expect(source.match(/pushedBottomHeight\(true, clampHeight\(/g)).toHaveLength(6) + }) }) From f52cb8779e166e20568199cbb6482ccdd8e837b0 Mon Sep 17 00:00:00 2001 From: Zzy Date: Sat, 22 Aug 2026 00:15:06 +0800 Subject: [PATCH 08/10] fix: reserve keyboard inset in layout push --- src/client/Sidebar.tsx | 10 +++++++--- tests/sidebar-layout-push.spec.ts | 8 +++++++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index cd58a9c..a9e39fe 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -694,7 +694,8 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { // `width + detailsWidth` — derived from the measured column, keeping the // drag write-only (no React re-render mid-drag). bottomRef.current?.style.setProperty('right', `${(window.innerWidth - centerRect.right) + (width - (state?.width ?? 0))}px`) - writeGeometry(width, height) + const bottomPush = !narrow && state?.bottomOpen === true ? height + keyboardInset : 0 + writeGeometry(width, bottomPush) } // Drags write at most once per frame: pointer events fire several times @@ -855,8 +856,11 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { viewportWidth: viewport.width, viewportHeight: layoutViewportHeight, }) - writeGeometry(width, height) - }, [narrow, snapshot.state?.panelOpen, snapshot.state?.width, snapshot.state?.bottomOpen, snapshot.state?.bottomHeight, viewport.width, layoutViewportHeight]) + const bottomPush = !narrow && snapshot.state?.bottomOpen === true + ? height + keyboardInset + : 0 + writeGeometry(width, bottomPush) + }, [narrow, snapshot.state?.panelOpen, snapshot.state?.width, snapshot.state?.bottomOpen, snapshot.state?.bottomHeight, viewport.width, layoutViewportHeight, keyboardInset]) // Unmount must release the push (issue #31): when the boundary swaps the // whole sidebar after a render crash (or the plugin fiber is disposed / // HMR), the CSS variables would otherwise stay on and layout.css diff --git a/tests/sidebar-layout-push.spec.ts b/tests/sidebar-layout-push.spec.ts index b94de12..1f0ea1b 100644 --- a/tests/sidebar-layout-push.spec.ts +++ b/tests/sidebar-layout-push.spec.ts @@ -13,7 +13,13 @@ describe('Sidebar layout-push integration', () => { expect(source).toContain('setVisualViewportHeight(Math.max(0, Math.round(vv.height)))') expect(source).toContain('visualViewportHeight ?? viewport.height') expect(source.match(/viewportHeight: layoutViewportHeight/g)).toHaveLength(4) - expect(source).toContain('viewport.width, layoutViewportHeight]') + expect(source).toContain('viewport.width, layoutViewportHeight, keyboardInset]') + }) + + it('adds the keyboard inset to the conversation push, not the panel height', () => { + expect(source.match(/height \+ keyboardInset/g)).toHaveLength(2) + expect(source).toContain('height: bottomPanelHeight') + expect(source).not.toContain('height: bottomPanelHeight + keyboardInset') }) it('reapplies the visible-height cap to every vertical drag result', () => { From a75928daf1900df540161459fd85890585f267e3 Mon Sep 17 00:00:00 2001 From: zhangqianfeng <278950578@qq.com> Date: Sun, 23 Aug 2026 01:50:07 +0800 Subject: [PATCH 09/10] fix(mermaid): suppress global error rendering --- src/client/mermaid.tsx | 4 ++++ tests/mermaid-markdown.spec.tsx | 12 +++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/client/mermaid.tsx b/src/client/mermaid.tsx index ea9acef..d2d3b38 100644 --- a/src/client/mermaid.tsx +++ b/src/client/mermaid.tsx @@ -48,6 +48,10 @@ function configureMermaid(): void { // forcing pure SVG text keeps labels visible and the HTML label channel // closed (strict already escapes label content). htmlLabels: false, + // Mermaid 11 renders a large error SVG into document.body before rejecting + // invalid diagrams. The component already has its own error fallback, so + // suppress that global side effect to keep the DSH shell intact. + suppressErrorRendering: true, theme: isDarkScheme() ? 'dark' : 'default', }) } diff --git a/tests/mermaid-markdown.spec.tsx b/tests/mermaid-markdown.spec.tsx index 6d0082f..f5e3f37 100644 --- a/tests/mermaid-markdown.spec.tsx +++ b/tests/mermaid-markdown.spec.tsx @@ -16,13 +16,14 @@ import { act } from 'react-dom/test-utils' vi.mock('mermaid', () => ({ default: { - initialize: () => {}, + initialize: vi.fn(), render: async () => ({ svg: 'MOCK-DIAGRAM', }), }, })) +import mermaid from 'mermaid' import { MermaidMarkdown } from '../src/client/mermaid.tsx' const codeLabels = { copyLabel: 'Copy', copiedLabel: 'Copied' } @@ -56,6 +57,15 @@ describe('MermaidMarkdown', () => { await unmount(root) }) + it('suppresses Mermaid global error rendering', async () => { + vi.mocked(mermaid.initialize).mockClear() + const { root } = await renderMarkdown('```mermaid\ngraph TD\n A-->B\n```') + expect(mermaid.initialize).toHaveBeenCalledWith(expect.objectContaining({ + suppressErrorRendering: true, + })) + await unmount(root) + }) + it('resolves cross-fence reference-style links (single markdown parse)', async () => { const text = [ '[before][shared]', From d0f194b67a8f1f61691d3eb689ea524103081ccd Mon Sep 17 00:00:00 2001 From: leokuang Date: Sun, 23 Aug 2026 02:50:37 +0800 Subject: [PATCH 10/10] =?UTF-8?q?fix(explorer):=20=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E5=90=8D=E6=90=9C=E7=B4=A2=E8=B7=B3=E8=BF=87=20node=5Fmodules?= =?UTF-8?q?=20=E7=AD=89=E5=99=AA=E5=A3=B0=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 右侧文件窗口的全局文件名搜索会深度优先遍历整个工作区,原先只跳过 .git。含大型 node_modules 的仓库会先把 10 万条访问预算耗尽,导致 docs/ 等后序目录里的真实文件搜不到。 搜索改为跳过依赖、包管理存储和常见构建缓存目录(不匹配也不下降), 并补回归测试钉住 node_modules / dist 内的命中不会泄漏。 --- AGENTS.md | 2 +- src/fs-search.ts | 43 +++++++++++++++++++++++++++++++++-------- tests/fs-search.spec.ts | 28 ++++++++++++++++++++++++--- 3 files changed, 61 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 24b7a9f..4803df2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -712,7 +712,7 @@ better-sidebar 自己的内置 tab 和 viewer 就是参考实现("吃狗粮" - **`tests/service.spec.ts`**:注册表生命周期 / 匹配算法 / dedupe / createTab / 启用态 gating 测试 - **`tests/builtins.spec.ts`**:内置注册清单断言(7 tab + 6 viewer + 声明式元数据) - **`src/client/plugins-tabs.ts`** / **`src/client/plugins-viewers.ts`**:推荐插件目录(名字/url/简介/安装脚本,分别对应 Tab 注册与文件预览注册),在设置页两个「添加插件」弹窗展示(共享类型在 `plugins-shared.ts`);插件作者可按扩展点加一条数据(弹窗内「跳转」直达仓库、「复制」把安装命令写入剪贴板,粘贴到 DSH 所在环境的终端执行)——数据完整性由 `tests/plugin-list.spec.ts` 守护 -- **`src/client/FileTree.tsx`** / **`src/client/TreePanel.tsx`** / **`src/fs-search.ts`**:受控文件树组件(纯树体,文件行右键菜单含「在新 Tab 中打开」「在侧边打开」,仅宿主编排提供回调时渲染)/ 树面板(搜索框 + 刷新 + FileTree,文件窗口的内嵌 dock 使用)与 host 侧递归文件名搜索(`fs.search` 路由,预算兜底 + 跳过 `.git`/symlink 目录;测试 `tests/fs-search.spec.ts`、组件测试 `tests/editor-host.spec.tsx`) +- **`src/client/FileTree.tsx`** / **`src/client/TreePanel.tsx`** / **`src/fs-search.ts`**:受控文件树组件(纯树体,文件行右键菜单含「在新 Tab 中打开」「在侧边打开」,仅宿主编排提供回调时渲染)/ 树面板(搜索框 + 刷新 + FileTree,文件窗口的内嵌 dock 使用)与 host 侧递归文件名搜索(`fs.search` 路由,预算兜底 + 跳过 `.git` / `node_modules` / 构建缓存 / symlink 目录;测试 `tests/fs-search.spec.ts`、组件测试 `tests/editor-host.spec.tsx`) - **`docs/plans/2026-08-11-service-registry-design.md`** / **`docs/plans/2026-08-11-declarative-sidebar-settings-design.md`** / **`docs/plans/2026-08-14-add-plugins-modal-design.md`**:设计文档(含实施偏差记录) 调试时直接读这些文件即可看到所有 API 的真实用法。 diff --git a/src/fs-search.ts b/src/fs-search.ts index 597bbb0..c78d98f 100644 --- a/src/fs-search.ts +++ b/src/fs-search.ts @@ -3,14 +3,14 @@ * Streams the tree with opendir and matches the query as a case-insensitive * substring of each entry's NAME (paths stay relative to the search root — * the client resolves them against the session cwd). No .gitignore semantics - * (this is a name lookup, not a code search), but `.git` directories are - * skipped outright (VCS internals are never useful results) and symlink - * directories are NOT descended (cycle safety). + * (this is a name lookup, not a code search), but known noise directories + * (`.git`, `node_modules`, package-manager stores, build caches) are + * skipped outright and symlink directories are NOT descended (cycle safety). * * Two performance budgets bound the walk: `maxMatches` (the client renders - * the flat list) and `maxVisited` (a runaway tree — a home directory root, - * a node_modules forest — must not stall the host). Exceeding either stops - * early with `truncated: true`. + * the flat list) and `maxVisited` (a runaway tree — a home directory root + * — must not stall the host). Exceeding either stops early with + * `truncated: true`. */ import { opendir } from 'node:fs/promises' import { join, relative, sep } from 'node:path' @@ -33,6 +33,33 @@ export interface FsSearchOptions { const DEFAULT_MAX_MATCHES = 200 const DEFAULT_MAX_VISITED = 100_000 +/** + * Directory names that are never useful filename-search results and would + * burn the visit budget before the walk reaches project files. Compared + * case-insensitively so `Node_Modules` / `.GIT` stay skipped on every + * platform. The directory itself is neither matched nor descended. + */ +const SEARCH_SKIP_DIRS = new Set([ + '.git', + 'node_modules', + '.pnpm-store', + '.yarn', + '.turbo', + '.turbopack', + '.next', + '.nuxt', + '.output', + '.cache', + '.parcel-cache', + 'coverage', + 'dist', + 'build', + 'out', + '.umi', + '.umi-production', + '.dumi', +]) + /** * Search `root` recursively for entries whose name contains `query` * (case-insensitive). @@ -63,8 +90,8 @@ export async function searchFiles(root: string, query: string, opts: FsSearchOpt truncated = true return } - // .git is VCS-internal noise: never matched, never descended. - if (dirent.isDirectory() && dirent.name === '.git') continue + // Dependency / VCS / build-output forests: never matched, never descended. + if (dirent.isDirectory() && SEARCH_SKIP_DIRS.has(dirent.name.toLowerCase())) continue if (dirent.name.toLowerCase().includes(needle)) { matches.push(join(relative(root, dir), dirent.name)) if (matches.length >= maxMatches) { diff --git a/tests/fs-search.spec.ts b/tests/fs-search.spec.ts index 9bb0c6b..ca10763 100644 --- a/tests/fs-search.spec.ts +++ b/tests/fs-search.spec.ts @@ -1,9 +1,10 @@ /** * fs-search: the host's recursive file-name search behind the editor side * panel's search box. Matches are case-insensitive name substrings, reported - * RELATIVE to the root ('/'-separated); `.git` directories are skipped, - * symlinked directories are never descended (cycle safety), and the - * maxMatches/maxVisited budgets stop a runaway walk with `truncated: true`. + * RELATIVE to the root ('/'-separated); noise directories (`.git`, + * `node_modules`, build caches) are skipped, symlinked directories are + * never descended (cycle safety), and the maxMatches/maxVisited budgets + * stop a runaway walk with `truncated: true`. */ import { describe, expect, it } from 'vitest' import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' @@ -86,6 +87,27 @@ describe('fs-search', () => { } }) + it('never descends into node_modules or other noise directories', async () => { + const dir = makeFixture() + try { + mkdirSync(join(dir, 'node_modules', 'left-pad'), { recursive: true }) + mkdirSync(join(dir, 'web', 'dist'), { recursive: true }) + writeFileSync(join(dir, 'node_modules', 'left-pad', 'guide.md'), 'dep') + writeFileSync(join(dir, 'web', 'dist', 'bundle.js'), 'build') + writeFileSync(join(dir, 'web', 'app.ts'), 'src') + // A match hidden behind node_modules / dist must not appear; project + // files after those forests must still be reachable within budget. + expect((await searchFiles(dir, 'guide')).matches).toEqual(['docs/guide.md']) + expect((await searchFiles(dir, 'left-pad')).matches).toEqual([]) + expect((await searchFiles(dir, 'bundle')).matches).toEqual([]) + expect((await searchFiles(dir, 'app.ts')).matches).toEqual(['web/app.ts']) + expect((await searchFiles(dir, 'node_modules')).matches).toEqual([]) + expect((await searchFiles(dir, 'dist')).matches).toEqual([]) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + it('an empty (or whitespace) query matches nothing without walking', async () => { const dir = makeFixture() try {