feat: anim pane type + bind button redesign (aligned double-circle icon, status popup) + auto-bind rule + workspaces route BOM tolerance

This commit is contained in:
Aisland
2026-08-19 01:55:36 +08:00
parent 2ec499ceed
commit 696f8070b9
9 changed files with 428 additions and 22 deletions
+52 -10
View File
@@ -382,7 +382,7 @@ async function promptIntoSession(sessionId: string, text: string): Promise<void>
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<void> {
export async function createCustomSession(projectId: string, projectName: string, requirement: string, group?: NewSessionGroup): Promise<string> {
const b = sessionBridge
if (!b || typeof b.sessions?.create !== 'function') throw new Error('sessions unavailable')
const text = [
@@ -424,6 +424,7 @@ export async function createCustomSession(projectId: string, projectName: string
const sessionId = await b.sessions.create(workspaceId ? { workspaceId } : {})
try { await b.sessions.open?.(sessionId) } catch {}
await promptIntoSession(sessionId, text)
return sessionId
}
/** 把自定义需求直接发到用户选定的已有会话(默认当前会话) */
@@ -553,7 +554,7 @@ function WorktableSection(props: any) {
// 注意:不走 props.useSessions hook(其宿主包装在部分版本会触发 useSyncExternalStore
// 崩溃),改为 apply 里订阅 ctx.sessions.list 后写入模块级 store,此处直接读取。
useEffect(() => {
setSplitEnv({
const env = {
getScope: () => {
const s = sessionScopeStore.snapshot
return s ? { sessionId: s.sessionId, cwd: s.cwd } : null
@@ -587,9 +588,20 @@ function WorktableSection(props: any) {
},
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'
},
},
})
return () => setSplitEnv(null)
}
setSplitEnv(env)
try { (window as any).__dshCustomEnv = env } catch {}
return () => { setSplitEnv(null); try { (window as any).__dshCustomEnv = null } catch {} }
}, [])
const floatRef = useRef<FloatRect | null>(null)
@@ -742,6 +754,15 @@ function WorktableSection(props: any) {
}
}, [])
/** 绑定会话信息:文件夹(工作区)+ 会话标题(供绑定弹窗状态行展示) */
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 实现) */
function buildCustomLayoutPrompt(req: string): string {
return [
@@ -1102,7 +1123,10 @@ function buildCustomLayoutPrompt(req: string): string {
bindBtn = document.createElement('span')
bindBtn.className = 'dsh-wt_bindBtn'
bindBtn.setAttribute('role', 'button')
bindBtn.textContent = '🔗'
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'
@@ -1110,7 +1134,7 @@ function buildCustomLayoutPrompt(req: string): string {
bindBtn.setAttribute('data-wt-bind', id)
const bound = projects.bindings[id]
bindBtn.setAttribute('data-bound', bound ? 'true' : 'false')
bindBtn.title = t('bind.title')
bindBtn.title = bound ? t('bind.titleBound') : t('bind.titleUnbound')
const icon = el.children[0] as HTMLElement | null
if (icon) {
const ovr = projects.iconOverrides[id]
@@ -1542,7 +1566,25 @@ function buildCustomLayoutPrompt(req: string): string {
{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>
{(() => {
const sid = projects.bindings[bindPick.id]
if (!sid) {
return (
<div className="dsh-wt_bindStatus dsh-wt_bindStatusNone">
<span className="dsh-wt_bindMini" aria-hidden><span className="dsh-wt_bindCircles" /></span>
<span className="dsh-wt_bindNoneText">{t('bind.unbound')}</span>
</div>
)
}
const info = bindInfoOf(bindGroups, sid)
return (
<div className="dsh-wt_bindStatus">
<span className="dsh-wt_bindFolder" title={info.folder}>📂 {info.folder}</span>
<span className="dsh-wt_bindSep" aria-hidden>|</span>
<span className="dsh-wt_bindConv" title={info.title}>{info.title}</span>
</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>
@@ -1622,7 +1664,7 @@ function buildCustomLayoutPrompt(req: string): string {
type="button"
className="dsh-wt_layout"
data-on={activeSplitId === l.id ? 'true' : 'false'}
style={{ order: effectiveOrder.indexOf(l.id) + 1000 }}
style={{ order: effectiveOrder.indexOf(l.id) + 1000, position: 'relative' }}
onClick={() => { openSplit(l); reportUsed(l.id) }}
>
<span
@@ -1640,9 +1682,9 @@ function buildCustomLayoutPrompt(req: string): string {
role="button"
tabIndex={0}
data-bound={projects.bindings[l.id] ? 'true' : 'false'}
title={t('bind.title')}
title={projects.bindings[l.id] ? t('bind.titleBound') : t('bind.titleUnbound')}
onClick={(e) => { e.stopPropagation(); openBindPick(l.id, e.currentTarget as HTMLElement) }}
>🔗</span>
><span className="dsh-wt_bindCircles" aria-hidden /></span>
<span className="dsh-wt_layoutArrow" aria-hidden>›</span>
</button>
))}
+16 -2
View File
@@ -58,6 +58,8 @@ export const zh = {
'pane.tasks': '任务管理',
'pane.terminal': '终端',
'pane.custom': '自定义',
'pane.anim': '动画',
'pane.animUrlPh': '粘贴动画站点地址后回车',
'custom.title': '自定义这个窗口',
'custom.hint': '发送后将自动新建一个会话,专门负责这个窗口的自定义设计。',
'custom.placeholder': '告诉 AI 你想让这个窗口做什么,例如:做一个展示我旅行足迹的时间线页面…',
@@ -83,7 +85,10 @@ export const zh = {
'custom.groupNeedPath': '请填写分组父目录和新分组名',
'bind.title': '绑定对话',
'bind.unbind': '解绑',
'bind.hint': '选择后,打开该项目时右侧对话窗自动切换到这个会话;解绑后不再切换。',
'bind.unbound': '未绑定对话',
'bind.titleUnbound': '绑定对话(未绑定 ○○):点击选择要跟随的对话',
'bind.titleBound': '已绑定对话(●●):点击更换或解绑',
'bind.hint': '一个项目只能绑定一个对话。选择后,打开该项目时右侧对话窗会自动切换过去;已绑定的项目不会因新建/发送对话而自动改绑。',
'customLayout.add': '自定义',
'customLayout.addTitle': '自定义一个布局(生成提示词)',
'customLayout.title': '自定义布局',
@@ -91,6 +96,8 @@ export const zh = {
'customLayout.copy': '复制提示词到剪贴板',
'customLayout.copied': '提示词已复制到剪贴板。去任意一个 DSH 对话里粘贴发送,对方的 dsh 就知道要做什么了。',
'customLayout.copyFail': '复制失败,请手动全选输入框内容复制。',
'custom.autoBound': '已自动把这个项目绑定到这个对话(卡片上的 ●● 按钮可更换或解绑)。',
'custom.keptBinding': '该项目已有绑定的对话,按规则未自动更改。',
'pane.wip': '开发中(后续版本接入)',
'pane.explorerNoCwd': '当前会话没有工作目录',
'pane.openLater': '该类型暂不支持预览',
@@ -172,6 +179,8 @@ export const en = {
'pane.tasks': 'Tasks',
'pane.terminal': 'Terminal',
'pane.custom': 'Custom',
'pane.anim': 'Animation',
'pane.animUrlPh': 'Paste the animation site URL and press Enter',
'custom.title': 'Customize this pane',
'custom.hint': 'Sending will create a new conversation dedicated to designing this pane.',
'custom.placeholder': 'Tell the AI what you want this pane to do, e.g. a timeline page of my travels…',
@@ -197,7 +206,10 @@ export const en = {
'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.',
'bind.unbound': 'No conversation bound',
'bind.titleUnbound': 'Bind conversation (unbound ○○): pick the conversation to follow',
'bind.titleBound': 'Conversation bound (●●): click to change or unbind',
'bind.hint': 'A project can bind exactly one conversation. When bound, opening the project switches the right chat pane to it; an existing binding is never auto-changed by creating/sending conversations.',
'customLayout.add': 'Custom',
'customLayout.addTitle': 'Customize a layout (generate a prompt)',
'customLayout.title': 'Custom layout',
@@ -205,6 +217,8 @@ export const en = {
'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.',
'custom.autoBound': 'This project was auto-bound to this conversation (use the ●● button on its card to change or unbind).',
'custom.keptBinding': 'This project already has a bound conversation, so it was not auto-changed.',
'pane.wip': 'Coming in a later version',
'pane.explorerNoCwd': 'No working directory for the current session',
'pane.openLater': 'Preview not supported for this type',
+39 -2
View File
@@ -23,7 +23,7 @@ hljs.registerLanguage('json', hljsJson)
* 内容与 chatSide 的变更经 onSpecMutated 回调交给工作台持久化(布局条目)。
*/
export type BuiltinType = 'browser' | 'explorer' | 'scm' | 'tasks' | 'terminal' | 'custom'
export type BuiltinType = 'browser' | 'anim' | 'explorer' | 'scm' | 'tasks' | 'terminal' | 'custom'
export type SplitContent =
| { kind: 'iframe'; url: string; title?: string }
@@ -123,6 +123,7 @@ const PERSIST_KEY = 'dsh.worktable.split.v2'
/** 内置内容窗图标 */
const BUILTIN_ICONS: Record<BuiltinType, string> = {
browser: '🌐',
anim: '🎬',
explorer: '📁',
scm: '🔀',
tasks: '✅',
@@ -132,6 +133,7 @@ const BUILTIN_ICONS: Record<BuiltinType, string> = {
const BUILTIN_LABEL_KEYS: Record<BuiltinType, string> = {
browser: 'pane.browser',
anim: 'pane.anim',
explorer: 'pane.explorer',
scm: 'pane.scm',
tasks: 'pane.tasks',
@@ -861,6 +863,31 @@ function BrowserPane() {
)
}
/** 动画播放窗:iframe 壳 + 地址栏(站内自带项目/场景列表、播放、画幅切换、导出等全部控件) */
function AnimPane() {
const [url, setUrl] = useState('')
const [src, setSrc] = useState('about:blank')
const go = () => {
const u = url.trim()
setSrc(/^(\/|https?:\/\/)/i.test(u) ? u : 'about:blank')
}
return (
<>
<div className="dsh-wt_browserBar">
<input
className="dsh-wt_browserInput"
value={url}
placeholder={T('pane.animUrlPh')}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') go() }}
/>
<button type="button" className="dsh-wt_browserGo" onClick={go}>↗</button>
</div>
<iframe className="dsh-wt_paneFrame" src={src} title="anim" />
</>
)
}
/** 文件夹图标(重绘 SVG,与 better-sidebar 同款风格) */
function FolderIcon() {
return (
@@ -1313,6 +1340,7 @@ function CustomPane() {
const [busy, setBusy] = useState(false)
const [done, setDone] = useState(false)
const [fail, setFail] = useState('')
const [bindNote, setBindNote] = useState<'auto' | 'kept' | 'none'>('none')
// 默认项目 = 当前工作区所属项目;默认会话 = 当前会话;默认分组 = 当前会话所在工作区
useEffect(() => {
const list = custom?.getProjects?.() ?? []
@@ -1344,10 +1372,12 @@ function CustomPane() {
if (!newGroupParent.trim() || !newGroupName.trim()) { setFail(T('custom.groupNeedPath')); return }
group = { kind: 'new', parent: newGroupParent.trim(), name: newGroupName.trim() }
}
await custom.submit(projectId, pname, text, group)
const sid = await custom.submit(projectId, pname, text, group)
setBindNote((custom.autoBind?.(sid) ?? 'none') as any)
} else {
if (!sessionId) return
await custom.sendToSession(sessionId, pname, text)
setBindNote((custom.autoBind?.(sessionId) ?? 'none') as any)
}
setDone(true)
} catch (e) {
@@ -1365,6 +1395,9 @@ function CustomPane() {
<span className="dsh-wt_customDone" aria-hidden>✅</span>
<p className="dsh-wt_customDoneText">{mode === 'new' ? T('custom.done') : T('custom.sent')}</p>
<p className="dsh-wt_customDoneHint">{T('custom.doneHint')}</p>
{bindNote !== 'none' && (
<p className="dsh-wt_customDoneBind">{bindNote === 'auto' ? T('custom.autoBound') : T('custom.keptBinding')}</p>
)}
</div>
)
}
@@ -1473,6 +1506,7 @@ function PaneTabBody(props: { tab: PaneTab; row: PaneRow; index: number }) {
return <FileViewer path={content.path} />
}
if (content.type === 'browser') return <BrowserPane />
if (content.type === 'anim') return <AnimPane />
if (content.type === 'explorer') return <ExplorerPane row={props.row} index={props.index} />
if (content.type === 'scm') return <GitPane />
if (content.type === 'tasks') return <JobsPane />
@@ -1547,6 +1581,9 @@ function PanePicker(props: { row: PaneRow; index: number }) {
<button type="button" className="dsh-wt_panePick" onClick={() => pick({ kind: 'builtin', type: 'browser' })}>
<span aria-hidden>🌐</span>{T('pane.browser')}
</button>
<button type="button" className="dsh-wt_panePick" onClick={() => pick({ kind: 'builtin', type: 'anim' })}>
<span aria-hidden>🎬</span>{T('pane.anim')}
</button>
<button type="button" className="dsh-wt_panePick" onClick={() => pick({ kind: 'builtin', type: 'explorer' })}>
<span aria-hidden>📁</span>{T('pane.explorer')}
</button>
+20 -7
View File
@@ -153,11 +153,16 @@ export const css = [
'.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)}',
// 项目卡片上的对话绑定按钮(○○ 空心 = 未绑定 / ●● 实心 = 已绑定;CSS 双圆单色绘制;
// 布局卡与入驻卡统一绝对定位于卡片中间偏右、垂直居中,保证所有卡片竖向对齐)
'.dsh-wt_bindBtn{position:absolute;right:26px;top:50%;transform:translateY(-50%);width:18px;height:14px;margin:0;display:block;color:var(--dsw-alias-label-tertiary,#6b7280);cursor:pointer;user-select:none}',
'.dsh-wt_bindBtn:hover{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_bindCircles{position:absolute;left:0;top:50%;width:18px;height:9px;transform:translateY(-50%)}',
'.dsh-wt_bindCircles::before,.dsh-wt_bindCircles::after{content:\'\';position:absolute;top:50%;width:8px;height:8px;margin-top:-4px;border-radius:50%;box-sizing:border-box;border:1.4px solid currentColor;background:transparent}',
'.dsh-wt_bindCircles::before{left:0}',
'.dsh-wt_bindCircles::after{left:9px}',
'.dsh-wt_bindBtn[data-bound=true] .dsh-wt_bindCircles::before,.dsh-wt_bindBtn[data-bound=true] .dsh-wt_bindCircles::after{background:currentColor}',
// 布局条目卡片
'.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}',
@@ -221,9 +226,17 @@ export const css = [
'.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_bindStatus{display:flex;align-items:center;gap:6px;margin-top:8px;padding:6px 8px;background:var(--dsw-alias-fill-l1,rgba(255,255,255,.03));border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:8px}',
'.dsh-wt_bindStatusNone{color:var(--dsw-alias-label-secondary,#9aa4b2)}',
'.dsh-wt_bindMini{position:relative;display:inline-block;width:18px;height:14px;flex:none}',
'.dsh-wt_bindNoneText{font-size:12px;line-height:18px}',
'.dsh-wt_bindFolder{flex:none;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#9aa4b2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:40%}',
'.dsh-wt_bindSep{flex:none;color:var(--dsw-alias-border-l2,#3a4150);font-size:11px}',
'.dsh-wt_bindConv{flex:1;min-width:0;padding:2px 8px;border:1px solid var(--dsw-alias-border-l2,#3a4150);border-radius:6px;background:var(--dsw-alias-fill-l2,rgba(255,255,255,.05));font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary,#e6e8eb);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
'.dsh-wt_bindHint{margin:6px 0 6px;font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary,#9aa4b2)}',
// 自定义窗完成页:自动绑定提示
'.dsh-wt_customDoneBind{margin:6px 0 0;font-size:11px;line-height:16px;color:var(--dsw-alias-state-accent-primary,#4f8ef7)}',
'.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}',
+2 -1
View File
@@ -292,7 +292,8 @@ export function apply(ctx: Context) {
try {
const file = pathResolve(homedir(), '.dsh', 'storages', 'workspace.json')
const raw = await readFile(file, 'utf8')
json(res, 200, JSON.parse(raw))
// 容忍 BOM(外部工具改写可能带 EF BB BF,JSON.parse 会抛错)
json(res, 200, JSON.parse(raw.charCodeAt(0) === 0xFEFF ? raw.slice(1) : raw))
} catch (err) {
json(res, 404, { error: String(err) })
}
@@ -724,6 +724,28 @@
- 重启后 functional-diag 全 20 STEP 复跑 ERROR_COUNT: 0(site/write/workspaces 路由全部就绪)。
- 备注:之前 send-self-test 遗留的一次性会话 session-91a6adea 用户未删,STEP20 itemCount 13→14
即该测试会话;再次提醒用户删除即可,不影响功能。
## 补记(动画窗类型 + 绑定按钮改版 + 自动绑定规则 + 会话硬删除)
- 用户要求:① 删掉归档的一次性会话 session-91a6adea;② 新增「动画」窗类型(iframe 壳 + 地址栏,
独立 🎬 图标,站内自带控件);③ 绑定按钮重画:竖向对齐 + ○○(未绑定)/●●(已绑定)双圆单色图标;
④ 绑定弹窗改版:未绑定显示「未绑定对话」,已绑定显示「📂文件夹 | [对话名框]」,去掉照抄项目名的
第二行;⑤ 自动绑定规则:子窗口新建/发送到会话且当前项目未绑定 → 自动绑定该会话;已绑定 → 永不
自动改绑;⑥ 资深 PM 式 hover 提示。
- 实现:① 会话目录 + workspace.json 引用删除(备份后清理,宿主已采纳);② BuiltinType 增 'anim'、
BUILTIN_ICONS 🎬、AnimPane(浏览器窗同款壳、默认 about:blank)、选择器第 2 位;③ 绑定按钮改为
绝对定位 right:26px/top:50% 统一对齐(布局卡与入驻卡一致),图标由 CSS 双圆绘制(::before/::after,
data-bound 切换空心/实心、单色 currentColor);④ 弹窗状态行(bindInfoOf 查文件夹+标题,对话名带框)
+ 提示文案「一个项目只能绑定一个对话…」;⑤ custom env 增 autoBind('auto'/'kept'/'none'),
createCustomSession 返回会话 id,CustomPane 完成页显示自动绑定/未改绑提示。
- 验证(probe-batch5/autobind-mini):pickCount=5、tabType=anim、地址栏+iframe ✓;3 个绑定按钮
全部 absolute/delta=0 对齐、8px 空心圆 ✓;弹窗未绑定态「未绑定对话」无对话框 ✓,绑定后
📂未分组 | [dsh-usage] 带框 ✓;autoBind 未绑定→'auto'+写入、再调→'kept'+不变 ✓;functional-diag
全 20 STEP ERROR_COUNT: 0。
- 事故与修复:删除会话时用 PowerShell Set-Content 重写 workspace.json 带上了 UTF-8 BOM,宿主路由
JSON.parse 抛错 → /api/worktable/workspaces 404、会话分组退化为平铺(STEP20 groupHeaders 0)。
已去 BOM 修复,并在服务端路由加 BOM 容忍(下次重启生效)。
- 备注:动画窗/绑定按钮纯客户端,F5 即生效;服务端 BOM 容忍改动需下次重启 dsh web 后生效。
## 补记(第 7/8 布局 + +自定义磁贴 + 窗口改名 + 对话绑定)
- 用户要求:① 新增第 7 个预设「田字格」——右聊天通高整列、左侧 2×2 四窗默认等大,横向贯通、
+63
View File
@@ -0,0 +1,63 @@
// autobind-mini.cjs — 未绑定项目:autoBind 应返回 'auto' 并写入;二次调用 'kept' 不改
const http = require('http');
const { spawn } = require('child_process');
const WebSocket = require('ws');
const PORT = 9344;
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-autobind',
'about:blank',
], { stdio: 'ignore' });
const getJSON = (p) => new Promise((res, rej) => {
http.get({ host: '127.0.0.1', port: PORT, path: p }, (r) => { let d=''; r.on('data',c=>d+=c); r.on('end',()=>{try{res(JSON.parse(d))}catch(e){rej(e)}}) }).on('error', rej);
});
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
(async () => {
let list = null;
for (let i = 0; i < 40; i++) { try { list = await getJSON('/json/list'); if (list && list.length) break; } catch {} await sleep(500); }
const target = list.find((t) => t.type === 'page') || list[0];
const ws = new WebSocket(target.webSocketDebuggerUrl);
let id = 0; const pending = new Map();
const send = (method, params) => new Promise((res) => { const mid = ++id; pending.set(mid, res); ws.send(JSON.stringify({ id: mid, method, params })); });
ws.on('message', (raw) => { const m = JSON.parse(String(raw)); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } });
await new Promise((r) => ws.on('open', r));
await send('Runtime.enable');
await send('Page.enable');
await send('Page.addScriptToEvaluateOnNewDocument', {
source: "try{ if(window===window.top && location.origin==='http://127.0.0.1:3080'){ localStorage.setItem('dsh.worktable.projects.v1', JSON.stringify({order:[],lastUsed:{},hidden:[],nameOverrides:{},shortcuts:[],layouts:[{id:'t-layout',title:'L',top:null,left:null,main:[{id:'p1',title:'W',min:200,content:null}],chatWidth:{default:320,min:240,max:600},chatSide:'right',chatFullHeight:false}],bindings:{}})); } }catch(e){}",
});
await send('Page.navigate', { url: 'http://127.0.0.1:3080/' });
const evaluate = async (expr) => {
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true });
if (r.result && r.result.exceptionDetails) return { __error: (r.result.exceptionDetails.exception || {}).description || r.result.exceptionDetails.text };
return r.result && r.result.result ? r.result.result.value : undefined;
};
let ready = null;
for (let i = 0; i < 30; i++) { await sleep(1000); ready = await evaluate("!!window.__dshCustomEnv"); if (ready === true) break; }
await sleep(1200);
const step = await evaluate(`(async function(){
var out = {};
var st = window.__dshWorktable.splitStore;
var env = window.__dshCustomEnv;
st.open(JSON.parse(localStorage.getItem('dsh.worktable.projects.v1')).layouts[0]);
await new Promise(function(r){ setTimeout(r, 600); });
var pid = st.spec && st.spec.id;
out.pid = pid;
out.r1 = env.custom.autoBind('fake-session-1');
await new Promise(function(r){ setTimeout(r, 400); });
out.binding1 = JSON.parse(localStorage.getItem('dsh.worktable.projects.v1')).bindings[pid];
out.r2 = env.custom.autoBind('fake-session-2');
await new Promise(function(r){ setTimeout(r, 400); });
out.binding2 = JSON.parse(localStorage.getItem('dsh.worktable.projects.v1')).bindings[pid];
out.unchanged = out.binding2 === out.binding1;
st.close();
var j = JSON.parse(localStorage.getItem('dsh.worktable.projects.v1'));
delete j.bindings[pid];
localStorage.setItem('dsh.worktable.projects.v1', JSON.stringify(j));
return JSON.stringify(out);
})()`);
console.log('AUTOBIND:', JSON.stringify(step));
ws.close(); proc.kill(); process.exit(0);
})().catch((e) => { console.error('FATAL', e && e.stack || e); try { proc.kill(); } catch {} process.exit(1); });
+80
View File
@@ -0,0 +1,80 @@
// bindpop-mini.cjs — 只测绑定弹窗打开,捕获 React 异常
const http = require('http');
const { spawn } = require('child_process');
const WebSocket = require('ws');
const PORT = 9343;
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-bindmini',
'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 events = [];
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); } else if (m.method) events.push(m); });
await new Promise((r) => ws.on('open', r));
await send('Runtime.enable');
await send('Page.enable');
await send('Page.addScriptToEvaluateOnNewDocument', {
source: "try{ if(window===window.top && location.origin==='http://127.0.0.1:3080'){ localStorage.setItem('dsh.worktable.projects.v1', JSON.stringify({order:[],lastUsed:{},hidden:[],nameOverrides:{},shortcuts:[],layouts:[{id:'t-layout',title:'L',top:null,left:null,main:[{id:'p1',title:'W',min:200,content:null}],chatWidth:{default:320,min:240,max:600},chatSide:'right',chatFullHeight:false}],bindings:{}})); } }catch(e){}",
});
await send('Page.navigate', { url: 'http://127.0.0.1:3080/' });
const evaluate = async (expr) => {
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true });
if (r.result && r.result.exceptionDetails) return { __error: (r.result.exceptionDetails.exception || {}).description || r.result.exceptionDetails.text };
return r.result && r.result.result ? r.result.result.value : undefined;
};
let ready = null;
for (let i = 0; i < 30; i++) { await sleep(1000); ready = await evaluate("!!window.__dshWorktable"); if (ready === true) break; }
await sleep(1500);
const step0 = await evaluate(`(async function(){
var st = window.__dshWorktable.splitStore;
st.open({id:'t-anim',title:'anim',top:null,main:[{id:'p1',title:'W',min:200,content:null}],chatWidth:{default:320,min:240,max:600}});
await new Promise(function(r){ setTimeout(r, 900); });
var picks = [].slice.call(document.querySelectorAll('.dsh-wt_panePick')).filter(function(el){ return (getComputedStyle(el).visibility!=='hidden'); });
var ap = picks.find(function(el){ return String(el.textContent).indexOf('\u52a8\u753b') >= 0; });
if (ap) { ap.click(); }
await new Promise(function(r){ setTimeout(r, 400); });
st.close();
await new Promise(function(r){ setTimeout(r, 300); });
return JSON.stringify({ opened: true, tab: st.spec ? null : 'closed' });
})()`);
console.log('STEP0:', JSON.stringify(step0));
const step1 = await evaluate(`(function(){
var out = {};
out.cards = document.querySelectorAll('.dsh-wt_layout').length;
out.ls = localStorage.getItem('dsh.worktable.projects.v1');
var b = document.querySelector('.dsh-wt_layout .dsh-wt_bindBtn');
out.btn = !!b;
if (b) { b.click(); }
return JSON.stringify(out);
})()`);
console.log('STEP1:', JSON.stringify(step1));
await sleep(800);
const step2 = await evaluate(`(function(){
var out = {};
out.pop = !!document.querySelector('.dsh-wt_bindPop');
out.pops = document.querySelectorAll('.dsh-wt_bindPop').length;
out.backdrops = document.querySelectorAll('.dsh-wt_popBackdrop').length;
var p = document.querySelector('.dsh-wt_bindPop');
out.visHidden = p ? getComputedStyle(p).visibility : null;
if (p) { var r = p.getBoundingClientRect(); out.rect = {x:r.x, y:r.y, w:r.width, h:r.height}; }
out.err = window.__lastErr || null;
return JSON.stringify(out);
})()`);
console.log('STEP2:', JSON.stringify(step2));
const errs = events.filter((e) => e.method === 'Runtime.exceptionThrown').slice(-5)
.map((e) => ((e.params.exceptionDetails.exception || {}).description || '').slice(0, 200));
console.log('EXCEPTIONS:', JSON.stringify(errs));
ws.close(); proc.kill(); process.exit(0);
})().catch((e) => { console.error('FATAL', e && e.stack || e); try { proc.kill(); } catch {} process.exit(1); });
+134
View File
@@ -0,0 +1,134 @@
// probe-batch5.cjs — 动画窗类型 + 绑定按钮对齐/双圆图标 + 弹窗状态行 + 自动绑定规则
const http = require('http');
const { spawn } = require('child_process');
const WebSocket = require('ws');
const PORT = 9342;
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-batch5',
'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 events = [];
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); } else if (m.method) events.push(m); });
await new Promise((r) => ws.on('open', r));
await send('Runtime.enable');
await send('Page.enable');
await send('Page.addScriptToEvaluateOnNewDocument', {
source: "try{ window.__seedRan = (window.__seedRan||0)+1; if(window===window.top && location.origin==='http://127.0.0.1:3080'){ localStorage.setItem('dsh.worktable.projects.v1', JSON.stringify({order:[],lastUsed:{},hidden:[],nameOverrides:{},shortcuts:[],layouts:[{id:'t-layout',title:'L',top:null,left:null,main:[{id:'p1',title:'W',min:200,content:null}],chatWidth:{default:320,min:240,max:600},chatSide:'right',chatFullHeight:false}],bindings:{}})); window.__seedDone = true; } }catch(e){ window.__seedErr = 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.__dshCustomEnv"); 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 st = window.__dshWorktable.splitStore;
try {
// A. 动画窗类型:打开工作区 → 6选1选择器多一项 → 打开 anim 标签 → 地址栏+iframe
st.open({id:'t-anim',title:'anim',top:null,main:[{id:'p1',title:'\u7a97\u53e31',min:200,content:null}],chatWidth:{default:320,min:240,max:600}});
await sleep(900);
var picks = [].slice.call(document.querySelectorAll('.dsh-wt_panePick')).filter(function(el){ return (getComputedStyle(el).visibility!=='hidden'); });
out.pickCount = picks.length;
out.pickHasAnim = picks.some(function(el){ return String(el.textContent).indexOf('\u52a8\u753b') >= 0; });
var animPick = picks.find(function(el){ return String(el.textContent).indexOf('\u52a8\u753b') >= 0; });
if (animPick) { animPick.click(); }
await sleep(400);
out.tabType = st.spec.main[0].tabs && st.spec.main[0].tabs[0].content.type;
out.animBar = !!vis('.dsh-wt_browserBar');
out.animIframe = !!vis('.dsh-wt_paneFrame');
st.close();
await sleep(300);
// B. 绑定按钮:双圆 CSS 图标 + 竖向对齐(所有卡片按钮中心与各自卡片中心偏差 <= 2px)
var btns = [].slice.call(document.querySelectorAll('.dsh-wt_bindBtn')).filter(function(el){ return el.getBoundingClientRect().height > 0; });
out.bindBtnCount = btns.length;
out.aligns = btns.map(function(b){
var card = b.closest('button');
var br = b.getBoundingClientRect(); var cr = card ? card.getBoundingClientRect() : null;
var ps = getComputedStyle(b);
var b4 = getComputedStyle(b.querySelector('.dsh-wt_bindCircles'), '::before');
return { pos: ps.position, right: ps.right, w: Math.round(br.width), h: Math.round(br.height), delta: card ? Math.round((br.top+br.height/2) - (cr.top+cr.height/2)) : null, circleW: b4.width, circleBorder: b4.borderTopWidth, circleFill: b4.backgroundColor };
});
out.allAbsAligned = out.aligns.every(function(a){ return a.pos === 'absolute' && Math.abs(a.delta) <= 2; });
out.circleStyle = out.aligns[0] || null;
// C. 绑定弹窗:未绑定状态行 → 绑定后状态行(📂 | [框])
out.lsBeforeC = localStorage.getItem('dsh.worktable.projects.v1');
out.seedRan = window.__seedRan; out.seedDone = window.__seedDone; out.seedErr = window.__seedErr || null;
out.layoutCards = document.querySelectorAll('.dsh-wt_layout').length;
var card0 = document.querySelector('.dsh-wt_layout');
var bbtn = card0 && card0.querySelector('.dsh-wt_bindBtn');
if (bbtn) { bbtn.click(); }
await sleep(900);
out.popup = !!vis('.dsh-wt_bindPop');
out.popupRaw = document.querySelectorAll('.dsh-wt_bindPop').length;
var pp = document.querySelector('.dsh-wt_bindPop');
if (pp) { var prr = pp.getBoundingClientRect(); out.popupRect = { x: Math.round(prr.x), y: Math.round(prr.y), w: Math.round(prr.width), h: Math.round(prr.height), vis: getComputedStyle(pp).visibility }; }
out.unboundText = vis('.dsh-wt_bindNoneText') ? String(vis('.dsh-wt_bindNoneText').textContent).trim() : null;
out.noConvBoxWhenUnbound = !vis('.dsh-wt_bindConv');
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 lid = JSON.parse(localStorage.getItem('dsh.worktable.projects.v1')).layouts[0].id;
var boundVal = JSON.parse(localStorage.getItem('dsh.worktable.projects.v1')).bindings[lid];
out.boundSaved = typeof boundVal === 'string' && boundVal.length > 0;
if (bbtn) { bbtn.click(); }
await sleep(900);
out.popup2 = !!vis('.dsh-wt_bindPop');
out.folderShown = vis('.dsh-wt_bindFolder') ? String(vis('.dsh-wt_bindFolder').textContent).trim() : null;
out.sepShown = !!vis('.dsh-wt_bindSep');
var conv = vis('.dsh-wt_bindConv');
out.convBox = conv ? { text: String(conv.textContent).trim(), border: getComputedStyle(conv).borderTopWidth, bg: getComputedStyle(conv).backgroundColor } : null;
out.noNoneTextWhenBound = !vis('.dsh-wt_bindNoneText');
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);
// D. 自动绑定规则:直接调 env.autoBind
var env = window.__dshCustomEnv;
st.open(JSON.parse(localStorage.getItem('dsh.worktable.projects.v1')).layouts[0]);
await sleep(600);
var pid = st.spec && st.spec.id;
out.autoPid = pid;
var r1 = env.custom.autoBind('fake-session-1');
out.auto1 = r1;
out.bindingAfter1 = JSON.parse(localStorage.getItem('dsh.worktable.projects.v1')).bindings[pid];
var r2 = env.custom.autoBind('fake-session-2');
out.auto2 = r2;
out.bindingAfter2 = JSON.parse(localStorage.getItem('dsh.worktable.projects.v1')).bindings[pid];
st.close();
// 清理:解绑
var layouts2 = JSON.parse(localStorage.getItem('dsh.worktable.projects.v1'));
delete layouts2.bindings[pid];
localStorage.setItem('dsh.worktable.projects.v1', JSON.stringify(layouts2));
// 恢复原会话
var S = window.__dshSessions;
var snap0 = S.list.getSnapshot();
if (snap0.current) { window.__dshOpenSession(snap0.current); }
} catch(e) { out.err = String(e); }
return JSON.stringify(out);
})()`);
console.log('BATCH5:', JSON.stringify(step));
const errs = events.filter((e) => e.method === 'Runtime.exceptionThrown').slice(-6)
.map((e) => ((e.params.exceptionDetails.exception || {}).description || '').slice(0, 300));
console.log('EXCEPTIONS:', JSON.stringify(errs));
ws.close(); proc.kill(); process.exit(0);
})().catch((e) => { console.error('FATAL', e && e.stack || e); try { proc.kill(); } catch {} process.exit(1); });