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

Move the plugin onto the DSH 0.1.5-alpha.1 baseline (peer floor
^0.1.5-alpha.1, CI pinned to the alpha dist-tag) and fix the two host
contract changes that alpha line introduced.

Baseline:
- all @deepseek-ai/dsh-* peer/dev deps -> 0.1.5-alpha.1; optional peer
  @deepseek-ai/dsh-client-ui-sidebar-right (declared, unused until the
  native-surface phase); devDep @deepseek-ai/dsh-agent-loop (inbox fold)
- dsh.plugin.json engines >=0.1.5-alpha.1; version 0.19.0-alpha.0;
  SIDEBAR_SERVICE_VERSION follows

Live assistant stream (assistant/chunk is gone):
- 0.1.5 publishes in-flight model deltas as process-local
  agent/assistant-stream frames instead of durable assistant/chunk events
  and embeds the settled stream in assistant/message / assistant/attempt.
  src/assistant-live.ts folds the frames into a bounded per-session buffer;
  sidechat.events serves the current attempt as `live` rows (replaced every
  poll, never a delta) and the transcript renders them; the inherited
  in-progress snapshot reads message content / attempt stream / live frames.

Cold session reads (persistence.inspect is gone):
- 0.1.5 reads a stored session through an explicit read handle
  (open(id,'read') -> handle.read() -> close()). src/session-store.ts owns
  that protocol; every cold read (sidechat transcript, cold-resume preset,
  session.cwd, changes replay) goes through it.

Dead code and docs:
- drop the remote.session.openWorkspacePath interception: 0.1.5's chat opens
  files through ctx.sidebarRight.openResource instead, so the wrapper had no
  caller and would hijack the host's "open in app" gesture; the
  interceptOpenPath pref, its settings row and its locale keys go with it
- AGENTS.md §3 + external-plugin-guide.md describe the new baseline
- vitest setup bridges Node's localStorage accessor to jsdom's store (the
  pre-existing 26 failures on Node >= 22)
- desktop-layout e2e seeds through host RPC and types into the Lexical
  composer (0.1.5 replaced the textarea) instead of driving the directory
  picker

