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) })
}