From 1d8e969a88f7f5b2f211daf4558a13efc9aa7d1e Mon Sep 17 00:00:00 2001 From: Aisland Date: Mon, 17 Aug 2026 12:10:34 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=A7=BB=E9=99=A4=20props.useSessions?= =?UTF-8?q?=EF=BC=88=E5=AE=BF=E4=B8=BBuseSyncExternalStore=E5=B4=A9?= =?UTF-8?q?=E6=BA=83=EF=BC=89=EF=BC=8C=E6=94=B9=20ctx.sessions.list=20?= =?UTF-8?q?=E8=AE=A2=E9=98=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 01_content/src/client/index.tsx | 46 +++++++--- .../worklogs/2026-08-16_worktable-v2.md | 16 ++++ 04_test/bundle-eval.cjs | 66 ++++++++++++++ 04_test/headless-diag.cjs | 86 +++++++++++++++++++ 4 files changed, 204 insertions(+), 10 deletions(-) create mode 100644 04_test/bundle-eval.cjs create mode 100644 04_test/headless-diag.cjs diff --git a/01_content/src/client/index.tsx b/01_content/src/client/index.tsx index 3fa6b7b..e03f492 100644 --- a/01_content/src/client/index.tsx +++ b/01_content/src/client/index.tsx @@ -229,6 +229,26 @@ function findSidebar(start: HTMLElement | null): HTMLElement | null { /** 子座位注册 id 序列(模块级 store;apply 里订阅 slots 变化写入)。 */ const registryStore: { ids: string[]; listeners: Set<() => void> } = { ids: [], listeners: new Set() } +/** 会话作用域快照(模块级;apply 里订阅 ctx.sessions.list 写入,组件与引擎只读) */ +const sessionScopeStore: { + snapshot: { sessionId: string; cwd: string; jobs: any[] } | null +} = { snapshot: null } + +function syncSessionScope(list: any) { + try { + const snap = list.getSnapshot() + const current: string = snap?.current ?? '' + const entry = snap?.items?.find((it: any) => it.sessionId === current) ?? null + sessionScopeStore.snapshot = { + sessionId: current, + cwd: entry?.cwd ?? '', + jobs: (snap?.jobsBySession?.[current] ?? []) as any[], + } + } catch { + sessionScopeStore.snapshot = { sessionId: '', cwd: '', jobs: [] } + } +} + function WorktableSection(props: any) { const wide = props.wide !== false const renderProjectSlot = typeof props.renderSlot === 'function' ? props.renderSlot : null @@ -272,17 +292,15 @@ function WorktableSection(props: any) { ) // 会话作用域(当前会话 + 工作目录)与后台任务:注入分栏引擎环境 - const useSessions = typeof props.useSessions === 'function' ? props.useSessions : null - const sessionsSnap = useSessions ? useSessions() : null - const currentEntry = sessionsSnap?.items?.find((it: any) => it.sessionId === sessionsSnap?.current) ?? null - const scopeRef = useRef<{ sessionId: string; cwd: string }>({ sessionId: '', cwd: '' }) - const jobsRef = useRef([]) - scopeRef.current = { sessionId: sessionsSnap?.current ?? '', cwd: currentEntry?.cwd ?? '' } - jobsRef.current = sessionsSnap?.jobsBySession?.[sessionsSnap?.current] ?? [] + // 注意:不走 props.useSessions hook(其宿主包装在部分版本会触发 useSyncExternalStore + // 崩溃),改为 apply 里订阅 ctx.sessions.list 后写入模块级 store,此处直接读取。 useEffect(() => { setSplitEnv({ - getScope: () => scopeRef.current, - getJobs: () => jobsRef.current, + getScope: () => { + const s = sessionScopeStore.snapshot + return s ? { sessionId: s.sessionId, cwd: s.cwd } : null + }, + getJobs: () => sessionScopeStore.snapshot?.jobs ?? [], }) return () => setSplitEnv(null) }, []) @@ -911,7 +929,7 @@ function WorktableSection(props: any) { ) } -export const inject = ['slots', 'locale'] +export const inject = ['slots', 'locale', 'sessions'] export function apply(ctx: any) { ctx.effect(() => { @@ -942,6 +960,14 @@ export function apply(ctx: any) { syncIds() ctx.effect(() => disposeSubscribe, 'dsh-worktable: project registry watch') + // 会话作用域(当前会话 cwd 与后台任务)→ 功能窗数据源 + const sessionsList = ctx.sessions?.list + if (sessionsList && typeof sessionsList.getSnapshot === 'function') { + syncSessionScope(sessionsList) + const disposeScope = sessionsList.subscribe(() => syncSessionScope(sessionsList)) + ctx.effect(() => disposeScope, 'dsh-worktable: session scope watch') + } + // 分栏工作区浮层(M1 通用引擎,shell.overlay 座位) ctx.slots.inject('shell.overlay', () => ctx.slots.register({ name: 'shell.overlay', diff --git a/02_process/worklogs/2026-08-16_worktable-v2.md b/02_process/worklogs/2026-08-16_worktable-v2.md index b1404ee..711a1f9 100644 --- a/02_process/worklogs/2026-08-16_worktable-v2.md +++ b/02_process/worklogs/2026-08-16_worktable-v2.md @@ -357,3 +357,19 @@ 未安排自动重启(用户要求,其他窗口仍在工作)。 - 待后续:文件打开/编辑、SCM diff/暂存/提交、终端 cwd 信任客户端(better-sidebar 用 服务端 header.cwd,后续对齐)、jobs 输出回放。 + +## 补记(重启后工作台消失:useSessions 崩溃排查与修复) + +- 现象:用户重启 dsh web 后侧边栏工作台与各项目卡片全部消失(服务端正常、bundle 正常下发)。 +- 排查:opencli 绑定用户标签页失败(被另一 Chrome 扩展占用调试通道);改用 + Node + headless Chrome + CDP 自建诊断(04_test/headless-diag.cjs),在真实浏览器引擎 + 中复现并捕获到根因:`slot entry crashed in 'sidebar.footer.action': + TypeError: w is not a function`(宿主 useSyncExternalStore 包装层)——触发点是 + WorktableSection 调用 `props.useSessions()`(GlobalStandardProps 的 selector hook + 在该宿主版本的 footer.action 座位里崩坏)。 +- 修复:不再使用 props.useSessions;改为 apply() 里订阅 `ctx.sessions.list` + (ObservableSnapshot.getSnapshot/subscribe,client inject 增加 'sessions'), + 写入模块级 sessionScopeStore;组件与分栏引擎(setSplitEnv getScope/getJobs)直接读该快照。 +- 回归验证:headless Chrome 重跑——wtSection/railBox 渲染正常、ERRORS_COUNT=0; + 用户刷新即恢复(客户端实时下发,无需重启)。 +- 工具沉淀:04_test/headless-diag.cjs(headless Chrome + CDP 页面诊断,可复用)。 diff --git a/04_test/bundle-eval.cjs b/04_test/bundle-eval.cjs new file mode 100644 index 0000000..f55832d --- /dev/null +++ b/04_test/bundle-eval.cjs @@ -0,0 +1,66 @@ +const fs = require("fs"); +const vm = require("vm"); +const code = fs.readFileSync("E:/AI_Workspace/DeepseekHarness/Projects/dsh-worktable/01_content/lib/client.js", "utf8"); +function makeEl() { + return { + setAttribute(){}, removeAttribute(){}, appendChild(){}, remove(){}, addEventListener(){}, + removeEventListener(){}, classList: { add(){}, remove(){}, contains(){ return false } }, + style: {}, dataset: {}, textContent: "", children: [], + }; +} +let registered = null; +const moduleLoader = { load(spec){ registered = spec; } }; +const sandbox = { self: null, + console, setTimeout, clearTimeout, setInterval, clearInterval, + window: { + __ModuleLoader__: moduleLoader, + addEventListener(){}, removeEventListener(){}, dispatchEvent(){ return true }, + innerWidth: 1440, innerHeight: 900, setTimeout, clearTimeout, setInterval, clearInterval, + }, + __ModuleLoader__: moduleLoader, + document: { + createElement(){ return makeEl() }, + head: { appendChild(){}, removeChild(){} }, + body: { appendChild(){} }, + querySelector(){ return null }, querySelectorAll(){ return [] }, + addEventListener(){}, removeEventListener(){}, + }, + localStorage: { getItem(){ return null }, setItem(){}, removeItem(){} }, + CustomEvent: class { constructor(t, o){ this.type = t; this.detail = o && o.detail } }, + ResizeObserver: class { observe(){} disconnect(){} }, + MutationObserver: class { observe(){} disconnect(){} }, + navigator: {}, location: { protocol: "http:", host: "127.0.0.1:3080" }, + fetch: async () => { throw new Error("no fetch") }, + WebSocket: class {}, + URL: require("url").URL, URLSearchParams: require("url").URLSearchParams, + TextDecoder: require("util").TextDecoder, TextEncoder: require("util").TextEncoder, + performance: { now: () => 0 }, +}; +sandbox.self = sandbox; try { + vm.createContext(sandbox); + vm.runInContext(code, sandbox, { filename: "client.js" }); + console.log("模块作用域执行 OK;已注册:", registered && registered.id); + if (registered) { + const fakeRequire = (name) => { + const stubs = { + "react": { useState: (v)=>[v,()=>{}], useEffect: ()=>{}, useLayoutEffect: ()=>{}, useMemo: (f)=>f(), useCallback: (f)=>f, useRef: (v)=>({current:v}), createElement: ()=>null, Fragment: null }, + "react/jsx-runtime": { jsx: ()=>({}) }, + "react/jsx-dev-runtime": { jsx: ()=>({}) }, + "@deepseek-ai/dsh-client-ui-slots": {}, + "@deepseek-ai/dsh-client-ui-primitives": {}, + "@deepseek-ai/dsh-client-locale": {}, + }; + if (name in stubs) return stubs[name]; + throw new Error("unexpected require: " + name); + }; + sandbox.self = sandbox; try { + const exports = registered.factory(fakeRequire); + console.log("工厂执行 OK, exports keys:", Object.keys(exports || {})); + } catch (e) { + console.log("工厂执行抛错:", e && e.stack ? e.stack.split("\n").slice(0,8).join("\n") : e); + } + } +} catch (e) { + console.log("模块作用域抛错:", e && e.stack ? e.stack.split("\n").slice(0,10).join("\n") : e); +} + diff --git a/04_test/headless-diag.cjs b/04_test/headless-diag.cjs new file mode 100644 index 0000000..bdbd7b8 --- /dev/null +++ b/04_test/headless-diag.cjs @@ -0,0 +1,86 @@ + +const http = require('http'); +const { spawn } = require('child_process'); +const fs = require('fs'); +const WebSocket = require('ws'); + +const PORT = 9333; +const candidates = [ + 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', + 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', + 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe', + 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe', +]; +const chrome = candidates.find((p) => fs.existsSync(p)); +if (!chrome) { console.log('NO_BROWSER_FOUND'); process.exit(1); } +console.log('browser:', chrome); + +const proc = spawn(chrome, [ + '--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check', + '--remote-debugging-port=' + PORT, + '--user-data-dir=C:\\Users\\SJL\\AppData\\Local\\Temp\\wt-chrome-debug', + 'about:blank', +], { stdio: 'ignore' }); + +function getJSON(path) { + return new Promise((res, rej) => { + http.get({ host: '127.0.0.1', port: PORT, path }, (r) => { + let d = ''; + r.on('data', (c) => d += c); + r.on('end', () => { try { res(JSON.parse(d)) } catch (e) { rej(e) } }); + }).on('error', rej); + }); +} +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +(async () => { + let list = null; + for (let i = 0; i < 40; i++) { + try { list = await getJSON('/json/list'); if (list && list.length) break; } catch {} + await sleep(500); + } + if (!list || !list.length) { console.log('NO_DEBUG_TARGET'); proc.kill(); process.exit(1); } + const target = list.find((t) => t.type === 'page') || list[0]; + const ws = new WebSocket(target.webSocketDebuggerUrl); + let id = 0; + const pending = new Map(); + const events = []; + const send = (method, params) => new Promise((res) => { + const mid = ++id; + pending.set(mid, res); + ws.send(JSON.stringify({ id: mid, method, params })); + }); + ws.on('message', (raw) => { + const msg = JSON.parse(String(raw)); + if (msg.id && pending.has(msg.id)) { pending.get(msg.id)(msg); pending.delete(msg.id); } + else if (msg.method) events.push(msg); + }); + await new Promise((r) => ws.on('open', r)); + await send('Runtime.enable'); + await send('Log.enable'); + await send('Page.enable'); + await send('Page.navigate', { url: 'http://127.0.0.1:3080/' }); + await sleep(12000); + const result = await send('Runtime.evaluate', { + expression: "JSON.stringify({ wtSection: !!document.querySelector('.dsh-wt_section'), taCard: !!document.querySelector('.ta_card'), prCard: !!document.querySelector('.pr_card'), taEntry: !!document.querySelector('.dsh-ta_entry'), usageDock: !!document.querySelector('.u_dock'), railBox: !!document.querySelector('.dsh-wt_railBox'), readyState: document.readyState, bodyText: (document.body ? document.body.innerText.slice(0,200) : '') })", + returnByValue: true, + }); + console.log('DOM_STATE:', result.result && result.result.result && result.result.result.value); + const errors = events.filter((e) => + e.method === 'Runtime.exceptionThrown' || + (e.method === 'Log.entryAdded' && (e.params.entry.level === 'error' || e.params.entry.level === 'warning')) || + (e.method === 'Runtime.consoleAPICalled' && e.params.type === 'error'), + ).slice(0, 25).map((e) => { + if (e.method === 'Runtime.exceptionThrown') { + const d = e.params.exceptionDetails; + return 'EXCEPTION: ' + (d.exception && d.exception.description || d.text) + ' @ ' + (d.url || '') + ':' + (d.lineNumber || 0); + } + if (e.method === 'Log.entryAdded') return 'LOG[' + e.params.entry.level + ']: ' + (e.params.entry.text || '').slice(0, 300); + return 'CONSOLE_ERROR: ' + (e.params.args || []).map((a) => a.value || a.description || '').join(' ').slice(0, 300); + }); + console.log('ERRORS_COUNT:', errors.length); + errors.forEach((x) => console.log(x)); + ws.close(); + proc.kill(); + process.exit(0); +})().catch((e) => { console.log('SCRIPT_FAIL:', e); proc.kill(); process.exit(1); });