Verified: pnpm typecheck / lint / build / test (124 files, 1327 tests) /
check:consumer-types, plus pnpm test:mount against a real
@deepseek-ai/dsh@0.1.5-alpha.1 host (18/18).
This commit is contained in:
Menghuan1918
2026-09-09 11:15:30 +08:00
parent d88dcfc3a5
commit ba0114749f
59 changed files with 1163 additions and 1011 deletions
+140
View File
@@ -0,0 +1,140 @@
/**
* Process-local live assistant stream buffer (DSH 0.1.5+).
*
* 0.1.2 appended a durable `assistant/chunk` session event for every model
* delta, so the side-chat transcript and the inherited in-progress snapshot
* could read streaming text straight out of the session log. 0.1.5 removed
* that event: an in-flight attempt now publishes `agent/assistant-stream`
* frames (start / chunk / end) that are NOT part of the log, and the durable
* record lands only at settlement — `assistant/message` (with the exact
* `stream` embedded) or `assistant/attempt` (a failed attempt that committed
* no message, also with its `stream`).
*
* This module folds those frames into a bounded per-session buffer of
* normalized chunks — the same information the old `assistant/chunk` events
* carried — so the plugin keeps a live transcript and an honest in-progress
* snapshot. The buffer is cleared when an attempt ends, so a settled step
* never duplicates its durable message.
*/
import type { Context } from './context-types.ts'
/** One normalized live delta, keyed by its attempt and dense position. */
export interface AssistantLiveChunk {
/** The attempt these chunks belong to (DSH `LlmAttemptId`). */
readonly attemptId: string
/** The turn the attempt belongs to. */
readonly turn: number
/** The step the attempt belongs to. */
readonly step: number
/** Dense zero-based position within the attempt (DSH's own index). */
readonly index: number
/** Safe-integer timestamp of the frame (reused by the durable stream). */
readonly time: number
/** The raw model stream chunk (`text-delta` / `reasoning-delta` / …). */
readonly chunk: Record<string, unknown>
}
/** The plugin's read face over the live frames of every session. */
export interface AssistantLiveBuffer {
/**
* The live chunks of one session's active attempt, in index order.
* @param sessionId - the session whose attempt is streaming.
* @returns the chunks; empty when nothing is streaming for that session.
*/
chunksFor(sessionId: string): readonly AssistantLiveChunk[]
/** Stop observing the agent stream. */
dispose(): void
}
/** Per-attempt buffer ceiling; beyond it the oldest deltas are dropped. */
export const LIVE_CHUNK_CAP = 4000
/** How many sessions may hold a live buffer at once (the oldest is dropped). */
export const LIVE_SESSION_CAP = 64
/** One session's active attempt as it accumulates. */
interface Attempt {
readonly attemptId: string
readonly turn: number
readonly step: number
readonly chunks: AssistantLiveChunk[]
/** The next dense frame index this attempt accepts (independent of the
* window below, which drops the oldest chunks once the cap is reached). */
nextIndex: number
}
/** A frame's `chunk` payload must be a JSON record to be worth buffering. */
function chunkOf(value: unknown): Record<string, unknown> | undefined {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: undefined
}
/** A finite non-negative integer, or undefined. */
function countOf(value: unknown): number | undefined {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined
}
/**
* Fold `agent/assistant-stream` frames into per-session live buffers.
*
* Frames arrive for every attached agent, so the buffer is keyed by the
* emitting session and ignores anything it cannot identify. An out-of-order
* or mismatched chunk drops the attempt rather than splicing a gap: a
* transcript with a hole is worse than one that settles at the next durable
* event.
* @param ctx - host plugin context (its `on` subscribes the session feed).
* @param cap - per-attempt chunk ceiling.
* @returns the read face and a disposer unbinding the listener.
*/
export function createAssistantLiveBuffer(ctx: Context, cap: number = LIVE_CHUNK_CAP): AssistantLiveBuffer {
const attempts = new Map<string, Attempt>()
const off = ctx.on('agent/assistant-stream', (payload) => {
const record = payload as { agent?: unknown; frame?: unknown } | null
const frame = record?.frame as Record<string, unknown> | undefined
if (frame === undefined) return
const session = (record?.agent as { session?: { id?: unknown } } | undefined)?.session
const sessionId = typeof session?.id === 'string' ? session.id : undefined
if (sessionId === undefined) return
const type = frame['type']
if (type === 'end') {
attempts.delete(sessionId)
return
}
const attemptId = typeof frame['attemptId'] === 'string' ? frame.attemptId : undefined
if (attemptId === undefined) return
if (type === 'start') {
const turn = countOf(frame['turn'])
const step = countOf(frame['step'])
if (turn === undefined || step === undefined) return
const known = attempts.has(sessionId)
attempts.delete(sessionId)
// Only a NEW session may evict the oldest tracked one.
if (!known && attempts.size >= LIVE_SESSION_CAP) {
const oldest = attempts.keys().next().value
if (oldest !== undefined) attempts.delete(oldest)
}
attempts.set(sessionId, { attemptId, turn, step, chunks: [], nextIndex: 0 })
return
}
if (type !== 'chunk') return
const attempt = attempts.get(sessionId)
if (attempt === undefined || attempt.attemptId !== attemptId) return
const index = countOf(frame['index'])
const chunk = chunkOf(frame['chunk'])
// Dense positions only: a gap means the buffer missed frames (a
// replacement attempt, a reconnect) and its text would be wrong.
if (index === undefined || chunk === undefined || index !== attempt.nextIndex) {
attempts.delete(sessionId)
return
}
attempt.nextIndex = index + 1
const time = countOf(frame['time']) ?? 0
attempt.chunks.push({ attemptId, turn: attempt.turn, step: attempt.step, index, time, chunk })
if (attempt.chunks.length > cap) attempt.chunks.splice(0, attempt.chunks.length - cap)
})
return {
chunksFor: sessionId => attempts.get(sessionId)?.chunks ?? [],
dispose: () => { off() },
}
}
-11
View File
@@ -901,17 +901,6 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
<span className={css.suffix}>{t('settingsWidthSuffix')}</span>
</span>
</div>
<div className={css.row}>
<span className={css.rowText}>
<span className={css.title}>{t('settingsOpenPathTitle')}</span>
<span className={css.desc}>{t('settingsOpenPathDesc')}</span>
</span>
<Switch
label={t('settingsOpenPathTitle')}
checked={prefs.interceptOpenPath}
onChange={(next) => { applyPref({ interceptOpenPath: next }) }}
/>
</div>
<div className={css.row}>
<span className={css.rowText}>
<span className={css.title}>{t('settingsOpenToolsTitle')}</span>
+28 -5
View File
@@ -58,6 +58,7 @@ import {
sideThreadRows,
threadHasCompletedTurn,
threadTrailingPending,
type SidechatLiveEvent,
type SidechatThreadInfo,
} from '../sidechat-core.ts'
import {
@@ -112,6 +113,8 @@ const inFlightStarts = new Set<string>()
* the afterSeq delta and never re-download what they already hold). */
interface ThreadCache {
entries: SidebarHistoryEntry[]
/** The CURRENT attempt's live deltas (replaced every pull, never merged). */
live: SidechatLiveEvent[]
}
/** Row-render labels (locale-dependent, memoized once per mount). */
@@ -383,7 +386,7 @@ export function SideChatView(props: {
const [info, setInfo] = useState<SidechatThreadInfo | null>(null)
const [menuOpen, setMenuOpen] = useState(false)
const cacheRef = useRef<ThreadCache>({ entries: [] })
const cacheRef = useRef<ThreadCache>({ entries: [], live: [] })
// The previous poll's rows (see the mapping's reuse pass below).
const prevRowsRef = useRef<SidechatTranscriptRow[]>([])
const controllerRef = useRef<AbortController | null>(null)
@@ -457,14 +460,18 @@ export function SideChatView(props: {
try {
const cache = cacheRef.current
const afterSeq = cache.entries.at(-1)?.event.seq
const { events } = await api.sidechatEvents(childId, afterSeq, controller.signal)
const { events, live } = await api.sidechatEvents(childId, afterSeq, controller.signal)
// The live set is the CURRENT attempt, so it replaces whatever the
// previous pull held; a settled step drops out of the host buffer and
// its durable assistant/message takes over in the mapping.
cache.live = live ?? []
if (events.length > 0) {
// Wire events arrive as parsed JSON; the mirror narrows data to the
// record the mapping reads.
const incoming = events.map(event => ({ event: event as SidebarSessionEvent }))
cache.entries = mergeBySeq(cache.entries, incoming)
setRevision(value => value + 1)
}
setRevision(value => value + 1)
} catch {
// Aborted by a newer pull or a wire failure: keep the last rows.
}
@@ -482,7 +489,7 @@ export function SideChatView(props: {
// Reset the transcript cache whenever the binding changes, then focus
// the composer — it owns the first message of a fresh thread.
useEffect(() => {
cacheRef.current = { entries: [] }
cacheRef.current = { entries: [], live: [] }
prevRowsRef.current = []
controllerRef.current?.abort()
setError(null)
@@ -532,8 +539,24 @@ export function SideChatView(props: {
// The previous poll's rows ride into the mapping so unchanged rows keep
// their object identity (see reuseRows): the 2s poll re-renders only the
// changed tail instead of re-parsing markdown for the whole transcript.
// The live deltas are appended AFTER the durable events (they are the
// in-flight tail), and a delta whose step already settled durably is
// dropped: the durable message is authoritative and would otherwise render
// twice.
const rows = useMemo(() => {
const next = threadId === undefined ? [] : transcriptRows(cacheRef.current.entries, prevRowsRef.current)
const cache = cacheRef.current
const settled = new Set(
cache.entries
.filter(entry => entry.event.type === 'assistant/message')
.map(entry => {
const data = entry.event.data as { turn?: unknown; step?: unknown }
return `${String(data.turn)}:${String(data.step)}`
}),
)
const live = cache.live
.filter(event => !settled.has(`${String(event.data.turn)}:${String(event.data.step)}`))
.map(event => ({ event: event as SidebarSessionEvent }))
const next = threadId === undefined ? [] : transcriptRows([...cache.entries, ...live], prevRowsRef.current)
prevRowsRef.current = next
return next
},
+6 -3
View File
@@ -8,7 +8,7 @@
*/
import { encodeHtmlUrl } from '../html-route.ts'
import type { LastActivity } from '../subagent-activity.ts'
import type { SidechatLogEvent, SidechatThreadInfo } from '../sidechat-core.ts'
import type { SidechatLiveEvent, SidechatLogEvent, SidechatThreadInfo } from '../sidechat-core.ts'
import type { SidebarSessionEvent } from '../context-types.ts'
import type { BrowserProbeResult } from './browser.ts'
@@ -390,9 +390,12 @@ export const api = {
call<SidechatThreadInfo>('sidechat.info', { childId }),
/** One transcript pull of a Side Chat thread: the thread's OWN events
* (the inherited seed is cut host-side and never crosses the wire).
* `afterSeq` narrows the response to the delta beyond it (poll tail). */
* `afterSeq` narrows the response to the delta beyond it (poll tail).
* `live` is the thread's in-flight model deltas (DSH 0.1.5 publishes them
* outside the session log) — the CURRENT attempt on every pull, never a
* delta, so the caller replaces its live set instead of appending. */
sidechatEvents: (childId: string, afterSeq?: number, signal?: AbortSignal) =>
call<{ events: SidechatLogEvent[] }>('sidechat.events', {
call<{ events: SidechatLogEvent[]; live: SidechatLiveEvent[] }>('sidechat.events', {
childId,
...(afterSeq !== undefined ? { afterSeq } : {}),
}, signal),
+1 -13
View File
@@ -17,7 +17,7 @@ import { revalidateChunksOnReactivate, setChunkModuleSystem } from './chunk-load
import { registerBuiltins } from './builtins/index.ts'
import { Sidebar } from './Sidebar.tsx'
import { RenderBoundary } from './RenderBoundary.tsx'
import { registerOpenPathInterception, registerTurnTailInterception } from './intercept.tsx'
import { registerTurnTailInterception } from './intercept.tsx'
import { registerLinkInterception } from './link-intercept.ts'
import { registerImeGuard } from './ime-guard.ts'
import { registerSettingsNavIcon } from './settings-nav-icon.ts'
@@ -342,18 +342,6 @@ export function apply(ctx: Context): void {
'dsh-better-sidebar: turn-tail interception',
)
ctx.effect(
() => {
try {
return registerOpenPathInterception(ctx, sidebarStore)
} catch (error) {
fail('interception', error)
return () => {}
}
},
'dsh-better-sidebar: open-path interception',
)
ctx.effect(
() => {
try {
+1 -46
View File
@@ -11,7 +11,6 @@ import type { Context } from '../context-types.ts'
import { firstLeaf, revealPaths, togglePanel, type SidebarStore } from './state.ts'
import { t } from './locales.ts'
import { resolveSidebarPath, selectProducedFiles } from './produced-files.ts'
import { wrapOpenWorkspacePath, type OpenWorkspacePathService } from './openpath-intercept.ts'
import css from './sidebar.module.css'
/** Open a file in the sidebar's editor (used by the intercepted row and the explorer). */
@@ -25,13 +24,6 @@ export function openSidebarFile(ctx: Context, store: SidebarStore, sessionId: st
ctx.get('betterSidebar')?.openTab({ type: 'editor', title, path: absolute, id: `editor:${absolute}` })
}
/**
* The produced files the turn-tail selector last matched for the visible
* session. The "Show in folder" gesture carries no file path of its own
* (`'.'`), so the reveal highlights exactly these rows when available.
*/
let lastProduced: readonly string[] = []
/**
* Reveal the produced files in the sidebar explorer: expand their parent
* directories, highlight the rows, and focus the explorer tab (expanding the
@@ -137,9 +129,7 @@ export function registerTurnTailInterception(ctx: Context, store: SidebarStore):
select: (owner) => {
if (store.getSuspended()) return null
if (store.getPrefs().tabsEnabled['editor'] === false) return null
const matched = selectProducedFiles(owner)
if (matched !== null) lastProduced = matched
return matched
return selectProducedFiles(owner)
},
priority: -1,
registrant: 'dsh-better-sidebar',
@@ -149,38 +139,3 @@ export function registerTurnTailInterception(ctx: Context, store: SidebarStore):
}),
}, SidebarProducedFiles))
}
/**
* Register the chat file-open interception: shadows
* `remote.session.openWorkspacePath` — the single funnel every chat-side
* file open goes through on alpha hosts (tool-row path links, the
* produced-files row, prose mentions, inline-code paths) — so opens land in
* the sidebar editor instead of the Host OS. The folder-reveal gesture
* ("Show in folder" passes `'.'`) is the one exception: it is routed to the
* explorer. Gated by BOTH the `interceptOpenPath` pref and the editor tab's
* enable switch; declined opens fall through to the original remote call.
*
* The `remote.session` namespace service mounts asynchronously (the gateway
* client creates it when the session-controller contribution arrives) and
* is recreated on contribution remounts, so the wrapper installs through
* `ctx.inject`: the callback runs once the service exists and re-runs after
* every remount, re-applying the shadow on the fresh instance. Returns the
* disposer (disposes the inject fiber, which restores the original method
* descriptor — HMR-safe).
*/
export function registerOpenPathInterception(ctx: Context, store: SidebarStore): () => void {
const fiber = ctx.inject(['remote.session'], (fctx) => {
fctx.effect(() => {
const service = fctx.get('remote.session') as OpenWorkspacePathService
return wrapOpenWorkspacePath(service, {
takeoverEnabled: () => !store.getSuspended()
&& store.getPrefs().interceptOpenPath !== false
&& store.getPrefs().tabsEnabled['editor'] !== false,
currentSessionId: () => ctx.sessions.list.getSnapshot().current,
openInSidebar: (path, sessionId) => { openSidebarFile(ctx, store, sessionId, path) },
revealInExplorer: (_path, sessionId) => { revealInExplorer(ctx, store, sessionId, lastProduced) },
})
}, 'dsh-better-sidebar: open-path interception wrap')
})
return () => { void fiber.dispose() }
}
-2
View File
@@ -238,8 +238,6 @@ export const ar: Record<string, string> = {
settingsWidthTitle: 'الحصة الافتراضية للعرض',
settingsWidthDesc: 'الحصة الافتراضية للبطاقة الجانبية من عرض النافذة للمحادثات الجديدة (20–60)',
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'فتح ملفات المحادثة في الشريط الجانبي',
settingsOpenPathDesc: 'فتح روابط الملفات في المحادثة (صفوف الأدوات، الملفات الناتجة، الإشارات) في محرّر الشريط الجانبي بدلاً من التطبيق الافتراضي للنظام',
settingsOpenToolsTitle: 'حقن أداة فتح الشريط الجانبي للنموذج',
settingsOpenToolsDesc: 'عند التفعيل، يمكن للنموذج فتح الملفات والمجلدات وصفحات HTTP(S) في الشريط الجانبي عبر أداة sidebar_open (معطّل افتراضياً)',
settingsTitleBarTitle: 'وضع توافق الموضع',
-2
View File
@@ -223,8 +223,6 @@ export const de: Record<string, string> = {
settingsWidthTitle: 'Standardbreite',
settingsWidthDesc: 'Standardanteil der Seitenkarte an der Fensterbreite für neue Unterhaltungen (20–60)',
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'Chat-Dateien in der Seitenleiste öffnen',
settingsOpenPathDesc: 'Dateilinks im Chat (Werkzeugzeilen, erstellte Dateien, Erwähnungen) werden im Seitenleisten-Editor geöffnet statt in der System-Standardanwendung',
settingsOpenToolsTitle: 'Seitenleisten-Öffnungswerkzeug für das Modell bereitstellen',
settingsOpenToolsDesc: 'Wenn aktiviert, kann das Modell über das sidebar_open-Werkzeug Dateien, Ordner und HTTP(S)-Seiten in der Seitenleiste öffnen (standardmäßig deaktiviert)',
settingsTitleBarTitle: 'Kompatibilitätsmodus der Position',
-2
View File
@@ -230,8 +230,6 @@ export const fr: Record<string, string> = {
settingsWidthTitle: 'Part de largeur par défaut',
settingsWidthDesc: 'Pourcentage de la largeur de la fenêtre occupé par la carte latérale pour les nouvelles sessions (20–60)',
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'Ouvrir les fichiers de discussion dans la barre latérale',
settingsOpenPathDesc: 'À la place de l’application par défaut du système, ouvrir dans l’éditeur de la barre latérale les liens vers les fichiers du chat (lignes d’outils, listes de produits, mentions de fichiers)',
settingsOpenToolsTitle: 'Injecter l\'outil d\'ouverture latérale pour le modèle',
settingsOpenToolsDesc: 'Une fois activé, le modèle peut ouvrir des fichiers, dossiers et pages HTTP(S) dans la barre latérale via l\'outil sidebar_open (désactivé par défaut)',
settingsTitleBarTitle: 'Mode de compatibilité de position',
-2
View File
@@ -237,8 +237,6 @@ export const hi: Record<string, string> = {
settingsWidthTitle: 'डिफ़ॉल्ट चौड़ाई हिस्सा',
settingsWidthDesc: 'नए वार्तालाप के लिए साइड कार्ड की विंडो चौड़ाई का डिफ़ॉल्ट हिस्सा (20–60)',
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'चैट फ़ाइलें साइडबार में खोलें',
settingsOpenPathDesc: 'चैट में फ़ाइल लिंक (टूल पंक्ति, उत्पादित फ़ाइलें, उल्लेख) क्लिक करने पर सिस्टम डिफ़ॉल्ट ऐप के बजाय साइडबार एडिटर में खोलें',
settingsOpenToolsTitle: 'मॉडल के लिए साइडबार ओपन टूल इंजेक्ट करें',
settingsOpenToolsDesc: 'चालू होने पर, मॉडल sidebar_open टूल से साइडबार में फ़ाइलें, फ़ोल्डर और HTTP(S) पेज खोल सकता है (डिफ़ॉल्ट रूप से बंद)',
settingsTitleBarTitle: 'स्थिति संगतता मोड',
-2
View File
@@ -235,8 +235,6 @@ export const id: Record<string, string> = {
settingsWidthTitle: 'Porsi lebar default',
settingsWidthDesc: 'Porsi lebar jendela default kartu samping untuk obrolan baru (20–60)',
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'Buka berkas obrolan di sidebar',
settingsOpenPathDesc: 'Buka tautan berkas di obrolan (baris alat, berkas yang dihasilkan, mention) di editor sidebar alih-alih aplikasi default sistem',
settingsOpenToolsTitle: 'Suntik alat buka sidebar untuk model',
settingsOpenToolsDesc: 'Saat diaktifkan, model dapat membuka file, folder, dan halaman HTTP(S) di sidebar melalui alat sidebar_open (nonaktif secara default)',
settingsTitleBarTitle: 'Mode kompatibilitas posisi',
-2
View File
@@ -228,8 +228,6 @@ export const it: Record<string, string> = {
settingsWidthTitle: 'Quota di larghezza predefinita',
settingsWidthDesc: 'La quota predefinita della scheda laterale sulla larghezza della finestra per le nuove conversazioni (20–60)',
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'Apri i file della chat nella barra laterale',
settingsOpenPathDesc: 'Apre i collegamenti ai file nella chat (righe di strumenti, file prodotti, menzioni) nell’editor della barra laterale invece dell’app predefinita di sistema',
settingsOpenToolsTitle: 'Iniettare lo strumento di apertura della barra laterale per il modello',
settingsOpenToolsDesc: 'Se attivato, il modello può aprire file, cartelle e pagine HTTP(S) nella barra laterale tramite lo strumento sidebar_open (disattivato per impostazione predefinita)',
settingsTitleBarTitle: 'Modalità di compatibilità della posizione',
-2
View File
@@ -237,8 +237,6 @@ export const ja: Record<string, string> = {
settingsWidthTitle: 'デフォルト幅の比率',
settingsWidthDesc: '新規会話時のサイドカードがウィンドウ幅に占める割合 (20–60)',
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'チャット内ファイルをサイドバーで開く',
settingsOpenPathDesc: 'チャット内のファイルリンク(ツール行、産物リスト、ファイル言及)クリック時に、システム既定アプリではなくサイドバーエディターで開く',
settingsOpenToolsTitle: 'モデルにサイドバー開くツールを注入',
settingsOpenToolsDesc: 'オンにすると、モデルは sidebar_open ツールでサイドバーにファイル・フォルダー・HTTP(S) ページを開ける(デフォルトオフ)',
settingsTitleBarTitle: '位置互換モード',
-2
View File
@@ -229,8 +229,6 @@ export const ko: Record<string, string> = {
settingsWidthTitle: '기본 너비 비율',
settingsWidthDesc: '새 대화에서 사이드 카드가 창 너비에서 차지하는 비율(%) (20–60)',
settingsWidthSuffix: '%',
settingsOpenPathTitle: '채팅 영역 파일을 사이드바에서 열기',
settingsOpenPathDesc: '채팅에서 파일 링크(도구 행, 산출물 목록, 파일 언급)를 클릭하면 사이드바 편집기에서 열고, 시스템 기본 앱은 호출하지 않습니다',
settingsOpenToolsTitle: '모델에 사이드바 열기 도구 주입',
settingsOpenToolsDesc: '켜면 모델이 sidebar_open 도구로 사이드바에서 파일·폴더·HTTP(S) 페이지를 열 수 있습니다(기본 꺼짐)',
settingsTitleBarTitle: '위치 호환 모드',
-2
View File
@@ -235,8 +235,6 @@ export const nl: Record<string, string> = {
settingsWidthTitle: 'Standaard breedte-aandeel',
settingsWidthDesc: 'Het standaard breedte-aandeel van de zijkaart voor nieuwe conversaties (20–60)',
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'Chatbestanden in de zijbalk openen',
settingsOpenPathDesc: 'Bestandslinks in de chat (toolrijen, geproduceerde bestanden, vermeldingen) openen in de zijbalk-editor in plaats van de standaard systeemapp',
settingsOpenToolsTitle: 'Zijbalk-openen-tool voor het model injecteren',
settingsOpenToolsDesc: 'Indien ingeschakeld kan het model bestanden, mappen en HTTP(S)-pagina\'s in de zijbalk openen via de sidebar_open-tool (standaard uit)',
settingsTitleBarTitle: 'Positiecompatibiliteitsmodus',
-2
View File
@@ -239,8 +239,6 @@ export const pl: Record<string, string> = {
settingsWidthTitle: 'Domyślny udział szerokości',
settingsWidthDesc: 'Domyślny udział szerokości okna dla karty bocznej w nowych rozmowach (20–60)',
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'Otwieraj pliki czatu w panelu bocznym',
settingsOpenPathDesc: 'Otwieraj linki plików na czacie (wiersze narzędzi, wyprodukowane pliki, wzmianki) w edytorze panelu bocznego zamiast w domyślnej aplikacji systemu',
settingsOpenToolsTitle: 'Wstrzyknij narzędzie otwierania panelu bocznego dla modelu',
settingsOpenToolsDesc: 'Po włączeniu model może otwierać pliki, foldery i strony HTTP(S) w panelu bocznym za pomocą narzędzia sidebar_open (domyślnie wyłączone)',
settingsTitleBarTitle: 'Tryb zgodności pozycji',
-2
View File
@@ -220,8 +220,6 @@ export const pt: Record<string, string> = {
settingsWidthTitle: 'Participação padrão na largura',
settingsWidthDesc: 'Participação padrão do cartão lateral na largura da janela em novas conversas (20–60)',
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'Abrir arquivos do chat na barra lateral',
settingsOpenPathDesc: 'Abrir links de arquivos no chat (linhas de ferramentas, arquivos produzidos, menções) no editor da barra lateral em vez do aplicativo padrão do sistema',
settingsOpenToolsTitle: 'Injetar a ferramenta de abertura na barra lateral para o modelo',
settingsOpenToolsDesc: 'Quando ativado, o modelo pode abrir arquivos, pastas e páginas HTTP(S) na barra lateral pela ferramenta sidebar_open (desativado por padrão)',
settingsTitleBarTitle: 'Modo de compatibilidade de posição',
-2
View File
@@ -234,8 +234,6 @@ export const ru: Record<string, string> = {
settingsWidthTitle: 'Доля ширины по умолчанию',
settingsWidthDesc: 'Доля боковой карточки в ширине окна для новых сессий, в процентах (20–60)',
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'Открывать файлы чата в боковой панели',
settingsOpenPathDesc: 'При щелчке по ссылке на файл в чате (строки инструментов, списки результатов, упоминания) файл открывается в редакторе боковой панели, а не в системном приложении по умолчанию',
settingsOpenToolsTitle: 'Внедрить инструмент открытия в боковой панели для модели',
settingsOpenToolsDesc: 'Если включено, модель может открывать файлы, папки и HTTP(S)-страницы в боковой панели с помощью инструмента sidebar_open (по умолчанию выключено)',
settingsTitleBarTitle: 'Режим совместимости заголовка',
-2
View File
@@ -220,8 +220,6 @@ export const sv: Record<string, string> = {
settingsWidthTitle: 'Standardbreddandel',
settingsWidthDesc: 'Sidokortets standardandel av fönsterbredden för nya konversationer (20–60)',
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'Öppna chattfiler i sidopanelen',
settingsOpenPathDesc: 'Öppna fillänkar i chatten (verktygsrader, producerade filer, omnämnanden) i sidopanelens editor i stället för systemets standardapp',
settingsOpenToolsTitle: 'Injicera sidopanel-öppningsverktyg för modellen',
settingsOpenToolsDesc: 'När aktiverat kan modellen öppna filer, mappar och HTTP(S)-sidor i sidopanelen via sidebar_open-verktyget (av som standard)',
settingsTitleBarTitle: 'Positions kompatibilitetsläge',
-2
View File
@@ -237,8 +237,6 @@ export const th: Record<string, string> = {
settingsWidthTitle: 'สัดส่วนความกว้างเริ่มต้น',
settingsWidthDesc: 'สัดส่วนความกว้างเริ่มต้นของการ์ดด้านข้างจากความกว้างหน้าต่างสำหรับแชทใหม่ (20–60)',
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'เปิดไฟล์แชทในแถบด้านข้าง',
settingsOpenPathDesc: 'เปิดลิงก์ไฟล์ในแชท (แถวเครื่องมือ, ไฟล์ที่สร้าง, การกล่าวถึง) ในตัวแก้ไขแถบด้านข้างแทนแอปเริ่มต้นของระบบ',
settingsOpenToolsTitle: 'ฉีดเครื่องมือเปิดแถบด้านข้างสำหรับโมเดล',
settingsOpenToolsDesc: 'เมื่อเปิดใช้ โมเดลสามารถเปิดไฟล์ โฟลเดอร์ และหน้า HTTP(S) ในแถบด้านข้างผ่านเครื่องมือ sidebar_open (ปิดเป็นค่าเริ่มต้น)',
settingsTitleBarTitle: 'โหมดความเข้ากันได้ของตำแหน่ง',
-2
View File
@@ -237,8 +237,6 @@ export const tr: Record<string, string> = {
settingsWidthTitle: 'Varsayılan genişlik payı',
settingsWidthDesc: 'Yeni sohbetler için yan kartın pencere genişliğindeki varsayılan payı (20–60)',
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'Sohbet dosyalarını kenar çubuğunda aç',
settingsOpenPathDesc: 'Sohbetteki dosya bağlantılarını (araç satırları, üretilen dosyalar, anılmalar) sistem varsayılan uygulaması yerine kenar çubuğu düzenleyicisinde aç',
settingsOpenToolsTitle: 'Model için kenar çubuğu açma aracı enjekte et',
settingsOpenToolsDesc: 'Etkinleştirildiğinde model, sidebar_open aracıyla kenar çubuğunda dosyaları, klasörleri ve HTTP(S) sayfalarını açabilir (varsayılan kapalı)',
settingsTitleBarTitle: 'Konum uyumluluk modu',
-2
View File
@@ -237,8 +237,6 @@ export const vi: Record<string, string> = {
settingsWidthTitle: 'Tỷ lệ chiều rộng mặc định',
settingsWidthDesc: 'Tỷ lệ phần trăm chiều rộng cửa sổ mà thẻ bên chiếm cho phiên mới (20–60)',
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'Mở tệp chat trong thanh bên',
settingsOpenPathDesc: 'Khi nhấp link tệp trong chat (dòng công cụ, danh sách sản phẩm, nhắc tệp), mở trong trình soạn thảo thanh bên thay vì ứng dụng mặc định hệ thống',
settingsOpenToolsTitle: 'Tiêm công cụ mở thanh bên cho mô hình',
settingsOpenToolsDesc: 'Khi bật, mô hình có thể mở tệp, thư mục và trang HTTP(S) trong thanh bên qua công cụ sidebar_open (mặc định tắt)',
settingsTitleBarTitle: 'Chế độ tương thích thanh tiêu đề',
-2
View File
@@ -252,8 +252,6 @@ export const zhHK: Record<string, string> = {
settingsWidthTitle: '預設寬度佔比',
settingsWidthDesc: '新增工作階段時側邊卡片佔視窗寬度的百分比 (20–60)',
settingsWidthSuffix: '%',
settingsOpenPathTitle: '聊天區檔案在側邊欄開啟',
settingsOpenPathDesc: '在聊天裡點擊檔案連結(工具行、產物列表、檔案提及)時,在側邊欄編輯器中開啟,不再呼叫系統預設應用程式',
settingsOpenToolsTitle: '為模型注入側邊欄開啟工具',
settingsOpenToolsDesc: '開啟後,模型可透過 sidebar_open 工具在側邊欄主動開啟檔案、資料夾和 HTTP(S) 網頁(預設關閉)',
settingsTitleBarTitle: '位置相容模式',
-2
View File
@@ -252,8 +252,6 @@ export const zhMO: Record<string, string> = {
settingsWidthTitle: '預設寬度佔比',
settingsWidthDesc: '新增工作階段時側邊卡片佔視窗寬度的百分比 (20–60)',
settingsWidthSuffix: '%',
settingsOpenPathTitle: '聊天區檔案在側邊欄開啟',
settingsOpenPathDesc: '在聊天裡點擊檔案連結(工具行、產物列表、檔案提及)時,在側邊欄編輯器中開啟,不再呼叫系統預設應用程式',
settingsOpenToolsTitle: '為模型注入側邊欄開啟工具',
settingsOpenToolsDesc: '開啟後,模型可透過 sidebar_open 工具在側邊欄主動開啟檔案、資料夾和 HTTP(S) 網頁(預設關閉)',
settingsTitleBarTitle: '位置相容模式',
-2
View File
@@ -252,8 +252,6 @@ export const zhTW: Record<string, string> = {
settingsWidthTitle: '預設寬度佔比',
settingsWidthDesc: '新增工作階段時側邊卡片佔視窗寬度的百分比 (20–60)',
settingsWidthSuffix: '%',
settingsOpenPathTitle: '聊天區檔案在側邊欄開啟',
settingsOpenPathDesc: '在聊天裡點擊檔案連結(工具行、產物列表、檔案提及)時,在側邊欄編輯器中開啟,不再呼叫系統預設應用程式',
settingsOpenToolsTitle: '為模型注入側邊欄開啟工具',
settingsOpenToolsDesc: '開啟後,模型可透過 sidebar_open 工具在側邊欄主動開啟檔案、資料夾和 HTTP(S) 網頁(預設關閉)',
settingsTitleBarTitle: '位置相容模式',
-4
View File
@@ -249,8 +249,6 @@ export const zh = {
settingsWidthTitle: '默认宽度占比',
settingsWidthDesc: '新建会话时侧边卡片占窗口宽度的百分比 (20–60)',
settingsWidthSuffix: '%',
settingsOpenPathTitle: '聊天区文件在侧边栏打开',
settingsOpenPathDesc: '在聊天里点击文件链接(工具行、产物列表、文件提及)时,在侧边栏编辑器中打开,不再调用系统默认应用',
settingsOpenToolsTitle: '为模型注入侧边栏打开工具',
settingsOpenToolsDesc: '开启后,模型可通过 sidebar_open 工具在侧边栏主动打开文件、文件夹和 HTTP(S) 网页(默认关闭)',
settingsTitleBarTitle: '位置兼容模式',
@@ -699,8 +697,6 @@ export const en: Record<keyof typeof zh, string> = {
settingsWidthTitle: 'Default width share',
settingsWidthDesc: 'The side card\'s default share of the window width for new conversations (20–60)',
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'Open chat files in the sidebar',
settingsOpenPathDesc: 'Open file links in the chat (tool rows, produced files, mentions) in the sidebar editor instead of the system default app',
settingsOpenToolsTitle: 'Inject the sidebar-open tool for the model',
settingsOpenToolsDesc: 'When enabled, the model can actively open files, folders, and HTTP(S) pages in the sidebar through the sidebar_open tool (off by default)',
settingsTitleBarTitle: 'Position compatibility mode',
-144
View File
@@ -1,144 +0,0 @@
/**
* Interception of the chat's file-open funnel (DSH 0.1.2-alpha hosts).
*
* The alpha funnel is the Typert remote namespace `remote.session`: every
* chat-side file open (tool-row path links, the produced-files row, prose
* file mentions, inline-code paths) funnels through ChatView's injected
* `openFile`, which calls `ctx.remote.session.openWorkspacePath({ path })`
* with the path ALREADY resolved against the session cwd (verified against
* the DSH source: `packages/client/ui-chat/src/client/apply.ts` is the only
* production caller). The host side (`api/session-controller`) hands the
* path to the OS's default application (`openNativePath` → xdg-open on
* Linux). The pre-alpha funnel `ctx.workspaces.openPath` is gone — the
* alpha `IWorkspaces` has no openPath at all.
*
* `remote.session` is a cordis service (the gateway client's
* RemoteNamespaceService, key `remote.session`) whose methods are installed
* as ACCESSOR properties (`Object.defineProperty(service, method, {
* configurable: true, get })`) — the getter reads the method table at
* ACCESS time and returns the invocation closure. A plain assignment would
* not stick (no setter), so the wrapper shadows the method with a data
* property and restores the captured accessor descriptor on dispose.
*
* The namespace service appears asynchronously (the session-controller
* contribution mounts after the connection is up) and is disposed/recreated
* on contribution remounts; the caller therefore enters through
* `ctx.inject(['remote.session'], …)`, which re-fires on every remount.
*
* The wrapper is dependency-free by design (no React / ui-primitives), so
* the takeover logic is unit-testable and the file stays importable from
* the test runtime.
*/
/** The one request shape the funnel method takes (mirror of the host's
* SessionOpenWorkspacePathRequest). */
export interface OpenWorkspacePathRequest {
/** Absolute path (the chat caller already resolved it against the cwd). */
path: string
}
/**
* What the funnel method resolves to (mirror of the typert `RemoteResult`
* envelope): EVERY remote method folds its business value into
* `{ ok: true, value }` / `{ ok: false, error }` — the chat caller branches
* on `result.ok`, so a bare business value would be misread as a failure
* (`result.error.message` reads `message` off undefined).
*/
export type OpenWorkspacePathResult =
| { readonly ok: true; readonly value: { opened: boolean } }
| { readonly ok: false; readonly error: { readonly code: string; readonly message: string; readonly details: object } }
/** The namespace slice the wrapper shadows (mirror of the gateway client's
* RemoteNamespaceService for the `session` namespace). */
export interface OpenWorkspacePathService {
openWorkspacePath(request: OpenWorkspacePathRequest, signal?: AbortSignal): Promise<OpenWorkspacePathResult>
}
/** Per-call decisions the wrapper needs (wired to the store + ctx in the client half). */
export interface OpenPathInterceptDeps {
/**
* Whether to take over this call: the `interceptOpenPath` pref AND the
* editor tab's own enable switch must both be on (an editor that cannot
* open must not swallow opens — they fall through to the Host).
*/
takeoverEnabled(): boolean
/** The session whose scope the sidebar editor loads the file in (current session). */
currentSessionId(): string | undefined
/** Route the open into the sidebar editor (the established openSidebarFile). */
openInSidebar(path: string, sessionId: string): void
/** Route a folder-reveal gesture ("Show in folder" passes '.') into the sidebar explorer. */
revealInExplorer(path: string, sessionId: string): void
}
/**
* Whether a path is the "Show in folder" folder-reveal gesture. The stock
* ui-deliverables row passes `'.'` (the session workspace root, resolved by
* the chat view to `"<cwd>/."`); any path whose final segment is `.` is the
* same gesture. A directory has no editor content, so these opens must reach
* the explorer instead of an editor tab.
*/
export function isFolderRevealPath(path: string): boolean {
if (path === '.' || path === './') return true
const trimmed = path.replace(/[\\/]+$/, '')
return trimmed === '.' || /[\\/]\.$/.test(trimmed)
}
/**
* Shadow `remote.session.openWorkspacePath`: intercepted calls open the file
* in the sidebar editor and resolve with the remote SUCCESS ENVELOPE
* (`{ ok: true, value: { opened: true } }`, so ChatView shows no error
* dialog); anything that declines falls through to the captured original
* closure (the host OS's default application) untouched.
* The one exception is the folder-reveal gesture, which is routed to
* {@link OpenPathInterceptDeps.revealInExplorer} instead.
*
* The original closure is captured by ACCESSING the accessor once at wrap
* time — it invokes whatever method records are mounted at that moment. If
* the contribution remounts its methods while the shadow is installed, the
* captured closure points at the old records; the session namespace is
* effectively permanent in practice, so this is accepted and the shadow is
* re-applied anyway when the remount recreates the service (the caller's
* `ctx.inject` re-fires).
*
* @param service - the `remote.session` namespace service.
* @param deps - per-call takeover decisions.
* @returns the disposer restoring the original accessor descriptor (HMR-safe).
*/
export function wrapOpenWorkspacePath(
service: OpenWorkspacePathService,
deps: OpenPathInterceptDeps,
): () => void {
const KEY = 'openWorkspacePath'
const target = service as object
const descriptor = Object.getOwnPropertyDescriptor(target, KEY)
// Invoke the accessor once to capture the CURRENT invocation closure.
const original = service.openWorkspacePath
// The gateway installs the namespace's whole method group in the same
// synchronous window as the service registration, so an inject-observed
// service always has the method — but a hand-rolled composition might not;
// wrapping nothing would turn fall-throughs into crashes, so decline.
if (typeof original !== 'function') return () => {}
const wrapped = (request: OpenWorkspacePathRequest, signal?: AbortSignal): Promise<OpenWorkspacePathResult> => {
if (deps.takeoverEnabled()) {
const sessionId = deps.currentSessionId()
if (sessionId !== undefined) {
if (isFolderRevealPath(request.path)) deps.revealInExplorer(request.path, sessionId)
else deps.openInSidebar(request.path, sessionId)
return Promise.resolve({ ok: true, value: { opened: true } })
}
}
return original.call(service, request, signal)
}
Object.defineProperty(target, KEY, {
configurable: true,
enumerable: true,
writable: true,
value: wrapped,
})
return () => {
// Restore the exact original property (the gateway's accessor) so a
// chain of wrappers keeps working across disposals in any order.
if (descriptor !== undefined) Object.defineProperty(target, KEY, descriptor)
else Reflect.deleteProperty(target, KEY)
}
}
-3
View File
@@ -76,9 +76,6 @@ export function parsePrefs(value: unknown): SidebarPrefs {
terminalFontSize: typeof record.terminalFontSize === 'number' && Number.isFinite(record.terminalFontSize)
? clampTerminalFontSize(record.terminalFontSize)
: SIDEBAR_PREFS_DEFAULTS.terminalFontSize,
interceptOpenPath: typeof record.interceptOpenPath === 'boolean'
? record.interceptOpenPath
: SIDEBAR_PREFS_DEFAULTS.interceptOpenPath,
editorExplorer: typeof record.editorExplorer === 'boolean'
? record.editorExplorer
: SIDEBAR_PREFS_DEFAULTS.editorExplorer,
+1 -1
View File
@@ -465,7 +465,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.18.1'
export const SIDEBAR_SERVICE_VERSION = '0.19.0-alpha.0'
/**
* Monotonic capability list consumers use to gate new API usage (features
+8 -6
View File
@@ -13,9 +13,10 @@
* shows only the thread's own conversation.
*
* Live streaming: `assistant/message` events only land when a step
* completes, but `assistant/chunk` events stream token-level text and
* reasoning deltas. The mapping accumulates both per block and supersedes
* them with the assembled message once it lands (settled rows).
* completes, but the host's `assistant/live-chunk` rows stream token-level
* text and reasoning deltas (DSH 0.1.5 publishes them outside the session
* log — see assistant-live.ts). The mapping accumulates both per block and
* supersedes them with the assembled message once it lands (settled rows).
*/
import type { DiffHunk, ReadBlockLine } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SidebarHistoryEntry } from '../context-types.ts'
@@ -299,12 +300,13 @@ function resultCard(
/**
* Map a thread child's history rows onto compact transcript rows: the
* inherited fork seed is cut at the last `session/end-seed`, context
* injections map onto a collapsible injection row, `assistant/chunk`
* injections map onto a collapsible injection row, `assistant/live-chunk`
* deltas accumulate into streaming rows per (turn, step, block) and are
* superseded by the assembled `assistant/message`, and tool invocations
* render one expandable line each (arguments, paired result text, failure
* marker; a still-executing call is marked until its result lands).
* @param entries - history rows (event + host-computed view) in seq order.
* @param entries - history rows (event + host-computed view) in seq order,
* the live deltas of the in-flight attempt appended after the durable tail.
* @returns display rows in log order.
*/
export function transcriptRows(entries: readonly SidebarHistoryEntry[], prev?: readonly SidechatTranscriptRow[]): SidechatTranscriptRow[] {
@@ -374,7 +376,7 @@ export function transcriptRows(entries: readonly SidebarHistoryEntry[], prev?: r
rows.push({ kind: 'user', seq: event.seq, text })
break
}
case 'assistant/chunk': {
case 'assistant/live-chunk': {
const chunk = data.chunk as { type?: unknown; text?: unknown } | undefined
if (chunk === null || typeof chunk !== 'object') break
const kind = chunk.type === 'text-delta' ? 'assistant' : chunk.type === 'reasoning-delta' ? 'reasoning' : null
-1
View File
@@ -124,7 +124,6 @@ export const PrefsSchema: z<SidebarPrefs> = z.object({
bottomPanelAutoTerminal: z.boolean().default(true),
terminalFontFamily: z.string().default(''),
terminalFontSize: z.number().step(1).min(TERMINAL_FONT_SIZE_MIN).max(TERMINAL_FONT_SIZE_MAX).default(TERMINAL_FONT_SIZE_DEFAULT),
interceptOpenPath: z.boolean().default(true),
editorExplorer: z.boolean().default(false),
changesDiffFloat: z.boolean().default(true),
workspaceFence: z.boolean().default(true),
+34 -31
View File
@@ -296,14 +296,34 @@ export interface SidebarSessionTitleService {
rename(session: unknown, title: string): { title: string; eventSeq: number }
}
/** The host session-persistence face (mirror of the sessionPersistence
* service): detached inspection of a persisted session, used to compose the
* recorded preset when a Side Chat thread cold-resumes. */
/**
* The host session-persistence face (mirror of the `sessionPersistence`
* service): durable, handle-addressed session storage.
*
* DSH 0.1.5 replaced the detached `inspect(id)` call with an explicit read
* handle: `open(id, 'read')` never takes write ownership and works while
* another process owns the session, `handle.read()` returns one contiguous
* slice of the log, and `close()` releases it. Every cold read in this plugin
* goes through {@link readPersistedSession} so the handle is always closed.
*/
export interface SidebarSessionPersistenceService {
inspect(sessionId: string): Promise<{
meta: { cwd?: string; agentPreset?: string }
events: readonly SidebarSessionEvent[]
}>
open(sessionId: string, access: 'read' | 'write'): Promise<SidebarSessionHandle>
}
/** One open channel onto a stored session (the fields this plugin reads). */
export interface SidebarSessionHandle {
/** Immutable stored header (cwd / agentPreset live here). */
readonly header: { cwd?: string; agentPreset?: string } & Record<string, unknown>
/** Exact fork-inherited prefix length stored with the log. */
readonly inheritedEventCount?: number
/**
* Read a slice of the valid contiguous log.
* @param offset - first logical seq to include (defaults to 0).
* @param length - maximum events (defaults to the rest of the log).
*/
read(offset?: number, length?: number): Promise<{ events: readonly SidebarSessionEvent[] }>
/** Release the handle (idempotent). */
close(): Promise<void>
}
/** The client session list snapshot the sidebar subscribes to. */
@@ -408,30 +428,6 @@ export interface SidebarConversation {
}
}
/**
* The client `remote.session` namespace face (mirror of the gateway client's
* RemoteNamespaceService for the session-controller contribution on alpha
* hosts). The chat's file-open funnel is `openWorkspacePath`: the caller
* resolves the path against the session cwd, and the host hands it to the
* OS's default application. Namespace methods are accessor properties (see
* `client/openpath-intercept.ts` for how the interception shadows them).
*/
export interface SidebarRemoteSessionService {
/**
* Open an absolute path with the Host operating system's default
* application. Resolves with the typert `RemoteResult` envelope
* (`{ ok: true, value: { opened } }` / `{ ok: false, error }`), like every
* remote method — callers branch on `result.ok`.
*/
openWorkspacePath(
request: { path: string },
signal?: AbortSignal,
): Promise<
| { readonly ok: true; readonly value: { opened: boolean } }
| { readonly ok: false; readonly error: { readonly code: string; readonly message: string; readonly details: object } }
>
}
/**
* The invariant service face (mirror of @deepseek-ai/dsh-invariants'
* InvariantRegistry). The upstream augmentation does not reach this Context
@@ -564,6 +560,13 @@ export interface SidebarContextShape {
* LIVE Session instance that appended it.
*/
on(event: string, listener: (session: unknown, event: SidebarSessionEvent) => void): () => void
/**
* The agent's process-local assistant stream (DSH 0.1.5+): one payload per
* `start` / `chunk` / `end` frame, carrying the emitting agent and the
* frame. These frames are NOT session events — see
* {@link ./assistant-live.ts} for why the plugin needs them.
*/
on(event: 'agent/assistant-stream', listener: (payload: { agent?: unknown; frame?: unknown }) => void): () => void
}
/**
+15 -5
View File
@@ -54,7 +54,9 @@ import { AgentOpenRegistry, registerOpenTool, type AgentOpenRequest } from './ag
import { buildJobsApi, type SidebarJobsRoutes } from './jobs-routes.ts'
import { buildSubagentLiveApi, type SidebarSubagentLiveRoutes } from './subagent-live-route.ts'
import { buildSidechatApi } from './sidechat-routes.ts'
import { createAssistantLiveBuffer, type AssistantLiveBuffer } from './assistant-live.ts'
import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts'
import { readPersistedSession } from './session-store.ts'
export { Config }
export type { SidebarConfig, ResolvedSidebarConfig }
@@ -129,8 +131,8 @@ async function sessionCwdOf(ctx: Context, sessionId: string, clientCwd?: string)
}
const persistence = ctx.get('sessionPersistence')
if (persistence !== undefined) {
const inspected = await persistence.inspect(sessionId)
const metaCwd = inspected.meta.cwd
const persisted = await readPersistedSession(persistence, sessionId)
const metaCwd = persisted.header.cwd
if (metaCwd !== undefined && metaCwd !== '') {
try {
return requireAbsolute(metaCwd)
@@ -298,6 +300,7 @@ function buildApi(
resolved: ResolvedSidebarConfig,
terminalShell: string,
getSettings: () => SidebarSettingsFace | undefined,
assistantLive: AssistantLiveBuffer,
): Record<string, ApiMethod> {
const cwdOf = async (payload: unknown): Promise<{ sessionId: string; cwd: string }> => {
const sessionId = requireString(payload, 'sessionId')
@@ -507,7 +510,7 @@ function buildApi(
const persistence = ctx.get('sessionPersistence')
if (persistence !== undefined) {
try {
events = (await persistence.inspect(sessionId)).events
events = (await readPersistedSession(persistence, sessionId)).events
} catch {
// Cold read unavailable (session never persisted): an empty
// window is the honest answer, not a wire error.
@@ -694,7 +697,7 @@ function buildApi(
// identities are fenced from the generic session RPCs (agent-lookup
// ownership), and the thread is created with a CUSTOM seed the stock
// fork APIs cannot express.
...buildSidechatApi(ctx),
...buildSidechatApi(ctx, assistantLive),
}
}
@@ -859,7 +862,14 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
})
// ── JSON API ────────────────────────────────────────────────────────────
const api = buildApi(ctx, ptyManager, agentPtyRegistry, resolved, terminalShell, () => settingsFace)
// The live assistant stream buffer: DSH 0.1.5 publishes in-flight model
// deltas as process-local `agent/assistant-stream` frames instead of the
// durable `assistant/chunk` events 0.1.2 logged, so the side-chat
// transcript and the inherited in-progress snapshot read them here. The
// effect releases the listener on fiber disposal.
const assistantLive = createAssistantLiveBuffer(ctx)
ctx.effect(() => () => { assistantLive.dispose() }, 'dsh-better-sidebar: live assistant stream buffer')
const api = buildApi(ctx, ptyManager, agentPtyRegistry, resolved, terminalShell, () => settingsFace, assistantLive)
ctx.effect(() => ctx.webServer.register({
kind: 'prefix',
path: '/sidebar/api',
-9
View File
@@ -63,14 +63,6 @@ export interface SidebarPrefs {
* row in the Side card settings.
*/
bottomPanelAutoTerminal: boolean
/**
* Whether chat-side file opens (tool-row path links, the produced-files
* row, prose file mentions — every path that funnels through the client
* runtime's `remote.session.openWorkspacePath`) open in the sidebar editor
* instead of the Host OS's default application. On by default; the editor
* tab's own enable switch gates it too (both must be on for the takeover).
*/
interceptOpenPath: boolean
/**
* Whether the editor tab runs in merged mode: a path input replaces the
* plain header and a toggleable file-tree panel (with a global name
@@ -276,7 +268,6 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
bottomPanelAutoTerminal: true,
terminalFontFamily: '',
terminalFontSize: TERMINAL_FONT_SIZE_DEFAULT,
interceptOpenPath: true,
editorExplorer: false,
changesDiffFloat: true,
workspaceFence: true,
+65
View File
@@ -0,0 +1,65 @@
/**
* Cold-session reads over the host `sessionPersistence` service.
*
* DSH 0.1.5 removed the detached `inspect(id)` call: a persisted session is
* read through an explicit handle — `open(id, 'read')` never takes write
* ownership (so it works while the session is live in another process),
* `handle.read()` returns one contiguous slice of the log, and `close()`
* releases it. Every cold read in this plugin goes through
* {@link readPersistedSession} so the handle is always released, even when
* the read throws.
*/
import type { Context, SidebarSessionEvent, SidebarSessionPersistenceService } from './context-types.ts'
/** One persisted session as this plugin reads it. */
export interface PersistedSession {
/** Immutable stored header (cwd / agentPreset live here). */
readonly header: { cwd?: string; agentPreset?: string } & Record<string, unknown>
/** The full stored event log. */
readonly events: readonly SidebarSessionEvent[]
/** Exact fork-inherited prefix length (0 when the header is not seeded). */
readonly inheritedEventCount: number
}
/**
* Read one persisted session's header and full event log.
* @param persistence - the live `sessionPersistence` service.
* @param sessionId - the stored session to read.
* @returns the header and log; rejects when the session does not exist.
*/
export async function readPersistedSession(
persistence: SidebarSessionPersistenceService,
sessionId: string,
): Promise<PersistedSession> {
const handle = await persistence.open(sessionId, 'read')
try {
const { events } = await handle.read()
return {
header: handle.header,
events,
inheritedEventCount: handle.inheritedEventCount ?? 0,
}
} finally {
await handle.close()
}
}
/**
* Resolve the service and read one persisted session, or `undefined` when
* the service is absent or the session cannot be read.
* @param ctx - host plugin context.
* @param sessionId - the stored session to read.
* @returns the persisted session, or `undefined`.
*/
export async function readPersistedSessionOf(
ctx: Context,
sessionId: string,
): Promise<PersistedSession | undefined> {
const persistence = ctx.get('sessionPersistence')
if (persistence === undefined) return undefined
try {
return await readPersistedSession(persistence, sessionId)
} catch {
return undefined
}
}
+143 -12
View File
@@ -17,6 +17,7 @@
* snapshot inside the boundary prompt.
*/
import type { SidebarHistoryEntry, SidebarSessionSummary } from './context-types.ts'
import type { AssistantLiveChunk } from './assistant-live.ts'
/** The durable thread-label prefix (also the row filter in the client list). */
export const SIDE_LABEL_PREFIX = 'Side: '
@@ -81,6 +82,55 @@ export interface SidechatLogEvent {
data: unknown
}
/**
* One live assistant delta on the plugin's wire, mirroring DSH 0.1.5's
* client-only `assistant/live-chunk` presentation row.
*
* DSH 0.1.5 no longer logs `assistant/chunk`: an in-flight attempt's deltas
* are process-local frames (`agent/assistant-stream`, folded by
* {@link ./assistant-live.ts}) and reach the transcript through the plugin's
* own `sidechat.events` route as these rows. They are NOT durable — the
* route returns the current attempt's rows on every poll and the client
* replaces its live set each time; the durable `assistant/message` settles
* them by `turn:step`.
*/
export interface SidechatLiveEvent {
type: 'assistant/live-chunk'
/** Ordering key among live rows only; durable seqs stay authoritative. */
seq: number
time: number
data: {
attemptId: string
turn: number
step: number
/** Dense zero-based position within the attempt. */
index: number
/** The raw model stream chunk. */
chunk: Record<string, unknown>
}
}
/**
* Project buffered live chunks into wire rows.
* @param chunks - the session's active-attempt chunks, in index order.
* @param tailSeq - the session's last durable seq (live rows order after it).
* @returns the rows to append to the transcript feed.
*/
export function liveEventsOf(chunks: readonly AssistantLiveChunk[], tailSeq: number): SidechatLiveEvent[] {
return chunks.map((delta, position) => ({
type: 'assistant/live-chunk',
seq: tailSeq + 1 + position,
time: delta.time,
data: {
attemptId: delta.attemptId,
turn: delta.turn,
step: delta.step,
index: delta.index,
chunk: delta.chunk,
},
}))
}
/** The result of cutting a parent log into a side-thread inheritance. */
export interface SidechatInheritance {
/** The child seed: contiguous from seq 0, ends outside any open turn. */
@@ -210,8 +260,14 @@ const SNAPSHOT_TOTAL_CAP = 8000
/**
* Build the side-thread inheritance for one parent log: the full event log
* up to the click moment, honestly closed when it ends inside an open turn.
* @param events - the parent's log (live or persisted).
* @param live - the parent's in-flight stream chunks (DSH 0.1.5+ publishes
* them outside the log); used only by the snapshot fallback.
*/
export function buildSidechatInheritance(events: readonly SidechatLogEvent[]): SidechatInheritance {
export function buildSidechatInheritance(
events: readonly SidechatLogEvent[],
live: readonly AssistantLiveChunk[] = [],
): SidechatInheritance {
if (events.length === 0) return { seed: [], snapshot: null }
const boundary = lastTurnBoundary(events)
if (boundary < 0 || events[boundary]?.type === 'turn/end') {
@@ -225,7 +281,7 @@ export function buildSidechatInheritance(events: readonly SidechatLogEvent[]): S
// the structured snapshot to the boundary prompt instead.
return {
seed: copyEvents(events.slice(0, boundary)),
snapshot: buildOpenTurnSnapshot(events),
snapshot: buildOpenTurnSnapshot(events, live),
}
}
const seed = copyEvents(events)
@@ -245,16 +301,79 @@ export function buildSidechatInheritance(events: readonly SidechatLogEvent[]): S
return { seed, snapshot: null }
}
/** One assistant content block reduced to the text the snapshot shows. */
function messageTexts(message: unknown): { text: string; reasoning: string } {
const content = (message as { content?: unknown } | undefined)?.content
let text = ''
let reasoning = ''
if (!Array.isArray(content)) return { text, reasoning }
for (const block of content) {
if (block === null || typeof block !== 'object') continue
const candidate = block as { type?: unknown; text?: unknown }
if (typeof candidate.text !== 'string' || candidate.text === '') continue
if (candidate.type === 'text') text += candidate.text
else if (candidate.type === 'reasoning') reasoning += candidate.text
}
return { text, reasoning }
}
/**
* Expand one attempt's compact `AssistantStreamRecord[]` (the durable stream
* DSH 0.1.5 embeds in `assistant/message.stream` and `assistant/attempt.stream`)
* into the text/reasoning it carried. Tool-call records contribute no text.
*/
function streamTexts(stream: unknown): { text: string; reasoning: string } {
let text = ''
let reasoning = ''
if (!Array.isArray(stream)) return { text, reasoning }
for (const record of stream) {
if (record === null || typeof record !== 'object') continue
const entry = record as { type?: unknown; texts?: unknown; chunk?: unknown }
if (entry.type === 'text-chunks' || entry.type === 'reasoning-chunks') {
if (!Array.isArray(entry.texts)) continue
const joined = entry.texts.filter((part): part is string => typeof part === 'string').join('')
if (entry.type === 'text-chunks') text += joined
else reasoning += joined
continue
}
if (entry.type === 'chunk') {
const chunk = entry.chunk as { type?: unknown; text?: unknown } | undefined
if (chunk === null || typeof chunk !== 'object' || typeof chunk.text !== 'string') continue
if (chunk.type === 'text-delta') text += chunk.text
else if (chunk.type === 'reasoning-delta') reasoning += chunk.text
}
}
return { text, reasoning }
}
/** One live delta's contribution to the snapshot. */
function liveTexts(chunk: Record<string, unknown>): { text: string; reasoning: string } {
if (typeof chunk.text !== 'string' || chunk.text === '') return { text: '', reasoning: '' }
if (chunk.type === 'text-delta') return { text: chunk.text, reasoning: '' }
if (chunk.type === 'reasoning-delta') return { text: '', reasoning: chunk.text }
return { text: '', reasoning: '' }
}
/**
* Structured text snapshot of the parent's OPEN turn (from its `turn/start`
* to the log tail): the accumulated assistant/reasoning output verbatim
* (code blocks ride the raw deltas) and the tool activity — executed tools
* with their result text, the still-executing one marked. Returns null when
* there is no open turn or nothing to show.
* to the log tail): the assistant/reasoning output so far and the tool
* activity — executed tools with their result text, the still-executing one
* marked. Returns null when there is no open turn or nothing to show.
*
* The in-flight step's text is NOT in the log on DSH 0.1.5 (the model stream
* is process-local until it settles), so it comes from `live`; settled steps
* read their durable `assistant/message` content, and a failed attempt reads
* its embedded `assistant/attempt.stream`.
* @param events - the parent's log.
* @param live - the parent's in-flight stream chunks, in index order.
*/
export function buildOpenTurnSnapshot(events: readonly SidechatLogEvent[]): string | null {
export function buildOpenTurnSnapshot(
events: readonly SidechatLogEvent[],
live: readonly AssistantLiveChunk[] = [],
): string | null {
const boundary = lastTurnBoundary(events)
if (boundary < 0 || events[boundary]?.type !== 'turn/start') return null
const openTurn = numberAt(dataOf(events[boundary]!), 'turn')
let text = ''
let reasoning = ''
const tools: string[] = []
@@ -267,11 +386,16 @@ export function buildOpenTurnSnapshot(events: readonly SidechatLogEvent[]): stri
pendingCalls.clear()
continue
}
if (event.type === 'assistant/chunk') {
const chunk = data.chunk as { type?: unknown; text?: unknown } | undefined
if (chunk === null || typeof chunk !== 'object') continue
if (chunk.type === 'text-delta' && typeof chunk.text === 'string') text += chunk.text
else if (chunk.type === 'reasoning-delta' && typeof chunk.text === 'string') reasoning += chunk.text
if (event.type === 'assistant/message') {
const settled = messageTexts(data.message)
text += settled.text
reasoning += settled.reasoning
continue
}
if (event.type === 'assistant/attempt') {
const attempt = streamTexts(data.stream)
text += attempt.text
reasoning += attempt.reasoning
continue
}
if (event.type === 'tool/call') {
@@ -304,6 +428,13 @@ export function buildOpenTurnSnapshot(events: readonly SidechatLogEvent[]): stri
const line = `- \`${call.name}\` (executing) — arguments: \`${call.args}\``
tools.push(line)
}
// The in-flight step: its deltas never reached the log.
for (const delta of live) {
if (delta.turn !== openTurn) continue
const contribution = liveTexts(delta.chunk)
text += contribution.text
reasoning += contribution.reasoning
}
const sections: string[] = []
if (text.trim() !== '') sections.push(`Assistant output so far:\n\n${text}`)
if (reasoning.trim() !== '') sections.push(`Reasoning so far:\n\n${reasoning}`)
+26 -10
View File
@@ -37,17 +37,21 @@ import type {
import {
boundaryDelivered,
buildSidechatInheritance,
liveEventsOf,
resolvePresetId,
SIDE_BOUNDARY_PROMPT,
SIDE_INJECTION_PLUGIN,
SIDE_NEW_THREAD_TITLE,
sideLabel,
type SeedEvent,
type SidechatLiveEvent,
type SidechatLogEvent,
type SidechatThreadInfo,
threadOwnLogEvents,
} from './sidechat-core.ts'
import type { AssistantLiveBuffer } from './assistant-live.ts'
import { requireString, SidebarError } from './wire.ts'
import { readPersistedSession } from './session-store.ts'
/** The six Side Chat routes of the sidebar API (wire method names). */
export interface SidechatRoutes {
@@ -66,8 +70,10 @@ export interface SidechatRoutes {
'sidechat.info'(payload: unknown): Promise<SidechatThreadInfo>
/** The thread's OWN transcript events, seed-cut host-side (the inherited
* parent log never crosses the wire); `afterSeq` narrows the response to
* the delta beyond it (poll tail). */
'sidechat.events'(payload: unknown): Promise<{ events: SidechatLogEvent[] }>
* the delta beyond it (poll tail). `live` carries the thread's in-flight
* model deltas, which DSH 0.1.5 publishes outside the session log — it is
* the CURRENT attempt's rows on every poll, never a delta. */
'sidechat.events'(payload: unknown): Promise<{ events: SidechatLogEvent[]; live: SidechatLiveEvent[] }>
}
/** Timeout guarding the create call (the registry detaches it before the
@@ -117,8 +123,8 @@ async function composePersistedSetup(
if (persistence === undefined) {
return () => Promise.resolve()
}
const inspected = await persistence.inspect(childId)
const presetId = resolvePresetId(inspected.meta, inspected.events)
const inspected = await readPersistedSession(persistence, childId)
const presetId = resolvePresetId(inspected.header, inspected.events)
const presets = ctx.get('agentPresets') as SidebarAgentPresetsService | undefined
if (presets === undefined || presetId === undefined) {
return () => Promise.resolve()
@@ -186,7 +192,7 @@ async function threadLogEvents(ctx: Context, childId: string): Promise<readonly
throw new SidebarError('sidechat-error', 'the session persistence service is unavailable', 503)
}
try {
const inspected = await persistence.inspect(childId)
const inspected = await readPersistedSession(persistence, childId)
return inspected.events as unknown as readonly SidechatLogEvent[]
} catch (error: unknown) {
throw new SidebarError(
@@ -199,8 +205,11 @@ async function threadLogEvents(ctx: Context, childId: string): Promise<readonly
/** Build the Side Chat routes (all optional services degrade to a wire
* error the tab surfaces inline). The record keys are the FULL wire method
* names the /sidebar/api dispatcher looks up (`api[method]`). */
export function buildSidechatApi(ctx: Context): SidechatRoutes {
* names the /sidebar/api dispatcher looks up (`api[method]`).
* @param ctx - host plugin context.
* @param live - the live assistant stream buffer; absent only in tests that
* never exercise streaming (then every `live` response is empty). */
export function buildSidechatApi(ctx: Context, live?: AssistantLiveBuffer): SidechatRoutes {
return {
'sidechat.start': async (payload: unknown) => {
const sessionId = requireString(payload, 'sessionId')
@@ -213,6 +222,7 @@ export function buildSidechatApi(ctx: Context): SidechatRoutes {
const parentSession = parent.session
const inheritance = buildSidechatInheritance(
parentSession.snapshotEvents() as unknown as readonly SidechatLogEvent[],
live?.chunksFor(sessionId) ?? [],
)
const { agentPreset, setup } = await composeChildSetup(
ctx,
@@ -388,8 +398,8 @@ export function buildSidechatApi(ctx: Context): SidechatRoutes {
const persistence = ctx.get('sessionPersistence') as SidebarSessionPersistenceService | undefined
if (persistence !== undefined) {
try {
const inspected = await persistence.inspect(childId)
const preset = resolvePresetId(inspected.meta, inspected.events)
const inspected = await readPersistedSession(persistence, childId)
const preset = resolvePresetId(inspected.header, inspected.events)
return { live: false, ...(preset === undefined ? {} : { preset }) }
} catch {
// Unknown/gone session: report a bare cold info.
@@ -408,7 +418,13 @@ export function buildSidechatApi(ctx: Context): SidechatRoutes {
const events = await threadLogEvents(ctx, childId)
const own = threadOwnLogEvents(events)
const fresh = rawAfter === undefined ? own : own.filter(event => event.seq > rawAfter)
return { events: fresh.length > EVENTS_CAP ? fresh.slice(fresh.length - EVENTS_CAP) : fresh }
const tailSeq = own.at(-1)?.seq ?? -1
return {
events: fresh.length > EVENTS_CAP ? fresh.slice(fresh.length - EVENTS_CAP) : fresh,
// The live rows ride every poll: they are process-local, so there is
// no durable seq to page them by. An idle thread has none.
live: liveEventsOf(live?.chunksFor(childId) ?? [], tailSeq),
}
},
}
}
+5 -3
View File
@@ -39,9 +39,11 @@ export interface LastActivity {
/**
* Fold a session event log into the last text output + last tool call (each
* is the LAST occurrence in event order). Lifecycle events and raw
* `assistant/chunk` rows are ignored — the card shows what the subagent is
* doing right now, not its plumbing. The scan runs BACKWARD from the newest
* is the LAST occurrence in event order). Lifecycle events and raw stream
* rows are ignored — the card shows what the subagent is doing right now,
* not its plumbing. (DSH 0.1.5 publishes the raw deltas as process-local
* frames instead of log events, so only the assembled `assistant/message`
* reaches this scan.) The scan runs BACKWARD from the newest
* event and stops once both fields are found, so a long history costs only
* the recent tail in the common case.
* @param events - the session's append-only event log (oldest → newest).