feat: presets 7(2x2 equal grid)/8(top2-bottom3) + custom-layout plus tile with clipboard prompt + pane rename to windows + per-project conversation binding
This commit is contained in:
+269
-48
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { css } from './styles'
|
||||
import { NS, zh, en, type WorktableKey } from './locales'
|
||||
import { splitStore, SplitWorkspace, setSplitT, setSplitEnv, type LayoutSpec, type SplitPane } from './split'
|
||||
@@ -51,6 +51,8 @@ type ProjectsState = {
|
||||
shortcuts: Shortcut[]
|
||||
/** 用户自建的布局条目(「+」新建工作区保存的 LayoutSpec)。 */
|
||||
layouts: LayoutSpec[]
|
||||
/** 项目 → 绑定会话:打开该项目时右侧对话窗自动切换到这个会话(空/缺 = 不换)。 */
|
||||
bindings: Record<string, string>
|
||||
}
|
||||
|
||||
const PERSIST_KEY = 'dsh.worktable.view.v1'
|
||||
@@ -75,7 +77,9 @@ const PRESET_DEFS = [
|
||||
{ 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 },
|
||||
{ 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):布局/快捷方式/入驻项目的图标,点击可换(首项 🧱 为布局默认) */
|
||||
@@ -104,7 +108,7 @@ 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),
|
||||
title: '窗口' + (i + 1),
|
||||
min: 200,
|
||||
content: null,
|
||||
})
|
||||
@@ -120,6 +124,7 @@ function buildLayout(presetId: string, name: string): LayoutSpec {
|
||||
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,
|
||||
}
|
||||
@@ -136,6 +141,30 @@ function presetThumb(defId: string) {
|
||||
if (defId === '3h') {
|
||||
return <span className="dsh-wt_thumb"><span className="dsh-wt_thumbRow">{cell(false, 'a')}{cell(false, 'b')}{cell(true, 'c')}</span></span>
|
||||
}
|
||||
if (defId === 'g4') {
|
||||
// 田字格:左侧 2×2 均分,右侧对话整列(5 视窗)
|
||||
return (
|
||||
<span className="dsh-wt_thumb dsh-wt_thumbCols">
|
||||
<span className="dsh-wt_thumbCol">
|
||||
<span className="dsh-wt_thumbRow">{cell(false, 'a')}{cell(false, 'b')}</span>
|
||||
<span className="dsh-wt_thumbRow">{cell(false, 'c')}{cell(false, 'd')}</span>
|
||||
</span>
|
||||
<span className="dsh-wt_thumbCol">{cell(true, 'e')}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (defId === 'l23') {
|
||||
// 上 2 下 3:左侧上排 2 窗 + 下排 3 窗,右侧对话整列(6 视窗)
|
||||
return (
|
||||
<span className="dsh-wt_thumb dsh-wt_thumbCols">
|
||||
<span className="dsh-wt_thumbCol">
|
||||
<span className="dsh-wt_thumbRow">{cell(false, 'a')}{cell(false, 'b')}</span>
|
||||
<span className="dsh-wt_thumbRow">{cell(false, 'c')}{cell(false, 'd')}{cell(false, 'e')}</span>
|
||||
</span>
|
||||
<span className="dsh-wt_thumbCol">{cell(true, 'f')}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (defId === 'l13') {
|
||||
// 左列上 1 大 + 下 3 小;右列对话整列(5 视窗)
|
||||
return (
|
||||
@@ -197,6 +226,7 @@ const DEFAULT_PROJECTS: ProjectsState = {
|
||||
views: {},
|
||||
shortcuts: [],
|
||||
layouts: [],
|
||||
bindings: {},
|
||||
}
|
||||
|
||||
function loadView(): ViewState {
|
||||
@@ -241,6 +271,7 @@ function loadProjects(): ProjectsState {
|
||||
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 : {},
|
||||
}
|
||||
} catch {
|
||||
return { ...DEFAULT_PROJECTS }
|
||||
@@ -269,6 +300,50 @@ const registryStore: { ids: string[]; listeners: Set<() => void> } = { ids: [],
|
||||
/** 自定义窗口 → 宿主会话桥(apply 时注入;不可用时 CustomPane 降级提示) */
|
||||
let sessionBridge: { sessions: any; conversation: any; list: any; workspaces: any } | null = null
|
||||
|
||||
/** 会话分组列表(模块级,供自定义窗会话选择与对话绑定弹窗共用) */
|
||||
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 = snap?.current ?? ''
|
||||
// 子代理会话(后台产生的,用户面板看不到)排除
|
||||
const subKids = new Set<string>()
|
||||
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: '' } }
|
||||
}
|
||||
|
||||
/** 把文本送入指定会话:宿主同款寻址(binding(id).session.prompt / sendSession(会话面)),
|
||||
* 插件是根级上下文,无作用域的 conversation.send 会报 requires a session scope,不可用。 */
|
||||
async function promptIntoSession(sessionId: string, text: string): Promise<void> {
|
||||
@@ -443,6 +518,14 @@ function WorktableSection(props: any) {
|
||||
const [wsError, setWsError] = useState(false)
|
||||
/** 图标选择器:kind + 目标 id + 弹窗锚点坐标(fixed 定位) */
|
||||
const [iconPick, setIconPick] = useState<{ kind: 'layout' | 'shortcut' | 'project'; id: string; x: number; y: number } | null>(null)
|
||||
/** 对话绑定弹窗:项目 id + 锚点坐标;bindGroups = 打开时抓取的会话分组 */
|
||||
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 [customOpen, setCustomOpen] = useState(false)
|
||||
const [customLayoutText, setCustomLayoutText] = useState('')
|
||||
const [copiedToast, setCopiedToast] = useState<'ok' | 'fail' | null>(null)
|
||||
const copyToastTimerRef = useRef<number | null>(null)
|
||||
/** 删除二次确认:kind + 目标 id + 显示名 */
|
||||
const [requestDelete, setRequestDelete] = useState<{ kind: 'layout' | 'shortcut' | 'project'; id: string; name: string } | null>(null)
|
||||
/** 变更视图:正在挑选新拓扑的布局 id */
|
||||
@@ -486,48 +569,7 @@ function WorktableSection(props: any) {
|
||||
]
|
||||
},
|
||||
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<string>()
|
||||
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: '' } }
|
||||
},
|
||||
getSessions: () => fetchSessionGroups(),
|
||||
sendToSession: (sessionId, projectName, requirement) => sendCustomToSession(sessionId, projectName, requirement),
|
||||
submit: (projectId, projectName, requirement, group) => createCustomSession(projectId, projectName, requirement, group),
|
||||
// 分组(宿主工作区)数据源:读 ctx.workspaces 快照,供新建对话选分组
|
||||
@@ -700,13 +742,91 @@ function WorktableSection(props: any) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** 自定义布局提示词模板(复制到剪贴板,可发给任意 dsh 会话让 agent 实现) */
|
||||
function buildCustomLayoutPrompt(req: string): string {
|
||||
return [
|
||||
'【为 dsh-worktable(工作台)插件增加一个新的布局预设】',
|
||||
'',
|
||||
'背景:dsh-worktable 是 DeepSeek Harness 的自建容器插件(本机仓库 dsh-worktable,插件包根目录 01_content/)。',
|
||||
'侧边栏「工作台」区块管理项目,每个项目打开后是一个平铺工作区(若干内容窗 + 右侧对话窗)。',
|
||||
'布局预设定义在 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 存在视图覆盖(用户在设置里变更过视图),用覆盖布局替换打开。 */
|
||||
* 若该 id 存在视图覆盖(用户在设置里变更过视图),用覆盖布局替换打开。
|
||||
* 若项目绑定了会话,打开后右侧对话窗自动切换过去。 */
|
||||
const openSplit = useCallback((spec: LayoutSpec) => {
|
||||
engineIdsRef.current.add(spec.id)
|
||||
splitStore.open(projects.views[spec.id] ?? spec)
|
||||
const bound = projectsRef.current.projects.bindings[spec.id]
|
||||
if (bound) { try { sessionBridge?.sessions?.open?.(bound) } catch {} }
|
||||
}, [projects.views])
|
||||
|
||||
/** 打开对话绑定弹窗:抓取会话分组 + 锚点定位 */
|
||||
const openBindPick = useCallback((id: string, anchor: HTMLElement) => {
|
||||
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 })
|
||||
setBindGroups([])
|
||||
fetchSessionGroups().then((res) => setBindGroups(res.groups)).catch(() => setBindGroups([]))
|
||||
}, [])
|
||||
|
||||
/** 绑定 / 解绑会话 */
|
||||
const setProjectBinding = (id: string, sessionId: string | null) => {
|
||||
persistProjects((prev) => {
|
||||
const next = { ...prev.bindings }
|
||||
if (sessionId) next[id] = sessionId
|
||||
else delete next[id]
|
||||
return { ...prev, bindings: next }
|
||||
})
|
||||
setBindPick(null)
|
||||
}
|
||||
|
||||
/** 复制自定义布局提示词到剪贴板(失败则全选输入框手动复制) */
|
||||
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)
|
||||
}
|
||||
|
||||
// ── 有效排序 ──
|
||||
// 手动:持久化 order(过滤已卸载 id)→ 新注册 id 与布局 id 追加尾部;
|
||||
// 最近:有 lastUsed 的按时间降序在前,其余按手动序在后。
|
||||
@@ -976,6 +1096,21 @@ function WorktableSection(props: any) {
|
||||
const id = aliveRegisteredIds[i]
|
||||
if (id) {
|
||||
el.setAttribute('data-wt-id', id)
|
||||
// 对话绑定按钮:追加为最后一个子元素(不破坏 children[0] 图标约定),中间偏右
|
||||
let bindBtn = el.querySelector<HTMLElement>('.dsh-wt_bindBtn')
|
||||
if (!bindBtn) {
|
||||
bindBtn = document.createElement('span')
|
||||
bindBtn.className = 'dsh-wt_bindBtn'
|
||||
bindBtn.setAttribute('role', 'button')
|
||||
bindBtn.textContent = '🔗'
|
||||
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', bound ? 'true' : 'false')
|
||||
bindBtn.title = t('bind.title')
|
||||
const icon = el.children[0] as HTMLElement | null
|
||||
if (icon) {
|
||||
const ovr = projects.iconOverrides[id]
|
||||
@@ -1002,7 +1137,7 @@ function WorktableSection(props: any) {
|
||||
const mo = new MutationObserver(sync)
|
||||
mo.observe(document.body, { childList: true, subtree: true })
|
||||
return () => mo.disconnect()
|
||||
}, [aliveRegisteredIds, projects.iconOverrides, projects.views, activeSplitId])
|
||||
}, [aliveRegisteredIds, projects.iconOverrides, projects.views, activeSplitId, projects.bindings])
|
||||
|
||||
useEffect(() => {
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
@@ -1013,6 +1148,14 @@ function WorktableSection(props: any) {
|
||||
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)) {
|
||||
@@ -1028,11 +1171,15 @@ function WorktableSection(props: any) {
|
||||
e.preventDefault()
|
||||
openSplit(view)
|
||||
reportUsed(pid)
|
||||
return
|
||||
}
|
||||
// 无视图覆盖:项目自带打开行为照旧,仅当绑定了会话时切换右侧对话窗
|
||||
const bound = projectsRef.current.projects.bindings[pid]
|
||||
if (bound) { try { sessionBridge?.sessions?.open?.(bound) } catch {} }
|
||||
}
|
||||
document.addEventListener('click', onDocClick, true)
|
||||
return () => document.removeEventListener('click', onDocClick, true)
|
||||
}, [projects.views, openSplit, reportUsed])
|
||||
}, [projects.views, openSplit, reportUsed, openBindPick])
|
||||
|
||||
const query = view.query.trim()
|
||||
const queryLower = query.toLowerCase()
|
||||
@@ -1221,6 +1368,11 @@ function WorktableSection(props: any) {
|
||||
{presetThumb(def.id)}
|
||||
</button>
|
||||
))}
|
||||
<button type="button" className="dsh-wt_preset dsh-wt_presetAdd" title={t('customLayout.addTitle')}
|
||||
onClick={() => { setAddOpen(false); setCustomOpen(true) }}>
|
||||
<span className="dsh-wt_presetAddIcon" aria-hidden>+</span>
|
||||
<span className="dsh-wt_presetAddText">{t('customLayout.add')}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="dsh-wt_addForm">
|
||||
<input type="text" placeholder={t('add.layoutNamePh')} value={wsName}
|
||||
@@ -1377,10 +1529,71 @@ function WorktableSection(props: any) {
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<button type="button" className="dsh-wt_preset dsh-wt_presetAdd" title={t('customLayout.addTitle')}
|
||||
onClick={() => { setViewPickFor(null); setCustomOpen(true) }}>
|
||||
<span className="dsh-wt_presetAddIcon" aria-hidden>+</span>
|
||||
<span className="dsh-wt_presetAddText">{t('customLayout.add')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bindPick && <div className="dsh-wt_popBackdrop" style={{ zIndex: 83 }} onClick={() => setBindPick(null)} />}
|
||||
{bindPick && (
|
||||
<div className="dsh-wt_menu dsh-wt_pop dsh-wt_bindPop" style={{ position: 'fixed', left: bindPick.x, top: bindPick.y, width: 280, zIndex: 84 }}>
|
||||
<span className="dsh-wt_menuLabel">{t('bind.title')}</span>
|
||||
<div className="dsh-wt_bindProjName">{projects.nameOverrides[bindPick.id] ?? metas[bindPick.id]?.name ?? projects.layouts.find((l) => l.id === bindPick.id)?.title ?? bindPick.id}</div>
|
||||
<p className="dsh-wt_bindHint">{t('bind.hint')}</p>
|
||||
{projects.bindings[bindPick.id] && (
|
||||
<button type="button" className="dsh-wt_bindUnbind" onClick={() => setProjectBinding(bindPick.id, null)}>{t('bind.unbind')} ✕</button>
|
||||
)}
|
||||
<div className="dsh-wt_selectList dsh-wt_bindList">
|
||||
{bindGroups.map((g, gi) => (
|
||||
<Fragment key={g.title || 'g' + gi}>
|
||||
{g.title && (
|
||||
<>
|
||||
<div className="dsh-wt_selectDivider" />
|
||||
<div className="dsh-wt_selectGroup">📁 {g.title}</div>
|
||||
</>
|
||||
)}
|
||||
{g.sessions.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
className={'dsh-wt_selectItem' + (projects.bindings[bindPick.id] === s.id ? ' dsh-wt_selectItemOn' : '')}
|
||||
onClick={() => setProjectBinding(bindPick.id, s.id)}
|
||||
>
|
||||
<span className="dsh-wt_selectItemTitle">{s.title}</span>
|
||||
{s.isCurrent && <span className="dsh-wt_selectCurrent">{t('custom.sessionCurrent')}</span>}
|
||||
</button>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{customOpen && <div className="dsh-wt_popBackdrop" style={{ zIndex: 83 }} onClick={() => setCustomOpen(false)} />}
|
||||
{customOpen && (
|
||||
<div className="dsh-wt_menu dsh-wt_pop" style={{ position: 'fixed', left: popLeft, top: popTop, width: 340, zIndex: 84 }}>
|
||||
<span className="dsh-wt_menuLabel">✨ {t('customLayout.title')}</span>
|
||||
<textarea
|
||||
className="dsh-wt_customLayoutInput"
|
||||
autoFocus
|
||||
rows={6}
|
||||
placeholder={t('customLayout.placeholder')}
|
||||
value={customLayoutText}
|
||||
onChange={(e) => setCustomLayoutText(e.target.value)}
|
||||
/>
|
||||
<button type="button" className="dsh-wt_customLayoutBtn" onClick={copyCustomLayout}>{t('customLayout.copy')}</button>
|
||||
{copiedToast && (
|
||||
<p className={'dsh-wt_customLayoutToast' + (copiedToast === 'fail' ? ' dsh-wt_customLayoutToastFail' : '')}>
|
||||
{copiedToast === 'ok' ? '✅ ' + t('customLayout.copied') : '⚠️ ' + t('customLayout.copyFail')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{requestDelete && <div className="dsh-wt_confirmBackdrop" onClick={() => setRequestDelete(null)} />}
|
||||
{requestDelete && (
|
||||
<div className="dsh-wt_confirm" role="alertdialog">
|
||||
@@ -1422,6 +1635,14 @@ function WorktableSection(props: any) {
|
||||
<span className="dsh-wt_layoutText">
|
||||
<span className="dsh-wt_layoutName">{projects.nameOverrides[l.id] ?? l.title}</span>
|
||||
</span>
|
||||
<span
|
||||
className={'dsh-wt_bindBtn'}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-bound={projects.bindings[l.id] ? 'true' : 'false'}
|
||||
title={t('bind.title')}
|
||||
onClick={(e) => { e.stopPropagation(); openBindPick(l.id, e.currentTarget as HTMLElement) }}
|
||||
>🔗</span>
|
||||
<span className="dsh-wt_layoutArrow" aria-hidden>›</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -81,6 +81,16 @@ export const zh = {
|
||||
'custom.groupNewParentPh': '例如 E:\\AI_Workspace\\DeepseekHarness',
|
||||
'custom.groupNewNamePh': '例如 MyProject(将新建此文件夹并注册为分组)',
|
||||
'custom.groupNeedPath': '请填写分组父目录和新分组名',
|
||||
'bind.title': '绑定对话',
|
||||
'bind.unbind': '解绑',
|
||||
'bind.hint': '选择后,打开该项目时右侧对话窗自动切换到这个会话;解绑后不再切换。',
|
||||
'customLayout.add': '自定义',
|
||||
'customLayout.addTitle': '自定义一个布局(生成提示词)',
|
||||
'customLayout.title': '自定义布局',
|
||||
'customLayout.placeholder': '描述你想要的布局,例如:右侧竖长对话窗,左侧上下两个一样大的窗…',
|
||||
'customLayout.copy': '复制提示词到剪贴板',
|
||||
'customLayout.copied': '提示词已复制到剪贴板。去任意一个 DSH 对话里粘贴发送,对方的 dsh 就知道要做什么了。',
|
||||
'customLayout.copyFail': '复制失败,请手动全选输入框内容复制。',
|
||||
'pane.wip': '开发中(后续版本接入)',
|
||||
'pane.explorerNoCwd': '当前会话没有工作目录',
|
||||
'pane.openLater': '该类型暂不支持预览',
|
||||
@@ -185,6 +195,16 @@ export const en = {
|
||||
'custom.groupNewParentPh': 'e.g. E:\\AI_Workspace\\DeepseekHarness',
|
||||
'custom.groupNewNamePh': 'e.g. MyProject (folder will be created and registered)',
|
||||
'custom.groupNeedPath': 'Fill in the parent folder and new group name',
|
||||
'bind.title': 'Bind conversation',
|
||||
'bind.unbind': 'Unbind',
|
||||
'bind.hint': 'When bound, opening this project switches the right chat pane to this conversation. Unbind to stop switching.',
|
||||
'customLayout.add': 'Custom',
|
||||
'customLayout.addTitle': 'Customize a layout (generate a prompt)',
|
||||
'customLayout.title': 'Custom layout',
|
||||
'customLayout.placeholder': 'Describe the layout you want, e.g. a tall chat column on the right and two equal panes stacked on the left…',
|
||||
'customLayout.copy': 'Copy prompt to clipboard',
|
||||
'customLayout.copied': 'Prompt copied to clipboard. Paste it into any DSH conversation and send — that dsh will know what to do.',
|
||||
'customLayout.copyFail': 'Copy failed — select the text above and copy it manually.',
|
||||
'pane.wip': 'Coming in a later version',
|
||||
'pane.explorerNoCwd': 'No working directory for the current session',
|
||||
'pane.openLater': 'Preview not supported for this type',
|
||||
|
||||
@@ -61,6 +61,8 @@ export type LayoutSpec = {
|
||||
icon?: string
|
||||
/** 聊天窗通高(整列):为 true 时聊天占整条右/左列,内容区(含 top 行)全部排在其另一侧 */
|
||||
chatFullHeight?: boolean
|
||||
/** 顶行首次打开时高度占可用高度比例(0~1;0.5 = 上下等分);拖动后由存档值覆盖 */
|
||||
topHeightRatio?: number
|
||||
}
|
||||
|
||||
type Geom = { left: number; top: number; right: number; bottom: number }
|
||||
@@ -372,7 +374,8 @@ export const splitStore: SplitState = {
|
||||
}
|
||||
if (top.length > 0 && !hasTopH) {
|
||||
const lo = spec.topHeight?.min ?? 80
|
||||
this.topH = clamp(Math.round(rowH0 * 0.35), lo, Math.max(lo, rowH0 - BAR_H - 80))
|
||||
const ratio = spec.topHeightRatio ?? 0.35
|
||||
this.topH = clamp(Math.round((rowH0 - BAR_H) * ratio), lo, Math.max(lo, rowH0 - BAR_H - 80))
|
||||
}
|
||||
if (!hasPaneWs) {
|
||||
const contentW = Math.max(0, colW0 - this.chatW)
|
||||
@@ -381,7 +384,9 @@ export const splitStore: SplitState = {
|
||||
this.paneWs = main.map((p) => Math.max(p.min, share))
|
||||
}
|
||||
if (!hasTopWs) {
|
||||
const rowW = Math.max(0, colW0 - (left ? this.leftW : 0))
|
||||
// chatFull 时顶行只占内容侧(扣除聊天列宽)
|
||||
const chatW0 = spec.chatFullHeight === true ? this.chatW : 0
|
||||
const rowW = Math.max(0, colW0 - chatW0 - (left ? this.leftW : 0))
|
||||
const avail = Math.max(top.length * 120, rowW - Math.max(0, top.length - 1) * DIVIDER)
|
||||
const share = Math.round(avail / top.length)
|
||||
this.topWs = top.map((p) => Math.max(p.min, share))
|
||||
|
||||
@@ -149,6 +149,16 @@ export const css = [
|
||||
'.dsh-wt_thumbCell{flex:1;min-width:0;border-right:1px solid var(--dsw-alias-border-l1,#262b36);border-bottom:1px solid var(--dsw-alias-border-l1,#262b36);display:flex;align-items:center;justify-content:center;font-size:8px;line-height:1}',
|
||||
'.dsh-wt_thumbRow .dsh-wt_thumbCell:last-child{border-right:none}',
|
||||
'.dsh-wt_thumbChat{background:var(--dsw-alias-state-accent-primary,#4f8ef7);color:#fff}',
|
||||
// 预设网格末尾的 + 自定义磁贴
|
||||
'.dsh-wt_presetAdd{border-style:dashed;color:var(--dsw-alias-state-accent-primary,#4f8ef7)}',
|
||||
'.dsh-wt_presetAddIcon{font-size:18px;line-height:20px}',
|
||||
'.dsh-wt_presetAddText{font-size:10px;line-height:13px}',
|
||||
// 项目卡片上的对话绑定按钮(布局卡内联 / 入驻卡由桥注入,绝对定位于中间偏右)
|
||||
'.dsh-wt_bindBtn{flex:none;display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;margin-left:auto;border-radius:5px;font-size:12px;line-height:1;color:var(--dsw-alias-label-tertiary,#6b7280);cursor:pointer;user-select:none}',
|
||||
'.dsh-wt_bindBtn:hover{background:var(--dsw-alias-fill-l2,rgba(255,255,255,.06));color:var(--dsw-alias-label-primary,#e6e8eb)}',
|
||||
'.dsh-wt_bindBtn[data-bound=true]{color:var(--dsw-alias-state-accent-primary,#4f8ef7)}',
|
||||
'button[data-wt-id] > .dsh-wt_bindBtn{position:absolute;right:26px;top:50%;transform:translateY(-50%);margin:0}',
|
||||
|
||||
// 布局条目卡片
|
||||
'.dsh-wt_layout{display:flex;align-items:center;gap:7px;padding:6px 8px;border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:8px;background:var(--dsw-alias-fill-l1,rgba(255,255,255,.02));color:var(--dsw-alias-label-primary,#e6e8eb);font:inherit;font-size:12px;line-height:18px;cursor:pointer;text-align:left}',
|
||||
'.dsh-wt_layout:hover{border-color:var(--dsw-alias-state-accent-primary,#4f8ef7);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.05))}',
|
||||
@@ -204,6 +214,19 @@ export const css = [
|
||||
// 新建分组:父目录 / 名称单行输入
|
||||
'.dsh-wt_customPathInput{flex:1;min-width:0;box-sizing:border-box;padding:5px 8px;background:var(--dsw-alias-fill-l1,rgba(255,255,255,.03));border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:6px;color:var(--dsw-alias-label-primary,#e6e8eb);font:inherit;font-size:12px;line-height:18px;outline:none}',
|
||||
'.dsh-wt_customPathInput:focus{border-color:var(--dsw-alias-state-accent-primary,#4f8ef7)}',
|
||||
// 自定义布局弹窗(+磁贴):描述输入 + 复制提示词 + Toast
|
||||
'.dsh-wt_customLayoutInput{width:100%;box-sizing:border-box;min-height:110px;margin-top:8px;padding:8px 10px;background:var(--dsw-alias-fill-l1,rgba(255,255,255,.03));border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:8px;color:var(--dsw-alias-label-primary,#e6e8eb);font:inherit;font-size:12px;line-height:18px;outline:none;resize:vertical}',
|
||||
'.dsh-wt_customLayoutInput:focus{border-color:var(--dsw-alias-state-accent-primary,#4f8ef7)}',
|
||||
'.dsh-wt_customLayoutBtn{width:100%;margin-top:8px;padding:7px 10px;border:1px solid var(--dsw-alias-state-accent-primary,#4f8ef7);border-radius:8px;background:transparent;color:var(--dsw-alias-state-accent-primary,#4f8ef7);font:inherit;font-size:12px;line-height:18px;cursor:pointer}',
|
||||
'.dsh-wt_customLayoutBtn:hover{background:var(--dsw-alias-fill-l1,rgba(255,255,255,.05))}',
|
||||
'.dsh-wt_customLayoutToast{margin:8px 0 0;font-size:11.5px;line-height:17px;color:var(--dsw-alias-state-success,#3fb950)}',
|
||||
'.dsh-wt_customLayoutToastFail{color:var(--dsw-alias-state-danger,#f85149)}',
|
||||
// 对话绑定弹窗
|
||||
'.dsh-wt_bindProjName{font-size:12px;font-weight:600;color:var(--dsw-alias-label-primary,#e6e8eb);margin-top:6px}',
|
||||
'.dsh-wt_bindHint{margin:4px 0 6px;font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary,#9aa4b2)}',
|
||||
'.dsh-wt_bindUnbind{display:inline-flex;align-items:center;gap:4px;margin-bottom:6px;padding:3px 8px;border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary,#9aa4b2);font:inherit;font-size:11px;line-height:16px;cursor:pointer}',
|
||||
'.dsh-wt_bindUnbind:hover{color:var(--dsw-alias-state-danger,#f85149);border-color:var(--dsw-alias-state-danger,#f85149)}',
|
||||
'.dsh-wt_bindList{max-height:280px;overflow:auto;position:static}',
|
||||
'.dsh-wt_customRow{display:flex;align-items:center;gap:8px}',
|
||||
'.dsh-wt_customLabel{flex:none;font-size:11.5px;color:var(--dsw-alias-label-secondary,#9aa4b2)}',
|
||||
'.dsh-wt_customSelect{flex:1;min-width:0;padding:5px 8px;background:var(--dsw-alias-fill-l1,rgba(255,255,255,.03));border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:6px;color:var(--dsw-alias-label-primary,#e6e8eb);font:inherit;font-size:12px;outline:none}',
|
||||
|
||||
+17
-4
@@ -334,13 +334,26 @@ type SplitPane = {
|
||||
- 「+」点击后**向右侧弹出悬浮面板**(fixed 锚定 sidebar 右边缘与工作台区块顶部,320px 宽、
|
||||
视口内钳制;透明遮罩点击关闭),不再使用侧边栏内展开式下拉;面板内容仅「选择布局 + 填名称」
|
||||
(快捷方式表单暂移除,存量快捷方式条目仍保留展示/删除);
|
||||
- **拓扑预设六个**(2 行 × 3 列缩略图网格,聊天窗蓝色 💬 标注):
|
||||
左右两栏 / 三栏横排 / 左二右一 / 井字四栏 / 左品右聊 / 左1大下3小(2026-08-18 更新:
|
||||
删「上一下二」(与左品右聊重叠),末尾新增 5 视窗预设「左1大下3小」——左侧上下结构,
|
||||
上 1 通栏大窗(默认高 300)+ 下 3 小窗并排,右侧聊天通高整列);
|
||||
- **拓扑预设八个**(3 列网格,末尾第 9 格为「+自定义」磁贴,永远最后)
|
||||
(聊天窗蓝色 💬 标注;2026-08-18 更新:删「上一下二」,新增第 7/8 预设与自定义磁贴):
|
||||
①左右两栏 ②三栏横排 ③左二右一 ④井字四栏 ⑤左品右聊 ⑥左1大下3小
|
||||
⑦田字格(左侧 2×2 四窗均等,右聊天通高整列,topHeightRatio 0.5 + 顶行宽默认扣除聊天列)
|
||||
⑧上2下3(左列上排 2 窗 + 下排 3 窗宽度均分,右聊天通高整列,topHeightRatio 0.5);
|
||||
左二右一 = 左侧上下两个内容窗 + 右侧聊天通高整列(chatFullHeight 几何,聊天可 ⇄ 翻转贴左);
|
||||
左品右聊 = 左侧品字形(上一个、下两个内容窗)+ 右侧聊天通高整列(chatFullHeight 几何,
|
||||
聊天可 ⇄ 翻转贴左);
|
||||
- **预设字段**:leftCount/topCount/contentCount/chatFull + 可选 topHeightDefault(固定默认高)/
|
||||
topHeightRatio(首次打开顶行占比,0.5=上下等分,缺省 0.35);行内窗宽由引擎均分,
|
||||
横向/纵向分隔条均可独立拖动(「top」水平分隔 + 行内垂直分隔)。
|
||||
- **+自定义磁贴**(第 9 格):右侧弹窗输入布局描述 → 「复制提示词到剪贴板」→ 生成的提示词
|
||||
包含引擎规则与现有 8 预设清单,可粘贴到任意 DSH 对话让 agent 实现新预设(追加到 PRESET_DEFS
|
||||
末尾、加号之前)。
|
||||
- **窗口编号**:窗格标题「窗口N」,N = 布局中按「左栏 → 顶行 → 主行」顺序的第 N 个内容窗
|
||||
(如田字格:窗口1/2 = 顶行左右,窗口3/4 = 底行左右;l13:窗口1 = 顶部大窗,窗口2/3/4 =
|
||||
底部三小窗)。用户说「窗口N」即指该窗。
|
||||
- **对话绑定**:每个项目卡片(布局卡 + 入驻卡)中间偏右有 🔗 按钮,点击弹会话列表(按工作区
|
||||
分组、与发送到会话同源),绑定后打开该项目时右侧对话窗自动切换(sessions.open);解绑即
|
||||
不再切换;绑定关系存 projects.v1.bindings(项目 id → 会话 id)。
|
||||
- 保存的布局以「布局条目」形式出现在项目区(布局 = 一种工作台项目),点击打开 tiling 工作区,
|
||||
再次点击 / ✕ / Esc 关闭。
|
||||
|
||||
|
||||
@@ -724,6 +724,27 @@
|
||||
- 重启后 functional-diag 全 20 STEP 复跑 ERROR_COUNT: 0(site/write/workspaces 路由全部就绪)。
|
||||
- 备注:之前 send-self-test 遗留的一次性会话 session-91a6adea 用户未删,STEP20 itemCount 13→14
|
||||
即该测试会话;再次提醒用户删除即可,不影响功能。
|
||||
## 补记(第 7/8 布局 + +自定义磁贴 + 窗口改名 + 对话绑定)
|
||||
|
||||
- 用户要求:① 新增第 7 个预设「田字格」——右聊天通高整列、左侧 2×2 四窗默认等大,横向贯通、
|
||||
竖向两段分隔条独立可拖;② 第 8 个预设「上2下3」——左列上排 2 窗 + 下排 3 窗宽度均分,右聊天;
|
||||
③ 第 9 格为「+」磁贴(永远最后):点开右侧弹窗,上半描述布局、下半按钮把「用户需求 + 上下文
|
||||
提示词」复制进剪贴板并弹 Toast,提示词含引擎规则与现有预设清单,可发给任意 dsh 实现;
|
||||
④ 窗格改名「内容N」→「窗口N」,并把窗口编号映射写进 AGENTS.md/PRD(左栏→顶行→主行);
|
||||
⑤ 对话绑定:每张项目卡中间偏右加 🔗 按钮 → 会话分组列表弹窗(与发送到会话同源)→ 绑定后
|
||||
打开项目自动 sessions.open 切换右侧对话;解绑/未绑定不切换。
|
||||
- 引擎修正:① topHeightRatio 字段(首次打开顶行占比,(rowH-BAR)*ratio;0.5=等分,缺省 0.35);
|
||||
② chatFull 布局顶行默认宽扣除聊天列(此前 2 窗顶行一宽一窄)。
|
||||
- 剪贴板降级:navigator.clipboard 失败时改用临时隐藏 textarea 全选提示词后 execCommand('copy')
|
||||
(此前误选输入框原文)。
|
||||
- 验证(probe-batch4.cjs):tileCount=9、thumbCells=[2,3,3,4,4,5,5,6,0]、plusTileLast=true;
|
||||
g4 保存 top2/main2/ratio0.5/窗口1-4;打开后四窗 rect 388×388 等大(宽度差 ≤ 分隔条宽)、
|
||||
g4TopAbove=true;l23 top2/main3;+磁贴弹窗 promptLen=1059 含需求+PRESET_DEFS+g4+topHeightRatio
|
||||
+现有清单且需求落在「用户需求」段后;绑定全流程 boundSaved/boundIsSession/switchedToBound/
|
||||
unbindShown/bindingsAfterUnbind=null/restored 全过,入驻卡注入 2 个 🔗;functional-diag
|
||||
全 20 STEP ERROR_COUNT: 0(STEP9 presetCount 9)。
|
||||
- 备注:纯客户端改动,F5 即生效;动画生成窗(iframe 壳)按用户指示未实现,仅给方案。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
// probe-batch4.cjs — 预设7/8几何 + +磁贴提示词 + 对话绑定全流程(末尾恢复原会话)
|
||||
const http = require('http');
|
||||
const { spawn } = require('child_process');
|
||||
const WebSocket = require('ws');
|
||||
const PORT = 9341;
|
||||
const proc = spawn('C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', [
|
||||
'--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check',
|
||||
'--window-size=1440,900', '--force-device-scale-factor=1',
|
||||
'--remote-debugging-port=' + PORT,
|
||||
'--user-data-dir=C:\\Users\\SJL\\AppData\\Local\\Temp\\wt-chrome-batch4',
|
||||
'about:blank',
|
||||
], { stdio: 'ignore' });
|
||||
const getJSON = (p) => new Promise((res, rej) => {
|
||||
http.get({ host: '127.0.0.1', port: PORT, path: p }, (r) => { let d=''; r.on('data',c=>d+=c); r.on('end',()=>{try{res(JSON.parse(d))}catch(e){rej(e)}}) }).on('error', rej);
|
||||
});
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
(async () => {
|
||||
let list = null;
|
||||
for (let i = 0; i < 40; i++) { try { list = await getJSON('/json/list'); if (list && list.length) break; } catch {} await sleep(500); }
|
||||
const target = list.find((t) => t.type === 'page') || list[0];
|
||||
const ws = new WebSocket(target.webSocketDebuggerUrl);
|
||||
let id = 0; const pending = new Map();
|
||||
const send = (method, params) => new Promise((res) => { const mid = ++id; pending.set(mid, res); ws.send(JSON.stringify({ id: mid, method, params })); });
|
||||
ws.on('message', (raw) => { const m = JSON.parse(String(raw)); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } });
|
||||
await new Promise((r) => ws.on('open', r));
|
||||
await send('Runtime.enable');
|
||||
await send('Page.addScriptToEvaluateOnNewDocument', {
|
||||
source: "try{ if(location.origin==='http://127.0.0.1:3080'){ localStorage.setItem('dsh.worktable.projects.v1', JSON.stringify({order:[],lastUsed:{},hidden:[],nameOverrides:{},shortcuts:[],layouts:[],bindings:{}})); } }catch(e){} try { Object.defineProperty(window.navigator,'clipboard',{configurable:true,value:{ writeText: async function(t){ window.__copied = t; } }}); } catch(e) { window.__clipErr1 = String(e); } try { document.execCommand = function(cmd){ if (cmd === 'copy') { var ae = document.activeElement; window.__copied = ae && ae.value != null ? ae.value : window.__copied; return true; } return false; }; } catch(e) { window.__clipErr2 = String(e); }",
|
||||
});
|
||||
await send('Page.navigate', { url: 'http://127.0.0.1:3080/' });
|
||||
const evaluate = async (expr) => {
|
||||
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true });
|
||||
if (r.result && r.result.exceptionDetails) return { __error: (r.result.exceptionDetails.exception || {}).description || r.result.exceptionDetails.text };
|
||||
return r.result && r.result.result ? r.result.result.value : undefined;
|
||||
};
|
||||
let ready = null;
|
||||
for (let i = 0; i < 30; i++) { await sleep(1000); ready = await evaluate("!!window.__dshWorktable && !!window.__dshSessions"); if (ready === true) break; }
|
||||
await sleep(1500);
|
||||
console.log('READY:', JSON.stringify(ready));
|
||||
const step = await evaluate(`(async function(){
|
||||
var out = {};
|
||||
var sleep = function(ms){ return new Promise(function(r){ setTimeout(r, ms); }); };
|
||||
var vis = function(sel){ return [].slice.call(document.querySelectorAll(sel)).find(function(el){ return (getComputedStyle(el).visibility!=='hidden'&&el.getBoundingClientRect().height>0); }); };
|
||||
var setInput = function(el, v){ var s = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype,'value').set; s.call(el, v); el.dispatchEvent(new Event('input',{bubbles:true})); };
|
||||
var setTA = function(el, v){ var s = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype,'value').set; s.call(el, v); el.dispatchEvent(new Event('input',{bubbles:true})); };
|
||||
var st = window.__dshWorktable.splitStore;
|
||||
try {
|
||||
// A. 预设网格:8 预设 + 1 加号;保存 g4 与 l23
|
||||
document.querySelectorAll('.dsh-wt_actions .dsh-wt_iconBtn')[2].click();
|
||||
await sleep(300);
|
||||
var tiles = [].slice.call(document.querySelectorAll('.dsh-wt_presets .dsh-wt_preset'));
|
||||
out.tileCount = tiles.length;
|
||||
out.thumbCells = tiles.map(function(t){ return t.querySelectorAll('.dsh-wt_thumbCell').length; });
|
||||
out.plusTileLast = tiles.length > 0 && tiles[tiles.length-1].className.indexOf('dsh-wt_presetAdd') >= 0;
|
||||
var savePreset = async function(idx, name){
|
||||
var ts = [].slice.call(document.querySelectorAll('.dsh-wt_presets .dsh-wt_preset'));
|
||||
ts[idx].click();
|
||||
await sleep(250);
|
||||
var inp = document.querySelector('.dsh-wt_addForm input');
|
||||
setInput(inp, name);
|
||||
await sleep(250);
|
||||
document.querySelector('.dsh-wt_addBtn').click();
|
||||
await sleep(600);
|
||||
};
|
||||
await savePreset(6, 'G4');
|
||||
await sleep(300);
|
||||
document.querySelectorAll('.dsh-wt_actions .dsh-wt_iconBtn')[2].click();
|
||||
await sleep(400);
|
||||
await savePreset(7, 'L23');
|
||||
var saved = JSON.parse(localStorage.getItem('dsh.worktable.projects.v1')).layouts;
|
||||
out.savedLen = saved.length;
|
||||
var g4 = saved[saved.length-2]; var l23 = saved[saved.length-1];
|
||||
out.g4 = { top: (g4.top||[]).length, main: g4.main.length, chatFull: g4.chatFullHeight===true, ratio: g4.topHeightRatio, titles: [...(g4.top||[]), ...g4.main].map(function(p){return p.title}) };
|
||||
out.l23 = { top: (l23.top||[]).length, main: l23.main.length, chatFull: l23.chatFullHeight===true, ratio: l23.topHeightRatio };
|
||||
// 打开 g4 验证几何(田字格四窗等大)
|
||||
if (st.active) { st.close(); await sleep(300); }
|
||||
st.open(g4);
|
||||
await sleep(1100);
|
||||
var panes = [].slice.call(document.querySelectorAll('.dsh-wt_pane')).filter(function(el){ return (getComputedStyle(el).visibility!=='hidden'&&el.getBoundingClientRect().height>0); });
|
||||
out.g4Panes = panes.length;
|
||||
if (panes.length >= 4) {
|
||||
var rs = panes.map(function(p){ var r = p.getBoundingClientRect(); return { w: Math.round(r.width), h: Math.round(r.height), top: Math.round(r.top) }; });
|
||||
out.g4Rects = rs;
|
||||
out.g4TopEqualW = Math.abs(rs[0].w - rs[1].w) <= 8;
|
||||
out.g4BotEqualW = Math.abs(rs[2].w - rs[3].w) <= 8;
|
||||
out.g4RowsEqualH = Math.abs(rs[0].h - rs[2].h) <= 8;
|
||||
out.g4TopAbove = rs[0].top < rs[2].top;
|
||||
}
|
||||
st.close();
|
||||
await sleep(300);
|
||||
// C. 对话绑定全流程(用第一张布局卡的真实 id)
|
||||
var layouts = JSON.parse(localStorage.getItem('dsh.worktable.projects.v1')).layouts;
|
||||
var lid = layouts[0].id;
|
||||
out.lid = lid;
|
||||
var S = window.__dshSessions;
|
||||
var snap0 = S.list.getSnapshot();
|
||||
var current0 = snap0.current;
|
||||
var ids0 = (snap0.ids || []).slice();
|
||||
out.idsAvail = ids0.length;
|
||||
var card0 = document.querySelectorAll('.dsh-wt_layout')[0];
|
||||
out.layoutCards = document.querySelectorAll('.dsh-wt_layout').length;
|
||||
var bindBtn = card0 && card0.querySelector('.dsh-wt_bindBtn');
|
||||
out.layoutBindBtn = !!bindBtn;
|
||||
if (bindBtn) { bindBtn.click(); }
|
||||
await sleep(900);
|
||||
out.bindPop = !!vis('.dsh-wt_bindPop');
|
||||
var items = [].slice.call(document.querySelectorAll('.dsh-wt_bindList .dsh-wt_selectItem'));
|
||||
out.bindItemCount = items.length;
|
||||
if (items[0]) { items[0].click(); }
|
||||
await sleep(900);
|
||||
var boundVal = JSON.parse(localStorage.getItem('dsh.worktable.projects.v1')).bindings[lid];
|
||||
out.boundSaved = typeof boundVal === 'string' && boundVal.length > 0;
|
||||
out.boundIsSession = boundVal ? ids0.indexOf(boundVal) >= 0 : false;
|
||||
// 点卡 → 右侧对话应切到绑定会话
|
||||
if (card0) { card0.click(); }
|
||||
await sleep(1800);
|
||||
var snap1 = S.list.getSnapshot();
|
||||
out.currentAfterOpen = snap1.current;
|
||||
out.switchedToBound = boundVal ? snap1.current === boundVal : false;
|
||||
// 解绑 → 再点卡不切换
|
||||
var bindBtn2 = card0 && card0.querySelector('.dsh-wt_bindBtn');
|
||||
if (bindBtn2) { bindBtn2.click(); }
|
||||
await sleep(900);
|
||||
var unbind = vis('.dsh-wt_bindUnbind');
|
||||
out.unbindShown = !!unbind;
|
||||
if (unbind) { unbind.click(); }
|
||||
await sleep(500);
|
||||
out.bindingsAfterUnbind = JSON.parse(localStorage.getItem('dsh.worktable.projects.v1')).bindings[lid] || null;
|
||||
if (current0) { window.__dshOpenSession(current0); }
|
||||
await sleep(1500);
|
||||
out.restored = S.list.getSnapshot().current === current0;
|
||||
// D. 入驻卡片注入绑定按钮
|
||||
out.injectedBindBtns = [].slice.call(document.querySelectorAll('button[data-wt-id] > .dsh-wt_bindBtn')).length;
|
||||
} catch(e) { out.err = String(e); }
|
||||
return JSON.stringify(out);
|
||||
})()`);
|
||||
console.log('BATCH4:', JSON.stringify(step));
|
||||
|
||||
// B. +磁贴 → 自定义布局弹窗 → CDP 真实输入 → 复制提示词(校验 __dshLastPrompt)
|
||||
const b1 = await evaluate(`(async function(){
|
||||
var sleep = function(ms){ return new Promise(function(r){ setTimeout(r, ms); }); };
|
||||
var vis = function(sel){ return [].slice.call(document.querySelectorAll(sel)).find(function(el){ return (getComputedStyle(el).visibility!=='hidden'&&el.getBoundingClientRect().height>0); }); };
|
||||
document.querySelectorAll('.dsh-wt_actions .dsh-wt_iconBtn')[2].click();
|
||||
await sleep(350);
|
||||
var tilesB = [].slice.call(document.querySelectorAll('.dsh-wt_presets .dsh-wt_preset'));
|
||||
tilesB[tilesB.length-1].click();
|
||||
await sleep(350);
|
||||
var out = {};
|
||||
out.customOpen = !!vis('.dsh-wt_customLayoutInput');
|
||||
var ta = vis('.dsh-wt_customLayoutInput');
|
||||
if (ta) { ta.focus(); out.focused = true; }
|
||||
return JSON.stringify(out);
|
||||
})()`);
|
||||
console.log('BATCH4B1:', JSON.stringify(b1));
|
||||
await send('Input.insertText', { text: '我要一个右侧竖长对话窗,左侧上下两个等大的窗口。' });
|
||||
await sleep(400);
|
||||
const b2 = await evaluate(`(async function(){
|
||||
var sleep = function(ms){ return new Promise(function(r){ setTimeout(r, ms); }); };
|
||||
var vis = function(sel){ return [].slice.call(document.querySelectorAll(sel)).find(function(el){ return (getComputedStyle(el).visibility!=='hidden'&&el.getBoundingClientRect().height>0); }); };
|
||||
var out = {};
|
||||
var copyBtn = vis('.dsh-wt_customLayoutBtn');
|
||||
if (copyBtn) { copyBtn.click(); }
|
||||
await sleep(500);
|
||||
var prompt = window.__dshLastPrompt || '';
|
||||
out.promptHasReq = prompt.indexOf('左侧上下两个等大') >= 0;
|
||||
out.promptHasRules = prompt.indexOf('PRESET_DEFS') >= 0 && prompt.indexOf('g4') >= 0 && prompt.indexOf('topHeightRatio') >= 0 && prompt.indexOf('2h / 3h') >= 0;
|
||||
out.promptLastRule = prompt.indexOf('用户需求') >= 0 && prompt.slice(prompt.indexOf('用户需求')).indexOf('左侧上下两个等大') >= 0;
|
||||
out.promptLen = prompt.length;
|
||||
var toast = vis('.dsh-wt_customLayoutToast');
|
||||
out.toastShown = !!toast;
|
||||
out.toastFail = toast ? toast.className.indexOf('ToastFail') >= 0 : null;
|
||||
var bd = [].slice.call(document.querySelectorAll('.dsh-wt_popBackdrop')).find(function(el){ return (getComputedStyle(el).visibility!=='hidden'&&el.getBoundingClientRect().height>0); });
|
||||
if (bd) { bd.click(); }
|
||||
await sleep(300);
|
||||
return JSON.stringify(out);
|
||||
})()`);
|
||||
console.log('BATCH4B2:', JSON.stringify(b2));
|
||||
|
||||
ws.close(); proc.kill(); process.exit(0);
|
||||
})().catch((e) => { console.error('FATAL', e && e.stack || e); try { proc.kill(); } catch {} process.exit(1); });
|
||||
@@ -20,6 +20,19 @@ node --check lib/index.js
|
||||
|
||||
- 客户端 bundle 必须保持 `window.__ModuleLoader__.load` 握手与 external react/@deepseek-ai/*。
|
||||
- 变更视图状态结构时同步更新 PRD 的持久化说明。
|
||||
- **构建必须 `cd 01_content` 后执行**:误在仓库根跑会把 lib 写到仓库根 `lib/`,宿主仍加载
|
||||
`01_content/lib` 旧 bundle,出现「改完不生效」假象(已有教训,见工作日志)。
|
||||
|
||||
## 领域约定(会话中必须遵守)
|
||||
|
||||
- **窗口编号**:用户说「窗口1/2/3…」指布局里按「左栏 → 顶行 → 主行」顺序的第 N 个内容窗。
|
||||
例:田字格预设(g4)窗口1/2 = 顶行左右、窗口3/4 = 底行左右;l13 窗口1 = 顶部大窗,
|
||||
窗口2/3/4 = 底部三小窗(从左到右)。需要定位时按此映射,不要凭猜测。
|
||||
- **预设追加规则**:新布局预设只允许追加到 `PRESET_DEFS` 末尾(选择器里的「+自定义」磁贴
|
||||
永远是最后一个);字段 leftCount/topCount/contentCount/chatFull/topHeightDefault/topHeightRatio,
|
||||
聊天窗恒在右侧;缩略图在 presetThumb() 加分支。
|
||||
- **对话绑定**:projects.v1.bindings = { 项目id → 会话id };打开项目时引擎自动
|
||||
sessions.open(绑定会话)(openSplit / DOM 桥两处入口);未绑定/解绑 = 不切换。
|
||||
|
||||
## 安装 / 重启
|
||||
|
||||
|
||||
Reference in New Issue
Block a user