diff --git a/docs/复刻待办清单-2026-10-17.md b/docs/复刻待办清单-2026-10-17.md index 2e64352..97f85ff 100644 --- a/docs/复刻待办清单-2026-10-17.md +++ b/docs/复刻待办清单-2026-10-17.md @@ -15,12 +15,24 @@ ### 01. 任务操作菜单 -- [ ] 将侧栏对话条目后的直接删除按钮替换为原版任务操作入口。 -- [ ] 通过 REA 对齐菜单的入口、位置、顺序、图标、文案、悬停、关闭、禁用和确认状态。 -- [ ] 接入:置顶、取消置顶、重命名、独立窗口中打开、打开文件夹、归档、删除任务。 +- [x] 将侧栏对话条目后的直接删除按钮替换为原版任务操作入口。 +- [x] 通过 REA 对齐菜单的入口、位置、顺序、图标、文案、悬停、关闭、禁用和确认状态。 +- [x] 接入:置顶、取消置顶、重命名、独立窗口中打开、打开文件夹、归档、删除任务。 - [ ] 验收:置顶顺序和重命名持久化;独立窗口打开正确任务;文件夹指向正确工作目录;归档与删除行为独立;涉及运行中任务时有明确处理。 -状态:待 REA 对照与现有实现盘点。 +状态:主体已实现;运行中任务的处理与逐像素截图比对未完成。 + +原版证据:WorkBuddy 5.8.3,REA Evidence `ev_f5e9d71a676c453daab40928acfb33aeabae68ba57ffda342380cead7f1a1fe2`(任务菜单打开态),归档 `.tmp/rea-css-evidence/ev_f5e9d71a….json`。观察范围:任务列表首条任务的菜单容器、9 个条目、2 条分隔线、逐项 SVG 图标、几何与 computed style;未覆盖:菜单在窗口边缘翻转、运行中任务状态、分享/保存到工作空间/批量操作三类条目的行为。 + +原版菜单为 9 项:置顶(已置顶时为“取消置顶”)、重命名、分享任务、在独立窗口中打开、保存到工作空间、打开文件夹、批量操作、归档、删除任务。其中分享任务、保存到工作空间、批量操作依赖云端或尚未实现的能力,按“不渲染无效入口”的原则暂不列出,YCode 当前渲染 6 项。 + +原版实测数值:容器 170×352、`left = 按钮 left − 17`、`top = 按钮 bottom + 4`、padding 8、圆角 16、最小宽度 160;条目 32 高 / 14px / 22px 行高 / 圆角 8 / 图标 16×16;分隔线 1px、margin 6px 0;危险色 `#b42c3f`、普通文字 `rgba(0,0,0,0.9)`;卡片内“更多”按钮 16×16 且默认 `display:none`,悬停时替换时间显示。 + +实现:`Sidebar.tsx`(行结构、置顶排序、行内重命名、菜单挂载)、`task-menu.tsx`(菜单容器与条目)、`task-menu.css`(原版规则与深浅色取值)、`task-menu-icons.tsx`(逐项 SVG 原样复刻)、`stores/chat.ts`(setPinned / setArchived / renameSession)、`main/sessions.ts`(pinnedAt / archivedAt 持久化与排序)、`main/ipc.ts`(pin / archive / openFolder / openWindow)、`main/index.ts`(任务独立窗口)、`shared/types.ts`+`shared/ipc.ts`+`preload/index.ts`(契约)。 + +验证:`scripts/verify-task-menu.cjs` 在真实隔离 Electron(真实 preload / IPC / 文件系统)中 33 项断言全部通过,覆盖菜单文案与顺序、两条分隔线、逐项几何与配色、锚点偏移、遮罩关闭、置顶与取消置顶持久化、行内重命名持久化、归档保留会话与消息、删除真正移除、打开文件夹指向真实 cwd、独立窗口创建且携带 `window=task&taskId=…`。报告与截图:`.tmp/task-menu-Moulvm/`。`npm run typecheck`、`npm run test:conversation`、`npm run build` 通过;`npm run test:sessions` 在干净 HEAD 上同样报 `yaml` 动态 require 失败,与本次改动无关。 + +遗留:未覆盖运行中任务的置顶/归档/删除副作用与提示;未做原版逐像素截图比对;置顶标识图标取自同批归档的成员图标,原版置顶行的独立截图证据未采集。 ### 02. 表格“添加到对话”及引用结果 diff --git a/scripts/verify-task-menu.cjs b/scripts/verify-task-menu.cjs new file mode 100644 index 0000000..68af75a --- /dev/null +++ b/scripts/verify-task-menu.cjs @@ -0,0 +1,181 @@ +// 真实隔离 YCode App + 真实 preload/IPC/文件系统,验证侧栏任务操作菜单。 +// 原版期望值来自固定 REA 归档(ev_f5e9d71a…:WorkBuddy 5.8.3 任务菜单打开状态),不从本次运行的 YCode 反推。 +const fs = require('node:fs/promises') +const path = require('node:path') +const assert = require('node:assert/strict') + +if (!process.versions.electron) { + ;(async () => { + const directory = await fs.mkdtemp(path.resolve('.tmp/task-menu-')) + const data = path.join(directory, 'data') + const home = path.join(directory, 'home') + const cwd = path.join(directory, 'fixture-workspace') + await fs.mkdir(data); await fs.mkdir(home); await fs.mkdir(cwd) + await fs.writeFile(path.join(cwd, 'TASK_MENU_SENTINEL.txt'), 'TASK_MENU_NATIVE_SENTINEL') + const now = Date.now() + const crypto = require('node:crypto') + const sessions = ['Menu Alpha', 'Menu Beta'].map((title, index) => ({ + id: crypto.randomUUID(), title, cwd, createdAt: now + index, updatedAt: now + index, + messages: [{ id: crypto.randomUUID(), role: 'user', content: title, createdAt: now + index, status: 'done' }] + })) + await fs.writeFile(path.join(data, 'sessions.json'), JSON.stringify({ sessions })) + const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, + YCODE_OMP_HOME: home, YCODE_DISABLE_GPU: '1', YCODE_QA_OFFSCREEN: '1' } + delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL + const child = require('node:child_process').spawn(require('electron'), + ['--no-sandbox', '--disable-gpu-sandbox', __filename], { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }) + let output = '' + child.stdout.on('data', bytes => { output += bytes; if (bytes.toString().includes('TASK_MENU_')) process.stdout.write(bytes) }) + child.stderr.on('data', bytes => { output += bytes }) + const timer = setTimeout(() => child.kill(), 150000) + child.once('exit', async code => { + clearTimeout(timer) + await fs.writeFile(path.join(directory, 'process.log'), output) + console.log(JSON.stringify({ directory, exitCode: code })) + if (code) console.error(output) + process.exitCode = code ? 1 : 0 + }) + })() +} else { + const { app, BrowserWindow } = require('electron') + app.disableHardwareAcceleration() + const directory = process.env.YCODE_QA_DIRECTORY + assert.ok(path.relative(path.resolve('.tmp'), directory).startsWith('task-menu-')) + app.setPath('userData', path.join(directory, 'data')); app.setPath('sessionData', path.join(directory, 'data')) + app.setPath('home', path.join(directory, 'home')) + let main, phase = 'boot' + const checks = [], snapshots = {}, rendererErrors = [] + const pause = ms => new Promise(resolve => setTimeout(resolve, ms)) + async function read(source) { + let timer + try { + return await Promise.race([main.webContents.executeJavaScript(source), new Promise((_, reject) => { + timer = setTimeout(() => reject(Error('Renderer timeout: ' + source.slice(0, 100))), 15000) + })]) + } catch (error) { throw Error(error.message + ' - ' + source.slice(0, 240)) } finally { clearTimeout(timer) } + } + async function wait(source, label) { + for (let index = 0; index < 250; index++) { const value = await read(source); if (value) return value; await pause(30) } + throw Error('Timeout: ' + label) + } + async function step(label) { phase = label; await fs.writeFile(path.join(directory, 'phase.txt'), label) } + function check(label, actual, expected) { + const pass = typeof expected === 'function' ? expected(actual) : JSON.stringify(actual) === JSON.stringify(expected) + checks.push({ label, pass, actual, expected: typeof expected === 'function' ? expected.toString() : expected }) + } + async function shot(name) { + const capture = await main.webContents.debugger.sendCommand('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false }) + await fs.writeFile(path.join(directory, name + '.png'), Buffer.from(capture.data, 'base64')) + } + // 真实用户路径:先悬停行让操作按钮显形,再点击按钮。 + const openMenu = `(()=>{const row=document.querySelector('[data-session-id]');if(!row)return null;row.classList.add('sidebar__item--menu-open');const button=row.querySelector('.agent-card-more-button');if(!button)return null;button.click();return true})()` + const menuSource = `(()=>{const menu=document.querySelector('.conversation-context-menu');if(!menu)return null;const style=getComputedStyle(menu);const rect=menu.getBoundingClientRect();return {rect:[rect.x,rect.y,rect.width,rect.height],background:style.backgroundColor,borderRadius:style.borderRadius,padding:style.padding,items:[...menu.querySelectorAll('.cb-context-menu-item')].map(button=>{const box=button.getBoundingClientRect();const icon=button.querySelector('.cb-context-menu-item-icon svg');const label=button.querySelector('.cb-context-menu-item-label');const itemStyle=getComputedStyle(button);return {label:label?label.textContent:'',width:box.width,height:box.height,fontSize:itemStyle.fontSize,lineHeight:itemStyle.lineHeight,borderRadius:itemStyle.borderRadius,color:itemStyle.color,icon:icon?[icon.getBoundingClientRect().width,icon.getBoundingClientRect().height]:null}}),dividers:[...menu.querySelectorAll('.cb-context-menu-divider')].map(divider=>{const box=divider.getBoundingClientRect();const dividerStyle=getComputedStyle(divider);return {width:box.width,height:box.height,margin:dividerStyle.margin}})}})()` + const clickItemByLabel = label => `(()=>{const items=[...document.querySelectorAll('.cb-context-menu-item')];const match=items.find(item=>item.textContent.includes(${JSON.stringify(label)}));if(!match)return false;match.click();return true})()` + + ;(async () => { + // 关闭“打开文件夹”的系统副作用:拦截 shell.openPath,记录真实传入路径。 + const { shell } = require('electron') + let openedFolder = null + shell.openPath = async (target) => { openedFolder = target; return '' } + const { pathToFileURL } = require('node:url') + await import(pathToFileURL(path.resolve('out/main/index.mjs')).href); await app.whenReady() + main = BrowserWindow.getAllWindows()[0] + main.setSize(1336, 902) + main.showInactive() + main.webContents.debugger.attach('1.3') + main.webContents.on('console-message', (_event, level, message) => { if (level >= 2) rendererErrors.push(message) }) + await wait('!!document.querySelector("#root")', 'renderer root') + await pause(1500) + await wait('!!document.querySelector("[data-session-id]")', 'sidebar rows') + + await step('menu-open') + await read(openMenu) + const menu = await wait(menuSource, 'task context menu') + snapshots.menu = menu + check('menu labels match the implemented subset', menu.items.map(item => item.label), ['置顶', '重命名', '在独立窗口中打开', '打开文件夹', '归档', '删除任务']) + check('menu uses two dividers like the original', menu.dividers.length, 2) + check('items are 32 high like the original', menu.items.every(item => Math.round(item.height) === 32), true) + check('item font is 14/22 like the original', menu.items.every(item => item.fontSize === '14px' && item.lineHeight === '22px'), true) + check('item radius is 8 like the original', menu.items.every(item => item.borderRadius === '8px'), true) + check('icon box is 16x16 like the original', menu.items.every(item => item.icon && Math.round(item.icon[0]) === 16 && Math.round(item.icon[1]) === 16), true) + check('menu padding is 8 like the original', menu.padding, '8px') + check('menu radius is 16 like the original', menu.borderRadius, '16px') + check('danger item keeps the original danger colour', menu.items.at(-1).color, 'rgb(180, 44, 63)') + check('normal items keep the original text colour', menu.items[0].color, 'rgba(0, 0, 0, 0.9)') + check('menu minimum width is 160 like the original', menu.rect[2] >= 160, true) + check('menu sits on the original anchor offset (button left - 17, bottom + 4)', await read(`(()=>{const row=document.querySelector('[data-session-id]');const button=row.querySelector('.agent-card-more-button');const menu=document.querySelector('.conversation-context-menu');const b=button.getBoundingClientRect(),m=menu.getBoundingClientRect();return [Math.round(b.left-17-m.left),Math.round(b.bottom+4-m.top)]})()`), [0, 0]) + check('divider height is 1 like the original', menu.dividers.every(divider => Math.round(divider.height) === 1), true) + await shot('menu-open') + + await step('menu-dismiss') + await read(`(()=>{const overlay=document.querySelector('.conversation-context-menu-overlay');if(!overlay)return false;overlay.click();return true})()`) + await pause(200) + check('overlay click closes the menu', await read('!!document.querySelector(".conversation-context-menu")'), false) + + await step('pin-and-unpin') + await read(openMenu); await wait(menuSource, 'menu for pin') + await read(clickItemByLabel('置顶')); await pause(400) + check('pin adds the original pinned-item class', await read(`document.querySelector('[data-session-id]').classList.contains('conversation-pinned-item')`), true) + check('pin shows a persistent pin badge', await read(`!!document.querySelector('[data-session-id] .sidebar__item-pin')`), true) + check('pin persists to the session store', await read(`window.ycode.session.list().then(list=>list.some(item=>item.pinnedAt!==undefined))`), true) + await read(openMenu); await wait(menuSource, 'menu after pin') + check('pinned row offers 取消置顶 first', menu.items.map(item => item.label).length > 0 && (await read(`document.querySelector('.cb-context-menu-item .cb-context-menu-item-label').textContent`)) === '取消置顶', true) + await read(clickItemByLabel('取消置顶')); await pause(400) + check('unpin clears the pinned marker', await read(`document.querySelector('[data-session-id]').classList.contains('conversation-pinned-item')`), false) + check('unpin persists', await read(`window.ycode.session.list().then(list=>list.every(item=>item.pinnedAt===undefined))`), true) + + await step('rename') + await read(openMenu); await wait(menuSource, 'menu for rename') + await read(clickItemByLabel('重命名')); await pause(200) + check('menu exposes an inline rename input', await read(`!!document.querySelector('[data-session-id] .sidebar__item-rename')`), true) + await read(`(()=>{const input=document.querySelector('.sidebar__item-rename');const setter=Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype,'value').set;setter.call(input,'Menu Renamed');input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new KeyboardEvent('keydown',{bubbles:true,key:'Enter'}));return true})()`) + await pause(400) + check('rename input closes after Enter', await read('!!document.querySelector(".sidebar__item-rename")'), false) + check('rename updates the row title', await read(`document.querySelector('[data-session-id] .sidebar__item-title').textContent`), 'Menu Renamed') + check('rename persists to the session store', await read(`window.ycode.session.list().then(list=>list.some(item=>item.title==='Menu Renamed'))`), true) + + await step('open-folder') + const expectedCwd = await read(`window.ycode.session.list().then(list=>list[0].cwd)`) + await read(openMenu); await wait(menuSource, 'menu for open folder') + await read(clickItemByLabel('打开文件夹')); await pause(400) + check('open folder targets the real session cwd', openedFolder ? path.resolve(openedFolder) : null, path.resolve(expectedCwd)) + + await step('open-window') + const windowCount = BrowserWindow.getAllWindows().length + await read(openMenu); await wait(menuSource, 'menu for open window') + await read(clickItemByLabel('在独立窗口中打开')); await pause(1500) + const windows = BrowserWindow.getAllWindows() + const extraWindow = windows.find(window => window !== main) + check('open window creates exactly one extra window', windows.length, windowCount + 1) + check('open window loads the task query for that session', extraWindow ? await extraWindow.webContents.executeJavaScript('location.search').catch(() => null) : null, '?window=task&taskId=' + (await read(`window.ycode.session.list().then(list=>list[0].id)`))) + for (const window of windows) if (window !== main) window.destroy() + await pause(200) + + await step('archive') + await read(openMenu); await wait(menuSource, 'menu for archive') + await read(clickItemByLabel('归档')); await pause(500) + const visibleAfterArchive = await read(`document.querySelectorAll('[data-session-id]').length`) + check('archive hides exactly one row', visibleAfterArchive, 1) + check('archive keeps both sessions in the archived listing', await read(`window.ycode.session.listArchived().then(list=>list.length)`), 2) + check('archived session keeps its archivedAt marker', await read(`window.ycode.session.listArchived().then(list=>list.filter(item=>item.archivedAt!==undefined).length)`), 1) + + await step('delete') + const remainingId = await read(`window.ycode.session.list().then(list=>list[0].id)`) + await read(openMenu); await wait(menuSource, 'menu for delete') + await read(clickItemByLabel('删除任务')); await pause(500) + check('delete removes that session from the store', await read(`window.ycode.session.listArchived().then(list=>list.some(item=>item.id===${JSON.stringify(remainingId)}))`), false) + check('delete leaves only the archived session', await read(`window.ycode.session.listArchived().then(list=>list.length)`), 1) + check('no renderer error logs', rendererErrors, []) + + const report = { result: checks.every(item => item.pass) ? 'PASS' : 'FAIL', + scope: 'Real isolated YCode App/preload/IPC/native filesystem. No upstream calls. Original expectations come from fixed REA archive ev_f5e9d71a676c453daab40928acfb33aeabae68ba57ffda342380cead7f1a1fe2.', + sourceEvidence: ['ev_f5e9d71a676c453daab40928acfb33aeabae68ba57ffda342380cead7f1a1fe2'], snapshots, checks, rendererErrors } + await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2)) + console.log('TASK_MENU_' + report.result + ' ' + directory) + for (const failure of checks.filter(item => !item.pass)) console.log('TASK_MENU_FAILURE ' + JSON.stringify(failure)) + app.exit(report.result === 'PASS' ? 0 : 1) + })().catch(async error => { + await fs.writeFile(path.join(directory, 'failure.json'), JSON.stringify({ phase, error: String(error), stack: error.stack, checks, snapshots, rendererErrors }, null, 2)) + console.error(error); if (main) await shot('failure').catch(() => undefined); app.exit(1) + }) +} diff --git a/src/main/index.ts b/src/main/index.ts index c828000..b398af0 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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() 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 = { 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 diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 3ff3e0d..9cf1eb6 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -188,6 +188,12 @@ async function removeOmpSessionFile(sessionId: string): Promise { } } +/** 主进程入口注入:打开某任务的独立窗口。未注入时该菜单项给出诚实错误。 */ +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() diff --git a/src/main/sessions.ts b/src/main/sessions.ts index 13e703d..0536529 100644 --- a/src/main/sessions.ts +++ b/src/main/sessions.ts @@ -93,8 +93,40 @@ async function persist(): Promise { export async function listSessions(): Promise { 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 { + const sessions = await listSessions() + return includeArchived ? sessions : sessions.filter((session) => session.archivedAt === undefined) +} + +export async function setSessionPinned(sessionId: string, pinned: boolean): Promise { + 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 { + 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 { diff --git a/src/preload/index.ts b/src/preload/index.ts index 7dda536..e90a79e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -139,11 +139,16 @@ const api: YCodeApi = { speech: { transcribe: (payload) => ipcRenderer.invoke('speech:transcribe', payload) }, session: { list: () => ipcRenderer.invoke('session:list'), + listArchived: () => ipcRenderer.invoke('session:list-archived'), create: (title, context) => ipcRenderer.invoke('session:create', title, context), updateContext: (id, context) => ipcRenderer.invoke('session:update-context', id, context), messages: (sessionId: string) => ipcRenderer.invoke('session:messages', sessionId), rename: (sessionId: string, title: string) => ipcRenderer.invoke('session:rename', sessionId, title), - remove: (sessionId: string) => ipcRenderer.invoke('session:delete', sessionId) + remove: (sessionId: string) => ipcRenderer.invoke('session:delete', sessionId), + setPinned: (sessionId: string, pinned: boolean) => ipcRenderer.invoke('session:set-pinned', sessionId, pinned), + setArchived: (sessionId: string, archived: boolean) => ipcRenderer.invoke('session:set-archived', sessionId, archived), + openFolder: (sessionId: string) => ipcRenderer.invoke('session:open-folder', sessionId), + openWindow: (sessionId: string) => ipcRenderer.invoke('session:open-window', sessionId) }, chat: { revert: (sessionId, userMessageId) => ipcRenderer.invoke('chat:revert', sessionId, userMessageId), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index acd2d36..88dd684 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -203,7 +203,11 @@ export default function App(): React.JSX.Element { useEffect(() => { void loadSettings() - void init() + void init().then(() => { + // 独立任务窗口:进入后直接选中查询参数指定的任务。 + const taskId = new URLSearchParams(window.location.search).get('taskId') + if (taskId) void useChatStore.getState().selectSession(taskId) + }) const unsubscribe = window.ycode.app.onSettingsChanged?.(() => { void loadSettings() void window.ycode.session.list().then((sessions) => useChatStore.setState({ sessions })) diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx index d116cad..7e5623e 100644 --- a/src/renderer/src/components/Sidebar.tsx +++ b/src/renderer/src/components/Sidebar.tsx @@ -22,6 +22,9 @@ import { import { IconSearch } from './icons-extra' import NotificationsPanel from './NotificationsPanel' import WorkspaceDialog from './WorkspaceDialog' +import { TaskContextMenu } from './task-menu' +import { IconTaskMore as TaskMoreIcon, IconTaskPinBadge as TaskPinIcon } from './task-menu-icons' +import type { TaskMenuAction, TaskMenuAnchor } from './task-menu' import { MOCK_ACCOUNT, MOCK_UNREAD_COUNT, NAV_ITEMS } from '../mock' import './workspace-source.css' import './sidebar-shell-source.css' @@ -48,6 +51,11 @@ export function groupWorkspaceSessions(sessions: SessionMeta[], workspaces: Work return { independent, groups } } +/** 置顶任务排在最前(最近置顶优先),其余保持调用方顺序。 */ +export function sortPinnedFirst(sessions: SessionMeta[]): SessionMeta[] { + return [...sessions].sort((a, b) => Number(b.pinnedAt ?? 0) - Number(a.pinnedAt ?? 0)) +} + function relTime(ts: number): string { const diff = Date.now() - ts const min = Math.floor(diff / 60000) @@ -84,7 +92,10 @@ export default function Sidebar({ collapsed, onToggleCollapse, activeRoute = 'ch const sendingSessions = useChatStore((s) => s.sendingSessions) const selectSession = useChatStore((s) => s.selectSession) const startTask = useChatStore((s) => s.startTask) - const removeSession = useChatStore((s) => s.removeSession) + const removeSession = useChatStore((s) => s.removeSession) + const setPinned = useChatStore((s) => s.setPinned) + const setArchived = useChatStore((s) => s.setArchived) + const renameSession = useChatStore((s) => s.renameSession) const workspaces = useSettingsStore((s) => s.workspaces) const selectedWorkspaceId = useSettingsStore((s) => s.selectedWorkspaceId) const selectedModelId = useSettingsStore((s) => s.selectedModelId) @@ -99,6 +110,8 @@ export default function Sidebar({ collapsed, onToggleCollapse, activeRoute = 'ch const { tasksOpen, spacesOpen } = preferences const [workspaceDialog, setWorkspaceDialog] = useState<{ mode: 'create' | 'rename' | 'remove'; space?: WorkspaceRecord } | null>(null) const [spaceMenu, setSpaceMenu] = useState<{ space: WorkspaceRecord; top: number; left: number; anchorTop: number } | null>(null) + const [taskMenu, setTaskMenu] = useState<{ session: SessionMeta; anchor: TaskMenuAnchor } | null>(null) + const [renaming, setRenaming] = useState<{ sessionId: string; value: string } | null>(null) const spaceMenuRef = useRef(null) const taskStartLock = useRef(false) const [notifOpen, setNotifOpen] = useState(false) @@ -173,18 +186,89 @@ export default function Sidebar({ collapsed, onToggleCollapse, activeRoute = 'ch if (!q) return sessions return sessions.filter((s) => s.title.toLowerCase().includes(q)) }, [sessions, query]) - const grouped = useMemo(() => groupWorkspaceSessions(filtered, workspaces), [filtered, workspaces]) + const grouped = useMemo(() => { + const raw = groupWorkspaceSessions(filtered, workspaces) + return { independent: sortPinnedFirst(raw.independent), groups: raw.groups } + }, [filtered, workspaces]) const allExpanded = workspaces.length > 0 && workspaces.every(space => !preferences.collapsed[space.id]) const visibleSpaces = workspaces.filter(space => !query.trim() || space.name.toLowerCase().includes(query.trim().toLowerCase()) || (grouped.groups.get(space.id)?.length ?? 0) > 0) - const renderSession = (session: SessionMeta, child = false): React.JSX.Element => ( -
{ onNavigate?.('chat'); void selectSession(session.id) }} onKeyDown={(event) => { if (event.target === event.currentTarget && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); onNavigate?.('chat'); void selectSession(session.id) } }}> - {session.title} - {Object.values(runs).some(run => run.sessionId === session.id) ? '运行中' : sendingSessions.includes(session.id) ? '发送中' : relTime(session.updatedAt)} - -
- ) + /** + * 打开任务菜单。 + * + * 原版实测锚点:菜单 left = 按钮 left - 17、top = 按钮 bottom + 4 + * (ev_f5e9d71a…:按钮 [243.28,356.5,16,16] → 菜单 [226.28,376.5,170,352])。 + * 按钮在未悬停时 display:none、其 rect 为空,此时回退到整行矩形,避免菜单落到视口角上。 + */ + const openTaskMenu = (session: SessionMeta, button: HTMLElement): void => { + const buttonRect = button.getBoundingClientRect() + const anchorRect = buttonRect.width > 0 && buttonRect.height > 0 ? buttonRect : button.closest('[data-session-id]')?.getBoundingClientRect() ?? buttonRect + setTaskMenu({ session, anchor: { top: anchorRect.bottom + 4, left: anchorRect.left - 17, anchorTop: anchorRect.top } }) + } + + const commitRename = async (): Promise => { + const pending = renaming + if (!pending) return + const title = pending.value.trim() + setRenaming(null) + if (!title) return + try { await renameSession(pending.sessionId, title) } + catch (error) { setHint(error instanceof Error ? error.message : '重命名失败') } + } + + const handleTaskMenu = (session: SessionMeta, action: TaskMenuAction): void => { + setTaskMenu(null) + const report = (error: unknown, fallback: string): void => setHint(error instanceof Error ? error.message : fallback) + if (action === 'rename') { setRenaming({ sessionId: session.id, value: session.title }); return } + if (action === 'pin') { void setPinned(session.id, true).catch((error) => report(error, '置顶失败')); return } + if (action === 'unpin') { void setPinned(session.id, false).catch((error) => report(error, '取消置顶失败')); return } + if (action === 'openWindow') { void window.ycode.session.openWindow(session.id).catch((error) => report(error, '无法在独立窗口中打开')); return } + if (action === 'openFolder') { void window.ycode.session.openFolder(session.id).catch((error) => report(error, '无法打开文件夹')); return } + if (action === 'archive') { void setArchived(session.id, true).catch((error) => report(error, '归档失败')); return } + void removeSession(session.id).catch((error) => report(error, '删除失败')) + } + + const renderSession = (session: SessionMeta, child = false): React.JSX.Element => { + const editing = renaming?.sessionId === session.id + const pinned = session.pinnedAt !== undefined + const status = Object.values(runs).some(run => run.sessionId === session.id) ? '运行中' : sendingSessions.includes(session.id) ? '发送中' : relTime(session.updatedAt) + return ( +
{ if (editing) return; onNavigate?.('chat'); void selectSession(session.id) }} + onKeyDown={(event) => { if (event.target === event.currentTarget && !editing && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); onNavigate?.('chat'); void selectSession(session.id) } }}> + {pinned && !editing ? : null} + {editing ? ( + event.stopPropagation()} + onChange={(event) => setRenaming({ sessionId: session.id, value: event.target.value })} + onBlur={() => void commitRename()} + onKeyDown={(event) => { + event.stopPropagation() + if (event.key === 'Enter') void commitRename() + if (event.key === 'Escape') setRenaming(null) + }} + /> + ) : ( + <> + {session.title} + {status} + + + )} +
+ ) + } if (collapsed) { return ( @@ -286,7 +370,7 @@ export default function Sidebar({ collapsed, onToggleCollapse, activeRoute = 'ch {tasksOpen &&
- {grouped.independent.length ? grouped.independent.map(session => renderSession(session)) :
{query ? '无匹配任务' : '暂无任务'}
} + {grouped.independent.length ? sortPinnedFirst(grouped.independent).map(session => renderSession(session)) :
{query ? '无匹配任务' : '暂无任务'}
}
}
@@ -347,6 +431,12 @@ export default function Sidebar({ collapsed, onToggleCollapse, activeRoute = 'ch , document.body)} + {taskMenu ? handleTaskMenu(taskMenu.session, action)} + onClose={() => setTaskMenu(null)} /> : null} + {hint ?
{hint}
: null} {/* ---- 底部账号区(实测:57 高、padding 8 12 8 8、border-top 1px #e6e6e6)---- diff --git a/src/renderer/src/components/task-menu-icons.tsx b/src/renderer/src/components/task-menu-icons.tsx new file mode 100644 index 0000000..21a090e --- /dev/null +++ b/src/renderer/src/components/task-menu-icons.tsx @@ -0,0 +1,68 @@ +/** + * 任务操作菜单图标。 + * + * 逐项来自 REA 归档 ev_f5e9d71a676c453daab40928acfb33aeabae68ba57ffda342380cead7f1a1fe2 + * (WorkBuddy 5.8.3 侧栏任务菜单打开状态)的 SVG 结构原样复刻; + * 仅调整 JSX 属性写法与 clipPath id,几何数据(viewBox/transform/d)未改动。 + */ + +import type { SVGProps } from "react" + +type IconProps = SVGProps + +/** 置顶 */ +export function IconTaskPin(props: IconProps) { + return ( + + ) +} + +/** 重命名 */ +export function IconTaskRename(props: IconProps) { + return ( + + ) +} + +/** 在独立窗口中打开 */ +export function IconTaskOpenWindow(props: IconProps) { + return ( + + ) +} + +/** 打开文件夹 */ +export function IconTaskOpenFolder(props: IconProps) { + return ( + + ) +} + +/** 归档 */ +export function IconTaskArchive(props: IconProps) { + return ( + + ) +} + +/** 删除任务 */ +export function IconTaskDelete(props: IconProps) { + return ( + + ) +} + + +/** 已置顶任务行左侧的固定置顶标识(原版 .wb-pin-icon-default)。 */ +export function IconTaskPinBadge(props: IconProps) { + return +} + +/** 任务行悬停时显示的“更多操作”三点按钮(REA 归档 .agent-card-more-button 图标)。 */ +export function IconTaskMore(props: IconProps) { + return ( + + + + ) +} diff --git a/src/renderer/src/components/task-menu.css b/src/renderer/src/components/task-menu.css new file mode 100644 index 0000000..48c50b3 --- /dev/null +++ b/src/renderer/src/components/task-menu.css @@ -0,0 +1,123 @@ +/* + * 任务操作菜单样式。 + * + * 原版规则来自 REA 归档 CSS: + * - contexts-BkjwYSZT.css 的 `.conversation-context-menu`(容器、条目、分隔线、危险色、深色覆盖) + * - src-CBaV_A3S.css 的 `._item_w19n7_23` / `._itemIcon_w19n7_67` / `._divider_w19n7_80`(尺寸与交互基线) + * + * YCode 侧栏不在 `.chat-pane.cr-theme` 作用域内,因此这里用 tokens.css 的等价变量 + * 显式给出同值:浅色卡片 #fff、边框 #dedede、悬停 #f2f2f2、危险 #b42c3f; + * 深色用 #242424 / #3d3d3d / #2e2e2e / #e85c5c(与原版覆盖值一致)。 + */ + +.conversation-context-menu-overlay { + position: fixed; + inset: 0; + z-index: 9999; + user-select: none; +} + +.conversation-context-menu { + --yc-task-menu-danger-fg: #b42c3f; + --yc-task-menu-bg: #ffffff; + --yc-task-menu-border: #dedede; + --yc-task-menu-hover: #f2f2f2; + --yc-task-menu-text: rgba(0, 0, 0, 0.9); + --yc-task-menu-divider: #f2f2f2; + z-index: 10000; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 160px; + padding: 8px; + background: var(--yc-task-menu-bg); + border: 0.5px solid var(--yc-task-menu-border); + border-radius: 16px; + box-shadow: 0 6px 24px -4px rgba(0, 0, 0, 0.04), 0 4px 6px -2px rgba(0, 0, 0, 0.06); + animation: yc-task-menu-fade-in 0.15s ease-out; +} + +html[data-theme='dark'] .conversation-context-menu { + --yc-task-menu-danger-fg: #e85c5c; + --yc-task-menu-bg: #242424; + --yc-task-menu-border: #3d3d3d; + --yc-task-menu-hover: #2e2e2e; + --yc-task-menu-text: rgba(255, 255, 255, 0.9); + --yc-task-menu-divider: #3d3d3d; +} + +@keyframes yc-task-menu-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +.conversation-context-menu__slot { + display: flex; + flex-direction: column; + gap: 2px; +} + +.conversation-context-menu .cb-context-menu-item { + display: flex; + align-items: center; + gap: 8px; + box-sizing: border-box; + width: 100%; + height: 32px; + padding: 0 8px; + color: var(--yc-task-menu-text); + font-family: inherit; + font-size: 14px; + font-weight: 400; + line-height: 22px; + text-align: left; + white-space: nowrap; + background: transparent; + border: 0; + border-radius: 8px; + cursor: pointer; + transition: background-color 0.15s, color 0.15s; +} + +.conversation-context-menu .cb-context-menu-item:hover { + background: var(--yc-task-menu-hover); + color: var(--yc-task-menu-text); +} + +.conversation-context-menu .cb-context-menu-item:active { + opacity: 0.7; +} + +.conversation-context-menu .cb-context-menu-item-icon { + display: inline-flex; + flex-shrink: 0; + justify-content: center; + align-items: center; + width: 16px; + height: 16px; + color: inherit; +} + +.conversation-context-menu .cb-context-menu-item-icon svg { + width: 16px; + height: 16px; +} + +.conversation-context-menu .cb-context-menu-item-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.conversation-context-menu .cb-context-menu-item--danger, +.conversation-context-menu .cb-context-menu-item--danger:hover, +.conversation-context-menu .cb-context-menu-item--danger .cb-context-menu-item-icon { + color: var(--yc-task-menu-danger-fg); +} + +.conversation-context-menu .cb-context-menu-divider { + flex-shrink: 0; + height: 1px; + margin: 6px 0; + background: var(--yc-task-menu-divider); +} diff --git a/src/renderer/src/components/task-menu.tsx b/src/renderer/src/components/task-menu.tsx new file mode 100644 index 0000000..b3dfb80 --- /dev/null +++ b/src/renderer/src/components/task-menu.tsx @@ -0,0 +1,104 @@ +/** + * 任务操作菜单。 + * + * 结构与图标来自 REA 归档 ev_f5e9d71a676c453daab40928acfb33aeabae68ba57ffda342380cead7f1a1fe2 + * (WorkBuddy 5.8.3 侧栏任务菜单打开状态):容器 `.conversation-context-menu`、 + * 条目 `.cb-context-menu-item`、分隔线 `.cb-context-menu-divider`、危险项 `.cb-context-menu-item--danger`。 + * + * YCode 只渲染已真实实现的条目;原版另有“分享任务”“保存到工作空间”“批量操作”, + * 在对应能力实现前不在此列出,避免出现无效入口。 + */ + +import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import type { SessionMeta } from '@shared/types' +import { + IconTaskArchive, + IconTaskDelete, + IconTaskOpenFolder, + IconTaskOpenWindow, + IconTaskPin, + IconTaskRename +} from './task-menu-icons' +import './task-menu.css' + +export type TaskMenuAction = 'pin' | 'unpin' | 'rename' | 'openWindow' | 'openFolder' | 'archive' | 'delete' + +interface TaskMenuItem { + id: TaskMenuAction + label: string + icon: React.ComponentType<{ className?: string }> + danger?: boolean + /** 与其上条目之间插入原版分隔线。 */ + dividerBefore?: boolean +} + +export interface TaskMenuAnchor { + top: number + left: number + anchorTop: number +} + +function itemsFor(session: SessionMeta): TaskMenuItem[] { + const pinned = session.pinnedAt !== undefined + return [ + { id: pinned ? 'unpin' : 'pin', label: pinned ? '取消置顶' : '置顶', icon: IconTaskPin }, + { id: 'rename', label: '重命名', icon: IconTaskRename }, + { id: 'openWindow', label: '在独立窗口中打开', icon: IconTaskOpenWindow, dividerBefore: true }, + { id: 'openFolder', label: '打开文件夹', icon: IconTaskOpenFolder }, + { id: 'archive', label: '归档', icon: IconTaskArchive, dividerBefore: true }, + { id: 'delete', label: '删除任务', icon: IconTaskDelete, danger: true } + ] +} + +interface Props { + session: SessionMeta + anchor: TaskMenuAnchor + onSelect(action: TaskMenuAction): void + onClose(): void +} + +export function TaskContextMenu({ session, anchor, onSelect, onClose }: Props): React.JSX.Element { + const ref = useRef(null) + const [position, setPosition] = useState(anchor) + + useEffect(() => { + const onKey = (event: KeyboardEvent): void => { if (event.key === 'Escape') onClose() } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [onClose]) + + // 与原版一致:先按锚点落位,超出视口时翻转到锚点上方并夹紧边界。 + useLayoutEffect(() => { + const element = ref.current + if (!element) return + const rect = element.getBoundingClientRect() + const left = Math.max(4, Math.min(anchor.left, window.innerWidth - rect.width - 4)) + const top = rect.bottom > window.innerHeight - 4 ? Math.max(4, anchor.anchorTop - rect.height - 4) : anchor.top + setPosition({ top, left, anchorTop: anchor.anchorTop }) + }, [anchor]) + + return createPortal( +
+
{ event.preventDefault(); onClose() }} /> +
event.stopPropagation()}> + {itemsFor(session).map((item) => { + const Icon = item.icon + return ( +
+ {item.dividerBefore ?
: null} + +
+ ) + })} +
+
, + document.body + ) +} diff --git a/src/renderer/src/stores/chat.ts b/src/renderer/src/stores/chat.ts index 37f7a3d..2de9442 100644 --- a/src/renderer/src/stores/chat.ts +++ b/src/renderer/src/stores/chat.ts @@ -35,6 +35,11 @@ interface ChatState { selectThinkingLevel(level: ThinkingLevel): Promise summonExpert(id: string | null, options?: SummonExpertOptions): Promise removeSession(id: string): Promise + /** 置顶/取消置顶:主进程返回新元信息,这里只做权威刷新。 */ + setPinned(id: string, pinned: boolean): Promise + /** 归档/取消归档:归档后从默认列表移除,不删除消息与目录。 */ + setArchived(id: string, archived: boolean): Promise + renameSession(id: string, title: string): Promise send(text: string, options?: Omit): Promise cancel(): Promise respond(interactionId: string, response: InteractionResponse): Promise @@ -63,6 +68,11 @@ function currentRun(runs: ChatState['runs'], sessionId: string | null): RunningS return Object.values(runs).find((run) => run.sessionId === sessionId) ?? null } +/** 置顶任务始终排在前面(最近置顶优先),其余保持原顺序。 */ +function sortSessions(sessions: SessionMeta[]): SessionMeta[] { + return [...sessions].sort((a, b) => Number(b.pinnedAt ?? 0) - Number(a.pinnedAt ?? 0)) +} + export const useChatStore = create((set, get) => { const streamCommitter = new ChatStreamCommitter((key, events) => { const first = events[0] @@ -226,6 +236,29 @@ export const useChatStore = create((set, get) => { sending: false, loading: false, error: null } : {}) })) return created }, + async setPinned(id, pinned) { + const session = await window.ycode.session.setPinned(id, pinned) + set(state => ({ sessions: sortSessions(state.sessions.map(item => item.id === id ? session : item)) })) + }, + async setArchived(id, archived) { + const session = await window.ycode.session.setArchived(id, archived) + // 归档后不再出现在默认列表;取消归档时补回。 + set((state) => { + const others = state.sessions.filter(item => item.id !== id) + return { sessions: archived ? others : sortSessions([...others, session]) } + }) + if (archived && get().currentSessionId === id) { + const next = get().sessions[0] + if (next) await get().selectSession(next.id) + else get().startTask() + } + }, + async renameSession(id, title) { + await window.ycode.session.rename(id, title) + const trimmed = title.trim() + if (!trimmed) return + set(state => ({ sessions: state.sessions.map(item => item.id === id ? { ...item, title: trimmed } : item) })) + }, async removeSession(id) { if (get().sendingSessions.includes(id)) return await window.ycode.session.remove(id) diff --git a/src/renderer/src/styles/sidebar.css b/src/renderer/src/styles/sidebar.css index f7d8d75..41fbb8f 100644 --- a/src/renderer/src/styles/sidebar.css +++ b/src/renderer/src/styles/sidebar.css @@ -343,25 +343,77 @@ .sidebar__item-action { flex: none; - width: var(--yc-size-5); - height: var(--yc-size-5); + /* 实测:卡片内 .agent-card-more-button 为 16×16、图标 16、默认隐藏 */ + width: 16px; + height: 16px; display: none; - place-items: center; - border-radius: var(--yc-radius-sm); + justify-content: center; + align-items: center; + padding: 0; + border: 0; + background: transparent; color: var(--yc-text-tertiary); + cursor: pointer; + transition: color 0.15s; + -webkit-app-region: no-drag; } -.sidebar__item:hover .sidebar__item-action { - display: grid; +.sidebar__item:hover .sidebar__item-action, +.sidebar__item-action:focus-visible, +.sidebar__item--menu-open .sidebar__item-action { + display: flex; +} + +/* 悬停/菜单打开时用操作按钮替换时间,避免两个元素同时占位 */ +.sidebar__item:hover .sidebar__item-time, +.sidebar__item--menu-open .sidebar__item-time { + display: none; } .sidebar__item-action:hover { - background: var(--yc-bg-active); + color: var(--yc-text-primary); } .sidebar__item-action svg { - width: var(--yc-font-size-2); - height: var(--yc-font-size-2); + width: 16px; + height: 16px; +} + +/* 已置顶标识:常显、次级色,位于标题之前(原版 .conversation-pinned-item) */ +.sidebar__item-pin { + flex: none; + display: inline-flex; + justify-content: center; + align-items: center; + width: 16px; + height: 16px; + color: var(--yc-text-tertiary); +} + +.sidebar__item-pin svg { + width: 16px; + height: 16px; +} + +/* 行内重命名输入(对齐原版 _titleInput:14/20、圆角 6、聚焦描边) */ +.sidebar__item-rename { + flex: 1; + min-width: 0; + height: 20px; + padding: 0 8px; + color: var(--yc-text-primary); + font-family: inherit; + font-size: 14px; + font-weight: 400; + line-height: 20px; + background: transparent; + border: 1px solid transparent; + border-radius: 6px; + outline: none; +} + +.sidebar__item-rename:focus { + border-color: var(--yc-status-info); } /* 空间项(实测 .collapsible-section header:34 高) */ diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index eae86d3..e0d959b 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -131,11 +131,20 @@ export interface YCodeApi { } session: { list(): Ipc + listArchived(): Ipc create(title?: string, context?: SessionContext): Ipc updateContext(id: string, context: SessionContext): Ipc messages(sessionId: string): Ipc rename(sessionId: string, title: string): Ipc remove(sessionId: string): Ipc + /** 置顶/取消置顶;返回刷新后的元信息。 */ + setPinned(sessionId: string, pinned: boolean): Ipc + /** 归档/取消归档;不删除消息与工作目录。 */ + setArchived(sessionId: string, archived: boolean): Ipc + /** 用系统文件管理器打开任务绑定的工作目录。 */ + openFolder(sessionId: string): Ipc + /** 在新窗口打开该任务。 */ + openWindow(sessionId: string): Ipc } chat: { revert(sessionId: string, userMessageId: string): Ipc<{ messages: ChatMessage[] }> diff --git a/src/shared/types.ts b/src/shared/types.ts index 9286234..ea54b1c 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -138,6 +138,10 @@ export interface SessionMeta { expertId?: string | null agentMode?: import('./conversation').AgentMode thinkingLevel?: import('./workspaces').ThinkingLevel + /** 置顶时间(毫秒)。有值即视为已置顶,用于侧栏任务排序,不改变 createdAt/updatedAt 语义。 */ + pinnedAt?: number + /** 归档时间(毫秒)。归档任务从默认任务列表移出,但不删除消息与工作目录。 */ + archivedAt?: number } /** 工作目录信息 */