feat(frontend): 侧边栏紧凑化与挤压式重设计,移除工作区面板功能拆分

- 对话侧边栏:行高/字号/圆角紧凑化,fixed 覆盖式改为文档流挤压式展开,移除工作区/虚拟显示器按钮
- 新增 WorkspaceSwitcher 浮层组件:列表即切换器,内联重命名/删除确认/新建表单动画,「…」菜单 fixed 逃逸裁切,刚好 7 行内部滚动
- 移除 LeftPanel 及两个旧工作区对话框:管理工作区迁入侧边栏,文件管理迁入输入栏 + 菜单(最多 4 项滚动)
- 个人空间移除默认隐藏工作区/隐藏快捷窗口设置项;删除 panelMode 门控轮询(QuickDock 常驻轮询 + socket 推送替代)
- 修复 flex 压缩导致菜单行高失效、分组对话 viewport 裁切最后一行、action-wrap 撑高对话项、/new 残留最近对话待办(无对话 id 不再请求 todo-list)
- 工作区入口按钮改内联 layers 图标并统一 18px,host/docker 模式 localStorage 缓存避免按钮首屏延迟出现
This commit is contained in:
JOJO 2026-07-23 16:47:49 +08:00
parent 15779be939
commit b593d63856
32 changed files with 2502 additions and 2640 deletions

View File

@ -0,0 +1,496 @@
/* ============================================================
工作区菜单重设计 Demo 交互
数据字段对齐真实结构workspace_id / label / path / running_task_count
============================================================ */
(function () {
'use strict';
/* ---------- 示例数据(字段语义与 HostWorkspaceManageDialog 一致) ---------- */
const DATA = {
host: [
{ workspace_id: 'default', label: '默认工作区', path: '~/Desktop/agents', running_task_count: 2 },
{ workspace_id: 'ws-client-a', label: '客户 A 项目', path: '~/Desktop/work/client-a', running_task_count: 0 },
{ workspace_id: 'ws-research', label: '论文实验', path: '~/Documents/research/thesis', running_task_count: 1 },
{ workspace_id: 'ws-playground', label: '临时测试', path: '/tmp/agent-playground', running_task_count: 0 }
],
docker: [
{ workspace_id: 'default', label: '默认项目', path: '', running_task_count: 0 },
{ workspace_id: 'ws-client-a', label: '客户 A 项目', path: '', running_task_count: 0 },
{ workspace_id: 'ws-research', label: '论文实验', path: '', running_task_count: 0 }
]
};
const state = {
mode: 'host', // host=宿主机(工作区) / docker=项目
workspaces: [],
currentId: 'default',
defaultId: 'default',
menuOpenId: null, // 「…」菜单展开的行
renamingId: null, // 重命名内联编辑的行
deletingId: null, // 删除确认内联的行
popoverOpen: true,
createOpen: false
};
const cloneData = (mode) => DATA[mode].map((w) => ({ ...w }));
state.workspaces = cloneData('host');
/* ---------- DOM ---------- */
const canvas = document.getElementById('canvas');
const popover = document.getElementById('wsPopover');
const listEl = document.getElementById('wsList');
const countEl = document.getElementById('wsCount');
const headerTitle = document.getElementById('headerTitle');
const entryBtn = document.getElementById('workspaceEntry');
const entryLabel = document.getElementById('entryLabel');
const entryCurrentName = document.getElementById('entryCurrentName');
const bottomEl = document.getElementById('wsBottom');
const footerEl = document.getElementById('wsFooter');
const createBtn = document.getElementById('createBtn');
const createBtnText = document.getElementById('createBtnText');
const createForm = document.getElementById('createForm');
const createName = document.getElementById('createName');
const createPath = document.getElementById('createPath');
const createCancel = document.getElementById('createCancel');
const statusEl = document.getElementById('stageStatus');
const esc = (s) =>
String(s == null ? '' : s)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
const setStatus = (msg) => { statusEl.textContent = msg; };
const isDocker = () => state.mode === 'docker';
const noun = () => (isDocker() ? '项目' : '工作区');
/* 内联 SVG 图标file:// 协议下 <use> 会被 Chrome 拦截,故内联) */
const ICON = {
folder: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg>',
dots: '<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></svg>'
};
/* ============================================================
浮层定位锚定工作区入口按钮右侧并夹紧在画布内
============================================================ */
function positionPopover() {
const c = canvas.getBoundingClientRect();
const b = entryBtn.getBoundingClientRect();
const ph = popover.offsetHeight || 0;
let top = b.top - c.top - 6;
const maxTop = Math.max(8, c.height - ph - 42); /* 42 = 底部状态条 + 余量 */
top = Math.max(8, Math.min(top, maxTop));
popover.style.left = b.right - c.left + 8 + 'px';
popover.style.top = top + 'px';
}
function setPopoverOpen(open) {
state.popoverOpen = open;
popover.classList.toggle('open', open);
entryBtn.classList.toggle('active', open);
entryBtn.setAttribute('aria-expanded', String(open));
if (open) {
positionPopover();
} else {
closeTransient();
if (state.createOpen) closeCreateForm(true);
}
}
function closeTransient() {
closeMenu();
state.renamingId = null;
state.deletingId = null;
}
/* ============================================================
二级菜单fixed 浮层逃逸容器裁切
PersonalizationDrawer settings-floating-menu 同方案
============================================================ */
let menuEl = null;
function ensureMenuEl() {
if (menuEl) return menuEl;
menuEl = document.createElement('div');
menuEl.className = 'ws-menu-floating';
document.body.appendChild(menuEl);
menuEl.addEventListener('click', (e) => {
const btn = e.target.closest('[data-act]');
if (!btn || btn.disabled) return;
e.stopPropagation();
handleMenuAction(btn.dataset.act);
});
return menuEl;
}
function openMenu(rowEl, id) {
state.menuOpenId = id;
state.renamingId = null;
state.deletingId = null;
renderRows();
const ws = state.workspaces.find((w) => w.workspace_id === id);
if (!ws) return;
const isDefault = id === state.defaultId;
const el = ensureMenuEl();
el.innerHTML =
'<button type="button" data-act="set-default"' + (isDefault ? ' disabled' : '') + '>设为默认</button>' +
'<button type="button" data-act="rename">重命名</button>' +
'<button type="button" data-act="reveal"' + (isDocker() ? ' disabled title="Docker 模式不可用"' : '') + '>在文件夹中打开</button>' +
'<div class="ws-menu-sep"></div>' +
'<button type="button" class="danger" data-act="delete">删除</button>';
el.style.display = 'flex';
/* renderRows 后旧行元素已摘除,必须按 data-id 重新查询再取坐标 */
const newRow = listEl.querySelector('.ws-row[data-id="' + id + '"]');
const btn = newRow ? newRow.querySelector('.ws-more') : null;
if (!btn) { closeMenu(); return; }
const rect = btn.getBoundingClientRect();
const mw = el.offsetWidth;
const mh = el.offsetHeight;
let left = rect.right - mw;
let top = rect.bottom + 4;
left = Math.max(8, Math.min(left, window.innerWidth - mw - 8));
if (top + mh > window.innerHeight - 8) top = rect.top - mh - 4;
el.style.left = Math.round(left) + 'px';
el.style.top = Math.round(top) + 'px';
requestAnimationFrame(() => el.classList.add('open'));
}
function closeMenu() {
state.menuOpenId = null;
if (menuEl) {
menuEl.classList.remove('open');
menuEl.style.display = 'none';
}
}
function handleMenuAction(act) {
const id = state.menuOpenId;
const ws = state.workspaces.find((w) => w.workspace_id === id);
closeMenu();
if (!ws) return;
if (act === 'set-default') {
state.defaultId = id;
setStatus('已将「' + ws.label + '」设为默认' + noun());
renderRows();
} else if (act === 'rename') {
state.renamingId = id;
renderRows();
} else if (act === 'reveal') {
/* 实现参考 InputComposer.vue 的 openProjectInFileManager */
setStatus('在文件夹中打开:' + (ws.path || ws.label));
} else if (act === 'delete') {
state.deletingId = id;
renderRows();
}
}
/* ============================================================
渲染列表行
============================================================ */
function renderRows() {
const current = state.workspaces.find((w) => w.workspace_id === state.currentId);
entryCurrentName.textContent = current ? current.label : '';
countEl.textContent = state.workspaces.length + ' 个';
listEl.innerHTML = '';
for (const ws of state.workspaces) {
const li = document.createElement('div');
const isCurrent = ws.workspace_id === state.currentId;
const isDefault = ws.workspace_id === state.defaultId;
li.className = 'ws-row'
+ (isDocker() ? ' docker' : '')
+ (isCurrent ? ' current' : '')
+ (state.menuOpenId === ws.workspace_id ? ' menu-open' : '');
li.dataset.id = ws.workspace_id;
li.setAttribute('role', 'option');
li.setAttribute('aria-selected', String(isCurrent));
if (state.deletingId === ws.workspace_id) {
/* --- 删除确认内联 --- */
li.innerHTML =
'<div class="ws-confirm">' +
'<div class="ws-confirm-text">删除「' + esc(ws.label) + '」?</div>' +
'<div class="ws-confirm-actions">' +
'<button type="button" class="ws-btn ghost" data-act="cancel-delete">取消</button>' +
'<button type="button" class="ws-btn danger" data-act="confirm-delete">删除</button>' +
'</div>' +
'</div>';
} else {
const metaHtml =
(isDefault ? '<span class="ws-tag">默认</span>' : '') +
'<button type="button" class="ws-more" data-act="menu" aria-label="更多操作" aria-expanded="' +
String(state.menuOpenId === ws.workspace_id) + '">' + ICON.dots + '</button>';
const nameHtml = state.renamingId === ws.workspace_id
? '<input class="ws-rename-input" data-rename-input value="' + esc(ws.label) + '" />'
: '<span class="ws-row-name">' + esc(ws.label) + '</span>';
const pathHtml = isDocker()
? ''
: '<span class="ws-row-path">' + esc(ws.path || '(未配置路径)') + '</span>';
li.innerHTML =
'<span class="ws-row-icon">' + ICON.folder + '</span>' +
'<span class="ws-row-text">' + nameHtml + pathHtml + '</span>' +
'<span class="ws-row-meta">' + metaHtml + '</span>';
}
listEl.appendChild(li);
}
/* 重命名输入框自动聚焦 */
const renameInput = listEl.querySelector('[data-rename-input]');
if (renameInput) {
renameInput.focus();
renameInput.select();
}
}
/* ============================================================
行为
============================================================ */
function switchWorkspace(id) {
if (id === state.currentId) return;
state.currentId = id;
const ws = state.workspaces.find((w) => w.workspace_id === id);
setStatus('已切换到「' + (ws ? ws.label : id) + '」');
renderRows();
}
function commitRename(id, value) {
const label = value.trim();
if (label) {
const ws = state.workspaces.find((w) => w.workspace_id === id);
if (ws && ws.label !== label) {
ws.label = label;
setStatus('已重命名为「' + label + '」');
}
}
state.renamingId = null;
renderRows();
}
function removeWorkspace(id) {
const idx = state.workspaces.findIndex((w) => w.workspace_id === id);
if (idx === -1) return;
const removed = state.workspaces[idx];
state.workspaces.splice(idx, 1);
if (state.currentId === id && state.workspaces.length) {
state.currentId = state.workspaces[0].workspace_id;
}
if (state.defaultId === id && state.workspaces.length) {
state.defaultId = state.workspaces[0].workspace_id;
}
state.deletingId = null;
setStatus('已删除「' + removed.label + '」');
renderRows();
}
/* ============================================================
新建表单底部区域高度动画 + 交叉淡入淡出
============================================================ */
let bottomAnimating = false;
function swapBottom(toForm, done) {
if (bottomAnimating) return;
bottomAnimating = true;
const fromEl = toForm ? footerEl : createForm;
const toEl = toForm ? createForm : footerEl;
const h0 = fromEl.offsetHeight;
bottomEl.style.height = h0 + 'px';
fromEl.classList.add('fading');
setTimeout(() => {
fromEl.hidden = true;
fromEl.classList.remove('fading');
toEl.hidden = false;
toEl.classList.add('fading');
const h1 = toEl.offsetHeight;
bottomEl.style.height = h1 + 'px';
requestAnimationFrame(() => {
requestAnimationFrame(() => toEl.classList.remove('fading'));
});
setTimeout(() => {
bottomEl.style.height = '';
bottomAnimating = false;
if (typeof done === 'function') done();
}, 210);
}, 130);
}
function openCreateForm() {
if (state.createOpen) return;
state.createOpen = true;
closeMenu();
createName.value = '';
createPath.value = '';
swapBottom(true, () => createName.focus());
}
function closeCreateForm(instant) {
if (!state.createOpen) return;
state.createOpen = false;
if (instant) {
createForm.hidden = true;
footerEl.hidden = false;
bottomEl.style.height = '';
return;
}
swapBottom(false);
}
createBtn.addEventListener('click', openCreateForm);
createCancel.addEventListener('click', () => closeCreateForm(false));
createForm.addEventListener('submit', (e) => {
e.preventDefault();
const label = createName.value.trim() || (isDocker() ? '未命名项目' : '未命名工作区');
const path = createPath.value.trim();
const id = 'ws-' + Date.now().toString(36);
state.workspaces.push({ workspace_id: id, label: label, path: path, running_task_count: 0 });
setStatus('已创建「' + label + '」');
closeCreateForm(false);
renderRows();
listEl.scrollTop = listEl.scrollHeight;
});
/* ============================================================
事件列表行事件委托
============================================================ */
listEl.addEventListener('click', (e) => {
const actBtn = e.target.closest('[data-act]');
const row = e.target.closest('.ws-row');
const id = row ? row.dataset.id : null;
if (actBtn) {
e.stopPropagation();
const act = actBtn.dataset.act;
if (act === 'menu') {
if (state.menuOpenId === id) {
closeMenu();
renderRows();
} else {
openMenu(row, id);
}
return;
}
if (act === 'cancel-delete') { state.deletingId = null; renderRows(); return; }
if (act === 'confirm-delete') { removeWorkspace(id); return; }
}
/* 点击行本体:切换工作区(重命名/删除确认态除外) */
if (row && id && !state.renamingId && !state.deletingId) {
if (state.menuOpenId) { closeMenu(); renderRows(); return; }
switchWorkspace(id);
}
});
/* 重命名输入Enter 提交 / Esc 取消 */
listEl.addEventListener('keydown', (e) => {
const input = e.target.closest('[data-rename-input]');
if (!input) return;
const row = input.closest('.ws-row');
const id = row ? row.dataset.id : null;
if (e.key === 'Enter') { e.preventDefault(); commitRename(id, input.value); }
if (e.key === 'Escape') { e.preventDefault(); state.renamingId = null; renderRows(); }
e.stopPropagation();
});
/* 列表滚动时收起浮动菜单 */
listEl.addEventListener('scroll', () => {
if (state.menuOpenId) { closeMenu(); renderRows(); }
});
/* ============================================================
事件入口按钮 / 外部点击 / Esc
============================================================ */
entryBtn.addEventListener('click', (e) => {
e.stopPropagation();
setPopoverOpen(!state.popoverOpen);
});
popover.addEventListener('click', (e) => { e.stopPropagation(); });
document.addEventListener('click', (e) => {
if (state.menuOpenId && !(menuEl && menuEl.contains(e.target))) {
closeMenu();
renderRows();
}
if (state.popoverOpen && !popover.contains(e.target) && !entryBtn.contains(e.target)) {
setPopoverOpen(false);
}
});
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
if (state.menuOpenId) { closeMenu(); renderRows(); return; }
if (state.renamingId || state.deletingId) { state.renamingId = null; state.deletingId = null; renderRows(); return; }
if (state.createOpen) { closeCreateForm(false); return; }
if (state.popoverOpen) setPopoverOpen(false);
});
window.addEventListener('resize', () => {
if (state.popoverOpen) positionPopover();
if (state.menuOpenId) { closeMenu(); renderRows(); }
});
/* ============================================================
Demo 外框主题切换 / 模式切换
============================================================ */
const themeBtns = document.querySelectorAll('[data-theme-btn]');
themeBtns.forEach((btn) => {
btn.addEventListener('click', () => {
document.documentElement.setAttribute('data-theme', btn.dataset.themeBtn);
themeBtns.forEach((b) => b.classList.toggle('active', b === btn));
});
});
const modeBtns = document.querySelectorAll('[data-mode-btn]');
modeBtns.forEach((btn) => {
btn.addEventListener('click', () => {
const mode = btn.dataset.modeBtn;
if (mode === state.mode) return;
state.mode = mode;
modeBtns.forEach((b) => b.classList.toggle('active', b === btn));
/* 切换模式:重置数据与瞬时态 */
state.workspaces = cloneData(mode);
state.currentId = state.workspaces[0] ? state.workspaces[0].workspace_id : '';
state.defaultId = state.currentId;
closeTransient();
if (state.createOpen) closeCreateForm(true);
/* 文案切换 */
headerTitle.textContent = noun();
entryLabel.textContent = noun();
createBtnText.textContent = '新建' + noun();
createName.placeholder = isDocker() ? '项目名称' : '工作区名称(可选)';
createPath.style.display = isDocker() ? 'none' : '';
popover.setAttribute('aria-label', noun());
popover.classList.toggle('docker', isDocker());
setStatus(isDocker()
? 'Docker 模式:叫「项目」,不显示路径,「在文件夹中打开」禁用'
: '宿主机模式:显示路径,可在文件夹中打开');
renderRows();
positionPopover();
});
});
/* ---------- 初始化 ---------- */
renderRows();
setPopoverOpen(true);
})();

View File

