feat: mandatory project folder (cwd binding) + project-conversation linkage (switch closes project, close restores prev) + window-aware prompt with knowledge pack

This commit is contained in:
Aisland
2026-08-19 21:29:44 +08:00
parent be0f4590b9
commit ce449c6620
8 changed files with 399 additions and 43 deletions
+185 -36
View File
@@ -53,6 +53,8 @@ type ProjectsState = {
layouts: LayoutSpec[]
/** 项目 → 绑定会话:打开该项目时右侧对话窗自动切换到这个会话(空/缺 = 不换)。 */
bindings: Record<string, string>
/** 项目 → 工作文件夹(绝对路径):该项目所有产出文件都放这里;新建项目时强制填写。 */
folders: Record<string, string>
}
const PERSIST_KEY = 'dsh.worktable.view.v1'
@@ -227,6 +229,7 @@ const DEFAULT_PROJECTS: ProjectsState = {
shortcuts: [],
layouts: [],
bindings: {},
folders: {},
}
function loadView(): ViewState {
@@ -272,6 +275,7 @@ function loadProjects(): ProjectsState {
? p.layouts.filter((l: any) => l && typeof l.id === 'string' && typeof l.title === 'string' && Array.isArray(l.main))
: [],
bindings: p.bindings && typeof p.bindings === 'object' ? p.bindings : {},
folders: p.folders && typeof p.folders === 'object' ? p.folders : {},
}
} catch {
return { ...DEFAULT_PROJECTS }
@@ -405,23 +409,44 @@ 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<string> {
/** 插件知识包:附在窗口任务提示词里,让接收方跳过对插件源码的重新侦察,直接干活 */
const KNOWLEDGE_PACK = [
'【插件知识包·请直接采用,不要重新侦察插件源码】',
'- dsh-worktable 是本机 DeepSeek Harness 的自建容器插件(仓库 E:\\AI_Workspace\\DeepseekHarness\\Projects\\dsh-worktable,插件包根目录 01_content/)。',
'- 窗口(内容窗)模型:每个窗 = 一个标签页;未指派时显示选择器(浏览器/动画/资源管理器/终端/✨自定义);',
' 窗内容还可放 iframe 网页与文件预览(.md/.txt/.tsx/.css/.html 等)。',
'- 把窗口做成内容的常用方式:① 产出单文件 HTML(放项目文件夹)→ 用户在资源管理器里点击即以整目录静态托管渲染;',
' ② 给一个可直接嵌入的 URL;③ 需要新窗类型时改插件 01_content/src/client/split.tsx 的 BuiltinType 与选择器。',
'- 服务端能力:/api/worktable/fs(列目录)、/api/worktable/write(写文件)、/api/worktable/mkdir(建目录)、',
' /api/worktable/git(git 状态)、/api/worktable/site(静态托管)。',
'- 改完插件在 01_content 目录执行 npm run build;重启 dsh web 或浏览器 F5 生效。',
'- 所有产出文件一律放进本任务标注的项目文件夹,保持用户目录干净。',
].join('\n')
/** 组装窗口任务提示词(新建/发送两模式共用;导出到 window 供自测校验) */
function buildWindowTaskText(projectId: string, projectName: string, windowLabel: string, requirement: string, folder: string | null, mode: 'new' | 'send'): string {
const win = windowLabel || '一个内容窗'
const folderLine = folder
? '项目文件夹:' + folder + '(本项目所有产出文件一律放进这个文件夹;不要写到别的默认位置)。'
: '本项目暂未设置专属文件夹:产出文件先向用户确认存放位置,不要随便写。'
const lines = [
'【工作台自定义窗口任务】',
'以下内容由 dsh-worktable(工作台)插件' + (mode === 'new' ? '自动发送' : '发送') + ':用户想把「' + projectName + '」项目中的「' + win + '」窗口打造成他想要的内容。',
'1. dsh-worktable 是侧边栏底部的自建「工作台」插件(官方文档没有它的说明,不了解之处可直接向用户提问)。',
'2. 用户需求:' + requirement,
'3. ' + folderLine,
'4. 请帮助完成「' + win + '」窗口的自定义设计:优先直接产出可放进窗口的内容(页面/HTML/方案),说明放进窗口的方式;',
' 该窗口位于「' + projectName + '」项目内,如需要也可协助该项目后续的其他自定义工作。',
KNOWLEDGE_PACK,
]
return lines.join('\n')
}
/** 自定义窗口任务:新建一个专属会话(可选指定分组/项目文件夹),注入窗口身份 + 知识包,并打开该会话 */
export async function createCustomSession(projectId: string, projectName: string, requirement: string, group?: NewSessionGroup, windowLabel = '', folder: string | null = null): Promise<string> {
const b = sessionBridge
if (!b || typeof b.sessions?.create !== 'function') throw new Error('sessions unavailable')
const text = [
'【工作台自定义窗口任务】',
'以下背景由 dsh-worktable(工作台)插件自动附加,用于帮助你理解任务:',
'1. dsh-worktable 是什么:它是本机 DeepSeek Harness 的自建容器插件——侧边栏底部的「工作台」区块管理多个项目;',
' 每个项目打开后是一个平铺工作区(若干内容窗 + 对话窗);内容窗可放浏览器、资源管理器、终端、',
' 文件预览(MD/TXT/代码/本地网页)等,支持 6 种布局预设、变更视图、窗内容以标签页组织、状态持久化。',
' 它是用户自建的工具,官方文档中没有它的说明;不了解之处可直接向用户提问。',
'2. 本对话的用途:专门负责「' + projectName + '」项目中一个窗口(内容窗)的自定义设计——',
' 用户希望把这个窗口做成自定义内容。',
'3. 用户需求:' + requirement,
'4. 请基于以上背景,帮助用户完成这个窗口的自定义设计(例如产出可直接放入窗口的内容、页面或实现方案);',
' 该窗口位于「' + projectName + '」项目内,如需要也可协助该项目后续的其他自定义工作。',
].join('\n')
const text = buildWindowTaskText(projectId, projectName, windowLabel, requirement, folder, 'new')
// 分组解析:existing → 直接带 workspaceId 建会话;new → 先建目录并注册为工作区
let workspaceId: string | null = null
const g = group ?? { kind: 'none' as const }
@@ -445,27 +470,34 @@ export async function createCustomSession(projectId: string, projectName: string
workspaceId = view?.workspaceId ?? view?.id ?? null
if (!workspaceId) throw new Error('workspace create failed')
}
const sessionId = await b.sessions.create(workspaceId ? { workspaceId } : {})
// 会话工作目录优先级:显式分组(workspaceId)> 项目文件夹(cwd)> 默认
let createOpts: any = {}
if (workspaceId) createOpts = { workspaceId }
else if (folder) createOpts = { cwd: folder }
const sessionId = await b.sessions.create(createOpts)
try { await b.sessions.open?.(sessionId) } catch {}
await promptIntoSession(sessionId, text)
return sessionId
}
/** 把自定义需求直接发到用户选定的已有会话(默认当前会话) */
export async function sendCustomToSession(sessionId: string, projectName: string, requirement: string): Promise<void> {
/** 把自定义需求直接发到用户选定的已有会话(默认当前会话),带窗口身份 + 知识包 */
export async function sendCustomToSession(sessionId: string, projectId: string, projectName: string, requirement: string, windowLabel = '', folder: string | null = null): Promise<void> {
const b = sessionBridge
if (!b) throw new Error('bridge unavailable')
const text = [
'【工作台自定义窗口任务】',
'以下内容由 dsh-worktable(工作台)插件发送:用户正在自定义「' + projectName + '」项目中的一个窗口。',
'1. dsh-worktable 是侧边栏底部的自建「工作台」插件(官方文档没有它的说明,不了解之处可向用户提问);',
'2. 用户需求:' + requirement,
'3. 请帮助完成该窗口的自定义设计(内容/页面/实现方案),该窗口位于「' + projectName + '」项目内。',
].join('\n')
const text = buildWindowTaskText(projectId, projectName, windowLabel, requirement, folder, 'send')
try { await b.sessions.open?.(sessionId) } catch {}
await promptIntoSession(sessionId, text)
}
// ── 项目 × 对话联动(模块级)──
/** 打开项目前所处的会话(关项目时回切);attached = 该项目绑定的会话(无绑定 = 打开前会话) */
const projectAttachRef: { sessionId: string | null; attached: string | null } = { sessionId: null, attached: null }
/** 因「切换会话」而自动关项目时置位,跳过回切 */
const suppressRestoreRef: { current: boolean } = { current: false }
/** 项目绑定表的最新快照(组件每渲染同步,供模块级联动逻辑读取) */
const projectBindingsRef: { current: Record<string, string> } = { current: {} }
let lastSessionScopeId = ''
/** 会话作用域快照(模块级;apply 里订阅 ctx.sessions.list 写入,组件与引擎只读) */
const sessionScopeStore: {
snapshot: { sessionId: string; cwd: string; jobs: any[]; subagents: any[] } | null
@@ -488,6 +520,19 @@ function syncSessionScope(list: any) {
jobs: (snap?.jobsBySession?.[current] ?? []) as any[],
subagents,
}
// 项目×对话联动:项目打开期间切到「非该项目绑定」的会话 → 自动关项目(保留用户新选的会话)
try {
if (current !== lastSessionScopeId) {
lastSessionScopeId = current
if (current && splitStore.active && splitStore.spec) {
const attached = projectAttachRef.attached
if (attached && current !== attached) {
suppressRestoreRef.current = true
splitStore.close()
}
}
}
} catch { /* 联动失败不影响主流程 */ }
} catch {
sessionScopeStore.snapshot = { sessionId: '', cwd: '', jobs: [], subagents: [] }
}
@@ -541,11 +586,20 @@ function WorktableSection(props: any) {
const [wsPreset, setWsPreset] = useState<string>('2h')
const [wsName, setWsName] = useState('')
const [wsError, setWsError] = useState(false)
// 新建项目强制工作文件夹:父目录(必填)+ 名称(留空 = 用项目名)
const [wsFolderParent, setWsFolderParent] = useState('')
const [wsFolderName, setWsFolderName] = useState('')
const [wsFolderError, setWsFolderError] = 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 [bindFolderEdit, setBindFolderEdit] = useState(false)
const [bindFolderParent, setBindFolderParent] = useState('')
const [bindFolderName, setBindFolderName] = useState('')
const [bindFolderError, setBindFolderError] = useState(false)
/** 自定义布局弹窗(预设网格末尾的 + 磁贴):描述 → 复制提示词到剪贴板 */
const [customOpen, setCustomOpen] = useState(false)
const [customLayoutText, setCustomLayoutText] = useState('')
@@ -595,8 +649,9 @@ function WorktableSection(props: any) {
},
currentProjectId: () => (splitStore.active && splitStore.spec ? splitStore.spec.id : null),
getSessions: () => fetchSessionGroups(),
sendToSession: (sessionId, projectName, requirement) => sendCustomToSession(sessionId, projectName, requirement),
submit: (projectId, projectName, requirement, group) => createCustomSession(projectId, projectName, requirement, group),
getProjectFolder: (id: string) => projectsRef.current.projects.folders[id] ?? null,
sendToSession: (sessionId, projectId, projectName, requirement, windowLabel) => sendCustomToSession(sessionId, projectId, projectName, requirement, windowLabel, projectsRef.current.projects.folders[projectId] ?? null),
submit: (projectId, projectName, requirement, group, windowLabel) => createCustomSession(projectId, projectName, requirement, group, windowLabel, projectsRef.current.projects.folders[projectId] ?? null),
// 分组(宿主工作区)数据源:读 ctx.workspaces 快照,供新建对话选分组
getWorkspaces: () => {
try {
@@ -652,9 +707,18 @@ function WorktableSection(props: any) {
return () => { registryStore.listeners.delete(sync) }
}, [])
// 分栏引擎激活态 → activeSplitId(卡片据此显示选中效果)
// 分栏引擎激活态 → activeSplitId(卡片据此显示选中效果);关闭项目 → 回切打开前的会话
useEffect(() => splitStore.subscribe(() => {
setActiveSplitId(splitStore.active && splitStore.spec ? splitStore.spec.id : null)
if (!splitStore.active) {
if (!suppressRestoreRef.current) {
const prev = projectAttachRef.sessionId
if (prev) { try { sessionBridge?.sessions?.open?.(prev) } catch {} }
}
projectAttachRef.sessionId = null
projectAttachRef.attached = null
}
suppressRestoreRef.current = false
}), [])
// 引擎内 spec 变更(窗内容/聊天位置/窗位互换)→ 回写持久化:
@@ -830,8 +894,15 @@ function buildCustomLayoutPrompt(req: string): string {
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 {} }
if (splitStore.active && splitStore.spec?.id === spec.id) {
// 记录打开前会话(关项目时回切)与该项目「归属会话」(切到别的会话 = 自动关项目)
let prev: string | null = null
try { prev = sessionBridge?.list?.getSnapshot?.()?.current ?? null } catch {}
projectAttachRef.sessionId = prev
projectAttachRef.attached = projectsRef.current.projects.bindings[spec.id] ?? prev
const bound = projectsRef.current.projects.bindings[spec.id]
if (bound) { try { sessionBridge?.sessions?.open?.(bound) } catch {} }
}
}, [projects.views])
/** 打开对话绑定弹窗:抓取会话分组 + 锚点定位 */
@@ -841,9 +912,28 @@ function buildCustomLayoutPrompt(req: string): string {
const y = clamp(Math.round(r.top), 8, window.innerHeight - 420)
setBindPick({ id, x, y })
setBindGroups([])
setBindFolderEdit(false)
setBindFolderError(false)
fetchSessionGroups().then((res) => setBindGroups(res.groups)).catch(() => setBindGroups([]))
}, [])
/** 保存绑定弹窗里的项目文件夹(建目录 + 持久化) */
const saveBindFolder = async () => {
if (!bindPick) return
const parent = bindFolderParent.trim()
const name = bindFolderName.trim()
if (!parent || !name) { setBindFolderError(true); return }
const folderPath = parent.replace(/[\\/]+$/, '') + '\\' + name.replace(/[\\/]+/g, '')
try {
const r = await fetch('/api/worktable/mkdir', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ path: folderPath }) })
const d = await r.json()
if (!r.ok || !d?.ok) { setBindFolderError(true); return }
} catch { setBindFolderError(true); return }
persistProjects((prev) => ({ ...prev, folders: { ...prev.folders, [bindPick.id]: folderPath } }))
setBindFolderEdit(false)
setBindFolderError(false)
}
/** 绑定 / 解绑会话 */
const setProjectBinding = (id: string, sessionId: string | null) => {
persistProjects((prev) => {
@@ -891,6 +981,7 @@ function buildCustomLayoutPrompt(req: string): string {
[registeredIds, projects.removed],
)
projectsRef.current = { projects, metas, aliveRegisteredIds }
projectBindingsRef.current = projects.bindings
const allIds = useMemo(() => [...aliveRegisteredIds, ...layoutIds], [aliveRegisteredIds, layoutIds])
const effectiveOrder = useMemo(() => {
const known = new Set(allIds)
@@ -1023,13 +1114,22 @@ function buildCustomLayoutPrompt(req: string): string {
persistProjects((prev) => ({ ...prev, shortcuts: prev.shortcuts.filter((s) => s.id !== id) }))
}
// ── 布局(新建工作区) ──
const saveLayout = () => {
// ── 布局(新建工作区) ── 强制项目文件夹:父目录必填,文件夹名留空 = 用项目名;保存时建目录
const saveLayout = async () => {
const name = wsName.trim()
if (!name) { setWsError(true); return }
const parent = wsFolderParent.trim()
if (!parent) { setWsFolderError(true); return }
const folderName = (wsFolderName.trim() || name).replace(/[\\/]+/g, '')
const folderPath = parent.replace(/[\\/]+$/, '') + '\\' + folderName
try {
const r = await fetch('/api/worktable/mkdir', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ path: folderPath }) })
const d = await r.json()
if (!r.ok || !d?.ok) { setWsFolderError(true); return }
} catch { setWsFolderError(true); return }
const layout = buildLayout(wsPreset, name)
persistProjects((prev) => ({ ...prev, layouts: [...prev.layouts, layout] }))
setWsName(''); setWsError(false)
persistProjects((prev) => ({ ...prev, layouts: [...prev.layouts, layout], folders: { ...prev.folders, [layout.id]: folderPath } }))
setWsName(''); setWsFolderParent(''); setWsFolderName(''); setWsError(false); setWsFolderError(false)
setAddOpen(false)
openSplit(layout)
reportUsed(layout.id)
@@ -1396,7 +1496,14 @@ function buildCustomLayoutPrompt(req: string): string {
className="dsh-wt_iconBtn"
aria-label={t('menu.add')}
title={t('menu.add')}
onClick={() => { setAddOpen((v) => !v); setViewOptionsOpen(false) }}
onClick={() => {
setAddOpen((v) => !v); setViewOptionsOpen(false)
// 父目录默认 = 当前会话工作目录(不落 C 盘默认位置)
if (!wsFolderParent) {
const cwd = sessionScopeStore.snapshot?.cwd ?? ''
if (cwd) setWsFolderParent(cwd)
}
}}
>{ICON_ADD}</button>
</div>
</div>
@@ -1441,9 +1548,20 @@ function buildCustomLayoutPrompt(req: string): string {
<div className="dsh-wt_addForm">
<input type="text" placeholder={t('add.layoutNamePh')} value={wsName}
onChange={(e) => { setWsName(e.target.value); setWsError(false) }} />
<div className="dsh-wt_addFolderRow">
<span className="dsh-wt_customLabel">{t('add.folderParent')}</span>
<input className="dsh-wt_customPathInput" type="text" placeholder={t('add.folderParentPh')} value={wsFolderParent}
onChange={(e) => { setWsFolderParent(e.target.value); setWsFolderError(false) }} />
</div>
<div className="dsh-wt_addFolderRow">
<span className="dsh-wt_customLabel">{t('add.folderName')}</span>
<input className="dsh-wt_customPathInput" type="text" placeholder={t('add.folderNamePh')} value={wsFolderName}
onChange={(e) => { setWsFolderName(e.target.value); setWsFolderError(false) }} />
</div>
<button type="button" className="dsh-wt_addBtn" onClick={saveLayout}>{t('add.layoutSave')}</button>
</div>
{wsError && <p className="dsh-wt_addError">{t('add.layoutInvalid')}</p>}
{wsFolderError && <p className="dsh-wt_addError">{t('add.folderRequired')}</p>}
</div>
)}
@@ -1606,6 +1724,37 @@ 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_bindFolderBox">
<div className="dsh-wt_bindFolderRow">
<span className="dsh-wt_bindFolderLabel">📁 {t('bind.folder')}</span>
<button
type="button"
className="dsh-wt_bindFolderChange"
onClick={() => {
const cur = projects.folders[bindPick.id] ?? ''
setBindFolderParent(cur.replace(/[\\/][^\\/]*$/, ''))
setBindFolderName(cur.split(/[\\/]/).pop() ?? '')
setBindFolderEdit((v) => !v)
setBindFolderError(false)
}}
>{t('bind.folderChange')}</button>
</div>
{bindFolderEdit ? (
<div className="dsh-wt_bindFolderForm">
<input className="dsh-wt_customPathInput" type="text" placeholder={t('add.folderParentPh')} value={bindFolderParent}
onChange={(e) => { setBindFolderParent(e.target.value); setBindFolderError(false) }} />
<input className="dsh-wt_customPathInput" type="text" placeholder={t('add.folderNamePh')} value={bindFolderName}
onChange={(e) => { setBindFolderName(e.target.value); setBindFolderError(false) }} />
<button type="button" className="dsh-wt_customLayoutBtn" onClick={saveBindFolder}>{t('add.layoutSave')}</button>
{bindFolderError && <p className="dsh-wt_addError">{t('add.folderRequired')}</p>}
</div>
) : (
<div className={'dsh-wt_bindFolderPath' + (projects.folders[bindPick.id] ? '' : ' dsh-wt_bindFolderPathNone')} title={projects.folders[bindPick.id] ?? ''}>
{projects.folders[bindPick.id] ?? t('bind.folderNone')}
</div>
)}
</div>
{(() => {
const sid = projects.bindings[bindPick.id]
if (!sid) {
@@ -1764,7 +1913,7 @@ export const inject = ['slots', 'locale', 'sessions', 'conversation', 'workspace
export function apply(ctx: any) {
// 自定义窗口 → 宿主会话桥:保存 sessions/conversation/list 服务引用(模块级)
sessionBridge = { sessions: ctx.sessions ?? null, conversation: ctx.conversation ?? null, list: ctx.sessions?.list ?? null, workspaces: ctx.workspaces ?? null }
try { (window as any).__dshOpenSession = (id: string) => ctx.sessions?.open?.(id); (window as any).__dshSessions = ctx.sessions; (window as any).__dshPromptIntoSession = (id: string, text: string) => promptIntoSession(id, text); (window as any).__dshWorkspaces = ctx.workspaces } catch {}
try { (window as any).__dshOpenSession = (id: string) => ctx.sessions?.open?.(id); (window as any).__dshSessions = ctx.sessions; (window as any).__dshPromptIntoSession = (id: string, text: string) => promptIntoSession(id, text); (window as any).__dshWorkspaces = ctx.workspaces; (window as any).__dshBuildWindowTaskText = buildWindowTaskText } catch {}
ctx.effect(() => {
+16
View File
@@ -40,6 +40,11 @@ export const zh = {
'shortcut.badge': '本地',
'add.chooseLayout': '选择布局模式',
'add.layoutNamePh': '布局名称',
'add.folderParent': '项目文件夹(父目录)',
'add.folderParentPh': '例如 E:\\AI_Workspace\\DeepseekHarness\\Projects',
'add.folderName': '文件夹名',
'add.folderNamePh': '留空 = 用项目名',
'add.folderRequired': '请填写项目文件夹的父目录(保存时将创建「父目录\项目名」)',
'add.layoutSave': '进入工作区',
'add.layoutInvalid': '请填写布局名称',
'manage.deleteLayout': '删除布局',
@@ -89,6 +94,9 @@ export const zh = {
'bind.titleUnbound': '绑定对话(未绑定 ○○):点击选择要跟随的对话',
'bind.titleBound': '已绑定对话(●●):点击更换或解绑',
'bind.tipUnbound': '未绑定对话:点击选择要跟随的对话',
'bind.folder': '项目文件夹',
'bind.folderChange': '更改',
'bind.folderNone': '未设置(建议设置:项目产出文件都会放进这里)',
'bind.tipBound': '已绑定对话:{name}',
'bind.hint': '一个项目只能绑定一个对话。选择后,打开该项目时右侧对话窗会自动切换过去;已绑定的项目不会因新建/发送对话而自动改绑。',
'customLayout.add': '自定义',
@@ -163,6 +171,11 @@ export const en = {
'shortcut.badge': 'Local',
'add.chooseLayout': 'Choose a layout',
'add.layoutNamePh': 'Layout name',
'add.folderParent': 'Project folder (parent dir)',
'add.folderParentPh': 'e.g. E:\\AI_Workspace\\DeepseekHarness\\Projects',
'add.folderName': 'Folder name',
'add.folderNamePh': 'Blank = use project name',
'add.folderRequired': 'Fill in the project folder parent (the folder parent\\project-name will be created)',
'add.layoutSave': 'Enter workspace',
'add.layoutInvalid': 'Enter a layout name',
'manage.deleteLayout': 'Delete layout',
@@ -212,6 +225,9 @@ export const en = {
'bind.titleUnbound': 'Bind conversation (unbound ○○): pick the conversation to follow',
'bind.titleBound': 'Conversation bound (●●): click to change or unbind',
'bind.tipUnbound': 'No conversation bound — click to pick one',
'bind.folder': 'Project folder',
'bind.folderChange': 'Change',
'bind.folderNone': 'Not set (recommended: project outputs go here)',
'bind.tipBound': 'Bound to: {name}',
'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',
+8 -6
View File
@@ -1323,7 +1323,9 @@ function SelectPop(props: {
}
/** 自定义窗口:居中对话框。两种模式:新建专属会话 / 发送到已有会话(默认当前会话)。 */
function CustomPane() {
function CustomPane(props: { paneTitle?: string }) {
const paneTitle = props.paneTitle ?? ''
try { (window as any).__dshLastCustomPaneTitle = paneTitle } catch {}
const custom = splitEnv?.custom
const [requirement, setRequirement] = useState('')
const [projectId, setProjectId] = useState<string | null>(null)
@@ -1372,11 +1374,11 @@ function CustomPane() {
if (!newGroupParent.trim() || !newGroupName.trim()) { setFail(T('custom.groupNeedPath')); return }
group = { kind: 'new', parent: newGroupParent.trim(), name: newGroupName.trim() }
}
const sid = await custom.submit(projectId, pname, text, group)
const sid = await custom.submit(projectId, pname, text, group, paneTitle)
setBindNote((custom.autoBind?.(sid) ?? 'none') as any)
} else {
if (!sessionId) return
await custom.sendToSession(sessionId, pname, text)
await custom.sendToSession(sessionId, projectId, pname, text, paneTitle)
setBindNote((custom.autoBind?.(sessionId) ?? 'none') as any)
}
setDone(true)
@@ -1497,7 +1499,7 @@ function CustomPane() {
}
/** 单个标签页的内容渲染 */
function PaneTabBody(props: { tab: PaneTab; row: PaneRow; index: number }) {
function PaneTabBody(props: { tab: PaneTab; row: PaneRow; index: number; paneTitle?: string }) {
const content = props.tab.content
if (content.kind === 'iframe') {
return <iframe className="dsh-wt_paneFrame" src={content.url} title={props.tab.title} />
@@ -1511,7 +1513,7 @@ function PaneTabBody(props: { tab: PaneTab; row: PaneRow; index: number }) {
if (content.type === 'scm') return <GitPane />
if (content.type === 'tasks') return <JobsPane />
if (content.type === 'terminal') return <TerminalPane />
if (content.type === 'custom') return <CustomPane />
if (content.type === 'custom') return <CustomPane paneTitle={props.paneTitle ?? ''} />
return (
<div className="dsh-wt_paneWip">
<span className="dsh-wt_paneWipIcon" aria-hidden>{BUILTIN_ICONS[content.type]}</span>
@@ -1551,7 +1553,7 @@ function PaneBody(props: { pane: SplitPane; row: PaneRow; index: number }) {
</span>
))}
</div>
<PaneTabBody tab={tabs[active]} row={row} index={index} />
<PaneTabBody tab={tabs[active]} row={row} index={index} paneTitle={pane.title} />
</>
)
}
+12
View File
@@ -225,6 +225,18 @@ 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)}',
// 新建项目强制文件夹:两行(父目录/名称)
'.dsh-wt_addFolderRow{display:flex;align-items:center;gap:8px;margin-top:6px}',
'.dsh-wt_addFolderRow .dsh-wt_customLabel{width:86px;text-align:right}',
// 绑定弹窗内的项目文件夹区
'.dsh-wt_bindFolderBox{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_bindFolderRow{display:flex;align-items:center;justify-content:space-between}',
'.dsh-wt_bindFolderLabel{font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary,#e6e8eb)}',
'.dsh-wt_bindFolderChange{flex:none;padding:1px 8px;border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:5px;background:transparent;color:var(--dsw-alias-label-secondary,#9aa4b2);font:inherit;font-size:11px;line-height:16px;cursor:pointer}',
'.dsh-wt_bindFolderChange:hover{color:var(--dsw-alias-label-primary,#e6e8eb);border-color:var(--dsw-alias-border-l2,#3a4150)}',
'.dsh-wt_bindFolderPath{margin-top:4px;font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary,#9aa4b2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
'.dsh-wt_bindFolderPathNone{color:var(--dsw-alias-label-tertiary,#6b7280)}',
'.dsh-wt_bindFolderForm{display:flex;flex-direction:column;gap:6px;margin-top:6px}',
// 自定义布局弹窗(+磁贴):描述输入 + 复制提示词 + 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)}',