diff --git a/01_content/src/client/index.tsx b/01_content/src/client/index.tsx index eea8bc1..a0256d1 100644 --- a/01_content/src/client/index.tsx +++ b/01_content/src/client/index.tsx @@ -1,7 +1,7 @@ import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { css } from './styles' import { NS, zh, en, type WorktableKey } from './locales' -import { splitStore, SplitWorkspace, setSplitT, setSplitEnv, type LayoutSpec, type SplitPane } from './split' +import { splitStore, SplitWorkspace, setSplitT, setSplitEnv, type LayoutSpec, type SplitPane, type ConsoleCardData } from './split' /** * dsh-worktable 客户端(v2):侧边栏底部「工作台」区块。 @@ -25,6 +25,8 @@ type ViewState = { orderBy: OrderBy dock: DockMode floatTop: number | null + /** 控制室面板主题:dark/light/system(system = 跟随宿主 color-scheme,即 DSH 深色/白色/跟随系统设置) */ + consoleTheme?: 'dark' | 'light' | 'system' } /** 卡片上报的项目元信息(协议 v2)。 */ @@ -249,6 +251,7 @@ function loadView(): ViewState { orderBy, dock: p.dock === 'float' ? 'float' : 'footer', floatTop: typeof p.floatTop === 'number' ? p.floatTop : null, + consoleTheme: p.consoleTheme === 'dark' || p.consoleTheme === 'light' || p.consoleTheme === 'system' ? p.consoleTheme : 'system', } } catch { return { ...DEFAULT_VIEW } @@ -304,6 +307,64 @@ const registryStore: { ids: string[]; listeners: Set<() => void> } = { ids: [], /** 自定义窗口 → 宿主会话桥(apply 时注入;不可用时 CustomPane 降级提示) */ let sessionBridge: { sessions: any; conversation: any; list: any; workspaces: any } | null = null +/** 控制室(工作台自己)项目:固定 id、固定排项目列表第一位、不可删除 */ +const CONSOLE_ID = 'wt-console' +const CONSOLE_ICON = '🖥️' + +/** 控制室默认布局:单一锁死大窗格(项目卡片网格)+ 右侧对话 */ +function buildConsoleSpec(): LayoutSpec { + return { + id: CONSOLE_ID, + title: '工作台', + icon: CONSOLE_ICON, + top: null, + main: [{ + id: 'console', + title: '控制室', + min: 240, + tabs: [{ id: 'c1', title: '控制室', content: { kind: 'builtin', type: 'console' } }], + active: 0, + }], + chatWidth: { default: 340, min: 280, max: 600 }, + topHeight: { default: 200, min: 120, max: 480 }, + chatSide: 'right', + } +} + +/** 控制室面板刷新总线(项目/会话快照变化时推送,ConsolePane 订阅重渲染) */ +const consoleListeners = new Set<() => void>() +const notifyConsole = () => consoleListeners.forEach((l) => { try { l() } catch {} }) + +/** 宿主工作区列表(分组选择数据源;读 ctx.workspaces 快照) */ +function listWorkspaces(): { id: string; title: string; path: string }[] { + try { + const snap = sessionBridge?.workspaces?.list?.getSnapshot?.() + const items = snap?.items ?? [] + return items.map((w: any) => ({ + id: w.workspaceId ?? w.id, + title: w.title ?? '', + path: w.path ?? '', + })) + } catch { return [] } +} + +/** 从会话快照节点里提取最近一条文本(纯读内存镜像,零 Token;无则 '') */ +function lastTextOf(sid: string): string { + try { + const face = sessionBridge?.sessions?.binding?.(sid)?.session?.getSnapshot?.() + const nodes: any[] = face?.nodes ?? [] + for (let i = nodes.length - 1; i >= 0; i--) { + const n = nodes[i] + const blocks = Array.isArray(n?.blocks) ? n.blocks : (Array.isArray(n?.content) ? n.content : []) + for (const b of blocks) { + const s = typeof b?.text === 'string' ? b.text.trim().replace(/\s+/g, ' ') : '' + if (s) return s + } + } + } catch {} + return '' +} + /** 会话分组列表(模块级,供自定义窗会话选择与对话绑定弹窗共用) */ async function fetchSessionGroups(): Promise<{ groups: { title: string; sessions: { id: string; title: string; isCurrent: boolean }[] }[]; current: string }> { try { @@ -715,6 +776,15 @@ function WorktableSection(props: any) { const [bindPick, setBindPick] = useState<{ id: string; x: number; y: number } | null>(null) const [bindGroups, setBindGroups] = useState<{ title: string; sessions: { id: string; title: string; isCurrent: boolean }[] }[]>([]) const [bindListOpen, setBindListOpen] = useState(false) + /** 控制室强制绑定弹窗:未绑定点开「工作台」时弹出(左加入现有对话 / 右新建对话) */ + const [consoleBind, setConsoleBind] = useState<{ x: number; y: number } | null>(null) + const [consoleGroups, setConsoleGroups] = useState<{ title: string; sessions: { id: string; title: string; isCurrent: boolean }[] }[]>([]) + const [consoleMode, setConsoleMode] = useState<'none' | 'existing' | 'new'>('none') + const [consoleWsId, setConsoleWsId] = useState('') + const [consoleParent, setConsoleParent] = useState('') + const [consoleName, setConsoleName] = useState('') + const [consoleErr, setConsoleErr] = useState(false) + const [consoleBusy, setConsoleBusy] = useState(false) /** 自定义布局弹窗(预设网格末尾的 + 磁贴):描述 → 复制提示词到剪贴板 */ const [customOpen, setCustomOpen] = useState(false) @@ -743,6 +813,102 @@ function WorktableSection(props: any) { const projectsRef = useRef<{ projects: ProjectsState; metas: Record; aliveRegisteredIds: string[] }>( { projects, metas, aliveRegisteredIds: registeredIds }, ) + /** 控制室动作引用(每渲染同步,供 env 闭包读取) */ + const actionsRef = useRef<{ openSplit: (spec: LayoutSpec) => void; openConsole: (explicitBound?: string | null) => void } | null>(null) + /** 视图状态最新快照(供 env 闭包读取控制室主题) */ + const viewRef = useRef(view) + viewRef.current = view + + /** 控制室卡片数据组装(env 闭包调用;只读镜像:状态/时长/子代理/预览全部来自宿主内存快照,零轮询零 Token) */ + const getConsoleCards = (): ConsoleCardData[] => { + const pr = projectsRef.current + const snap = sessionsSnapshotStore.snapshot + const byId: Record = snap?.byId ?? {} + const jobsMap: Record = snap?.jobsBySession ?? {} + const now = Date.now() + const kidsSetOf = (sid: string): Set => { + const set = new Set() + for (const [cid, ce] of Object.entries(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 + } + // 三态判定(同卡片提醒逻辑,但不过滤 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 (e.completed === true) return 'done' + if (e.running === true) return 'busy' + return 'idle' + } + // 运行时长:本会话正在运行的后台任务最早 startedAt 起算;无任务时读会话面 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 + } + const make = (id: string, name: string, icon: string, self: boolean): ConsoleCardData => { + const sid = pr.projects.bindings[id] + return { + id, name, icon, + status: statusOf(sid), + runtimeMs: runtimeOf(sid), + kids: sid ? kidsSetOf(sid).size : 0, + preview: sid ? lastTextOf(sid) : '', + bound: !!sid, + self, + } + } + const cards: ConsoleCardData[] = [] + cards.push(make(CONSOLE_ID, t('console.name'), CONSOLE_ICON, true)) + const ids = [...pr.aliveRegisteredIds, ...pr.projects.layouts.map((l) => l.id)] + const known = new Set(ids) + const stored = pr.projects.order.filter((id) => known.has(id)) + const ordered = [...stored, ...ids.filter((id) => !stored.includes(id))].filter((id) => id !== CONSOLE_ID) + for (const id of ordered) { + const meta = pr.metas[id] + const layout = pr.projects.layouts.find((l) => l.id === id) + if (!meta && !layout) continue + const name = pr.projects.nameOverrides[id] ?? meta?.name ?? layout?.title ?? id + const icon = meta?.icon ?? layout?.icon ?? '🧱' + cards.push(make(id, name, icon, false)) + } + return cards + } // 会话作用域(当前会话 + 工作目录)与后台任务:注入分栏引擎环境 // 注意:不走 props.useSessions hook(其宿主包装在部分版本会触发 useSyncExternalStore @@ -793,6 +959,31 @@ function WorktableSection(props: any) { return 'auto' }, }, + // 控制室:卡片数据订阅 + 打开项目 / 跳绑定对话 + console: { + subscribe: (fn) => { consoleListeners.add(fn); return () => { consoleListeners.delete(fn) } }, + getCards: () => getConsoleCards(), + getTheme: () => viewRef.current.consoleTheme ?? 'system', + setTheme: (th: 'dark' | 'light' | 'system') => persistView({ consoleTheme: th }), + onOpen: (id) => { + if (id === CONSOLE_ID) return + const pr = projectsRef.current.projects + const layout = pr.layouts.find((l) => l.id === id) + const view = pr.views[id] + if (view || layout) actionsRef.current?.openSplit((view ?? layout) as LayoutSpec) + else { + // 入驻项目无视图覆盖:仅切换其绑定对话(对齐卡片自带打开行为) + const bound = pr.bindings[id] + if (bound) { try { sessionBridge?.sessions?.open?.(bound) } catch {} } + } + }, + onJump: (id) => { + const sid = projectsRef.current.projects.bindings[id] + if (!sid) return + markPluginSessionOpen(sid) // 插件发起的切换:不触发「切会话关项目」联动 + try { sessionBridge?.sessions?.open?.(sid) } catch {} + }, + }, } setSplitEnv(env) try { (window as any).__dshCustomEnv = env } catch {} @@ -853,7 +1044,7 @@ function WorktableSection(props: any) { // 分栏引擎 UI 文案(窗选择器等) useEffect(() => { - setSplitT((k) => t(k as WorktableKey)) + setSplitT((k, p) => t(k as WorktableKey, p as Record | undefined)) return () => setSplitT(null) }, [t]) @@ -1033,6 +1224,80 @@ function buildCustomLayoutPrompt(req: string): string { } }, [projects.views]) + /** 打开「工作台」控制室:已绑定 → 打开并切换绑定对话;explicitBound 供强制绑定流程传入刚绑定的会话 */ + const openConsole = useCallback((explicitBound?: string | null) => { + const spec = projects.views[CONSOLE_ID] ?? buildConsoleSpec() + engineIdsRef.current.add(CONSOLE_ID) + splitStore.open(spec) + if (splitStore.active && splitStore.spec?.id === CONSOLE_ID) { + let prev: string | null = null + try { prev = sessionBridge?.list?.getSnapshot?.()?.current ?? 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 {} } + ackProjectNotify(CONSOLE_ID) + try { notifyConsole() } catch {} + } + }, [projects.views]) + + /** 控制室卡片点击:已绑定 → 打开控制室;未绑定 → 强制绑定弹窗 */ + const clickConsoleCard = (anchor: HTMLElement) => { + if (projectsRef.current.projects.bindings[CONSOLE_ID]) { openConsole(); return } + const r = anchor.getBoundingClientRect() + setConsoleBind({ + x: clamp(Math.round(r.right + 8), 8, window.innerWidth - 640), + y: clamp(Math.round(r.top), 8, window.innerHeight - 460), + }) + setConsoleGroups([]) + fetchSessionGroups().then((res) => setConsoleGroups(res.groups)).catch(() => setConsoleGroups([])) + } + + /** 强制绑定:加入现有对话(绑定后直接打开控制室) */ + const bindConsoleExisting = (sid: string) => { + persistProjects((prev) => ({ ...prev, bindings: { ...prev.bindings, [CONSOLE_ID]: sid } })) + setConsoleBind(null) + openConsole(sid) + } + + /** 强制绑定:新建空会话并绑定(分组同自定义窗:无 / 现有 / 新建) */ + const bindConsoleNew = async () => { + const b = sessionBridge + if (!b || typeof b.sessions?.create !== 'function') { setConsoleErr(true); return } + if (consoleMode === 'new' && (!consoleParent.trim() || !consoleName.trim())) { setConsoleErr(true); return } + setConsoleBusy(true); setConsoleErr(false) + try { + let workspaceId: string | null = null + if (consoleMode === 'existing' && consoleWsId) workspaceId = consoleWsId + else if (consoleMode === 'new') { + const ws = b.workspaces as any + const parent = consoleParent.replace(/[\\/]+$/, '') + const name = consoleName.replace(/^[\\/]+/, '').replace(/[\\/]+$/, '') + const full = parent + '\\' + name + try { await ws?.createDirectory?.(parent, name) } catch {} + try { + const r = await fetch('/api/worktable/mkdir', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ parent, name }) }) + const d = await r.json() + if (!r.ok || !d?.ok) throw new Error(d?.error ?? 'mkdir failed') + } catch { /* 目录建不出的错误最终由 workspace.create 暴露 */ } + const view = await ws.create({ path: full }) + workspaceId = view?.workspaceId ?? view?.id ?? null + if (!workspaceId) throw new Error('workspace create failed') + } + const folder = projectsRef.current.projects.folders[CONSOLE_ID] ?? null + let createOpts: any = {} + if (workspaceId) createOpts = { workspaceId } + else if (folder) createOpts = { cwd: folder } + const sessionId = await b.sessions.create(createOpts) + markPluginSessionOpen(sessionId) + persistProjects((prev) => ({ ...prev, bindings: { ...prev.bindings, [CONSOLE_ID]: sessionId } })) + setConsoleBind(null) + openConsole(sessionId) + } catch { setConsoleErr(true) } finally { setConsoleBusy(false) } + } + + actionsRef.current = { openSplit, openConsole } + /** 打开对话绑定弹窗:抓取会话分组 + 锚点定位 */ const openBindPick = useCallback((id: string, anchor: HTMLElement) => { const r = anchor.getBoundingClientRect() @@ -1101,13 +1366,16 @@ function buildCustomLayoutPrompt(req: string): string { copyToastTimerRef.current = window.setTimeout(() => setCopiedToast(null), 6000) } - // 会话快照变化 → 重渲染(驱动项目卡片的完成/待决提醒镜像) + // 会话快照变化 → 重渲染(驱动项目卡片的完成/待决提醒镜像 + 控制室面板) useEffect(() => { - const l = () => setNotifyTick((t) => t + 1) + const l = () => { setNotifyTick((t) => t + 1); notifyConsole() } sessionsSnapshotStore.listeners.add(l) return () => { sessionsSnapshotStore.listeners.delete(l) } }, []) + // 项目表变化 → 控制室卡片刷新(打开中的面板即时更新) + useEffect(() => { notifyConsole() }, [projects]) + /** 自动挂载:绑定会话完成 → 读项目文件夹 widget-result.json → 产物自动挂进对应窗口 */ const tryAutoMount = useCallback(async (projectId: string, sid: string) => { const folder = projectsRef.current.projects.folders[projectId] @@ -1439,7 +1707,9 @@ function buildCustomLayoutPrompt(req: string): string { } // ── 删除(全部走二次确认;项目彻底移出工作台:对话与项目文件均保留,仅清理本地关联状态) ── + // 「工作台」控制室项目不可删除(界面不提供入口,这里兜底拒绝) const removeProject = (id: string) => { + if (id === CONSOLE_ID) return persistProjects((prev) => { const next = { ...prev, @@ -2038,6 +2308,65 @@ function buildCustomLayoutPrompt(req: string): string { ) })()} + {/* 控制室强制绑定弹窗:左「加入现有对话」+ 右「新建对话」,选定/建成后自动打开控制室 */} + {consoleBind &&
setConsoleBind(null)} />} + {consoleBind && ( +
+
+
+ ➕ {t('console.joinExisting')} +
+ {consoleGroups.map((g, gi) => ( + + {g.title && ( + <> +
+
📁 {g.title}
+ + )} + {g.sessions.map((s) => ( + + ))} + + ))} + {consoleGroups.length === 0 &&
{t('console.noSessions')}
} +
+
+
+ ✨ {t('console.newConv')} + + {consoleMode === 'existing' && ( + + )} + {consoleMode === 'new' && ( + <> + setConsoleParent(e.target.value)} /> + setConsoleName(e.target.value)} /> + + )} + {consoleErr &&

{t('console.bindFail')}

} + +
+
+
+ )} + {customOpen &&
setCustomOpen(false)} />} {customOpen && (
@@ -2078,6 +2407,34 @@ function buildCustomLayoutPrompt(req: string): string { )}
+ {/* 「工作台」控制室项目:固定首位、不可删除;未绑定点开 = 强制绑定弹窗 */} + {renderProjectSlot ? renderProjectSlot('sidebar.worktable.project', ownerProps) :
{t('empty')}
} diff --git a/01_content/src/client/locales.ts b/01_content/src/client/locales.ts index ecdaa14..88a97d0 100644 --- a/01_content/src/client/locales.ts +++ b/01_content/src/client/locales.ts @@ -63,6 +63,7 @@ export const zh = { 'pane.terminal': '终端', 'pane.custom': '自定义', 'pane.anim': '动画', + 'pane.console': '控制室', 'pane.refresh': '刷新', 'pane.animUrlPh': '粘贴动画站点地址后回车', 'custom.title': '自定义这个窗口', @@ -112,6 +113,31 @@ export const zh = { 'customLayout.copyFail': '复制失败,请手动全选输入框内容复制。', 'custom.autoBound': '已自动把这个项目绑定到这个对话(卡片上的 ●● 按钮可更换或解绑)。', 'custom.keptBinding': '该项目已有绑定的对话,按规则未自动更改。', + 'console.name': '工作台', + 'console.title': '控制室', + 'console.themeLabel': '面板主题', + 'console.themeDark': '深色', + 'console.themeLight': '白色', + 'console.themeSystem': '跟随系统', + 'console.idle': '空闲', + 'console.busy': '工作中', + 'console.need': '待你决定', + 'console.done': '已完成', + 'console.kids': '子代理', + 'console.noPreview': '暂无消息', + 'console.unboundShort': '未绑定对话', + 'console.jump': '跳转到绑定对话', + 'console.empty': '还没有项目', + 'console.joinExisting': '加入现有对话', + 'console.newConv': '新建对话', + 'console.groupNone': '无分组(用项目文件夹)', + 'console.groupExisting': '加入现有分组', + 'console.groupNew': '新建分组', + 'console.newParentPh': '分组父目录,例如 E:\\AI_Workspace', + 'console.newNamePh': '分组文件夹名(将创建并注册)', + 'console.createBind': '新建并绑定', + 'console.bindFail': '创建失败,请重试', + 'console.noSessions': '没有可用对话', 'pane.wip': '开发中(后续版本接入)', 'pane.explorerNoCwd': '当前会话没有工作目录', 'pane.openLater': '该类型暂不支持预览', @@ -198,6 +224,7 @@ export const en = { 'pane.terminal': 'Terminal', 'pane.custom': 'Custom', 'pane.anim': 'Animation', + 'pane.console': 'Console', 'pane.refresh': 'Refresh', 'pane.animUrlPh': 'Paste the animation site URL and press Enter', 'custom.title': 'Customize this pane', @@ -247,6 +274,31 @@ export const en = { 'customLayout.copyFail': 'Copy failed — select the text above and copy it manually.', 'custom.autoBound': 'This project was auto-bound to this conversation (use the ●● button on its card to change or unbind).', 'custom.keptBinding': 'This project already has a bound conversation, so it was not auto-changed.', + 'console.name': 'Worktable', + 'console.title': 'Console', + 'console.themeLabel': 'Panel theme', + 'console.themeDark': 'Dark', + 'console.themeLight': 'Light', + 'console.themeSystem': 'System', + 'console.idle': 'Idle', + 'console.busy': 'Working', + 'console.need': 'Needs you', + 'console.done': 'Done', + 'console.kids': 'subagents', + 'console.noPreview': 'No messages yet', + 'console.unboundShort': 'No conversation bound', + 'console.jump': 'Jump to bound conversation', + 'console.empty': 'No projects yet', + 'console.joinExisting': 'Join existing conversation', + 'console.newConv': 'New conversation', + 'console.groupNone': 'No group (use project folder)', + 'console.groupExisting': 'Join existing group', + 'console.groupNew': 'New group', + 'console.newParentPh': 'Group parent folder, e.g. E:\\AI_Workspace', + 'console.newNamePh': 'Group folder name (created & registered)', + 'console.createBind': 'Create & bind', + 'console.bindFail': 'Creation failed, try again', + 'console.noSessions': 'No sessions available', 'pane.wip': 'Coming in a later version', 'pane.explorerNoCwd': 'No working directory for the current session', 'pane.openLater': 'Preview not supported for this type', diff --git a/01_content/src/client/split.tsx b/01_content/src/client/split.tsx index 60a933c..ff9a1c9 100644 --- a/01_content/src/client/split.tsx +++ b/01_content/src/client/split.tsx @@ -23,7 +23,7 @@ hljs.registerLanguage('json', hljsJson) * 内容与 chatSide 的变更经 onSpecMutated 回调交给工作台持久化(布局条目)。 */ -export type BuiltinType = 'browser' | 'anim' | 'explorer' | 'scm' | 'tasks' | 'terminal' | 'custom' +export type BuiltinType = 'browser' | 'anim' | 'explorer' | 'scm' | 'tasks' | 'terminal' | 'custom' | 'console' export type SplitContent = | { kind: 'iframe'; url: string; title?: string } @@ -131,6 +131,7 @@ const BUILTIN_ICONS: Record = { tasks: '✅', terminal: '▸_', custom: '✨', + console: '🖥️', } const BUILTIN_LABEL_KEYS: Record = { @@ -141,6 +142,7 @@ const BUILTIN_LABEL_KEYS: Record = { tasks: 'pane.tasks', terminal: 'pane.terminal', custom: 'pane.custom', + console: 'pane.console', } function tabTitleOf(content: SplitContent): string { @@ -170,11 +172,11 @@ function sameContent(a: SplitContent, b: SplitContent): boolean { } /** 分栏 UI 文案提供者(由工作台注入 locale t) */ -let uiT: ((key: string) => string) | null = null -export function setSplitT(fn: ((key: string) => string) | null) { +let uiT: ((key: string, params?: Record) => string) | null = null +export function setSplitT(fn: ((key: string, params?: Record) => string) | null) { uiT = fn } -const T = (key: string): string => (uiT ? uiT(key) : key) +const T = (key: string, params?: Record): string => (uiT ? uiT(key, params) : key) /** 工作区环境(由工作台注入:当前会话作用域与后台任务列表) */ export type SplitScope = { sessionId: string; cwd: string } @@ -187,6 +189,25 @@ export type SplitJob = { startedAt: number finishedAt?: number } +/** 控制室卡片数据(index.tsx 组装;纯读镜像,零轮询零 Token) */ +export type ConsoleCardData = { + id: string + name: string + icon: string + /** 三态 + 空闲:need(待决黄) > done(完成绿) > busy(工作中蓝) > idle */ + status: 'idle' | 'busy' | 'need' | 'done' + /** 正在运行时的本轮已用毫秒;非运行态 null */ + runtimeMs: number | null + /** 子代理数量 */ + kids: number + /** 最近一条消息预览(单行文本,可能为空) */ + preview: string + /** 是否绑定对话 */ + bound: boolean + /** 是否为「工作台」自己(卡片点击无操作) */ + self: boolean +} + type SplitEnv = { getScope: () => SplitScope | null getJobs: () => SplitJob[] @@ -199,6 +220,15 @@ type SplitEnv = { submit: (projectId: string, projectName: string, requirement: string) => Promise sendToSession: (sessionId: string, projectName: string, requirement: string) => Promise } + /** 控制室:卡片数据订阅 + 打开项目 / 跳绑定对话 + 主题 */ + console?: { + subscribe: (fn: () => void) => () => void + getCards: () => ConsoleCardData[] + onOpen: (id: string) => void + onJump: (id: string) => void + getTheme: () => 'dark' | 'light' | 'system' + setTheme: (th: 'dark' | 'light' | 'system') => void + } } let splitEnv: SplitEnv | null = null export function setSplitEnv(env: SplitEnv | null) { @@ -919,6 +949,118 @@ function AnimPane(props: { reloadKey: number }) { ) } +/** 控制室面板:项目卡片网格(每行 3 张、超出换行);数据由工作台组装推送(纯读镜像)。 + * 主题:dark/light 直接生效;system = 跟随宿主 html 的 color-scheme(DSH 深色/白色/跟随系统都会反映到它) */ +function ConsolePane() { + const [, setTick] = useState(0) + const [now, setNow] = useState(() => Date.now()) + const [themeMode, setThemeMode] = useState<'dark' | 'light' | 'system'>(() => splitEnv?.console?.getTheme?.() ?? 'system') + const [sysDark, setSysDark] = useState(() => { + try { return window.matchMedia('(prefers-color-scheme: dark)').matches } catch { return true } + }) + useEffect(() => { + const env = splitEnv?.console + if (!env) return + const bump = () => { setTick((t) => t + 1); setThemeMode(env.getTheme?.() ?? 'system') } + const un = env.subscribe(bump) + bump() + return un + }, []) + // 跟随系统:监听 OS 深浅切换(仅 themeMode==='system' 时影响渲染) + useEffect(() => { + let mq: MediaQueryList | null = null + try { mq = window.matchMedia('(prefers-color-scheme: dark)') } catch {} + if (!mq) return + const f = (e: any) => setSysDark(!!e?.matches) + try { mq.addEventListener('change', f) } catch { try { (mq as any).addListener(f) } catch {} } + return () => { try { mq.removeEventListener('change', f) } catch { try { (mq as any).removeListener(f) } catch {} } } + }, []) + // 运行时长的分钟级刷新:每秒重渲染一次(仅控制室开着时存在) + useEffect(() => { + const iv = window.setInterval(() => setNow(Date.now()), 1000) + return () => window.clearInterval(iv) + }, []) + void now + const env = splitEnv?.console + const cards = env ? env.getCards() : [] + const resolvedTheme: 'dark' | 'light' = (() => { + if (themeMode !== 'system') return themeMode + try { + const cs = getComputedStyle(document.documentElement).colorScheme || '' + if (cs.includes('dark') && !cs.includes('light')) return 'dark' + if (cs.includes('light') && !cs.includes('dark')) return 'light' + } catch {} + return sysDark ? 'dark' : 'light' + })() + const setTheme = (th: 'dark' | 'light' | 'system') => { setThemeMode(th); env?.setTheme?.(th) } + const fmtDur = (ms: number) => { + const s = Math.max(0, Math.floor(ms / 1000)) + const h = Math.floor(s / 3600) + const m = Math.floor((s % 3600) / 60) + const ss = s % 60 + return h > 0 ? h + ':' + String(m).padStart(2, '0') + ':' + String(ss).padStart(2, '0') : m + ':' + String(ss).padStart(2, '0') + } + const statusLabel: Record = { idle: T('console.idle'), busy: T('console.busy'), need: T('console.need'), done: T('console.done') } + const themeOpts: { mode: 'dark' | 'light' | 'system'; key: string }[] = [ + { mode: 'dark', key: 'console.themeDark' }, + { mode: 'light', key: 'console.themeLight' }, + { mode: 'system', key: 'console.themeSystem' }, + ] + return ( +
+
+ 🖥️ {T('console.title')} +
+ {themeOpts.map((o) => ( + + ))} +
+
+
+ {cards.map((c) => ( +
{ if (!c.self && env) env.onOpen(c.id) }} + > +
+ {c.icon} + {c.name} + +
+
+ {statusLabel[c.status]} + {c.runtimeMs != null && ⏱ {fmtDur(c.runtimeMs)}} + {T('console.kids')} {c.kids} +
+
+ {c.preview || (c.bound ? T('console.noPreview') : T('console.unboundShort'))} +
+ {c.bound && !c.self && ( + + )} +
+ ))} +
+ {cards.length === 0 &&
{T('console.empty')}
} +
+ ) +} + /** 文件夹图标(重绘 SVG,与 better-sidebar 同款风格) */ function FolderIcon() { return ( @@ -1542,6 +1684,7 @@ function PaneTabBody(props: { tab: PaneTab; row: PaneRow; index: number; paneTit } if (content.type === 'browser') return if (content.type === 'anim') return + if (content.type === 'console') return if (content.type === 'explorer') return if (content.type === 'scm') return if (content.type === 'tasks') return diff --git a/01_content/src/client/styles.ts b/01_content/src/client/styles.ts index 67fbdf9..b299ee1 100644 --- a/01_content/src/client/styles.ts +++ b/01_content/src/client/styles.ts @@ -380,4 +380,48 @@ export const css = xtermCss + '\n' + [ '.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)}', + // 控制室面板:项目卡片网格(每行 3 张、超出换行;DSH 简洁 + 苹果式卡片)。 + // 主题变量自带作用域:dark 缺省;data-wt-theme=light 换浅色;system 由面板读宿主 color-scheme 落成 dark/light。 + '.dsh-wt_console{--wt-bg:#10141b;--wt-card:#171c25;--wt-cardHover:#1d232e;--wt-border:#2a3140;--wt-borderHover:#39445a;--wt-text:#e6e8eb;--wt-text2:#9aa4b2;--wt-text3:#6e7683;--wt-chip:rgba(255,255,255,.06);--wt-shadow:0 6px 20px rgba(0,0,0,.35);flex:1;min-height:0;overflow:auto;padding:14px;background:var(--wt-bg)}', + '.dsh-wt_console[data-wt-theme=light]{--wt-bg:#f2f4f7;--wt-card:#ffffff;--wt-cardHover:#f7f9fb;--wt-border:#d8dee6;--wt-borderHover:#b9c4d2;--wt-text:#1c2128;--wt-text2:#57606a;--wt-text3:#8b949e;--wt-chip:rgba(27,31,36,.07);--wt-shadow:0 6px 20px rgba(31,41,55,.10)}', + '.dsh-wt_consoleHead{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:12px}', + '.dsh-wt_consoleTitle{font-size:14px;font-weight:700;line-height:20px;color:var(--wt-text)}', + '.dsh-wt_consoleTheme{display:inline-flex;align-items:center;padding:2px;border:1px solid var(--wt-border);border-radius:999px;background:var(--wt-card)}', + '.dsh-wt_consoleThemeBtn{flex:none;padding:2px 10px;border:none;border-radius:999px;background:transparent;color:var(--wt-text2);font:inherit;font-size:11px;line-height:18px;cursor:pointer}', + '.dsh-wt_consoleThemeBtnOn{background:var(--wt-chip);color:var(--wt-text);font-weight:600}', + '.dsh-wt_consoleGrid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;align-content:start}', + '.dsh-wt_consoleCard{position:relative;display:flex;flex-direction:column;gap:8px;min-height:96px;padding:12px 14px;background:var(--wt-card);border:1px solid var(--wt-border);border-radius:12px;cursor:pointer;transition:transform .12s ease,border-color .12s ease,box-shadow .12s ease}', + '.dsh-wt_consoleCard:hover{transform:translateY(-1px);border-color:var(--wt-borderHover);box-shadow:var(--wt-shadow)}', + '.dsh-wt_consoleCardSelf{cursor:default;opacity:.96}', + '.dsh-wt_consoleCardHead{display:flex;align-items:center;gap:8px;min-width:0}', + '.dsh-wt_consoleIcon{flex:none;font-size:20px;line-height:1}', + '.dsh-wt_consoleName{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;font-weight:600;line-height:20px;color:var(--wt-text)}', + '.dsh-wt_consoleDot{flex:none;width:8px;height:8px;border-radius:50%;background:var(--wt-text3)}', + '.dsh-wt_consoleDot-busy{background:var(--dsw-alias-state-accent-primary,#4f8ef7);box-shadow:0 0 0 3px rgba(79,142,247,.15)}', + '.dsh-wt_consoleDot-need{background:var(--dsw-alias-state-warning,#d29922);box-shadow:0 0 0 3px rgba(210,153,34,.15)}', + '.dsh-wt_consoleDot-done{background:var(--dsw-alias-state-success,#3fb950);box-shadow:0 0 0 3px rgba(63,185,80,.15)}', + '.dsh-wt_consoleBadges{display:flex;align-items:center;gap:6px;flex-wrap:wrap}', + '.dsh-wt_consoleBadge{flex:none;padding:1px 7px;border-radius:999px;font-size:10.5px;line-height:16px;color:var(--wt-text2);background:var(--wt-chip)}', + '.dsh-wt_consoleBadge-busy{color:var(--dsw-alias-state-accent-primary,#4f8ef7)}', + '.dsh-wt_consoleBadge-need{color:var(--dsw-alias-state-warning,#d29922)}', + '.dsh-wt_consoleBadge-done{color:var(--dsw-alias-state-success,#3fb950)}', + '.dsh-wt_consolePreview{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px;line-height:16px;color:var(--wt-text2)}', + '.dsh-wt_consolePreviewNone{color:var(--wt-text3);font-style:italic}', + '.dsh-wt_consoleJump{position:absolute;top:10px;right:10px;width:22px;height:22px;padding:0;border:none;border-radius:6px;background:transparent;color:var(--wt-text3);font-size:12px;line-height:1;cursor:pointer;opacity:0;transition:opacity .12s ease}', + '.dsh-wt_consoleCard:hover .dsh-wt_consoleJump{opacity:1}', + '.dsh-wt_consoleJump:hover{color:var(--dsw-alias-state-accent-primary,#4f8ef7);background:var(--wt-chip)}', + '.dsh-wt_consoleEmpty{padding:40px 0;text-align:center;font-size:12px;color:var(--wt-text3)}', + // 控制室强制绑定弹窗:左加入现有 / 右新建对话 + '.dsh-wt_consoleBindCols{display:flex;gap:12px}', + '.dsh-wt_consoleBindCol{flex:1;min-width:0;display:flex;flex-direction:column;gap:6px}', + '.dsh-wt_consoleBindColNew{border-left:1px solid var(--dsw-alias-border-l1,#262b36);padding-left:12px}', + '.dsh-wt_consoleBindLabel{font-size:12px;font-weight:600;line-height:18px;color:var(--dsw-alias-label-primary,#e6e8eb)}', + '.dsh-wt_consoleBindList{max-height:300px;overflow:auto;position:static}', + '.dsh-wt_consoleBindEmpty{padding:20px 0;text-align:center;font-size:11px;color:var(--dsw-alias-label-tertiary,#6e7683)}', + '.dsh-wt_consoleSelect,.dsh-wt_consoleInput{width:100%;box-sizing:border-box;padding:6px 8px;background:var(--dsw-alias-fill-l1,rgba(255,255,255,.03));border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:8px;color:var(--dsw-alias-label-primary,#e6e8eb);font:inherit;font-size:11.5px;line-height:16px;outline:none}', + '.dsh-wt_consoleSelect:focus,.dsh-wt_consoleInput:focus{border-color:var(--dsw-alias-state-accent-primary,#4f8ef7)}', + '.dsh-wt_consoleErr{margin:0;font-size:11px;line-height:16px;color:var(--dsw-alias-state-danger,#f85149)}', + '.dsh-wt_consoleCreateBtn{margin-top:2px;padding:7px 10px;border:1px solid var(--dsw-alias-state-accent-primary,#4f8ef7);border-radius:8px;background:transparent;color:var(--dsw-alias-state-accent-primary,#4f8ef7);font:inherit;font-size:12px;line-height:18px;cursor:pointer}', + '.dsh-wt_consoleCreateBtn:hover:not(:disabled){background:var(--dsw-alias-fill-l1,rgba(255,255,255,.05))}', + '.dsh-wt_consoleCreateBtn:disabled{opacity:.55;cursor:default}', ].join('\n') diff --git a/02_process/worklogs/2026-08-16_worktable-v2.md b/02_process/worklogs/2026-08-16_worktable-v2.md index 35de94d..e74ade7 100644 --- a/02_process/worklogs/2026-08-16_worktable-v2.md +++ b/02_process/worklogs/2026-08-16_worktable-v2.md @@ -1142,3 +1142,34 @@ - 备注:纯客户端改动,F5 即生效。 - 微调:用户指出两按钮符号一前一后不对称——统一为符号都在文字后: 「更改 ↻」+「解绑 ✕」;probe-batch15 断言改 box0ChangeText/box0ChangeSymAtEnd。 + +## 补记(控制室「工作台」项目:默认卡片 + 强制绑定 + 3 列监控面板 + 主题开关) + +- 用户需求(定案):工作台自带一个默认项目「工作台」(固定首位、不可删除),点开未绑定 + 强制绑定(左「加入现有对话」/右「新建对话」,新建同自定义窗分组选择);面板 = 大窗格内 + 每行 3 张卡片(布局选择窗式排法),每卡显示三态圆点/运行时长/子代理数/最近消息预览; + 覆盖所有项目;不要「停止对话」;风格 DSH 简洁 + 苹果式;主题跟随 DSH 深色/白色/跟随系统, + 不方便就加三选一开关。 +- 调研结论(只读侦察宿主类型包):SessionSummary 有 running/pendingInteraction/completed/ + updatedAt;jobsBySession 的 JobView 有 startedAt/finishedAt;subagentsByParent 目录计数; + SessionFace.getSnapshot().nodes 有对话文本 → 监控四要素全部来自宿主内存快照,零轮询零 + Token(模型不参与显示)。宿主**不发布 --dsw-alias-***(全文档无解析),但 html 上有 + color-scheme(当前 dark)——DSH 主题设置会反映到它,作为「跟随系统」的宿主信号。 +- 实现:① index.tsx 新增 CONSOLE_ID 'wt-console' + buildConsoleSpec(单大窗格 console 类型); + 侧栏首位渲染控制室入口卡(dsh-wt_layout + dsh-wt_consoleEntry,order 0);设置管理列表 + 不含它 + removeProject 兜底拒绝。② 强制绑定弹窗 dsh-wt_consoleBindPop(左会话列表 / 右 + 分组 select+目录输入+新建并绑定,bindConsoleExisting/bindConsoleNew,建空会话 + sessions.create + markPluginSessionOpen + 自动绑定 + openConsole)。③ split.tsx 新增 + BuiltinType 'console' + ConsolePane(网格 3 列;env.console = subscribe/getCards/onOpen/ + onJump/getTheme/setTheme;getConsoleCards 组装四要素:状态聚合同提醒逻辑但不过滤 ack、 + 时长 = 运行任务最早 startedAt 或 turnTimings 未结束轮、子代理双通道计数、预览 = 会话面 + nodes 末条文本);卡片点击开项目、💬 跳绑定对话(豁免联动,控制室不关)。④ 主题:面板 + 三选一(深色/白色/跟随系统,view.v1.consoleTheme);system 读 html color-scheme + + prefers-color-scheme;--wt-* 作用域变量 + data-wt-theme=light 浅色整套;样式全走变量 + (DSH 简洁 + 苹果式:12px 圆角卡、999px 徽章、悬浮轻抬)。⑤ setSplitT 桥加 params 支持。 +- 验证(probe-batch16.cjs 三阶段 + theme-probe.cjs):入口卡首位/order0/🖥️/设置无删除入口 ✓; + 未绑定点开强制弹窗(左 23 会话/右三选一)✓;加入现有 → 绑定持久化 + 控制室打开 ✓; + 网格 3 列、首卡自己、圆点/徽章/预览齐 ✓;点卡片开项目 ✓;💬 跳转控制室保持 ✓;解绑后 + 新建对话路径(分组默认无)✓;主题深/白/跟随切换 + data-wt-theme 落定 + 持久化 ✓; + functional-diag 全 20 STEP(布局卡选择器收窄排除控制室卡)ERROR_COUNT: 0。 +- 备注:纯客户端改动,F5 即生效;AGENTS.md 已补控制室规则。 diff --git a/04_test/functional-diag.cjs b/04_test/functional-diag.cjs index c71792f..a55ac3b 100644 --- a/04_test/functional-diag.cjs +++ b/04_test/functional-diag.cjs @@ -89,8 +89,8 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const step6 = await evaluate(`(async function(){ var out={}; - out.cards=document.querySelectorAll('.dsh-wt_layout').length; - var card=document.querySelector('.dsh-wt_layout'); + out.cards=document.querySelectorAll('.dsh-wt_projects .dsh-wt_layout:not(.dsh-wt_consoleEntry)').length; + var card=document.querySelector('.dsh-wt_projects .dsh-wt_layout:not(.dsh-wt_consoleEntry)'); if(card){ var ic=card.querySelector('.dsh-wt_layoutIcon'); out.icon0=ic?ic.textContent:null; @@ -222,7 +222,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); try{ var st=window.__dshWorktable.splitStore; // A) 布局项目:卡片打开 → 加终端标签 → 关闭 → 再开 → 标签还在 - var layCard=document.querySelector('.dsh-wt_layout'); + var layCard=document.querySelector('.dsh-wt_projects .dsh-wt_layout:not(.dsh-wt_consoleEntry)'); if(layCard){ layCard.click(); } await new Promise(function(r){setTimeout(r,400)}); out.layoutOpenId=st.spec?st.spec.id:null; @@ -231,7 +231,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); try{ out.layoutSavedTabs=JSON.parse(localStorage.getItem('dsh.worktable.projects.v1')).layouts[0].main[0].tabs.length }catch(e){ out.layoutSavedTabs='ERR' } st.close(); await new Promise(function(r){setTimeout(r,300)}); - layCard=document.querySelector('.dsh-wt_layout'); + layCard=document.querySelector('.dsh-wt_projects .dsh-wt_layout:not(.dsh-wt_consoleEntry)'); if(layCard){ layCard.click(); } await new Promise(function(r){setTimeout(r,400)}); out.layoutRestoredTabs=st.spec?st.spec.main[0].tabs.length:null; @@ -312,10 +312,10 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); var out={}; var prIcon=document.querySelector('.dsh-wt_projects .pr_cardIcon'); var taIcon=document.querySelector('.dsh-wt_projects .ta_cardIcon'); - var layIcon=document.querySelector('.dsh-wt_layoutIcon'); + var layIcon=document.querySelector('.dsh-wt_projects .dsh-wt_layout:not(.dsh-wt_consoleEntry) .dsh-wt_layoutIcon'); var scIcon=document.querySelector('.dsh-wt_shortcutIcon'); var taName=document.querySelector('.dsh-wt_projects .ta_cardName'); - var laName=document.querySelector('.dsh-wt_layoutName'); + var laName=document.querySelector('.dsh-wt_projects .dsh-wt_layout:not(.dsh-wt_consoleEntry) .dsh-wt_layoutName'); if(prIcon) out.prIconSize=getComputedStyle(prIcon).fontSize; if(taIcon) out.taIconBeforeSize=getComputedStyle(taIcon,'::before').fontSize; if(layIcon) out.layIconSize=getComputedStyle(layIcon).fontSize; @@ -323,7 +323,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); if(taName) out.taNameWeight=getComputedStyle(taName).fontWeight; if(laName) out.laNameWeight=getComputedStyle(laName).fontWeight; function h(sel){ var el=document.querySelector(sel); return el?Math.round(el.getBoundingClientRect().height):null; } - out.heights={ta:h('.dsh-wt_projects .ta_card'),pr:h('.dsh-wt_projects .pr_card'),layout:h('.dsh-wt_layout'),shortcut:h('.dsh-wt_shortcut')}; + out.heights={ta:h('.dsh-wt_projects .ta_card'),pr:h('.dsh-wt_projects .pr_card'),layout:h('.dsh-wt_projects .dsh-wt_layout:not(.dsh-wt_consoleEntry)'),shortcut:h('.dsh-wt_shortcut')}; out.cardsBefore={ta:document.querySelectorAll('.dsh-wt_projects .ta_card').length,pr:document.querySelectorAll('.dsh-wt_projects .pr_card').length}; var btn=document.querySelector('.dsh-wt_actions .dsh-wt_iconBtn:nth-child(2)'); if(btn){ btn.click(); } @@ -358,7 +358,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); var del2=document.querySelector('.dsh-wt_confirmDelete'); if(del2){ del2.click(); } await new Promise(function(r){setTimeout(r,300)}); - out.layoutGoneAfterDelete=!document.querySelector('.dsh-wt_layout'); + out.layoutGoneAfterDelete=!document.querySelector('.dsh-wt_projects .dsh-wt_layout:not(.dsh-wt_consoleEntry)'); try{ out.layoutsLeft=JSON.parse(localStorage.getItem('dsh.worktable.projects.v1')).layouts.length }catch(e){ out.layoutsLeft='ERR' } // 真删除:「已删除的项目」区块已移除——删除即彻底移出工作台(无重新添加入口) out.removedSection=!!document.querySelector('.dsh-wt_settings .dsh-wt_manageRowRemoved'); diff --git a/04_test/probe-batch16.cjs b/04_test/probe-batch16.cjs new file mode 100644 index 0000000..d735b0d --- /dev/null +++ b/04_test/probe-batch16.cjs @@ -0,0 +1,224 @@ +// probe-batch16.cjs — 控制室:默认卡片首位/不可删 → 强制绑定(左现有/右新建)→ 面板网格 → 卡片动作 → 💬 跳转 +const http = require('http'); +const { spawn } = require('child_process'); +const WebSocket = require('ws'); +const PORT = 9373; +const proc = spawn('C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', [ + '--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check', + '--window-size=1440,900', '--force-device-scale-factor=1', + '--remote-debugging-port=' + PORT, + '--user-data-dir=C:\\Users\\SJL\\AppData\\Local\\Temp\\wt-chrome-batch16b', + 'about:blank', +], { stdio: 'ignore' }); +const getJSON = (p) => new Promise((res, rej) => { + http.get({ host: '127.0.0.1', port: PORT, path: p }, (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); } + const target = list.find((t) => t.type === 'page') || list[0]; + const ws = new WebSocket(target.webSocketDebuggerUrl); + let id = 0; const pending = new Map(); + 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 m = JSON.parse(String(raw)); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } }); + await new Promise((r) => ws.on('open', r)); + await send('Runtime.enable'); + await send('Page.enable'); + await send('Page.addScriptToEvaluateOnNewDocument', { + source: "try{ if(location.origin==='http://127.0.0.1:3080'){ localStorage.setItem('dsh.worktable.view.v1', JSON.stringify({query:'',searchOpen:false,orderBy:'manual',dock:'footer',floatTop:null,sortMigratedV2:true})); localStorage.setItem('dsh.worktable.projects.v1', JSON.stringify({order:['t-bind'],lastUsed:{},hidden:[],nameOverrides:{},iconOverrides:{},shortcuts:[],layouts:[{id:'t-bind',title:'\u7ed1\u5b9a\u6d4b\u8bd5',icon:'\ud83e\uddea',top:null,left:null,main:[{id:'p1',title:'\u5185\u5bb91',min:200,content:null,tabs:[],active:0}],leftWidth:{default:260,min:160,max:480},chatWidth:{default:360,min:240,max:600},topHeight:{default:200,min:120,max:480},chatSide:'right',chatFullHeight:false}],bindings:{},folders:{},views:{},removed:[]})); } }catch(e){}", + }); + await send('Page.navigate', { url: 'http://127.0.0.1:3080/' }); + const evaluate = async (expr) => { + const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); + if (r.result && r.result.exceptionDetails) return { __error: (r.result.exceptionDetails.exception || {}).description || r.result.exceptionDetails.text }; + return r.result && r.result.result ? r.result.result.value : undefined; + }; + let ready = null; + for (let i = 0; i < 30; i++) { await sleep(1000); ready = await evaluate("!!window.__dshWorktable"); if (ready === true) break; } + await sleep(1500); + await evaluate("(function(){ var b=document.querySelector('button[title=\"Open sidebar\"],button[aria-label=\"Open sidebar\"]'); if(b){b.click(); return true} return false })()"); + await sleep(1000); + const expr1 = `(async function(){ + var out = {}; + var sleep = function(ms){ return new Promise(function(r){ setTimeout(r, ms); }); }; + var vis = function(sel){ return [].slice.call(document.querySelectorAll(sel)).find(function(el){ return (getComputedStyle(el).visibility!=='hidden'&&el.getBoundingClientRect().height>0); }); }; + var st = window.__dshWorktable.splitStore; + try { + var cs = getComputedStyle(document.body); + out.themeFill = cs.getPropertyValue('--dsw-alias-fill-l1').trim() || null; + out.htmlDataTheme = document.documentElement.getAttribute('data-theme'); + var first = document.querySelector('.dsh-wt_projects > *'); + out.firstIsConsole = !!(first && first.classList && first.classList.contains('dsh-wt_consoleEntry')); + out.consoleName = first ? (first.querySelector('.dsh-wt_layoutName') ? first.querySelector('.dsh-wt_layoutName').textContent : null) : null; + out.consoleIcon = first ? (first.querySelector('.dsh-wt_layoutIcon') ? first.querySelector('.dsh-wt_layoutIcon').textContent : null) : null; + out.consoleOrder = first ? getComputedStyle(first).order : null; + out.consoleHasBindBtn = !!(first && first.querySelector('.dsh-wt_bindBtn')); + var btnS = document.querySelector('.dsh-wt_actions .dsh-wt_iconBtn:nth-child(2)'); + if (btnS) { btnS.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true})); } + await sleep(400); + var rows = document.querySelectorAll('.dsh-wt_settings .dsh-wt_manageRow'); + out.manageRows = rows.length; + out.consoleInManage = [].slice.call(rows).some(function(r){ return (r.textContent||'').indexOf('工作台') >= 0; }); + var done = document.querySelector('.dsh-wt_manageDone'); + if (done) { done.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true})); } + await sleep(300); + var card = vis('.dsh-wt_projects .dsh-wt_consoleEntry'); + card.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true})); + await sleep(600); + var bpop = vis('.dsh-wt_consoleBindPop'); + out.bindPopOpen = !!bpop; + if (bpop) { + out.leftItems = bpop.querySelectorAll('.dsh-wt_consoleBindList .dsh-wt_selectItem').length; + out.hasSelect = !!bpop.querySelector('.dsh-wt_consoleSelect'); + out.createBtn = bpop.querySelector('.dsh-wt_consoleCreateBtn') ? bpop.querySelector('.dsh-wt_consoleCreateBtn').textContent : null; + var labels = bpop.querySelectorAll('.dsh-wt_consoleBindLabel'); + out.leftLabel = labels[0] ? labels[0].textContent : null; + out.rightLabel = labels[1] ? labels[1].textContent : null; + var item = bpop.querySelector('.dsh-wt_consoleBindList .dsh-wt_selectItem'); + if (item) { item.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true})); } + await sleep(900); + } + out.bindPopClosed = !vis('.dsh-wt_consoleBindPop'); + out.specId = st.active && st.spec ? st.spec.id : null; + out.mainType = st.spec && st.spec.main[0] && st.spec.main[0].tabs && st.spec.main[0].tabs[0] ? st.spec.main[0].tabs[0].content.type : null; + var saved = JSON.parse(localStorage.getItem('dsh.worktable.projects.v1')); + out.savedConsoleBinding = saved.bindings['wt-console']; + await sleep(700); + var grid = vis('.dsh-wt_consoleGrid'); + out.gridFound = !!grid; + if (grid) { + out.gridCols = getComputedStyle(grid).gridTemplateColumns.split(' ').length; + var cards = grid.querySelectorAll('.dsh-wt_consoleCard'); + out.cardCount = cards.length; + var c0 = cards[0]; + out.firstCardSelf = !!(c0 && c0.classList.contains('dsh-wt_consoleCardSelf')); + out.firstCardIcon = c0 ? c0.querySelector('.dsh-wt_consoleIcon').textContent : null; + out.firstCardDot = c0 && c0.querySelector('.dsh-wt_consoleDot') ? c0.querySelector('.dsh-wt_consoleDot').className.replace('dsh-wt_consoleDot','').trim() : null; + out.firstCardBadges = c0 ? c0.querySelectorAll('.dsh-wt_consoleBadge').length : 0; + out.firstCardHasPreview = !!(c0 && c0.querySelector('.dsh-wt_consolePreview')); + out.anyLayoutCard = [].slice.call(cards).some(function(c){ return (c.textContent||'').indexOf('绑定测试') >= 0; }); + out.layoutCardHasJump = [].slice.call(cards).some(function(c){ return (c.textContent||'').indexOf('绑定测试') >= 0 && !!c.querySelector('.dsh-wt_consoleJump'); }); + var lcard = [].slice.call(cards).find(function(c){ return (c.textContent||'').indexOf('绑定测试') >= 0; }); + if (lcard) { lcard.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true})); } + await sleep(800); + } + out.afterCardClickSpec = st.active && st.spec ? st.spec.id : null; + st.close(); await sleep(300); + out.boundSid = saved.bindings['wt-console']; + } catch (e) { out.err = String(e && e.stack || e); } + return out; + })()`; + const out1 = await evaluate(expr1); + console.log('RESULT1 ' + JSON.stringify(out1)); + // 阶段2(不重载,走 UI):给 t-bind 用绑定弹窗绑一个会话 → 重开控制室 → 💬 跳转不关控制室 + const expr2 = `(async function(){ + var out = {}; + var sleep = function(ms){ return new Promise(function(r){ setTimeout(r, ms); }); }; + var vis = function(sel){ return [].slice.call(document.querySelectorAll(sel)).find(function(el){ return (getComputedStyle(el).visibility!=='hidden'&&el.getBoundingClientRect().height>0); }); }; + var st = window.__dshWorktable.splitStore; + try { + var tcard = [].slice.call(document.querySelectorAll('.dsh-wt_projects .dsh-wt_layout')).find(function(b){ return (b.textContent||'').indexOf('绑定测试') >= 0; }); + out.tCardFound = !!tcard; + var bb = tcard && tcard.querySelector('.dsh-wt_bindBtn'); + if (bb) { bb.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true})); } + await sleep(500); + out.bindPopOpen = !!vis('.dsh-wt_bindPop'); + var row = vis('.dsh-wt_bindPop .dsh-wt_bindConvRow'); + if (row) { row.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true})); } + await sleep(500); + var lpop = vis('.dsh-wt_bindListPop'); + out.listOpen = !!lpop; + var item = lpop && lpop.querySelector('.dsh-wt_selectItem'); + if (item) { item.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true})); } + await sleep(600); + var saved = JSON.parse(localStorage.getItem('dsh.worktable.projects.v1')); + out.tBound = !!saved.bindings['t-bind']; + var bd = vis('.dsh-wt_popBackdrop'); + if (bd) { bd.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true})); } + await sleep(400); + var card = vis('.dsh-wt_projects .dsh-wt_consoleEntry'); + card.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true})); + await sleep(1400); + out.reopenSpec = st.active && st.spec ? st.spec.id : null; + out.bindPopNotShown = !vis('.dsh-wt_consoleBindPop'); + var grid = vis('.dsh-wt_consoleGrid'); + var lc = grid && [].slice.call(grid.querySelectorAll('.dsh-wt_consoleCard')).find(function(c){ return (c.textContent||'').indexOf('绑定测试') >= 0; }); + out.layoutJumpBtn = !!(lc && lc.querySelector('.dsh-wt_consoleJump')); + out.layoutCardStatus = lc && lc.querySelector('.dsh-wt_consoleDot') ? lc.querySelector('.dsh-wt_consoleDot').className.replace('dsh-wt_consoleDot','').trim() : null; + if (lc && lc.querySelector('.dsh-wt_consoleJump')) { + lc.querySelector('.dsh-wt_consoleJump').dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true})); + await sleep(700); + out.specAfterJump = st.active && st.spec ? st.spec.id : null; + } + st.close(); await sleep(300); + } catch (e) { out.err = String(e && e.stack || e); } + return out; + })()`; + const out2 = await evaluate(expr2); + console.log('RESULT2 ' + JSON.stringify(out2)); + // 阶段3:解绑控制室 → 点开走强制绑定 → 右侧「新建对话」(默认无分组)→ 自动绑定并打开控制室; + // 顺带验证主题变量在控制室面板内实际解析(非空 = 跟随宿主深浅色)。 + const expr3 = `(async function(){ + var out = {}; + var sleep = function(ms){ return new Promise(function(r){ setTimeout(r, ms); }); }; + var vis = function(sel){ return [].slice.call(document.querySelectorAll(sel)).find(function(el){ return (getComputedStyle(el).visibility!=='hidden'&&el.getBoundingClientRect().height>0); }); }; + var st = window.__dshWorktable.splitStore; + try { + // 打开控制室 → 其卡片 ●● 打开绑定弹窗 → 解绑 + var card = vis('.dsh-wt_projects .dsh-wt_consoleEntry'); + card.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true})); + await sleep(1200); + var bb = card.querySelector('.dsh-wt_bindBtn'); + if (bb) { bb.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true})); } + await sleep(500); + var ub = vis('.dsh-wt_bindPop .dsh-wt_bindUnbind'); + out.unbindBtnFound = !!ub; + if (ub) { ub.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true})); } + await sleep(500); + var saved = JSON.parse(localStorage.getItem('dsh.worktable.projects.v1')); + out.unboundPersisted = !saved.bindings['wt-console']; + var bd = vis('.dsh-wt_popBackdrop'); + if (bd) { bd.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true})); } + await sleep(400); + st.close(); await sleep(400); + // 点控制室卡片 → 强制绑定弹窗 → 新建(默认无分组) + card.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true})); + await sleep(600); + var bpop = vis('.dsh-wt_consoleBindPop'); + out.forcedAgain = !!bpop; + var cb = bpop && bpop.querySelector('.dsh-wt_consoleCreateBtn'); + out.groupDefault = bpop && bpop.querySelector('.dsh-wt_consoleSelect') ? bpop.querySelector('.dsh-wt_consoleSelect').value : null; + if (cb) { cb.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true})); } + await sleep(1800); + out.panelClosedAfterCreate = !vis('.dsh-wt_consoleBindPop'); + out.specAfterCreate = st.active && st.spec ? st.spec.id : null; + var saved2 = JSON.parse(localStorage.getItem('dsh.worktable.projects.v1')); + out.newBindingSet = !!saved2.bindings['wt-console']; + out.newBindingDiffers = saved2.bindings['wt-console'] !== undefined; + // 主题:三选一开关 + data-wt-theme 落定 + 持久化 + var con = vis('.dsh-wt_console'); + out.themeAttr0 = con ? con.getAttribute('data-wt-theme') : null; + out.themeBtns = con ? con.querySelectorAll('.dsh-wt_consoleThemeBtn').length : 0; + var btns = con ? con.querySelectorAll('.dsh-wt_consoleThemeBtn') : []; + out.themeBtnTexts = [].slice.call(btns).map(function(b){ return b.textContent; }); + var cc = vis('.dsh-wt_consoleCard'); + out.cardBgDark = cc ? getComputedStyle(cc).backgroundColor : null; + if (btns[1]) { btns[1].dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true})); } + await sleep(400); + out.themeAttrLight = con ? con.getAttribute('data-wt-theme') : null; + out.cardBgLight = cc ? getComputedStyle(cc).backgroundColor : null; + if (btns[2]) { btns[2].dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true})); } + await sleep(400); + out.themeAttrSystem = con ? con.getAttribute('data-wt-theme') : null; + var sv = JSON.parse(localStorage.getItem('dsh.worktable.view.v1')); + out.savedTheme = sv.consoleTheme; + st.close(); await sleep(300); + } catch (e) { out.err = String(e && e.stack || e); } + return out; + })()`; + const out3 = await evaluate(expr3); + console.log('RESULT3 ' + JSON.stringify(out3)); + try { ws.close(); } catch {} + proc.kill(); +})().catch((e) => { console.error('PROBE FAIL', e); try { proc.kill(); } catch {} }); diff --git a/04_test/theme-probe.cjs b/04_test/theme-probe.cjs new file mode 100644 index 0000000..f1620f5 --- /dev/null +++ b/04_test/theme-probe.cjs @@ -0,0 +1,87 @@ +// theme-probe.cjs — 找宿主 --dsw-alias-* 变量定义位置 +const http = require('http'); +const { spawn } = require('child_process'); +const WebSocket = require('ws'); +const PORT = 9374; +const proc = spawn('C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', [ + '--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check', + '--window-size=1440,900', '--force-device-scale-factor=1', + '--remote-debugging-port=' + PORT, + '--user-data-dir=C:\\Users\\SJL\\AppData\\Local\\Temp\\wt-chrome-theme1', + 'about:blank', +], { stdio: 'ignore' }); +const getJSON = (p) => new Promise((res, rej) => { + http.get({ host: '127.0.0.1', port: PORT, path: p }, (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); } + const target = list.find((t) => t.type === 'page') || list[0]; + const ws = new WebSocket(target.webSocketDebuggerUrl); + let id = 0; const pending = new Map(); + 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 m = JSON.parse(String(raw)); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } }); + await new Promise((r) => ws.on('open', r)); + await send('Runtime.enable'); + await send('Page.enable'); + await send('Page.navigate', { url: 'http://127.0.0.1:3080/' }); + const evaluate = async (expr) => { + const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); + if (r.result && r.result.exceptionDetails) return { __error: (r.result.exceptionDetails.exception || {}).description || r.result.exceptionDetails.text }; + return r.result && r.result.result ? r.result.result.value : undefined; + }; + let ready = null; + for (let i = 0; i < 30; i++) { await sleep(1000); ready = await evaluate("!!window.__dshWorktable"); if (ready === true) break; } + await sleep(1200); + const out = await evaluate(`(function(){ + var out = {}; + var v = function(el, name){ return el ? (getComputedStyle(el).getPropertyValue(name)||'').trim() : null; }; + out.html = v(document.documentElement, '--dsw-alias-fill-l1'); + out.body = v(document.body, '--dsw-alias-fill-l1'); + // 逐层向上找第一个解析出变量的元素(限深 6 层,从工作台卡片起) + var el = document.querySelector('.dsh-wt_projects'); + var chain = []; + var cur = el; var d = 0; + while (cur && d < 8) { + var val = v(cur, '--dsw-alias-fill-l1'); + chain.push({ tag: cur.tagName, cls: String(cur.className||'').slice(0,60), val: val }); + if (val) break; + cur = cur.parentElement; d++; + } + out.chain = chain; + // 侧栏卡片同变量解析情况 + var card = document.querySelector('.dsh-wt_consoleEntry'); + out.entryVal = v(card, '--dsw-alias-fill-l1'); + // 全文档扫描前 30 个解析出该变量的元素(类名采样) + var found = []; + var all = document.querySelectorAll('*'); + for (var i = 0; i < all.length && found.length < 12; i++) { + if (v(all[i], '--dsw-alias-fill-l1')) { + found.push({ tag: all[i].tagName, cls: String(all[i].className||'').slice(0,50) }); + if (found.length === 1) { + var cs = getComputedStyle(all[i]); + out.firstVals = { + fill1: cs.getPropertyValue('--dsw-alias-fill-l1').trim(), + labelPrimary: cs.getPropertyValue('--dsw-alias-label-primary').trim(), + bgBase: cs.getPropertyValue('--dsw-alias-bg-base').trim(), + accent: cs.getPropertyValue('--dsw-alias-state-accent-primary').trim(), + }; + } + } + } + out.foundSample = found; + // 主题切换类名线索 + out.themeClsHints = [].slice.call(document.querySelectorAll('[class*=theme i],[class*=dark i],[class*=light i]')).slice(0,8).map(function(e){ return String(e.className).slice(0,60); }); + out.colorSchemeHtml = getComputedStyle(document.documentElement).colorScheme; + out.prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + // 宿主根元素背景色(判断实际渲染主题) + var app = document.querySelector('.hHd-Xa_root') || document.body; + out.hostBg = getComputedStyle(app).backgroundColor; + out.hostColor = getComputedStyle(app).color; + return out; + })()`); + console.log('RESULT ' + JSON.stringify(out)); + try { ws.close(); } catch {} + proc.kill(); +})().catch((e) => { console.error('PROBE FAIL', e); try { proc.kill(); } catch {} }); diff --git a/AGENTS.md b/AGENTS.md index 4ddd79f..b4ea4f0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,6 +75,25 @@ node --check lib/index.js ackProjectNotify 同步 ack 子代理。 **ack 生命周期**:ack 只在「同一轮待决未解决」期间压制提醒;状态转移(needNow 真↔假)时 clearNotifyAck 自动清除旧 ack——原生 UI 每个新问题都重新亮黄,镜像必须同样重新点亮。 +- **「工作台」控制室项目(默认自带)**: + - 固定 id `wt-console`(CONSOLE_ID),卡片恒排项目列表第一位(order 0)、不可删除 + (不进设置管理列表 + removeProject 兜底拒绝);图标 🖥️,名称走 locale console.name。 + - 点开:已绑定 → openConsole(默认布局 buildConsoleSpec:单一大窗格 content + {kind:'builtin',type:'console'} + 右侧对话,spec 持久化在 views['wt-console']); + 未绑定 → 强制绑定弹窗(左「加入现有对话」列表 / 右「新建对话」:分组 无/现有/新建, + 建空会话 sessions.create 后自动绑定并打开控制室)。绑定也走 projects.v1.bindings。 + - 控制室面板(split.tsx ConsolePane):卡片网格每行 3 张、超出换行;每卡 = 图标/名称/ + 三态圆点(need>done>busy>idle,不过滤 ack,永远显示事实状态)/运行时长(后台任务 + JobView.startedAt 或会话面 turnTimings 未结束轮次)/子代理数(byId.parentId + + subagentsByParent 双通道计数)/最近消息预览(会话面快照 nodes 末条文本,纯读内存 + 零 Token)。数据组装 = index.tsx getConsoleCards(env.console 注入),刷新走 + consoleListeners(项目/会话快照变化推送)+ 面板每秒 tick。 + - 卡片动作:点卡片 = 打开该项目(openSplit 或入驻项目切绑定对话);💬 = 跳绑定对话 + (markPluginSessionOpen 豁免联动,控制室保持打开);工作台自己的卡片点击无操作。 + - 主题:面板三选一开关(深色/白色/跟随系统,存 view.v1 consoleTheme);system 读宿主 + html 的 color-scheme(DSH 深色/白色/跟随系统设置都会反映到它)+ prefers-color-scheme + 兜底;落成 .dsh-wt_console[data-wt-theme=dark|light] 作用域变量 --wt-*(宿主不发布 + --dsw-alias-*,工作台全站一直靠回退色渲染——控制室自带主题作用域,不受其影响)。 ## 安装 / 重启