@ -0,0 +1,157 @@
<!DOCTYPE html>
<html lang="zh-CN" data-theme="classic">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>工作区菜单重设计 Demo</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<!-- ========== Demo 外框(仅 demo 用,非产品部分) ========== -->
<header class="demo-chrome">
<div class="demo-chrome-title">
<strong>管理工作区菜单 · 重设计 Demo</strong>
<span>对话侧边栏内嵌的工作区切换 + 管理浮层</span>
</div>
<div class="demo-chrome-controls">
<div class="demo-theme-switch" role="group" aria-label="运行模式切换">
<button type="button" data-mode-btn="host" class="active">宿主机</button>
<button type="button" data-mode-btn="docker">Docker</button>
</div>
<div class="demo-theme-switch" role="group" aria-label="主题切换">
<button type="button" data-theme-btn="classic" class="active">经典</button>
<button type="button" data-theme-btn="light">浅色</button>
<button type="button" data-theme-btn="dark">深色</button>
</div>
</div>
</header>
<main class="demo-stage">
<!-- ========== 产品画布:侧边栏 + 浮层 ========== -->
<section class="stage-canvas" id="canvas">
<aside class="sidebar-mock">
<nav class="side-nav">
<button type="button" class="side-nav-row">
<span class="side-icon">
<svg viewBox="0 0 28 28" fill="none" stroke="currentColor" stroke-width="1.45" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 6.5c0-1.38 1.12-2.5 2.5-2.5h13c1.38 0 2.5 1.12 2.5 2.5v8.5c0 1.38-1.12 2.5-2.5 2.5h-5.6l-3.4 3.2.6-3.2H7.5c-1.38 0-2.5-1.12-2.5-2.5V6.5z" />
<path d="M9 9.5h10" /><path d="M9 13h6" />
</svg>
</span>
<span class="side-label">对话记录</span>
</button>
<button type="button" class="side-nav-row">
<span class="side-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />
<path d="m15 5 4 4" />
</svg>
</span>
<span class="side-label">新建对话</span>
</button>
<!-- 新入口:工作区(替代原「工作区 / 虚拟显示器」两个按钮) -->
<button type="button" class="side-nav-row workspace-entry active" id="workspaceEntry" aria-expanded="true">
<span class="side-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z" />
<path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65" />
<path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65" />
</svg>
</span>
<span class="side-label" id="entryLabel">工作区</span>
<span class="side-current" id="entryCurrentName">默认工作区</span>
</button>
</nav>
<div class="side-search">
<span class="side-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" />
</svg>
</span>
<input type="text" placeholder="搜索对话" />
</div>
<div class="side-list">
<div class="conv-row active"><span class="conv-title">前端侧边栏紧凑化改造</span><span class="conv-dot"></span></div>
<div class="conv-row"><span class="conv-title">工作区菜单交互稿评审</span></div>
<div class="conv-row"><span class="conv-title">API 轮询延迟排查记录</span></div>
<div class="conv-row"><span class="conv-title">Android 客户端发版 checklist</span></div>
<div class="conv-row"><span class="conv-title">子智能体通知池设计</span></div>
<div class="conv-row"><span class="conv-title">主题 token 收敛方案</span></div>
<div class="conv-row"><span class="conv-title">部署脚本 venv 修复</span></div>
</div>
<div class="side-footer">
<button type="button" class="side-nav-row">
<span class="side-icon">
<svg viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm-7 9a7 7 0 1 1 14 0H3z" />
</svg>
</span>
<span class="side-label">个人空间</span>
</button>
</div>
</aside>
<!-- ========== 工作区浮层(本次重设计主体) ========== -->
<div class="ws-popover" id="wsPopover" role="dialog" aria-label="工作区">
<div class="ws-header">
<span class="ws-header-title" id="headerTitle">工作区</span>
<span class="ws-header-count" id="wsCount"></span>
</div>
<div class="ws-list" id="wsList"><!-- 行由 JS 渲染 --></div>
<!-- 底部区域:新建按钮 ⇄ 新建表单,容器高度动画切换 -->
<div class="ws-bottom" id="wsBottom">
<div class="ws-footer" id="wsFooter">
<button type="button" class="ws-create-btn" id="createBtn">
<span class="ws-create-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14" /><path d="M12 5v14" />
</svg>
</span>
<span id="createBtnText">新建工作区</span>
</button>
</div>
<form class="ws-create-form" id="createForm" hidden>
<input type="text" id="createName" placeholder="工作区名称(可选)" autocomplete="off" />
<input type="text" id="createPath" placeholder="路径:绝对路径或相对仓库路径" autocomplete="off" />
<div class="ws-create-actions">
<button type="button" class="ws-btn ghost" id="createCancel">取消</button>
<button type="submit" class="ws-btn primary">创建</button>
</div>
</form>
</div>
</div>
<!-- demo 操作反馈条 -->
<div class="stage-status" id="stageStatus">点击工作区行可切换hover 行尾出现「…」菜单</div>
</section>
<!-- ========== 设计说明 ========== -->
<aside class="stage-notes">
<h2>设计决策</h2>
<ul>
<li><b>入口合并</b>:侧边栏新增「工作区」入口(行尾常显当前工作区名),替代原「工作区 / 虚拟显示器」按钮与左侧面板里的管理入口。</li>
<li><b>列表即切换器</b>:工作区行是可选项,单击整行切换;当前行用中性底色标识。</li>
<li><b>管理动作收拢</b>:设为默认 / 重命名 / 在文件夹中打开 / 删除收进每行 hover 的「…」菜单;菜单用 fixed 浮层定位(同个人空间二级菜单),不被浮层裁切。</li>
<li><b>在文件夹中打开</b>:复用输入栏 git 状态区的现成能力仅宿主机模式可用Docker 模式下禁用。</li>
<li><b>全内联操作</b>:新建 / 重命名 / 删除确认都在浮层内联完成;新建区域带高度展开动画。</li>
<li><b>反套娃</b>:单层扁平容器 + 细发丝分隔线;行内不再出现任何边框卡片。</li>
<li><b>对齐</b>:行结构统一;名称 13px / 路径 12px 单行截断;图标与首行文字视觉对齐。</li>
<li><b>token 化</b>:颜色全部走三主题语义 tokenhover / 选中用中性灰accent 仅用于「创建」CTA。</li>
</ul>
<h2>模式差异</h2>
<ul class="notes-compare">
<li>宿主机模式:显示路径行,菜单含「在文件夹中打开」。</li>
<li>Docker 模式:叫「项目」,不显示路径,「在文件夹中打开」禁用,新建只需名称。</li>
</ul>
</aside>
</main>
<script src="app.js"></script>
</body>
</html>

View File

