diff --git a/demo/todo_float_window/app.js b/demo/todo_float_window/app.js
new file mode 100644
index 00000000..0f882266
--- /dev/null
+++ b/demo/todo_float_window/app.js
@@ -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);
+});
diff --git a/demo/todo_float_window/file.css b/demo/todo_float_window/file.css
new file mode 100644
index 00000000..29223761
--- /dev/null
+++ b/demo/todo_float_window/file.css
@@ -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); }
diff --git a/demo/todo_float_window/file.js b/demo/todo_float_window/file.js
new file mode 100644
index 00000000..ec8b6287
--- /dev/null
+++ b/demo/todo_float_window/file.js
@@ -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: `
+
+
+
+
+
+
+`,
+ },
+ {
+ 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 = '';
+ 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);
+});
diff --git a/demo/todo_float_window/index.html b/demo/todo_float_window/index.html
new file mode 100644
index 00000000..990bbf3b
--- /dev/null
+++ b/demo/todo_float_window/index.html
@@ -0,0 +1,146 @@
+
+
+
+
+
+悬浮窗口栈 Demo · 待办 / 子智能体 / 后台指令
+
+
+
+
+
+
+
+
+ 把待办、子智能体、后台指令都做成右上角的悬浮窗口
+ 好的,三个窗口上下排布在对话区右上角。子智能体和后台指令会持续产生输出,点击条目可以在左侧展开详情面板。
+ 条目左侧的 ⋯ 可以展开菜单(强制关闭);运行中的条目状态点会呼吸,完成后变灰。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/demo/todo_float_window/runner.css b/demo/todo_float_window/runner.css
new file mode 100644
index 00000000..7c14dcae
--- /dev/null
+++ b/demo/todo_float_window/runner.css
@@ -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; }
+}
+
+/* 进入动画复用待办的 keyframes(todo-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;
+}
diff --git a/demo/todo_float_window/runner.js b/demo/todo_float_window/runner.js
new file mode 100644
index 00000000..22033033
--- /dev/null
+++ b/demo/todo_float_window/runner.js
@@ -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 = '';
+ 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'
+ ? '✓'
+ : '';
+ const result = item.status === 'done'
+ ? `${item.result}`
+ : '';
+ return `${icon}${item.name}${item.param}${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 而非 animationend:feed 行动画的 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);
+});
diff --git a/demo/todo_float_window/style.css b/demo/todo_float_window/style.css
new file mode 100644
index 00000000..4090a0a5
--- /dev/null
+++ b/demo/todo_float_window/style.css
@@ -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); }
diff --git a/docs/float_window_design.md b/docs/float_window_design.md
new file mode 100644
index 00000000..8ece51e8
--- /dev/null
+++ b/docs/float_window_design.md
@@ -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 兄弟节点,位于 `` 之后、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 兄弟节点,宽 294px(270 + 左右 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 细边框。
+- 标题栏 38px:SVG 图标(accent 色)+ 标题(12.5px 次要色)+ 右侧计数(11.5px 三级色)。
+- **列表无上下 padding**:首行 hover 紧贴标题栏分隔线,末行紧贴窗口底边。
+- 行:固定 36px 高,直接铺在窗口上(无嵌套卡片/背景/边框),超长文字省略号。
+- 正式接入时配色全部换成三主题语义 token(demo 为经典主题暖色写死)。
+
+## 3. 各窗口设计
+
+### 3.1 待办事项
+
+- 行结构:`[状态点] 任务文字`;**无 hover、不可点击**。
+- 计数:`done/total`。
+- 动画:
+ - **创建**:逐行从左向右(位移 14px + 淡入,0.34s,stagger 60ms)。
+ - **完成**:横线从左往右划过文字(260ms,`strike-in` scaleX 0→1,origin left)→ 文字延迟 120ms 变灰 + 状态点变色弹跳(`dot-pop`)。静态完成态(整表渲染时)不播划线,直接呈现。
+ - **整表替换**(收到新 todo):旧行从上到下逐行从右向左移出(0.26s,stagger 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] 后端:编辑文件路径记录写入对话 JSON(write/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//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`:`` 后挂载 `QuickDock` + `FilePreviewPanel`(移动端与 hide_quick_dock 时不渲染)
+- `static/src/components/personalization/PersonalizationDrawer.vue`:「隐藏快捷窗口」设置行(默认隐藏工作区之后)
+
+**验证状态**:py_compile ✓ / 6 项后端冒烟 ✓ / tsc+stylelint+vite build ✓ / 新文件 eslint 0 问题,存量文件 lint 与基线持平。8091 服务重启后的实际运行验证待手动进行。
diff --git a/modules/personalization_manager.py b/modules/personalization_manager.py
index 446301ad..91d072b3 100644
--- a/modules/personalization_manager.py
+++ b/modules/personalization_manager.py
@@ -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"))
diff --git a/server/app_legacy.py b/server/app_legacy.py
index 269839d7..4cb81b8d 100644
--- a/server/app_legacy.py
+++ b/server/app_legacy.py
@@ -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:
diff --git a/server/conversation.py b/server/conversation.py
index 5e044e14..d89a7fa5 100644
--- a/server/conversation.py
+++ b/server/conversation.py
@@ -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:
diff --git a/server/conversation_bootstrap.py b/server/conversation_bootstrap.py
index e1b0e914..ae438257 100644
--- a/server/conversation_bootstrap.py
+++ b/server/conversation_bootstrap.py
@@ -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:
diff --git a/static/src/App.vue b/static/src/App.vue
index a3968edf..ad027864 100644
--- a/static/src/App.vue
+++ b/static/src/App.vue
@@ -385,6 +385,13 @@
/>
+
+
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 || {};
diff --git a/static/src/app/methods/history.ts b/static/src/app/methods/history.ts
index 9ab7d3be..20f79d9c 100644
--- a/static/src/app/methods/history.ts
+++ b/static/src/app/methods/history.ts
@@ -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');
diff --git a/static/src/app/methods/taskPolling/lifecycle.ts b/static/src/app/methods/taskPolling/lifecycle.ts
index 49e788e9..b9aa10b6 100644
--- a/static/src/app/methods/taskPolling/lifecycle.ts
+++ b/static/src/app/methods/taskPolling/lifecycle.ts
@@ -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;
diff --git a/static/src/app/watchers.ts b/static/src/app/watchers.ts
index 3fa75c41..59248537 100644
--- a/static/src/app/watchers.ts
+++ b/static/src/app/watchers.ts
@@ -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;
+ }
+ // 同步到 conversationStore(QuickDock 等组件监听 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') {
diff --git a/static/src/components/chat/quickdock/FilePreviewPanel.vue b/static/src/components/chat/quickdock/FilePreviewPanel.vue
new file mode 100644
index 00000000..d28d7dfd
--- /dev/null
+++ b/static/src/components/chat/quickdock/FilePreviewPanel.vue
@@ -0,0 +1,175 @@
+
+
+
+
+
+
+
diff --git a/static/src/components/chat/quickdock/FileWindow.vue b/static/src/components/chat/quickdock/FileWindow.vue
new file mode 100644
index 00000000..8c1937f1
--- /dev/null
+++ b/static/src/components/chat/quickdock/FileWindow.vue
@@ -0,0 +1,177 @@
+
+
+
+
+ -
+
+ {{ basename(row.path) }}
+
+
+
+
+
+
diff --git a/static/src/components/chat/quickdock/QuickDock.vue b/static/src/components/chat/quickdock/QuickDock.vue
new file mode 100644
index 00000000..a2b2ed2f
--- /dev/null
+++ b/static/src/components/chat/quickdock/QuickDock.vue
@@ -0,0 +1,289 @@
+
+
+
+
+
+
+
diff --git a/static/src/components/chat/quickdock/RunnerDetailPanel.vue b/static/src/components/chat/quickdock/RunnerDetailPanel.vue
new file mode 100644
index 00000000..ffcf1a58
--- /dev/null
+++ b/static/src/components/chat/quickdock/RunnerDetailPanel.vue
@@ -0,0 +1,507 @@
+
+
+
+
+
+
+
+ {{ activityLoading ? '加载中…' : '暂无进度' }}
+
+
+
+
+ {{ item.state === 'failed' ? '✕' : '✓' }}
+ {{ item.toolName }}
+ {{ item.text }}
+
+ {{ item.stateLabel }}
+
+
+ {{ item.content }}
+
+
+
+
+
+
+ {{ detailLoading ? '加载中…' : '暂无输出' }}
+
+
+ {{ line }}
+
+
+
+
+
+
+
diff --git a/static/src/components/chat/quickdock/RunnerWindow.vue b/static/src/components/chat/quickdock/RunnerWindow.vue
new file mode 100644
index 00000000..95cc3131
--- /dev/null
+++ b/static/src/components/chat/quickdock/RunnerWindow.vue
@@ -0,0 +1,295 @@
+
+
+
+
+ -
+
+ {{ row.name }}
+
+
+
+
+
+
+
diff --git a/static/src/components/chat/quickdock/TodoWindow.vue b/static/src/components/chat/quickdock/TodoWindow.vue
new file mode 100644
index 00000000..63ca68dc
--- /dev/null
+++ b/static/src/components/chat/quickdock/TodoWindow.vue
@@ -0,0 +1,309 @@
+
+
+
+
+ -
+
+ {{ row.title }}
+
+
+
+
+
+
diff --git a/static/src/components/chat/quickdock/quickdock.css b/static/src/components/chat/quickdock/quickdock.css
new file mode 100644
index 00000000..ea0af4f6
--- /dev/null
+++ b/static/src/components/chat/quickdock/quickdock.css
@@ -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;
+}
diff --git a/static/src/components/personalization/PersonalizationDrawer.vue b/static/src/components/personalization/PersonalizationDrawer.vue
index 0ea6be8f..8976d180 100644
--- a/static/src/components/personalization/PersonalizationDrawer.vue
+++ b/static/src/components/personalization/PersonalizationDrawer.vue
@@ -637,6 +637,28 @@
class="fancy-path"
>
+