feat(quickdock): 新增对话区右侧快捷窗口(待办/子智能体/后台指令/文件记录)

- 四窗口固定顺序占位布局,空时收起到 0 宽;动画区分「运行中新出现」与「切换/加载」
- 文件记录:edit/write 埋点写入对话 metadata.edited_files,预览走既有 /api/file/content
- 详情面板:lastDetail/lastStatus 快照、首次填充静态+瞬间到底、终态四色分类
- 数据通道全部走 REST 轮询(带 conversation_id 对话级隔离),含 demo 与设计文档
This commit is contained in:
JOJO 2026-07-22 00:52:36 +08:00
parent 1901aeb82f
commit 171833e4d1
32 changed files with 5128 additions and 19 deletions

View File

@ -0,0 +1,192 @@
/* ============================================================
待办事项悬浮窗 Demo 交互逻辑
三组动画
1. 创建逐行从左向右进入stagger
2. 完成横线从左往右划过 + 文字变灰
3. 更新旧行逐行向左移出高度收拢 新行逐行进入
============================================================ */
const ENTER_STAGGER = 60; // 进入动画每行间隔 ms
const LEAVE_STAGGER = 45; // 移出动画每行间隔 ms
const LEAVE_DURATION = 260; // 单行移出动画时长 ms
const listEl = document.getElementById('todoList');
const windowEl = document.getElementById('todoWindow');
const counterEl = document.getElementById('todoCounter');
const state = { items: [], busy: false };
/* ---------------- 演示数据 ---------------- */
const SCENARIOS = {
initial: [
{ text: '梳理对话区布局,确定悬浮窗挂载位置' },
{ text: '实现悬浮窗容器:圆角、最小 / 最大宽度' },
{ text: '待办行直接铺在窗口上,去掉嵌套卡片' },
{ text: '编写创建 / 完成 / 更新三组动画' },
{ text: '联调后端 todo 事件,接入真实数据' },
],
updated: [
{ text: '复核悬浮窗最大 / 最小宽度表现', done: true },
{ text: '把划线动画时序再调顺手一点' },
{ text: '超长任务名省略号验证(这一条故意写得特别特别长来测试溢出)' },
{ text: '设计文件编辑记录窗口的数据结构' },
{ text: '子智能体与后台指令窗口渲染方案' },
{ text: '三种主题下核对一遍配色' },
],
long: [
{ text: '拉取最新代码并跑一遍冒烟测试' },
{ text: '检查待办窗口在窄屏下的最小宽度' },
{ text: '把完成动画的横线改成从左向右' },
{ text: '补五条以上时的内部滚动' },
{ text: '写一条特别长的任务用来验证文字溢出时省略号效果到底行不行' },
{ text: '同步更新 AGENTS.md 的布局说明' },
{ text: '给悬浮窗预留多窗口上下排布的间距' },
{ text: '收拾 demo 控制台,准备演示' },
],
};
/* ---------------- 工具 ---------------- */
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function updateCounter() {
const done = state.items.filter((t) => t.done).length;
counterEl.textContent = `${done}/${state.items.length}`;
}
function ensureWindowVisible() {
if (!windowEl.hidden) return;
windowEl.hidden = false;
windowEl.classList.add('window-enter');
windowEl.addEventListener('animationend', () => windowEl.classList.remove('window-enter'), { once: true });
}
/* ---------------- 渲染 ---------------- */
function renderTodos(items) {
listEl.innerHTML = '';
items.forEach((item, i) => {
const li = document.createElement('li');
li.className = 'todo-item is-entering' + (item.done ? ' is-done' : '');
li.style.animationDelay = `${i * ENTER_STAGGER}ms`;
const dot = document.createElement('span');
dot.className = 'todo-dot';
const text = document.createElement('span');
text.className = 'todo-text';
text.textContent = item.text;
li.append(dot, text);
li.addEventListener('animationend', () => {
li.classList.remove('is-entering');
li.style.animationDelay = '';
}, { once: true });
listEl.appendChild(li);
});
updateCounter();
}
/* 让当前列表逐行向左移出,返回动画总耗时 */
async function playLeaveAnimation() {
const rows = [...listEl.children];
if (!rows.length) return;
rows.forEach((li, i) => {
li.style.animationDelay = `${i * LEAVE_STAGGER}ms`;
li.classList.add('is-leaving');
});
await wait(rows.length * LEAVE_STAGGER + LEAVE_DURATION + 20);
}
/* ---------------- 三个核心动作 ---------------- */
/* 1. 创建 / 整表替换(收到新 todo 列表) */
async function replaceTodos(newItems) {
if (state.busy) return;
state.busy = true;
await playLeaveAnimation(); // 旧列表逐行移出
state.items = newItems.map((t) => ({ text: t.text, done: !!t.done }));
ensureWindowVisible();
renderTodos(state.items); // 新列表逐行进入
state.busy = false;
}
/* 若目标行未完全显示,先平滑滚动到可见位置;已可见则立即 resolve */
function scrollRowIntoView(li) {
return new Promise((resolve) => {
const listRect = listEl.getBoundingClientRect();
const liRect = li.getBoundingClientRect();
const fullyVisible = liRect.top >= listRect.top && liRect.bottom <= listRect.bottom;
if (fullyVisible) { resolve(); return; }
let done = false;
let timer;
const finish = () => {
if (done) return;
done = true;
clearTimeout(timer);
listEl.removeEventListener('scroll', onScroll);
resolve();
};
const onScroll = () => {
clearTimeout(timer);
timer = setTimeout(finish, 90); // 滚动停止 90ms 后认为到位
};
listEl.addEventListener('scroll', onScroll);
li.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
setTimeout(finish, 800); // 兜底,避免意外卡死
});
}
/* 2. 完成下一项(先滚动到可见 → 再划线动画) */
async function completeNext() {
if (state.busy) return;
const idx = state.items.findIndex((t) => !t.done);
if (idx === -1) return;
state.busy = true;
const li = listEl.children[idx];
if (li) await scrollRowIntoView(li); // 未完全显示的先滚出来
state.items[idx].done = true;
if (li) {
li.classList.add('is-done', 'just-done');
setTimeout(() => li.classList.remove('just-done'), 400);
}
updateCounter();
state.busy = false;
}
/* 3. 清空(移出后隐藏窗口) */
async function clearTodos() {
if (state.busy || !state.items.length) return;
state.busy = true;
await playLeaveAnimation();
state.items = [];
renderTodos(state.items);
windowEl.classList.add('window-leave');
windowEl.addEventListener('animationend', () => {
windowEl.classList.remove('window-leave');
windowEl.hidden = true;
}, { once: true });
state.busy = false;
}
/* ---------------- 绑定演示按钮 ---------------- */
document.getElementById('btnCreate').addEventListener('click', () => replaceTodos(SCENARIOS.initial));
document.getElementById('btnComplete').addEventListener('click', completeNext);
document.getElementById('btnUpdate').addEventListener('click', () => replaceTodos(SCENARIOS.updated));
document.getElementById('btnLong').addEventListener('click', () => replaceTodos(SCENARIOS.long));
document.getElementById('btnClear').addEventListener('click', clearTodos);
/* 页面加载后自动演示一次创建 */
window.addEventListener('load', () => {
setTimeout(() => replaceTodos(SCENARIOS.initial), 500);
});

View File

@ -0,0 +1,194 @@
/* ============================================================
文件记录窗口 + 右侧预览侧边栏 + toast 样式
复用 style.css 变量 按钮复用 runner.css .item-menu-btn
============================================================ */
/* ---------------- 文件条目:[⋯] 左 + 文件名,无状态点 ---------------- */
.file-item {
height: var(--row-h);
display: flex;
align-items: center;
gap: 7px;
padding: 0 10px 0 6px;
overflow: hidden;
cursor: pointer;
transition: background 0.12s ease;
}
.file-item:hover { background: var(--hover-wash); }
/* 当前正在预览的文件保持高亮 */
.file-item.is-active { background: var(--surface-wash); }
.file-item.is-entering {
opacity: 0;
animation: todo-in 0.34s var(--ease-out) forwards;
}
.file-name {
flex: 1;
min-width: 0;
font-size: 13px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ---------------- 右侧预览侧边栏 ---------------- */
/* 打开时悬浮窗栈向左让位 */
.float-stack { transition: right 0.3s var(--ease-out); }
body.preview-open .float-stack { right: calc(20px + 440px + 12px); }
/* 详情面板同样让位 */
.detail-panel { transition: right 0.3s var(--ease-out); }
body.preview-open .detail-panel {
right: calc(20px + 440px + 12px + var(--window-max-w) * 0.9 + 12px);
}
.file-preview {
position: fixed;
top: 20px;
right: 20px;
bottom: 20px;
width: 440px;
max-width: calc(100vw - 80px);
background: var(--surface-float);
border: 1px solid var(--border-subtle);
border-radius: 12px;
box-shadow: var(--shadow-float);
display: flex;
flex-direction: column;
overflow: hidden;
z-index: 95;
/* 初始藏在屏幕右缘外;用 visibility 配合 transform 做滑入滑出 */
transform: translateX(calc(100% + 24px));
visibility: hidden;
transition: transform 0.3s var(--ease-out), visibility 0s linear 0.3s;
}
.file-preview.is-open {
transform: translateX(0);
visibility: visible;
transition: transform 0.3s var(--ease-out), visibility 0s;
}
/* 预览头部:文件名 + 目录 + 关闭 */
.preview-header {
flex: none;
height: 44px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 14px;
border-bottom: 1px solid var(--border-subtle);
}
.preview-header svg {
flex: none;
width: 15px;
height: 15px;
color: var(--accent);
}
.preview-name {
flex: none;
font-size: 13px;
font-weight: 600;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
color: var(--text-primary);
}
.preview-path {
flex: 1;
min-width: 0;
font-size: 11.5px;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
color: var(--text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.preview-close {
flex: none;
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
background: none;
border: none;
border-radius: 6px;
color: var(--text-tertiary);
cursor: pointer;
transition: background 0.12s ease, color 0.12s ease;
}
.preview-close svg { width: 13px; height: 13px; color: inherit; }
.preview-close:hover { background: rgba(38, 36, 31, 0.07); color: var(--text-secondary); }
/* 预览内容:等宽 + 行号 */
.preview-body {
flex: 1;
overflow: auto;
padding: 8px 0 12px;
scrollbar-width: none;
-ms-overflow-style: none;
}
.preview-body::-webkit-scrollbar { display: none; width: 0; height: 0; }
.code-line {
display: flex;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px;
line-height: 1.75;
}
.code-line:hover { background: var(--hover-wash); }
.line-no {
flex: none;
width: 44px;
padding-right: 12px;
text-align: right;
color: var(--text-tertiary);
user-select: none;
}
.line-text {
flex: 1;
padding-right: 14px;
white-space: pre;
color: var(--text-primary);
}
/* ---------------- 文件菜单(普通项,非危险色) ---------------- */
.file-menu .menu-item { color: var(--text-primary); }
.file-menu .menu-item:hover { background: var(--surface-wash); }
/* ---------------- demo toast 反馈 ---------------- */
.demo-toast {
position: fixed;
bottom: 28px;
left: 50%;
transform: translateX(-50%) translateY(8px);
padding: 8px 14px;
border-radius: 8px;
background: #26241f;
color: #faf9f5;
font-size: 12.5px;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease, transform 0.2s ease;
z-index: 400;
}
.demo-toast.is-show { opacity: 1; transform: translateX(-50%) translateY(0); }

View File

@ -0,0 +1,355 @@
/* ============================================================
文件记录窗口 Demo 逻辑
- 条目[] 文件名无状态点点击条目从右侧滑出预览侧边栏
- 菜单下载 / 在文件管理器中打开 / 复制路径工作区相对路径
- 显示逻辑正式版文件被编辑过 + 文件仍存在
- 复用 runner.js showFloatWindow / scrollRowIntoView
============================================================ */
const fileWindowEl = document.getElementById('fileWindow');
const fileListEl = document.getElementById('fileList');
const fileCounterEl = document.getElementById('fileCounter');
const fileMenuEl = document.getElementById('fileMenu');
const menuDownload = document.getElementById('menuDownload');
const menuReveal = document.getElementById('menuReveal');
const menuCopyPath = document.getElementById('menuCopyPath');
const previewEl = document.getElementById('filePreview');
const previewNameEl = document.getElementById('previewName');
const previewPathEl = document.getElementById('previewPath');
const previewBodyEl = document.getElementById('previewBody');
const previewCloseBtn = document.getElementById('previewClose');
const toastEl = document.getElementById('demoToast');
const fileState = { files: [], poolIdx: 0 };
const previewState = { path: null };
let menuFilePath = null;
let toastTimer = null;
/* ---------------- 演示文件数据 ---------------- */
const FILES_POOL = [
{
path: 'static/src/components/chat/ChatArea.vue',
content: `<template>
<div class="chat-area" ref="chatAreaRef">
<MessageList
:messages="visibleMessages"
:loading="isLoading"
@retry="handleRetry"
/>
<InputBar
v-model="draft"
:disabled="!connected"
@send="handleSend"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useChatStore } from '@/stores/chat'
const chatStore = useChatStore()
const draft = ref('')
const visibleMessages = computed(() => chatStore.activeMessages)
function handleSend() {
if (!draft.value.trim()) return
chatStore.sendUserMessage(draft.value)
draft.value = ''
}
</script>`,
},
{
path: 'server/chat_flow_tool_loop.py',
content: `async def execute_tool_calls(self, tool_calls, messages):
"""执行一轮工具调用,并把结果回填到消息列表。"""
results = []
for call in tool_calls:
tool_name = call.get("name")
tool_args = call.get("arguments", {})
handler = self.tool_handlers.get(tool_name)
if handler is None:
results.append({"success": False, "error": f"未知工具: {tool_name}"})
continue
try:
result = await handler(**tool_args)
results.append(result)
except Exception as exc:
logger.exception("tool %s failed", tool_name)
results.append({"success": False, "error": str(exc)})
for call, result in zip(tool_calls, results):
messages.append({
"role": "tool",
"tool_call_id": call["id"],
"content": format_tool_result_for_context(call["name"], result),
})
return results`,
},
{
path: 'docs/float_window_design.md',
content: `# 悬浮窗口设计
## 布局
- 位置对话显示区域右上角
- 排列多个窗口上下排布右对齐
- 尺寸 270px列表区固定 5
## 窗口列表
1. 待办事项任务创建 / 完成 / 替换动画
2. 文件记录本次对话编辑过的文件
3. 子智能体运行状态 + 详情面板
4. 后台指令运行状态 + 终端输出
## 动画规范
- 进入从左向右位移 14px + 淡入逐行 60ms 交错
- 完成横线从左往右划过 + 文字变灰
- 移出从右向左 + 高度收拢
- 显示区域外的条目先滚动露出再播动画`,
},
{
path: 'config/limits.py',
content: `"""运行限制相关配置。"""
MAX_TODO_ITEMS = 8
"""单次待办列表最多条目数。"""
FLOAT_WINDOW_ROWS = 5
"""悬浮窗口列表区固定行数。"""
FLOAT_WINDOW_WIDTH = 270
"""悬浮窗口宽度(最大宽度的 90%)。"""
SUB_AGENT_MAX_ROUNDS = 12
"""子智能体单轮任务最大对话轮数。"""
LOG_ROTATE_MAX_BYTES = 20 * 1024 * 1024
"""单日志文件最大体积,超过触发轮转。"""`,
},
];
/* ---------------- 工具 ---------------- */
function basename(p) { return p.split('/').pop(); }
function dirname(p) { return p.split('/').slice(0, -1).join('/') + '/'; }
function showToast(text) {
toastEl.textContent = text;
toastEl.classList.add('is-show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toastEl.classList.remove('is-show'), 1800);
}
function updateFileCounter() {
fileCounterEl.textContent = String(fileState.files.length);
}
/* ---------------- 文件条目 ---------------- */
function simulateEditFile() {
// 正式版逻辑:同一文件只出现一次;被删除的文件从列表移除
if (fileState.poolIdx >= FILES_POOL.length) {
showToast('演示文件已全部在列表中');
return;
}
const file = FILES_POOL[fileState.poolIdx];
fileState.poolIdx += 1;
fileState.files.push(file);
showFloatWindow(fileWindowEl);
renderFileRow(file);
updateFileCounter();
}
function renderFileRow(file) {
const li = document.createElement('li');
li.className = 'file-item';
li.dataset.path = file.path;
li.title = file.path;
const menuBtn = document.createElement('button');
menuBtn.className = 'item-menu-btn';
menuBtn.title = '更多';
menuBtn.innerHTML = '<svg viewBox="0 0 16 16"><circle cx="3.5" cy="8" r="1.3" fill="currentColor"/><circle cx="8" cy="8" r="1.3" fill="currentColor"/><circle cx="12.5" cy="8" r="1.3" fill="currentColor"/></svg>';
menuBtn.addEventListener('click', (e) => {
e.stopPropagation();
showFileMenu(file, menuBtn);
});
const nameEl = document.createElement('span');
nameEl.className = 'file-name';
nameEl.textContent = basename(file.path);
li.append(menuBtn, nameEl);
li.addEventListener('click', () => openPreview(file));
fileListEl.appendChild(li);
/* 先隐身插入;若在可视区域外先滚动出来,再播进入动画 */
li.style.opacity = '0';
scrollRowIntoView(fileListEl, li).then(() => {
li.classList.add('is-entering');
li.addEventListener('animationend', () => {
li.classList.remove('is-entering');
li.style.opacity = '';
}, { once: true });
});
}
/* ---------------- ⋯ 菜单:下载 / 在文件管理器中打开 / 复制路径 ---------------- */
function showFileMenu(file, btn) {
if (!fileMenuEl.hidden && menuFilePath === file.path) { hideFileMenu(); return; }
menuFilePath = file.path;
const rm = document.getElementById('itemMenu'); // 与子智能体菜单互斥
if (rm) rm.hidden = true;
const rect = btn.getBoundingClientRect();
/* ⋯ 在条目最左侧,菜单与按钮左对齐、向下展开 */
fileMenuEl.style.left = `${rect.left}px`;
fileMenuEl.style.right = 'auto';
fileMenuEl.style.top = `${rect.bottom + 6}px`;
fileMenuEl.hidden = false;
fileMenuEl.classList.add('menu-enter');
fileMenuEl.addEventListener('animationend', () => fileMenuEl.classList.remove('menu-enter'), { once: true });
}
function hideFileMenu() {
fileMenuEl.hidden = true;
menuFilePath = null;
}
function currentMenuFile() {
return fileState.files.find((f) => f.path === menuFilePath);
}
menuDownload.addEventListener('click', () => {
const file = currentMenuFile();
if (file) {
const blob = new Blob([file.content], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = basename(file.path);
a.click();
URL.revokeObjectURL(url);
showToast(`已下载 ${a.download}`);
}
hideFileMenu();
});
menuReveal.addEventListener('click', () => {
// 正式版:复用 git 状态侧边栏逻辑用默认应用打开docker 模式下无此选项
showToast('已在文件管理器中打开demo 示意)');
hideFileMenu();
});
menuCopyPath.addEventListener('click', () => {
const file = currentMenuFile();
if (file) copyText(file.path);
hideFileMenu();
});
function copyText(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(
() => showToast('已复制相对路径'),
() => fallbackCopy(text),
);
} else {
fallbackCopy(text);
}
}
function fallbackCopy(text) {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try {
document.execCommand('copy');
showToast('已复制相对路径');
} catch (err) {
showToast('复制失败');
}
ta.remove();
}
document.addEventListener('click', (e) => {
if (!fileMenuEl.hidden && !e.target.closest('.file-menu') && !e.target.closest('.item-menu-btn')) {
hideFileMenu();
}
});
/* ---------------- 右侧预览侧边栏 ---------------- */
function openPreview(file) {
if (previewState.path === file.path) { closePreview(); return; } // 再点同一文件收起
previewState.path = file.path;
previewNameEl.textContent = basename(file.path);
previewPathEl.textContent = dirname(file.path);
previewBodyEl.innerHTML = '';
file.content.split('\n').forEach((line, i) => {
const div = document.createElement('div');
div.className = 'code-line';
const no = document.createElement('span');
no.className = 'line-no';
no.textContent = String(i + 1);
const text = document.createElement('span');
text.className = 'line-text';
text.textContent = line;
div.append(no, text);
previewBodyEl.appendChild(div);
});
previewBodyEl.scrollTop = 0;
document.querySelectorAll('.file-item.is-active').forEach((el) => el.classList.remove('is-active'));
const row = fileListEl.querySelector(`[data-path="${CSS.escape(file.path)}"]`);
if (row) row.classList.add('is-active');
document.body.classList.add('preview-open');
previewEl.classList.add('is-open');
}
function closePreview() {
previewState.path = null;
document.body.classList.remove('preview-open');
previewEl.classList.remove('is-open');
document.querySelectorAll('.file-item.is-active').forEach((el) => el.classList.remove('is-active'));
}
previewCloseBtn.addEventListener('click', closePreview);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !e.defaultPrevented) {
if (!fileMenuEl.hidden) hideFileMenu();
else if (previewState.path) closePreview();
}
});
/* 窗口栈整体滚动时,关闭所有已打开的 ⋯ 菜单(菜单是 fixed 定位,不随栈滚动) */
document.querySelector('.float-stack').addEventListener('scroll', () => {
hideItemMenu();
hideFileMenu();
});
/* ---------------- 演示按钮 & 自动演示 ---------------- */
document.getElementById('btnEditFile').addEventListener('click', simulateEditFile);
window.addEventListener('load', () => {
setTimeout(simulateEditFile, 2900);
setTimeout(simulateEditFile, 3600);
});

View File

@ -0,0 +1,146 @@
<!DOCTYPE html>
<html lang="zh-CN">
<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">
<link rel="stylesheet" href="runner.css">
<link rel="stylesheet" href="file.css">
</head>
<body>
<!-- 模拟对话显示区域 -->
<main class="mock-chat">
<div class="mock-bubble mock-bubble--user">把待办、子智能体、后台指令都做成右上角的悬浮窗口</div>
<div class="mock-bubble mock-bubble--ai">好的,三个窗口上下排布在对话区右上角。子智能体和后台指令会持续产生输出,点击条目可以在左侧展开详情面板。</div>
<div class="mock-bubble mock-bubble--ai">条目左侧的 ⋯ 可以展开菜单(强制关闭);运行中的条目状态点会呼吸,完成后变灰。</div>
</main>
<!-- 右上角悬浮窗口栈 -->
<div class="float-stack">
<!-- 待办事项 -->
<section class="float-window todo-window" id="todoWindow" hidden>
<header class="float-window__header">
<svg class="float-window__icon" viewBox="0 0 16 16" fill="none">
<path d="M2 4.5 3.5 6 6 3.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.5 5H14" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
<path d="M2 11 3.5 12.5 6 10" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.5 11.5H14" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
</svg>
<span class="float-window__title">待办事项</span>
<span class="todo-counter" id="todoCounter">0/0</span>
</header>
<ul class="todo-list" id="todoList"></ul>
</section>
<!-- 子智能体 -->
<section class="float-window run-window" id="agentWindow" hidden>
<header class="float-window__header">
<svg class="float-window__icon" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="5.5" r="2.5" stroke="currentColor" stroke-width="1.4"/>
<path d="M3 13c.7-2.6 2.7-4 5-4s4.3 1.4 5 4" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
</svg>
<span class="float-window__title">子智能体</span>
<span class="todo-counter" id="agentCounter">0/0</span>
</header>
<ul class="run-list" id="agentList"></ul>
</section>
<!-- 后台指令 -->
<section class="float-window run-window" id="cmdWindow" hidden>
<header class="float-window__header">
<svg class="float-window__icon" viewBox="0 0 16 16" fill="none">
<path d="M2.5 4.5 6 8l-3.5 3.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 12h5.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
</svg>
<span class="float-window__title">后台指令</span>
<span class="todo-counter" id="cmdCounter">0/0</span>
</header>
<ul class="run-list" id="cmdList"></ul>
</section>
<!-- 文件记录 -->
<section class="float-window" id="fileWindow" hidden>
<header class="float-window__header">
<svg class="float-window__icon" viewBox="0 0 16 16" fill="none">
<path d="M4 2.5h5.5L12 5v8.5H4V2.5z" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/>
<path d="M9.5 2.5V5H12" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/>
</svg>
<span class="float-window__title">文件</span>
<span class="todo-counter" id="fileCounter">0</span>
</header>
<ul class="run-list" id="fileList"></ul>
</section>
</div>
<!-- 详情面板(悬浮窗左侧展开) -->
<section class="detail-panel" id="detailPanel" hidden>
<header class="detail-header">
<span class="detail-status-dot" id="detailDot"></span>
<span class="detail-title" id="detailTitle"></span>
<span class="detail-badge" id="detailBadge">运行中</span>
<button class="detail-close" id="detailClose" title="关闭">
<svg viewBox="0 0 16 16" fill="none">
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
</svg>
</button>
</header>
<div class="detail-body" id="detailBody"></div>
</section>
<!-- 条目 ⋯ 菜单(子智能体/后台指令,全局单例) -->
<div class="item-menu" id="itemMenu" hidden>
<button class="menu-item menu-item--danger" id="menuKill">强制关闭</button>
</div>
<!-- 文件 ⋯ 菜单(全局单例) -->
<div class="item-menu file-menu" id="fileMenu" hidden>
<button class="menu-item" id="menuDownload">下载</button>
<button class="menu-item" id="menuReveal">在文件管理器中打开</button>
<button class="menu-item" id="menuCopyPath">复制路径</button>
</div>
<!-- 文件预览侧边栏(右侧滑入) -->
<section class="file-preview" id="filePreview">
<header class="preview-header">
<svg viewBox="0 0 16 16" fill="none">
<path d="M4 2.5h5.5L12 5v8.5H4V2.5z" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/>
<path d="M9.5 2.5V5H12" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/>
</svg>
<span class="preview-name" id="previewName"></span>
<span class="preview-path" id="previewPath"></span>
<button class="preview-close" id="previewClose" title="关闭">
<svg viewBox="0 0 16 16" fill="none">
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
</svg>
</button>
</header>
<div class="preview-body" id="previewBody"></div>
</section>
<!-- demo toast -->
<div class="demo-toast" id="demoToast"></div>
<!-- demo 控制台 -->
<div class="demo-panel">
<p class="demo-panel__hint">悬浮窗口栈 · 动画演示</p>
<div class="demo-panel__buttons">
<button class="demo-btn demo-btn--primary" id="btnCreate">创建待办(进入动画)</button>
<button class="demo-btn" id="btnComplete">完成下一项(划线动画)</button>
<button class="demo-btn" id="btnUpdate">收到新待办(替换动画)</button>
<button class="demo-btn" id="btnLong">长列表8 条 · 滚动)</button>
<button class="demo-btn demo-btn--ghost" id="btnClear">清空</button>
<div class="demo-divider"></div>
<button class="demo-btn demo-btn--primary" id="btnNewAgent"> 新子智能体</button>
<button class="demo-btn demo-btn--primary" id="btnNewCmd"> 新后台指令</button>
<button class="demo-btn demo-btn--primary" id="btnEditFile">✎ 模拟编辑文件</button>
</div>
</div>
<script src="app.js"></script>
<script src="runner.js"></script>
<script src="file.js"></script>
</body>
</html>

