diff --git a/src/client/DiffTab.tsx b/src/client/DiffTab.tsx index 345c2fb..585c27c 100644 --- a/src/client/DiffTab.tsx +++ b/src/client/DiffTab.tsx @@ -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). */ diff --git a/src/client/EditorHost.tsx b/src/client/EditorHost.tsx index 2181739..ab8da93 100644 --- a/src/client/EditorHost.tsx +++ b/src/client/EditorHost.tsx @@ -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) } /** diff --git a/src/client/TreePanel.tsx b/src/client/TreePanel.tsx index 8134ac1..48ebac2 100644 --- a/src/client/TreePanel.tsx +++ b/src/client/TreePanel.tsx @@ -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, diff --git a/src/client/builtins/tabs.tsx b/src/client/builtins/tabs.tsx index 55e12ec..70b2b71 100644 --- a/src/client/builtins/tabs.tsx +++ b/src/client/builtins/tabs.tsx @@ -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} /> ), diff --git a/src/client/changes/DiffPane.tsx b/src/client/changes/DiffPane.tsx index e7c1d80..3c576f6 100644 --- a/src/client/changes/DiffPane.tsx +++ b/src/client/changes/DiffPane.tsx @@ -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' diff --git a/src/client/changes/GitLens.tsx b/src/client/changes/GitLens.tsx index 21840a6..d4b43cd 100644 --- a/src/client/changes/GitLens.tsx +++ b/src/client/changes/GitLens.tsx @@ -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' diff --git a/src/client/index.tsx b/src/client/index.tsx index d5c82a3..9b622d5 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -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 { diff --git a/src/client/intercept.tsx b/src/client/intercept.tsx deleted file mode 100644 index b4f130b..0000000 --- a/src/client/intercept.tsx +++ /dev/null @@ -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 ( -
- {t('produced')} - {shown.map(path => { - const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) - const name = at === -1 ? path : path.slice(at + 1) - return ( - - ) - })} - {hidden > 0 && +{hidden}} - {hidden > 0 && ( - - )} -
- ) -} - -/** - * 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)) -} diff --git a/src/client/native/index.ts b/src/client/native/index.ts index cd574ef..59f1c10 100644 --- a/src/client/native/index.ts +++ b/src/client/native/index.ts @@ -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() diff --git a/src/client/paths.ts b/src/client/paths.ts index e43ea10..7a26f52 100644 --- a/src/client/paths.ts +++ b/src/client/paths.ts @@ -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}` +} diff --git a/src/client/produced-files.ts b/src/client/produced-files.ts deleted file mode 100644 index 23a2f1e..0000000 --- a/src/client/produced-files.ts +++ /dev/null @@ -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() - 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() - 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}` -} diff --git a/src/client/sidebar-file.ts b/src/client/sidebar-file.ts new file mode 100644 index 0000000..8ad0f96 --- /dev/null +++ b/src/client/sidebar-file.ts @@ -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}` }) +} diff --git a/src/client/sidebar.module.css b/src/client/sidebar.module.css index a72905a..a42c6e6 100644 --- a/src/client/sidebar.module.css +++ b/src/client/sidebar.module.css @@ -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 diff --git a/tests/native-surface.spec.ts b/tests/native-surface.spec.ts index a82f874..9c1fbb6 100644 --- a/tests/native-surface.spec.ts +++ b/tests/native-surface.spec.ts @@ -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() }) diff --git a/tests/paths.spec.ts b/tests/paths.spec.ts index cee6fec..029cbe4 100644 --- a/tests/paths.spec.ts +++ b/tests/paths.spec.ts @@ -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', () => { diff --git a/tests/produced-files.spec.ts b/tests/produced-files.spec.ts deleted file mode 100644 index 0afb9c5..0000000 --- a/tests/produced-files.spec.ts +++ /dev/null @@ -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') - }) -}) diff --git a/tests/turn-tail-intercept.spec.ts b/tests/turn-tail-intercept.spec.ts deleted file mode 100644 index 352100c..0000000 --- a/tests/turn-tail-intercept.spec.ts +++ /dev/null @@ -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 - 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, 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() - }) -})