feat(dsh): adapt to DSH 0.1.5-alpha.2

Baseline moves to DSH 0.1.5-alpha.2 only (peer floor ^0.1.5-alpha.2,
CI pin, manifest engines); alpha.1 users stay on v0.19.0-alpha.0.

Host-contract changes handled:
- the native guide entry lost its `description` field (entries are
  icon+title capsules now): `TabDescriptor.description` and the six
  `guideDesc*` keys plus the `nativeGuideDesc` fallback are removed.
- the file address grammar changed: `fileAddressFor` always returns a
  session-scoped address (absolute paths keep their leading `/`),
  `parseFileAddress` is prefix-based and strips `?`/`#`. The plugin's
  purity-gated mirror follows.
- global main panels replaced the `conversation` root slot with the
  keyed `main` slot (`main.conversation`): the bottom workbench's
  center-column locator now accepts both keys and skips
  `display: contents` slot hosts.

Fixes a pre-existing fill bug the alpha.2 probe made obvious: the native
tab body host is a block scroller with a definite height, so tab roots
that only declared `flex: 1` collapsed to content height and the side
chat composer drifted off the pane bottom. The native adapter now wraps
every tab body in a `height: 100%` column flex host.

Also promotes the dependencies `dsh-client-ui-primitives@0.1.5-alpha.2`
dropped from its manifest (anser / shiki / mdast-util-* / micromark-* /
katex / @shikijs/langs) and the newly unmet `dsh-session-persistence`
peer into devDependencies.
This commit is contained in:
Menghuan1918
2026-09-09 23:09:48 +08:00
parent d4765819c0
commit 0b6ac5d267
43 changed files with 944 additions and 930 deletions
-6
View File
@@ -82,7 +82,6 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
return [
{
id: 'editor',
description: () => t('guideDescFiles'),
// The single files window: an editor tab with no path IS the file
// explorer (empty hint + docked tree); with a path it previews/edits
// the file. Visible in the + menu in the explorer's old slot.
@@ -149,7 +148,6 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
// log, and the git status needs a fetch — both stay out of the badge).
id: 'git',
title: () => t('changes'),
description: () => t('guideDescGit'),
icon: (size: number) => <IconDiffOutline16 size={size} />,
order: 20,
single: true,
@@ -172,7 +170,6 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
{
id: 'subagent',
title: () => t('subagent'),
description: () => t('guideDescSubagent'),
icon: (size: number) => <IconThinkOutline16 size={size} />,
order: 30,
single: true,
@@ -201,7 +198,6 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
{
id: 'sidechat',
title: () => t('sideChat'),
description: () => t('guideDescSidechat'),
icon: (size: number) => <IconNewChatOutline16 size={size} />,
order: 35,
// Codex-style: EVERY side conversation is its own tab. A plain open
@@ -247,7 +243,6 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
{
id: 'terminal',
title: () => t('terminal'),
description: () => t('guideDescTerminal'),
icon: (size: number) => <IconTerminalOutline16 size={size} />,
order: 40,
available: (_ctx, _scope, state) => uiTerminalCount(state) < TERMINAL_LIMIT,
@@ -312,7 +307,6 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
{
id: 'browser',
title: () => t('browser'),
description: () => t('guideDescBrowser'),
icon: (size: number) => <IconGlobeOutline16 size={size} />,
order: 50,
// Declarative settings: the sandbox escape hatch, the link-takeover
+30 -3
View File
@@ -1,5 +1,15 @@
/** Stable selector for the DSH AppFrame conversation slot. */
const CENTER_COLUMN_SELECTOR = '#root [data-slot="conversation"]'
/**
* Anchor selectors for the DSH AppFrame conversation slot host, newest first:
*
* - DSH 0.1.5-alpha.2 replaced the root `conversation` slot with a root-scoped
* keyed `main` slot, so the conversation renders under `main.conversation`
* (ConversationPanel -> `renderSlot('main.conversation')`);
* - alpha.1 and earlier spell the same host `conversation`.
*
* Both keys are accepted deliberately so one build spans the alpha.1 → alpha.2
* boundary; only one exists in any given host.
*/
const CENTER_COLUMN_SELECTOR = '#root [data-slot="main.conversation"], #root [data-slot="conversation"]'
/** Match Sidebar's existing last-resort retry cadence (issue #248). */
export const CENTER_COLUMN_REVALIDATE_MS = 1500
@@ -27,6 +37,22 @@ const validatedAt = new WeakMap<HTMLElement, number>()
*/
const documentStyleState = new WeakMap<Document, string | null>()
/**
* From the matched conversation slot host, walk up through transparent slot
* hosts to the real column: DSH slot hosts render with `display: contents`,
* so on alpha.2 the `main.conversation` host (and any further keyed slot
* wrappers above it) contributes no box, and the center column is the first
* ancestor whose computed display is NOT `contents`.
*/
function columnOfSlotHost(host: Element): HTMLElement | null {
const view = host.ownerDocument.defaultView
let node = host.parentElement
while (node !== null && (view?.getComputedStyle(node).display ?? '') === 'contents') {
node = node.parentElement
}
return node
}
/**
* Resolve the AppFrame center column while keeping streaming mutations cheap.
*
@@ -74,7 +100,8 @@ export function resolveCenterColumn(
}
const query = options.query ?? (() => doc.querySelector(CENTER_COLUMN_SELECTOR))
const col = query()?.parentElement as HTMLElement | null | undefined
const host = query()
const col = host === null || host === undefined ? null : columnOfSlotHost(host)
if (col === null || col === undefined || !col.isConnected) return undefined
validatedAt.set(col, now)
+5 -2
View File
@@ -15,8 +15,11 @@
* transitions so the reserved edge tracks the pointer.
*/
/* The AppFrame's center column. The Sidebar shell's locator (Sidebar.tsx
locate/measureCenter) tags the measured column node with
[data-dsh-center-col] — one attribute write per node lifetime. The rule
locate/measureCenter, resolving via center-column.ts) tags the measured
column node with [data-dsh-center-col] — one attribute write per node
lifetime. The locator anchors on the conversation slot host
(`main.conversation` on DSH 0.1.5-alpha.2+, `conversation` on alpha.1)
and skips the transparent display:contents slot hosts above it. The rule
used to anchor on a structural has()-selector over the conversation slot
wrapper, whose match cache invalidates on every #root subtree mutation
(the streaming chat mutates it constantly), re-evaluating the selector
-7
View File
@@ -18,13 +18,6 @@
/** The ar dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const ar: Record<string, string> = {
files: 'الملفات',
nativeGuideDesc: 'Open in the DSH sidebar',
guideDescFiles: 'استعرض شجرة ملفات مساحة العمل؛ انقر للمعاينة أو التحرير',
guideDescGit: 'تغييرات Git وتعديلات ملفات هذه الجلسة في تبويب واحد',
guideDescSubagent: 'بنية الوكلاء الفرعيين والمهام الخلفية مباشرةً',
guideDescSidechat: 'خيط متابعة مستقل يرث سياق هذه الجلسة',
guideDescTerminal: 'صدفة حقيقية (xterm + pty) مع إعادة الاتصال',
guideDescBrowser: 'متصفح مدمج يمكنه فتح روابط المحادثة',
changesSessionEmpty: 'لا توجد عمليات ملفات في هذه الجلسة بعد',
changesRead: 'قراءة',
changesMdReading: 'قراءة',
-7
View File
@@ -3,13 +3,6 @@
*/
export const de: Record<string, string> = {
files: 'Dateien',
nativeGuideDesc: 'Open in the DSH sidebar',
guideDescFiles: 'Dateibaum des Arbeitsbereichs durchsuchen; Klick öffnet Vorschau oder Editor',
guideDescGit: 'Git-Änderungen und Dateiänderungen dieser Sitzung in einem Tab',
guideDescSubagent: 'Subagent-Topologie und Hintergrundaufgaben, live',
guideDescSidechat: 'Ein unabhängiger Folge-Thread mit dem Kontext dieser Sitzung',
guideDescTerminal: 'Eine echte Shell (xterm + pty) mit Wiederverbindung',
guideDescBrowser: 'Eingebetteter Browser, der Chat-Links übernehmen kann',
changesSessionEmpty: 'Noch keine Dateioperationen in dieser Sitzung',
changesRead: 'Lesen',
changesMdReading: 'Lesen',
-7
View File
@@ -10,13 +10,6 @@
/** fr dictionary for the `betterSidebar` namespace. */
export const fr: Record<string, string> = {
files: 'Fichiers',
nativeGuideDesc: 'Open in the DSH sidebar',
guideDescFiles: 'Parcourir l\'arborescence du workspace ; un clic ouvre l\'aperçu ou l\'édition',
guideDescGit: 'Modifications Git et éditions de cette session dans un seul onglet',
guideDescSubagent: 'Topologie des sous-agents et tâches d\'arrière-plan, en direct',
guideDescSidechat: 'Un fil de suivi indépendant héritant du contexte de cette session',
guideDescTerminal: 'Un vrai shell (xterm + pty) avec reconnexion',
guideDescBrowser: 'Navigateur intégré, qui peut reprendre les liens du chat',
changesSessionEmpty: 'Aucune opération de fichier dans cette session',
changesRead: 'Lecture',
changesMdReading: 'Lecture',
-7
View File
@@ -17,13 +17,6 @@
/** The hi dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const hi: Record<string, string> = {
files: 'फ़ाइलें',
nativeGuideDesc: 'Open in the DSH sidebar',
guideDescFiles: 'वर्कस्पेस फ़ाइल ट्री देखें; क्लिक करने पर प्रीव्यू या संपादन खुलता है',
guideDescGit: 'Git बदलाव और इस सत्र के फ़ाइल बदलाव एक ही टैब में',
guideDescSubagent: 'सबएजेंट टोपोलॉजी और बैकग्राउंड कार्य, लाइव',
guideDescSidechat: 'इस सत्र का संदर्भ विरासत में लेने वाला स्वतंत्र फ़ॉलो-अप थ्रेड',
guideDescTerminal: 'असली शेल (xterm + pty), रीकनेक्ट सहित',
guideDescBrowser: 'अंतर्निहित ब्राउज़र, चैट के लिंक भी खोल सकता है',
changesSessionEmpty: 'इस सत्र में अभी कोई फ़ाइल ऑपरेशन नहीं',
changesRead: 'पढ़ना',
changesMdReading: 'पठन',
-7
View File
@@ -15,13 +15,6 @@
export const id: Record<string, string> = {
files: 'Berkas',
nativeGuideDesc: 'Open in the DSH sidebar',
guideDescFiles: 'Telusuri pohon berkas workspace; klik untuk pratinjau atau edit',
guideDescGit: 'Perubahan Git dan perubahan berkas sesi ini dalam satu tab',
guideDescSubagent: 'Topologi subagent dan tugas latar belakang, langsung',
guideDescSidechat: 'Utas lanjutan independen yang mewarisi konteks sesi ini',
guideDescTerminal: 'Shell sungguhan (xterm + pty), bisa menyambung ulang',
guideDescBrowser: 'Peramban tertanam, bisa mengambil alih tautan chat',
changesSessionEmpty: 'Belum ada operasi file di sesi ini',
changesRead: 'Baca',
changesMdReading: 'Baca',
-7
View File
@@ -8,13 +8,6 @@
export const it: Record<string, string> = {
files: 'File',
nativeGuideDesc: 'Open in the DSH sidebar',
guideDescFiles: 'Sfoglia l\'albero del workspace; un clic apre anteprima o editor',
guideDescGit: 'Modifiche Git e modifiche ai file di questa sessione in un solo tab',
guideDescSubagent: 'Topologia dei subagent e attività in background, in tempo reale',
guideDescSidechat: 'Un thread di approfondimento indipendente che eredita il contesto della sessione',
guideDescTerminal: 'Una vera shell (xterm + pty) con riconnessione',
guideDescBrowser: 'Browser integrato, può aprire i link della chat',
changesSessionEmpty: 'Ancora nessuna operazione sui file in questa sessione',
changesRead: 'Lettura',
changesMdReading: 'Lettura',
-7
View File
@@ -17,13 +17,6 @@
/** The ja dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const ja: Record<string, string> = {
files: 'ファイル',
nativeGuideDesc: 'DSH のサイドバーで開く',
guideDescFiles: 'ワークスペースのファイルツリーを閲覧し、クリックでプレビュー/編集',
guideDescGit: 'Git の変更と今回のセッションのファイル変更を 1 つのタブに',
guideDescSubagent: 'サブエージェントの構成とバックグラウンドタスクをリアルタイム表示',
guideDescSidechat: '現在のセッションの文脈を引き継ぐ独立した追加スレッド',
guideDescTerminal: '本物のシェル(xterm + pty)、再接続対応',
guideDescBrowser: '埋め込みブラウザ。チャット内のリンクも引き受け可能',
changesSessionEmpty: 'このセッションにはファイル操作がまだありません',
changesRead: '読み取り',
changesMdReading: '閲覧',
-7
View File
@@ -9,13 +9,6 @@
export const ko: Record<string, string> = {
files: '파일',
nativeGuideDesc: 'Open in the DSH sidebar',
guideDescFiles: '워크스페이스 파일 트리를 보고 클릭하면 미리보기/편집',
guideDescGit: 'Git 변경 사항과 이번 세션의 파일 변경을 한 탭에서',
guideDescSubagent: '하위 에이전트 구조와 백그라운드 작업을 실시간으로',
guideDescSidechat: '현재 세션의 컨텍스트를 이어받는 독립 후속 스레드',
guideDescTerminal: '실제 셸(xterm + pty), 재연결 지원',
guideDescBrowser: '내장 브라우저, 채팅 링크도 대신 열 수 있음',
changesSessionEmpty: '이 세션에 아직 파일 작업이 없습니다',
changesRead: '읽기',
changesMdReading: '읽기',
-7
View File
@@ -15,13 +15,6 @@
*/
export const nl: Record<string, string> = {
files: 'Bestanden',
nativeGuideDesc: 'Open in the DSH sidebar',
guideDescFiles: 'Blader door de bestandsboom van de workspace; klik opent voorbeeld of editor',
guideDescGit: 'Git-wijzigingen en bestandswijzigingen van deze sessie in één tabblad',
guideDescSubagent: 'Subagent-topologie en achtergrondtaken, live',
guideDescSidechat: 'Een onafhankelijke vervolgthread met de context van deze sessie',
guideDescTerminal: 'Een echte shell (xterm + pty) met herverbinding',
guideDescBrowser: 'Ingebouwde browser die chatlinks kan overnemen',
changesSessionEmpty: 'Nog geen bestandsbewerkingen in deze sessie',
changesRead: 'Lezen',
changesMdReading: 'Lezen',
-7
View File
@@ -19,13 +19,6 @@
/** The pl dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const pl: Record<string, string> = {
files: 'Pliki',
nativeGuideDesc: 'Open in the DSH sidebar',
guideDescFiles: 'Przeglądaj drzewo plików obszaru roboczego; klik otwiera podgląd lub edycję',
guideDescGit: 'Zmiany Git i zmiany plików z tej sesji w jednej karcie',
guideDescSubagent: 'Topologia subagentów i zadania w tle na żywo',
guideDescSidechat: 'Niezależny wątek uzupełniający dziedziczący kontekst tej sesji',
guideDescTerminal: 'Prawdziwy shell (xterm + pty) z ponownym łączeniem',
guideDescBrowser: 'Wbudowana przeglądarka, może przejmować linki z czatu',
changesSessionEmpty: 'Brak operacji na plikach w tej sesji',
changesRead: 'Odczyt',
changesMdReading: 'Czytanie',
-7
View File
@@ -1,12 +1,5 @@
export const pt: Record<string, string> = {
files: 'Arquivos',
nativeGuideDesc: 'Open in the DSH sidebar',
guideDescFiles: 'Navegue pela árvore do workspace; um clique abre a pré-visualização ou a edição',
guideDescGit: 'Alterações do Git e edições desta sessão em um único separador',
guideDescSubagent: 'Topologia de subagentes e tarefas em segundo plano, ao vivo',
guideDescSidechat: 'Uma conversa paralela independente que herda o contexto desta sessão',
guideDescTerminal: 'Um shell real (xterm + pty) com reconexão',
guideDescBrowser: 'Navegador incorporado, que pode abrir links do chat',
changesSessionEmpty: 'Ainda sem operações de arquivo nesta sessão',
changesRead: 'Leitura',
changesMdReading: 'Leitura',
-7
View File
@@ -13,13 +13,6 @@
export const ru: Record<string, string> = {
files: 'Файлы',
nativeGuideDesc: 'Open in the DSH sidebar',
guideDescFiles: 'Дерево файлов рабочей области: клик открывает просмотр или редактор',
guideDescGit: 'Изменения Git и правки файлов этой сессии в одной вкладке',
guideDescSubagent: 'Топология субагентов и фоновые задачи в реальном времени',
guideDescSidechat: 'Независимый дополнительный тред с контекстом этой сессии',
guideDescTerminal: 'Настоящий shell (xterm + pty) с переподключением',
guideDescBrowser: 'Встроенный браузер, перехватывает ссылки из чата',
changesSessionEmpty: 'В этой сессии пока нет файловых операций',
changesRead: 'Чтение',
changesMdReading: 'Чтение',
-7
View File
@@ -1,12 +1,5 @@
export const sv: Record<string, string> = {
files: 'Filer',
nativeGuideDesc: 'Open in the DSH sidebar',
guideDescFiles: 'Bläddra i arbetsytans filträd; klick öppnar förhandsvisning eller redigering',
guideDescGit: 'Git-ändringar och sessionens filändringar i en flik',
guideDescSubagent: 'Subagenttopologi och bakgrundsjobb, live',
guideDescSidechat: 'En oberoende uppföljningstråd som ärver sessionens kontext',
guideDescTerminal: 'Ett riktigt skal (xterm + pty) med återanslutning',
guideDescBrowser: 'Inbäddad webbläsare som kan ta över chattlänkar',
changesSessionEmpty: 'Inga filoperationer i den här sessionen ännu',
changesRead: 'Läs',
changesMdReading: 'Läsning',
-7
View File
@@ -17,13 +17,6 @@
/** The th dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const th: Record<string, string> = {
files: 'ไฟล์',
nativeGuideDesc: 'Open in the DSH sidebar',
guideDescFiles: 'เรียกดูแผนผังไฟล์ของเวิร์กสเปซ คลิกเพื่อดูตัวอย่างหรือแก้ไข',
guideDescGit: 'การเปลี่ยนแปลง Git และไฟล์ที่แก้ในเซสชันนี้ในแท็บเดียว',
guideDescSubagent: 'โครงสร้างซับเอเจนต์และงานเบื้องหลังแบบเรียลไทม์',
guideDescSidechat: 'เธรดถามต่ออิสระที่สืบทอดบริบทของเซสชันนี้',
guideDescTerminal: 'เชลล์จริง (xterm + pty) เชื่อมต่อใหม่ได้',
guideDescBrowser: 'เบราว์เซอร์ในตัว รับช่วงลิงก์จากแชทได้',
changesSessionEmpty: 'ยังไม่มีการดำเนินการไฟล์ในเซสชันนี้',
changesRead: 'อ่าน',
changesMdReading: 'อ่าน',
-7
View File
@@ -17,13 +17,6 @@
/** The tr dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const tr: Record<string, string> = {
files: 'Dosyalar',
nativeGuideDesc: 'Open in the DSH sidebar',
guideDescFiles: 'Çalışma alanı dosya ağacına göz atın; tıklayınca önizleme veya düzenleme açılır',
guideDescGit: 'Git değişiklikleri ve bu oturumun dosya değişiklikleri tek sekmede',
guideDescSubagent: 'Alt ajan topolojisi ve arka plan görevleri, canlı',
guideDescSidechat: 'Bu oturumun bağlamını devralan bağımsız bir yan konu',
guideDescTerminal: 'Gerçek bir shell (xterm + pty), yeniden bağlanma destekli',
guideDescBrowser: 'Sohbet bağlantılarını devralabilen gömülü tarayıcı',
changesSessionEmpty: 'Bu oturumda henüz dosya işlemi yok',
changesRead: 'Okuma',
changesMdReading: 'Okuma',
-7
View File
@@ -17,13 +17,6 @@
/** The vi dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const vi: Record<string, string> = {
files: 'Tệp',
nativeGuideDesc: 'Open in the DSH sidebar',
guideDescFiles: 'Duyệt cây tệp của workspace; bấm để xem trước hoặc chỉnh sửa',
guideDescGit: 'Thay đổi Git và thay đổi tệp của phiên này trong một tab',
guideDescSubagent: 'Sơ đồ subagent và tác vụ nền, theo thời gian thực',
guideDescSidechat: 'Luồng hỏi tiếp độc lập kế thừa ngữ cảnh của phiên này',
guideDescTerminal: 'Shell thật (xterm + pty), tự kết nối lại',
guideDescBrowser: 'Trình duyệt nhúng, có thể mở link trong chat',
changesSessionEmpty: 'Chưa có thao tác tệp nào trong phiên này',
changesRead: 'Đọc',
changesMdReading: 'Đọc',
-7
View File
@@ -32,13 +32,6 @@
/** The zh-HK dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const zhHK: Record<string, string> = {
files: '檔案',
nativeGuideDesc: 'Open in the DSH sidebar',
guideDescFiles: '瀏覽工作區檔案樹,點開即可預覽或編輯',
guideDescGit: 'Git 變更與本輪檔案改動,雙視角合一',
guideDescSubagent: '子代理拓撲與背景任務,即時狀態',
guideDescSidechat: '繼承目前工作階段上下文的獨立追問執行緒',
guideDescTerminal: '真實 shell 終端(xterm + pty),斷線重連',
guideDescBrowser: '內嵌網頁瀏覽器,可接管聊天中的外鏈',
changesSessionEmpty: '本工作階段還沒有檔案操作',
changesRead: '讀取',
changesMdReading: '閱讀',
-7
View File
@@ -32,13 +32,6 @@
/** The zh-HK dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const zhMO: Record<string, string> = {
files: '檔案',
nativeGuideDesc: 'Open in the DSH sidebar',
guideDescFiles: '瀏覽工作區檔案樹,點開即可預覽或編輯',
guideDescGit: 'Git 變更與本輪檔案改動,雙視角合一',
guideDescSubagent: '子代理拓撲與背景任務,即時狀態',
guideDescSidechat: '繼承目前工作階段上下文的獨立追問執行緒',
guideDescTerminal: '真實 shell 終端(xterm + pty),斷線重連',
guideDescBrowser: '內嵌網頁瀏覽器,可接管聊天中的外鏈',
changesSessionEmpty: '本工作階段還沒有檔案操作',
changesRead: '讀取',
changesMdReading: '閱讀',
-7
View File
@@ -32,13 +32,6 @@
/** The zh-HK dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const zhTW: Record<string, string> = {
files: '檔案',
nativeGuideDesc: 'Open in the DSH sidebar',
guideDescFiles: '瀏覽工作區檔案樹,點開即可預覽或編輯',
guideDescGit: 'Git 變更與本輪檔案改動,雙視角合一',
guideDescSubagent: '子代理拓撲與背景任務,即時狀態',
guideDescSidechat: '繼承目前工作階段上下文的獨立追問執行緒',
guideDescTerminal: '真實 shell 終端(xterm + pty),斷線重連',
guideDescBrowser: '內嵌網頁瀏覽器,可接管聊天中的外鏈',
changesSessionEmpty: '本作業階段還沒有檔案操作',
changesRead: '讀取',
changesMdReading: '閱讀',
-14
View File
@@ -24,13 +24,6 @@
/** The zh dictionary (also registered into the DSH locale registry under {@link LOCALE_NS}). */
export const zh = {
files: '文件',
nativeGuideDesc: '在 DSH 原生右侧栏打开',
guideDescFiles: '浏览工作区文件树,点开即预览或编辑',
guideDescGit: 'Git 变更与本轮文件改动,双视角合一',
guideDescSubagent: '子代理拓扑与后台任务,实时状态',
guideDescSidechat: '继承当前会话上下文的独立追问线程',
guideDescTerminal: '真实 shell 终端(xterm + pty),断线重连',
guideDescBrowser: '内嵌网页浏览器,可接管聊天里的外链',
changesSessionEmpty: '本会话还没有文件操作',
changesRead: '读取',
changesMdReading: '阅读',
@@ -465,13 +458,6 @@ export const zh = {
/** The en dictionary (key-set-equal to zh, enforced by the type annotation). */
export const en: Record<keyof typeof zh, string> = {
files: 'Files',
nativeGuideDesc: 'Open in the DSH sidebar',
guideDescFiles: 'Browse the workspace tree; click a file to preview or edit',
guideDescGit: 'Git changes and this session\'s file edits, in one tab',
guideDescSubagent: 'Subagent topology and background jobs, live',
guideDescSidechat: 'An independent follow-up thread inheriting this session\'s context',
guideDescTerminal: 'A real shell (xterm + pty) with reconnect',
guideDescBrowser: 'An embedded browser that can take over chat links',
changesSessionEmpty: 'No file operations in this session yet',
changesRead: 'Read',
changesMdReading: 'Reading',
+1 -14
View File
@@ -43,7 +43,7 @@ interface NativeTabRegistry {
priority?: 'extension' | 'builtin' | 'fallback'
canOpen?: (address: string) => boolean
title: (address: string) => string
guide?: readonly { order: number; title: () => string; description: () => string; icon?: unknown }[]
guide?: readonly { order: number; title: () => string; icon?: unknown }[]
}): () => void
}
@@ -63,17 +63,6 @@ function titleOf(descriptor: TabDescriptor): string {
return typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title
}
/**
* The descriptor's guide line, evaluated fresh for the current locale. The
* host's new-tab list renders one line per entry; without a declared
* description every plugin page would read the same generic sentence.
*/
function descriptionOf(descriptor: TabDescriptor | undefined): string {
const description = descriptor?.description
if (description === undefined) return t('nativeGuideDesc')
return typeof description === 'function' ? description() : description
}
/**
* The guide row's glyph for a descriptor icon (nothing when it has none).
* The native guide renders `entry.icon`, so a takeover registered without one
@@ -192,7 +181,6 @@ export function registerNativeSurface(deps: NativeSurfaceDeps): () => void {
guide: [{
order: descriptor.order ?? 100,
title: () => titleOf(descriptor),
description: () => descriptionOf(descriptor),
...guideIconOf(icon),
}],
}),
@@ -219,7 +207,6 @@ export function registerNativeSurface(deps: NativeSurfaceDeps): () => void {
guide: [{
order: 10,
title: () => t('files'),
description: () => descriptionOf(editor),
// The takeover IS the editor descriptor's page, so it carries the
// editor's glyph: without it the "Files" row is the only guide
// entry with an empty icon slot.
+39 -23
View File
@@ -281,33 +281,49 @@ export function NativeTabBody(props: NativeBodyInjected & NativeBodyFrameworkPro
})
useEffect(() => () => { records.drop(nativeTab.id) }, [records, nativeTab.id])
if (descriptor === undefined) {
return createElement(OrphanedTab, { ctx, store, scope, tab: view.tab, visible: nativeTab.visible })
// The orphaned fallback sits in the SAME native host as a live body, so
// it gets the same full-height box (its own root also relies on the
// `flex: 1` contract the column host restores). The host div carries
// `data-dsh-native-tab-host` (empty value) so the e2e lane can assert
// the fill, mirroring `data-dsh-better-sidebar`.
return createElement(
'div',
{ className: css.nativeTabHost, 'data-dsh-native-tab-host': '' },
createElement(OrphanedTab, { ctx, store, scope, tab: view.tab, visible: nativeTab.visible }),
)
}
return createElement(
RenderBoundary,
{ className: css.tabBoundaryError },
createElement(descriptor.component, {
ctx,
store,
scope,
tab: view.tab,
visible: nativeTab.visible,
expanded: view.expanded,
revealed: view.revealed,
onToggleDir: (path: string) => { records.toggleExpanded(nativeTab.id, path) },
onReferenceFile: (path: string, isDir: boolean) => { referenceInChat(ctx, sessionId, cwd, path, isDir) },
onOpenDiff: (tab: SidebarTab) => {
service.openTab({
type: 'diff',
title: tab.title,
id: tab.id,
...(tab.diff === undefined ? {} : { diff: tab.diff }),
}, scope)
},
onSubagentJump: (childSessionId: string) => {
service.openTab({ type: 'subagent', meta: { childSessionId } }, scope)
},
}),
// The full-height host wrapper (see the `.nativeTabHost` rule in
// sidebar.module.css for the native `.paneBody` contract); its
// `data-dsh-native-tab-host` attribute lets the e2e lane assert the fill.
createElement(
'div',
{ className: css.nativeTabHost, 'data-dsh-native-tab-host': '' },
createElement(descriptor.component, {
ctx,
store,
scope,
tab: view.tab,
visible: nativeTab.visible,
expanded: view.expanded,
revealed: view.revealed,
onToggleDir: (path: string) => { records.toggleExpanded(nativeTab.id, path) },
onReferenceFile: (path: string, isDir: boolean) => { referenceInChat(ctx, sessionId, cwd, path, isDir) },
onOpenDiff: (tab: SidebarTab) => {
service.openTab({
type: 'diff',
title: tab.title,
id: tab.id,
...(tab.diff === undefined ? {} : { diff: tab.diff }),
}, scope)
},
onSubagentJump: (childSessionId: string) => {
service.openTab({ type: 'subagent', meta: { childSessionId } }, scope)
},
}),
),
)
}
+36 -24
View File
@@ -5,11 +5,16 @@
* tab's content identity: the same address is the same tab. Two scopes exist:
*
* - `dsh-resource://file/session/<sessionId>/<path>` names a file by its path
* relative to that session's workspace root (`src/a.ts`, no leading `/`);
* relative to that session's workspace root OR by its absolute path kept
* absolute inside the session scope (`src/a.ts` vs `/outside/a.ts` — the
* leading `/` survives; the host resolves it against the root it holds for
* the session);
* - `dsh-resource://file/absolute/<path>` names a file by its absolute path
* with the leading `/` dropped (`absolute/home/me/x.txt`; Windows
* `absolute/C:/x/y.txt`; a UNC path keeps an empty first segment,
* `absolute//server/share/x.txt`). It carries no session.
* `absolute//server/share/x.txt`). It carries no session. `fileAddressFor`
* no longer produces this scope on DSH 0.1.5-alpha.2, but it must keep
* parsing: legacy and third-party addresses still spell it.
*
* Every id and path segment is component-encoded, so a name carrying `#`, `?`
* or a space survives the round trip; `:` stays literal so a drive letter
@@ -18,8 +23,10 @@
* The plugin parses these addresses itself instead of importing
* `@deepseek-ai/dsh-util-workspace-path`: the client bundle's purity gate
* forbids value-importing an unlisted `@deepseek-ai/*` package. This module
* mirrors that package's implementation (`packages/util/workspace-path` in
* DSH 0.1.5) and is pinned by tests/resource-address.spec.ts.
* mirrors that package's implementation — `packages/util/workspace-path/src/file-address.ts`
* and `fileAddressFor` in `packages/util/workspace-path/src/index.ts` in
* DSH 0.1.5-alpha.2 (github.com/deepseek-ai/deepseek-harness, tag
* `dsh-v0.1.5-alpha.2`) — and is pinned by tests/resource-address.spec.ts.
*/
/** The scheme and type every file address opens with. */
@@ -31,9 +38,9 @@ export const FILE_ADDRESS_PREFIX = 'dsh-resource://file/'
export type FileAddress =
| {
readonly scope: 'session'
/** The session whose workspace root the path is relative to. */
/** The session whose workspace root resolves the path. */
readonly sessionId: string
/** Workspace-relative `/`-separated path, no leading `/`; empty for the root itself. */
/** Absolute or workspace-relative `/`-separated path; empty for the workspace root itself. */
readonly path: string
}
| {
@@ -58,19 +65,21 @@ function isDriveSegment(segment: string | undefined): boolean {
}
/**
* Build the address of a file inside one session's workspace.
* @param sessionId - the session whose workspace root the path is relative to.
* @param path - workspace-relative path; backslashes normalize to `/`, a leading `./` or `/` is dropped.
* Build the address of a file read through one session.
* @param sessionId - the session whose workspace root resolves the path.
* @param path - absolute or workspace-relative path; backslashes are normalized
* to `/`, and leading `./` prefixes are dropped (a leading `/` is KEPT: an
* absolute path stays absolute inside the session scope).
* @returns the `dsh-resource://file/session/<sessionId>/<path>` address.
*/
export function sessionFileAddress(sessionId: string, path: string): string {
const relative = path.replace(/\\/g, '/').replace(/^(?:\.\/)+/, '').replace(/^\/+/, '')
return `${FILE_ADDRESS_PREFIX}session/${encodeSegment(sessionId)}/${encodePath(relative)}`
const normalized = path.replace(/\\/g, '/').replace(/^(?:\.\/)+/, '')
return `${FILE_ADDRESS_PREFIX}session/${encodeSegment(sessionId)}/${encodePath(normalized)}`
}
/**
* Build the address of a file by its absolute path.
* @param path - absolute path; backslashes normalize to `/` and the leading `/`
* @param path - absolute path; backslashes are normalized to `/` and the leading `/`
* is dropped, except that a UNC path keeps one empty first segment.
* @returns the `dsh-resource://file/absolute/<path>` address.
*/
@@ -82,16 +91,18 @@ export function absoluteFileAddress(path: string): string {
}
/**
* Read a file address back into its parts.
* Read a file address back into its parts without resolving `.` or `..`.
* Query and fragment suffixes are ignored; encoded path segments are decoded.
* @param address - a candidate address.
* @returns the parts, or `undefined` when the string is not a
* `dsh-resource://file/` URI in a known scope with a valid path.
* `dsh-resource://file/` URI in a known scope with a path, or a segment is
* not validly encoded.
*/
export function parseFileAddress(address: string): FileAddress | undefined {
try {
const url = new URL(address)
if (url.protocol !== 'dsh-resource:' || url.host !== 'file') return undefined
const [, scope, ...rest] = url.pathname.split('/')
if (!address.startsWith(FILE_ADDRESS_PREFIX)) return undefined
const end = address.search(/[?#]/)
const [scope, ...rest] = address.slice(FILE_ADDRESS_PREFIX.length, end === -1 ? undefined : end).split('/')
if (scope === 'session') {
const [id, ...segments] = rest
if (id === undefined || id === '' || segments.length === 0) return undefined
@@ -107,8 +118,7 @@ export function parseFileAddress(address: string): FileAddress | undefined {
}
return undefined
} catch {
// `new URL` throws on a non-URL and `decodeURIComponent` on a malformed
// escape; both mean "not a file address".
// `decodeURIComponent` throws URIError on a malformed escape.
return undefined
}
}
@@ -119,10 +129,12 @@ function isAbsolutePath(path: string): boolean {
}
/**
* The address for a path as a caller holds it: a relative path, or an absolute
* path inside the session's workspace, becomes a `session`-scoped address; an
* absolute path outside it, or one whose workspace root is unknown, becomes an
* `absolute`-scoped address. (Mirror of `fileAddressFor`.)
* The address for a path as a caller holds it: ALWAYS session-scoped. A
* relative path, or an absolute path inside the session's workspace, becomes
* the relative spelling; an absolute path outside it, or one whose workspace
* root is unknown, keeps its absolute path in that session's address. (Mirror
* of `fileAddressFor` — on alpha.2 this helper no longer produces the
* `absolute` scope, though such addresses still parse.)
* @param sessionId - the session the path is read in.
* @param cwd - that session's workspace root, when known.
* @param path - absolute or workspace-relative path, either separator spelling.
@@ -134,5 +146,5 @@ export function fileAddressFor(sessionId: string, cwd: string | undefined, path:
const root = cwd === undefined ? '' : cwd.replace(/\\/g, '/').replace(/\/+$/, '')
if (root !== '' && normalized === root) return sessionFileAddress(sessionId, '')
if (root !== '' && normalized.startsWith(`${root}/`)) return sessionFileAddress(sessionId, normalized.slice(root.length + 1))
return absoluteFileAddress(normalized)
return sessionFileAddress(sessionId, normalized)
}
+1 -9
View File
@@ -162,14 +162,6 @@ export interface TabDescriptor {
/** Unique id; also the `SidebarTab.type` value (`'explorer'`, `'my-plugin:db'`). */
id: string
title: string | (() => string)
/**
* One-line description of what this tab shows, rendered under the title in
* the host's new-tab list (DSH's native right Sidebar guide page). Without
* it the entry falls back to a generic "opens in the DSH sidebar" line, so
* every plugin page reads the same — declare the real purpose instead.
* Evaluated at render time, so a function follows the active locale.
*/
description?: string | (() => string)
icon?: ReactNode | ((size: number) => ReactNode)
/** + menu sort order (ascending); default 100. */
order?: number
@@ -526,7 +518,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.19.0-alpha.0'
export const SIDEBAR_SERVICE_VERSION = '0.19.0-alpha.1'
/**
* Monotonic capability list consumers use to gate new API usage (features
+18
View File
@@ -2004,6 +2004,24 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
color: var(--dsw-alias-state-error-primary);
}
/* ── Native right-Sidebar tab-body host ──────────────────────────────────
DSH's native tab body host (`.paneBody` in dsh-client-ui-dockkit) is a
BLOCK scroller with a definite height, not a flex container — the host's
own tab bodies (TextPreview, FilesBody) declare `height: 100%` on their
root for exactly this reason. Our tab roots declare `flex: 1; min-height:
0`, which collapses to content height in that block host (the side-chat
composer would float mid-pane). A column flex host of `height: 100%`
restores the `flex: 1` semantics our tab roots rely on, and gives
third-party descriptors' components the same box inside the native pane.
The wrapper div carries `data-dsh-native-tab-host` so the e2e lane can
assert the fill (mirroring `data-dsh-better-sidebar`). */
.nativeTabHost {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
/* ── Diff tab (generic states + shell; the changes tab owns the rest in
changes.module.css, and the diff rows themselves live in
diff/diff.module.css) ────────────────────────────────────────────────── */
+16 -11
View File
@@ -3,8 +3,11 @@
* the bottom workbench spans ONLY the app shell's center column ("squeezes
* the agent output area") — it starts at the app sidebar's right edge and
* ends at the details column's left edge. Measured directly from the
* AppFrame's center column DOM (the parent of the [data-slot="conversation"]
* wrapper — layout.css's center column) so the workbench tracks the column's
* AppFrame's center column DOM (center-column.ts anchors on the conversation
* slot host — `main.conversation` on DSH 0.1.5-alpha.2+, `conversation` on
* alpha.1 — and walks up the transparent `display: contents` slot hosts to
* the column; layout.css's center column) so the workbench tracks the
* column's
* real horizontal edges, including the animated track moves while DSH's
* native right Sidebar opens/closes; a frame that never appears keeps the
* initial zero-size fallback (the panel renders at 0 width until measured).
@@ -67,15 +70,17 @@ export function useCenterColumn(
let disposed = false
let observer: ResizeObserver | undefined
// Locate the AppFrame's center column. DSH 0.1.x wraps slot hosts in
// [data-slot] containers: the conversation slot wrapper
// ([data-slot="conversation"]) sits directly inside the center column,
// so its parent IS that column — no hashed-class or positional
// dependency (layout.css uses the same anchor). The shell swaps the
// boot page for the AppFrame only AFTER boot settles, so the first
// query may miss it. Never give up: watch #root's subtree (the swap and
// HMR re-renders mutate it) and re-run this locator — querying once and
// bailing would strand the panel at the zero-size fallback forever
// (observed: a 1px sliver at the viewport's left edge).
// [data-slot] containers: the conversation slot host (key
// `main.conversation` on 0.1.5-alpha.2+, `conversation` on alpha.1)
// sits inside the center column behind transparent `display: contents`
// slot hosts, so the resolver walks up to the first ancestor that
// renders a box — no hashed-class or positional dependency
// (layout.css uses the same anchor). The shell swaps the boot page for
// the AppFrame only AFTER boot settles, so the first query may miss it.
// Never give up: watch #root's subtree (the swap and HMR re-renders
// mutate it) and re-run this locator — querying once and bailing would
// strand the panel at the zero-size fallback forever (observed: a 1px
// sliver at the viewport's left edge).
const locate = (): void => {
if (disposed) return
// Hot path (#403): streaming output mutates #root at token cadence.