View File

@ -0,0 +1,362 @@
/* ============================================================
子智能体 / 后台指令窗口 + 详情面板 + 条目菜单 样式
复用 style.css 的变量与窗口基础样式
============================================================ */
/* ---------------- 条目列表(与待办同规格:固定 5 行) ---------------- */
.run-list {
list-style: none;
padding: 0;
height: calc(var(--row-h) * 5);
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
.run-list::-webkit-scrollbar { display: none; width: 0; height: 0; }
/* 条目:[状态点] 左 + 名称 + [⋯] 右,整行可点击 */
.run-item {
height: var(--row-h);
display: flex;
align-items: center;
gap: 7px;
padding: 0 8px 0 12px;
overflow: hidden;
cursor: pointer;
transition: background 0.12s ease;
}
.run-item:hover { background: var(--hover-wash); }
/* 当前打开详情的条目保持高亮 */
.run-item.is-active { background: var(--surface-wash); }
/* ⋯ 按钮 */
.item-menu-btn {
flex: none;
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
background: none;
border: none;
border-radius: 5px;
color: var(--text-tertiary);
cursor: pointer;
transition: background 0.12s ease, color 0.12s ease;
}
.item-menu-btn svg { width: 14px; height: 14px; }
.item-menu-btn:hover { background: rgba(38, 36, 31, 0.07); color: var(--text-secondary); }
/* 名称 */
.item-name {
flex: 1;
min-width: 0;
font-size: 13px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 后台指令名称用等宽字体 */
.run-item[data-kind="cmd"] .item-name {
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px;
}
/* 完成后名称变灰 */
.run-item.is-done .item-name { color: var(--text-tertiary); }
/* 状态点:运行中呼吸,完成/终止变灰 */
.item-status {
flex: none;
width: 6px;
height: 6px;
border-radius: 50%;
background: transparent;
}
.run-item.is-running .item-status {
background: var(--accent);
animation: status-pulse 1.6s ease-in-out infinite;
}
.run-item.is-done .item-status { background: var(--text-tertiary); }
@keyframes status-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
/* 进入动画复用待办的 keyframestodo-in 定义在 style.css全局可用 */
.run-item.is-entering {
opacity: 0;
animation: todo-in 0.34s var(--ease-out) forwards;
}
/* ---------------- 详情面板 ---------------- */
/* display:flex 会覆盖 hidden 属性的 display:none必须显式补回 */
.detail-panel[hidden] { display: none; }
.detail-panel {
position: fixed;
top: 20px;
right: calc(20px + var(--window-max-w) * 0.9 + 12px); /* 悬浮窗左侧 */
width: 480px;
max-width: calc(100vw - 360px);
height: 460px;
max-height: calc(100vh - 40px);
background: var(--surface-float);
border: 1px solid var(--border-subtle);
border-radius: 12px;
box-shadow: var(--shadow-float);
display: flex;
flex-direction: column;
overflow: hidden;
z-index: 90;
}
.detail-panel.panel-enter { animation: panel-in 0.24s var(--ease-out); }
@keyframes panel-in {
from { opacity: 0; transform: translateX(12px); }
to { opacity: 1; transform: translateX(0); }
}
.detail-panel.panel-leave { animation: panel-out 0.18s ease-in forwards; }
@keyframes panel-out {
to { opacity: 0; transform: translateX(8px); }
}
/* 详情头部 */
.detail-header {
flex: none;
height: 42px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
border-bottom: 1px solid var(--border-subtle);
}
.detail-status-dot {
flex: none;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--text-tertiary);
}
.detail-status-dot.is-running {
background: var(--accent);
animation: status-pulse 1.6s ease-in-out infinite;
}
.detail-title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.detail-badge {
flex: none;
font-size: 11px;
line-height: 1;
padding: 4px 8px;
border-radius: 99px;
background: color-mix(in srgb, var(--accent) 12%, transparent);
color: var(--accent);
}
.detail-badge.is-done { background: var(--surface-wash); color: var(--text-tertiary); }
.detail-close {
flex: none;
margin-left: auto;
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
background: none;
border: none;
border-radius: 6px;
color: var(--text-tertiary);
cursor: pointer;
transition: background 0.12s ease, color 0.12s ease;
}
.detail-close svg { width: 13px; height: 13px; }
.detail-close:hover { background: rgba(38, 36, 31, 0.07); color: var(--text-secondary); }
/* 详情内容区 */
.detail-body {
flex: 1;
overflow-y: auto;
padding: 6px 0 10px;
scrollbar-width: none;
-ms-overflow-style: none;
}
.detail-body::-webkit-scrollbar { display: none; width: 0; height: 0; }
.detail-body.body-fade { animation: body-fade-in 0.18s ease-out; }
@keyframes body-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
/* ---------------- feed 行(工具 + 输出,扁平行式,无嵌套卡片) ---------------- */
.feed-row {
display: flex;
align-items: center;
gap: 8px;
padding: 0 14px;
min-height: 28px;
font-size: 12.5px;
animation: feed-in 0.25s var(--ease-out);
}
/* 新进度出现动画:淡入 + 轻微上浮 */
@keyframes feed-in {
from { opacity: 0; transform: translateY(5px); }
to { opacity: 1; transform: translateY(0); }
}
/* 智能体文本输出 */
.feed-row.feed-text {
align-items: flex-start;
padding-top: 5px;
padding-bottom: 5px;
color: var(--text-secondary);
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
}
/* 工具调用行 */
.feed-tool .tool-name {
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px;
color: var(--text-primary);
flex: none;
}
.feed-tool .tool-param {
color: var(--text-tertiary);
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.feed-tool .tool-result {
flex: none;
margin-left: auto;
font-size: 11.5px;
color: var(--text-tertiary);
font-variant-numeric: tabular-nums;
}
/* 工具运行中 spinner / 完成 ✓ */
.tool-spinner {
flex: none;
width: 11px;
height: 11px;
border-radius: 50%;
border: 1.5px solid var(--border-strong);
border-top-color: var(--accent);
animation: tool-spin 0.7s linear infinite;
}
@keyframes tool-spin {
to { transform: rotate(360deg); }
}
.tool-done {
flex: none;
width: 11px;
text-align: center;
font-size: 11px;
color: var(--accent);
}
/* 终端输出行(后台指令) */
.feed-row.feed-term {
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-all;
min-height: 24px;
}
.feed-row.feed-term.is-command {
color: var(--text-primary);
font-weight: 600;
}
/* 终止提示行 */
.feed-row.feed-killed { color: #c0453a; }
/* ---------------- 条目 ⋯ 菜单 ---------------- */
.item-menu {
position: fixed;
z-index: 300;
min-width: 116px;
padding: 4px;
background: var(--surface-float);
border: 1px solid var(--border-subtle);
border-radius: 8px;
box-shadow: var(--shadow-float);
}
.item-menu.menu-enter { animation: menu-in 0.16s ease-out; }
@keyframes menu-in {
from { opacity: 0; transform: translateY(-3px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.menu-item {
display: flex;
align-items: center;
width: 100%;
height: 30px;
padding: 0 10px;
background: none;
border: none;
border-radius: 5px;
font-size: 12.5px;
text-align: left;
cursor: pointer;
transition: background 0.12s ease;
}
.menu-item--danger { color: #c0453a; }
.menu-item--danger:hover:not(:disabled) { background: rgba(192, 69, 58, 0.08); }
.menu-item--danger:disabled { color: var(--text-tertiary); cursor: default; }
/* ---------------- demo 控制台补充 ---------------- */
.demo-divider {
width: 100%;
height: 1px;
background: var(--border-subtle);
margin: 5px 0;
}

View File

@ -0,0 +1,421 @@
/* ============================================================
子智能体 / 后台指令窗口 Demo 逻辑
- 新条目出现动画与待办一致todo-in keyframes从左向右进入
- 运行中条目用定时器模拟流式输出工具调用 / 终端行
- 点击条目在左侧展开详情面板新进度淡入 + 自动向下滚动
- 菜单强制关闭完成后置灰无删除
============================================================ */
const agentWindowEl = document.getElementById('agentWindow');
const cmdWindowEl = document.getElementById('cmdWindow');
const agentListEl = document.getElementById('agentList');
const cmdListEl = document.getElementById('cmdList');
const agentCounterEl = document.getElementById('agentCounter');
const cmdCounterEl = document.getElementById('cmdCounter');
const detailPanel = document.getElementById('detailPanel');
const detailDot = document.getElementById('detailDot');
const detailTitle = document.getElementById('detailTitle');
const detailBadge = document.getElementById('detailBadge');
const detailBody = document.getElementById('detailBody');
const detailCloseBtn = document.getElementById('detailClose');
const itemMenu = document.getElementById('itemMenu');
const menuKill = document.getElementById('menuKill');
const runners = new Map();
let runnerSeq = 0;
let agentSeq = 0;
let cmdSeq = 0;
const detailState = { id: null };
let menuRunnerId = null;
/* ---------------- 演示脚本 ---------------- */
const AGENT_ROLES = ['UI Operator', 'Code Reviewer', 'Researcher'];
const AGENT_SCRIPTS = [
[
{ type: 'text', text: '收到,先看一下 MonitorDirector 的当前实现…' },
{ type: 'tool', name: 'read_file', param: 'static/src/components/chat/monitor/MonitorDirector.ts', result: '342 行', duration: 1400 },
{ type: 'tool', name: 'read_file', param: 'static/src/stores/monitor.ts', result: '186 行', duration: 1000 },
{ type: 'text', text: '定位到事件队列逻辑,开始修改…' },
{ type: 'tool', name: 'edit_file', param: 'MonitorDirector.ts · 3 处替换', result: '✓ 成功', duration: 1800 },
{ type: 'tool', name: 'run_command', param: 'npm run build', result: '✓ 2.31s', duration: 2600 },
{ type: 'text', text: '构建通过,改动已完成。' },
],
[
{ type: 'text', text: '开始审查本次改动的 diff…' },
{ type: 'tool', name: 'run_command', param: 'git diff HEAD~1..HEAD', result: '+86 24', duration: 1500 },
{ type: 'text', text: '发现两处潜在问题,进一步确认上下文…' },
{ type: 'tool', name: 'read_file', param: 'server/chat_flow_tool_loop.py · 1320-1380 行', result: '已读取', duration: 1200 },
{ type: 'text', text: '审查完成2 个建议0 个阻塞问题,已同步给 Team Leader。' },
],
[
{ type: 'text', text: '正在检索相关资料…' },
{ type: 'tool', name: 'web_search', param: 'vue3 transition-group FLIP 列表动画', result: '8 条结果', duration: 1600 },
{ type: 'tool', name: 'extract_webpage', param: 'vuejs.org/guide/built-ins/transition-group', result: '4.2k 字', duration: 1300 },
{ type: 'text', text: '整理完成:推荐用 FLIP + 交错延迟实现列表重排动画。' },
],
];
const CMD_POOL = [
{
name: 'npm run build',
script: [
{ text: '$ npm run build', isCommand: true },
{ text: '> vite v5.4.19 building for production...' },
{ text: 'transforming (1823) src/main.ts' },
{ text: '✓ 1823 modules transformed.' },
{ text: 'dist/index.html 0.46 kB │ gzip: 0.31 kB' },
{ text: 'dist/assets/index-C8f3k2m.js 412.18 kB │ gzip: 132.40 kB' },
{ text: '✓ built in 2.31s' },
],
},
{
name: 'npm run lint',
script: [
{ text: '$ npm run lint', isCommand: true },
{ text: '> eslint static/src --ext .ts,.vue' },
{ text: 'checked 214 files' },
{ text: '✓ no problems found' },
],
},
{
name: 'python3 -m unittest test.test_server_refactor_smoke',
script: [
{ text: '$ python3 -m unittest test.test_server_refactor_smoke', isCommand: true },
{ text: 'test_chat_smoke ... ok' },
{ text: 'test_status_smoke ... ok' },
{ text: 'test_tasks_smoke ... ok' },
{ text: 'Ran 3 tests in 0.42s' },
{ text: 'OK' },
],
},
];
/* ---------------- 工具 ---------------- */
function showFloatWindow(el) {
if (!el.hidden) return;
el.hidden = false;
el.classList.add('window-enter');
el.addEventListener('animationend', () => el.classList.remove('window-enter'), { once: true });
}
function updateRunCounter(kind) {
const all = [...runners.values()].filter((r) => r.kind === kind);
const running = all.filter((r) => r.status === 'running').length;
(kind === 'agent' ? agentCounterEl : cmdCounterEl).textContent = `${running}/${all.length}`;
}
/* ---------------- 创建条目 ---------------- */
function createRunner(kind) {
const id = ++runnerSeq;
let name; let script;
if (kind === 'agent') {
name = `${AGENT_ROLES[agentSeq % AGENT_ROLES.length]}_${Math.floor(agentSeq / AGENT_ROLES.length) + 1}`;
script = AGENT_SCRIPTS[agentSeq % AGENT_SCRIPTS.length];
agentSeq += 1;
} else {
const pool = CMD_POOL[cmdSeq % CMD_POOL.length];
name = pool.name;
script = pool.script;
cmdSeq += 1;
}
const runner = {
id,
kind,
name,
status: 'running', // running | done | killed
feed: [],
script,
cursor: 0,
timer: null,
rowEl: null,
listEl: kind === 'agent' ? agentListEl : cmdListEl,
};
runners.set(id, runner);
showFloatWindow(kind === 'agent' ? agentWindowEl : cmdWindowEl);
renderRunnerRow(runner);
updateRunCounter(kind);
playScript(runner);
}
/* 若目标行未完全显示,先平滑滚动到可见位置;已可见则立即 resolve */
function scrollRowIntoView(listEl, li) {
return new Promise((resolve) => {
const listRect = listEl.getBoundingClientRect();
const liRect = li.getBoundingClientRect();
const fullyVisible = liRect.top >= listRect.top && liRect.bottom <= listRect.bottom;
if (fullyVisible) { resolve(); return; }
let done = false;
let timer;
const finish = () => {
if (done) return;
done = true;
clearTimeout(timer);
listEl.removeEventListener('scroll', onScroll);
resolve();
};
const onScroll = () => {
clearTimeout(timer);
timer = setTimeout(finish, 90);
};
listEl.addEventListener('scroll', onScroll);
li.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
setTimeout(finish, 800);
});
}
function renderRunnerRow(runner) {
const li = document.createElement('li');
li.className = 'run-item is-running';
li.dataset.kind = runner.kind;
li.dataset.id = runner.id;
const statusEl = document.createElement('span');
statusEl.className = 'item-status';
const nameEl = document.createElement('span');
nameEl.className = 'item-name';
nameEl.textContent = runner.name;
const menuBtn = document.createElement('button');
menuBtn.className = 'item-menu-btn';
menuBtn.title = '更多';
menuBtn.innerHTML = '<svg viewBox="0 0 16 16"><circle cx="3.5" cy="8" r="1.3" fill="currentColor"/><circle cx="8" cy="8" r="1.3" fill="currentColor"/><circle cx="12.5" cy="8" r="1.3" fill="currentColor"/></svg>';
menuBtn.addEventListener('click', (e) => {
e.stopPropagation();
showItemMenu(runner, menuBtn);
});
li.append(statusEl, nameEl, menuBtn);
li.addEventListener('click', () => openDetail(runner.id));
runner.rowEl = li;
runner.listEl.appendChild(li);
/* 先隐身插入;若在可视区域外先滚动出来,再播进入动画 */
li.style.opacity = '0';
scrollRowIntoView(runner.listEl, li).then(() => {
li.classList.add('is-entering');
li.addEventListener('animationend', () => {
li.classList.remove('is-entering');
li.style.opacity = '';
}, { once: true });
});
}
/* ---------------- 模拟流式输出 ---------------- */
function playScript(runner) {
const step = () => {
if (runner.status !== 'running') return;
if (runner.cursor >= runner.script.length) {
finishRunner(runner, 'done');
return;
}
const item = runner.script[runner.cursor];
runner.cursor += 1;
if (item.type === 'tool') {
// 工具行先以 running 态出现duration 后落定结果
const feedItem = { ...item, status: 'running' };
pushFeed(runner, feedItem);
runner.timer = setTimeout(() => {
feedItem.status = 'done';
updateFeedRowEl(feedItem);
runner.timer = setTimeout(step, 380);
}, item.duration);
} else if (item.type === 'text') {
pushFeed(runner, { ...item });
runner.timer = setTimeout(step, 950);
} else {
// 终端行
pushFeed(runner, { type: 'term', ...item });
runner.timer = setTimeout(step, 620);
}
};
runner.timer = setTimeout(step, 650);
}
function finishRunner(runner, status) {
if (runner.status !== 'running') return;
runner.status = status;
clearTimeout(runner.timer);
runner.rowEl.classList.remove('is-running');
runner.rowEl.classList.add('is-done');
updateRunCounter(runner.kind);
if (status === 'killed') {
pushFeed(runner, { type: 'text', killed: true, text: '⛔ 已被强制关闭' });
}
if (detailState.id === runner.id) updateDetailHeader(runner);
}
/* ---------------- feed 行渲染 ---------------- */
function rowClassFor(item) {
if (item.type === 'tool') return 'feed-row feed-tool';
if (item.type === 'term') return 'feed-row feed-term' + (item.isCommand ? ' is-command' : '');
return 'feed-row feed-text' + (item.killed ? ' feed-killed' : '');
}
function rowInnerFor(item) {
if (item.type === 'tool') {
const icon = item.status === 'done'
? '<span class="tool-done">✓</span>'
: '<span class="tool-spinner"></span>';
const result = item.status === 'done'
? `<span class="tool-result">${item.result}</span>`
: '';
return `${icon}<span class="tool-name">${item.name}</span><span class="tool-param">${item.param}</span>${result}`;
}
return escapeText(item.text);
}
function escapeText(t) {
const div = document.createElement('div');
div.textContent = t;
return div.innerHTML;
}
function pushFeed(runner, item) {
runner.feed.push(item);
if (detailState.id === runner.id) appendFeedRow(item);
}
/* 追加一行到详情面板:新行淡入,贴近底部时自动向下平滑滚动 */
function appendFeedRow(item) {
const el = detailBody;
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
const div = document.createElement('div');
div.className = rowClassFor(item);
div.innerHTML = rowInnerFor(item);
el.appendChild(div);
item.el = div;
if (nearBottom) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
}
function updateFeedRowEl(item) {
if (!item.el) return;
item.el.className = rowClassFor(item);
item.el.innerHTML = rowInnerFor(item);
const el = detailBody;
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
if (nearBottom) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
}
/* ---------------- 详情面板 ---------------- */
function updateDetailHeader(runner) {
detailDot.className = 'detail-status-dot' + (runner.status === 'running' ? ' is-running' : '');
detailBadge.textContent = runner.status === 'running' ? '运行中' : runner.status === 'done' ? '已完成' : '已终止';
detailBadge.classList.toggle('is-done', runner.status !== 'running');
}
function openDetail(id) {
if (detailState.id === id) { closeDetail(); return; } // 再点同一行收起
const runner = runners.get(id);
detailState.id = id;
detailTitle.textContent = runner.name;
updateDetailHeader(runner);
detailBody.innerHTML = '';
runner.feed.forEach((item) => appendFeedRow(item));
detailBody.scrollTop = detailBody.scrollHeight; // 打开时直接定位到底部
document.querySelectorAll('.run-item.is-active').forEach((el) => el.classList.remove('is-active'));
runner.rowEl.classList.add('is-active');
if (detailPanel.hidden) {
detailPanel.hidden = false;
detailPanel.classList.remove('panel-leave');
detailPanel.classList.add('panel-enter');
setTimeout(() => detailPanel.classList.remove('panel-enter'), 260);
} else {
// 切换条目时内容区快速淡入
detailBody.classList.remove('body-fade');
void detailBody.offsetWidth;
detailBody.classList.add('body-fade');
}
}
function closeDetail() {
if (detailPanel.hidden) return;
detailState.id = null;
document.querySelectorAll('.run-item.is-active').forEach((el) => el.classList.remove('is-active'));
detailPanel.classList.remove('panel-enter');
detailPanel.classList.add('panel-leave');
/* 用 setTimeout 而非 animationendfeed 行动画的 animationend 会冒泡到面板造成干扰 */
setTimeout(() => {
detailPanel.classList.remove('panel-leave');
detailPanel.hidden = true;
}, 190);
}
/* ---------------- 条目 ⋯ 菜单 ---------------- */
function showItemMenu(runner, btn) {
if (!itemMenu.hidden && menuRunnerId === runner.id) { hideItemMenu(); return; }
menuRunnerId = runner.id;
const fm = document.getElementById('fileMenu'); // 与文件菜单互斥
if (fm) fm.hidden = true;
const rect = btn.getBoundingClientRect();
/* ⋯ 按钮在行的最右侧,菜单与按钮右对齐、向下展开 */
itemMenu.style.left = 'auto';
itemMenu.style.right = `${window.innerWidth - rect.right}px`;
itemMenu.style.top = `${rect.bottom + 6}px`;
menuKill.disabled = runner.status !== 'running';
itemMenu.hidden = false;
itemMenu.classList.add('menu-enter');
itemMenu.addEventListener('animationend', () => itemMenu.classList.remove('menu-enter'), { once: true });
}
function hideItemMenu() {
itemMenu.hidden = true;
menuRunnerId = null;
}
menuKill.addEventListener('click', () => {
const runner = runners.get(menuRunnerId);
if (runner) finishRunner(runner, 'killed');
hideItemMenu();
});
document.addEventListener('click', (e) => {
if (!itemMenu.hidden && !e.target.closest('.item-menu') && !e.target.closest('.item-menu-btn')) {
hideItemMenu();
}
});
detailCloseBtn.addEventListener('click', closeDetail);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if (!itemMenu.hidden) { hideItemMenu(); e.preventDefault(); }
else if (!detailPanel.hidden) { closeDetail(); e.preventDefault(); }
}
});
/* ---------------- 演示按钮 & 自动演示 ---------------- */
document.getElementById('btnNewAgent').addEventListener('click', () => createRunner('agent'));
document.getElementById('btnNewCmd').addEventListener('click', () => createRunner('cmd'));
window.addEventListener('load', () => {
setTimeout(() => createRunner('agent'), 1200);
setTimeout(() => createRunner('cmd'), 2100);
});

View File

@ -0,0 +1,276 @@
/* ============================================================
待办事项悬浮窗 Demo 样式
配色暂用经典主题暖色系正式接入时替换为语义 token
============================================================ */
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--surface-base: #faf9f5;
--surface-float: #ffffff;
--surface-wash: #f5f0e8;
--border-subtle: #ece5d8;
--border-strong: #e2d9c8;
--text-primary: #26241f;
--text-secondary: #6f6a5e;
--text-tertiary: #a9a294;
--accent: #cc785c;
--accent-hover: #c06a4e;
--hover-wash: rgba(38, 36, 31, 0.04);
--shadow-float: 0 1px 2px rgba(38, 36, 31, 0.05), 0 8px 24px rgba(38, 36, 31, 0.10);
--row-h: 36px;
--window-max-w: 300px;
--ease-out: cubic-bezier(0.22, 0.9, 0.28, 1);
--ease-strike: cubic-bezier(0.55, 0.06, 0.35, 1);
}
body {
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", sans-serif;
background: var(--surface-base);
color: var(--text-primary);
min-height: 100vh;
}
/* ---------------- 模拟对话区 ---------------- */
.mock-chat {
max-width: 640px;
margin: 0 auto;
padding: 64px 24px 220px;
display: flex;
flex-direction: column;
gap: 14px;
}
.mock-bubble {
max-width: 78%;
padding: 10px 14px;
border-radius: 14px;
font-size: 14px;
line-height: 1.6;
}
.mock-bubble--user { align-self: flex-end; background: #efe9de; }
.mock-bubble--ai { align-self: flex-start; background: #ffffff; border: 1px solid var(--border-subtle); }
/* ---------------- 悬浮窗口栈 ---------------- */
.float-stack {
position: fixed;
top: 20px;
right: 20px;
z-index: 100;
display: flex;
flex-direction: column;
align-items: flex-end; /* 多个窗口右对齐,上下排布 */
gap: 12px;
/* 整体超出屏幕高度时可滚动,滚动条隐藏 */
max-height: calc(100vh - 40px);
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
/* padding + 负 margin扩大滚动盒以容纳窗口阴影避免被 overflow 裁剪 */
padding: 24px;
margin: -24px;
}
.float-stack::-webkit-scrollbar { display: none; width: 0; height: 0; }
/* 窗口:圆角矩形 + 固定宽度(最大宽度的 90% + 不透明背景 + 克制阴影 */
.float-window {
flex: none; /* 禁止被栈 flex 压缩,保持固定高度 */
width: calc(var(--window-max-w) * 0.9); /* 300 * 0.9 = 270px */
background: var(--surface-float);
border: 1px solid var(--border-subtle);
border-radius: 12px;
box-shadow: var(--shadow-float);
overflow: hidden;
}
/* 窗口自身出现 / 消失(高度变化由行展开动画带动,这里只做淡入) */
.float-window.window-enter { animation: window-in 0.3s ease-out; }
@keyframes window-in {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: translateY(0); }
}
.float-window.window-leave { animation: window-out 0.22s ease-in forwards; }
@keyframes window-out {
to { opacity: 0; transform: translateY(-4px) scale(0.98); }
}
/* 窗口标题栏 */
.float-window__header {
height: 38px;
display: flex;
align-items: center;
gap: 7px;
padding: 0 12px;
border-bottom: 1px solid var(--border-subtle);
}
.float-window__icon { width: 15px; height: 15px; color: var(--accent); flex: none; }
.float-window__title {
font-size: 12.5px;
font-weight: 600;
color: var(--text-secondary);
letter-spacing: 0.02em;
}
.todo-counter {
margin-left: auto;
font-size: 11.5px;
color: var(--text-tertiary);
font-variant-numeric: tabular-nums;
}
/* ---------------- 待办列表 ---------------- */
/* 窗口高度固定为 5 不足留空超出内部滚动滚动条完全隐藏
无上下 padding首行 hover 紧贴标题栏分隔线末行紧贴窗口底边 */
.todo-list {
list-style: none;
padding: 0;
height: calc(var(--row-h) * 5);
overflow-y: auto;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* 旧 Edge */
}
.todo-list::-webkit-scrollbar { display: none; width: 0; height: 0; }
/* 每一行直接铺在窗口上:无背景、无边框、无嵌套卡片 */
/* 待办行不可点击,无 hover 效果 */
.todo-item {
height: var(--row-h);
display: flex;
align-items: center;
gap: 9px;
padding: 0 12px;
overflow: hidden; /* 配合移出动画的高度收拢 */
}
/* 状态点 */
.todo-dot {
flex: none;
width: 6px;
height: 6px;
border-radius: 50%;
border: 1.5px solid var(--text-tertiary);
background: transparent;
transition: background 0.2s ease, border-color 0.2s ease;
}
.todo-item.is-done .todo-dot { background: var(--accent); border-color: var(--accent); }
/* 任务文字:固定一行,超长省略 */
.todo-text {
position: relative;
flex: 1;
min-width: 0;
font-size: 13px;
line-height: 1;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: color 0.22s ease 0.12s; /* 变灰略滞后于划线 */
}
/* 删除线(默认收起) */
.todo-text::after {
content: '';
position: absolute;
left: -1px;
right: -1px;
top: 50%;
height: 1.5px;
margin-top: -0.75px;
background: var(--text-tertiary);
border-radius: 1px;
transform: scaleX(0);
transform-origin: left center;
pointer-events: none;
}
/* 完成态(静态,用于整表渲染时已完成行) */
.todo-item.is-done .todo-text { color: var(--text-tertiary); }
.todo-item.is-done .todo-text::after { transform: scaleX(1); }
/* 完成动作瞬间:横线从左往右划过 + 状态点弹跳 */
.todo-item.just-done .todo-text::after {
animation: strike-in 0.28s var(--ease-strike) forwards;
}
@keyframes strike-in {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
.todo-item.just-done .todo-dot { animation: dot-pop 0.32s var(--ease-out); }
@keyframes dot-pop {
0% { transform: scale(1); }
45% { transform: scale(1.6); }
100% { transform: scale(1); }
}
/* 进入动画:从左向右位移 + 淡入stagger 由 JS 写 animation-delay */
.todo-item.is-entering {
opacity: 0;
animation: todo-in 0.34s var(--ease-out) forwards;
}
@keyframes todo-in {
from { opacity: 0; transform: translateX(-14px); }
to { opacity: 1; transform: translateX(0); }
}
/* 移出动画:从右向左位移 + 淡出,随后高度收拢让下方行平滑上移 */
.todo-item.is-leaving {
animation: todo-out 0.26s ease-in forwards;
}
@keyframes todo-out {
0% { opacity: 1; transform: translateX(0); height: var(--row-h); }
55% { opacity: 0; transform: translateX(-14px); height: var(--row-h); }
100% { opacity: 0; transform: translateX(-14px); height: 0; }
}
/* ---------------- demo 控制台 ---------------- */
.demo-panel {
position: fixed;
left: 20px;
bottom: 20px;
z-index: 100;
}
.demo-panel__hint {
font-size: 12px;
color: var(--text-tertiary);
margin-bottom: 10px;
}
.demo-panel__buttons {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.demo-btn {
height: 34px;
padding: 0 14px;
border-radius: 8px;
border: 1px solid var(--border-strong);
background: var(--surface-float);
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease;
}
.demo-btn:hover { background: var(--surface-wash); }
.demo-btn--primary { background: var(--accent); border-color: var(--accent); color: #ffffff; }
.demo-btn--primary:hover { background: var(--accent-hover); }
.demo-btn--ghost { border-style: dashed; color: var(--text-tertiary); }

132
docs/float_window_design.md Normal file
View File

@ -0,0 +1,132 @@
# 对话区快捷窗口栈 · 设计定稿2026-07-21
> 状态demo 已验收,正式接入 `static/src` 中。
> Demo 位置:`demo/todo_float_window/`(目录名为历史遗留,内含全部 4 个窗口;浏览器直接打开 `index.html`)。
> 本文档是唯一设计依据;压缩对话后凭本文档 + demo 继续。
## 0.1 正式接入变更2026-07-21 用户补充,覆盖 demo 的悬浮定位)
1. **命名**:该功能正式名称为「**快捷窗口**」quick dock / quick window
2. **布局:占位而非悬浮**:快捷窗口**不是** `position: fixed` 悬浮,而是在**对话区域右侧空白区域占位显示**的一列,常驻时会**把对话区域向左挤压**(与 git/终端 right-panels 同为 `chat-container` 的 flex 兄弟节点,位于 `</main>` 之后、right-panels 之前)。有内容时才显示该列。
3. **设置项**个人空间PersonalizationDrawer新增「**隐藏快捷窗口**」开关,**默认关闭**(即默认显示)。字段 `hide_quick_dock: boolean` 存 personalization form。
4. 详情面板(子智能体/后台指令)保持 fixed 悬浮从快捷窗口列左侧滑出文件预览侧边栏在快捷窗口列右侧git/终端面板左侧)占位/滑出。
5. 移动端isMobileViewport第一版先隐藏。
## 0. Demo 文件地图
| 文件 | 内容 |
|------|------|
| `index.html` | 页面结构:模拟对话区 + 悬浮栈4 窗口)+ 详情面板 + 预览侧边栏 + 菜单 + toast |
| `style.css` | 变量(配色/尺寸/缓动)+ 窗口基础 + 待办样式与全部 keyframes |
| `app.js` | 待办逻辑(创建/完成/替换/滚动划线) |
| `runner.css` / `runner.js` | 子智能体 + 后台指令窗口、左侧详情面板、⋯ 菜单(强制关闭) |
| `file.css` / `file.js` | 文件记录窗口、右侧预览侧边栏、⋯ 菜单(下载/打开/复制路径、toast |
## 1. 总体布局
- 位置:对话区域右侧**占位列**(非悬浮):`chat-container` 的 flex 兄弟节点,宽 294px270 + 左右 padding 12`flex: none`,整列上下排布。对话区被自然向左挤压。
- 窗口顺序(从上到下,固定):**待办事项 → 子智能体 → 后台指令 → 文件**。
- 窗口上下排布,右对齐,`gap: 12px`。
- **整列可滚动**:列高 100%`overflow-y: auto`,滚动条隐藏。
- 防阴影被 overflow 裁剪:栈 `padding: 24px; margin: -24px`
- 栈滚动时关闭所有打开的 ⋯ 菜单(菜单 fixed 定位不随栈滚动)。
- 各窗口初始隐藏有内容时淡入出现fade + translateY(-6px)0.3s)。
## 2. 窗口通用规格
- **宽度固定 270px**= 最大宽度 300px × 90%,变量 `--window-max-w` 联动)。
- **列表区高度固定 5 行**(行高 36px → 180px不足留空超出内部滚动滚动条隐藏。
- 窗口 `flex: none`(防止被栈 flex 压缩变矮,已踩坑)。
- 圆角 12px背景不透明实体面板禁半透明中性阴影无发光1px 细边框。
- 标题栏 38pxSVG 图标accent 色)+ 标题12.5px 次要色)+ 右侧计数11.5px 三级色)。
- **列表无上下 padding**:首行 hover 紧贴标题栏分隔线,末行紧贴窗口底边。
- 行:固定 36px 高,直接铺在窗口上(无嵌套卡片/背景/边框),超长文字省略号。
- 正式接入时配色全部换成三主题语义 tokendemo 为经典主题暖色写死)。
## 3. 各窗口设计
### 3.1 待办事项
- 行结构:`[状态点] 任务文字`**无 hover、不可点击**。
- 计数:`done/total`。
- 动画:
- **创建**:逐行从左向右(位移 14px + 淡入0.34sstagger 60ms
- **完成**横线从左往右划过文字260ms`strike-in` scaleX 0→1origin left→ 文字延迟 120ms 变灰 + 状态点变色弹跳(`dot-pop`)。静态完成态(整表渲染时)不播划线,直接呈现。
- **整表替换**(收到新 todo旧行从上到下逐行从右向左移出0.26sstagger 45ms**带高度收拢**让下方行平滑上移)→ 新行逐行进入。
- **区域外条目**:要完成的行未完全显示时,先 `scrollIntoView({behavior:'smooth', block:'nearest'})` 滚出,滚动停稳 90ms 后再播划线800ms 兜底)。
### 3.2 子智能体 / 3.3 后台指令(两者同构,仅详情内容不同)
- 行结构:`[●状态点] 名称 [⋯]`**有 hover整行可点击**;后台指令名称用等宽字体。
- 状态点:运行中 = accent 色呼吸(`status-pulse` 1.6s);完成/终止 = 灰,名称同步变灰。
- 计数:`running/total`。
- 新条目:进入动画同待办;**区域外先滚动露出再播进入动画**(先隐身插入,滚动到位后播)。
- **无删除**(条目永久保留)。
- ⋯ 菜单:仅「强制关闭」(警示红色,运行完/已终止置灰);菜单与按钮**右对齐**向下展开两菜单本菜单与文件菜单互斥Esc / 点空白关闭。
- **点击行 → 左侧展开详情面板**480×460`right: calc(20px + 270px + 12px)`,滑入 0.24s
- 子智能体:工具调用 + 文本输出流,**扁平行式无嵌套卡片**;工具行:运行中 spinner旋转→ 完成变 ✓ + 右侧落定结果;文本行次要色。
- 后台指令:等宽终端行,首行 `$ 命令` 加粗。
- 新进度出现动画:淡入 + translateY(5px) 上浮0.25s)。
- **自动滚动**:距底 <80px 时新内容平滑滚到底用户上翻则不打断
- 再点同一行 / ✕ / Esc 关闭;点其他行切换(内容区快速淡入)。
- ⚠️ **实际 API 颗粒度提醒**:子智能体进度 API 没有细化的"运行中流式状态"**每个步骤完成后才推送一次进度**。demo 里的流式效果仅为演示,正式接入时的显示颗粒度届时再定。
### 3.4 文件
- 行结构:`[⋯] 文件名`**无状态点**;有 hover整行可点击。
- 计数:文件总数。
- 显示逻辑(正式版):**文件被编辑/创建过 + 文件仍存在**才显示;同一文件按 path 去重只出现一次。
- ⋯ 菜单(与按钮**左对齐**向下展开):
1. **下载**
2. **在文件管理器中打开**——复用 git 状态侧边栏已有逻辑,用默认应用打开;**docker 模式下无此选项**
3. **复制路径**——工作区内相对路径
- **点击行 → 右侧滑出预览侧边栏**(非左侧详情面板):
- 440px 宽、全高top/bottom 20px`translateX(100%+24px)` ↔ `0`0.3s。
- 打开时**悬浮窗栈与详情面板同步左移让位**(栈 `right: 20+440+12`,均带 0.3s transition
- 头部:文件图标 + 文件名(等宽加粗)+ 目录(灰小字)+ 关闭按钮。
- 内容:等宽 12px + 行号44px 灰),行 hover 有底色,滚动条隐藏。
- 再点同一文件 / ✕ / Esc 关闭;点其他文件直接切换。
- 仅支持特定种类 UTF-8 文本文件(正式版按扩展名白名单)。
## 4. 后端配合点(正式接入)
1. **文件记录**:每次 `write_file` / `edit_file` 成功后,把文件**相对路径**(含时间戳、操作类型)写入**对话文件(对话 JSON**;同一 path 更新而非追加重复项。
2. **前端读取**:前端从对话数据中读取文件记录列表渲染文件窗口。
3. **文件内容**:预览用**现有文件内容 API**(用户确认已有,无需新做)。
4. 待办 / 子智能体 / 后台指令:接各自现有数据流与事件推送。
## 5. 已踩过的实现坑(接入时避开)
1. `display: flex` 会覆盖 `hidden` 属性的 `display: none` → 必须补 `.panel[hidden] { display: none; }`
2. flex 容器会压缩子元素(窗口变矮)→ 窗口加 `flex: none`
3. `animationend` 会冒泡feed 行 → 详情面板),用 `{once:true}` 监听会被提前消费 → 时序控制改用 `setTimeout`
4. `overflow` 裁剪窗口阴影 → 滚动容器 `padding + 负 margin` 扩大滚动盒。
5. 菜单 fixed 定位不随栈滚动 → 栈滚动时主动关闭菜单。
6. 两个 ⋯ 菜单 + Esc 多层浮层菜单互斥Esc 按 菜单 → 详情面板 → 预览栏 顺序逐个关(用 `e.preventDefault()` + `e.defaultPrevented` 串联)。
## 6. 正式接入任务清单2026-07-21 已全部完成)
- [x] 后端:编辑文件路径记录写入对话 JSONwrite/edit 钩子)+ 对话数据带出该列表
- [x] 前端:`static/src` 快捷窗口占位列容器(非悬浮,挤压对话区)
- [x] 前端4 个窗口组件 + 详情面板 + 文件预览侧边栏(配色走语义 token 三主题)
- [x] 前端:接入待办 / 子智能体 / 后台指令 / 文件记录的真实数据与事件推送
- [x] 前端:文件预览内容接现有文件内容 API扩展名白名单
- [x] 动画参数按 demo 定稿迁移(位移 14px、stagger 60/45ms、划线 260ms 等)
### 接入实现位置2026-07-21
**后端**
- `core/main_terminal_parts/tools_execution.py``_record_edited_file` / `_remove_edited_file` / `_rename_edited_file` + `_mutate_edited_files`(写入对话 `metadata.edited_files`,同 path 去重;广播 `edited_files_updated`
- `server/conversation.py``GET /api/conversations/<id>/messages` 返回 `edited_files`(过滤不存在文件)
- `server/app_legacy.py``terminal_broadcast` 白名单加 `edited_files_updated`
- `modules/personalization_manager.py``hide_quick_dock` 字段(默认 False
**前端**
- `static/src/components/chat/quickdock/``QuickDock.vue`(占位列容器+菜单+Esc 分层+轮询)、`TodoWindow.vue`、`RunnerWindow.vue`(子智能体/后台指令通用)、`RunnerDetailPanel.vue`、`FileWindow.vue`、`FilePreviewPanel.vue`、`quickdock.css`(全部 keyframes 与语义 token 配色)
- `static/src/stores/quickDock.ts`editedFiles / detail / previewPath / menu 状态
- `static/src/composables/useLegacySocket.ts`:监听 `edited_files_updated`(按 conversation_id 过滤)
- `static/src/app/methods/history.ts`messages 加载后回填 editedFiles
- `static/src/App.vue``</main>` 后挂载 `QuickDock` + `FilePreviewPanel`(移动端与 hide_quick_dock 时不渲染)
- `static/src/components/personalization/PersonalizationDrawer.vue`:「隐藏快捷窗口」设置行(默认隐藏工作区之后)
**验证状态**py_compile ✓ / 6 项后端冒烟 ✓ / tsc+stylelint+vite build ✓ / 新文件 eslint 0 问题,存量文件 lint 与基线持平。8091 服务重启后的实际运行验证待手动进行。

View File

@ -105,6 +105,7 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = {
"agents_md_auto_inject": False, # AGENTS.md 自动注入开关
"allow_root_file_creation": False, # 允许在根目录创建文件开关
"default_hide_workspace": False, # 默认隐藏工作区
"hide_quick_dock": False, # 隐藏快捷窗口(对话区右侧的待办/子智能体/后台指令/文件窗口列)
"group_sidebar_by_workspace": False, # 侧边栏按工作区/项目分组显示对话
"sidebar_pinned_workspaces": [], # 分组侧边栏中永久置顶的工作区ID列表
"sidebar_workspace_order": [], # 分组侧边栏中非置顶工作区的显示顺序
@ -528,6 +529,12 @@ def sanitize_personalization_payload(
else:
base["default_hide_workspace"] = bool(base.get("default_hide_workspace", False))
# 隐藏快捷窗口
if "hide_quick_dock" in data:
base["hide_quick_dock"] = bool(data.get("hide_quick_dock"))
else:
base["hide_quick_dock"] = bool(base.get("hide_quick_dock", False))
# 侧边栏按工作区/项目分组显示对话
if "group_sidebar_by_workspace" in data:
base["group_sidebar_by_workspace"] = bool(data.get("group_sidebar_by_workspace"))

View File

@ -1196,7 +1196,7 @@ def terminal_broadcast(event_type, data):
"""广播终端事件到所有订阅者"""
try:
# 对于全局事件,发送给所有连接的客户端
if event_type in ('token_update', 'todo_updated'):
if event_type in ('token_update', 'todo_updated', 'edited_files_updated'):
socketio.emit(event_type, data) # 全局广播,不限制房间
debug_log(f"全局广播{event_type}: {data}")
else:

View File

@ -1018,13 +1018,36 @@ def get_conversation_messages(conversation_id, terminal: WebTerminal, workspace:
limit = request.args.get('limit', type=int)
if limit:
messages = messages[-limit:] # 获取最后N条消息
# 快捷窗口:本次对话编辑/创建过的文件记录(过滤不存在的文件)
edited_files: list = []
raw_edited = (conversation_data.get("metadata") or {}).get("edited_files")
if isinstance(raw_edited, list):
file_manager = getattr(terminal, "file_manager", None)
for item in raw_edited:
if not isinstance(item, dict):
continue
rel_path = str(item.get("path") or "").strip()
if not rel_path:
continue
try:
valid, _err, full_path = file_manager._validate_path(rel_path) if file_manager else (False, None, None)
except Exception:
valid, full_path = False, None
if valid and full_path is not None and full_path.exists() and full_path.is_file():
edited_files.append({
"path": rel_path,
"op": item.get("op") or "edit",
"ts": item.get("ts") or "",
})
return jsonify({
"success": True,
"data": {
"conversation_id": conversation_id,
"messages": messages,
"total_count": len(conversation_data.get("messages", []))
"total_count": len(conversation_data.get("messages", [])),
"edited_files": edited_files
}
})
else:

View File

@ -205,6 +205,28 @@ def bootstrap_conversation(conversation_id: str):
"is_truly_active": is_main_running or any(bg_status.values()),
}
# 快捷窗口:本次对话编辑/创建过的文件记录(过滤不存在的文件)
edited_files: list = []
raw_edited = raw_meta.get("edited_files")
if isinstance(raw_edited, list):
file_manager = getattr(ws_terminal, "file_manager", None)
for item in raw_edited:
if not isinstance(item, dict):
continue
rel_path = str(item.get("path") or "").strip()
if not rel_path:
continue
try:
valid, _err, full_path = file_manager._validate_path(rel_path) if file_manager else (False, None, None)
except Exception:
valid, full_path = False, None
if valid and full_path is not None and full_path.exists() and full_path.is_file():
edited_files.append({
"path": rel_path,
"op": item.get("op") or "edit",
"ts": item.get("ts") or "",
})
data: Dict[str, Any] = {
"conversation_id": normalized_id,
"meta": {
@ -221,6 +243,7 @@ def bootstrap_conversation(conversation_id: str):
},
"messages": messages,
"running": running,
"edited_files": edited_files,
}
if is_main_running:

View File

@ -385,6 +385,13 @@
/>
</div>
</main>
<QuickDock
v-if="!isMobileViewport && !personalizationStore?.form?.hide_quick_dock"
:host-mode="versioningHostMode"
/>
<FilePreviewPanel
v-if="!isMobileViewport && !personalizationStore?.form?.hide_quick_dock"
/>
<div
v-if="!isMobileViewport && (terminalPanelOpen || gitChangesPanelOpen)"
class="resize-handle resize-handle--right-panels"
@ -888,6 +895,8 @@
import { defineAsyncComponent, onMounted, ref } from 'vue';
import appOptions from './app';
import VideoPicker from './components/overlay/VideoPicker.vue';
import QuickDock from './components/chat/quickdock/QuickDock.vue';
import FilePreviewPanel from './components/chat/quickdock/FilePreviewPanel.vue';
import { useTutorialStore } from './stores/tutorial';
import { usePersonalizationStore } from './stores/personalization';

View File

@ -1,5 +1,6 @@
// @ts-nocheck
import { debugLog, traceLog } from '../common';
import { useQuickDockStore } from '../../../stores/quickDock';
/**
* B
@ -110,6 +111,15 @@ export const bootstrapMethods = {
this.lastHistoryLoadedConversationId = normalizedId;
this.refreshBlankHeroState();
// 4.5 快捷窗口:回填本次对话编辑/创建文件记录。
// 必须先等 currentConversationId 的级联 watcherapp watcher 清空 → store 同步 →
// QuickDock 内清空)全部执行完,否则回填数据会被 watcher 覆盖。
// setTimeout(0) 走宏任务,比 $nextTick 的 microtask 更保险。
await new Promise((resolve) => setTimeout(resolve, 0));
useQuickDockStore().setEditedFiles(
Array.isArray(data.edited_files) ? data.edited_files : []
);
// 5. 运行中任务快速恢复(任务/事件/判据已由 bootstrap 聚合,
// 免去 GET /api/tasks + 历史死等 + GET /api/tasks/{id} 三次请求)
const running = data.running || {};

View File

@ -1,5 +1,6 @@
// @ts-nocheck
import { debugLog } from './common';
import { useQuickDockStore } from '../../stores/quickDock';
const jsonDebug = (...args: any[]) => {
console.log('[JSONDEBUG]', ...args);
};
@ -157,6 +158,10 @@ export const historyMethods = {
const rawMessages = Array.isArray(messagesData?.data?.messages)
? messagesData.data.messages
: [];
// 快捷窗口:回填本次对话编辑/创建文件记录(后端已过滤不存在文件)
useQuickDockStore().setEditedFiles(
Array.isArray(messagesData?.data?.edited_files) ? messagesData.data.edited_files : []
);
const lastAssistantRaw = [...rawMessages]
.reverse()
.find((m: any) => m?.role === 'assistant');

View File

@ -1,6 +1,7 @@
// @ts-nocheck
import { debugLog, goalModeDebugLog } from '../common';
import { useTaskStore } from '../../../stores/task';
import { useQuickDockStore } from '../../../stores/quickDock';
import { getMessageVisibility, messageStartsWork } from '../../../utils/messageVisibility';
import {
debugNotifyLog,
@ -293,6 +294,24 @@ export const lifecycleMethods = {
case 'conversation_resolved':
this.handleConversationResolved(eventData, eventIdx);
break;
case 'todo_updated':
// 任务期广播的待办快照(创建/勾选/清空都会触发。live=true 让窗口播动画。
// 快照直接可用;缺失时退化为 REST 拉取fetchTodoList 带 conversation_id
if (eventData && 'todo_list' in eventData) {
this.fileSetTodoList(eventData.todo_list || null, true);
} else if (typeof this.scheduleTodoListRefresh === 'function') {
this.scheduleTodoListRefresh(0);
}
break;
case 'edited_files_updated':
// 快捷窗口文件记录edit/write/delete/rename 后广播payload 携带最新列表
useQuickDockStore().setEditedFiles(
Array.isArray(eventData?.edited_files) ? eventData.edited_files : [],
true
);
break;
case 'compression_state':
// 重放历史事件时不处理管理类事件,避免触发副作用(如 toast 闪烁 / 状态回退)。
if (this._rebuildingFromScratch) break;

View File

@ -1,11 +1,30 @@
// @ts-nocheck
import { debugLog, traceLog } from './methods/common';
import { useConversationStore } from '../stores/conversation';
import { useQuickDockStore } from '../stores/quickDock';
import { useFileStore } from '../stores/file';
export const watchers = {
multiAgentMode(newValue) {
useConversationStore().$patch({ multiAgentMode: !!newValue });
},
currentConversationId(newId, oldId) {
if (newId === oldId) {
return;
}
// 同步到 conversationStoreQuickDock 等组件监听 store 侧的 id
useConversationStore().setCurrentConversationId(newId || null);
const quickDock = useQuickDockStore();
// 关闭详情/预览/菜单等瞬态(必须立即)
quickDock.resetTransient();
if (!newId) {
// /new 等无对话场景:不会有 bootstrap 回填,立即清空让列折叠
quickDock.setEditedFiles([]);
useFileStore().setTodoList(null);
}
// 切到另一对话:不清列表 —— 旧内容短暂保留,由 bootstrap/fetch 回填自然覆盖;
// 新对话无内容时窗口在回填后才消失,避免列「先收起再展开」的闪烁。
},
inputMessage() {
this.autoResizeInput();
if (typeof this.scheduleComposerDraftPersist === 'function') {

View File

@ -0,0 +1,175 @@
<template>
<transition name="qd-preview-slide">
<aside v-if="previewPath" class="qd-preview">
<section class="qd-preview__panel">
<header class="qd-preview__header">
<svg viewBox="0 0 16 16" fill="none">
<path
d="M4 2.5h5.5L12 5v8.5H4V2.5z"
stroke="currentColor"
stroke-width="1.3"
stroke-linejoin="round"
/>
<path
d="M9.5 2.5V5H12"
stroke="currentColor"
stroke-width="1.3"
stroke-linejoin="round"
/>
</svg>
<span class="qd-preview__name" :title="previewPath">{{ fileName }}</span>
<span class="qd-preview__path" :title="previewPath">{{ dirName }}</span>
<button class="qd-preview__close" title="关闭" @click="close">
<svg viewBox="0 0 16 16" fill="none">
<path
d="M4 4l8 8M12 4l-8 8"
stroke="currentColor"
stroke-width="1.4"
stroke-linecap="round"
/>
</svg>
</button>
</header>
<div class="qd-preview__body">
<div v-if="loading" class="qd-preview__loading">加载中</div>
<div v-else-if="error" class="qd-preview__error">{{ error }}</div>
<template v-else>
<div v-for="(line, i) in lines" :key="i" class="qd-code-line">
<span class="line-no">{{ i + 1 }}</span>
<span class="line-text">{{ line }}</span>
</div>
</template>
</div>
</section>
</aside>
</transition>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { useQuickDockStore } from '@/stores/quickDock';
/**
* 文件预览侧边栏占位列位于快捷窗口列右侧git/终端面板左侧
* 内容走现有 /api/file/content仅支持白名单内的 UTF-8 文本类文件
*/
const PREVIEWABLE_EXTS = new Set([
'.txt',
'.md',
'.markdown',
'.csv',
'.json',
'.json5',
'.yaml',
'.yml',
'.js',
'.ts',
'.jsx',
'.tsx',
'.vue',
'.py',
'.rb',
'.go',
'.rs',
'.java',
'.c',
'.h',
'.cpp',
'.hpp',
'.cs',
'.php',
'.swift',
'.kt',
'.sh',
'.bash',
'.zsh',
'.ps1',
'.bat',
'.cmd',
'.html',
'.htm',
'.css',
'.scss',
'.sass',
'.less',
'.xml',
'.svg',
'.toml',
'.ini',
'.cfg',
'.conf',
'.log',
'.sql'
]);
const quickDock = useQuickDockStore();
const { previewPath } = storeToRefs(quickDock);
const lines = ref<string[]>([]);
const loading = ref(false);
const error = ref('');
const fileName = computed(() => {
const p = previewPath.value || '';
return p.split('/').pop() || p;
});
const dirName = computed(() => {
const p = previewPath.value || '';
const parts = p.split('/');
return parts.length > 1 ? `${parts.slice(0, -1).join('/')}/` : '';
});
function extOf(path: string): string {
const name = path.split('/').pop() || '';
const idx = name.lastIndexOf('.');
return idx >= 0 ? name.slice(idx).toLowerCase() : '';
}
let loadSeq = 0;
watch(
previewPath,
async (path) => {
const mySeq = ++loadSeq;
lines.value = [];
error.value = '';
if (!path) {
return;
}
if (!PREVIEWABLE_EXTS.has(extOf(path))) {
error.value = '该文件类型不支持预览';
return;
}
loading.value = true;
try {
const resp = await fetch(`/api/file/content?path=${encodeURIComponent(path)}`);
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
throw new Error(payload?.error || `加载失败HTTP ${resp.status}`);
}
const text = await resp.text();
if (mySeq !== loadSeq) {
return;
}
lines.value = text.split('\n');
} catch (err: any) {
if (mySeq !== loadSeq) {
return;
}
error.value = err?.message || '加载失败';
} finally {
if (mySeq === loadSeq) {
loading.value = false;
}
}
},
{ immediate: true }
);
function close() {
quickDock.closePreview();
}
</script>

View File

@ -0,0 +1,177 @@
<template>
<section v-if="rows.length" class="qd-window" :class="{ 'qd-window-enter': windowEntering }">
<header class="qd-window__header">
<svg class="qd-window__icon" viewBox="0 0 16 16" fill="none">
<path
d="M4 2.5h5.5L12 5v8.5H4V2.5z"
stroke="currentColor"
stroke-width="1.3"
stroke-linejoin="round"
/>
<path d="M9.5 2.5V5H12" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round" />
</svg>
<span class="qd-window__title">文件</span>
<span class="qd-window__counter">{{ rows.length }}</span>
</header>
<ul ref="listRef" class="qd-list">
<li
v-for="row in rows"
:key="row.path"
class="qd-file-item"
:class="{ 'is-active': previewPath === row.path, 'qd-row-enter': row.entering }"
:style="row.pendingEnter ? { opacity: '0' } : undefined"
:title="row.path"
@click="openRow(row)"
>
<button class="qd-row-menu-btn" title="更多" @click.stop="openMenu($event, row)">
<svg viewBox="0 0 16 16">
<circle cx="3.5" cy="8" r="1.3" fill="currentColor" />
<circle cx="8" cy="8" r="1.3" fill="currentColor" />
<circle cx="12.5" cy="8" r="1.3" fill="currentColor" />
</svg>
</button>
<span class="qd-file-name">{{ basename(row.path) }}</span>
</li>
</ul>
</section>
</template>
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { useQuickDockStore } from '@/stores/quickDock';
/**
* 文件记录窗口
* 本次对话中编辑/创建过且仍存在的文件同一 path 只出现一次
* 行结构[] 文件名点击行 右侧预览侧边栏 菜单下载/打开/复制路径
*/
interface Row {
path: string;
entering: boolean;
pendingEnter: boolean;
}
const quickDock = useQuickDockStore();
const { editedFiles, previewPath } = storeToRefs(quickDock);
const rows = ref<Row[]>([]);
const windowEntering = ref(false);
const listRef = ref<HTMLElement | null>(null);
let windowEnterShown = false;
function basename(p: string): string {
return p.split('/').pop() || p;
}
function openRow(row: Row) {
quickDock.closeMenu();
quickDock.openPreview(row.path);
}
function openMenu(e: MouseEvent, row: Row) {
const btn = e.currentTarget as HTMLElement;
const rect = btn.getBoundingClientRect();
quickDock.openMenu({
type: 'file',
key: row.path,
left: rect.left, //
top: rect.bottom + 6,
alignRight: false
});
}
function scrollElIntoView(el: HTMLElement): Promise<void> {
return new Promise((resolve) => {
const listEl = listRef.value;
if (!listEl) {
resolve();
return;
}
const listRect = listEl.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
const fullyVisible = elRect.top >= listRect.top && elRect.bottom <= listRect.bottom;
if (fullyVisible) {
resolve();
return;
}
let done = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const finish = () => {
if (done) {
return;
}
done = true;
if (timer) {
clearTimeout(timer);
}
listEl.removeEventListener('scroll', onScroll);
resolve();
};
const onScroll = () => {
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(finish, 90);
};
listEl.addEventListener('scroll', onScroll);
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
timer = setTimeout(finish, 800);
});
}
async function playEnter(row: Row) {
await nextTick();
const listEl = listRef.value;
const index = rows.value.indexOf(row);
const el = listEl?.children[index] as HTMLElement | undefined;
if (!el) {
row.pendingEnter = false;
row.entering = true;
setTimeout(() => {
row.entering = false;
}, 380);
return;
}
await scrollElIntoView(el);
row.pendingEnter = false;
row.entering = true;
setTimeout(() => {
row.entering = false;
}, 380);
}
watch(
editedFiles,
(list) => {
// live=true false /bootstrap
const live = quickDock.editedFilesLive;
const byPath = new Map(list.map((item) => [item.path, item]));
// /
rows.value = rows.value.filter((row) => byPath.has(row.path));
//
const existing = new Set(rows.value.map((row) => row.path));
const added = list.filter((item) => !existing.has(item.path));
if (added.length) {
if (live && !windowEnterShown && !rows.value.length) {
windowEnterShown = true;
windowEntering.value = true;
setTimeout(() => {
windowEntering.value = false;
}, 320);
}
added.forEach((item) => {
const row: Row = { path: item.path, entering: false, pendingEnter: live };
rows.value.push(row);
if (live) {
void playEnter(row);
}
});
}
},
{ immediate: true }
);
</script>

View File

@ -0,0 +1,289 @@
<template>
<aside class="quick-dock" :class="{ 'quick-dock--empty': !hasContent }">
<div ref="scrollRef" class="quick-dock__scroll" @scroll.passive="handleStackScroll">
<TodoWindow />
<RunnerWindow kind="agent" />
<RunnerWindow kind="cmd" />
<FileWindow />
</div>
<!-- 详情面板fixed 浮在列左侧 -->
<RunnerDetailPanel />
<!-- 全局 菜单fixed 单例 -->
<div v-if="menu" class="qd-menu menu-enter" :style="menuStyle" @click.stop>
<template v-if="menu.type === 'runner'">
<button
class="qd-menu__item qd-menu__item--danger"
:disabled="!menuTargetRunning"
@click="killRunner"
>
强制关闭
</button>
</template>
<template v-else>
<button class="qd-menu__item" @click="downloadFile">下载</button>
<button v-if="hostMode" class="qd-menu__item" @click="revealInManager">
在文件管理器中打开
</button>
<button class="qd-menu__item" @click="copyPath">复制路径</button>
</template>
</div>
</aside>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { useQuickDockStore } from '@/stores/quickDock';
import { useSubAgentStore } from '@/stores/subAgent';
import { useBackgroundCommandStore } from '@/stores/backgroundCommand';
import { useConversationStore } from '@/stores/conversation';
import { useFileStore } from '@/stores/file';
import { useUiStore } from '@/stores/ui';
import TodoWindow from './TodoWindow.vue';
import RunnerWindow from './RunnerWindow.vue';
import RunnerDetailPanel from './RunnerDetailPanel.vue';
import FileWindow from './FileWindow.vue';
/**
* 快捷窗口Quick Dock容器
* 对话区右侧占位列待办 / 子智能体 / 后台指令 / 文件 四个窗口上下排布
* 同时负责全局 菜单Esc 分层关闭列表轮询对话切换重置
*/
defineProps<{ hostMode: boolean }>();
const quickDock = useQuickDockStore();
const subAgentStore = useSubAgentStore();
const bgStore = useBackgroundCommandStore();
const conversationStore = useConversationStore();
const fileStore = useFileStore();
const uiStore = useUiStore();
const { menu } = storeToRefs(quickDock);
const scrollRef = ref<HTMLElement | null>(null);
/** 四个窗口任一有内容时才占位显示(空列不挤压对话区) */
const hasContent = computed(() => {
const todoCount = fileStore.todoList?.tasks?.length || 0;
return (
todoCount > 0 ||
subAgentStore.subAgents.length > 0 ||
bgStore.commands.length > 0 ||
quickDock.editedFiles.length > 0
);
});
const AGENT_TERMINAL = new Set(['completed', 'failed', 'timeout', 'terminated']);
const CMD_TERMINAL = new Set(['completed', 'failed', 'timeout', 'cancelled']);
/* ---------------- 菜单定位与目标状态 ---------------- */
const MENU_ESTIMATED_HEIGHT = 128;
const menuStyle = computed(() => {
const m = menu.value;
if (!m) {
return {};
}
const top = Math.min(m.top, window.innerHeight - MENU_ESTIMATED_HEIGHT - 8);
if (m.alignRight) {
return { right: `${window.innerWidth - m.left}px`, top: `${top}px` };
}
return { left: `${m.left}px`, top: `${top}px` };
});
const menuTargetRunning = computed(() => {
const m = menu.value;
if (!m || m.type !== 'runner') {
return false;
}
if (m.kind === 'agent') {
const agent = subAgentStore.subAgents.find((a) => a.task_id === m.key);
const status = (agent?.status || '').toString().toLowerCase();
return !!agent && !AGENT_TERMINAL.has(status);
}
const cmd = bgStore.commands.find((c) => c.command_id === m.key);
const status = (cmd?.status || '').toString().toLowerCase();
return !!cmd && !CMD_TERMINAL.has(status);
});
/* ---------------- 菜单动作 ---------------- */
async function killRunner() {
const m = menu.value;
if (!m || m.type !== 'runner') {
return;
}
quickDock.closeMenu();
const result =
m.kind === 'agent'
? await subAgentStore.terminateSubAgent(m.key)
: await bgStore.cancelCommand(m.key);
if (!result?.success) {
uiStore.pushToast({ message: result?.error || '强制关闭失败', type: 'error' });
}
}
function basename(p: string): string {
return p.split('/').pop() || p;
}
function downloadFile() {
const m = menu.value;
if (!m || m.type !== 'file') {
return;
}
quickDock.closeMenu();
const a = document.createElement('a');
a.href = `/api/download/file?path=${encodeURIComponent(m.key)}`;
a.download = basename(m.key);
document.body.appendChild(a);
a.click();
a.remove();
}
/** 用系统默认应用打开(候选列表第一个即系统默认 handler */
async function revealInManager() {
const m = menu.value;
if (!m || m.type !== 'file') {
return;
}
const path = m.key;
quickDock.closeMenu();
try {
const resp = await fetch(`/api/project/file-open-apps?path=${encodeURIComponent(path)}`);
const payload = await resp.json().catch(() => ({}));
if (!resp.ok || !payload?.success) {
throw new Error(payload?.error || '检测可用应用失败');
}
const apps = Array.isArray(payload?.data?.apps) ? payload.data.apps : [];
if (!apps.length) {
throw new Error('未找到可用应用');
}
const openResp = await fetch('/api/project/open-file-with-app', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, app_id: apps[0].id })
});
const openPayload = await openResp.json().catch(() => ({}));
if (!openResp.ok || !openPayload?.success) {
throw new Error(openPayload?.error || '打开文件失败');
}
} catch (err: any) {
uiStore.pushToast({ message: err?.message || '打开文件失败', type: 'error' });
}
}
async function copyPath() {
const m = menu.value;
if (!m || m.type !== 'file') {
return;
}
const path = m.key;
quickDock.closeMenu();
const toast = (message: string, type = 'info') =>
uiStore.pushToast({ message, type, duration: 2000 });
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(path);
toast('已复制相对路径', 'success');
return;
} catch {
// fallback
}
}
const ta = document.createElement('textarea');
ta.value = path;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try {
document.execCommand('copy');
toast('已复制相对路径', 'success');
} catch {
toast('复制失败', 'error');
}
ta.remove();
}
/* ---------------- 全局事件Esc 分层关闭 / 点空白关菜单 / 栈滚动关菜单 ---------------- */
function onKeydown(e: KeyboardEvent) {
if (e.key !== 'Escape' || e.defaultPrevented) {
return;
}
if (menu.value) {
quickDock.closeMenu();
e.preventDefault();
return;
}
if (quickDock.detail) {
quickDock.closeDetail();
e.preventDefault();
return;
}
if (quickDock.previewPath) {
quickDock.closePreview();
e.preventDefault();
}
}
function onDocumentClick(e: MouseEvent) {
if (!menu.value) {
return;
}
const target = e.target as HTMLElement | null;
if (target?.closest('.qd-menu') || target?.closest('.qd-row-menu-btn')) {
return;
}
quickDock.closeMenu();
}
function handleStackScroll() {
// fixed
if (menu.value) {
quickDock.closeMenu();
}
}
/* ---------------- 轮询常驻5s与对话切换 ---------------- */
let pollTimer: ReturnType<typeof setInterval> | null = null;
function refreshAll() {
void subAgentStore.fetchSubAgents();
void bgStore.fetchCommands();
}
onMounted(() => {
refreshAll();
pollTimer = setInterval(refreshAll, 5000);
document.addEventListener('keydown', onKeydown);
document.addEventListener('click', onDocumentClick);
});
onBeforeUnmount(() => {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
document.removeEventListener('keydown', onKeydown);
document.removeEventListener('click', onDocumentClick);
quickDock.resetTransient();
});
watch(
() => conversationStore.currentConversationId,
() => {
// bootstrapedited_files fetchTodoList
// /new app watcher
quickDock.resetTransient();
refreshAll();
}
);
</script>
<style src="./quickdock.css"></style>

