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:
@@ -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() },
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
|
||||
@@ -238,8 +238,6 @@ export const ar: Record<string, string> = {
|
||||
settingsWidthTitle: 'الحصة الافتراضية للعرض',
|
||||
settingsWidthDesc: 'الحصة الافتراضية للبطاقة الجانبية من عرض النافذة للمحادثات الجديدة (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: 'فتح ملفات المحادثة في الشريط الجانبي',
|
||||
settingsOpenPathDesc: 'فتح روابط الملفات في المحادثة (صفوف الأدوات، الملفات الناتجة، الإشارات) في محرّر الشريط الجانبي بدلاً من التطبيق الافتراضي للنظام',
|
||||
settingsOpenToolsTitle: 'حقن أداة فتح الشريط الجانبي للنموذج',
|
||||
settingsOpenToolsDesc: 'عند التفعيل، يمكن للنموذج فتح الملفات والمجلدات وصفحات HTTP(S) في الشريط الجانبي عبر أداة sidebar_open (معطّل افتراضياً)',
|
||||
settingsTitleBarTitle: 'وضع توافق الموضع',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -237,8 +237,6 @@ export const hi: Record<string, string> = {
|
||||
settingsWidthTitle: 'डिफ़ॉल्ट चौड़ाई हिस्सा',
|
||||
settingsWidthDesc: 'नए वार्तालाप के लिए साइड कार्ड की विंडो चौड़ाई का डिफ़ॉल्ट हिस्सा (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: 'चैट फ़ाइलें साइडबार में खोलें',
|
||||
settingsOpenPathDesc: 'चैट में फ़ाइल लिंक (टूल पंक्ति, उत्पादित फ़ाइलें, उल्लेख) क्लिक करने पर सिस्टम डिफ़ॉल्ट ऐप के बजाय साइडबार एडिटर में खोलें',
|
||||
settingsOpenToolsTitle: 'मॉडल के लिए साइडबार ओपन टूल इंजेक्ट करें',
|
||||
settingsOpenToolsDesc: 'चालू होने पर, मॉडल sidebar_open टूल से साइडबार में फ़ाइलें, फ़ोल्डर और HTTP(S) पेज खोल सकता है (डिफ़ॉल्ट रूप से बंद)',
|
||||
settingsTitleBarTitle: 'स्थिति संगतता मोड',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -237,8 +237,6 @@ export const ja: Record<string, string> = {
|
||||
settingsWidthTitle: 'デフォルト幅の比率',
|
||||
settingsWidthDesc: '新規会話時のサイドカードがウィンドウ幅に占める割合 (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: 'チャット内ファイルをサイドバーで開く',
|
||||
settingsOpenPathDesc: 'チャット内のファイルリンク(ツール行、産物リスト、ファイル言及)クリック時に、システム既定アプリではなくサイドバーエディターで開く',
|
||||
settingsOpenToolsTitle: 'モデルにサイドバー開くツールを注入',
|
||||
settingsOpenToolsDesc: 'オンにすると、モデルは sidebar_open ツールでサイドバーにファイル・フォルダー・HTTP(S) ページを開ける(デフォルトオフ)',
|
||||
settingsTitleBarTitle: '位置互換モード',
|
||||
|
||||
@@ -229,8 +229,6 @@ export const ko: Record<string, string> = {
|
||||
settingsWidthTitle: '기본 너비 비율',
|
||||
settingsWidthDesc: '새 대화에서 사이드 카드가 창 너비에서 차지하는 비율(%) (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: '채팅 영역 파일을 사이드바에서 열기',
|
||||
settingsOpenPathDesc: '채팅에서 파일 링크(도구 행, 산출물 목록, 파일 언급)를 클릭하면 사이드바 편집기에서 열고, 시스템 기본 앱은 호출하지 않습니다',
|
||||
settingsOpenToolsTitle: '모델에 사이드바 열기 도구 주입',
|
||||
settingsOpenToolsDesc: '켜면 모델이 sidebar_open 도구로 사이드바에서 파일·폴더·HTTP(S) 페이지를 열 수 있습니다(기본 꺼짐)',
|
||||
settingsTitleBarTitle: '위치 호환 모드',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -234,8 +234,6 @@ export const ru: Record<string, string> = {
|
||||
settingsWidthTitle: 'Доля ширины по умолчанию',
|
||||
settingsWidthDesc: 'Доля боковой карточки в ширине окна для новых сессий, в процентах (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: 'Открывать файлы чата в боковой панели',
|
||||
settingsOpenPathDesc: 'При щелчке по ссылке на файл в чате (строки инструментов, списки результатов, упоминания) файл открывается в редакторе боковой панели, а не в системном приложении по умолчанию',
|
||||
settingsOpenToolsTitle: 'Внедрить инструмент открытия в боковой панели для модели',
|
||||
settingsOpenToolsDesc: 'Если включено, модель может открывать файлы, папки и HTTP(S)-страницы в боковой панели с помощью инструмента sidebar_open (по умолчанию выключено)',
|
||||
settingsTitleBarTitle: 'Режим совместимости заголовка',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -237,8 +237,6 @@ export const th: Record<string, string> = {
|
||||
settingsWidthTitle: 'สัดส่วนความกว้างเริ่มต้น',
|
||||
settingsWidthDesc: 'สัดส่วนความกว้างเริ่มต้นของการ์ดด้านข้างจากความกว้างหน้าต่างสำหรับแชทใหม่ (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: 'เปิดไฟล์แชทในแถบด้านข้าง',
|
||||
settingsOpenPathDesc: 'เปิดลิงก์ไฟล์ในแชท (แถวเครื่องมือ, ไฟล์ที่สร้าง, การกล่าวถึง) ในตัวแก้ไขแถบด้านข้างแทนแอปเริ่มต้นของระบบ',
|
||||
settingsOpenToolsTitle: 'ฉีดเครื่องมือเปิดแถบด้านข้างสำหรับโมเดล',
|
||||
settingsOpenToolsDesc: 'เมื่อเปิดใช้ โมเดลสามารถเปิดไฟล์ โฟลเดอร์ และหน้า HTTP(S) ในแถบด้านข้างผ่านเครื่องมือ sidebar_open (ปิดเป็นค่าเริ่มต้น)',
|
||||
settingsTitleBarTitle: 'โหมดความเข้ากันได้ของตำแหน่ง',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 đề',
|
||||
|
||||
@@ -252,8 +252,6 @@ export const zhHK: Record<string, string> = {
|
||||
settingsWidthTitle: '預設寬度佔比',
|
||||
settingsWidthDesc: '新增工作階段時側邊卡片佔視窗寬度的百分比 (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: '聊天區檔案在側邊欄開啟',
|
||||
settingsOpenPathDesc: '在聊天裡點擊檔案連結(工具行、產物列表、檔案提及)時,在側邊欄編輯器中開啟,不再呼叫系統預設應用程式',
|
||||
settingsOpenToolsTitle: '為模型注入側邊欄開啟工具',
|
||||
settingsOpenToolsDesc: '開啟後,模型可透過 sidebar_open 工具在側邊欄主動開啟檔案、資料夾和 HTTP(S) 網頁(預設關閉)',
|
||||
settingsTitleBarTitle: '位置相容模式',
|
||||
|
||||
@@ -252,8 +252,6 @@ export const zhMO: Record<string, string> = {
|
||||
settingsWidthTitle: '預設寬度佔比',
|
||||
settingsWidthDesc: '新增工作階段時側邊卡片佔視窗寬度的百分比 (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: '聊天區檔案在側邊欄開啟',
|
||||
settingsOpenPathDesc: '在聊天裡點擊檔案連結(工具行、產物列表、檔案提及)時,在側邊欄編輯器中開啟,不再呼叫系統預設應用程式',
|
||||
settingsOpenToolsTitle: '為模型注入側邊欄開啟工具',
|
||||
settingsOpenToolsDesc: '開啟後,模型可透過 sidebar_open 工具在側邊欄主動開啟檔案、資料夾和 HTTP(S) 網頁(預設關閉)',
|
||||
settingsTitleBarTitle: '位置相容模式',
|
||||
|
||||
@@ -252,8 +252,6 @@ export const zhTW: Record<string, string> = {
|
||||
settingsWidthTitle: '預設寬度佔比',
|
||||
settingsWidthDesc: '新增工作階段時側邊卡片佔視窗寬度的百分比 (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: '聊天區檔案在側邊欄開啟',
|
||||
settingsOpenPathDesc: '在聊天裡點擊檔案連結(工具行、產物列表、檔案提及)時,在側邊欄編輯器中開啟,不再呼叫系統預設應用程式',
|
||||
settingsOpenToolsTitle: '為模型注入側邊欄開啟工具',
|
||||
settingsOpenToolsDesc: '開啟後,模型可透過 sidebar_open 工具在側邊欄主動開啟檔案、資料夾和 HTTP(S) 網頁(預設關閉)',
|
||||
settingsTitleBarTitle: '位置相容模式',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user