feat: adapt to DSH 0.1.7-alpha.1 and yield the read-only previews

DSH 0.1.7-alpha.1 is 1299 commits past 0.1.6-alpha.2 and rewrites four
contracts this plugin was built on. The peer floor moves to
^0.1.7-alpha.1 (semver's prerelease rule means ^0.1.6-alpha.2 can never
match a 0.1.7 prerelease); 0.1.6-alpha.2 and earlier stay on v0.19.1.
Released as 0.21.0-alpha.1 (dist-tag alpha).

Hard breaks:
- dsh-settings was rewritten: register(ns, schema) is gone, a form is
  addressed by the plugin row's Loader entry id and read from the
  module's exported Config. The preferences are merged into Config and
  marked volatile — which requires DSH's own schemastery, because only
  that build wraps a volatile field in the cosmokit reference the
  loader's volatile commit walks. With the public package a settings
  write reports success and revision 1 while the effective value never
  changes.
- ui-primitives renamed its whole icon family (IconXxx14/16 ->
  IconXxxRegular/Medium): 26 imported symbols, 17 files. The failing
  mode is render-time, not load-time. Five call sites that relied on the
  old 14px default now pass size={14} explicitly.
- Session format v4 rejects source.kind === 'plugin'; the side-chat
  boundary injection now writes plugin:dsh-better-sidebar, the same kind
  DSH's own v3->v4 migration produces, so historical threads survive.
  Tool results moved to role 'tool' with top-level content/isError; all
  four parsers accept both shapes.
- The client session snapshot dropped subagentsByParent/jobsBySession
  and the on-demand subagent-catalog observation API. The Tasks page
  reads projectionsBySession and a new jobs.list route.

Yields and convergence:
- The read-only previews go to DSH's documentpreview (Office->PDF, a
  spreadsheet worker, zoom, per-directory refresh); editor.canOpen
  refuses those 26 extensions. Markdown, HTML (with its sandbox
  switches) and the editable code buffer stay.
- The host browser kind is desktop-profile-only now, so the web e2e
  lanes expect no browser entry and assert its absence.
- Link takeover claims only urlTarget links and passes the rest to the
  host, fixing a regression from the 0.1.6 round: the fallback pointed
  at a 'browser' type the plugin no longer registers, so http links in
  its own markdown did nothing.

Also fixed, found along the way:
- The file tree refreshes live (new directory watch over the plugin's
  own socket), since the plugin owns the `files` kind.
- ctx.sessions.list has no `current` field at either tag; the three
  readers were dead. They now use ctx.sidebarRight.mounted.
- package.json.files was missing lib/client-locale.js.
- Two genuinely dead call sites: sessions.openSubagent/open/
  refreshSubagents do not exist on the current baseline, so the
  topology click, the jump-back and the retry were all no-ops.
- The test harness window shim lacked addEventListener, which the 0.1.7
  primitives call at module-evaluation time; and the e2e token exchange
  needs Playwright's request stack, not Node's fetch.
