feat: group option in new-conversation pane (join existing / create new workspace via mkdir route)

This commit is contained in:
Aisland
2026-08-18 23:27:16 +08:00
parent fa1a0b3866
commit 7b3388bfff
8 changed files with 273 additions and 12 deletions
+49 -8
View File
@@ -263,7 +263,7 @@ function findSidebar(start: HTMLElement | null): HTMLElement | null {
const registryStore: { ids: string[]; listeners: Set<() => void> } = { ids: [], listeners: new Set() }
/** 自定义窗口 → 宿主会话桥(apply 时注入;不可用时 CustomPane 降级提示) */
let sessionBridge: { sessions: any; conversation: any; list: any } | null = null
let sessionBridge: { sessions: any; conversation: any; list: any; workspaces: any } | null = null
/** 把文本送入指定会话:宿主同款寻址(binding(id).session.prompt / sendSession(会话面)),
* 插件是根级上下文,无作用域的 conversation.send 会报 requires a session scope,不可用。 */
@@ -299,8 +299,11 @@ async function promptIntoSession(sessionId: string, text: string): Promise<void>
throw new Error('no send path: session face unavailable')
}
/** 自定义窗口任务:新建一个专属会话,注入工作台背景 + 用户需求,并打开该会话 */
export async function createCustomSession(projectId: string, projectName: string, requirement: 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> {
const b = sessionBridge
if (!b || typeof b.sessions?.create !== 'function') throw new Error('sessions unavailable')
const text = [
@@ -316,7 +319,30 @@ export async function createCustomSession(projectId: string, projectName: string
'4. 请基于以上背景,帮助用户完成这个窗口的自定义设计(例如产出可直接放入窗口的内容、页面或实现方案);',
' 该窗口位于「' + projectName + '」项目内,如需要也可协助该项目后续的其他自定义工作。',
].join('\n')
const sessionId = await b.sessions.create({})
// 分组解析:existing → 直接带 workspaceId 建会话;new → 先建目录并注册为工作区
let workspaceId: string | null = null
const g = group ?? { kind: 'none' as const }
if (g.kind === 'existing') {
workspaceId = g.workspaceId
} else if (g.kind === 'new') {
const ws = b.workspaces as any
if (!ws || typeof ws.create !== 'function') throw new Error('workspaces unavailable')
const parent = g.parent.replace(/[\\/]+$/, '')
const name = g.name.replace(/^[\\/]+/, '').replace(/[\\/]+$/, '')
if (!parent || !name) throw new Error('invalid group path')
const full = parent + '\\' + name
try { await ws.createDirectory?.(parent, name) } catch { /* 宿主 browse 能力本机为 native 时不可用 */ }
try {
// 兜底:插件服务端 mkdir(父目录必须已存在,避免误建深层目录)
const r = await fetch('/api/worktable/mkdir', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ path: full }) })
const d = await r.json()
if (!r.ok || !d?.ok) throw new Error(d?.error ?? 'mkdir failed')
} catch { /* 目录建不出来的错误最终由 workspace.create 暴露 */ }
const view = await ws.create({ path: full })
workspaceId = view?.workspaceId ?? view?.id ?? null
if (!workspaceId) throw new Error('workspace create failed')
}
const sessionId = await b.sessions.create(workspaceId ? { workspaceId } : {})
try { await b.sessions.open?.(sessionId) } catch {}
await promptIntoSession(sessionId, text)
}
@@ -499,7 +525,22 @@ function WorktableSection(props: any) {
} catch { return { groups: [], current: '' } }
},
sendToSession: (sessionId, projectName, requirement) => sendCustomToSession(sessionId, projectName, requirement),
submit: (projectId, projectName, requirement) => createCustomSession(projectId, projectName, requirement),
submit: (projectId, projectName, requirement, group) => createCustomSession(projectId, projectName, requirement, group),
// 分组(宿主工作区)数据源:读 ctx.workspaces 快照,供新建对话选分组
getWorkspaces: () => {
try {
const snap = sessionBridge?.workspaces?.list?.getSnapshot?.()
const items = snap?.items ?? []
return items.map((w: any) => ({
id: w.workspaceId ?? w.id,
title: w.title ?? '',
path: w.path ?? '',
sessionIds: Array.isArray(w.sessionIds) ? w.sessionIds : [],
}))
} catch { return [] }
},
createWorkspace: (path: string) => sessionBridge?.workspaces?.create?.({ path }),
createWorkspaceDir: (parent: string, name: string) => sessionBridge?.workspaces?.createDirectory?.(parent, name),
},
})
return () => setSplitEnv(null)
@@ -1410,12 +1451,12 @@ function WorktableSection(props: any) {
)
}
export const inject = ['slots', 'locale', 'sessions', 'conversation']
export const inject = ['slots', 'locale', 'sessions', 'conversation', 'workspaces']
export function apply(ctx: any) {
// 自定义窗口 → 宿主会话桥:保存 sessions/conversation/list 服务引用(模块级)
sessionBridge = { sessions: ctx.sessions ?? null, conversation: ctx.conversation ?? null, list: ctx.sessions?.list ?? null }
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) } catch {}
sessionBridge = { sessions: ctx.sessions ?? null, conversation: ctx.conversation ?? null, list: ctx.sessions?.list ?? null, workspaces: ctx.workspaces ?? null }
try { (window as any).__dshOpenSession = (id: string) => ctx.sessions?.open?.(id); (window as any).__dshSessions = ctx.sessions; (window as any).__dshPromptIntoSession = (id: string, text: string) => promptIntoSession(id, text); (window as any).__dshWorkspaces = ctx.workspaces } catch {}
ctx.effect(() => {
+16
View File
@@ -73,6 +73,14 @@ export const zh = {
'custom.done': '已新建专属对话',
'custom.doneHint': 'AI 正在新会话中为你设计这个窗口。关闭本标签即可返回窗口选择。',
'custom.fail': '创建对话失败',
'custom.group': '分组(工作区)',
'custom.groupNone': '未分组',
'custom.groupNew': '+ 新建一个分组…',
'custom.groupNewParent': '分组父目录',
'custom.groupNewName': '新分组名',
'custom.groupNewParentPh': '例如 E:\\AI_Workspace\\DeepseekHarness',
'custom.groupNewNamePh': '例如 MyProject(将新建此文件夹并注册为分组)',
'custom.groupNeedPath': '请填写分组父目录和新分组名',
'pane.wip': '开发中(后续版本接入)',
'pane.explorerNoCwd': '当前会话没有工作目录',
'pane.openLater': '该类型暂不支持预览',
@@ -169,6 +177,14 @@ export const en = {
'custom.done': 'Conversation created',
'custom.doneHint': 'The AI is designing your pane in the new conversation. Close this tab to return to the pane picker.',
'custom.fail': 'Failed to create conversation',
'custom.group': 'Group (workspace)',
'custom.groupNone': 'Ungrouped',
'custom.groupNew': '+ Create a new group…',
'custom.groupNewParent': 'Group parent folder',
'custom.groupNewName': 'New group name',
'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',
'pane.wip': 'Coming in a later version',
'pane.explorerNoCwd': 'No working directory for the current session',
'pane.openLater': 'Preview not supported for this type',
+65 -4
View File
@@ -1299,19 +1299,29 @@ function CustomPane() {
const [mode, setMode] = useState<'new' | 'existing'>('new')
const [sessionGroups, setSessionGroups] = useState<{ title: string; sessions: { id: string; title: string; isCurrent: boolean }[] }[]>([])
const [sessionId, setSessionId] = useState<string | null>(null)
// 分组(宿主工作区)三态:未分组 / 现有组 / 新建组
const [wsGroups, setWsGroups] = useState<{ id: string; title: string; path: string }[]>([])
const [groupMode, setGroupMode] = useState<'none' | 'existing' | 'new'>('none')
const [groupId, setGroupId] = useState<string | null>(null)
const [newGroupParent, setNewGroupParent] = useState('')
const [newGroupName, setNewGroupName] = useState('')
const [busy, setBusy] = useState(false)
const [done, setDone] = useState(false)
const [fail, setFail] = useState('')
// 默认项目 = 当前工作区所属项目;默认会话 = 当前会话
// 默认项目 = 当前工作区所属项目;默认会话 = 当前会话;默认分组 = 当前会话所在工作区
useEffect(() => {
const list = custom?.getProjects?.() ?? []
setProjects(list)
const cur = custom?.currentProjectId?.() ?? null
setProjectId(cur && list.some((p) => p.id === cur) ? cur : (list[0]?.id ?? null))
setWsGroups((custom?.getWorkspaces?.() ?? []).map((w) => ({ id: w.id, title: w.title, path: w.path })))
custom?.getSessions?.().then((res) => {
setSessionGroups(res.groups)
const flat = res.groups.flatMap((g) => g.sessions)
setSessionId(flat.find((s) => s.isCurrent)?.id ?? flat[0]?.id ?? null)
const curId = flat.find((s) => s.isCurrent)?.id
const home = curId ? (custom?.getWorkspaces?.() ?? []).find((w) => (w.sessionIds ?? []).includes(curId)) : null
if (home) { setGroupMode('existing'); setGroupId(home.id) }
}).catch(() => { setSessionGroups([]) })
}, [custom])
const submit = async () => {
@@ -1322,8 +1332,15 @@ function CustomPane() {
try {
const proj = projects.find((p) => p.id === projectId)
const pname = proj?.name ?? projectId
if (mode === 'new') await custom.submit(projectId, pname, text)
else {
if (mode === 'new') {
let group: any = { kind: 'none' }
if (groupMode === 'existing' && groupId) group = { kind: 'existing', workspaceId: groupId }
else if (groupMode === 'new') {
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)
} else {
if (!sessionId) return
await custom.sendToSession(sessionId, pname, text)
}
@@ -1385,10 +1402,54 @@ function CustomPane() {
onChange={setProjectId}
/>
</div>
{mode === 'new' && (
<div className="dsh-wt_customRow">
<span className="dsh-wt_customLabel">{T('custom.group')}</span>
<SelectPop
value={groupMode === 'none' ? '__none' : groupMode === 'new' ? '__new' : groupId}
groups={[{
title: '',
items: [
{ id: '__none', label: T('custom.groupNone') },
...wsGroups.map((w) => ({ id: w.id, label: w.title })),
{ id: '__new', label: T('custom.groupNew') },
],
}]}
placeholder={T('custom.group')}
onChange={(id) => {
if (id === '__none') setGroupMode('none')
else if (id === '__new') setGroupMode('new')
else { setGroupMode('existing'); setGroupId(id) }
}}
/>
</div>
)}
{mode === 'new' && groupMode === 'new' && (
<>
<div className="dsh-wt_customRow">
<span className="dsh-wt_customLabel">{T('custom.groupNewParent')}</span>
<input
className="dsh-wt_customPathInput"
placeholder={T('custom.groupNewParentPh')}
value={newGroupParent}
onChange={(e) => setNewGroupParent(e.target.value)}
/>
</div>
<div className="dsh-wt_customRow">
<span className="dsh-wt_customLabel">{T('custom.groupNewName')}</span>
<input
className="dsh-wt_customPathInput"
placeholder={T('custom.groupNewNamePh')}
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
/>
</div>
</>
)}
<button
type="button"
className="dsh-wt_customSend"
disabled={busy || !requirement.trim() || !projectId || (mode === 'existing' && !sessionId)}
disabled={busy || !requirement.trim() || !projectId || (mode === 'existing' && !sessionId) || (mode === 'new' && groupMode === 'new' && (!newGroupParent.trim() || !newGroupName.trim()))}
onClick={submit}
>{busy ? '…' : (mode === 'new' ? T('custom.send') : T('custom.sendToSession'))}</button>
{fail && <p className="dsh-wt_customFail">{T('custom.fail')}:{fail}</p>}
+3
View File
@@ -201,6 +201,9 @@ export const css = [
'.dsh-wt_customModeBtnOn{border-color:var(--dsw-alias-state-accent-primary,#4f8ef7);color:var(--dsw-alias-state-accent-primary,#4f8ef7);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.05))}',
'.dsh-wt_customInput{width:100%;box-sizing:border-box;min-height:96px;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_customInput:focus{border-color:var(--dsw-alias-state-accent-primary,#4f8ef7)}',
// 新建分组:父目录 / 名称单行输入
'.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_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}',
+22
View File
@@ -320,6 +320,28 @@ export function apply(ctx: Context) {
},
})
// 新建分组:创建目录(仅当父目录已存在,避免递归误建深层垃圾目录)
webServer.register({
kind: 'exact',
path: '/api/worktable/mkdir',
handler: async (req: any, res: any) => {
try {
if (req.method !== 'POST') { res.writeHead(405); res.end(); return }
const body = await readJsonBody(req)
const p = typeof body.path === 'string' ? body.path.trim() : ''
if (!p) { json(res, 400, { error: 'missing path' }); return }
const abs = pathResolve(p)
const fsx = await import('node:fs/promises')
const parent = abs.split(/[\\/]/).slice(0, -1).join('\\') || '\\'
try { await fsx.access(parent) } catch { json(res, 400, { error: 'parent not found' }); return }
await fsx.mkdir(abs)
json(res, 200, { ok: true, path: abs })
} catch (err: any) {
json(res, err?.code === 'EEXIST' ? 200 : 500, err?.code === 'EEXIST' ? { ok: true, exists: true } : { error: String(err) })
}
},
})
webServer.register({
kind: 'exact',
path: '/api/worktable/git',