View File

@ -0,0 +1,507 @@
<template>
<section
v-if="renderVisible"
class="qd-detail"
:class="{ 'panel-enter': entering, 'panel-leave': leaving }"
>
<header class="qd-detail__header">
<span class="qd-detail__dot" :class="`is-${stateClass}`"></span>
<span class="qd-detail__title" :title="title">{{ title }}</span>
<span class="qd-detail__badge" :class="`is-${stateClass}`">{{ statusText }}</span>
<button class="qd-detail__close" title="关闭" @click="close">
<svg viewBox="0 0 16 16" fill="none">
<path
d="M4 4l8 8M12 4l-8 8"
stroke="currentColor"
stroke-width="1.4"
stroke-linecap="round"
/>
</svg>
</button>
</header>
<div ref="bodyRef" class="qd-detail__body" :class="{ 'body-fade': bodyFading }">
<!-- 子智能体工具调用 + 文本输出时间线 -->
<template v-if="effectiveDetail?.kind === 'agent'">
<div v-if="!timelineItems.length" class="qd-detail__empty">
{{ activityLoading ? '加载中…' : '暂无进度' }}
</div>
<div
v-for="item in timelineItems"
:key="item.key"
class="qd-feed-row"
:class="[item.kind === 'tool' ? 'feed-tool' : 'feed-text', { 'is-new': animatedKeys.has(item.key) }]"
>
<template v-if="item.kind === 'tool'">
<span v-if="item.state === 'running'" class="qd-tool-spinner"></span>
<span v-else class="qd-tool-done">{{ item.state === 'failed' ? '✕' : '✓' }}</span>
<span class="tool-name">{{ item.toolName }}</span>
<span class="tool-param" :title="item.text">{{ item.text }}</span>
<span class="tool-result" :class="{ 'is-error': item.state === 'failed' }">
{{ item.stateLabel }}
</span>
</template>
<template v-else>{{ item.content }}</template>
</div>
</template>
<!-- 后台指令终端输出行 -->
<template v-else>
<div v-if="!outputLines.length" class="qd-detail__empty">
{{ detailLoading ? '加载中…' : '暂无输出' }}
</div>
<div
v-for="(line, i) in outputLines"
:key="i"
class="qd-feed-row feed-term"
:class="{ 'is-command': i === 0, 'is-new': animatedKeys.has(`term-${i}`) }"
>
{{ line }}
</div>
</template>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { useQuickDockStore } from '@/stores/quickDock';
import { useSubAgentStore } from '@/stores/subAgent';
import { useBackgroundCommandStore } from '@/stores/backgroundCommand';
/**
* 详情面板fixed 浮在快捷窗口列左侧
* - 子智能体activity entries 时间线工具行 + 文本行
* - 后台指令终端输出行首行 $ 命令加粗
* - 新行自动播 feed-in 动画距底 <80px 时新内容平滑滚到底用户上翻不打断
*/
const AGENT_TERMINAL = new Set(['completed', 'failed', 'timeout', 'terminated']);
const CMD_TERMINAL = new Set(['completed', 'failed', 'timeout', 'cancelled']);
const quickDock = useQuickDockStore();
const subAgentStore = useSubAgentStore();
const bgStore = useBackgroundCommandStore();
const { detail } = storeToRefs(quickDock);
const { activityEntries, activityLoading } = storeToRefs(subAgentStore);
const { activeDetail, detailLoading } = storeToRefs(bgStore);
/** null detail detail null 190ms
* 期间所有状态显示改用此快照避免角标/标题闪变为已结束/ */
const lastDetail = ref<{ kind: 'agent' | 'cmd'; id: string } | null>(null);
watch(
detail,
(val) => {
if (val) {
lastDetail.value = { kind: val.kind, id: val.id };
}
},
{ immediate: true }
);
const effectiveDetail = computed(() => detail.value || lastDetail.value);
/** / store
* 离开动画期间回退到此快照避免空状态被误判为运行中 */
const lastStatus = ref('');
/** 实时状态(面板打开期间随 store 刷新) */
const liveStatus = computed(() => {
const target = effectiveDetail.value;
if (!target) {
return '';
}
if (target.kind === 'agent') {
const agent = subAgentStore.subAgents.find((a) => a.task_id === target.id);
const active =
subAgentStore.activeAgent?.task_id === target.id ? subAgentStore.activeAgent : null;
return String(active?.status || agent?.status || '');
}
const cmd = bgStore.commands.find((c) => c.command_id === target.id);
const active = bgStore.activeCommand?.command_id === target.id ? bgStore.activeCommand : null;
return String(active?.status || cmd?.status || '');
});
watch(
liveStatus,
(val) => {
if (val) {
lastStatus.value = val;
}
},
{ immediate: true }
);
const renderVisible = ref(false);
const entering = ref(false);
const leaving = ref(false);
const bodyFading = ref(false);
const bodyRef = ref<HTMLElement | null>(null);
/** 当前条目状态与标题(关闭离开动画期间沿用最后快照) */
const currentStatus = computed(() => {
return liveStatus.value || lastStatus.value || '';
});
const isRunning = computed(() => {
const status = currentStatus.value.toLowerCase();
if (!effectiveDetail.value || !status) {
//
return false;
}
// idle
if (status === 'idle') {
return false;
}
return !(effectiveDetail.value.kind === 'agent' ? AGENT_TERMINAL : CMD_TERMINAL).has(status);
});
/** 状态分类running=运行中 / idle=空闲 / done=完成 / ended=失败·超时·终止 */
const stateClass = computed(() => {
const status = currentStatus.value.toLowerCase();
if (status === 'idle') {
return 'idle';
}
if (isRunning.value) {
return 'running';
}
if (status === 'completed') {
return 'done';
}
return 'ended';
});
const title = computed(() => {
if (!effectiveDetail.value) {
return '';
}
if (effectiveDetail.value.kind === 'agent') {
const agent = subAgentStore.subAgents.find((a) => a.task_id === effectiveDetail.value?.id);
return (
agent?.display_name || agent?.summary || subAgentStore.activeAgent?.display_name || '子智能体'
);
}
const cmd = bgStore.commands.find((c) => c.command_id === effectiveDetail.value?.id);
return cmd?.command || bgStore.activeCommand?.command || '后台指令';
});
const statusText = computed(() => {
const status = currentStatus.value.toLowerCase();
if (status === 'idle') {
return '空闲';
}
if (isRunning.value) {
return '运行中';
}
if (status === 'completed') {
return '已完成';
}
if (status === 'failed') {
return '已失败';
}
if (status === 'timeout') {
return '已超时';
}
if (status === 'terminated' || status === 'cancelled') {
return '已终止';
}
return '已结束';
});
/* ---------------- 子智能体时间线(逻辑借鉴 SubAgentActivityDialog ---------------- */
function normalizeStatus(status?: string) {
if (status === 'running' || status === 'in_progress') return 'running';
if (status === 'completed' || status === 'done' || status === 'success') return 'completed';
if (status === 'failed' || status === 'error') return 'failed';
return status || 'running';
}
function isEntryTerminal(status?: string) {
const normalized = (status || '').toString().toLowerCase();
return [
'completed',
'failed',
'timeout',
'terminated',
'cancelled',
'done',
'success',
'error'
].includes(normalized);
}
function buildText(entry: any): string {
const tool = entry.tool || '';
const args = entry.args || {};
if (tool === 'read_file') return `阅读 ${args.path || args.file_path || ''}`;
if (tool === 'write_file') return `写入文件 ${args.file_path || args.path || ''}`;
if (tool === 'read_skill') return `阅读技能 ${args.skill_name || ''}`;
if (tool === 'web_search') return `搜索 ${args.query || args.q || ''}`;
if (tool === 'extract_webpage') return `提取 ${args.url || ''}`;
if (tool === 'run_command') return `运行命令 ${args.command || ''}`;
if (tool === 'edit_file') return `编辑 ${args.path || args.file_path || ''}`;
if (tool === 'read_mediafile') return `读取媒体文件 ${args.path || args.file_path || ''}`;
return tool || '工具';
}
interface ToolTimelineItem {
kind: 'tool';
key: string;
state: string;
stateLabel: string;
toolName: string;
text: string;
}
interface OutputTimelineItem {
kind: 'output';
key: string;
content: string;
}
const timelineItems = computed<(ToolTimelineItem | OutputTimelineItem)[]>(() => {
const entries = activityEntries.value || [];
const rawItems: ({ kind: 'tool'; key: string; entry: any } | OutputTimelineItem)[] = [];
let currentToolGroup: { kind: 'tool'; key: string; entry: any } | null = null;
const flushToolGroup = () => {
if (currentToolGroup) {
rawItems.push(currentToolGroup);
currentToolGroup = null;
}
};
entries.forEach((entry: any, index: number) => {
if (
entry?.type === 'progress' &&
entry?.subtype === 'output' &&
typeof entry.content === 'string'
) {
flushToolGroup();
rawItems.push({
kind: 'output',
key: `output-${entry.ts || index}`,
content: entry.content
});
return;
}
if (!entry || entry.type !== 'progress' || !entry.tool) {
return;
}
const baseKey = entry.id || `${entry.tool}-${entry.ts || index}`;
if (
currentToolGroup &&
(currentToolGroup.entry.id === entry.id || currentToolGroup.key === baseKey) &&
!isEntryTerminal(currentToolGroup.entry.status)
) {
currentToolGroup.entry = { ...currentToolGroup.entry, ...entry };
return;
}
flushToolGroup();
let key = baseKey;
let suffix = 0;
while (rawItems.some((item) => item.kind === 'tool' && item.key === key)) {
suffix += 1;
key = `${baseKey}--${suffix}`;
}
currentToolGroup = { kind: 'tool', key, entry: { ...entry } };
});
flushToolGroup();
return rawItems.map((item) => {
if (item.kind === 'output') {
return item;
}
const state = normalizeStatus(item.entry.status);
return {
kind: 'tool' as const,
key: item.key,
state,
stateLabel: state === 'completed' ? '完成' : state === 'failed' ? '失败' : '进行中',
toolName: item.entry.tool || '工具',
text: buildText(item.entry)
};
});
});
/* ---------------- 后台指令输出 ---------------- */
const outputLines = computed<string[]>(() => {
if (effectiveDetail.value?.kind !== 'cmd') {
return [];
}
const output = activeDetail.value?.output;
if (typeof output !== 'string') {
return [];
}
return output.split('\n');
});
/* ---------------- feed 行进入动画:仅面板打开期间新出现的行播放 ---------------- */
/** 打开/切换详情后首次填充的行直接静态显示;之后新出现的行才带 is-new 播动画 */
const animatedKeys = ref<Set<string>>(new Set());
let seenFeedKeys = new Set<string>();
let feedHydrated = false;
function resetFeedAnimState() {
seenFeedKeys = new Set();
feedHydrated = false;
animatedKeys.value = new Set();
}
watch(
[timelineItems, outputLines],
() => {
const keys =
effectiveDetail.value?.kind === 'agent'
? timelineItems.value.map((t) => t.key)
: outputLines.value.map((_, i) => `term-${i}`);
if (!feedHydrated) {
// entries
// watch
// + <80
if (!keys.length) {
return;
}
//
//
feedHydrated = true;
keys.forEach((k) => seenFeedKeys.add(k));
nextTick(() => scrollToBottom(false));
return;
}
const newKeys = keys.filter((k) => !seenFeedKeys.has(k));
if (!newKeys.length) {
return;
}
newKeys.forEach((k) => seenFeedKeys.add(k));
animatedKeys.value = new Set(newKeys);
// DOM
setTimeout(() => {
animatedKeys.value = new Set();
}, 400);
},
{ flush: 'post' }
);
/* ---------------- 数据联动:打开/切换/关闭详情 ---------------- */
watch(
detail,
(target, prev) => {
if (!target) {
//
//
const cleanup = () => {
if (prev?.kind === 'agent') {
subAgentStore.closeSubAgent();
}
if (prev?.kind === 'cmd') {
bgStore.closeCommand();
}
};
if (renderVisible.value) {
entering.value = false;
leaving.value = true;
// setTimeout animationendfeed animationend
setTimeout(() => {
leaving.value = false;
renderVisible.value = false;
cleanup();
}, 190);
} else {
cleanup();
}
return;
}
//
if (prev?.kind === 'agent') {
subAgentStore.closeSubAgent();
}
if (prev?.kind === 'cmd') {
bgStore.closeCommand();
}
// feed
resetFeedAnimState();
// store
// silent activeAgent/activeCommand
//SubAgentActivityDialog / BackgroundCommandDialog activeXxx
if (target.kind === 'agent') {
const agent = subAgentStore.subAgents.find((a) => a.task_id === target.id);
if (agent) {
subAgentStore.openSubAgent(agent, { silent: true });
}
} else {
const cmd = bgStore.commands.find((c) => c.command_id === target.id);
if (cmd) {
bgStore.openCommand(cmd, { silent: true });
}
}
if (!renderVisible.value) {
renderVisible.value = true;
entering.value = true;
setTimeout(() => {
entering.value = false;
}, 260);
nextTick(() => {
scrollToBottom(false);
});
} else {
//
bodyFading.value = false;
nextTick(() => {
bodyFading.value = true;
scrollToBottom(false);
setTimeout(() => {
bodyFading.value = false;
}, 200);
});
}
},
{ immediate: true }
);
/* ---------------- 自动滚动:距底 <80px 时新内容滚到底 ---------------- */
function scrollToBottom(smooth: boolean) {
const el = bodyRef.value;
if (!el) {
return;
}
el.scrollTo({ top: el.scrollHeight, behavior: smooth ? 'smooth' : 'auto' });
}
function maybeAutoScroll() {
const el = bodyRef.value;
if (!el) {
return;
}
// feed watch
//
if (!feedHydrated) {
return;
}
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
if (nearBottom) {
nextTick(() => scrollToBottom(true));
}
}
watch(
() => [timelineItems.value.length, outputLines.value.length],
() => {
maybeAutoScroll();
}
);
function close() {
quickDock.closeDetail();
}
</script>

