feat(ui): 显示 Agent 任务进度列表 (#251)

This commit is contained in:
RuoJi6
2026-08-14 10:44:03 +08:00
committed by GitHub
parent 7c45011074
commit a1615143be
18 changed files with 1131 additions and 6 deletions
+203
View File
@@ -0,0 +1,203 @@
/* Agent-maintained plan: compact progress chip, expanded on hover/focus. */
.agent-plan-progress {
--agent-plan-surface: var(--card-bg);
--agent-plan-surface-hover: var(--bg-tertiary);
--agent-plan-text: var(--text-primary);
--agent-plan-text-secondary: var(--text-secondary);
--agent-plan-border: var(--border-color);
--agent-plan-spinner-track: color-mix(in srgb, var(--accent-color) 30%, transparent);
--agent-plan-trigger-bottom: 14px;
--agent-plan-panel-bottom: 70px;
position: relative;
z-index: 42;
flex: 0 0 0;
width: 100%;
height: 0;
display: flex;
justify-content: center;
pointer-events: none;
}
.agent-plan-progress[hidden] {
display: none;
}
.agent-plan-progress-trigger {
position: absolute;
right: 50%;
bottom: var(--agent-plan-trigger-bottom);
transform: translateX(50%);
display: inline-flex;
align-items: center;
gap: 10px;
min-height: 46px;
padding: 0 18px;
border: 1px solid var(--agent-plan-border);
border-radius: 18px;
color: var(--agent-plan-text);
background: var(--agent-plan-surface);
box-shadow: var(--shadow-lg);
font: inherit;
font-size: 15px;
font-weight: 600;
line-height: 1;
white-space: nowrap;
cursor: pointer;
pointer-events: auto;
transition: background-color 150ms ease, border-color 150ms ease, transform 150ms ease;
}
.agent-plan-progress-trigger:hover,
.agent-plan-progress-trigger:focus-visible {
color: var(--agent-plan-text);
background: var(--agent-plan-surface-hover);
border-color: var(--accent-color);
outline: none;
}
.agent-plan-progress-panel {
position: absolute;
right: 50%;
bottom: var(--agent-plan-panel-bottom);
width: max-content;
min-width: 360px;
max-width: min(560px, calc(100vw - 40px));
max-height: min(54vh, 440px);
padding: 14px 18px;
overflow: auto;
border: 1px solid var(--agent-plan-border);
border-radius: 18px;
color: var(--agent-plan-text);
background: var(--agent-plan-surface);
box-shadow: var(--shadow-lg);
opacity: 0;
transform: translate(50%, 8px) scale(0.985);
transform-origin: bottom center;
visibility: hidden;
pointer-events: none;
transition: opacity 140ms ease, transform 140ms ease, visibility 140ms ease;
}
/*
* “回到最新消息”只在用户离开底部时出现。它与任务进度同为居中浮层,
* 两者同时可见时让任务进度上移,保留回到底部按钮靠近输入框的位置。
*/
.chat-return-latest:not([hidden]) + .agent-plan-progress:not([hidden]) {
--agent-plan-trigger-bottom: 64px;
--agent-plan-panel-bottom: 120px;
}
.agent-plan-progress.is-hover-active .agent-plan-progress-panel,
.agent-plan-progress:focus-within .agent-plan-progress-panel,
.agent-plan-progress.is-open .agent-plan-progress-panel {
opacity: 1;
transform: translate(50%, 0) scale(1);
visibility: visible;
pointer-events: auto;
}
.agent-plan-task {
display: grid;
grid-template-columns: 22px minmax(0, 1fr);
align-items: start;
gap: 10px;
min-height: 34px;
padding: 4px 0;
font-size: 15px;
font-weight: 570;
line-height: 1.45;
}
.agent-plan-task-label {
min-width: 0;
overflow-wrap: anywhere;
}
.agent-plan-task--completed {
color: var(--agent-plan-text-secondary);
}
.agent-plan-task--in_progress {
color: var(--agent-plan-text);
}
.agent-plan-task--pending {
color: var(--agent-plan-text-secondary);
}
.agent-plan-task-status {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
margin-top: 2px;
border: 2px solid var(--agent-plan-text-secondary);
border-radius: 50%;
box-sizing: border-box;
flex: 0 0 auto;
}
.agent-plan-task-status--completed {
border-color: var(--agent-plan-text-secondary);
color: var(--agent-plan-text-secondary);
}
.agent-plan-task-check {
width: 13px;
height: 13px;
fill: none;
stroke: currentColor;
stroke-width: 2.3;
stroke-linecap: round;
stroke-linejoin: round;
}
.agent-plan-task-status--in_progress {
border-color: var(--agent-plan-spinner-track);
border-top-color: var(--accent-color);
animation: agent-plan-spin 820ms linear infinite;
}
.agent-plan-progress-trigger .agent-plan-task-status {
margin-top: 0;
}
@keyframes agent-plan-spin {
to { transform: rotate(360deg); }
}
@media (max-width: 640px) {
.agent-plan-progress {
--agent-plan-trigger-bottom: 10px;
--agent-plan-panel-bottom: 66px;
}
.chat-return-latest:not([hidden]) + .agent-plan-progress:not([hidden]) {
--agent-plan-trigger-bottom: 60px;
--agent-plan-panel-bottom: 116px;
}
.agent-plan-progress-panel {
min-width: min(360px, calc(100vw - 28px));
max-width: calc(100vw - 28px);
padding: 12px 15px;
}
.agent-plan-progress-trigger {
min-height: 42px;
padding: 0 15px;
}
}
@media (prefers-reduced-motion: reduce) {
.agent-plan-progress-trigger,
.agent-plan-progress-panel {
transition: none;
}
.agent-plan-task-status--in_progress {
animation-duration: 1.8s;
}
}
+7 -1
View File
@@ -3489,6 +3489,7 @@ html[data-theme="dark"] .sidebar-content:hover::-webkit-scrollbar-thumb:hover {
.chat-container.is-conversation-restoring #chat-messages,
.chat-container.is-conversation-restoring #chat-turn-rail,
.chat-container.is-conversation-restoring #chat-return-latest,
.chat-container.is-conversation-restoring #agent-plan-progress,
.chat-container.is-conversation-restoring #chat-input-container {
visibility: hidden;
}
@@ -3546,7 +3547,6 @@ html[data-theme="dark"] .sidebar-content:hover::-webkit-scrollbar-thumb:hover {
.chat-messages:hover::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, var(--text-muted) 80%, transparent);
}
.chat-welcome-empty-state {
flex: 1 0 auto;
width: 100%;
@@ -45316,6 +45316,12 @@ html[data-theme="dark"] .conversation-sidebar .recent-conversations-section {
container-type: inline-size;
}
/* The composer is an isolated stacking context. Lift that whole context while the
settings popover is open so the turn rail and its preview cannot paint above it. */
.chat-input-container.is-session-settings-open {
z-index: 121;
}
.chat-composer-context {
position: relative;
z-index: 1;
+4
View File
@@ -562,6 +562,10 @@
"conversationPreviewViewed": "Viewed",
"conversationPreviewConversation": "Conversation",
"returnToLatest": "Jump to latest message",
"taskProgressStep": "Step {{current}} of {{total}}",
"taskProgressOpen": "View task progress",
"taskProgressDetails": "Task progress details",
"taskProgressUnnamed": "Untitled task",
"completedUnread": "Completed, not viewed",
"newConversationInProject": "Start a new conversation in this project",
"newUnassignedConversation": "Start a new conversation without a project",
+4
View File
@@ -550,6 +550,10 @@
"conversationPreviewViewed": "已查看",
"conversationPreviewConversation": "对话",
"returnToLatest": "回到最新消息",
"taskProgressStep": "第 {{current}} / {{total}} 步",
"taskProgressOpen": "查看任务进度",
"taskProgressDetails": "任务进度详情",
"taskProgressUnnamed": "未命名任务",
"completedUnread": "已完成,尚未查看",
"newConversationInProject": "在此项目中新建对话",
"newUnassignedConversation": "新建无项目对话",
+7
View File
@@ -42,3 +42,10 @@ test('欢迎语随项目和无项目状态更新', () => {
assert.equal(zh.chat.welcomeSubtitle, '请输入您的测试需求,系统将自动执行相应的安全测试。');
assert.equal(typeof en.chat.projectWelcomeMessage, 'string');
});
test('会话设置打开时提升整个输入区层级并遮住轮次导航', () => {
assert.match(chat, /function syncChatSessionSettingsLayerState\(\)/);
assert.match(chat, /inputBar\.classList\.toggle\('is-session-settings-open', open\)/);
assert.match(styles, /\.chat-input-container\.is-session-settings-open\s*\{[\s\S]*?z-index:\s*121/);
assert.match(styles, /\.chat-turn-rail\s*\{[\s\S]*?z-index:\s*20/);
});
+316
View File
@@ -0,0 +1,316 @@
(function (root) {
'use strict';
const PLAN_TOOL_NAMES = new Set(['taskcreate', 'taskupdate', 'tasklist', 'taskget']);
const POLL_INTERVAL_MS = 1500;
const FINAL_STATE_HOLD_MS = 2400;
function normalizeTask(raw, index) {
const task = raw && typeof raw === 'object' ? raw : {};
const status = String(task.status || 'pending').trim().toLowerCase();
return {
id: String(task.id || (index + 1)),
subject: String(task.subject || '').trim(),
description: String(task.description || '').trim(),
activeForm: String(task.activeForm || '').trim(),
status: ['pending', 'in_progress', 'completed', 'deleted'].includes(status) ? status : 'pending'
};
}
function deriveProgress(rawTasks) {
const tasks = (Array.isArray(rawTasks) ? rawTasks : [])
.map(normalizeTask)
.filter((task) => task.status !== 'deleted');
let activeIndex = tasks.findIndex((task) => task.status === 'in_progress');
if (activeIndex < 0) activeIndex = tasks.findIndex((task) => task.status !== 'completed');
if (activeIndex < 0 && tasks.length) activeIndex = tasks.length - 1;
return {
tasks,
total: tasks.length,
completed: tasks.filter((task) => task.status === 'completed').length,
activeStep: activeIndex >= 0 ? activeIndex + 1 : 0,
allCompleted: tasks.length > 0 && tasks.every((task) => task.status === 'completed')
};
}
function applyTaskUpdate(rawTasks, args) {
const update = args && typeof args === 'object' ? args : {};
const taskID = String(update.taskId || update.taskID || '').trim();
if (!taskID) return deriveProgress(rawTasks).tasks;
return deriveProgress(rawTasks).tasks
.filter((task) => !(String(update.status || '').toLowerCase() === 'deleted' && task.id === taskID))
.map((task) => {
if (task.id !== taskID) return task;
return Object.assign({}, task, {
subject: String(update.subject || task.subject).trim(),
description: String(update.description || task.description).trim(),
activeForm: String(update.activeForm || task.activeForm).trim(),
status: String(update.status || task.status).trim().toLowerCase()
});
});
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = { normalizeTask, deriveProgress, applyTaskUpdate };
return;
}
const state = {
conversationId: '',
tasks: [],
signature: '',
expanded: false,
requestSequence: 0,
abortController: null,
pollTimer: null,
refreshTimer: null,
finalHoldUntil: 0,
taskCalls: new Map()
};
const host = root.document && root.document.getElementById('agent-plan-progress');
if (!host) return;
let passiveHoverAnchor = null;
function clearPassiveHoverVisual() {
host.classList.remove('is-hover-active');
}
function resetPassiveHover() {
clearPassiveHoverVisual();
passiveHoverAnchor = null;
}
function disarmPassiveHover(event) {
clearPassiveHoverVisual();
if (!event || !Number.isFinite(event.clientX) || !Number.isFinite(event.clientY)) return;
passiveHoverAnchor = { x: event.clientX, y: event.clientY };
}
function armHoverAfterPointerMove(event) {
if (event && event.pointerType && event.pointerType !== 'mouse') return;
if (passiveHoverAnchor) {
if (!event || !Number.isFinite(event.clientX) || !Number.isFinite(event.clientY)) return;
// Smooth scrolling and layout shifts may emit pointermove without the
// physical pointer moving. Keep the panel locked until coordinates change.
if (event.clientX === passiveHoverAnchor.x && event.clientY === passiveHoverAnchor.y) return;
passiveHoverAnchor = null;
}
host.classList.add('is-hover-active');
}
host.addEventListener('pointermove', armHoverAfterPointerMove);
host.addEventListener('pointerleave', clearPassiveHoverVisual);
const returnLatestButton = root.document.getElementById('chat-return-latest');
if (returnLatestButton) {
// Clicking the return-to-latest control moves this plan chip downward.
// Clear the hover gate before that layout shift so a stationary pointer
// cannot accidentally reveal the plan panel underneath it.
returnLatestButton.addEventListener('pointerdown', disarmPassiveHover);
returnLatestButton.addEventListener('click', disarmPassiveHover);
}
function translate(key, fallback, params) {
if (typeof root.t === 'function') {
const value = root.t(key, params || {});
if (value && value !== key) return value;
}
let value = fallback;
Object.entries(params || {}).forEach(([name, replacement]) => {
value = value.replaceAll('{{' + name + '}}', String(replacement));
});
return value;
}
function createSVG(className, pathData) {
const namespace = 'http://www.w3.org/2000/svg';
const svg = root.document.createElementNS(namespace, 'svg');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('aria-hidden', 'true');
svg.classList.add(className);
const path = root.document.createElementNS(namespace, 'path');
path.setAttribute('d', pathData);
svg.appendChild(path);
return svg;
}
function statusIcon(status) {
const icon = root.document.createElement('span');
icon.className = 'agent-plan-task-status agent-plan-task-status--' + status;
icon.setAttribute('aria-hidden', 'true');
if (status === 'completed') {
icon.appendChild(createSVG('agent-plan-task-check', 'M7.5 12.5 10.5 15.5 16.8 8.8'));
}
return icon;
}
function taskLabel(task) {
if (task.status === 'in_progress' && task.activeForm) return task.activeForm;
return task.subject || translate('chat.taskProgressUnnamed', '未命名任务');
}
function render(force) {
const progress = deriveProgress(state.tasks);
const signature = JSON.stringify(progress.tasks) + '|' + state.expanded;
if (!force && signature === state.signature) return;
state.signature = signature;
host.replaceChildren();
if (!progress.total) {
host.hidden = true;
resetPassiveHover();
return;
}
host.hidden = false;
host.classList.toggle('is-open', state.expanded);
const panel = root.document.createElement('div');
panel.className = 'agent-plan-progress-panel';
panel.id = 'agent-plan-progress-panel';
panel.setAttribute('role', 'status');
panel.setAttribute('aria-label', translate('chat.taskProgressDetails', '任务进度详情'));
progress.tasks.forEach((task) => {
const row = root.document.createElement('div');
row.className = 'agent-plan-task agent-plan-task--' + task.status;
row.appendChild(statusIcon(task.status));
const label = root.document.createElement('span');
label.className = 'agent-plan-task-label';
label.textContent = taskLabel(task);
if (task.description) label.title = task.description;
row.appendChild(label);
panel.appendChild(row);
});
const trigger = root.document.createElement('button');
trigger.type = 'button';
trigger.className = 'agent-plan-progress-trigger';
trigger.setAttribute('aria-controls', panel.id);
trigger.setAttribute('aria-expanded', state.expanded ? 'true' : 'false');
trigger.setAttribute('aria-label', translate('chat.taskProgressOpen', '查看任务进度'));
trigger.appendChild(statusIcon(progress.allCompleted ? 'completed' : 'in_progress'));
const count = root.document.createElement('span');
count.className = 'agent-plan-progress-count';
count.textContent = translate('chat.taskProgressStep', '第 {{current}} / {{total}} 步', {
current: progress.activeStep,
total: progress.total
});
trigger.appendChild(count);
trigger.addEventListener('click', () => {
state.expanded = !state.expanded;
render(true);
if (state.expanded) host.querySelector('.agent-plan-progress-trigger')?.focus();
});
host.append(panel, trigger);
}
function currentConversationId() {
return String(root.currentConversationId || '').trim();
}
function setConversation(conversationId) {
const next = String(conversationId || '').trim();
if (next === state.conversationId) return false;
state.conversationId = next;
state.tasks = [];
state.signature = '';
state.expanded = false;
state.finalHoldUntil = 0;
state.taskCalls.clear();
resetPassiveHover();
state.requestSequence += 1;
if (state.abortController) state.abortController.abort();
state.abortController = null;
render(true);
return true;
}
async function fetchPlanTasks() {
const conversationId = currentConversationId();
setConversation(conversationId);
if (!conversationId || root.document.hidden) return;
const sequence = ++state.requestSequence;
if (state.abortController) state.abortController.abort();
const controller = new AbortController();
state.abortController = controller;
try {
const fetcher = typeof root.apiFetch === 'function' ? root.apiFetch : root.fetch.bind(root);
const response = await fetcher('/api/conversations/' + encodeURIComponent(conversationId) + '/plan-tasks', {
signal: controller.signal
});
if (!response.ok) throw new Error('HTTP ' + response.status);
const payload = await response.json();
if (sequence !== state.requestSequence || conversationId !== state.conversationId) return;
if (payload && payload.running === false) {
state.tasks = [];
state.expanded = false;
state.finalHoldUntil = 0;
render(false);
return;
}
const tasks = deriveProgress(payload && payload.tasks).tasks;
if (!tasks.length && state.tasks.length && Date.now() < state.finalHoldUntil) return;
state.tasks = tasks;
render(false);
} catch (error) {
if (error && error.name === 'AbortError') return;
// A task list is supplemental UI; a transient poll failure must not
// interfere with the chat or erase the last known task state.
} finally {
if (sequence === state.requestSequence) state.abortController = null;
}
}
function scheduleRefresh(delay) {
root.clearTimeout(state.refreshTimer);
state.refreshTimer = root.setTimeout(fetchPlanTasks, Number(delay) || 0);
}
function handlePlanToolEvent(event) {
const detail = event && event.detail && typeof event.detail === 'object' ? event.detail : {};
const conversationId = String(detail.conversationId || '').trim();
if (!conversationId || conversationId !== currentConversationId()) return;
const data = detail.data && typeof detail.data === 'object' ? detail.data : {};
const toolName = String(data.toolName || '').trim().toLowerCase();
if (!PLAN_TOOL_NAMES.has(toolName)) return;
const callId = String(data.toolCallId || '').trim();
if (detail.eventType === 'tool_call') {
if (callId) state.taskCalls.set(callId, { toolName, args: data.argumentsObj || {} });
scheduleRefresh(60);
return;
}
if (detail.eventType !== 'tool_result') return;
const call = callId ? state.taskCalls.get(callId) : null;
if (callId) state.taskCalls.delete(callId);
if (data.success !== false && call && call.toolName === 'taskupdate') {
state.tasks = applyTaskUpdate(state.tasks, call.args);
const progress = deriveProgress(state.tasks);
if (progress.allCompleted) state.finalHoldUntil = Date.now() + FINAL_STATE_HOLD_MS;
render(false);
}
scheduleRefresh(100);
}
root.addEventListener('agent-plan-task-event', handlePlanToolEvent);
root.addEventListener('conversation-changed', (event) => {
setConversation(event && event.detail ? event.detail.conversationId : currentConversationId());
scheduleRefresh(0);
});
root.document.addEventListener('visibilitychange', () => {
if (!root.document.hidden) scheduleRefresh(0);
});
root.document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape' || !state.expanded) return;
state.expanded = false;
render(true);
});
root.document.addEventListener('pointerdown', (event) => {
if (!state.expanded || host.contains(event.target)) return;
state.expanded = false;
render(true);
});
setConversation(currentConversationId());
fetchPlanTasks();
state.pollTimer = root.setInterval(fetchPlanTasks, POLL_INTERVAL_MS);
})(typeof window !== 'undefined' ? window : globalThis);
+74
View File
@@ -0,0 +1,74 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const { deriveProgress, applyTaskUpdate } = require('./chat-plan-progress.js');
test('任务进度优先定位进行中步骤并保留完成项', () => {
const progress = deriveProgress([
{ id: '1', subject: '梳理需求', status: 'completed' },
{ id: '2', subject: '实现组件', activeForm: '正在实现组件', status: 'in_progress' },
{ id: '3', subject: '浏览器验证', status: 'pending' }
]);
assert.equal(progress.activeStep, 2);
assert.equal(progress.completed, 1);
assert.equal(progress.total, 3);
assert.equal(progress.allCompleted, false);
});
test('TaskUpdate 成功后即时勾选,最终步骤显示全部完成', () => {
const initial = [
{ id: '1', subject: '接口', status: 'completed' },
{ id: '2', subject: '界面', status: 'in_progress' }
];
const updated = applyTaskUpdate(initial, { taskId: '2', status: 'completed' });
const progress = deriveProgress(updated);
assert.equal(progress.activeStep, 2);
assert.equal(progress.completed, 2);
assert.equal(progress.allCompleted, true);
});
test('删除任务不会出现在悬浮清单中', () => {
const tasks = applyTaskUpdate([
{ id: '1', subject: '保留', status: 'pending' },
{ id: '2', subject: '删除', status: 'pending' }
], { taskId: '2', status: 'deleted' });
assert.deepEqual(tasks.map((task) => task.id), ['1']);
});
test('任务进度样式跟随系统主题变量而非固定深色', () => {
const css = fs.readFileSync('web/static/css/chat-plan-progress.css', 'utf8');
assert.match(css, /--agent-plan-surface:\s*var\(--card-bg\)/);
assert.match(css, /background:\s*var\(--agent-plan-surface\)/);
assert.match(css, /color:\s*var\(--agent-plan-text\)/);
assert.doesNotMatch(css, /background:\s*#(?:292929|2b2b2b|303030)/i);
});
test('回到最新按钮与任务进度同时显示时采用上下避让布局', () => {
const css = fs.readFileSync('web/static/css/chat-plan-progress.css', 'utf8');
const template = fs.readFileSync('web/templates/index.html', 'utf8');
assert.match(css, /\.chat-return-latest:not\(\[hidden\]\)\s*\+\s*\.agent-plan-progress:not\(\[hidden\]\)/);
assert.match(css, /--agent-plan-trigger-bottom:\s*64px/);
assert.match(css, /--agent-plan-panel-bottom:\s*120px/);
assert.match(css, /bottom:\s*var\(--agent-plan-trigger-bottom\)/);
assert.match(css, /bottom:\s*var\(--agent-plan-panel-bottom\)/);
assert.match(template, /<button[^>]+id="chat-return-latest"[\s\S]*?<\/button>\s*<div id="agent-plan-progress"/);
});
test('计划详情只在真实鼠标移动或主动操作后展开', () => {
const css = fs.readFileSync('web/static/css/chat-plan-progress.css', 'utf8');
const source = fs.readFileSync('web/static/js/chat-plan-progress.js', 'utf8');
assert.doesNotMatch(css, /\.agent-plan-progress:hover\s+\.agent-plan-progress-panel/);
assert.match(css, /\.agent-plan-progress\.is-hover-active\s+\.agent-plan-progress-panel/);
assert.match(source, /host\.addEventListener\('pointermove',\s*armHoverAfterPointerMove\)/);
assert.match(source, /returnLatestButton\.addEventListener\('pointerdown',\s*disarmPassiveHover\)/);
assert.match(source, /passiveHoverAnchor = \{ x: event\.clientX, y: event\.clientY \}/);
assert.match(source, /event\.clientX === passiveHoverAnchor\.x && event\.clientY === passiveHoverAnchor\.y/);
assert.match(source, /host\.classList\.remove\('is-hover-active'\)/);
});
test('服务端判定任务停止后立即清空旧任务卡片', () => {
const source = fs.readFileSync('web/static/js/chat-plan-progress.js', 'utf8');
assert.match(source, /payload && payload\.running === false/);
assert.match(source, /state\.tasks = \[\][\s\S]{0,160}state\.expanded = false/);
});
+2 -2
View File
@@ -342,7 +342,7 @@ test('刷新指定对话时立即恢复且加载完成前不闪出无项目状
assert.match(css, /\.chat-container\.is-conversation-restoring #chat-messages/);
assert.match(css, /\.chat-container\.is-conversation-restoring #chat-input-container/);
assert.match(html, /router\.js\?v=20260813-2/);
assert.match(html, /chat\.js\?v=20260813-3/);
assert.match(html, /chat\.js\?v=20260813-4/);
});
test('刷新运行中回复会复用已持久化 planning 并继续追加未来增量', () => {
@@ -394,5 +394,5 @@ test('暗色模式对话三点悬浮不会触发浅色父行背景', () => {
const css = fs.readFileSync('web/static/css/style.css', 'utf8');
assert.match(css, /html\[data-theme="dark"\] \.project-conversation-row:hover \.project-conversation-item/);
assert.match(css, /html\[data-theme="dark"\] \.project-folder-action:hover,[\s\S]*?background: rgba\(71, 85, 105, 0\.28\);[\s\S]*?box-shadow: none;/);
assert.match(html, /style\.css\?v=20260813-5/);
assert.match(html, /style\.css\?v=20260813-6/);
});
+15
View File
@@ -314,6 +314,16 @@ function mountChatSessionSettingsPopover() {
composerSurface.appendChild(wrap);
}
wrap.classList.add('chat-session-settings-popover');
syncChatSessionSettingsLayerState();
}
function syncChatSessionSettingsLayerState() {
const wrap = document.getElementById('chat-reasoning-wrapper');
const inputBar = document.getElementById('chat-input-container');
if (!inputBar) return;
const open = !!wrap && wrap.style.display !== 'none' &&
!wrap.classList.contains('conversation-reasoning-collapsed');
inputBar.classList.toggle('is-session-settings-open', open);
}
function initChatReasoningBarHeightSync() {
@@ -1574,6 +1584,7 @@ function openChatSessionSettings(section, event) {
if (!wrap || !toggle || wrap.style.display === 'none') return;
syncChatReasoningBarHeight();
wrap.classList.remove('conversation-reasoning-collapsed');
syncChatSessionSettingsLayerState();
toggle.setAttribute('aria-expanded', 'true');
if (typeof closeAgentModePanel === 'function') closeAgentModePanel();
if (typeof closeRoleSelectionPanel === 'function') closeRoleSelectionPanel();
@@ -1732,6 +1743,7 @@ function closeChatReasoningPanel() {
const wrap = document.getElementById('chat-reasoning-wrapper');
const toggle = document.getElementById('conversation-reasoning-toggle');
if (wrap) wrap.classList.add('conversation-reasoning-collapsed');
syncChatSessionSettingsLayerState();
if (toggle) toggle.setAttribute('aria-expanded', 'false');
}
@@ -1741,6 +1753,7 @@ function toggleConversationReasoningCard() {
if (!wrap || !toggle) return;
syncChatReasoningBarHeight();
wrap.classList.toggle('conversation-reasoning-collapsed');
syncChatSessionSettingsLayerState();
const collapsed = wrap.classList.contains('conversation-reasoning-collapsed');
toggle.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
if (!collapsed) {
@@ -5434,6 +5447,7 @@ async function startNewConversation(options = {}) {
try {
window.currentConversationId = '';
} catch (e) { /* ignore */ }
window.dispatchEvent(new CustomEvent('conversation-changed', { detail: { conversationId: '' } }));
updateChatPrimaryActionState();
currentConversationGroupId = null; // 新对话不属于任何分组
// 顶部“新任务”继承当前文件夹;文件夹内的“+”仍可显式指定(包括无项目)。
@@ -5945,6 +5959,7 @@ async function loadConversation(conversationId) {
try {
window.currentConversationId = conversationId;
} catch (e) { /* ignore */ }
window.dispatchEvent(new CustomEvent('conversation-changed', { detail: { conversationId: conversationId } }));
updateChatPrimaryActionState();
if (typeof refreshChatProjectSelector === 'function') {
refreshChatProjectSelector({ reloadFolders: false, renderFolders: false });
+2 -2
View File
@@ -246,8 +246,8 @@ test('多对话并发时释放隐藏主流且旧请求不能覆盖新对话状
assert.match(chat, /signal: conversationLoadController\.signal/);
assert.match(template, /monitor\.js\?v=20260813-9/);
assert.match(template, /chat-scroll\.js\?v=20260813-6/);
assert.match(template, /chat\.js\?v=20260813-3/);
assert.match(template, /style\.css\?v=20260813-5/);
assert.match(template, /chat\.js\?v=20260813-4/);
assert.match(template, /style\.css\?v=20260813-6/);
});
test('输入区 Agent 审查文字保留足够行高且不会裁切字形', () => {
+18
View File
@@ -32,6 +32,7 @@ function setCurrentConversationIdFromStream(cid) {
currentConversationId = cid;
try {
window.currentConversationId = cid;
window.dispatchEvent(new CustomEvent('conversation-changed', { detail: { conversationId: cid } }));
if (typeof window.syncChatConversationHash === 'function') {
window.syncChatConversationHash(cid);
}
@@ -2591,6 +2592,22 @@ function formatEinoRunRetryTitle(data) {
return base;
}
function dispatchAgentPlanTaskEvent(event, fallbackConversationId) {
if (!event || (event.type !== 'tool_call' && event.type !== 'tool_result')) return;
const data = event.data && typeof event.data === 'object' ? event.data : {};
const toolName = String(data.toolName || '').trim().toLowerCase();
if (!['taskcreate', 'taskupdate', 'tasklist', 'taskget'].includes(toolName)) return;
const conversationId = String(data.conversationId || fallbackConversationId || window.currentConversationId || '').trim();
if (!conversationId) return;
window.dispatchEvent(new CustomEvent('agent-plan-task-event', {
detail: {
eventType: event.type,
conversationId: conversationId,
data: data
}
}));
}
// 处理流式事件
function handleStreamEvent(event, progressElement, progressId,
getAssistantId, setAssistantId, getMcpIds, setMcpIds, options) {
@@ -2619,6 +2636,7 @@ function handleStreamEvent(event, progressElement, progressId,
return;
}
}
dispatchAgentPlanTaskEvent(event, expectedConversationId || eventConversationId);
const streamScrollWasPinned = typeof window.captureScrollPinState === 'function'
? window.captureScrollPinState()
: (typeof window.isChatMessagesPinnedToBottom === 'function' ? window.isChatMessagesPinnedToBottom() : true);
+4 -1
View File
@@ -32,6 +32,7 @@
})();
</script>
<link rel="stylesheet" href="/static/css/style.css?v=20260813-6">
<link rel="stylesheet" href="/static/css/chat-plan-progress.css?v=20260813-4">
<link rel="stylesheet" href="/static/css/c2.css">
<link rel="stylesheet" href="/static/vendor/xterm.css">
<script src="/static/js/router.js?v=20260813-2"></script>
@@ -1180,6 +1181,7 @@
<span class="chat-return-latest-dot"></span>
</span>
</button>
<div id="agent-plan-progress" class="agent-plan-progress" hidden aria-live="polite"></div>
<div id="chat-input-container" class="chat-input-container">
<div class="chat-composer-context" aria-label="当前会话上下文">
<div class="chat-input-leading">
@@ -6820,7 +6822,8 @@
<script src="/static/js/dashboard.js"></script>
<script src="/static/js/chat-scroll.js?v=20260813-6"></script>
<script src="/static/js/monitor.js?v=20260813-9"></script>
<script src="/static/js/chat.js?v=20260813-3"></script>
<script src="/static/js/chat.js?v=20260813-4"></script>
<script src="/static/js/chat-plan-progress.js?v=20260813-3"></script>
<script src="/static/js/hitl.js?v=20260811-4"></script>
<script src="/static/js/settings.js?v=20260717-1"></script>
<script src="/static/js/audit-datetime-picker.js"></script>