diff --git a/src/client/SubagentView.tsx b/src/client/SubagentView.tsx index 5ea2f74..3f28979 100644 --- a/src/client/SubagentView.tsx +++ b/src/client/SubagentView.tsx @@ -715,7 +715,7 @@ export function SubagentView(props: { try { sessions.openSubagent?.(address) } catch (error) { - console.warn('[dsh-better-sidebar] openSubagent failed:', error) + console.error('[dsh-better-sidebar] openSubagent failed:', error) } }, [sessions, onOpenChild]) @@ -725,7 +725,7 @@ export function SubagentView(props: { try { sessions.open?.(rootId) } catch (error) { - console.warn('[dsh-better-sidebar] open session failed:', error) + console.error('[dsh-better-sidebar] open session failed:', error) } }, [sessions, rootId]) diff --git a/src/client/api.ts b/src/client/api.ts index 206fafc..e780f28 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -129,6 +129,25 @@ export type TerminalDepsStatus = note?: string } +/** + * Parse one `/sidebar` JSON response envelope into its value. A non-ok + * status, an unparseable body, or any shape other than `{ok: true, value}` + * surfaces as {@link SidebarApiError} carrying the wire code (falling back + * to the HTTP status). Shared by the JSON api route and the raw upload + * route, whose envelopes are identical. + */ +async function readEnvelope(response: Response): Promise { + const parsed: { ok?: boolean; value?: unknown; error?: { code?: string; message?: string } } | null + = await response.json().catch(() => null) + if (!response.ok || parsed === null || parsed.ok !== true || parsed.value === undefined) { + throw new SidebarApiError( + parsed?.error?.code ?? 'http', + parsed?.error?.message ?? `HTTP ${response.status}`, + ) + } + return parsed.value as T +} + async function call(method: string, payload: Record, signal?: AbortSignal): Promise { let response: Response try { @@ -141,15 +160,7 @@ async function call(method: string, payload: Record, signal? } catch (error) { throw new SidebarApiError('network', error instanceof Error ? error.message : String(error)) } - const parsed: { ok?: boolean; value?: unknown; error?: { code?: string; message?: string } } | null - = await response.json().catch(() => null) - if (!response.ok || parsed === null || parsed.ok !== true || parsed.value === undefined) { - throw new SidebarApiError( - parsed?.error?.code ?? 'http', - parsed?.error?.message ?? `HTTP ${response.status}`, - ) - } - return parsed.value as T + return readEnvelope(response) } /** @@ -180,15 +191,7 @@ async function fetchUpload( if (error instanceof DOMException && error.name === 'AbortError') throw error throw new SidebarApiError('network', error instanceof Error ? error.message : String(error)) } - const parsed: { ok?: boolean; value?: unknown; error?: { code?: string; message?: string } } | null - = await response.json().catch(() => null) - if (!response.ok || parsed === null || parsed.ok !== true || parsed.value === undefined) { - throw new SidebarApiError( - parsed?.error?.code ?? 'http', - parsed?.error?.message ?? `HTTP ${response.status}`, - ) - } - return parsed.value as T + return readEnvelope(response) } /** One request's session scope: the conversation id plus its cwd when known. */ diff --git a/src/client/changes/GitLens.tsx b/src/client/changes/GitLens.tsx index 71b7324..106d06e 100644 --- a/src/client/changes/GitLens.tsx +++ b/src/client/changes/GitLens.tsx @@ -64,6 +64,12 @@ function refNames(refs: string): string[] { )] } +/** One thrown value as display text (every error banner/row here normalizes + * through this so non-Error rejections never render as '[object Object]'). */ +function errorMessage(reason: unknown): string { + return reason instanceof Error ? reason.message : String(reason) +} + /** The pending destructive action (discard / revert / cherry-pick), gated by a confirm modal. */ interface ConfirmState { title: string @@ -157,7 +163,7 @@ export function GitLens(props: GitLensProps) { setLogEnded(logResult.length < LOG_BATCH) } catch (reason) { if (options.generation === refreshGeneration.current) { - setError(reason instanceof Error ? reason.message : String(reason)) + setError(errorMessage(reason)) } } finally { if (options.loading && options.generation === refreshGeneration.current) setLoading(false) @@ -219,7 +225,7 @@ export function GitLens(props: GitLensProps) { await refreshTarget(target, { loading: !silent, generation }) } catch (reason) { if (generation === refreshGeneration.current) { - setError(reason instanceof Error ? reason.message : String(reason)) + setError(errorMessage(reason)) if (!silent) setLoading(false) } } finally { @@ -289,7 +295,7 @@ export function GitLens(props: GitLensProps) { if (next.length < LOG_BATCH) setLogEnded(true) } catch (reason) { if (generation === refreshGeneration.current && target === chosenPathRef.current) { - setCommitError(`${t('historyLoadError')}: ${reason instanceof Error ? reason.message : String(reason)}`) + setCommitError(`${t('historyLoadError')}: ${errorMessage(reason)}`) } } finally { if (generation === refreshGeneration.current && target === chosenPathRef.current) setLogLoadingMore(false) @@ -355,7 +361,7 @@ export function GitLens(props: GitLensProps) { setCommitMsg('') await refresh() } catch (reason) { - setCommitError(reason instanceof Error ? reason.message : String(reason)) + setCommitError(errorMessage(reason)) } finally { setBusy(false) } @@ -369,7 +375,7 @@ export function GitLens(props: GitLensProps) { await api.gitCheckout(gitScope, branch, selectedWorktree) await refresh() } catch (reason) { - setCommitError(`${t('checkoutError')}: ${reason instanceof Error ? reason.message : String(reason)}`) + setCommitError(`${t('checkoutError')}: ${errorMessage(reason)}`) } finally { setBusy(false) } @@ -384,7 +390,7 @@ export function GitLens(props: GitLensProps) { await confirmState.onConfirm() await refresh() } catch (reason) { - setCommitError(reason instanceof Error ? reason.message : String(reason)) + setCommitError(errorMessage(reason)) } finally { setBusy(false) } diff --git a/src/client/lang.ts b/src/client/lang.ts index af83e23..424ddc0 100644 --- a/src/client/lang.ts +++ b/src/client/lang.ts @@ -48,14 +48,12 @@ import { pascal } from '@codemirror/legacy-modes/mode/pascal' import { vb } from '@codemirror/legacy-modes/mode/vb' import { vhdl } from '@codemirror/legacy-modes/mode/vhdl' import { stex } from '@codemirror/legacy-modes/mode/stex' +import { extOf } from './paths.ts' -/** The lowercased file extension of a path ('' when none). */ -export function extOf(path: string): string { - const at = path.lastIndexOf('.') - if (at === -1) return '' - const base = path.slice(at + 1).toLowerCase() - return base.includes('/') || base.includes('\\') ? '' : base -} +// Re-exported for the established import surface (tests import extOf from +// this module); the implementation lives in the dependency-free paths.ts so +// core-bundle modules can share it without pulling the CodeMirror packages. +export { extOf } /** Language key for an extension, or null for plain text. Pure (tested). */ export function languageKeyForExt(ext: string): string | null { diff --git a/src/client/paths.ts b/src/client/paths.ts index 7712aa1..e43ea10 100644 --- a/src/client/paths.ts +++ b/src/client/paths.ts @@ -70,3 +70,16 @@ export function baseName(path: string): string { const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) return at === -1 ? path : path.slice(at + 1) } + +/** + * The lowercased file extension of a path ('' when none). The dot must sit + * inside the last segment — a dot in a directory name is not an extension. + * Shared by the editor language mapping (lang.ts) and the viewer registry's + * extension matching (service.ts), which both live in the core bundle. + */ +export function extOf(path: string): string { + const at = path.lastIndexOf('.') + if (at === -1) return '' + const base = path.slice(at + 1).toLowerCase() + return base.includes('/') || base.includes('\\') ? '' : base +} diff --git a/src/client/service.ts b/src/client/service.ts index 45562f9..54856ff 100644 --- a/src/client/service.ts +++ b/src/client/service.ts @@ -27,6 +27,7 @@ import { type SidebarSnapshot, type SidebarState, type SidebarStore, type SidebarTab, } from './state.ts' import { isNarrowWidth } from './breakpoints.ts' +import { extOf } from './paths.ts' import type { SessionScope } from './api.ts' import type { SidebarPrefs } from '../prefs-shared.ts' @@ -427,14 +428,6 @@ export interface BetterSidebarService { openFile(scope: SessionScope, path: string, title?: string): void } -/** Extract the lowercase extension without leading dot from a path. */ -function extOfPath(path: string): string { - const at = path.lastIndexOf('.') - if (at === -1) return '' - const base = path.slice(at + 1).toLowerCase() - return base.includes('/') || base.includes('\\') ? '' : base -} - /** The file name of a path (both separators). */ function baseNameOf(path: string): string { const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) @@ -571,7 +564,7 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe const isViewerEnabled = (id: string): boolean => store.getPrefs().viewersEnabled[id] !== false const matchFileViewer = (path: string, head?: Uint8Array): FileViewerDescriptor | undefined => { - const ext = extOfPath(path) + const ext = extOf(path) // Single pass in priority order (descending; stable for equal // priorities — insertion order). Each descriptor gets first refusal in // its own turn: `detect` (when head bytes are available) beats its own diff --git a/src/client/subagent-detect.ts b/src/client/subagent-detect.ts index f45b3ad..a8668ef 100644 --- a/src/client/subagent-detect.ts +++ b/src/client/subagent-detect.ts @@ -9,23 +9,20 @@ * - {@link countSubagentDescendants}: uninterrupted subagent-origin lineage * totals (mirror of the official `indexSubagentDescendants` over the * plugin's own summary rows). + * + * The lineage walks themselves ({@link isSideThreadSummary}, {@link + * rootAncestor}, {@link countSubagentDescendants}) live in + * ./subagent-lineage.ts — the single shared walk implementation — and are + * re-exported below for their established import sites. */ import type { SidebarSessionList, - SidebarSessionSummary, SidebarSubagentCatalog, } from '../context-types.ts' -import { SIDE_LABEL_PREFIX } from '../sidechat-core.ts' +import { countSubagentDescendants, isSideThreadSummary, rootAncestor } from './subagent-lineage.ts' -/** - * Side Chat threads ride the subagent origin (main-list hiding + the RPC - * ownership fence) but they are NOT subagent topology: they carry the - * durable 'Side: ' label and live as sidebar tabs. Excluding them here - * keeps the auto-open trigger and the Subagent page counts clean. - */ -export function isSideThreadSummary(summary: SidebarSessionSummary): boolean { - return summary.origin === 'subagent' && summary.displayTitle.startsWith(SIDE_LABEL_PREFIX) -} +export { countSubagentDescendants, isSideThreadSummary, rootAncestor } +export type { SubagentDescendantTotals } from './subagent-lineage.ts' /** Count the direct subagent children of one session (durable `origin` rows). */ export function directSubagentCount( @@ -40,28 +37,6 @@ export function directSubagentCount( return count } -/** - * The main agent of the current session's tree: walk the durable parent - * chain upward until the first non-subagent session. The Subagent page shows - * THIS root's full topology regardless of how deep the current selection is - * (a session whose row is still hydrating, or a broken chain, degrades to - * the session itself). - */ -export function rootAncestor( - byId: SidebarSessionList['byId'], - sessionId: string | undefined, -): string | undefined { - if (sessionId === undefined) return undefined - const seen = new Set() - let current: SidebarSessionSummary | undefined = byId[sessionId] - while (current !== undefined && current.origin === 'subagent' - && current.parentId !== undefined && !seen.has(current.id)) { - seen.add(current.id) - current = byId[current.parentId] - } - return current?.id ?? sessionId -} - /** * Collect every catalog branch (an entry with `hasChildren`) reachable from * the root — the set of catalogs the always-expanded topology consumes. @@ -101,37 +76,3 @@ export function detectNewDirectSubagent( return directSubagentCount(prev.byId, sessionId) === 0 && directSubagentCount(next.byId, sessionId) > 0 } - -/** Descendant totals of one session through an uninterrupted subagent-origin chain. */ -export interface SubagentDescendantTotals { - count: number - runningCount: number -} - -/** - * Index every subagent descendant under each ancestor it reaches through an - * uninterrupted subagent-origin chain (same semantics as the official - * `indexSubagentDescendants`; cycles fail soft). - */ -export function countSubagentDescendants( - byId: SidebarSessionList['byId'], - sessionId: string, -): SubagentDescendantTotals { - const totals: SubagentDescendantTotals = { count: 0, runningCount: 0 } - for (const descendant of Object.values(byId)) { - if (descendant.origin !== 'subagent' || isSideThreadSummary(descendant)) continue - const seen = new Set() - let current: SidebarSessionSummary | undefined = descendant - while (current?.origin === 'subagent' && current.parentId !== undefined - && !seen.has(current.id)) { - seen.add(current.id) - if (current.parentId === sessionId) { - totals.count += 1 - if (descendant.running === true) totals.runningCount += 1 - break - } - current = byId[current.parentId] - } - } - return totals -} diff --git a/src/client/subagent-jobs.ts b/src/client/subagent-jobs.ts index 4962392..76af4a6 100644 --- a/src/client/subagent-jobs.ts +++ b/src/client/subagent-jobs.ts @@ -8,11 +8,11 @@ */ import type { SidebarSessionList, - SidebarSessionSummary, SidebarJobStatus, SidebarJobView, } from '../context-types.ts' import type { CopyKey } from './locales.ts' +import { treeSessionIds } from './subagent-lineage.ts' /** One row of the jobs section: the job plus its owning session's title. */ export interface TreeJob { @@ -26,36 +26,10 @@ export function isJobLive(job: SidebarJobView): boolean { return job.status === 'running' || job.status === 'stopping' } -/** - * Every session id of the topology tree rooted at `rootId` (the root plus - * each session whose uninterrupted subagent-origin chain reaches it — same - * lineage semantics as {@link countSubagentDescendants}; cycles fail soft). - * Sessions outside the tree (orphans, other trees) are excluded, so the - * jobs section never shows foreign work. - */ -export function treeSessionIds( - byId: SidebarSessionList['byId'], - rootId: string | undefined, -): Set { - const ids = new Set() - if (rootId === undefined) return ids - for (const summary of Object.values(byId)) { - const seen = new Set() - let current: SidebarSessionSummary | undefined = summary - let reachesRoot = false - while (current !== undefined && !seen.has(current.id)) { - seen.add(current.id) - if (current.id === rootId) { - reachesRoot = true - break - } - if (current.origin !== 'subagent' || current.parentId === undefined) break - current = byId[current.parentId] - } - if (reachesRoot) ids.add(summary.id) - } - return ids -} +// The lineage walk itself lives in ./subagent-lineage.ts (the single shared +// subagent-origin chain implementation); re-exported for the established +// import sites of this module. +export { treeSessionIds } /** * Whether a NEW background job appeared for one session between two diff --git a/src/client/subagent-lineage.ts b/src/client/subagent-lineage.ts new file mode 100644 index 0000000..d4cce66 --- /dev/null +++ b/src/client/subagent-lineage.ts @@ -0,0 +1,131 @@ +/** + * The shared subagent-lineage walks over the sessions list feed (structural + * mirror world — no runtime imports). Every public walk here answers a + * topology question by following the DURABLE subagent-origin chain upward + * (child → parent → …); one private generator ({@link subagentOriginChain}) + * is the single implementation of that walk, seen-set guarded so cycles fail + * soft (the walk just stops; nothing throws). + * + * subagent-detect.ts and subagent-jobs.ts re-export these for their + * established import sites — the auto-activation triggers and the Subagent + * page both consume them through the original module paths. + */ +import type { SidebarSessionList, SidebarSessionSummary } from '../context-types.ts' +import { SIDE_LABEL_PREFIX } from '../sidechat-core.ts' + +/** + * Side Chat threads ride the subagent origin (main-list hiding + the RPC + * ownership fence) but they are NOT subagent topology: they carry the + * durable 'Side: ' label and live as sidebar tabs. Excluding them here + * keeps the auto-open trigger and the Subagent page counts clean. + */ +export function isSideThreadSummary(summary: SidebarSessionSummary): boolean { + return summary.origin === 'subagent' && summary.displayTitle.startsWith(SIDE_LABEL_PREFIX) +} + +/** + * Yield `start`'s uninterrupted subagent-origin chain upward: each summary + * while it is a subagent with a known parent, then its parent row, and so + * on. A revisited id ends the walk (cycles fail soft); the row that BREAKS + * the chain — the first non-subagent ancestor, or a parent id with no row — + * is deliberately NOT yielded: callers observe it through where the walk + * stopped (the row after the last yielded one is `byId[last.parentId]`). + */ +function* subagentOriginChain( + byId: SidebarSessionList['byId'], + start: SidebarSessionSummary, +): Generator { + const seen = new Set() + let current: SidebarSessionSummary | undefined = start + while (current?.origin === 'subagent' && current.parentId !== undefined && !seen.has(current.id)) { + seen.add(current.id) + yield current + current = byId[current.parentId] + } +} + +/** + * The main agent of the current session's tree: walk the durable parent + * chain upward until the first non-subagent session. The Subagent page shows + * THIS root's full topology regardless of how deep the current selection is + * (a session whose row is still hydrating, or a broken chain, degrades to + * the session itself). + */ +export function rootAncestor( + byId: SidebarSessionList['byId'], + sessionId: string | undefined, +): string | undefined { + if (sessionId === undefined) return undefined + const start = byId[sessionId] + // A hydrating (or unknown) row degrades to the session itself. + if (start === undefined) return sessionId + let last: SidebarSessionSummary | undefined + for (const node of subagentOriginChain(byId, start)) last = node + if (last === undefined) return start.id + // The walk stopped one row past the last chain node: that parent row is + // the root (still missing from the feed → the session itself). Every + // yielded node carries a parent id (the generator's walk condition). + return byId[last.parentId!]?.id ?? sessionId +} + +/** Descendant totals of one session through an uninterrupted subagent-origin chain. */ +export interface SubagentDescendantTotals { + count: number + runningCount: number +} + +/** + * Index every subagent descendant under each ancestor it reaches through an + * uninterrupted subagent-origin chain (same semantics as the official + * `indexSubagentDescendants`; cycles fail soft). + */ +export function countSubagentDescendants( + byId: SidebarSessionList['byId'], + sessionId: string, +): SubagentDescendantTotals { + const totals: SubagentDescendantTotals = { count: 0, runningCount: 0 } + for (const descendant of Object.values(byId)) { + if (descendant.origin !== 'subagent' || isSideThreadSummary(descendant)) continue + for (const node of subagentOriginChain(byId, descendant)) { + if (node.parentId === sessionId) { + totals.count += 1 + if (descendant.running === true) totals.runningCount += 1 + break + } + } + } + return totals +} + +/** + * Every session id of the topology tree rooted at `rootId` (the root plus + * each session whose uninterrupted subagent-origin chain reaches it — same + * lineage semantics as {@link countSubagentDescendants}; cycles fail soft). + * Sessions outside the tree (orphans, other trees) are excluded, so the + * jobs section never shows foreign work. + */ +export function treeSessionIds( + byId: SidebarSessionList['byId'], + rootId: string | undefined, +): Set { + const ids = new Set() + // A root with no row in the feed cannot be reached by walking: only real + // rows anchor tree membership (a parent pointer naming a hydrating root + // does not pull the orphaned branch into the tree). + if (rootId === undefined || byId[rootId] === undefined) return ids + for (const summary of Object.values(byId)) { + // The root row itself belongs to its own tree even though it is not a + // subagent (it is usually the main agent). + if (summary.id === rootId) { + ids.add(summary.id) + continue + } + for (const node of subagentOriginChain(byId, summary)) { + if (node.parentId === rootId) { + ids.add(summary.id) + break + } + } + } + return ids +} diff --git a/src/sidechat-core.ts b/src/sidechat-core.ts index a818ef6..51b2a4b 100644 --- a/src/sidechat-core.ts +++ b/src/sidechat-core.ts @@ -245,11 +245,6 @@ export function buildSidechatInheritance(events: readonly SidechatLogEvent[]): S return { seed, snapshot: null } } -/** The seed half of {@link buildSidechatInheritance} (test convenience). */ -export function sidechatSeed(events: readonly SidechatLogEvent[]): SeedEvent[] { - return buildSidechatInheritance(events).seed -} - /** * Structured text snapshot of the parent's OPEN turn (from its `turn/start` * to the log tail): the accumulated assistant/reasoning output verbatim