View File

@ -0,0 +1,295 @@
<template>
<section v-if="rows.length" class="qd-window" :class="{ 'qd-window-enter': windowEntering }">
<header class="qd-window__header">
<svg v-if="kind === 'agent'" class="qd-window__icon" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="5.5" r="2.5" stroke="currentColor" stroke-width="1.4" />
<path
d="M3 13c.7-2.6 2.7-4 5-4s4.3 1.4 5 4"
stroke="currentColor"
stroke-width="1.4"
stroke-linecap="round"
/>
</svg>
<svg v-else class="qd-window__icon" viewBox="0 0 16 16" fill="none">
<path
d="M2.5 4.5 6 8l-3.5 3.5"
stroke="currentColor"
stroke-width="1.4"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path d="M8 12h5.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" />
</svg>
<span class="qd-window__title">{{ kind === 'agent' ? '子智能体' : '后台指令' }}</span>
<span class="qd-window__counter">{{ runningCount }}/{{ rows.length }}</span>
</header>
<ul ref="listRef" class="qd-list">
<li
v-for="row in rows"
:key="row.id"
class="qd-run-item"
:data-kind="kind"
:class="[
`is-${row.state}`,
{
'is-active': isActive(row),
'qd-row-enter': row.entering
}
]"
:style="row.pendingEnter ? { opacity: '0' } : undefined"
@click="openRow(row)"
>
<span class="qd-run-status"></span>
<span class="qd-run-name" :title="row.name">{{ row.name }}</span>
<button class="qd-row-menu-btn" title="更多" @click.stop="openMenu($event, row)">
<svg viewBox="0 0 16 16">
<circle cx="3.5" cy="8" r="1.3" fill="currentColor" />
<circle cx="8" cy="8" r="1.3" fill="currentColor" />
<circle cx="12.5" cy="8" r="1.3" fill="currentColor" />
</svg>
</button>
</li>
</ul>
</section>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { useSubAgentStore } from '@/stores/subAgent';
import { useBackgroundCommandStore } from '@/stores/backgroundCommand';
import { useConversationStore } from '@/stores/conversation';
import { useQuickDockStore } from '@/stores/quickDock';
/**
* 子智能体 / 后台指令窗口两者同构
* - 行结构[状态点] 名称 []运行中状态点呼吸终态变灰
* - 新条目先隐身插入若在可视区域外先平滑滚动露出再播进入动画
* - 点击行 左侧详情面板 菜单强制关闭
*/
/** 行状态running=运行中(蓝) / idle=空闲 / done=完成绿 / ended=失败·超时·终止
* 配色对齐旧工作区面板的 .sub-agent-status 文字色 */
type RowState = 'running' | 'idle' | 'done' | 'ended';
function agentStateOf(status: string): RowState {
if (status === 'idle') return 'idle';
if (status === 'completed') return 'done';
if (status === 'failed' || status === 'timeout' || status === 'terminated' || status === 'cancelled') {
return 'ended';
}
return 'running';
}
function cmdStateOf(status: string): RowState {
if (status === 'completed') return 'done';
if (status === 'failed' || status === 'timeout' || status === 'cancelled') {
return 'ended';
}
return 'running';
}
interface Row {
id: string;
name: string;
state: RowState;
entering: boolean;
pendingEnter: boolean;
}
const props = defineProps<{ kind: 'agent' | 'cmd' }>();
const subAgentStore = useSubAgentStore();
const bgStore = useBackgroundCommandStore();
const quickDock = useQuickDockStore();
const convStore = useConversationStore();
const { detail } = storeToRefs(quickDock);
const rows = ref<Row[]>([]);
const windowEntering = ref(false);
const listRef = ref<HTMLElement | null>(null);
let windowEnterShown = false;
/** /
* 之后运行期间新出现的条目才播初始 true 保证首帧加载即静态 */
const staticNext = ref(true);
watch(
() => convStore.currentConversationId,
() => {
staticNext.value = true;
}
);
const runningCount = computed(() => rows.value.filter((r) => r.state === 'running').length);
interface SourceItem {
id: string;
name: string;
state: RowState;
}
const sourceItems = computed<SourceItem[]>(() => {
if (props.kind === 'agent') {
return subAgentStore.subAgents
.map((a) => {
const id = (a.task_id || '').toString();
if (!id) {
return null;
}
const status = (a.status || '').toString().toLowerCase();
return {
id,
name: a.display_name || a.summary || `子智能体 ${a.agent_id ?? id}`,
state: agentStateOf(status)
};
})
.filter((x): x is SourceItem => x !== null);
}
return bgStore.commands
.map((c) => {
const id = (c.command_id || '').toString();
if (!id) {
return null;
}
const status = (c.status || '').toString().toLowerCase();
return {
id,
name: c.command || id,
state: cmdStateOf(status)
};
})
.filter((x): x is SourceItem => x !== null);
});
function isActive(row: Row) {
return detail.value?.kind === props.kind && detail.value?.id === row.id;
}
function openRow(row: Row) {
quickDock.closeMenu();
quickDock.openDetail(props.kind, row.id);
}
function openMenu(e: MouseEvent, row: Row) {
const btn = e.currentTarget as HTMLElement;
const rect = btn.getBoundingClientRect();
quickDock.openMenu({
type: 'runner',
kind: props.kind,
key: row.id,
left: rect.right, // alignRight
top: rect.bottom + 6,
alignRight: true
});
}
/** 若目标行未完全显示,先平滑滚动到可见位置 */
function scrollElIntoView(el: HTMLElement): Promise<void> {
return new Promise((resolve) => {
const listEl = listRef.value;
if (!listEl) {
resolve();
return;
}
const listRect = listEl.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
const fullyVisible = elRect.top >= listRect.top && elRect.bottom <= listRect.bottom;
if (fullyVisible) {
resolve();
return;
}
let done = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const finish = () => {
if (done) {
return;
}
done = true;
if (timer) {
clearTimeout(timer);
}
listEl.removeEventListener('scroll', onScroll);
resolve();
};
const onScroll = () => {
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(finish, 90);
};
listEl.addEventListener('scroll', onScroll);
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
timer = setTimeout(finish, 800);
});
}
async function playEnter(row: Row) {
await nextTick();
const listEl = listRef.value;
const index = rows.value.indexOf(row);
const el = listEl?.children[index] as HTMLElement | undefined;
if (!el) {
row.pendingEnter = false;
row.entering = true;
setTimeout(() => {
row.entering = false;
}, 380);
return;
}
await scrollElIntoView(el);
row.pendingEnter = false;
row.entering = true;
setTimeout(() => {
row.entering = false;
}, 380);
}
watch(
sourceItems,
(list) => {
//
//
const isStatic = staticNext.value;
staticNext.value = false;
const byId = new Map(list.map((item) => [item.id, item]));
//
rows.value = rows.value.filter((row) => byId.has(row.id));
rows.value.forEach((row) => {
const item = byId.get(row.id);
if (item) {
row.name = item.name;
row.state = item.state;
}
});
//
const existingIds = new Set(rows.value.map((row) => row.id));
const added = list.filter((item) => !existingIds.has(item.id));
if (added.length) {
if (!isStatic && !windowEnterShown && !rows.value.length) {
windowEnterShown = true;
windowEntering.value = true;
setTimeout(() => {
windowEntering.value = false;
}, 320);
}
added.forEach((item) => {
const row: Row = {
id: item.id,
name: item.name,
state: item.state,
entering: false,
pendingEnter: !isStatic
};
rows.value.push(row);
if (!isStatic) {
void playEnter(row);
}
});
}
},
{ immediate: true }
);
</script>