@ -0,0 +1,666 @@
/* ============================================================
工作区菜单重设计 Demo 样式
颜色全部走三主题语义 token static/src/styles/base/_tokens.scss 对齐
============================================================ */
/* ---------- 主题 token取自项目真实值 ---------- */
:root[data-theme='classic'] {
--surface-base: #faf9f5;
--surface-panel: #faf9f5;
--surface-rail: #f5f0e8;
--surface-sidebar: #f5f0e8;
--surface-card: #efe9de;
--surface-raised: #ffffff;
--surface-soft: #f5f0e8;
--surface-muted: #e8e0d2;
--text-primary: #141413;
--text-secondary: #3d3d3a;
--text-tertiary: #6c6a64;
--text-muted: rgba(108, 106, 100, 0.55);
--border-default: #e6dfd8;
--border-strong: #d8cfc4;
--border-card-strong: #e6dfd8;
--accent: #cc785c;
--accent-strong: #a9583e;
--accent-hover: #bd6a4f;
--accent-active: #a9583e;
--on-accent: #ffffff;
--state-success: #10b981;
--state-warning: #f59e0b;
--state-danger: #dc2626;
--state-danger-strong: #b91c1c;
--shadow-pop: 0 1px 2px rgba(60, 50, 40, 0.06), 0 12px 32px rgba(60, 50, 40, 0.1);
--shadow-card: 0 1px 2px rgba(60, 50, 40, 0.05);
}
:root[data-theme='light'] {
--surface-base: #ffffff;
--surface-panel: #ffffff;
--surface-rail: #f9f9f9;
--surface-sidebar: #f9f9f9;
--surface-card: #ffffff;
--surface-raised: #ffffff;
--surface-soft: #f9f9f9;
--surface-muted: #f3f3f3;
--text-primary: #0d0d0d;
--text-secondary: #5d5d5d;
--text-tertiary: #8f8f8f;
--text-muted: rgba(13, 13, 13, 0.35);
--border-default: rgba(13, 13, 13, 0.1);
--border-strong: rgba(13, 13, 13, 0.15);
--border-card-strong: rgba(13, 13, 13, 0.1);
--accent: #181818;
--accent-strong: #181818;
--accent-hover: #303030;
--accent-active: #414141;
--on-accent: #ffffff;
--state-success: #10b981;
--state-warning: #f59e0b;
--state-danger: #dc2626;
--state-danger-strong: #b91c1c;
--shadow-pop: 0 1px 2px rgba(0, 0, 0, 0.05), 0 12px 32px rgba(0, 0, 0, 0.1);
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.05);
}
:root[data-theme='dark'] {
--surface-base: #1a1a1a;
--surface-panel: #1a1a1a;
--surface-rail: #181818;
--surface-sidebar: #181818;
--surface-card: #0a0a0a;
--surface-raised: #242424;
--surface-soft: #0f0f0f;
--surface-muted: #141414;
--text-primary: #ffffff;
--text-secondary: #a0a0a0;
--text-tertiary: #707070;
--text-muted: rgba(255, 255, 255, 0.3);
--border-default: rgba(255, 255, 255, 0.08);
--border-strong: rgba(255, 255, 255, 0.12);
--border-card-strong: rgba(255, 255, 255, 0.1);
--accent: #606060;
--accent-strong: #505050;
--accent-hover: #707070;
--accent-active: #505050;
--on-accent: #ffffff;
--state-success: #10b981;
--state-warning: #f59e0b;
--state-danger: #dc2626;
--state-danger-strong: #b91c1c;
--shadow-pop: 0 1px 2px rgba(0, 0, 0, 0.4), 0 12px 32px rgba(0, 0, 0, 0.5);
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.35);
}
/* ---------- 基础 ---------- */
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Segoe UI', 'Helvetica Neue', sans-serif;
background: var(--surface-base);
color: var(--text-primary);
font-size: 13px;
line-height: 1.45;
-webkit-font-smoothing: antialiased;
transition: background 160ms ease, color 160ms ease;
}
button {
font: inherit;
color: inherit;
background: none;
border: 0;
cursor: pointer;
}
input {
font: inherit;
color: var(--text-primary);
background: transparent;
border: 0;
outline: none;
}
svg { display: block; width: 100%; height: 100%; }
/* ============================================================
Demo 外框非产品部分
============================================================ */
.demo-chrome {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 20px;
border-bottom: 1px solid var(--border-default);
}
.demo-chrome-title { display: flex; flex-direction: column; gap: 2px; }
.demo-chrome-title strong { font-size: 14px; font-weight: 600; }
.demo-chrome-title span { font-size: 12px; color: var(--text-tertiary); }
.demo-chrome-controls { display: flex; align-items: center; gap: 10px; }
.demo-theme-switch {
display: flex;
gap: 2px;
padding: 2px;
border: 1px solid var(--border-default);
border-radius: 8px;
background: var(--surface-soft);
}
.demo-theme-switch button {
height: 26px;
padding: 0 12px;
border-radius: 6px;
font-size: 12px;
color: var(--text-secondary);
}
.demo-theme-switch button.active {
background: var(--surface-raised);
color: var(--text-primary);
box-shadow: var(--shadow-card);
}
.demo-stage {
display: grid;
grid-template-columns: minmax(0, 1fr) 300px;
gap: 24px;
padding: 24px;
align-items: start;
}
.stage-canvas {
position: relative;
min-height: 560px;
border: 1px solid var(--border-default);
border-radius: 12px;
background: var(--surface-panel);
overflow: hidden;
}
.stage-status {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 34px;
display: flex;
align-items: center;
padding: 0 14px;
border-top: 1px solid var(--border-default);
background: var(--surface-soft);
color: var(--text-tertiary);
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ============================================================
侧边栏 Mock紧凑化后的未来形态
============================================================ */
.sidebar-mock {
width: 232px;
height: 526px; /* 560 - 状态条 34 */
display: flex;
flex-direction: column;
background: var(--surface-sidebar);
border-right: 1px solid var(--border-default);
}
.side-nav { padding: 8px; display: flex; flex-direction: column; gap: 1px; }
.side-nav-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
height: 30px; /* 紧凑化:原 ~36px */
padding: 0 8px;
border-radius: 6px; /* 紧凑化:原 ~10px */
color: var(--text-secondary);
font-size: 12.5px;
text-align: left;
}
.side-nav-row:hover { background: color-mix(in srgb, var(--text-primary) 5%, transparent); }
.side-nav-row.active {
background: color-mix(in srgb, var(--text-primary) 7%, transparent);
color: var(--text-primary);
}
.side-icon {
flex: none;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.side-icon svg { width: 15px; height: 15px; }
.side-label { flex: none; }
.side-current {
margin-left: auto;
min-width: 0;
max-width: 88px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
color: var(--text-tertiary);
}
.side-search {
display: flex;
align-items: center;
gap: 7px;
margin: 0 8px;
height: 28px;
padding: 0 8px;
border: 1px solid var(--border-default);
border-radius: 6px;
color: var(--text-tertiary);
}
.side-search .side-icon svg { width: 13px; height: 13px; }
.side-search input { flex: 1; min-width: 0; font-size: 12px; }
.side-search input::placeholder { color: var(--text-muted); }
.side-list {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 8px; /* 与左右间距一致hover 圆角四边均匀 */
display: flex;
flex-direction: column;
gap: 1px;
}
.conv-row {
display: flex;
align-items: center;
gap: 6px;
height: 27px; /* 紧凑化:原 ~34px */
padding: 0 8px;
border-radius: 6px;
font-size: 12px;
color: var(--text-secondary);
cursor: pointer;
}
.conv-row:hover { background: color-mix(in srgb, var(--text-primary) 4%, transparent); }
.conv-row.active {
background: color-mix(in srgb, var(--text-primary) 7%, transparent);
color: var(--text-primary);
}
.conv-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.conv-dot {
flex: none;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--state-success);
}
.side-footer { padding: 8px; border-top: 1px solid var(--border-default); }
/* ============================================================
工作区浮层重设计主体
============================================================ */
.ws-popover {
--ws-row-h: 50px;
position: absolute;
width: 300px;
display: flex;
flex-direction: column;
border: 1px solid var(--border-default);
border-radius: 12px;
background: var(--surface-raised);
box-shadow: var(--shadow-pop);
overflow: hidden;
opacity: 0;
transform: translateY(-4px) scale(0.98);
transform-origin: top left;
pointer-events: none;
transition: opacity 120ms ease, transform 150ms cubic-bezier(0.22, 1, 0.36, 1);
}
.ws-popover.open {
opacity: 1;
transform: translateY(0) scale(1);
pointer-events: auto;
}
.ws-popover.docker { --ws-row-h: 38px; }
/* 头部:安静的类型标题,发丝线分隔 */
.ws-header {
flex: none;
display: flex;
align-items: center;
justify-content: space-between;
height: 42px;
padding: 0 14px;
border-bottom: 1px solid var(--border-default);
}
.ws-header-title { font-size: 12.5px; font-weight: 600; color: var(--text-secondary); }
.ws-header-count { font-size: 11px; color: var(--text-tertiary); }
/* 列表:最多显示 5 个工作区超出内部滚动padding 四边一致保证 hover 圆角均匀 */
.ws-list {
flex: none;
max-height: calc(var(--ws-row-h) * 5 + 16px); /* 5行 + 4条1px间隔 + 上下各6px padding */
overflow-y: auto;
padding: 6px;
display: flex;
flex-direction: column;
gap: 1px;
scrollbar-width: thin;
scrollbar-color: var(--border-strong) transparent;
}
.ws-list::-webkit-scrollbar { width: 8px; }
.ws-list::-webkit-scrollbar-thumb {
background: var(--border-strong);
border-radius: 4px;
border: 2px solid var(--surface-raised);
}
.ws-list::-webkit-scrollbar-track { background: transparent; }
/* 工作区行:可选项,固定高度,宿主机两行 / Docker 单行 */
.ws-row {
position: relative;
display: flex;
align-items: center;
gap: 10px;
width: 100%;
height: var(--ws-row-h);
padding: 0 8px;
border-radius: 8px;
text-align: left;
cursor: pointer;
}
.ws-row:hover { background: color-mix(in srgb, var(--text-primary) 4%, transparent); }
.ws-row.current { background: color-mix(in srgb, var(--text-primary) 6%, transparent); }
.ws-row.current:hover { background: color-mix(in srgb, var(--text-primary) 7%, transparent); }
.ws-row-icon {
flex: none;
width: 17px;
height: 17px;
margin-top: -13px; /* 与首行文字视觉对齐,而非两行整体居中 */
color: var(--text-tertiary);
}
.ws-row.docker .ws-row-icon { margin-top: 0; }
.ws-row.current .ws-row-icon { color: var(--text-secondary); }
.ws-row-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.ws-row-name {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ws-row-path {
font-size: 11.5px;
color: var(--text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ws-row-meta {
flex: none;
display: flex;
align-items: center;
gap: 8px;
}
.ws-tag {
font-size: 11px;
color: var(--text-tertiary);
white-space: nowrap;
}
/* 行尾「…」:仅 hover / 菜单展开时出现 */
.ws-more {
flex: none;
width: 24px;
height: 24px;
display: none;
align-items: center;
justify-content: center;
border-radius: 6px;
color: var(--text-tertiary);
}
.ws-more svg { width: 15px; height: 15px; }
.ws-more:hover { color: var(--text-primary); background: color-mix(in srgb, var(--text-primary) 7%, transparent); }
.ws-row:hover .ws-more,
.ws-row.menu-open .ws-more { display: flex; }
/* 「…」二级菜单fixed 浮层,逃逸浮层裁切(同个人空间二级菜单方案) */
.ws-menu-floating {
position: fixed;
z-index: 3000;
min-width: 136px;
padding: 4px;
border: 1px solid var(--border-default);
border-radius: 10px;
background: var(--surface-raised);
box-shadow: var(--shadow-pop);
display: none;
flex-direction: column;
opacity: 0;
transform: translateY(-3px) scale(0.98);
transform-origin: top right;
transition: opacity 110ms ease, transform 140ms cubic-bezier(0.22, 1, 0.36, 1);
}
.ws-menu-floating.open { opacity: 1; transform: translateY(0) scale(1); }
.ws-menu-floating button {
display: flex;
align-items: center;
width: 100%;
height: 30px;
padding: 0 10px;
border-radius: 6px;
font-size: 12.5px;
color: var(--text-secondary);
text-align: left;
white-space: nowrap;
}
.ws-menu-floating button:hover { background: color-mix(in srgb, var(--text-primary) 5%, transparent); color: var(--text-primary); }
.ws-menu-floating button.danger { color: var(--state-danger); }
.ws-menu-floating button.danger:hover { background: color-mix(in srgb, var(--state-danger) 8%, transparent); }
.ws-menu-floating button:disabled { color: var(--text-muted); cursor: default; }
.ws-menu-floating button:disabled:hover { background: none; color: var(--text-muted); }
.ws-menu-sep { height: 1px; margin: 4px 6px; background: var(--border-default); }
/* 重命名内联输入:替换名称文字 */
.ws-rename-input {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
border-bottom: 1px solid var(--accent);
padding: 1px 0;
width: 100%;
}
/* 删除确认内联:整行内容替换 */
.ws-confirm {
display: flex;
flex-direction: column;
gap: 5px;
width: 100%;
padding: 2px 0;
}
.ws-confirm-text {
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ws-confirm-actions { display: flex; gap: 8px; }
/* 通用小按钮 */
.ws-btn {
height: 26px;
padding: 0 10px;
border-radius: 6px;
font-size: 12px;
border: 1px solid var(--border-strong);
color: var(--text-secondary);
white-space: nowrap;
}
.ws-btn:hover { background: color-mix(in srgb, var(--text-primary) 5%, transparent); }
.ws-btn.primary {
background: var(--accent);
border-color: var(--accent);
color: var(--on-accent);
}
.ws-btn.primary:hover { background: var(--accent-hover); }
.ws-btn.danger {
background: var(--state-danger);
border-color: var(--state-danger);
color: #ffffff;
}
.ws-btn.danger:hover { background: var(--state-danger-strong); }
.ws-btn.ghost { border-color: var(--border-default); }
/* 底部区域:新建按钮 ⇄ 新建表单,容器高度动画 */
.ws-bottom {
flex: none;
border-top: 1px solid var(--border-default);
overflow: hidden;
transition: height 200ms cubic-bezier(0.22, 1, 0.36, 1);
}
.ws-footer { padding: 6px; transition: opacity 130ms ease; }
.ws-footer.fading { opacity: 0; }
.ws-create-btn {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
height: 34px;
padding: 0 8px;
border-radius: 8px;
font-size: 12.5px;
color: var(--text-secondary);
}
.ws-create-btn:hover { background: color-mix(in srgb, var(--text-primary) 4%, transparent); color: var(--text-primary); }
.ws-create-icon { flex: none; width: 15px; height: 15px; color: var(--text-tertiary); }
.ws-create-btn:hover .ws-create-icon { color: var(--text-secondary); }
/* 新建表单:内联,平铺无卡片 */
.ws-create-form {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
transition: opacity 150ms ease;
}
.ws-create-form.fading { opacity: 0; }
.ws-create-form input {
height: 32px;
padding: 0 10px;
border: 1px solid var(--border-default);
border-radius: 8px;
font-size: 12.5px;
background: var(--surface-soft);
}
.ws-create-form input:focus { border-color: var(--accent); }
.ws-create-form input::placeholder { color: var(--text-muted); }
.ws-create-actions { display: flex; justify-content: flex-end; gap: 8px; }
/* 隐藏互斥切换 */
.ws-footer[hidden], .ws-create-form[hidden] { display: none; }
/* ============================================================
设计说明栏
============================================================ */
.stage-notes {
border: 1px solid var(--border-default);
border-radius: 12px;
background: var(--surface-soft);
padding: 16px 18px;
font-size: 12.5px;
color: var(--text-secondary);
}
.stage-notes h2 {
font-size: 12px;
font-weight: 600;
color: var(--text-tertiary);
margin: 14px 0 8px;
letter-spacing: 0.02em;
}
.stage-notes h2:first-child { margin-top: 0; }
.stage-notes ul { list-style: none; display: flex; flex-direction: column; gap: 7px; }
.stage-notes li { padding-left: 12px; position: relative; line-height: 1.55; }
.stage-notes li::before {
content: '';
position: absolute;
left: 0;
top: 8px;
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--text-muted);
}
.stage-notes li b { color: var(--text-primary); font-weight: 600; }
.notes-compare li { color: var(--text-tertiary); }
@media (max-width: 980px) {
.demo-stage { grid-template-columns: 1fr; }
}

View File

@ -66,11 +66,13 @@
:current-task-in-progress="taskInProgress" :current-task-in-progress="taskInProgress"
:current-conversation-id="currentConversationId" :current-conversation-id="currentConversationId"
:current-workspace-id="currentHostWorkspaceId" :current-workspace-id="currentHostWorkspaceId"
:display-mode="chatDisplayMode" :default-workspace-id="defaultHostWorkspaceId"
:display-mode-disabled="displayModeSwitchDisabled"
:workspaces="hostWorkspaces" :workspaces="hostWorkspaces"
:workspace-kind="versioningHostMode ? 'workspace' : 'project'" :workspace-kind="versioningHostMode ? 'workspace' : 'project'"
:host-workspace-enabled="versioningHostMode || dockerProjectMode" :host-workspace-enabled="versioningHostMode || dockerProjectMode"
:workspace-busy="hostWorkspaceSwitching || hostWorkspaceManageSubmitting"
:workspace-create-submitting="hostWorkspaceCreateSubmitting"
:workspace-error="hostWorkspaceCreateError"
:group-by-workspace="personalizationStore?.form?.group_sidebar_by_workspace" :group-by-workspace="personalizationStore?.form?.group_sidebar_by_workspace"
:versioning-host-mode="versioningHostMode" :versioning-host-mode="versioningHostMode"
@toggle="toggleSidebar" @toggle="toggleSidebar"
@ -84,8 +86,10 @@
@personal="openPersonalPage" @personal="openPersonalPage"
@delete="deleteConversation" @delete="deleteConversation"
@duplicate="duplicateConversation" @duplicate="duplicateConversation"
@toggle-workspace="handleWorkspaceToggle" @switch-workspace="handleHostWorkspaceSwitch"
@toggle-display-mode="handleDisplayModeToggle" @create-workspace="submitHostWorkspaceCreateFromManage"
@delete-workspace="handleDeleteWorkspaceFromSwitcher"
@set-default-workspace="setDefaultHostWorkspace"
@select-workspace-conversation="handleSelectWorkspaceConversation" @select-workspace-conversation="handleSelectWorkspaceConversation"
@create-workspace-conversation="createWorkspaceConversation" @create-workspace-conversation="createWorkspaceConversation"
@reveal-workspace="revealHostWorkspace" @reveal-workspace="revealHostWorkspace"
@ -93,47 +97,9 @@
@pin-workspace="handlePinWorkspaceFromSidebar" @pin-workspace="handlePinWorkspaceFromSidebar"
/> />
<div v-if="!isMobileViewport" class="workspace-region">
<LeftPanel
ref="leftPanel"
class="workspace-panel"
:class="{ 'workspace-panel--collapsed': workspaceCollapsed }"
:width="leftWidth"
:collapsed="workspaceCollapsed"
:icon-style="iconStyle"
:agent-version="agentVersion"
:thinking-mode="thinkingMode"
:run-mode="resolvedRunMode"
:is-connected="isConnected"
:panel-menu-open="panelMenuOpen"
:panel-mode="panelMode"
:file-manager-disabled="policyUiBlocks.block_file_manager"
:host-workspace-enabled="versioningHostMode || dockerProjectMode"
:workspace-kind="versioningHostMode ? 'workspace' : 'project'"
:host-workspaces="hostWorkspaces"
:host-workspace-id="currentHostWorkspaceId"
:host-workspace-default-id="defaultHostWorkspaceId"
:host-workspace-switching="hostWorkspaceSwitching"
@toggle-panel-menu="togglePanelMenu"
@select-panel="selectPanelMode"
@open-file-manager="openGuiFileManager"
@toggle-thinking-mode="handleQuickModeToggle"
@switch-host-workspace="handleHostWorkspaceSwitch"
@create-host-workspace="handleCreateHostWorkspace"
@manage-host-workspace="openHostWorkspaceManageDialog"
/>
<div
v-if="!workspaceCollapsed"
class="resize-handle"
@mousedown="startResize('left', $event)"
></div>
</div>
<main <main
class="chat-container" class="chat-container"
:class="{ :class="{
'chat-container--immersive': workspaceCollapsed,
'chat-container--mobile': isMobileViewport, 'chat-container--mobile': isMobileViewport,
'chat-container--app-shell': isAppShell, 'chat-container--app-shell': isAppShell,
'chat-container--monitor': chatDisplayMode === 'monitor', 'chat-container--monitor': chatDisplayMode === 'monitor',
@ -351,6 +317,7 @@
@send-message="sendMessage" @send-message="sendMessage"
@send-or-stop="handleSendOrStop" @send-or-stop="handleSendOrStop"
@quick-upload="handleQuickUpload" @quick-upload="handleQuickUpload"
@open-file-manager="openGuiFileManager"
@toggle-tool-menu="toggleToolMenu" @toggle-tool-menu="toggleToolMenu"
@toggle-mode-menu="toggleModeMenu" @toggle-mode-menu="toggleModeMenu"
@toggle-model-menu="toggleModelMenu" @toggle-model-menu="toggleModelMenu"
@ -386,11 +353,11 @@
</div> </div>
</main> </main>
<QuickDock <QuickDock
v-if="!isMobileViewport && !personalizationStore?.form?.hide_quick_dock" v-if="!isMobileViewport"
:host-mode="versioningHostMode" :host-mode="versioningHostMode"
/> />
<FilePreviewPanel <FilePreviewPanel
v-if="!isMobileViewport && !personalizationStore?.form?.hide_quick_dock" v-if="!isMobileViewport"
/> />
<div <div
v-if="!isMobileViewport && (terminalPanelOpen || gitChangesPanelOpen)" v-if="!isMobileViewport && (terminalPanelOpen || gitChangesPanelOpen)"
@ -556,38 +523,6 @@
@confirm="confirmVersioningRestore" @confirm="confirmVersioningRestore"
/> />
</transition> </transition>
<transition name="overlay-fade">
<HostWorkspaceCreateDialog
v-if="hostWorkspaceCreateDialogOpen"
:open="hostWorkspaceCreateDialogOpen"
:path-value="hostWorkspaceCreatePath"
:label-value="hostWorkspaceCreateLabel"
:submitting="hostWorkspaceCreateSubmitting"
:error-message="hostWorkspaceCreateError"
:workspace-kind="versioningHostMode ? 'workspace' : 'project'"
@close="closeHostWorkspaceCreateDialog"
@submit="submitHostWorkspaceCreate"
@update:path-value="hostWorkspaceCreatePath = $event"
@update:label-value="hostWorkspaceCreateLabel = $event"
/>
</transition>
<transition name="overlay-fade">
<HostWorkspaceManageDialog
v-if="hostWorkspaceManageDialogOpen"
:workspaces="hostWorkspaces"
:current-workspace-id="currentHostWorkspaceId"
:default-workspace-id="defaultHostWorkspaceId"
:workspace-kind="versioningHostMode ? 'workspace' : 'project'"
:busy="hostWorkspaceCreateSubmitting || hostWorkspaceManageSubmitting"
:create-submitting="hostWorkspaceCreateSubmitting"
:error-message="hostWorkspaceCreateError"
@close="closeHostWorkspaceManageDialog"
@create="submitHostWorkspaceCreateFromManage"
@rename="renameHostWorkspace"
@delete="deleteHostWorkspace"
@set-default="setDefaultHostWorkspace"
/>
</transition>
<TutorialOverlay v-if="tutorialStore.running" /> <TutorialOverlay v-if="tutorialStore.running" />
<NewUserTutorialPrompt <NewUserTutorialPrompt
:visible="tutorialPromptVisible" :visible="tutorialPromptVisible"
@ -664,21 +599,6 @@
</svg> </svg>
<div class="item-label">对话记录</div> <div class="item-label">对话记录</div>
</button> </button>
<button
type="button"
class="dropdown-item mobile-menu-item"
data-tutorial="mobile-menu-workspace"
aria-label="工作文件"
@click="openMobileOverlay('workspace')"
>
<img
class="mobile-menu-icon"
:src="mobileMenuIcons.workspace"
alt=""
aria-hidden="true"
/>
<div class="item-label">工作文件</div>
</button>
<button <button
type="button" type="button"
class="dropdown-item mobile-menu-item mobile-menu-item--personal" class="dropdown-item mobile-menu-item mobile-menu-item--personal"
@ -790,11 +710,13 @@
:current-task-in-progress="taskInProgress" :current-task-in-progress="taskInProgress"
:current-conversation-id="currentConversationId" :current-conversation-id="currentConversationId"
:current-workspace-id="currentHostWorkspaceId" :current-workspace-id="currentHostWorkspaceId"
:display-mode="chatDisplayMode" :default-workspace-id="defaultHostWorkspaceId"
:display-mode-disabled="displayModeSwitchDisabled"
:workspaces="hostWorkspaces" :workspaces="hostWorkspaces"
:workspace-kind="versioningHostMode ? 'workspace' : 'project'" :workspace-kind="versioningHostMode ? 'workspace' : 'project'"
:host-workspace-enabled="versioningHostMode || dockerProjectMode" :host-workspace-enabled="versioningHostMode || dockerProjectMode"
:workspace-busy="hostWorkspaceSwitching || hostWorkspaceManageSubmitting"
:workspace-create-submitting="hostWorkspaceCreateSubmitting"
:workspace-error="hostWorkspaceCreateError"
:group-by-workspace="personalizationStore?.form?.group_sidebar_by_workspace" :group-by-workspace="personalizationStore?.form?.group_sidebar_by_workspace"
:versioning-host-mode="versioningHostMode" :versioning-host-mode="versioningHostMode"
:show-collapse-button="true" :show-collapse-button="true"
@ -810,7 +732,10 @@
@personal="openPersonalPage" @personal="openPersonalPage"
@delete="deleteConversation" @delete="deleteConversation"
@duplicate="duplicateConversation" @duplicate="duplicateConversation"
@toggle-display-mode="handleDisplayModeToggle" @switch-workspace="handleHostWorkspaceSwitch"
@create-workspace="submitHostWorkspaceCreateFromManage"
@delete-workspace="handleDeleteWorkspaceFromSwitcher"
@set-default-workspace="setDefaultHostWorkspace"
@select-workspace-conversation="handleSelectWorkspaceConversation" @select-workspace-conversation="handleSelectWorkspaceConversation"
@create-workspace-conversation="createWorkspaceConversation" @create-workspace-conversation="createWorkspaceConversation"
@reveal-workspace="revealHostWorkspace" @reveal-workspace="revealHostWorkspace"
@ -821,49 +746,6 @@
</div> </div>
</transition> </transition>
<transition name="mobile-panel-overlay">
<div
v-if="isMobileViewport && activeMobileOverlay === 'workspace'"
class="mobile-panel-overlay mobile-panel-overlay--left"
@click.self="closeMobileOverlay"
>
<div class="mobile-panel-sheet mobile-panel-sheet--workspace">
<button
type="button"
class="mobile-overlay-close"
data-tutorial="mobile-workspace-close"
aria-label="关闭面板"
@click="closeMobileOverlay"
>
×
</button>
<LeftPanel
class="mobile-overlay-content"
:width="Math.min(leftWidth, 420)"
:icon-style="iconStyle"
:agent-version="agentVersion"
:thinking-mode="thinkingMode"
:run-mode="resolvedRunMode"
:is-connected="isConnected"
:panel-menu-open="panelMenuOpen"
:panel-mode="panelMode"
:host-workspace-enabled="versioningHostMode || dockerProjectMode"
:workspace-kind="versioningHostMode ? 'workspace' : 'project'"
:host-workspaces="hostWorkspaces"
:host-workspace-id="currentHostWorkspaceId"
:host-workspace-default-id="defaultHostWorkspaceId"
:host-workspace-switching="hostWorkspaceSwitching"
@toggle-panel-menu="togglePanelMenu"
@select-panel="selectPanelMode"
@open-file-manager="openGuiFileManager"
@switch-host-workspace="handleHostWorkspaceSwitch"
@create-host-workspace="handleCreateHostWorkspace"
@manage-host-workspace="openHostWorkspaceManageDialog"
/>
</div>
</div>
</transition>
<transition name="mobile-panel-overlay"> <transition name="mobile-panel-overlay">
<div <div
v-if="isMobileViewport && activeMobileOverlay === 'approval'" v-if="isMobileViewport && activeMobileOverlay === 'approval'"
@ -932,7 +814,6 @@ onMounted(() => {
}); });
const mobileMenuIcons = { const mobileMenuIcons = {
workspace: new URL('../icons/folder.svg', import.meta.url).href,
personal: new URL('../icons/user.svg', import.meta.url).href, personal: new URL('../icons/user.svg', import.meta.url).href,
newChat: new URL('../icons/pencil.svg', import.meta.url).href, newChat: new URL('../icons/pencil.svg', import.meta.url).href,
refresh: new URL('../icons/refresh-cw.svg', import.meta.url).href refresh: new URL('../icons/refresh-cw.svg', import.meta.url).href

View File

@ -56,12 +56,7 @@ const appOptions = {
...mapActions(useUiStore, { ...mapActions(useUiStore, {
uiToggleSidebar: 'toggleSidebar', uiToggleSidebar: 'toggleSidebar',
uiSetSidebarCollapsed: 'setSidebarCollapsed', uiSetSidebarCollapsed: 'setSidebarCollapsed',
uiSetWorkspaceCollapsed: 'setWorkspaceCollapsed',
uiToggleWorkspaceCollapsed: 'toggleWorkspaceCollapsed',
uiSetChatDisplayMode: 'setChatDisplayMode', uiSetChatDisplayMode: 'setChatDisplayMode',
uiSetPanelMode: 'setPanelMode',
uiSetPanelMenuOpen: 'setPanelMenuOpen',
uiTogglePanelMenu: 'togglePanelMenu',
uiSetMobileViewport: 'setIsMobileViewport', uiSetMobileViewport: 'setIsMobileViewport',
uiSetMobileOverlayMenuOpen: 'setMobileOverlayMenuOpen', uiSetMobileOverlayMenuOpen: 'setMobileOverlayMenuOpen',
uiToggleMobileOverlayMenu: 'toggleMobileOverlayMenu', uiToggleMobileOverlayMenu: 'toggleMobileOverlayMenu',
@ -186,14 +181,10 @@ const appOptions = {
forceUnlockMonitor: 'resetVisualState' forceUnlockMonitor: 'resetVisualState'
}), }),
...mapActions(useSubAgentStore, { ...mapActions(useSubAgentStore, {
subAgentFetch: 'fetchSubAgents', subAgentFetch: 'fetchSubAgents'
subAgentStartPolling: 'startPolling',
subAgentStopPolling: 'stopPolling'
}), }),
...mapActions(useBackgroundCommandStore, { ...mapActions(useBackgroundCommandStore, {
backgroundCommandFetch: 'fetchCommands', backgroundCommandFetch: 'fetchCommands'
backgroundCommandStartPolling: 'startPolling',
backgroundCommandStopPolling: 'stopPolling'
}), }),
...mapActions(useFocusStore, { ...mapActions(useFocusStore, {
focusFetchFiles: 'fetchFocusedFiles', focusFetchFiles: 'fetchFocusedFiles',

View File

@ -1,7 +1,6 @@
import { defineAsyncComponent } from 'vue'; import { defineAsyncComponent } from 'vue';
import ChatArea from '../components/chat/ChatArea.vue'; import ChatArea from '../components/chat/ChatArea.vue';
import ConversationSidebar from '../components/sidebar/ConversationSidebar.vue'; import ConversationSidebar from '../components/sidebar/ConversationSidebar.vue';
import LeftPanel from '../components/panels/LeftPanel.vue';
import ToolApprovalPanel from '../components/panels/ToolApprovalPanel.vue'; import ToolApprovalPanel from '../components/panels/ToolApprovalPanel.vue';
import GitChangesPanel from '../components/panels/GitChangesPanel.vue'; import GitChangesPanel from '../components/panels/GitChangesPanel.vue';
import TerminalPanel from '../components/panels/TerminalPanel.vue'; import TerminalPanel from '../components/panels/TerminalPanel.vue';
@ -28,12 +27,6 @@ const BackgroundCommandDialog = defineAsyncComponent(
const VersioningDialog = defineAsyncComponent( const VersioningDialog = defineAsyncComponent(
() => import('../components/overlay/VersioningDialog.vue') () => import('../components/overlay/VersioningDialog.vue')
); );
const HostWorkspaceCreateDialog = defineAsyncComponent(
() => import('../components/overlay/HostWorkspaceCreateDialog.vue')
);
const HostWorkspaceManageDialog = defineAsyncComponent(
() => import('../components/overlay/HostWorkspaceManageDialog.vue')
);
const UserQuestionDialog = defineAsyncComponent( const UserQuestionDialog = defineAsyncComponent(
() => import('../components/overlay/UserQuestionDialog.vue') () => import('../components/overlay/UserQuestionDialog.vue')
); );
@ -50,7 +43,6 @@ const GoalProgressDialog = defineAsyncComponent(
export const appComponents = { export const appComponents = {
ChatArea, ChatArea,
ConversationSidebar, ConversationSidebar,
LeftPanel,
ToolApprovalPanel, ToolApprovalPanel,
GitChangesPanel, GitChangesPanel,
TerminalPanel, TerminalPanel,
@ -66,8 +58,6 @@ export const appComponents = {
SubAgentActivityDialog, SubAgentActivityDialog,
BackgroundCommandDialog, BackgroundCommandDialog,
VersioningDialog, VersioningDialog,
HostWorkspaceCreateDialog,
HostWorkspaceManageDialog,
UserQuestionDialog, UserQuestionDialog,
TutorialOverlay, TutorialOverlay,
NewUserTutorialPrompt, NewUserTutorialPrompt,

View File

@ -67,11 +67,7 @@ export const computed = {
...mapState(useFileStore, ['contextMenu', 'fileTree', 'expandedFolders', 'todoList']), ...mapState(useFileStore, ['contextMenu', 'fileTree', 'expandedFolders', 'todoList']),
...mapWritableState(useUiStore, [ ...mapWritableState(useUiStore, [
'sidebarCollapsed', 'sidebarCollapsed',
'workspaceCollapsed',
'chatDisplayMode', 'chatDisplayMode',
'panelMode',
'panelMenuOpen',
'leftWidth',
'rightWidth', 'rightWidth',
'rightCollapsed', 'rightCollapsed',
'isResizing', 'isResizing',

View File

@ -70,7 +70,6 @@ export async function mounted() {
} }
document.addEventListener('click', this.handleClickOutsideQuickMenu); document.addEventListener('click', this.handleClickOutsideQuickMenu);
document.addEventListener('click', this.handleClickOutsidePanelMenu);
document.addEventListener('click', this.handleClickOutsideHeaderMenu); document.addEventListener('click', this.handleClickOutsideHeaderMenu);
document.addEventListener('click', this.handleClickOutsideMobileMenu); document.addEventListener('click', this.handleClickOutsideMobileMenu);
document.addEventListener('click', this.handleCopyCodeClick); document.addEventListener('click', this.handleCopyCodeClick);
@ -80,9 +79,7 @@ export async function mounted() {
this.setupMobileViewportWatcher(); this.setupMobileViewportWatcher();
this.subAgentFetch(); this.subAgentFetch();
this.subAgentStartPolling();
this.backgroundCommandFetch(); this.backgroundCommandFetch();
this.backgroundCommandStartPolling();
this.$nextTick(() => { this.$nextTick(() => {
this.autoResizeInput(); this.autoResizeInput();
@ -112,7 +109,6 @@ export function beforeUnmount() {
} }
document.removeEventListener('click', this.handleClickOutsideQuickMenu); document.removeEventListener('click', this.handleClickOutsideQuickMenu);
document.removeEventListener('click', this.handleClickOutsidePanelMenu);
document.removeEventListener('click', this.handleClickOutsideHeaderMenu); document.removeEventListener('click', this.handleClickOutsideHeaderMenu);
document.removeEventListener('click', this.handleClickOutsideMobileMenu); document.removeEventListener('click', this.handleClickOutsideMobileMenu);
document.removeEventListener('click', this.handleCopyCodeClick); document.removeEventListener('click', this.handleCopyCodeClick);
@ -120,8 +116,6 @@ export function beforeUnmount() {
window.removeEventListener('keydown', this.handleMobileOverlayEscape); window.removeEventListener('keydown', this.handleMobileOverlayEscape);
window.removeEventListener('beforeunload', this.handleBeforeUnloadDraftPersist); window.removeEventListener('beforeunload', this.handleBeforeUnloadDraftPersist);
this.teardownMobileViewportWatcher(); this.teardownMobileViewportWatcher();
this.subAgentStopPolling();
this.backgroundCommandStopPolling();
this.stopProjectGitSummaryIdleRefresh?.(); this.stopProjectGitSummaryIdleRefresh?.();
this.stopTerminalCountIdleRefresh?.(); this.stopTerminalCountIdleRefresh?.();
this.resourceStopContainerStatsPolling(); this.resourceStopContainerStatsPolling();

View File

@ -30,20 +30,6 @@ export const dialogMethods = {
} }
this.personalizationOpenDrawer(); this.personalizationOpenDrawer();
}, },
closeHostWorkspaceManageDialog() {
if (this.hostWorkspaceCreateSubmitting || this.hostWorkspaceManageSubmitting) {
return;
}
this.hostWorkspaceManageDialogOpen = false;
this.hostWorkspaceCreateError = '';
},
closeHostWorkspaceCreateDialog() {
if (this.hostWorkspaceCreateSubmitting) {
return;
}
this.hostWorkspaceCreateDialogOpen = false;
this.hostWorkspaceCreateError = '';
},
minimizeUserQuestionDialog() { minimizeUserQuestionDialog() {
if (!Array.isArray(this.pendingUserQuestions) || !this.pendingUserQuestions.length) { if (!Array.isArray(this.pendingUserQuestions) || !this.pendingUserQuestions.length) {
return; return;

View File

@ -154,26 +154,6 @@ export const hostWorkspaceMethods = {
this.hostWorkspaceSwitching = false; this.hostWorkspaceSwitching = false;
} }
}, },
async handleCreateHostWorkspace() {
if (!(this.versioningHostMode || this.dockerProjectMode)) {
return;
}
if (this.hostWorkspaceSwitching || this.hostWorkspaceCreateSubmitting) {
return;
}
this.hostWorkspaceCreatePath = '';
this.hostWorkspaceCreateLabel = '';
this.hostWorkspaceCreateError = '';
this.hostWorkspaceCreateDialogOpen = true;
},
async openHostWorkspaceManageDialog() {
if (!(this.versioningHostMode || this.dockerProjectMode)) {
return;
}
this.hostWorkspaceCreateError = '';
await this.fetchHostWorkspaces();
this.hostWorkspaceManageDialogOpen = true;
},
async submitHostWorkspaceCreate() { async submitHostWorkspaceCreate() {
if (!(this.versioningHostMode || this.dockerProjectMode)) { if (!(this.versioningHostMode || this.dockerProjectMode)) {
return; return;
@ -279,7 +259,11 @@ export const hostWorkspaceMethods = {
this.hostWorkspaceManageSubmitting = false; this.hostWorkspaceManageSubmitting = false;
} }
}, },
async deleteHostWorkspace(item) { async handleDeleteWorkspaceFromSwitcher(item) {
/* 切换浮层已内联确认,跳过全局确认弹窗 */
await this.deleteHostWorkspace(item, { skipConfirm: true });
},
async deleteHostWorkspace(item, opts = {}) {
if (!(this.versioningHostMode || this.dockerProjectMode)) { if (!(this.versioningHostMode || this.dockerProjectMode)) {
return; return;
} }
@ -287,7 +271,7 @@ export const hostWorkspaceMethods = {
if (!workspaceId) return; if (!workspaceId) return;
const kindLabel = this.versioningHostMode ? '工作区' : '项目'; const kindLabel = this.versioningHostMode ? '工作区' : '项目';
const wasCurrent = workspaceId === this.currentHostWorkspaceId; const wasCurrent = workspaceId === this.currentHostWorkspaceId;
const ok = await this.confirmAction({ const ok = opts.skipConfirm ? true : await this.confirmAction({
title: `删除${kindLabel}`, title: `删除${kindLabel}`,
message: this.versioningHostMode message: this.versioningHostMode
? `确定要从列表中删除“${item?.label || workspaceId}”吗?不会删除磁盘上的工作区文件夹。` ? `确定要从列表中删除“${item?.label || workspaceId}”吗?不会删除磁盘上的工作区文件夹。`

View File

@ -75,16 +75,5 @@ export const menuMethods = {
return; return;
} }
this.closeHeaderMenu(); this.closeHeaderMenu();
},
handleClickOutsidePanelMenu(event) {
if (!this.panelMenuOpen) {
return;
}
const leftPanel = this.$refs.leftPanel;
const wrapper = leftPanel && leftPanel.panelMenuWrapper ? leftPanel.panelMenuWrapper : null;
if (wrapper && wrapper.contains(event.target)) {
return;
}
this.uiSetPanelMenuOpen(false);
} }
}; };

View File

@ -31,24 +31,6 @@ export const panelMethods = {
} }
this.uiToggleSidebar(); this.uiToggleSidebar();
}, },
togglePanelMenu() {
this.uiTogglePanelMenu();
},
selectPanelMode(mode) {
this.uiSetPanelMode(mode);
this.uiSetPanelMenuOpen(false);
if (mode === 'subAgents') {
// 切换到子智能体面板时立即刷新,避免等待轮询间隔
this.subAgentFetch();
this.subAgentStartPolling();
return;
}
if (mode === 'backgroundCommands') {
// 切换到后台指令面板时立即刷新,避免等待轮询间隔
this.backgroundCommandFetch();
this.backgroundCommandStartPolling();
}
},
toggleFocusPanel() { toggleFocusPanel() {
this.rightCollapsed = !this.rightCollapsed; this.rightCollapsed = !this.rightCollapsed;
if (!this.rightCollapsed && this.rightWidth < this.minPanelWidth) { if (!this.rightCollapsed && this.rightWidth < this.minPanelWidth) {
@ -138,35 +120,6 @@ export const panelMethods = {
console.log('[UI_DEBUG] handleTokenPanelToggleClick: calling toggleTokenPanel'); console.log('[UI_DEBUG] handleTokenPanelToggleClick: calling toggleTokenPanel');
this.toggleTokenPanel(); this.toggleTokenPanel();
}, },
handleWorkspaceToggle() {
if (this.isMobileViewport) {
return;
}
if (this.isPolicyBlocked('collapse_workspace', '工作区已被管理员强制折叠')) {
this.uiSetWorkspaceCollapsed(true);
return;
}
const nextState = !this.workspaceCollapsed;
this.uiSetWorkspaceCollapsed(nextState);
if (nextState) {
this.uiSetPanelMenuOpen(false);
}
},
handleDisplayModeToggle() {
if (this.displayModeSwitchDisabled) {
// 显式提示管理员禁用
this.isPolicyBlocked('block_virtual_monitor', '虚拟显示器已被管理员禁用');
return;
}
if (
this.chatDisplayMode === 'chat' &&
this.isPolicyBlocked('block_virtual_monitor', '虚拟显示器已被管理员禁用')
) {
return;
}
const next = this.chatDisplayMode === 'chat' ? 'monitor' : 'chat';
this.uiSetChatDisplayMode(next);
},
getMessagesAreaElement() { getMessagesAreaElement() {
const ref = this.$refs.messagesArea; const ref = this.$refs.messagesArea;
if (!ref) { if (!ref) {

View File

@ -1,5 +1,6 @@
// @ts-nocheck // @ts-nocheck
import { debugLog } from '../common'; import { debugLog } from '../common';
import { persistWorkspaceMode } from '../../state';
import { usePolicyStore } from '../../../stores/policy'; import { usePolicyStore } from '../../../stores/policy';
import { useModelStore } from '../../../stores/model'; import { useModelStore } from '../../../stores/model';
import { usePersonalizationStore } from '../../../stores/personalization'; import { usePersonalizationStore } from '../../../stores/personalization';
@ -307,13 +308,13 @@ export const socketMethods = {
const isHostMode = statusData?.container?.mode === 'host'; const isHostMode = statusData?.container?.mode === 'host';
this.versioningHostMode = !!isHostMode; this.versioningHostMode = !!isHostMode;
this.dockerProjectMode = !isHostMode; this.dockerProjectMode = !isHostMode;
persistWorkspaceMode(!!isHostMode);
if (isHostMode) { if (isHostMode) {
this.fileMarkTreeUnavailable('宿主机模式下文件树不可用'); this.fileMarkTreeUnavailable('宿主机模式下文件树不可用');
await this.fetchHostWorkspaces(); await this.fetchHostWorkspaces();
} else { } else {
this.fileMarkTreeUnavailable('Docker 模式下文件区已改为项目列表'); this.fileMarkTreeUnavailable('Docker 模式下文件区已改为项目列表');
await this.fetchHostWorkspaces(); await this.fetchHostWorkspaces();
this.hostWorkspaceCreateDialogOpen = false;
this.hostWorkspaceCreatePath = ''; this.hostWorkspaceCreatePath = '';
this.hostWorkspaceCreateLabel = ''; this.hostWorkspaceCreateLabel = '';
this.hostWorkspaceCreateError = ''; this.hostWorkspaceCreateError = '';

View File

@ -1,5 +1,6 @@
// @ts-nocheck // @ts-nocheck
import { debugLog } from './common'; import { debugLog } from './common';
import { persistWorkspaceMode } from '../state';
const normalizeTrackingMode = (value: any): 'workspace_and_conversation' | 'conversation_only' => { const normalizeTrackingMode = (value: any): 'workspace_and_conversation' | 'conversation_only' => {
return String(value || '').toLowerCase() === 'conversation_only' return String(value || '').toLowerCase() === 'conversation_only'
@ -23,6 +24,7 @@ export const versioningMethods = {
} }
const payload = data.data || {}; const payload = data.data || {};
this.versioningHostMode = !!payload.host_mode; this.versioningHostMode = !!payload.host_mode;
persistWorkspaceMode(!!payload.host_mode);
this.versioningEnabled = !!payload.enabled; this.versioningEnabled = !!payload.enabled;
this.versioningMode = 'overwrite'; this.versioningMode = 'overwrite';
this.versioningRestoreMode = 'overwrite'; this.versioningRestoreMode = 'overwrite';

View File

@ -1,6 +1,30 @@
// @ts-nocheck // @ts-nocheck
import { ICONS, TOOL_CATEGORY_ICON_MAP } from '../utils/icons'; import { ICONS, TOOL_CATEGORY_ICON_MAP } from '../utils/icons';
/* host/docker
flagsocket
*/
const WORKSPACE_MODE_STORAGE_KEY = 'agents_workspace_mode';
const loadCachedWorkspaceMode = (): 'host' | 'docker' | null => {
try {
const v = window.localStorage.getItem(WORKSPACE_MODE_STORAGE_KEY);
return v === 'host' || v === 'docker' ? v : null;
} catch {
return null;
}
};
export const persistWorkspaceMode = (isHostMode: boolean): void => {
try {
window.localStorage.setItem(WORKSPACE_MODE_STORAGE_KEY, isHostMode ? 'host' : 'docker');
} catch {
/* ignore */
}
};
const cachedWorkspaceMode = loadCachedWorkspaceMode();
export function dataState() { export function dataState() {
return { return {
// 路由相关 // 路由相关
@ -150,18 +174,16 @@ export function dataState() {
pathAuthorizationReadableDraft: '', pathAuthorizationReadableDraft: '',
pathAuthorizationDraft: '', pathAuthorizationDraft: '',
pathAuthorizationSaving: false, pathAuthorizationSaving: false,
versioningHostMode: false, versioningHostMode: cachedWorkspaceMode === 'host',
dockerProjectMode: false, dockerProjectMode: cachedWorkspaceMode === 'docker',
hostWorkspaces: [], hostWorkspaces: [],
currentHostWorkspaceId: '', currentHostWorkspaceId: '',
defaultHostWorkspaceId: '', defaultHostWorkspaceId: '',
hostWorkspaceSwitching: false, hostWorkspaceSwitching: false,
hostWorkspaceCreateDialogOpen: false,
hostWorkspaceCreatePath: '', hostWorkspaceCreatePath: '',
hostWorkspaceCreateLabel: '', hostWorkspaceCreateLabel: '',
hostWorkspaceCreateSubmitting: false, hostWorkspaceCreateSubmitting: false,
hostWorkspaceCreateError: '', hostWorkspaceCreateError: '',
hostWorkspaceManageDialogOpen: false,
hostWorkspaceManageSubmitting: false, hostWorkspaceManageSubmitting: false,
versioningEnabled: false, versioningEnabled: false,
versioningTrackingMode: 'conversation_only', versioningTrackingMode: 'conversation_only',

View File

@ -364,6 +364,7 @@
:multi-agent-mode="multiAgentMode" :multi-agent-mode="multiAgentMode"
:has-running-task="hasRunningTask" :has-running-task="hasRunningTask"
@quick-upload="triggerQuickUpload" @quick-upload="triggerQuickUpload"
@open-file-manager="$emit('open-file-manager')"
@pick-images="$emit('pick-images')" @pick-images="$emit('pick-images')"
@pick-video="$emit('pick-video')" @pick-video="$emit('pick-video')"
@toggle-tool-menu="$emit('toggle-tool-menu')" @toggle-tool-menu="$emit('toggle-tool-menu')"
@ -532,6 +533,7 @@ const emit = defineEmits([
'send-message', 'send-message',
'send-or-stop', 'send-or-stop',
'quick-upload', 'quick-upload',
'open-file-manager',
'pick-images', 'pick-images',
'pick-video', 'pick-video',
'toggle-tool-menu', 'toggle-tool-menu',
@ -2432,7 +2434,7 @@ const hasComposerContent = computed(() => {
const showStopIcon = computed(() => { const showStopIcon = computed(() => {
// streaming/ // streaming/
// x LeftPanel // x
return !!props.streamingMessage && !hasComposerContent.value; return !!props.streamingMessage && !hasComposerContent.value;
}); });

View File

@ -1,6 +1,7 @@
<template> <template>
<transition name="quick-menu"> <transition name="quick-menu">
<div v-if="open" class="quick-menu" @click.stop> <div v-if="open" class="quick-menu" @click.stop>
<div class="quick-menu-list">
<button <button
type="button" type="button"
class="menu-entry" class="menu-entry"
@ -10,6 +11,15 @@
> >
{{ uploading ? '上传中...' : '上传文件' }} {{ uploading ? '上传中...' : '上传文件' }}
</button> </button>
<button
type="button"
class="menu-entry"
data-tutorial="quick-file-manager"
@click.stop="$emit('open-file-manager')"
:disabled="!isConnected"
>
文件管理
</button>
<button <button
type="button" type="button"
class="menu-entry" class="menu-entry"
@ -87,6 +97,7 @@
设置 设置
<span class="entry-arrow"></span> <span class="entry-arrow"></span>
</button> </button>
</div>
<transition name="submenu-slide"> <transition name="submenu-slide">
<div class="quick-submenu tool-submenu" v-if="toolMenuOpen"> <div class="quick-submenu tool-submenu" v-if="toolMenuOpen">
@ -220,6 +231,7 @@ const props = defineProps<{
defineEmits<{ defineEmits<{
(event: 'quick-upload'): void; (event: 'quick-upload'): void;
(event: 'open-file-manager'): void;
(event: 'toggle-tool-menu'): void; (event: 'toggle-tool-menu'): void;
(event: 'toggle-settings'): void; (event: 'toggle-settings'): void;
(event: 'update-tool-category', id: string, enabled: boolean): void; (event: 'update-tool-category', id: string, enabled: boolean): void;
@ -254,6 +266,25 @@ const goalCompleted = computed(() => String(props.goalProgress?.status || '').to
</script> </script>
<style scoped> <style scoped>
.quick-menu-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 194px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--text-primary) 22%, transparent) transparent;
}
.quick-menu-list::-webkit-scrollbar {
width: 4px;
}
.quick-menu-list::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--text-primary) 22%, transparent);
border-radius: 2px;
}
.quick-menu-list::-webkit-scrollbar-track {
background: transparent;
}
.submenu-desc { .submenu-desc {
display: block; display: block;
font-size: 12px; font-size: 12px;

View File

@ -1,220 +0,0 @@
<template>
<div
class="host-workspace-dialog-overlay"
role="dialog"
aria-modal="true"
:aria-label="workspaceKind === 'project' ? '新建项目' : '新建工作区'"
@click.self="handleOverlayClose"
>
<form class="host-workspace-dialog" @submit.prevent="$emit('submit')">
<div class="host-workspace-dialog__header">
<div class="host-workspace-dialog__title">
{{ workspaceKind === 'project' ? '新建项目' : '新建工作区' }}
</div>
<button
type="button"
class="host-workspace-dialog__close"
:disabled="submitting"
@click="$emit('close')"
>
关闭
</button>
</div>
<div class="host-workspace-dialog__body">
<label v-if="workspaceKind !== 'project'" class="host-workspace-dialog__field">
<span class="host-workspace-dialog__label">
工作区路径
</span>
<input
:value="pathValue"
type="text"
class="host-workspace-dialog__input"
placeholder="请输入绝对路径或相对仓库路径"
autocomplete="off"
:disabled="submitting"
@input="$emit('update:pathValue', ($event.target as HTMLInputElement).value)"
/>
</label>
<label class="host-workspace-dialog__field">
<span class="host-workspace-dialog__label">
{{ workspaceKind === 'project' ? '项目名称' : '工作区名称(可选)' }}
</span>
<input
:value="labelValue"
type="text"
class="host-workspace-dialog__input"
:placeholder="workspaceKind === 'project' ? '例如:客户 A 项目' : '例如客户A项目'"
autocomplete="off"
:disabled="submitting"
@input="$emit('update:labelValue', ($event.target as HTMLInputElement).value)"
/>
</label>
<div v-if="errorMessage" class="host-workspace-dialog__error">{{ errorMessage }}</div>
</div>
<div class="host-workspace-dialog__actions">
<button type="button" class="host-workspace-dialog__btn ghost" :disabled="submitting" @click="$emit('close')">
取消
</button>
<button type="submit" class="host-workspace-dialog__btn primary" :disabled="submitting">
{{ submitting ? '创建中...' : (workspaceKind === 'project' ? '创建项目' : '创建工作区') }}
</button>
</div>
</form>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'HostWorkspaceCreateDialog' });
const props = defineProps<{
open: boolean;
pathValue: string;
labelValue: string;
submitting?: boolean;
errorMessage?: string;
workspaceKind?: 'workspace' | 'project';
}>();
const emits = defineEmits<{
(event: 'close'): void;
(event: 'submit'): void;
(event: 'update:pathValue', value: string): void;
(event: 'update:labelValue', value: string): void;
}>();
const workspaceKind = props.workspaceKind || 'workspace';
const handleOverlayClose = () => {
if (props.submitting) return;
emits('close');
};
</script>
<style scoped>
.host-workspace-dialog-overlay {
position: fixed;
inset: 0;
z-index: 1300;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: var(--theme-overlay-scrim);
backdrop-filter: blur(10px);
}
.host-workspace-dialog {
width: min(520px, 96vw);
border-radius: 18px;
border: 1px solid var(--theme-control-border-strong);
background: var(--theme-surface-card);
box-shadow: var(--theme-shadow-soft);
display: flex;
flex-direction: column;
}
.host-workspace-dialog__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 18px;
border-bottom: 1px solid var(--theme-control-border);
}
.host-workspace-dialog__title {
font-size: 16px;
font-weight: 700;
color: var(--claude-text);
}
.host-workspace-dialog__close {
border: 1px solid var(--theme-control-border-strong);
background: transparent;
color: var(--claude-text-secondary);
border-radius: 10px;
padding: 6px 12px;
cursor: pointer;
}
.host-workspace-dialog__close:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.host-workspace-dialog__body {
padding: 16px 18px;
display: grid;
gap: 12px;
}
.host-workspace-dialog__field {
display: grid;
gap: 6px;
}
.host-workspace-dialog__label {
font-size: 13px;
color: var(--claude-text-secondary);
}
.host-workspace-dialog__input {
border: 1px solid var(--theme-control-border-strong);
border-radius: 10px;
background: var(--theme-surface-soft);
color: var(--claude-text);
padding: 10px 12px;
font-size: 14px;
line-height: 1.4;
outline: none;
}
.host-workspace-dialog__input:focus {
border-color: var(--claude-accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--claude-accent) 20%, transparent);
}
.host-workspace-dialog__input:disabled {
opacity: 0.7;
}
.host-workspace-dialog__error {
font-size: 13px;
color: var(--state-danger);
}
.host-workspace-dialog__actions {
padding: 16px 18px;
border-top: 1px solid var(--theme-control-border);
display: flex;
justify-content: flex-end;
gap: 10px;
}
.host-workspace-dialog__btn {
border-radius: 10px;
padding: 8px 14px;
border: 1px solid var(--theme-control-border-strong);
cursor: pointer;
}
.host-workspace-dialog__btn.ghost {
background: transparent;
color: var(--claude-text-secondary);
}
.host-workspace-dialog__btn.primary {
background: var(--claude-accent);
border-color: var(--claude-accent-strong);
color: var(--on-accent);
}
.host-workspace-dialog__btn:disabled {
opacity: 0.65;
cursor: not-allowed;
}
</style>

View File

@ -1,409 +0,0 @@
<template>
<div
class="host-workspace-dialog-overlay"
role="dialog"
aria-modal="true"
:aria-label="workspaceKind === 'project' ? '管理项目' : '管理工作区'"
@click.self="handleOverlayClose"
>
<form class="host-workspace-dialog host-workspace-manage" @submit.prevent="submitCreate">
<div class="host-workspace-dialog__header">
<div class="host-workspace-dialog__title">
{{ workspaceKind === 'project' ? '管理项目' : '管理工作区' }}
</div>
<button
type="button"
class="host-workspace-dialog__close"
:disabled="busy"
@click="$emit('close')"
>
关闭
</button>
</div>
<div class="host-workspace-dialog__body">
<section class="workspace-manage-section">
<div class="workspace-manage-section__title">
{{ workspaceKind === 'project' ? '新建项目' : '新建工作区' }}
</div>
<label v-if="workspaceKind !== 'project'" class="host-workspace-dialog__field">
<span class="host-workspace-dialog__label">工作区路径</span>
<input
v-model="createPath"
type="text"
class="host-workspace-dialog__input"
placeholder="请输入绝对路径或相对仓库路径"
autocomplete="off"
:disabled="busy"
/>
</label>
<label class="host-workspace-dialog__field">
<span class="host-workspace-dialog__label">
{{ workspaceKind === 'project' ? '项目名称' : '工作区名称(可选)' }}
</span>
<input
v-model="createLabel"
type="text"
class="host-workspace-dialog__input"
:placeholder="workspaceKind === 'project' ? '例如:客户 A 项目' : '例如客户A项目'"
autocomplete="off"
:disabled="busy"
/>
</label>
<button type="submit" class="host-workspace-dialog__btn primary" :disabled="busy">
{{ createSubmitting ? '创建中...' : (workspaceKind === 'project' ? '新建项目' : '新建工作区') }}
</button>
</section>
<section class="workspace-manage-section">
<div class="workspace-manage-section__title">
{{ workspaceKind === 'project' ? '已有项目' : '已有工作区' }}
</div>
<div v-if="workspaces.length" class="workspace-manage-list">
<div
v-for="item in workspaces"
:key="item.workspace_id"
class="workspace-manage-row"
:class="{ current: item.workspace_id === currentWorkspaceId }"
>
<div class="workspace-manage-main">
<input
class="host-workspace-dialog__input workspace-manage-name"
:value="renameDrafts[item.workspace_id] ?? item.label"
:disabled="busy"
@input="setRenameDraft(item.workspace_id, ($event.target as HTMLInputElement).value)"
/>
<div v-if="workspaceKind !== 'project'" class="workspace-manage-path">
{{ item.path || '(未配置路径)' }}
</div>
<div class="workspace-manage-badges">
<span v-if="item.workspace_id === defaultWorkspaceId" class="workspace-manage-badge default">
默认
</span>
<span v-if="Number(item.running_task_count || 0) > 0" class="workspace-manage-badge running">
运行中
</span>
</div>
</div>
<div class="workspace-manage-actions">
<button
type="button"
class="host-workspace-dialog__btn ghost"
:class="{ active: item.workspace_id === defaultWorkspaceId }"
:disabled="busy || item.workspace_id === defaultWorkspaceId"
@click="submitSetDefault(item)"
>
{{ item.workspace_id === defaultWorkspaceId ? '已默认' : '设为默认' }}
</button>
<button
type="button"
class="host-workspace-dialog__btn ghost"
:disabled="busy || !hasRenameChange(item)"
@click="submitRename(item)"
>
重命名
</button>
<button
type="button"
class="host-workspace-dialog__btn danger"
:disabled="busy || !canDelete(item)"
@click="$emit('delete', item)"
>
删除
</button>
</div>
</div>
</div>
<div v-else class="workspace-manage-empty">
{{ workspaceKind === 'project' ? '暂无项目' : '暂无工作区' }}
</div>
</section>
<div v-if="errorMessage" class="host-workspace-dialog__error">{{ errorMessage }}</div>
</div>
</form>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, watch } from 'vue';
defineOptions({ name: 'HostWorkspaceManageDialog' });
type WorkspaceItem = {
workspace_id: string;
label: string;
path?: string;
running_task_count?: number;
};
const props = defineProps<{
workspaces: WorkspaceItem[];
currentWorkspaceId?: string;
defaultWorkspaceId?: string;
workspaceKind?: 'workspace' | 'project';
busy?: boolean;
createSubmitting?: boolean;
errorMessage?: string;
}>();
const emits = defineEmits<{
(event: 'close'): void;
(event: 'create', payload: { path: string; label: string }): void;
(event: 'rename', payload: { workspace_id: string; label: string }): void;
(event: 'delete', item: WorkspaceItem): void;
(event: 'setDefault', payload: { workspace_id: string }): void;
}>();
const workspaceKind = props.workspaceKind || 'workspace';
const createPath = ref('');
const createLabel = ref('');
const renameDrafts = reactive<Record<string, string>>({});
watch(
() => props.workspaces,
(items) => {
for (const item of items || []) {
renameDrafts[item.workspace_id] = item.label || item.workspace_id;
}
},
{ immediate: true }
);
const handleOverlayClose = () => {
if (props.busy) return;
emits('close');
};
const setRenameDraft = (workspaceId: string, value: string) => {
renameDrafts[workspaceId] = value;
};
const hasRenameChange = (item: WorkspaceItem) => {
const draft = String(renameDrafts[item.workspace_id] ?? '').trim();
return !!draft && draft !== String(item.label || '').trim();
};
const canDelete = (item: WorkspaceItem) => {
if (Number(item.running_task_count || 0) > 0) return false;
if (workspaceKind === 'project' && item.workspace_id === 'default') return false;
return true;
};
const submitCreate = () => {
emits('create', {
path: createPath.value.trim(),
label: createLabel.value.trim()
});
};
const submitRename = (item: WorkspaceItem) => {
emits('rename', {
workspace_id: item.workspace_id,
label: String(renameDrafts[item.workspace_id] ?? '').trim()
});
};
const submitSetDefault = (item: WorkspaceItem) => {
if (item.workspace_id === props.defaultWorkspaceId) return;
emits('setDefault', { workspace_id: item.workspace_id });
};
</script>
<style scoped>
.host-workspace-dialog-overlay {
position: fixed;
inset: 0;
z-index: 1300;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: var(--theme-overlay-scrim);
backdrop-filter: blur(10px);
}
.host-workspace-dialog {
width: min(680px, 96vw);
height: min(760px, 92vh);
border-radius: 18px;
border: 1px solid var(--theme-control-border-strong);
background: var(--theme-surface-card);
box-shadow: var(--theme-shadow-soft);
display: flex;
flex-direction: column;
overflow: hidden;
}
.host-workspace-dialog__header,
.host-workspace-dialog__actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 18px;
border-bottom: 1px solid var(--theme-control-border);
}
.host-workspace-dialog__title {
font-size: 16px;
font-weight: 700;
color: var(--claude-text);
}
.host-workspace-dialog__close,
.host-workspace-dialog__btn {
border: 1px solid var(--theme-control-border-strong);
background: transparent;
color: var(--claude-text-secondary);
border-radius: 10px;
padding: 7px 12px;
cursor: pointer;
white-space: nowrap;
}
.host-workspace-dialog__close:disabled,
.host-workspace-dialog__btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.host-workspace-dialog__body {
padding: 16px 18px;
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
gap: 16px;
overflow: hidden;
}
.workspace-manage-section {
display: grid;
gap: 10px;
}
.workspace-manage-section__title {
font-size: 13px;
font-weight: 700;
color: var(--claude-text-secondary);
}
.host-workspace-dialog__field {
display: grid;
gap: 6px;
}
.host-workspace-dialog__label,
.workspace-manage-path {
font-size: 12px;
color: var(--claude-text-secondary);
}
.host-workspace-dialog__input {
border: 1px solid var(--theme-control-border-strong);
border-radius: 10px;
background: var(--theme-surface-soft);
color: var(--claude-text);
padding: 9px 11px;
font-size: 14px;
line-height: 1.4;
outline: none;
}
.host-workspace-dialog__input:focus {
border-color: var(--claude-accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--claude-accent) 20%, transparent);
}
.host-workspace-dialog__btn.primary {
justify-self: flex-start;
background: var(--claude-accent);
border-color: var(--claude-accent-strong);
color: var(--on-accent);
}
.host-workspace-dialog__btn.danger {
color: var(--state-danger);
border-color: color-mix(in srgb, var(--state-danger) 45%, var(--theme-control-border-strong));
}
.workspace-manage-list {
display: grid;
align-content: start;
gap: 8px;
min-height: 0;
max-height: 100%;
overflow: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
.workspace-manage-list::-webkit-scrollbar {
display: none;
}
.workspace-manage-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
padding: 10px;
border: 1px solid var(--theme-control-border);
border-radius: 12px;
background: var(--theme-surface-soft);
}
.workspace-manage-row.current {
border-color: color-mix(in srgb, var(--claude-accent) 45%, var(--theme-control-border));
box-shadow: 0 0 0 1px color-mix(in srgb, var(--claude-accent) 18%, transparent);
}
.workspace-manage-main {
display: grid;
gap: 6px;
min-width: 0;
}
.workspace-manage-name {
width: 100%;
}
.workspace-manage-actions,
.workspace-manage-badges {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.workspace-manage-badge {
font-size: 11px;
color: var(--claude-text-secondary);
border: 1px solid var(--theme-control-border);
border-radius: 999px;
padding: 2px 7px;
}
.workspace-manage-badge.running {
color: var(--claude-accent);
}
.workspace-manage-badge.default {
color: var(--state-success);
border-color: color-mix(in srgb, var(--state-success) 45%, var(--theme-control-border-strong));
}
.workspace-manage-empty {
font-size: 13px;
color: var(--claude-text-secondary);
}
.host-workspace-dialog__error {
font-size: 13px;
color: var(--state-danger);
}
@media (max-width: 640px) {
.workspace-manage-row {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -1,470 +0,0 @@
<template>
<aside class="sidebar left-sidebar" :style="panelStyle">
<div class="sidebar-status">
<div class="compact-status-card">
<div class="status-line">
<div class="status-brand">
<span
class="status-logo-image status-logo-inline"
v-html="statusLogoSvg"
aria-hidden="true"
></span>
<div class="brand-text">
<span class="brand-name">Astrion</span>
<span class="agent-version" v-if="agentVersion">{{ agentVersion }}</span>
</div>
</div>
<div class="status-indicators">
<button
type="button"
class="mode-indicator"
data-tutorial="run-mode-indicator"
:class="modeIndicatorClass"
:title="modeIndicatorTitle"
@click="$emit('toggle-thinking-mode')"
>
<transition name="mode-icon" mode="out-in">
<span
class="icon icon-sm"
:style="iconStyle(modeIndicatorIcon)"
:key="modeIndicatorIcon"
aria-hidden="true"
></span>
</transition>
</button>
<span
class="connection-dot"
data-tutorial="connection-indicator"
:class="{ active: isConnected }"
:title="isConnected ? '已连接' : '未连接'"
></span>
</div>
</div>
</div>
</div>
<div class="sidebar-panel-card-wrapper">
<div class="sidebar-panel-card">
<div class="sidebar-header">
<div class="panel-menu-wrapper" ref="panelMenuWrapper">
<button
class="sidebar-view-toggle"
data-tutorial="panel-menu-toggle"
@click.stop="$emit('toggle-panel-menu')"
title="切换侧边栏"
>
<span class="icon icon-md" :style="iconStyle('menu')" aria-hidden="true"></span>
</button>
<transition name="fade">
<div class="panel-menu" data-tutorial="panel-menu" v-if="panelMenuOpen">
<button
type="button"
:class="{ active: panelMode === 'files' }"
@click.stop="$emit('select-panel', 'files')"
:title="workspaceKind === 'project' ? '项目' : '项目文件'"
>
<span class="icon icon-md" :style="iconStyle('folder')" aria-hidden="true"></span>
</button>
<button
type="button"
:class="{ active: panelMode === 'todo' }"
@click.stop="$emit('select-panel', 'todo')"
title="待办列表"
>
<span
class="icon icon-md"
:style="iconStyle('stickyNote')"
aria-hidden="true"
></span>
</button>
<button
type="button"
:class="{ active: panelMode === 'subAgents' }"
@click.stop="$emit('select-panel', 'subAgents')"
title="子智能体"
>
<span class="icon icon-md" :style="iconStyle('bot')" aria-hidden="true"></span>
</button>
<button
type="button"
:class="{ active: panelMode === 'backgroundCommands' }"
@click.stop="$emit('select-panel', 'backgroundCommands')"
title="后台指令"
>
<span
class="icon icon-md"
:style="iconStyle('terminal')"
aria-hidden="true"
></span>
</button>
<button
v-if="panelMode === 'backgroundCommands' && runningBackgroundCommandCount > 0"
type="button"
class="background-stop-btn"
title="终止所有后台指令"
@click.stop="handleStopAllCommands"
>
<span class="icon icon-sm" :style="iconStyle('stop')" aria-hidden="true"></span>
<span class="background-stop-label">停止</span>
</button>
</div>
</transition>
</div>
<button
class="sidebar-manage-btn"
@click="$emit('open-file-manager')"
:title="fileManagerDisabled ? '已被管理员禁用' : '打开桌面式文件管理器'"
>
管理
</button>
<h3>
<span v-if="panelMode === 'files'" class="icon-label">
<span class="icon icon-sm" :style="iconStyle('folder')" aria-hidden="true"></span>
<span>{{ workspaceKind === 'project' ? '项目' : '项目文件' }}</span>
</span>
<span v-else-if="panelMode === 'todo'" class="icon-label">
<span class="icon icon-sm" :style="iconStyle('stickyNote')" aria-hidden="true"></span>
<span>待办列表</span>
</span>
<span v-else class="icon-label">
<template v-if="panelMode === 'subAgents'">
<span class="icon icon-sm" :style="iconStyle('bot')" aria-hidden="true"></span>
<span>子智能体</span>
</template>
<template v-else>
<span class="icon icon-sm" :style="iconStyle('terminal')" aria-hidden="true"></span>
<span>后台指令</span>
</template>
</span>
</h3>
</div>
<div class="sidebar-panel-content">
<div v-if="panelMode === 'todo'" class="todo-panel">
<div v-if="!todoList" class="todo-empty">暂无待办列表</div>
<div v-else>
<div
class="todo-task"
v-for="task in todoList.tasks || []"
:key="task.index"
:class="{ done: task.status === 'done' }"
>
<span class="todo-task-title">task{{ task.index }}{{ task.title }}</span>
<span class="todo-task-status icon-label">
<span
class="icon icon-sm"
:style="iconStyle(task.status === 'done' ? 'check' : 'checkbox')"
aria-hidden="true"
></span>
<span>{{ task.status === 'done' ? '完成' : '未完成' }}</span>
</span>
</div>
<div class="todo-instruction">{{ todoList.instruction }}</div>
</div>
</div>
<div v-else-if="panelMode === 'subAgents'" class="sub-agent-panel">
<div v-if="!subAgents.length" class="sub-agent-empty">暂无运行中的子智能体</div>
<div v-else class="sub-agent-cards">
<div
class="sub-agent-card"
v-for="agent in subAgents"
:key="agent.task_id"
@click="openSubAgent(agent)"
>
<div class="sub-agent-header">
<span class="sub-agent-summary-text">{{ agent.summary || agent.display_name || `#${agent.agent_id}` }}</span>
<span class="sub-agent-status" :class="agent.status">{{ agent.status }}</span>
</div>
<div class="sub-agent-meta">
<span class="sub-agent-name" v-if="agent.display_name">{{ agent.display_name }}</span>
<span class="sub-agent-tokens" v-if="agent.current_context_tokens">{{ formatTokens(agent.current_context_tokens) }}</span>
</div>
<div class="sub-agent-tool" v-if="agent.last_tool">当前{{ agent.last_tool }}</div>
</div>
</div>
</div>
<div v-else-if="panelMode === 'backgroundCommands'" class="sub-agent-panel">
<div v-if="!backgroundCommands.length" class="sub-agent-empty">暂无后台指令</div>
<div v-else class="sub-agent-cards">
<div
class="sub-agent-card"
v-for="command in backgroundCommands"
:key="command.command_id"
@click="openBackgroundCommand(command)"
>
<div class="sub-agent-header">
<span class="sub-agent-id">状态</span>
<span class="sub-agent-status" :class="command.status">{{
formatBackgroundCommandStatus(command.status)
}}</span>
</div>
<div class="sub-agent-summary background-command-summary">
{{ formatBackgroundCommandPreview(command.command) }}
</div>
<div class="sub-agent-tool">
{{ formatBackgroundCommandMeta(command) }}
</div>
</div>
</div>
</div>
<div v-else class="file-tree" @contextmenu.prevent>
<template v-if="fileTreeUnavailable">
<div v-if="hostWorkspaceEnabled" class="host-workspace-section">
<div class="host-workspace-header-box">
<button
type="button"
class="host-workspace-add-btn"
:disabled="hostWorkspaceSwitching"
@click="handleManageHostWorkspace"
:title="workspaceKind === 'project' ? '管理项目' : '管理工作区'"
>
<span class="icon icon-sm" :style="iconStyle('settings')" aria-hidden="true"></span>
</button>
<div class="host-workspace-title">
{{ workspaceKind === 'project' ? '管理项目' : '管理工作区' }}
</div>
</div>
<div class="host-workspace-list" v-if="hostWorkspaceOptions.length">
<button
v-for="item in hostWorkspaceOptions"
:key="item.workspace_id"
type="button"
class="host-workspace-card"
:class="{ active: item.workspace_id === hostWorkspaceId }"
:disabled="hostWorkspaceSwitching || item.workspace_id === hostWorkspaceId"
@click="handleHostWorkspaceClick(item.workspace_id)"
>
<div class="host-workspace-card-head">
<span class="host-workspace-label">{{ item.label }}</span>
<span class="host-workspace-badges">
<span
v-if="item.workspace_id === hostWorkspaceDefaultId"
class="host-workspace-badge default"
>
默认
</span>
<span
v-if="Number(item.running_task_count || 0) > 0"
class="host-workspace-badge running"
>
运行中
</span>
</span>
</div>
<div v-if="workspaceKind !== 'project'" class="host-workspace-path">
{{ item.path || (workspaceKind === 'project' ? '项目文件' : '(未配置路径)') }}
</div>
</button>
</div>
<div v-else class="file-tree-empty">
{{ workspaceKind === 'project' ? '暂无项目,点击齿轮管理' : '暂无工作区,点击齿轮管理' }}
</div>
<div class="host-workspace-hint" v-if="hostWorkspaceSwitching">
正在切换请稍候
</div>
</div>
<div v-else class="file-tree-empty">{{ fileTreeMessage }}</div>
</template>
<template v-else>
<div v-if="!fileTree.length" class="file-tree-empty">暂无文件</div>
<FileNode
v-for="node in fileTree"
:key="node.path"
:node="node"
:level="0"
:expanded-folders="expandedFolders"
:icon-style="iconStyle"
/>
</template>
</div>
</div>
</div>
</div>
</aside>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { storeToRefs } from 'pinia';
import FileNode from '@/components/files/FileNode.vue';
import { useFileStore } from '@/stores/file';
import { useSubAgentStore } from '@/stores/subAgent';
import { useBackgroundCommandStore } from '@/stores/backgroundCommand';
import statusLogoSvgRaw from '../../../logo/logo.svg?raw';
defineOptions({ name: 'LeftPanel' });
const props = defineProps<{
width: number;
collapsed?: boolean;
iconStyle: (key: string) => Record<string, string>;
agentVersion: string | null;
thinkingMode: boolean;
isConnected: boolean;
panelMenuOpen: boolean;
panelMode: 'files' | 'todo' | 'subAgents' | 'backgroundCommands';
runMode: 'fast' | 'thinking';
fileManagerDisabled?: boolean;
hostWorkspaceEnabled?: boolean;
hostWorkspaces?: Array<{
workspace_id: string;
label: string;
path?: string;
is_current?: boolean;
running_task_count?: number;
}>;
hostWorkspaceId?: string;
hostWorkspaceDefaultId?: string;
hostWorkspaceSwitching?: boolean;
workspaceKind?: 'workspace' | 'project';
}>();
const emits = defineEmits<{
(event: 'toggle-panel-menu'): void;
(event: 'select-panel', mode: 'files' | 'todo' | 'subAgents' | 'backgroundCommands'): void;
(event: 'open-file-manager'): void;
(event: 'toggle-thinking-mode'): void;
(event: 'switch-host-workspace', workspaceId: string): void;
(event: 'create-host-workspace'): void;
(event: 'manage-host-workspace'): void;
}>();
const panelMenuWrapper = ref<HTMLElement | null>(null);
const statusLogoSvg = statusLogoSvgRaw.replace(/fill="#000000"/g, 'fill="currentColor"');
const workspaceKind = computed(() => props.workspaceKind || 'workspace');
const panelStyle = computed(() => {
const px = `${props.width}px`;
const layoutWidth = props.collapsed ? '0px' : px;
return {
'--workspace-content-width': px,
width: layoutWidth,
minWidth: layoutWidth
};
});
const resolveRunMode = () => {
// deep thinking
const rawMode = props.runMode as string;
if (rawMode === 'deep' || rawMode === 'thinking') {
return 'thinking';
}
if (rawMode === 'fast') {
return 'fast';
}
return props.thinkingMode ? 'thinking' : 'fast';
};
const modeIndicatorClass = computed(() => {
const mode = resolveRunMode();
if (mode === 'thinking') {
return 'thinking';
}
return 'fast';
});
const modeIndicatorIcon = computed(() => {
const mode = resolveRunMode();
if (mode === 'thinking') {
return 'brain';
}
return 'zap';
});
const modeIndicatorTitle = computed(() => {
const mode = resolveRunMode();
if (mode === 'thinking') {
return '思考模式(点击切换)';
}
return '快速模式(点击切换)';
});
const fileStore = useFileStore();
const subAgentStore = useSubAgentStore();
const backgroundCommandStore = useBackgroundCommandStore();
const { fileTree, expandedFolders, todoList, fileTreeUnavailable, fileTreeMessage } =
storeToRefs(fileStore);
const { subAgents } = storeToRefs(subAgentStore);
const { commands: backgroundCommands } = storeToRefs(backgroundCommandStore);
const hostWorkspaceOptions = computed(() => {
const list = Array.isArray(props.hostWorkspaces) ? props.hostWorkspaces : [];
return list.filter((item) => item && item.workspace_id);
});
const handleHostWorkspaceClick = (workspaceId: string) => {
const targetId = String(workspaceId || '').trim();
if (!targetId || targetId === props.hostWorkspaceId) {
return;
}
if (props.hostWorkspaceSwitching) {
return;
}
emits('switch-host-workspace', targetId);
};
const handleCreateHostWorkspace = () => {
if (props.hostWorkspaceSwitching) {
return;
}
emits('create-host-workspace');
};
const handleManageHostWorkspace = () => {
if (props.hostWorkspaceSwitching) {
return;
}
emits('manage-host-workspace');
};
const openSubAgent = (agent: any) => {
subAgentStore.openSubAgent(agent);
};
const openBackgroundCommand = (command: any) => {
backgroundCommandStore.openCommand(command);
};
const BG_COMMAND_TERMINAL = new Set(['completed', 'failed', 'timeout', 'cancelled']);
const runningBackgroundCommandCount = computed(() => {
return (backgroundCommands.value || []).filter((c: any) => {
const s = String(c?.status || '').toLowerCase();
return !BG_COMMAND_TERMINAL.has(s);
}).length;
});
const handleStopAllCommands = async () => {
const result = await backgroundCommandStore.stopAllCommands();
if (!result.success) {
console.error('[LeftPanel] 批量停止后台指令失败:', result.error);
}
};
const formatBackgroundCommandMeta = (command: any) => {
const code = command?.return_code;
if (code === null || code === undefined) {
return '状态码:--';
}
return `状态码:${code}`;
};
const formatBackgroundCommandStatus = (status: any) => {
const normalized = (status || 'running').toString().toLowerCase();
return normalized;
};
const formatBackgroundCommandPreview = (rawCommand: any) => {
const content = (rawCommand || '(空命令)').toString().replace(/\s+/g, ' ').trim();
if (!content) {
return '(空命令)';
}
return content;
};
const formatTokens = (tokens: number) => {
if (!tokens || tokens <= 0) return '';
if (tokens >= 1000) {
return `${(tokens / 1000).toFixed(1)}k ctx`;
}
return `${tokens} ctx`;
};
defineExpose({
panelMenuWrapper
});
</script>

View File

@ -618,47 +618,6 @@
</div> </div>
</div> </div>
</div> </div>
<label class="settings-toggle-row"
><span class="settings-row-copy"
><span class="settings-row-title">默认隐藏工作区</span
><span class="settings-row-desc"
>打开页面时默认收起左侧工作区仍可随时手动展开</span
></span
><input
type="checkbox"
:checked="form.default_hide_workspace"
@change="applyDefaultHideWorkspaceOption($event.target.checked)" /><span
class="fancy-check"
aria-hidden="true"
><svg viewBox="0 0 64 64">
<path
d="M 0 16 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 16 L 32 48 L 64 16 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 16"
pathLength="575.0541381835938"
class="fancy-path"
></path></svg></span
></label>
<label class="settings-toggle-row"
><span class="settings-row-copy"
><span class="settings-row-title">隐藏快捷窗口</span
><span class="settings-row-desc"
>关闭对话区右侧的待办 / 子智能体 / 后台指令 / 文件窗口列</span
></span
><input
type="checkbox"
:checked="form.hide_quick_dock"
@change="
personalization.updateField({
key: 'hide_quick_dock',
value: $event.target.checked
})
" /><span class="fancy-check" aria-hidden="true"
><svg viewBox="0 0 64 64">
<path
d="M 0 16 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 16 L 32 48 L 64 16 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 16"
pathLength="575.0541381835938"
class="fancy-path"
></path></svg></span
></label>
<label class="settings-toggle-row" <label class="settings-toggle-row"
><span class="settings-row-copy" ><span class="settings-row-copy"
><span class="settings-row-title">按项目分组对话</span ><span class="settings-row-title">按项目分组对话</span
@ -2113,7 +2072,7 @@ import { ref, computed, watch, onMounted, nextTick, onBeforeUnmount } from 'vue'
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { usePersonalizationStore } from '@/stores/personalization'; import { usePersonalizationStore } from '@/stores/personalization';
import { useResourceStore } from '@/stores/resource'; import { useResourceStore } from '@/stores/resource';
import { useUiStore, persistDefaultHideWorkspace } from '@/stores/ui'; import { useUiStore } from '@/stores/ui';
import { usePolicyStore } from '@/stores/policy'; import { usePolicyStore } from '@/stores/policy';
import { useTutorialStore } from '@/stores/tutorial'; import { useTutorialStore } from '@/stores/tutorial';
import { useModelStore } from '@/stores/model'; import { useModelStore } from '@/stores/model';
@ -3341,28 +3300,6 @@ const applyThemeOption = async (theme: ThemeKey) => {
} }
}; };
const applyDefaultHideWorkspaceOption = async (hidden: boolean) => {
const next = !!hidden;
persistDefaultHideWorkspace(next);
personalization.updateField({ key: 'default_hide_workspace', value: next });
if (next) {
uiStore.setWorkspaceCollapsed(true);
}
try {
const resp = await fetch('/api/personalization', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ default_hide_workspace: next })
});
const result = await resp.json();
if (!resp.ok || !result.success) {
console.warn('保存默认隐藏工作区失败:', result.error);
}
} catch (error) {
console.warn('保存默认隐藏工作区失败:', error);
}
};
</script> </script>
<style scoped> <style scoped>

View File

@ -1,5 +1,5 @@
<template> <template>
<aside class="conversation-sidebar" :class="{ collapsed, 'monitor-mode': monitorModeActive }"> <aside class="conversation-sidebar" :class="{ collapsed }">
<div class="conversation-sidebar-inner"> <div class="conversation-sidebar-inner">
<div class="conversation-primary-actions"> <div class="conversation-primary-actions">
<button <button
@ -59,30 +59,41 @@
</button> </button>
<button <button
v-if="hostWorkspaceEnabled"
type="button" type="button"
class="sidebar-nav-row workspace-control-btn" class="sidebar-nav-row workspace-entry-btn"
:class="{ active: workspaceCollapsed }" :class="{ active: workspaceSwitcherOpen }"
:title="workspaceCollapsed ? '显示工作区' : '隐藏工作区'" :title="workspaceKind === 'project' ? '项目' : '工作区'"
@click="$emit('toggle-workspace')" data-tutorial="workspace-toggle"
ref="workspaceEntryBtn"
@click="toggleWorkspaceSwitcher"
> >
<span class="sidebar-nav-icon" data-tutorial="workspace-toggle" aria-hidden="true"> <span class="sidebar-nav-icon layers-icon" aria-hidden="true">
<span class="icon icon-md" :style="iconStyle('layers')"></span> <!-- 与其他导航按钮统一内联 layers 图标CSS 限到 18px 与铅笔一致避免 mask 图标异步加载闪烁 -->
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z"
/>
<path
d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12"
/>
<path
d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17"
/>
</svg>
</span> </span>
<span class="sidebar-nav-label">工作区</span> <span class="sidebar-nav-label">{{ workspaceKind === 'project' ? '项目' : '工作区' }}</span>
</button> <span class="workspace-entry-current">{{ currentWorkspaceLabel }}</span>
<button
type="button"
class="sidebar-nav-row monitor-mode-btn"
:class="{ active: displayMode === 'monitor', blocked: displayModeDisabled }"
:aria-disabled="displayModeDisabled"
:title="displayMode === 'monitor' ? '退出显示器' : '虚拟显示器'"
@click="$emit('toggle-display-mode')"
>
<span class="sidebar-nav-icon" data-tutorial="monitor-toggle" aria-hidden="true">
<span class="icon icon-md" :style="iconStyle('monitor')"></span>
</span>
<span class="sidebar-nav-label">虚拟显示器</span>
</button> </button>
</div> </div>
@ -493,6 +504,25 @@
</div> </div>
</div> </div>
<WorkspaceSwitcher
:open="workspaceSwitcherOpen"
:anchor-rect="workspaceAnchorRect"
:workspaces="workspaces"
:current-workspace-id="currentWorkspaceId"
:default-workspace-id="defaultWorkspaceId"
:workspace-kind="workspaceKind"
:busy="workspaceBusy"
:create-submitting="workspaceCreateSubmitting"
:error-message="workspaceError"
@close="workspaceSwitcherOpen = false"
@switch="$emit('switch-workspace', $event)"
@create="$emit('create-workspace', $event)"
@rename="$emit('rename-workspace', $event)"
@delete="$emit('delete-workspace', $event)"
@set-default="$emit('set-default-workspace', $event)"
@reveal="$emit('reveal-workspace', $event)"
/>
<div class="conversation-personal-entry" :class="{ active: personalPageVisible }"> <div class="conversation-personal-entry" :class="{ active: personalPageVisible }">
<button <button
type="button" type="button"
@ -525,6 +555,7 @@ import { storeToRefs } from 'pinia';
import { useUiStore } from '@/stores/ui'; import { useUiStore } from '@/stores/ui';
import { useConversationStore } from '@/stores/conversation'; import { useConversationStore } from '@/stores/conversation';
import { usePersonalizationStore } from '@/stores/personalization'; import { usePersonalizationStore } from '@/stores/personalization';
import WorkspaceSwitcher from './WorkspaceSwitcher.vue';
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@ -532,12 +563,14 @@ const props = withDefaults(
iconStyle?: (key: string) => Record<string, string>; iconStyle?: (key: string) => Record<string, string>;
showCollapseButton?: boolean; showCollapseButton?: boolean;
collapseButtonVariant?: 'toggle' | 'close'; collapseButtonVariant?: 'toggle' | 'close';
displayMode?: 'chat' | 'monitor';
displayModeDisabled?: boolean;
runningTasks?: any[]; runningTasks?: any[];
currentWorkspaceId?: string; currentWorkspaceId?: string;
defaultWorkspaceId?: string;
workspaces?: any[]; workspaces?: any[];
workspaceKind?: 'workspace' | 'project'; workspaceKind?: 'workspace' | 'project';
workspaceBusy?: boolean;
workspaceCreateSubmitting?: boolean;
workspaceError?: string;
hostWorkspaceEnabled?: boolean; hostWorkspaceEnabled?: boolean;
groupByWorkspace?: boolean; groupByWorkspace?: boolean;
versioningHostMode?: boolean; versioningHostMode?: boolean;
@ -548,12 +581,14 @@ const props = withDefaults(
{ {
showCollapseButton: true, showCollapseButton: true,
collapseButtonVariant: 'toggle', collapseButtonVariant: 'toggle',
displayMode: 'chat',
displayModeDisabled: false,
runningTasks: () => [], runningTasks: () => [],
currentWorkspaceId: '', currentWorkspaceId: '',
defaultWorkspaceId: '',
workspaces: () => [], workspaces: () => [],
workspaceKind: 'workspace', workspaceKind: 'workspace',
workspaceBusy: false,
workspaceCreateSubmitting: false,
workspaceError: '',
hostWorkspaceEnabled: false, hostWorkspaceEnabled: false,
groupByWorkspace: false, groupByWorkspace: false,
versioningHostMode: false, versioningHostMode: false,
@ -574,8 +609,10 @@ const emit = defineEmits<{
(event: 'personal'): void; (event: 'personal'): void;
(event: 'delete', id: string): void; (event: 'delete', id: string): void;
(event: 'duplicate', id: string): void; (event: 'duplicate', id: string): void;
(event: 'toggle-workspace'): void; (event: 'switch-workspace', workspaceId: string): void;
(event: 'toggle-display-mode'): void; (event: 'create-workspace', payload: { path: string; label: string }): void;
(event: 'delete-workspace', item: any): void;
(event: 'set-default-workspace', workspaceId: string): void;
(event: 'select-workspace-conversation', payload: { conversationId: string; workspaceId: string }): void; (event: 'select-workspace-conversation', payload: { conversationId: string; workspaceId: string }): void;
(event: 'create-workspace-conversation', workspaceId: string): void; (event: 'create-workspace-conversation', workspaceId: string): void;
(event: 'reveal-workspace', workspaceId: string): void; (event: 'reveal-workspace', workspaceId: string): void;
@ -587,11 +624,7 @@ const uiStore = useUiStore();
const conversationStore = useConversationStore(); const conversationStore = useConversationStore();
const personalizationStore = usePersonalizationStore(); const personalizationStore = usePersonalizationStore();
const { const { sidebarCollapsed: collapsed } = storeToRefs(uiStore);
sidebarCollapsed: collapsed,
workspaceCollapsed,
chatDisplayMode: uiChatDisplayMode
} = storeToRefs(uiStore);
const { const {
searchQuery, searchQuery,
searchResults, searchResults,
@ -610,11 +643,40 @@ const {
const { visible: personalPageVisible } = storeToRefs(personalizationStore); const { visible: personalPageVisible } = storeToRefs(personalizationStore);
const iconStyle = (key: string) => (props.iconStyle ? props.iconStyle(key) : {}); const iconStyle = (key: string) => (props.iconStyle ? props.iconStyle(key) : {});
const displayMode = computed(() => props.displayMode);
const monitorModeActive = computed( /* ---------- 工作区切换浮层 ---------- */
() => displayMode.value === 'monitor' || uiChatDisplayMode.value === 'monitor' const workspaceEntryBtn = ref<HTMLElement | null>(null);
); const workspaceSwitcherOpen = ref(false);
const displayModeDisabled = computed(() => props.displayModeDisabled); const workspaceAnchorRect = ref<{ left: number; top: number; right: number; bottom: number } | null>(null);
const currentWorkspaceLabel = computed(() => {
const list = Array.isArray(props.workspaces) ? props.workspaces : [];
const current = list.find((item: any) => String(item?.workspace_id || '') === props.currentWorkspaceId);
return current?.label || '';
});
const toggleWorkspaceSwitcher = () => {
if (workspaceSwitcherOpen.value) {
workspaceSwitcherOpen.value = false;
return;
}
const el = workspaceEntryBtn.value;
if (el) {
const rect = el.getBoundingClientRect();
/* sidebar-inner overflow
按钮布局右缘在可视区外锚点右缘需夹紧到侧边栏可视右缘
否则浮层会出现在展开后的位置而不是紧贴折叠栏 */
const sidebarEl = el.closest('.conversation-sidebar');
const visibleRight = sidebarEl ? sidebarEl.getBoundingClientRect().right : rect.right;
workspaceAnchorRect.value = {
left: rect.left,
top: rect.top,
right: Math.min(rect.right, visibleRight),
bottom: rect.bottom
};
}
workspaceSwitcherOpen.value = true;
};
const openActionMenuId = ref<string | null>(null); const openActionMenuId = ref<string | null>(null);
const actionMenuPosition = ref<{ top: number; right: number }>({ top: 0, right: 0 }); const actionMenuPosition = ref<{ top: number; right: number }>({ top: 0, right: 0 });
const hoverWorkspaceId = ref<string | null>(null); const hoverWorkspaceId = ref<string | null>(null);

View File

@ -0,0 +1,917 @@
<template>
<Teleport to="body">
<transition name="ws-switcher">
<div
v-if="open"
ref="popoverEl"
class="ws-switcher"
:class="{ 'ws-switcher--project': isProject }"
:style="popoverStyle"
role="dialog"
:aria-label="noun"
@click.stop
>
<div class="ws-switcher__header">
<span class="ws-switcher__title">{{ noun }}</span>
<span class="ws-switcher__count">{{ workspaces.length }} </span>
</div>
<div class="ws-switcher__list" ref="listEl" @scroll="closeMenu">
<div
v-for="item in workspaces"
:key="item.workspace_id"
class="ws-row"
:data-ws-id="item.workspace_id"
:class="{
current: item.workspace_id === currentWorkspaceId,
'menu-open': menuOpenId === item.workspace_id
}"
role="option"
:aria-selected="item.workspace_id === currentWorkspaceId"
@click="handleRowClick(item)"
>
<!-- 删除确认内联 -->
<div v-if="deletingId === item.workspace_id" class="ws-confirm">
<div class="ws-confirm-text">
删除{{ item.label }}{{ isProject ? '项目文件夹和对话记录将一并删除。' : '不会删除磁盘上的文件夹。' }}
</div>
<div class="ws-confirm-actions">
<button type="button" class="ws-btn ghost" :disabled="busy" @click.stop="deletingId = null">
取消
</button>
<button type="button" class="ws-btn danger" :disabled="busy" @click.stop="confirmDelete(item)">
删除
</button>
</div>
</div>
<template v-else>
<span class="ws-row-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
</svg>
</span>
<span class="ws-row-text">
<input
v-if="renamingId === item.workspace_id"
ref="renameInputEl"
v-model="renameDraft"
class="ws-rename-input"
:disabled="busy"
@click.stop
@keydown.enter.prevent="commitRename(item)"
@keydown.esc.prevent="renamingId = null"
/>
<span v-else class="ws-row-name">{{ item.label }}</span>
<span v-if="!isProject" class="ws-row-path">{{ item.path || '未配置路径' }}</span>
</span>
<span class="ws-row-meta">
<span v-if="item.workspace_id === defaultWorkspaceId" class="ws-tag">默认</span>
<button
type="button"
class="ws-more"
:aria-expanded="menuOpenId === item.workspace_id"
aria-label="更多操作"
@click.stop="toggleMenu(item)"
>
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<circle cx="5" cy="12" r="1.6" /><circle cx="12" cy="12" r="1.6" /><circle cx="19" cy="12" r="1.6" />
</svg>
</button>
</span>
</template>
</div>
<div v-if="!workspaces.length" class="ws-empty">
{{ isProject ? '暂无项目' : '暂无工作区' }}
</div>
</div>
<!-- 底部新建按钮 新建表单高度动画容器 -->
<div class="ws-bottom" ref="bottomEl">
<div v-if="!createOpen" class="ws-footer" :class="{ fading: bottomFading }">
<button type="button" class="ws-create-btn" :disabled="busy" @click.stop="openCreateForm">
<span class="ws-create-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14" /><path d="M12 5v14" />
</svg>
</span>
<span>新建{{ noun }}</span>
</button>
</div>
<form v-else class="ws-create-form" :class="{ fading: bottomFading }" @submit.prevent="submitCreate">
<input
ref="createNameEl"
v-model="createLabel"
type="text"
:placeholder="isProject ? '项目名称' : '工作区名称(可选)'"
autocomplete="off"
:disabled="createSubmitting"
/>
<input
v-if="!isProject"
v-model="createPath"
type="text"
placeholder="路径:绝对路径或相对仓库路径"
autocomplete="off"
:disabled="createSubmitting"
/>
<div v-if="errorMessage" class="ws-create-error">{{ errorMessage }}</div>
<div class="ws-create-actions">
<button type="button" class="ws-btn ghost" :disabled="createSubmitting" @click.stop="closeCreateForm">
取消
</button>
<button type="submit" class="ws-btn primary" :disabled="createSubmitting">
{{ createSubmitting ? '创建中...' : '创建' }}
</button>
</div>
</form>
</div>
</div>
</transition>
<!-- 二级菜单fixed 浮层逃逸容器裁切同个人空间二级菜单方案 -->
<transition name="ws-menu">
<div
v-if="menuOpenId"
class="ws-menu-floating"
:style="menuStyle"
role="menu"
@click.stop
>
<button
type="button"
:disabled="menuOpenId === defaultWorkspaceId || busy"
@click="handleMenuAction('set-default')"
>
设为默认
</button>
<button type="button" :disabled="busy" @click="handleMenuAction('rename')">重命名</button>
<button type="button" :disabled="isProject || busy" @click="handleMenuAction('reveal')">
在文件夹中打开
</button>
<div class="ws-menu-sep"></div>
<button type="button" class="danger" :disabled="busy" @click="handleMenuAction('delete')">删除</button>
</div>
</transition>
</Teleport>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
defineOptions({ name: 'WorkspaceSwitcher' });
type WorkspaceItem = {
workspace_id: string;
label: string;
path?: string;
running_task_count?: number;
};
type AnchorRect = { left: number; top: number; right: number; bottom: number };
const props = withDefaults(
defineProps<{
open: boolean;
anchorRect?: AnchorRect | null;
workspaces?: WorkspaceItem[];
currentWorkspaceId?: string;
defaultWorkspaceId?: string;
workspaceKind?: 'workspace' | 'project';
busy?: boolean;
createSubmitting?: boolean;
errorMessage?: string;
}>(),
{
anchorRect: null,
workspaces: () => [],
currentWorkspaceId: '',
defaultWorkspaceId: '',
workspaceKind: 'workspace',
busy: false,
createSubmitting: false,
errorMessage: ''
}
);
const emit = defineEmits<{
(event: 'close'): void;
(event: 'switch', workspaceId: string): void;
(event: 'create', payload: { path: string; label: string }): void;
(event: 'rename', payload: { workspace_id: string; label: string }): void;
(event: 'delete', item: WorkspaceItem): void;
(event: 'set-default', workspaceId: string): void;
(event: 'reveal', workspaceId: string): void;
}>();
const isProject = computed(() => props.workspaceKind === 'project');
const noun = computed(() => (isProject.value ? '项目' : '工作区'));
const popoverEl = ref<HTMLElement | null>(null);
const listEl = ref<HTMLElement | null>(null);
const bottomEl = ref<HTMLElement | null>(null);
const createNameEl = ref<HTMLInputElement | null>(null);
const renameInputEl = ref<HTMLInputElement[] | null>(null);
const menuOpenId = ref<string | null>(null);
const renamingId = ref<string | null>(null);
const deletingId = ref<string | null>(null);
const renameDraft = ref('');
const createOpen = ref(false);
const createPath = ref('');
const createLabel = ref('');
const bottomFading = ref(false);
/* ---------- 浮层定位:锚定入口按钮,夹紧在视口内 ---------- */
const POPOVER_WIDTH = 300;
const popoverStyle = ref<Record<string, string>>({});
const updatePosition = () => {
const anchor = props.anchorRect;
if (!anchor || typeof window === 'undefined') return;
const vw = window.innerWidth;
const vh = window.innerHeight;
const popH = popoverEl.value?.offsetHeight || 360;
let left = anchor.right + 8;
let top = anchor.top - 6;
/* 右侧空间不足(如移动端全屏侧边栏)→ 改为在入口下方展开 */
if (left + POPOVER_WIDTH > vw - 8) {
left = Math.max(8, Math.min(anchor.left, vw - POPOVER_WIDTH - 8));
top = anchor.bottom + 6;
}
if (top + popH > vh - 8) top = Math.max(8, vh - popH - 8);
popoverStyle.value = {
left: `${Math.round(left)}px`,
top: `${Math.round(top)}px`
};
};
/* ---------- 「…」浮动菜单定位 ---------- */
const menuStyle = ref<Record<string, string>>({});
const closeMenu = () => {
menuOpenId.value = null;
};
const toggleMenu = async (item: WorkspaceItem) => {
if (props.busy) return;
if (menuOpenId.value === item.workspace_id) {
closeMenu();
return;
}
renamingId.value = null;
deletingId.value = null;
menuOpenId.value = item.workspace_id;
await nextTick();
const id = item.workspace_id;
const rowEl = listEl.value?.querySelector(`.ws-row[data-ws-id="${CSS.escape(id)}"]`);
const btn = rowEl?.querySelector('.ws-more') as HTMLElement | null;
if (!btn) {
closeMenu();
return;
}
const rect = btn.getBoundingClientRect();
const menuEl = document.querySelector('.ws-menu-floating') as HTMLElement | null;
const mw = menuEl?.offsetWidth || 150;
const mh = menuEl?.offsetHeight || 150;
let left = rect.right - mw;
let top = rect.bottom + 4;
left = Math.max(8, Math.min(left, window.innerWidth - mw - 8));
if (top + mh > window.innerHeight - 8) top = rect.top - mh - 4;
menuStyle.value = { left: `${Math.round(left)}px`, top: `${Math.round(top)}px` };
};
const handleMenuAction = (act: 'set-default' | 'rename' | 'reveal' | 'delete') => {
const id = menuOpenId.value;
const item = props.workspaces.find((w) => w.workspace_id === id);
closeMenu();
if (!item) return;
if (act === 'set-default') {
emit('set-default', item.workspace_id);
} else if (act === 'rename') {
renamingId.value = item.workspace_id;
renameDraft.value = item.label;
nextTick(() => {
const el = renameInputEl.value?.[0];
el?.focus();
el?.select();
});
} else if (act === 'reveal') {
emit('reveal', item.workspace_id);
} else if (act === 'delete') {
deletingId.value = item.workspace_id;
}
};
/* ---------- 行行为 ---------- */
const handleRowClick = (item: WorkspaceItem) => {
if (props.busy) return;
if (renamingId.value || deletingId.value) return;
if (menuOpenId.value) {
closeMenu();
return;
}
if (item.workspace_id !== props.currentWorkspaceId) {
emit('switch', item.workspace_id);
}
};
const commitRename = (item: WorkspaceItem) => {
const label = renameDraft.value.trim();
if (label && label !== item.label) {
emit('rename', { workspace_id: item.workspace_id, label });
}
renamingId.value = null;
};
const confirmDelete = (item: WorkspaceItem) => {
deletingId.value = null;
emit('delete', item);
};
/* ---------- 新建表单:底部高度动画 + 交叉淡入淡出 ---------- */
let bottomAnimating = false;
const swapBottom = (toForm: boolean, done?: () => void) => {
if (bottomAnimating) return;
bottomAnimating = true;
const bottom = bottomEl.value;
if (!bottom) {
createOpen.value = toForm;
bottomAnimating = false;
if (done) done();
return;
}
const h0 = bottom.offsetHeight;
bottom.style.height = `${h0}px`;
bottomFading.value = true;
window.setTimeout(() => {
createOpen.value = toForm;
nextTick(() => {
/* scrollHeight clientHeight
auto 测真实目标高度此时新内容仍在淡出态视觉无跳变 */
bottom.style.height = 'auto';
const h1 = bottom.offsetHeight;
bottom.style.height = `${h0}px`;
void bottom.offsetHeight; /* 强制 reflow确保过渡从 h0 开始 */
bottom.style.height = `${h1}px`;
bottomFading.value = false;
window.setTimeout(() => {
bottom.style.height = '';
bottomAnimating = false;
if (done) done();
}, 210);
});
}, 130);
};
const openCreateForm = () => {
if (createOpen.value || props.busy) return;
closeMenu();
createPath.value = '';
createLabel.value = '';
swapBottom(true, () => createNameEl.value?.focus());
};
const closeCreateForm = () => {
if (!createOpen.value) return;
swapBottom(false);
};
const submitCreate = () => {
emit('create', {
path: createPath.value.trim(),
label: createLabel.value.trim()
});
};
/* 创建成功后由父级刷新列表;成功后收起表单 */
watch(
() => props.createSubmitting,
(submitting, prev) => {
if (prev && !submitting && createOpen.value && !props.errorMessage) {
closeCreateForm();
}
}
);
/* ---------- 全局关闭 ---------- */
const onDocClick = (event: MouseEvent) => {
const target = event.target as Node | null;
if (!target) return;
if (popoverEl.value && !popoverEl.value.contains(target)) {
/* toggle
避免 capture 阶段先 close按钮 click 又重新打开导致重播动画 */
const anchor = props.anchorRect;
if (anchor) {
const x = event.clientX;
const y = event.clientY;
if (x >= anchor.left && x <= anchor.right && y >= anchor.top && y <= anchor.bottom) {
return;
}
}
emit('close');
}
};
const onDocKeydown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
if (menuOpenId.value) {
closeMenu();
return;
}
if (renamingId.value || deletingId.value) {
renamingId.value = null;
deletingId.value = null;
return;
}
if (createOpen.value) {
closeCreateForm();
return;
}
emit('close');
};
const onWindowResize = () => {
closeMenu();
updatePosition();
};
watch(
() => props.open,
async (open) => {
if (open) {
menuOpenId.value = null;
renamingId.value = null;
deletingId.value = null;
await nextTick();
updatePosition();
document.addEventListener('click', onDocClick, true);
document.addEventListener('keydown', onDocKeydown);
window.addEventListener('resize', onWindowResize);
} else {
if (createOpen.value) createOpen.value = false;
document.removeEventListener('click', onDocClick, true);
document.removeEventListener('keydown', onDocKeydown);
window.removeEventListener('resize', onWindowResize);
}
}
);
onBeforeUnmount(() => {
document.removeEventListener('click', onDocClick, true);
document.removeEventListener('keydown', onDocKeydown);
window.removeEventListener('resize', onWindowResize);
});
</script>
<style scoped>
/* ---------- 浮层主体:单层扁平容器 ---------- */
.ws-switcher {
--ws-row-h: 50px;
position: fixed;
z-index: 1500;
width: 300px;
display: flex;
flex-direction: column;
border: 1px solid var(--border-default);
border-radius: 12px;
background: var(--surface-raised);
box-shadow: var(--theme-shadow-soft);
overflow: hidden;
}
.ws-switcher--project {
--ws-row-h: 38px;
}
.ws-switcher-enter-active,
.ws-switcher-leave-active {
transition: opacity 120ms ease, transform 150ms cubic-bezier(0.22, 1, 0.36, 1);
}
.ws-switcher-enter-from,
.ws-switcher-leave-to {
opacity: 0;
transform: translateY(-4px) scale(0.98);
}
/* 头部 */
.ws-switcher__header {
flex: none;
display: flex;
align-items: center;
justify-content: space-between;
height: 42px;
padding: 0 14px;
border-bottom: 1px solid var(--border-default);
}
.ws-switcher__title {
font-size: 12.5px;
font-weight: 600;
color: var(--text-secondary);
}
.ws-switcher__count {
font-size: 11px;
color: var(--text-tertiary);
}
/* 列表:刚好 7 行7h + 6gap + 上下padding超出内部滚动 */
.ws-switcher__list {
flex: none;
max-height: calc(var(--ws-row-h) * 7 + 18px);
overflow-y: auto;
padding: 6px;
display: flex;
flex-direction: column;
gap: 1px;
scrollbar-width: thin;
scrollbar-color: var(--border-strong) transparent;
}
.ws-switcher__list::-webkit-scrollbar {
width: 8px;
}
.ws-switcher__list::-webkit-scrollbar-thumb {
background: var(--border-strong);
border-radius: 4px;
border: 2px solid var(--surface-raised);
}
.ws-switcher__list::-webkit-scrollbar-track {
background: transparent;
}
/* 工作区行 */
.ws-row {
position: relative;
/* flex shrink:1 max-height min-content
双行文字 35px导致行高失效可见行数虚多必须禁止压缩 */
flex: none;
display: flex;
align-items: center;
gap: 10px;
width: 100%;
height: var(--ws-row-h);
padding: 0 8px;
border-radius: 8px;
text-align: left;
cursor: pointer;
}
.ws-row:hover {
background: color-mix(in srgb, var(--text-primary) 4%, transparent);
}
.ws-row.current {
background: color-mix(in srgb, var(--text-primary) 6%, transparent);
}
.ws-row.current:hover {
background: color-mix(in srgb, var(--text-primary) 7%, transparent);
}
.ws-row-icon {
flex: none;
width: 17px;
height: 17px; /* 行容器 align-items: center整行上下居中 */
color: var(--text-tertiary);
}
.ws-row-icon svg {
width: 100%;
height: 100%;
display: block;
}
.ws-row.current .ws-row-icon {
color: var(--text-secondary);
}
.ws-row-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.ws-row-name {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ws-row-path {
font-size: 11.5px;
color: var(--text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ws-row-meta {
flex: none;
display: flex;
align-items: center;
gap: 8px;
}
.ws-tag {
font-size: 11px;
color: var(--text-tertiary);
white-space: nowrap;
}
.ws-more {
flex: none;
width: 24px;
height: 24px;
display: none;
align-items: center;
justify-content: center;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
}
.ws-more svg {
width: 15px;
height: 15px;
display: block;
}
.ws-more:hover {
color: var(--text-primary);
background: color-mix(in srgb, var(--text-primary) 7%, transparent);
}
.ws-row:hover .ws-more,
.ws-row.menu-open .ws-more {
display: flex;
}
.ws-empty {
padding: 18px 8px;
font-size: 12px;
color: var(--text-tertiary);
text-align: center;
}
/* 重命名内联输入 */
.ws-rename-input {
font: inherit;
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
background: transparent;
border: 0;
border-bottom: 1px solid var(--accent);
outline: none;
padding: 1px 0;
width: 100%;
}
/* 删除确认内联 */
.ws-confirm {
display: flex;
flex-direction: column;
gap: 5px;
width: 100%;
padding: 2px 0;
}
.ws-confirm-text {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.4;
}
.ws-confirm-actions {
display: flex;
gap: 8px;
}
/* 通用小按钮 */
.ws-btn {
height: 26px;
padding: 0 10px;
border: 1px solid var(--border-strong);
border-radius: 6px;
background: transparent;
font-size: 12px;
color: var(--text-secondary);
cursor: pointer;
white-space: nowrap;
}
.ws-btn:hover:not(:disabled) {
background: color-mix(in srgb, var(--text-primary) 5%, transparent);
}
.ws-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.ws-btn.primary {
background: var(--accent);
border-color: var(--accent);
color: var(--on-accent);
}
.ws-btn.primary:hover:not(:disabled) {
background: var(--accent-hover);
}
.ws-btn.danger {
background: var(--state-danger);
border-color: var(--state-danger);
color: var(--on-accent);
}
.ws-btn.danger:hover:not(:disabled) {
background: var(--state-danger-strong);
}
.ws-btn.ghost {
border-color: var(--border-default);
}
/* 底部区域:高度动画容器 */
.ws-bottom {
flex: none;
border-top: 1px solid var(--border-default);
overflow: hidden;
transition: height 200ms cubic-bezier(0.22, 1, 0.36, 1);
}
.ws-footer {
padding: 6px;
transition: opacity 130ms ease;
}
.ws-footer.fading {
opacity: 0;
}
.ws-create-btn {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
height: 34px;
padding: 0 8px;
border: 0;
border-radius: 8px;
background: transparent;
font-size: 12.5px;
color: var(--text-secondary);
cursor: pointer;
}
.ws-create-btn:hover:not(:disabled) {
background: color-mix(in srgb, var(--text-primary) 4%, transparent);
color: var(--text-primary);
}
.ws-create-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.ws-create-icon {
flex: none;
width: 15px;
height: 15px;
color: var(--text-tertiary);
}
.ws-create-icon svg {
width: 100%;
height: 100%;
display: block;
}
.ws-create-btn:hover:not(:disabled) .ws-create-icon {
color: var(--text-secondary);
}
/* 新建表单 */
.ws-create-form {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
transition: opacity 150ms ease;
}
.ws-create-form.fading {
opacity: 0;
}
.ws-create-form input {
height: 32px;
padding: 0 10px;
border: 1px solid var(--border-default);
border-radius: 8px;
background: var(--surface-soft);
color: var(--text-primary);
font: inherit;
font-size: 12.5px;
outline: none;
}
.ws-create-form input:focus {
border-color: var(--accent);
}
.ws-create-form input::placeholder {
color: var(--text-muted);
}
.ws-create-error {
font-size: 12px;
color: var(--state-danger);
}
.ws-create-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
/* 「…」浮动菜单Teleport 到 bodyfixed 定位逃逸裁切) */
.ws-menu-floating {
position: fixed;
z-index: 1600;
min-width: 136px;
padding: 4px;
border: 1px solid var(--border-default);
border-radius: 10px;
background: var(--surface-raised);
box-shadow: var(--theme-shadow-soft);
display: flex;
flex-direction: column;
}
.ws-menu-enter-active,
.ws-menu-leave-active {
transition: opacity 110ms ease, transform 140ms cubic-bezier(0.22, 1, 0.36, 1);
}
.ws-menu-enter-from,
.ws-menu-leave-to {
opacity: 0;
transform: translateY(-3px) scale(0.98);
}
.ws-menu-floating button {
display: flex;
align-items: center;
width: 100%;
height: 30px;
padding: 0 10px;
border: 0;
border-radius: 6px;
background: transparent;
font-size: 12.5px;
color: var(--text-secondary);
text-align: left;
white-space: nowrap;
cursor: pointer;
}
.ws-menu-floating button:hover:not(:disabled) {
background: color-mix(in srgb, var(--text-primary) 5%, transparent);
color: var(--text-primary);
}
.ws-menu-floating button.danger {
color: var(--state-danger);
}
.ws-menu-floating button.danger:hover:not(:disabled) {
background: color-mix(in srgb, var(--state-danger) 8%, transparent);
}
.ws-menu-floating button:disabled {
color: var(--text-muted);
cursor: default;
}
.ws-menu-sep {
height: 1px;
margin: 4px 6px;
background: var(--border-default);
}
</style>

View File

@ -9,7 +9,6 @@ interface ResizeContext {
rightWidth?: number; rightWidth?: number;
minPanelWidth?: number; minPanelWidth?: number;
maxPanelWidth?: number; maxPanelWidth?: number;
leftWidth?: number;
sidebarCollapsed?: boolean; sidebarCollapsed?: boolean;
handleResize?: (event: MouseEvent) => void; handleResize?: (event: MouseEvent) => void;
stopResize?: () => void; stopResize?: () => void;
@ -51,12 +50,7 @@ export function handleResize(ctx: ResizeContext, event: MouseEvent) {
const minWidth = ctx.minPanelWidth ?? 240; const minWidth = ctx.minPanelWidth ?? 240;
const maxWidth = ctx.maxPanelWidth ?? 480; const maxWidth = ctx.maxPanelWidth ?? 480;
if (ctx.resizingPanel === 'left') { if (ctx.resizingPanel === 'right') {
const offset = ctx.sidebarCollapsed ? 60 : 300;
let newWidth = event.clientX - offset;
newWidth = clamp(newWidth, minWidth, maxWidth);
ctx.leftWidth = newWidth;
} else if (ctx.resizingPanel === 'right') {
let newWidth = containerWidth - event.clientX; let newWidth = containerWidth - event.clientX;
newWidth = clamp(newWidth, minWidth, maxWidth); newWidth = clamp(newWidth, minWidth, maxWidth);
ctx.rightWidth = newWidth; ctx.rightWidth = newWidth;

View File

@ -1,5 +1,4 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { useUiStore } from './ui';
import { useConversationStore } from './conversation'; import { useConversationStore } from './conversation';
interface BackgroundCommand { interface BackgroundCommand {
@ -21,7 +20,6 @@ interface BackgroundCommandDetail extends BackgroundCommand {
interface BackgroundCommandState { interface BackgroundCommandState {
commands: BackgroundCommand[]; commands: BackgroundCommand[];
pollTimer: ReturnType<typeof setInterval> | null;
detailPollTimer: ReturnType<typeof setInterval> | null; detailPollTimer: ReturnType<typeof setInterval> | null;
activeCommand: BackgroundCommand | null; activeCommand: BackgroundCommand | null;
/** QuickDock 静默查看的 command_id拉详情/轮询但不设 activeCommand避免弹出旧详情窗口 */ /** QuickDock 静默查看的 command_id拉详情/轮询但不设 activeCommand避免弹出旧详情窗口 */
@ -44,7 +42,6 @@ const TERMINAL_STATUSES = new Set(['completed', 'failed', 'timeout', 'cancelled'
export const useBackgroundCommandStore = defineStore('backgroundCommand', { export const useBackgroundCommandStore = defineStore('backgroundCommand', {
state: (): BackgroundCommandState => ({ state: (): BackgroundCommandState => ({
commands: [], commands: [],
pollTimer: null,
detailPollTimer: null, detailPollTimer: null,
activeCommand: null, activeCommand: null,
silentDetailCommandId: null, silentDetailCommandId: null,
@ -84,23 +81,6 @@ export const useBackgroundCommandStore = defineStore('backgroundCommand', {
console.error('获取后台指令列表失败:', error); console.error('获取后台指令列表失败:', error);
} }
}, },
startPolling() {
if (this.pollTimer) {
return;
}
const uiStore = useUiStore();
this.pollTimer = setInterval(() => {
if (uiStore.panelMode === 'backgroundCommands') {
this.fetchCommands();
}
}, 5000);
},
stopPolling() {
if (this.pollTimer) {
clearInterval(this.pollTimer);
this.pollTimer = null;
}
},
openCommand(command: BackgroundCommand, options?: { silent?: boolean }) { openCommand(command: BackgroundCommand, options?: { silent?: boolean }) {
if (!command || !command.command_id) { if (!command || !command.command_id) {
return; return;

View File

@ -166,13 +166,16 @@ export const useFileStore = defineStore('file', {
}; };
}, },
async fetchTodoList() { async fetchTodoList() {
// /new 等无对话场景:待办是对话级数据,直接清空不发请求。
// 不带 id 时后端返回工作区级服务 terminal——它可能被 /new 首条消息的
// 任务污染而持有最近对话的待办,会导致 /new 页面残留老待办。
const convId = useConversationStore().currentConversationId;
if (!convId) {
this.setTodoList(null);
return;
}
try { try {
// 必须携带 conversation_idtodo 数据保存在对话级 terminal 的内存中, const url = `/api/todo-list?conversation_id=${encodeURIComponent(convId)}`;
// 不带 id 时后端返回工作区级服务 terminal永远为空
const convId = useConversationStore().currentConversationId;
const url = convId
? `/api/todo-list?conversation_id=${encodeURIComponent(convId)}`
: '/api/todo-list';
const response = await fetch(url); const response = await fetch(url);
const data = await response.json(); const data = await response.json();
if (data && data.success) { if (data && data.success) {

View File

@ -1,6 +1,6 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { useModelStore } from './model'; import { useModelStore } from './model';
import { persistDefaultHideWorkspace, useUiStore } from './ui';
export type BlockDisplayMode = 'traditional' | 'stacked' | 'minimal'; export type BlockDisplayMode = 'traditional' | 'stacked' | 'minimal';
export type CompactMessageDisplay = 'full' | 'brief'; export type CompactMessageDisplay = 'full' | 'brief';
@ -62,8 +62,6 @@ interface PersonalForm {
deep_compress_form: 'file' | 'inject'; deep_compress_form: 'file' | 'inject';
agents_md_auto_inject: boolean; agents_md_auto_inject: boolean;
allow_root_file_creation: boolean; allow_root_file_creation: boolean;
default_hide_workspace: boolean;
hide_quick_dock: boolean;
group_sidebar_by_workspace: boolean; group_sidebar_by_workspace: boolean;
sidebar_pinned_workspaces: string[]; sidebar_pinned_workspaces: string[];
sidebar_workspace_order: string[]; sidebar_workspace_order: string[];
@ -225,8 +223,6 @@ const defaultForm = (): PersonalForm => ({
deep_compress_form: 'file', deep_compress_form: 'file',
agents_md_auto_inject: false, agents_md_auto_inject: false,
allow_root_file_creation: false, allow_root_file_creation: false,
default_hide_workspace: false,
hide_quick_dock: false,
group_sidebar_by_workspace: false, group_sidebar_by_workspace: false,
sidebar_pinned_workspaces: [], sidebar_pinned_workspaces: [],
sidebar_workspace_order: [], sidebar_workspace_order: [],
@ -466,8 +462,6 @@ export const usePersonalizationStore = defineStore('personalization', {
deep_compress_form: data.deep_compress_form === 'inject' ? 'inject' : 'file', deep_compress_form: data.deep_compress_form === 'inject' ? 'inject' : 'file',
agents_md_auto_inject: !!data.agents_md_auto_inject, agents_md_auto_inject: !!data.agents_md_auto_inject,
allow_root_file_creation: !!data.allow_root_file_creation, allow_root_file_creation: !!data.allow_root_file_creation,
default_hide_workspace: !!data.default_hide_workspace,
hide_quick_dock: !!data.hide_quick_dock,
group_sidebar_by_workspace: !!data.group_sidebar_by_workspace, group_sidebar_by_workspace: !!data.group_sidebar_by_workspace,
sidebar_pinned_workspaces: Array.isArray(data.sidebar_pinned_workspaces) sidebar_pinned_workspaces: Array.isArray(data.sidebar_pinned_workspaces)
? data.sidebar_pinned_workspaces.filter((item: unknown) => typeof item === 'string') ? data.sidebar_pinned_workspaces.filter((item: unknown) => typeof item === 'string')
@ -497,12 +491,8 @@ export const usePersonalizationStore = defineStore('personalization', {
if (this.form.theme !== currentTheme) { if (this.form.theme !== currentTheme) {
this.applyTheme(this.form.theme); this.applyTheme(this.form.theme);
} }
persistDefaultHideWorkspace(this.form.default_hide_workspace);
persistStackedHideBorders(this.form.stacked_hide_borders); persistStackedHideBorders(this.form.stacked_hide_borders);
persistMinimalExpandHeightLimited(this.form.minimal_expand_height_limited); persistMinimalExpandHeightLimited(this.form.minimal_expand_height_limited);
if (this.form.default_hide_workspace) {
useUiStore().setWorkspaceCollapsed(true);
}
// 简略消息显示:以配置文件为准,同步到旧版 localStorage 镜像供 ChatArea 读取。 // 简略消息显示:以配置文件为准,同步到旧版 localStorage 镜像供 ChatArea 读取。
// 一次性迁移:后端仍为默认 full但本地缓存遗留 brief旧版纯前端记录回写到配置文件。 // 一次性迁移:后端仍为默认 full但本地缓存遗留 brief旧版纯前端记录回写到配置文件。
const cachedCompact = this.experiments.compactMessageDisplay; const cachedCompact = this.experiments.compactMessageDisplay;

View File

@ -1,5 +1,4 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { useUiStore } from './ui';
import { useConversationStore } from './conversation'; import { useConversationStore } from './conversation';
interface SubAgent { interface SubAgent {
@ -25,7 +24,6 @@ interface SubAgentActivityEntry {
interface SubAgentState { interface SubAgentState {
subAgents: SubAgent[]; subAgents: SubAgent[];
pollTimer: ReturnType<typeof setInterval> | null;
activityTimer: ReturnType<typeof setInterval> | null; activityTimer: ReturnType<typeof setInterval> | null;
activeAgent: SubAgent | null; activeAgent: SubAgent | null;
/** QuickDock 静默查看的 task_id不设 activeAgent避免弹出旧进度窗口 */ /** QuickDock 静默查看的 task_id不设 activeAgent避免弹出旧进度窗口 */
@ -41,7 +39,6 @@ const TERMINAL_STATUSES = new Set(['completed', 'failed', 'timeout', 'terminated
export const useSubAgentStore = defineStore('subAgent', { export const useSubAgentStore = defineStore('subAgent', {
state: (): SubAgentState => ({ state: (): SubAgentState => ({
subAgents: [], subAgents: [],
pollTimer: null,
activityTimer: null, activityTimer: null,
activeAgent: null, activeAgent: null,
/** QuickDock 静默查看的 task_id拉 activity/轮询但不设 activeAgent避免弹出旧进度窗口 */ /** QuickDock 静默查看的 task_id拉 activity/轮询但不设 activeAgent避免弹出旧进度窗口 */
@ -92,23 +89,6 @@ export const useSubAgentStore = defineStore('subAgent', {
console.error('获取子智能体列表失败:', error); console.error('获取子智能体列表失败:', error);
} }
}, },
startPolling() {
if (this.pollTimer) {
return;
}
const uiStore = useUiStore();
this.pollTimer = setInterval(() => {
if (uiStore.panelMode === 'subAgents') {
this.fetchSubAgents();
}
}, 5000);
},
stopPolling() {
if (this.pollTimer) {
clearInterval(this.pollTimer);
this.pollTimer = null;
}
},
openSubAgent(agent: SubAgent, options?: { silent?: boolean }) { openSubAgent(agent: SubAgent, options?: { silent?: boolean }) {
if (!agent || !agent.task_id) { if (!agent || !agent.task_id) {
return; return;

View File

@ -1,23 +1,7 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
type PanelMode = 'files' | 'todo' | 'subAgents' | 'backgroundCommands';
type ResizingPanel = 'left' | 'right' | null; type ResizingPanel = 'left' | 'right' | null;
type MobileOverlayTarget = 'conversation' | 'workspace' | 'focus' | 'approval' | null; type MobileOverlayTarget = 'conversation' | 'focus' | 'approval' | null;
const DEFAULT_HIDE_WORKSPACE_STORAGE_KEY = 'agents_default_hide_workspace';
export const loadDefaultHideWorkspace = (): boolean => {
if (typeof window === 'undefined' || !window.localStorage) {
return false;
}
return window.localStorage.getItem(DEFAULT_HIDE_WORKSPACE_STORAGE_KEY) === '1';
};
export const persistDefaultHideWorkspace = (hidden: boolean) => {
if (typeof window === 'undefined' || !window.localStorage) {
return;
}
window.localStorage.setItem(DEFAULT_HIDE_WORKSPACE_STORAGE_KEY, hidden ? '1' : '0');
};
interface QuotaToast { interface QuotaToast {
message: string; message: string;
@ -68,11 +52,7 @@ interface EasterEggState {
interface UiState { interface UiState {
sidebarCollapsed: boolean; sidebarCollapsed: boolean;
workspaceCollapsed: boolean;
chatDisplayMode: 'chat' | 'monitor'; chatDisplayMode: 'chat' | 'monitor';
panelMode: PanelMode;
panelMenuOpen: boolean;
leftWidth: number;
rightWidth: number; rightWidth: number;
rightCollapsed: boolean; rightCollapsed: boolean;
rightSplitRatio: number; rightSplitRatio: number;
@ -95,11 +75,7 @@ interface UiState {
export const useUiStore = defineStore('ui', { export const useUiStore = defineStore('ui', {
state: (): UiState => ({ state: (): UiState => ({
sidebarCollapsed: true, sidebarCollapsed: true,
workspaceCollapsed: loadDefaultHideWorkspace(),
chatDisplayMode: 'chat', chatDisplayMode: 'chat',
panelMode: 'files',
panelMenuOpen: false,
leftWidth: 350,
rightWidth: 420, rightWidth: 420,
rightCollapsed: true, rightCollapsed: true,
rightSplitRatio: 0.5, rightSplitRatio: 0.5,
@ -133,30 +109,12 @@ export const useUiStore = defineStore('ui', {
toggleSidebar() { toggleSidebar() {
this.sidebarCollapsed = !this.sidebarCollapsed; this.sidebarCollapsed = !this.sidebarCollapsed;
}, },
setWorkspaceCollapsed(collapsed: boolean) {
this.workspaceCollapsed = collapsed;
},
setChatDisplayMode(mode: 'chat' | 'monitor') { setChatDisplayMode(mode: 'chat' | 'monitor') {
this.chatDisplayMode = mode; this.chatDisplayMode = mode;
}, },
toggleChatDisplayMode() { toggleChatDisplayMode() {
this.chatDisplayMode = this.chatDisplayMode === 'chat' ? 'monitor' : 'chat'; this.chatDisplayMode = this.chatDisplayMode === 'chat' ? 'monitor' : 'chat';
}, },
toggleWorkspaceCollapsed() {
this.workspaceCollapsed = !this.workspaceCollapsed;
},
setPanelMode(mode: PanelMode) {
this.panelMode = mode;
},
setPanelMenuOpen(open: boolean) {
this.panelMenuOpen = open;
},
togglePanelMenu() {
this.panelMenuOpen = !this.panelMenuOpen;
},
setLeftWidth(width: number) {
this.leftWidth = width;
},
setRightWidth(width: number) { setRightWidth(width: number) {
this.rightWidth = width; this.rightWidth = width;
}, },

File diff suppressed because it is too large Load Diff

View File

@ -1,13 +1,11 @@
/* Modern reveal conversation sidebar */ /* Modern reveal conversation sidebar(挤压式:在文档流内推开主内容) */
.conversation-sidebar { .conversation-sidebar {
--conversation-collapsed-width: 52px; --conversation-collapsed-width: 44px;
--conversation-expanded-width: 306px; --conversation-expanded-width: 306px;
--conversation-icon-cell: 40px; --conversation-icon-cell: 32px;
--conversation-row-height: 40px; --conversation-row-height: 32px;
position: fixed; position: relative;
left: 0; flex-shrink: 0;
top: 0;
z-index: 120;
width: var(--conversation-expanded-width); width: var(--conversation-expanded-width);
height: var(--app-viewport) !important; height: var(--app-viewport) !important;
min-height: var(--app-viewport) !important; min-height: var(--app-viewport) !important;
@ -44,7 +42,7 @@
height: 100%; height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 18px 8px 14px 6px; padding: 12px 6px 10px 6px;
background: inherit; background: inherit;
} }
@ -61,7 +59,7 @@
width: 100%; width: 100%;
min-height: var(--conversation-row-height); min-height: var(--conversation-row-height);
border: 0; border: 0;
border-radius: 12px; border-radius: 8px;
background: transparent; background: transparent;
color: var(--claude-text); color: var(--claude-text);
display: grid; display: grid;
@ -81,7 +79,7 @@
.sidebar-nav-icon { .sidebar-nav-icon {
width: var(--conversation-icon-cell); width: var(--conversation-icon-cell);
height: var(--conversation-row-height); height: var(--conversation-row-height);
border-radius: 12px; border-radius: 8px;
display: grid; display: grid;
place-items: center; place-items: center;
color: currentColor; color: currentColor;
@ -119,6 +117,7 @@
.sidebar-nav-label, .sidebar-nav-label,
.conversation-search, .conversation-search,
.conversation-list, .conversation-list,
.workspace-entry-current,
.personal-label { .personal-label {
opacity: 1; opacity: 1;
transition: opacity 220ms ease; transition: opacity 220ms ease;
@ -128,6 +127,7 @@
.conversation-sidebar.collapsed .sidebar-nav-label, .conversation-sidebar.collapsed .sidebar-nav-label,
.conversation-sidebar.collapsed .conversation-search, .conversation-sidebar.collapsed .conversation-search,
.conversation-sidebar.collapsed .conversation-list, .conversation-sidebar.collapsed .conversation-list,
.conversation-sidebar.collapsed .workspace-entry-current,
.conversation-sidebar.collapsed .personal-label { .conversation-sidebar.collapsed .personal-label {
opacity: 0; opacity: 0;
transition-delay: 0ms; transition-delay: 0ms;
@ -135,28 +135,33 @@
.sidebar-nav-label { .sidebar-nav-label {
min-width: 0; min-width: 0;
padding-left: 8px; padding-left: 6px;
padding-right: 12px; padding-right: 10px;
font-size: 15px; font-size: 13px;
line-height: 1; line-height: 1;
letter-spacing: -0.015em; letter-spacing: -0.01em;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.chat-icon svg { .chat-icon svg {
width: 29px; width: 23px;
height: 29px; height: 23px;
} }
.conversation-menu-btn .chat-icon svg { .conversation-menu-btn .chat-icon svg {
transform: translateY(3px); transform: translateY(2px);
} }
.pencil-icon svg { .pencil-icon svg {
width: 22px; width: 18px;
height: 22px; height: 18px;
}
.layers-icon svg {
width: 18px;
height: 18px;
} }
.monitor-mode-btn.blocked { .monitor-mode-btn.blocked {
@ -166,7 +171,7 @@
.conversation-search { .conversation-search {
flex: 0 0 auto; flex: 0 0 auto;
margin-top: 10px; margin-top: 8px;
} }
.search-input-wrap { .search-input-wrap {
@ -194,7 +199,7 @@
padding: 0 12px 0 8px; padding: 0 12px 0 8px;
background: transparent; background: transparent;
color: var(--claude-text); color: var(--claude-text);
font-size: 14px; font-size: 13px;
letter-spacing: -0.01em; letter-spacing: -0.01em;
} }
@ -207,7 +212,7 @@
flex: 1 1 auto; flex: 1 1 auto;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
padding-top: 12px; padding-top: 8px;
scrollbar-width: none !important; scrollbar-width: none !important;
-ms-overflow-style: none; -ms-overflow-style: none;
} }
@ -243,9 +248,9 @@
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
gap: 3px; gap: 3px;
margin: 1px 0; margin: 1px 0;
padding: 8px 10px 8px 12px; padding: 6px 10px 6px 12px;
border: 0; border: 0;
border-radius: 12px; border-radius: 8px;
background: transparent; background: transparent;
color: var(--claude-text); color: var(--claude-text);
text-align: left; text-align: left;
@ -273,7 +278,7 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
font-size: 14px; font-size: 13px;
line-height: 1.25; line-height: 1.25;
letter-spacing: -0.01em; letter-spacing: -0.01em;
color: var(--claude-text); color: var(--claude-text);
@ -299,9 +304,13 @@ body[data-theme='dark'] .conversation-sidebar .running-task-item.active {
.conversation-sidebar .conversation-item { .conversation-sidebar .conversation-item {
position: relative; position: relative;
grid-template-columns: minmax(0, 1fr) 40px; grid-template-columns: minmax(0, 1fr) 32px;
margin: 1px 0; margin: 1px 0;
padding: 0; padding: 0;
/* 固定行高规范带文字容器禁止被内容撑高min-height 会被 34px
action-wrap 撑破导致分组 viewport N 行计算失准最后一行被裁 */
height: var(--conversation-row-height);
flex: none;
cursor: pointer; cursor: pointer;
background: transparent !important; background: transparent !important;
border: 0 !important; border: 0 !important;
@ -439,7 +448,7 @@ body[data-theme='dark'] .conversation-sidebar .conversation-item.active:focus-wi
padding-left: 12px; padding-left: 12px;
padding-right: 4px; padding-right: 4px;
color: var(--claude-text); color: var(--claude-text);
font-size: 14px; font-size: 13px;
line-height: 1.25; line-height: 1.25;
letter-spacing: -0.01em; letter-spacing: -0.01em;
white-space: nowrap; white-space: nowrap;
@ -450,7 +459,7 @@ body[data-theme='dark'] .conversation-sidebar .conversation-item.active:focus-wi
.conversation-sidebar .conversation-action-wrap { .conversation-sidebar .conversation-action-wrap {
position: relative; position: relative;
width: 40px; width: 40px;
height: 34px; height: var(--conversation-row-height);
display: grid; display: grid;
place-items: center; place-items: center;
justify-self: start; justify-self: start;
@ -599,9 +608,25 @@ body[data-theme='dark'] .conversation-sidebar .conversation-item.active:focus-wi
flex: 0 0 auto; flex: 0 0 auto;
} }
/* 工作区入口行:图标 + 名称 + 当前工作区名(第三列) */
.sidebar-nav-row.workspace-entry-btn {
grid-template-columns: var(--conversation-icon-cell) minmax(0, 1fr) auto;
}
.workspace-entry-current {
min-width: 0;
max-width: 96px;
padding-right: 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
color: var(--claude-text-tertiary);
}
.personal-page-btn svg { .personal-page-btn svg {
width: 22px; width: 18px;
height: 22px; height: 18px;
} }
.loading-conversations, .loading-conversations,
@ -707,7 +732,7 @@ body[data-theme='dark'] .conversation-sidebar .conversation-item.active:focus-wi
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
align-items: center; align-items: center;
height: var(--conversation-row-height); height: var(--conversation-row-height);
border-radius: 12px; border-radius: 8px;
transition: background 140ms ease; transition: background 140ms ease;
} }
@ -722,13 +747,13 @@ body[data-theme='dark'] .workspace-group-header:hover {
.workspace-group-toggle { .workspace-group-toggle {
display: grid; display: grid;
grid-template-columns: 24px minmax(0, 1fr) auto; grid-template-columns: 22px minmax(0, 1fr) auto;
align-items: center; align-items: center;
gap: 6px; gap: 5px;
height: 100%; height: 100%;
padding: 0 10px; padding: 0 8px;
border: 0; border: 0;
border-radius: 12px; border-radius: 8px;
background: transparent; background: transparent;
color: var(--claude-text); color: var(--claude-text);
text-align: left; text-align: left;
@ -741,7 +766,7 @@ body[data-theme='dark'] .workspace-group-header:hover {
.workspace-group-label { .workspace-group-label {
min-width: 0; min-width: 0;
font-size: 14px; font-size: 13px;
font-weight: 600; font-weight: 600;
letter-spacing: -0.01em; letter-spacing: -0.01em;
white-space: nowrap; white-space: nowrap;

View File

@ -5,7 +5,6 @@
@use './layout/app-shell'; @use './layout/app-shell';
@use './layout/panels'; @use './layout/panels';
@use './components/sidebar/conversation'; @use './components/sidebar/conversation';
@use './components/panels/left-panel';
@use './components/panels/right-panel'; @use './components/panels/right-panel';
@use './components/panels/resource-panel'; @use './components/panels/resource-panel';
@use './components/chat/chat-area'; @use './components/chat/chat-area';