feat(settings): 侧边栏设置声明式化 — 注册表驱动的开关清单 + 内置支持插头化
- SidebarPrefs 新增 tabsEnabled / viewersEnabled 开放 map(缺省 = 启用), PrefsSchema 用 z.dict 校验,旧文档零迁移 - TabDescriptor/FileViewerDescriptor 新增声明式设置面:viewer 补 title/icon, 两者补 settings.toggles(子代理声明 autoOpenSubagent、终端声明 agentTerminalTools) - 服务新增 isTabEnabled/isViewerEnabled;openTab 拒绝禁用类型、matchFileViewer 跳过禁用 viewer - SideCardSection 重写为注册表驱动清单:侧边栏内容(图标+类型id+开关+嵌套开关) + 文件预览(图标+标题+扩展名+开关),开关持久化 + 乐观提交回滚 - 消费点 gating:+ 菜单过滤、子代理自动展开、agent 终端 reconcile、 产出文件拦截(editor 关闭时回落默认行)、新会话 explorer 预置 - 内置 viewer 补 6 个手绘 outline 图标;store 快照携带 prefs 使开关变更即时生效 - 测试:service gating / builtins 声明 / parsePrefs map 校验 / schema 断言 / 新增 SideCardSection renderToString 规格 - 文档:README 设置小节、AGENTS.md 描述符与服务方法、新增设计文档;版本 0.5.0
This commit is contained in:
@@ -25,6 +25,43 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Nested declarative toggle (a tab's related setting): indented child row. */
|
||||
.subRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 10px 0 10px 28px;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Inventory section heading (侧边栏内容 / 文件预览). */
|
||||
.sectionHeading {
|
||||
padding: 18px 0 6px;
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* Icon of one inventory row, inline with the title. */
|
||||
.rowIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* Title with the icon beside it. */
|
||||
.titleLine {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rowText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
+159
-52
@@ -1,20 +1,30 @@
|
||||
/**
|
||||
* "Side card" settings section: the user-facing preferences for the sidebar
|
||||
* panel, rendered natively in the DSH Settings shell (nav label "Side card").
|
||||
* Three rows:
|
||||
* - new conversations open the panel by default (native checkbox),
|
||||
* - the default panel width as a percent of the window (number input + %),
|
||||
* - auto-open the Subagent page when a new subagent appears (native checkbox,
|
||||
* on by default).
|
||||
*
|
||||
* The section is DECLARATIVE — it renders the enable/disable inventory from
|
||||
* the sidebar service's registries instead of hardcoding rows:
|
||||
* - 常规: new conversations open the panel by default (native checkbox),
|
||||
* and the default panel width as a percent of the window (number input).
|
||||
* - 侧边栏内容: one row per REGISTERED tab type (built-ins and external
|
||||
* plugins alike) — icon + title + type id + an on/off switch persisted in
|
||||
* `prefs.tabsEnabled[id]`. A tab's `settings.toggles` declaration renders
|
||||
* as nested switches under its row while the tab is enabled (e.g. the
|
||||
* Subagent page's "auto-open when a subagent appears").
|
||||
* - 文件预览: one row per REGISTERED file viewer — icon + title + the
|
||||
* extensions it covers + an on/off switch persisted in
|
||||
* `prefs.viewersEnabled[id]`.
|
||||
*
|
||||
* Writes ride the plugin's own fenced settings route (the host calls the
|
||||
* settings seam in-process — the DSH settings RPC domain does not serve
|
||||
* third-party namespaces to configuration clients); the shared SidebarStore
|
||||
* is refreshed on success so the very next brand-new session seeds from the
|
||||
* new values. Any failure reverts the optimistic UI and shows the wire error
|
||||
* inline — a broken settings surface never crashes the shell.
|
||||
* new values and the sidebar's consumption points (the + menu, derived
|
||||
* flows) re-render immediately. Any failure reverts the optimistic UI and
|
||||
* shows the wire error inline — a broken settings surface never crashes the
|
||||
* shell.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { Input } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
// Type-only: pulls the settings shell's SlotMap merges ('settings.section').
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
@@ -29,11 +39,18 @@ import { api } from './api.ts'
|
||||
import { parsePrefs } from './prefs.ts'
|
||||
import { t } from './locales.ts'
|
||||
import type { SidebarStore } from './state.ts'
|
||||
import type {
|
||||
BetterSidebarService,
|
||||
FileViewerDescriptor,
|
||||
SidebarSettingToggle,
|
||||
TabDescriptor,
|
||||
} from './service.ts'
|
||||
import css from './SideCardSection.module.css'
|
||||
|
||||
/** Injected business face: the shared store (prefs cache). */
|
||||
/** Injected business face: the shared store (prefs cache) + the sidebar service (registries). */
|
||||
export interface SideCardSectionInjected {
|
||||
store: SidebarStore
|
||||
service: BetterSidebarService
|
||||
}
|
||||
|
||||
/** Full section props: the runtime share plus the injected face. */
|
||||
@@ -47,16 +64,54 @@ function messageOf(error: unknown): string {
|
||||
return `${t('settingsSaveFailed')} ${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
|
||||
/** Resolve an i18n-friendly string-or-function value. */
|
||||
function textOf(value: string | (() => string) | undefined): string {
|
||||
if (value === undefined) return ''
|
||||
return typeof value === 'function' ? value() : value
|
||||
}
|
||||
|
||||
/** Resolve a descriptor icon (ReactNode or size function). */
|
||||
function iconOf(icon: ReactNode | ((size: number) => ReactNode) | undefined, size: number): ReactNode {
|
||||
if (icon === undefined) return null
|
||||
return typeof icon === 'function' ? icon(size) : icon
|
||||
}
|
||||
|
||||
/** Tab inventory order: hidden types (editor/diff) last, then + menu order. */
|
||||
function tabOrder(a: TabDescriptor, b: TabDescriptor): number {
|
||||
if (a.hidden !== b.hidden) return a.hidden === true ? 1 : -1
|
||||
return (a.order ?? 100) - (b.order ?? 100)
|
||||
}
|
||||
|
||||
/** Viewer inventory order: priority desc (the catch-all `code` comes last). */
|
||||
function viewerOrder(a: FileViewerDescriptor, b: FileViewerDescriptor): number {
|
||||
return (b.priority ?? 0) - (a.priority ?? 0)
|
||||
}
|
||||
|
||||
/** Read one boolean pref by declarative key (missing = false). */
|
||||
function prefBool(prefs: SidebarPrefs, key: string): boolean {
|
||||
return (prefs as unknown as Record<string, boolean>)[key] === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the Side card preferences section.
|
||||
* @param props - composed slot props (runtime share + injected store).
|
||||
* @param props - composed slot props (runtime share + injected store/service).
|
||||
* @returns the section element tree.
|
||||
*/
|
||||
export function SideCardSection({ store }: SideCardSectionProps) {
|
||||
export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
const [prefs, setPrefs] = useState<SidebarPrefs>(() => store.getPrefs())
|
||||
const [widthDraft, setWidthDraft] = useState<string>(String(store.getPrefs().defaultWidthPercent))
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// The declarative inventory: the registered tab types and file viewers.
|
||||
// Local state + service.subscribe (registry changes are rare — plugin
|
||||
// load/unload — so a plain effect is enough; no external-store ceremony).
|
||||
const [tabs, setTabs] = useState<TabDescriptor[]>(() => [...service.getTabs()].sort(tabOrder))
|
||||
const [viewers, setViewers] = useState<FileViewerDescriptor[]>(() => [...service.getFileViewers()].sort(viewerOrder))
|
||||
useEffect(() => service.subscribe(() => {
|
||||
setTabs([...service.getTabs()].sort(tabOrder))
|
||||
setViewers([...service.getFileViewers()].sort(viewerOrder))
|
||||
}), [service])
|
||||
|
||||
// The settings document revision (guards concurrent writes). A ref: commits
|
||||
// read the freshest value at execution time, no re-render needed.
|
||||
const revisionRef = useRef<number | undefined>(undefined)
|
||||
@@ -84,7 +139,7 @@ export function SideCardSection({ store }: SideCardSectionProps) {
|
||||
}, [])
|
||||
|
||||
/** Persist one patch through the settings route (serialized, revision-guarded). */
|
||||
const commit = (patch: Partial<SidebarPrefs>): Promise<{ ok: boolean; prefs: SidebarPrefs }> => {
|
||||
const commit = (patch: Record<string, unknown>): Promise<{ ok: boolean; prefs: SidebarPrefs }> => {
|
||||
dirtyRef.current = true
|
||||
const run = inFlightRef.current.then(async () => {
|
||||
const view = await api.settingsUpdate(
|
||||
@@ -114,25 +169,31 @@ export function SideCardSection({ store }: SideCardSectionProps) {
|
||||
setWidthDraft(String(settled.defaultWidthPercent))
|
||||
}
|
||||
|
||||
/** Optimistically flip one boolean pref, then commit (revert on failure). */
|
||||
const togglePref = (patch: Record<string, unknown>): void => {
|
||||
const previous = prefs
|
||||
setPrefs({ ...previous, ...patch } as SidebarPrefs)
|
||||
setError(null)
|
||||
void commit(patch).then(outcome => applyOutcome(previous, outcome))
|
||||
}
|
||||
|
||||
const onToggle = (next: boolean): void => {
|
||||
const previous = prefs
|
||||
setPrefs({ ...previous, openByDefault: next })
|
||||
setError(null)
|
||||
void commit({ openByDefault: next }).then(outcome => applyOutcome(previous, outcome))
|
||||
togglePref({ openByDefault: next })
|
||||
}
|
||||
|
||||
const onToggleSubagent = (next: boolean): void => {
|
||||
const previous = prefs
|
||||
setPrefs({ ...previous, autoOpenSubagent: next })
|
||||
setError(null)
|
||||
void commit({ autoOpenSubagent: next }).then(outcome => applyOutcome(previous, outcome))
|
||||
/** Flip one per-tab enable switch (merge into the tabsEnabled map). */
|
||||
const onToggleTab = (id: string, next: boolean): void => {
|
||||
togglePref({ tabsEnabled: { ...prefs.tabsEnabled, [id]: next } })
|
||||
}
|
||||
|
||||
const onToggleTools = (next: boolean): void => {
|
||||
const previous = prefs
|
||||
setPrefs({ ...previous, agentTerminalTools: next })
|
||||
setError(null)
|
||||
void commit({ agentTerminalTools: next }).then(outcome => applyOutcome(previous, outcome))
|
||||
/** Flip one per-viewer enable switch (merge into the viewersEnabled map). */
|
||||
const onToggleViewer = (id: string, next: boolean): void => {
|
||||
togglePref({ viewersEnabled: { ...prefs.viewersEnabled, [id]: next } })
|
||||
}
|
||||
|
||||
/** Flip one declaratively-declared toggle (a SidebarPrefs boolean field). */
|
||||
const onToggleSetting = (toggle: SidebarSettingToggle, next: boolean): void => {
|
||||
togglePref({ [toggle.key]: next })
|
||||
}
|
||||
|
||||
const commitWidth = (): void => {
|
||||
@@ -149,6 +210,34 @@ export function SideCardSection({ store }: SideCardSectionProps) {
|
||||
void commit({ defaultWidthPercent: clamped }).then(outcome => applyOutcome(previous, outcome))
|
||||
}
|
||||
|
||||
/** One enable/disable row: icon + title + desc + checkbox. */
|
||||
const renderRow = (props: {
|
||||
title: string
|
||||
desc: string
|
||||
icon?: ReactNode
|
||||
checked: boolean
|
||||
label: string
|
||||
onToggle: (next: boolean) => void
|
||||
sub?: boolean
|
||||
}) => (
|
||||
<label className={props.sub === true ? css.subRow : css.row}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.titleLine}>
|
||||
{props.icon !== null && props.icon !== undefined && <span className={css.rowIcon}>{props.icon}</span>}
|
||||
<span className={css.title}>{props.title}</span>
|
||||
</span>
|
||||
<span className={css.desc}>{props.desc}</span>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className={css.toggle}
|
||||
checked={props.checked}
|
||||
aria-label={props.label}
|
||||
onChange={event => { props.onToggle(event.currentTarget.checked) }}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={css.section}>
|
||||
<label className={css.row}>
|
||||
@@ -187,32 +276,50 @@ export function SideCardSection({ store }: SideCardSectionProps) {
|
||||
<span className={css.suffix}>{t('settingsWidthSuffix')}</span>
|
||||
</span>
|
||||
</div>
|
||||
<label className={css.row}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{t('settingsSubagentTitle')}</span>
|
||||
<span className={css.desc}>{t('settingsSubagentDesc')}</span>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className={css.toggle}
|
||||
checked={prefs.autoOpenSubagent}
|
||||
aria-label={t('settingsSubagentTitle')}
|
||||
onChange={event => { onToggleSubagent(event.currentTarget.checked) }}
|
||||
/>
|
||||
</label>
|
||||
<label className={css.row}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{t('settingsToolsTitle')}</span>
|
||||
<span className={css.desc}>{t('settingsToolsDesc')}</span>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className={css.toggle}
|
||||
checked={prefs.agentTerminalTools}
|
||||
aria-label={t('settingsToolsTitle')}
|
||||
onChange={event => { onToggleTools(event.currentTarget.checked) }}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* 侧边栏内容: one row per registered tab type, icon + type id + switch,
|
||||
plus each tab's declaratively-declared nested toggles (shown only
|
||||
while the tab itself is enabled). */}
|
||||
<div className={css.sectionHeading}>{t('settingsTabsTitle')}</div>
|
||||
{tabs.map(tab => (
|
||||
<Fragment key={tab.id}>
|
||||
{renderRow({
|
||||
title: textOf(tab.title),
|
||||
desc: tab.id,
|
||||
icon: iconOf(tab.icon, 16),
|
||||
checked: prefs.tabsEnabled[tab.id] !== false,
|
||||
label: textOf(tab.title),
|
||||
onToggle: (next) => { onToggleTab(tab.id, next) },
|
||||
})}
|
||||
{prefs.tabsEnabled[tab.id] !== false && (tab.settings?.toggles ?? []).map(toggle => (
|
||||
<Fragment key={`${tab.id}:${toggle.key}`}>
|
||||
{renderRow({
|
||||
title: textOf(toggle.title),
|
||||
desc: textOf(toggle.desc),
|
||||
checked: prefBool(prefs, toggle.key),
|
||||
label: textOf(toggle.title),
|
||||
onToggle: (next) => { onToggleSetting(toggle, next) },
|
||||
sub: true,
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
{/* 文件预览: one row per registered file viewer, icon + title + exts + switch. */}
|
||||
<div className={css.sectionHeading}>{t('settingsViewersTitle')}</div>
|
||||
{viewers.map(viewer => (
|
||||
<Fragment key={viewer.id}>
|
||||
{renderRow({
|
||||
title: textOf(viewer.title) || viewer.id,
|
||||
desc: viewer.exts.length === 0 ? t('settingsViewerCatchAll') : viewer.exts.join(' · '),
|
||||
icon: iconOf(viewer.icon, 16),
|
||||
checked: prefs.viewersEnabled[viewer.id] !== false,
|
||||
label: textOf(viewer.title) || viewer.id,
|
||||
onToggle: (next) => { onToggleViewer(viewer.id, next) },
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
{error !== null && (
|
||||
<div className={css.error} role="alert">
|
||||
{error}
|
||||
|
||||
+17
-9
@@ -65,12 +65,14 @@ function TabContent(props: {
|
||||
|
||||
/** The + menu options for the current state, driven by the tab registry.
|
||||
* Hidden tabs (editor/diff) never show; `available` returning false shows
|
||||
* a disabled row (e.g. terminal at capacity) instead of hiding the option. */
|
||||
* a disabled row (e.g. terminal at capacity) instead of hiding the option.
|
||||
* Tabs the user disabled in the side card settings are filtered out
|
||||
* entirely — re-enabling them is the settings page's job. */
|
||||
function buildNewTabOptions(state: SidebarState, ctx: Context, scope: SessionScope): NewTabOption[] {
|
||||
const service = ctx.betterSidebar
|
||||
if (service === undefined) return []
|
||||
return service.getTabs()
|
||||
.filter(d => !d.hidden)
|
||||
.filter(d => !d.hidden && service.isTabEnabled(d.id))
|
||||
.sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
|
||||
.map(d => ({
|
||||
id: d.id,
|
||||
@@ -127,6 +129,8 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
* the same shell without losing the agent's work — capped like the
|
||||
* terminal view's own reconnect loop, so a refused endpoint never spins
|
||||
* forever (the next session switch restarts the loop).
|
||||
* While the terminal tab type is disabled in settings, pushes are
|
||||
* ignored (no auto-added tabs); re-enabling makes the next push converge.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (sessionId === undefined) return
|
||||
@@ -145,7 +149,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
try {
|
||||
const list = JSON.parse(event.data) as Array<{ uuid: string; title: string; command: string; exited: boolean }>
|
||||
if (!Array.isArray(list)) return
|
||||
store.reduce(s => reconcileAgentTerminals(s, list))
|
||||
store.reduce(s => ctx.betterSidebar?.isTabEnabled('terminal') === false
|
||||
? s
|
||||
: reconcileAgentTerminals(s, list))
|
||||
} catch {
|
||||
// Malformed push: ignore (the next push will reconcile).
|
||||
}
|
||||
@@ -171,12 +177,13 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
|
||||
/**
|
||||
* Subagent auto-activation: the moment the current conversation spawns its
|
||||
* FIRST direct subagent (a 0 → N transition on the list feed) and the
|
||||
* "auto open" pref is on, open the panel (if collapsed) and focus the
|
||||
* Subagent page (single-instance: an existing tab is focused, never
|
||||
* duplicated). Switching to a session that already has subagents never
|
||||
* triggers — its baseline starts at the current count — so a deliberate
|
||||
* layout is never fought.
|
||||
* FIRST direct subagent (a 0 → N transition on the list feed), the "auto
|
||||
* open" pref is on, and the Subagent tab type is enabled in settings,
|
||||
* open the panel (if collapsed) and focus the Subagent page
|
||||
* (single-instance: an existing tab is focused, never duplicated).
|
||||
* Switching to a session that already has subagents never triggers — its
|
||||
* baseline starts at the current count — so a deliberate layout is never
|
||||
* fought.
|
||||
*/
|
||||
const listBaselineRef = useRef<SidebarSessionList | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
@@ -185,6 +192,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
if (sessionId === undefined || prev === undefined) return
|
||||
if (!detectNewDirectSubagent(prev, sessionList, sessionId)) return
|
||||
if (!store.getPrefs().autoOpenSubagent) return
|
||||
if (ctx.betterSidebar?.isTabEnabled('subagent') === false) return
|
||||
store.reduce(s => s.panelOpen ? s : togglePanel(s))
|
||||
ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') })
|
||||
}, [sessionList, sessionId, store, ctx])
|
||||
|
||||
@@ -80,6 +80,15 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
|
||||
icon: (size: number) => <IconThinkOutline16 size={size} />,
|
||||
order: 30,
|
||||
single: true,
|
||||
// Declarative settings: the auto-open switch renders under this row in
|
||||
// the Side card settings page (the Subagent page's own related setting).
|
||||
settings: {
|
||||
toggles: [{
|
||||
key: 'autoOpenSubagent',
|
||||
title: () => t('settingsSubagentTitle'),
|
||||
desc: () => t('settingsSubagentDesc'),
|
||||
}],
|
||||
},
|
||||
component: ({ ctx, scope, visible, onSubagentJump }) => (
|
||||
<SubagentView
|
||||
sessionId={scope.sessionId}
|
||||
@@ -95,6 +104,16 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
|
||||
icon: (size: number) => <IconTerminalOutline16 size={size} />,
|
||||
order: 40,
|
||||
available: (_ctx, _scope, state) => uiTerminalCount(state) < TERMINAL_LIMIT,
|
||||
// Declarative settings: the model-facing terminal tools switch renders
|
||||
// under this row in the Side card settings page (the Terminal page's
|
||||
// own related setting; the host gates the toolset on it independently).
|
||||
settings: {
|
||||
toggles: [{
|
||||
key: 'agentTerminalTools',
|
||||
title: () => t('settingsToolsTitle'),
|
||||
desc: () => t('settingsToolsDesc'),
|
||||
}],
|
||||
},
|
||||
createTab: (state) => {
|
||||
const count = uiTerminalCount(state)
|
||||
if (count >= TERMINAL_LIMIT) return null
|
||||
|
||||
@@ -5,13 +5,27 @@
|
||||
* `binary-download` viewer sniffs NUL bytes via `detect` for unknown
|
||||
* binaries and serves doc/xls/ppt by extension; `code` is the catch-all
|
||||
* (`exts: []`, lowest priority) that claims any file no other viewer did.
|
||||
*
|
||||
* Every viewer carries the declarative settings-surface fields — `title`
|
||||
* and `icon` — so the Side card settings page can render the enable/disable
|
||||
* inventory without hardcoding (eating our own dogfood).
|
||||
*/
|
||||
import { IconCodeOutline16, IconDownloadOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { DocxView, XlsxView } from '../office-view.tsx'
|
||||
import { PdfView } from '../PdfView.tsx'
|
||||
import { PptxView } from '../PptxView.tsx'
|
||||
import { TextEditor } from '../TextEditor.tsx'
|
||||
import { BinaryDownload } from '../binary-download.tsx'
|
||||
import {
|
||||
IconImageOutline16,
|
||||
IconMarkdownOutline16,
|
||||
IconPdfOutline16,
|
||||
IconDocxOutline16,
|
||||
IconXlsxOutline16,
|
||||
IconPptxOutline16,
|
||||
} from '../icons.tsx'
|
||||
import type { FileViewerDescriptor } from '../service.ts'
|
||||
import { t } from '../locales.ts'
|
||||
import css from '../sidebar.module.css'
|
||||
|
||||
/** The 8 built-in file viewer descriptors. */
|
||||
@@ -19,6 +33,8 @@ export function builtinViewers(): readonly FileViewerDescriptor[] {
|
||||
return [
|
||||
{
|
||||
id: 'image',
|
||||
title: () => t('viewerImage'),
|
||||
icon: (size: number) => <IconImageOutline16 size={size} />,
|
||||
exts: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: ({ mediaUrl: url, title }) => (
|
||||
@@ -29,6 +45,8 @@ export function builtinViewers(): readonly FileViewerDescriptor[] {
|
||||
},
|
||||
{
|
||||
id: 'pdf',
|
||||
title: () => t('viewerPdf'),
|
||||
icon: (size: number) => <IconPdfOutline16 size={size} />,
|
||||
exts: ['pdf'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: ({ scope, path, title }) => (
|
||||
@@ -37,6 +55,8 @@ export function builtinViewers(): readonly FileViewerDescriptor[] {
|
||||
},
|
||||
{
|
||||
id: 'docx',
|
||||
title: () => t('viewerDocx'),
|
||||
icon: (size: number) => <IconDocxOutline16 size={size} />,
|
||||
exts: ['docx'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: ({ scope, path, title }) => (
|
||||
@@ -45,6 +65,8 @@ export function builtinViewers(): readonly FileViewerDescriptor[] {
|
||||
},
|
||||
{
|
||||
id: 'xlsx',
|
||||
title: () => t('viewerXlsx'),
|
||||
icon: (size: number) => <IconXlsxOutline16 size={size} />,
|
||||
exts: ['xlsx'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: ({ scope, path, title }) => (
|
||||
@@ -53,6 +75,8 @@ export function builtinViewers(): readonly FileViewerDescriptor[] {
|
||||
},
|
||||
{
|
||||
id: 'pptx',
|
||||
title: () => t('viewerPptx'),
|
||||
icon: (size: number) => <IconPptxOutline16 size={size} />,
|
||||
exts: ['pptx'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: ({ scope, path, title }) => (
|
||||
@@ -61,12 +85,16 @@ export function builtinViewers(): readonly FileViewerDescriptor[] {
|
||||
},
|
||||
{
|
||||
id: 'markdown',
|
||||
title: () => t('viewerMarkdown'),
|
||||
icon: (size: number) => <IconMarkdownOutline16 size={size} />,
|
||||
exts: ['md', 'markdown'],
|
||||
fetchStrategy: 'fsRead',
|
||||
component: (props) => <TextEditor {...props} />,
|
||||
},
|
||||
{
|
||||
id: 'code',
|
||||
title: () => t('viewerCode'),
|
||||
icon: (size: number) => <IconCodeOutline16 size={size} />,
|
||||
exts: [],
|
||||
priority: -100,
|
||||
fetchStrategy: 'fsRead',
|
||||
@@ -74,6 +102,8 @@ export function builtinViewers(): readonly FileViewerDescriptor[] {
|
||||
},
|
||||
{
|
||||
id: 'binary-download',
|
||||
title: () => t('viewerBinary'),
|
||||
icon: (size: number) => <IconDownloadOutline16 size={size} />,
|
||||
exts: ['doc', 'xls', 'ppt'],
|
||||
priority: -50,
|
||||
fetchStrategy: 'binary-download',
|
||||
|
||||
@@ -26,3 +26,60 @@ export const IconDiffOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<path d="M9.5 12.5h2.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
// ── File-viewer inventory glyphs (Side card settings page) ────────────────
|
||||
|
||||
/** Image viewer glyph: a picture frame with a sun and a mountain. */
|
||||
export const IconImageOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1.5" y="2.5" width="13" height="11" rx="2" stroke="currentColor" strokeWidth="1.5" />
|
||||
<circle cx="5.5" cy="6" r="1.2" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="m3.5 12 3-3 2.25 2.25L11.5 8.5 13 10.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** PDF viewer glyph: a document frame with the "PDF" label. */
|
||||
export const IconPdfOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.5 1.5h6.5L13.5 5v9.5h-10z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
|
||||
<path d="M9.5 1.5V5h4" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
|
||||
<path d="M5 13.5v-3h1.4c.75 0 1.1.32 1.1.85 0 .54-.35.85-1.1.85H5.3" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M8.3 13.5v-3h1.05c.8 0 1.35.5 1.35 1.5s-.55 1.5-1.35 1.5z" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M11.6 13.5v-3h1.3" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** Word viewer glyph: a document frame with a "W". */
|
||||
export const IconDocxOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.5 1.5h6.5L13.5 5v9.5h-10z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
|
||||
<path d="M9.5 1.5V5h4" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
|
||||
<path d="M6.2 13.4 7.4 10l1.2 3.4M7.4 10.6l-.35-1.1c-.2-.62.2-1.25.85-1.25h.2c.65 0 1.05.63.85 1.25l-.35 1.1" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M8.75 10.6 9.2 9.4" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** Excel viewer glyph: a spreadsheet grid. */
|
||||
export const IconXlsxOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1.5" y="2" width="13" height="12" rx="2" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M1.5 6h13M1.5 9.5h13M6 6v8M10.5 6v8" stroke="currentColor" strokeWidth="1.25" />
|
||||
<path d="m3.8 13.2 2-3M5.8 13.2l-2-3" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** PowerPoint viewer glyph: a chart with rising bars. */
|
||||
export const IconPptxOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1.5" y="2.5" width="13" height="11" rx="2" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M4 12.5v-3M7 12.5V7M10 12.5V4.5M13 12.5v-1.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** Markdown viewer glyph: the classic "M with a down arrow" badge. */
|
||||
export const IconMarkdownOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1.5" y="2.5" width="13" height="11" rx="2" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M4 10.5V5.5l2 2.5 2-2.5v5M9.5 10.5v-5l2 2.5 2-2.5v5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
@@ -149,13 +149,14 @@ export function apply(ctx: Context): void {
|
||||
// The "Side card" settings section: appears in the DSH Settings shell
|
||||
// once the shell's declaration is on the ledger (slots.inject waits for
|
||||
// it); the section reads/writes the prefs through the plugin's own
|
||||
// fenced settings route and keeps the shared store in sync.
|
||||
// fenced settings route, keeps the shared store in sync, and renders the
|
||||
// declarative enable/disable inventory from the tab/viewer registry.
|
||||
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
||||
name: 'settings.section',
|
||||
id: 'better-sidebar',
|
||||
order: 100,
|
||||
label: () => t('settingsNav'),
|
||||
inject: () => ({ store: sidebarStore }),
|
||||
inject: () => ({ store: sidebarStore, service }),
|
||||
}, SideCardSection))
|
||||
} catch (error) {
|
||||
fail('load', error)
|
||||
|
||||
@@ -60,7 +60,13 @@ export function SidebarProducedFiles(props: {
|
||||
export function registerTurnTailInterception(ctx: Context, store: SidebarStore): () => void {
|
||||
return ctx.slots.register({
|
||||
name: 'conversation.chat.turnTail',
|
||||
select: selectProducedFiles,
|
||||
// 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.
|
||||
select: (owner) => {
|
||||
if (store.getPrefs().tabsEnabled['editor'] === false) return null
|
||||
return selectProducedFiles(owner)
|
||||
},
|
||||
priority: -1,
|
||||
registrant: 'dsh-better-sidebar',
|
||||
inject: (sessionId: string) => ({
|
||||
|
||||
@@ -121,6 +121,17 @@ const zh = {
|
||||
settingsSubagentDesc: '当前会话产生新的子代理时,自动展开侧边栏并打开子代理页面;关闭后需手动打开',
|
||||
settingsToolsTitle: '为模型注入终端工具',
|
||||
settingsToolsDesc: '开启后,模型可通过 terminal_create 等 8 个工具创建并操作侧边栏终端(默认关闭)',
|
||||
settingsTabsTitle: '侧边栏内容',
|
||||
settingsViewersTitle: '文件预览',
|
||||
settingsViewerCatchAll: '兜底:任意文件',
|
||||
viewerImage: '图片',
|
||||
viewerPdf: 'PDF',
|
||||
viewerDocx: 'Word 文档',
|
||||
viewerXlsx: 'Excel 表格',
|
||||
viewerPptx: 'PPT 演示',
|
||||
viewerMarkdown: 'Markdown',
|
||||
viewerCode: '代码',
|
||||
viewerBinary: '二进制下载',
|
||||
subagent: '子代理',
|
||||
openSubagent: '子代理',
|
||||
subagentMainAgent: '主代理',
|
||||
@@ -254,6 +265,17 @@ const en: Record<keyof typeof zh, string> = {
|
||||
settingsSubagentDesc: 'Expand the side card and open the Subagent page when the current conversation spawns a new subagent; turn off to open it manually',
|
||||
settingsToolsTitle: 'Inject terminal tools for the model',
|
||||
settingsToolsDesc: 'When enabled, the model can create and drive sidebar terminals through the 8 terminal_* tools (off by default)',
|
||||
settingsTabsTitle: 'Sidebar content',
|
||||
settingsViewersTitle: 'File viewers',
|
||||
settingsViewerCatchAll: 'Catch-all: any file',
|
||||
viewerImage: 'Image',
|
||||
viewerPdf: 'PDF',
|
||||
viewerDocx: 'Word',
|
||||
viewerXlsx: 'Excel',
|
||||
viewerPptx: 'PowerPoint',
|
||||
viewerMarkdown: 'Markdown',
|
||||
viewerCode: 'Code',
|
||||
viewerBinary: 'Binary download',
|
||||
subagent: 'Subagents',
|
||||
openSubagent: 'Subagents',
|
||||
subagentMainAgent: 'Main agent',
|
||||
|
||||
+17
-2
@@ -21,8 +21,7 @@ export type { SidebarPrefs }
|
||||
/** The settings wire face the preferences need (a subset of the plugin api). */
|
||||
export type SidebarSettingsClient = Pick<typeof api, 'settingsGet' | 'settingsUpdate'>
|
||||
|
||||
/**
|
||||
* Validate one raw resolved value into {@link SidebarPrefs}. Used for the
|
||||
/** Validate one raw resolved value into {@link SidebarPrefs}. Used for the
|
||||
* settings.get payload AND the settings.update response (both carry the
|
||||
* layered resolved value); any malformed field falls back to its default.
|
||||
* @param value - the raw resolved section from the settings wire.
|
||||
@@ -44,9 +43,25 @@ export function parsePrefs(value: unknown): SidebarPrefs {
|
||||
agentTerminalTools: typeof record.agentTerminalTools === 'boolean'
|
||||
? record.agentTerminalTools
|
||||
: SIDEBAR_PREFS_DEFAULTS.agentTerminalTools,
|
||||
tabsEnabled: booleanMapOf(record.tabsEnabled),
|
||||
viewersEnabled: booleanMapOf(record.viewersEnabled),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate one enable-switch map (per-tab / per-viewer). Only boolean values
|
||||
* survive; a non-object or a non-boolean entry falls back to the empty map /
|
||||
* drops the entry — an absent key means the feature stays enabled.
|
||||
*/
|
||||
function booleanMapOf(value: unknown): Record<string, boolean> {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) return {}
|
||||
const out: Record<string, boolean> = {}
|
||||
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (typeof item === 'boolean') out[key] = item
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the resolved side card preferences through the plugin's settings route.
|
||||
* @param settings - the settings wire face (the plugin api by default).
|
||||
|
||||
+69
-3
@@ -27,6 +27,29 @@ import {
|
||||
} from './state.ts'
|
||||
import type { SessionScope } from './api.ts'
|
||||
|
||||
/** One declarative boolean setting of a tab/viewer, rendered as a nested
|
||||
* switch row in the Side card settings page (e.g. the Subagent page's
|
||||
* "auto-open when a subagent appears"). */
|
||||
export interface SidebarSettingToggle {
|
||||
/** The SidebarPrefs field this toggle reads and writes ('autoOpenSubagent'). */
|
||||
key: string
|
||||
/** Row title (i18n friendly: string or () => string). */
|
||||
title: string | (() => string)
|
||||
/** Row description (i18n friendly). */
|
||||
desc?: string | (() => string)
|
||||
}
|
||||
|
||||
/** Declarative settings of one registered tab or file viewer. */
|
||||
export interface SidebarSettingsDeclaration {
|
||||
/**
|
||||
* Extra boolean toggles rendered under the feature's own row in the
|
||||
* settings page (only while the feature is enabled). Keys must be fields
|
||||
* of the host's PrefsSchema (built-ins: 'autoOpenSubagent',
|
||||
* 'agentTerminalTools'); unknown keys are dropped by the settings seam.
|
||||
*/
|
||||
toggles?: readonly SidebarSettingToggle[]
|
||||
}
|
||||
|
||||
/** Props every tab component receives (builtins and external alike). */
|
||||
export interface TabComponentProps {
|
||||
ctx: Context
|
||||
@@ -81,6 +104,13 @@ export interface TabDescriptor {
|
||||
* When omitted, a default `{ id, type, title }` tab is created.
|
||||
*/
|
||||
createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial<SidebarState> } | null
|
||||
/**
|
||||
* Declarative settings shown in the Side card settings page: every
|
||||
* registered tab gets an enable/disable switch (icon + title + id), and
|
||||
* `settings.toggles` adds nested switches tied to SidebarPrefs fields
|
||||
* (e.g. the subagent tab's 'autoOpenSubagent').
|
||||
*/
|
||||
settings?: SidebarSettingsDeclaration
|
||||
component: (props: TabComponentProps) => ReactNode
|
||||
}
|
||||
|
||||
@@ -114,6 +144,10 @@ export interface FileViewerProps {
|
||||
export interface FileViewerDescriptor {
|
||||
/** Unique id (`'image'`, `'pdf'`, `'my-plugin:csv'`). */
|
||||
id: string
|
||||
/** Display name for the settings inventory (falls back to `id` when absent). */
|
||||
title?: string | (() => string)
|
||||
/** Icon shown in the settings inventory. */
|
||||
icon?: ReactNode | ((size: number) => ReactNode)
|
||||
/** Lowercase extensions without leading dot (`['png','jpg']`). `[]` = match any (catch-all). */
|
||||
exts: readonly string[]
|
||||
/** Higher wins; default 0. Builtins use 0; the catch-all `code` viewer uses -100. */
|
||||
@@ -126,6 +160,11 @@ export interface FileViewerDescriptor {
|
||||
detect?: (path: string, head: Uint8Array) => boolean
|
||||
/** fetchStrategy='custom' loader. */
|
||||
load?: (path: string, scope: SessionScope) => Promise<unknown>
|
||||
/**
|
||||
* Declarative settings shown in the Side card settings page: every
|
||||
* registered viewer gets an enable/disable switch (icon + title + exts).
|
||||
*/
|
||||
settings?: SidebarSettingsDeclaration
|
||||
component: (props: FileViewerProps) => ReactNode
|
||||
}
|
||||
|
||||
@@ -137,13 +176,25 @@ export interface BetterSidebarService {
|
||||
getFileViewers(): readonly FileViewerDescriptor[]
|
||||
/** Find a tab descriptor by id (undefined if not registered). */
|
||||
getTab(id: string): TabDescriptor | undefined
|
||||
/** Find a file viewer for a path (priority desc; detect first, then exts). */
|
||||
/**
|
||||
* Whether a tab type is enabled in the side card prefs. An absent
|
||||
* `tabsEnabled[id]` entry means enabled — only an explicit `false`
|
||||
* disables the type (hidden from the + menu, `openTab` refuses, and
|
||||
* derived flows gate on it).
|
||||
*/
|
||||
isTabEnabled(id: string): boolean
|
||||
/** Whether a file viewer is enabled (absent `viewersEnabled[id]` = enabled). */
|
||||
isViewerEnabled(id: string): boolean
|
||||
/**
|
||||
* Find a file viewer for a path (priority desc; detect first, then exts).
|
||||
* Disabled viewers are skipped, so files fall through to the next match.
|
||||
*/
|
||||
matchFileViewer(path: string, head?: Uint8Array): FileViewerDescriptor | undefined
|
||||
/**
|
||||
* Open a tab (used by external tabs and the + menu). `title` overrides
|
||||
* the descriptor's title when given (the editor tab shows the file name);
|
||||
* when the descriptor provides `createTab` it mints the tab itself and
|
||||
* `title`/`path`/`id` are ignored.
|
||||
* `title`/`path`/`id` are ignored. A disabled tab type is a no-op.
|
||||
*/
|
||||
openTab(seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string }): void
|
||||
/** Close a tab by id. */
|
||||
@@ -211,6 +262,11 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
const getFileViewers = (): readonly FileViewerDescriptor[] => Array.from(viewers.values())
|
||||
const getTab = (id: string): TabDescriptor | undefined => tabs.get(id)
|
||||
|
||||
// The enable switches come from the user's side card prefs (the shared
|
||||
// store the service is bound to): an absent key means enabled.
|
||||
const isTabEnabled = (id: string): boolean => store.getPrefs().tabsEnabled[id] !== false
|
||||
const isViewerEnabled = (id: string): boolean => store.getPrefs().viewersEnabled[id] !== false
|
||||
|
||||
const matchFileViewer = (path: string, head?: Uint8Array): FileViewerDescriptor | undefined => {
|
||||
const ext = extOfPath(path)
|
||||
// Single pass in priority order (descending; stable for equal
|
||||
@@ -218,10 +274,11 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
// its own turn: `detect` (when head bytes are available) beats its own
|
||||
// `exts`, and `exts: []` is a catch-all matching any path — so the
|
||||
// catch-all `code` viewer (-100) only sees paths no higher-priority
|
||||
// descriptor claimed.
|
||||
// descriptor claimed. Disabled viewers are skipped entirely.
|
||||
for (const v of Array.from(viewers.values()).sort(
|
||||
(a, b) => (b.priority ?? 0) - (a.priority ?? 0),
|
||||
)) {
|
||||
if (!isViewerEnabled(v.id)) continue
|
||||
// Content sniff first (only when head bytes are available).
|
||||
if (head !== undefined && v.detect !== undefined) {
|
||||
if (v.detect(path, head)) return v
|
||||
@@ -241,6 +298,13 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
}
|
||||
|
||||
const openTab = (seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string }): void => {
|
||||
// A type the user disabled in settings never opens — neither from the
|
||||
// + menu nor from derived flows (file opens, subagent auto-open,
|
||||
// external plugins). Already-open tabs keep rendering.
|
||||
if (!isTabEnabled(seed.type)) {
|
||||
console.warn(`[dsh-better-sidebar] tab type "${seed.type}" is disabled in the side card settings`)
|
||||
return
|
||||
}
|
||||
store.reduce((state) => {
|
||||
const descriptor = tabs.get(seed.type)
|
||||
if (descriptor === undefined) return state
|
||||
@@ -278,6 +342,8 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
getTabs,
|
||||
getFileViewers,
|
||||
getTab,
|
||||
isTabEnabled,
|
||||
isViewerEnabled,
|
||||
matchFileViewer,
|
||||
openTab,
|
||||
closeTab,
|
||||
|
||||
+36
-13
@@ -114,11 +114,16 @@ function maxCounterId(parsed: unknown): number {
|
||||
/** A fresh default state: one explorer tab in one pane, open per the caller's
|
||||
* preference. `width` is the caller's preferred panel width (default
|
||||
* PANEL_DEFAULT) and `panelOpen` whether the panel starts expanded (default
|
||||
* true); the store seeds new sessions from the user's side card prefs. */
|
||||
export function makeDefaultState(width = PANEL_DEFAULT, panelOpen = true): SidebarState {
|
||||
* true); the store seeds new sessions from the user's side card prefs.
|
||||
* `seedExplorer` places the default explorer tab — the store passes false
|
||||
* when the user disabled the explorer tab type in settings, so a fresh
|
||||
* session starts with an empty pane instead of a tab they turned off. */
|
||||
export function makeDefaultState(width = PANEL_DEFAULT, panelOpen = true, seedExplorer = true): SidebarState {
|
||||
const leaf: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [], active: null }
|
||||
leaf.tabs = [{ id: uid('tab'), type: 'explorer', title: 'Explorer' }]
|
||||
leaf.active = leaf.tabs[0]!.id
|
||||
if (seedExplorer) {
|
||||
leaf.tabs = [{ id: uid('tab'), type: 'explorer', title: 'Explorer' }]
|
||||
leaf.active = leaf.tabs[0]!.id
|
||||
}
|
||||
return {
|
||||
panelOpen,
|
||||
width,
|
||||
@@ -510,6 +515,12 @@ const STORAGE_PREFIX = 'dsh-sidebar:v1'
|
||||
export interface SidebarSnapshot {
|
||||
sessionId: string | undefined
|
||||
state: SidebarState | undefined
|
||||
/**
|
||||
* The current side card prefs. Carried IN the snapshot (not a separate
|
||||
* subscription) so prefs changes re-render the consumers that gate on
|
||||
* them — the + menu hides a tab type the moment its switch flips.
|
||||
*/
|
||||
prefs: SidebarPrefs
|
||||
}
|
||||
|
||||
/** Default panel width for one viewport: the prefs percent of the window,
|
||||
@@ -535,13 +546,14 @@ function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState {
|
||||
}
|
||||
// New sessions seed from the user's side card prefs: the width is the
|
||||
// chosen percent of the window (clamped to the panel floor and the
|
||||
// viewport so a huge percent can never crush the app shell), and the
|
||||
// panel starts open only when the preference says so.
|
||||
// viewport so a huge percent can never crush the app shell), the panel
|
||||
// starts open only when the preference says so, and the default explorer
|
||||
// tab is skipped when the user disabled the explorer tab type.
|
||||
const viewport = typeof window !== 'undefined' ? window.innerWidth : undefined
|
||||
const width = viewport === undefined
|
||||
? PANEL_DEFAULT
|
||||
: defaultWidthFor(viewport, prefs.defaultWidthPercent)
|
||||
return makeDefaultState(width, prefs.openByDefault)
|
||||
return makeDefaultState(width, prefs.openByDefault, prefs.tabsEnabled['explorer'] !== false)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -659,15 +671,26 @@ function sanitizeNode(node: unknown, seen: Set<string>, reid: Map<string, string
|
||||
/** The session-scoped store: one state per conversation, localStorage-backed. */
|
||||
export class SidebarStore {
|
||||
private readonly bySession = new Map<string, SidebarState>()
|
||||
private snapshot: SidebarSnapshot = { sessionId: undefined, state: undefined }
|
||||
private snapshot: SidebarSnapshot = {
|
||||
sessionId: undefined,
|
||||
state: undefined,
|
||||
prefs: { ...SIDEBAR_PREFS_DEFAULTS },
|
||||
}
|
||||
private readonly listeners = new Set<() => void>()
|
||||
private persistTimer: number | undefined
|
||||
/** User-facing side card prefs seeding brand-new session states (defaults until the settings RPC resolves). */
|
||||
private prefs: SidebarPrefs = { ...SIDEBAR_PREFS_DEFAULTS }
|
||||
|
||||
/** Replace the side card prefs (the settings RPC result / settings page write). */
|
||||
/**
|
||||
* Replace the side card prefs (the settings RPC result / settings page
|
||||
* write). Notifies like any store change: the snapshot carries the prefs,
|
||||
* so consumers that gate on enable switches (the + menu, derived flows)
|
||||
* re-render with the new values immediately.
|
||||
*/
|
||||
setPrefs(prefs: SidebarPrefs): void {
|
||||
this.prefs = { ...prefs }
|
||||
this.snapshot = { ...this.snapshot, prefs: this.prefs }
|
||||
this.notify()
|
||||
}
|
||||
|
||||
/** The current side card prefs (seeds new sessions; persisted states win). */
|
||||
@@ -679,7 +702,7 @@ export class SidebarStore {
|
||||
setSession(sessionId: string | undefined): void {
|
||||
if (this.snapshot.sessionId === sessionId) return
|
||||
if (sessionId === undefined) {
|
||||
this.snapshot = { sessionId: undefined, state: undefined }
|
||||
this.snapshot = { sessionId: undefined, state: undefined, prefs: this.prefs }
|
||||
} else {
|
||||
let state = this.bySession.get(sessionId)
|
||||
if (state === undefined) {
|
||||
@@ -691,7 +714,7 @@ export class SidebarStore {
|
||||
// pane/split ids can never collide with its tree.
|
||||
nextIdCounter = maxCounterId(state)
|
||||
}
|
||||
this.snapshot = { sessionId, state }
|
||||
this.snapshot = { sessionId, state, prefs: this.prefs }
|
||||
}
|
||||
this.notify()
|
||||
}
|
||||
@@ -713,7 +736,7 @@ export class SidebarStore {
|
||||
const draft = structuredClone(state)
|
||||
mutator(draft)
|
||||
this.bySession.set(sessionId, draft)
|
||||
this.snapshot = { sessionId, state: draft }
|
||||
this.snapshot = { sessionId, state: draft, prefs: this.prefs }
|
||||
this.schedulePersist(sessionId, draft)
|
||||
this.notify()
|
||||
}
|
||||
@@ -740,7 +763,7 @@ export class SidebarStore {
|
||||
if (sessionId === undefined || state === undefined) return
|
||||
const next = reducer(state)
|
||||
this.bySession.set(sessionId, next)
|
||||
this.snapshot = { sessionId, state: next }
|
||||
this.snapshot = { sessionId, state: next, prefs: this.prefs }
|
||||
this.schedulePersist(sessionId, next)
|
||||
this.notify()
|
||||
}
|
||||
|
||||
@@ -80,4 +80,9 @@ export const PrefsSchema: z<SidebarPrefs> = z.object({
|
||||
defaultWidthPercent: z.number().step(1).min(WIDTH_PERCENT_MIN).max(WIDTH_PERCENT_MAX).default(WIDTH_PERCENT_DEFAULT),
|
||||
autoOpenSubagent: z.boolean().default(true),
|
||||
agentTerminalTools: z.boolean().default(false),
|
||||
// Per-feature enable switches are OPEN maps (any tab/viewer id, built-in or
|
||||
// external): an absent key means enabled, so old documents resolve to {}
|
||||
// (everything on) with no migration. Non-boolean values fail validation.
|
||||
tabsEnabled: z.dict(z.boolean()).default({}),
|
||||
viewersEnabled: z.dict(z.boolean()).default({}),
|
||||
})
|
||||
|
||||
@@ -27,6 +27,23 @@ export interface SidebarPrefs {
|
||||
* user explicitly enables it in the side card settings.
|
||||
*/
|
||||
agentTerminalTools: boolean
|
||||
/**
|
||||
* Per-tab enable switches, keyed by tab descriptor id (`'explorer'`,
|
||||
* `'my-plugin:db'`). An ABSENT key means enabled — only an explicit
|
||||
* `false` disables a tab type (hidden from the + menu, `openTab` refuses,
|
||||
* and derived flows like subagent auto-open / agent-terminal tabs stop).
|
||||
* Already-open tabs of a disabled type keep rendering (closing one
|
||||
* prevents reopening), matching the "existing conversations keep their
|
||||
* own layouts" rule.
|
||||
*/
|
||||
tabsEnabled: Record<string, boolean>
|
||||
/**
|
||||
* Per-viewer enable switches, keyed by file viewer descriptor id
|
||||
* (`'image'`, `'my-plugin:csv'`). An ABSENT key means enabled; a disabled
|
||||
* viewer is skipped by `matchFileViewer` so files fall through to the
|
||||
* next matching viewer (or the download button when none match).
|
||||
*/
|
||||
viewersEnabled: Record<string, boolean>
|
||||
}
|
||||
|
||||
/** Range contract of {@link SidebarPrefs.defaultWidthPercent}. */
|
||||
@@ -40,6 +57,8 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
|
||||
defaultWidthPercent: WIDTH_PERCENT_DEFAULT,
|
||||
autoOpenSubagent: true,
|
||||
agentTerminalTools: false,
|
||||
tabsEnabled: {},
|
||||
viewersEnabled: {},
|
||||
}
|
||||
|
||||
/** Clamp one width percent into the contract range (shared by schema and client reads). */
|
||||
|
||||
Reference in New Issue
Block a user