View File

@ -0,0 +1,309 @@
<template>
<section v-if="visible" class="qd-window" :class="{ 'qd-window-enter': windowEntering }">
<header class="qd-window__header">
<svg class="qd-window__icon" viewBox="0 0 16 16" fill="none">
<path
d="M2 4.5 3.5 6 6 3.5"
stroke="currentColor"
stroke-width="1.4"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path d="M8.5 5H14" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" />
<path
d="M2 11 3.5 12.5 6 10"
stroke="currentColor"
stroke-width="1.4"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path d="M8.5 11.5H14" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" />
</svg>
<span class="qd-window__title">待办事项</span>
<span class="qd-window__counter">{{ doneCount }}/{{ rows.length }}</span>
</header>
<ul ref="listRef" class="qd-list">
<li
v-for="row in rows"
:key="row.key"
class="qd-todo-item"
:class="{
'is-done': row.done,
'just-done': row.justDone,
'qd-row-enter': row.entering,
'qd-row-leave': row.leaving
}"
:style="rowStyle(row)"
>
<span class="qd-todo-dot"></span>
<span class="qd-todo-text">{{ row.title }}</span>
</li>
</ul>
</section>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { useFileStore } from '@/stores/file';
/**
* 待办事项窗口
* 三组动画参数与 demo 定稿一致
* 1. 创建/整表替换旧行逐行向左移出stagger 45ms + 高度收拢 新行逐行从左向右进入stagger 60ms
* 2. 完成未完全显示的行先平滑滚动露出 横线从左往右划过 + 文字变灰 + 状态点弹跳
* 3. 清空移出后隐藏窗口
*/
const ENTER_STAGGER = 60;
const LEAVE_STAGGER = 45;
const LEAVE_DURATION = 260;
interface TodoTask {
index: number;
title: string;
status: string;
}
interface Row {
key: string;
title: string;
done: boolean;
entering: boolean;
leaving: boolean;
justDone: boolean;
enterDelay: number;
leaveDelay: number;
}
const fileStore = useFileStore();
const todoList = computed(() => fileStore.todoList);
const rows = ref<Row[]>([]);
const visible = ref(false);
const windowEntering = ref(false);
const busy = ref(false);
const listRef = ref<HTMLElement | null>(null);
/** 代际标记:动画流程中途来了新数据时,旧流程不再写状态 */
let gen = 0;
const doneCount = computed(() => rows.value.filter((r) => r.done).length);
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
function tasksOf(val: any): TodoTask[] {
return Array.isArray(val?.tasks) ? val.tasks : [];
}
function signature(tasks: TodoTask[]): string {
return tasks.map((t) => `${t.index}:${t.title}`).join('|');
}
function isDoneStatus(status: any): boolean {
return status === 'done' || status === 'completed';
}
function rowStyle(row: Row) {
if (row.entering && row.enterDelay) {
return { animationDelay: `${row.enterDelay}ms` };
}
if (row.leaving && row.leaveDelay) {
return { animationDelay: `${row.leaveDelay}ms` };
}
return undefined;
}
async function playLeave() {
const current = rows.value;
if (!current.length) {
return;
}
current.forEach((row, i) => {
row.leaveDelay = i * LEAVE_STAGGER;
row.leaving = true;
});
await wait(current.length * LEAVE_STAGGER + LEAVE_DURATION + 20);
}
function renderEntering(tasks: TodoTask[]) {
rows.value = tasks.map((t, i) => ({
key: `${t.index}:${t.title}`,
title: t.title,
done: isDoneStatus(t.status),
entering: true,
leaving: false,
justDone: false,
enterDelay: i * ENTER_STAGGER,
leaveDelay: 0
}));
// setTimeout animationend
const total = tasks.length * ENTER_STAGGER + 340 + 30;
const myGen = gen;
setTimeout(() => {
if (myGen !== gen) {
return;
}
rows.value.forEach((row) => {
row.entering = false;
row.enterDelay = 0;
});
}, total);
}
/** 若目标行未完全显示,先平滑滚动到可见位置 */
function scrollRowIntoView(index: number): Promise<void> {
return new Promise((resolve) => {
nextTick(() => {
const listEl = listRef.value;
const li = listEl?.children[index] as HTMLElement | undefined;
if (!listEl || !li) {
resolve();
return;
}
const listRect = listEl.getBoundingClientRect();
const liRect = li.getBoundingClientRect();
const fullyVisible = liRect.top >= listRect.top && liRect.bottom <= listRect.bottom;
if (fullyVisible) {
resolve();
return;
}
let done = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const finish = () => {
if (done) {
return;
}
done = true;
if (timer) {
clearTimeout(timer);
}
listEl.removeEventListener('scroll', onScroll);
resolve();
};
const onScroll = () => {
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(finish, 90); // 90ms
};
listEl.addEventListener('scroll', onScroll);
li.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
timer = setTimeout(finish, 800); //
});
});
}
function renderStatic(tasks: TodoTask[]) {
rows.value = tasks.map((t) => ({
key: `${t.index}:${t.title}`,
title: t.title,
done: isDoneStatus(t.status),
entering: false,
leaving: false,
justDone: false,
enterDelay: 0,
leaveDelay: 0
}));
}
watch(
todoList,
async (newVal, oldVal) => {
const myGen = ++gen;
const newTasks = tasksOf(newVal);
const oldTasks = tasksOf(oldVal);
// live=true false //fetch
const live = fileStore.todoListLive;
//
if (!newTasks.length) {
if (rows.value.length && live) {
busy.value = true;
await playLeave();
if (myGen !== gen) {
return;
}
busy.value = false;
}
rows.value = [];
visible.value = false;
return;
}
// +
if (!rows.value.length) {
visible.value = true;
if (live) {
windowEntering.value = true;
setTimeout(() => {
windowEntering.value = false;
}, 320);
renderEntering(newTasks);
} else {
renderStatic(newTasks);
}
return;
}
// title = =
if (signature(oldTasks) === signature(newTasks)) {
// 线
if (!live) {
newTasks.forEach((t, i) => {
const row = rows.value[i];
if (row) {
row.done = isDoneStatus(t.status);
}
});
return;
}
for (let i = 0; i < newTasks.length; i += 1) {
const row = rows.value[i];
if (!row) {
continue;
}
const done = isDoneStatus(newTasks[i].status);
if (row.done === done) {
continue;
}
//
while (busy.value) {
await wait(50);
if (myGen !== gen) {
return;
}
}
if (done) {
await scrollRowIntoView(i); //
if (myGen !== gen) {
return;
}
}
row.done = done;
if (done) {
row.justDone = true;
const target = row;
setTimeout(() => {
target.justDone = false;
}, 400);
}
}
return;
}
// +
if (!live) {
renderStatic(newTasks);
return;
}
busy.value = true;
await playLeave();
if (myGen !== gen) {
return;
}
renderEntering(newTasks);
busy.value = false;
},
{ flush: 'sync' }
);
</script>