This commit is contained in:
Menghuan1918
2026-09-22 17:09:08 +08:00
parent de99fd4cfb
commit 6586e3e2d3
96 changed files with 4567 additions and 2080 deletions
+2 -2
View File
@@ -9,7 +9,7 @@
* renderer — the same one the changes tab's inline preview uses.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconRefreshOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SessionScope } from './api.ts'
import { api } from './api.ts'
import type { SidebarDiffRef } from './state.ts'
@@ -170,7 +170,7 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif
title={t('refresh')}
onClick={refresh}
>
<IconRefreshOutline16 size={14} />
<IconRefreshOutlineRegular size={14} />
</button>
</div>
{loading && <div className={css.gitPlaceholder}>{t('loading')}</div>}
+4 -4
View File
@@ -25,7 +25,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
import { createElement } from 'react'
import clsx from 'clsx'
import { IconCheckOutline16, IconFolderOpen16, IconRefreshOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconCheckOutlineRegular, IconFolderOpenRegular, IconRefreshOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from '../context-types.ts'
import { api, isOutsideWorkspaceMessage, mediaUrl, type SessionScope } from './api.ts'
import { BinaryDownload } from './binary-download.tsx'
@@ -447,7 +447,7 @@ export function EditorHost(props: {
title={`${t('save')} (Ctrl/Cmd+S)`}
onClick={() => { controlsRef.current?.save() }}
>
<IconCheckOutline16 size={14} />
<IconCheckOutlineRegular size={14} />
</button>
)}
{saveLabel !== '' && (
@@ -461,7 +461,7 @@ export function EditorHost(props: {
title={t('refresh')}
onClick={refreshFile}
>
<IconRefreshOutline14 size={14} />
<IconRefreshOutlineRegular size={14} />
</button>
)}
<button
@@ -472,7 +472,7 @@ export function EditorHost(props: {
aria-pressed={treeOpen}
onClick={toggleTree}
>
<IconFolderOpen16 size={14} />
<IconFolderOpenRegular size={14} />
</button>
</div>
<div className={css.editorBody}>
+25 -13
View File
@@ -24,8 +24,8 @@ import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type
import { createPortal } from 'react-dom'
import clsx from 'clsx'
import {
Button, IconChevronRightOutline14, IconCloseFill14, IconCodeOutline16, IconCopyOutline16, IconDownloadOutline16,
IconEditOutline16, IconLinkOutline16, IconTrashOutline16, Menu, Modal, type MenuEntry, type MenuItem, writeClipboard,
Button, IconChevronRightOutlineRegular, IconCloseFillRegular, IconCodeOutlineRegular, IconCopyOutlineRegular, IconDownloadOutlineRegular,
IconEditOutlineRegular, IconLinkOutlineRegular, IconTrashOutlineRegular, Menu, Modal, type MenuEntry, type MenuItem, writeClipboard,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { SiCursor, SiZedindustries } from 'react-icons/si'
import { VscFolderOpened, VscLinkExternal, VscPin, VscPinned } from 'react-icons/vsc'
@@ -41,6 +41,7 @@ import { t } from './locales.ts'
import type { BetterSidebarService } from './service.ts'
import type { SidebarStore } from './state.ts'
import { uploadItemsFromDrop, uploadItemsFromFiles, type UploadItem } from './upload.ts'
import { useDirectoryWatch } from './use-dir-watch.ts'
import css from './sidebar.module.css'
interface LevelData {
@@ -393,6 +394,17 @@ export function FileTree(props: {
for (const dir of expanded) loadDir(dir)
}, [cwd, expanded, refreshTick, loadDir])
// Live refresh: the host watches the folders this tree has expanded and
// names the one that changed, so exactly that level is dropped and
// re-listed instead of the whole tree. Without it a folder listed once when
// it was opened stayed stale for the rest of the session.
useDirectoryWatch({
sessionId,
root: cwd,
dirs: expanded,
onStale: retryDir,
})
// Bring a "Show in folder" reveal into view: the ancestors expand above
// (revealPaths), but the row may not be scrolled into sight — a reveal on
// a long tree should surface the highlighted file. Re-runs when the tree
@@ -486,7 +498,7 @@ export function FileTree(props: {
if (target.id === 'vscode') return <IconVscode16 size={14} />
if (target.id === 'cursor') return <SiCursor size={14} />
if (target.id === 'zed') return <SiZedindustries size={14} />
return <IconCodeOutline16 size={14} />
return <IconCodeOutlineRegular size={14} />
}
const pinned = openWithTargets
.filter(target => pinnedIds.includes(target.id))
@@ -537,7 +549,7 @@ export function FileTree(props: {
label: (
<span className={css.openWithLabel}>
<span className={css.openWithName}>{t('openWithMenu')}</span>
<IconChevronRightOutline14 size={14} className={css.openWithChevron} aria-hidden />
<IconChevronRightOutlineRegular size={14} className={css.openWithChevron} aria-hidden />
</span>
),
icon: <VscLinkExternal size={14} />,
@@ -661,7 +673,7 @@ export function FileTree(props: {
>
{dirRowIcon(entry.path, isOpen)}
<span className={css.explorerName}>{entry.name}</span>
{entry.isSymlink && <IconLinkOutline16 size={12} className={css.explorerSymlink} />}
{entry.isSymlink && <IconLinkOutlineRegular size={12} className={css.explorerSymlink} />}
{rowActions(entry)}
</div>
{isOpen && renderLevel(entry.path, depth + 1)}
@@ -694,7 +706,7 @@ export function FileTree(props: {
>
{fileRowIcon(entry.path)}
<span className={css.explorerName}>{entry.name}</span>
{entry.isSymlink && <IconLinkOutline16 size={12} className={css.explorerSymlink} />}
{entry.isSymlink && <IconLinkOutlineRegular size={12} className={css.explorerSymlink} />}
{rowActions(entry)}
</div>
)
@@ -726,7 +738,7 @@ export function FileTree(props: {
aria-label={t('dismiss')}
onClick={() => { setActionError(null) }}
>
<IconCloseFill14 />
<IconCloseFillRegular size={14} />
</button>
</div>
)}
@@ -829,7 +841,7 @@ export function FileTree(props: {
items={[
// The open escapes head the FILE menu (dirs only get copy).
...(rowMenu?.isDir === false && onOpenFileNewTab !== undefined
? [{ id: 'open-new-tab', label: t('openFileNewTab'), icon: <IconCodeOutline16 size={14} /> }]
? [{ id: 'open-new-tab', label: t('openFileNewTab'), icon: <IconCodeOutlineRegular size={14} /> }]
: []),
...(rowMenu?.isDir === false && onOpenFileSide !== undefined
? [{ id: 'open-side', label: t('openFileSide'), icon: <VscFolderOpened size={14} /> }]
@@ -837,21 +849,21 @@ export function FileTree(props: {
...openWithEntries(),
// Download applies to files only (the host route refuses directories).
...(rowMenu?.isDir === false
? [{ id: 'download', label: t('download'), icon: <IconDownloadOutline16 size={14} /> }]
? [{ id: 'download', label: t('download'), icon: <IconDownloadOutlineRegular size={14} /> }]
: []),
// Upload into a directory (incl. the workspace root row).
...(rowMenu?.isDir === true
? [{ id: 'upload-here', label: t('uploadHere'), icon: <IconUploadOutline16 size={14} /> }]
: []),
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={14} /> },
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={14} /> },
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutlineRegular size={14} /> },
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutlineRegular size={14} /> },
// Explorer mutations close the menu; the workspace ROOT row is the
// session itself — never renamable or deletable (server double-guards).
...(rowMenu !== null && rowMenu.path !== cwd
? [
{ id: 'mutate-sep', type: 'separator' } as MenuEntry,
{ id: 'rename', label: t('rename'), icon: <IconEditOutline16 size={14} /> },
{ id: 'delete', label: t('delete'), icon: <IconTrashOutline16 size={14} />, danger: true },
{ id: 'rename', label: t('rename'), icon: <IconEditOutlineRegular size={14} /> },
{ id: 'delete', label: t('delete'), icon: <IconTrashOutlineRegular size={14} />, danger: true },
]
: []),
]}
+8 -8
View File
@@ -42,9 +42,9 @@
*/
import { Fragment, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import {
IconChevronDownOutline14,
IconPlusOutline16,
IconSettingsOutline16,
IconChevronDownOutlineRegular,
IconPlusOutlineRegular,
IconSettingsOutlineRegular,
Input,
Menu,
Modal,
@@ -420,7 +420,7 @@ function SelectMenu(props: {
<span className={css.selectAnchorText}>
{selected.length === 0 ? (placeholder ?? '—') : selected.map(option => textOf(option.title)).join(', ')}
</span>
<IconChevronDownOutline14 size={12} />
<IconChevronDownOutlineRegular size={12} />
</button>
)
@@ -817,7 +817,7 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
aria-label={`${props.title} ${t('settingsPopup')}`}
onClick={props.onOpenSettings}
>
<IconSettingsOutline16 size={12} />
<IconSettingsOutlineRegular size={12} />
<span>{t('settingsPopup')}</span>
</button>
)}
@@ -893,7 +893,7 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
title={t('settingsPopup')}
onClick={() => { setStripSettingsOpen(true) }}
>
<IconSettingsOutline16 size={14} />
<IconSettingsOutlineRegular size={14} />
</button>
)}
</span>
@@ -935,7 +935,7 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
>
<span className={css.cardTop}>
<span className={css.cardIconChip}>
<IconPlusOutline16 size={16} />
<IconPlusOutlineRegular size={16} />
</span>
<span className={css.cardTitle}>{t('addPluginsTabCard')}</span>
</span>
@@ -974,7 +974,7 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
>
<span className={css.cardTop}>
<span className={css.cardIconChip}>
<IconPlusOutline16 size={16} />
<IconPlusOutlineRegular size={16} />
</span>
<span className={css.cardTitle}>{t('addPluginsViewerCard')}</span>
</span>
+20 -20
View File
@@ -29,16 +29,16 @@ import clsx from 'clsx'
import {
ConnectionIndicator,
DiffBlock,
IconApiOutline14,
IconBrowseOutline16,
IconChevronRightOutline14,
IconEditOutline16,
IconNewChatOutline16,
IconPlusOutline16,
IconSearchOutline16,
IconSendOutline14,
IconSparkle16,
IconStopFill16,
IconApiOutlineRegular,
IconBrowseOutlineRegular,
IconChevronRightOutlineRegular,
IconEditOutlineRegular,
IconNewChatOutlineRegular,
IconPlusOutlineRegular,
IconSearchOutlineRegular,
IconSendOutlineRegular,
IconSparkleRegular,
IconStopFillRegular,
MarkdownText,
Menu,
ReadBlock,
@@ -198,7 +198,7 @@ function CollapsibleRow(props: {
)}
>
<span className={css.sidechatRowChevron}>
<IconChevronRightOutline14 size={12} />
<IconChevronRightOutlineRegular size={12} />
</span>
{leading}
{label}
@@ -239,19 +239,19 @@ function toolLeading(name: string, failed: boolean): React.ReactNode {
switch (name) {
case 'bash':
case 'pwsh':
return <IconApiOutline14 size={14} />
return <IconApiOutlineRegular size={14} />
case 'read':
case 'web_fetch':
return <IconBrowseOutline16 size={14} />
return <IconBrowseOutlineRegular size={14} />
case 'edit':
case 'write':
return <IconEditOutline16 size={14} />
return <IconEditOutlineRegular size={14} />
case 'grep':
case 'glob':
case 'web_search':
return <IconSearchOutline16 size={14} />
return <IconSearchOutlineRegular size={14} />
default:
return <IconSparkle16 size={14} />
return <IconSparkleRegular size={14} />
}
}
@@ -596,7 +596,7 @@ export function SideChatView(props: {
const menuItems = useMemo<MenuEntry[]>(() => {
const items: MenuEntry[] = [
{ id: '$new', label: t('sideChatNew'), icon: <IconPlusOutline16 /> },
{ id: '$new', label: t('sideChatNew'), icon: <IconPlusOutlineRegular /> },
]
if (threads.length > 0) {
items.push({ type: 'separator', id: '$sep' })
@@ -677,7 +677,7 @@ export function SideChatView(props: {
return (
<div className={css.sidechat}>
<div className={css.sidechatHero}>
<IconNewChatOutline16 />
<IconNewChatOutlineRegular />
<div
className={clsx(
css.sidechatHeroTitle,
@@ -793,7 +793,7 @@ export function SideChatView(props: {
disabled={busy !== null}
title={t('sideChatCancelTitle')}
>
<IconStopFill16 />
<IconStopFillRegular />
</button>
) : (
<button
@@ -804,7 +804,7 @@ export function SideChatView(props: {
disabled={composer.trim() === '' || busy !== null}
title={t('sideChatSend')}
>
<IconSendOutline14 size={16} />
<IconSendOutlineRegular size={16} />
</button>
)}
</div>
+14 -3
View File
@@ -27,7 +27,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent, type ReactNode } from 'react'
import { useSyncExternalStore } from 'react'
import clsx from 'clsx'
import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconCloseFillRegular, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from '../context-types.ts'
import { referenceInChat as referenceInChatShared } from './reference-in-chat.ts'
import {
@@ -47,6 +47,7 @@ import { computeTitleBarStrip } from './titlebar-strip.ts'
import { TabContent, buildNewTabOptions } from './sidebar/TabContent.tsx'
import { useCenterColumn } from './sidebar/use-center-column.ts'
import { useHostFeeds } from './sidebar/use-host-feeds.ts'
import { mountedSessions } from './native/surface.ts'
import type { TabDragPayload } from './TabBar.tsx'
import { t } from './locales.ts'
import { api } from './api.ts'
@@ -195,7 +196,17 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
useMemo(() => (callback: () => void) => ctx.sessions.list.subscribe(callback), [ctx]),
useCallback(() => ctx.sessions.list.getSnapshot(), [ctx]),
)
const current = sessionList.current
// Which conversation is on screen. The session-list snapshot has no
// current-session field in ANY DSH release, so the old read of one was
// permanently `undefined` and `store.setSession` below never bound a
// session: DSH 0.1.7 publishes the mounted seat instead
// (`ctx.sidebarRight.mounted`), and that is what the per-session state
// follows.
const mounted = useMemo(() => mountedSessions(ctx), [ctx])
const current = useSyncExternalStore(
useCallback((callback: () => void) => mounted.subscribe(callback), [mounted]),
useCallback(() => mounted.getSnapshot(), [mounted]),
)
// Per-session sidebar state.
const snapshot = useSyncExternalStore(
@@ -709,7 +720,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
aria-label={t('collapseBottomPanel')}
onClick={() => { store.reduce(toggleBottomPanel) }}
>
<IconCloseFill14 />
<IconCloseFillRegular size={14} />
</button>
</Tooltip>
<div className={css.panelBody}>
+139 -117
View File
@@ -9,38 +9,52 @@
* back to the main session), with its subagents hanging below it in clearly
* LAYERED levels: tree connector lines (first level included) and per-level
* indentation show the hierarchy, and the currently-open session is
* highlighted in place. Every branch is expanded automatically (lazy
* catalogs hydrate on demand and consume live membership while visible).
* highlighted in place. Every branch is expanded automatically.
*
* The child rows come from DSH 0.1.7's host-computed `subagentCatalog`
* projection (folded per parent by ./subagent-catalog.ts), which the client
* loads for every session with the connection — there is no per-parent
* observe/refresh handshake to run any more. What a row no longer carries is
* derived: a branch stays open until the child's own catalog is known-empty,
* and live status comes from the `subagents.live` batch channel.
*
* Each node card carries live status (state dot, durable label, mode and
* activity); while a child RUNS, its card additionally shows the LAST text
* output and LAST tool call pulled from its history tail, auto-refreshing
* every few seconds while the page is visible. Clicking a card jumps
* straight into the child transcript (`openSubagent`); the page stays open
* straight into the child transcript (`openChild`); the page stays open
* and the topology remains rooted at the main session.
*
* The background-jobs section below the tree is fed by the plugin's
* `jobs.list` route, one polled read per tree session (the registry's fence
* admits a job only to its owner, and 0.1.7 stopped mirroring jobs into the
* client snapshot).
*/
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
import { useSyncExternalStore } from 'react'
import clsx from 'clsx'
import {
IconRefreshOutline14, StateDot,
IconRefreshOutlineRegular, StateDot,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type {
Context,
SidebarSessionList,
SidebarSessionSummary,
SidebarSubagentAddress,
SidebarSubagentCatalog,
SidebarSubagentChildEntry,
SidebarSubagentDiagnosticEntry,
SidebarSubagentCatalogEntry,
SidebarJobView,
} from '../context-types.ts'
import {
collectBranchIds,
countSubagentDescendants,
isSideThreadSummary,
rootAncestor,
} from './subagent-detect.ts'
import {
childActivity,
isKnownLeaf,
subagentCatalogs,
type SubagentCatalogView,
} from './subagent-catalog.ts'
import { type LastActivity } from '../subagent-activity.ts'
import { SIDE_LABEL_PREFIX } from '../sidechat-core.ts'
import {
@@ -50,6 +64,7 @@ import {
orderJobs,
jobDotState,
jobStatusLabel,
treeSessionIds,
type TreeJob,
} from './subagent-jobs.ts'
import { api, type JobOutputResult } from './api.ts'
@@ -60,6 +75,8 @@ import css from './SubagentView.module.css'
/** Refresh cadence of the live "last text + tool call" lines while a child runs. */
const POLL_MS = 3000
/** Refresh cadence of the tree's background-job lists (one read per tree session). */
const JOBS_POLL_MS = 3000
/** Preview cap of one tool-call argument line. */
const ARGS_PREVIEW = 60
/** Refresh cadence of an expanded job-output panel while its job runs. */
@@ -82,29 +99,37 @@ function directChildren(
/** Human label of one catalog child: durable label, then summary title, then id. */
function childLabel(
entry: SidebarSubagentChildEntry,
entry: SidebarSubagentCatalogEntry,
summary: SidebarSessionSummary | undefined,
): string {
return entry.label ?? summary?.displayTitle ?? entry.id
}
function diagnosticReason(entry: SidebarSubagentDiagnosticEntry): string {
switch (entry.reason) {
case 'corrupt': return t('subagentDiagCorrupt')
case 'unsupported': return t('subagentDiagUnsupported')
case 'unavailable': return t('subagentDiagUnavailable')
/**
* The mode word of one catalog row. DSH 0.1.7 added `unknown` (a child the
* host's catalog fold kept without a readable descriptor): it claims NEITHER
* mode, so the segment is omitted rather than mislabelled — printing
* "Continuable" would be a lie, and the shipped dictionaries have no key for
* it yet (adding one would have to touch every locale chunk).
*/
function modeLabel(mode: SidebarSubagentCatalogEntry['mode']): string | undefined {
switch (mode) {
case 'one-shot': return t('subagentModeOneShot')
case 'continuable': return t('subagentModeContinuable')
case 'unknown': return undefined
}
}
/** The secondary line of one card: title · mode · activity (skips empty parts). */
function cardSecondary(
summary: SidebarSessionSummary | undefined,
entry: SidebarSubagentChildEntry,
entry: SidebarSubagentCatalogEntry,
activity: 'running' | 'inactive',
): string {
return [
summary?.displayTitle,
entry.mode === 'one-shot' ? t('subagentModeOneShot') : t('subagentModeContinuable'),
entry.activity === 'running' ? t('subagentRunning') : t('subagentInactive'),
modeLabel(entry.mode),
activity === 'running' ? t('subagentRunning') : t('subagentInactive'),
].filter(Boolean).join(' · ')
}
@@ -211,8 +236,8 @@ function useSubagentLive(
interface RowsProps {
parentSessionId: string
catalog: SidebarSubagentCatalog | undefined
catalogs: Readonly<Record<string, SidebarSubagentCatalog>>
catalog: SubagentCatalogView | undefined
catalogs: Readonly<Record<string, SubagentCatalogView>>
byId: Readonly<Record<string, SidebarSessionSummary>>
level: number
/** The currently-open session id (highlighted in the topology). */
@@ -223,7 +248,7 @@ interface RowsProps {
refresh: (parentSessionId: string) => void
}
/** Render one topology level; branches are always expanded (lazy catalogs). */
/** Render one topology level; branches are always expanded. */
function CatalogRows({
parentSessionId, catalog, catalogs, byId, level, currentSessionId, live,
openChild, refresh,
@@ -231,12 +256,12 @@ function CatalogRows({
const emptyLoading = catalog?.state === 'loading' && catalog.entries.length === 0
// Side Chat threads are honest catalog citizens (durable descriptor, 'Side: '
// label) but they are NOT subagent topology — filter them out here (the tab
// strip owns them). Legacy threads created before the descriptor fix still
// arrive as corrupt diagnostics; they are recognized by summary title.
const visibleEntries = (catalog?.entries ?? []).filter((entry) => {
if (entry.kind === 'child') return !(entry.label?.startsWith(SIDE_LABEL_PREFIX) ?? false)
return !(byId[entry.id]?.displayTitle.startsWith(SIDE_LABEL_PREFIX) ?? false)
})
// strip owns them). The label lives on the entry for a continuable child and
// on the summary for a one-shot whose descriptor carried none, so both are
// checked.
const visibleEntries = (catalog?.entries ?? []).filter(entry =>
!(entry.label?.startsWith(SIDE_LABEL_PREFIX) ?? false)
&& !(byId[entry.id]?.displayTitle.startsWith(SIDE_LABEL_PREFIX) ?? false))
return (
<>
{emptyLoading && (
@@ -250,43 +275,31 @@ function CatalogRows({
className={css.subagentErrorRetry}
onClick={() => { refresh(parentSessionId) }}
>
<IconRefreshOutline14 />
<IconRefreshOutlineRegular size={14} />
{t('retry')}
</button>
</div>
)}
{visibleEntries.map((entry) => {
if (entry.kind === 'diagnostic') {
return (
<div key={entry.id} className={css.subagentNode}>
<div
role="treeitem"
aria-disabled="true"
aria-level={level}
className={`${css.subagentRow} ${css.subagentRowDisabled}`}
title={diagnosticReason(entry)}
>
<StateDot state="error" className={css.subagentDot} />
<span className={css.subagentContent}>
<span className={css.subagentLabel}>{entry.id}</span>
<span className={css.subagentSecondary}>{diagnosticReason(entry)}</span>
</span>
</div>
</div>
)
}
const childCatalog = catalogs[entry.id]
const knownLeaf = !entry.hasChildren
// A branch stays open until the child's OWN catalog loaded empty; a
// catalog that is missing, still loading, or failed keeps its level so
// the rows appear the moment the projection lands.
const knownLeaf = isKnownLeaf(catalogs, entry.id)
const summary = byId[entry.id]
const label = childLabel(entry, summary)
const secondary = cardSecondary(summary, entry)
const activity = childActivity(live, entry.id)
const secondary = cardSecondary(summary, entry, activity)
const childLoading = childCatalog === undefined
|| (childCatalog.state === 'loading' && childCatalog.entries.length === 0)
const address: SidebarSubagentAddress = {
parentSessionId,
childSessionId: entry.id,
mode: entry.mode,
// DSH 0.1.7 widened the runtime address to `mode: 'unknown'` (the
// row's own mode). This plugin's structural mirror of the address
// still spells the 0.1.6 pair — the cast is that pending widening,
// not a claim about a shape the host rejects.
mode: entry.mode as SidebarSubagentAddress['mode'],
}
const current = entry.id === currentSessionId
@@ -310,13 +323,13 @@ function CatalogRows({
}}
>
<StateDot
state={entry.activity === 'running' ? 'ongoing' : 'done'}
state={activity === 'running' ? 'ongoing' : 'done'}
className={css.subagentDot}
/>
<span className={css.subagentContent}>
<span className={css.subagentLabel}>{label}</span>
<span className={css.subagentSecondary}>{secondary}</span>
{entry.activity === 'running' && (
{activity === 'running' && (
<SubagentLiveLines live={live[entry.id]} />
)}
</span>
@@ -450,20 +463,42 @@ function JobOutputPane(props: {
/**
* The background-job section of the Subagent page: every job of the whole
* current tree (main agent + subagents, owner-labeled), fed by the harness
* `session/jobs` push mirror. Clicking a row feeds its model-read output to
* the shared bottom dock (event replay — never the model's cursor); live
* rows carry a two-click-confirm kill button. Renders nothing while the
* tree has no jobs.
* current tree (main agent + subagents, owner-labeled), read through the
* plugin's `jobs.list` route while the page is visible. Clicking a row feeds
* its model-read output to the shared bottom dock (event replay — never the
* model's cursor); live rows carry a two-click-confirm kill button. Renders
* nothing while the tree has no jobs.
*/
function JobsSection(props: {
byId: SidebarSessionList['byId']
jobsBySession: SidebarSessionList['jobsBySession']
rootId: string | undefined
/** The page is visible (active tab + open panel): skip polling otherwise. */
active: boolean
}) {
const { byId, jobsBySession, rootId, active } = props
const { byId, rootId, active } = props
// The registry's access fence admits a job to its OWNER session only, so the
// tree's jobs are one read per tree session — fanned out on each tick and
// keyed by owner for the (pure) collection below.
const treeIds = useMemo(() => [...treeSessionIds(byId, rootId)], [byId, rootId])
const [jobsBySession, setJobsBySession] = useState<Readonly<Record<string, readonly SidebarJobView[]>>>({})
const poll = useCallback(async (signal: AbortSignal): Promise<void> => {
const entries = await Promise.all(treeIds.map(async (sessionId): Promise<[string, readonly SidebarJobView[]]> => {
try {
const result = await api.jobsList(sessionId, signal)
return [sessionId, result.jobs]
} catch {
// A host without the jobs service (503) or one dropped read degrades
// to an empty set for THIS session; the next tick retries.
return [sessionId, []]
}
}))
if (!signal.aborted) setJobsBySession(Object.fromEntries(entries))
}, [treeIds])
usePolling(active && treeIds.length > 0, poll, {
intervalMs: JOBS_POLL_MS,
mode: 'self-scheduling',
immediate: true,
})
const rows = useMemo(
() => orderJobs(collectTreeJobs(byId, jobsBySession, rootId)),
[byId, jobsBySession, rootId],
@@ -614,11 +649,28 @@ function JobsSection(props: {
)
}
/**
* The client's own "show this conversation" verb. DSH 0.1.6 moved child and
* session navigation OFF `ISessions` (0.1.5's `open` / `openSubagent`, neither
* of which exists any more — the optional calls this page used were silently
* dead) onto the workspace face, which owns the main-view selection. The
* pre-0.1.6 faces stay as the fallback for a host that predates the move.
*/
interface ConversationNavigation {
openSession?(target: SidebarSubagentAddress | string): void
}
/** The per-parent catalog refresh across the two DSH generations. */
interface CatalogRefreshFace {
refreshProjections?(sessionId: string): Promise<void>
refreshSubagents?(sessionId: string): Promise<void>
}
/**
* The sidebar's Subagent topology page.
* @param props - current session id, whether the page is actually visible
* (active tab + open panel), the client context, and an optional
* jump-notify hook fired right before `openSubagent` (lets the sidebar
* jump-notify hook fired right before the child is opened (lets the sidebar
* shell re-open the Subagent page after the conversation switch lands on
* the child session).
* @returns the main agent's topology tree, or the empty/error/loading states.
@@ -632,17 +684,19 @@ export function SubagentView(props: {
const { sessionId, active, ctx, onOpenChild } = props
const sessions = ctx.sessions
// The same list feed the official catalog consumes (byId lineage + the
// lazy per-parent catalogs). Older DSH snapshots without the subagent seam
// simply leave these surfaces empty — the page degrades to the empty state.
// The same list feed the official catalog consumes (`byId` lineage + the
// host-computed projection values). A snapshot without the projection seam
// (a pre-0.1.7 runtime) leaves every catalog absent, so the topology shows
// the root card alone — no rows, and no empty-state copy either.
const list = useSyncExternalStore(
useMemo(() => (callback: () => void) => sessions.list.subscribe(callback), [sessions]),
useCallback(() => sessions.list.getSnapshot(), [sessions]),
)
const byId = list.byId
// Memoized so the empty-catalog fallback keeps a stable identity — a fresh
// `{}` per render would invalidate every catalog-dependent memo/effect.
const catalogs = useMemo(() => list.subagentsByParent ?? {}, [list.subagentsByParent])
// One fold per snapshot: the views are what every row, the branch test and
// the loading/error states read, and a fresh object per render would
// invalidate each of their memos.
const catalogs = useMemo(() => subagentCatalogs(list.projectionsBySession), [list.projectionsBySession])
// The topology root: the main agent of the current session's tree.
const rootId = useMemo(() => rootAncestor(byId, sessionId), [byId, sessionId])
@@ -650,50 +704,15 @@ export function SubagentView(props: {
const rootSummary = rootId === undefined ? undefined : byId[rootId]
const live = useSubagentLive(rootId, active)
/** Catalog owners currently consuming live membership updates. */
const observedRef = useRef(new Set<string>())
const observe = useCallback((parentSessionId: string, open: boolean): void => {
sessions.setSubagentCatalogOpen?.(parentSessionId, open)
if (open) observedRef.current.add(parentSessionId)
else observedRef.current.delete(parentSessionId)
}, [sessions])
// While the page is visible the topology root consumes live membership; a
// root change (switching to another main agent's tree) or the page hiding
// (tab switched away / panel collapsed) releases everything observed.
useEffect(() => {
if (rootId === undefined || !active) return
observe(rootId, true)
return () => {
// The cleanup must release everything observed AT cleanup time (the set
// mutates as branches open), so reading the ref here is the point.
// eslint-disable-next-line react-hooks/exhaustive-deps
for (const parentSessionId of observedRef.current) {
sessions.setSubagentCatalogOpen?.(parentSessionId, false)
}
observedRef.current.clear()
const openConversation = useCallback((target: SidebarSubagentAddress | string): void => {
const workspace = ctx.get('uiWorkspace') as unknown as ConversationNavigation | undefined
if (typeof workspace?.openSession === 'function') {
workspace.openSession(target)
return
}
}, [rootId, active, observe, sessions])
// Every branch of the always-expanded topology consumes live membership
// (add-only: a branch stays observed until the root changes or the page
// hides, which releases the whole set via the root effect's cleanup).
const branches = useMemo(() => collectBranchIds(catalogs, rootId), [catalogs, rootId])
useEffect(() => {
if (!active) return
for (const id of branches) {
if (!observedRef.current.has(id)) observe(id, true)
}
}, [branches, active, observe])
// Unobserve everything on unmount (the host stops refreshing unused catalogs).
useEffect(() => () => {
for (const parentSessionId of observedRef.current) {
sessions.setSubagentCatalogOpen?.(parentSessionId, false)
}
observedRef.current.clear()
}, [sessions])
if (typeof target === 'string') sessions.open?.(target)
else sessions.openSubagent?.(target)
}, [ctx, sessions])
const openChild = useCallback((address: SidebarSubagentAddress): void => {
// Notify the shell first: the jump switches the sidebar to the child
@@ -702,24 +721,28 @@ export function SubagentView(props: {
// highlighted) — the README "page stays open" contract.
onOpenChild?.(address)
try {
sessions.openSubagent?.(address)
openConversation(address)
} catch (error) {
console.error('[dsh-better-sidebar] openSubagent failed:', error)
console.error('[dsh-better-sidebar] open subagent failed:', error)
}
}, [sessions, onOpenChild])
}, [openConversation, onOpenChild])
/** Jump back to the main agent (the topology root) from its node. */
const openMain = useCallback((): void => {
if (rootId === undefined) return
try {
sessions.open?.(rootId)
openConversation(rootId)
} catch (error) {
console.error('[dsh-better-sidebar] open session failed:', error)
}
}, [sessions, rootId])
}, [openConversation, rootId])
const refresh = useCallback((parentSessionId: string): void => {
void sessions.refreshSubagents?.(parentSessionId)
// 0.1.7 renamed the per-parent catalog refresh to `refreshProjections`
// (the projection store owns every session's values now); calling the
// removed 0.1.6 name alone would make this button a silent no-op.
const face = sessions as CatalogRefreshFace
void (face.refreshProjections ?? face.refreshSubagents)?.(parentSessionId)
}, [sessions])
const totals = useMemo(
@@ -789,7 +812,7 @@ export function SubagentView(props: {
disabled={rootId === undefined}
onClick={() => { if (rootId !== undefined) refresh(rootId) }}
>
<IconRefreshOutline14 />
<IconRefreshOutlineRegular size={14} />
</button>
</div>
<div
@@ -862,7 +885,6 @@ export function SubagentView(props: {
</div>
<JobsSection
byId={byId}
jobsBySession={list.jobsBySession}
rootId={rootId}
active={active}
/>
+3 -3
View File
@@ -10,7 +10,7 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'
import clsx from 'clsx'
import {
IconCloseFill14, IconPlusOutline16, Menu,
IconCloseFillRegular, IconPlusOutlineRegular, Menu,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SidebarTab } from './state.ts'
import { t } from './locales.ts'
@@ -223,7 +223,7 @@ export function TabBar(props: {
onClose(tab.id)
}}
>
<IconCloseFill14 />
<IconCloseFillRegular size={14} />
</button>
</div>
))}
@@ -256,7 +256,7 @@ export function TabBar(props: {
title={t('newTab')}
onClick={() => { setMenuOpen(v => !v); setTabMenu(null) }}
>
<IconPlusOutline16 />
<IconPlusOutlineRegular />
</button>
)}
/>
+2 -2
View File
@@ -19,7 +19,7 @@ import clsx from 'clsx'
import { EditorState } from '@codemirror/state'
import { EditorView as CodeMirrorView, keymap, lineNumbers } from '@codemirror/view'
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
import { IconCheckOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconCheckOutlineRegular, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import { markdownTextProps } from './markdown-labels.tsx'
import { api, htmlUrl } from './api.ts'
import { markdownPreviewSource } from './markdown-frontmatter.ts'
@@ -462,7 +462,7 @@ export function TextEditor(props: FileViewerProps) {
title={`${t('save')} (Ctrl/Cmd+S)`}
onClick={save}
>
<IconCheckOutline16 />
<IconCheckOutlineRegular />
</button>
)}
{saveLabel !== '' && <span className={clsx(css.editorStatus, saveState === 'failed' && css.editorStatusError)}>{saveLabel}</span>}
+3 -3
View File
@@ -17,7 +17,7 @@
*/
import { useEffect, useRef, useState, type InputHTMLAttributes } from 'react'
import clsx from 'clsx'
import { IconFolderOpen16, IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconFolderOpenRegular, IconRefreshOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import { api } from './api.ts'
import type { BetterSidebarService } from './service.ts'
import type { SidebarStore } from './state.ts'
@@ -195,7 +195,7 @@ export function TreePanel(props: {
title={t('refresh')}
onClick={() => { setRefreshTick(tick => tick + 1) }}
>
<IconRefreshOutline16 size={14} />
<IconRefreshOutlineRegular size={14} />
</button>
<button
type="button"
@@ -215,7 +215,7 @@ export function TreePanel(props: {
disabled={busy}
onClick={() => { folderInputRef.current?.click() }}
>
<IconFolderOpen16 size={14} />
<IconFolderOpenRegular size={14} />
</button>
<input
ref={fileInputRef}
+12 -1
View File
@@ -9,7 +9,7 @@
import { encodeHtmlUrl } from '../html-route.ts'
import type { LastActivity } from '../subagent-activity.ts'
import type { SidechatLiveEvent, SidechatLogEvent, SidechatThreadInfo } from '../sidechat-core.ts'
import type { SidebarSessionEvent } from '../context-types.ts'
import type { SidebarJobView, SidebarSessionEvent } from '../context-types.ts'
/** One wire failure. */
export class SidebarApiError extends Error {
@@ -329,6 +329,17 @@ export const api = {
*/
jobOutput: (scope: SessionScope, id: string, signal?: AbortSignal) =>
call<JobOutputResult>('jobs.output', scopePayload(scope, { id }), signal),
/**
* The background-job list of one session (the Tasks page's jobs section and
* the job auto-open trigger). DSH 0.1.7 dropped the client session
* snapshot's jobs mirror, so the registry is read through the plugin's own
* `jobs.list` route. The registry's access fence admits a job to its OWNER
* session (and to unowned jobs) only, so a caller that needs the whole tree
* asks once per tree session. A host without the jobs service answers 503:
* the rejection is the caller's to degrade from (an empty section).
*/
jobsList: (sessionId: string, signal?: AbortSignal) =>
call<{ jobs: SidebarJobView[] }>('jobs.list', { sessionId }, signal),
/** Request cancellation of one background job (live jobs flip to stopping). */
jobKill: (scope: SessionScope, id: string, reason?: string) =>
call<{ ok: true; outcome: 'requested' | 'already-finished' }>('jobs.kill', scopePayload(scope, {
+4 -4
View File
@@ -12,7 +12,7 @@
* from the chat's http(s) links) owns embedded pages. See
* docs/plans/2026-09-21-dsh-0.1.6-alpha.2-adaptation.md.
*/
import { IconCodeOutline16, IconPanelLeftOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconCodeOutlineRegular, IconPanelLeftOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import {
changesTabIcon, filesTabIcon, sidechatTabIcon, tasksTabIcon,
} from './tab-icons.tsx'
@@ -27,7 +27,7 @@ import { consumeSidechatSeed, SideChatView, sidechatThreadIdOf } from '../SideCh
import { api } from '../api.ts'
import type { TabDescriptor } from '../service.ts'
/** The 6 built-in tab descriptors. */
/** The 5 built-in tab descriptors (the host owns terminal and browser). */
export function builtinTabs(): readonly TabDescriptor[] {
return [
{
@@ -57,13 +57,13 @@ export function builtinTabs(): readonly TabDescriptor[] {
options: [
{
value: true,
icon: (size: number) => <IconPanelLeftOutline16 size={size} />,
icon: (size: number) => <IconPanelLeftOutlineRegular size={size} />,
title: () => t('editorExplorerMerged'),
desc: () => t('editorExplorerMergedDesc'),
},
{
value: false,
icon: (size: number) => <IconCodeOutline16 size={size} />,
icon: (size: number) => <IconCodeOutlineRegular size={size} />,
title: () => t('editorExplorerSplit'),
desc: () => t('editorExplorerSplitDesc'),
},
+23 -54
View File
@@ -1,41 +1,44 @@
/**
* The 6 built-in file viewer descriptors: every preview surface is a
* registered viewer (image / pdf / markdown / html / code /
* binary-download), exactly like external plugins register theirs. Office
* previews (.docx / .xlsx / .pptx) are NOT built in anymore — they moved to
* the recommended office plugin (see plugins-viewers.ts), which registers
* the same ids through this service.
* The 3 built-in file viewer descriptors (markdown / html / code), exactly
* like external plugins register theirs.
*
* The `binary-download` viewer sniffs NUL bytes via `detect` for unknown
* binaries and serves legacy doc/xls/ppt by extension; `code` is the
* catch-all (`exts: []`, lowest priority) that claims any file no other
* viewer did.
* DSH 0.1.7 ships `ui-sidebar-documentpreview`, its own code / spreadsheet /
* office / pdf / image / html / markdown / text previews with zoom and
* auto-refresh, so this plugin yields every READ-ONLY preview it used to own
* and keeps only the surfaces where it is not equivalent:
* - `markdown` — this plugin's own renderer (front matter, embedded HTML
* sanitation, floating TOC), which the user prefers to the host's;
* - `html` — the sandboxed iframe preview plus the host-less
* `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` escape hatches;
* - `code` — the catch-all (`exts: []`, lowest priority) that claims any
* file no other viewer did, and it is an EDITABLE CodeMirror with save —
* the host's equivalents are read-only previews.
* The yielded ids (image / pdf / binary-download) are deliberately NOT
* registered here; an external plugin may still claim them through this
* service. Office previews (.docx / .xlsx / .pptx) were already not built
* in — they live in the recommended office plugin (see plugins-viewers.ts),
* which registers the same ids through this service.
*
* The heavy viewers (the CodeMirror-backed markdown/html/code) render
* through {@link lazyChunkComponent} wrappers — their libraries are fetched
* only when such a file is first opened (see chunk-loader.ts). The
* descriptor metadata (id/exts/priority/detect) is identical either way,
* so matching semantics and external-plugin overrides are unaffected; the
* descriptor metadata (id/exts/priority) is identical either way, so
* matching semantics and external-plugin overrides are unaffected; the
* `component` wrapper keeps the descriptor contract `(props) => ReactNode`.
*
* 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 { IconCodeOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import { lazyChunkComponent } from '../lazy-chunk.tsx'
import { PdfView } from '../PdfView.tsx'
import { BinaryDownload } from '../binary-download.tsx'
import {
IconImageOutline16,
IconMarkdownOutline16,
IconPdfOutline16,
IconHtmlOutline16,
} from '../icons.tsx'
import type { ComponentType } from 'react'
import type { FileViewerDescriptor, FileViewerProps } from '../service.ts'
import { t } from '../locales.ts'
import css from '../sidebar.module.css'
/**
* Lazy wrapper over the chunk-resident viewer component. The `pick`
@@ -45,31 +48,9 @@ import css from '../sidebar.module.css'
*/
const LazyTextEditor = lazyChunkComponent<FileViewerProps>('editor', (mod) => mod.TextEditor as ComponentType<FileViewerProps> | undefined)
/** The 6 built-in file viewer descriptors. */
/** The 3 built-in file viewer descriptors. */
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 }) => (
<div className={css.editorImageWrap}>
<img className={css.editorImage} src={url} alt={title} />
</div>
),
},
{
id: 'pdf',
title: () => t('viewerPdf'),
icon: (size: number) => <IconPdfOutline16 size={size} />,
exts: ['pdf'],
fetchStrategy: 'mediaUrl',
component: ({ scope, path, title }) => (
<PdfView scope={scope} path={path} title={title} />
),
},
{
id: 'markdown',
title: () => t('viewerMarkdown'),
@@ -103,23 +84,11 @@ export function builtinViewers(): readonly FileViewerDescriptor[] {
{
id: 'code',
title: () => t('viewerCode'),
icon: (size: number) => <IconCodeOutline16 size={size} />,
icon: (size: number) => <IconCodeOutlineRegular size={size} />,
exts: [],
priority: -100,
fetchStrategy: 'fsRead',
component: (props) => <LazyTextEditor {...props} />,
},
{
id: 'binary-download',
title: () => t('viewerBinary'),
icon: (size: number) => <IconDownloadOutline16 size={size} />,
exts: ['doc', 'xls', 'ppt'],
priority: -50,
fetchStrategy: 'binary-download',
// NUL probe: a file whose head bytes contain a NUL is binary — claimed
// before the catch-all code viewer on the head re-match.
detect: (_path, head) => head.includes(0),
component: ({ scope, path }) => <BinaryDownload scope={scope} path={path} />,
},
]
}
+4 -4
View File
@@ -9,7 +9,7 @@
* diff tab via the shell.
*/
import { useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'
import { IconCloseOutline16, IconRefreshOutline16, IconRightUpOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconCloseOutlineRegular, IconRefreshOutlineRegular, IconRightUpOutlineRegular, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SessionScope } from '../api.ts'
import { api, htmlUrl } from '../api.ts'
import { t } from '../locales.ts'
@@ -492,7 +492,7 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
disabled={loading}
onClick={() => { setTick(value => value + 1) }}
>
<IconRefreshOutline16 size={14} />
<IconRefreshOutlineRegular size={14} />
</button>
<button
type="button"
@@ -501,7 +501,7 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
title={t('changesOpenDiffTab')}
onClick={onExpand}
>
<IconRightUpOutline16 size={14} />
<IconRightUpOutlineRegular size={14} />
</button>
</>
)}
@@ -544,7 +544,7 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
title={t('changesClosePreview')}
onClick={onClose}
>
<IconCloseOutline16 size={14} />
<IconCloseOutlineRegular size={14} />
</button>
</div>
{target.kind === 'op' && htmlOp && rendering && htmlRenderSrc !== ''
+13 -13
View File
@@ -12,8 +12,8 @@
*/
import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNode } from 'react'
import {
Button, IconCodeOutline16, IconCopyOutline16, IconPlusOutline16,
IconRefreshOutline16, IconTrashOutline16, Input, Menu, Modal, writeClipboard,
Button, IconCodeOutlineRegular, IconCopyOutlineRegular, IconPlusOutlineRegular,
IconRefreshOutlineRegular, IconTrashOutlineRegular, Input, Menu, Modal, writeClipboard,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { GitLogEntry, GitStatusEntry, GitStatusResult, GitWorktree, SessionScope } from '../api.ts'
import { api } from '../api.ts'
@@ -450,7 +450,7 @@ export function GitLens(props: GitLensProps) {
disabled={busy}
onClick={() => { void stageEntry(entry, staged) }}
>
{staged ? <IconTrashOutline16 /> : <IconPlusOutline16 />}
{staged ? <IconTrashOutlineRegular /> : <IconPlusOutlineRegular />}
</button>
</div>
)
@@ -504,7 +504,7 @@ export function GitLens(props: GitLensProps) {
title={t('refresh')}
onClick={() => { void refresh() }}
>
<IconRefreshOutline16 size={14} />
<IconRefreshOutlineRegular size={14} />
</button>
</div>
@@ -624,17 +624,17 @@ export function GitLens(props: GitLensProps) {
// that confuses the user; with the fence disarmed (the
// `workspaceFence` pref) the open is allowed through.
...(fileMenu !== null && (store.getPrefs().workspaceFence === false || isWithinWorkspace(scope.cwd ?? '', resolveSidebarPath(repoRoot ?? selectedWorktree ?? scope.cwd, fileMenu.entry.path)))
? [{ id: 'open', label: t('openEditor'), icon: <IconCodeOutline16 size={14} /> }]
? [{ id: 'open', label: t('openEditor'), icon: <IconCodeOutlineRegular size={14} /> }]
: []),
fileMenu?.staged === true
? { id: 'stage', label: t('unstage'), icon: <IconTrashOutline16 size={14} /> }
: { id: 'stage', label: t('stage'), icon: <IconPlusOutline16 size={14} /> },
? { id: 'stage', label: t('unstage'), icon: <IconTrashOutlineRegular size={14} /> }
: { id: 'stage', label: t('stage'), icon: <IconPlusOutlineRegular size={14} /> },
...(fileMenu !== null && !isUntracked(fileMenu.entry)
? [{ id: 'discard', label: t('discard'), icon: <IconTrashOutline16 size={14} />, danger: true }]
? [{ id: 'discard', label: t('discard'), icon: <IconTrashOutlineRegular size={14} />, danger: true }]
: []),
{ type: 'separator', id: 'sep1' },
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={14} /> },
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={14} /> },
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutlineRegular size={14} /> },
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutlineRegular size={14} /> },
]}
onSelect={(id) => {
const target = fileMenu
@@ -683,9 +683,9 @@ export function GitLens(props: GitLensProps) {
onClose={() => { setHistoryMenu(null) }}
items={[
{ id: 'view', label: t('viewCommitDiff') },
{ id: 'copyShort', label: t('copyShortHash'), icon: <IconCopyOutline16 size={14} /> },
{ id: 'copyFull', label: t('copyFullHash'), icon: <IconCopyOutline16 size={14} /> },
{ id: 'copySubject', label: t('copySubject'), icon: <IconCopyOutline16 size={14} /> },
{ id: 'copyShort', label: t('copyShortHash'), icon: <IconCopyOutlineRegular size={14} /> },
{ id: 'copyFull', label: t('copyFullHash'), icon: <IconCopyOutlineRegular size={14} /> },
{ id: 'copySubject', label: t('copySubject'), icon: <IconCopyOutlineRegular size={14} /> },
{ type: 'separator', id: 'sep2' },
{ id: 'revert', label: t('revertCommit'), danger: true },
{ id: 'cherryPick', label: t('cherryPickCommit'), danger: true },
+33 -18
View File
@@ -74,6 +74,8 @@ function pathOf(args: Record<string, unknown>): string | undefined {
interface ToolResultMessageLike {
source?: { kind?: unknown; callId?: unknown }
content?: unknown
/** The 0.1.7 first-class tool message lifts the error flag onto the message. */
isError?: unknown
}
/** One 'tool-result' content block (inner blocks carry the text). */
@@ -83,33 +85,46 @@ interface ToolResultBlockLike {
isError?: unknown
}
/** The finalized plain text of one tool result (inner text blocks joined). */
function resultText(message: ToolResultMessageLike): string | undefined {
/**
* The result blocks and error flag of one tool/result message, read under BOTH
* logged shapes: 0.1.6 wrapped the result in a single `type: 'tool-result'`
* content block on a user-role message (the text nested inside it, `isError`
* on the wrapper), 0.1.7's first-class tool-role message carries the blocks at
* the message's own top level with `isError` lifted onto the message.
* Historical logs keep the old shape forever, so both are read. Undefined when
* the message carries no block array.
*/
function resultOf(message: ToolResultMessageLike): { blocks: readonly unknown[]; isError: boolean } | undefined {
if (!Array.isArray(message.content)) return undefined
const parts: string[] = []
for (const block of message.content) {
if (block === null || typeof block !== 'object') continue
const candidate = block as ToolResultBlockLike
if (candidate.type !== 'tool-result') continue
const inner = candidate.content
if (!Array.isArray(inner)) continue
for (const item of inner) {
if (item === null || typeof item !== 'object') continue
const textItem = item as { type?: unknown; text?: unknown }
if (textItem.type === 'text' && typeof textItem.text === 'string') parts.push(textItem.text)
const wrapper = block as ToolResultBlockLike
if (wrapper.type !== 'tool-result') continue
return {
blocks: Array.isArray(wrapper.content) ? wrapper.content as readonly unknown[] : [],
isError: wrapper.isError === true,
}
}
return { blocks: message.content as readonly unknown[], isError: message.isError === true }
}
/** The finalized plain text of one tool result (its text blocks joined). */
function resultText(message: ToolResultMessageLike): string | undefined {
const blocks = resultOf(message)?.blocks
if (blocks === undefined) return undefined
const parts: string[] = []
for (const item of blocks) {
if (item === null || typeof item !== 'object') continue
const textItem = item as { type?: unknown; text?: unknown }
if (textItem.type === 'text' && typeof textItem.text === 'string') parts.push(textItem.text)
}
return parts.length > 0 ? parts.join('\n') : undefined
}
/** Whether a tool result reported an error (the inner block's isError flag). */
/** Whether a tool result reported an error (0.1.7's message-level flag, else
* the 0.1.6 wrapper block's flag). */
function resultIsError(message: ToolResultMessageLike): boolean {
if (!Array.isArray(message.content)) return false
return message.content.some((block) => {
if (block === null || typeof block !== 'object') return false
return (block as ToolResultBlockLike).type === 'tool-result'
&& (block as ToolResultBlockLike).isError === true
})
return resultOf(message)?.isError === true
}
/**
-20
View File
@@ -60,26 +60,6 @@ export const IconUploadOutline16 = ({ size = 16, className }: IconProps) => (
// ── 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>
)
/** 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">
+30 -24
View File
@@ -21,7 +21,7 @@ import { createNativeTabRecords } from './native/tab-adapter.tsx'
import { registerNativeSurface } from './native/index.ts'
import { registerBottomToggle } from './sidebar/bottom-toggle.tsx'
import { createNativeSurface } from './native/surface.ts'
import { registerLinkInterception } from './link-intercept.ts'
import { isTargetAvailable, openInterceptedLink, registerLinkInterception, shouldTakeOverLink } from './link-intercept.ts'
import { registerImeGuard } from './ime-guard.ts'
import { registerSettingsNavIcon } from './settings-nav-icon.ts'
import { loadBootDecision } from './prefs.ts'
@@ -338,36 +338,42 @@ export function apply(ctx: Context): void {
ctx.effect(
() => {
try {
// External http(s) links in the chat/GUI open the sidebar instead
// of a new window. Gated on the browserInterceptLinks MASTER pref,
// the URL's protocol flag (browserInterceptHttp / Https — https
// defaults OFF: most https sites refuse iframe embedding), and the
// target tab's enable switch; Ctrl/Cmd+click always bypasses. The
// target is the first registered tab whose `urlTarget` claims the
// URL (enabled tabs only), else the built-in browser tab.
// External links are taken over ONLY when a registered tab type
// claims the URL through `urlTarget` (and this sidebar is not
// suspended); Ctrl/Cmd+click and non-http(s) / same-origin links
// always bypass. Everything else is left to the host: since DSH
// 0.1.7 the destination of a chat link is decided by the user's
// `linkOpening` setting plus the host's own browser tab — a kind
// the Web profile leaves disabled — so `preventDefault`ing an
// unclaimed link would swallow it (the host's `MarkdownAnchor`
// does not re-check `defaultPrevented`, and plugin-drawn markdown
// — sidechat transcripts, editor previews, HTML previews, diff
// panes — would lose the click entirely). Host-rendered chat prose
// therefore goes back to the host's `openExternalLink`, and
// plugin-drawn markdown to the anchor's own `window.open`.
const urlTargetOf = (url: URL): string | undefined => {
const prefs = sidebarStore.getPrefs()
const enabled = service.getTabs().filter(tab => prefs.tabsEnabled[tab.id] !== false)
return matchUrlTarget(enabled, url)?.id
}
return registerLinkInterception({
takeoverEnabled: (url) => {
if (sidebarStore.getSuspended()) return false
const prefs = sidebarStore.getPrefs()
if (prefs.browserInterceptLinks === false) return false
const protocolOn = url.protocol === 'https:'
? prefs.browserInterceptHttps !== false
: prefs.browserInterceptHttp !== false
if (!protocolOn) return false
// A plugin claim is the target (already enabled-filtered);
// otherwise the built-in browser must be enabled.
return urlTargetOf(url) !== undefined || prefs.tabsEnabled['browser'] !== false
},
takeoverEnabled: (url) => shouldTakeOverLink(url, {
suspended: sidebarStore.getSuspended(),
resolveTarget: urlTargetOf,
}),
openInSidebar: (url) => {
let title: string | undefined
try { title = new URL(url).hostname } catch { /* keep the default title */ }
const type = urlTargetOf(new URL(url)) ?? 'browser'
ctx.get('betterSidebar')?.openTab({ type, url, title })
openInterceptedLink(url, {
resolveTarget: urlTargetOf,
// Re-checked at open time: the claim above and this open are
// separate turns, so the type may be gone (plugin unloaded,
// switched off) by now.
isAvailable: (type) => isTargetAvailable(
type,
service.getTabs(),
sidebarStore.getPrefs().tabsEnabled,
),
sidebar: ctx.get('betterSidebar'),
})
},
selfOrigin: window.location.origin,
})
+93 -25
View File
@@ -1,31 +1,39 @@
/**
* Chat/GUI external-link interception: clicking an http(s) link that points
* OUTSIDE the GUI (chat messages, tool rows, prose mentions) opens the
* sidebar instead of a new browser tab. Gated by the caller through
* `takeoverEnabled(url)` — the `browserInterceptLinks` master, the URL's
* protocol flag (`browserInterceptHttp` / `browserInterceptHttps`) and the
* target tab's enable switch — and a Ctrl/Cmd/Shift/Alt-modified click
* always bypasses the takeover so the user can still force a real browser
* tab.
* External-link interception: clicking an http(s) link that a REGISTERED tab
* type CLAIMS through `urlTarget` opens that type in the sidebar. Everything
* else is left alone — the plugin never `preventDefault`s a link it cannot
* serve, because the destination of a chat link is not the plugin's call:
* since DSH 0.1.7 the host's `ui-chat` `openExternalLink` applies the user's
* `linkOpening` setting and the host's own browser tab (a kind the Web
* profile disables), and inside plugin-drawn markdown (sidechat
* transcripts, editor previews, HTML previews, diff panes) the anchor's own
* default (`window.open`) applies.
*
* Only the GUI's OWN document is watched — links inside the browser tab's
* sandboxed iframe live in another document and never bubble here (and
* their clicks must keep working inside the sidebar).
* A Ctrl/Cmd/Shift/Alt-modified click always bypasses the takeover so the
* user can still force a real browser tab.
*
* Only the GUI's OWN document is watched — links inside a sandboxed iframe
* live in another document and never bubble here (and their clicks must keep
* working inside the sidebar).
*/
/** The pure decision: the URL to open in the sidebar, or null to let the
* click fall through. Extracted so the policy is unit-testable without a
* DOM. `anchorHref` must be the ABSOLUTE href (`<a>.href` already is).
* The protocol/same-origin policy lives HERE; the prefs gates (master +
* protocol flags + target enablement) live in the caller's
* `takeoverEnabled(url)` callback. */
export function shouldInterceptLink(anchorHref: string, selfOrigin: string): string | null {
let url: URL
/** `new URL(value)`, or null for an unparsable href. */
function parseUrl(value: string): URL | null {
try {
url = new URL(anchorHref)
return new URL(value)
} catch {
return null
}
}
/** The pure decision: the URL a click may hand to the sidebar, or null to let
* the click fall through. Extracted so the policy is unit-testable without a
* DOM. `anchorHref` must be the ABSOLUTE href (`<a>.href` already is).
* The protocol/same-origin policy lives HERE; whether some enabled tab type
* actually claims the URL is the caller's `takeoverEnabled` callback. */
export function shouldInterceptLink(anchorHref: string, selfOrigin: string): string | null {
const url = parseUrl(anchorHref)
if (url === null) return null
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
// Same-origin links are GUI-internal navigation (settings pages, tool
// docs) — never routed into the sidebar.
@@ -43,14 +51,74 @@ export function isPlainLeftClick(event: { button: number; metaKey: boolean; ctrl
}
/**
* Register the document-level click capture that funnels external links
* into the sidebar. Returns the disposer (HMR-safe).
* The caller-side gate: take a link over only when the sidebar is not
* suspended AND some enabled tab type claims the URL. An unclaimed link is
* the host's to route (its `openExternalLink` / the anchor's own default).
*/
export function shouldTakeOverLink(url: URL, opts: {
/** Another panel provider owns the right column — the sidebar must not act. */
suspended: boolean
/** The tab type claiming this URL through `urlTarget` (enabled tabs only). */
resolveTarget: (url: URL) => string | undefined
}): boolean {
if (opts.suspended) return false
return opts.resolveTarget(url) !== undefined
}
/**
* Whether a claimed type is still on offer at OPEN time: registered in the
* tab registry AND not switched off in the settings. The claim (gate) and the
* open are two separate turns of the event loop, so the type may disappear in
* between — a plugin unloaded, the user disabled it, the registry reset.
*/
export function isTargetAvailable(
type: string,
tabs: readonly { id: string }[],
tabsEnabled: Record<string, boolean>,
): boolean {
return tabs.some(tab => tab.id === type && tabsEnabled[tab.id] !== false)
}
/**
* Open an intercepted link: the sidebar tab when the claimed type is still
* available, a real browser tab otherwise. The fallback is not decoration —
* the plugin's own `openTab` silently ignores an unknown / disabled type
* (service.ts returns early), so without it a taken-over click would do
* nothing at all. Returns the path taken (tests assert the fallback).
*/
export function openInterceptedLink(url: string, deps: {
/** The tab type claiming this URL through `urlTarget` (enabled tabs only). */
resolveTarget: (url: URL) => string | undefined
/** Whether that type is still registered and enabled ({@link isTargetAvailable}). */
isAvailable: (type: string) => boolean
/** The plugin's own tab service (`ctx.get('betterSidebar')`), when attached. */
sidebar: { openTab: (seed: { type: string; url: string; title?: string }) => void } | undefined
}): 'sidebar' | 'window' {
const parsed = parseUrl(url)
const type = parsed === null ? undefined : deps.resolveTarget(parsed)
if (parsed !== null && type !== undefined && deps.sidebar !== undefined && deps.isAvailable(type)) {
deps.sidebar.openTab({ type, url, title: parsed.hostname })
return 'sidebar'
}
// Nothing in the sidebar can serve this URL (unclaimed, the type is gone or
// switched off, or the plugin's service is detached): hand it to the real
// browser so the click is never a silent no-op. A click IS the user
// gesture, so the popup blocker lets this through.
window.open(url, '_blank', 'noopener,noreferrer')
return 'window'
}
/**
* Register the document-level click capture that funnels CLAIMED external
* links into the sidebar. Returns the disposer (HMR-safe).
*/
export function registerLinkInterception(opts: {
/** Whether the takeover may happen for THIS url (the caller's prefs
* gates: master switch, protocol flag, target enablement). */
/** Whether the takeover may happen for THIS url (the caller's gate —
* {@link shouldTakeOverLink}: not suspended and claimed by an enabled
* tab type). */
takeoverEnabled: (url: URL) => boolean
/** Open the sidebar tab at `url` (the caller resolves the target type). */
/** Open the URL — the caller resolves the claimed type and falls back to a
* real browser tab when it is gone ({@link openInterceptedLink}). */
openInSidebar: (url: string) => void
/** The GUI's own origin (window.location.origin at registration). */
selfOrigin: string
-9
View File
@@ -281,11 +281,8 @@ export const ar: Record<string, string> = {
settingsGeneralTitle: 'عام',
settingsPopup: 'إعدادات الميزة',
settingsViewerCatchAll: 'شامل: أي ملف',
viewerImage: 'صورة',
viewerPdf: 'PDF',
viewerMarkdown: 'Markdown',
viewerCode: 'كود',
viewerBinary: 'تنزيل ثنائي',
viewerHtml: 'HTML',
browser: 'المتصفح',
browserPlaceholder: 'أدخل عنوان URL، مثل example.com',
@@ -306,12 +303,6 @@ export const ar: Record<string, string> = {
settingsHtmlSandboxDesc: 'مع تعطيل الحماية، يعمل HTML المعاين بنفس أصل الواجهة: يمكنه قراءة ملفات الجلسة والتخزين المحلي واستدعاء واجهات API الداخلية. فعّله فقط للملفات الموثوقة بالكامل',
settingsBrowserSandboxTitle: 'تعطيل حماية المتصفح (غير آمن)',
settingsBrowserSandboxDesc: 'مع تعطيل الحماية، يعمل أي موقع تزوره بنفس أصل الواجهة: يمكنه قراءة بيانات الجلسة وانتحال جلسة تسجيل الدخول الخاصة بك. فعّله فقط للمواقع الموثوقة بالكامل',
settingsBrowserLinksTitle: 'فتح الروابط الخارجية للمحادثة في الشريط الجانبي',
settingsBrowserLinksDesc: 'عند التفعيل، النقر على رابط خارجي في المحادثة أو الواجهة يفتح الشريط الجانبي بدلاً من نافذة جديدة؛ يتحكّم في HTTP وHTTPS بشكل منفصل بالمبدلات بالأسفل؛ Ctrl/Cmd+نقر يتجاوز دائماً',
settingsBrowserHttpTitle: 'فتح صفحات HTTP في الشريط الجانبي',
settingsBrowserHttpDesc: 'عند التفعيل، النقر على رابط HTTP خارجي في المحادثة أو الواجهة يفتح الشريط الجانبي (صفحات الإضافات المعرّفة عن urlTarget تفوز)؛ Ctrl/Cmd+نقر يتجاوز دائماً',
settingsBrowserHttpsTitle: 'فتح صفحات HTTPS في الشريط الجانبي',
settingsBrowserHttpsDesc: 'عند التفعيل، النقر على رابط HTTPS خارجي في المحادثة أو الواجهة يفتح الشريط الجانبي. معطّل افتراضياً: معظم مواقع HTTPS ترفض التضمين، لذا فالمتصفح الافتراضي للنظام هو الخيار الأنسب',
settingsBrowserLoopbackTitle: 'العناوين المحلية المسموح بها',
settingsBrowserLoopbackDesc: 'قائمة بيضاء مفصولة بفواصل لعناوين الاسترجاع المحلي (مثل localhost:5174 أو 127.0.0.1:8080) يمكن للمتصفح الجانبي زيارتها؛ فارغة افتراضيًا تمنع جميع العناوين المحلية. تظل صندوق الحماية سارية — لا يمكن للصفحات قراءة بيانات الواجهة',
settingsBrowserLoopbackPlaceholder: 'مثال localhost:5174, 127.0.0.1:8080',
-9
View File
@@ -266,11 +266,8 @@ export const de: Record<string, string> = {
settingsGeneralTitle: 'Allgemein',
settingsPopup: 'Funktionseinstellungen',
settingsViewerCatchAll: 'Catch-all: beliebige Datei',
viewerImage: 'Bild',
viewerPdf: 'PDF',
viewerMarkdown: 'Markdown',
viewerCode: 'Code',
viewerBinary: 'Binär-Download',
viewerHtml: 'HTML',
browser: 'Browser',
browserPlaceholder: 'URL eingeben, z. B. example.com',
@@ -291,12 +288,6 @@ export const de: Record<string, string> = {
settingsHtmlSandboxDesc: 'Ohne Sandbox läuft das angezeigte HTML mit der gleichen Herkunft wie die Oberfläche: Es kann Sitzungsdateien und lokalen Speicher lesen und interne APIs aufrufen. Nur für vollständig vertrauenswürdige Dateien aktivieren',
settingsBrowserSandboxTitle: 'Browser-Sandbox deaktivieren (unsicher)',
settingsBrowserSandboxDesc: 'Ohne Sandbox läuft jede besuchte Website mit der gleichen Herkunft wie die Oberfläche: Sie kann Sitzungsdaten lesen und sich als Ihre Anmeldung ausgeben. Nur für vollständig vertrauenswürdige Websites aktivieren',
settingsBrowserLinksTitle: 'Externe Links aus dem Chat in der Seitenleiste öffnen',
settingsBrowserLinksDesc: 'Wenn aktiviert, öffnet ein Klick auf einen externen Link im Chat oder in der Oberfläche die Seitenleiste statt eines neuen Fensters; HTTP und HTTPS werden getrennt über die Schalter unten gesteuert; Ctrl/Cmd-Klick umgeht dies immer',
settingsBrowserHttpTitle: 'HTTP-Seiten in der Seitenleiste öffnen',
settingsBrowserHttpDesc: 'Wenn aktiviert, öffnet ein Klick auf einen HTTP-Externlink im Chat oder in der Oberfläche die Seitenleiste (Plugin-Seiten mit urlTarget gewinnen); Ctrl/Cmd-Klick umgeht dies immer',
settingsBrowserHttpsTitle: 'HTTPS-Seiten in der Seitenleiste öffnen',
settingsBrowserHttpsDesc: 'Wenn aktiviert, öffnet ein Klick auf einen HTTPS-Externlink im Chat oder in der Oberfläche die Seitenleiste. Standardmäßig deaktiviert: Die meisten HTTPS-Sites verweigern die Einbettung, der Systembrowser ist der reibungslosere Standard',
settingsBrowserLoopbackTitle: 'Erlaubte lokale Adressen',
settingsBrowserLoopbackDesc: 'Kommagetrennte Allowlist von Loopback-Adressen (z. B. localhost:5174 oder 127.0.0.1:8080), die der Seitenleisten-Browser besuchen darf; leer blockiert standardmäßig alle lokalen Adressen. Die Sandbox bleibt aktiv — Seiten können keine GUI-Daten lesen',
settingsBrowserLoopbackPlaceholder: 'z. B. localhost:5174, 127.0.0.1:8080',
-9
View File
@@ -273,11 +273,8 @@ export const fr: Record<string, string> = {
settingsGeneralTitle: 'Général',
settingsPopup: 'Réglages de la fonctionnalité',
settingsViewerCatchAll: 'Par défaut : n’importe quel fichier',
viewerImage: 'Image',
viewerPdf: 'PDF',
viewerMarkdown: 'Markdown',
viewerCode: 'Code',
viewerBinary: 'Téléchargement binaire',
viewerHtml: 'HTML',
browser: 'Navigateur',
browserPlaceholder: 'Saisissez une adresse, ex. example.com',
@@ -298,12 +295,6 @@ export const fr: Record<string, string> = {
settingsHtmlSandboxDesc: 'Une fois désactivé, le HTML de l’aperçu s’exécute avec la même origine que l’interface, peut lire les fichiers de session, le stockage local et appeler les interfaces internes. À activer uniquement pour des fichiers entièrement fiables',
settingsBrowserSandboxTitle: 'Désactiver le bac à sable du navigateur (non sécurisé)',
settingsBrowserSandboxDesc: 'Une fois désactivé, tout site visité s’exécute avec la même origine que l’interface, peut lire les données de session et usurper votre état de connexion. À activer uniquement pour des sites entièrement fiables',
settingsBrowserLinksTitle: 'Ouvrir les liens externes du chat dans la barre latérale',
settingsBrowserLinksDesc: 'Une fois activé, cliquer sur un lien externe dans le chat ou l’interface l’ouvre dans la barre latérale au lieu d’une nouvelle fenêtre ; HTTP et HTTPS sont contrôlés séparément par les interrupteurs ci-dessous ; Ctrl/Cmd+clic autorise temporairement',
settingsBrowserHttpTitle: 'Ouvrir les pages HTTP de côté',
settingsBrowserHttpDesc: 'Une fois activé, cliquer sur un lien externe HTTP du chat ou de l’interface l’ouvre dans la barre latérale (les pages de plugin déclarant urlTarget sont prioritaires) ; Ctrl/Cmd+clic autorise temporairement',
settingsBrowserHttpsTitle: 'Ouvrir les pages HTTPS de côté',
settingsBrowserHttpsDesc: 'Une fois activé, cliquer sur un lien externe HTTPS du chat ou de l’interface l’ouvre dans la barre latérale. Désactivé par défaut : la plupart des sites HTTPS refusent l’encapsulation, le navigateur système est plus fluide',
settingsBrowserLoopbackTitle: 'Adresses locales autorisées',
settingsBrowserLoopbackDesc: 'Liste blanche séparée par des virgules des adresses de bouclage (ex. localhost:5174 ou 127.0.0.1:8080) que le navigateur de la barre latérale peut visiter ; vide bloque toutes les adresses locales par défaut. Le bac à sable reste actif — les pages ne peuvent pas lire les données de l interface',
settingsBrowserLoopbackPlaceholder: 'ex. localhost:5174, 127.0.0.1:8080',
-9
View File
@@ -280,11 +280,8 @@ export const hi: Record<string, string> = {
settingsGeneralTitle: 'सामान्य',
settingsPopup: 'सुविधा सेटिंग्स',
settingsViewerCatchAll: 'कैच-ऑल: कोई भी फ़ाइल',
viewerImage: 'छवि',
viewerPdf: 'PDF',
viewerMarkdown: 'Markdown',
viewerCode: 'कोड',
viewerBinary: 'बाइनरी डाउनलोड',
viewerHtml: 'HTML',
browser: 'ब्राउज़र',
browserPlaceholder: 'URL दर्ज करें, जैसे example.com',
@@ -305,12 +302,6 @@ export const hi: Record<string, string> = {
settingsHtmlSandboxDesc: 'सैंडबॉक्स बंद होने पर, पूर्वावलोकित HTML GUI के समान मूल के साथ चलता है: सत्र फ़ाइलें, स्थानीय स्टोरेज पढ़ सकता है और आंतरिक API कॉल कर सकता है। केवल पूर्ण विश्वसनीय फ़ाइलों के लिए सक्षम करें',
settingsBrowserSandboxTitle: 'ब्राउज़र सैंडबॉक्स अक्षम करें (असुरक्षित)',
settingsBrowserSandboxDesc: 'सैंडबॉक्स बंद होने पर, कोई भी विज़िट की गई साइट GUI के समान मूल के साथ चलती है: सत्र डेटा पढ़ सकती है और आपके लॉग-इन सत्र के रूप में कार्य कर सकती है। केवल पूर्ण विश्वसनीय साइटों के लिए सक्षम करें',
settingsBrowserLinksTitle: 'चैट बाहरी लिंक साइडबार में खोलें',
settingsBrowserLinksDesc: 'चालू होने पर, चैट या GUI में बाहरी लिंक क्लिक करने पर नई विंडो के बजाय साइडबार खुलता है; HTTP और HTTPS नीचे स्विच द्वारा अलग से नियंत्रित; Ctrl/Cmd+क्लिक हमेशा बायपास करता है',
settingsBrowserHttpTitle: 'HTTP पृष्ठ साइडबार में खोलें',
settingsBrowserHttpDesc: 'चालू होने पर, चैट या GUI में HTTP बाहरी लिंक क्लिक करने पर साइडबार खुलता है (urlTarget घोषित प्लगइन पृष्ठ जीतते हैं); Ctrl/Cmd+क्लिक हमेशा बायपास करता है',
settingsBrowserHttpsTitle: 'HTTPS पृष्ठ साइडबार में खोलें',
settingsBrowserHttpsDesc: 'चालू होने पर, चैट या GUI में HTTPS बाहरी लिंक क्लिक करने पर साइडबार खुलता है। डिफ़ॉल्ट रूप से बंद: अधिकांश HTTPS साइटें एम्बेड होने से इनकार करती हैं, सिस्टम ब्राउज़र चिकना डिफ़ॉल्ट है',
settingsBrowserLoopbackTitle: 'अनुमत स्थानीय पते',
settingsBrowserLoopbackDesc: 'लूपबैक पतों की कॉमा-सेपरेटेड अनुमति सूची (जैसे localhost:5174 या 127.0.0.1:8080) जिन्हें साइडबार ब्राउज़र देख सकता है; खाली डिफ़ॉल्ट रूप से सभी स्थानीय पते ब्लॉक करता है। सैंडबॉक्स लागू रहता है — पेज GUI डेटा नहीं पढ़ सकते',
settingsBrowserLoopbackPlaceholder: 'जैसे localhost:5174, 127.0.0.1:8080',
-9
View File
@@ -278,11 +278,8 @@ export const id: Record<string, string> = {
settingsGeneralTitle: 'Umum',
settingsPopup: 'Pengaturan fitur',
settingsViewerCatchAll: 'Catch-all: berkas apa pun',
viewerImage: 'Gambar',
viewerPdf: 'PDF',
viewerMarkdown: 'Markdown',
viewerCode: 'Kode',
viewerBinary: 'Unduhan biner',
viewerHtml: 'HTML',
browser: 'Browser',
browserPlaceholder: 'Masukkan URL, mis. example.com',
@@ -303,12 +300,6 @@ export const id: Record<string, string> = {
settingsHtmlSandboxDesc: 'Dengan sandbox mati, HTML yang dipratinjau berjalan dengan asal yang sama dengan GUI: dapat membaca berkas sesi, penyimpanan lokal, dan memanggil API internal. Hanya aktifkan untuk berkas yang sepenuhnya tepercaya',
settingsBrowserSandboxTitle: 'Nonaktifkan sandbox browser (tidak aman)',
settingsBrowserSandboxDesc: 'Dengan sandbox mati, situs yang dikunjungi berjalan dengan asal yang sama dengan GUI: dapat membaca data sesi dan menyamar sebagai sesi login Anda. Hanya aktifkan untuk situs yang sepenuhnya tepercaya',
settingsBrowserLinksTitle: 'Buka tautan eksternal obrolan di sidebar',
settingsBrowserLinksDesc: 'Saat aktif, mengeklik tautan eksternal di obrolan atau GUI membuka sidebar alih-alih jendela baru; HTTP dan HTTPS dikendalikan terpisah oleh sakelar di bawah; Ctrl/Cmd+klik selalu menembus',
settingsBrowserHttpTitle: 'Buka halaman HTTP di sidebar',
settingsBrowserHttpDesc: 'Saat aktif, mengeklik tautan eksternal HTTP di obrolan atau GUI membuka sidebar (halaman plugin yang mendeklarasikan urlTarget menang); Ctrl/Cmd+klik selalu menembus',
settingsBrowserHttpsTitle: 'Buka halaman HTTPS di sidebar',
settingsBrowserHttpsDesc: 'Saat aktif, mengeklik tautan eksternal HTTPS di obrolan atau GUI membuka sidebar. Nonaktif secara default: kebanyakan situs HTTPS menolak untuk disematkan, jadi browser sistem adalah default yang lebih mulus',
settingsBrowserLoopbackTitle: 'Alamat lokal yang diizinkan',
settingsBrowserLoopbackDesc: 'Daftar putih alamat loopback yang dipisahkan koma (mis. localhost:5174 atau 127.0.0.1:8080) yang dapat dikunjungi browser sidebar; kosong memblokir semua alamat lokal secara default. Sandbox tetap berlaku — halaman tidak dapat membaca data GUI',
settingsBrowserLoopbackPlaceholder: 'mis. localhost:5174, 127.0.0.1:8080',
-9
View File
@@ -271,11 +271,8 @@ export const it: Record<string, string> = {
settingsGeneralTitle: 'Generali',
settingsPopup: 'Impostazioni della funzionalità',
settingsViewerCatchAll: 'Raccoglitore: qualsiasi file',
viewerImage: 'Immagine',
viewerPdf: 'PDF',
viewerMarkdown: 'Markdown',
viewerCode: 'Codice',
viewerBinary: 'Download binario',
viewerHtml: 'HTML',
browser: 'Browser',
browserPlaceholder: 'Inserisca un URL, es. example.com',
@@ -296,12 +293,6 @@ export const it: Record<string, string> = {
settingsHtmlSandboxDesc: 'Con la sandbox disattivata, l’HTML in anteprima viene eseguito con la stessa origine dell’interfaccia: può leggere i file di sessione, l’archiviazione locale e chiamare le API interne. Attivi solo per file completamente attendibili',
settingsBrowserSandboxTitle: 'Disattiva la sandbox del browser (non sicuro)',
settingsBrowserSandboxDesc: 'Con la sandbox disattivata, ogni sito visitato viene eseguito con la stessa origine dell’interfaccia: può leggere i dati di sessione e fingersi la sua sessione di login. Attivi solo per siti completamente attendibili',
settingsBrowserLinksTitle: 'Apri i collegamenti esterni della chat nella barra laterale',
settingsBrowserLinksDesc: 'Se attivato, cliccando un collegamento esterno nella chat o nell’interfaccia si apre la barra laterale invece di una nuova finestra; HTTP e HTTPS sono controllati separatamente dagli interruttori qui sotto; Ctrl/Cmd+clic li aggira sempre',
settingsBrowserHttpTitle: 'Apri le pagine HTTP nella barra laterale',
settingsBrowserHttpDesc: 'Se attivato, cliccando un collegamento HTTP esterno nella chat o nell’interfaccia si apre la barra laterale (le pagine dei plugin che dichiarano urlTarget vincono); Ctrl/Cmd+clic lo aggira sempre',
settingsBrowserHttpsTitle: 'Apri le pagine HTTPS nella barra laterale',
settingsBrowserHttpsDesc: 'Se attivato, cliccando un collegamento HTTPS esterno nella chat o nell’interfaccia si apre la barra laterale. Disattivato per impostazione predefinita: la maggior parte dei siti HTTPS rifiuta di essere incorporata, per cui il browser di sistema è l’impostazione più fluida',
settingsBrowserLoopbackTitle: 'Indirizzi locali consentiti',
settingsBrowserLoopbackDesc: 'Allowlist separata da virgole di indirizzi di loopback (es. localhost:5174 o 127.0.0.1:8080) che il browser della barra laterale può visitare; vuoto blocca tutti gli indirizzi locali per impostazione predefinita. La sandbox si applica ancora — le pagine non possono leggere i dati della GUI',
settingsBrowserLoopbackPlaceholder: 'es. localhost:5174, 127.0.0.1:8080',
-9
View File
@@ -280,11 +280,8 @@ export const ja: Record<string, string> = {
settingsGeneralTitle: '一般',
settingsPopup: '機能設定',
settingsViewerCatchAll: 'フォールバック:任意のファイル',
viewerImage: '画像',
viewerPdf: 'PDF',
viewerMarkdown: 'Markdown',
viewerCode: 'コード',
viewerBinary: 'バイナリダウンロード',
viewerHtml: 'HTML',
browser: 'ブラウザー',
browserPlaceholder: 'URL を入力、例: example.com',
@@ -305,12 +302,6 @@ export const ja: Record<string, string> = {
settingsHtmlSandboxDesc: 'サンドボックスをオフにすると、プレビューされる HTML は GUI と同一オリジンで動作し、会話ファイル・ローカルストレージ・内部 API にアクセス可能。完全に信頼できるファイルのみで有効化',
settingsBrowserSandboxTitle: 'ブラウザーサンドボックスを無効化(非安全)',
settingsBrowserSandboxDesc: 'サンドボックスをオフにすると、訪問するあらゆるサイトが GUI と同一オリジンで動作し、会話データを読み取ったりログイン状態を偽装したりできます。完全に信頼できるサイトのみで有効化',
settingsBrowserLinksTitle: 'チャット内の外部リンクをサイドバーで開く',
settingsBrowserLinksDesc: 'オンにすると、チャットや GUI の外部リンククリック時に新しいウィンドウではなくサイドバーで開く。HTTP と HTTPS は下のスイッチで個別制御。Ctrl/Cmd+クリックで一時的にバイパス',
settingsBrowserHttpTitle: 'HTTP ページをサイドバーで開く',
settingsBrowserHttpDesc: 'オンにすると、チャットや GUI の HTTP 外部リンククリック時にサイドバーで開く(urlTarget を宣言したプラグインページが優先)。Ctrl/Cmd+クリックで一時的にバイパス',
settingsBrowserHttpsTitle: 'HTTPS ページをサイドバーで開く',
settingsBrowserHttpsDesc: 'オンにすると、チャットや GUI の HTTPS 外部リンククリック時にサイドバーで開く。デフォルトオフ:大半の HTTPS サイトは埋め込みを拒否するため、システムブラウザーの方がスムーズ',
settingsBrowserLoopbackTitle: '許可されたローカルアドレス',
settingsBrowserLoopbackDesc: 'サイドバーブラウザがアクセスできるループバックアドレスのカンマ区切り許可リスト(例:localhost:5174 や 127.0.0.1:8080)。空の場合、デフォルトですべてのローカルアドレスをブロックします。サンドボックスは引き続き適用され、ページは GUI データを読み取れません',
settingsBrowserLoopbackPlaceholder: '例 localhost:5174, 127.0.0.1:8080',
-9
View File
@@ -272,11 +272,8 @@ export const ko: Record<string, string> = {
settingsGeneralTitle: '일반',
settingsPopup: '기능 설정',
settingsViewerCatchAll: '폴백: 모든 파일',
viewerImage: '이미지',
viewerPdf: 'PDF',
viewerMarkdown: 'Markdown',
viewerCode: '코드',
viewerBinary: '이진 다운로드',
viewerHtml: 'HTML',
browser: '브라우저',
browserPlaceholder: 'URL 입력, 예: example.com',
@@ -297,12 +294,6 @@ export const ko: Record<string, string> = {
settingsHtmlSandboxDesc: '끄면 미리보기 HTML이 인터페이스와 동일 출처로 실행되어 세션 파일, 로컬 저장소를 읽고 내부 인터페이스를 호출할 수 있습니다. 완전히 신뢰하는 파일에서만 켜세요',
settingsBrowserSandboxTitle: '브라우저 샌드박스 끄기(안전하지 않음)',
settingsBrowserSandboxDesc: '끄면 방문하는 모든 사이트가 인터페이스와 동일 출처로 실행되어 세션 데이터를 읽고 사용자의 로그인 상태를 가장할 수 있습니다. 완전히 신뢰하는 사이트에서만 켜세요',
settingsBrowserLinksTitle: '채팅 영역 외부 링크를 사이드바에서 열기',
settingsBrowserLinksDesc: '켜면 채팅이나 인터페이스에서 외부 링크를 클릭할 때 새 창 대신 사이드바에서 엽니다. HTTP와 HTTPS는 아래 스위치로 각각 제어할 수 있습니다. Ctrl/Cmd 클릭으로 임시 허용할 수 있습니다',
settingsBrowserHttpTitle: 'HTTP 페이지를 사이드바에서 열기',
settingsBrowserHttpDesc: '켜면 채팅이나 인터페이스에서 HTTP 외부 링크를 클릭할 때 사이드바에서 엽니다(urlTarget을 선언한 플러그인 페이지가 우선). Ctrl/Cmd 클릭으로 임시 허용할 수 있습니다',
settingsBrowserHttpsTitle: 'HTTPS 페이지를 사이드바에서 열기',
settingsBrowserHttpsDesc: '켜면 채팅이나 인터페이스에서 HTTPS 외부 링크를 클릭할 때 사이드바에서 엽니다. 기본 꺼짐: 대부분의 HTTPS 사이트가 임베드를 거부하므로 시스템 브라우저가 더 원활합니다',
settingsBrowserLoopbackTitle: '허용된 로컬 주소',
settingsBrowserLoopbackDesc: '사이드바 브라우저가 방문할 수 있는 루프백 주소의 쉼표로 구분된 허용 목록(예: localhost:5174 또는 127.0.0.1:8080). 비어 있으면 기본적으로 모든 로컬 주소를 차단합니다. 샌드박스는 계속 적용되며, 페이지는 GUI 데이터를 읽을 수 없습니다',
settingsBrowserLoopbackPlaceholder: '예: localhost:5174, 127.0.0.1:8080',
-9
View File
@@ -278,11 +278,8 @@ export const nl: Record<string, string> = {
settingsGeneralTitle: 'Algemeen',
settingsPopup: 'Functie-instellingen',
settingsViewerCatchAll: 'Catch-all: elk bestand',
viewerImage: 'Afbeelding',
viewerPdf: 'PDF',
viewerMarkdown: 'Markdown',
viewerCode: 'Code',
viewerBinary: 'Binaire download',
viewerHtml: 'HTML',
browser: 'Browser',
browserPlaceholder: 'Voer een URL in, bijv. example.com',
@@ -303,12 +300,6 @@ export const nl: Record<string, string> = {
settingsHtmlSandboxDesc: 'Met de sandbox uit draait gepreviewde HTML met dezelfde origin als de GUI: kan sessiebestanden, lokale opslag lezen en interne API\'s aanroepen. Alleen inschakelen voor volledig vertrouwde bestanden',
settingsBrowserSandboxTitle: 'Browser-sandbox uitschakelen (onveilig)',
settingsBrowserSandboxDesc: 'Met de sandbox uit draait elke bezochte site met dezelfde origin als de GUI: kan sessiegegevens lezen en zich voordoen als uw ingelogde sessie. Alleen inschakelen voor volledig vertrouwde sites',
settingsBrowserLinksTitle: 'Externe chatlinks in de zijbalk openen',
settingsBrowserLinksDesc: 'Indien aan, opent een klik op een externe link in de chat of GUI de zijbalk in plaats van een nieuw venster; HTTP en HTTPS worden apart bestuurd door de schakelaars hieronder; Ctrl/Cmd+klik omzeilt altijd',
settingsBrowserHttpTitle: 'HTTP-pagina\'s in de zijbalk openen',
settingsBrowserHttpDesc: 'Indien aan, opent een klik op een externe HTTP-link in de chat of GUI de zijbalk (plugin-pagina\'s die urlTarget declareren winnen); Ctrl/Cmd+klik omzeilt altijd',
settingsBrowserHttpsTitle: 'HTTPS-pagina\'s in de zijbalk openen',
settingsBrowserHttpsDesc: 'Indien aan, opent een klik op een externe HTTPS-link in de chat of GUI de zijbalk. Standaard uit: de meeste HTTPS-sites weigeren ingebed te worden, dus de systeembrowser is soepeler',
settingsBrowserLoopbackTitle: 'Toegestane lokale adressen',
settingsBrowserLoopbackDesc: 'Door komma gescheiden allowlist van loopback-adressen (bijv. localhost:5174 of 127.0.0.1:8080) die de zijbalk-browser kan bezoeken; leeg blokkeert standaard alle lokale adressen. De sandbox blijft van toepassing — pagina s kunnen geen GUI-gegevens lezen',
settingsBrowserLoopbackPlaceholder: 'bijv. localhost:5174, 127.0.0.1:8080',
-9
View File
@@ -282,11 +282,8 @@ export const pl: Record<string, string> = {
settingsGeneralTitle: 'Ogólne',
settingsPopup: 'Ustawienia funkcji',
settingsViewerCatchAll: 'Zapasowy: dowolny plik',
viewerImage: 'Obraz',
viewerPdf: 'PDF',
viewerMarkdown: 'Markdown',
viewerCode: 'Kod',
viewerBinary: 'Pobieranie binarne',
viewerHtml: 'HTML',
browser: 'Przeglądarka',
browserPlaceholder: 'Wpisz adres URL, np. example.com',
@@ -307,12 +304,6 @@ export const pl: Record<string, string> = {
settingsHtmlSandboxDesc: 'Po wyłączeniu podglądany HTML działa w tym samym originie co interfejs: może czytać pliki sesji, localStorage i wywoływać wewnętrzne API. Włączaj tylko dla w pełni zaufanych plików',
settingsBrowserSandboxTitle: 'Wyłącz piaskownicę przeglądarki (niebezpieczne)',
settingsBrowserSandboxDesc: 'Po wyłączeniu każda odwiedzana strona działa w tym samym originie co interfejs: może czytać dane sesji i podszywać się pod Twoją sesję logowania. Włączaj tylko dla w pełni zaufanych witryn',
settingsBrowserLinksTitle: 'Otwieraj linki zewnętrzne z czatu w panelu bocznym',
settingsBrowserLinksDesc: 'Po włączeniu kliknięcie linku zewnętrznego na czacie lub w interfejsie otwiera panel boczny zamiast nowego okna; HTTP i HTTPS sterowane są osobnymi przełącznikami poniżej; Ctrl/Cmd+klik zawsze pomija',
settingsBrowserHttpTitle: 'Otwieraj strony HTTP w panelu bocznym',
settingsBrowserHttpDesc: 'Po włączeniu kliknięcie zewnętrznego linku HTTP na czacie lub w interfejsie otwiera panel boczny (strony wtyczek deklarujące urlTarget mają pierwszeństwo); Ctrl/Cmd+klik zawsze pomija',
settingsBrowserHttpsTitle: 'Otwieraj strony HTTPS w panelu bocznym',
settingsBrowserHttpsDesc: 'Po włączeniu kliknięcie zewnętrznego linku HTTPS na czacie lub w interfejsie otwiera panel boczny. Domyślnie wyłączone: większość stron HTTPS odmawia osadzania, więc przeglądarka systemowa jest płynniejszym domyślnym wyborem',
settingsBrowserLoopbackTitle: 'Dozwolone adresy lokalne',
settingsBrowserLoopbackDesc: 'Rozdzielana przecinkami lista dozwolonych adresów loopback (np. localhost:5174 lub 127.0.0.1:8080), które może odwiedzać przeglądarka paska bocznego; puste domyślnie blokuje wszystkie adresy lokalne. Piaskownica nadal obowiązuje — strony nie mogą odczytywać danych GUI',
settingsBrowserLoopbackPlaceholder: 'np. localhost:5174, 127.0.0.1:8080',
-9
View File
@@ -263,11 +263,8 @@ export const pt: Record<string, string> = {
settingsGeneralTitle: 'Geral',
settingsPopup: 'Configurações do recurso',
settingsViewerCatchAll: 'Curinga: qualquer arquivo',
viewerImage: 'Imagem',
viewerPdf: 'PDF',
viewerMarkdown: 'Markdown',
viewerCode: 'Código',
viewerBinary: 'Download binário',
viewerHtml: 'HTML',
browser: 'Navegador',
browserPlaceholder: 'Digite uma URL, ex.: example.com',
@@ -288,12 +285,6 @@ export const pt: Record<string, string> = {
settingsHtmlSandboxDesc: 'Com o sandbox desativado, o HTML pré-visualizado roda com a mesma origem da interface: pode ler arquivos de sessão, armazenamento local e chamar APIs internas. Ative apenas para arquivos totalmente confiáveis',
settingsBrowserSandboxTitle: 'Desativar o sandbox do navegador (inseguro)',
settingsBrowserSandboxDesc: 'Com o sandbox desativado, qualquer site visitado roda com a mesma origem da interface: pode ler dados da sessão e agir como sua sessão conectada. Ative apenas para sites totalmente confiáveis',
settingsBrowserLinksTitle: 'Abrir links externos do chat na barra lateral',
settingsBrowserLinksDesc: 'Quando ativado, clicar em um link externo no chat ou na interface abre na barra lateral em vez de uma nova janela; HTTP e HTTPS são controlados separadamente pelos interruptores abaixo; Ctrl/Cmd+clique sempre ignora',
settingsBrowserHttpTitle: 'Abrir páginas HTTP na barra lateral',
settingsBrowserHttpDesc: 'Quando ativado, clicar em um link externo HTTP no chat ou na interface abre na barra lateral (páginas de plugins que declaram urlTarget vencem); Ctrl/Cmd+clique sempre ignora',
settingsBrowserHttpsTitle: 'Abrir páginas HTTPS na barra lateral',
settingsBrowserHttpsDesc: 'Quando ativado, clicar em um link externo HTTPS no chat ou na interface abre na barra lateral. Desativado por padrão: a maioria dos sites HTTPS se recusa a ser incorporada, então o navegador do sistema é o padrão mais suave',
settingsBrowserLoopbackTitle: 'Endereços locais permitidos',
settingsBrowserLoopbackDesc: 'Lista de permissões separada por vírgulas de endereços de loopback (ex. localhost:5174 ou 127.0.0.1:8080) que o navegador da barra lateral pode visitar; vazio bloqueia todos os endereços locais por padrão. A sandbox ainda se aplica — as páginas não podem ler dados da GUI',
settingsBrowserLoopbackPlaceholder: 'ex. localhost:5174, 127.0.0.1:8080',
-9
View File
@@ -277,11 +277,8 @@ export const ru: Record<string, string> = {
settingsGeneralTitle: 'Общие',
settingsPopup: 'Настройки функции',
settingsViewerCatchAll: 'Универсальный: любой файл',
viewerImage: 'Изображение',
viewerPdf: 'PDF',
viewerMarkdown: 'Markdown',
viewerCode: 'Код',
viewerBinary: 'Бинарная загрузка',
viewerHtml: 'HTML',
browser: 'Браузер',
browserPlaceholder: 'Введите URL, например example.com',
@@ -302,12 +299,6 @@ export const ru: Record<string, string> = {
settingsHtmlSandboxDesc: 'При отключении предпросматриваемый HTML работает в том же источнике, что интерфейс: может читать файлы сессии, локальное хранилище и вызывать внутренние API. Включайте только для полностью доверенных файлов',
settingsBrowserSandboxTitle: 'Отключить песочницу браузера (небезопасно)',
settingsBrowserSandboxDesc: 'При отключении любой посещаемый сайт работает в том же источнике, что интерфейс: может читать данные сессии и выдавать себя за ваш вход. Включайте только для полностью доверенных сайтов',
settingsBrowserLinksTitle: 'Открывать внешние ссылки чата в боковой панели',
settingsBrowserLinksDesc: 'Если включено, щелчок по внешней ссылке в чате или интерфейсе открывает её в боковой панели, а не в новом окне; HTTP и HTTPS управляются отдельно переключателями ниже; Ctrl/Cmd+щелчок всегда пропускает',
settingsBrowserHttpTitle: 'Открывать HTTP-страницы в боковой панели',
settingsBrowserHttpDesc: 'Если включено, щелчок по HTTP-ссылке в чате или интерфейсе открывает её в боковой панели (страницы плагинов с urlTarget имеют приоритет); Ctrl/Cmd+щелчок всегда пропускает',
settingsBrowserHttpsTitle: 'Открывать HTTPS-страницы в боковой панели',
settingsBrowserHttpsDesc: 'Если включено, щелчок по HTTPS-ссылке в чате или интерфейсе открывает её в боковой панели. По умолчанию выключено: большинство HTTPS-сайтов отказываются встраиваться, поэтому системный браузер работает плавнее',
settingsBrowserLoopbackTitle: 'Разрешённые локальные адреса',
settingsBrowserLoopbackDesc: 'Разделённый запятыми список разрешённых loopback-адресов (напр. localhost:5174 или 127.0.0.1:8080), которые может посещать браузер боковой панели; пустое значение по умолчанию блокирует все локальные адреса. Песочница продолжает действовать — страницы не могут читать данные интерфейса',
settingsBrowserLoopbackPlaceholder: 'напр. localhost:5174, 127.0.0.1:8080',
-9
View File
@@ -263,11 +263,8 @@ export const sv: Record<string, string> = {
settingsGeneralTitle: 'Allmänt',
settingsPopup: 'Funktionsinställningar',
settingsViewerCatchAll: 'Fånga alla: alla filer',
viewerImage: 'Bild',
viewerPdf: 'PDF',
viewerMarkdown: 'Markdown',
viewerCode: 'Kod',
viewerBinary: 'Binär nedladdning',
viewerHtml: 'HTML',
browser: 'Webbläsare',
browserPlaceholder: 'Ange en URL, t.ex. example.com',
@@ -288,12 +285,6 @@ export const sv: Record<string, string> = {
settingsHtmlSandboxDesc: 'Med sandlådan av körs förhandsgranskad HTML med samma ursprung som GUI: kan läsa sessionsfiler, lokal lagring och anropa interna API:er. Aktivera endast för fullt betrodda filer',
settingsBrowserSandboxTitle: 'Avaktivera webbläsarsandlåda (osäkert)',
settingsBrowserSandboxDesc: 'Med sandlådan av körs alla besökta sajter med samma ursprung som GUI: kan läsa sessionsdata och agera som er inloggade session. Aktivera endast för fullt betrodda sajter',
settingsBrowserLinksTitle: 'Öppna chattens externa länkar i sidopanelen',
settingsBrowserLinksDesc: 'När på, klick på en extern länk i chatten eller GUI öppnar sidopanelen i stället för ett nytt fönster; HTTP och HTTPS styrs separat av knapparna nedan; Ctrl/Cmd+klick kringgår alltid',
settingsBrowserHttpTitle: 'Öppna HTTP-sidor i sidopanelen',
settingsBrowserHttpDesc: 'När på, klick på en extern HTTP-länk i chatten eller GUI öppnar sidopanelen (pluginsidor som deklarerar urlTarget vinner); Ctrl/Cmd+klick kringgår alltid',
settingsBrowserHttpsTitle: 'Öppna HTTPS-sidor i sidopanelen',
settingsBrowserHttpsDesc: 'När på, klick på en extern HTTPS-länk i chatten eller GUI öppnar sidopanelen. Av som standard: de flesta HTTPS-sajter vägrar bli inbäddade, så systemwebbläsaren är det smidigare standardvalet',
settingsBrowserLoopbackTitle: 'Tillåtna lokala adresser',
settingsBrowserLoopbackDesc: 'Kommaseparerad tillåten lista över loopback-adresser (t.ex. localhost:5174 eller 127.0.0.1:8080) som sidofältswebbläsaren kan besöka; tom blockerar alla lokala adresser som standard. Sandlådan gäller fortfarande — sidor kan inte läsa GUI-data',
settingsBrowserLoopbackPlaceholder: 't.ex. localhost:5174, 127.0.0.1:8080',
-9
View File
@@ -280,11 +280,8 @@ export const th: Record<string, string> = {
settingsGeneralTitle: 'ทั่วไป',
settingsPopup: 'การตั้งค่าคุณสมบัติ',
settingsViewerCatchAll: 'Catch-all: ไฟล์ใดๆ',
viewerImage: 'รูปภาพ',
viewerPdf: 'PDF',
viewerMarkdown: 'Markdown',
viewerCode: 'โค้ด',
viewerBinary: 'ดาวน์โหลดไบนารี',
viewerHtml: 'HTML',
browser: 'เบราว์เซอร์',
browserPlaceholder: 'กรอก URL เช่น example.com',
@@ -305,12 +302,6 @@ export const th: Record<string, string> = {
settingsHtmlSandboxDesc: 'เมื่อปิดแซนด์บ็อกซ์ HTML ที่พรีวิวจะทำงานด้วย origin เดียวกับ GUI: สามารถอ่านไฟล์เซสชัน, ที่เก็บข้อมูลในเครื่อง และเรียก API ภายในได้ เปิดใช้เฉพาะสำหรับไฟล์ที่เชื่อถือได้อย่างเต็มที่',
settingsBrowserSandboxTitle: 'ปิดแซนด์บ็อกซ์เบราว์เซอร์ (ไม่ปลอดภัย)',
settingsBrowserSandboxDesc: 'เมื่อปิดแซนด์บ็อกซ์ ไซต์ที่เยี่ยมชมใดๆ จะทำงานด้วย origin เดียวกับ GUI: สามารถอ่านข้อมูลเซสชันและแสดงเป็นเซสชันล็อกอินของคุณได้ เปิดใช้เฉพาะสำหรับไซต์ที่เชื่อถือได้อย่างเต็มที่',
settingsBrowserLinksTitle: 'เปิดลิงก์ภายนอกของแชทในแถบด้านข้าง',
settingsBrowserLinksDesc: 'เมื่อเปิด การคลิกลิงก์ภายนอกในแชทหรือ GUI จะเปิดแถบด้านข้างแทนหน้าต่างใหม่; HTTP และ HTTPS ถูกควบคุมแยกโดยสวิตช์ด้านล่าง; Ctrl/Cmd+click จะข้ามเสมอ',
settingsBrowserHttpTitle: 'เปิดหน้า HTTP ในแถบด้านข้าง',
settingsBrowserHttpDesc: 'เมื่อเปิด การคลิกลิงก์ HTTP ภายนอกในแชทหรือ GUI จะเปิดแถบด้านข้าง (หน้าปลั๊กอินที่ประกาศ urlTarget win มีความสำคัญก่อน); Ctrl/Cmd+click จะข้ามเสมอ',
settingsBrowserHttpsTitle: 'เปิดหน้า HTTPS ในแถบด้านข้าง',
settingsBrowserHttpsDesc: 'เมื่อเปิด การคลิกลิงก์ HTTPS ภายนอกในแชทหรือ GUI จะเปิดแถบด้านข้าง ปิดเป็นค่าเริ่มต้น: ไซต์ HTTPS ส่วนใหญ่ปฏิเสธที่จะฝัง ดังนั้นเบราว์เซอร์ระบบจะราบรื่นกว่าเป็นค่าเริ่มต้น',
settingsBrowserLoopbackTitle: 'ที่อยู่ภายในเครื่องที่อนุญาต',
settingsBrowserLoopbackDesc: 'รายการที่อนุญาตแบบคั่นด้วยจุลภาคของที่อยู่ลูปแบ็ก (เช่น localhost:5174 หรือ 127.0.0.1:8080) ที่เบราว์เซอร์แถบด้านข้างสามารถเยี่ยมชมได้ ค่าว่างจะบล็อกที่อยู่ภายในทั้งหมดโดยค่าเริ่มต้น แซนด์บ็อกซ์ยังคงมีผล — หน้าเว็บไม่สามารถอ่านข้อมูล GUI ได้',
settingsBrowserLoopbackPlaceholder: 'เช่น localhost:5174, 127.0.0.1:8080',
-9
View File
@@ -280,11 +280,8 @@ export const tr: Record<string, string> = {
settingsGeneralTitle: 'Genel',
settingsPopup: 'Özellik ayarları',
settingsViewerCatchAll: 'Hepsi yakala: herhangi bir dosya',
viewerImage: 'Resim',
viewerPdf: 'PDF',
viewerMarkdown: 'Markdown',
viewerCode: 'Kod',
viewerBinary: 'İkili indirme',
viewerHtml: 'HTML',
browser: 'Tarayıcı',
browserPlaceholder: 'Bir URL girin, örn. example.com',
@@ -305,12 +302,6 @@ export const tr: Record<string, string> = {
settingsHtmlSandboxDesc: 'Kum kapalıyken önizlenen HTML arayüzle aynı kaynakta çalışır: oturum dosyalarını, yerel depoyu okuyabilir ve iç API’leri çağırabilir. Yalnızca tamamen güvenilen dosyalar için etkinleştirin',
settingsBrowserSandboxTitle: 'Tarayıcı kumunu devre dışı bırak (güvenli değil)',
settingsBrowserSandboxDesc: 'Kum kapalıyken ziyaret edilen her site arayüzle aynı kaynakta çalışır: oturum verilerini okuyabilir ve oturum açmış halinize bürünebilir. Yalnızca tamamen güvenilen siteler için etkinleştirin',
settingsBrowserLinksTitle: 'Sohbet dış bağlantılarını kenar çubuğunda aç',
settingsBrowserLinksDesc: 'Açıkken sohbetteki veya arayüzdeki bir dış bağlantıya tıklamak yeni pencere yerine kenar çubuğunu açar; HTTP ve HTTPS aşağıdaki anahtarlarla ayrı ayrı denetlenir; Ctrl/Cmd+tıklama her zaman atlatır',
settingsBrowserHttpTitle: 'HTTP sayfalarını kenar çubuğunda aç',
settingsBrowserHttpDesc: 'Açıkken sohbetteki veya arayüzdeki bir HTTP dış bağlantısına tıklamak kenar çubuğunu açar (urlTarget bildiren eklenti sayfaları öncelikli); Ctrl/Cmd+tıklama her zaman atlatır',
settingsBrowserHttpsTitle: 'HTTPS sayfalarını kenar çubuğunda aç',
settingsBrowserHttpsDesc: 'Açıkken sohbetteki veya arayüzdeki bir HTTPS dış bağlantısına tıklamak kenar çubuğunu açar. Varsayılan kapalı: çoğu HTTPS sitesi gömülmeyi reddeder, bu yüzden sistem tarayıcısı daha akıcı varsayılandır',
settingsBrowserLoopbackTitle: 'İzin verilen yerel adresler',
settingsBrowserLoopbackDesc: 'Kenar çubuğu tarayıcısının ziyaret edebileceği geri döngü adreslerinin virgülle ayrılmış izin listesi (ör. localhost:5174 veya 127.0.0.1:8080); boş, varsayılan olarak tüm yerel adresleri engeller. Kum havuzu hala geçerlidir — sayfalar GUI verilerini okuyamaz',
settingsBrowserLoopbackPlaceholder: 'ör. localhost:5174, 127.0.0.1:8080',
-9
View File
@@ -280,11 +280,8 @@ export const vi: Record<string, string> = {
settingsGeneralTitle: 'Chung',
settingsPopup: 'Cài đặt tính năng',
settingsViewerCatchAll: 'Catch-all: mọi tệp',
viewerImage: 'Hình ảnh',
viewerPdf: 'PDF',
viewerMarkdown: 'Markdown',
viewerCode: 'Mã',
viewerBinary: 'Tải nhị phân',
viewerHtml: 'HTML',
browser: 'Trình duyệt',
browserPlaceholder: 'Nhập URL, vd: example.com',
@@ -305,12 +302,6 @@ export const vi: Record<string, string> = {
settingsHtmlSandboxDesc: 'Khi tắt, HTML xem trước sẽ chạy cùng nguồn với UI: có thể đọc tệp phiên, local storage và gọi API nội bộ. Chỉ bật cho tệp hoàn toàn đáng tin cậy',
settingsBrowserSandboxTitle: 'Tắt sandbox trình duyệt (không an toàn)',
settingsBrowserSandboxDesc: 'Khi tắt, mọi trang truy cập sẽ chạy cùng nguồn với UI: có thể đọc dữ liệu phiên và mạo danh phiên đăng nhập. Chỉ bật cho trang hoàn toàn đáng tin cậy',
settingsBrowserLinksTitle: 'Mở link ngoài trong chat ở thanh bên',
settingsBrowserLinksDesc: 'Khi bật, nhấp link ngoài trong chat hoặc UI sẽ mở trong thanh bên thay vì cửa sổ mới; HTTP và HTTPS được kiểm soát riêng bởi công tắc bên dưới; Ctrl/Cmd+nhấp luôn bỏ qua',
settingsBrowserHttpTitle: 'Mở trang HTTP trong thanh bên',
settingsBrowserHttpDesc: 'Khi bật, nhấp link HTTP ngoài trong chat hoặc UI sẽ mở trong thanh bên (trang plugin khai báo urlTarget ưu tiên); Ctrl/Cmd+nhấp luôn bỏ qua',
settingsBrowserHttpsTitle: 'Mở trang HTTPS trong thanh bên',
settingsBrowserHttpsDesc: 'Khi bật, nhấp link HTTPS ngoài trong chat hoặc UI sẽ mở trong thanh bên. Mặc định tắt: đa số trang HTTPS từ chối nhúng, trình duyệt hệ thống mượt mà hơn',
settingsBrowserLoopbackTitle: 'Địa chỉ cục bộ được phép',
settingsBrowserLoopbackDesc: 'Danh sách cho phép phân tách bằng dấu phẩy của địa chỉ loopback (vd. localhost:5174 hoặc 127.0.0.1:8080) mà trình duyệt thanh bên có thể truy cập; để trống sẽ chặn tất cả địa chỉ cục bộ theo mặc định. Hộp cát vẫn áp dụng — các trang không thể đọc dữ liệu GUI',
settingsBrowserLoopbackPlaceholder: 'vd. localhost:5174, 127.0.0.1:8080',
-9
View File
@@ -295,11 +295,8 @@ export const zhHK: Record<string, string> = {
settingsGeneralTitle: '一般',
settingsPopup: '功能設定',
settingsViewerCatchAll: '兜底:任意檔案',
viewerImage: '圖片',
viewerPdf: 'PDF',
viewerMarkdown: 'Markdown',
viewerCode: '程式碼',
viewerBinary: '二進位下載',
viewerHtml: 'HTML',
browser: '瀏覽器',
browserPlaceholder: '輸入網址,例如 example.com',
@@ -320,12 +317,6 @@ export const zhHK: Record<string, string> = {
settingsHtmlSandboxDesc: '關閉後,預覽的 HTML 將與介面同源執行,可讀取工作階段檔案、本地儲存並呼叫內部介面。僅對完全可信的檔案開啟',
settingsBrowserSandboxTitle: '關閉瀏覽器沙箱(不安全)',
settingsBrowserSandboxDesc: '關閉後,存取的任何網站都將與介面同源執行,可讀取工作階段資料並冒充你的登入狀態。僅對完全可信的網站開啟',
settingsBrowserLinksTitle: '聊天區外鏈在側邊欄開啟',
settingsBrowserLinksDesc: '開啟後,點擊聊天或介面中的外鏈時在側邊欄開啟,不再彈出新視窗;HTTP 與 HTTPS 可分別透過下方開關控制;Ctrl/Cmd 點擊可臨時放行',
settingsBrowserHttpTitle: '側邊開啟HTTP網頁',
settingsBrowserHttpDesc: '開啟後,點擊聊天或介面中的 HTTP 外鏈時在側邊欄開啟(宣告了 urlTarget 的插件頁面優先);Ctrl/Cmd 點擊可臨時放行',
settingsBrowserHttpsTitle: '側邊開啟HTTPS網頁',
settingsBrowserHttpsDesc: '開啟後,點擊聊天或介面中的 HTTPS 外鏈時在側邊欄開啟。預設關閉:多數 HTTPS 網站拒絕被嵌入,走系統瀏覽器更順暢',
settingsBrowserLoopbackTitle: '允許訪問的本機地址',
settingsBrowserLoopbackDesc: '逗號分隔的本機回環地址白名單(如 localhost:5174 或 127.0.0.1:8080),側邊欄瀏覽器可訪問這些本機服務;預設留空則本機地址全部攔截。沙箱隔離仍然生效,頁面無法讀取介面資料',
settingsBrowserLoopbackPlaceholder: '例如 localhost:5174, 127.0.0.1:8080',
-9
View File
@@ -295,11 +295,8 @@ export const zhMO: Record<string, string> = {
settingsGeneralTitle: '一般',
settingsPopup: '功能設定',
settingsViewerCatchAll: '兜底:任意檔案',
viewerImage: '圖片',
viewerPdf: 'PDF',
viewerMarkdown: 'Markdown',
viewerCode: '程式碼',
viewerBinary: '二進位下載',
viewerHtml: 'HTML',
browser: '瀏覽器',
browserPlaceholder: '輸入網址,例如 example.com',
@@ -320,12 +317,6 @@ export const zhMO: Record<string, string> = {
settingsHtmlSandboxDesc: '關閉後,預覽的 HTML 將與介面同源執行,可讀取工作階段檔案、本地儲存並呼叫內部介面。僅對完全可信的檔案開啟',
settingsBrowserSandboxTitle: '關閉瀏覽器沙箱(不安全)',
settingsBrowserSandboxDesc: '關閉後,存取的任何網站都將與介面同源執行,可讀取工作階段資料並冒充你的登入狀態。僅對完全可信的網站開啟',
settingsBrowserLinksTitle: '聊天區外鏈在側邊欄開啟',
settingsBrowserLinksDesc: '開啟後,點擊聊天或介面中的外鏈時在側邊欄開啟,不再彈出新視窗;HTTP 與 HTTPS 可分別透過下方開關控制;Ctrl/Cmd 點擊可臨時放行',
settingsBrowserHttpTitle: '側邊開啟HTTP網頁',
settingsBrowserHttpDesc: '開啟後,點擊聊天或介面中的 HTTP 外鏈時在側邊欄開啟(宣告了 urlTarget 的插件頁面優先);Ctrl/Cmd 點擊可臨時放行',
settingsBrowserHttpsTitle: '側邊開啟HTTPS網頁',
settingsBrowserHttpsDesc: '開啟後,點擊聊天或介面中的 HTTPS 外鏈時在側邊欄開啟。預設關閉:多數 HTTPS 網站拒絕被嵌入,走系統瀏覽器更順暢',
settingsBrowserLoopbackTitle: '允許訪問的本機地址',
settingsBrowserLoopbackDesc: '逗號分隔的本機回環地址白名單(如 localhost:5174 或 127.0.0.1:8080),側邊欄瀏覽器可訪問這些本機服務;預設留空則本機地址全部攔截。沙箱隔離仍然生效,頁面無法讀取介面資料',
settingsBrowserLoopbackPlaceholder: '例如 localhost:5174, 127.0.0.1:8080',
-9
View File
@@ -295,11 +295,8 @@ export const zhTW: Record<string, string> = {
settingsGeneralTitle: '一般',
settingsPopup: '功能設定',
settingsViewerCatchAll: '兜底:任意檔案',
viewerImage: '圖片',
viewerPdf: 'PDF',
viewerMarkdown: 'Markdown',
viewerCode: '程式碼',
viewerBinary: '二進位下載',
viewerHtml: 'HTML',
browser: '瀏覽器',
browserPlaceholder: '輸入網址,例如 example.com',
@@ -320,12 +317,6 @@ export const zhTW: Record<string, string> = {
settingsHtmlSandboxDesc: '關閉後,預覽的 HTML 將與介面同源執行,可讀取工作階段檔案、本地儲存並呼叫內部介面。僅對完全可信的檔案開啟',
settingsBrowserSandboxTitle: '關閉瀏覽器沙箱(不安全)',
settingsBrowserSandboxDesc: '關閉後,存取的任何網站都將與介面同源執行,可讀取工作階段資料並冒充你的登入狀態。僅對完全可信的網站開啟',
settingsBrowserLinksTitle: '聊天區外鏈在側邊欄開啟',
settingsBrowserLinksDesc: '開啟後,點擊聊天或介面中的外鏈時在側邊欄開啟,不再彈出新視窗;HTTP 與 HTTPS 可分別透過下方開關控制;Ctrl/Cmd 點擊可臨時放行',
settingsBrowserHttpTitle: '側邊開啟HTTP網頁',
settingsBrowserHttpDesc: '開啟後,點擊聊天或介面中的 HTTP 外鏈時在側邊欄開啟(宣告了 urlTarget 的插件頁面優先);Ctrl/Cmd 點擊可臨時放行',
settingsBrowserHttpsTitle: '側邊開啟HTTPS網頁',
settingsBrowserHttpsDesc: '開啟後,點擊聊天或介面中的 HTTPS 外鏈時在側邊欄開啟。預設關閉:多數 HTTPS 網站拒絕被嵌入,走系統瀏覽器更順暢',
settingsBrowserLoopbackTitle: '允許存取的本機地址',
settingsBrowserLoopbackDesc: '逗號分隔的本機回環地址白名單(如 localhost:5174 或 127.0.0.1:8080),側邊欄瀏覽器可存取這些本機服務;預設留空則本機地址全部攔截。沙箱隔離仍然生效,頁面無法讀取介面資料',
settingsBrowserLoopbackPlaceholder: '例如 localhost:5174, 127.0.0.1:8080',
-18
View File
@@ -292,11 +292,8 @@ export const zh = {
settingsGeneralTitle: '常规',
settingsPopup: '功能设置',
settingsViewerCatchAll: '兜底:任意文件',
viewerImage: '图片',
viewerPdf: 'PDF',
viewerMarkdown: 'Markdown',
viewerCode: '代码',
viewerBinary: '二进制下载',
viewerHtml: 'HTML',
browser: '浏览器',
browserPlaceholder: '输入网址,例如 example.com',
@@ -317,12 +314,6 @@ export const zh = {
settingsHtmlSandboxDesc: '关闭后,预览的 HTML 将与界面同源运行,可读取会话文件、本地存储并调用内部接口。仅对完全可信的文件开启',
settingsBrowserSandboxTitle: '关闭浏览器沙箱(不安全)',
settingsBrowserSandboxDesc: '关闭后,访问的任何网站都将与界面同源运行,可读取会话数据并冒充你的登录状态。仅对完全可信的站点开启',
settingsBrowserLinksTitle: '聊天区外链在侧边栏打开',
settingsBrowserLinksDesc: '开启后,点击聊天或界面中的外链时在侧边栏打开,不再弹出新窗口;HTTP 与 HTTPS 可分别通过下方开关控制;Ctrl/Cmd 点击可临时放行',
settingsBrowserHttpTitle: '侧边打开HTTP网页',
settingsBrowserHttpDesc: '开启后,点击聊天或界面中的 HTTP 外链时在侧边栏打开(声明了 urlTarget 的插件页面优先);Ctrl/Cmd 点击可临时放行',
settingsBrowserHttpsTitle: '侧边打开HTTPS网页',
settingsBrowserHttpsDesc: '开启后,点击聊天或界面中的 HTTPS 外链时在侧边栏打开。默认关闭:多数 HTTPS 站点拒绝被嵌入,走系统浏览器更顺畅',
settingsBrowserLoopbackTitle: '允许访问的本机地址',
settingsBrowserLoopbackDesc: '逗号分隔的本地回环地址白名单(如 localhost:5174 或 127.0.0.1:8080),侧边栏浏览器可访问这些本地服务;默认留空则本机地址全部拦截。沙箱隔离仍然生效,页面无法读取界面数据',
settingsBrowserLoopbackPlaceholder: '例如 localhost:5174, 127.0.0.1:8080',
@@ -734,11 +725,8 @@ export const en: Record<keyof typeof zh, string> = {
settingsGeneralTitle: 'General',
settingsPopup: 'Feature settings',
settingsViewerCatchAll: 'Catch-all: any file',
viewerImage: 'Image',
viewerPdf: 'PDF',
viewerMarkdown: 'Markdown',
viewerCode: 'Code',
viewerBinary: 'Binary download',
viewerHtml: 'HTML',
browser: 'Browser',
browserPlaceholder: 'Enter a URL, e.g. example.com',
@@ -759,12 +747,6 @@ export const en: Record<keyof typeof zh, string> = {
settingsHtmlSandboxDesc: 'With the sandbox off, previewed HTML runs with the same origin as the GUI: it can read session files, local storage and call internal APIs. Only enable for fully trusted files',
settingsBrowserSandboxTitle: 'Disable browser sandbox (unsafe)',
settingsBrowserSandboxDesc: 'With the sandbox off, any visited site runs with the same origin as the GUI: it can read session data and act as your logged-in session. Only enable for fully trusted sites',
settingsBrowserLinksTitle: 'Open chat external links in the sidebar',
settingsBrowserLinksDesc: 'When on, clicking an external link in the chat or GUI opens the sidebar instead of a new window; HTTP and HTTPS are controlled separately by the switches below; Ctrl/Cmd+click always bypasses',
settingsBrowserHttpTitle: 'Open HTTP pages in the sidebar',
settingsBrowserHttpDesc: 'When on, clicking an HTTP external link in the chat or GUI opens the sidebar (plugin pages declaring urlTarget win); Ctrl/Cmd+click always bypasses',
settingsBrowserHttpsTitle: 'Open HTTPS pages in the sidebar',
settingsBrowserHttpsDesc: 'When on, clicking an HTTPS external link in the chat or GUI opens the sidebar. Off by default: most HTTPS sites refuse to be embedded, so the system browser is the smoother default',
settingsBrowserLoopbackTitle: 'Allowed local addresses',
settingsBrowserLoopbackDesc: 'Comma-separated allowlist of loopback addresses (e.g. localhost:5174 or 127.0.0.1:8080) the sidebar browser may visit; empty blocks all local addresses by default. The sandbox still applies — pages cannot read GUI data',
settingsBrowserLoopbackPlaceholder: 'e.g. localhost:5174, 127.0.0.1:8080',
+2 -2
View File
@@ -18,7 +18,7 @@
*/
import { useLayoutEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { IconListPenOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconListPenOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import { t } from './locales.ts'
import css from './sidebar.module.css'
@@ -136,7 +136,7 @@ export function MdToc(): ReactNode {
aria-expanded={open}
onClick={() => { setOpen(!open) }}
>
<IconListPenOutline16 />
<IconListPenOutlineRegular />
</button>
)}
</div>
+2 -2
View File
@@ -28,7 +28,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState, type MouseEv
import { createPortal } from 'react-dom'
import { createRoot, type Root } from 'react-dom/client'
import mermaid from 'mermaid'
import { IconCopyOutline16, MarkdownText, writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconCopyOutlineRegular, MarkdownText, writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives'
import { isDarkScheme, subscribeColorScheme } from './theme.ts'
import { markdownTextProps } from './markdown-labels.tsx'
import { t } from './locales.ts'
@@ -288,7 +288,7 @@ function MermaidDiagram({ code }: { code: string }): React.ReactNode {
aria-label={t('copy')}
title={t('copy')}
>
<IconCopyOutline16 />
<IconCopyOutlineRegular />
<span>{copied ? t('copied') : t('copy')}</span>
</button>
</div>
+47 -4
View File
@@ -5,9 +5,11 @@
*
* - the `editor` type is both a page (the files window) and a resource
* viewer — it claims `dsh-resource://file/**` at the default `extension`
* band, which outranks the built-in `text` preview (`fallback`) and the
* built-in `files` tree (`builtin`), so every file the product opens lands
* in the plugin's editor;
* band, which outranks the built-in `text` preview (`fallback`), so a file
* the product opens lands in the plugin's editor — EXCEPT the formats
* {@link HOST_OWNED_EXTS} hands back to DSH's own previews, which are
* strictly better for binary documents (host-side Office→PDF conversion,
* a worker-backed spreadsheet table, a zoom viewport);
* - the built-in `files` page kind is TAKEN OVER by an `extension`
* registration of the same kind, so `openTab('files')` draws the plugin's
* explorer instead of the built-in tree; the built-in resumes when this
@@ -34,6 +36,42 @@ import {
type NativeTabRecords,
} from './tab-adapter.tsx'
/**
* Extensions DSH's own previews own, and this plugin therefore refuses.
*
* DSH 0.1.7 grew a real document-preview package (host-side Office→PDF
* conversion, a worker-backed spreadsheet table, image/PDF zoom viewports and
* per-directory auto-refresh) for exactly these formats, while the plugin's
* equivalents are read-only fallbacks. The plugin keeps what the built-in does
* NOT do: Markdown through its own renderer, HTML through its sandboxed route
* with the `htmlViewerNoSandbox` safety valve, and — through the `code`
* catch-all — a genuinely EDITABLE CodeMirror buffer for every text file.
*
* Refusing here is what hands the address over: `canOpen` returning false
* leaves the built-in `text` type (the `fallback` band) as the only claimant.
*/
const HOST_OWNED_EXTS: ReadonlySet<string> = new Set([
// Spreadsheets: the built-in renders a table in a worker.
'xlsx', 'xls', 'xlsb', 'xlt', 'xltx', 'xltm', 'ods', 'ots', 'fods', 'csv', 'tsv',
// PDF: the built-in viewer pages and zooms.
'pdf',
// Images: the built-in viewer adds a zoom viewport.
'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif',
// Office documents: the built-in converts them to PDF host-side.
'doc', 'docx', 'dot', 'dotx', 'ppt', 'pptx',
])
/**
* Whether DSH's own preview owns one path.
* @param path - the address's decoded path.
* @returns true for a {@link HOST_OWNED_EXTS} extension (a dotfile is not one).
*/
function hostOwnedPath(path: string): boolean {
const name = path.slice(path.lastIndexOf('/') + 1)
const dot = name.lastIndexOf('.')
return dot > 0 && HOST_OWNED_EXTS.has(name.slice(dot + 1).toLowerCase())
}
/** The native tab-type registry face (`ctx.sidebarRightTabs`). */
interface NativeTabRegistry {
register(definition: {
@@ -190,7 +228,12 @@ export function registerNativeSurface(deps: NativeSurfaceDeps): () => void {
...(isEditor
? {
patterns: ['dsh-resource://file/**'],
canOpen: (address: string) => parseFileAddress(address) !== undefined,
canOpen: (address: string) => {
const file = parseFileAddress(address)
// A non-file address, or one of the formats the built-in
// previews own, leaves the address to DSH's own `text` type.
return file !== undefined && !hostOwnedPath(file.path)
},
}
: {}),
// An external implementation outranks the product's own viewers, which
+93 -16
View File
@@ -10,10 +10,13 @@
*
* - the surface exists only while a session's panel is mounted, and the
* service's public face (`ISidebarRight`) writes only into THAT session.
* The controller also carries `openTabIn` / `openResourceIn` /
* `closeIn`, which act on any session whose store the runtime has minted;
* both are probed at call time, and an open for a session that has no
* store yet is QUEUED and replayed when that session comes on screen;
* "Which session that is" comes from the controller's `mounted` observation
* ({@link mountedSessions}) — never from the session list, which has no
* current-session field. The controller also carries `openTabIn` /
* `openResourceIn` / `closeIn`, which act on any session whose store the
* runtime has minted; both are probed at call time, and an open for a
* session that has no store yet is QUEUED and replayed when that session
* comes on screen;
* - layout state is memory-only, so a queued open is not durable either.
*/
import type { Context } from '../../context-types.ts'
@@ -26,11 +29,24 @@ type Pending =
| { kind: 'tab'; sessionId: string; tabKind: string; params: NativeTabParams; revealIfOpened: boolean }
| { kind: 'resource'; sessionId: string; address: string; line: number | undefined; revealIfOpened: boolean }
/**
* The observation "which session's seat is on screen": DSH 0.1.7 publishes it
* as `ISidebarRight.mounted` (`ObservableSnapshot<SessionId | undefined>`, set
* only when the mounted seat really changes). `undefined` means NO seat is
* drawn — a global panel, or a right column that was never mounted.
*/
export interface MountedSessions {
getSnapshot(): string | undefined
subscribe(listener: () => void): () => void
}
/** The controller face this module uses (a structural slice of `ISidebarRight`). */
interface NativeController {
openTab(kind: string, options?: { params?: unknown; revealIfOpened?: boolean }): void
openResource(address: string, options?: { params?: unknown; revealIfOpened?: boolean }): void
close(tabId: string): void
/** The mounted-seat observation (0.1.7 `ISidebarRight.mounted`). */
mounted?: MountedSessions
/** Not part of `ISidebarRight`: the concrete controller's per-session writes. */
openTabIn?(sessionId: string, kind: string, options?: { params?: unknown; revealIfOpened?: boolean }): void
openResourceIn?(sessionId: string, address: string, options?: { params?: unknown; revealIfOpened?: boolean }): void
@@ -41,35 +57,82 @@ interface NativeController {
export interface NativeSurface extends SidebarSurface {
/** Replay opens that were queued for a session that had no mounted surface. */
flushPending(): void
/** Stop observing the session list. */
/** Stop observing the session list and the mounted-seat feed. */
dispose(): void
}
/** The active session id, as the client list reports it. */
function activeSessionId(ctx: Context): string | undefined {
/** The native controller, probed at call time (the service can arrive late). */
function controllerOf(ctx: Context): NativeController | undefined {
try {
return ctx.sessions.list.getSnapshot().current
return ctx.get('sidebarRight') as unknown as NativeController | undefined
} catch {
return undefined
}
}
/**
* The on-screen-session feed, as anything outside this module should read it.
*
* The session-list snapshot carries NO current-session field in any DSH
* release (0.1.6 and 0.1.7 both publish only `ids` / `byId` / `phase` plus
* projections), so a read of one was always `undefined`: `mounted` is the
* only sanctioned source, and the plugin's own type invented the field it
* used to read.
*
* The probe tolerates a host without `mounted` (or a controller the runtime
* has not provided yet — the seat race this plugin already hit once on
* 0.1.5): the session list then doubles as the change pulse, so a late
* service is still picked up on the next list publish instead of never.
*
* @param ctx - the client context.
* @returns the observable face of the mounted seat's session id.
*/
export function mountedSessions(ctx: Context): MountedSessions {
return {
getSnapshot: () => {
try {
const mounted = controllerOf(ctx)?.mounted
return typeof mounted?.getSnapshot === 'function' ? mounted.getSnapshot() : undefined
} catch {
return undefined
}
},
subscribe: (listener) => {
const mounted = controllerOf(ctx)?.mounted
return typeof mounted?.subscribe === 'function'
? mounted.subscribe(listener)
: ctx.sessions.list.subscribe(listener)
},
}
}
/**
* The session whose seat is on screen, or `undefined` — no seat is mounted
* (global panel, column not mounted) or the host has no mounted feed. Callers
* treat `undefined` as "not this session": nothing the plugin draws belongs to
* that surface, so it must neither write into it nor manage its column.
*
* @param ctx - the client context.
* @returns the on-screen session id, when there is one.
*/
export function mountedSessionId(ctx: Context): string | undefined {
return mountedSessions(ctx).getSnapshot()
}
/**
* Bind the plugin's write face to the native controller.
* @param ctx - the client context (session list + `ctx.sidebarRight`).
* @param records - the plugin's native tab record registry.
* @returns the surface, plus a disposer unbinding its session subscription.
* @returns the surface, plus a disposer unbinding its two feed subscriptions.
*/
export function createNativeSurface(ctx: Context, records: NativeTabRecords): NativeSurface {
const pending: Pending[] = []
const controller = (): NativeController | undefined =>
ctx.get('sidebarRight') as unknown as NativeController | undefined
const controller = (): NativeController | undefined => controllerOf(ctx)
const place = (entry: Pending): boolean => {
const api = controller()
if (api === undefined) return false
const active = activeSessionId(ctx)
const onScreen = active !== undefined && active === entry.sessionId
const onScreen = mountedSessionId(ctx) === entry.sessionId
if (entry.kind === 'tab') {
const options = { params: entry.params, revealIfOpened: entry.revealIfOpened }
if (onScreen) {
@@ -109,7 +172,18 @@ export function createNativeSurface(ctx: Context, records: NativeTabRecords): Na
if (!place(entry)) pending.push(entry)
}
const unsubscribe = ctx.sessions.list.subscribe(flushPending)
// Two feeds flush the queue: the mounted seat (a session coming on screen)
// and the session list, which also stays the pulse that picks the native
// service up when it is provided after this surface was created.
let mountedUnsubscribe: (() => void) | undefined
const onListChange = (): void => {
if (mountedUnsubscribe === undefined) {
const mounted = controller()?.mounted
if (typeof mounted?.subscribe === 'function') mountedUnsubscribe = mounted.subscribe(flushPending)
}
flushPending()
}
const unsubscribeList = ctx.sessions.list.subscribe(onListChange)
return {
openTab({ sessionId, kind, params, revealIfOpened }) {
enqueue({ kind: 'tab', sessionId, tabKind: kind, params, revealIfOpened })
@@ -126,7 +200,7 @@ export function createNativeSurface(ctx: Context, records: NativeTabRecords): Na
records.drop(tabId)
const api = controller()
if (api !== undefined) {
if (sessionId === activeSessionId(ctx)) api.close(tabId)
if (sessionId === mountedSessionId(ctx)) api.close(tabId)
else if (api.closeIn !== undefined) api.closeIn(sessionId, tabId)
}
return { type: record.tab.type, title: record.tab.title }
@@ -144,6 +218,9 @@ export function createNativeSurface(ctx: Context, records: NativeTabRecords): Na
},
has: tabId => records.has(tabId),
flushPending,
dispose: () => { unsubscribe() },
dispose: () => {
unsubscribeList()
mountedUnsubscribe?.()
},
}
}
+2 -2
View File
@@ -10,7 +10,7 @@
* this component owns only its own section.
*/
import { useState } from 'react'
import { IconCloseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconCloseOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import {
isValidCustomEditor,
newCustomEditorId,
@@ -121,7 +121,7 @@ export function OpenWithSettings(props: {
title={t('openWithSettingsRemove')}
onClick={() => { removeCustom(editor.id) }}
>
<IconCloseOutline16 size={14} />
<IconCloseOutlineRegular size={14} />
</button>
</div>
))}
-9
View File
@@ -82,15 +82,6 @@ export function parsePrefs(value: unknown): SidebarPrefs {
htmlViewerDefaultUnsafe: typeof record.htmlViewerDefaultUnsafe === 'boolean'
? record.htmlViewerDefaultUnsafe
: SIDEBAR_PREFS_DEFAULTS.htmlViewerDefaultUnsafe,
browserInterceptLinks: typeof record.browserInterceptLinks === 'boolean'
? record.browserInterceptLinks
: SIDEBAR_PREFS_DEFAULTS.browserInterceptLinks,
browserInterceptHttp: typeof record.browserInterceptHttp === 'boolean'
? record.browserInterceptHttp
: SIDEBAR_PREFS_DEFAULTS.browserInterceptHttp,
browserInterceptHttps: typeof record.browserInterceptHttps === 'boolean'
? record.browserInterceptHttps
: SIDEBAR_PREFS_DEFAULTS.browserInterceptHttps,
tabsEnabled: booleanMapOf(record.tabsEnabled),
viewersEnabled: booleanMapOf(record.viewersEnabled),
pluginSettings: pluginSettingsMapOf(record.pluginSettings),
+8 -9
View File
@@ -208,16 +208,15 @@ export interface TabDescriptor {
createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial<SidebarState> } | null
/**
* External-link target claim (v0.13.0+): when a GUI external-link click
* is taken over (the `browserInterceptLinks` master AND the URL's
* protocol flag — `browserInterceptHttp` / `browserInterceptHttps` —
* are on), the first registered tab whose `urlTarget(url)` returns true
* is opened with `openTab({ type, url, title: hostname })` — the URL is
* the whole payload (the tab reads it from `tab.path`). Registration
* is taken over, the first registered tab whose `urlTarget(url)` returns
* true is opened with `openTab({ type, url, title: hostname })` — the URL
* is the whole payload (the tab reads it from `tab.path`). Registration
* order wins (first claim first served); a disabled tab type is skipped;
* a throwing predicate is swallowed (console.error, the type is skipped).
* The built-in browser tab declares NO urlTarget — it stays the implicit
* fallback target, so plugins can never be shadowed by it. To host more
* than one URL at a time, mint per-URL ids through `createTab` (the
* A click no type claims is NOT taken over at all: it stays with whoever
* rendered the link (DSH 0.1.7's own chat view routes http(s) by the
* user's link-opening preference and falls back to a real browser tab).
* To host more than one URL at a time, mint per-URL ids through `createTab` (the
* browser builtin's pattern); otherwise the id safety net focuses the
* existing tab of the same type and the new URL is not applied.
*/
@@ -634,7 +633,7 @@ export function matchUrlTarget(tabs: readonly TabDescriptor[], url: URL): TabDes
* The plugin version this service instance reports. Keep in lockstep with
* `package.json`'s version — `tests/service.spec.ts` asserts the pair.
*/
export const SIDEBAR_SERVICE_VERSION = '0.20.0'
export const SIDEBAR_SERVICE_VERSION = '0.21.0-alpha.1'
/**
* Monotonic capability list consumers use to gate new API usage (features
-16
View File
@@ -1581,22 +1581,6 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
color: var(--dsw-alias-label-primary);
}
.editorImageWrap {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
overflow: auto;
padding: 12px;
}
.editorImage {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.editorMd {
flex: 1;
min-height: 0;
+55 -14
View File
@@ -1,22 +1,34 @@
/**
* Host-feed subscriptions (extracted from Sidebar.tsx, behavior identical):
* the WebSocket push (agent opens) and the session-list driven
* the WebSocket push (agent opens) and the session-list / job-list driven
* auto-activation triggers (subagent, background jobs, topology
* jump-back). All of it reacts to the host's live feeds for the CURRENT
* session; the sidebar shell only consumes the returned jump-back ref.
*/
import { useEffect, useRef } from 'react'
import type { Context, SidebarSessionList } from '../../context-types.ts'
import { useCallback, useEffect, useRef } from 'react'
import type { Context, SidebarJobView, SidebarSessionList } from '../../context-types.ts'
import type { SidebarStore } from '../state.ts'
import { isNarrowWidth } from '../breakpoints.ts'
import { detectNewDirectSubagent } from '../subagent-detect.ts'
import { detectNewJob } from '../subagent-jobs.ts'
import { api } from '../api.ts'
import { mountedSessionId } from '../native/surface.ts'
import { usePolling } from '../use-polling.ts'
import { t } from '../locales.ts'
/** How many consecutive reconnect failures stop the agent-opens push loop
* (the loop restarts on session switch). */
const FAILURE_LIMIT = 3
/**
* Background-job poll cadence (ms) for the job auto-open trigger. DSH 0.1.7
* removed the client session snapshot's jobs mirror, so the trigger reads the
* plugin's `jobs.list` route on a timer instead of reacting to a push. One
* in-process registry read per tick bounds the auto-open latency at this
* value.
*/
const JOB_POLL_MS = 2_000
/**
* Subagent auto-open debounce (ms). The host delivers a new child's origin
* and its title in SEPARATE frames: a Side Chat thread's first visible
@@ -66,8 +78,12 @@ function activateTasksPage(ctx: Context, sessionId: string, options: { backgroun
const column = ctx.get('sidebarRight') as unknown as NativeColumnFace | undefined
const park = options.background
// The face acts on the MOUNTED session: parking is only meaningful (and
// only safe) when the activation targets the one on screen.
&& ctx.sessions.list.getSnapshot().current === sessionId
// only safe) when the activation targets the one on screen. "On screen"
// is the native surface's own mounted seat — the session list has no
// current-session field — and an absent seat (a global panel, or a host
// without the feed) reads as "not this session": the plugin then leaves
// the column alone rather than toggling one it is not drawing.
&& mountedSessionId(ctx) === sessionId
&& isNarrowWidth(window.innerWidth)
// Only a column the user had COLLAPSED is put back: an expanded one is in
// use, and closing it under the user would be worse than the takeover.
@@ -211,25 +227,50 @@ export function useHostFeeds(feeds: {
/**
* Job auto-activation: the moment a NEW background job appears for the
* current conversation (a job id the previous snapshot lacked), the
* current conversation (a job id the previous read of its list lacked), the
* auto-open pref is on, and the Tasks tab type is enabled, activate the Tasks
* page that contains the background-jobs section — in DSH's native right
* Sidebar, expanded on wide viewports and parked on narrow ones exactly like
* the subagent trigger ({@link activateTasksPage}). Unlike that trigger
* (0 → N only), ANY new job id triggers: the agent may start several jobs in
* one session, and each should surface. A fresh page load never triggers —
* its baseline starts at the current snapshot.
* one session, and each should surface.
*
* The list is POLLED: DSH 0.1.7 stopped mirroring background jobs into the
* client session snapshot, so the registry (fenced per OWNER session) is
* read through the plugin's `jobs.list` route. A fresh page load never
* triggers — the FIRST successful read only arms the baseline — and a host
* without the jobs service (503) leaves the baseline unarmed, so the first
* list that does arrive is never mistaken for new work.
*/
const jobBaselineRef = useRef<SidebarSessionList | undefined>(undefined)
useEffect(() => {
const jobBaselineRef = useRef<readonly SidebarJobView[] | undefined>(undefined)
// A session switch voids the previous session's baseline BEFORE the poller
// below restarts on the new id (effects run in declaration order).
useEffect(() => { jobBaselineRef.current = undefined }, [sessionId])
const pollJobs = useCallback(async (signal: AbortSignal): Promise<void> => {
if (sessionId === undefined) return
let jobs: readonly SidebarJobView[]
try {
const result = await api.jobsList(sessionId, signal)
jobs = result.jobs
} catch {
// No jobs service, or a dropped read: keep the last baseline and let the
// next tick retry (never treat an unreadable list as an empty one).
return
}
if (signal.aborted) return
const prev = jobBaselineRef.current
jobBaselineRef.current = sessionList
if (sessionId === undefined || prev === undefined) return
if (!detectNewJob(prev, sessionList, sessionId)) return
jobBaselineRef.current = jobs
if (prev === undefined) return
if (!detectNewJob(prev, jobs)) return
if (!store.getPrefs().autoOpenJobs) return
if (ctx.get('betterSidebar')?.isTabEnabled('subagent') === false) return
activateTasksPage(ctx, sessionId, { background: true })
}, [sessionList, sessionId, store, ctx])
}, [sessionId, store, ctx])
usePolling(sessionId !== undefined, pollJobs, {
intervalMs: JOB_POLL_MS,
mode: 'self-scheduling',
immediate: true,
})
/**
* Topology jump-back: clicking a subagent node on the Subagent page calls
+29 -17
View File
@@ -140,25 +140,37 @@ export function toolArgsSummary(args: string | undefined): string {
return flatTruncate(args)
}
/** The plain text of a tool/result message (text blocks inside its
* `tool-result` content block). */
function resultTextOf(data: Record<string, unknown>): string {
const message = data.message as { content?: unknown } | undefined
const content = message?.content
if (!Array.isArray(content)) return ''
const parts: string[] = []
/**
* The result content blocks of one tool/result message under BOTH logged
* shapes: 0.1.6 wrapped them in a single `type: 'tool-result'` content block
* on a user-role message, 0.1.7's first-class tool-role message carries them
* at the message's own top level. Historical logs keep the old shape forever,
* so both are read. Undefined when the message carries no block array.
*/
function resultBlocks(content: unknown): readonly unknown[] | undefined {
if (!Array.isArray(content)) return undefined
for (const block of content) {
if (block === null || typeof block !== 'object') continue
const candidate = block as { type?: unknown; content?: unknown }
if (candidate.type !== 'tool-result') continue
const inner = candidate.content
if (!Array.isArray(inner)) continue
for (const item of inner) {
if (item === null || typeof item !== 'object') continue
const textItem = item as { type?: unknown; text?: unknown }
if (textItem.type === 'text' && typeof textItem.text === 'string') {
parts.push(textItem.text)
}
const wrapper = block as { type?: unknown; content?: unknown }
if (wrapper.type === 'tool-result' && Array.isArray(wrapper.content)) {
return wrapper.content as readonly unknown[]
}
}
return content as readonly unknown[]
}
/** The plain text of a tool/result message (its text blocks, under either of
* the two shapes {@link resultBlocks} reads). */
function resultTextOf(data: Record<string, unknown>): string {
const message = data.message as { content?: unknown } | undefined
const blocks = resultBlocks(message?.content)
if (blocks === undefined) return ''
const parts: string[] = []
for (const item of blocks) {
if (item === null || typeof item !== 'object') continue
const textItem = item as { type?: unknown; text?: unknown }
if (textItem.type === 'text' && typeof textItem.text === 'string') {
parts.push(textItem.text)
}
}
return parts.join('\n')
+104
View File
@@ -0,0 +1,104 @@
/**
* The plugin's view of DSH 0.1.7's host-computed session projections — the
* subagent catalog the Tasks page's topology renders, plus the two facts the
* projection row does not carry.
*
* 0.1.6 published a per-parent lazy catalog through the sessions snapshot
* (`subagentsByParent`, entries carrying `activity` / `hasChildren`) and the
* page had to OBSERVE every parent it drew, releasing them when it hid. 0.1.7
* replaced that whole handshake: the client loads every session's projections
* once per connection, `SessionListState.projectionsBySession` publishes the
* finished values, and a row is exactly `{ id, createdAt, mode, label? }`
* (see the subagent catalog projection's `viewSchema`). So this module is a
* pure adapter — no request, no subscription, no per-parent bookkeeping.
*
* The two dropped facts are derived, not invented:
*
* - a row HAS CHILDREN unless the child's own catalog is known-empty
* ({@link isKnownLeaf} — upstream's own disclosure rule, which keeps a
* branch open while its catalog is still loading instead of flapping it
* into a leaf);
* - `activity` comes from the live channel (`subagents.live`, which folds the
* host's RUNNING children): a child the live map does not mention is not
* running ({@link childActivity}).
*
* What the projection genuinely does not carry any more is the 0.1.6
* `diagnostic` row (a child whose descriptor was corrupt / unsupported /
* unavailable): the host's fold publishes healthy identities only, so an
* unreadable child is not a row at all and the page has nothing to show for
* it. See the module report — no local equivalent exists.
*/
import type {
SidebarSessionList,
SidebarSubagentCatalogEntry,
} from '../context-types.ts'
import type { LastActivity } from '../subagent-activity.ts'
/** One parent's direct-child catalog, as the topology consumes it. */
export interface SubagentCatalogView {
entries: readonly SidebarSubagentCatalogEntry[]
state: 'loading' | 'ready' | 'error'
error: { code?: string; message?: string } | null
}
/**
* Fold the snapshot's projection values into per-parent catalog views.
*
* An `idle` row has been published but not read yet (the manager seeds rows
* from the persisted checkpoint before the connection's read lands): it is
* `loading` when it carries no value at all and `ready` when a value is
* already there — the same reading upstream's own catalog view uses.
*
* @param projections - `SessionListState.projectionsBySession` (optional: a
* host without the projection store leaves every catalog empty).
* @returns one view per session the snapshot knows about.
*/
export function subagentCatalogs(
projections: SidebarSessionList['projectionsBySession'],
): Readonly<Record<string, SubagentCatalogView>> {
const views: Record<string, SubagentCatalogView> = {}
for (const [sessionId, snapshot] of Object.entries(projections ?? {})) {
const entries = snapshot.values.subagentCatalog
views[sessionId] = {
entries: entries ?? [],
state: snapshot.state === 'idle'
? entries === undefined ? 'loading' : 'ready'
: snapshot.state,
error: snapshot.error ?? null,
}
}
return views
}
/**
* Whether a child is a KNOWN LEAF: its own catalog finished loading and holds
* no children. Everything else (a catalog still loading, one that failed, or
* one this snapshot does not carry yet) keeps its disclosure — the topology
* then draws the child's own level, whose rows hydrate when the projection
* lands.
*
* @param catalogs - the views of one snapshot ({@link subagentCatalogs}).
* @param childSessionId - the child row whose own catalog decides.
*/
export function isKnownLeaf(
catalogs: Readonly<Record<string, SubagentCatalogView>>,
childSessionId: string,
): boolean {
const catalog = catalogs[childSessionId]
return catalog?.state === 'ready' && catalog.entries.length === 0
}
/**
* The child's live state. The `subagents.live` route folds only RUNNING
* children (a Side Chat thread and an idle child are absent), so absence is
* the inactive answer rather than missing data.
*
* @param live - the batch live map (`child id → latest activity`).
* @param childSessionId - the child row being drawn.
*/
export function childActivity(
live: Readonly<Record<string, LastActivity>>,
childSessionId: string,
): 'running' | 'inactive' {
return live[childSessionId] === undefined ? 'inactive' : 'running'
}
+1 -29
View File
@@ -15,10 +15,7 @@
* ./subagent-lineage.ts — the single shared walk implementation — and are
* re-exported below for their established import sites.
*/
import type {
SidebarSessionList,
SidebarSubagentCatalog,
} from '../context-types.ts'
import type { SidebarSessionList } from '../context-types.ts'
import { countSubagentDescendants, isSideThreadSummary, rootAncestor } from './subagent-lineage.ts'
export { countSubagentDescendants, isSideThreadSummary, rootAncestor }
@@ -37,31 +34,6 @@ export function directSubagentCount(
return count
}
/**
* Collect every catalog branch (an entry with `hasChildren`) reachable from
* the root — the set of catalogs the always-expanded topology consumes.
* Cycles fail soft.
*/
export function collectBranchIds(
catalogs: Readonly<Record<string, SidebarSubagentCatalog>>,
rootId: string | undefined,
): string[] {
const out: string[] = []
const seen = new Set<string>()
const visit = (parentId: string): void => {
if (seen.has(parentId)) return
seen.add(parentId)
for (const entry of catalogs[parentId]?.entries ?? []) {
if (entry.kind === 'child' && entry.hasChildren) {
out.push(entry.id)
visit(entry.id)
}
}
}
if (rootId !== undefined) visit(rootId)
return out
}
/**
* Whether a new direct subagent appeared under `sessionId` between two
* consecutive list snapshots (the count crossed 0 → >0). Switching to a
+20 -16
View File
@@ -1,10 +1,12 @@
/**
* Pure derivations for the Subagent page's background-job section. Kept
* framework-free so the node test environment can unit-test them: the job
* rows arrive through the harness `session/jobs` push mirror
* (`jobsBySession` in the sessions list feed) — nothing here issues
* requests, and the row ordering / status mapping mirror the official
* ui-jobs header list.
* rows are collected from the per-session lists the client reads through the
* plugin's `jobs.list` route (DSH 0.1.7 dropped the harness `session/jobs`
* push mirror that used to feed `jobsBySession`, and the registry's access
* fence admits a job only to its OWNER session — so the caller fans one read
* out per tree session). Nothing here issues requests, and the row ordering /
* status mapping mirror the official ui-jobs header list.
*/
import type {
SidebarSessionList,
@@ -32,26 +34,28 @@ export function isJobLive(job: SidebarJobView): boolean {
export { treeSessionIds }
/**
* Whether a NEW background job appeared for one session between two
* consecutive list snapshots (a job id the previous snapshot lacked).
* Whether a NEW background job appeared for the current session between two
* consecutive reads of its job list (a job id the previous list lacked).
* Unlike the subagent auto-open (0 → N only), ANY new job id triggers: the
* agent may start several jobs over a session, and each new one should
* surface the Tasks page containing the background-jobs section (a fresh page
* load never triggers — its baseline starts at the current snapshot).
* surface the Tasks page containing the background-jobs section. A fresh page
* load never triggers — the caller builds its baseline from the first list it
* reads instead of comparing against one (see the poller in
* sidebar/use-host-feeds.ts).
*/
export function detectNewJob(
prev: SidebarSessionList,
next: SidebarSessionList,
sessionId: string,
prev: readonly SidebarJobView[],
next: readonly SidebarJobView[],
): boolean {
const prevIds = new Set((prev.jobsBySession?.[sessionId] ?? []).map(job => job.id))
return (next.jobsBySession?.[sessionId] ?? []).some(job => !prevIds.has(job.id))
const prevIds = new Set(prev.map(job => job.id))
return next.some(job => !prevIds.has(job.id))
}
/**
* Collect the background jobs of the whole current tree, owner-labeled.
* Sessions without a mirror entry contribute nothing; an absent mirror
* (runtime older than the jobs feed) yields an empty list.
* Collect the background jobs of the whole current tree, owner-labeled. The
* map is keyed by the OWNER session (the fence's unit of access): sessions the
* caller could not read, or that returned nothing, contribute nothing — an
* absent entry is an empty set, never a dropped row of another tree.
*/
export function collectTreeJobs(
byId: SidebarSessionList['byId'],
+140
View File
@@ -0,0 +1,140 @@
/**
* Live refresh for the file tree.
*
* The tree lists a folder when it is expanded and caches the level, so
* anything written outside the plugin — a build, a formatter, the model's own
* `bash` — stayed invisible until the reader refreshed. DSH 0.1.7 gave its own
* tree per-directory watching; this plugin owns its tree (it took the `files`
* kind over), so it carries its own.
*
* One socket per session, open for as long as the tree is mounted. The client
* announces the folders it has expanded and the host watches exactly those;
* every notice names a directory whose listing is stale, and the tree drops
* that one cached level and re-lists it. Folders the reader collapsed are
* un-watched, so a long session does not accumulate handles.
*/
import { useEffect, useRef } from 'react'
/** One server frame: a stale directory, a watch verdict, or a refusal. */
interface FsWatchFrame {
dir?: unknown
ok?: unknown
}
/** What the tree hands the hook. */
export interface DirectoryWatchOptions {
/** Session whose workspace is being browsed; no socket while undefined. */
sessionId: string | undefined
/** The workspace root, always watched (the tree always lists it). */
root: string | undefined
/** Expanded directories below the root, in the host's absolute path form. */
dirs: readonly string[]
/**
* A directory whose listing is stale.
* @param dir - absolute directory path, exactly the form the tree keys levels by.
*/
onStale: (dir: string) => void
}
/** How long to wait before re-opening a dropped socket; failures back off to the cap. */
const RETRY_BASE_MS = 1000
const RETRY_MAX_MS = 15_000
/**
* Keep the file tree's expanded folders watched for the component's lifetime.
* @param options - session, root, expanded directories, and the staleness callback.
*/
export function useDirectoryWatch(options: DirectoryWatchOptions): void {
const { sessionId, root, dirs, onStale } = options
// The expanded set is a fresh array on every render, so neither the socket
// effect nor the staleness callback may depend on their identity.
const wantedRef = useRef<string[]>([])
wantedRef.current = root === undefined ? [...dirs] : [root, ...dirs]
const staleRef = useRef(onStale)
staleRef.current = onStale
/** Set by the socket effect; reconciles the host's watch set with the tree's. */
const reconcileRef = useRef<() => void>(() => {})
// One string per expanded-set change: a stable dependency for the effect below.
const wantedKey = wantedRef.current.join('\u0000')
useEffect(() => {
if (sessionId === undefined) return
let socket: WebSocket | null = null
let retry: number | undefined
let closed = false
let failures = 0
/** Directories the host confirmed it is watching (its own resolved paths). */
const watching = new Set<string>()
const send = (op: 'watch' | 'unwatch', path: string): void => {
if (socket !== null && socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ op, path }))
}
/**
* Bring the host's watch set in line with the tree's expanded set. The
* host answers every `watch` with the directory it resolved, so `watching`
* is the host's own answer rather than a client-side guess.
*/
const reconcile = (): void => {
const wanted = new Set(wantedRef.current)
for (const path of wanted) send('watch', path)
for (const dir of [...watching]) {
if (wanted.has(dir)) continue
send('unwatch', dir)
watching.delete(dir)
}
}
reconcileRef.current = reconcile
const connect = (): void => {
if (closed) return
const url = new URL('/sidebar/ws/fs-watch', location.origin)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
url.search = new URLSearchParams({ sessionId }).toString()
socket = new WebSocket(url.toString())
socket.onopen = () => {
failures = 0
watching.clear()
reconcile()
}
socket.onmessage = (event) => {
if (typeof event.data !== 'string') return
let frame: FsWatchFrame
try {
frame = JSON.parse(event.data) as FsWatchFrame
} catch {
return
}
if (frame === null || typeof frame !== 'object' || typeof frame.dir !== 'string') return
// `ok: true` confirms a watch; `ok: false` refuses one (fenced path,
// watcher cap, unreadable directory) and must NOT re-list. A frame
// carrying only `dir` is the stale notice.
if (frame.ok === true) {
watching.add(frame.dir)
return
}
if (frame.ok === undefined) staleRef.current(frame.dir)
}
socket.onclose = () => {
if (closed) return
failures += 1
retry = window.setTimeout(connect, Math.min(RETRY_BASE_MS * 2 ** (failures - 1), RETRY_MAX_MS))
}
}
connect()
return () => {
closed = true
reconcileRef.current = () => {}
if (retry !== undefined) window.clearTimeout(retry)
socket?.close()
}
}, [sessionId])
// Reconciling on its own effect keeps an expand/collapse from tearing the
// socket down and re-opening it.
useEffect(() => {
void wantedKey
reconcileRef.current()
}, [wantedKey])
}