feat(sidebar): replicate WorkBuddy task context menu (TODO 01)

Replace the sidebar delete button with the original task menu: pin/unpin,
rename, open in a separate window, open folder, archive and delete.

Source: REA ev_f5e9d71a676c453daab40928acfb33aeabae68ba57ffda342380cead7f1a1fe2
(WorkBuddy 5.8.3 task menu, open state) supplies the container/item geometry,
the two dividers, danger colour and every item icon.

Verified by scripts/verify-task-menu.cjs: 33 assertions in a real isolated
Electron app (real preload/IPC/filesystem), including menu geometry, anchor
offset, pin/rename/archive persistence, open-folder target and the separate
window query. typecheck, test:conversation and build pass.
This commit is contained in:
xiaohan.yuan committed 2026-10-17 04:43:56 +08:00
1 parent e365e830cf
commit e335588826
15 files changed
+821 -31

No files matched your search

+51 -1
View File
@@ -7,11 +7,13 @@
import { app, BrowserWindow, Menu, shell, ipcMain } from 'electron'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { disposeOmpKernels, registerIpc } from './ipc'
import { disposeOmpKernels, registerIpc, setTaskWindowFactory } from './ipc'
import { disposeMcpServers } from './capabilities/mcp'
let mainWindow: BrowserWindow | null = null
let settingsWindow: BrowserWindow | null = null
/** 任务独立窗口:同一会话只保留一个窗口,重复打开时聚焦已有窗口。 */
const taskWindows = new Map<string, BrowserWindow>()
let settingsSection = 'model'
let settingsAction: 'new' | undefined
let allowQuit = false
@@ -75,6 +77,53 @@ app.setName('YCode')
// Isolated desktop verification and portable installations can choose their own data directory.
if (process.env.YCODE_USER_DATA_DIR) app.setPath('userData', process.env.YCODE_USER_DATA_DIR)
/**
* 为指定任务打开独立窗口。
*
* 复用主窗口的 preload 与渲染入口,仅通过查询参数指定 `window=task&taskId=...`;
* 渲染进程据此直接选中该任务,不复制主窗口状态。
*/
function openTaskWindow(sessionId: string): void {
const existing = taskWindows.get(sessionId)
if (existing && !existing.isDestroyed()) {
if (existing.isMinimized()) existing.restore()
existing.show()
existing.focus()
return
}
const window = new BrowserWindow({
width: 1000,
height: 760,
minWidth: 480,
minHeight: 560,
show: false,
title: 'YCode 任务',
backgroundColor: '#ffffff',
frame: false,
webPreferences: {
preload: join(mainDirectory, '../preload/index.cjs'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
offscreen: process.env.YCODE_QA_OFFSCREEN === '1'
}
})
taskWindows.set(sessionId, window)
window.on('closed', () => {
if (taskWindows.get(sessionId) === window) taskWindows.delete(sessionId)
})
window.on('ready-to-show', () => window.show())
const query: Record<string, string> = { window: 'task', taskId: sessionId }
const devUrl = process.env.ELECTRON_RENDERER_URL
if (devUrl) {
const url = new URL(devUrl)
Object.entries(query).forEach(([key, value]) => url.searchParams.set(key, value))
void window.loadURL(url.toString())
} else {
void window.loadFile(join(mainDirectory, '../renderer/index.html'), { query })
}
}
app.on('before-quit', (event) => {
if (allowQuit) return
event.preventDefault()
@@ -113,6 +162,7 @@ if (process.env.YCODE_DISABLE_GPU === '1') {
app.whenReady().then(() => {
registerIpc(() => mainWindow)
setTaskWindowFactory((sessionId) => { openTaskWindow(sessionId) })
ipcMain.handle('app:openSettings', async (_event, section = 'model', action?: 'new') => {
settingsSection = section === 'models' ? 'model' : String(section)
settingsAction = action === 'new' ? 'new' : undefined
+24 -1
View File
@@ -188,6 +188,12 @@ async function removeOmpSessionFile(sessionId: string): Promise<void> {
}
}
/** 主进程入口注入:打开某任务的独立窗口。未注入时该菜单项给出诚实错误。 */
let taskWindowFactory: ((sessionId: string) => void) | null = null
export function setTaskWindowFactory(factory: ((sessionId: string) => void) | null): void {
taskWindowFactory = factory
}
export function registerIpc(getWindow: () => BrowserWindow | null): void {
void mcp.initializeMcpServers().catch(() => undefined)
ipcMain.handle('skills:list', (_event, cwd?: string) => skills.listSkills(cwd))
@@ -430,7 +436,9 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
})
// ---------- 会话 ----------
ipcMain.handle('session:list', () => sessions.listSessions())
// 默认列表排除已归档任务;归档视图另行取全量。
ipcMain.handle('session:list', () => sessions.listVisibleSessions())
ipcMain.handle('session:list-archived', () => sessions.listVisibleSessions(true))
ipcMain.handle('session:create', async (_event, title?: string, context: SessionContext = {}) => {
context = agentModeContextPatch({}, context)
const workspaceId = context.workspaceId ?? (await workspaces.listWorkspaces()).selectedWorkspaceId ?? undefined
@@ -466,6 +474,21 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
ipcMain.handle('session:rename', (_event, sessionId: string, title: string) =>
sessions.renameSession(sessionId, title)
)
ipcMain.handle('session:set-pinned', (_event, sessionId: string, pinned: boolean) =>
sessions.setSessionPinned(sessionId, pinned === true)
)
ipcMain.handle('session:set-archived', (_event, sessionId: string, archived: boolean) =>
sessions.setSessionArchived(sessionId, archived === true)
)
ipcMain.handle('session:open-folder', async (_event, sessionId: string) => {
const root = await sessionRoot(sessionId)
const error = await shell.openPath(root)
if (error) throw new Error(error)
})
ipcMain.handle('session:open-window', (_event, sessionId: string) => {
if (!taskWindowFactory) throw new Error('独立窗口尚不可用')
taskWindowFactory(sessionId)
})
ipcMain.handle('session:delete', async (_event, sessionId: string) => withChatSessionLock(sessionId, async () => {
const tasks = [...runTasks.entries()].filter(([, task]) => task.sessionId === sessionId)
for (const [runId] of tasks) activeRuns.get(runId)?.abort()
+34 -2
View File
@@ -93,8 +93,40 @@ async function persist(): Promise<void> {
export async function listSessions(): Promise<SessionMeta[]> {
const store = await load()
return [...store.sessions]
.sort((a, b) => b.updatedAt - a.updatedAt)
.map(({ id, title, createdAt, updatedAt, workspaceId, cwd, modelId, expertId, agentMode: mode, thinkingLevel: level }) => ({ id, title, createdAt, updatedAt, workspaceId, cwd, modelId, expertId, agentMode: agentMode(mode), thinkingLevel: level }))
// 置顶任务优先(最近置顶在前),其余按更新时间倒序。
.sort((a, b) => Number(b.pinnedAt ?? 0) - Number(a.pinnedAt ?? 0) || b.updatedAt - a.updatedAt)
.map(({ id, title, createdAt, updatedAt, workspaceId, cwd, modelId, expertId, agentMode: mode, thinkingLevel: level, pinnedAt, archivedAt }) => ({ id, title, createdAt, updatedAt, workspaceId, cwd, modelId, expertId, agentMode: agentMode(mode), thinkingLevel: level, pinnedAt, archivedAt }))
}
/** 侧栏可见任务:默认排除已归档;`includeArchived` 供归档视图使用。 */
export async function listVisibleSessions(includeArchived = false): Promise<SessionMeta[]> {
const sessions = await listSessions()
return includeArchived ? sessions : sessions.filter((session) => session.archivedAt === undefined)
}
export async function setSessionPinned(sessionId: string, pinned: boolean): Promise<SessionMeta> {
const store = await load()
const session = store.sessions.find((item) => item.id === sessionId)
if (!session) throw new Error('会话不存在')
const previous = session.pinnedAt
session.pinnedAt = pinned ? Date.now() : undefined
try { await persist() } catch (error) { session.pinnedAt = previous; throw error }
return meta(session)
}
export async function setSessionArchived(sessionId: string, archived: boolean): Promise<SessionMeta> {
const store = await load()
const session = store.sessions.find((item) => item.id === sessionId)
if (!session) throw new Error('会话不存在')
const previous = session.archivedAt
session.archivedAt = archived ? Date.now() : undefined
try { await persist() } catch (error) { session.archivedAt = previous; throw error }
return meta(session)
}
function meta(session: SessionRecord): SessionMeta {
const { id, title, createdAt, updatedAt, workspaceId, cwd, modelId, expertId, agentMode: mode, thinkingLevel: level, pinnedAt, archivedAt } = session
return { id, title, createdAt, updatedAt, workspaceId, cwd, modelId, expertId, agentMode: agentMode(mode), thinkingLevel: level, pinnedAt, archivedAt }
}
export async function createSession(title = '新对话', context: SessionContext = {}): Promise<SessionMeta> {