View File

@ -0,0 +1,924 @@
/* ============================================================
快捷窗口Quick Dock共享样式
配色全部走三主题语义 token动画参数迁移自 demo/todo_float_window
============================================================ */
.quick-dock,
.qd-detail,
.qd-preview,
.qd-menu {
--qd-row-h: 36px;
--qd-width: 270px;
--qd-ease-out: cubic-bezier(0.22, 0.9, 0.28, 1);
--qd-ease-strike: cubic-bezier(0.55, 0.06, 0.35, 1);
}
/* ---------------- 占位列容器 ---------------- */
.quick-dock {
flex: none;
width: 294px; /* 270 + 左右 12 padding */
padding: 20px 12px 20px 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
transition:
width 0.3s ease,
padding 0.3s ease,
opacity 0.25s ease,
transform 0.3s var(--qd-ease-out);
}
/* 四个窗口都无内容时收起到 0 宽,不挤压对话区 */
.quick-dock--empty {
width: 0;
padding: 0;
opacity: 0;
transform: translateX(24px);
}
.quick-dock__scroll {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 12px;
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
/* padding + 负 margin扩大滚动盒以容纳窗口阴影避免被 overflow 裁剪 */
padding: 24px;
margin: -24px;
}
.quick-dock__scroll::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
/* ---------------- 窗口通用 ---------------- */
.qd-window {
flex: none; /* 禁止被列 flex 压缩变矮 */
width: var(--qd-width);
background: var(--surface-raised);
border: 1px solid var(--border-default);
border-radius: 12px;
box-shadow: var(--shadow-card);
overflow: hidden;
}
.qd-window.qd-window-enter {
animation: qd-window-in 0.3s ease-out;
}
@keyframes qd-window-in {
from {
opacity: 0;
transform: translateY(-6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.qd-window__header {
height: 38px;
display: flex;
align-items: center;
gap: 7px;
padding: 0 12px;
border-bottom: 1px solid var(--border-default);
}
.qd-window__icon {
width: 15px;
height: 15px;
color: var(--accent);
flex: none;
}
.qd-window__title {
font-size: 12.5px;
font-weight: 600;
color: var(--text-secondary);
letter-spacing: 0.02em;
}
.qd-window__counter {
margin-left: auto;
font-size: 11.5px;
color: var(--text-tertiary);
font-variant-numeric: tabular-nums;
}
/* 列表:固定 5 行,无上下 padding首行紧贴标题分隔线滚动条隐藏 */
.qd-list {
list-style: none;
margin: 0;
padding: 0;
height: calc(var(--qd-row-h) * 5);
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
.qd-list::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
/* ---------------- 行进入 / 移出动画(待办 / 子智能体 / 后台指令 / 文件共用) ---------------- */
.qd-row-enter {
opacity: 0;
animation: qd-row-in 0.34s var(--qd-ease-out) forwards;
}
@keyframes qd-row-in {
from {
opacity: 0;
transform: translateX(-14px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.qd-row-leave {
animation: qd-row-out 0.26s ease-in forwards;
}
@keyframes qd-row-out {
0% {
opacity: 1;
transform: translateX(0);
height: var(--qd-row-h);
}
55% {
opacity: 0;
transform: translateX(-14px);
height: var(--qd-row-h);
}
100% {
opacity: 0;
transform: translateX(-14px);
height: 0;
}
}
/* ---------------- 待办行 ---------------- */
.qd-todo-item {
height: var(--qd-row-h);
display: flex;
align-items: center;
gap: 9px;
padding: 0 12px;
overflow: hidden; /* 配合移出动画的高度收拢 */
}
.qd-todo-dot {
flex: none;
width: 6px;
height: 6px;
border-radius: 50%;
border: 1.5px solid var(--text-tertiary);
background: transparent;
transition:
background 0.2s ease,
border-color 0.2s ease;
}
.qd-todo-item.is-done .qd-todo-dot {
background: var(--accent);
border-color: var(--accent);
}
.qd-todo-text {
position: relative;
flex: 1;
min-width: 0;
font-size: 13px;
line-height: 1;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: color 0.22s ease 0.12s; /* 变灰略滞后于划线 */
}
.qd-todo-text::after {
content: '';
position: absolute;
left: -1px;
right: -1px;
top: 50%;
height: 1.5px;
margin-top: -0.75px;
background: var(--text-tertiary);
border-radius: 1px;
transform: scaleX(0);
transform-origin: left center;
pointer-events: none;
}
/* 完成静态态(整表渲染时已完成行直接呈现,不播划线) */
.qd-todo-item.is-done .qd-todo-text {
color: var(--text-tertiary);
}
.qd-todo-item.is-done .qd-todo-text::after {
transform: scaleX(1);
}
/* 完成动作瞬间:横线从左往右划过 + 状态点弹跳 */
.qd-todo-item.just-done .qd-todo-text::after {
animation: qd-strike-in 0.28s var(--qd-ease-strike) forwards;
}
@keyframes qd-strike-in {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
.qd-todo-item.just-done .qd-todo-dot {
animation: qd-dot-pop 0.32s var(--qd-ease-out);
}
@keyframes qd-dot-pop {
0% {
transform: scale(1);
}
45% {
transform: scale(1.6);
}
100% {
transform: scale(1);
}
}
/* ---------------- 子智能体 / 后台指令行 ---------------- */
.qd-run-item {
height: var(--qd-row-h);
display: flex;
align-items: center;
gap: 7px;
padding: 0 8px 0 12px;
overflow: hidden;
cursor: pointer;
transition: background 0.12s ease;
}
.qd-run-item:hover {
background: var(--hover-bg);
}
.qd-run-item.is-active {
background: var(--surface-soft);
}
.qd-run-status {
flex: none;
width: 6px;
height: 6px;
border-radius: 50%;
background: transparent;
}
/* 状态配色对齐旧工作区面板 .sub-agent-status
运行中=info / 空闲=次要文字白 / 完成=success 绿 / 失败·超时·终止=danger */
.qd-run-item.is-running .qd-run-status {
background: var(--state-info);
animation: qd-status-pulse 1.6s ease-in-out infinite;
}
.qd-run-item.is-idle .qd-run-status {
background: var(--text-secondary);
}
.qd-run-item.is-done .qd-run-status {
background: var(--state-success);
}
.qd-run-item.is-ended .qd-run-status {
background: var(--state-danger);
}
@keyframes qd-status-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
.qd-run-name {
flex: 1;
min-width: 0;
font-size: 13px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.qd-run-item[data-kind='cmd'] .qd-run-name {
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
font-size: 12px;
}
.qd-run-item.is-done .qd-run-name,
.qd-run-item.is-ended .qd-run-name {
color: var(--text-tertiary);
}
/* ⋯ 按钮(行内) */
.qd-row-menu-btn {
flex: none;
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
background: none;
border: none;
border-radius: 5px;
color: var(--text-tertiary);
cursor: pointer;
transition:
background 0.12s ease,
color 0.12s ease;
}
.qd-row-menu-btn svg {
width: 14px;
height: 14px;
}
.qd-row-menu-btn:hover {
background: var(--tab-active);
color: var(--text-secondary);
}
/* ---------------- 文件行 ---------------- */
.qd-file-item {
height: var(--qd-row-h);
display: flex;
align-items: center;
gap: 7px;
padding: 0 10px 0 6px;
overflow: hidden;
cursor: pointer;
transition: background 0.12s ease;
}
.qd-file-item:hover {
background: var(--hover-bg);
}
.qd-file-item.is-active {
background: var(--surface-soft);
}
.qd-file-name {
flex: 1;
min-width: 0;
font-size: 13px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ---------------- 详情面板fixed 浮在列左侧) ---------------- */
.qd-detail {
position: fixed;
top: 20px;
right: calc(294px + 12px); /* 快捷窗口列宽 + gap */
width: 480px;
max-width: calc(100vw - 360px);
height: 460px;
max-height: calc(100vh - 40px);
background: var(--surface-raised);
border: 1px solid var(--border-default);
border-radius: 12px;
box-shadow: var(--shadow-card);
display: flex;
flex-direction: column;
overflow: hidden;
z-index: 90;
}
.qd-detail.panel-enter {
animation: qd-panel-in 0.24s var(--qd-ease-out);
}
@keyframes qd-panel-in {
from {
opacity: 0;
transform: translateX(12px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.qd-detail.panel-leave {
animation: qd-panel-out 0.18s ease-in forwards;
}
@keyframes qd-panel-out {
to {
opacity: 0;
transform: translateX(8px);
}
}
.qd-detail__header {
flex: none;
height: 42px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
border-bottom: 1px solid var(--border-default);
}
.qd-detail__dot {
flex: none;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--text-tertiary);
}
.qd-detail__dot.is-running {
background: var(--state-info);
animation: qd-status-pulse 1.6s ease-in-out infinite;
}
.qd-detail__dot.is-idle {
background: var(--text-secondary);
}
.qd-detail__dot.is-done {
background: var(--state-success);
}
.qd-detail__dot.is-ended {
background: var(--state-danger);
}
.qd-detail__title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.qd-detail__badge {
flex: none;
font-size: 11px;
line-height: 1;
padding: 4px 8px;
border-radius: 99px;
background: var(--surface-soft);
color: var(--text-tertiary);
}
.qd-detail__badge.is-running {
background: color-mix(in srgb, var(--state-info) 14%, transparent);
color: var(--state-info);
}
.qd-detail__badge.is-idle {
background: var(--surface-soft);
color: var(--text-secondary);
}
.qd-detail__badge.is-done {
background: color-mix(in srgb, var(--state-success) 14%, transparent);
color: var(--state-success);
}
.qd-detail__badge.is-ended {
background: color-mix(in srgb, var(--state-danger) 14%, transparent);
color: var(--state-danger);
}
.qd-detail__close {
flex: none;
margin-left: auto;
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
background: none;
border: none;
border-radius: 6px;
color: var(--text-tertiary);
cursor: pointer;
transition:
background 0.12s ease,
color 0.12s ease;
}
.qd-detail__close svg {
width: 13px;
height: 13px;
}
.qd-detail__close:hover {
background: var(--tab-active);
color: var(--text-secondary);
}
.qd-detail__body {
flex: 1;
overflow-y: auto;
padding: 6px 0 10px;
scrollbar-width: none;
-ms-overflow-style: none;
}
.qd-detail__body::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
.qd-detail__body.body-fade {
animation: qd-body-fade-in 0.18s ease-out;
}
@keyframes qd-body-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.qd-detail__empty {
padding: 18px 14px;
font-size: 12.5px;
color: var(--text-tertiary);
}
/* ---------------- feed 行(扁平行式,无嵌套卡片) ---------------- */
.qd-feed-row {
display: flex;
align-items: center;
gap: 8px;
padding: 0 14px;
min-height: 28px;
font-size: 12.5px;
}
/* 面板打开期间新出现的行播进入动画打开时已有的行由组件静态渲染
避免每次打开详情全部行重播一遍出现动画 */
.qd-feed-row.is-new {
animation: qd-feed-in 0.25s var(--qd-ease-out);
}
@keyframes qd-feed-in {
from {
opacity: 0;
transform: translateY(5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.qd-feed-row.feed-text {
align-items: flex-start;
padding-top: 5px;
padding-bottom: 5px;
color: var(--text-secondary);
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
}
.qd-feed-row.feed-tool .tool-name {
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
font-size: 12px;
color: var(--text-primary);
flex: none;
}
.qd-feed-row.feed-tool .tool-param {
color: var(--text-tertiary);
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.qd-feed-row.feed-tool .tool-result {
flex: none;
margin-left: auto;
font-size: 11.5px;
color: var(--text-tertiary);
font-variant-numeric: tabular-nums;
}
.qd-feed-row.feed-tool .tool-result.is-error {
color: var(--state-danger);
}
.qd-tool-spinner {
flex: none;
width: 11px;
height: 11px;
border-radius: 50%;
border: 1.5px solid var(--border-strong);
border-top-color: var(--accent);
animation: qd-tool-spin 0.7s linear infinite;
}
@keyframes qd-tool-spin {
to {
transform: rotate(360deg);
}
}
.qd-tool-done {
flex: none;
width: 11px;
text-align: center;
font-size: 11px;
color: var(--accent);
}
.qd-feed-row.feed-term {
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
font-size: 12px;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-all;
min-height: 24px;
}
.qd-feed-row.feed-term.is-command {
color: var(--text-primary);
font-weight: 600;
}
.qd-feed-row.feed-killed {
color: var(--state-danger);
}
/* ---------------- 文件预览侧边栏(占位列) ---------------- */
.qd-preview {
flex: none;
width: 452px; /* 440 + 右 12 padding */
max-width: calc(100vw - 200px);
padding: 20px 12px 20px 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
min-height: 0;
}
.qd-preview__panel {
flex: 1;
min-height: 0;
background: var(--surface-raised);
border: 1px solid var(--border-default);
border-radius: 12px;
box-shadow: var(--shadow-card);
display: flex;
flex-direction: column;
overflow: hidden;
}
/* 预览侧边栏滑入滑出(对齐 git/终端面板 right-panels-slide 的参数) */
.qd-preview-slide-enter-active,
.qd-preview-slide-leave-active {
transition:
width 0.24s ease,
padding 0.24s ease,
opacity 0.2s ease,
transform 0.24s var(--qd-ease-out);
overflow: hidden;
}
.qd-preview-slide-enter-from,
.qd-preview-slide-leave-to {
width: 0 !important;
padding-right: 0 !important;
opacity: 0;
transform: translateX(24px);
}
.qd-preview__header {
flex: none;
height: 44px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 14px;
border-bottom: 1px solid var(--border-default);
}
.qd-preview__header > svg {
flex: none;
width: 15px;
height: 15px;
color: var(--accent);
}
.qd-preview__name {
flex: none;
max-width: 45%;
font-size: 13px;
font-weight: 600;
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.qd-preview__path {
flex: 1;
min-width: 0;
font-size: 11.5px;
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
color: var(--text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.qd-preview__close {
flex: none;
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
background: none;
border: none;
border-radius: 6px;
color: var(--text-tertiary);
cursor: pointer;
transition:
background 0.12s ease,
color 0.12s ease;
}
.qd-preview__close svg {
width: 13px;
height: 13px;
}
.qd-preview__close:hover {
background: var(--tab-active);
color: var(--text-secondary);
}
.qd-preview__body {
flex: 1;
overflow: auto;
padding: 8px 0 12px;
scrollbar-width: none;
-ms-overflow-style: none;
}
.qd-preview__body::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
.qd-preview__loading,
.qd-preview__error {
padding: 18px 14px;
font-size: 12.5px;
color: var(--text-tertiary);
}
.qd-preview__error {
color: var(--state-danger);
}
.qd-code-line {
display: flex;
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
font-size: 12px;
line-height: 1.75;
}
.qd-code-line:hover {
background: var(--hover-bg);
}
.qd-code-line .line-no {
flex: none;
width: 44px;
padding-right: 12px;
text-align: right;
color: var(--text-tertiary);
user-select: none;
}
.qd-code-line .line-text {
flex: 1;
padding-right: 14px;
white-space: pre;
color: var(--text-primary);
}
/* ---------------- 全局 ⋯ 菜单fixed 单例) ---------------- */
.qd-menu {
position: fixed;
z-index: 300;
min-width: 116px;
padding: 4px;
background: var(--surface-raised);
border: 1px solid var(--border-default);
border-radius: 8px;
box-shadow: var(--shadow-card);
}
.qd-menu.menu-enter {
animation: qd-menu-in 0.16s ease-out;
}
@keyframes qd-menu-in {
from {
opacity: 0;
transform: translateY(-3px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.qd-menu__item {
display: flex;
align-items: center;
width: 100%;
height: 30px;
padding: 0 10px;
background: none;
border: none;
border-radius: 5px;
font-size: 12.5px;
text-align: left;
color: var(--text-primary);
cursor: pointer;
transition: background 0.12s ease;
}
.qd-menu__item:hover:not(:disabled) {
background: var(--surface-soft);
}
.qd-menu__item--danger {
color: var(--state-danger);
}
.qd-menu__item--danger:hover:not(:disabled) {
background: color-mix(in srgb, var(--state-danger) 8%, transparent);
}
.qd-menu__item:disabled {
color: var(--text-tertiary);
cursor: default;
}

View File

@ -637,6 +637,28 @@
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"
><span class="settings-row-copy"
><span class="settings-row-title">按项目分组对话</span
@ -654,7 +676,7 @@
" /><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"
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 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

View File

@ -20,6 +20,7 @@ import { io as createSocketClient } from 'socket.io-client';
import { renderLatexInRealtime } from './useMarkdownRenderer';
import { getMessageVisibility, messageStartsWork } from '../utils/messageVisibility';
import { goalModeDebugLog } from '../app/methods/common';
import { useQuickDockStore } from '../stores/quickDock';
export async function initializeLegacySocket(ctx: any) {
try {
@ -733,6 +734,23 @@ export async function initializeLegacySocket(ctx: any) {
ctx.fileSetTodoList((data && data.todo_list) || null);
});
// 快捷窗口:本次对话编辑/创建文件记录更新
ctx.socket.on('edited_files_updated', (data) => {
socketLog('收到编辑文件记录更新事件:', data);
if (!data) {
return;
}
// 全局广播带 conversation_id只响应当前对话的更新
if (
data.conversation_id &&
ctx.currentConversationId &&
data.conversation_id !== ctx.currentConversationId
) {
return;
}
useQuickDockStore().setEditedFiles(data.edited_files || []);
});
// 系统就绪
ctx.socket.on('system_ready', (data) => {
ctx.projectPath = data.project_path || '';

View File

@ -1,5 +1,6 @@
import { defineStore } from 'pinia';
import { useUiStore } from './ui';
import { useConversationStore } from './conversation';
interface BackgroundCommand {
command_id: string;
@ -23,12 +24,21 @@ interface BackgroundCommandState {
pollTimer: ReturnType<typeof setInterval> | null;
detailPollTimer: ReturnType<typeof setInterval> | null;
activeCommand: BackgroundCommand | null;
/** QuickDock 静默查看的 command_id拉详情/轮询但不设 activeCommand避免弹出旧详情窗口 */
silentDetailCommandId: string | null;
stoppingCommandIds: Record<string, boolean>;
activeDetail: BackgroundCommandDetail | null;
detailLoading: boolean;
detailError: string | null;
}
/** conversation_idwith_terminal terminal
* manager / current_conversation_id terminal */
function convQuery(): string {
const convId = useConversationStore().currentConversationId;
return convId ? `conversation_id=${encodeURIComponent(convId)}` : '';
}
const TERMINAL_STATUSES = new Set(['completed', 'failed', 'timeout', 'cancelled']);
export const useBackgroundCommandStore = defineStore('backgroundCommand', {
@ -37,6 +47,7 @@ export const useBackgroundCommandStore = defineStore('backgroundCommand', {
pollTimer: null,
detailPollTimer: null,
activeCommand: null,
silentDetailCommandId: null,
stoppingCommandIds: {},
activeDetail: null,
detailLoading: false,
@ -45,7 +56,16 @@ export const useBackgroundCommandStore = defineStore('backgroundCommand', {
actions: {
async fetchCommands() {
try {
const resp = await fetch('/api/background_commands?limit=200');
// /new 等无对话场景:不请求后端,直接置空(服务 terminal 可能命中其他对话的记录)
const convId = useConversationStore().currentConversationId;
if (!convId) {
this.commands = [];
return;
}
const query = convQuery();
const resp = await fetch(
`/api/background_commands?limit=200${query ? `&${query}` : ''}`
);
if (!resp.ok) {
throw new Error(await resp.text());
}
@ -81,11 +101,16 @@ export const useBackgroundCommandStore = defineStore('backgroundCommand', {
this.pollTimer = null;
}
},
openCommand(command: BackgroundCommand) {
openCommand(command: BackgroundCommand, options?: { silent?: boolean }) {
if (!command || !command.command_id) {
return;
}
this.activeCommand = command;
if (options?.silent) {
this.silentDetailCommandId = command.command_id;
} else {
this.activeCommand = command;
this.silentDetailCommandId = null;
}
this.activeDetail = null;
this.detailError = null;
this.fetchCommandDetail(command.command_id);
@ -94,6 +119,7 @@ export const useBackgroundCommandStore = defineStore('backgroundCommand', {
closeCommand() {
this.stopDetailPolling();
this.activeCommand = null;
this.silentDetailCommandId = null;
this.activeDetail = null;
this.detailError = null;
this.detailLoading = false;
@ -103,7 +129,7 @@ export const useBackgroundCommandStore = defineStore('backgroundCommand', {
return;
}
this.detailPollTimer = setInterval(() => {
const commandId = this.activeCommand?.command_id;
const commandId = this.activeCommand?.command_id || this.silentDetailCommandId;
if (commandId) {
this.fetchCommandDetail(commandId);
}
@ -119,7 +145,10 @@ export const useBackgroundCommandStore = defineStore('backgroundCommand', {
if (!commandId) return;
this.detailLoading = true;
try {
const resp = await fetch(`/api/background_commands/${encodeURIComponent(commandId)}`);
const query = convQuery();
const resp = await fetch(
`/api/background_commands/${encodeURIComponent(commandId)}${query ? `?${query}` : ''}`
);
if (!resp.ok) {
throw new Error(await resp.text());
}
@ -158,8 +187,9 @@ export const useBackgroundCommandStore = defineStore('backgroundCommand', {
[normalizedId]: true
};
try {
const query = convQuery();
const resp = await fetch(
`/api/background_commands/${encodeURIComponent(normalizedId)}/cancel`,
`/api/background_commands/${encodeURIComponent(normalizedId)}/cancel${query ? `?${query}` : ''}`,
{ method: 'POST' }
);
const data = await resp.json().catch(() => ({}));

View File

@ -75,6 +75,10 @@ export const useConversationStore = defineStore('conversation', {
multiAgentMode: false
}),
actions: {
/** 当前对话 id 同步(由 app watcher 在 this.currentConversationId 变化时写入) */
setCurrentConversationId(id: string | null) {
this.currentConversationId = id;
},
resetConversations() {
this.conversations = [];
this.searchResults = [];

View File

@ -1,4 +1,5 @@
import { defineStore } from 'pinia';
import { useConversationStore } from './conversation';
const FILE_STORE_DEBUG_LOGS = false;
function fileDebugLog(...args: unknown[]) {
@ -38,6 +39,8 @@ interface FileState {
fileTree: FileNode[];
expandedFolders: Record<string, boolean>;
todoList: TodoList | null;
/** true = 来自任务期实时事件播动画false = 来自加载/fetch静态呈现 */
todoListLive: boolean;
fileTreeUnavailable: boolean;
fileTreeMessage: string;
contextMenu: ContextMenuState;
@ -78,6 +81,7 @@ export const useFileStore = defineStore('file', {
fileTree: [],
expandedFolders: {},
todoList: null,
todoListLive: false,
fileTreeUnavailable: false,
fileTreeMessage: '',
contextMenu: {
@ -163,17 +167,25 @@ export const useFileStore = defineStore('file', {
},
async fetchTodoList() {
try {
const response = await fetch('/api/todo-list');
// 必须携带 conversation_idtodo 数据保存在对话级 terminal 的内存中,
// 不带 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 data = await response.json();
if (data && data.success) {
this.todoList = data.data || null;
this.todoListLive = false;
}
} catch (error) {
console.error('获取待办列表失败:', error);
}
},
setTodoList(payload: TodoList | null) {
setTodoList(payload: TodoList | null, live = false) {
this.todoList = payload;
this.todoListLive = live;
},
showContextMenu(payload: { node: FileNode; event: MouseEvent }) {
if (!payload || !payload.node) {

View File

@ -63,6 +63,7 @@ interface PersonalForm {
agents_md_auto_inject: boolean;
allow_root_file_creation: boolean;
default_hide_workspace: boolean;
hide_quick_dock: boolean;
group_sidebar_by_workspace: boolean;
sidebar_pinned_workspaces: string[];
sidebar_workspace_order: string[];
@ -225,6 +226,7 @@ const defaultForm = (): PersonalForm => ({
agents_md_auto_inject: false,
allow_root_file_creation: false,
default_hide_workspace: false,
hide_quick_dock: false,
group_sidebar_by_workspace: false,
sidebar_pinned_workspaces: [],
sidebar_workspace_order: [],
@ -465,6 +467,7 @@ export const usePersonalizationStore = defineStore('personalization', {
agents_md_auto_inject: !!data.agents_md_auto_inject,
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,
sidebar_pinned_workspaces: Array.isArray(data.sidebar_pinned_workspaces)
? data.sidebar_pinned_workspaces.filter((item: unknown) => typeof item === 'string')

View File

@ -0,0 +1,122 @@
import { defineStore } from 'pinia';
/**
* Quick Dock
* / / /
* store
*/
export interface EditedFileEntry {
path: string;
op?: string;
ts?: string;
}
export interface QuickDockDetailTarget {
kind: 'agent' | 'cmd';
id: string;
}
export interface QuickDockMenuState {
/** runner = 子智能体/后台指令条目菜单强制关闭file = 文件条目菜单 */
type: 'runner' | 'file';
/** 仅 runner区分子智能体 / 后台指令 */
kind?: 'agent' | 'cmd';
/** runner: task_id / command_idfile: 相对路径 */
key: string;
/** fixed 定位坐标 */
left: number;
top: number;
/** 菜单与触发按钮右对齐runner 的 ⋯ 在行右侧) */
alignRight: boolean;
}
interface QuickDockState {
editedFiles: EditedFileEntry[];
/** true = 来自任务期实时事件播动画false = 来自加载/bootstrap静态呈现 */
editedFilesLive: boolean;
detail: QuickDockDetailTarget | null;
previewPath: string | null;
menu: QuickDockMenuState | null;
}
export const useQuickDockStore = defineStore('quickDock', {
state: (): QuickDockState => ({
editedFiles: [],
editedFilesLive: false,
detail: null,
previewPath: null,
menu: null
}),
actions: {
setEditedFiles(list: EditedFileEntry[] | null | undefined, live = false) {
this.editedFilesLive = live;
if (!Array.isArray(list)) {
this.editedFiles = [];
return;
}
const seen = new Set<string>();
const normalized: EditedFileEntry[] = [];
for (const item of list) {
if (!item || typeof item.path !== 'string' || !item.path) {
continue;
}
if (seen.has(item.path)) {
continue;
}
seen.add(item.path);
normalized.push({ path: item.path, op: item.op, ts: item.ts });
}
this.editedFiles = normalized;
// 列表移除正在预览的文件时,同步关闭预览
if (this.previewPath && !seen.has(this.previewPath)) {
this.previewPath = null;
}
},
openDetail(kind: 'agent' | 'cmd', id: string) {
if (!id) {
return;
}
// 再点同一条目 = 收起
if (this.detail && this.detail.kind === kind && this.detail.id === id) {
this.detail = null;
return;
}
this.detail = { kind, id };
},
closeDetail() {
this.detail = null;
},
openPreview(path: string) {
if (!path) {
return;
}
// 再点同一文件 = 收起
if (this.previewPath === path) {
this.previewPath = null;
return;
}
this.previewPath = path;
},
closePreview() {
this.previewPath = null;
},
openMenu(menu: QuickDockMenuState) {
// 同一按钮再点 = 收起
if (this.menu && this.menu.type === menu.type && this.menu.key === menu.key) {
this.menu = null;
return;
}
this.menu = menu;
},
closeMenu() {
this.menu = null;
},
/** 切换对话 / 隐藏快捷窗口时重置全部瞬态 */
resetTransient() {
this.detail = null;
this.previewPath = null;
this.menu = null;
}
}
});

View File

@ -28,6 +28,8 @@ interface SubAgentState {
pollTimer: ReturnType<typeof setInterval> | null;
activityTimer: ReturnType<typeof setInterval> | null;
activeAgent: SubAgent | null;
/** QuickDock 静默查看的 task_id不设 activeAgent避免弹出旧进度窗口 */
silentActivityTaskId: string | null;
stoppingTaskIds: Record<string, boolean>;
activityEntries: SubAgentActivityEntry[];
activityLoading: boolean;
@ -42,6 +44,8 @@ export const useSubAgentStore = defineStore('subAgent', {
pollTimer: null,
activityTimer: null,
activeAgent: null,
/** QuickDock 静默查看的 task_id拉 activity/轮询但不设 activeAgent避免弹出旧进度窗口 */
silentActivityTaskId: null,
stoppingTaskIds: {},
activityEntries: [],
activityLoading: false,
@ -51,11 +55,21 @@ export const useSubAgentStore = defineStore('subAgent', {
async fetchSubAgents() {
try {
const conversationStore = useConversationStore();
const convId = conversationStore.currentConversationId;
// /new 等无对话场景:不请求后端,直接置空。
// 否则后端服务 terminal 的「当前对话」可能是最近加载的对话(或命中全局
// running 兜底),把其他对话的子智能体显示到新对话页。
if (!convId) {
this.subAgents = [];
return;
}
let resp;
if (conversationStore.multiAgentMode && conversationStore.currentConversationId) {
resp = await fetch(`/api/multiagent/active_sub_agents?conversation_id=${encodeURIComponent(conversationStore.currentConversationId)}`);
if (conversationStore.multiAgentMode) {
resp = await fetch(`/api/multiagent/active_sub_agents?conversation_id=${encodeURIComponent(convId)}`);
} else {
resp = await fetch('/api/sub_agents');
// 必须携带 conversation_id后端按 terminal 的当前对话过滤,无参数时落到
// 工作区级服务 terminal其 current_conversation_id 与用户查看的对话不一致。
resp = await fetch(`/api/sub_agents?conversation_id=${encodeURIComponent(convId)}`);
}
if (!resp.ok) {
throw new Error(await resp.text());
@ -95,11 +109,16 @@ export const useSubAgentStore = defineStore('subAgent', {
this.pollTimer = null;
}
},
openSubAgent(agent: SubAgent) {
openSubAgent(agent: SubAgent, options?: { silent?: boolean }) {
if (!agent || !agent.task_id) {
return;
}
this.activeAgent = agent;
if (options?.silent) {
this.silentActivityTaskId = agent.task_id;
} else {
this.activeAgent = agent;
this.silentActivityTaskId = null;
}
this.activityEntries = [];
this.activityError = null;
this.fetchSubAgentActivity(agent.task_id);
@ -108,6 +127,7 @@ export const useSubAgentStore = defineStore('subAgent', {
closeSubAgent() {
this.stopActivityPolling();
this.activeAgent = null;
this.silentActivityTaskId = null;
this.activityEntries = [];
this.activityError = null;
this.activityLoading = false;
@ -117,7 +137,7 @@ export const useSubAgentStore = defineStore('subAgent', {
return;
}
this.activityTimer = setInterval(() => {
const taskId = this.activeAgent?.task_id;
const taskId = this.activeAgent?.task_id || this.silentActivityTaskId;
if (taskId) {
this.fetchSubAgentActivity(taskId);
}
@ -133,7 +153,11 @@ export const useSubAgentStore = defineStore('subAgent', {
if (!taskId) return;
this.activityLoading = true;
try {
const resp = await fetch(`/api/sub_agents/${taskId}/activity?limit=100000`);
// 携带 conversation_id子智能体跑在对话级 terminal服务 terminal 的
// sub_agent_manager.tasks 里查不到该任务404 → 前端永远「暂无进度」)。
const convId = useConversationStore().currentConversationId;
const query = convId ? `&conversation_id=${encodeURIComponent(convId)}` : '';
const resp = await fetch(`/api/sub_agents/${taskId}/activity?limit=100000${query}`);
if (!resp.ok) {
throw new Error(await resp.text());
}
@ -147,6 +171,11 @@ export const useSubAgentStore = defineStore('subAgent', {
status: data.data.status || this.activeAgent.status
};
}
// 静默查看时同步列表项状态,保证 QuickDock 详情面板的状态角标及时更新
const listItem = this.subAgents.find((item) => item.task_id === taskId);
if (listItem && data.data.status) {
listItem.status = data.data.status;
}
const status = (data.data.status || '').toString();
if (TERMINAL_STATUSES.has(status)) {
this.stopActivityPolling();