From 60a2f9026ea07b458640f84ced8d7c99c17214fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=87=BF=E9=A9=B9-=E4=BC=AF=E4=B9=90?= Date: Fri, 28 Aug 2026 14:59:15 +0800 Subject: [PATCH 01/76] docs: add dsh-better-sidebar-icons to the enhancements catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds eg-bole/dsh-better-sidebar-icons (VSCode-style file/folder icon theme, DOM overlay, npm-published 0.1.2) to the '增强与工具' plugin list in the README. --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 669c674..f9f05eb 100644 --- a/README.md +++ b/README.md @@ -241,10 +241,11 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar ### 🧰 增强与工具
-2 个插件(点击展开) +3 个插件(点击展开) | 插件 | ⭐ | 简介 | |---|---|---| +| [eg-bole/dsh-better-sidebar-icons](https://github.com/eg-bole/dsh-better-sidebar-icons) | stars | VSCode 风格文件 / 文件夹图标主题:文件树与编辑器 Tab 换上熟悉的开发环境图标(vscode-icons 移植,纯 DOM 覆盖零侵入,安装 / 卸载零残留) | | [dong-victor/dsh-better-sidebar-terminal-plus](https://github.com/dong-victor/dsh-better-sidebar-terminal-plus) | stars | 终端增强:内嵌 Nerd Font 图标字体、修复 xterm 图标渲染、稳定终端 cwd | | [Max-Null/dsh-sidebar-preview-select](https://github.com/Max-Null/dsh-sidebar-preview-select) | stars | 预览划选增强:侧边栏预览里划选文本 → 浮动「发送到会话」 | From a6c7cf63c136b8a79e24c5308a9d3e3210ff42b0 Mon Sep 17 00:00:00 2001 From: Kunping Wang <146500293+wang-kaopu@users.noreply.github.com> Date: Sat, 29 Aug 2026 10:13:52 +0800 Subject: [PATCH 02/76] =?UTF-8?q?perf(sidebar):=20=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E5=B7=B2=E5=AE=9A=E4=BD=8D=E4=BC=9A=E8=AF=9D=E5=88=97=E6=97=B6?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E6=9F=A5=E8=AF=A2=20DOM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 缓存会话列仍连接时跳过 #root 全树 querySelector - 保留 detached/HMR 回退定位与 1.5s safety net - 补充 locator 行为测试覆盖缓存复用与节点替换 --- src/client/Sidebar.tsx | 14 +++++--- src/client/center-column.ts | 22 ++++++++++++ tests/center-column-locator.spec.ts | 53 +++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 5 deletions(-) create mode 100644 src/client/center-column.ts create mode 100644 tests/center-column-locator.spec.ts diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index 8897571..1190a6f 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -47,6 +47,7 @@ import { IconPanelBottomOutline16, IconPanelRightOutline16 } from './icons.tsx' import { Workbench, type WorkbenchActions } from './split-pane.tsx' import { isNarrowWidth, useViewportSize } from './breakpoints.ts' import { layoutPushSize } from './layout-push.ts' +import { resolveCenterColumn } from './center-column.ts' import { parseDesktopEnv } from './desktop-env.ts' import { getWcoSnapshot, subscribeWco } from './wco.ts' import { getShellPreset } from './shell-presets.ts' @@ -738,8 +739,10 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { // (observed: a 1px sliver at the viewport's left edge). const locate = (): void => { if (disposed) return - const col = document.querySelector('#root [data-slot="conversation"]') - ?.parentElement as HTMLElement | undefined + // Hot path (#403): streaming output mutates #root at token cadence. + // Reuse a still-connected center column and only query after boot/HMR + // detached the cached node. + const col = resolveCenterColumn(centerColRef.current) if (col === undefined || !col.isConnected) { if (centerColRef.current !== null) { centerColRef.current = null @@ -771,7 +774,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { // Mid-drag every frame writes --dsh-sidebar-* on 's style // attribute, which is the mutation this watcher observes — relocating // per drag frame is pointless (the center column node cannot change - // while the pointer is captured) and adds a querySelector to every + // while the pointer is captured) and adds locator work to every // frame's budget (#315). The 1.5s retry below still covers any node // swap that somehow lands mid-drag. if (draggingRef.current) return @@ -798,8 +801,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { // may end up byte-identical, and the col may be swapped before the // subtree watcher attaches). A slow unconditional re-locate makes the // panel converge on the real column within a couple of seconds no - // matter what sequence the shell used. locate() is cheap when nothing - // changed (one querySelector + an identity compare; no forced layout). + // matter what sequence the shell used. locate() is query-free while the + // cached column stays connected; only a detached/missing cache falls + // back to the document selector. const retry = window.setInterval(locate, 1500) return () => { disposed = true diff --git a/src/client/center-column.ts b/src/client/center-column.ts new file mode 100644 index 0000000..ed9f5ab --- /dev/null +++ b/src/client/center-column.ts @@ -0,0 +1,22 @@ +/** Stable selector for the DSH AppFrame conversation slot. */ +const CENTER_COLUMN_SELECTOR = '#root [data-slot="conversation"]' + +/** + * Resolve the AppFrame center column while keeping the hot path query-free. + * + * Streaming chat output mutates `#root` at token cadence, so Sidebar's + * MutationObserver can call this once per animation frame. Once the center + * column has been found, its DOM identity is stable for the conversation + * lifecycle; checking `isConnected` is enough to reuse it without scanning + * the whole app tree again. Boot/HMR replacement still falls through to the + * document query as soon as the cached node is detached. + */ +export function resolveCenterColumn( + current: HTMLElement | null, + query: () => Element | null = () => document.querySelector(CENTER_COLUMN_SELECTOR), +): HTMLElement | undefined { + if (current !== null && current.isConnected) return current + + const col = query()?.parentElement + return col !== null && col !== undefined && col.isConnected ? col : undefined +} diff --git a/tests/center-column-locator.spec.ts b/tests/center-column-locator.spec.ts new file mode 100644 index 0000000..d1f9f90 --- /dev/null +++ b/tests/center-column-locator.spec.ts @@ -0,0 +1,53 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { resolveCenterColumn } from '../src/client/center-column.ts' + +afterEach(() => { + document.body.innerHTML = '' +}) + +function connectedColumn(): { col: HTMLDivElement; slot: HTMLDivElement } { + const col = document.createElement('div') + const slot = document.createElement('div') + slot.dataset.slot = 'conversation' + col.append(slot) + document.body.append(col) + return { col, slot } +} + +describe('center-column locator (issue #403)', () => { + it('reuses a connected cached column without querying the app tree', () => { + const { col } = connectedColumn() + const query = vi.fn<() => Element | null>(() => null) + + expect(resolveCenterColumn(col, query)).toBe(col) + expect(query).not.toHaveBeenCalled() + }) + + it('queries and adopts the replacement after the cached column detaches', () => { + const stale = connectedColumn().col + stale.remove() + const { col: replacement, slot } = connectedColumn() + const query = vi.fn<() => Element | null>(() => slot) + + expect(resolveCenterColumn(stale, query)).toBe(replacement) + expect(query).toHaveBeenCalledTimes(1) + }) + + it('queries when no column has been cached yet', () => { + const { col, slot } = connectedColumn() + const query = vi.fn<() => Element | null>(() => slot) + + expect(resolveCenterColumn(null, query)).toBe(col) + expect(query).toHaveBeenCalledTimes(1) + }) + + it('rejects a missing or detached query result', () => { + expect(resolveCenterColumn(null, () => null)).toBeUndefined() + + const detachedCol = document.createElement('div') + const detachedSlot = document.createElement('div') + detachedCol.append(detachedSlot) + expect(resolveCenterColumn(null, () => detachedSlot)).toBeUndefined() + }) +}) From f1da0fe345eff166314a73ec9b0d7d37a67ca979 Mon Sep 17 00:00:00 2001 From: Kunping Wang <146500293+wang-kaopu@users.noreply.github.com> Date: Sat, 29 Aug 2026 10:21:08 +0800 Subject: [PATCH 03/76] =?UTF-8?q?perf(sidebar):=20=E4=BF=9D=E7=95=99?= =?UTF-8?q?=E5=85=9C=E5=BA=95=E5=AE=9A=E4=BD=8D=E5=B9=B6=E9=99=90=E5=88=B6?= =?UTF-8?q?=E5=85=A8=E6=A0=91=E6=9F=A5=E8=AF=A2=E9=A2=91=E7=8E=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 缓存节点连接期间按 1.5s safety-net 周期完整复核 - html style 变化时立即绕过 fast path 重新定位 - 覆盖流式热路径、定时复核、HMR 信号与节点替换行为 --- src/client/center-column.ts | 82 ++++++++++++++++++++---- tests/center-column-locator.spec.ts | 98 ++++++++++++++++++++++------- 2 files changed, 145 insertions(+), 35 deletions(-) diff --git a/src/client/center-column.ts b/src/client/center-column.ts index ed9f5ab..5bf7e73 100644 --- a/src/client/center-column.ts +++ b/src/client/center-column.ts @@ -1,22 +1,82 @@ /** Stable selector for the DSH AppFrame conversation slot. */ const CENTER_COLUMN_SELECTOR = '#root [data-slot="conversation"]' +/** Match Sidebar's existing last-resort retry cadence (issue #248). */ +export const CENTER_COLUMN_REVALIDATE_MS = 1500 + +interface CenterColumnResolveOptions { + /** Explicit document for tests / callers that do not yet have a cached node. */ + document?: Document + /** Injectable DOM lookup for tests. */ + query?: () => Element | null + /** Injectable clock for deterministic retry-boundary tests. */ + now?: () => number + /** Injectable fingerprint for deterministic HMR tests. */ + htmlStyle?: () => string | null + /** Override only for tests; production follows the 1.5s safety-net cadence. */ + revalidateMs?: number +} + +/** Last full DOM validation for each center-column node. Weak keys avoid leaks. */ +const validatedAt = new WeakMap() + /** - * Resolve the AppFrame center column while keeping the hot path query-free. + * The html-style watcher is an HMR/layout resync signal. Remember the last + * fingerprint per document so a style change can bypass the connected-node + * fast path immediately, without requiring Sidebar to run a second locator. + */ +const documentStyleState = new WeakMap() + +/** + * Resolve the AppFrame center column while keeping streaming mutations cheap. * - * Streaming chat output mutates `#root` at token cadence, so Sidebar's - * MutationObserver can call this once per animation frame. Once the center - * column has been found, its DOM identity is stable for the conversation - * lifecycle; checking `isConnected` is enough to reuse it without scanning - * the whole app tree again. Boot/HMR replacement still falls through to the - * document query as soon as the cached node is detached. + * Sidebar intentionally keeps both recovery mechanisms that predate #403: + * the `#root` subtree MutationObserver catches boot/HMR DOM swaps, and the + * 1.5s interval is a last-resort safety net for interleavings no observer is + * guaranteed to see (#248). The expensive part was letting every scheduled + * locate scan `#root` with querySelector at streaming-token cadence. + * + * Once a connected column is cached, normal calls therefore reuse it without + * a document query. A full query is still forced when either: + * - the cached node disconnects; + * - `` changes (the existing HMR/layout resync signal); or + * - 1.5s has elapsed since the last full validation (the safety-net cadence). + * + * This preserves recovery semantics while bounding whole-tree selector work + * to low-frequency validation instead of chat mutation frequency. */ export function resolveCenterColumn( current: HTMLElement | null, - query: () => Element | null = () => document.querySelector(CENTER_COLUMN_SELECTOR), + options: CenterColumnResolveOptions = {}, ): HTMLElement | undefined { - if (current !== null && current.isConnected) return current + const doc = options.document ?? current?.ownerDocument ?? document + const now = (options.now ?? Date.now)() + const revalidateMs = options.revalidateMs ?? CENTER_COLUMN_REVALIDATE_MS + const htmlStyle = (options.htmlStyle ?? (() => doc.documentElement.getAttribute('style')))() - const col = query()?.parentElement - return col !== null && col !== undefined && col.isConnected ? col : undefined + const previousStyle = documentStyleState.get(doc) + const styleChanged = previousStyle !== undefined && previousStyle !== htmlStyle + documentStyleState.set(doc, htmlStyle) + + if (current !== null && current.isConnected) { + const lastValidation = validatedAt.get(current) + + // A connected node passed in for the first time is already a trustworthy + // cache hit. Start its safety-net window without paying an eager query. + if (lastValidation === undefined && !styleChanged) { + validatedAt.set(current, now) + return current + } + + if (!styleChanged && lastValidation !== undefined && now - lastValidation < revalidateMs) { + return current + } + } + + const query = options.query ?? (() => doc.querySelector(CENTER_COLUMN_SELECTOR)) + const col = query()?.parentElement as HTMLElement | null | undefined + if (col === null || col === undefined || !col.isConnected) return undefined + + validatedAt.set(col, now) + return col } diff --git a/tests/center-column-locator.spec.ts b/tests/center-column-locator.spec.ts index d1f9f90..026ab8b 100644 --- a/tests/center-column-locator.spec.ts +++ b/tests/center-column-locator.spec.ts @@ -1,53 +1,103 @@ // @vitest-environment jsdom -import { afterEach, describe, expect, it, vi } from 'vitest' -import { resolveCenterColumn } from '../src/client/center-column.ts' +import { describe, expect, it, vi } from 'vitest' +import { + CENTER_COLUMN_REVALIDATE_MS, + resolveCenterColumn, +} from '../src/client/center-column.ts' -afterEach(() => { - document.body.innerHTML = '' -}) +interface Fixture { + doc: Document + col: HTMLDivElement + slot: HTMLDivElement +} -function connectedColumn(): { col: HTMLDivElement; slot: HTMLDivElement } { - const col = document.createElement('div') - const slot = document.createElement('div') +function connectedColumn(doc = document.implementation.createHTMLDocument()): Fixture { + const col = doc.createElement('div') + const slot = doc.createElement('div') slot.dataset.slot = 'conversation' col.append(slot) - document.body.append(col) - return { col, slot } + doc.body.append(col) + return { doc, col, slot } } describe('center-column locator (issue #403)', () => { - it('reuses a connected cached column without querying the app tree', () => { - const { col } = connectedColumn() - const query = vi.fn<() => Element | null>(() => null) + it('reuses a connected cached column without querying during the hot-path window', () => { + const { doc, col, slot } = connectedColumn() + let now = 0 + const query = vi.fn<() => Element | null>(() => slot) + const options = { document: doc, query, now: () => now } - expect(resolveCenterColumn(col, query)).toBe(col) + expect(resolveCenterColumn(col, options)).toBe(col) + now = CENTER_COLUMN_REVALIDATE_MS - 1 + expect(resolveCenterColumn(col, options)).toBe(col) expect(query).not.toHaveBeenCalled() }) - it('queries and adopts the replacement after the cached column detaches', () => { - const stale = connectedColumn().col + it('fully revalidates a connected cache at the 1.5s safety-net boundary', () => { + const { doc, col, slot } = connectedColumn() + let now = 0 + const query = vi.fn<() => Element | null>(() => slot) + const options = { document: doc, query, now: () => now } + + expect(resolveCenterColumn(col, options)).toBe(col) + expect(query).not.toHaveBeenCalled() + + now = CENTER_COLUMN_REVALIDATE_MS + expect(resolveCenterColumn(col, options)).toBe(col) + expect(query).toHaveBeenCalledTimes(1) + }) + + it('revalidates immediately when the html style signal changes', () => { + const { doc, col, slot } = connectedColumn() + let now = 0 + const query = vi.fn<() => Element | null>(() => slot) + const options = { document: doc, query, now: () => now } + + expect(resolveCenterColumn(col, options)).toBe(col) + expect(query).not.toHaveBeenCalled() + + doc.documentElement.style.setProperty('--dsh-sidebar-width', '320px') + now = 1 + expect(resolveCenterColumn(col, options)).toBe(col) + expect(query).toHaveBeenCalledTimes(1) + + // The same fingerprint is cheap again; one style change causes one full + // validation instead of turning later streaming mutations into queries. + now = 2 + expect(resolveCenterColumn(col, options)).toBe(col) + expect(query).toHaveBeenCalledTimes(1) + }) + + it('queries and adopts the replacement as soon as the cached column detaches', () => { + const first = connectedColumn() + const stale = first.col stale.remove() - const { col: replacement, slot } = connectedColumn() + const { col: replacement, slot } = connectedColumn(first.doc) const query = vi.fn<() => Element | null>(() => slot) - expect(resolveCenterColumn(stale, query)).toBe(replacement) + expect(resolveCenterColumn(stale, { document: first.doc, query, now: () => 1 })).toBe(replacement) expect(query).toHaveBeenCalledTimes(1) }) it('queries when no column has been cached yet', () => { - const { col, slot } = connectedColumn() + const { doc, col, slot } = connectedColumn() const query = vi.fn<() => Element | null>(() => slot) - expect(resolveCenterColumn(null, query)).toBe(col) + expect(resolveCenterColumn(null, { document: doc, query, now: () => 0 })).toBe(col) expect(query).toHaveBeenCalledTimes(1) }) it('rejects a missing or detached query result', () => { - expect(resolveCenterColumn(null, () => null)).toBeUndefined() + const doc = document.implementation.createHTMLDocument() + expect(resolveCenterColumn(null, { document: doc, query: () => null, now: () => 0 })).toBeUndefined() - const detachedCol = document.createElement('div') - const detachedSlot = document.createElement('div') + const detachedCol = doc.createElement('div') + const detachedSlot = doc.createElement('div') detachedCol.append(detachedSlot) - expect(resolveCenterColumn(null, () => detachedSlot)).toBeUndefined() + expect(resolveCenterColumn(null, { + document: doc, + query: () => detachedSlot, + now: () => 1, + })).toBeUndefined() }) }) From 5d8c33832c89d36013c0eb61d60333c5668eeb26 Mon Sep 17 00:00:00 2001 From: Kunping Wang <146500293+wang-kaopu@users.noreply.github.com> Date: Sat, 29 Aug 2026 09:19:39 +0800 Subject: [PATCH 04/76] =?UTF-8?q?fix(git):=20=E5=85=BC=E5=AE=B9=E6=97=A7?= =?UTF-8?q?=E7=89=88=20Git=20=E7=9A=84=20worktree=20=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/git.ts | 18 ++++++++++++++- tests/git-spawn.spec.ts | 50 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/src/git.ts b/src/git.ts index 9b9f151..29e5aad 100644 --- a/src/git.ts +++ b/src/git.ts @@ -323,11 +323,27 @@ function pathIdentity(path: string): string { return process.platform === 'win32' ? absolute.toLowerCase() : absolute } +/** Whether the current Git binary supports NUL-framed `worktree list` output. + * Git < 2.36 rejects `-z`; cache the capability after the first attempt so + * the SCM panel's polling does not repeatedly spawn a command known to fail. */ +let worktreeListSupportsZ: boolean | undefined + /** Raw usable checkout records, shared by inventory and target validation. * Prunable records point at missing paths and are deliberately excluded from * both the selector and the command-target allowlist. */ async function listedWorktrees(cwd: string): Promise { - const raw = await runGit(cwd, ['worktree', 'list', '--porcelain', '-z']) + let raw: string + if (worktreeListSupportsZ === false) { + raw = await runGit(cwd, ['worktree', 'list', '--porcelain']) + } else { + try { + raw = await runGit(cwd, ['worktree', 'list', '--porcelain', '-z']) + worktreeListSupportsZ = true + } catch { + worktreeListSupportsZ = false + raw = await runGit(cwd, ['worktree', 'list', '--porcelain']) + } + } return parseWorktreeList(raw).filter(entry => !entry.prunable) } diff --git a/tests/git-spawn.spec.ts b/tests/git-spawn.spec.ts index eab01ec..569df70 100644 --- a/tests/git-spawn.spec.ts +++ b/tests/git-spawn.spec.ts @@ -6,7 +6,7 @@ const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() })) vi.mock('node:child_process', () => ({ spawn: spawnMock })) -import { isGitRepo } from '../src/git.ts' +import { isGitRepo, worktrees } from '../src/git.ts' afterEach(() => { spawnMock.mockReset() @@ -38,4 +38,52 @@ describe('git subprocess spawning', () => { expect.objectContaining({ windowsHide: true }), ) }) + + it('falls back when worktree list does not support -z', async () => { + const gitChild = (stdoutText = '', stderrText = '', code = 0): EventEmitter => { + const child = new EventEmitter() + const stdout = new PassThrough() + const stderr = new PassThrough() + Object.assign(child, { stdout, stderr, kill: vi.fn() }) + queueMicrotask(() => { + stdout.end(stdoutText) + stderr.end(stderrText) + child.emit('close', code) + }) + return child + } + + spawnMock.mockImplementation((_file: string, args: string[]) => { + const command = args.slice(5) + if (command[0] === 'rev-parse' && command[1] === '--is-inside-work-tree') return gitChild('true\n') + if (command[0] === 'rev-parse' && command[1] === '--show-toplevel') return gitChild('C:\\repo\n') + if (command[0] === 'rev-parse' && command[1] === '--abbrev-ref') return gitChild('main\n') + if (command[0] === 'status') return gitChild('') + if (command[0] === 'worktree' && command.includes('-z')) { + return gitChild('', "error: unknown switch `z'\n", 129) + } + if (command[0] === 'worktree') { + return gitChild([ + 'worktree C:\\repo', + 'HEAD abc', + 'branch refs/heads/main', + '', + ].join('\n')) + } + throw new Error(`unexpected git command: ${command.join(' ')}`) + }) + + await expect(worktrees('C:\\repo')).resolves.toEqual([ + { path: 'C:\\repo', branch: 'main', current: true, changes: 0 }, + ]) + await expect(worktrees('C:\\repo')).resolves.toEqual([ + { path: 'C:\\repo', branch: 'main', current: true, changes: 0 }, + ]) + + const nulAttempts = spawnMock.mock.calls.filter((call) => { + const args = call[1] as string[] + return args.includes('worktree') && args.includes('-z') + }) + expect(nulAttempts).toHaveLength(1) + }) }) From cdd5706f5b80a3112f5ea36880f32fd4683b0d0b Mon Sep 17 00:00:00 2001 From: Justin Skowyra <289723040+Jstn-1g@users.noreply.github.com> Date: Thu, 27 Aug 2026 07:13:39 -0400 Subject: [PATCH 05/76] fix(terminal): contain transient resize failures --- src/agent-pty.ts | 22 ++++++++++++++++++++++ src/index.ts | 8 +++----- tests/agent-pty.spec.ts | 39 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 62 insertions(+), 7 deletions(-) diff --git a/src/agent-pty.ts b/src/agent-pty.ts index 7e4030e..c5d8393 100644 --- a/src/agent-pty.ts +++ b/src/agent-pty.ts @@ -40,6 +40,28 @@ export function clampDims(cols: number, rows: number): { cols: number; rows: num return { cols: clamp(cols), rows: clamp(rows) } } +/** + * Best-effort resize for WebSocket-driven terminal views. Layout animation + * can briefly produce unusable dimensions, and node-pty can reject a resize + * after the socket setup's outer try/catch has returned. Ignore that one + * frame so the host stays alive and a later valid measurement can retry. + * Returns whether node-pty accepted the resize. + */ +export function tryResizePty( + pty: Pick, + cols: number, + rows: number, +): boolean { + if (!Number.isFinite(cols) || !Number.isFinite(rows)) return false + const dims = clampDims(cols, rows) + try { + pty.resize(dims.cols, dims.rows) + return true + } catch { + return false + } +} + /** * Serializable snapshot of one agent terminal — the shape the model sees * through `terminal_list` and the sidebar sees through the push endpoint. diff --git a/src/index.ts b/src/index.ts index 46c9481..4808ce6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,7 +41,7 @@ import { launchExternal } from './open-external.ts' import * as git from './git.ts' import { SettingsConflictError, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings' import { defaultShell, ensureSpawnHelper, PtyManager, shellDisplayName } from './pty-manager.ts' -import { AgentPtyRegistry, clampDims, type AgentTerminalHandle } from './agent-pty.ts' +import { AgentPtyRegistry, tryResizePty, type AgentTerminalHandle } from './agent-pty.ts' import { DSH_NODE_PTY_RANGE, depsStatus, @@ -1199,8 +1199,7 @@ async function attachTerminal( && control.type === 'resize' && typeof control.cols === 'number' && typeof control.rows === 'number' ) { - const dims = clampDims(control.cols, control.rows) - handle.pty.resize(dims.cols, dims.rows) + tryResizePty(handle.pty, control.cols, control.rows) } else { handle.pty.write(text) } @@ -1268,8 +1267,7 @@ function pumpAgentTerminal( && control.type === 'resize' && typeof control.cols === 'number' && typeof control.rows === 'number' ) { - const dims = clampDims(control.cols, control.rows) - handle.pty.resize(dims.cols, dims.rows) + tryResizePty(handle.pty, control.cols, control.rows) } else if (control === null) { // Raw text input (a JSON-looking string the pty would have received // verbatim is reachable in theory but is exotic for an agent terminal; diff --git a/tests/agent-pty.spec.ts b/tests/agent-pty.spec.ts index f3530fd..88896c6 100644 --- a/tests/agent-pty.spec.ts +++ b/tests/agent-pty.spec.ts @@ -5,8 +5,14 @@ * console noise from node-pty's ConPTY module on Windows is expected and * does not affect the assertions. */ -import { describe, expect, it } from 'vitest' -import { AgentPtyRegistry, ALLOWED_SIGNALS, snapshotOf, type AgentTerminalSnapshot } from '../src/agent-pty.ts' +import { describe, expect, it, vi } from 'vitest' +import { + AgentPtyRegistry, + ALLOWED_SIGNALS, + snapshotOf, + tryResizePty, + type AgentTerminalSnapshot, +} from '../src/agent-pty.ts' /** * Resolve a shell binary for tests: on Windows use PowerShell (available on @@ -33,6 +39,35 @@ async function waitForTranscript( return handle?.transcript ?? '' } +describe('tryResizePty', () => { + it('clamps valid dimensions before resizing', () => { + const resize = vi.fn() + + expect(tryResizePty({ resize }, 5000, 80.9)).toBe(true) + expect(resize).toHaveBeenCalledWith(1024, 80) + }) + + it('contains a node-pty resize failure', () => { + const resize = vi.fn(() => { + throw new Error('Usage: pty.resize(fd, cols, rows, xPixel, yPixel)') + }) + + expect(tryResizePty({ resize }, 80, 24)).toBe(false) + expect(resize).toHaveBeenCalledWith(80, 24) + }) + + it.each([ + [Number.NaN, 24], + [Number.POSITIVE_INFINITY, 24], + [80, Number.NEGATIVE_INFINITY], + ])('rejects non-finite dimensions without calling node-pty (%s, %s)', (cols, rows) => { + const resize = vi.fn() + + expect(tryResizePty({ resize }, cols, rows)).toBe(false) + expect(resize).not.toHaveBeenCalled() + }) +}) + describe('AgentPtyRegistry', () => { it('creates a terminal with a uuid, writes the command to stdin, and lists it', async () => { const registry = new AgentPtyRegistry(testShell()) From d258f3f8bc5a478521ea6c6e0b895097bed542bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=87=BF=E9=A9=B9-=E4=BC=AF=E4=B9=90?= Date: Thu, 27 Aug 2026 12:53:12 +0800 Subject: [PATCH 06/76] fix(editor): dismiss the selection popup when its surface leaves the viewport (#425) The portaled position:fixed add-to-conversation button stays pinned to its viewport anchor while the sidebar keeps every tab mounted (tab switches only set display:none; collapsing the panel translates it off-screen), so the popup could outlive its editor surface. Extract the popup into useSelectionPopup and close it on any mousedown outside the button, Escape, document hidden, window blur, and - the tab-switch/collapse path - when an IntersectionObserver reports the editor surface off-viewport. --- src/client/TextEditor.tsx | 84 ++++++--------- src/client/selection-popup.ts | 157 +++++++++++++++++++++++++++ tests/selection-popup.spec.tsx | 190 +++++++++++++++++++++++++++++++++ 3 files changed, 379 insertions(+), 52 deletions(-) create mode 100644 src/client/selection-popup.ts create mode 100644 tests/selection-popup.spec.tsx diff --git a/src/client/TextEditor.tsx b/src/client/TextEditor.tsx index 25114c9..c698d8d 100644 --- a/src/client/TextEditor.tsx +++ b/src/client/TextEditor.tsx @@ -28,6 +28,7 @@ import { cmSurfaceTheme, CmThemeCompartment } from './cm-themes.ts' import { isDarkScheme, subscribeColorScheme } from './theme.ts' import { SandboxStatusBar } from './SandboxStatusBar.tsx' import { appendToDraft } from './conversation-draft.ts' +import { useSelectionPopup } from './selection-popup.ts' import { buildSelectionInsert, linesOfSelection } from './selection-payload.ts' import { lazyChunkComponent } from './lazy-chunk.tsx' import { analyzeMarkdownHtml } from './markdown-html.ts' @@ -41,13 +42,6 @@ import css from './sidebar.module.css' /** Previewable files (rendered output vs source editing). */ type ViewMode = 'preview' | 'edit' -/** The floating "add to conversation" action: payload + viewport anchor. */ -interface SelectionPopup { - insert: string - left: number - top: number -} - /** * The sandbox tokens of the HTML preview iframe. NO allow-same-origin (the * preview must stay in an opaque origin — with the route's own origin it @@ -71,36 +65,23 @@ export function TextEditor(props: FileViewerProps) { const themeCompRef = useRef(null) /** The app's resolved color scheme; the editor re-themes in place on flips. */ const [dark, setDark] = useState(() => isDarkScheme()) - /** The floating "add to conversation" popup (viewport-anchored; null = hidden). */ - const [popup, setPopup] = useState(null) - /** Live mirror of the popup state for click-time reads (no re-render race). */ - const popupRef = useRef(null) /** The markdown preview container (selection-containment + line lookup). */ const mdRef = useRef(null) + const markdown = viewerId === 'markdown' + const html = viewerId === 'html' - const hidePopup = (): void => { - popupRef.current = null - setPopup(null) - } - - /** Anchor the popup above the selection center; clamp inside the viewport. */ - const showPopup = (insert: string, left: number, top: number): void => { - const next: SelectionPopup = { - insert, - left: Math.min(Math.max(left, 80), window.innerWidth - 80), - top, - } - popupRef.current = next - setPopup(next) - } - - /** The popup button's click: insert the stored payload into the draft. */ - const commitPopup = (): void => { - const current = popupRef.current - if (current === null) return - appendToDraft(ctx, scope.sessionId, current.insert) - hidePopup() - } + /** + * The floating "add to conversation" popup (viewport-anchored; null = + * hidden). The hook owns show/hide/commit plus the global dismissal + * listeners (outside mousedown, Escape, hidden tab/window, surface + * leaving the viewport) — see selection-popup.ts. + */ + const selectionPopup = useSelectionPopup({ + onCommit: (insert) => { appendToDraft(ctx, scope.sessionId, insert) }, + // The surface that must stay on screen: the markdown preview container + // in preview mode, the CodeMirror host otherwise. + getSurface: () => (markdown && mode === 'preview' ? mdRef.current : hostRef.current), + }) useEffect(() => subscribeColorScheme(() => { setDark(isDarkScheme()) }), []) @@ -110,7 +91,7 @@ export function TextEditor(props: FileViewerProps) { setDraft(null) setDirty(false) setSaveState('idle') - hidePopup() + selectionPopup.hide() }, [content]) // Create the CodeMirror editor once the content is loaded. The view owns @@ -159,33 +140,33 @@ export function TextEditor(props: FileViewerProps) { ...(viewerId === 'code' || viewerId === 'markdown' ? [ CodeMirrorView.updateListener.of((update) => { if (update.geometryChanged || update.viewportChanged) { - hidePopup() + selectionPopup.hide() return } if (!update.view.hasFocus) { - hidePopup() + selectionPopup.hide() return } if (!(update.selectionSet || update.docChanged || update.focusChanged)) return const sel = update.state.selection.main if (sel.empty) { - hidePopup() + selectionPopup.hide() return } const text = update.state.sliceDoc(sel.from, sel.to) if (text.trim() === '') { - hidePopup() + selectionPopup.hide() return } // Page coordinates (the document root may scroll); the popup is // position:fixed, so convert to viewport coordinates. const rect = update.view.coordsAtPos(sel.head) if (rect === null) { - hidePopup() + selectionPopup.hide() return } const doc = update.state.doc - showPopup( + selectionPopup.show( buildSelectionInsert(path, scope.cwd, { start: doc.lineAt(sel.from).number, end: doc.lineAt(sel.to).number, @@ -222,7 +203,7 @@ export function TextEditor(props: FileViewerProps) { // it becomes visible again (CodeMirror sizes itself on reveal). A mode // flip also invalidates any anchored selection popup. useEffect(() => { - hidePopup() + selectionPopup.hide() if (mode === 'edit') viewRef.current?.requestMeasure() }, [mode]) @@ -242,8 +223,6 @@ export function TextEditor(props: FileViewerProps) { }) } - const markdown = viewerId === 'markdown' - 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 @@ -295,22 +274,22 @@ export function TextEditor(props: FileViewerProps) { const handlePreviewMouseUp = (): void => { const sel = window.getSelection() if (sel === null || sel.isCollapsed || sel.anchorNode === null || sel.focusNode === null) { - hidePopup() + selectionPopup.hide() return } const host = mdRef.current if (host === null || !host.contains(sel.anchorNode) || !host.contains(sel.focusNode)) { - hidePopup() + selectionPopup.hide() return } const text = sel.toString() if (text.trim() === '') { - hidePopup() + selectionPopup.hide() return } const rect = sel.getRangeAt(0).getBoundingClientRect() const lines = linesOfSelection(mdText, text) - showPopup( + selectionPopup.show( buildSelectionInsert(path, scope.cwd, lines ?? undefined, text), rect.left + rect.width / 2, rect.top, @@ -401,7 +380,7 @@ export function TextEditor(props: FileViewerProps) { className={css.editorMd} ref={mdRef} onMouseUp={handlePreviewMouseUp} - onScroll={hidePopup} + onScroll={selectionPopup.hide} > {/* The fence copy-button labels must come from this plugin's own dictionary: the DSH MarkdownText/CodeBlock are cordis-free and @@ -449,15 +428,16 @@ export function TextEditor(props: FileViewerProps) { /> )} - {popup !== null && createPortal( + {selectionPopup.popup !== null && createPortal( , diff --git a/src/client/selection-popup.ts b/src/client/selection-popup.ts new file mode 100644 index 0000000..5386d9c --- /dev/null +++ b/src/client/selection-popup.ts @@ -0,0 +1,157 @@ +/** + * The floating "add selection to conversation" popup shared by the text + * viewers (markdown preview + the catch-all code viewer): a viewport-anchored + * button portaled to `document.body`, kept alive across the selection gesture + * and committed on click. + * + * Dismissal contract (fixes upstream issue #425): the popup must never + * outlive its editor surface. The sidebar keeps every tab MOUNTED — switching + * tabs only flips the pane cell to `display:none` and collapsing the panel + * translates it off-screen — while the portaled `position:fixed` button stays + * pinned to its viewport anchor, ignoring both. The caller already hides on + * surface scrolls, selection collapse and mode flips; this hook adds the + * global dismissal that covers everything else: + * + * - any `mousedown` outside the button (tab bar, composer, another pane, + * the collapse toggle, …) closes it; + * - `Escape` closes it; + * - the document going hidden (`visibilitychange`) or the window losing + * focus closes it; + * - an `IntersectionObserver` on the editor surface closes it as soon as the + * surface leaves the viewport — the tab-switch (`display:none`) and + * panel-collapse (translated off-screen) paths have no DOM events of their + * own, so the geometry signal is the only reliable one. + * + * The button's own `mousedown` is never treated as an outside click: the + * caller preventDefaults it to keep the selection/caret alive until the + * click commits (the hook's capture-phase listener runs first and must not + * hide for it). + */ +import { useEffect, useRef, useState, type RefObject } from 'react' + +/** The floating "add to conversation" action: payload + viewport anchor. */ +export interface SelectionPopup { + insert: string + left: number + top: number +} + +export interface SelectionPopupOptions { + /** Commit the payload into the composer draft (button click). */ + onCommit(insert: string): void + /** + * The DOM surface that must stay on screen for the popup to live: the + * markdown preview container in preview mode, the CodeMirror host + * otherwise. Called lazily (refs are null until the content loads). + */ + getSurface(): HTMLElement | null +} + +export interface SelectionPopupControls { + /** The current popup (null = hidden). */ + popup: SelectionPopup | null + /** Attach to the portaled button element. */ + buttonRef: RefObject + /** Anchor the popup above a selection (viewport-clamped). */ + show(insert: string, left: number, top: number): void + /** Hide the popup (idempotent). */ + hide(): void + /** The button's click: commit the stored payload, then hide. */ + commit(): void +} + +export function useSelectionPopup(options: SelectionPopupOptions): SelectionPopupControls { + // Latest-callback refs: the dismissal listeners live for the mount's + // lifetime, so they must not capture stale closures across renders. + const onCommitRef = useRef(options.onCommit) + const getSurfaceRef = useRef(options.getSurface) + onCommitRef.current = options.onCommit + getSurfaceRef.current = options.getSurface + + const [popup, setPopup] = useState(null) + /** Live mirror for click/event-time reads (no re-render race). */ + const popupRef = useRef(null) + /** The portaled button itself (for the outside-click guard). */ + const buttonRef = useRef(null) + /** The surface visibility observer (created lazily on open). */ + const observerRef = useRef(null) + + const show = (insert: string, left: number, top: number): void => { + const next: SelectionPopup = { + insert, + left: Math.min(Math.max(left, 80), window.innerWidth - 80), + top, + } + popupRef.current = next + setPopup(next) + } + + const hide = (): void => { + popupRef.current = null + setPopup(null) + } + + const commit = (): void => { + const current = popupRef.current + if (current === null) return + onCommitRef.current(current.insert) + hide() + } + + // Global dismissal listeners, registered once; every handler reads the + // live popup ref so it no-ops while no popup is open. + useEffect(() => { + const onMouseDown = (event: MouseEvent): void => { + if (popupRef.current === null) return + const button = buttonRef.current + if (button !== null && (button === event.target || button.contains(event.target as Node))) return + hide() + } + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Escape' && popupRef.current !== null) hide() + } + const onVisibilityChange = (): void => { + if (document.hidden && popupRef.current !== null) hide() + } + const onWindowBlur = (): void => { + if (popupRef.current !== null) hide() + } + document.addEventListener('mousedown', onMouseDown, true) + document.addEventListener('keydown', onKeyDown, true) + document.addEventListener('visibilitychange', onVisibilityChange) + window.addEventListener('blur', onWindowBlur) + return () => { + document.removeEventListener('mousedown', onMouseDown, true) + document.removeEventListener('keydown', onKeyDown, true) + document.removeEventListener('visibilitychange', onVisibilityChange) + window.removeEventListener('blur', onWindowBlur) + observerRef.current?.disconnect() + observerRef.current = null + } + }, []) + + // Re-target the visibility observer whenever a popup opens: the surface + // refs may have been null at mount (content loads async, mode flips swap + // the preview container for the CodeMirror host), so the surface is only + // trustworthy at open time. + useEffect(() => { + if (popup === null) return + observerRef.current?.disconnect() + observerRef.current = null + if (typeof IntersectionObserver === 'undefined') return + const surface = getSurfaceRef.current() + if (surface === null) return + const observer = new IntersectionObserver((entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) hide() + } + }, { threshold: 0 }) + observerRef.current = observer + observer.observe(surface) + // eslint-disable-next-line react-hooks/exhaustive-deps -- the boolean + // flip is the only thing that must re-run this; an anchor move while + // open keeps the same surface. + }, [popup !== null]) + + return { popup, buttonRef, show, hide, commit } +} \ No newline at end of file diff --git a/tests/selection-popup.spec.tsx b/tests/selection-popup.spec.tsx new file mode 100644 index 0000000..c366c2e --- /dev/null +++ b/tests/selection-popup.spec.tsx @@ -0,0 +1,190 @@ +/** + * useSelectionPopup spec — the "add to conversation" popup's global dismissal + * contract (upstream issue #425 ①). The portaled fixed button must hide on an + * outside mousedown, on Escape, when the document goes hidden, when the + * window loses focus, and as soon as the editor surface leaves the viewport + * (the geometry signal behind tab switches and panel collapse) — yet it must + * survive a mousedown ON the button so the click can still commit the + * selection. + */ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { createElement, useRef } from 'react' +import { createPortal } from 'react-dom' +import { createRoot } from 'react-dom/client' +import { act } from 'react-dom/test-utils' +import { useSelectionPopup } from '../src/client/selection-popup.ts' + +// The act() environment flag (React 18.2 reads it before flushing effects). +;(globalThis as Record).IS_REACT_ACT_ENVIRONMENT = true + +/** jsdom ships no IntersectionObserver; a controllable stand-in. */ +class FakeIntersectionObserver implements IntersectionObserver { + static last: FakeIntersectionObserver | null = null + readonly root: Element | Document | null = null + readonly rootMargin = '0px' + readonly thresholds: readonly number[] = [0] + readonly targets = new Set() + constructor(readonly callback: IntersectionObserverCallback) { + FakeIntersectionObserver.last = this + } + observe(target: Element): void { this.targets.add(target) } + unobserve(target: Element): void { this.targets.delete(target) } + disconnect(): void { this.targets.clear() } + takeRecords(): IntersectionObserverEntry[] { return [] } + /** Test hook: report that every observed target is (or is not) intersecting. */ + fire(intersecting: boolean): void { + this.callback([{ isIntersecting: intersecting } as IntersectionObserverEntry], this) + } +} + +interface HarnessCtl { + show(insert: string): void + button(): HTMLButtonElement | null +} + +let ctl: HarnessCtl + +/** The popup consumer's rendering shape: the hook + a portaled button. */ +function Harness(props: { onCommit: (insert: string) => void }) { + const surfaceRef = useRef(null) + const surface = useSelectionPopup({ + onCommit: props.onCommit, + getSurface: () => surfaceRef.current, + }) + ctl = { + show: (insert: string) => act(() => { surface.show(insert, 100, 100) }), + button: () => document.querySelector('[data-popup]'), + } + return createElement('div', null, + createElement('div', { ref: surfaceRef, 'data-surface': true }), + surface.popup === null ? null : createPortal( + createElement('button', { + type: 'button', + ref: surface.buttonRef, + 'data-popup': true, + onClick: surface.commit, + }, surface.popup.insert), + document.body, + ), + ) +} + +let container: HTMLDivElement +let root: ReturnType +let commitCalls: string[] + +function mount(): void { + container = document.createElement('div') + document.body.append(container) + root = createRoot(container) + commitCalls = [] + act(() => { + root.render(createElement(Harness, { onCommit: (insert: string) => { commitCalls.push(insert) } })) + }) +} + +function unmount(): void { + act(() => { root.unmount() }) + container.remove() +} + +const RealIntersectionObserver = globalThis.IntersectionObserver + +beforeEach(() => { + ;(globalThis as { IntersectionObserver: unknown }).IntersectionObserver = FakeIntersectionObserver + FakeIntersectionObserver.last = null +}) + +afterEach(() => { + ;(globalThis as { IntersectionObserver: unknown }).IntersectionObserver = RealIntersectionObserver + document.body.innerHTML = '' +}) + +describe('useSelectionPopup', () => { + it('shows the popup button with the payload', () => { + mount() + ctl.show('PAYLOAD') + const button = ctl.button() + expect(button).not.toBeNull() + expect(button?.textContent).toBe('PAYLOAD') + unmount() + }) + + it('commits the payload on click, then hides', () => { + mount() + ctl.show('PAYLOAD') + const button = ctl.button()! + act(() => { button.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) + expect(commitCalls).toEqual(['PAYLOAD']) + expect(ctl.button()).toBeNull() + unmount() + }) + + it('hides on a mousedown outside the button', () => { + mount() + ctl.show('PAYLOAD') + const elsewhere = document.createElement('div') + document.body.append(elsewhere) + act(() => { elsewhere.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) }) + expect(ctl.button()).toBeNull() + unmount() + }) + + it('survives a mousedown ON the button so the click can commit', () => { + mount() + ctl.show('PAYLOAD') + const button = ctl.button()! + act(() => { button.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) }) + expect(ctl.button()).not.toBeNull() + unmount() + }) + + it('hides on Escape', () => { + mount() + ctl.show('PAYLOAD') + act(() => { document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) }) + expect(ctl.button()).toBeNull() + unmount() + }) + + it('hides when the document goes hidden (browser tab switched away)', () => { + mount() + ctl.show('PAYLOAD') + const prototypeDescriptor = Object.getOwnPropertyDescriptor(Document.prototype, 'hidden') + Object.defineProperty(document, 'hidden', { value: true, configurable: true }) + try { + act(() => { document.dispatchEvent(new Event('visibilitychange')) }) + expect(ctl.button()).toBeNull() + } finally { + if (prototypeDescriptor !== undefined) Object.defineProperty(document, 'hidden', prototypeDescriptor) + else delete (document as { hidden?: boolean }).hidden + } + unmount() + }) + + it('hides when the window loses focus', () => { + mount() + ctl.show('PAYLOAD') + act(() => { window.dispatchEvent(new Event('blur')) }) + expect(ctl.button()).toBeNull() + unmount() + }) + + it('watches the editor surface: off-viewport (tab switch / collapse) hides, on-viewport keeps', () => { + mount() + ctl.show('PAYLOAD') + const observer = FakeIntersectionObserver.last + expect(observer).not.toBeNull() + expect(observer!.targets.size).toBe(1) + // The observed target is the hook's surface element. + expect([...observer!.targets][0]?.getAttribute('data-surface')).toBe('true') + // Surfaced on screen (or display:none removed): the popup stays. + act(() => { observer!.fire(true) }) + expect(ctl.button()).not.toBeNull() + // Hidden / translated off-screen: the popup must not survive it. + act(() => { observer!.fire(false) }) + expect(ctl.button()).toBeNull() + unmount() + }) +}) \ No newline at end of file From a3379ef17d8a0665d3cb36a6872ac03706da029b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=87=BF=E9=A9=B9-=E4=BC=AF=E4=B9=90?= Date: Thu, 27 Aug 2026 12:53:47 +0800 Subject: [PATCH 07/76] fix(conversation): insert the selection popup text at the composer caret (#425) appendToDraft always spliced at the end of the draft. The conversation service exposes no caret API, so probe the live composer textarea selection from the DOM (value-sync guarded) and splice at that position with whitespace-aware joins; an unknown or stale caret falls back to appending. The explorer @-reference shares this path, so it inserts at the caret too. --- src/client/conversation-draft.ts | 84 +++++++++++++++++++-- tests/conversation-draft.spec.ts | 122 +++++++++++++++++++++++++++++++ 2 files changed, 200 insertions(+), 6 deletions(-) create mode 100644 tests/conversation-draft.spec.ts diff --git a/src/client/conversation-draft.ts b/src/client/conversation-draft.ts index 9047054..f6c8fef 100644 --- a/src/client/conversation-draft.ts +++ b/src/client/conversation-draft.ts @@ -1,16 +1,88 @@ /** - * Append text to the current session's composer draft through the + * Insert text into the current session's composer draft through the * conversation service — the shared path behind the explorer's @-reference * button and the viewer selection popup. The service is resolved lazily * through `ctx.get` (the inject-free read the app's own plugins use); a * missing service or scope degrades to a logged no-op, never a crash. + * + * Insert position (fixes upstream issue #425): the draft store only exposes + * the whole string (`getSnapshot().draft` + `setDraft(text)`) — there is no + * caret API on the conversation service. The composer's `