import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' 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 { worktableUpgrade } from './hostTransport' import { LOCAL_VERSION, cmpVer, newerUpdateInfo, type UpdateInfo } from './updateCheck' import { WidgetMountRegistry, newWidgetId, widgetManifestPath, type WidgetBinding } from './widgetMount' import { currentSessionOf, openHostSession, sendHostSession, createSessionSnapshotAdapter, childSessionIdsOf, pendingAckOf, sessionRuntimeMs, consoleStatusOf } from './sessionCompat' import { previewFromEvents, previewFromNodes, readSessionPreview, presetApiOf, modelApiOf, createHostSession, blankSessionNeedsWorkspace } from './sessionDetails' import { photoStore, kindOf } from './photoStore' import { DEFAULT_BG_SVG, b64ToBlob } from './defaultBg' import { WAVE_BG_B64 } from './waveBg' import { loadHostState, queueHostSave, markHydrated, staysReadOnly, flushHostSave } from './hostState' /** * dsh-worktable 客户端(v2):侧边栏底部「工作台」区块。 * 结构:分隔线 → [≡ 手柄][工作台][搜索/视图选项/添加+] → 项目卡片区(子座位)。 * v2 新增(PRD §10 定案): * - 卡片规范 v2 渐进上报协议:owner props 下发 order/hidden/nameOverrides, * 卡片可选上报 reportMeta/reportUsed;v1 卡片零改动兼容(按注册序排在最前)。 * - 视图选项只留「排序:手动/最近」(分类已按用户定案取消)。 * - 「管理项目…」编辑模式:改名/隐藏/排序(拖拽 + ↑↓),全存 localStorage。 * - 「+」:接入指引 + 本地快捷方式(名称/图标/链接,点击新标签打开)。 * - 完整 zh/en 词典接入 dsh-client-locale(NS 'worktable')。 * 持久化:dsh.worktable.view.v1(视图)+ dsh.worktable.projects.v1(项目元状态)。 */ type OrderBy = 'manual' | 'recent' type DockMode = 'footer' | 'float' // ── 更新检查(客户端直连 GitHub Releases API,只读 GET;失败静默)── const UPDATE_REPO = 'Aisland-SJL/dsh-worktable' const { command: UPGRADE_CMD, prompt: UPGRADE_AI, desktop: DESKTOP_HOST } = worktableUpgrade() // 更新提示图标(手绘 SVG,避免 emoji 跨平台渲染差异) const ICON_SYNC = ( ) const ICON_SPARK = ( ) async function copyText(text: string): Promise { try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return true } } catch { /* fallthrough */ } return false } type ViewState = { query: string searchOpen: boolean orderBy: OrderBy dock: DockMode floatTop: number | null /** 控制室面板主题:dark/light/system(system = 跟随宿主 color-scheme,即 DSH 深色/白色/跟随系统设置) */ consoleTheme?: 'dark' | 'light' | 'system' /** 控制室每行显示项目数(1-5,默认 3)*/ consoleCols?: number /** 控制室卡片形状:square/circle(默认 square)*/ consoleShape?: 'square' | 'circle' /** 控制室背景:plain/glow/photo(默认 glow)*/ consoleBg?: 'plain' | 'glow' | 'photo' /** 纯色背景 HSL(默认对应 #0a0d13 = 220/31/6)*/ consoleBgPlainHsl?: { h: number; s: number; l: number } /** 流光背景 HSL(hue-rotate/saturate/brightness;默认无调整 = 0/100/100)*/ consoleBgGlowHsl?: { h: number; s: number; l: number } /** 当前选中的背景照片 id(photoRecords 中的一条记录)*/ consoleBgPhotoId?: string /** 各背景照片的 HSL 滤镜记录(id → {h,s,l};默认无调整 = 0/100/100)*/ consoleBgPhotoHsls?: Record /** 照片背景上的网格线开关(默认开)*/ consoleBgPhotoGrid?: boolean /** 照片背景网格线透明度 %(0-30,默认 8)*/ consoleBgGridOpacity?: number /** 项目卡片贴片模糊 px(0-20,默认 0)——照片/自定义背景的记忆 */ consoleCardBlur?: number /** 按背景记忆:纯色/流光的贴片模糊 px(0-20,默认 0)*/ consoleBgPlainBlur?: number consoleBgGlowBlur?: number /** 按背景记忆:纯色/流光的网格线不透明度 %(0-30,默认 5 / 8)*/ consoleBgPlainGrid?: number consoleBgGlowGrid?: number /** 流光移动速度(0-100,默认 50 = 原速×1;0 = 完全不动;100 = 2× 快)*/ consoleGlowSpeed?: number } /** 卡片上报的项目元信息(协议 v2)。 */ export type ProjectMeta = { id: string; name: string; icon?: string } /** 本地快捷方式(仅存 localStorage)。 */ export type Shortcut = { id: string; name: string; icon: string; href: string } type ProjectsState = { /** 手动排序的 id 序列(新注册 id 未出现在其中时追加在尾部)。 */ order: string[] /** id → 最近使用时间戳(毫秒),来源:卡片点击时 reportUsed。 */ lastUsed: Record /** 被隐藏的项目 id 集。 */ hidden: string[] /** 显示名覆盖(编辑模式改名;空值删除覆盖)。 */ nameOverrides: Record /** 图标覆盖(点图标换 emoji;含入驻插件项目 id → emoji)。 */ iconOverrides: Record /** 已从工作台删除的入驻项目 id(真删除:恢复默认不复活;在「已删除的项目」里可重新添加)。 */ removed: string[] /** 入驻项目的视图覆盖(id → LayoutSpec):变更视图后按此打开,替换项目自声明的布局。 */ views: Record /** 本地快捷方式条目。 */ shortcuts: Shortcut[] /** 用户自建的布局条目(「+」新建工作区保存的 LayoutSpec)。 */ layouts: LayoutSpec[] /** 项目 → 绑定会话:打开该项目时右侧对话窗自动切换到这个会话(空/缺 = 不换)。 */ bindings: Record /** 项目 → 工作文件夹(绝对路径):该项目所有产出文件都放这里;新建项目时强制填写。 */ folders: Record } const PERSIST_KEY = 'dsh.worktable.view.v1' const PROJECTS_KEY = 'dsh.worktable.projects.v1' /** 本地最后写入时间(毫秒):与宿主镜像的 updatedAt 比较,决定启动时以哪边为准 */ const SAVED_AT_KEY = 'dsh.worktable.savedAt.v1' const MIN_TOP = 56 const SNAP_PX = 32 /** 插件市场外链(GitHub 仓库,已核实可访问;PRD 提及的 dshfind.com 未验证,不用死链)。 */ const MARKET_URL = 'https://github.com/hikariming/dshfind' /** 已报到卡片的 CSS order 偏移:未上报(order=0)的 v1 卡片永远排在已上报卡片之前。 */ const ORDER_OFFSET = 1000 /** 非引擎项目埋点冷却(毫秒):同 id 两次计使用的最小间隔 */ const LEGACY_BUMP_COOLDOWN = 15000 /** 落点判定余量:松手时指针越出有效落点区(超出底部/顶部/侧边)即视为「无有效落点」。 */ const OVER_BOTTOM_PX = 24 const OVER_TOP_PX = 24 const OVER_SIDE_PX = 80 /** 拓扑预设(聊天窗恒贴右,PRD §13.2 硬约束):左右/三栏/左二右一/井字/左品右聊/左1大下3小 */ const PRESET_DEFS = [ { id: '2h', leftCount: 0, topCount: 0, contentCount: 1, chatFull: false }, { id: '3h', leftCount: 0, topCount: 0, contentCount: 2, chatFull: false }, { id: 'l2', leftCount: 0, topCount: 1, contentCount: 1, chatFull: true }, { id: 'grid', leftCount: 0, topCount: 2, contentCount: 1, chatFull: false }, { id: 't3', leftCount: 0, topCount: 1, contentCount: 2, chatFull: true }, { id: 'l13', leftCount: 0, topCount: 1, contentCount: 3, chatFull: true, topHeightDefault: 420, topHeightRatio: 0.55 }, { id: 'g4', leftCount: 0, topCount: 2, contentCount: 2, chatFull: true, topHeightRatio: 0.5 }, { id: 'l23', leftCount: 0, topCount: 2, contentCount: 3, chatFull: true, topHeightRatio: 0.5 }, ] as const /** 侧栏图标备选集(emoji):布局/快捷方式/入驻项目的图标,点击可换(首项 🧱 为布局默认) */ const EMOJI_SET = ['🧱', '🏠', '🎓', '🚗', '✈️', '🌍', '🏥', '📚', '✏️', '⚙️', '🎨', '🎮', '🌏', '📐', '🧪', '🤖', '📦', '💬'] /** 官方工作区头部按钮图标(自 DSH Web GUI 工作区面板取样,fill=currentColor 跟随主题) */ const ICON_SEARCH = ( ) const ICON_VIEW_OPTIONS = ( ) const ICON_ADD = ( ) function buildLayout(presetId: string, name: string): LayoutSpec { const def = PRESET_DEFS.find((d) => d.id === presetId) ?? PRESET_DEFS[0] const mk = (i: number): SplitPane => ({ id: 'p' + (i + 1), title: '窗口' + (i + 1), min: 200, content: null, }) const left = def.leftCount > 0 ? mk(0) : null const top = Array.from({ length: def.topCount }, (_, i) => mk(def.leftCount + i)) const main = Array.from({ length: def.contentCount }, (_, i) => mk(def.leftCount + def.topCount + i)) return { id: newWidgetId('layout'), title: name, left: left ?? null, top: top.length > 0 ? top : null, main, leftWidth: { default: 260, min: 160, max: 480 }, chatWidth: { default: 360, min: 240, max: 600 }, topHeight: { default: (def as any).topHeightDefault ?? 200, min: 120, max: 480 }, topHeightRatio: (def as any).topHeightRatio ?? 0.35, chatSide: 'right', chatFullHeight: def.chatFull === true, } } /** 布局缩略图(迷你窗格示意;聊天窗蓝色 💬) */ function presetThumb(defId: string) { const cell = (chat: boolean, key: string) => ( {chat ? '💬' : ''} ) if (defId === '2h') { return {cell(false, 'a')}{cell(true, 'b')} } if (defId === '3h') { return {cell(false, 'a')}{cell(false, 'b')}{cell(true, 'c')} } if (defId === 'g4') { // 田字格:左侧 2×2 均分,右侧对话整列(5 视窗) return ( {cell(false, 'a')}{cell(false, 'b')} {cell(false, 'c')}{cell(false, 'd')} {cell(true, 'e')} ) } if (defId === 'l23') { // 上 2 下 3:左侧上排 2 窗 + 下排 3 窗,右侧对话整列(6 视窗) return ( {cell(false, 'a')}{cell(false, 'b')} {cell(false, 'c')}{cell(false, 'd')}{cell(false, 'e')} {cell(true, 'f')} ) } if (defId === 'l13') { // 左列上 1 大 + 下 3 小;右列对话整列(5 视窗) return ( {cell(false, 'a')} {cell(false, 'b')}{cell(false, 'c')}{cell(false, 'd')} {cell(true, 'e')} ) } if (defId === 'grid') { return ( {cell(false, 'a')}{cell(false, 'b')} {cell(false, 'c')}{cell(true, 'd')} ) } if (defId === 'l2') { // 左二右一:左侧上下两个内容窗,右侧整列对话(💬) return ( {cell(false, 'a')} {cell(false, 'b')} {cell(true, 'c')} ) } return ( {cell(false, 'a')} {cell(false, 'b')}{cell(false, 'c')} {cell(true, 'd')} ) } const DEFAULT_VIEW: ViewState = { query: '', searchOpen: false, orderBy: 'manual', dock: 'footer', floatTop: null, } const DEFAULT_PROJECTS: ProjectsState = { order: [], lastUsed: {}, hidden: [], nameOverrides: {}, iconOverrides: {}, removed: [], views: {}, shortcuts: [], layouts: [], bindings: {}, folders: {}, } /** HSL 归一化:色相映射到 -180..180(0 = 中性/中间),饱和/明度只接受 0..200 数值;非法返回 undefined */ function normHsl(v: any): { h: number; s: number; l: number } | undefined { if (!v || typeof v.h !== 'number' || typeof v.s !== 'number' || typeof v.l !== 'number') return undefined const h = ((Math.round(v.h) % 360) + 360) % 360 return { h: h > 180 ? h - 360 : h, s: Math.min(Math.max(Math.round(v.s), 0), 200), l: Math.min(Math.max(Math.round(v.l), 0), 200), } } /** 视图状态归一化(本地存档与宿主镜像共用同一套字段校验) */ function normalizeView(raw: any): ViewState { try { if (!raw || typeof raw !== 'object') return { ...DEFAULT_VIEW } const p = raw // 显式挑字段:旧版遗留的 groupBy 等未知字段直接忽略。 // 一次性迁移:v2 起默认「手动」排序(最近排序点击即置顶、体验差),旧存「最近」回落为「手动」; // 之后用户手动选择「最近」会写入 sortMigratedV2 标记并被尊重。 const orderBy = p.sortMigratedV2 === true ? (p.orderBy === 'recent' ? 'recent' : 'manual') : 'manual' return { query: typeof p.query === 'string' ? p.query : '', searchOpen: p.searchOpen === true, 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', consoleCols: typeof p.consoleCols === 'number' && p.consoleCols >= 1 && p.consoleCols <= 5 ? Math.round(p.consoleCols) : 3, consoleShape: p.consoleShape === 'circle' ? 'circle' : 'square', consoleBg: p.consoleBg === 'plain' || p.consoleBg === 'photo' ? p.consoleBg : 'glow', consoleBgPlainHsl: normHsl(p.consoleBgPlainHsl), consoleBgGlowHsl: normHsl(p.consoleBgGlowHsl), consoleBgPhotoId: typeof p.consoleBgPhotoId === 'string' && p.consoleBgPhotoId ? p.consoleBgPhotoId : undefined, consoleBgPhotoGrid: p.consoleBgPhotoGrid !== false, consoleBgGridOpacity: typeof p.consoleBgGridOpacity === 'number' && p.consoleBgGridOpacity >= 0 && p.consoleBgGridOpacity <= 30 ? Math.round(p.consoleBgGridOpacity) : undefined, consoleCardBlur: typeof p.consoleCardBlur === 'number' && p.consoleCardBlur >= 0 && p.consoleCardBlur <= 20 ? Math.round(p.consoleCardBlur) : undefined, consoleBgPlainBlur: typeof p.consoleBgPlainBlur === 'number' && p.consoleBgPlainBlur >= 0 && p.consoleBgPlainBlur <= 20 ? Math.round(p.consoleBgPlainBlur) : undefined, consoleBgGlowBlur: typeof p.consoleBgGlowBlur === 'number' && p.consoleBgGlowBlur >= 0 && p.consoleBgGlowBlur <= 20 ? Math.round(p.consoleBgGlowBlur) : undefined, consoleBgPlainGrid: typeof p.consoleBgPlainGrid === 'number' && p.consoleBgPlainGrid >= 0 && p.consoleBgPlainGrid <= 30 ? Math.round(p.consoleBgPlainGrid) : undefined, consoleBgGlowGrid: typeof p.consoleBgGlowGrid === 'number' && p.consoleBgGlowGrid >= 0 && p.consoleBgGlowGrid <= 30 ? Math.round(p.consoleBgGlowGrid) : undefined, consoleGlowSpeed: typeof p.consoleGlowSpeed === 'number' && p.consoleGlowSpeed >= 0 && p.consoleGlowSpeed <= 100 ? Math.round(p.consoleGlowSpeed) : undefined, consoleBgPhotoHsls: (() => { const src = p.consoleBgPhotoHsls if (!src || typeof src !== 'object') return undefined const out: Record = {} for (const k of Object.keys(src)) { const v = normHsl((src as any)[k]) if (v) out[k] = v } return Object.keys(out).length > 0 ? out : undefined })(), } } catch { return { ...DEFAULT_VIEW } } } function loadView(): ViewState { try { const raw = localStorage.getItem(PERSIST_KEY) if (!raw) return { ...DEFAULT_VIEW } return normalizeView(JSON.parse(raw)) } catch { return { ...DEFAULT_VIEW } } } /** 项目状态归一化(本地存档与宿主镜像共用同一套字段校验) */ function normalizeProjects(raw: any): ProjectsState { try { if (!raw || typeof raw !== 'object') return { ...DEFAULT_PROJECTS } const p = raw return { order: Array.isArray(p.order) ? p.order.filter((x: unknown): x is string => typeof x === 'string') : [], lastUsed: p.lastUsed && typeof p.lastUsed === 'object' ? p.lastUsed : {}, hidden: Array.isArray(p.hidden) ? p.hidden.filter((x: unknown): x is string => typeof x === 'string') : [], nameOverrides: p.nameOverrides && typeof p.nameOverrides === 'object' ? p.nameOverrides : {}, iconOverrides: p.iconOverrides && typeof p.iconOverrides === 'object' ? p.iconOverrides : {}, removed: Array.isArray(p.removed) ? p.removed.filter((x: unknown): x is string => typeof x === 'string') : [], views: p.views && typeof p.views === 'object' ? p.views : {}, shortcuts: Array.isArray(p.shortcuts) ? p.shortcuts.filter((s: any) => s && typeof s.id === 'string' && typeof s.name === 'string' && typeof s.href === 'string') : [], layouts: Array.isArray(p.layouts) ? p.layouts.filter((l: any) => l && typeof l.id === 'string' && typeof l.title === 'string' && Array.isArray(l.main)) : [], bindings: p.bindings && typeof p.bindings === 'object' ? p.bindings : {}, folders: p.folders && typeof p.folders === 'object' ? p.folders : {}, } } catch { return { ...DEFAULT_PROJECTS } } } function loadProjects(): ProjectsState { try { const raw = localStorage.getItem(PROJECTS_KEY) if (!raw) return { ...DEFAULT_PROJECTS } return normalizeProjects(JSON.parse(raw)) } catch { return { ...DEFAULT_PROJECTS } } } /** 记下本地写入时间,供启动时与宿主镜像比较新鲜度 */ function markSaved(): void { try { localStorage.setItem(SAVED_AT_KEY, String(Date.now())) } catch {} } const clamp = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), hi) type FloatRect = { top: number } /** 从组件挂载点向上找 sidebar 容器:className 含 SidebarRoot/sidebar,或标签 aside/nav,到 body 为止。 */ function findSidebar(start: HTMLElement | null): HTMLElement | null { let el: HTMLElement | null = start while (el && el !== document.body) { const tag = el.tagName.toLowerCase() if (tag === 'aside' || tag === 'nav') return el if (typeof el.className === 'string' && /SidebarRoot|sidebar/i.test(el.className)) return el el = el.parentElement } return null } /** 子座位注册 id 序列(模块级 store;apply 里订阅 slots 变化写入)。 */ const registryStore: { ids: string[]; listeners: Set<() => void> } = { ids: [], listeners: new Set() } /** 自定义窗口 → 宿主会话桥(apply 时注入;不可用时 CustomPane 降级提示) */ let sessionBridge: { sessions: any; conversation: any; list: any; workspaces: any } | null = null /** 客户端根 ctx(apply 时保存;用于运行时探测 0.1.2 的 remote.directoryPicker 等新服务,不硬写 inject) */ let clientCtx: any = null /** 宿主 API 客户端(apply 时从 connection 服务取;agentPresets/sessions 用于修复新会话继承失效模型的 bug) */ let hostApi: { agentPresets?: any; sessions?: any } | null = null /** 新会话显式应用「部署默认预设」:宿主新会话座位同款逻辑(api.agentPresets.select,仅对 blank 会话生效)。 * 修复:用户删掉默认模型后,新建会话继承到失效预设,prompt 报 model-unavailable 导致窗口建不出来。 */ async function ensureSessionPreset(sessionId: string): Promise { const api = presetApiOf(clientCtx, hostApi?.agentPresets) if (!api || typeof api.list !== 'function' || typeof api.select !== 'function') return try { const listRes = await api.list({}) const presets = listRes?.result?.ok ? listRes?.result?.value?.presets : null if (!Array.isArray(presets) || presets.length === 0) return // 部署默认优先,若其本身也失效(如引用了被删的 provider),逐个尝试其余预设直到成功 const ordered = [...presets] const defIdx = ordered.findIndex((p: any) => p?.isDefault) if (defIdx > 0) ordered.unshift(ordered.splice(defIdx, 1)[0]) for (const preset of ordered) { const pid = preset?.id if (!pid) continue try { const selRes = await api.select({ sessionId, agentPreset: pid }) if (selRes?.result?.ok) { try { sessionBridge?.sessions?.noteAgentPreset?.(sessionId, pid) } catch {} return } try { if (typeof console !== 'undefined' && console.warn) console.warn('[worktable] preset select failed:', pid, selRes?.result?.error?.message ?? '') } catch {} } catch (err) { try { if (typeof console !== 'undefined' && console.warn) console.warn('[worktable] preset select threw:', pid, String(err)) } catch {} } } } catch { /* 修复失败不阻断主流程,错误仍按原路径提示 */ } } /** 按「用户既有选择」在可用目录里找最贴近的模型(不擅自选): * 1) id 完全一致;2) id/名称含基准模型的家族词(pro/max/mini/flash…);3) 目录首个。 */ function pickInheritedModel(groups: any[], baseModel: string | undefined): { provider: string; model: string } | null { if (!Array.isArray(groups) || groups.length === 0) return null const flat: { provider: string; model: string; name: string }[] = [] for (const g of groups) { if (!g?.id || !Array.isArray(g.models)) continue for (const m of g.models) if (m?.id) flat.push({ provider: g.id, model: m.id, name: String(m.name ?? '') }) } if (flat.length === 0) return null const base = String(baseModel ?? '').toLowerCase() if (base) { const exact = flat.find((f) => f.model.toLowerCase() === base) if (exact) return { provider: exact.provider, model: exact.model } const tokens = base.split(/[-_.]/).filter((t) => t && (/^(pro|max|mini|flash|lite|ultra|plus|sonnet|opus|haiku|turbo)$/i.test(t) || t.length >= 4)) for (const tok of tokens) { const hit = flat.find((f) => f.model.toLowerCase().includes(tok) || f.name.toLowerCase().includes(tok)) if (hit) return { provider: hit.provider, model: hit.model } } } return { provider: flat[0].provider, model: flat[0].model } } /** 新会话模型选择修复(真正的根因修复):当前选择指向已删 provider(routable=false)时, * 不擅自选模型——① 优先继承用户当前会话正在用的模型;② 兜底按失效选择里的模型家族词 * 找同款;③ 最后才目录首个。session.selectModel 同时把新选择存为默认,顺带修复后续新会话。 */ async function ensureSessionModel(sessionId: string): Promise { 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 }) if (!mRes?.result?.ok) return const val = mRes.result.value const groups = val?.groups let target: { provider: string; model: string } | null = null let effort: string | undefined // ① 无条件继承用户当前会话的选择(用户正在用哪个模型,新会话就用哪个) try { const cur = currentSessionOf(sessionBridge?.list?.getSnapshot?.()) if (cur && cur !== sessionId) { const cRes = await api.models({ sessionId: cur }) if (cRes?.result?.ok) { const cval = cRes.result.value if (cval?.routable === true && cval?.current?.provider && cval?.current?.model) { const same = val?.current?.provider === cval.current.provider && val?.current?.model === cval.current.model if (!same) { target = { provider: cval.current.provider, model: cval.current.model } effort = typeof cval.current.reasoningEffort === 'string' ? cval.current.reasoningEffort : undefined } } } } } catch {} // ② 无当前会话且新会话选择不可用 → 最近会话众数(用户习惯) if (!target && val?.routable !== true) { try { const snap = sessionBridge?.list?.getSnapshot?.() const byId: Record = snap?.byId ?? {} const ids: string[] = (snap?.ids ?? []).filter((x: string) => x !== sessionId) const scored: { key: string; provider: string; model: string; effort?: string; count: number }[] = [] await Promise.all(ids.slice(0, 6).map(async (sid) => { const row = byId[sid] if (row?.blank === true) return try { const r2 = await api.models({ sessionId: sid }) if (!r2?.result?.ok) return const cv = r2.result.value if (cv?.routable !== true || !cv?.current?.provider || !cv?.current?.model) return const key = cv.current.provider + '|' + cv.current.model const found = scored.find((s) => s.key === key) if (found) found.count++ else scored.push({ key, provider: cv.current.provider, model: cv.current.model, effort: typeof cv.current.reasoningEffort === 'string' ? cv.current.reasoningEffort : undefined, count: 1 }) } catch {} })) scored.sort((a, b) => b.count - a.count) if (scored[0]) { target = { provider: scored[0].provider, model: scored[0].model } effort = scored[0].effort } } catch {} } // ③/④ 兜底(仅当新会话选择不可用):家族词 → 目录首个 if (!target && val?.routable !== true) target = pickInheritedModel(groups, val?.current?.model) if (!target) return await api.selectModel({ sessionId, provider: target.provider, model: target.model, ...(effort !== undefined ? { reasoningEffort: effort } : {}) }) } catch { /* 修复失败不阻断主流程,错误仍按原路径提示 */ } } /** 控制室(工作台自己)项目:固定 id、固定排项目列表第一位、不可删除 */ const CONSOLE_ID = 'wt-console' const CONSOLE_ICON = '🖥️' /** 控制室布局是否健全:主窗格首格存在 console 类型标签(旧版关标签的坏存档没有它) */ function specHasConsoleTab(spec: LayoutSpec | undefined): boolean { return !!spec?.main?.[0]?.tabs?.some((t) => t.content?.kind === 'builtin' && t.content.type === 'console') } /** 控制室默认布局:单一锁死大窗格(项目卡片网格)+ 右侧对话 */ function buildConsoleSpec(t: (key: string) => string): LayoutSpec { return { id: CONSOLE_ID, title: t('console.name'), 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 [] } } /** 冷会话最近消息缓存与预热:0.2 临时持有会话、读 eventSource 后释放;旧版保留 history 通道。 * 只读消息文本,不请求模型;拉取是带宽成本,不产生模型 Token。 */ const previewCache = new Map() const previewFetching = new Set() let previewSweepBusy = false let previewTimer: number | null = null /** 预热所有已绑定会话的最近消息(逐个只读拉取;同一在途请求不重复,失败回退缓存/内存路径) */ async function sweepPreviews() { if (previewSweepBusy) return previewSweepBusy = true try { const sids = Array.from(new Set(Object.values(projectBindingsRef.current).filter((x): x is string => !!x))) for (const sid of sids) { if (previewFetching.has(sid)) continue previewFetching.add(sid) try { const txt = await readSessionPreview(sessionBridge?.sessions, sid) previewCache.set(sid, txt) notifyConsole() } catch {} finally { previewFetching.delete(sid) } } } finally { previewSweepBusy = false } } /** 防抖调度:控制室开着且会话快照变化时刷新预览(合并 6s 内的连续变化) */ function schedulePreviewSweep() { // Temporary retain/release also changes the catalog: do not schedule ourselves forever. if (previewSweepBusy || previewTimer != null) return previewTimer = window.setTimeout(() => { previewTimer = null; sweepPreviews() }, 6000) } /** 从宿主内存镜像提取最近助手文本;无可读答复则留空,不回退注入/推理/工具内容。 */ function lastTextOf(sid: string): string { try { const binding = sessionBridge?.sessions?.binding?.(sid) if (binding?.eventSource?.getSnapshot) return previewFromEvents(binding.eventSource.getSnapshot().entries) const face = binding?.session?.getSnapshot?.() return previewFromNodes(face?.nodes) } catch {} return '' } /** 会话分组列表(模块级,供自定义窗会话选择与对话绑定弹窗共用) */ async function fetchSessionGroups(): Promise<{ groups: { title: string; sessions: { id: string; title: string; isCurrent: boolean }[] }[]; current: string }> { try { const snap = sessionBridge?.list?.getSnapshot?.() const byId = snap?.byId ?? {} const current = currentSessionOf(snap) // 子代理会话(后台产生的,用户面板看不到)排除 const subKids = new Set() try { const map = snap?.subagentsByParent ?? {} for (const k of Object.keys(map)) { const v = map[k] 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') subKids.add(cid) }) } } catch {} const titleOf = (sid: string) => byId[sid]?.title ?? byId[sid]?.displayTitle ?? sid const mk = (sid: string) => ({ id: sid, title: titleOf(sid), isCurrent: sid === current }) // 工作区分组:服务端读宿主 workspace.json(含 archived 排除),按用户面板结构分组; // 不属于任何工作区的会话收进「未分组」组(绑定弹窗/发送到会话都要能看到它们) try { const r = await fetch('/api/worktable/workspaces') const d = await r.json() const order: string[] = Array.isArray(d?.global?.workspaceIds) ? d.global.workspaceIds : [] const archived: string[] = Array.isArray(d?.global?.archivedSessionIds) ? d.global.archivedSessionIds : [] const table = d?.tables?.workspaces ?? {} const accounted = new Set() const groups = order .map((wid: string) => { const sessIds = ((table[wid]?.sessionIds ?? []) as string[]) sessIds.forEach((sid) => accounted.add(sid)) return { title: (table[wid]?.title ?? wid) as string, sessions: sessIds .filter((sid) => !archived.includes(sid) && !subKids.has(sid)) .map(mk), } }) .filter((g) => g.sessions.length > 0) const allIds: string[] = Array.isArray(snap?.ids) ? snap.ids : [] const ungrouped = allIds.filter((sid) => !accounted.has(sid) && !archived.includes(sid) && !subKids.has(sid)) const withUngrouped = ungrouped.length > 0 ? [{ title: '未分组', sessions: ungrouped.map(mk) }, ...groups] : groups if (withUngrouped.length > 0) return { groups: withUngrouped, current } } catch {} // 回退:平铺(排除子代理) const ids: string[] = Array.isArray(snap?.ids) ? snap.ids : [] return { groups: [{ title: '', sessions: ids.filter((x) => !subKids.has(x)).map(mk) }], current } } catch { return { groups: [], current: '' } } } /** hover 气泡:挂在 document.body 的独立元素(不受侧栏堆叠上下文限制,可向右伸出显示) */ let bindTipEl: HTMLDivElement | null = null function showBindTip(btn: HTMLElement) { const tip = btn.getAttribute('data-tip') if (!tip) return if (!bindTipEl) { bindTipEl = document.createElement('div') bindTipEl.className = 'dsh-wt_bindTip' document.body.appendChild(bindTipEl) } bindTipEl.textContent = tip const r = btn.getBoundingClientRect() bindTipEl.style.left = (r.right + 8) + 'px' bindTipEl.style.top = (r.top + r.height / 2) + 'px' bindTipEl.style.display = 'block' // 右侧放不下时翻到左侧(一般不会:侧栏 ~288px + 气泡 ~200px 远小于视口宽) const tw = bindTipEl.offsetWidth const x = r.right + 8 + tw > window.innerWidth - 8 ? Math.max(8, r.left - 8 - tw) : r.right + 8 bindTipEl.style.left = x + 'px' } function hideBindTip() { if (bindTipEl) bindTipEl.style.display = 'none' } /** 把文本送入指定会话:宿主同款寻址(binding(id).session.prompt / sendSession(会话面)), * 插件是根级上下文,无作用域的 conversation.send 会报 requires a session scope,不可用。 */ async function promptIntoSession(sessionId: string, text: string): Promise { const b = sessionBridge if (!b) throw new Error('bridge unavailable') await sendHostSession(b, sessionId, text) } /** 新建会话的分组选择:未分组 / 加入现有分组 / 新建一个分组(父目录 + 名称) */ export type NewSessionGroup = { kind: 'none' } | { kind: 'existing'; workspaceId: string } | { kind: 'new'; parent: string; name: string } /** 插件知识包:附在窗口任务提示词里,让接收方跳过对插件源码的重新侦察,直接干活 */ const KNOWLEDGE_PACK = [ '【插件知识包·请直接采用,不要重新侦察插件源码】', '- dsh-worktable 是 DeepSeek Harness 的自建容器插件(工作台:侧边栏里的项目应用抽屉;不了解的细节可直接向用户提问)。', '- 窗口(内容窗)模型:每个窗 = 一个标签页;未指派时显示选择器(浏览器/动画/资源管理器/终端/✨自定义);', ' 窗内容还可放 iframe 网页与文件预览(.md/.txt/.tsx/.css/.html 等)。', '- 窗口可装载的形式:HTML 单文件(交互 UI,放项目文件夹后在资源管理器点击渲染)、网页 URL、', ' 文件预览(.md/.txt/.tsx/.css/.html/图片/.mp4 等);产出形式按任务类型选,不要一律 HTML。', '- 产出 HTML 必须用工作台原生皮肤:在页面 加 ,', ' 组件类参考 /api/worktable/template/dshell.html(卡片/按钮/状态徽标/标签页/列表/统计/进度/表格等),', ' 界面即可与 DSH 原生风格一致;自造样式控制在最小范围。', '- 服务端能力:/api/worktable/fs(列目录)、/api/worktable/write(写文件)、/api/worktable/mkdir(建目录)、', ' /api/worktable/git(git 状态)、/api/worktable/site(静态托管)。', '- 改完插件在 01_content 目录执行 npm run build;网页端重启 dsh web 并刷新,桌面端完整退出后手动重新打开。', '- 所有产出文件一律放进本任务标注的项目文件夹,保持用户目录干净。', '- 标注协议:收到「📌 标注-窗口N …(坐标/元素/框内文字…)…」= 工作台标注(窗口编号约定:左栏→顶行→主行,从 1 起)。', ' 处理:标注已标明框选区域与「最可能目标」时直接作答、不要二次询问(仅当确实无法从标注定位时才问一次);能查看截图或打开窗口则先核实再回答。', ].join('\n') /** 组装窗口任务提示词(新建/发送两模式共用;导出到 window 供自测校验) */ function buildWindowTaskText(projectId: string, projectName: string, windowLabel: string, requirement: string, folder: string | null, mode: 'new' | 'send', widgetBindings: WidgetBinding[] = []): string { const win = windowLabel || '一个内容窗' const folderLine = folder ? '项目文件夹:' + folder + '(本项目所有产出文件一律放进这个文件夹;不要写到别的默认位置)。' : '本项目暂未设置专属文件夹:产出文件先向用户确认存放位置,不要随便写。' const lines = [ '【工作台】', '以下内容由 dsh-worktable(工作台)插件' + (mode === 'new' ? '自动发送' : '发送') + ':用户想把「' + projectName + '」项目中的「' + win + '」窗口打造成他想要的内容。', '1. dsh-worktable 是侧边栏底部的自建「工作台」插件(官方文档没有它的说明,不了解之处可直接向用户提问)。', '2. 用户需求:' + requirement, '3. ' + folderLine, '4. 产出形式请按任务类型选择(不要一律用 HTML):', ' - 交互小工具 / 看板 / 表单 / 图表(如计算器、互动按钮、数据看板)→ 单文件 HTML,放进项目文件夹;', ' - 文档 / 演示(PPT、报告、表格)→ 生成真实文件(.pptx / .md / .xlsx)放进项目文件夹,用户用本机软件打开编辑;', ' - 视频 / 动画 → 生成 .mp4 / .gif(或 Lottie JSON)文件放进项目文件夹;', ' - 工作台已有内置窗能力(资源管理器 / 终端 / 浏览器 / 动画站)→ 不要重复造轮子,直接建议用户改用内置窗。', '5. 完成后用一两句话说明作品与目标窗口。写清单不代表界面已确认挂载成功;只有实际看到挂载结果时,才说「已挂载」。该窗口属于「' + projectName + '」项目。', '6. 产物握手 v2:只使用下列本项目专属清单路径,不读写项目根目录旧的 widget-result.json,也不要复用其他项目的清单。作品仍放在项目文件夹,path 按该文件夹解析。先完成作品,再创建清单父目录并写入对应 JSON;kind 为 html / url / file,替换示例中的 path 与 kind,其他身份字段保持原样。一次生成多个窗口时分别写各窗口的清单。以后在本会话里继续修改同名作品,仍更新这个清单;不得根据清单文件名变化与否判断是否完成。', widgetBindings.length ? widgetBindings.map((b) => b.window + ':' + widgetManifestPath(b) + '\n' + JSON.stringify({ version: 2, projectId: b.projectId, paneId: b.paneId, bindingId: b.bindingId, window: b.window, path: '产物相对路径', kind: 'html' })).join('\n') : '当前没有有效的自动挂载绑定,请用户从目标窗口重新发送自定义任务;不要写旧清单猜测归属。', KNOWLEDGE_PACK, ] return lines.join('\n') } /** 自定义窗口任务:新建一个专属会话(可选指定分组/项目文件夹),注入窗口身份 + 知识包,并打开该会话 */ export async function createCustomSession(projectId: string, projectName: string, requirement: string, group?: NewSessionGroup, windowLabel = '', folder: string | null = null): Promise { const b = sessionBridge if (!b || typeof b.sessions?.create !== 'function') throw new Error('sessions unavailable') // 分组解析:existing → 直接带 workspaceId 建会话;new → 先建目录并注册为工作区 let workspaceId: string | null = null const g = group ?? { kind: 'none' as const } if (g.kind === 'existing') { workspaceId = g.workspaceId } else if (g.kind === 'new') { const ws = b.workspaces as any if (!ws || typeof ws.create !== 'function') throw new Error('workspaces unavailable') const parent = g.parent.replace(/[\\/]+$/, '') const name = g.name.replace(/^[\\/]+/, '').replace(/[\\/]+$/, '') if (!parent || !name) throw new Error('invalid group path') const full = joinPath(parent, name) try { await ws.createDirectory?.(parent, name) } catch { /* 宿主 browse 能力本机为 native 时不可用 */ } try { // 兜底:插件服务端 mkdir(父目录必须已存在,避免误建深层目录) const r = await fetch('/api/worktable/mkdir', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ path: full }) }) 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') } // 会话工作目录优先级:显式分组(workspaceId)> 项目文件夹(cwd)> 默认 let createOpts: any = {} if (workspaceId) createOpts = { workspaceId } else if (folder) createOpts = { cwd: folder } const sessionId = await createHostSession(b.sessions, b.workspaces, createOpts, g.kind === 'none' ? 'none' : 'auto') await ensureSessionPreset(sessionId) // 新会话应用部署默认预设 await ensureSessionModel(sessionId) // 修复继承失效 provider(selectModel 同步存默认,顺带修复后续新会话) markPluginSessionOpen(sessionId) // 插件发起的切换:不触发「切会话关项目」联动 try { await openHostSession(clientCtx, b.sessions, sessionId) } catch {} const widgetBindings = prepareWidgetTask(projectId, folder, sessionId, windowLabel) const text = buildWindowTaskText(projectId, projectName, windowLabel, requirement, folder, 'new', widgetBindings) await promptIntoSession(sessionId, text) return sessionId } /** 把自定义需求直接发到用户选定的已有会话(默认当前会话),带窗口身份 + 知识包 */ export async function sendCustomToSession(sessionId: string, projectId: string, projectName: string, requirement: string, windowLabel = '', folder: string | null = null): Promise { const b = sessionBridge if (!b) throw new Error('bridge unavailable') const widgetBindings = prepareWidgetTask(projectId, folder, sessionId, windowLabel) const text = buildWindowTaskText(projectId, projectName, windowLabel, requirement, folder, 'send', widgetBindings) markPluginSessionOpen(sessionId) // 插件发起的切换:不触发「切会话关项目」联动 try { await openHostSession(clientCtx, b.sessions, sessionId) } catch {} await promptIntoSession(sessionId, text) } // ── 项目 × 对话联动(模块级)── /** 打开项目前所处的会话(关项目时回切);attached = 该项目绑定的会话(无绑定 = 打开前会话) */ const projectAttachRef: { sessionId: string | null; attached: string | null } = { sessionId: null, attached: null } /** 因「切换会话」而自动关项目时置位,跳过回切 */ const suppressRestoreRef: { current: boolean } = { current: false } /** 项目绑定表的最新快照(组件每渲染同步,供模块级联动逻辑读取) */ const projectBindingsRef: { current: Record } = { current: {} } /** 插件自己发起的会话切换(新建/发送/回切):联动逻辑不得因此自动关项目 */ const pluginOpenedSessionsRef: { current: Set } = { current: new Set() } function markPluginSessionOpen(sessionId: string) { if (sessionId) { const s = pluginOpenedSessionsRef.current if (s.size > 50) s.clear() s.add(sessionId) } } let lastSessionScopeId = '' /** 会话作用域快照(模块级;apply 里订阅 ctx.sessions.list 写入,组件与引擎只读) */ const sessionScopeStore: { snapshot: { sessionId: string; cwd: string; jobs: any[]; subagents: any[] } | null } = { snapshot: null } /** 完整会话快照(模块级):项目卡片的任务完成/待决提醒镜像数据源 */ const sessionsSnapshotStore: { snapshot: any | null; listeners: Set<() => void> } = { snapshot: null, listeners: new Set() } /** 提醒确认:会话 id → done / 旧 need / need:不含正文的待决身份集合。 */ function loadNotifyAck(): Record { try { const raw = localStorage.getItem('dsh.worktable.notifyAck.v1') const p = raw ? JSON.parse(raw) : {} return p && typeof p === 'object' ? p : {} } catch { return {} } } function saveNotifyAck(sid: string, state: string) { try { const ack = loadNotifyAck() ack[sid] = state localStorage.setItem('dsh.worktable.notifyAck.v1', JSON.stringify(ack)) } catch {} } /** 自动挂载:按项目与会话消费完成边沿,同一会话可服务不同项目。 */ const mountConsumedRef: { current: Set } = { current: new Set() } /** v1 无归属清单/pending 不执行;原有 layouts/views 仍按项目 id 恢复。 */ const widgetRegistry = new WidgetMountRegistry((() => { try { return localStorage } catch { return null } })()) let widgetProjectOf: (id: string) => { folder?: string; spec?: LayoutSpec } = () => ({}) function prepareWidgetTask(projectId: string, folder: string | null, sessionId: string, windowLabel: string): WidgetBinding[] { if (!folder) return [] const current = widgetProjectOf(projectId) if (!folder || current.folder !== folder || !current.spec) throw new Error('project folder or window changed') return widgetRegistry.begin(projectId, current.spec, folder, sessionId, windowLabel) } /** 待挂载保存归属,打开时重新读取文件并校验当前目录/绑定/稳定窗格 id。 */ const pendingMountRef: { current: Record } = { current: (() => { try { return JSON.parse(localStorage.getItem('dsh.worktable.pendingMount.v2') ?? '{}') ?? {} } catch { return {} } })() } /** 启动扫挂按绑定指纹去重;新的完成边沿允许同路径、同清单作品重新加载。 */ const mountedWidgetRef: { current: Record } = { current: (() => { try { return JSON.parse(localStorage.getItem('dsh.worktable.mountedWidget.v2') ?? '{}') ?? {} } catch { return {} } })() } const widgetReadSeqRef: { current: Record } = { current: {} } function recordMountedWidget(bindingId: string, raw: string) { if (!bindingId || !raw) return mountedWidgetRef.current[bindingId] = raw try { localStorage.setItem('dsh.worktable.mountedWidget.v2', JSON.stringify(mountedWidgetRef.current)) } catch {} } function persistPendingWidgets() { try { localStorage.setItem('dsh.worktable.pendingMount.v2', JSON.stringify(pendingMountRef.current)) } catch {} } function invalidateWidgetProject(projectId: string) { widgetRegistry.revokeProject(projectId) for (const [id, pending] of Object.entries(pendingMountRef.current)) { if (pending?.binding?.projectId === projectId) delete pendingMountRef.current[id] } persistPendingWidgets() } /** 产物清单 → 窗口内容(html=目录级托管 iframe / url=外链 iframe / file=文件预览) */ function buildMountContent(folder: string, d: any): any { const p = String(d?.path ?? '').trim() if (!p) return null if (d?.kind === 'url') return { kind: 'iframe', url: p, title: p } const full = isAbs(p) ? p : joinPath(folder, p) if (d?.kind === 'file') return { kind: 'file', path: full } const dir = parentPathOf(full) const name = basenameOf(full) return { kind: 'iframe', url: '/api/worktable/site/' + encodeURIComponent(dir) + '/' + encodeURIComponent(name), title: name } } /** 每会话最近观察到的待决身份;同为 need 的问题替换也必须重新点亮。 */ const notifyStateSeenRef: { current: Record } = { current: {} } /** 每会话最近观察到的结束边沿:新一轮结束清除旧 done ack;结束不表示成功。 */ const doneSeenRef: { current: Record } = { current: {} } /** 清除某会话的 ack 记录(状态转移时调用,保证新问题不会被旧确认压住) */ function clearNotifyAck(sid: string) { try { const all = loadNotifyAck() if (all[sid]) { delete all[sid] localStorage.setItem('dsh.worktable.notifyAck.v1', JSON.stringify(all)) } } catch {} } /** 会话状态 → 提醒类型:待决(黄) 优先于 本轮结束(中性)——等待用户判断时 pendingInteraction 与 * running 会同时为真,原生 UI 以黄点优先,镜像必须一致 */ function sessionNotifyState(entry: any): 'done' | 'need' | null { if (!entry) return null if (entry.pendingInteraction != null) return 'need' if (entry.running === true) return null if (entry.completed === true) return 'done' 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 = 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[] = [] 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, } // 完整快照 + 通知监听(项目卡片提醒镜像) try { sessionsSnapshotStore.snapshot = snap sessionsSnapshotStore.listeners.forEach((l) => { try { l() } catch {} }) } catch {} // 项目×对话联动:项目打开期间切到「非该项目绑定」的会话 → 自动关项目(保留用户新选的会话) try { if (current !== lastSessionScopeId) { lastSessionScopeId = current if (current && splitStore.active && splitStore.spec) { const attached = projectAttachRef.attached if (attached && current !== attached) { // 插件自身发起的切换(新建/发送到会话)不关项目——用户要继续在项目里跟新对话沟通 const pluginSwitch = pluginOpenedSessionsRef.current.has(current) pluginOpenedSessionsRef.current.delete(current) if (!pluginSwitch) { suppressRestoreRef.current = true splitStore.close() } } } } } catch { /* 联动失败不影响主流程 */ } } catch { sessionScopeStore.snapshot = { sessionId: '', cwd: '', jobs: [], subagents: [] } } } /** 显示/隐藏切换图标:睁眼/闭眼同族 SVG(对称设计,符合可见性规范) */ function EyeIcon(props: { closed: boolean }) { return ( {props.closed ? ( <> ) : ( <> )} ) } /** 管理面板改名输入:本地草稿,blur/Enter 才提交(清空时不会立刻弹回原名) */ function RenameInput(props: { initial: string; placeholder: string; onCommit: (v: string) => void }) { const [val, setVal] = useState(props.initial) useEffect(() => { setVal(props.initial) }, [props.initial]) const commit = () => { const v = val props.onCommit(v) // 清空提交 = 删除覆盖:回显原名(与文件改名一致) if (!v.trim()) setVal(props.initial) } return ( setVal(e.target.value)} onBlur={commit} onKeyDown={(e) => { if (e.key === 'Enter') { commit(); (e.target as HTMLInputElement).blur() } }} /> ) } function WorktableSection(props: any) { const wide = props.wide !== false const renderProjectSlot = typeof props.renderSlot === 'function' ? props.renderSlot : null /** locale 座席 t;宿主未安装 locale 服务时回退 zh 词典(保持独立可用)。 */ const t = (key: WorktableKey, params?: Record): string => { if (typeof props.t === 'function') { try { return props.t(key, params) } catch { /* 回退 zh */ } } let s: string = zh[key] ?? key if (params) for (const [k, v] of Object.entries(params)) s = s.replace('{' + k + '}', v) return s } const [view, setView] = useState(loadView) const [notifyTick, setNotifyTick] = useState(0) const [projects, setProjects] = useState(loadProjects) const [metas, setMetas] = useState>({}) const [registeredIds, setRegisteredIds] = useState(() => [...registryStore.ids]) const [addOpen, setAddOpen] = useState(false) const [viewOptionsOpen, setViewOptionsOpen] = useState(false) // 更新检查:徽标 / 更新卡 / 版本行共用;节流一天一次,忽略按版本号存 localStorage const [rawUpdateInfo, setUpdateInfo] = useState(null) const updateInfo = newerUpdateInfo(rawUpdateInfo) const [updateCheckOn, setUpdateCheckOn] = useState(() => localStorage.getItem('dsh.worktable.updateCheck.v1') !== '0') const [updateCopied, setUpdateCopied] = useState(false) const [updateStatus, setUpdateStatus] = useState<'idle' | 'checking' | 'uptodate' | 'failed'>('idle') const updateCheckingRef = useRef(false) const updateAliveRef = useRef(true) useEffect(() => () => { updateAliveRef.current = false }, []) const checkUpdates = useCallback(async (force = false) => { if (updateCheckingRef.current) return // 防重入:并发点击只保留一个 in-flight let last = 0 try { last = Number(localStorage.getItem('dsh.worktable.lastUpdateCheck.v1') ?? '0') } catch {} const age = Date.now() - last if (!force && Number.isFinite(last) && last > 0 && age >= 0 && age < 24 * 3600 * 1000) return // 自动检查节流;手动「立即检查」绕过节流 updateCheckingRef.current = true setUpdateInfo(null) // Every new result replaces the old card, including failure/no update. setUpdateStatus('checking') try { let d: { tag_name?: string; body?: string; html_url?: string } | null = null for (let attempt = 0; attempt < 3 && !d && updateAliveRef.current; attempt++) { const ctrl = new AbortController() const timer = setTimeout(() => ctrl.abort(), 8000) // 单次 8s 超时,计入下一轮重试 try { const r = await fetch('https://api.github.com/repos/' + UPDATE_REPO + '/releases/latest', { headers: { Accept: 'application/vnd.github+json' }, cache: 'no-store', signal: ctrl.signal }) if (r.ok) d = await r.json() as { tag_name?: string; body?: string; html_url?: string } else if (r.status === 403 || r.status === 404) break // 限流/不存在:不再重试 } catch { /* 网络抖动/超时:下一轮重试 */ } finally { clearTimeout(timer) } } if (!updateAliveRef.current) return // 组件已卸载:不再更新状态 if (!d) { setUpdateStatus('failed'); return } try { localStorage.setItem('dsh.worktable.lastUpdateCheck.v1', String(Date.now())) } catch {} const tag = typeof d.tag_name === 'string' ? d.tag_name.trim() : '' const order = cmpVer(tag, LOCAL_VERSION) if (order == null) { setUpdateStatus('failed'); return } if (order <= 0) { setUpdateStatus('uptodate'); return } setUpdateInfo(newerUpdateInfo({ latest: tag.replace(/^v/, ''), notes: typeof d.body === 'string' ? d.body.slice(0, 800) : '', url: typeof d.html_url === 'string' ? d.html_url : '' })) setUpdateStatus('uptodate') } finally { updateCheckingRef.current = false } }, []) useEffect(() => { if (updateCheckOn) void checkUpdates() }, [updateCheckOn, checkUpdates]) const copyUpgradeAi = async () => { const ok = await copyText(UPGRADE_AI) if (ok) { setUpdateCopied(true); setTimeout(() => setUpdateCopied(false), 2200) } } const skipUpdate = () => { if (updateInfo) { localStorage.setItem('dsh.worktable.skipVersion.v1', updateInfo.latest) setUpdateInfo(null) } } const toggleUpdateCheck = () => { setUpdateCheckOn((v) => { localStorage.setItem('dsh.worktable.updateCheck.v1', v ? '0' : '1') return !v }) } const [wsPreset, setWsPreset] = useState('2h') const [wsName, setWsName] = useState('') const [wsError, setWsError] = useState(false) // 新建项目强制工作文件夹:经「选择位置…」弹窗选定(系统资源管理器式选择窗) const [wsFolderParent, setWsFolderParent] = useState('') const [wsFolderError, setWsFolderError] = useState(false) /** 文件夹选择器状态:busy=等待系统选择器;err=按入口分开的失败原因(用户取消不报错);manual=手工输入展开目标 */ const [pickBusy, setPickBusy] = useState(false) const [pickErr, setPickErr] = useState<{ add: string; bind: string }>({ add: '', bind: '' }) const [manualPathFor, setManualPathFor] = useState<'add' | 'bind' | null>(null) const [manualPathText, setManualPathText] = useState('') /** 文件夹选择在途锁 + 请求序号(useRef:跨渲染稳定;弹窗关闭/切换目标/手工确认时自增使旧请求失效) */ const pickSeqRef = useRef(0) const pickBusyRef = useRef(false) /** 组件卸载后不再应用旧选择结果 */ const mountedRef = useRef(true) useEffect(() => () => { mountedRef.current = false }, []) /** 图标选择器:kind + 目标 id + 弹窗锚点坐标(fixed 定位) */ const [iconPick, setIconPick] = useState<{ kind: 'layout' | 'shortcut' | 'project'; id: string; x: number; y: number } | null>(null) /** 对话绑定弹窗:项目 id + 锚点坐标;bindGroups = 打开时抓取的会话分组; * bindListOpen = 右侧对话列表弹层(点「绑定对话」行弹出) */ 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 consoleNeedsWorkspace = blankSessionNeedsWorkspace(sessionBridge?.sessions, consoleMode === 'none' ? 'none' : 'auto') /** 自定义布局弹窗(预设网格末尾的 + 磁贴):描述 → 复制提示词到剪贴板 */ const [customOpen, setCustomOpen] = useState(false) const [customLayoutText, setCustomLayoutText] = useState('') const [copiedToast, setCopiedToast] = useState<'ok' | 'fail' | null>(null) const copyToastTimerRef = useRef(null) /** 删除二次确认:kind + 目标 id + 显示名 */ const [requestDelete, setRequestDelete] = useState<{ kind: 'layout' | 'shortcut' | 'project'; id: string; name: string } | null>(null) /** 变更视图:正在挑选新拓扑的布局 id */ const [viewPickFor, setViewPickFor] = useState(null) const [float, setFloat] = useState(() => view.dock === 'float' && view.floatTop != null ? { top: view.floatTop } : null, ) const rootRef = useRef(null) const dragRef = useRef<{ startY: number; startX: number; startRect: DOMRect; dragging: boolean; prevFloat: FloatRect | null } | null>(null) const dragIdRef = useRef(null) const [railRect, setRailRect] = useState<{ left: number; width: number } | null>(null) const [bottomInset, setBottomInset] = useState(0) const bottomInsetRef = useRef(0) const [floatGeo, setFloatGeo] = useState<{ left: number; width: number | null } | null>(null) const [sidebarRight, setSidebarRight] = useState(null) const [activeSplitId, setActiveSplitId] = useState(() => splitStore.active && splitStore.spec ? splitStore.spec.id : null, ) /** 供分栏引擎读取的最新项目快照(setSplitEnv 闭包取最新值用) */ 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 ackRef = useRef<((projectId: string) => 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 now = Date.now() 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' // Read an existing binding only; never activate a cold session to classify status. let face: any try { face = sessionBridge?.sessions?.binding?.(sid)?.session?.getSnapshot?.() } catch {} return consoleStatusOf(e, pendingAckForSession(sid), face) } // 运行时长:任务 startedAt → 新版 chat legacy.turnTimings → 旧会话面;未知不虚构。 const runtimeOf = (sid: string | undefined): number | null => { if (!sid) 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] !== pendingAckForSession(sid))) return { id, name, icon, status, runtimeMs: runtimeOf(sid), kids: sid ? kidsSetOf(sid).size : 0, preview: sid ? (previewCache.get(sid) ?? lastTextOf(sid)) : '', bound: !!sid, self, glow, } } 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) { if (pr.projects.hidden.includes(id)) continue 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 } /** 打开「添加项目」面板(侧栏 + 与控制室创建卡共用):默认父目录 = 当前会话工作目录 */ const openAddPanel = () => { setAddOpen(true) setViewOptionsOpen(false) if (!wsFolderParent) { const cwd = sessionScopeStore.snapshot?.cwd ?? '' if (cwd) setWsFolderParent(cwd) } } // 会话作用域(当前会话 + 工作目录)与后台任务:注入分栏引擎环境 // 注意:不走 props.useSessions hook(其宿主包装在部分版本会触发 useSyncExternalStore // 崩溃),改为 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 }, getJobs: () => sessionScopeStore.snapshot?.jobs ?? [], getSubagents: () => sessionScopeStore.snapshot?.subagents ?? [], custom: { getProjects: () => { const p = projectsRef.current return [ ...p.aliveRegisteredIds.map((id) => ({ id, name: p.projects.nameOverrides[id] ?? p.metas[id]?.name ?? id })), ...p.projects.layouts.map((l) => ({ id: l.id, name: p.projects.nameOverrides[l.id] ?? l.title })), ] }, currentProjectId: () => (splitStore.active && splitStore.spec ? splitStore.spec.id : null), getSessions: () => fetchSessionGroups(), getProjectFolder: (id: string) => projectsRef.current.projects.folders[id] ?? null, sendToSession: (sessionId, projectId, projectName, requirement, windowLabel) => sendCustomToSession(sessionId, projectId, projectName, requirement, windowLabel, projectsRef.current.projects.folders[projectId] ?? null), submit: (projectId, projectName, requirement, group, windowLabel) => createCustomSession(projectId, projectName, requirement, group, windowLabel, projectsRef.current.projects.folders[projectId] ?? null), // 分组(宿主工作区)数据源:读 ctx.workspaces 快照,供新建对话选分组 getWorkspaces: () => { 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 ?? '', sessionIds: Array.isArray(w.sessionIds) ? w.sessionIds : [], })) } catch { return [] } }, createWorkspace: (path: string) => sessionBridge?.workspaces?.create?.({ path }), createWorkspaceDir: (parent: string, name: string) => sessionBridge?.workspaces?.createDirectory?.(parent, name), // 自动绑定规则:当前项目未绑定 → 绑定到刚新建/发送的会话;已绑定 → 不改(返回 'kept') autoBind: (sessionId: string): 'auto' | 'kept' | 'none' => { const pid = splitStore.active && splitStore.spec ? splitStore.spec.id : null if (!pid || typeof sessionId !== 'string' || !sessionId) return 'none' const already = projectsRef.current.projects.bindings[pid] if (already) return 'kept' persistProjects((prev) => (prev.bindings[pid] ? prev : { ...prev, bindings: { ...prev.bindings, [pid]: sessionId } })) 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 }), getCols: () => viewRef.current.consoleCols ?? 3, setCols: (n: number) => persistView({ consoleCols: n }), getShape: () => viewRef.current.consoleShape ?? 'square', setShape: (s: 'square' | 'circle') => persistView({ consoleShape: s }), getBg: () => viewRef.current.consoleBg ?? 'glow', setBg: (m: 'plain' | 'glow' | 'photo') => persistView({ consoleBg: m }), getPhotoLib: async () => { let records = await photoStore.list().catch(() => []) // 首次使用:媒体库为空且未预置过 → 注入默认背景示例图(极小 SVG,可删) if (records.length === 0 && localStorage.getItem('dsh.worktable.defaultBgSeeded.v1') !== '1') { try { await photoStore.add(new Blob([DEFAULT_BG_SVG], { type: 'image/svg+xml' })) await photoStore.add(b64ToBlob(WAVE_BG_B64, 'image/jpeg')) localStorage.setItem('dsh.worktable.defaultBgSeeded.v1', '1') records = await photoStore.list().catch(() => []) } catch {} } const stored = viewRef.current.consoleBgPhotoId ?? null const activeId = stored && records.some((r) => r.id === stored) ? stored : null return { list: records.map((r) => ({ id: r.id, kind: r.kind, url: URL.createObjectURL(r.blob) })), activeId, } }, addPhoto: async (blob: Blob) => { const id = await photoStore.add(blob) persistView({ consoleBgPhotoId: id }) try { localStorage.removeItem('dsh.worktable.consoleBgPhoto.v1') } catch {} return { id, kind: kindOf(blob), url: URL.createObjectURL(blob) } }, setPhotoId: (id: string) => persistView({ consoleBgPhotoId: id }), removePhoto: async (id: string) => { await photoStore.remove(id) }, reorderPhotos: async (ids: string[]) => { await photoStore.reorder(ids) }, getPhotoGrid: () => viewRef.current.consoleBgPhotoGrid !== false, setPhotoGrid: (v: boolean) => persistView({ consoleBgPhotoGrid: v }), getGridOpacity: () => viewRef.current.consoleBgGridOpacity ?? 8, setGridOpacity: (v: number) => persistView({ consoleBgGridOpacity: v }), getCardBlur: () => viewRef.current.consoleCardBlur ?? 8, setCardBlur: (v: number) => persistView({ consoleCardBlur: v }), getPlainBlur: () => viewRef.current.consoleBgPlainBlur ?? 0, setPlainBlur: (v: number) => persistView({ consoleBgPlainBlur: v }), getGlowBlur: () => viewRef.current.consoleBgGlowBlur ?? 8, setGlowBlur: (v: number) => persistView({ consoleBgGlowBlur: v }), getPlainGrid: () => viewRef.current.consoleBgPlainGrid ?? 5, setPlainGrid: (v: number) => persistView({ consoleBgPlainGrid: v }), getGlowGrid: () => viewRef.current.consoleBgGlowGrid ?? 8, setGlowGrid: (v: number) => persistView({ consoleBgGlowGrid: v }), getGlowSpeed: () => viewRef.current.consoleGlowSpeed ?? 50, setGlowSpeed: (v: number) => persistView({ consoleGlowSpeed: v }), getPlainHsl: () => viewRef.current.consoleBgPlainHsl ?? { h: -140, s: 31, l: 6 }, setPlainHsl: (v: { h: number; s: number; l: number }) => persistView({ consoleBgPlainHsl: { ...v } }), getGlowHsl: () => viewRef.current.consoleBgGlowHsl ?? { h: 0, s: 100, l: 100 }, setGlowHsl: (v: { h: number; s: number; l: number }) => persistView({ consoleBgGlowHsl: { ...v } }), getPhotoHsl: (id: string) => viewRef.current.consoleBgPhotoHsls?.[id] ?? { h: 0, s: 100, l: 100 }, setPhotoHsl: (id: string, v: { h: number; s: number; l: number }) => persistView({ consoleBgPhotoHsls: { ...(viewRef.current.consoleBgPhotoHsls ?? {}), [id]: { ...v } } }), onAck: (id) => { ackRef.current?.(id); setNotifyTick((t) => t + 1); notifyConsole() }, refreshPreviews: () => { if (previewTimer != null) { window.clearTimeout(previewTimer); previewTimer = null } sweepPreviews() }, onAdd: () => openAddPanel(), 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 { openHostSession(clientCtx, sessionBridge?.sessions, bound) } catch {} } } }, onJump: (id) => { const sid = projectsRef.current.projects.bindings[id] if (!sid) return markPluginSessionOpen(sid) // 插件发起的切换:不触发「切会话关项目」联动 try { openHostSession(clientCtx, sessionBridge?.sessions, sid) } catch {} }, }, } setSplitEnv(env) try { (window as any).__dshCustomEnv = env } catch {} return () => { setSplitEnv(null); try { (window as any).__dshCustomEnv = null } catch {} } }, []) const floatRef = useRef(null) const persistView = (patch: Partial) => { setView((prev) => { const next = { ...prev, ...patch } try { localStorage.setItem(PERSIST_KEY, JSON.stringify({ ...next, sortMigratedV2: true })) } catch {} markSaved() queueHostSave({ view: next }) return next }) } const persistProjects = (patch: Partial | ((prev: ProjectsState) => ProjectsState)) => { setProjects((prev) => { const next = typeof patch === 'function' ? patch(prev) : { ...prev, ...patch } try { localStorage.setItem(PROJECTS_KEY, JSON.stringify(next)) } catch {} markSaved() queueHostSave({ projects: next }) return next }) } /** * 宿主侧状态恢复(本地补丁):桌面端每次启动端口随机,localStorage 按来源隔离, * 重启后本地必然是空的。这里比对「本地最后写入时间」与「宿主镜像 updatedAt」, * 只在宿主更新时回填,避免用旧镜像盖掉当前页面刚改的内容。 * 恢复判定结束前不写宿主(markHydrated),防止首屏默认值先把镜像冲掉。 */ useEffect(() => { let cancelled = false void (async () => { let localAt = 0 try { localAt = Number(localStorage.getItem(SAVED_AT_KEY) ?? '0') || 0 } catch {} const res = await loadHostState(localAt) if (cancelled) return if (!res.reachable) { // 读不到就不写:宁可本次只在本地,也不拿空状态覆盖宿主镜像 staysReadOnly() console.warn('[dsh-worktable] 宿主状态暂不可读,本次会话不写镜像') return } const host = res.state const hostAt = typeof host?.updatedAt === 'number' ? host.updatedAt : 0 if (host && hostAt > localAt) { if (host.projects) persistProjects(normalizeProjects(host.projects)) if (host.view) persistView(normalizeView(host.view)) // 以宿主为准:作废首屏自愈等早于恢复的待写内容,避免半截空值回写 markHydrated({ projects: host.projects, view: host.view }) } else { // 本地更新或宿主还没有镜像:什么都不发,等用户真的改动再写 markHydrated(null) } })() const flush = () => flushHostSave() window.addEventListener('pagehide', flush) window.addEventListener('beforeunload', flush) return () => { cancelled = true window.removeEventListener('pagehide', flush) window.removeEventListener('beforeunload', flush) } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // 自愈:启动时若控制室存档是坏布局(旧版关掉控制室标签造成窗格退化成选择器),立即重建默认面板 useEffect(() => { const saved = projects.views[CONSOLE_ID] if (!specHasConsoleTab(saved)) { const spec = buildConsoleSpec((k) => t(k as WorktableKey)) persistProjects((prev) => ({ ...prev, views: { ...prev.views, [CONSOLE_ID]: spec } })) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [projects.views[CONSOLE_ID]]) // 子座位注册变化 → 刷新 id 序列 useEffect(() => { const sync = () => setRegisteredIds([...registryStore.ids]) registryStore.listeners.add(sync) return () => { registryStore.listeners.delete(sync) } }, []) // 分栏引擎激活态 → activeSplitId(卡片据此显示选中效果);关闭项目 → 回切打开前的会话 useEffect(() => splitStore.subscribe(() => { setActiveSplitId(splitStore.active && splitStore.spec ? splitStore.spec.id : null) if (!splitStore.active) { if (!suppressRestoreRef.current) { const prev = projectAttachRef.sessionId if (prev) { try { openHostSession(clientCtx, sessionBridge?.sessions, prev) } catch {} } } projectAttachRef.sessionId = null projectAttachRef.attached = null } suppressRestoreRef.current = false }), []) // 引擎内 spec 变更(窗内容/聊天位置/窗位互换)→ 回写持久化: // 布局项目写 layouts 条目;入驻项目(视图覆盖工作区)写 views[id]。每个项目都保持上一次的样子。 useEffect(() => { splitStore.onSpecMutated = (spec) => { persistProjects((prev) => { if (prev.layouts.some((l) => l.id === spec.id)) { return { ...prev, layouts: prev.layouts.map((l) => (l.id === spec.id ? spec : l)) } } return { ...prev, views: { ...prev.views, [spec.id]: spec } } }) } splitStore.onPaneContentEdited = (projectId, paneId) => { widgetRegistry.revokePane(projectId, paneId) for (const [id, p] of Object.entries(pendingMountRef.current)) { if (p?.binding?.projectId === projectId && p.binding.paneId === paneId) delete pendingMountRef.current[id] } persistPendingWidgets() } return () => { splitStore.onSpecMutated = null; splitStore.onPaneContentEdited = null } }, []) // 分栏引擎 UI 文案(窗选择器等) useEffect(() => { setSplitT((k, p) => t(k as WorktableKey, p as Record | undefined)) return () => setSplitT(null) }, [t]) // 侧边栏折叠/展开:保持原停靠位置(不再回弹 footer);折叠态由项目图标框承载。 // 折叠且浮动时:等折叠动画结束(320/750ms 双次重测,取收敛后的几何)再以 fixed 定位 // 图标框到拖前高度——避免在过渡帧上测得展开态宽度导致图标框偏离窄栏中心。 const measureRailRect = useCallback(() => { const el = rootRef.current if (!el) return const r = el.getBoundingClientRect() if (r.width > 0) { setRailRect((prev) => (prev && Math.abs(prev.left - r.left) < 1 && Math.abs(prev.width - r.width) < 1 ? prev : { left: r.left, width: r.width })) } }, []) useLayoutEffect(() => { if (!wide) { const t1 = window.setTimeout(measureRailRect, 320) const t2 = window.setTimeout(measureRailRect, 750) return () => { window.clearTimeout(t1); window.clearTimeout(t2) } } }, [wide, measureRailRect]) // ── 悬浮窗宽度/水平定位:与 sidebar 几何联动(ResizeObserver 实时跟随,无轮询) ── // dockWidth = sidebar 宽 − paddingLeft − paddingRight − 40(每边内缩 20) // left = sidebar 左边缘视口坐标 + paddingLeft + 20 // 降级:找不到 sidebar 或宽度 ≤0 → left 固定 14px,宽度不设内联(交 CSS min/max 处理) const measureFloatGeo = useCallback(() => { const sidebar = findSidebar(rootRef.current) if (!sidebar) { setFloatGeo({ left: 14, width: null }) return } const rect = sidebar.getBoundingClientRect() const cs = getComputedStyle(sidebar) const padLeft = parseFloat(cs.paddingLeft) || 0 const padRight = parseFloat(cs.paddingRight) || 0 const margin = 20 const width = rect.width - padLeft - padRight - margin * 2 setSidebarRight(Math.round(rect.right)) if (width > 0) { setFloatGeo({ left: Math.round(rect.left + padLeft + margin), width: Math.round(width) }) } else { setFloatGeo({ left: 14, width: null }) } }, []) useLayoutEffect(() => { measureFloatGeo() const sidebar = findSidebar(rootRef.current) if (!sidebar) return const ro = new ResizeObserver(() => measureFloatGeo()) ro.observe(sidebar) return () => ro.disconnect() }, [measureFloatGeo]) // 窗口尺寸变化 → 回弹默认停靠(浮动的列宽已失效) useEffect(() => { const onResize = () => { setFloat(null); persistView({ dock: 'footer' }) } window.addEventListener('resize', onResize) return () => window.removeEventListener('resize', onResize) }, []) // ── 协议 v2 回调(引用稳定,避免卡片 reportMeta effect 循环)── const reportMeta = useCallback((meta: ProjectMeta) => { if (!meta || typeof meta.id !== 'string' || !meta.id) return setMetas((prev) => { const cur = prev[meta.id] if (cur && cur.name === meta.name && cur.icon === meta.icon) return prev return { ...prev, [meta.id]: { id: meta.id, name: typeof meta.name === 'string' ? meta.name : meta.id, icon: meta.icon } } }) }, []) const engineIdsRef = useRef>(new Set()) const lastLegacyBumpRef = useRef>({}) /** 使用埋点:仅「工作区真正打开」计一次使用;点击关闭/重复点击不计(避免每次点击置顶) */ const reportUsed = useCallback((id: string) => { if (typeof id !== 'string' || !id) return const now = Date.now() const bump = () => { persistProjects((prev) => { if (prev.lastUsed[id] === now) return prev return { ...prev, lastUsed: { ...prev.lastUsed, [id]: now } } }) } const engineOpen = splitStore.active && splitStore.spec?.id === id const knownEngine = engineIdsRef.current.has(id) if (engineOpen) { bump() // 引擎项目:本次点击打开了工作区 → 计一次使用 } else if (knownEngine) { // 引擎项目:本次是关闭/重复点击 → 不计 return } else { // 非引擎项目(自带分栏实现等):冷却去重(打开计一次;快速开关/关闭点击不重复计) if (now - (lastLegacyBumpRef.current[id] ?? 0) > LEGACY_BUMP_COOLDOWN) { lastLegacyBumpRef.current[id] = now bump() } } }, []) /** 绑定会话显示名(同步读会话快照;找不到回退 id)——供按钮 hover 气泡使用 */ function boundSessionTitle(sid: string): string { try { const snap = sessionBridge?.list?.getSnapshot?.() const e = snap?.byId?.[sid] if (e) return e.displayTitle ?? e.title ?? sid } catch {} return sid } /** 绑定会话信息:文件夹(工作区)+ 会话标题(供绑定弹窗状态行展示) */ function bindInfoOf(groups: { title: string; sessions: { id: string; title: string; isCurrent: boolean }[] }[], sid: string): { folder: string; title: string } { for (const g of groups) { const s = g.sessions.find((x) => x.id === sid) if (s) return { folder: g.title || '未分组', title: s.title } } return { folder: '', title: sid } } /** 自定义布局提示词模板(复制到剪贴板,可发给任意 dsh 会话让 agent 实现)。 * 零泄漏硬约束:禁止写入用户的个人工作区分组名/项目名/私人路径——本提示词会被发给别人的 DSH。 */ function buildCustomLayoutPrompt(req: string): string { return [ '【为 dsh-worktable(工作台)插件增加一个新的布局预设】', '', '背景:dsh-worktable 是 DeepSeek Harness 的自建容器插件(本机仓库 dsh-worktable;不了解的细节可直接向用户提问)。', '侧边栏「工作台」区块管理项目,每个项目打开后是一个平铺工作区(若干内容窗 + 右侧对话窗)。', '布局预设定义在 01_content/src/client/index.tsx 的 PRESET_DEFS 数组,选择器缩略图在 presetThumb() 函数。', '', '任务:按下方「用户需求」新增一个布局预设。', '', '规则(必须遵守):', '1. 新预设追加到 PRESET_DEFS 数组末尾;预设选择器按数组顺序显示,末尾的「+」自定义磁贴永远是最后一个,新预设自动出现在它前面。', '2. 预设字段:id(kebab-case 唯一);leftCount = 左侧整列内容窗数(0/1);topCount = 顶部通栏行窗数;contentCount = 主行窗数;chatFull = 对话窗是否通高整列(true/false);可选 topHeightDefault = 顶行固定默认高(px);可选 topHeightRatio = 首次打开时顶行高度占可用高度比例(0~1,0.5 = 上下等分;缺省 0.35)。', '3. 硬约束:对话窗恒在右侧(chatSide 固定 right);聊天 ⇄ 翻转贴左由引擎自带,不要改。', '4. 想要「上下等大」的网格就用 topHeightRatio: 0.5;每行内的窗格宽度由引擎按可用宽度自动均分,无需配置。', '5. 窗格最小宽 200、聊天窗最小宽 240,引擎有钳制,无需配置。', '6. 在 presetThumb(defId) 里为新 id 增加一个缩略图分支(用 dsh-wt_thumb / dsh-wt_thumbCol / dsh-wt_thumbRow / cell();聊天窗用 cell(true) 的蓝色 💬 格)。', '7. 现有预设的顺序与内容不要改动:2h / 3h / l2 / grid / t3 / l13 / g4 / l23。', '8. 改完在 01_content 目录执行 npm run build 重新打包,浏览器 F5 即生效。', '9. 需求若超出以上字段的表达能力(例如三行、嵌套分栏),按现有引擎模型做最接近的实现,并在回复里说明取舍。', '', '用户需求:', req, ].join('\n') } /** 分栏工作区入口(M1 引擎):项目卡片调用 openSplit(spec) 打开声明式布局; * 若该 id 存在视图覆盖(用户在设置里变更过视图),用覆盖布局替换打开。 * 若项目绑定了会话,打开后右侧对话窗自动切换过去。 */ const openSplit = useCallback((spec: LayoutSpec) => { engineIdsRef.current.add(spec.id) splitStore.open(projects.views[spec.id] ?? spec) if (splitStore.active && splitStore.spec?.id === spec.id) { // 记录打开前会话(关项目时回切)与该项目「归属会话」(切到别的会话 = 自动关项目) let prev: string | null = null 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 { openHostSession(clientCtx, sessionBridge?.sessions, bound) } catch {} } ackProjectNotify(spec.id) // 读取失败保留 pending 供下次打开重试;成功挂载后才由 loader 清除。 for (const pending of Object.values(pendingMountRef.current)) { if (pending?.binding?.projectId !== spec.id) continue void applyWidgetManifest(pending.binding, null, true) } } }, [projects.views]) /** 打开「工作台」控制室:已绑定 → 打开并切换绑定对话;explicitBound 供强制绑定流程传入刚绑定的会话 */ const openConsole = useCallback((explicitBound?: string | null) => { const saved = projects.views[CONSOLE_ID] // 自愈:坏存档(旧版关掉控制室标签导致窗格退化成选择器)→ 重建默认面板并写回 const broken = !specHasConsoleTab(saved) const spec = broken ? buildConsoleSpec((k) => t(k as WorktableKey)) : (saved as LayoutSpec) if (broken) persistProjects((prev) => ({ ...prev, views: { ...prev.views, [CONSOLE_ID]: spec } })) engineIdsRef.current.add(CONSOLE_ID) splitStore.open(spec) if (splitStore.active && splitStore.spec?.id === CONSOLE_ID) { let prev: string | null = null 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 { openHostSession(clientCtx, sessionBridge?.sessions, bound) } catch {} } ackProjectNotify(CONSOLE_ID) try { notifyConsole() } catch {} // 打开即预热所有绑定会话的最近消息(新宿主临时持有,旧宿主 history) try { sweepPreviews() } catch {} } }, [projects.views, t]) /** 控制室卡片点击:已绑定 → 打开控制室;未绑定 → 强制绑定弹窗 */ 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 (blankSessionNeedsWorkspace(b.sessions, consoleMode === 'none' ? 'none' : 'auto')) return if (consoleMode === 'existing' && !consoleWsId) { 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 = joinPath(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({ path: full }) }) 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 createHostSession(b.sessions, b.workspaces, createOpts, consoleMode === 'none' ? 'none' : 'auto') await ensureSessionPreset(sessionId) // 管理对话同样应用部署默认预设 await ensureSessionModel(sessionId) // 管理对话同样修复失效 provider 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) => { invalidatePickState() // 切换目标:失效在途选择请求、清空旧错误与手工输入 const r = anchor.getBoundingClientRect() const x = clamp(Math.round(r.right + 8), 8, window.innerWidth - 300) const y = clamp(Math.round(r.top), 8, window.innerHeight - 420) setBindPick({ id, x, y }) setBindListOpen(false) setBindGroups([]) fetchSessionGroups().then((res) => setBindGroups(res.groups)).catch(() => setBindGroups([])) }, []) /** 弹出系统文件夹选择窗(宿主 pickDirectory);选中后回调。 * - useRef 在途锁 + 请求序号:真实防重入;手工确认/新请求会使旧请求失效,旧弹窗返回后丢弃 * - 返回 null = 用户取消:不报错 * - 抛异常 = 显示真实错误信息,不再静默吞掉 * - 宿主能力三级探测(运行时,不硬写 inject): * ① 0.1.1-rc.2:ctx.workspaces.pickDirectory() → string | null * ② 0.1.2-rc.1:ctx remote.directoryPicker.pick() → { ok, value, error }(value 即路径或 null) * ③ 0.1.2 新能力不可用 → 提示改用「手动输入」 */ const pickFolder = async (target: 'add' | 'bind', apply: (p: string) => void) => { if (pickBusyRef.current) return // 解析本次选择器调用路径 const ws = sessionBridge?.workspaces as any const legacy = ws && typeof ws.pickDirectory === 'function' ? () => ws.pickDirectory() : null let remotePicker: (() => Promise) | null = null try { const rp = clientCtx?.get?.('remote.directoryPicker') if (rp && typeof rp.pick === 'function') remotePicker = () => rp.pick() } catch {} if (!remotePicker) { try { const dp = (clientCtx as any)?.directoryPicker if (dp && typeof dp.pick === 'function') remotePicker = () => dp.pick() } catch {} } if (!legacy && !remotePicker) { setPickErr((prev) => ({ ...prev, [target]: t('add.folderPickerUnavailable') })) return } pickBusyRef.current = true setPickBusy(true) setPickErr((prev) => ({ ...prev, [target]: '' })) const seq = ++pickSeqRef.current try { let p: string | null = null if (legacy) { p = await legacy() } else { const r = await remotePicker!() if (r && typeof r === 'object' && 'ok' in r) { if (!r.ok) throw new Error(r?.error?.message ?? 'directory picker failed') const v = r.value p = typeof v === 'string' ? v : (v && typeof v === 'object' && typeof v.path === 'string' ? v.path : null) } else { p = typeof r === 'string' ? r : null } } if (seq !== pickSeqRef.current) return // 已被手工确认/新请求/弹窗关闭失效:丢弃旧弹窗结果 if (!mountedRef.current) return // 组件已卸载:不再应用 if (p && typeof p === 'string') apply(p) // p 为 null:用户取消,静默 } catch (e: any) { if (seq !== pickSeqRef.current) return if (!mountedRef.current) return // 组件已卸载:不再更新状态 const msg = (e && typeof e === 'object' && typeof e.message === 'string') ? e.message : String(e ?? '') setPickErr((prev) => ({ ...prev, [target]: t('add.folderPickFail', { msg: msg || 'unknown' }) })) } finally { if (seq === pickSeqRef.current && mountedRef.current) { pickBusyRef.current = false setPickBusy(false) } } } /** 使在途选择请求失效并清空选择器 UI 状态(弹窗关闭/切换目标时调用) */ const invalidatePickState = () => { pickSeqRef.current++ pickBusyRef.current = false setPickBusy(false) setManualPathFor(null) setManualPathText('') setPickErr({ add: '', bind: '' }) } /** 手工输入绝对路径(选择器不可用/失败的降级入口):必须通过 isAbs,并失效在途系统选择请求 */ const applyManualPath = (target: 'add' | 'bind') => { const p = manualPathText.trim() if (!p) { setPickErr((prev) => ({ ...prev, [target]: t('add.folderManualEmpty') })); return } if (!isAbs(p)) { setPickErr((prev) => ({ ...prev, [target]: t('add.folderManualAbs') })); return } pickSeqRef.current++ // 使在途系统选择请求失效(旧弹窗返回后丢弃) pickBusyRef.current = false setPickBusy(false) if (target === 'add') { setWsFolderParent(p); setWsFolderError(false) } else if (bindPick) { if (projectsRef.current.projects.folders[bindPick.id] !== p) invalidateWidgetProject(bindPick.id) persistProjects((prev) => ({ ...prev, folders: { ...prev.folders, [bindPick.id]: p } })) } setManualPathFor(null) setManualPathText('') setPickErr((prev) => ({ ...prev, [target]: '' })) } /** 绑定弹窗「更改」:弹窗选择文件夹,直接作为项目文件夹 */ const changeBindFolder = () => { if (!bindPick || pickBusyRef.current) return pickFolder('bind', (p) => { if (projectsRef.current.projects.folders[bindPick.id] !== p) invalidateWidgetProject(bindPick.id) persistProjects((prev) => ({ ...prev, folders: { ...prev.folders, [bindPick.id]: p } })) }) } /** 绑定 / 解绑会话(弹窗保持打开,绑定结果即时显示在「绑定对话」框里) */ const setProjectBinding = (id: string, sessionId: string | null) => { if ((projectsRef.current.projects.bindings[id] ?? null) !== sessionId) invalidateWidgetProject(id) persistProjects((prev) => { const next = { ...prev.bindings } if (sessionId) next[id] = sessionId else delete next[id] return { ...prev, bindings: next } }) setBindListOpen(false) } /** 复制自定义布局提示词到剪贴板(失败则全选输入框手动复制) */ const copyCustomLayout = async () => { const text = customLayoutText.trim() if (!text) { setCopiedToast('fail'); return } const prompt = buildCustomLayoutPrompt(text) try { (window as any).__dshLastPrompt = prompt } catch {} let ok = false try { await navigator.clipboard.writeText(prompt); ok = true } catch {} if (!ok) { // 降级:临时隐藏 textarea 选中提示词全文后 execCommand('copy') try { const tmp = document.createElement('textarea') tmp.value = prompt tmp.style.position = 'fixed' tmp.style.opacity = '0' document.body.appendChild(tmp) tmp.focus() tmp.select() ok = document.execCommand('copy') tmp.remove() } catch {} } setCopiedToast(ok ? 'ok' : 'fail') if (copyToastTimerRef.current != null) window.clearTimeout(copyToastTimerRef.current) copyToastTimerRef.current = window.setTimeout(() => setCopiedToast(null), 6000) } // 会话快照变化 → 重渲染(驱动项目卡片的完成/待决提醒镜像 + 控制室面板 + 预览防抖刷新) useEffect(() => { const l = () => { setNotifyTick((t) => t + 1) notifyConsole() if (splitStore.active && splitStore.spec?.id === CONSOLE_ID) schedulePreviewSweep() } sessionsSnapshotStore.listeners.add(l) return () => { sessionsSnapshotStore.listeners.delete(l) } }, []) // 项目表变化 → 控制室卡片刷新(打开中的面板即时更新) useEffect(() => { notifyConsole() }, [projects]) const [widgetTick, setWidgetTick] = useState(0) useEffect(() => widgetRegistry.subscribe(() => setWidgetTick((n) => n + 1)), []) /** 只处理当前有效绑定。await 后与实际替换前重新查归属,窗口编号非法时不退到窗口1。 */ const applyWidgetManifest = useCallback(async (binding: WidgetBinding, rawManifest: string | null, force = false): Promise => { let current = widgetProjectOf(binding.projectId) if (!mountedRef.current || !widgetRegistry.isCurrent(binding, current.spec, current.folder)) return const readSeq = (widgetReadSeqRef.current[binding.bindingId] ?? 0) + 1 widgetReadSeqRef.current[binding.bindingId] = readSeq try { let raw = rawManifest if (raw == null) { const r = await fetch('/api/worktable/file?path=' + encodeURIComponent(widgetManifestPath(binding)), { cache: 'no-store' }) if (!r.ok) return raw = (await r.text()).trim() } current = widgetProjectOf(binding.projectId) if (!mountedRef.current || !raw || widgetReadSeqRef.current[binding.bindingId] !== readSeq) return const resolved = widgetRegistry.resolve(binding, raw, current.spec, current.folder) if (!resolved || (!force && mountedWidgetRef.current[binding.bindingId] === raw)) return const content = buildMountContent(binding.folder, resolved.item) if (!content) return const open = splitStore.active && splitStore.spec?.id === binding.projectId if (open) { // 新标签 id 使同名 HTML 在下一次完成后重新加载;不会用 raw 指纹挡住追改。 splitStore.lockPane(resolved.target.row, resolved.target.index, content) recordMountedWidget(binding.bindingId, raw) delete pendingMountRef.current[binding.bindingId] persistPendingWidgets() } else { pendingMountRef.current[binding.bindingId] = { binding } persistPendingWidgets() } } catch { /* 没有可验证的结果,不挂载旧目录的其他文件。 */ } }, []) // 完成边沿只触发读取,不是成功证明;实际挂载仍要求有效 v2 结果。 useEffect(() => { const byId = sessionsSnapshotStore.snapshot?.byId ?? {} const scopePairs = new Map(widgetRegistry.entries().map((b) => [JSON.stringify([b.projectId, b.sessionId]), b])) for (const key of mountConsumedRef.current) if (!scopePairs.has(key)) mountConsumedRef.current.delete(key) for (const [pid, sid] of Object.entries(projects.bindings)) { const e = byId[sid] if (!e) continue if (e.completed === true) { if (doneSeenRef.current[sid] !== true) clearNotifyAck(sid) doneSeenRef.current[sid] = true } else { doneSeenRef.current[sid] = false } } for (const [key, binding] of scopePairs) { const e = byId[binding.sessionId] if (!e) continue if (e.completed !== true) { mountConsumedRef.current.delete(key); continue } if (mountConsumedRef.current.has(key)) continue mountConsumedRef.current.add(key) for (const b of widgetRegistry.entries(binding.projectId)) { if (b.sessionId === binding.sessionId) void applyWidgetManifest(b, null, true) } } }, [notifyTick, projects.bindings, widgetTick, applyWidgetManifest]) // 只扫描已明确登记的专属路径。新项目没有绑定,所以即使共用目录也保持空白。 useEffect(() => { let cancelled = false ;(async () => { for (const binding of widgetRegistry.entries()) { if (cancelled) return await applyWidgetManifest(binding, null) } })() return () => { cancelled = true } }, [projects.folders, widgetTick, applyWidgetManifest]) /** 收集某会话的子代理 id 集:byId 的 parentId 标注 + subagentsByParent 目录双通道 */ const collectKids = useCallback((sid: string): Set => { return childSessionIdsOf(sessionsSnapshotStore.snapshot, sid) }, []) /** 项目 → 提醒态:绑定会话(含其子代理)待决(黄) > 本轮结束(中性) > 工作中(蓝); * 状态转移自动清除旧 ack——原生 UI 每个新问题都会重新亮黄,镜像不得被旧确认压住 */ const bindNotifyMap: Record = useMemo(() => { const map: Record = {} const byId = sessionsSnapshotStore.snapshot?.byId ?? {} const ack = loadNotifyAck() const seen = notifyStateSeenRef.current for (const [pid, sid] of Object.entries(projects.bindings)) { const e = byId[sid] if (!e) continue const pendingAck = pendingAckForSession(sid) // 状态转移 → 清除旧 ack(新一轮待决重新点亮) if (pendingAck !== seen[sid]) { if (seen[sid] !== undefined) { clearNotifyAck(sid); delete ack[sid] } seen[sid] = pendingAck } 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' } return map }, [projects.bindings, notifyTick, collectKids]) /** 点开项目 = 确认提醒:ack 当前会话(含子代理)的待决/完成状态,圆点恢复常态实心 */ const ackProjectNotify = (projectId: string) => { const sid = projectsRef.current.projects.bindings[projectId] if (!sid) return const byId = sessionsSnapshotStore.snapshot?.byId ?? {} const pendingAck = pendingAckForSession(sid) for (const cid of collectKids(sid)) { if (sessionNotifyState(byId[cid]) === 'need') { const childAck = pendingAckForSession(cid) if (childAck != null) { saveNotifyAck(cid, childAck); notifyStateSeenRef.current[cid] = childAck } } } if (pendingAck != null) { saveNotifyAck(sid, pendingAck); notifyStateSeenRef.current[sid] = pendingAck } else { const st = sessionNotifyState(byId[sid]) if (st === 'done') saveNotifyAck(sid, 'done') } } ackRef.current = ackProjectNotify // ── 有效排序 ── // 手动:持久化 order(过滤已卸载 id)→ 新注册 id 与布局 id 追加尾部; // 最近:有 lastUsed 的按时间降序在前,其余按手动序在后。 const layoutIds = useMemo(() => projects.layouts.map((l) => l.id), [projects.layouts]) const aliveRegisteredIds = useMemo( () => registeredIds.filter((id) => !projects.removed.includes(id)), [registeredIds, projects.removed], ) projectsRef.current = { projects, metas, aliveRegisteredIds } widgetProjectOf = (id) => ({ folder: projectsRef.current.projects.folders[id], spec: (splitStore.active && splitStore.spec?.id === id ? splitStore.spec : undefined) ?? projectsRef.current.projects.views[id] ?? projectsRef.current.projects.layouts.find((l) => l.id === id), }) projectBindingsRef.current = projects.bindings const allIds = useMemo(() => [...aliveRegisteredIds, ...layoutIds], [aliveRegisteredIds, layoutIds]) const effectiveOrder = useMemo(() => { const known = new Set(allIds) const stored = projects.order.filter((id) => known.has(id)) const rest = allIds.filter((id) => !stored.includes(id)) let list = [...stored, ...rest] if (view.orderBy === 'recent') { const hot = list.filter((id) => projects.lastUsed[id] != null) const cold = list.filter((id) => projects.lastUsed[id] == null) hot.sort((a, b) => (projects.lastUsed[b] ?? 0) - (projects.lastUsed[a] ?? 0)) list = [...hot, ...cold] } return list }, [allIds, projects.order, projects.lastUsed, view.orderBy]) const ownerProps = { query: view.query.trim(), wide, managing: viewOptionsOpen, order: effectiveOrder, hidden: [...projects.hidden, ...projects.removed], nameOverrides: projects.nameOverrides, iconOverrides: projects.iconOverrides, reportMeta, reportUsed, openSplit, activeSplitId, } // ── 拖动(≡ 手柄,与 v1 相同)── const onHandlePointerDown = (e: any) => { const root = rootRef.current if (!root) return const startRect = root.getBoundingClientRect() dragRef.current = { startY: e.clientY, startX: e.clientX, startRect, dragging: false, prevFloat: float } try { (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId) } catch {} invalidatePickState() setViewOptionsOpen(false); setAddOpen(false) } const onHandlePointerMove = (e: any) => { const d = dragRef.current if (!d) return const dy = e.clientY - d.startY if (!d.dragging && Math.abs(dy) < 6) return d.dragging = true // 浮动上限按区块实际高度计算:停靠位紧邻其下方,向上拖即可自然落位 const maxTop = Math.max(MIN_TOP, window.innerHeight - d.startRect.height - 12) setFloat({ top: clamp(d.startRect.top + dy, MIN_TOP, maxTop) }) } const onHandlePointerUp = (e: any) => { const d = dragRef.current dragRef.current = null if (!d || !d.dragging) return // 落点判定:以「松手瞬间指针位置」判断是否有有效落点(快速甩出范围 = 无落点) const maxTop = Math.max(MIN_TOP, window.innerHeight - d.startRect.height - 12) const rawTop = d.startRect.top + (e.clientY - d.startY) const outOfRange = rawTop > maxTop + OVER_BOTTOM_PX || rawTop < MIN_TOP - OVER_TOP_PX || e.clientX < d.startRect.left - OVER_SIDE_PX || e.clientX > d.startRect.right + OVER_SIDE_PX if (outOfRange) { // 无有效落点:回归拖前位置(不持久化任何变化) setFloat(d.prevFloat) return } setFloat((cur) => { if (!cur) return null if (Math.abs(cur.top - d.startRect.top) < SNAP_PX) { persistView({ dock: 'footer', floatTop: null }) return null } persistView({ dock: 'float', floatTop: cur.top }) return cur }) } const resetDock = () => { setFloat(null); persistView({ dock: 'footer', floatTop: null }) } // Esc 关闭搜索 const onSearchKeyDown = (e: any) => { if (e.key === 'Escape') { persistView({ searchOpen: false, query: '' }) } } // ── 编辑模式动作(排序只用左缘 ≡ 抓手拖拽,无 ↑↓ 按钮) ── const moveTo = (id: string, targetId: string) => { if (id === targetId) return const list = [...effectiveOrder] const from = list.indexOf(id) const to = list.indexOf(targetId) if (from < 0 || to < 0) return list.splice(from, 1) list.splice(to, 0, id) persistProjects({ order: list }) } const toggleHidden = (id: string) => { persistProjects((prev) => { const hidden = prev.hidden.includes(id) ? prev.hidden.filter((x) => x !== id) : [...prev.hidden, id] return { ...prev, hidden } }) } const renameProject = (id: string, name: string) => { persistProjects((prev) => { const next = { ...prev.nameOverrides } if (name.trim()) next[id] = name else delete next[id] return { ...prev, nameOverrides: next } }) } // ── 快捷方式(表单已移除,仅保留存量条目的删除能力) ── const removeShortcut = (id: string) => { persistProjects((prev) => ({ ...prev, shortcuts: prev.shortcuts.filter((s) => s.id !== id) })) } // ── 布局(新建工作区) ── 强制项目文件夹:必须经「选择位置…」选定;路径即为项目文件夹 const saveLayout = async () => { const name = wsName.trim() if (!name) { setWsError(true); return } const folderPath = wsFolderParent.trim() if (!folderPath) { setWsFolderError(true); return } if (!isAbs(folderPath)) { setWsFolderError(true); return } try { // 兜底建目录;HTTP 非 2xx 视为失败,不能继续保存(路径可能无效) const r = await fetch('/api/worktable/mkdir', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ path: folderPath }) }) if (!r.ok) { setWsFolderError(true); setPickErr((prev) => ({ ...prev, add: t('add.folderCreateFail') })); return } } catch { setWsFolderError(true); setPickErr((prev) => ({ ...prev, add: t('add.folderCreateFail') })); return } const layout = buildLayout(wsPreset, name) persistProjects((prev) => ({ ...prev, layouts: [...prev.layouts, layout], folders: { ...prev.folders, [layout.id]: folderPath } })) invalidatePickState() // 保存成功:失效在途选择请求(防止旧选择器稍后返回写回已重置的表单) setWsName(''); setWsFolderParent(''); setWsError(false); setWsFolderError(false) setAddOpen(false) openSplit(layout) reportUsed(layout.id) } const removeLayout = (id: string) => { invalidateWidgetProject(id) persistProjects((prev) => ({ ...prev, layouts: prev.layouts.filter((l) => l.id !== id) })) } // ── 变更视图:所有项目通用。布局项目 = 重建其布局条目;入驻项目 = 建立/更新视图覆盖。 // 现有窗内容(标签)按序迁入新拓扑,不丢失。 ── const applyLayoutChange = (id: string, presetId: string) => { invalidateWidgetProject(id) const layout = projects.layouts.find((l) => l.id === id) const meta = metas[id] const current = layout ?? projects.views[id] const sources = current ? [...(current.top ?? []), ...current.main] .map((pp) => pp.tabs ?? []) .filter((tabs) => tabs.length > 0) : [] const next = buildLayout(presetId, layout ? layout.title : (meta?.name ?? id)) next.id = id next.icon = layout ? layout.icon : (projects.iconOverrides[id] ?? meta?.icon) const targets = [...(next.left ? [next.left] : []), ...(next.top ?? []), ...next.main] let si = 0 for (const pane of targets) { if (si < sources.length) { pane.tabs = sources[si] pane.active = 0 pane.content = null si++ } } const overflow = sources.slice(si).flat() if (overflow.length > 0 && targets.length > 0) { const last = targets[targets.length - 1] last.tabs = [...(last.tabs ?? []), ...overflow] last.active = 0 } persistProjects((prev) => layout ? { ...prev, layouts: prev.layouts.map((l) => (l.id === id ? next : l)) } : { ...prev, views: { ...prev.views, [id]: next } }) // 该视图当前打开时:关旧开新,工作区即时变为新视图 const wasOpen = splitStore.active && splitStore.spec?.id === id if (wasOpen) { splitStore.close() openSplit(next) } setViewPickFor(null) } /** 布局当前拓扑对应的预设 id(用于视图选择器高亮) */ const presetOf = (l: LayoutSpec): string => { const leftCount = l.left ? 1 : 0 const topCount = (l.top ?? []).length const contentCount = l.main.length const chatFull = l.chatFullHeight === true const def = PRESET_DEFS.find((d) => d.leftCount === leftCount && d.topCount === topCount && d.contentCount === contentCount && d.chatFull === chatFull, ) return def ? def.id : '2h' } // ── 删除(全部走二次确认;项目彻底移出工作台:对话与项目文件均保留,仅清理本地关联状态) ── // 「工作台」控制室项目不可删除(界面不提供入口,这里兜底拒绝) const removeProject = (id: string) => { if (id === CONSOLE_ID) return invalidateWidgetProject(id) persistProjects((prev) => { const next = { ...prev, removed: prev.removed.includes(id) ? prev.removed : [...prev.removed, id], hidden: prev.hidden.filter((x) => x !== id), order: prev.order.filter((x) => x !== id), } if (next.bindings[id]) { next.bindings = { ...next.bindings }; delete next.bindings[id] } if (next.folders[id]) { next.folders = { ...next.folders }; delete next.folders[id] } if (next.views[id]) { next.views = { ...next.views }; delete next.views[id] } return next }) } const askDelete = (kind: 'layout' | 'shortcut' | 'project', id: string, name: string) => { setRequestDelete({ kind, id, name }) } const doDelete = () => { const r = requestDelete if (!r) return if (r.kind === 'layout') removeLayout(r.id) else if (r.kind === 'shortcut') removeShortcut(r.id) else removeProject(r.id) setRequestDelete(null) } // ── 图标选择器(布局 / 快捷方式 / 入驻项目的侧栏 emoji 点击可换) ── // anchor:DOM 锚点元素(自己卡片里的 icon 元素或事件 currentTarget) const openIconPick = (kind: 'layout' | 'shortcut' | 'project', id: string, anchor: HTMLElement) => { const r = anchor.getBoundingClientRect() const x = Math.min(r.right + 8, Math.max(16, window.innerWidth - 284)) const y = Math.max(MIN_TOP, Math.min(r.top - 4, window.innerHeight - 316)) setIconPick({ kind, id, x, y }) } const setLayoutIcon = (id: string, icon: string) => { persistProjects((prev) => ({ ...prev, layouts: prev.layouts.map((l) => (l.id === id ? { ...l, icon } : l)), })) } const setShortcutIcon = (id: string, icon: string) => { persistProjects((prev) => ({ ...prev, shortcuts: prev.shortcuts.map((s) => (s.id === id ? { ...s, icon } : s)), })) } const setProjectIcon = (id: string, icon: string) => { persistProjects((prev) => ({ ...prev, iconOverrides: { ...prev.iconOverrides, [id]: icon } })) } // ── 入驻项目卡片通用 DOM 桥(不写死任何项目名/类名) ── // 卡片按子座位注册顺序渲染,与 aliveRegisteredIds 一一对应: // 1) 位置映射:给每张卡片标 data-wt-id,图标覆盖写到卡片第一个子元素(通用结构约定); // 2) 委托点击(捕获阶段):卡片图标 → 图标选择器;有视图覆盖的项目 → 用引擎打开该视图。 useEffect(() => { const sync = () => { const box = document.querySelector('.dsh-wt_projects') if (!box) return // 子座位把所有卡片包在一个无类容器里:直接取卡片按钮(DOM 序 = 注册序),排除自渲染的布局卡 const cards = Array.from(box.querySelectorAll('button:not(.dsh-wt_layout)')) cards.forEach((el, i) => { const id = aliveRegisteredIds[i] if (id) { el.setAttribute('data-wt-id', id) // 对话绑定按钮:追加为最后一个子元素(不破坏 children[0] 图标约定),中间偏右 let bindBtn = el.querySelector('.dsh-wt_bindBtn') if (!bindBtn) { bindBtn = document.createElement('span') bindBtn.className = 'dsh-wt_bindBtn' bindBtn.setAttribute('role', 'button') const circles = document.createElement('span') circles.className = 'dsh-wt_bindCircles' circles.setAttribute('aria-hidden', 'true') bindBtn.appendChild(circles) el.appendChild(bindBtn) const cs = getComputedStyle(el) if (cs.position === 'static') el.style.position = 'relative' } bindBtn.setAttribute('data-wt-bind', id) const bound = projects.bindings[id] bindBtn.setAttribute('data-bound', bindNotifyMap[id] ?? (bound ? 'true' : 'false')) // 入驻卡自带箭头(文本 ›):加统一对齐类,视觉居中(字面框偏下补偿 1px) const kids = Array.from(el.children) const arrow = kids.find((k: any) => k.tagName === 'SPAN' && String((k as HTMLElement).textContent ?? '').trim() === '›') if (arrow) (arrow as HTMLElement).classList.add('dsh-wt_resArrow') const tip = bound ? t('bind.tipBound', { name: boundSessionTitle(bound) }) + (bindNotifyMap[id] === 'busy' ? t('bind.tipBusy') : bindNotifyMap[id] === 'done' ? t('bind.tipDone') : bindNotifyMap[id] === 'need' ? t('bind.tipNeed') : '') : t('bind.tipUnbound') bindBtn.setAttribute('data-tip', tip) bindBtn.setAttribute('aria-label', tip) const icon = el.children[0] as HTMLElement | null if (icon) { const ovr = projects.iconOverrides[id] if (ovr) icon.setAttribute('data-wt-icon', ovr) else icon.removeAttribute('data-wt-icon') } // 选中态统一由工作台判定: // - 有视图覆盖的项目:点击恒由工作台接管 → 高亮完全跟随 activeSplitId(关闭即熄灭); // - 无视图覆盖:引擎打开时点亮;引擎开着别的时熄灭;引擎空闲时保留卡片自带状态 // (兼容自带分栏实现的插件)。 if (projects.views[id]) { el.setAttribute('data-on', activeSplitId === id ? 'true' : 'false') } else if (activeSplitId === id) { el.setAttribute('data-on', 'true') } else if (activeSplitId != null) { el.setAttribute('data-on', 'false') } } else { el.removeAttribute('data-wt-id') } }) } sync() const mo = new MutationObserver(sync) mo.observe(document.body, { childList: true, subtree: true }) return () => mo.disconnect() }, [aliveRegisteredIds, projects.iconOverrides, projects.views, activeSplitId, projects.bindings, bindNotifyMap]) useEffect(() => { const onDocClick = (e: MouseEvent) => { const target = e.target as HTMLElement | null const card = target && target.closest ? (target.closest('.dsh-wt_projects [data-wt-id]') as HTMLElement | null) : null if (!card) return const pid = card.getAttribute('data-wt-id') if (!pid) return // 绑定按钮点击 → 打开对话绑定弹窗(阻止卡片自身打开项目) const bindBtn = (target && target.closest ? target.closest('.dsh-wt_bindBtn') : null) as HTMLElement | null if (bindBtn) { e.stopPropagation() e.preventDefault() openBindPick(pid, bindBtn) return } // 图标点击(卡片第一个子元素内) → 打开图标选择器(阻止卡片自身打开项目) const first = card.children[0] as HTMLElement | null if (first && target && first.contains(target)) { e.stopPropagation() e.preventDefault() openIconPick('project', pid, first) return } // 有视图覆盖 → 用引擎打开该视图(替换卡片自带行为) const view = projects.views[pid] if (view) { e.stopPropagation() e.preventDefault() openSplit(view) reportUsed(pid) return } // 无视图覆盖:项目自带打开行为照旧,仅当绑定了会话时切换右侧对话窗 const bound = projectsRef.current.projects.bindings[pid] if (bound) { try { openHostSession(clientCtx, sessionBridge?.sessions, bound) } catch {} } ackProjectNotify(pid) } document.addEventListener('click', onDocClick, true) return () => document.removeEventListener('click', onDocClick, true) }, [projects.views, openSplit, reportUsed, openBindPick]) const query = view.query.trim() const queryLower = query.toLowerCase() const visibleShortcuts = projects.shortcuts.filter((s) => !queryLower || (s.name + ' ' + s.href).toLowerCase().includes(queryLower), ) const visibleLayouts = projects.layouts.filter((l) => { if (projects.hidden.includes(l.id)) return false if (!queryLower) return true const paneTitles = [...(l.top ?? []), ...l.main].map((p) => p.title).join(' ') return (l.title + ' ' + paneTitles).toLowerCase().includes(queryLower) }) const isFloat = float != null const floatStyle = isFloat ? { position: 'fixed' as const, top: float.top, left: floatGeo?.left ?? 14, ...(floatGeo?.width != null ? { width: floatGeo.width } : {}), zIndex: 70, } : undefined useEffect(() => { bottomInsetRef.current = bottomInset }, [bottomInset]) useEffect(() => { floatRef.current = float }, [float]) // ── 底部悬浮面板避让 ── // 停靠态下检测「侧边栏列内、贴近底部、且与区块自然位置重叠」的 fixed 面板 // (如 dsh-usage 的余额 dock),把区块整体抬到面板上方,双方互不遮挡、都可调整位置。 const measureBottomOverlay = useCallback(() => { if (isFloat) return const root = rootRef.current if (!root) return const self = root.getBoundingClientRect() if (self.width < 10) return const naturalBottom = self.bottom + bottomInsetRef.current const loX = self.left - 8 const hiX = self.right + 8 const regionTop = window.innerHeight - 300 let needed = 0 const nodes = document.querySelectorAll('body *') for (const el of nodes) { const r = el.getBoundingClientRect() if (r.width < 40 || r.height < 16) continue if (r.top < regionTop) continue if (r.left > 80) continue if (r.right < loX || r.left > hiX) continue const cs = getComputedStyle(el) if (cs.position !== 'fixed') continue if (cs.visibility === 'hidden' || Number(cs.opacity) === 0) continue if (r.top >= naturalBottom + 4) continue const overlap = Math.min(r.bottom, naturalBottom + 400) - r.top if (overlap > 8) { needed = Math.max(needed, Math.min(naturalBottom - r.top + 8, 340)) } } setBottomInset((prev) => (Math.abs(prev - needed) < 2 ? prev : needed)) }, [isFloat]) // 停靠/折叠变化时立即重测;停靠期间每 2s 轮询(悬浮面板自身可移动) useEffect(() => { if (!isFloat) measureBottomOverlay() }, [isFloat, wide, measureBottomOverlay]) useEffect(() => { const timer = window.setInterval(() => { if (!floatRef.current) measureBottomOverlay() }, 2000) return () => window.clearInterval(timer) }, [measureBottomOverlay]) const dockedStyle = !isFloat && bottomInset > 0 ? { marginBottom: bottomInset } : undefined // + 弹窗:向右弹出、锚定 sidebar 右边缘与工作台区块顶部(视口内钳制) const sectionTop = rootRef.current?.getBoundingClientRect().top ?? 100 const popLeft = sidebarRight != null ? Math.min(sidebarRight + 8, Math.max(16, window.innerWidth - 344)) : 16 const popTop = clamp(sectionTop, MIN_TOP, Math.max(MIN_TOP, window.innerHeight - 540)) // 设置弹窗底部防溢出:弹窗底与视口底保持 POP_BOTTOM_MARGIN;触底后以底为基准向上生长 const POP_BOTTOM_MARGIN = 12 const settingsRef = useRef(null) const [settingsTop, setSettingsTop] = useState(null) useLayoutEffect(() => { const recalc = () => { if (!viewOptionsOpen || !settingsRef.current) return const h = settingsRef.current.offsetHeight const rawTop = Math.max(MIN_TOP, sectionTop) const maxTop = Math.max(MIN_TOP, window.innerHeight - h - POP_BOTTOM_MARGIN) setSettingsTop(Math.min(rawTop, maxTop)) } recalc() window.addEventListener('resize', recalc) return () => window.removeEventListener('resize', recalc) }, [viewOptionsOpen, updateInfo, sectionTop]) /** 收起态点击项目图标:视图覆盖/布局 → openSplit;入驻无视图 → 切绑定对话 */ const openRailProject = (id: string) => { const pr = projectsRef.current.projects const layout = pr.layouts.find((l) => l.id === id) const view = pr.views[id] if (view || layout) openSplit((view ?? layout) as LayoutSpec) else { const bound = pr.bindings[id] if (bound) { try { openHostSession(clientCtx, sessionBridge?.sessions, bound) } catch {} } } } const popupLayer = ( <> {addOpen &&
{ invalidatePickState(); setAddOpen(false) }} />} {addOpen && (
{t('add.chooseLayout')}
{PRESET_DEFS.map((def) => ( ))}
{ setWsName(e.target.value); setWsError(false) }} />
{t('add.folderParent')} {wsFolderParent || t('add.folderNone')}
{pickBusy &&

{t('add.folderPickerHint')}

} {manualPathFor === 'add' && (
setManualPathText(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') applyManualPath('add') }} />
)} {pickErr.add &&

{pickErr.add}

}
{wsError &&

{t('add.layoutInvalid')}

} {wsFolderError &&

{t('add.folderRequired')}

}
)} {iconPick && ( <>
setIconPick(null)} />
{t('icons.title')}
{EMOJI_SET.map((em) => { const cur = iconPick.kind === 'layout' ? (projects.layouts.find((l) => l.id === iconPick.id)?.icon ?? '🧱') : iconPick.kind === 'shortcut' ? (projects.shortcuts.find((s) => s.id === iconPick.id)?.icon ?? '🔗') : (projects.iconOverrides[iconPick.id] ?? metas[iconPick.id]?.icon ?? '📦') return ( ) })}
)} {viewOptionsOpen &&
setViewOptionsOpen(false)} />} {viewOptionsOpen && (
{updateInfo && (
{t('update.available')} · v{updateInfo.latest}
{t('update.current')} v{LOCAL_VERSION} → v{updateInfo.latest}
{updateInfo.notes &&
{updateInfo.notes}
}
{UPGRADE_CMD}
{t(DESKTOP_HOST ? 'update.upgradeHintDesktop' : 'update.upgradeHint')}
)}
{t('sort.label')}
{t('manage.title')}
{effectiveOrder.map((id) => { const meta = metas[id] const layout = projects.layouts.find((l) => l.id === id) const display = projects.nameOverrides[id] ?? layout?.title ?? meta?.name ?? id const isHidden = projects.hidden.includes(id) return (
{ dragIdRef.current = id; try { e.dataTransfer.effectAllowed = 'move' } catch {} }} onDragOver={(e: any) => { e.preventDefault() const dragId = dragIdRef.current if (dragId && dragId !== id) moveTo(dragId, id) }} onDrop={(e: any) => e.preventDefault()} onDragEnd={() => { dragIdRef.current = null }} > ≡ {layout ? { e.stopPropagation(); openIconPick('layout', id, e.currentTarget as HTMLElement) }} >{layout.icon ?? '🧱'} : { e.stopPropagation(); openIconPick('project', id, e.currentTarget as HTMLElement) }} >{projects.iconOverrides[id] ?? meta?.icon ?? '📦'}} renameProject(id, v)} />
) })} {projects.shortcuts.map((s) => (
🔗 { e.stopPropagation(); openIconPick('shortcut', s.id, e.currentTarget as HTMLElement) }} >{s.icon} {s.name}
))}
v{LOCAL_VERSION} {updateStatus === 'uptodate' && !updateInfo ? ' · ' + t('update.upToDate') : ''} {updateStatus === 'failed' && !updateInfo ? ' · ' + t('update.checkFail') : ''} {t('update.autoCheck')}
)} {viewPickFor &&
setViewPickFor(null)} />} {viewPickFor && (
{t('viewPick.title')}
{PRESET_DEFS.map((def) => { const cur = projects.layouts.find((l) => l.id === viewPickFor) ?? projects.views[viewPickFor] return ( ) })}
)} {bindPick &&
{ invalidatePickState(); setBindPick(null); setBindListOpen(false) }} />} {bindPick && (
{/* 项目文件夹框(格式基准):第一行 emoji+标题,第二行路径;可随时更改 */}
📁 {t('bind.folder')}
{pickBusy &&

{t('add.folderPickerHint')}

}
{projects.folders[bindPick.id] ?? t('bind.folderNone')}
{manualPathFor === 'bind' && (
setManualPathText(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') applyManualPath('bind') }} />
)} {pickErr.bind &&

{pickErr.bind}

}
{/* 绑定对话框(与项目文件夹同格式):第一行 💬+标题(最右解绑);第二行 分组 | 对话名(点击右侧弹列表,再点反选收起);框内下方:说明 */}
💬 {t('bind.title')} {projects.bindings[bindPick.id] && ( )}
{(() => { const sid = projects.bindings[bindPick.id] if (!sid) { return ( ) } const info = bindInfoOf(bindGroups, sid) return ( ) })()}

{t('bind.hint')}

)} {/* 对话列表弹层:点「绑定对话」行弹出(优先右侧,放不下翻到左侧);选中即绑定并收起 */} {bindPick && bindListOpen && (() => { // 主弹窗实际渲染宽 = 280 + 菜单内边距/边框(约 292),右侧列表按 300 偏移避免重叠 const rightFits = bindPick.x + 300 + 260 <= window.innerWidth - 8 const listLeft = rightFits ? bindPick.x + 300 : Math.max(8, bindPick.x - 268) return (
{bindGroups.map((g, gi) => ( {g.title && ( <>
📁 {g.title}
)} {g.sessions.map((s) => ( ))} ))}
) })()} {/* 控制室强制绑定弹窗:左「加入现有对话」+ 右「新建对话」,选定/建成后自动打开控制室 */} {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)} /> )} {consoleNeedsWorkspace &&

{t('console.blankNeedsGroup')}

} {consoleErr && !consoleNeedsWorkspace &&

{t('console.bindFail')}

}
)} {customOpen &&
setCustomOpen(false)} />} {customOpen && (
✨ {t('customLayout.title')}