fix(native): meet DSH 0.1.6-alpha.2 guide-id and turn-tail contracts

DSH 0.1.6-alpha.2 made two plugin-facing contracts stricter, and both broke
this plugin silently:

- `SidebarRightGuideEntry.id` is now required and unique per provider. The
  native tab registrations sent guide entries without one, so the second kind
  collided on `undefined` and `SidebarRightTabRegistry.register` threw
  'duplicate guide entry id'. The throw happens inside `ctx.inject`'s callback
  body, which cordis swallows, so the whole native surface stayed empty with no
  symptom beyond a missing guide row.
- `conversation.chat.turnTail` changed from a `chain` to an additive `list`.
  The chain's `select` made one entry render and let this plugin REPLACE the
  host's produced-files row; a list cannot express that, and the registration
  now throws 'list slot ... requires options.id'. Upstream chose the list
  deliberately ('An additive list lets both plugins contribute without either
  knowing the other's data or rendering'), so the plugin's row can only ever
  duplicate the host's deliverables row -- which itself opens files through the
  host's openFile, i.e. into this plugin's editor. The contribution is removed
  and the row is the host's alone.

The file-opening half survives as `sidebar-file.ts` (the changes tab and the
editor host still use it), and `resolveSidebarPath` moves to the `paths.ts`
path-helper module beside its siblings. `registerNativeSurface` grows an
optional `reportFailure` so a future contract break reaches the visible
diagnostic strip instead of a silently empty guide, and one bad descriptor can
no longer take the remaining registrations down with it.
This commit is contained in:
Menghuan1918
2026-09-21 20:18:16 +08:00
parent 1fcf43ccbe
commit 58ba17d462
17 changed files with 135 additions and 611 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ import type { SidebarDiffRef } from './state.ts'
import { DiffFiles } from './diff/DiffFiles.tsx'
import { displayPath, foldRowsFromContents, type DiffFile, type DiffRow, type FoldSegment } from './diff/rows.ts'
import { t } from './locales.ts'
import { resolveSidebarPath } from './produced-files.ts'
import { resolveSidebarPath } from './paths.ts'
import css from './sidebar.module.css'
/** The loaded diff surface (untracked content rendered as a full addition). */
+4 -4
View File
@@ -33,13 +33,13 @@ import { FenceErrorNotice } from './FenceErrorNotice.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 { openSidebarFile } from './sidebar-file.ts'
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'
import { resolveSidebarPath } from './produced-files.ts'
import { resolveSidebarPath } from './paths.ts'
import { closePathTabs, retargetPathTabs } from './tree-mutations.ts'
import type { EditorToolbarControls, EditorToolbarState, FileViewerDescriptor } from './service.ts'
import { firstLeaf, insertLeafAt, leafWithTab, mintTabId, type SidebarStore, type SidebarTab } from './state.ts'
@@ -162,13 +162,13 @@ export function EditorHost(props: {
if (inPlace) {
ctx.get('betterSidebar')?.updateTab(tab.id, { path: absolute, title: baseName(absolute) })
} else {
openSidebarFile(ctx, store, scope.sessionId, absolute)
openSidebarFile(ctx, scope.sessionId, absolute)
}
}
/** The context menu's explicit "new tab" escape (per-path dedupe). */
const openFileNewTab = (absolute: string): void => {
openSidebarFile(ctx, store, scope.sessionId, absolute)
openSidebarFile(ctx, scope.sessionId, absolute)
}
/**
+1 -1
View File
@@ -25,7 +25,7 @@ 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 { resolveSidebarPath } from './paths.ts'
import { UploadOverlay } from './UploadOverlay.tsx'
import {
summarizeResults, uploadHintText, uploadItemsFromFiles, uploadToDir,
+2 -2
View File
@@ -15,7 +15,7 @@ import {
} from './tab-icons.tsx'
import { allLeaves, isAgentTabId, type SidebarState } from '../state.ts'
import { t } from '../locales.ts'
import { openSidebarFile } from '../intercept.tsx'
import { openSidebarFile } from '../sidebar-file.ts'
import { EditorHost } from '../EditorHost.tsx'
import { OpenWithSettings } from '../open-with-settings.tsx'
import { lazyChunkComponent } from '../lazy-chunk.tsx'
@@ -166,7 +166,7 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
scope={scope}
tab={tab}
visible={visible}
onOpenFile={(path) => { openSidebarFile(ctx, store, scope.sessionId, path) }}
onOpenFile={(path) => { openSidebarFile(ctx, scope.sessionId, path) }}
onOpenDiff={onOpenDiff}
/>
),
+1 -1
View File
@@ -14,7 +14,7 @@ import type { SessionScope } from '../api.ts'
import { api, htmlUrl } from '../api.ts'
import { t } from '../locales.ts'
import { baseName } from '../paths.ts'
import { resolveSidebarPath } from '../produced-files.ts'
import { resolveSidebarPath } from '../paths.ts'
import { HTML_IFRAME_SANDBOX } from '../html-preview.ts'
import type { SidebarDiffRef, SidebarTab } from '../state.ts'
import { DiffRows, ReadRows } from '../diff/DiffRows.tsx'
+1 -1
View File
@@ -19,7 +19,7 @@ import type { GitLogEntry, GitStatusEntry, GitStatusResult, GitWorktree, Session
import { api } from '../api.ts'
import { usePolling } from '../use-polling.ts'
import { baseName, isWithinWorkspace, relativeTo } from '../paths.ts'
import { resolveSidebarPath } from '../produced-files.ts'
import { resolveSidebarPath } from '../paths.ts'
import { relativeTime, t } from '../locales.ts'
import type { SidebarDiffRef, SidebarStore } from '../state.ts'
import css from './changes.module.css'
+30 -43
View File
@@ -2,9 +2,8 @@
* Client half of dsh-better-sidebar: resolves the user's "Side card"
* preferences through the plugin's own fenced settings route, mounts the
* right sidebar portal (inside an error boundary so a rendering failure
* shows an error strip instead of a blank panel), registers the turn-tail
* interception, and contributes the Side card settings section to the DSH
* Settings shell. Requires the runtime's slots and sessions services; the
* shows an error strip instead of a blank panel), and contributes the Side
* card settings section to the DSH Settings shell. Requires the runtime's slots and sessions services; the
* bundle itself is a module-table consumer only (react + ui-primitives +
* xterm, all provided or inlined).
*/
@@ -17,7 +16,6 @@ import { revalidateChunksOnReactivate, setChunkModuleSystem } from './chunk-load
import { registerBuiltins } from './builtins/index.ts'
import { Sidebar } from './Sidebar.tsx'
import { RenderBoundary } from './RenderBoundary.tsx'
import { registerTurnTailInterception } from './intercept.tsx'
import { createNativeTabRecords } from './native/tab-adapter.tsx'
import { registerNativeSurface } from './native/index.ts'
import { registerBottomToggle } from './sidebar/bottom-toggle.tsx'
@@ -38,9 +36,7 @@ import './layout.css'
* (rc.8+) is the client module system the chunk loader resolves its
* externals through; `connection` (0.1.2-alpha.2+) is the Remote transport's
* recovery lifecycle the side chat's disconnect banner reads — Cordis guards
* service access without inject. The `remote.session` namespace is NOT here:
* it mounts asynchronously, so the open-path interception reaches it through
* `ctx.inject` (see intercept.tsx). */
* service access without inject. */
export const inject = ['slots', 'sessions', 'locale', 'modules', 'connection']
/**
@@ -130,6 +126,29 @@ export function apply(ctx: Context): void {
attachBetterLocale(undefined)
}
}, 'dsh-better-sidebar: better-locale lazy integration')
// A failure anywhere in the client lifecycle must never take the app down
// silently: log with the plugin prefix and pin a visible diagnostic strip
// to the page so a blank panel is never the only symptom. This strip is
// the last-resort reporter (no CSS module is reachable from here), so its
// colors go through skin token chains with the previous hexes as the
// chain tails — worst case (no skin tokens on the page) it renders
// byte-identical to the old hardcoded bar, and any `--dsw-alias-*` skin
// re-themes it (guide §12: no hardcoded colors).
const fail = (phase: string, error: unknown): void => {
console.error(`[dsh-better-sidebar] ${phase} error:`, error)
try {
const bar = document.createElement('div')
bar.style.cssText = 'position:fixed;left:8px;bottom:8px;z-index:2147483000;max-width:70vw;padding:8px 12px;'
+ 'font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;'
+ 'color:var(--dsw-alias-state-error-primary,#f2a1a1);'
+ 'background:var(--dsw-alias-bg-layer-3,var(--dsw-alias-bg-base,#1b1b22));'
+ 'border:1px solid var(--dsw-alias-state-error-primary,#f2a1a1);border-radius:8px;white-space:pre-wrap'
bar.textContent = `[dsh-better-sidebar] ${phase} error: ${error instanceof Error ? error.message : String(error)}`
document.body.appendChild(bar)
} catch {
// Nothing left to report with.
}
}
// One store instance per activation: production code creates it only here,
// then hands it to the mounted panel and closes over it in the slot
// registrations (the official createXXXStore() factory rule — no
@@ -149,7 +168,10 @@ export function apply(ctx: Context): void {
const nativeSurface = createNativeSurface(ctx, nativeRecords)
service.setSurface(nativeSurface)
ctx.effect(
() => registerNativeSurface({ ctx, store: sidebarStore, service, records: nativeRecords }),
() => registerNativeSurface({
ctx, store: sidebarStore, service, records: nativeRecords,
reportFailure: (phase, error) => { fail(`native ${phase}`, error) },
}),
'dsh-better-sidebar: native right-Sidebar registrations',
)
// The bottom workbench's expand/collapse button in DSH's session header
@@ -188,29 +210,6 @@ export function apply(ctx: Context): void {
() => registerBuiltins(ctx, service, { terminalTitle: () => terminalTitle }),
'dsh-better-sidebar: register built-in tabs and viewers',
)
// A failure anywhere in the client lifecycle must never take the app down
// silently: log with the plugin prefix and pin a visible diagnostic strip
// to the page so a blank panel is never the only symptom. This strip is
// the last-resort reporter (no CSS module is reachable from here), so its
// colors go through skin token chains with the previous hexes as the
// chain tails — worst case (no skin tokens on the page) it renders
// byte-identical to the old hardcoded bar, and any `--dsw-alias-*` skin
// re-themes it (guide §12: no hardcoded colors).
const fail = (phase: string, error: unknown): void => {
console.error(`[dsh-better-sidebar] ${phase} error:`, error)
try {
const bar = document.createElement('div')
bar.style.cssText = 'position:fixed;left:8px;bottom:8px;z-index:2147483000;max-width:70vw;padding:8px 12px;'
+ 'font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;'
+ 'color:var(--dsw-alias-state-error-primary,#f2a1a1);'
+ 'background:var(--dsw-alias-bg-layer-3,var(--dsw-alias-bg-base,#1b1b22));'
+ 'border:1px solid var(--dsw-alias-state-error-primary,#f2a1a1);border-radius:8px;white-space:pre-wrap'
bar.textContent = `[dsh-better-sidebar] ${phase} error: ${error instanceof Error ? error.message : String(error)}`
document.body.appendChild(bar)
} catch {
// Nothing left to report with.
}
}
try {
// rc.8+ exposes the client module system as the `ctx.modules` service;
// the chunk loader needs it to resolve its externals, so inject it
@@ -354,18 +353,6 @@ export function apply(ctx: Context): void {
}
}, 'dsh-better-sidebar: sidebar mount')
ctx.effect(
() => {
try {
return registerTurnTailInterception(ctx, sidebarStore)
} catch (error) {
fail('interception', error)
return () => {}
}
},
'dsh-better-sidebar: turn-tail interception',
)
ctx.effect(
() => {
try {
-131
View File
@@ -1,131 +0,0 @@
/**
* Interception of the chat's produced-files row: the turn-tail chain entry
* that replaces ui-deliverables' row when the closing turn produced files.
* The takeover looks identical (same chip row); the chips open the file in
* the sidebar instead of the host OS. Priority -1 runs before the default-0
* deliverables entry; when nothing was produced the selector returns null
* and the original row renders unchanged.
*/
import { IconCodeOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from '../context-types.ts'
import { revealPaths, type SidebarStore } from './state.ts'
import { t } from './locales.ts'
import { resolveSidebarPath, selectProducedFiles } from './produced-files.ts'
import css from './sidebar.module.css'
/** Open a file in the sidebar's editor (used by the intercepted row and the explorer). */
export function openSidebarFile(ctx: Context, store: SidebarStore, sessionId: string, path: string): void {
const summary = ctx.sessions.list.getSnapshot().byId[sessionId]
const absolute = resolveSidebarPath(summary?.cwd, path)
const at = Math.max(absolute.lastIndexOf('/'), absolute.lastIndexOf('\\'))
const title = at === -1 ? absolute : absolute.slice(at + 1)
// Route through the sidebar service so the editor descriptor's dedupeKey
// (per-path) applies; the id is path-derived so multiple editors coexist.
ctx.get('betterSidebar')?.openTab({ type: 'editor', title, path: absolute, id: `editor:${absolute}` })
}
/**
* Reveal the produced files in the sidebar explorer: expand their parent
* directories, highlight the rows, and focus the explorer tab. Unknown
* files fall back to revealing the workspace root itself.
*/
export function revealInExplorer(
ctx: Context,
store: SidebarStore,
sessionId: string,
files: readonly string[],
): void {
const summary = ctx.sessions.list.getSnapshot().byId[sessionId]
const cwd = summary?.cwd
// Deliverables report paths as-is (often relative to the session cwd), but
// the explorer tree and revealPaths work on absolute paths — resolve every
// target so the ancestors expand and the row actually matches.
const targets = files.length > 0
? files.map(path => resolveSidebarPath(cwd, path))
: cwd === undefined ? [] : [cwd]
store.reduce(state => revealPaths(state, cwd, targets))
// Focus the single-instance editor home tab (the files window) where the
// reveal highlight renders. Read via ctx.get like every other internal
// consumer (#357): the provider is not on this fiber chain, so a direct
// ctx.betterSidebar read can throw before optional chaining applies.
ctx.get('betterSidebar')?.openTab({ type: 'editor', title: t('files') })
}
/** The intercepted produced-files row (visual twin of the deliverables chips). */
export function SidebarProducedFiles(props: {
matched: readonly string[]
openInSidebar: (path: string) => void
/** Reveal the produced files in the explorer ("Show in folder" twin). */
onShowInFolder: (files: readonly string[]) => void
}) {
const { matched, openInSidebar, onShowInFolder } = props
const shown = matched.slice(0, 6)
const hidden = matched.length - shown.length
return (
<div className={css.producedRow}>
<span className={css.producedLabel}>{t('produced')}</span>
{shown.map(path => {
const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
const name = at === -1 ? path : path.slice(at + 1)
return (
<button
key={path}
type="button"
className={css.producedChip}
title={path}
onClick={() => { openInSidebar(path) }}
>
<IconCodeOutline16 size={12} />
<span>{name}</span>
</button>
)
})}
{hidden > 0 && <span className={css.producedMore}>+{hidden}</span>}
{hidden > 0 && (
<button
type="button"
className={css.producedMore}
style={{ cursor: 'pointer', textDecoration: 'underline', textUnderlineOffset: 2 }}
onClick={() => { onShowInFolder(matched) }}
>
{t('showInFolder')}
</button>
)}
</div>
)
}
/**
* Register the turn-tail interception (returns the disposer).
*
* The slot is a CHILD slot the host's ui-conversation declares in its
* `conversation.chat.node` children table (kind: chain, scope: session).
* Registering it directly races the declaration — the ui-slots core's
* load-time validation throws "not declared (a parent entry's children
* table must declare it)" when the parent entry is not on the ledger yet.
* slots.inject waits for the declaration: the callback runs synchronously
* when the slot is already declared, otherwise it runs inside the declaring
* register() call once the declaration commits; declaration collapse
* disposes the entry and a later declaration re-registers it. This mirrors
* @deepseek-ai/dsh-client-ui-deliverables' registration of the same slot.
*/
export function registerTurnTailInterception(ctx: Context, store: SidebarStore): () => void {
return ctx.slots.inject('conversation.chat.turnTail', () => ctx.slots.register({
name: 'conversation.chat.turnTail',
// Decline the takeover while the editor tab type is disabled in the side
// card settings: the produced-files row falls back to the default
// deliverables behavior instead of offering chips that cannot open. Also
// while the sidebar is externally disabled (aionui-panel chosen).
select: (owner) => {
if (store.getSuspended()) return null
if (store.getPrefs().tabsEnabled['editor'] === false) return null
return selectProducedFiles(owner)
},
priority: -1,
registrant: 'dsh-better-sidebar',
inject: (sessionId: string) => ({
openInSidebar: (path: string) => { openSidebarFile(ctx, store, sessionId, path) },
onShowInFolder: (files: readonly string[]) => { revealInExplorer(ctx, store, sessionId, files) },
}),
}, SidebarProducedFiles))
}
+36 -4
View File
@@ -39,11 +39,12 @@ interface NativeTabRegistry {
register(definition: {
id: string
kind: string
multiple?: boolean
patterns?: readonly string[]
priority?: 'extension' | 'builtin' | 'fallback'
canOpen?: (address: string) => boolean
title: (address: string) => string
guide?: readonly { order: number; title: () => string; description?: () => string; icon?: unknown }[]
guide?: readonly { id: string; order: number; title: () => string; description?: () => string; icon?: unknown }[]
}): () => void
}
@@ -115,6 +116,18 @@ export interface NativeSurfaceDeps {
readonly service: BetterSidebarService
/** The shared native tab record registry (the surface writes it too). */
readonly records: NativeTabRecords
/**
* Report a tab-type registration failure (phase label + cause).
*
* `ctx.inject`'s callback body and the registry's own subscriber callbacks
* run OUTSIDE this module's control flow, so a throw from
* `sidebarRightTabs.register` is swallowed by cordis and the whole native
* surface stays empty with no symptom beyond a missing guide. Reporting
* through the client's diagnostic strip (index.tsx `fail`) turns a silent
* contract break into a visible one — the failure mode observed when DSH
* 0.1.6-alpha.2 made `SidebarRightGuideEntry.id` required.
*/
readonly reportFailure?: (phase: string, error: unknown) => void
}
/**
@@ -124,7 +137,7 @@ export interface NativeSurfaceDeps {
* @returns a disposer unregistering everything.
*/
export function registerNativeSurface(deps: NativeSurfaceDeps): () => void {
const { ctx, store, service, records } = deps
const { ctx, store, service, records, reportFailure } = deps
// Wait for the tab-type REGISTRY (a service), not for the slot declaration:
// the native seat declares `sidebar.right.pane.tab` BEFORE it provides
// `sidebarRightTabs`, so a declaration-triggered registration reads the
@@ -194,6 +207,11 @@ export function registerNativeSurface(deps: NativeSurfaceDeps): () => void {
? {}
: {
guide: [{
// DSH 0.1.6-alpha.2 made `id` REQUIRED and unique per provider
// (a duplicate throws `sidebarRight: duplicate guide entry id`).
// The descriptor id is already unique per implementation, which
// is exactly the uniqueness the registry asks for.
id: descriptor.id,
order: descriptor.order ?? 100,
title: () => titleOf(descriptor),
...guideDescriptionOf(descriptor),
@@ -221,6 +239,10 @@ export function registerNativeSurface(deps: NativeSurfaceDeps): () => void {
priority: 'extension',
title: () => t('files'),
guide: [{
// Required and unique per provider since DSH 0.1.6-alpha.2. This
// takeover is its own implementation id, so its guide row takes
// that same id.
id: 'files',
order: 10,
title: () => t('files'),
// The takeover IS the editor descriptor's page, so it carries the
@@ -251,14 +273,24 @@ export function registerNativeSurface(deps: NativeSurfaceDeps): () => void {
}
for (const [descriptorId, create] of wanted) {
if (live.has(descriptorId)) continue
live.set(descriptorId, { dispose: create() })
// One descriptor must not take the rest of the surface down with it:
// report and continue so the remaining types still register.
try {
live.set(descriptorId, { dispose: create() })
} catch (error) {
reportFailure?.(`register ${descriptorId}`, error)
}
}
// The built-in files kind follows the editor type's switch: with the
// editor disabled the plugin has no explorer to put there.
const wantsFiles = service.isTabEnabled(EDITOR_KIND)
const hasFiles = live.has(FILES_KIND)
if (wantsFiles && !hasFiles) {
live.set(FILES_KIND, { dispose: registerFilesKind(service.getTab(EDITOR_KIND)) })
try {
live.set(FILES_KIND, { dispose: registerFilesKind(service.getTab(EDITOR_KIND)) })
} catch (error) {
reportFailure?.(`register ${FILES_KIND}`, error)
}
}
if (!wantsFiles && hasFiles) {
live.get(FILES_KIND)?.dispose()
+13
View File
@@ -83,3 +83,16 @@ export function extOf(path: string): string {
const base = path.slice(at + 1).toLowerCase()
return base.includes('/') || base.includes('\\') ? '' : base
}
/**
* Resolve a (possibly relative) path against the session cwd for the sidebar.
* Absolute detection mirrors the host (see {@link isAbsolutePath}): POSIX
* roots, drive letters and UNC shares must not be joined onto the cwd.
*/
export function resolveSidebarPath(cwd: string | undefined, path: string): string {
if (isAbsolutePath(path)) return path
const base = cwd ?? ''
if (base === '') return path
const separator = base.includes('\\') ? '\\' : '/'
return `${base.replace(/[\\/]+$/, '')}${separator}${path}`
}
-120
View File
@@ -1,120 +0,0 @@
/**
* Pure derivation of one turn's produced files from finalized conversation
* nodes — a structural replica of ui-deliverables' `producedForClosing`
* (the mutation tools' follow-along `locations`, by render intent: a diff
* card or a generic edit card; reads/deletes/failures produce nothing).
* Kept dependency-free so the takeover logic is unit-testable and the
* replica is easy to diff against upstream when it drifts.
*/
import { isAbsolutePath } from './paths.ts'
/** Paths a tool-result view reports as produced, by render intent. */
export function producedPaths(view: unknown): readonly string[] {
if (view === null || typeof view !== 'object') return []
const record = view as { card?: unknown; kind?: unknown; locations?: unknown }
const isEdit = record.card === 'diff' || (record.card === 'generic' && record.kind === 'edit')
if (!isEdit) return []
if (!Array.isArray(record.locations)) return []
const paths: string[] = []
for (const location of record.locations) {
if (location !== null && typeof location === 'object' && typeof (location as { path?: unknown }).path === 'string') {
paths.push((location as { path: string }).path)
}
}
return paths
}
/**
* Files produced by the turn the assistant at `seq` closes. Accumulation
* resets on turn boundaries (a user message, or a node reporting a different
* turn number); paths keep first-seen order and appear once.
* @param nodes - snapshot nodes in surface order (structural, unknown-safe).
* @param seq - the closing assistant's seq (the render site's anchor).
* @returns produced paths; empty when the turn wrote nothing.
*/
export function producedForClosing(nodes: readonly unknown[], seq: number): readonly string[] {
let pending: string[] = []
let seen = new Set<string>()
let turn: number | undefined
for (const node of nodes) {
if (node === null || typeof node !== 'object') continue
const record = node as { kind?: unknown; isError?: unknown; callView?: unknown; turn?: unknown; seq?: unknown }
if (record.kind === 'tool-result') {
if (record.isError === true) continue
for (const path of producedPaths(record.callView)) {
if (seen.has(path)) continue
seen.add(path)
pending.push(path)
}
continue
}
if (record.kind === 'user') {
turn = undefined
pending = []
seen = new Set()
} else if (typeof record.turn === 'number') {
if (turn !== undefined && record.turn !== turn) {
pending = []
seen = new Set()
}
turn = record.turn
}
if (record.kind === 'assistant' && record.seq === seq) return pending
}
return []
}
/**
* Claim the turn-tail chain only when the closing turn produced files.
*
* The authoritative source is the engine Turn data — the same value
* ui-deliverables reads (`owner.turn.data.get('deliverables')`): a
* `{ produced: [{ seq, path }, ...] }` record accumulated per Turn. The
* node-based replica below stays as a fallback for compositions that do not
* publish it.
* @param owner - the turn-tail owner currency ({turn, seq, openFile}).
* @returns produced paths as the matched value, or null to decline.
*/
export function selectProducedFiles(owner: unknown): readonly string[] | null {
const record = owner as {
turn?: { data?: { get?: (key: string) => unknown } }
nodes?: unknown
seq?: unknown
} | null
if (record === null || typeof record !== 'object') return null
const seq = typeof record.seq === 'number' ? record.seq : Number.POSITIVE_INFINITY
const data = record.turn?.data?.get?.('deliverables') as
| { produced?: unknown }
| null
| undefined
if (data !== null && typeof data === 'object' && Array.isArray(data.produced)) {
const paths: string[] = []
const seen = new Set<string>()
for (const item of data.produced) {
if (item === null || typeof item !== 'object') continue
const produced = item as { path?: unknown; seq?: unknown }
if (typeof produced.path !== 'string' || produced.path === '') continue
if (typeof produced.seq === 'number' && produced.seq > seq) continue
if (seen.has(produced.path)) continue
seen.add(produced.path)
paths.push(produced.path)
}
return paths.length === 0 ? null : paths
}
if (!Array.isArray(record.nodes)) return null
const paths = producedForClosing(record.nodes, seq)
return paths.length === 0 ? null : paths
}
/**
* Resolve a (possibly relative) path against the session cwd for the sidebar.
* Absolute detection mirrors the host (see client/paths.isAbsolutePath):
* POSIX roots, drive letters and UNC shares must not be joined onto the cwd.
*/
export function resolveSidebarPath(cwd: string | undefined, path: string): string {
if (isAbsolutePath(path)) return path
const base = cwd ?? ''
if (base === '') return path
const separator = base.includes('\\') ? '\\' : '/'
return `${base.replace(/[\\/]+$/, '')}${separator}${path}`
}
+30
View File
@@ -0,0 +1,30 @@
/**
* Opening one workspace file in the sidebar's editor tab.
*
* Extracted from the removed turn-tail interception (`intercept.tsx`): DSH
* 0.1.6-alpha.2 turned `conversation.chat.turnTail` from a `chain` into an
* additive `list`, so a plugin can no longer REPLACE the built-in
* deliverables row — the plugin's own produced-files row became a duplicate
* of a row the host already draws, and was deleted. The file-opening half is
* the only part still used by the changes tab and the editor host.
*/
import type { Context } from '../context-types.ts'
import { resolveSidebarPath } from './paths.ts'
/**
* Open a file in the sidebar's editor.
*
* Routes through the sidebar service so the editor descriptor's dedupeKey
* (per-path) applies; the id is path-derived so multiple editors coexist.
* @param ctx - client context carrying `betterSidebar`.
* @param sessionId - the session whose cwd resolves a relative path.
* @param path - absolute or session-relative file path.
* @returns nothing.
*/
export function openSidebarFile(ctx: Context, sessionId: string, path: string): void {
const summary = ctx.sessions.list.getSnapshot().byId[sessionId]
const absolute = resolveSidebarPath(summary?.cwd, path)
const at = Math.max(absolute.lastIndexOf('/'), absolute.lastIndexOf('\\'))
const title = at === -1 ? absolute : absolute.slice(at + 1)
ctx.get('betterSidebar')?.openTab({ type: 'editor', title, path: absolute, id: `editor:${absolute}` })
}
-46
View File
@@ -2119,52 +2119,6 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
/* A history row: line 1 = hash + subject, line 2 = ref badges + author/date
(badges live on the second line so they never crowd the subject). */
/* ── Intercepted produced-files row ────────────────────────────────────── */
.producedRow {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
padding: 4px 0;
}
.producedLabel {
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
}
.producedChip {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 200px;
padding: 2px 8px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 999px;
background: var(--dsw-alias-bg-layer-2);
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xxs-12);
cursor: pointer;
overflow: hidden;
}
.producedChip:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.producedChip span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.producedMore {
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
}
/* ── Shared interaction rules ──────────────────────────────────────────── */
/* Keyboard focus stays visible on every interactive control (the styling
+15
View File
@@ -297,6 +297,21 @@ describe('registerNativeSurface lifecycle (service-driven registration)', () =>
expect(browserGuide?.[0]?.description).toBeUndefined()
expect('description' in (browserGuide?.[0] ?? {})).toBe(false)
expect(browserGuide?.[0]?.title?.()).toBe('Browser')
// DSH 0.1.6-alpha.2 made `SidebarRightGuideEntry.id` REQUIRED and unique
// per provider: a registration whose guide entries carry no id collides
// on `undefined` and `SidebarRightTabRegistry.register` throws
// `duplicate guide entry id`, which cordis swallows — leaving the whole
// native surface silently empty (no guide row, no plugin tab types).
const guideIds: string[] = []
for (const entry of registered) {
const guide = entry.guide as Array<{ id?: unknown }> | undefined
for (const item of guide ?? []) {
expect(typeof item.id, `guide id of ${entry.kind}`).toBe('string')
guideIds.push(item.id as string)
}
}
expect(guideIds.length).toBeGreaterThan(0)
expect(new Set(guideIds).size).toBe(guideIds.length)
dispose()
})
+1 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { isAbsolutePath, relativeTo } from '../src/client/paths.ts'
import { resolveSidebarPath } from '../src/client/produced-files.ts'
import { resolveSidebarPath } from '../src/client/paths.ts'
import { htmlUrl } from '../src/client/api.ts'
describe('path helpers', () => {
-56
View File
@@ -1,56 +0,0 @@
import { describe, expect, it } from 'vitest'
import { producedForClosing, resolveSidebarPath, selectProducedFiles } from '../src/client/produced-files.ts'
describe('produced-files derivation', () => {
const diffResult = (path: string) => ({
kind: 'tool-result', isError: false, callView: { card: 'diff', locations: [{ path }] },
})
const editResult = (path: string) => ({
kind: 'tool-result', isError: false, callView: { card: 'generic', kind: 'edit', locations: [{ path }] },
})
it('collects diff/edit locations of the closing turn, first-seen order', () => {
const nodes = [
{ kind: 'assistant', seq: 1, turn: 1 },
diffResult('a.ts'),
editResult('b.ts'),
diffResult('a.ts'),
{ kind: 'assistant', seq: 2, turn: 1 },
]
expect(producedForClosing(nodes, 2)).toEqual(['a.ts', 'b.ts'])
})
it('resets on user messages and turn changes', () => {
const nodes = [
{ kind: 'assistant', seq: 1, turn: 1 },
diffResult('old.ts'),
{ kind: 'user' },
{ kind: 'assistant', seq: 2, turn: 2 },
diffResult('new.ts'),
{ kind: 'assistant', seq: 3, turn: 2 },
]
expect(producedForClosing(nodes, 3)).toEqual(['new.ts'])
})
it('ignores reads, deletes, errors, and unknown cards', () => {
const nodes = [
{ kind: 'assistant', seq: 1, turn: 1 },
{ kind: 'tool-result', isError: true, callView: { card: 'diff', locations: [{ path: 'x.ts' }] } },
{ kind: 'tool-result', isError: false, callView: { card: 'read', locations: [{ path: 'r.ts' }] } },
{ kind: 'tool-result', isError: false, callView: { card: 'generic', kind: 'delete', locations: [{ path: 'd.ts' }] } },
]
expect(producedForClosing(nodes, 1)).toEqual([])
})
it('selector claims only when files exist', () => {
expect(selectProducedFiles({ nodes: [{ kind: 'assistant', seq: 1, turn: 1 }], seq: 1 })).toBeNull()
expect(selectProducedFiles({ nodes: [diffResult('a.ts'), { kind: 'assistant', seq: 1, turn: 1 }], seq: 1 })).toEqual(['a.ts'])
expect(selectProducedFiles(null)).toBeNull()
})
it('resolves relative paths against the session cwd', () => {
expect(resolveSidebarPath('/work/proj', 'src/a.ts')).toBe('/work/proj/src/a.ts')
expect(resolveSidebarPath('/work/proj', '/abs/x.ts')).toBe('/abs/x.ts')
expect(resolveSidebarPath(undefined, 'a.ts')).toBe('a.ts')
})
})
-200
View File
@@ -1,200 +0,0 @@
/**
* Turn-tail interception registration spec (issue #15): `registerTurnTailInterception`
* must go through `ctx.slots.inject` — the slot is a CHILD slot the host's
* ui-conversation declares in its `conversation.chat.node` children table, so a
* direct `slots.register` races the declaration and the ui-slots core throws
* "not declared (a parent entry's children table must declare it)".
*
* The fake `slots` mirrors SlotRegistry.inject's semantics: run the callback
* synchronously when the slot is already declared; otherwise wait and run it
* when the declaration commits; the returned disposer cancels a pending wait
* and disposes any active registration; the register disposer is idempotent.
*/
import { describe, expect, it, vi } from 'vitest'
import { createSidebarStore } from '../src/client/state.ts'
import { registerTurnTailInterception } from '../src/client/intercept.tsx'
import type { Context } from '../src/context-types.ts'
interface RegisteredSlot {
options: Record<string, unknown>
component: unknown
}
/**
* A structural fake of the client slots service. `declared` selects the
* timing: already-on-ledger (callback runs synchronously) vs. pending
* (callback runs on `declare()`, unless the controller was disposed first).
*/
const fakeSlots = (declared: boolean) => {
const registered: RegisteredSlot[] = []
const disposals: number[] = []
const pendings: Array<() => void> = []
return {
registered,
disposals,
pendings,
slots: {
register: (options: Record<string, unknown>, component: unknown) => {
registered.push({ options, component })
return () => { disposals.push(1) }
},
inject: (key: string, callback: () => () => void) => {
if (key !== 'conversation.chat.turnTail') {
throw new Error(`unexpected injected key "${key}"`)
}
let active: (() => void) | undefined
let stopped = false
const run = (): void => {
if (stopped || active !== undefined) return
active = callback()
}
if (declared) run()
else pendings.push(run)
return () => {
stopped = true
active?.()
active = undefined
}
},
},
}
}
/** A produced-files owner currency: one closing assistant seq + its nodes. */
const producedOwner = (paths: string[]): unknown => ({
nodes: [
{ kind: 'assistant', seq: 1, turn: 1 },
...paths.map(path => ({
kind: 'tool-result', isError: false, callView: { card: 'diff', locations: [{ path }] },
})),
{ kind: 'assistant', seq: 2, turn: 1 },
],
seq: 2,
})
const emptyOwner = (): unknown => ({ nodes: [{ kind: 'assistant', seq: 1, turn: 1 }], seq: 1 })
/** The minimal client-context fake the registration (and its seats) touch. */
const clientCtx = (slots: unknown): Context => {
const betterSidebar = { openTab: vi.fn() }
return {
slots,
sessions: {
list: { getSnapshot: () => ({ current: 's1', byId: { s1: { id: 's1', cwd: '/w', displayTitle: 's1' } } }) },
},
betterSidebar,
get: (name: string) => name === 'betterSidebar' ? betterSidebar : undefined,
} as unknown as Context
}
describe('turn-tail interception registration (issue #15)', () => {
it('registers through slots.inject and lands once the slot is already declared', () => {
const fake = fakeSlots(true)
const store = createSidebarStore()
const restore = registerTurnTailInterception(clientCtx(fake.slots), store)
// Exactly one registration, with the takeover descriptor.
expect(fake.registered).toHaveLength(1)
const { options, component } = fake.registered[0]!
expect(options.name).toBe('conversation.chat.turnTail')
expect(options.priority).toBe(-1)
expect(options.registrant).toBe('dsh-better-sidebar')
expect(options.select).toBeTypeOf('function')
expect(options.inject).toBeTypeOf('function')
expect(component).toBeTypeOf('function')
// Disposal removes the registration; the disposer is idempotent.
restore()
expect(fake.disposals).toHaveLength(1)
restore()
expect(fake.disposals).toHaveLength(1)
})
it('waits for the host declaration instead of throwing (the pre-fix race)', () => {
const fake = fakeSlots(false)
const store = createSidebarStore()
const restore = registerTurnTailInterception(clientCtx(fake.slots), store)
// The slot is undeclared: nothing registered yet, no error.
expect(fake.registered).toHaveLength(0)
// The host's ui-conversation commits the declaration → the entry lands.
expect(fake.pendings).toHaveLength(1)
fake.pendings[0]!()
expect(fake.registered).toHaveLength(1)
// A later re-declaration does not double-register while the entry is live.
fake.pendings[0]!()
expect(fake.registered).toHaveLength(1)
restore()
expect(fake.disposals).toHaveLength(1)
})
it('a disposal before the declaration cancels the wait permanently', () => {
const fake = fakeSlots(false)
const store = createSidebarStore()
const restore = registerTurnTailInterception(clientCtx(fake.slots), store)
restore()
// The declaration arrives after the controller was disposed: ignored.
fake.pendings[0]!()
expect(fake.registered).toHaveLength(0)
})
it('declines the takeover while the editor tab is disabled in the settings', () => {
const fake = fakeSlots(true)
const store = createSidebarStore()
const restore = registerTurnTailInterception(clientCtx(fake.slots), store)
const select = fake.registered[0]!.options.select as (owner: unknown) => unknown
// Enabled (default): a produced turn claims the chain; an empty one declines.
expect(select(producedOwner(['a.ts', 'b.ts']))).toEqual(['a.ts', 'b.ts'])
expect(select(emptyOwner())).toBeNull()
// The engine Turn data path (the real owner currency: { turn, seq,
// openFile }) claims through the deliverables record too.
expect(select({
turn: { data: { get: (key: string) => key === 'deliverables' ? { produced: [{ seq: 1, path: 'a.ts' }] } : undefined } },
seq: 1,
})).toEqual(['a.ts'])
// Editor tab disabled: even a produced turn falls back to the default
// deliverables row (chips that cannot open must not be offered).
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { editor: false } })
expect(select(producedOwner(['a.ts']))).toBeNull()
restore()
})
it('wires the openInSidebar and onShowInFolder seats', () => {
const fake = fakeSlots(true)
const ctx = clientCtx(fake.slots)
const store = createSidebarStore()
const restore = registerTurnTailInterception(ctx, store)
const inject = fake.registered[0]!.options.inject as (sessionId: string) => {
openInSidebar: (path: string) => void
onShowInFolder: (files: readonly string[]) => void
}
// The seat hands the session-scoped opener to the chips row.
const seat = inject('s1')
expect(seat.openInSidebar).toBeTypeOf('function')
seat.openInSidebar('/w/src/a.ts')
expect(ctx.betterSidebar.openTab).toHaveBeenCalledWith({
type: 'editor',
title: 'a.ts',
path: '/w/src/a.ts',
id: 'editor:/w/src/a.ts',
})
// The show-in-folder seat reveals the produced files in the files window
// (the editor home tab) — the panel expands and the rows highlight.
expect(seat.onShowInFolder).toBeTypeOf('function')
seat.onShowInFolder(['/w/src/a.ts'])
expect(ctx.betterSidebar.openTab).toHaveBeenLastCalledWith(expect.objectContaining({
type: 'editor',
}))
restore()
})
})