fix(v0.3.4): adapt worktable to DSH 0.2 Web

This commit is contained in:
Aisland
2026-10-02 12:58:44 +08:00
parent 2f4f6c87d5
commit e63d122142
19 changed files with 1312 additions and 486 deletions
+25 -3
View File
@@ -1,6 +1,28 @@
/** 更新公告正文(v0.3.3:DSH 0.1.2 兼容 + 数据路径修复;呈现时按纯文本保留换行,不做 md 渲染) */
export const CHANGELOG_VERSION = 'v0.3.3'
export const CHANGELOG_V030 = `更新公告 · v0.3.3
/** 更新公告正文(v0.3.4:DSH 0.2 网页端适配;呈现时按纯文本保留换行,不做 md 渲染) */
export const CHANGELOG_VERSION = 'v0.3.4'
export const CHANGELOG_V030 = `更新公告 · v0.3.4
🛠️ 本次更新:适配 DSH 0.2 网页端
【项目与会话联动】接入新版 DSH 的会话识别与跳转接口,适配打开绑定项目、关闭项目后回到原会话、控制室卡片跳转等入口,不替换宿主官方组件。
【新建对话】修复新版 DSH 中只设置项目路径创建会话,可能出现「对话能打开,却无法输入」的问题。新建时先通过宿主登记或复用工作区,再创建会话;预设与模型选择也接入新版接口。此改动不自动迁移已有会话。
【标注输入】适配新版聊天输入框,通过宿主接口追加标注,保留已有草稿与文件引用,不自动发送;无法写入时仍可复制到剪贴板。修复旧版已有文件引用时追加位置计算错误的问题;旧版输入框保留兼容分支,不再误选工作台自己的标注框或文件编辑框。
【控制室状态与计时】适配新版会话状态、子代理列表及最近消息读取。支持识别新的待确认事项:确认过上一条后,下一条仍会重新亮起提醒;计时读取宿主已有的任务或会话起点,取不到有效起点时不显示,不编造运行时长。任务停止不等于任务成功。
【发送与消息预览】适配新版会话的临时持有与释放机制。发送请求失败或被拒绝后,不再换另一条通道重复发送;控制室预览从宿主已加载的消息窗口提取普通文本,不代表读取了完整历史。
【数据与升级】本次未更名或迁移工作台的项目、布局与媒体存储,不需要为了本次更新主动清空浏览器数据。升级前请备份配置及重要数据,确认其他插件也支持目标 DSH 版本;安装后完整退出并重启 DSH,再刷新页面。若仍加载失败,请保留完整错误信息反馈,不要把它当作正常现象。
【验证范围】本轮在 Windows + DSH 0.2.0-rc.2 网页端验证了冷启动、工作台/控制室页面、通过工作台新建并绑定对话、发送短文本并收到模型回复、关闭项目后回切与重新打开;同时补充旧版服务回退与引用坐标等定向回归。兼容声明仅列 0.1.1-rc.2、0.1.2-rc.1、0.2.0-rc.2;前两个版本沿用此前页面验收基础并做代码回归,本轮未重跑整套旧版 GUI,不承诺其他版本全部兼容。
【尚未闭环】实际审批/子代理流程及自动挂载全链路尚未完成业务验收。官方桌面端尚未适配,macOS 尚未真机验证;本次也不代表所有用户的 DSH 启动失败已解决。公开发布信息以 GitHub Release 为准。
───────
此前版本说明(v0.3.3)
🛠️ 本版本更新
+54
View File
@@ -0,0 +1,54 @@
import { currentSessionOf } from './sessionCompat'
/** Append annotations without sending or replacing existing reference chips. */
export function appendHostInput(bridge: any, text: string, doc: Document = document): boolean {
try {
const resolver = bridge?.conversation?.input
if (typeof resolver?.for === 'function') {
const snapshot = bridge.sessions?.list?.getSnapshot?.()
const id = currentSessionOf(snapshot)
const scope = id ? bridge.sessions?.scope?.(id) : null
if (!scope) return false
const input = resolver.for(scope)
const state = input?.state?.getSnapshot?.()
if (!state || typeof state.draft !== 'string' || !Number.isInteger(state.draftRev)
|| !['plain', 'claimed'].includes(state.phase) || !Array.isArray(state.occurrences)) return false
// DSH 0.1's current-based list uses display-text coordinates. DSH 0.2's
// mainView list uses one U+FFFC unit per chip in insertion coordinates.
const compactReferences = typeof snapshot?.current !== 'string'
let end = state.draft.length
let previousEnd = 0
for (const item of state.occurrences) {
if (!Number.isInteger(item.offset) || !Number.isInteger(item.length)
|| item.length < 0 || item.offset < previousEnd
|| item.offset + item.length > state.draft.length) return false
previousEnd = item.offset + item.length
if (compactReferences) end += 1 - item.length
}
const span = { start: end, end, draftRev: state.draftRev }
const addition = (state.draft.trim() ? '\n\n' : '') + text
// Public action face provides revision/phase guards and an undo boundary.
// The scoped event is the public compatibility path when actions are absent.
const inserted = typeof input.actions?.insertText === 'function'
? input.actions.insertText(addition, span) === true
: scope.bail?.('slash/input-insert-text', { text: addition, span }) === true
if (inserted) { try { input.focus?.() } catch {} }
return inserted
}
// Legacy DSH 0.1 textarea. Never select an arbitrary textarea: it may be
// the annotation editor itself or an open Worktable file editor.
const ta = doc.querySelector<HTMLTextAreaElement>('textarea[data-phase]')
if (!ta || ta.disabled || ta.readOnly || ta.closest('.dsh-wt_annotUi')) return false
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set
const next = ta.value && ta.value.trim() ? ta.value + '\n\n' + text : text
if (setter) setter.call(ta, next)
else ta.value = next
ta.dispatchEvent(new Event('input', { bubbles: true }))
try {
ta.focus()
ta.dispatchEvent(new Event('change', { bubbles: true }))
ta.setSelectionRange(ta.value.length, ta.value.length)
} catch {}
return true
} catch { return false }
}
+87 -188
View File
@@ -3,6 +3,9 @@ import { css } from './styles'
import { NS, zh, en, type WorktableKey } from './locales'
import { isAbs, joinPath, parentPathOf, basenameOf } from './pathutil'
import { splitStore, SplitWorkspace, setSplitT, setSplitEnv, type LayoutSpec, type SplitPane, type ConsoleCardData } from './split'
import { appendHostInput } from './hostInput'
import { currentSessionOf, openHostSession, sendHostSession, createSessionSnapshotAdapter, childSessionIdsOf, pendingAckOf, sessionRuntimeMs } from './sessionCompat'
import { cleanPreviewText, previewFromEvents, readSessionPreview, presetApiOf, modelApiOf, createHostSession } from './sessionDetails'
import { photoStore, kindOf } from './photoStore'
import { DEFAULT_BG_SVG, b64ToBlob } from './defaultBg'
import { WAVE_BG_B64 } from './waveBg'
@@ -421,7 +424,7 @@ let hostApi: { agentPresets?: any; sessions?: any } | null = null
/** 新会话显式应用「部署默认预设」:宿主新会话座位同款逻辑(api.agentPresets.select,仅对 blank 会话生效)。
* 修复:用户删掉默认模型后,新建会话继承到失效预设,prompt 报 model-unavailable 导致窗口建不出来。 */
async function ensureSessionPreset(sessionId: string): Promise<void> {
const api = hostApi?.agentPresets
const api = presetApiOf(clientCtx, hostApi?.agentPresets)
if (!api || typeof api.list !== 'function' || typeof api.select !== 'function') return
try {
const listRes = await api.list({})
@@ -475,7 +478,7 @@ function pickInheritedModel(groups: any[], baseModel: string | undefined): { pro
* 不擅自选模型——① 优先继承用户当前会话正在用的模型;② 兜底按失效选择里的模型家族词
* 找同款;③ 最后才目录首个。session.selectModel 同时把新选择存为默认,顺带修复后续新会话。 */
async function ensureSessionModel(sessionId: string): Promise<void> {
const api = hostApi?.sessions
const api = modelApiOf(clientCtx, sessionBridge?.sessions, hostApi?.sessions)
if (!api || typeof api.models !== 'function' || typeof api.selectModel !== 'function') return
try {
const mRes = await api.models({ sessionId })
@@ -486,7 +489,7 @@ async function ensureSessionModel(sessionId: string): Promise<void> {
let effort: string | undefined
// ① 无条件继承用户当前会话的选择(用户正在用哪个模型,新会话就用哪个)
try {
const cur = sessionBridge?.list?.getSnapshot?.()?.current
const cur = currentSessionOf(sessionBridge?.list?.getSnapshot?.())
if (cur && cur !== sessionId) {
const cRes = await api.models({ sessionId: cur })
if (cRes?.result?.ok) {
@@ -582,57 +585,14 @@ function listWorkspaces(): { id: string; title: string; path: string }[] {
} catch { return [] }
}
/** 预览文本清洗:去掉围栏代码块(```…```,含 dsh-ui 等)与行内代码,压缩空白;
* 代码为主的片段会被滤空 → 调用方回退到更早的消息。只清洗显示用副本,不改原文。 */
function cleanPreviewText(raw: string): string {
let s = String(raw ?? '')
s = s.replace(/```[a-zA-Z0-9_+-]*[\s\S]*?```/g, ' ')
s = s.replace(/```[a-zA-Z0-9_+-]*[\s\S]*$/g, ' ')
s = s.replace(/`[^`\n]{1,200}`/g, ' ')
s = s.replace(/```/g, ' ')
s = s.replace(/\s+/g, ' ').trim()
return s
}
/** 冷会话最近消息缓存与预热:宿主 history 只读通道(face.history 为运行期内建方法,非公开接口,
* 只读侦察确认可用;拉取是带宽成本不是 Token 成本,模型不参与)。 */
/** 冷会话最近消息缓存与预热:0.2 临时持有会话、读 eventSource 后释放;旧版保留 history 通道。
* 只读消息文本,不请求模型;拉取是带宽成本,不产生模型 Token。 */
const previewCache = new Map<string, string>()
const previewFetching = new Set<string>()
let previewSweepBusy = false
let previewTimer: number | null = null
/** 从 history 事件流尾部提取最近一条成品消息文本(优先 text 块;清洗代码后仍太短则回退更早消息) */
async function coldPreviewOf(face: any): Promise<string> {
if (!face || typeof face.history !== 'function') return ''
const r1 = await face.history({ maxMessages: 6 })
const evs = r1?.result?.value?.events
if (!Array.isArray(evs)) return ''
const textOf = (blocks: any): string => {
let fallback = ''
if (!Array.isArray(blocks)) return ''
for (const b of blocks) {
const s = typeof b?.text === 'string' ? b.text.trim() : ''
if (!s) continue
if (b?.type === 'text') return s
if (!fallback) fallback = s
}
return fallback
}
for (let i = evs.length - 1; i >= 0; i--) {
const ev = evs[i]?.event
if (!ev) continue
const d = ev.data ?? {}
let raw = ''
if (ev.type === 'user/message') raw = textOf(d.content ?? d.blocks)
else if (ev.type === 'assistant/message') { const m = d.message ?? d; raw = textOf(m.content ?? m.blocks) }
if (!raw) continue
const clean = cleanPreviewText(raw)
if (clean.length >= 8) return clean.slice(0, 220)
}
return ''
}
/** 预热所有已绑定会话的最近消息(逐个只读拉取;已在拉/已拉过未完成的跳过,失败静默回退内存路径) */
/** 预热所有已绑定会话的最近消息(逐个只读拉取;同一在途请求不重复,失败回退缓存/内存路径) */
async function sweepPreviews() {
if (previewSweepBusy) return
previewSweepBusy = true
@@ -642,9 +602,9 @@ async function sweepPreviews() {
if (previewFetching.has(sid)) continue
previewFetching.add(sid)
try {
const face = sessionBridge?.sessions?.binding?.(sid)?.session
const txt = await coldPreviewOf(face)
if (txt) { previewCache.set(sid, txt); notifyConsole() }
const txt = await readSessionPreview(sessionBridge?.sessions, sid)
previewCache.set(sid, txt)
notifyConsole()
} catch {} finally { previewFetching.delete(sid) }
}
} finally { previewSweepBusy = false }
@@ -652,14 +612,17 @@ async function sweepPreviews() {
/** 防抖调度:控制室开着且会话快照变化时刷新预览(合并 6s 内的连续变化) */
function schedulePreviewSweep() {
if (previewTimer != null) return
// Temporary retain/release also changes the catalog: do not schedule ourselves forever.
if (previewSweepBusy || previewTimer != null) return
previewTimer = window.setTimeout(() => { previewTimer = null; sweepPreviews() }, 6000)
}
/** 从会话快照节点里提取最近一条文本(纯读内存镜像,零 Token;代码清洗后无则 '') */
function lastTextOf(sid: string): string {
try {
const face = sessionBridge?.sessions?.binding?.(sid)?.session?.getSnapshot?.()
const binding = sessionBridge?.sessions?.binding?.(sid)
if (binding?.eventSource?.getSnapshot) return previewFromEvents(binding.eventSource.getSnapshot().entries)
const face = binding?.session?.getSnapshot?.()
const nodes: any[] = face?.nodes ?? []
for (let i = nodes.length - 1; i >= 0; i--) {
const n = nodes[i]
@@ -684,7 +647,7 @@ async function fetchSessionGroups(): Promise<{ groups: { title: string; sessions
try {
const snap = sessionBridge?.list?.getSnapshot?.()
const byId = snap?.byId ?? {}
const current = snap?.current ?? ''
const current = currentSessionOf(snap)
// 子代理会话(后台产生的,用户面板看不到)排除
const subKids = new Set<string>()
try {
@@ -763,33 +726,7 @@ function hideBindTip() {
async function promptIntoSession(sessionId: string, text: string): Promise<void> {
const b = sessionBridge
if (!b) throw new Error('bridge unavailable')
const sessions = b.sessions as any
// 新会话入列可能异步:最多等 2s 直到 binding 可解析
let session: any = null
for (let i = 0; i < 10; i++) {
try { session = sessions?.binding?.(sessionId)?.session ?? null } catch { session = null }
if (session) break
await new Promise((r) => setTimeout(r, 200))
}
if (session) {
// 1) 宿主包装(正确签名:会话面 + 空图片 + queue 投递)
if (typeof b.conversation?.sendSession === 'function') {
try { await b.conversation.sendSession(session, text, [], 'queue'); return } catch { /* 包装失败则直连 */ }
}
// 2) 直连会话面 prompt(宿主 sendSession 内部同款路径)
if (typeof session.prompt === 'function') {
const result = await session.prompt([{ type: 'text', text }], 'queue')
if (result && result.ok) return
if (result && !result.ok) throw new Error('session.prompt: ' + (result.error?.code ?? 'rejected') + (result.error?.message ? ': ' + result.error.message : ''))
}
}
// 3) 备用:作用域上下文里取 conversation 服务再发
try {
const scoped = sessions?.scope?.(sessionId)
const conv = scoped?.get?.('conversation')
if (conv && typeof conv.send === 'function') { await conv.send(text); return }
} catch { /* 落入最终报错 */ }
throw new Error('no send path: session face unavailable')
await sendHostSession(b, sessionId, text)
}
/** 新建会话的分组选择:未分组 / 加入现有分组 / 新建一个分组(父目录 + 名称) */
@@ -870,11 +807,11 @@ export async function createCustomSession(projectId: string, projectName: string
let createOpts: any = {}
if (workspaceId) createOpts = { workspaceId }
else if (folder) createOpts = { cwd: folder }
const sessionId = await b.sessions.create(createOpts)
const sessionId = await createHostSession(b.sessions, b.workspaces, createOpts)
await ensureSessionPreset(sessionId) // 新会话应用部署默认预设
await ensureSessionModel(sessionId) // 修复继承失效 provider(selectModel 同步存默认,顺带修复后续新会话)
markPluginSessionOpen(sessionId) // 插件发起的切换:不触发「切会话关项目」联动
try { await b.sessions.open?.(sessionId) } catch {}
try { await openHostSession(clientCtx, b.sessions, sessionId) } catch {}
await promptIntoSession(sessionId, text)
return sessionId
}
@@ -885,7 +822,7 @@ export async function sendCustomToSession(sessionId: string, projectId: string,
if (!b) throw new Error('bridge unavailable')
const text = buildWindowTaskText(projectId, projectName, windowLabel, requirement, folder, 'send')
markPluginSessionOpen(sessionId) // 插件发起的切换:不触发「切会话关项目」联动
try { await b.sessions.open?.(sessionId) } catch {}
try { await openHostSession(clientCtx, b.sessions, sessionId) } catch {}
await promptIntoSession(sessionId, text)
}
@@ -915,7 +852,7 @@ const sessionScopeStore: {
/** 完整会话快照(模块级):项目卡片的任务完成/待决提醒镜像数据源 */
const sessionsSnapshotStore: { snapshot: any | null; listeners: Set<() => void> } = { snapshot: null, listeners: new Set() }
/** 提醒确认(ack)持久化:会话 id → 已确认的状态('done' | 'need') */
/** 提醒确认:会话 id → done / 旧 need / need:不含正文的待决身份集合。 */
function loadNotifyAck(): Record<string, string> {
try {
const raw = localStorage.getItem('dsh.worktable.notifyAck.v1')
@@ -986,8 +923,8 @@ function buildMountContent(folder: string, d: any): any {
return { kind: 'iframe', url: '/api/worktable/site/' + encodeURIComponent(dir) + '/' + encodeURIComponent(name), title: name }
}
/** 每会话最近一次观察到的「需要判断」布尔值:状态转移时清除旧 ack(新一轮待决重新点亮) */
const notifyStateSeenRef: { current: Record<string, boolean> } = { current: {} }
/** 每会话最近观察到的待决身份;同为 need 的问题替换也必须重新点亮。 */
const notifyStateSeenRef: { current: Record<string, string | null> } = { current: {} }
/** 每会话最近观察到的「已完成」布尔值:新一次完成时清除旧 done ack(控制室绿光重新点亮) */
const doneSeenRef: { current: Record<string, boolean> } = { current: {} }
/** 清除某会话的 ack 记录(状态转移时调用,保证新问题不会被旧确认压住) */
@@ -1010,10 +947,18 @@ function sessionNotifyState(entry: any): 'done' | 'need' | null {
return null
}
let sessionStatusSource: any = null
let sessionConversationSource: any = null
let adaptSessionSnapshot = createSessionSnapshotAdapter()
function pendingAckForSession(sid: string): string | null {
let face: any
try { face = sessionBridge?.sessions?.binding?.(sid)?.session?.getSnapshot?.() } catch {}
return pendingAckOf(sessionsSnapshotStore.snapshot, sid, face)
}
function syncSessionScope(list: any) {
try {
const snap = list.getSnapshot()
const current: string = snap?.current ?? ''
const snap = adaptSessionSnapshot(list.getSnapshot(), sessionStatusSource)
const current = currentSessionOf(snap)
const entry = snap?.byId?.[current] ?? snap?.items?.find((it: any) => it.sessionId === current) ?? null ?? null
const cat = snap?.subagentsByParent?.[current]
let subagents: any[] = []
@@ -1249,32 +1194,14 @@ function WorktableSection(props: any) {
const pr = projectsRef.current
const snap = sessionsSnapshotStore.snapshot
const byId: Record<string, any> = snap?.byId ?? {}
const jobsMap: Record<string, any[]> = snap?.jobsBySession ?? {}
const now = Date.now()
const kidsSetOf = (sid: string): Set<string> => {
const set = new Set<string>()
for (const [cid, ce] of Object.entries<any>(byId)) if (ce?.parentId === sid) set.add(cid)
const v = (snap?.subagentsByParent ?? {})[sid]
const arr = Array.isArray(v) ? v : (v?.entries ?? v?.items ?? [])
if (Array.isArray(arr)) arr.forEach((c: any) => {
const cid = c?.sessionId ?? c?.id
if (typeof cid === 'string') set.add(cid)
})
return set
}
const kidsSetOf = (sid: string) => childSessionIdsOf(snap, sid)
// 三态判定(同卡片提醒逻辑,但不过滤 ack——监控室永远显示事实状态)
const statusOf = (sid: string | undefined): ConsoleCardData['status'] => {
if (!sid) return 'idle'
const e = byId[sid]
if (!e) return 'idle'
if (sessionNotifyState(e) === 'need') return 'need'
for (const cid of kidsSetOf(sid)) {
if (byId[cid] && sessionNotifyState(byId[cid]) === 'need') return 'need'
}
try {
const face = sessionBridge?.sessions?.binding?.(sid)?.session?.getSnapshot?.()
if (Array.isArray(face?.pending) && face.pending.length > 0) return 'need'
} catch {}
if (pendingAckForSession(sid) != null) return 'need'
if (e.completed === true) return 'done'
// 会话面兜底:宿主快照可能滞后或缺少完成字段
try {
@@ -1284,39 +1211,17 @@ function WorktableSection(props: any) {
if (e.running === true) return 'busy'
return 'idle'
}
// 运行时长:本会话正在运行的后台任务最早 startedAt 起算;无任务时读会话面 turnTimings 未结束轮次
// 运行时长:任务 startedAt → 新版 chat legacy.turnTimings → 旧会话面;未知不虚构。
const runtimeOf = (sid: string | undefined): number | null => {
if (!sid) return null
const e = byId[sid]
if (!e || e.running !== true) return null
let start: number | null = null
const jobs = jobsMap[sid] ?? []
for (const j of jobs) {
if (j?.status === 'running' && typeof j.startedAt === 'number' && (start == null || j.startedAt < start)) start = j.startedAt
}
if (start != null) return Math.max(0, now - start)
try {
const face = sessionBridge?.sessions?.binding?.(sid)?.session?.getSnapshot?.()
const timings = face?.turnTimings
if (timings instanceof Map) {
for (const t of Array.from(timings.values()).reverse()) {
if (t && typeof t.startTime === 'number' && t.endTime == null) return Math.max(0, now - t.startTime)
}
} else if (timings && typeof timings === 'object') {
for (const k of Object.keys(timings).reverse()) {
const t = (timings as any)[k]
if (t && typeof t.startTime === 'number' && t.endTime == null) return Math.max(0, now - t.startTime)
}
}
} catch {}
return null
return sessionRuntimeMs(snap, sid, sessionBridge?.sessions, sessionConversationSource, now)
}
const ackMap = loadNotifyAck()
const make = (id: string, name: string, icon: string, self: boolean): ConsoleCardData => {
const sid = pr.projects.bindings[id]
const status = statusOf(sid)
// 发光 = 完成/待决且本轮未被确认;点卡片确认后熄灭
const glow = !!sid && ((status === 'done' && ackMap[sid] !== 'done') || (status === 'need' && ackMap[sid] !== 'need'))
const glow = !!sid && ((status === 'done' && ackMap[sid] !== 'done') || (status === 'need' && ackMap[sid] !== pendingAckForSession(sid)))
return {
id, name, icon,
status,
@@ -1361,6 +1266,7 @@ function WorktableSection(props: any) {
// 崩溃),改为 apply 里订阅 ctx.sessions.list 后写入模块级 store,此处直接读取。
useEffect(() => {
const env = {
fillHostInput: (text: string) => appendHostInput(sessionBridge, text),
getScope: () => {
const s = sessionScopeStore.snapshot
return s ? { sessionId: s.sessionId, cwd: s.cwd } : null
@@ -1481,14 +1387,14 @@ function WorktableSection(props: any) {
else {
// 入驻项目无视图覆盖:仅切换其绑定对话(对齐卡片自带打开行为)
const bound = pr.bindings[id]
if (bound) { try { sessionBridge?.sessions?.open?.(bound) } catch {} }
if (bound) { try { openHostSession(clientCtx, sessionBridge?.sessions, bound) } catch {} }
}
},
onJump: (id) => {
const sid = projectsRef.current.projects.bindings[id]
if (!sid) return
markPluginSessionOpen(sid) // 插件发起的切换:不触发「切会话关项目」联动
try { sessionBridge?.sessions?.open?.(sid) } catch {}
try { openHostSession(clientCtx, sessionBridge?.sessions, sid) } catch {}
},
},
}
@@ -1537,7 +1443,7 @@ function WorktableSection(props: any) {
if (!splitStore.active) {
if (!suppressRestoreRef.current) {
const prev = projectAttachRef.sessionId
if (prev) { try { sessionBridge?.sessions?.open?.(prev) } catch {} }
if (prev) { try { openHostSession(clientCtx, sessionBridge?.sessions, prev) } catch {} }
}
projectAttachRef.sessionId = null
projectAttachRef.attached = null
@@ -1722,11 +1628,11 @@ function buildCustomLayoutPrompt(req: string): string {
if (splitStore.active && splitStore.spec?.id === spec.id) {
// 记录打开前会话(关项目时回切)与该项目「归属会话」(切到别的会话 = 自动关项目)
let prev: string | null = null
try { prev = sessionBridge?.list?.getSnapshot?.()?.current ?? null } catch {}
try { prev = currentSessionOf(sessionBridge?.list?.getSnapshot?.()) || null } catch {}
projectAttachRef.sessionId = prev
projectAttachRef.attached = projectsRef.current.projects.bindings[spec.id] ?? prev
const bound = projectsRef.current.projects.bindings[spec.id]
if (bound) { try { sessionBridge?.sessions?.open?.(bound) } catch {} }
if (bound) { try { openHostSession(clientCtx, sessionBridge?.sessions, bound) } catch {} }
ackProjectNotify(spec.id)
// 补挂:此前项目未打开时暂存的产物(entries = 多窗口挂载列表),现在自动挂进各目标窗格;
// 全部落位成功记录指纹(供自愈扫挂去重)
@@ -1760,14 +1666,14 @@ function buildCustomLayoutPrompt(req: string): string {
splitStore.open(spec)
if (splitStore.active && splitStore.spec?.id === CONSOLE_ID) {
let prev: string | null = null
try { prev = sessionBridge?.list?.getSnapshot?.()?.current ?? null } catch {}
try { prev = currentSessionOf(sessionBridge?.list?.getSnapshot?.()) || null } catch {}
projectAttachRef.sessionId = prev
const bound = explicitBound !== undefined ? explicitBound : projectsRef.current.projects.bindings[CONSOLE_ID]
projectAttachRef.attached = bound ?? prev
if (bound) { try { sessionBridge?.sessions?.open?.(bound) } catch {} }
if (bound) { try { openHostSession(clientCtx, sessionBridge?.sessions, bound) } catch {} }
ackProjectNotify(CONSOLE_ID)
try { notifyConsole() } catch {}
// 打开即预热所有绑定会话的最近消息(冷会话走 history 只读通道)
// 打开即预热所有绑定会话的最近消息(新宿主临时持有,旧宿主 history)
try { sweepPreviews() } catch {}
}
}, [projects.views, t])
@@ -1819,7 +1725,7 @@ function buildCustomLayoutPrompt(req: string): string {
let createOpts: any = {}
if (workspaceId) createOpts = { workspaceId }
else if (folder) createOpts = { cwd: folder }
const sessionId = await b.sessions.create(createOpts)
const sessionId = await createHostSession(b.sessions, b.workspaces, createOpts)
await ensureSessionPreset(sessionId) // 管理对话同样应用部署默认预设
await ensureSessionModel(sessionId) // 管理对话同样修复失效 provider
markPluginSessionOpen(sessionId)
@@ -2095,20 +2001,7 @@ function buildCustomLayoutPrompt(req: string): string {
/** 收集某会话的子代理 id 集:byId 的 parentId 标注 + subagentsByParent 目录双通道 */
const collectKids = useCallback((sid: string): Set<string> => {
const kids = new Set<string>()
const snap = sessionsSnapshotStore.snapshot
const byId = snap?.byId ?? {}
for (const [cid, ce] of Object.entries<any>(byId)) {
if (ce?.parentId === sid) kids.add(cid)
}
const subMap = snap?.subagentsByParent ?? {}
const v = (subMap as any)[sid]
const arr = Array.isArray(v) ? v : (v?.entries ?? v?.items ?? [])
if (Array.isArray(arr)) arr.forEach((c: any) => {
const cid = c?.sessionId ?? c?.id
if (typeof cid === 'string') kids.add(cid)
})
return kids
return childSessionIdsOf(sessionsSnapshotStore.snapshot, sid)
}, [])
/** 项目 → 提醒态:绑定会话(含其子代理)待决(黄) > 完成(绿) > 工作中(蓝);
@@ -2121,26 +2014,13 @@ function buildCustomLayoutPrompt(req: string): string {
for (const [pid, sid] of Object.entries(projects.bindings)) {
const e = byId[sid]
if (!e) continue
// 是否需要判断:自身列表字段 / 子代理 / 会话面兜底,三通道聚合
let needNow = sessionNotifyState(e) === 'need'
if (!needNow) {
for (const cid of collectKids(sid)) {
const ce = byId[cid]
if (ce && sessionNotifyState(ce) === 'need') { needNow = true; break }
}
}
if (!needNow) {
try {
const face = sessionBridge?.sessions?.binding?.(sid)?.session?.getSnapshot?.()
if (Array.isArray(face?.pending) && face.pending.length > 0) needNow = true
} catch {}
}
const pendingAck = pendingAckForSession(sid)
// 状态转移 → 清除旧 ack(新一轮待决重新点亮)
if (needNow !== seen[sid]) {
if (seen[sid] !== undefined) clearNotifyAck(sid)
seen[sid] = needNow
if (pendingAck !== seen[sid]) {
if (seen[sid] !== undefined) { clearNotifyAck(sid); delete ack[sid] }
seen[sid] = pendingAck
}
if (needNow) { if (ack[sid] !== 'need') map[pid] = 'need'; continue }
if (pendingAck != null) { if (ack[sid] !== pendingAck) map[pid] = 'need'; continue }
if (sessionNotifyState(e) === 'done' && ack[sid] !== 'done') { map[pid] = 'done'; continue }
if (e.running === true) map[pid] = 'busy'
}
@@ -2152,17 +2032,14 @@ function buildCustomLayoutPrompt(req: string): string {
const sid = projectsRef.current.projects.bindings[projectId]
if (!sid) return
const byId = sessionsSnapshotStore.snapshot?.byId ?? {}
let needNow = sessionNotifyState(byId[sid]) === 'need'
const pendingAck = pendingAckForSession(sid)
for (const cid of collectKids(sid)) {
if (sessionNotifyState(byId[cid]) === 'need') { needNow = true; saveNotifyAck(cid, 'need') }
if (sessionNotifyState(byId[cid]) === 'need') {
const childAck = pendingAckForSession(cid)
if (childAck != null) { saveNotifyAck(cid, childAck); notifyStateSeenRef.current[cid] = childAck }
}
}
if (!needNow) {
try {
const face = sessionBridge?.sessions?.binding?.(sid)?.session?.getSnapshot?.()
if (Array.isArray(face?.pending) && face.pending.length > 0) needNow = true
} catch {}
}
if (needNow) saveNotifyAck(sid, 'need')
if (pendingAck != null) { saveNotifyAck(sid, pendingAck); notifyStateSeenRef.current[sid] = pendingAck }
else {
const st = sessionNotifyState(byId[sid])
if (st === 'done') saveNotifyAck(sid, 'done')
@@ -2543,7 +2420,7 @@ function buildCustomLayoutPrompt(req: string): string {
}
// 无视图覆盖:项目自带打开行为照旧,仅当绑定了会话时切换右侧对话窗
const bound = projectsRef.current.projects.bindings[pid]
if (bound) { try { sessionBridge?.sessions?.open?.(bound) } catch {} }
if (bound) { try { openHostSession(clientCtx, sessionBridge?.sessions, bound) } catch {} }
ackProjectNotify(pid)
}
document.addEventListener('click', onDocClick, true)
@@ -2655,7 +2532,7 @@ function buildCustomLayoutPrompt(req: string): string {
if (view || layout) openSplit((view ?? layout) as LayoutSpec)
else {
const bound = pr.bindings[id]
if (bound) { try { sessionBridge?.sessions?.open?.(bound) } catch {} }
if (bound) { try { openHostSession(clientCtx, sessionBridge?.sessions, bound) } catch {} }
}
}
@@ -3308,7 +3185,7 @@ export function apply(ctx: any) {
clientCtx = ctx
try { hostApi = ctx.get?.('connection')?.api ?? null } catch { hostApi = null }
try { (window as any).__dshHostApi = hostApi } catch {}
try { (window as any).__dshOpenSession = (id: string) => ctx.sessions?.open?.(id); (window as any).__dshSessions = ctx.sessions; (window as any).__dshPromptIntoSession = (id: string, text: string) => promptIntoSession(id, text); (window as any).__dshWorkspaces = ctx.workspaces; (window as any).__dshBuildWindowTaskText = buildWindowTaskText; (window as any).__dshSyncSessionScope = () => syncSessionScope(sessionBridge?.list) } catch {}
try { (window as any).__dshOpenSession = (id: string) => openHostSession(ctx, ctx.sessions, id); (window as any).__dshSessions = ctx.sessions; (window as any).__dshPromptIntoSession = (id: string, text: string) => promptIntoSession(id, text); (window as any).__dshWorkspaces = ctx.workspaces; (window as any).__dshBuildWindowTaskText = buildWindowTaskText; (window as any).__dshSyncSessionScope = () => syncSessionScope(sessionBridge?.list) } catch {}
ctx.effect(() => {
@@ -3367,9 +3244,31 @@ export function apply(ctx: any) {
// 会话作用域(当前会话 cwd 与后台任务)→ 功能窗数据源
const sessionsList = ctx.sessions?.list
if (sessionsList && typeof sessionsList.getSnapshot === 'function') {
adaptSessionSnapshot = createSessionSnapshotAdapter()
syncSessionScope(sessionsList)
const disposeScope = sessionsList.subscribe(() => syncSessionScope(sessionsList))
ctx.effect(() => disposeScope, 'dsh-worktable: session scope watch')
// Optional dependency: older DSH hosts have no uiSession service.
ctx.inject(['uiSession'], (statusCtx: any) => {
const source = statusCtx.uiSession?.sessionStatus
if (!source?.getSnapshot || !source?.subscribe) return
sessionStatusSource = source
syncSessionScope(sessionsList)
const disposeStatus = source.subscribe(() => syncSessionScope(sessionsList))
statusCtx.effect(() => () => {
disposeStatus()
if (sessionStatusSource === source) sessionStatusSource = null
}, 'dsh-worktable: session status watch')
})
// Optional read-only timer source; getting a snapshot does not activate a chat target.
ctx.inject(['uiConversation'], (conversationCtx: any) => {
const source = conversationCtx.uiConversation
if (typeof source?.binding !== 'function') return
sessionConversationSource = source
conversationCtx.effect(() => () => {
if (sessionConversationSource === source) sessionConversationSource = null
}, 'dsh-worktable: conversation timer source')
})
}
// 分栏工作区浮层(M1 通用引擎,shell.overlay 座位)
+149
View File
@@ -0,0 +1,149 @@
/** DSH 0.1 stores current on the list; DSH 0.2 uses mainView retention. */
export function currentSessionOf(snapshot: any): string {
if (typeof snapshot?.current === 'string') return snapshot.current
const rows = Object.values(snapshot?.byId ?? {}) as any[]
return rows.find((row) => (row?.retainedBy?.mainView ?? 0) > 0)?.id ?? ''
}
/** Navigate through the host's UI owner, without modifying the host service. */
export function openHostSession(ctx: any, sessions: any, id: string): any {
// Cordis property access throws for an uninjected service; get() safely returns undefined.
const ui = typeof ctx?.get === 'function' ? ctx.get('uiWorkspace') : ctx?.uiWorkspace
if (typeof ui?.openSession === 'function') return ui.openSession(id)
if (typeof sessions?.open === 'function') return sessions.open(id)
throw new Error('Host session navigation unavailable')
}
/** Own a temporary reference on 0.2; borrowing a cold binding is no longer sufficient. */
export async function sendHostSession(bridge: any, id: string, text: string): Promise<void> {
const sessions = bridge?.sessions
const check = (result: any) => {
if (result?.kind === 'error' || result?.ok === false) {
throw new Error(result.text || result.error?.message || result.error?.code || 'Session submission rejected')
}
}
const send = async (session: any) => {
// Select one available transport BEFORE sending. A rejection/timeout must never retry
// through a second transport: the first request may already have reached the host.
if (session && typeof bridge?.conversation?.sendSession === 'function') {
check(await bridge.conversation.sendSession(session, text, [], 'queue'))
return
}
if (session && typeof session.prompt === 'function') {
check(await session.prompt([{ type: 'text', text }], 'queue'))
return
}
const conv = sessions?.scope?.(id)?.get?.('conversation')
if (typeof conv?.send === 'function') { check(await conv.send(text)); return }
throw new Error('no send path: session face unavailable')
}
if (typeof sessions?.using === 'function') {
await sessions.using(id, { source: 'dshWorktable' }, async (ref: any) => {
const binding = await ref.ready
await send(binding.session)
})
return
}
let face: any = null
for (let i = 0; i < 10; i++) {
face = sessions?.binding?.(id)?.session
if (face) break
await new Promise((resolve) => setTimeout(resolve, 200))
}
await send(face)
}
/** Read-only adapter. A stopped run is not proof that a task succeeded. */
export function createSessionSnapshotAdapter() {
const previousRunning = new Map<string, boolean>()
const stopped = new Set<string>()
return (snapshot: any, statusSource?: any): any => {
// Preserve the legacy contract verbatim; idle old sessions must not become completed.
if (!snapshot || typeof snapshot.current === 'string') return snapshot
const statuses = statusSource?.getSnapshot?.()
const byId: Record<string, any> = {}
const subagentsByParent = { ...snapshot.subagentsByParent }
for (const [id, row] of Object.entries(snapshot.byId ?? {}) as [string, any][]) {
const status = statuses?.get?.(id)
const running = status?.running ?? row.running
if (running === true) stopped.delete(id)
else if (running === false && previousRunning.get(id) === true) stopped.add(id)
if (typeof running === 'boolean') previousRunning.set(id, running)
byId[id] = {
...row, running,
pendingInteraction: status ? status.pendingInteraction : row.pendingInteraction,
// completionUnread clears when opening the main view. Keep a witnessed run-stop
// until the next run so an active project's widget-result handshake still runs.
completed: running !== true && (stopped.has(id) || status?.completionUnread === true || row.completed === true),
}
const children = snapshot.projectionsBySession?.[id]?.values?.subagentCatalog ?? row.projectionValues?.subagentCatalog
if (Array.isArray(children)) subagentsByParent[id] = children
}
if (snapshot.phase === 'ready') {
for (const id of previousRunning.keys()) if (!byId[id]) { previousRunning.delete(id); stopped.delete(id) }
}
return { ...snapshot, byId, subagentsByParent }
}
}
/** Parent links and the host's child catalog can each contain otherwise missing children. */
export function childSessionIdsOf(snapshot: any, sid: string): Set<string> {
const children = new Set<string>()
for (const [id, row] of Object.entries(snapshot?.byId ?? {}) as [string, any][]) {
if (id !== sid && row?.parentId === sid) children.add(id)
}
const catalog = snapshot?.subagentsByParent?.[sid]
const rows = Array.isArray(catalog) ? catalog : (catalog?.entries ?? catalog?.items ?? [])
if (Array.isArray(rows)) for (const row of rows) {
const id = row?.sessionId ?? row?.id
if (typeof id === 'string' && id !== sid) children.add(id)
}
return children
}
/** Persist only opaque interaction identities, never their questions or answers.
* Legacy hosts with no interaction key keep the old boolean-state acknowledgement.
* New hosts must not let acknowledgement of question A suppress replacement B.
*/
export function pendingAckOf(snapshot: any, sid: string, legacyFace?: any): string | null {
const identities: string[] = []
let identified = false
const add = (id: string, pending: any) => {
if (pending == null) return
const key = typeof pending?.key === 'string' ? pending.key : ''
if (key) identified = true
identities.push(JSON.stringify([id, String(pending?.kind ?? ''), key]))
}
const ids = [sid, ...childSessionIdsOf(snapshot, sid)]
for (const id of ids) add(id, snapshot?.byId?.[id]?.pendingInteraction)
// Read the old face only when the parent list has not already supplied its pending state.
if (snapshot?.byId?.[sid]?.pendingInteraction == null && Array.isArray(legacyFace?.pending)) {
for (const pending of legacyFace.pending) add(sid, pending)
}
if (!identities.length) return null
return identified ? 'need:' + JSON.stringify([...new Set(identities)].sort()) : 'need'
}
/** Read host memory only. Do not activate a chat target or retain a cold session to get a timer. */
export function sessionRuntimeMs(snapshot: any, sid: string, sessions: any, uiConversation?: any, now = Date.now()): number | null {
if (snapshot?.byId?.[sid]?.running !== true) return null
const validStart = (value: any): value is number => typeof value === 'number' && Number.isFinite(value) && value >= 0
let start: number | null = null
for (const job of snapshot?.jobsBySession?.[sid] ?? []) {
if (job?.status === 'running' && validStart(job.startedAt) && (start == null || job.startedAt < start)) start = job.startedAt
}
if (start != null) return Math.max(0, now - start)
let timings: any
try { timings = uiConversation?.binding?.(sid)?.target?.('chat')?.getSnapshot?.()?.legacy?.turnTimings } catch {}
if (timings == null) {
try { timings = sessions?.binding?.(sid)?.session?.getSnapshot?.()?.turnTimings } catch {}
}
// ReadonlyMap/another realm's Map must work as well as the old object representation.
const turns: any[] = typeof timings?.values === 'function'
? Array.from(timings.values()) : Object.values(timings ?? {})
for (const turn of turns) {
if (turn?.endTime == null && validStart(turn?.startTime) && (start == null || turn.startTime > start)) start = turn.startTime
}
return start == null ? null : Math.max(0, now - start)
}
+75
View File
@@ -0,0 +1,75 @@
/** Text-only preview. Never returns reasoning, tool arguments, or modifies stored messages. */
export function cleanPreviewText(raw: string): string {
return String(raw ?? '')
.replace(/```[a-zA-Z0-9_+-]*[\s\S]*?```/g, ' ')
.replace(/```[a-zA-Z0-9_+-]*[\s\S]*$/g, ' ')
.replace(/`[^`\n]{1,200}`/g, ' ')
.replace(/```/g, ' ')
.replace(/\s+/g, ' ').trim()
}
export function previewFromEvents(entries: any): string {
if (!Array.isArray(entries)) return ''
for (let i = entries.length - 1; i >= 0; i--) {
const ev = entries[i]?.event
if (ev?.type !== 'user/message' && ev?.type !== 'assistant/message') continue
const data = ev.type === 'assistant/message' ? ev.data?.message ?? ev.data : ev.data
const blocks = data?.content ?? data?.blocks
if (!Array.isArray(blocks)) continue
const raw = blocks.filter((b: any) => b?.type === 'text' && typeof b.text === 'string').map((b: any) => b.text).join('\n')
const text = cleanPreviewText(raw)
if (text.length >= 8) return text.slice(0, 220)
}
return ''
}
export async function readSessionPreview(sessions: any, id: string): Promise<string> {
if (typeof sessions?.using === 'function') {
return sessions.using(id, { source: 'dshWorktablePreview' }, async (ref: any) => {
const binding = await ref.ready
return previewFromEvents(binding.eventSource?.getSnapshot?.()?.entries)
})
}
const face = sessions?.binding?.(id)?.session
if (typeof face?.history !== 'function') return ''
const result = await face.history({ maxMessages: 6 })
return previewFromEvents(result?.result?.value?.events)
}
/** Normalize only the transport envelope; preserve the existing selection policy. */
export function presetApiOf(ctx: any, legacy: any): any {
const api = typeof ctx?.get === 'function' ? ctx.get('remote.agentPresets') : ctx?.remote?.agentPresets
if (typeof api?.list !== 'function' || typeof api?.select !== 'function') return legacy
return {
list: async () => ({ result: await api.list() }),
select: async ({ sessionId, agentPreset }: any) => ({ result: await api.select(sessionId, agentPreset) }),
}
}
/** 0.2 model directories belong to retained scopes. Do not cache them after release. */
export function modelApiOf(ctx: any, sessions: any, legacy: any): any {
if (typeof sessions?.using !== 'function') return legacy
const resolver = typeof ctx?.get === 'function' ? ctx.get('modelDirectories') : ctx?.modelDirectories
if (typeof resolver?.directoryFor !== 'function') return legacy
const withDirectory = (id: string, operation: (directory: any) => Promise<any>) =>
sessions.using(id, { source: 'dshWorktableModel' }, async (ref: any) => {
await ref.ready
return operation(resolver.directoryFor(id))
})
return {
models: async ({ sessionId }: any) => withDirectory(sessionId, async (directory) => ({ result: { ok: true, value: await directory.load() } })),
selectModel: async ({ sessionId, ...selection }: any) => withDirectory(sessionId, async (directory) => ({ result: await directory.select(selection) })),
}
}
/** 0.2's blank composer requires a registered Workspace, not only a Session cwd. */
export async function createHostSession(sessions: any, workspaces: any, options: { workspaceId?: string; cwd?: string; sessionId?: string } = {}): Promise<string> {
if (options.workspaceId || typeof sessions?.using !== 'function') return sessions.create(options)
// The Host owns path canonicalization and idempotent registration. Never invent an id.
const workspace = options.cwd
? await workspaces?.create?.({ path: options.cwd })
: await workspaces?.initializeDefault?.()
if (typeof workspace?.workspaceId !== 'string' || !workspace.workspaceId) throw new Error('workspace unavailable for new session')
const { cwd: _cwd, ...rest } = options
return sessions.create({ ...rest, workspaceId: workspace.workspaceId })
}
+2 -19
View File
@@ -220,6 +220,7 @@ export type ConsoleCardData = {
}
type SplitEnv = {
fillHostInput: (text: string) => boolean
getScope: () => SplitScope | null
getJobs: () => SplitJob[]
getSubagents: () => any[]
@@ -504,25 +505,7 @@ async function copyTextSafe(text: string): Promise<boolean> {
/** 注入宿主对话框输入框(不发送);失败返回 false */
function fillHostInput(text: string): boolean {
try {
const ta = document.querySelector<HTMLTextAreaElement>('textarea[data-phase]')
?? Array.from(document.querySelectorAll<HTMLTextAreaElement>('textarea')).pop()
if (!ta) return false
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set
// 连续标注:已有内容不顶掉,换行追加(用户可以同一对话连续标注多处)
const next = ta.value && ta.value.trim() ? ta.value + '\n\n' + text : text
if (setter) setter.call(ta, next)
else ta.value = next
ta.dispatchEvent(new Event('input', { bubbles: true }))
try {
ta.focus()
ta.dispatchEvent(new Event('change', { bubbles: true }))
ta.setSelectionRange(ta.value.length, ta.value.length)
} catch {}
return true
} catch {
return false
}
return splitEnv?.fillHostInput(text) ?? false
}
/** ✓ 确认:打包并注入,不发送 */