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:
1 parent
e365e830cf
commit
e335588826
15 files changed
+821
-31
No files matched your search
+51
-1
@@ -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
@@ -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
@@ -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> {
|
||||
|
||||
Reference in new issue
Block a user