Merge upstream main into fix/composer-clipped-by-layout-push

This commit is contained in:
Zzy
2026-08-23 11:19:36 +08:00
61 changed files with 4067 additions and 516 deletions
+2 -2
View File
@@ -22,9 +22,9 @@ import {
IconChevronRightOutline14,
IconLinkOutline14,
IconRefreshOutline14,
IconRightUpOutline16,
IconWarningOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { VscLinkExternal } from 'react-icons/vsc'
import { api } from './api.ts'
import { embeddabilityOf, normalizeBrowserUrl } from './browser.ts'
import { patchTab } from './state.ts'
@@ -186,7 +186,7 @@ export function BrowserView(props: TabComponentProps) {
if (url !== undefined) window.open(url, '_blank', 'noopener')
}}
>
<IconRightUpOutline16 size={15} />
<VscLinkExternal size={15} />
</button>
</div>
{message !== null && <div className={css.browserMessage}>{message}</div>}
+48 -9
View File
@@ -9,7 +9,7 @@
* The parser is a pure function (`parseUnifiedDiff`) so the interesting
* cases are unit-tested without a DOM.
*/
import { useMemo, useState, type ReactNode } from 'react'
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import clsx from 'clsx'
import { t } from './locales.ts'
import css from './sidebar.module.css'
@@ -171,6 +171,25 @@ function fileTag(file: DiffFile): string | null {
/** Cap the flattened rows like DiffBlock: head + tail, expand button between. */
const MAX_DIFF_ROWS = 500
const TEST_PATH = /(^|\/)(?:__tests__|tests?|specs?|fixtures?|mocks?|snapshots?)(?:\/|$)|\.(?:test|spec)\.[^/]+$/i
const DOC_PATH = /(^|\/)(?:docs?|documentation)(?:\/|$)|(^|\/)(?:readme|changelog|contributing|license|authors|notice)(?:\.[^/]*)?$/i
const GENERATED_PATH = /(^|\/)(?:dist|build|coverage|generated|vendor|node_modules)(?:\/|$)|(^|\/)(?:package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?|composer\.lock|cargo\.lock|poetry\.lock)$/i
const SOURCE_PATH = /\.(?:js|jsx|mjs|cjs|ts|tsx|mts|cts|py|pyw|rb|php|java|kt|kts|scala|go|rs|swift|c|h|cc|cpp|cxx|hpp|hh|hxx|cs|fs|fsx|vb|dart|lua|r|ex|exs|erl|hrl|clj|cljs|cljc|groovy|sh|bash|zsh|fish|ps1|sql|vue|svelte|astro|html|htm|css|scss|sass|less)$/i
/** Source files open by default; tests, docs, generated files and unknown types stay folded. */
function defaultExpandedFiles(files: DiffFile[]): Set<number> {
const expanded = new Set<number>()
files.forEach((file, index) => {
const path = displayPath(file.newPath === '/dev/null' ? file.oldPath : file.newPath)
if (!file.binary && file.hunks.length > 0
&& !TEST_PATH.test(path) && !DOC_PATH.test(path) && !GENERATED_PATH.test(path)
&& SOURCE_PATH.test(path)) {
expanded.add(index)
}
})
return expanded
}
export interface DiffViewProps {
/** Unified diff text (`git.diff` or `git.commit-diff` payloads). */
diff: string
@@ -187,22 +206,25 @@ export function DiffView({ diff, untrackedPath, untrackedContent }: DiffViewProp
return parseUnifiedDiff(diff)
}, [diff, untrackedPath, untrackedContent])
const [expanded, setExpanded] = useState(false)
const [expandedFiles, setExpandedFiles] = useState<Set<number>>(() => defaultExpandedFiles(parsed.files))
useEffect(() => { setExpandedFiles(defaultExpandedFiles(parsed.files)) }, [parsed])
// Flatten into display rows so the cap can slice a single list.
const rows = useMemo(() => {
const out: Array<{ key: string; file: DiffFile; type: 'path' | 'hunk' | 'line'; hunk?: DiffHunk; line?: DiffLine }> = []
const out: Array<{ key: string; file: DiffFile; fileIndex: number; type: 'path' | 'hunk' | 'line'; hunk?: DiffHunk; line?: DiffLine }> = []
parsed.files.forEach((file, fileIndex) => {
out.push({ key: `f${fileIndex}`, file, type: 'path' })
if (file.binary) return
out.push({ key: `f${fileIndex}`, file, fileIndex, type: 'path' })
if (file.binary || !expandedFiles.has(fileIndex)) return
file.hunks.forEach((hunk, hunkIndex) => {
out.push({ key: `f${fileIndex}h${hunkIndex}`, file, type: 'hunk', hunk })
out.push({ key: `f${fileIndex}h${hunkIndex}`, file, fileIndex, type: 'hunk', hunk })
hunk.lines.forEach((line, lineIndex) => {
out.push({ key: `f${fileIndex}h${hunkIndex}l${lineIndex}`, file, type: 'line', hunk, line })
out.push({ key: `f${fileIndex}h${hunkIndex}l${lineIndex}`, file, fileIndex, type: 'line', hunk, line })
})
})
})
return out
}, [parsed])
}, [parsed, expandedFiles])
const hidden = rows.length - MAX_DIFF_ROWS
const capped = hidden > 0 && !expanded
@@ -218,12 +240,29 @@ export function DiffView({ diff, untrackedPath, untrackedContent }: DiffViewProp
const tag = fileTag(row.file)
const from = displayPath(row.file.oldPath)
const to = displayPath(row.file.newPath)
const expandable = !row.file.binary && row.file.hunks.length > 0
const fileExpanded = expandedFiles.has(row.fileIndex)
return (
<div key={row.key} className={css.gitDiffFile}>
<button
key={row.key}
type="button"
className={css.gitDiffFile}
disabled={!expandable}
aria-expanded={expandable ? fileExpanded : undefined}
onClick={() => {
setExpandedFiles(current => {
const next = new Set(current)
if (next.has(row.fileIndex)) next.delete(row.fileIndex)
else next.add(row.fileIndex)
return next
})
}}
>
{expandable && <span aria-hidden="true" className={clsx(css.gitDiffFileChevron, fileExpanded && css.gitDiffFileChevronExpanded)}>›</span>}
<span className={css.gitDiffFilePath}>{to}</span>
{from !== to && <span className={css.gitDiffFileOld}>← {from}</span>}
{tag !== null && <span className={css.gitDiffFileTag}>{tag}</span>}
</div>
</button>
)
}
if (row.type === 'hunk') {
+76 -2
View File
@@ -22,7 +22,7 @@
* The strategy dispatch is pure (planFirstMatch / planFsReadOutcome in
* editor-load.ts); this component only wires it to the host APIs.
*/
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
import { createElement } from 'react'
import clsx from 'clsx'
import { IconCheckOutline16, IconFolderOpen16 } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -31,7 +31,10 @@ import { api, mediaUrl, type SessionScope } from './api.ts'
import { BinaryDownload } from './binary-download.tsx'
import { planFirstMatch, planFsReadOutcome, type EditorLoadAction } from './editor-load.ts'
import { baseName } from './FileTree.tsx'
import { createFrameBatcher } from './frame-batcher.ts'
import { openSidebarFile } from './intercept.tsx'
import { openWithSshActive, openWithUrl, parseOpenWithConfig, resolveOpenWithTargets } from './open-with.ts'
import { updatePluginSettings } from './plugin-settings.ts'
import { TreePanel } from './TreePanel.tsx'
import { t } from './locales.ts'
import { relativeTo } from './paths.ts'
@@ -51,6 +54,10 @@ const TREE_WIDTH_DEFAULT = 240
const TREE_WIDTH_MIN = 160
const TREE_WIDTH_MAX = 480
/** Stable empty blob for the editor pluginSettings read (a fresh `?? {}`
* would change identity every snapshot and loop useSyncExternalStore). */
const EMPTY_PLUGIN_BLOB: Record<string, unknown> = {}
/** The tab's persisted meta object (a malformed meta reads as empty). */
function metaOf(tab: SidebarTab): Record<string, unknown> {
return tab.meta !== null && typeof tab.meta === 'object' && !Array.isArray(tab.meta)
@@ -105,6 +112,16 @@ export function EditorHost(props: {
useCallback((callback: () => void) => store.subscribe(callback), [store]),
useCallback(() => store.getSnapshot().prefs.editorExplorer, [store]),
)
// The file tree's "open with" configuration (pluginSettings['editor']): a
// blob subscription, so a pin click or a settings-page edit re-renders the
// menu immediately. The parsed config also drives which targets are shown
// (SSH mode hides the host-local ones).
const editorBlob = useSyncExternalStore(
useCallback((callback: () => void) => store.subscribe(callback), [store]),
useCallback(() => store.getSnapshot().prefs.pluginSettings['editor'] ?? EMPTY_PLUGIN_BLOB, [store]),
)
const openWithConfig = useMemo(() => parseOpenWithConfig(editorBlob.openWith), [editorBlob])
const openWithTargets = useMemo(() => resolveOpenWithTargets(openWithConfig), [openWithConfig])
// A path-less tab shows the empty-state hint in merged mode — and in split
// mode it is the standalone explorer (tree-only, see the render below).
const showEmpty = path === ''
@@ -149,6 +166,39 @@ export function EditorHost(props: {
})
}
/** The context menu's "open with" action: reveal the path in the OS file
* manager, or hand the target's URL (a local `file` URL, or the SSH-remote
* form for VSCode-family editors in remote mode) to the host's external
* opener. Failures are logged only — a missing handler is the OS's
* dialog, not a sidebar error. */
const openWith = (targetId: string, absolute: string): void => {
const target = openWithTargets.find(item => item.id === targetId)
if (target === undefined) return
if (target.kind === 'reveal') {
void api.openExternal({ action: 'reveal', path: absolute }).catch(
(error: unknown) => { console.error('open external failed', error) },
)
return
}
const url = openWithUrl(target, absolute, openWithConfig)
if (url === undefined) return
void api.openExternal({ action: 'url', url }).catch(
(error: unknown) => { console.error('open external failed', error) },
)
}
/** Toggle one target's pinned state. The write is serialized (see
* plugin-settings.ts) and the menu re-renders when the store prefs land. */
const toggleOpenWithPin = (targetId: string): void => {
updatePluginSettings(store, 'editor', (blob) => {
const config = parseOpenWithConfig(blob.openWith)
const pinned = config.pinned.includes(targetId)
? config.pinned.filter(id => id !== targetId)
: [...config.pinned, targetId]
return { ...blob, openWith: { ...config, pinned } }
})
}
// The viewer's toolbar, hoisted into THIS header: the text editor reports
// its state and registers its commands (both null/absent for viewers
// without a toolbar — image, pdf, binary download).
@@ -165,8 +215,16 @@ export function EditorHost(props: {
// (no window listeners — the captured pointer keeps tracking even off the
// handle). Local width while dragging, persisted into meta.treeWidth on
// release. The panel docks right, so dragging LEFT widens it.
// Moves are BATCHED per frame (createFrameBatcher): applying every
// pointermove is a setState that re-renders this host AND the editor
// viewer below it (CodeMirror re-lays out on the width change) at event
// cadence — the visible drag lag on slower CPUs (#315). The batch applies
// the latest width once per frame; release flushes it and commits.
const [dragWidth, setDragWidth] = useState<number | null>(null)
const dragRef = useRef<{ startX: number; startWidth: number } | null>(null)
const pendingWidthRef = useRef(0)
const dragBatcher = useRef(createFrameBatcher()).current
useEffect(() => () => dragBatcher.dispose(), [dragBatcher])
const treeWidth = dragWidth ?? treeWidthOf(tab)
const onResizeStart = (event: React.PointerEvent): void => {
@@ -178,11 +236,17 @@ export function EditorHost(props: {
const onResizeMove = (event: React.PointerEvent): void => {
const drag = dragRef.current
if (drag === null) return
setDragWidth(clampTreeWidth(drag.startWidth + (drag.startX - event.clientX)))
pendingWidthRef.current = clampTreeWidth(drag.startWidth + (drag.startX - event.clientX))
dragBatcher.schedule(() => setDragWidth(pendingWidthRef.current))
}
const onResizeEnd = (event: React.PointerEvent): void => {
const drag = dragRef.current
if (drag === null) return
// Flush the last pending frame (a release can land with the final move
// still queued; without the flush a stray frame would re-apply the
// drag width AFTER the null below). Both setStates batch into this same
// event, so the committed treeWidth wins visually.
dragBatcher.flushNow()
dragRef.current = null
setDragWidth(null)
const finalWidth = clampTreeWidth(drag.startWidth + (drag.startX - event.clientX))
@@ -272,6 +336,11 @@ export function EditorHost(props: {
onOpenFile={openFile}
onOpenFileNewTab={openFileNewTab}
onOpenFileSide={openFileSide}
openWithTargets={openWithTargets}
openWithPinned={openWithConfig.pinned}
openWithSsh={openWithSshActive(openWithConfig)}
onOpenWith={openWith}
onToggleOpenWithPin={toggleOpenWithPin}
onReferenceFile={onReferenceFile}
/>
</div>
@@ -365,6 +434,11 @@ export function EditorHost(props: {
onOpenFile={openFile}
onOpenFileNewTab={openFileNewTab}
onOpenFileSide={openFileSide}
openWithTargets={openWithTargets}
openWithPinned={openWithConfig.pinned}
openWithSsh={openWithSshActive(openWithConfig)}
onOpenWith={openWith}
onToggleOpenWithPin={toggleOpenWithPin}
onReferenceFile={onReferenceFile}
/>
</div>
+128 -13
View File
@@ -24,11 +24,14 @@ import { useCallback, useEffect, useRef, useState, type DragEvent, type MouseEve
import { createPortal } from 'react-dom'
import clsx from 'clsx'
import {
IconCodeOutline16, IconCopyOutline16, IconDownloadOutline16, IconFolderClose16, IconFolderOpen16,
IconLinkOutline16, Menu, writeClipboard,
IconChevronRightOutline14, IconCodeOutline16, IconCopyOutline16, IconDownloadOutline16,
IconLinkOutline16, Menu, type MenuEntry, type MenuItem, writeClipboard,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { SiCursor, SiZedindustries } from 'react-icons/si'
import { VscFile, VscFolder, VscFolderOpened, VscLinkExternal, VscPin, VscPinned } from 'react-icons/vsc'
import { api, downloadUrl, type FsEntry } from './api.ts'
import { IconUploadOutline16 } from './icons.tsx'
import { IconUploadOutline16, IconVscode16 } from './icons.tsx'
import type { OpenWithTarget } from './open-with.ts'
import { relativeTo } from './paths.ts'
import { t } from './locales.ts'
import { uploadItemsFromDrop, uploadItemsFromFiles, type UploadItem } from './upload.ts'
@@ -52,6 +55,13 @@ function parentOf(path: string): string {
return at <= 0 ? path : path.slice(0, at)
}
/** Only OS file drags belong to the upload surface; in-app drags (tab reorder,
* split zones) must pass through untouched to the pane's tab-drop handling
* (mirror of Sidebar.tsx's panel-host shield gate). */
function isFileDrag(event: DragEvent): boolean {
return event.dataTransfer?.types.includes('Files') ?? false
}
/** How long the row's "copied" label stays after a successful write. */
const COPIED_MS = 1200
@@ -102,6 +112,19 @@ export function FileTree(props: {
onOpenFileNewTab?: (path: string) => void
/** Context-menu "open to the side" (file rows; absent → no entry). */
onOpenFileSide?: (path: string) => void
/**
* The "open with" menu: resolved external targets (already SSH-filtered
* and in menu order). Absent → the whole section is hidden.
*/
openWithTargets?: OpenWithTarget[]
/** Ids of targets pinned to the menu's top level (subset of the ids). */
openWithPinned?: string[]
/** Whether the workspace is remote (appends the SSH hint to target labels). */
openWithSsh?: boolean
/** Open one target externally (reveal or URL — the caller decides). */
onOpenWith?: (targetId: string, path: string) => void
/** Toggle one target's pinned state (the submenu row's pushpin). */
onToggleOpenWithPin?: (targetId: string) => void
/** Insert `@<relative path>` into the composer draft. */
onReferenceFile: (path: string) => void
/** Bump to wipe the level cache and reload the visible set. */
@@ -111,7 +134,7 @@ export function FileTree(props: {
/** True while an upload is in flight (drops are ignored). */
busy: boolean
}) {
const { sessionId, cwd, expanded, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, onReferenceFile, refreshTick, onUploadRequest, busy } = props
const { sessionId, cwd, expanded, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, refreshTick, onUploadRequest, busy } = props
const [data, setData] = useState<Record<string, LevelData>>({})
const dataRef = useRef(data)
/** The row whose path was just copied ("copied" label replaces its button). */
@@ -160,12 +183,14 @@ export function FileTree(props: {
})
}
const handleBodyDrop = (event: DragEvent): void => {
if (!isFileDrag(event)) return
event.preventDefault()
event.stopPropagation()
resetDrop()
if (cwd !== undefined) reportDrop(cwd, event.dataTransfer)
}
const handleDirDrop = (event: DragEvent, dir: string): void => {
if (!isFileDrag(event)) return
event.preventDefault()
event.stopPropagation()
resetDrop()
@@ -176,6 +201,7 @@ export function FileTree(props: {
handleDirDrop(event, parentOf(path))
}
const handleBodyDragEnter = (event: DragEvent): void => {
if (!isFileDrag(event)) return
event.preventDefault()
event.stopPropagation()
dropDepth.current += 1
@@ -195,6 +221,7 @@ export function FileTree(props: {
setDropRect(null)
}
const handleBodyDragOver = (event: DragEvent): void => {
if (!isFileDrag(event)) return
event.preventDefault()
event.stopPropagation()
event.dataTransfer.dropEffect = busy ? 'none' : 'copy'
@@ -205,6 +232,7 @@ export function FileTree(props: {
setDropTarget(null)
}
const handleRowDragOver = (event: DragEvent, dir: string): void => {
if (!isFileDrag(event)) return
event.preventDefault()
event.stopPropagation()
event.dataTransfer.dropEffect = busy ? 'none' : 'copy'
@@ -295,6 +323,88 @@ export function FileTree(props: {
anchor.remove()
}
/** The menu label of one open target: a locale key for the built-ins, the
* user's own name for custom editors, plus the SSH hint in remote mode. */
const openWithLabelOf = (target: OpenWithTarget): string => {
const name = target.nameKey !== undefined ? t(target.nameKey) : target.name
return openWithSsh === true && !target.localOnly ? `${name}${t('openWithSshSuffix')}` : name
}
/**
* The "open with" menu entries: the pinned targets as DIRECT rows, then
* the parent row with every target as a nested submenu. Both only render
* when the caller wired the feature and at least one target is visible.
*/
const openWithEntries = (): MenuEntry[] => {
if (openWithTargets === undefined || onOpenWith === undefined || openWithTargets.length === 0) return []
const pinnedIds = openWithPinned ?? []
/** Brand marks for the built-ins (monochrome silhouettes, currentColor);
* reveal gets the folder glyph, custom editors a generic code mark. */
const itemIcon = (target: OpenWithTarget): ReactNode => {
if (target.kind === 'reveal') return <VscFolderOpened size={16} />
if (target.id === 'vscode') return <IconVscode16 size={16} />
if (target.id === 'cursor') return <SiCursor size={16} />
if (target.id === 'zed') return <SiZedindustries size={16} />
return <IconCodeOutline16 size={16} />
}
const pinned = openWithTargets
.filter(target => pinnedIds.includes(target.id))
.map<MenuItem>(target => ({
id: `open-with:${target.id}`,
label: openWithLabelOf(target),
icon: itemIcon(target),
}))
const submenu = openWithTargets.map<MenuItem>(target => {
const pinnedNow = pinnedIds.includes(target.id)
return {
id: `open-with:${target.id}`,
label: (
<span className={css.openWithLabel}>
<span className={css.openWithName}>{openWithLabelOf(target)}</span>
{/* The pushpin: a span (never a button — the Menu row itself is
a button, so a nested interactive element would be invalid).
Clicking it pins/unpins the target at the menu's top level
WITHOUT selecting the row: the pin stops propagation, so the
menu stays open and the icon flips on the next render. */}
<span
role="button"
tabIndex={-1}
className={clsx(css.openWithPin, pinnedNow && css.openWithPinActive)}
aria-label={pinnedNow ? t('unpinOpenWith') : t('pinOpenWith')}
title={pinnedNow ? t('unpinOpenWith') : t('pinOpenWith')}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
onToggleOpenWithPin?.(target.id)
}}
>
{pinnedNow ? <VscPinned size={14} /> : <VscPin size={14} />}
</span>
</span>
),
icon: itemIcon(target),
}
})
return [
...pinned,
...(pinned.length > 0 ? [{ id: 'open-with-sep', type: 'separator' } as MenuEntry] : []),
{
id: 'open-with-menu',
// The primitives Menu renders no chevron for submenu parents — the
// trailing arrow is supplied inside the label (full-width flex row,
// right-aligned), matching how the submenu rows right-align the pin.
label: (
<span className={css.openWithLabel}>
<span className={css.openWithName}>{t('openWithMenu')}</span>
<IconChevronRightOutline14 size={14} className={css.openWithChevron} aria-hidden />
</span>
),
icon: <VscLinkExternal size={16} />,
submenu,
},
]
}
const root = cwd
const renderLevel = (dir: string, depth: number): ReactNode => {
@@ -334,7 +444,7 @@ export function FileTree(props: {
onDrop={(event) => { handleDirDrop(event, entry.path) }}
onContextMenu={(event) => { openRowMenu(event, entry.path, true) }}
>
{isOpen ? <IconFolderOpen16 size={14} /> : <IconFolderClose16 size={14} />}
{isOpen ? <VscFolderOpened size={14} /> : <VscFolder size={14} />}
<span className={css.explorerName}>{entry.name}</span>
{entry.isSymlink && <IconLinkOutline16 size={12} className={css.explorerSymlink} />}
{rowActions(entry)}
@@ -365,7 +475,7 @@ export function FileTree(props: {
onDrop={(event) => { handleFileDrop(event, entry.path) }}
onContextMenu={(event) => { openRowMenu(event, entry.path, false) }}
>
<IconCodeOutline16 size={14} />
<VscFile size={14} />
<span className={css.explorerName}>{entry.name}</span>
{entry.isSymlink && <IconLinkOutline16 size={12} className={css.explorerSymlink} />}
{rowActions(entry)}
@@ -394,7 +504,7 @@ export function FileTree(props: {
onDrop={(event) => { handleDirDrop(event, root) }}
onContextMenu={(event) => { openRowMenu(event, root, true) }}
>
<IconFolderOpen16 size={14} />
<VscFolderOpened size={14} />
<span className={css.explorerName}>{baseName(root)}</span>
{copiedPath === root
? <span className={css.explorerCopied}>{t('copied')}</span>
@@ -486,21 +596,22 @@ export function FileTree(props: {
items={[
// The open escapes head the FILE menu (dirs only get copy).
...(rowMenu?.isDir === false && onOpenFileNewTab !== undefined
? [{ id: 'open-new-tab', label: t('openFileNewTab'), icon: <IconCodeOutline16 size={14} /> }]
? [{ id: 'open-new-tab', label: t('openFileNewTab'), icon: <IconCodeOutline16 size={16} /> }]
: []),
...(rowMenu?.isDir === false && onOpenFileSide !== undefined
? [{ id: 'open-side', label: t('openFileSide'), icon: <IconFolderOpen16 size={14} /> }]
? [{ id: 'open-side', label: t('openFileSide'), icon: <VscFolderOpened size={16} /> }]
: []),
...openWithEntries(),
// Download applies to files only (the host route refuses directories).
...(rowMenu?.isDir === false
? [{ id: 'download', label: t('download'), icon: <IconDownloadOutline16 size={14} /> }]
? [{ id: 'download', label: t('download'), icon: <IconDownloadOutline16 size={16} /> }]
: []),
// Upload into a directory (incl. the workspace root row).
...(rowMenu?.isDir === true
? [{ id: 'upload-here', label: t('uploadHere'), icon: <IconUploadOutline16 size={14} /> }]
? [{ id: 'upload-here', label: t('uploadHere'), icon: <IconUploadOutline16 size={16} /> }]
: []),
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={14} /> },
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={14} /> },
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={16} /> },
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={16} /> },
]}
onSelect={(id) => {
const target = rowMenu
@@ -514,6 +625,10 @@ export function FileTree(props: {
onOpenFileSide?.(target.path)
return
}
if (id.startsWith('open-with:')) {
onOpenWith?.(id.slice('open-with:'.length), target.path)
return
}
if (id === 'download') {
downloadFile(target.path)
return
+65
View File
@@ -893,3 +893,68 @@
color: var(--dsw-alias-label-secondary);
padding: 2px 2px 0;
}
/* Open-with settings panel: one custom editor row (name + URL template +
VSCode-family checkbox + remove) and the row-validity hint. */
.openWithEditorRow {
display: grid;
grid-template-columns: 1fr 1.5fr auto auto;
gap: 8px;
align-items: center;
min-width: 0;
}
.openWithEditorInput,
.openWithEditorTemplate {
width: 100%;
min-width: 0;
box-sizing: border-box;
padding: 5px 8px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 8px;
background: var(--dsw-alias-bg-layer-3);
color: var(--dsw-alias-label-primary);
font: inherit;
font-size: 13px;
line-height: 20px;
}
.openWithEditorInput:focus-visible,
.openWithEditorTemplate:focus-visible {
outline: 2px solid var(--dsw-alias-state-business-primary);
outline-offset: 1px;
}
.openWithFamily {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 12px;
color: var(--dsw-alias-label-secondary);
white-space: nowrap;
cursor: pointer;
}
.openWithRemove {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px;
border: none;
border-radius: 8px;
background: none;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.openWithRemove:hover {
color: var(--dsw-alias-state-error-primary);
background: var(--dsw-alias-bg-layer-2);
}
.openWithHint {
font-size: 12px;
line-height: 17px;
color: var(--dsw-alias-state-error-primary);
padding: 0 2px;
}
+45 -42
View File
@@ -492,12 +492,13 @@ function SelectRow(props: {
/**
* The secondary settings popup body of one feature (tab or viewer):
* - `settings.render` (custom panel) when declared — rendered with the
* shared store/service, the live prefs, the descriptor's own plugin
* settings blob, a persistence helper, and a close callback;
* - otherwise the host-prefs `toggles` rows, then the plugin-owned
* `pluginToggles` rows (their values live in `pluginSettings[feature.id]`,
* projected onto the prefs face so the shared row renderer reads them).
* - the host-prefs `toggles` rows, then the plugin-owned `pluginToggles`
* rows (their values live in `pluginSettings[feature.id]`, projected onto
* the prefs face so the shared row renderer reads them);
* - `settings.render` (custom panel) AFTER those rows when declared — the
* custom panel is an extension of the row list, not a replacement, so a
* feature can keep its declarative rows (e.g. the editor's
* open-behavior picker) and still ship a custom configuration area.
*/
export function SettingsBody(props: {
feature: TabDescriptor | FileViewerDescriptor
@@ -515,48 +516,50 @@ export function SettingsBody(props: {
}) {
const { feature, prefs, store, service, onToggle, onCommit, onSelectValue, onPluginToggle, onPluginCommit, onPluginSelectValue, onPluginWrite, onClose } = props
const render = feature.settings?.render
if (render !== undefined) {
return (
<SettingsRender
render={render}
renderProps={{
store,
service,
prefs,
pluginSettings: prefs.pluginSettings[feature.id] ?? {},
updatePluginSetting: onPluginWrite,
close: onClose,
}}
/>
)
}
const toggles = feature.settings?.toggles ?? []
const pluginToggles = feature.settings?.pluginToggles ?? []
if (toggles.length === 0 && pluginToggles.length === 0) return null
if (render === undefined && toggles.length === 0 && pluginToggles.length === 0) return null
// Plugin rows read their values from the descriptor's OWN blob through
// an explicit value source — no projection onto the prefs face, so a
// plugin key can never collide with (or silently read) a host pref of
// the same name.
const pluginBlob = prefs.pluginSettings[feature.id] ?? {}
return (
<div className={css.popupRows}>
{toggles.length > 0 && (
<FeatureSettingsRows
toggles={toggles}
prefs={prefs}
onToggle={onToggle}
onCommit={onCommit}
onSelectValue={onSelectValue}
/>
<div>
{(toggles.length > 0 || pluginToggles.length > 0) && (
<div className={css.popupRows}>
{toggles.length > 0 && (
<FeatureSettingsRows
toggles={toggles}
prefs={prefs}
onToggle={onToggle}
onCommit={onCommit}
onSelectValue={onSelectValue}
/>
)}
{pluginToggles.length > 0 && (
<FeatureSettingsRows
toggles={pluginToggles}
prefs={prefs}
onToggle={onPluginToggle}
onCommit={onPluginCommit}
onSelectValue={onPluginSelectValue}
valueSource={(key) => pluginBlob[key]}
/>
)}
</div>
)}
{pluginToggles.length > 0 && (
<FeatureSettingsRows
toggles={pluginToggles}
prefs={prefs}
onToggle={onPluginToggle}
onCommit={onPluginCommit}
onSelectValue={onPluginSelectValue}
valueSource={(key) => pluginBlob[key]}
{render !== undefined && (
<SettingsRender
render={render}
renderProps={{
store,
service,
prefs,
pluginSettings: prefs.pluginSettings[feature.id] ?? {},
updatePluginSetting: onPluginWrite,
close: onClose,
}}
/>
)}
</div>
@@ -1045,9 +1048,9 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
feature is open — the Modal primitive runs hooks unconditionally,
so a closed-but-mounted Modal would break SSR (and the
renderToString spec) under the test dual-react split.
Content: `settings.render` (custom panel) when declared, else the
host-prefs `toggles` rows followed by the plugin-owned
`pluginToggles` rows (their values live in pluginSettings[id]). */}
Content: the host-prefs `toggles` rows, the plugin-owned
`pluginToggles` rows (their values live in pluginSettings[id]),
then the custom `settings.render` panel when declared. */}
{settingsFor !== null && (
<Modal
open
+105 -64
View File
@@ -2,10 +2,12 @@
* language mirrors the main conversation pane: right-aligned user bubbles
* on --dsw-specific-bubble, full-width assistant markdown, a capsule
* composer card on --dsw-specific-input-major with a circular accent
* send/stop button, and shimmer reserved for the running status line and
* executing tools. Motion is crossfade-only (≤200ms entrances, no
* springs); reduced-motion disables every loop. All values consume DSW
* tokens (fallbacks point at older alias tokens for rc-era hosts). */
* send/stop button. Tool calls, thinking and context injections share one
* collapsible-line chrome (no cards); shimmer is reserved for generating
* states (running status line, streaming rows, the creating hero). Motion
* is crossfade-only (≤200ms entrances, no springs); reduced-motion disables
* every loop. All values consume DSW tokens (fallbacks point at older alias
* tokens for rc-era hosts). */
.sidechat {
flex: 1;
min-height: 0;
@@ -172,82 +174,99 @@
overflow-wrap: anywhere;
}
.sidechatReasoning {
/* ── collapsible context rows (tool / thinking / injection, Codex-style) ──
* One quiet line — chevron + label + one-line summary — that expands into
* an indented body hung on a hairline thread. No cards, no fills: hierarchy
* rides ink weight alone. Streaming rows shimmer the label (shimmer =
* generating); failed rows go danger; reduced-motion stills every loop. */
.sidechatRow {
align-self: stretch;
}
.sidechatReasoningSummary {
display: inline-block;
.sidechatRowLine {
display: flex;
align-items: center;
gap: 6px;
padding: 1px 0;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
list-style: none;
}
.sidechatReasoningSummary::-webkit-details-marker {
.sidechatRowSummary {
cursor: pointer;
list-style: none;
user-select: none;
}
.sidechatRowSummary::-webkit-details-marker {
display: none;
}
.sidechatReasoningBody {
margin-top: 2px;
padding-left: 2px;
.sidechatRowSummary:hover {
color: var(--dsw-alias-label-secondary);
}
.sidechatRowStatic {
cursor: default;
}
.sidechatRowChevron {
flex: none;
display: inline-flex;
align-items: center;
transition: transform 100ms ease-out;
}
.sidechatRow[open] .sidechatRowChevron {
transform: rotate(90deg);
}
.sidechatRowLabel {
flex: none;
max-width: 60%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--dsw-alias-label-secondary);
}
.sidechatRowMono {
font-family: var(--dsw-font-mono);
}
.sidechatRowMeta {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--dsw-font-mono);
color: var(--dsw-alias-label-tertiary);
}
.sidechatRowFailed .sidechatRowLabel,
.sidechatRowFailed .sidechatRowMeta {
color: var(--dsw-alias-danger);
}
.sidechatRowBody {
margin: 2px 0 4px 7px;
padding: 2px 0 2px 10px;
border-left: 1px solid var(--dsw-alias-hairline);
}
.sidechatRowProse {
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
white-space: pre-wrap;
overflow-wrap: anywhere;
max-height: 240px;
overflow-y: auto;
}
.sidechatTool {
align-self: stretch;
border: 1px solid var(--dsw-alias-hairline);
border-radius: 10px;
background: var(--dsw-alias-bg-base);
overflow: hidden;
}
.sidechatToolSummary {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-secondary);
cursor: pointer;
list-style: none;
}
.sidechatToolSummary::-webkit-details-marker {
display: none;
}
.sidechatToolName {
font-family: var(--dsw-font-mono);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Running tool: a light sweep across the summary row (shimmer = generating). */
.sidechatToolExecuting {
background-image: linear-gradient(
90deg,
transparent 0%,
var(--dsw-alias-interactive-bg-hover) 50%,
transparent 100%
);
background-size: 200% 100%;
animation: sidechatSweep 2.6s linear infinite;
}
.sidechatToolFailed {
color: var(--dsw-alias-danger);
}
.sidechatToolArgs,
.sidechatToolResult {
.sidechatRowCode {
margin: 0;
padding: 6px 10px;
border-top: 1px solid var(--dsw-alias-hairline);
padding: 4px 0;
font: var(--dsw-font-xxs-12);
font-family: var(--dsw-font-mono);
color: var(--dsw-alias-label-secondary);
@@ -257,6 +276,25 @@
overflow-y: auto;
}
.sidechatRowCode + .sidechatRowCode {
border-top: 1px solid var(--dsw-alias-hairline);
}
/* Shimmer sweep text: generating (streaming row labels, creating hero). */
.sidechatShimmerText {
background-image: linear-gradient(
90deg,
var(--dsw-alias-label-tertiary) 0%,
var(--dsw-alias-label-primary) 50%,
var(--dsw-alias-label-tertiary) 100%
);
background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: sidechatSweep 2.6s linear infinite;
}
/* ── running status line (above the composer, main-pane style) ──────── */
.sidechatStatus {
flex: none;
@@ -388,9 +426,12 @@
animation: none;
}
.sidechatStatusText,
.sidechatToolExecuting {
.sidechatShimmerText {
animation: none;
background-image: none;
color: var(--dsw-alias-label-tertiary);
}
.sidechatRowChevron {
transition: none;
}
}
+103 -27
View File
@@ -24,6 +24,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useSyncExternalStore } from 'react'
import clsx from 'clsx'
import {
IconChevronRightOutline14,
IconNewChatOutline16,
IconPlusOutline16,
IconSendOutline16,
@@ -43,7 +44,7 @@ import {
threadTrailingPending,
type SidechatThreadInfo,
} from '../sidechat-core.ts'
import { collectOwnEvents, transcriptRows, type SidechatTranscriptRow } from './sidechat-transcript.ts'
import { collectOwnEvents, toolArgsSummary, transcriptRows, type SidechatTranscriptRow } from './sidechat-transcript.ts'
import { api } from './api.ts'
import { t } from './locales.ts'
import type { SessionScope } from './api.ts'
@@ -102,6 +103,7 @@ interface RowLabels {
copyLabel: string
copiedLabel: string
thinkLabel: string
injectionLabel: string
}
/** Merge history entries by event seq (newest wins), log order preserved. */
@@ -122,48 +124,112 @@ function threadDisplayTitle(title: string): string {
return title.startsWith(SIDE_LABEL_PREFIX) ? title.slice(SIDE_LABEL_PREFIX.length) : title
}
/**
* One collapsible context row — the shared Codex-style chrome of tool
* calls, thinking and context injections: a single quiet line (chevron +
* label + one-line summary) that expands into an indented body hung on a
* hairline thread. Rows with nothing to reveal render as a static line.
*/
function CollapsibleRow(props: {
label: string
meta?: string
mono?: boolean
streaming?: boolean
failed?: boolean
children?: React.ReactNode
}): React.ReactNode {
const label = (
<span
className={clsx(
css.sidechatRowLabel,
props.mono === true && css.sidechatRowMono,
props.streaming === true && css.sidechatShimmerText,
)}
>
{props.label}
</span>
)
const meta = props.meta !== undefined && props.meta !== ''
? <span className={css.sidechatRowMeta}>{props.meta}</span>
: null
if (props.children === undefined) {
return (
<div className={clsx(css.sidechatRowLine, css.sidechatRowStatic, props.failed === true && css.sidechatRowFailed)}>
{label}
{meta}
</div>
)
}
return (
<details className={css.sidechatRow}>
<summary
className={clsx(
css.sidechatRowLine,
css.sidechatRowSummary,
props.failed === true && css.sidechatRowFailed,
)}
>
<span className={css.sidechatRowChevron}>
<IconChevronRightOutline14 size={12} />
</span>
{label}
{meta}
</summary>
<div className={css.sidechatRowBody}>{props.children}</div>
</details>
)
}
/** One row renderer (React keys ride the source event seq). */
function renderRow(row: SidechatTranscriptRow, labels: RowLabels): React.ReactNode {
switch (row.kind) {
case 'user':
return (
<div key={row.seq} className={css.sidechatUser}>
<div key={`${row.kind}:${row.seq}`} className={css.sidechatUser}>
<MarkdownText text={row.text} codeLabels={labels} />
</div>
)
case 'assistant':
return (
<div key={row.seq} className={css.sidechatAssistant}>
<div key={`${row.kind}:${row.seq}`} className={css.sidechatAssistant}>
<MarkdownText text={row.text} codeLabels={labels} />
</div>
)
case 'reasoning':
return (
<details key={row.seq} className={css.sidechatReasoning}>
<summary className={css.sidechatReasoningSummary}>{labels.thinkLabel}</summary>
<div className={css.sidechatReasoningBody}>{row.text}</div>
</details>
<CollapsibleRow
key={`${row.kind}:${row.seq}`}
label={labels.thinkLabel}
streaming={!row.settled}
>
<div className={css.sidechatRowProse}>{row.text}</div>
</CollapsibleRow>
)
case 'tool':
case 'injection':
return (
<details key={row.seq} className={css.sidechatTool}>
<summary
className={clsx(
css.sidechatToolSummary,
row.executing === true && css.sidechatToolExecuting,
row.failed && css.sidechatToolFailed,
)}
>
<StateDot
state={row.executing === true ? 'ongoing' : row.failed ? 'error' : 'done'}
size={8}
/>
<span className={css.sidechatToolName}>{row.name}</span>
</summary>
{row.args !== undefined && <pre className={css.sidechatToolArgs}>{row.args}</pre>}
{row.resultText !== undefined && <pre className={css.sidechatToolResult}>{row.resultText}</pre>}
</details>
<CollapsibleRow key={`${row.kind}:${row.seq}`} label={labels.injectionLabel}>
<div className={css.sidechatRowProse}>{row.text}</div>
</CollapsibleRow>
)
case 'tool': {
const body = (
<>
{row.args !== undefined && <pre className={css.sidechatRowCode}>{row.args}</pre>}
{row.resultText !== undefined && <pre className={css.sidechatRowCode}>{row.resultText}</pre>}
</>
)
return (
<CollapsibleRow
key={`${row.kind}:${row.seq}`}
label={row.name}
meta={toolArgsSummary(row.args)}
mono
streaming={row.executing === true}
failed={row.failed}
{...(row.args === undefined && row.resultText === undefined ? {} : { children: body })}
/>
)
}
}
}
@@ -176,7 +242,12 @@ export function SideChatView(props: {
}): React.ReactNode {
const { ctx, scope, tab, visible } = props
const rowLabels = useMemo<RowLabels>(
() => ({ copyLabel: t('copy'), copiedLabel: t('copied'), thinkLabel: t('sideChatThink') }),
() => ({
copyLabel: t('copy'),
copiedLabel: t('copied'),
thinkLabel: t('sideChatThink'),
injectionLabel: t('sideChatInjection'),
}),
[],
)
@@ -448,7 +519,12 @@ export function SideChatView(props: {
<div className={css.sidechat}>
<div className={css.sidechatHero}>
<IconNewChatOutline16 />
<div className={css.sidechatHeroTitle}>
<div
className={clsx(
css.sidechatHeroTitle,
busy === 'starting' && css.sidechatShimmerText,
)}
>
{busy === 'starting' ? t('sideChatCreating') : t('sideChatEmpty')}
</div>
<div className={css.sidechatHeroDesc}>{t('sideChatEmptyDesc')}</div>
+102 -31
View File
@@ -28,7 +28,7 @@
* drawer floats). Widening does not migrate back: the tabs keep living in
* the right tree.
*/
import { createElement, useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type ReactNode } from 'react'
import { createElement, memo, useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type ReactNode } from 'react'
import { useSyncExternalStore } from 'react'
import clsx from 'clsx'
import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -53,6 +53,7 @@ import type { TabDragPayload } from './TabBar.tsx'
import { relativeTo } from './paths.ts'
import { OrphanedTab } from './OrphanedTab.tsx'
import { RenderBoundary } from './RenderBoundary.tsx'
import { tabContentCompare, type TabContentMemoKey } from './tab-content-memo.ts'
import { detectNewDirectSubagent } from './subagent-detect.ts'
import { detectNewJob } from './subagent-jobs.ts'
import { t } from './locales.ts'
@@ -63,6 +64,16 @@ import css from './sidebar.module.css'
* (mirror of the terminal view's own cap; the loop restarts on session switch). */
const FAILURE_LIMIT = 3
/**
* Subagent auto-open debounce (ms). The host delivers a new child's origin
* and its title in SEPARATE frames: a Side Chat thread's first visible
* frame still shows a fallback title (no 'Side: ' prefix), so an immediate
* 0→N decision mistakes it for a genuine subagent and pops the task page.
* The trigger therefore re-evaluates against the live snapshot once the
* title frame has had time to land.
*/
const AUTO_OPEN_DEBOUNCE_MS = 500
/**
* OS file drags over the sidebar belong to the sidebar, not to the chat:
* DSH's composer (InputBar) listens for file drags on the DOCUMENT and
@@ -104,23 +115,25 @@ function injectUserCss(attr: string, id: string, cssText: string): HTMLStyleElem
return tag
}
/** Render the content of one tab (dispatched by type). */
function TabContent(props: {
tab: SidebarTab
sessionId: string
cwd: string | undefined
expanded: string[]
/** Props of one tab's content cell = the memo key (tab-content-memo.ts) plus
* the runtime objects/callbacks the cell renders with. The memo comparator
* is the pure `tabContentCompare`; anything in the key decides a re-render
* must propagate, anything outside it must be a stable object (ctx/store)
* or covered by a compared field (paneId covers onOpenDiff's captured
* pane; sessionId/cwd cover onReferenceFile). */
interface TabContentProps extends TabContentMemoKey {
onToggleDir: (path: string) => void
onReferenceFile: (path: string) => void
ctx: Context
store: SidebarStore
/** Whether this tab is the active one AND the panel is open (live views pause otherwise). */
visible: boolean
/** Fired before a topology node jumps to its child session (see Sidebar). */
onSubagentJump: (childSessionId: string) => void
/** Open a diff tab from the git panel (placement handled by the store). */
onOpenDiff: (tab: SidebarTab) => void
}) {
}
/** Render the content of one tab (dispatched by type). */
const TabContent = memo(function TabContent(props: TabContentProps) {
const { tab, sessionId, cwd, expanded, onToggleDir, onReferenceFile, ctx, store, visible, onSubagentJump, onOpenDiff } = props
const scope = { sessionId, cwd }
const descriptor = ctx.betterSidebar?.getTab(tab.type)
@@ -143,7 +156,7 @@ function TabContent(props: {
onToggleDir, onReferenceFile, onOpenDiff, onSubagentJump,
}),
)
}
}, tabContentCompare)
/** The + menu options for the current state, driven by the tab registry.
* Hidden tabs (editor/diff) never show; `available` returning false shows
@@ -176,6 +189,18 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
)
void localeRevision
// Tab-registry revision: TabContent memo cells must pick up a descriptor
// a plugin registers/disposes after mount (the + menu / icons already read
// the registry at render). Rare events (plugin (un)mount), so one full
// re-render per change is fine — this is what keeps the memoized cells
// from going stale, mirroring the localeRevision mechanism above.
const [tabsVersion, setTabsVersion] = useState(0)
useEffect(() => {
const service = ctx.betterSidebar
if (service === undefined) return
return service.subscribe(() => setTabsVersion(version => version + 1))
}, [ctx])
// Narrow (mobile) viewports collapse the two panels into one: the right
// panel becomes a full-width drawer holding BOTH workbenches, the bottom
// panel (and its toggle button) disappears, and the layout push is
@@ -412,23 +437,45 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
* Switching to a session that already has subagents never triggers — its
* baseline starts at the current count — so a deliberate layout is never
* fought.
*
* The decision is DEBOUNCED (AUTO_OPEN_DEBOUNCE_MS): a Side Chat thread
* is also a subagent-origin child, and its 'Side: ' title lands one frame
* after its origin — an immediate check would misread that first frame as
* a new subagent and pop this page on every thread creation. The timer
* re-evaluates the ORIGINAL baseline against the live snapshot; by then
* the title filter (isSideThreadSummary) sees the settled label.
*/
const listBaselineRef = useRef<SidebarSessionList | undefined>(undefined)
const autoOpenPendingRef = useRef<{ baseline: SidebarSessionList; timer: number } | null>(null)
useEffect(() => {
const prev = listBaselineRef.current
listBaselineRef.current = sessionList
if (sessionId === undefined || prev === undefined) return
if (autoOpenPendingRef.current !== null) return
if (!detectNewDirectSubagent(prev, sessionList, sessionId)) return
if (!store.getPrefs().autoOpenSubagent) return
if (ctx.betterSidebar?.isTabEnabled('subagent') === false) return
store.reduce(s => s.panelOpen ? s : togglePanel(s))
// Pin the landing to the right panel: the auto-opened Subagent page must
// appear where the panel just expanded, not in a bottom-panel pane the
// user last touched.
store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') })
const baseline = prev
const timer = window.setTimeout(() => {
autoOpenPendingRef.current = null
if (!detectNewDirectSubagent(baseline, ctx.sessions.list.getSnapshot(), sessionId)) return
if (!store.getPrefs().autoOpenSubagent) return
if (ctx.betterSidebar?.isTabEnabled('subagent') === false) return
store.reduce(s => s.panelOpen ? s : togglePanel(s))
// Pin the landing to the right panel: the auto-opened Subagent page must
// appear where the panel just expanded, not in a bottom-panel pane the
// user last touched.
store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') })
}, AUTO_OPEN_DEBOUNCE_MS)
autoOpenPendingRef.current = { baseline, timer }
}, [sessionList, sessionId, store, ctx])
// A session switch (or unmount) voids any armed auto-open recheck.
useEffect(() => () => {
const pending = autoOpenPendingRef.current
if (pending !== null) window.clearTimeout(pending.timer)
autoOpenPendingRef.current = null
}, [sessionId])
/**
* Job auto-activation: the moment a NEW background job appears for the
* current conversation (a job id the previous snapshot lacked), the
@@ -482,7 +529,16 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// horizontal edges — including the animated margin-right push while the
// right panel opens/closes; a frame that never appears keeps the initial
// zero-size fallback (the panel renders at 0 width until measured).
const [centerRect, setCenterRect] = useState({ left: 0, right: 0 })
// The rect lives in a REF (not state): the open/close transition resizes
// the center column EVERY frame for its duration, and reacting per frame
// with setState re-renders the whole Sidebar (every mounted tab) at
// animation cadence — the visible toggle jank (#315). measureCenter
// writes the bottom panel's edges directly (same DOM-write pattern as
// applyDrag), so the panel still tracks the column per frame with zero
// React work; `centerMeasured` flips ONCE to gate the hidden→visible
// first-paint fallback.
const centerRectRef = useRef({ left: 0, right: 0 })
const [centerMeasured, setCenterMeasured] = useState(false)
// Refs keep the measure step stable across renders and let it skip work
// mid-drag: during a width/corner drag the layout push resizes the center
// column every frame, and reacting (setCenterRect → re-render) would
@@ -503,13 +559,18 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
return
}
const rect = col.getBoundingClientRect()
// The bottom panel only cares about the horizontal edges: a pure height
// change (the bottom panel itself opening/closing) must not re-render,
// so keep the previous object when left/right are unchanged.
setCenterRect(prev =>
prev.left === rect.left && prev.right === rect.right
? prev
: { left: rect.left, right: rect.right })
// Ref + direct DOM write (see the centerRectRef comment): the bottom
// panel keeps tracking the center column per frame during the right
// panel's open/close animation without re-rendering the shell. The
// one-shot measured flip renders the panel visible once (a stale
// {0,0} fallback would flash full-width).
centerRectRef.current = { left: rect.left, right: rect.right }
const bottom = bottomRef.current
if (bottom !== null) {
bottom.style.setProperty('left', `${rect.left}px`)
bottom.style.setProperty('right', `${window.innerWidth - rect.right}px`)
}
setCenterMeasured(prev => (prev ? prev : true))
}, [])
useEffect(() => {
let disposed = false
@@ -556,6 +617,13 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
let locateFrame: number | null = null
const scheduleLocate = (): void => {
if (locateFrame !== null) return
// Mid-drag every frame writes --dsh-sidebar-* on <html>'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
// frame's budget (#315). The 1.5s retry below still covers any node
// swap that somehow lands mid-drag.
if (draggingRef.current) return
locateFrame = requestAnimationFrame(() => {
locateFrame = null
locate()
@@ -693,7 +761,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// width (innerWidth - state.width - detailsWidth), so this equals
// `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`)
bottomRef.current?.style.setProperty('right', `${(window.innerWidth - centerRectRef.current.right) + (width - (state?.width ?? 0))}px`)
const bottomPush = !narrow && state?.bottomOpen === true ? height + keyboardInset : 0
writeGeometry(width, bottomPush)
}
@@ -1040,6 +1108,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
const renderTab = (tab: SidebarTab, active: boolean, paneId: string, bottom = false) => (
<TabContent
tab={tab}
paneId={paneId}
sessionId={sessionId}
cwd={cwd}
expanded={state.expanded}
@@ -1050,6 +1119,8 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
visible={bottom ? state.bottomOpen && active : state.panelOpen && active}
onSubagentJump={(childSessionId) => { subagentJumpRef.current = childSessionId }}
onOpenDiff={(diffTab) => { store.reduce(s => openDiffTab(s, paneId, diffTab)) }}
localeRevision={localeRevision}
tabsVersion={tabsVersion}
/>
)
@@ -1238,7 +1309,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
data-dsh-bottom-panel
style={{
height: bottomPanelHeight,
left: centerRect.left,
left: centerRectRef.current.left,
// Keep the panel above the on-screen keyboard when the visual
// viewport shrinks (see the keyboardInset effect).
bottom: keyboardInset > 0 ? `${keyboardInset}px` : undefined,
@@ -1247,14 +1318,14 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// details column's left edge (the details column sits between the
// center and the right panel, and the right panel's margin-right
// push is already baked into centerRect.right).
right: window.innerWidth - centerRect.right,
right: window.innerWidth - centerRectRef.current.right,
// The seam against the open right panel needs its own hairline
// (the right panel's border-left alone is covered by this panel's
// fill — without it the corner looks cut off).
borderRight: state.panelOpen ? '1px solid var(--dsw-alias-border-l2)' : undefined,
// Unmeasured center column → keep the panel invisible (zero-size
// geometry would flash full-width overflow instead).
visibility: centerRect.right > 0 ? undefined : 'hidden',
visibility: centerMeasured ? undefined : 'hidden',
}}
data-dragging={(draggingBottom || draggingCorner) || undefined}
+58 -2
View File
@@ -3,7 +3,9 @@
* overflow scrolls horizontally, a close button per tab, a four-way split
* button cluster, and the + menu that opens new tabs (explorer / git /
* terminal). Tabs are draggable; dropping onto another tab inserts before it,
* dropping on the strip background appends to this pane.
* dropping on the strip background appends to this pane. Right-clicking a
* tab opens the tab context menu (close / close others / close to the left /
* close to the right, all scoped to this pane).
*/
import { useEffect, useRef, useState, type ReactNode } from 'react'
import clsx from 'clsx'
@@ -71,8 +73,14 @@ export function TabBar(props: {
paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, getTabIcon, getTabBadge,
} = props
const [menuOpen, setMenuOpen] = useState(false)
// The tab right-click context menu: the target tab plus the cursor
// position (the portaled Menu anchors there, following GitView/FileTree).
const [tabMenu, setTabMenu] = useState<{ tabId: string; x: number; y: number } | null>(null)
const [dragOver, setDragOver] = useState(false)
const listRef = useRef<HTMLDivElement>(null)
// The context target's index in the render-time tab snapshot; -1 when the
// tab disappeared since the menu opened (the menu hides then).
const tabMenuIndex = tabMenu === null ? -1 : tabs.findIndex(tab => tab.id === tabMenu.tabId)
// Middle-click close: the press target is recorded on middle mousedown
// (preventDefaulted to disarm Chrome's middle-click autoscroll — its
@@ -194,6 +202,14 @@ export function TabBar(props: {
middlePressed.current = { id: tab.id, node: event.currentTarget }
}
}}
onContextMenu={(event) => {
// Take over the browser menu: the tab context menu offers the
// close operations for this pane. Opening it also dismisses
// the + menu (only one menu at a time).
event.preventDefault()
setMenuOpen(false)
setTabMenu({ tabId: tab.id, x: event.clientX, y: event.clientY })
}}
>
{getTabIcon?.(tab) ?? null}
{getTabBadge?.(tab) ?? null}
@@ -237,12 +253,52 @@ export function TabBar(props: {
className={css.tabBarPlus}
aria-label={t('newTab')}
title={t('newTab')}
onClick={() => { setMenuOpen(v => !v) }}
onClick={() => { setMenuOpen(v => !v); setTabMenu(null) }}
>
<IconPlusOutline16 />
</button>
)}
/>
{/*
The tab context menu, positioned at the right-click cursor (portal
so the panel's overflow clip cannot crop it). Close operations are
scoped to THIS pane: "close others/left/right" walk the render-time
tab snapshot and reuse the per-tab onClose path (which routes
through the service and releases terminals), so the target tab is
never closed and the pane never empties mid-loop.
*/}
<Menu
open={tabMenu !== null && tabMenuIndex >= 0}
onClose={() => { setTabMenu(null) }}
items={[
{ id: 'close', label: t('close') },
{ id: 'closeOthers', label: t('closeOtherTabs'), ...(tabs.length <= 1 ? { disabled: true } : {}) },
{ id: 'closeLeft', label: t('closeLeftTabs'), ...(tabMenuIndex <= 0 ? { disabled: true } : {}) },
{ id: 'closeRight', label: t('closeRightTabs'), ...(tabMenuIndex >= tabs.length - 1 ? { disabled: true } : {}) },
]}
onSelect={(id) => {
const target = tabMenu
if (target === null) return
setTabMenu(null)
const index = tabs.findIndex(tab => tab.id === target.tabId)
if (index < 0) return
if (id === 'close') {
onClose(target.tabId)
} else if (id === 'closeOthers') {
for (const tab of tabs) {
if (tab.id !== target.tabId) onClose(tab.id)
}
} else if (id === 'closeLeft') {
for (const tab of tabs.slice(0, index)) onClose(tab.id)
} else if (id === 'closeRight') {
for (const tab of tabs.slice(index + 1)) onClose(tab.id)
}
}}
portal
align="start"
getAnchorRect={() => (tabMenu === null ? null : new DOMRect(tabMenu.x, tabMenu.y, 0, 0))}
anchor={<span />}
/>
</div>
</div>
)
+38 -13
View File
@@ -8,16 +8,28 @@
* retry, and repeated unreasoned failures surface the close code after three
* attempts, so the banner never spins forever.
*
* Three control frames shape the pty lifecycle on unmount:
* - `{type:'close'}` — the user closed the tab. The host kills the pty
* immediately (quota released).
* - `{type:'park'}` — the user switched to another conversation. The tab is
* still open in its session's persisted state but its view unmounted; the
* host keeps the pty alive indefinitely (no grace countdown), so switching
* back reattaches the same shell instead of respawning one.
* - bare socket drop (no frame) — page refresh, crash, plugin teardown, or a
* same-session re-render. The host's reconnect grace keeps the shell alive
* for a quick reconnect.
*
* Two attach modes share one upgrade endpoint:
* - `tabId` starting with `agent:` is an agent-owned terminal (created by
* the `terminal_create` tool). The uuid is the suffix after `agent:`; the
* view connects with `?uuid=...`. A close frame kills the pty (the agent's
* terminal closes when the user closes the tab); a bare socket drop
* leaves the pty alive (the agent owns the lifetime).
* leaves the pty alive (the agent owns the lifetime) — agent terminals
* never send park (their lifetime is already indefinite on bare drop).
* - Any other `tabId` is a UI-tab terminal (the user created it from the +
* menu). The view connects with `?tab=...&sessionId=...&cwd=...`. A close
* frame schedules a 0-ms close; a bare socket drop gets the host's
* reconnect grace.
* frame schedules a 0-ms close; a park frame marks the pty as parked; a
* bare socket drop gets the host's reconnect grace.
*/
import { useEffect, useRef, useState } from 'react'
import { Terminal, type ITheme } from '@xterm/xterm'
@@ -274,18 +286,31 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
fontSub()
schemeSub()
inputSub.dispose()
// The close frame tells the host the owning tab is GONE (immediate
// pty release). A bare unmount — conversation switch, re-render,
// page unload — leaves the tab open, so the socket drop alone hands
// the process to the host's reconnect grace: switching back or
// refreshing reattaches the SAME shell instead of respawning one.
// (The host respawns on its own when the authoritative cwd changed.)
// Agent terminals follow the same rule: a close frame kills the pty
// (the user closed the sidebar tab); a bare socket drop leaves it
// alive (the agent owns the lifetime).
if (!store.tabOpen(scope.sessionId, tabId)
// Three unmount cases, distinguished by the store's tab/open state and
// the active session id:
// 1. The tab was closed by the user (NOT in its session's state): send
// `{type:'close'}` — the host releases the pty immediately.
// 2. The user switched to another conversation (the tab IS still open
// in scope.sessionId's state, but the active session is now a
// different one): send `{type:'park'}` — the host keeps the pty
// alive indefinitely (no grace countdown), so switching back
// reattaches the SAME shell. Without this, the bare socket drop
// would start the 30s reconnect-grace countdown and kill the shell
// while the user is still actively working in the other session.
// 3. A same-session unmount (page refresh, crash, plugin teardown, a
// re-render that re-mounts the view): bare socket drop — the host's
// reconnect grace keeps the shell alive for a quick reconnect.
// Agent terminals follow the close-frame rule; their lifetime is owned
// by the agent, so a bare drop (case 3) already leaves them alive
// indefinitely — no park frame needed.
const tabStillOpen = store.tabOpen(scope.sessionId, tabId)
const sessionSwitched = store.getSnapshot().sessionId !== scope.sessionId
if (!tabStillOpen
&& socket !== null && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'close' }))
} else if (tabStillOpen && sessionSwitched && !isAgentTabId(tabId)
&& socket !== null && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'park' }))
}
socket?.close()
term.dispose()
+14 -1
View File
@@ -21,6 +21,7 @@ import { IconFolderOpen16, IconRefreshOutline16 } from '@deepseek-ai/dsh-client-
import { api } from './api.ts'
import { FileTree } from './FileTree.tsx'
import { IconUploadOutline16 } from './icons.tsx'
import type { OpenWithTarget } from './open-with.ts'
import { t } from './locales.ts'
import { resolveSidebarPath } from './produced-files.ts'
import { UploadOverlay } from './UploadOverlay.tsx'
@@ -50,12 +51,19 @@ export function TreePanel(props: {
onOpenFileNewTab?: (path: string) => void
/** File context-menu "open to the side" (passed through to FileTree). */
onOpenFileSide?: (path: string) => void
/** The "open with" menu surface (passed through to FileTree; absent →
* the whole section is hidden). */
openWithTargets?: OpenWithTarget[]
openWithPinned?: string[]
openWithSsh?: boolean
onOpenWith?: (targetId: string, path: string) => void
onToggleOpenWithPin?: (targetId: string) => void
onReferenceFile: (path: string) => void
/** Full-window presentation: the panel fills its host instead of docking
* at a fixed width. */
full?: boolean
}) {
const { sessionId, cwd, expanded, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, onReferenceFile, full } = props
const { sessionId, cwd, expanded, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, full } = props
const [query, setQuery] = useState('')
const [results, setResults] = useState<{ matches: string[]; truncated: boolean } | null>(null)
const [error, setError] = useState<string | null>(null)
@@ -217,6 +225,11 @@ export function TreePanel(props: {
onOpenFile={onOpenFile}
onOpenFileNewTab={onOpenFileNewTab}
onOpenFileSide={onOpenFileSide}
openWithTargets={openWithTargets}
openWithPinned={openWithPinned}
openWithSsh={openWithSsh}
onOpenWith={onOpenWith}
onToggleOpenWithPin={onToggleOpenWithPin}
onReferenceFile={onReferenceFile}
refreshTick={refreshTick}
onUploadRequest={startUpload}
+6
View File
@@ -289,6 +289,12 @@ export const api = {
* check; see the host's browser.probe route). */
browserProbe: (url: string, signal?: AbortSignal) =>
call<BrowserProbeResult>('browser.probe', { url }, signal),
/** External open for the file tree's "open with" menu: reveal a path in
* the OS file manager, or hand a custom-scheme URL (vscode://, cursor://,
* zed://, custom editors) to its registered handler. The host launches
* the platform opener (argv, no shell). */
openExternal: (payload: { action: 'reveal'; path: string } | { action: 'url'; url: string }) =>
call<{ started: boolean }>('open.external', payload),
}
/** Absolute URL of the media route for one path (images only). */
+7 -1
View File
@@ -13,6 +13,7 @@ import { allLeaves, isAgentTabId, type SidebarState } from '../state.ts'
import { t } from '../locales.ts'
import { openSidebarFile } from '../intercept.tsx'
import { EditorHost } from '../EditorHost.tsx'
import { OpenWithSettings } from '../open-with-settings.tsx'
import { lazyChunkComponent } from '../lazy-chunk.tsx'
import { GitView } from '../GitView.tsx'
import { DiffTab } from '../DiffTab.tsx'
@@ -90,7 +91,9 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
dedupeKey: (tab) => tab.path,
// Declarative settings: the file-open behavior picker (in-place switch
// vs per-path windows) renders as an iconed select row under the
// editor card's gear in the Side card settings page.
// editor card's gear in the Side card settings page; the "open with"
// configuration (SSH host + custom editors) is the custom panel BELOW
// those rows — the settings seam renders rows first, custom panel after.
settings: {
toggles: [{
key: 'editorExplorer',
@@ -112,6 +115,9 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
},
],
}],
render: ({ pluginSettings, updatePluginSetting }) => (
<OpenWithSettings pluginSettings={pluginSettings} updatePluginSetting={updatePluginSetting} />
),
},
component: ({ ctx, store, scope, tab, expanded, onToggleDir, onReferenceFile }) => (
<EditorHost
+56
View File
@@ -0,0 +1,56 @@
/**
* Per-frame task coalescing for pointer-driven interactions.
*
* Pointer streams fire several times faster than the display refresh, and
* applying every event (a React setState, a store reduce) re-renders whole
* trees at event cadence — the visible drag lag on slower CPUs (#315). This
* batcher keeps the LATEST scheduled task, runs it at most once per
* animation frame, and lets the caller flush synchronously on release (so
* the final pointer position is never lost to a cancelled frame).
*
* Callers compose the pending value in a ref before scheduling; the
* scheduled task reads that ref, so replacing the task on every event is
* cheap (one ref write + one rAF check).
*/
export interface FrameBatcher {
/** Schedule (or replace) the per-frame task. Safe to call on every event. */
schedule(task: () => void): void
/** Cancel any pending frame and run the scheduled task synchronously
* (no-op when nothing is scheduled). */
flushNow(): void
/** Cancel any pending frame and drop the task without running it. */
dispose(): void
}
export function createFrameBatcher(): FrameBatcher {
let frame: number | null = null
let task: (() => void) | null = null
const run = (): void => {
frame = null
const current = task
task = null
current?.()
}
return {
schedule(next) {
task = next
if (frame === null) frame = requestAnimationFrame(run)
},
flushNow() {
if (frame !== null) {
cancelAnimationFrame(frame)
frame = null
}
run()
},
dispose() {
if (frame !== null) {
cancelAnimationFrame(frame)
frame = null
}
task = null
},
}
}
+13
View File
@@ -137,3 +137,16 @@ export const IconSaveOutline16 = ({ size = 16, className }: IconProps) => (
<path d="M5.4 14.5v-4.2h5.2v4.2" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
</svg>
)
/**
* Visual Studio Code brand mark for the file-tree "open with" menu. The
* path is the Simple Icons `visualstudiocode` glyph (CC0 1.0,
* simple-icons@11.0.0 — later releases dropped it over Microsoft's brand
* policy, so it is inlined here rather than pulled from react-icons),
* rendered monochrome via currentColor to follow the active skin.
*/
export const IconVscode16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M23.15 2.587L18.21.21a1.494 1.494 0 0 0-1.705.29l-9.46 8.63-4.12-3.128a.999.999 0 0 0-1.276.057L.327 7.261A1 1 0 0 0 .326 8.74L3.899 12 .326 15.26a1 1 0 0 0 .001 1.479L1.65 17.94a.999.999 0 0 0 1.276.057l4.12-3.128 9.46 8.63a1.492 1.492 0 0 0 1.704.29l4.942-2.377A1.5 1.5 0 0 0 24 20.06V3.939a1.5 1.5 0 0 0-.85-1.352zm-5.146 14.861L10.826 12l7.178-5.448v10.896z" />
</svg>
)
+48
View File
@@ -30,6 +30,26 @@ export const zh = {
editorEmptyHint: '从右侧文件树或上方路径输入框选择文件开始预览',
openFileNewTab: '在新 Tab 中打开',
openFileSide: '在侧边打开',
openWithMenu: '在应用中打开',
openWithSshSuffix: ' (SSH)',
pinOpenWith: '固定到菜单',
unpinOpenWith: '取消固定',
openWithExplorer: '资源管理器',
openWithVscode: 'VS Code',
openWithCursor: 'Cursor',
openWithZed: 'Zed',
openWithSettingsSshTitle: 'SSH 远端主机',
openWithSettingsSshDesc: '留空为本地工作区;填入 user@host 或 SSH 别名后,VSCode 系打开方式将改用 vscode-remote/ssh-remote 协议,资源管理器 / Zed / 非 VSCode 系自定义编辑器将从菜单隐藏',
openWithSettingsSshPlaceholder: 'user@host 或 SSH 别名',
openWithSettingsCustomTitle: '自定义编辑器',
openWithSettingsCustomDesc: '名称 + URL 模板({path} 占位符)+ 是否 VSCode 系;SSH 模式下仅 VSCode 系可打开远端',
openWithSettingsAdd: '添加',
openWithSettingsName: '名称',
openWithSettingsTemplate: '如 cursor://file/{path}',
openWithSettingsFamily: 'VSCode 系',
openWithSettingsFamilyDesc: '该编辑器使用 VSCode 的 URL 协议(支持 SSH 远端打开)',
openWithSettingsRemove: '删除',
openWithSettingsInvalidHint: '名称或模板(需含 {path} 且以 scheme:// 开头)未填写的编辑器不会出现在菜单中',
newTab: '新建标签页',
openExplorer: '资源管理器',
brokenSymlink: '失效的软链接',
@@ -37,6 +57,9 @@ export const zh = {
newTerminal: '新终端',
terminalLimit: '终端数量已达上限 (3)',
close: '关闭',
closeOtherTabs: '关闭其他页签',
closeLeftTabs: '关闭左侧页签',
closeRightTabs: '关闭右侧页签',
collapse: '折叠侧边栏',
expand: '展开侧边栏',
collapseBottomPanel: '折叠底部面板',
@@ -264,6 +287,7 @@ export const zh = {
sideChatComposerPlaceholder: '追问…',
sideChatThinking: '正在深入…',
sideChatThink: '思考过程',
sideChatInjection: '已注入上下文',
sideChatSend: '发送',
sideChatCancel: '停止',
sideChatCancelTitle: '中止当前回合(保留队列)',
@@ -336,6 +360,26 @@ export const en: Record<keyof typeof zh, string> = {
editorEmptyHint: 'Pick a file from the tree panel or the path input above to start previewing',
openFileNewTab: 'Open in New Tab',
openFileSide: 'Open to the Side',
openWithMenu: 'Open with',
openWithSshSuffix: ' (SSH)',
pinOpenWith: 'Pin to menu',
unpinOpenWith: 'Unpin',
openWithExplorer: 'File Manager',
openWithVscode: 'VS Code',
openWithCursor: 'Cursor',
openWithZed: 'Zed',
openWithSettingsSshTitle: 'SSH remote host',
openWithSettingsSshDesc: 'Empty = local workspace; with a user@host or SSH alias, VSCode-family openers switch to the vscode-remote/ssh-remote protocol and the File Manager / Zed / non-VSCode-family custom editors are hidden from the menu',
openWithSettingsSshPlaceholder: 'user@host or SSH alias',
openWithSettingsCustomTitle: 'Custom editors',
openWithSettingsCustomDesc: 'Name + URL template ({path} placeholder) + VSCode-family flag; in remote mode only VSCode-family editors can open a remote path',
openWithSettingsAdd: 'Add',
openWithSettingsName: 'Name',
openWithSettingsTemplate: 'e.g. cursor://file/{path}',
openWithSettingsFamily: 'VSCode-family',
openWithSettingsFamilyDesc: 'This editor speaks the VSCode URL dialect (supports SSH-remote opens)',
openWithSettingsRemove: 'Remove',
openWithSettingsInvalidHint: 'Editors with a missing name or a template without {path} / scheme:// are not shown in the menu',
newTab: 'New tab',
openExplorer: 'Explorer',
brokenSymlink: 'Broken symlink',
@@ -343,6 +387,9 @@ export const en: Record<keyof typeof zh, string> = {
newTerminal: 'New terminal',
terminalLimit: 'Terminal limit reached (3)',
close: 'Close',
closeOtherTabs: 'Close Other Tabs',
closeLeftTabs: 'Close Tabs to the Left',
closeRightTabs: 'Close Tabs to the Right',
collapse: 'Collapse sidebar',
expand: 'Expand sidebar',
collapseBottomPanel: 'Collapse bottom panel',
@@ -570,6 +617,7 @@ export const en: Record<keyof typeof zh, string> = {
sideChatComposerPlaceholder: 'Ask a follow-up…',
sideChatThinking: 'Deep diving…',
sideChatThink: 'Thinking',
sideChatInjection: 'Context injected',
sideChatSend: 'Send',
sideChatCancel: 'Stop',
sideChatCancelTitle: 'Abort the running turn (queued work is kept)',
+135
View File
@@ -0,0 +1,135 @@
/**
* The editor tab's custom settings panel ("打开方式"): the file tree's
* "open with" configuration — the optional SSH host marking the workspace as
* remote, and the user-defined editors (name + URL template with `{path}` +
* whether they speak the VSCode URL dialect). Persisted as the editor
* blob's `openWith` key through the settings popup's `updatePluginSetting`.
*
* The popup renders the declarative rows (the editorExplorer picker) ABOVE
* this panel — SettingsBody renders the custom panel after the row list, so
* this component owns only its own section.
*/
import { useState } from 'react'
import { IconCloseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import {
isValidCustomEditor,
newCustomEditorId,
parseOpenWithConfig,
type CustomEditor,
type OpenWithConfig,
} from './open-with.ts'
import { t } from './locales.ts'
import css from './SideCardSection.module.css'
export function OpenWithSettings(props: {
pluginSettings: Record<string, unknown>
updatePluginSetting: (key: string, value: unknown) => void
}) {
const { pluginSettings, updatePluginSetting } = props
// Local draft seeded from the persisted blob (each commit re-renders the
// popup with the round-tripped prefs; the local state stays authoritative
// while the popup is open, so typing never fights an in-flight write).
const [draft, setDraft] = useState<OpenWithConfig>(() => parseOpenWithConfig(pluginSettings.openWith))
const commit = (next: OpenWithConfig): void => {
setDraft(next)
updatePluginSetting('openWith', next)
}
const setSshHost = (sshHost: string): void => commit({ ...draft, sshHost })
const patchCustom = (id: string, patch: Partial<CustomEditor>): void => {
commit({
...draft,
customEditors: draft.customEditors.map(editor => editor.id === id ? { ...editor, ...patch } : editor),
})
}
const removeCustom = (id: string): void => {
commit({
...draft,
customEditors: draft.customEditors.filter(editor => editor.id !== id),
// A removed editor can never stay pinned (the menu prunes unknown ids
// anyway; this keeps the blob clean).
pinned: draft.pinned.filter(pinnedId => pinnedId !== `custom:${id}`),
})
}
const addCustom = (): void => {
commit({
...draft,
customEditors: [...draft.customEditors, { id: newCustomEditorId(), name: '', urlTemplate: '', isVscodeFamily: false }],
})
}
const hasInvalid = draft.customEditors.some(editor => !isValidCustomEditor(editor))
return (
<div className={css.popupRows}>
<div className={css.popupRow}>
<span className={css.rowText}>
<span className={css.title}>{t('openWithSettingsSshTitle')}</span>
<span className={css.desc}>{t('openWithSettingsSshDesc')}</span>
</span>
<input
className={css.typedInput}
value={draft.sshHost}
placeholder={t('openWithSettingsSshPlaceholder')}
spellCheck={false}
onChange={(event) => { setSshHost(event.target.value) }}
/>
</div>
<div className={css.popupRow}>
<span className={css.rowText}>
<span className={css.title}>{t('openWithSettingsCustomTitle')}</span>
<span className={css.desc}>{t('openWithSettingsCustomDesc')}</span>
</span>
<span className={css.control}>
<button type="button" className={css.done} onClick={addCustom}>
{t('openWithSettingsAdd')}
</button>
</span>
</div>
{draft.customEditors.map(editor => (
<div key={editor.id} className={css.openWithEditorRow}>
<input
className={css.openWithEditorInput}
value={editor.name}
placeholder={t('openWithSettingsName')}
spellCheck={false}
onChange={(event) => { patchCustom(editor.id, { name: event.target.value }) }}
/>
<input
className={css.openWithEditorTemplate}
value={editor.urlTemplate}
placeholder={t('openWithSettingsTemplate')}
spellCheck={false}
onChange={(event) => { patchCustom(editor.id, { urlTemplate: event.target.value }) }}
/>
<label className={css.openWithFamily} title={t('openWithSettingsFamilyDesc')}>
<input
type="checkbox"
checked={editor.isVscodeFamily}
onChange={(event) => { patchCustom(editor.id, { isVscodeFamily: event.currentTarget.checked }) }}
/>
<span>{t('openWithSettingsFamily')}</span>
</label>
<button
type="button"
className={css.openWithRemove}
aria-label={t('openWithSettingsRemove')}
title={t('openWithSettingsRemove')}
onClick={() => { removeCustom(editor.id) }}
>
<IconCloseOutline16 size={14} />
</button>
</div>
))}
{hasInvalid && (
<div className={css.openWithHint} role="note">
{t('openWithSettingsInvalidHint')}
</div>
)}
</div>
)
}
+221
View File
@@ -0,0 +1,221 @@
/**
* The file tree's "open with" vocabulary: which external editors / file
* managers the context menu can hand a row's path to, how the per-user
* configuration (custom editors, SSH host, pinned ids) is parsed out of the
* persisted blob, and how the external URL is built (local `file` URLs and
* VSCode-family SSH-remote URLs).
*
* Pure by design (no React / no `api`), so the whole surface is unit-testable
* without a DOM or a host route. The menu and the settings panel only consume
* the exported types and functions.
*/
import type { CopyKey } from './locales.ts'
/** One user-configured editor (persisted in `pluginSettings['editor'].openWith`). */
export interface CustomEditor {
id: string
/** Human label shown in the menu (e.g. "Windsurf"). */
name: string
/** URL template with a `{path}` placeholder (e.g. `cursor://file/{path}`). */
urlTemplate: string
/** Whether the editor speaks the VSCode URL dialect — the only editors
* with an SSH-remote form (`<scheme>://vscode-remote/ssh-remote+<host>/…`). */
isVscodeFamily: boolean
}
/** The per-user "open with" configuration (one key of the editor blob). */
export interface OpenWithConfig {
/** SSH host (user@host or an ~/.ssh/config alias); '' = local workspace. */
sshHost: string
/** User-defined editors, appended after the built-ins. */
customEditors: CustomEditor[]
/** Ids of open-with targets pinned to the menu's top level
* ('explorer' | 'vscode' | 'cursor' | 'zed' | 'custom:<id>'). */
pinned: string[]
}
/** One menu-visible open target (built-in or custom, SSH-filtered). */
export interface OpenWithTarget {
/** Stable id (`explorer` / `vscode` / `cursor` / `zed` / `custom:<id>`). */
id: string
/** Locale key of a built-in label (custom editors carry `name` instead). */
nameKey?: CopyKey
/** User-defined label (custom editors only; '' for built-ins). */
name: string
/** 'reveal' = show in the OS file manager; 'url' = open a URL. */
kind: 'reveal' | 'url'
/** URL template with `{path}`; undefined for reveal targets. */
urlTemplate?: string
/** Whether the editor talks the VSCode URL dialect. */
isVscodeFamily: boolean
/** Hidden in SSH mode (a host-local opener cannot reach a remote path). */
localOnly: boolean
}
/** The default open-with configuration (fresh documents). */
export const OPEN_WITH_DEFAULTS: OpenWithConfig = {
sshHost: '',
customEditors: [],
pinned: [],
}
/** The built-in open targets, in menu order. */
export const OPEN_WITH_BUILTINS: readonly OpenWithTarget[] = [
{
id: 'explorer',
nameKey: 'openWithExplorer',
name: '',
kind: 'reveal',
isVscodeFamily: false,
localOnly: true,
},
{
id: 'vscode',
nameKey: 'openWithVscode',
name: '',
kind: 'url',
urlTemplate: 'vscode://file/{path}',
isVscodeFamily: true,
localOnly: false,
},
{
id: 'cursor',
nameKey: 'openWithCursor',
name: '',
kind: 'url',
urlTemplate: 'cursor://file/{path}',
isVscodeFamily: true,
localOnly: false,
},
{
id: 'zed',
nameKey: 'openWithZed',
name: '',
kind: 'url',
urlTemplate: 'zed://file/{path}',
isVscodeFamily: false,
localOnly: true,
},
]
/** Whether a persisted value makes a structurally valid custom-editor row.
* Name/template may be empty — the settings panel edits rows in place and
* an in-progress row must survive the round-trip; the MENU hides rows that
* fail the stricter {@link isValidCustomEditor} check. */
function isCustomEditor(value: unknown): value is CustomEditor {
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false
const record = value as Record<string, unknown>
return typeof record.id === 'string' && record.id !== ''
&& typeof record.name === 'string'
&& typeof record.urlTemplate === 'string'
&& typeof record.isVscodeFamily === 'boolean'
}
/**
* Parse the persisted `openWith` blob (tolerant): malformed fields fall back
* to the defaults, malformed custom-editor rows are dropped, and pinned ids
* are kept verbatim (unknown ids are pruned when the targets are resolved —
* the menu is the only consumer of the resolved list).
*/
export function parseOpenWithConfig(raw: unknown): OpenWithConfig {
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return { ...OPEN_WITH_DEFAULTS }
const record = raw as Record<string, unknown>
const sshHost = typeof record.sshHost === 'string' ? record.sshHost : ''
const customEditors = Array.isArray(record.customEditors)
? record.customEditors.filter(isCustomEditor)
: []
const pinned = Array.isArray(record.pinned)
? record.pinned.filter((id): id is string => typeof id === 'string' && id !== '')
: []
return { sshHost, customEditors, pinned }
}
/** Whether a custom editor id belongs to this config (id prefix match). */
function customIdOf(id: string): string {
return `custom:${id}`
}
/**
* The menu-visible open targets, in order (built-ins then custom editors).
* In SSH mode the local-only targets (the OS file manager, Zed, custom
* editors without the VSCode dialect) are dropped — they cannot reach a
* remote path. Unknown pinned ids are pruned here too.
*/
export function resolveOpenWithTargets(config: OpenWithConfig): OpenWithTarget[] {
const ssh = config.sshHost.trim() !== ''
const targets: OpenWithTarget[] = [
...OPEN_WITH_BUILTINS,
...config.customEditors
.filter(isValidCustomEditor)
.map((editor): OpenWithTarget => ({
id: customIdOf(editor.id),
name: editor.name,
kind: 'url',
urlTemplate: editor.urlTemplate,
isVscodeFamily: editor.isVscodeFamily,
localOnly: !editor.isVscodeFamily,
})),
]
return targets.filter(target => !(ssh && target.localOnly))
}
/** The SSH hint appended to a target's label in remote mode. */
export function openWithSshActive(config: OpenWithConfig): boolean {
return config.sshHost.trim() !== ''
}
/**
* The URL to open for one resolved target, or undefined when the target has
* no URL form (reveal) or the template is malformed. The path is inserted
* RAW into the template (browsers percent-encode as needed; VSCode-family
* URL parsers consume the absolute path with its leading slash, e.g.
* `vscode://file//home/u/f.ts` or `vscode://file/C:/Users/u/f.ts`).
*/
export function openWithUrl(target: OpenWithTarget, path: string, config: OpenWithConfig): string | undefined {
if (target.kind !== 'url' || target.urlTemplate === undefined) return undefined
const normalized = normalizeUrlPath(path)
const ssh = openWithSshActive(config)
if (ssh && target.isVscodeFamily) {
const scheme = schemeOf(target.urlTemplate)
if (scheme === undefined) return undefined
// `ssh-remote+<host>` owns NO slash of its own: the path keeps its
// leading slash, so `/home/u/f.ts` lands as `…+host/home/u/f.ts`.
return `${scheme}://vscode-remote/ssh-remote+${config.sshHost.trim()}${normalized}`
}
if (!target.urlTemplate.includes('{path}') || !hasUrlScheme(target.urlTemplate)) return undefined
return target.urlTemplate.replace('{path}', normalized)
}
/** Whether a template starts with a `scheme://` prefix (the only shape the
* host's external opener accepts and the settings panel suggests). */
function hasUrlScheme(template: string): boolean {
return /^[a-z][a-z0-9+.-]*:\/\//i.test(template)
}
/** The scheme of a URL template (the part before the first ':'), or undefined. */
function schemeOf(template: string): string | undefined {
const at = template.indexOf(':')
if (at <= 0) return undefined
const scheme = template.slice(0, at)
return /^[a-z][a-z0-9+.-]*$/i.test(scheme) ? scheme : undefined
}
/** Normalize a filesystem path for embedding in a URL (backslashes → '/'). */
export function normalizeUrlPath(path: string): string {
return path.replace(/\\/g, '/')
}
/** A fresh custom-editor id (uuid when available, time-based fallback). */
export function newCustomEditorId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`
}
/** Validate one custom-editor row before the settings panel accepts it. */
export function isValidCustomEditor(row: { name: string; urlTemplate: string }): boolean {
return row.name.trim() !== ''
&& row.urlTemplate.includes('{path}')
&& /^[a-z][a-z0-9+.-]*:\/\//i.test(row.urlTemplate.trim())
}
+43
View File
@@ -0,0 +1,43 @@
/**
* Pending-writes queue for the file tree's open-with config: pin toggles and
* (outside the settings popup) config edits land in the sidebar prefs as
* `pluginSettings['editor']`. Writes are serialized through one promise chain
* so a quick burst of pin clicks can never read a stale pluginSettings map
* and drop an earlier toggle; each write pushes the whole open map patch
* through the revision-free settings route and adopts the returned document.
*
* (The settings popup has its own serialized commit — SideCardSection's —
* so its rows and this helper rarely race; the shared route's last-write-wins
* semantics cover the uncommon overlap.)
*/
import { api } from './api.ts'
import { parsePrefs } from './prefs.ts'
import type { SidebarStore } from './state.ts'
let queue: Promise<void> = Promise.resolve()
/**
* Merge one plugin-owned settings blob of one descriptor and persist it.
* @param store - the sidebar store (its prefs are replaced by the write result).
* @param descriptorId - the descriptor whose blob is patched ('editor' here).
* @param updater - pure patch function; receives a shallow copy of the blob.
*/
export function updatePluginSettings(
store: SidebarStore,
descriptorId: string,
updater: (blob: Record<string, unknown>) => Record<string, unknown>,
): void {
queue = queue.then(async () => {
const prefs = store.getPrefs()
const blob = prefs.pluginSettings[descriptorId] ?? {}
const next = updater({ ...blob })
const view = await api.settingsUpdate({
pluginSettings: { ...prefs.pluginSettings, [descriptorId]: next },
})
store.setPrefs(parsePrefs(view.value))
}).catch((error: unknown) => {
// The pin stays visually unchanged (no optimistic flip) and the menu
// keeps working — the write failure is logged, not surfaced.
console.error('open-with settings write failed', error)
})
}
+1 -1
View File
@@ -470,7 +470,7 @@ export function matchUrlTarget(tabs: readonly TabDescriptor[], url: URL): TabDes
* The plugin version this service instance reports. Keep in lockstep with
* `package.json`'s version — `tests/service.spec.ts` asserts the pair.
*/
export const SIDEBAR_SERVICE_VERSION = '0.15.0'
export const SIDEBAR_SERVICE_VERSION = '0.15.2'
/**
* Monotonic capability list consumers use to gate new API usage (features
+76
View File
@@ -2132,10 +2132,34 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
/* One changed file's section: path header, hunk headers, aligned lines. */
.gitDiffFile {
width: 100%;
display: flex;
align-items: baseline;
gap: 6px;
padding: 8px 2px 2px;
border: 0;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.gitDiffFile:disabled {
cursor: default;
}
.gitDiffFile:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
}
.gitDiffFileChevron {
flex: none;
color: var(--dsw-alias-label-tertiary);
transform: rotate(0deg);
}
.gitDiffFileChevronExpanded {
transform: rotate(90deg);
}
.gitDiffFilePath {
@@ -2441,6 +2465,7 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
.gitCommitButton:focus-visible,
.gitLogRow:focus-visible,
.gitLogMore:focus-visible,
.gitDiffFile:focus-visible,
.gitDiffExpand:focus-visible,
.terminalRetry:focus-visible,
.editorModeButton:focus-visible,
@@ -2496,3 +2521,54 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
max-width: 128px;
}
}
/* The file tree "open with" menu rows: the label area is a BLOCK-level flex
row that fills the primitives `.itemLabel` span (which is itself `flex: 1`
inside the row), so the trailing control — the pushpin on submenu rows,
the chevron on the submenu parent — docks at the row's far right, exactly
like the trailing check on selected rows of every other menu. */
.openWithLabel {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-width: 0;
}
.openWithName {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* The trailing "expands into a submenu" chevron on the open-with parent row
(the primitives Menu renders no arrow for submenu parents). */
.openWithChevron {
flex: none;
color: var(--dsw-alias-label-tertiary);
}
/* The pushpin toggle on an "open with" submenu row: brand ink once the
target is pinned to the menu's top level. */
.openWithPin {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 6px;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.openWithPin:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.openWithPinActive {
color: var(--dsw-alias-state-business-primary);
}
+67 -10
View File
@@ -5,8 +5,9 @@
*
* A thread child's log starts with the ENTIRE inherited parent log as its
* fork seed. The mapping therefore cuts everything up to the LAST
* `session/end-seed` marker and drops the "Side conversation boundary"
* prompt row, so the view shows only the thread's own conversation.
* `session/end-seed` marker and maps context injections (the "Side
* conversation boundary" prompt, plugin-sourced context) onto a collapsible
* injection row, so the view shows only the thread's own conversation.
*
* Live streaming: `assistant/message` events only land when a step
* completes, but `assistant/chunk` events stream token-level text and
@@ -14,7 +15,7 @@
* them with the assembled message once it lands (settled rows).
*/
import type { SidebarHistoryEntry } from '../context-types.ts'
import { SIDE_BOUNDARY_PREFIX } from '../sidechat-core.ts'
import { isContextInjectionMessage, SIDE_BOUNDARY_PROMPT } from '../sidechat-core.ts'
/** One compact transcript row rendered in the thread view. `seq` is the
* source event's log sequence — stable row identity for React keys across
@@ -22,6 +23,10 @@ import { SIDE_BOUNDARY_PREFIX } from '../sidechat-core.ts'
* rows). */
export type SidechatTranscriptRow =
| { kind: 'user'; seq: number; text: string }
/** A context injection (the side boundary prompt + the parked in-progress
* snapshot, or any plugin-sourced context): rendered as one collapsible
* row, never as a user bubble. */
| { kind: 'injection'; seq: number; text: string }
/** `settled` distinguishes an assembled message from a still-streaming
* chunk accumulation (streaming rows are superseded by the settle). */
| { kind: 'assistant'; seq: number; text: string; settled: boolean }
@@ -54,6 +59,39 @@ export function blockText(content: readonly unknown[]): string {
return text === '' ? '…' : text
}
/** Cap for a tool row's one-line argument summary (display only). */
const ARGS_SUMMARY_MAX = 80
/** The most identifying argument keys, in priority order (bash's command,
* fs tools' paths, search's pattern, …). */
const ARGS_SUMMARY_KEYS = ['command', 'file_path', 'path', 'pattern', 'query', 'url', 'prompt'] as const
function flatTruncate(text: string): string {
const flat = text.replace(/\s+/g, ' ').trim()
return flat.length > ARGS_SUMMARY_MAX ? `${flat.slice(0, ARGS_SUMMARY_MAX - 1)}…` : flat
}
/**
* One-line summary of a tool call's raw arguments JSON for the collapsed
* row: the first identifying string field when the JSON parses, else the
* flattened raw text; empty when there is nothing worth showing.
*/
export function toolArgsSummary(args: string | undefined): string {
if (args === undefined) return ''
try {
const parsed = JSON.parse(args) as Record<string, unknown> | null
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
for (const key of ARGS_SUMMARY_KEYS) {
const value = parsed[key]
if (typeof value === 'string' && value.trim() !== '') return flatTruncate(value)
}
}
} catch {
// Raw text fallthrough.
}
return flatTruncate(args)
}
/** The plain text of a tool/result message (text blocks inside its
* `tool-result` content block). */
function resultTextOf(data: Record<string, unknown>): string {
@@ -141,12 +179,12 @@ export async function collectOwnEvents(
/**
* Map a thread child's history rows onto compact transcript rows: the
* inherited fork seed is cut at the last `session/end-seed`, the boundary
* prompt row is dropped, `assistant/chunk` deltas accumulate into streaming
* rows per (turn, step, block) and are superseded by the assembled
* `assistant/message`, and tool invocations render one expandable line each
* (arguments, paired result text, failure marker; a still-executing call is
* marked until its result lands).
* inherited fork seed is cut at the last `session/end-seed`, context
* injections map onto a collapsible injection row, `assistant/chunk`
* deltas accumulate into streaming rows per (turn, step, block) and are
* superseded by the assembled `assistant/message`, and tool invocations
* render one expandable line each (arguments, paired result text, failure
* marker; a still-executing call is marked until its result lands).
* @param entries - history rows (event + host-computed view) in seq order.
* @returns display rows in log order.
*/
@@ -166,7 +204,26 @@ export function transcriptRows(entries: readonly SidebarHistoryEntry[]): Sidecha
switch (event.type) {
case 'user/message': {
const text = blockText(Array.isArray(data.content) ? data.content : [])
if (text.startsWith(SIDE_BOUNDARY_PREFIX)) break
// Context injections (the boundary prompt + snapshot, plugin-sourced
// context) collapse into an injection row; genuine user messages —
// including the FIRST one, which the host now delivers as its own
// event — render as user rows.
if (isContextInjectionMessage(data)) {
const source = data.source as { kind?: unknown } | undefined
// Threads logged BEFORE the host split carry boundary(+snapshot)+
// question in ONE 'user' message. The boundary prompt is a known
// constant, so the message splits THERE: the injection row keeps
// the prompt, the remainder (snapshot + question if any — pure
// question in the common case) renders as the user's real message.
if (source?.kind === 'user' && text.startsWith(`${SIDE_BOUNDARY_PROMPT}\n\n`)) {
rows.push({ kind: 'injection', seq: event.seq, text: SIDE_BOUNDARY_PROMPT })
const body = text.slice(SIDE_BOUNDARY_PROMPT.length + 2)
if (body !== '') rows.push({ kind: 'user', seq: event.seq, text: body })
break
}
rows.push({ kind: 'injection', seq: event.seq, text })
break
}
rows.push({ kind: 'user', seq: event.seq, text })
break
}
+25 -2
View File
@@ -15,6 +15,7 @@ import clsx from 'clsx'
import type { SidebarState, SidebarTab, SplitNode } from './state.ts'
import type { DropZone } from './state.ts'
import { TabBar, type NewTabOption, parseDrag, type TabDragPayload } from './TabBar.tsx'
import { createFrameBatcher } from './frame-batcher.ts'
import css from './sidebar.module.css'
/** Actions the workbench needs (bound to the store by the sidebar shell). */
@@ -34,7 +35,18 @@ export interface WorkbenchActions {
* Deltas are incremental — each move reports the displacement since the
* previous move — because the store adds every reported delta to the pane
* sizes; a cumulative (since-pointer-down) delta would be re-added on each
* move and the divider would run away from the cursor. */
* move and the divider would run away from the cursor.
*
* The moves are BATCHED per frame (createFrameBatcher): a pointer stream
* fires faster than the display refresh, and applying each move is a store
* reduce that re-renders both workbenches (terminals, editors, trees) per
* event — the visible drag lag on slower CPUs (#315). The batch accumulates
* the incremental deltas in a ref and applies the summed fraction at most
* once per frame; the sum equals what the per-event application would have
* produced (the reducer clamps each application, and at a settled position
* a clamped sum is clamped to the same boundary), so the result is
* indistinguishable at rest and at most one frame behind the cursor.
*/
function Divider(props: { dir: 'row' | 'col'; onResize: (deltaFrac: number) => void }) {
const { dir, onResize } = props
// `last` is the previous pointer position while dragging; `size` is the
@@ -42,6 +54,9 @@ function Divider(props: { dir: 'row' | 'col'; onResize: (deltaFrac: number) => v
// normalize the px delta into a fraction.
const last = useRef({ x: 0, y: 0, size: 0 })
const [dragging, setDragging] = useState(false)
const pendingDelta = useRef(0)
const batcher = useRef(createFrameBatcher()).current
useEffect(() => () => batcher.dispose(), [batcher])
return (
<div
@@ -60,12 +75,20 @@ function Divider(props: { dir: 'row' | 'col'; onResize: (deltaFrac: number) => v
onPointerMove={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
const delta = dir === 'row' ? event.clientX - last.current.x : event.clientY - last.current.y
onResize(delta / Math.max(1, last.current.size))
pendingDelta.current += delta
batcher.schedule(() => {
const accumulated = pendingDelta.current
pendingDelta.current = 0
if (accumulated !== 0) onResize(accumulated / Math.max(1, last.current.size))
})
last.current.x = event.clientX
last.current.y = event.clientY
}}
onPointerUp={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
// Flush any pending baked delta so the final pointer position lands
// (the release event is not a move; dropping it would lose the tail).
batcher.flushNow()
event.currentTarget.releasePointerCapture(event.pointerId)
setDragging(false)
}}
+24 -5
View File
@@ -825,8 +825,9 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined {
// bottom tree.
const seen = new Set<string>()
const reid = new Map<string, string>()
const splits = sanitizeNode(record.splits, seen, reid)
if (splits === undefined) return undefined
const restoredSplits = sanitizeNode(record.splits, seen, reid)
if (restoredSplits === undefined) return undefined
const splits = pruneEmptyPanes(restoredSplits)
// Bottom-panel fields arrived in a later build: a missing or malformed
// value on an OLDER persisted state defaults (closed / default height /
// empty pane) so existing layouts keep loading, like nextBrowser.
@@ -840,15 +841,23 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined {
? record.bottomHeight
: BOTTOM_DEFAULT
const bottomHeight = Math.min(bottomCap, Math.max(BOTTOM_MIN, Math.round(rawHeight)))
const bottomSplits = sanitizeNode(record.bottomSplits, seen, reid)
?? { kind: 'leaf' as const, id: uid('pane'), tabs: [], active: null }
const bottomSplits = pruneEmptyPanes(sanitizeNode(record.bottomSplits, seen, reid)
?? { kind: 'leaf' as const, id: uid('pane'), tabs: [], active: null })
const requestedActivePane = typeof record.activePane === 'string'
? (reid.get(record.activePane) ?? record.activePane)
: null
const activePane = requestedActivePane === null
? null
: treeHasId(splits, requestedActivePane) || treeHasId(bottomSplits, requestedActivePane)
? requestedActivePane
: firstLeaf(splits).id
const maxWidth = typeof window !== 'undefined' ? window.innerWidth : Infinity
return {
panelOpen: record.panelOpen,
width: Math.max(PANEL_MIN, Math.min(record.width, maxWidth)),
// A stale duplicate pane id may have been re-ided; follow the rename so
// new tabs still land in the pane the user was using.
activePane: typeof record.activePane === 'string' ? (reid.get(record.activePane) ?? record.activePane) : null,
activePane,
nextTerminal: record.nextTerminal,
nextBrowser,
expanded: record.expanded as string[],
@@ -863,6 +872,16 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined {
}
}
/** Collapse persisted split panes left empty after ephemeral diff tabs are dropped. */
function pruneEmptyPanes(node: SplitNode): SplitNode {
const leaves = allLeaves(node)
if (!leaves.some(leaf => leaf.tabs.length > 0)) return node
return leaves.reduce(
(tree, leaf) => leaf.tabs.length === 0 ? removeLeafAt(tree, leaf.id) : tree,
node,
)
}
/**
* One tree node id, deduplicated against the ids already seen in this
* state. Duplicates are exactly the pre-seeding counter-reset corruption
+46
View File
@@ -0,0 +1,46 @@
/**
* The memo comparator for one tab's content cell (TabContent in Sidebar.tsx).
*
* Kept as a pure module so the "what re-renders a tab cell" contract is unit
* testable: geometry/store re-renders of the Sidebar shell must NOT reconcile
* every mounted tab (xterm/CodeMirror/FileTree subtrees — issue #315), while
* locale switches (localeRevision), tab-registry updates (tabsVersion), and
* a tab MOVING BETWEEN PANES (paneId — onOpenDiff closes over the pane id,
* and moveTab reuses the SAME tab object, so only the pane id identifies the
* move) must re-render the cell.
*/
import type { SidebarTab } from './state.ts'
/** The fields whose change invalidates a tab cell's memo. Anything a cell's
* render (or its callbacks) depends on must be listed here. */
export interface TabContentMemoKey {
tab: SidebarTab
/** The pane this tab currently lives in. onOpenDiff closes over it, and
* moveTab reuses the same tab object when the tab moves panes — without
* comparing paneId the cell would keep the source pane's callback. */
paneId: string
sessionId: string
cwd: string | undefined
visible: boolean
expanded: string[]
localeRevision: string
tabsVersion: number
}
/** True when the cell may skip a re-render (all render-affecting fields
* unchanged). Callback/context identities are deliberately ignored: their
* captured dependencies are stable or covered by the compared fields
* (onReferenceFile → sessionId/cwd, onSubagentJump/onToggleDir → stable
* refs/closures, onOpenDiff → paneId). */
export function tabContentCompare(prev: TabContentMemoKey, next: TabContentMemoKey): boolean {
return (
prev.tab === next.tab &&
prev.paneId === next.paneId &&
prev.sessionId === next.sessionId &&
prev.cwd === next.cwd &&
prev.visible === next.visible &&
prev.expanded === next.expanded &&
prev.localeRevision === next.localeRevision &&
prev.tabsVersion === next.tabsVersion
)
}
+10 -2
View File
@@ -96,6 +96,7 @@ function runGit(cwd: string, args: string[], timeoutMs = 30_000): Promise<string
return new Promise<string>((resolvePromise, reject) => {
const child = spawn('git', full, {
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
})
let stdout = ''
@@ -143,13 +144,20 @@ export async function currentBranch(cwd: string): Promise<string> {
return out.trim()
}
/** Working-tree status (untracked included). */
/**
* Working-tree status (untracked included). `--untracked-files=all` lists
* the CONTENTS of new directories as individual entries (`?? newdir/a.ts`
* rather than a collapsed `?? newdir/`), so every row in the source-control
* panel is a real file whose diff tab can load. With `=normal`, git folds a
* new folder into one trailing-slash entry that has no diff output and
* cannot be read as a file.
*/
export async function status(cwd: string): Promise<GitStatusResult> {
const repo = await isGitRepo(cwd)
if (!repo) return { isRepo: false, entries: [] }
const [branch, raw] = await Promise.all([
currentBranch(cwd).catch(() => 'HEAD'),
runGit(cwd, ['status', '--porcelain=v1', '-z', '--untracked-files=normal']),
runGit(cwd, ['status', '--porcelain=v1', '-z', '--untracked-files=all']),
])
return { isRepo: true, branch, entries: parsePorcelainZ(raw) }
}
+36 -4
View File
@@ -35,6 +35,7 @@ import { decodeHtmlUrl } from './html-route.ts'
import { extractFrameAncestors } from './browser-probe.ts'
import { isTrustedApiRequest, isLoopbackHostname } from './trust-fence.ts'
import { registerBundleRoute } from './bundle-route.ts'
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'
@@ -479,6 +480,19 @@ function buildApi(
clearTimeout(timer)
}
},
// External open for the file tree's "open with" menu: reveal a path in
// the OS file manager, or hand a custom-scheme URL (vscode://,
// cursor://, zed://, custom editors) to its registered handler. The
// client is a browser renderer where raw scheme navigation is
// unreliable, so the launch always goes through the host — the same
// fence as every other route, argv-only (no shell interpolation).
'open.external': (payload) => {
const record = payload as { action?: unknown } | null
const action = record?.action
if (action === 'reveal') return launchExternal('reveal', requireString(payload, 'path'))
if (action === 'url') return launchExternal('url', requireString(payload, 'url'))
throw new SidebarError('bad-request', 'action must be "reveal" or "url"')
},
// Side Chat: create a side-thread child seeded with the parent's full
// log up to now, deliver follow-ups (cold-resuming when the thread's
// agent is gone), abort a running thread, and release a thread's agent.
@@ -895,6 +909,10 @@ async function attachAgentList(
* - `?tab=...&sessionId=...` attaches to a UI-tab terminal (the user
* created it from the + menu). The close frame schedules a 0-ms close
* (the host's reconnect grace keeps the shell alive across a refresh).
* The park frame (sent when the user switches to another conversation)
* marks the pty as parked so the upcoming bare socket drop does NOT start
* the grace countdown — the tab is still open in its session's state, so
* the shell must survive until the user switches back or closes the tab.
*/
async function attachTerminal(
ctx: Context,
@@ -971,6 +989,16 @@ async function attachTerminal(
ptyManager.scheduleClose(handle.key, 0)
return
}
if (control !== null && control.type === 'park') {
// The user switched to another conversation: the tab is still open in
// its session's persisted state, but its view unmounted. Park the pty
// so the upcoming bare socket drop does NOT start the reconnect-grace
// countdown — the pty stays alive until the user switches back (a
// reconnecting view clears the parked state) or explicitly closes the
// tab (a close frame's scheduleClose clears it).
ptyManager.park(handle.key)
return
}
if (handle.exited) return
if (
control !== null
@@ -986,10 +1014,14 @@ async function attachTerminal(
ws.on('close', () => {
dataSub.dispose()
exitSub.dispose()
// A bare socket drop (refresh, tab switch) leaves the process alive
// for a grace period so a quick reconnect keeps it; the reconnect's
// open() cancels the pending close.
ptyManager.scheduleClose(handle.key, resolved.reconnectGraceMs)
// A parked pty (the user switched conversations and sent `{type:'park'}`)
// stays alive indefinitely — do NOT start the grace countdown. A bare
// socket drop without a prior park (refresh, crash) starts the grace
// period so a quick reconnect keeps the process; the reconnect's open()
// cancels the pending close.
if (!ptyManager.isParked(handle.key)) {
ptyManager.scheduleClose(handle.key, resolved.reconnectGraceMs)
}
})
} catch (error) {
ws.close(1011, error instanceof Error ? error.message : String(error))
+90
View File
@@ -0,0 +1,90 @@
/**
* External open actions for the file tree's "open with" menu: hand a path to
* the OS file manager (reveal/select) or launch a URL scheme's registered
* handler (vscode://, cursor://, zed://, custom schemes).
*
* The client runs in a browser / DSH Desktop renderer where a raw `vscode://`
* navigation is unreliable, so both actions fan out through this host route
* and spawn the platform opener with an argv array (no shell interpolation).
* The command builders are pure — the platform is injectable — so every
* per-platform branch is unit-testable without spawning anything.
*/
import { spawn } from 'node:child_process'
import { parentOf, requireAbsolute } from './fs-tree.ts'
import { SidebarError } from './wire.ts'
/** The two external open actions the route accepts. */
export type OpenExternalAction = 'reveal' | 'url'
/** One platform opener invocation (argv array — never a shell string). */
export interface ExternalCommand {
command: string
args: string[]
}
/** Reveal/select a path in the OS file manager. On Linux there is no common
* select protocol — the containing directory is opened instead (KISS). */
export function revealCommand(path: string, platform: NodeJS.Platform = process.platform): ExternalCommand {
switch (platform) {
case 'darwin':
return { command: 'open', args: ['-R', path] }
// `explorer /select,<path>` — the classic "show in folder" incantation.
// Passed as separate argv entries (no shell); verify on Windows, with
// `cmd /c start "" explorer.exe "/select,<path>"` as the fallback.
case 'win32':
return { command: 'explorer.exe', args: ['/select,', path] }
default: {
const parent = parentOf(path)
return { command: 'xdg-open', args: [parent ?? path] }
}
}
}
/** Hand a custom-scheme URL to the OS protocol handler. */
export function urlCommand(url: string, platform: NodeJS.Platform = process.platform): ExternalCommand {
switch (platform) {
case 'darwin':
return { command: 'open', args: [url] }
// url.dll,FileProtocolHandler launches the registered protocol handler;
// `cmd /c start "" <url>` is the fallback if rundll32 misbehaves.
case 'win32':
return { command: 'rundll32.exe', args: ['url.dll,FileProtocolHandler', url] }
default:
return { command: 'xdg-open', args: [url] }
}
}
/** Validate a URL-scheme open target: a parseable custom-scheme URL (never
* http/https — those would only dump the URL into a browser tab). */
export function validateExternalUrl(raw: string): string {
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) {
throw new SidebarError('bad-request', 'url must be a custom-scheme URL')
}
let url: URL
try {
url = new URL(raw)
} catch {
throw new SidebarError('bad-request', 'invalid url')
}
if (url.protocol === 'http:' || url.protocol === 'https:') {
throw new SidebarError('bad-request', 'only custom-scheme urls can be opened externally')
}
return raw
}
/**
* Launch one external open action and return immediately (detached, no
* stdio). Spawn failures are reported through the child's 'error' event —
* by then the route already returned, so the event is swallowed (the OS
* dialog about a missing handler is the user-visible outcome either way).
*/
export function launchExternal(action: OpenExternalAction, value: string): { started: true } {
const platform = process.platform
const spec = action === 'reveal'
? revealCommand(requireAbsolute(value), platform)
: urlCommand(validateExternalUrl(value), platform)
const child = spawn(spec.command, spec.args, { detached: true, stdio: 'ignore' })
child.on('error', () => { /* opener missing/denied: handled by the OS */ })
child.unref()
return { started: true }
}
+49 -2
View File
@@ -64,10 +64,32 @@ export interface SidebarPty {
/**
* The terminal registry. `maxPerSession` bounds concurrent processes per
* conversation (the client caps tabs at the same number).
*
* Lifecycle of a UI-tab pty when its WebSocket drops:
* - **Close frame** (`{type:'close'}`): the user closed the tab → schedule a
* 0-ms close (quota released immediately).
* - **Park frame** (`{type:'park'}`): the user switched to another
* conversation; the tab is still open in its session's persisted state but
* its view unmounted → mark the pty as parked (no auto-close countdown).
* The pty stays alive until the user switches back (a reconnecting view
* calls `open()` which clears the parked state) or the tab is later closed
* (a `{type:'close'}` frame from a fresh connection). Without `park`, a
* bare socket drop would start the reconnect-grace countdown and kill the
* shell after `reconnectGraceMs` — wrong for a session switch, where the
* user is still actively using the app, just in another conversation.
* - **Bare socket drop** (no frame): page refresh, crash, plugin teardown →
* schedule a close after `reconnectGraceMs` so a quick reconnect reattaches
* the same shell.
*/
export class PtyManager {
private readonly sessions = new Map<string, SidebarPty>()
private readonly pendingCloses = new Map<string, ReturnType<typeof setTimeout>>()
/** Tabs whose view unmounted because the user switched conversations — the
* tab is still open in its session's state, so the pty must NOT enter the
* reconnect-grace countdown. Cleared by `cancelClose` (a reconnecting
* view's `open()` cancels it) or by `scheduleClose` (an explicit close
* frame still kills a parked pty). */
private readonly parked = new Set<string>()
constructor(
private readonly shell: string,
@@ -159,7 +181,9 @@ export class PtyManager {
* Schedule the terminal's destruction after `delayMs`. A tab close sends
* delay 0 (release the quota immediately); a bare socket drop (refresh,
* crash) uses the grace period so a quick reconnect keeps the process.
* `open()` cancels any pending close.
* `open()` cancels any pending close. Clears the parked state — an explicit
* close frame on a parked pty (the user switched back and closed the tab)
* still kills it.
*/
scheduleClose(key: string, delayMs: number): void {
const handle = this.sessions.get(key)
@@ -169,13 +193,36 @@ export class PtyManager {
this.pendingCloses.set(key, timer)
}
/** Cancel a pending scheduled close (the terminal is being reopened). */
/**
* Park a terminal: the owning tab's view unmounted because the user
* switched to another conversation, but the tab is still open in its
* session's persisted state. Cancels any pending grace close and marks
* the pty so the host's `ws.on('close')` handler does NOT start the
* reconnect-grace countdown — the pty stays alive until the user switches
* back (a reconnecting view's `open()` clears this) or explicitly closes
* the tab (a `{type:'close'}` frame's `scheduleClose` clears this).
*/
park(key: string): void {
if (this.sessions.get(key) === undefined) return
this.cancelClose(key)
this.parked.add(key)
}
/** Whether this pty was parked (its view unmounted for a session switch). */
isParked(key: string): boolean {
return this.parked.has(key)
}
/** Cancel a pending scheduled close (the terminal is being reopened).
* Also clears the parked state — a reconnecting view reattaches a parked
* pty and resumes normal lifecycle. */
cancelClose(key: string): void {
const timer = this.pendingCloses.get(key)
if (timer !== undefined) {
clearTimeout(timer)
this.pendingCloses.delete(key)
}
this.parked.delete(key)
}
/** Resolve a live handle by key, or undefined. */
+32 -8
View File
@@ -35,6 +35,11 @@ export const LABEL_MAX_CHARS = 48
* the two plugins' threads render consistently in either UI). */
export const SIDE_BOUNDARY_PREFIX = 'Side conversation boundary'
/** The plugin identity stamped on the source of context-injection messages
* (boundary prompt + parked snapshot), so the transcript recognizes them
* structurally — not by text prefix. */
export const SIDE_INJECTION_PLUGIN = 'dsh-better-sidebar'
/**
* The boundary prompt delivered as the thread's first user message: the
* inherited seed is reference context only, never active instruction.
@@ -365,18 +370,37 @@ export function sideLabel(question: string): string {
export function boundaryDelivered(events: readonly SidechatLogEvent[]): boolean {
for (const event of events) {
if (event.type !== 'user/message') continue
const content = dataOf(event).content
const first = Array.isArray(content) ? content[0] : content
const text = typeof first === 'string'
? first
: (typeof first === 'object' && first !== null && 'text' in first
? String((first as { text: unknown }).text)
: '')
if (text.startsWith(SIDE_BOUNDARY_PREFIX)) return true
if (messageLeadText(dataOf(event)).startsWith(SIDE_BOUNDARY_PREFIX)) return true
}
return false
}
/** The leading text of a user/message's content (block array or bare string). */
function messageLeadText(data: Record<string, unknown>): string {
const content = data.content
const first = Array.isArray(content) ? content[0] : content
return typeof first === 'string'
? first
: (typeof first === 'object' && first !== null && 'text' in first
? String((first as { text: unknown }).text)
: '')
}
/**
* Whether a logged user/message is a CONTEXT INJECTION (the boundary prompt
* plus the parked in-progress snapshot) rather than a real user message.
* New threads deliver the injection via `agent.inject` stamped with a
* non-'user' source kind; threads created before that split carry
* boundary+question in ONE 'user' message, recognized by the boundary
* prefix. Both render as one collapsible injection row — never as a user
* bubble.
*/
export function isContextInjectionMessage(data: Record<string, unknown>): boolean {
const source = data.source as { kind?: unknown } | null | undefined
if (source?.kind !== undefined && source.kind !== 'user') return true
return messageLeadText(data).startsWith(SIDE_BOUNDARY_PREFIX)
}
/** The info the thread header shows (live runtime state + agent identity). */
export interface SidechatThreadInfo {
/** A live agent drives the thread right now (false = cold/persisted). */
+23 -4
View File
@@ -37,6 +37,7 @@ import {
buildSidechatInheritance,
resolvePresetId,
SIDE_BOUNDARY_PROMPT,
SIDE_INJECTION_PLUGIN,
SIDE_NEW_THREAD_TITLE,
sideLabel,
type SeedEvent,
@@ -124,6 +125,26 @@ function admitFollowup(agent: Agent, blocks: ContentBlock[]): void {
agent.followup(message)
}
/**
* Deliver the thread's FIRST contact as TWO log-separated messages: the
* boundary prompt (+ the parked in-progress snapshot) rides `agent.inject`
* — queued model-facing context that does NOT wake the driver and is
* claimed FIRST at the opening step (Inbox.claim drains next-step before
* next-turn) — and the user's question is the follow-up that wakes it. The
* log therefore records two user/message events (injection, then question)
* instead of one wrapped blob: the transcript shows the question as a user
* bubble and collapses the injection as a context row. The injection source
* is stamped `kind: 'plugin'` so recognition is structural; its text still
* opens with SIDE_BOUNDARY_PREFIX, keeping boundaryDelivered intact.
*/
function admitFirstContact(agent: Agent, injectionText: string, question: string): void {
agent.inject(createUserMessage({
content: textPrompt(injectionText),
source: { kind: 'plugin', plugin: SIDE_INJECTION_PLUGIN },
}))
admitFollowup(agent, textPrompt(question))
}
/** The live thread agent, or undefined (cold — the caller resumes). */
function liveThreadAgent(ctx: Context, childId: string): Agent | undefined {
const agents = ctx.get('agents') as { get(id: string): Agent | undefined } | undefined
@@ -217,8 +238,7 @@ export function buildSidechatApi(ctx: Context): SidechatRoutes {
} else {
const promptParts = [SIDE_BOUNDARY_PROMPT]
if (inheritance.snapshot !== null) promptParts.push(inheritance.snapshot)
promptParts.push(question)
admitFollowup(handle.agent, textPrompt(promptParts.join('\n\n')))
admitFirstContact(handle.agent, promptParts.join('\n\n'), question)
pinTitle(sideLabel(question))
}
return { childId }
@@ -257,8 +277,7 @@ export function buildSidechatApi(ctx: Context): SidechatRoutes {
const snapshot = pendingSnapshots.get(childId)
pendingSnapshots.delete(childId)
if (snapshot !== undefined) parts.push(snapshot)
parts.push(text)
admitFollowup(agent, textPrompt(parts.join('\n\n')))
admitFirstContact(agent, parts.join('\n\n'), text)
const titles = ctx.get('sessionTitle') as SidebarSessionTitleService | undefined
if (titles !== undefined) {
try {