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
@@ -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. 表格“添加到对话”及引用结果
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
+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> {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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<HTMLDivElement>(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 => (
|
||||
<div key={session.id} className={`sidebar__item workspace-source-task conversation-agent-card${child ? ' conversation-agent-card--group-child' : ' conversation-agent-card--standalone'}${session.id === currentId ? ' selected' : ''}`}
|
||||
data-selected={session.id === currentId} data-session-id={session.id} role="button" tabIndex={0}
|
||||
onClick={() => { 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) } }}>
|
||||
<span className="sidebar__item-title" title={session.title}>{session.title}</span>
|
||||
<span className="sidebar__item-time">{Object.values(runs).some(run => run.sessionId === session.id) ? '运行中' : sendingSessions.includes(session.id) ? '发送中' : relTime(session.updatedAt)}</span>
|
||||
<button className="sidebar__item-action" title="删除对话" onClick={(event) => { event.stopPropagation(); void removeSession(session.id).catch(error => setHint(error instanceof Error ? error.message : '删除失败')) }}><IconClose /></button>
|
||||
</div>
|
||||
)
|
||||
/**
|
||||
* 打开任务菜单。
|
||||
*
|
||||
* 原版实测锚点:菜单 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<void> => {
|
||||
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 (
|
||||
<div key={session.id} className={`sidebar__item workspace-source-task conversation-agent-card${child ? ' conversation-agent-card--group-child' : ' conversation-agent-card--standalone'}${pinned ? ' conversation-pinned-item' : ''}${session.id === currentId ? ' selected' : ''}${taskMenu?.session.id === session.id ? ' sidebar__item--menu-open' : ''}`}
|
||||
data-selected={session.id === currentId} data-session-id={session.id} role="button" tabIndex={0}
|
||||
onClick={() => { 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 ? <span className="sidebar__item-pin" title="已置顶"><TaskPinIcon /></span> : null}
|
||||
{editing ? (
|
||||
<input
|
||||
className="sidebar__item-rename"
|
||||
value={renaming.value}
|
||||
autoFocus
|
||||
onClick={(event) => 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)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span className="sidebar__item-title" title={session.title}>{session.title}</span>
|
||||
<span className="sidebar__item-time">{status}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar__item-action agent-card-more-button"
|
||||
title="更多操作"
|
||||
aria-label={`${session.title} 的更多操作`}
|
||||
onClick={(event) => { event.stopPropagation(); openTaskMenu(session, event.currentTarget) }}
|
||||
>
|
||||
<TaskMoreIcon />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
@@ -286,7 +370,7 @@ export default function Sidebar({ collapsed, onToggleCollapse, activeRoute = 'ch
|
||||
<span className={`conversation-section-chevron${tasksOpen ? ' conversation-section-chevron-expanded' : ''}`}><IconChevronDown /></span>
|
||||
</div>
|
||||
{tasksOpen && <div className="sidebar__section-body conversation-tasks-independent-block">
|
||||
{grouped.independent.length ? grouped.independent.map(session => renderSession(session)) : <div className="conversation-group-empty-placeholder">{query ? '无匹配任务' : '暂无任务'}</div>}
|
||||
{grouped.independent.length ? sortPinnedFirst(grouped.independent).map(session => renderSession(session)) : <div className="conversation-group-empty-placeholder">{query ? '无匹配任务' : '暂无任务'}</div>}
|
||||
</div>}
|
||||
</section>
|
||||
<section className="sidebar__section conversation-section conversation-section-groups conversation-section-label-groups conversation-section-grouped-space">
|
||||
@@ -347,6 +431,12 @@ export default function Sidebar({ collapsed, onToggleCollapse, activeRoute = 'ch
|
||||
</div>
|
||||
</div></div>, document.body)}
|
||||
|
||||
{taskMenu ? <TaskContextMenu
|
||||
session={taskMenu.session}
|
||||
anchor={taskMenu.anchor}
|
||||
onSelect={(action) => handleTaskMenu(taskMenu.session, action)}
|
||||
onClose={() => setTaskMenu(null)} /> : null}
|
||||
|
||||
{hint ? <div className="sidebar__hint">{hint}</div> : null}
|
||||
|
||||
{/* ---- 底部账号区(实测:57 高、padding 8 12 8 8、border-top 1px #e6e6e6)----
|
||||
|
||||
@@ -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<SVGSVGElement>
|
||||
|
||||
/** 置顶 */
|
||||
export function IconTaskPin(props: IconProps) {
|
||||
return (
|
||||
<svg {...props} viewBox="0 0 16 16" fill="currentColor" width="16" height="16" ><path transform="matrix(0.707107 0.707107 -0.707107 0.707107 9.66 -1.44)" fillRule="evenodd" d="M8.9091 1.7259C9.0313 2.1014 9.0313 2.5676 9.0313 3.5L9.0313 3.9998C9.0313 4.6592 9.0313 4.9889 9.0829 5.3072C9.1518 5.7316 9.2887 6.1423 9.4883 6.5232C9.638 6.8088 9.8359 7.0725 10.2316 7.6C10.7349 8.271 11.0022 8.6051 11.0295 8.9079C11.0634 9.2833 10.8774 9.6554 10.5568 9.8535C10.3173 10.0015 9.9329 10.001 9.2114 10.0002L9.2087 10.0002L9.208 10.0002C9.1513 10.0001 9.0926 10 9.0317 10L7.3316 10L7.3316 8.8L9.0317 8.8Q9.0949 8.8 9.2128 8.8002Q9.457 8.8005 9.628 8.7982Q9.5272 8.66 9.3804 8.4649Q9.3093 8.3703 9.2717 8.3201Q8.62 7.4515 8.4255 7.0803Q8.0336 6.3326 7.8984 5.4994Q7.8313 5.0857 7.8313 3.9998L7.8313 3.5Q7.8313 2.2917 7.768 2.0971Q7.5633 1.468 6.9341 1.2633Q6.7396 1.2 5.5313 1.2Q4.323 1.2 4.1284 1.2633Q3.4992 1.468 3.2946 2.0971Q3.2313 2.2917 3.2313 3.5L3.2313 4.0002Q3.2313 5.0862 3.1641 5.4999Q3.029 6.3325 2.6376 7.0797Q2.4431 7.451 1.7916 8.3199Q1.754 8.37 1.6829 8.4647Q1.5361 8.6599 1.4352 8.7982Q1.6064 8.8005 1.8507 8.8002Q1.9695 8.8 2.0318 8.8L6.1316 8.8L6.1316 10L6.1313 10L6.1317 17L4.9317 17.0001L4.9313 10L2.0318 10C1.9696 10 1.9098 10.0001 1.8521 10.0002C1.1304 10.001 0.7459 10.0015 0.5064 9.8534C0.1859 9.6553 0 9.2835 0.0338 8.9083C0.061 8.6054 0.3283 8.2712 0.8316 7.6C1.2272 7.0723 1.425 6.8085 1.5746 6.5229C1.774 6.1422 1.9108 5.7319 1.9796 5.3077C2.0313 4.9894 2.0313 4.6597 2.0313 4.0002L2.0313 3.5C2.0313 2.5676 2.0313 2.1014 2.1534 1.7259C2.4 0.9678 2.9991 0.3688 3.7572 0.1222C4.1327 0 4.5989 0 5.5313 0C6.4637 0 6.9299 0 7.3054 0.1222C8.0634 0.3688 8.6625 0.9678 8.9091 1.7259Z" /></svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** 重命名 */
|
||||
export function IconTaskRename(props: IconProps) {
|
||||
return (
|
||||
<svg {...props} viewBox="0 0 16 16" fill="none" width="16" height="16" ><path fill="currentColor" fillRule="evenodd" transform="matrix(1 0 0 1 0.999868 1.49989)" d="M0.3011 12.4122Q0.6022 12.7102 1.0178 12.6286L1.9012 12.4553Q4.4733 11.9508 6.3267 10.0973L11.9021 4.5219Q12.678 3.7461 12.678 2.6489Q12.678 1.5517 11.9021 0.7758Q11.1263 0 10.0291 0Q8.9319 0 8.1561 0.7758L2.6125 6.3194Q0.7248 8.2071 0.2379 10.8318L0.0773 11.6977Q0 12.1142 0.3011 12.4122ZM11.0536 3.6734L10.7156 4.0114L8.6671 1.9618L9.0046 1.6244Q9.429 1.2 10.0291 1.2Q10.6292 1.2 11.0536 1.6244Q11.478 2.0487 11.478 2.6489Q11.478 3.249 11.0536 3.6734ZM1.3645 11.3378L1.6702 11.2778Q3.8834 10.8436 5.4782 9.2488L9.8671 4.86L7.8186 2.8103L3.461 7.1679Q1.8368 8.7922 1.4178 11.0507L1.3645 11.3378ZM6.0001 11.3L13.0001 11.3L13.0001 12.5L6.0001 12.5L6.0001 11.3Z" /></svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** 在独立窗口中打开 */
|
||||
export function IconTaskOpenWindow(props: IconProps) {
|
||||
return (
|
||||
<svg {...props} viewBox="0 0 16 16" fill="currentColor" width="16" height="16" ><path transform="translate(0.999987, 1.5)" fillRule="evenodd" d="M8 0C10.8284 0 12.2424 0.0002 13.1211 0.8789C13.8411 1.5989 13.9686 2.6782 13.9922 4.6006L12.793 4.6006L12.793 4.5996L1.2071 4.5996C1.2021 5.0101 1.2002 5.4731 1.2002 6C1.2002 7.4481 1.2023 9.4135 1.2988 10.1318C1.3908 10.8157 1.5483 11.0932 1.7275 11.2725C1.9068 11.4517 2.1843 11.6092 2.8682 11.7012C3.5866 11.7977 4.5519 11.7998 6 11.7998L6 13L5.0039 12.998C2.982 12.9869 1.8209 12.9038 1.0498 12.2764L0.8789 12.1211C0.0002 11.2424 0 9.8284 0 6L0 5.9969C0 3.3474 0 1.9392 0.7237 1.0498L0.8789 0.8789C1.7576 0.0002 3.1716 0 6 0L8 0ZM6 1.2002C4.552 1.2002 3.5866 1.2023 2.8682 1.2988C2.1843 1.3908 1.9068 1.5483 1.7276 1.7275C1.5483 1.9068 1.3908 2.1843 1.2989 2.8682C1.2769 3.0318 1.2604 3.2083 1.2471 3.4004L12.7529 3.4004C12.7396 3.2083 12.7232 3.0318 12.7012 2.8682C12.6092 2.1843 12.4517 1.9068 12.2725 1.7275C12.0932 1.5483 11.8157 1.3908 11.1318 1.2988C10.4135 1.2023 9.4481 1.2002 8 1.2002L6 1.2002ZM14 6.3393L14 5.7998L14 5.7998L14 6C14 6.1153 14 6.2283 14 6.3393ZM7.4757 12.0757L11.9514 7.6L9.3999 7.6L9.3999 6.4L12.4136 6.4Q12.8579 6.4 13.0324 6.4238Q13.4342 6.4786 13.6778 6.7222Q13.9213 6.9657 13.9761 7.3675Q14 7.542 13.9999 7.9863L13.9999 11L12.7999 11L12.7999 8.4485L8.3242 12.9242L7.4757 12.0757Z" /></svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** 打开文件夹 */
|
||||
export function IconTaskOpenFolder(props: IconProps) {
|
||||
return (
|
||||
<svg {...props} viewBox="0 0 16 16" fill="currentColor" width="16" height="16" ><path transform="matrix(1 0 0 1 0.900391 0.90039)" d="M4.4893 0C4.8371 0 5.0591 -0.0008 5.2754 0.0244C6.3094 0.1455 7.0485 0.6989 7.6641 1.3145L8.6494 2.2998L6.9521 2.2998L6.8154 2.1631C6.2812 1.6289 5.7808 1.2913 5.1367 1.2158C5.0059 1.2005 4.8651 1.1992 4.4893 1.1992C3.3369 1.1992 2.5394 1.4806 2.0293 1.9717C1.5234 2.4588 1.1992 3.2499 1.1992 4.4893L1.1992 8.0996C1.1992 9.0452 1.1999 9.7144 1.2471 10.2353C1.2935 10.7481 1.382 11.0583 1.5215 11.2998C1.8929 11.9429 2.4455 12.2436 3.1484 12.3867C3.519 12.4622 3.9095 12.4906 4.3184 12.5L9.2217 12.5C9.8441 12.4975 10.462 12.477 11.0312 12.3564C11.7703 12.1999 12.3358 11.8919 12.6777 11.2998C12.8172 11.0583 12.9057 10.7481 12.9521 10.2353C12.9914 9.8018 12.9958 9.2656 12.9971 8.5527L12.9961 8.1064C12.9832 6.8588 12.9095 6.3058 12.6777 5.9043C12.3358 5.3123 11.7703 5.0042 11.0312 4.8477C10.462 4.7271 9.8441 4.7066 9.2217 4.7041L2.5996 4.7041L2.5996 3.5049L8.1572 3.5049L8.1514 3.499L9.8486 3.499L9.8652 3.5156C10.3318 3.533 10.8171 3.5747 11.2803 3.6729C12.2112 3.8701 13.1449 4.3124 13.7178 5.3047C14.1305 6.02 14.1839 6.8921 14.1963 8.0938L14.1963 8.0996L14.1992 8.0996C14.1992 8.2186 14.1974 8.3345 14.1973 8.4473L14.1992 9.1025L14.1982 9.1035L14.1992 9.1045L14.1934 9.1045C14.1885 9.5777 14.1788 9.9874 14.1465 10.3437C14.0925 10.9395 13.9801 11.4449 13.7178 11.8994C13.1449 12.8917 12.2112 13.334 11.2803 13.5312C10.3688 13.7243 9.3719 13.6992 8.5996 13.6992L5.5996 13.6992C4.8004 13.6992 3.8041 13.7447 2.9092 13.5625C1.9757 13.3724 1.0723 12.9228 0.4814 11.8994C0.2191 11.4449 0.1067 10.9395 0.0527 10.3437C-0.0005 9.7561 0 9.0232 0 8.0996L0 4.4893C0 3.0495 0.3791 1.8952 1.1973 1.1074C2.0113 0.3238 3.1594 0 4.4893 0Z" /></svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** 归档 */
|
||||
export function IconTaskArchive(props: IconProps) {
|
||||
return (
|
||||
<svg {...props} viewBox="0 0 16 16" fill="currentColor" width="16" height="16" ><path transform="translate(1, 1.5)" d="M10.25 0C10.9467 0 11.2952 0 11.585 0.0576C12.775 0.2943 13.7057 1.225 13.9424 2.415C14 2.7048 14 3.0533 14 3.75C14 3.9823 13.9997 4.0987 13.9805 4.1953L13.9404 4.3398C13.8227 4.6658 13.5422 4.9114 13.1953 4.9805L13.0234 4.998L12.75 5L2.2002 5L2.2002 7C2.2002 8.4479 2.2023 9.4135 2.2988 10.1318C2.3908 10.8155 2.5484 11.0932 2.7275 11.2725C2.9068 11.4516 3.1844 11.6083 3.8682 11.7002C4.5866 11.7968 5.5519 11.7998 7 11.7998C8.4481 11.7998 9.4134 11.7968 10.1318 11.7002C10.8156 11.6083 11.0932 11.4516 11.2725 11.2725C11.4516 11.0932 11.6092 10.8155 11.7012 10.1318C11.7977 9.4135 11.7998 8.4479 11.7998 7L11.7998 6.1992L13 6.1992L13 7L12.998 7.9961C12.986 10.1734 12.8898 11.3523 12.1211 12.1211C11.2424 12.9997 9.8283 13 7 13L6.0039 12.998C3.9817 12.9869 2.8209 12.9031 2.0498 12.2754L1.8789 12.1211C1.1102 11.3523 1.014 10.1734 1.002 7.9961L1 7L1 4.9971C0.9179 4.9949 0.8582 4.9911 0.8047 4.9805C0.4578 4.9114 0.1773 4.6658 0.0596 4.3398L0.0195 4.1953C0.0003 4.0987 0 3.9823 0 3.75C0 3.2271 0.0001 2.9004 0.0244 2.6475L0.0576 2.415C0.2795 1.2995 1.1109 0.4121 2.1943 0.1104L2.415 0.0576C2.5599 0.0288 2.7194 0.014 2.9297 0.0068L3.75 0L10.25 0ZM9 7.5996L5 7.5996L5 6.4004L9 6.4004L9 7.5996ZM3.75 1.2002C2.9955 1.2002 2.7931 1.2056 2.6484 1.2344C1.9347 1.3765 1.3765 1.9347 1.2344 2.6484C1.2056 2.7931 1.2002 2.9955 1.2002 3.75C1.2002 3.7677 1.2002 3.7842 1.2002 3.7998L12.7998 3.7998C12.7998 3.7842 12.7998 3.7677 12.7998 3.75C12.7998 2.9955 12.7944 2.7931 12.7656 2.6484C12.6235 1.9347 12.0653 1.3765 11.3516 1.2344C11.2069 1.2056 11.0045 1.2002 10.25 1.2002L3.75 1.2002Z" /></svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** 删除任务 */
|
||||
export function IconTaskDelete(props: IconProps) {
|
||||
return (
|
||||
<svg {...props} viewBox="0 0 16 16" fill="currentColor" width="16" height="16" ><path transform="matrix(1 0 0 1 1.03834 1.20291)" fillRule="evenodd" d="M4.6312 0L9.6312 0L9.6312 1.2L4.6312 1.2L4.6312 0ZM0 2.9061L13.9633 2.9061L13.9633 4.1061L2.7311 4.1061L2.7311 8.2934Q2.7311 10.2897 2.8934 10.7529Q3.3549 12.0696 4.6717 12.5311Q5.1348 12.6934 7.1311 12.6934Q9.1274 12.6934 9.5906 12.5311Q10.9074 12.0696 11.3688 10.7529Q11.5311 10.2897 11.5311 8.2934L11.5311 5.0392L12.7311 5.0392L12.7311 8.2934Q12.7311 10.4939 12.5013 11.1497Q11.8489 13.0112 9.9875 13.6636Q9.3316 13.8934 7.1311 13.8934Q4.9306 13.8934 4.2748 13.6636Q2.4133 13.0112 1.7609 11.1497Q1.5311 10.4939 1.5311 8.2934L1.5311 4.1061L0 4.1061L0 2.9061ZM7.7312 11.2934L7.7312 5.2934L6.5312 5.2934L6.5312 11.2934L7.7312 11.2934Z" /></svg>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/** 已置顶任务行左侧的固定置顶标识(原版 .wb-pin-icon-default)。 */
|
||||
export function IconTaskPinBadge(props: IconProps) {
|
||||
return <IconTaskPin {...props} />
|
||||
}
|
||||
|
||||
/** 任务行悬停时显示的“更多操作”三点按钮(REA 归档 .agent-card-more-button 图标)。 */
|
||||
export function IconTaskMore(props: IconProps) {
|
||||
return (
|
||||
<svg {...props} width="16" height="16" viewBox="0 0 16 16">
|
||||
<path fill="currentColor" transform="matrix(1 0 0 1 2 7)" d="M0 1C0 0.4477 0.4477 0 1 0C1.5523 0 2 0.4477 2 1C2 1.5523 1.5523 2 1 2C0.4477 2 0 1.5523 0 1ZM5 1C5 0.4477 5.4477 0 6 0C6.5523 0 7 0.4477 7 1C7 1.5523 6.5523 2 6 2C5.4477 2 5 1.5523 5 1ZM10 1C10 0.4477 10.4477 0 11 0C11.5523 0 12 0.4477 12 1C12 1.5523 11.5523 2 11 2C10.4477 2 10 1.5523 10 1Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<HTMLDivElement>(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(
|
||||
<div className="workspace-source">
|
||||
<div className="conversation-context-menu-overlay" onClick={onClose} onContextMenu={(event) => { event.preventDefault(); onClose() }} />
|
||||
<div ref={ref} role="menu" className="conversation-context-menu" style={{ position: 'fixed', top: position.top, left: position.left }}
|
||||
onClick={(event) => event.stopPropagation()}>
|
||||
{itemsFor(session).map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<div className="conversation-context-menu__slot" key={item.id}>
|
||||
{item.dividerBefore ? <div className="cb-context-menu-divider" role="separator" /> : null}
|
||||
<button type="button" role="menuitem"
|
||||
className={`cb-context-menu-item${item.danger ? ' cb-context-menu-item--danger' : ''}`}
|
||||
onClick={() => onSelect(item.id)}>
|
||||
<span className="cb-context-menu-item-icon"><Icon /></span>
|
||||
<span className="cb-context-menu-item-label">{item.label}</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -35,6 +35,11 @@ interface ChatState {
|
||||
selectThinkingLevel(level: ThinkingLevel): Promise<void>
|
||||
summonExpert(id: string | null, options?: SummonExpertOptions): Promise<void>
|
||||
removeSession(id: string): Promise<void>
|
||||
/** 置顶/取消置顶:主进程返回新元信息,这里只做权威刷新。 */
|
||||
setPinned(id: string, pinned: boolean): Promise<void>
|
||||
/** 归档/取消归档:归档后从默认列表移除,不删除消息与目录。 */
|
||||
setArchived(id: string, archived: boolean): Promise<void>
|
||||
renameSession(id: string, title: string): Promise<void>
|
||||
send(text: string, options?: Omit<SendPayload, 'sessionId' | 'text'>): Promise<void>
|
||||
cancel(): Promise<void>
|
||||
respond(interactionId: string, response: InteractionResponse): Promise<void>
|
||||
@@ -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<ChatState>((set, get) => {
|
||||
const streamCommitter = new ChatStreamCommitter((key, events) => {
|
||||
const first = events[0]
|
||||
@@ -226,6 +236,29 @@ export const useChatStore = create<ChatState>((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)
|
||||
|
||||
@@ -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 高) */
|
||||
|
||||
@@ -131,11 +131,20 @@ export interface YCodeApi {
|
||||
}
|
||||
session: {
|
||||
list(): Ipc<SessionMeta[]>
|
||||
listArchived(): Ipc<SessionMeta[]>
|
||||
create(title?: string, context?: SessionContext): Ipc<SessionMeta>
|
||||
updateContext(id: string, context: SessionContext): Ipc<SessionMeta>
|
||||
messages(sessionId: string): Ipc<ChatMessage[]>
|
||||
rename(sessionId: string, title: string): Ipc<void>
|
||||
remove(sessionId: string): Ipc<void>
|
||||
/** 置顶/取消置顶;返回刷新后的元信息。 */
|
||||
setPinned(sessionId: string, pinned: boolean): Ipc<SessionMeta>
|
||||
/** 归档/取消归档;不删除消息与工作目录。 */
|
||||
setArchived(sessionId: string, archived: boolean): Ipc<SessionMeta>
|
||||
/** 用系统文件管理器打开任务绑定的工作目录。 */
|
||||
openFolder(sessionId: string): Ipc<void>
|
||||
/** 在新窗口打开该任务。 */
|
||||
openWindow(sessionId: string): Ipc<void>
|
||||
}
|
||||
chat: {
|
||||
revert(sessionId: string, userMessageId: string): Ipc<{ messages: ChatMessage[] }>
|
||||
|
||||
@@ -138,6 +138,10 @@ export interface SessionMeta {
|
||||
expertId?: string | null
|
||||
agentMode?: import('./conversation').AgentMode
|
||||
thinkingLevel?: import('./workspaces').ThinkingLevel
|
||||
/** 置顶时间(毫秒)。有值即视为已置顶,用于侧栏任务排序,不改变 createdAt/updatedAt 语义。 */
|
||||
pinnedAt?: number
|
||||
/** 归档时间(毫秒)。归档任务从默认任务列表移出,但不删除消息与工作目录。 */
|
||||
archivedAt?: number
|
||||
}
|
||||
|
||||
/** 工作目录信息 */
|
||||
|
||||
Reference in new issue
Block a user