diff --git a/01_content/src/client/index.tsx b/01_content/src/client/index.tsx index e03f492..e81f882 100644 --- a/01_content/src/client/index.tsx +++ b/01_content/src/client/index.tsx @@ -231,7 +231,7 @@ const registryStore: { ids: string[]; listeners: Set<() => void> } = { ids: [], /** 会话作用域快照(模块级;apply 里订阅 ctx.sessions.list 写入,组件与引擎只读) */ const sessionScopeStore: { - snapshot: { sessionId: string; cwd: string; jobs: any[] } | null + snapshot: { sessionId: string; cwd: string; jobs: any[]; subagents: any[] } | null } = { snapshot: null } function syncSessionScope(list: any) { @@ -239,13 +239,20 @@ function syncSessionScope(list: any) { const snap = list.getSnapshot() const current: string = snap?.current ?? '' const entry = snap?.items?.find((it: any) => it.sessionId === current) ?? null + const cat = snap?.subagentsByParent?.[current] + let subagents: any[] = [] + if (Array.isArray(cat)) subagents = cat + else if (cat && Array.isArray(cat.entries)) subagents = cat.entries + else if (cat && Array.isArray(cat.items)) subagents = cat.items + else if (cat && Array.isArray(cat.children)) subagents = cat.children sessionScopeStore.snapshot = { sessionId: current, cwd: entry?.cwd ?? '', jobs: (snap?.jobsBySession?.[current] ?? []) as any[], + subagents, } } catch { - sessionScopeStore.snapshot = { sessionId: '', cwd: '', jobs: [] } + sessionScopeStore.snapshot = { sessionId: '', cwd: '', jobs: [], subagents: [] } } } @@ -301,6 +308,7 @@ function WorktableSection(props: any) { return s ? { sessionId: s.sessionId, cwd: s.cwd } : null }, getJobs: () => sessionScopeStore.snapshot?.jobs ?? [], + getSubagents: () => sessionScopeStore.snapshot?.subagents ?? [], }) return () => setSplitEnv(null) }, []) diff --git a/01_content/src/client/locales.ts b/01_content/src/client/locales.ts index 4c55ac6..1384090 100644 --- a/01_content/src/client/locales.ts +++ b/01_content/src/client/locales.ts @@ -62,6 +62,9 @@ export const zh = { 'pane.jobsEmpty': '当前会话暂无后台任务', 'pane.termFail': '终端不可用(宿主缺少 node-pty/ws 或连接失败)', 'pane.closeTab': '关闭标签页', + 'pane.jobsTitle': '后台任务', + 'pane.subagents': '子代理', + 'pane.subagentsEmpty': '当前会话暂无子代理', 'split.flip': '切换对话窗位置(左⇄右)', 'split.dragSwap': '拖动标题栏与其他窗交换位置', 'empty': '暂无项目', @@ -128,6 +131,9 @@ export const en = { 'pane.jobsEmpty': 'No background jobs in the current session', 'pane.termFail': 'Terminal unavailable (host missing node-pty/ws, or connection failed)', 'pane.closeTab': 'Close tab', + 'pane.jobsTitle': 'Background jobs', + 'pane.subagents': 'Subagents', + 'pane.subagentsEmpty': 'No subagents in the current session', 'split.flip': 'Flip chat side (left ⇄ right)', 'split.dragSwap': 'Drag the title bar to swap panes', 'empty': 'No projects yet', diff --git a/01_content/src/client/split.tsx b/01_content/src/client/split.tsx index 3ff2a5c..f87c880 100644 --- a/01_content/src/client/split.tsx +++ b/01_content/src/client/split.tsx @@ -151,6 +151,7 @@ export type SplitJob = { type SplitEnv = { getScope: () => SplitScope | null getJobs: () => SplitJob[] + getSubagents: () => any[] } let splitEnv: SplitEnv | null = null export function setSplitEnv(env: SplitEnv | null) { @@ -722,63 +723,122 @@ function BrowserPane() { ) } -/** 资源管理器窗(服务端 /api/worktable/fs) */ +/** 文件夹图标(重绘 SVG,与 better-sidebar 同款风格) */ +function FolderIcon() { + return ( + + + + + ) +} + +/** 文件图标(重绘 SVG) */ +function FileIcon() { + return ( + + + + + ) +} + +/** 资源管理器窗:树形展开(懒加载子目录;刷新/上一级均可用) */ function ExplorerPane() { - const [path, setPath] = useState('') - const [entries, setEntries] = useState([]) + const cacheRef = useRef>({}) + const expandedRef = useRef>(new Set()) + const [rootPath, setRootPath] = useState('') const [error, setError] = useState('') - const [loading, setLoading] = useState(false) - const [history, setHistory] = useState([]) - const load = useCallback((p: string, push: boolean) => { - setLoading(true) - postJson('/api/worktable/fs', { - path: p, - sessionId: splitEnv?.getScope()?.sessionId ?? '', - cwd: splitEnv?.getScope()?.cwd ?? '', - }) - .then((d) => { - setPath(d.path ?? p) - setEntries(d.entries ?? []) - setError('') - setHistory((h) => (push && p !== d.path ? [...h, p] : h)) + const [, setTick] = useState(0) + const rerender = () => setTick((t) => t + 1) + + const fetchDir = useCallback(async (path: string, force = false) => { + if (!force && cacheRef.current[path]) return { path, entries: cacheRef.current[path] } + try { + const d = await postJson('/api/worktable/fs', { + path, + sessionId: splitEnv?.getScope()?.sessionId ?? '', + cwd: splitEnv?.getScope()?.cwd ?? '', }) - .catch((e) => setError(String(e))) - .finally(() => setLoading(false)) + const entries: any[] = d.entries ?? [] + cacheRef.current[d.path] = entries + setError(d.error ? String(d.error) : '') + return { path: d.path, entries } + } catch (e) { + setError(String(e)) + return { path, entries: [] } + } finally { + rerender() + } }, []) - useEffect(() => { - load(splitEnv?.getScope()?.cwd ?? '', false) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - const back = () => { - setHistory((h) => { - if (h.length === 0) return h - const prev = h[h.length - 1] - load(prev, false) - return h.slice(0, -1) - }) + + const initRoot = useCallback(async () => { + const r = await fetchDir(splitEnv?.getScope()?.cwd ?? '') + setRootPath(r.path) + rerender() + }, [fetchDir]) + + useEffect(() => { initRoot() }, [initRoot]) + + const toggle = (path: string) => { + if (expandedRef.current.has(path)) expandedRef.current.delete(path) + else { expandedRef.current.add(path); fetchDir(path) } + rerender() } + + const refresh = () => { + cacheRef.current = {} + expandedRef.current.clear() + setError('') + initRoot() + } + + const goUp = () => { + if (!rootPath) return + const parent = parentPathOf(rootPath) + if (parent === rootPath) return + cacheRef.current = {} + expandedRef.current.clear() + setError('') + fetchDir(parent).then((r) => { setRootPath(r.path); rerender() }) + } + + const renderLevel = (path: string, depth: number): any[] => { + const entries = cacheRef.current[path] + if (!entries) return [] + const nodes: any[] = [] + for (const e of entries) { + const isOpen = expandedRef.current.has(e.path) + nodes.push( +
+ + {e.isDir && isOpen && renderLevel(e.path, depth + 1)} +
, + ) + } + return nodes + } + return ( <>
- - - - {path || '…'} + + + {rootPath || '…'}
{error &&
{error}
} - {!error && !loading && entries.length === 0 &&
—
} - {entries.map((e) => ( - - ))} + {!error && cacheRef.current[rootPath]?.length === 0 &&
—
} + {renderLevel(rootPath, 0)}
) @@ -818,7 +878,7 @@ function GitPane() { ) } -/** 任务管理窗(客户端 sessions 快照 jobsBySession;2s 刷新) */ +/** 任务管理窗:后台任务 + 子代理(Agent 情况;2s 刷新) */ function JobsPane() { const [, setTick] = useState(0) useEffect(() => { @@ -826,8 +886,10 @@ function JobsPane() { return () => window.clearInterval(timer) }, []) const jobs = splitEnv?.getJobs?.() ?? [] + const subagents = splitEnv?.getSubagents?.() ?? [] return (
+
{T('pane.jobsTitle')}
{jobs.length === 0 &&
{T('pane.jobsEmpty')}
} {jobs.map((j) => (
@@ -836,6 +898,15 @@ function JobsPane() { {j.kind}
))} +
{T('pane.subagents')}
+ {subagents.length === 0 &&
{T('pane.subagentsEmpty')}
} + {subagents.map((s: any, i: number) => ( +
+ ● + {s?.label ?? s?.title ?? s?.name ?? '—'} + {s?.status && {s.status}} +
+ ))}
) } diff --git a/01_content/src/client/styles.ts b/01_content/src/client/styles.ts index dbc61b3..7cb8b67 100644 --- a/01_content/src/client/styles.ts +++ b/01_content/src/client/styles.ts @@ -160,4 +160,12 @@ export const css = [ '.dsh-wt_tabTitle{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}', '.dsh-wt_tabClose{flex:none;width:14px;height:14px;padding:0;border:none;border-radius:3px;background:transparent;color:var(--dsw-alias-label-tertiary,#6e7683);font-size:9px;line-height:1;cursor:pointer}', '.dsh-wt_tabClose:hover{color:var(--dsw-alias-label-primary,#e6e8eb);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.08))}', + // 树形资源管理器 + '.dsh-wt_treeRow{display:flex;align-items:center;gap:5px;width:100%;box-sizing:border-box;padding:3px 6px;border:none;border-radius:5px;background:transparent;color:var(--dsw-alias-label-primary,#e6e8eb);font:inherit;font-size:11px;line-height:16px;cursor:pointer;text-align:left}', + '.dsh-wt_treeRow:hover{background:var(--dsw-alias-fill-l1,rgba(255,255,255,.05))}', + '.dsh-wt_treeArrow{flex:none;width:10px;font-size:8px;color:var(--dsw-alias-label-tertiary,#6e7683);transition:transform .12s ease}', + '.dsh-wt_treeArrowOpen{transform:rotate(90deg)}', + '.dsh-wt_treeIcon{flex:none}', + '.dsh-wt_treeName{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}', + '.dsh-wt_subSection{flex:none;padding:6px 8px 2px;font-size:10px;font-weight:600;letter-spacing:.05em;color:var(--dsw-alias-label-tertiary,#6e7683)}', ].join('\n') diff --git a/01_content/src/index.ts b/01_content/src/index.ts index cb92467..c786db3 100644 --- a/01_content/src/index.ts +++ b/01_content/src/index.ts @@ -1,7 +1,9 @@ import { Context } from '@deepseek-ai/cordis' import { execFile } from 'node:child_process' -import { readdir } from 'node:fs/promises' -import { resolve, sep } from 'node:path' +import { readdir, realpathSync } from 'node:fs' +import { dirname, resolve as pathResolve, sep } from 'node:path' +import { createRequire } from 'node:module' +import { fileURLToPath, pathToFileURL } from 'node:url' /** * dsh-worktable 服务端:健康路由 + 工作区内容窗的数据路由。 @@ -19,6 +21,28 @@ export const HEALTH_PATH = '/api/worktable/health' const MAX_ENTRIES = 500 +/** + * 从本插件模块位置向祖先方向查找并加载 node_modules 包(如 ws / node-pty)。 + * 本包经 junction 链接进 profile,普通 import 可能解析不到 profile 级依赖; + * 同时尝试 junction 路径与 realpath 两条祖先链。 + */ +function loadPkg(pkg: string): any | null { + const starts = new Set() + try { starts.add(dirname(fileURLToPath(import.meta.url))) } catch {} + try { starts.add(realpathSync(dirname(fileURLToPath(import.meta.url)))) } catch {} + for (const start of starts) { + let dir: string | null = start + while (dir && dir !== pathResolve(dir, '..')) { + try { + const req = createRequire(pathToFileURL(pathResolve(dir, '__wt_probe__.js')).href) + return req(pkg) + } catch {} + dir = pathResolve(dir, '..') + } + } + return null +} + /** 解析会话工作目录:服务端 header.cwd 优先,其次客户端传入 cwd,最后进程 cwd */ function serverCwd(ctx: any, sessionId?: string, clientCwd?: string): string { if (sessionId) { @@ -89,7 +113,12 @@ async function setupTerminal(webServer: any, ctx: any) { let ptyMod: any = null try { wsMod = await import('ws') } catch {} try { ptyMod = await import('node-pty') } catch {} - if (!wsMod || !ptyMod) return + if (!wsMod) wsMod = loadPkg('ws') + if (!ptyMod) ptyMod = loadPkg('node-pty') + if (!wsMod || !ptyMod) { + ctx.logger?.warn('[dsh-worktable] 终端路由未注册:ws/node-pty 均不可用') + return + } const WebSocketServer = wsMod.WebSocketServer ?? wsMod.default?.WebSocketServer if (!WebSocketServer) return const pty = ptyMod.default ?? ptyMod diff --git a/02_process/worklogs/2026-08-16_worktable-v2.md b/02_process/worklogs/2026-08-16_worktable-v2.md index 592c4c1..dd91430 100644 --- a/02_process/worklogs/2026-08-16_worktable-v2.md +++ b/02_process/worklogs/2026-08-16_worktable-v2.md @@ -389,3 +389,22 @@ ③ 任务窗数据源不变(jobsBySession,无后台任务时显示空态文案)。 - 验证:构建 + node --check 通过;headless Chrome 回归零报错、区块正常渲染。 - 注意:服务端 cwd 解析需**重启 dsh web 后生效**;标签页模型为客户端改动、刷新即生效。 + +## 补记(对照 better-sidebar 源码重做:树形资源管理器 / 子代理任务窗 / 终端依赖解析) + +- 用户反馈:① 资源管理器刷新/后退按钮失效(只有上一级能用);② 终端空白;③ 任务窗 + 没有 Agent 情况;④ 要求对照 better-sidebar 源码重做(树形展开 + 重绘图标)。 +- 调研:better-sidebar 客户端 16px 描边 SVG 图标 + 子代理树(aria-expanded、缩进、当前高亮); + 服务端 inject ['webServer','sessions','webRuntime','tools']。 +- 实现: + ① 资源管理器重写为树形:懒加载子目录(缓存 + 展开集)、▸/▾ 旋转箭头、重绘文件夹/文件 + SVG 图标、缩进层级;刷新(清缓存重载根)与上一级(根上移)修复可用(旧版在 setState + 更新器里做副作用导致按钮失效,已避免)。 + ② 任务窗增加「子代理」区(sessions 快照 subagentsByParent[current],防御式取数, + 状态圆点 + 缩进),与后台任务并列。 + ③ 终端空白根因:服务端 import('node-pty')/import('ws') 失败——本包经 junction 链接, + 模块真实路径在工作区,向上找不到 profile 级依赖。修复:loadPkg 沿「junction 路径 + + realpath」两条祖先链 createRequire 查找并加载;已本地验证 junction 路径可解析 + node-pty/ws。**需重启 dsh web 后终端路由才注册。** +- 验证:构建 + node --check 通过;headless Chrome 回归零报错;依赖解析测试通过。 +- 差距(M3 待续):文件打开/编辑器、SCM diff/暂存/提交、任务输出回放。