import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { css } from './styles' import { NS, zh, en, type WorktableKey } from './locales' import { splitStore, SplitWorkspace, setSplitT, setSplitEnv, type LayoutSpec, type SplitPane } from './split' /** * 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' type ViewState = { query: string searchOpen: boolean orderBy: OrderBy dock: DockMode floatTop: number | null } /** 卡片上报的项目元信息(协议 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[] } const PERSIST_KEY = 'dsh.worktable.view.v1' const PROJECTS_KEY = 'dsh.worktable.projects.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 }, ] 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: 'layout-' + Date.now().toString(36), 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 }, 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 === '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: [], } function loadView(): ViewState { try { const raw = localStorage.getItem(PERSIST_KEY) if (!raw) return { ...DEFAULT_VIEW } const p = JSON.parse(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, } } catch { return { ...DEFAULT_VIEW } } } function loadProjects(): ProjectsState { try { const raw = localStorage.getItem(PROJECTS_KEY) if (!raw) return { ...DEFAULT_PROJECTS } const p = JSON.parse(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)) : [], } } catch { return { ...DEFAULT_PROJECTS } } } 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 /** 把文本送入指定会话:宿主同款寻址(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') const sessions = b.sessions as any // 新会话入列可能异步:最多等 2s 直到 binding 可解析 let session: any = null for (let i = 0; i < 10; i++) { try { session = sessions?.binding?.(sessionId)?.session ?? null } catch { session = null } if (session) break await new Promise((r) => setTimeout(r, 200)) } if (session) { // 1) 宿主包装(正确签名:会话面 + 空图片 + queue 投递) if (typeof b.conversation?.sendSession === 'function') { try { await b.conversation.sendSession(session, text, [], 'queue'); return } catch { /* 包装失败则直连 */ } } // 2) 直连会话面 prompt(宿主 sendSession 内部同款路径) if (typeof session.prompt === 'function') { const result = await session.prompt([{ type: 'text', text }], 'queue') if (result && result.ok) return if (result && !result.ok) throw new Error('session.prompt: ' + (result.error?.code ?? 'rejected') + (result.error?.message ? ': ' + result.error.message : '')) } } // 3) 备用:作用域上下文里取 conversation 服务再发 try { const scoped = sessions?.scope?.(sessionId) const conv = scoped?.get?.('conversation') if (conv && typeof conv.send === 'function') { await conv.send(text); return } } catch { /* 落入最终报错 */ } throw new Error('no send path: session face unavailable') } /** 新建会话的分组选择:未分组 / 加入现有分组 / 新建一个分组(父目录 + 名称) */ export type NewSessionGroup = { kind: 'none' } | { kind: 'existing'; workspaceId: string } | { kind: 'new'; parent: string; name: string } /** 自定义窗口任务:新建一个专属会话(可选指定分组),注入工作台背景 + 用户需求,并打开该会话 */ export async function createCustomSession(projectId: string, projectName: string, requirement: string, group?: NewSessionGroup): Promise { const b = sessionBridge if (!b || typeof b.sessions?.create !== 'function') throw new Error('sessions unavailable') const text = [ '【工作台自定义窗口任务】', '以下背景由 dsh-worktable(工作台)插件自动附加,用于帮助你理解任务:', '1. dsh-worktable 是什么:它是本机 DeepSeek Harness 的自建容器插件——侧边栏底部的「工作台」区块管理多个项目;', ' 每个项目打开后是一个平铺工作区(若干内容窗 + 对话窗);内容窗可放浏览器、资源管理器、终端、', ' 文件预览(MD/TXT/代码/本地网页)等,支持 6 种布局预设、变更视图、窗内容以标签页组织、状态持久化。', ' 它是用户自建的工具,官方文档中没有它的说明;不了解之处可直接向用户提问。', '2. 本对话的用途:专门负责「' + projectName + '」项目中一个窗口(内容窗)的自定义设计——', ' 用户希望把这个窗口做成自定义内容。', '3. 用户需求:' + requirement, '4. 请基于以上背景,帮助用户完成这个窗口的自定义设计(例如产出可直接放入窗口的内容、页面或实现方案);', ' 该窗口位于「' + projectName + '」项目内,如需要也可协助该项目后续的其他自定义工作。', ].join('\n') // 分组解析: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 = 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') } const sessionId = await b.sessions.create(workspaceId ? { workspaceId } : {}) try { await b.sessions.open?.(sessionId) } catch {} await promptIntoSession(sessionId, text) } /** 把自定义需求直接发到用户选定的已有会话(默认当前会话) */ export async function sendCustomToSession(sessionId: string, projectName: string, requirement: string): Promise { const b = sessionBridge if (!b) throw new Error('bridge unavailable') const text = [ '【工作台自定义窗口任务】', '以下内容由 dsh-worktable(工作台)插件发送:用户正在自定义「' + projectName + '」项目中的一个窗口。', '1. dsh-worktable 是侧边栏底部的自建「工作台」插件(官方文档没有它的说明,不了解之处可向用户提问);', '2. 用户需求:' + requirement, '3. 请帮助完成该窗口的自定义设计(内容/页面/实现方案),该窗口位于「' + projectName + '」项目内。', ].join('\n') try { await b.sessions.open?.(sessionId) } catch {} await promptIntoSession(sessionId, text) } /** 会话作用域快照(模块级;apply 里订阅 ctx.sessions.list 写入,组件与引擎只读) */ const sessionScopeStore: { snapshot: { sessionId: string; cwd: string; jobs: any[]; subagents: any[] } | null } = { snapshot: null } function syncSessionScope(list: any) { try { const snap = list.getSnapshot() const current: string = snap?.current ?? '' 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, } } catch { sessionScopeStore.snapshot = { sessionId: '', cwd: '', jobs: [], subagents: [] } } } /** 管理面板改名输入:本地草稿,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 [projects, setProjects] = useState(loadProjects) const [metas, setMetas] = useState>({}) const [registeredIds, setRegisteredIds] = useState(() => [...registryStore.ids]) const [addOpen, setAddOpen] = useState(false) const [viewOptionsOpen, setViewOptionsOpen] = useState(false) const [wsPreset, setWsPreset] = useState('2h') const [wsName, setWsName] = useState('') const [wsError, setWsError] = useState(false) /** 图标选择器:kind + 目标 id + 弹窗锚点坐标(fixed 定位) */ const [iconPick, setIconPick] = useState<{ kind: 'layout' | 'shortcut' | 'project'; id: string; x: number; y: number } | null>(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 }, ) // 会话作用域(当前会话 + 工作目录)与后台任务:注入分栏引擎环境 // 注意:不走 props.useSessions hook(其宿主包装在部分版本会触发 useSyncExternalStore // 崩溃),改为 apply 里订阅 ctx.sessions.list 后写入模块级 store,此处直接读取。 useEffect(() => { setSplitEnv({ 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: async () => { try { const snap = sessionBridge?.list?.getSnapshot?.() const byId = snap?.byId ?? {} const current = snap?.current ?? '' // 子代理会话(后台产生的,用户面板看不到)排除 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 groups = order .map((wid: string) => ({ title: (table[wid]?.title ?? wid) as string, sessions: ((table[wid]?.sessionIds ?? []) as string[]) .filter((sid) => !archived.includes(sid) && !subKids.has(sid)) .map(mk), })) .filter((g) => g.sessions.length > 0) if (groups.length > 0) return { groups, 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: '' } } }, sendToSession: (sessionId, projectName, requirement) => sendCustomToSession(sessionId, projectName, requirement), submit: (projectId, projectName, requirement, group) => createCustomSession(projectId, projectName, requirement, group), // 分组(宿主工作区)数据源:读 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), }, }) return () => setSplitEnv(null) }, []) 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 {} 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 {} return next }) } // 子座位注册变化 → 刷新 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) }), []) // 引擎内 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 } } }) } return () => { splitStore.onSpecMutated = null } }, []) // 分栏引擎 UI 文案(窗选择器等) useEffect(() => { setSplitT((k) => t(k as WorktableKey)) 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() } } }, []) /** 分栏工作区入口(M1 引擎):项目卡片调用 openSplit(spec) 打开声明式布局; * 若该 id 存在视图覆盖(用户在设置里变更过视图),用覆盖布局替换打开。 */ const openSplit = useCallback((spec: LayoutSpec) => { engineIdsRef.current.add(spec.id) splitStore.open(projects.views[spec.id] ?? spec) }, [projects.views]) // ── 有效排序 ── // 手动:持久化 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 } 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 {} 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 readdProject = (id: string) => { persistProjects((prev) => ({ ...prev, removed: prev.removed.filter((x) => x !== id), })) } // ── 快捷方式(表单已移除,仅保留存量条目的删除能力) ── const removeShortcut = (id: string) => { persistProjects((prev) => ({ ...prev, shortcuts: prev.shortcuts.filter((s) => s.id !== id) })) } // ── 布局(新建工作区) ── const saveLayout = () => { const name = wsName.trim() if (!name) { setWsError(true); return } const layout = buildLayout(wsPreset, name) persistProjects((prev) => ({ ...prev, layouts: [...prev.layouts, layout] })) setWsName(''); setWsError(false) setAddOpen(false) openSplit(layout) reportUsed(layout.id) } const removeLayout = (id: string) => { persistProjects((prev) => ({ ...prev, layouts: prev.layouts.filter((l) => l.id !== id) })) } // ── 变更视图:所有项目通用。布局项目 = 重建其布局条目;入驻项目 = 建立/更新视图覆盖。 // 现有窗内容(标签)按序迁入新拓扑,不丢失。 ── const applyLayoutChange = (id: string, presetId: string) => { 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) => { persistProjects((prev) => ({ ...prev, removed: prev.removed.includes(id) ? prev.removed : [...prev.removed, id], hidden: prev.hidden.filter((x) => x !== id), })) } 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) 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]) 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 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) } } document.addEventListener('click', onDocClick, true) return () => document.removeEventListener('click', onDocClick, true) }, [projects.views, openSplit, reportUsed]) 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)) if (!wide) { const projectIcons = aliveRegisteredIds.map((id) => projects.iconOverrides[id] ?? metas[id]?.icon ?? '📦') const shortcutIcons = projects.shortcuts.map((s) => s.icon) const layoutIcons = projects.layouts.map((l) => l.icon ?? '🧱') const icons = [...projectIcons, ...shortcutIcons, ...layoutIcons] const railNames = [ ...aliveRegisteredIds.map((id) => projects.nameOverrides[id] ?? metas[id]?.name ?? id), ...projects.shortcuts.map((s) => s.name), ...projects.layouts.map((l) => projects.nameOverrides[l.id] ?? l.title), ] // 浮动态:fixed 定位到拖前高度(左/宽取折叠列实测几何);停靠态:文档流原位 const railStyle = isFloat && railRect ? { position: 'fixed' as const, top: float.top, left: railRect.left, width: railRect.width, zIndex: 70 } : bottomInset > 0 ? { marginBottom: bottomInset } : undefined return (
{icons.length > 0 ? icons.map((icon, i) => {icon}) : ≡}
) } return (
{t('title')}
{view.searchOpen && (
persistView({ query: e.target.value })} onKeyDown={onSearchKeyDown} />
)} {addOpen &&
setAddOpen(false)} />} {addOpen && (
{t('add.chooseLayout')}
{PRESET_DEFS.map((def) => ( ))}
{ setWsName(e.target.value); setWsError(false) }} />
{wsError &&

{t('add.layoutInvalid')}

}
)} {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 && (
{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}
))} {projects.removed.length > 0 && ( <>
{t('manage.removed')} {projects.removed.map((rid) => (
{projects.nameOverrides[rid] ?? metas[rid]?.name ?? rid}
))} )}
)} {viewPickFor &&
setViewPickFor(null)} />} {viewPickFor && (
{t('viewPick.title')}
{PRESET_DEFS.map((def) => { const cur = projects.layouts.find((l) => l.id === viewPickFor) ?? projects.views[viewPickFor] return ( ) })}
)} {requestDelete &&
setRequestDelete(null)} />} {requestDelete && (
⚠️ {t('confirm.title')}
{requestDelete.kind === 'layout' ? t('confirm.layoutBody', { name: requestDelete.name }) : requestDelete.kind === 'shortcut' ? t('confirm.shortcutBody', { name: requestDelete.name }) : t('confirm.projectBody', { name: requestDelete.name })}
)}
{renderProjectSlot ? renderProjectSlot('sidebar.worktable.project', ownerProps) :
{t('empty')}
} {visibleLayouts.map((l) => ( ))}
{visibleShortcuts.length > 0 && ( )}
) } export const inject = ['slots', 'locale', 'sessions', 'conversation', 'workspaces'] export function apply(ctx: any) { // 自定义窗口 → 宿主会话桥:保存 sessions/conversation/list 服务引用(模块级) sessionBridge = { sessions: ctx.sessions ?? null, conversation: ctx.conversation ?? null, list: ctx.sessions?.list ?? null, workspaces: ctx.workspaces ?? null } try { (window as any).__dshOpenSession = (id: string) => ctx.sessions?.open?.(id); (window as any).__dshSessions = ctx.sessions; (window as any).__dshPromptIntoSession = (id: string, text: string) => promptIntoSession(id, text); (window as any).__dshWorkspaces = ctx.workspaces } catch {} ctx.effect(() => { const style = document.createElement('style') style.setAttribute('data-dsh-plugin', 'dsh-worktable') style.textContent = css document.head.appendChild(style) return () => { style.remove() } }, 'dsh-worktable: styles') // locale 词典(宿主 locale 服务缺席时由 t 的回退分支兜底) ctx.effect(() => { if (ctx.locale?.register) return ctx.locale.register(NS, { zh, en }) }, 'dsh-worktable: dictionaries') // 子座位注册 id 序列跟踪(供排序/编辑模式使用) const syncIds = () => { const entries = ctx.slots.entries('sidebar.worktable.project') const ids: string[] = [] for (const entry of entries) { const id = entry?.options?.id if (typeof id === 'string' && id && !ids.includes(id)) ids.push(id) } registryStore.ids = ids for (const fn of registryStore.listeners) fn() } const disposeSubscribe = ctx.slots.subscribe('sidebar.worktable.project', syncIds) syncIds() ctx.effect(() => disposeSubscribe, 'dsh-worktable: project registry watch') // 会话作用域(当前会话 cwd 与后台任务)→ 功能窗数据源 const sessionsList = ctx.sessions?.list if (sessionsList && typeof sessionsList.getSnapshot === 'function') { syncSessionScope(sessionsList) const disposeScope = sessionsList.subscribe(() => syncSessionScope(sessionsList)) ctx.effect(() => disposeScope, 'dsh-worktable: session scope watch') } // 分栏工作区浮层(M1 通用引擎,shell.overlay 座位) ctx.slots.inject('shell.overlay', () => ctx.slots.register({ name: 'shell.overlay', id: 'dsh-worktable-split', order: 100, }, SplitWorkspace), 'dsh-worktable: split workspace overlay') ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({ name: 'sidebar.footer.action', id: 'dsh-worktable', order: 20, children: { 'sidebar.worktable.project': { kind: 'list', scope: 'root', }, }, }, WorktableSection), 'dsh-worktable: worktable section') } export { WorktableSection }