feat(web): improve conversation side rails

This commit is contained in:
caoqianming 2026-09-01 08:54:03 +08:00
parent 38deea7d1d
commit 37aa83f8fb
5 changed files with 109 additions and 39 deletions

View File

@ -8,6 +8,8 @@
## Unreleased
- 对话两侧的软件作业与轮次导航改为更紧凑的一致样式,并增加常显的名称和数量;既可悬停速览,也可点击固定展开,状态和当前位置更清楚。
- DeepSeek Flash 的思考过程恢复实时显示,不再等到正文开始后才集中出现。
- Windows 专业软件节点启用完整的新管理界面节点状态、首次注册、软件环境、本机任务、ANSYS 验收和数据目录迁移均可直接管理;应用图标、导航、状态提示和窄窗口布局也更清晰,关闭窗口后仍会继续在托盘运行。

View File

@ -241,6 +241,10 @@ class StaticVendorTests(unittest.TestCase):
self.assertNotIn('<div class="role">科研助手</div>', frontend)
self.assertIn(".tool-call.activity > summary", css)
self.assertIn("#task-progress-dock .task-progress", css)
self.assertIn(".conversation-rail-caption", html)
self.assertIn("#msg-outline-rail.pinned .ol-label", html)
self.assertIn("点击固定展开或收起对话导航", frontend)
self.assertIn("点击固定展开或收起软件作业", frontend)
def test_frontend_surfaces_context_and_cache_stats(self) -> None:
src = _frontend_source()

View File

@ -843,56 +843,94 @@
0%, 100% { box-shadow: none; }
18% { box-shadow: 0 0 0 2px var(--accent), 0 0 0 6px var(--accent-soft); }
}
/* 消息目录:悬浮在对话右缘的圆点轨道。容器 pointer-events:none 让圆点间隙漏给
滚动条/正文(不挡滚动),只有圆点本身可点;hover 圆点整列展开标题(ChatGPT 式)。 */
/* 对话两侧索引轨共用紧凑视觉收起时只占一条窄边轨hover / 键盘聚焦后
展开可读标签。轨道本身不拦截正文滚动,只有条目可交互。 */
#msg-outline-rail {
position: absolute; right: 4px; top: 50%; transform: translateY(-50%);
position: absolute; right: 6px; top: 50%; transform: translateY(-50%);
max-height: 72%; display: flex; flex-direction: column; align-items: flex-end;
gap: 7px; padding: 8px 4px; overflow-y: auto; overflow-x: hidden; z-index: 5;
pointer-events: none; scrollbar-width: none;
gap: 3px; padding: 5px 3px; overflow-y: auto; overflow-x: hidden; z-index: 5;
border: 1px solid rgba(225, 227, 229, .82); border-radius: 10px;
background: rgba(255, 255, 255, .84); box-shadow: 0 2px 10px rgba(24, 32, 41, .06);
backdrop-filter: blur(8px); pointer-events: none; scrollbar-width: none;
transition: border-color .15s ease, box-shadow .15s ease;
}
#msg-outline-rail:hover, #msg-outline-rail:focus-within {
border-color: var(--border); box-shadow: 0 6px 20px rgba(24, 32, 41, .10);
}
#msg-outline-rail::-webkit-scrollbar { display: none; }
.ol-dot {
display: flex; align-items: center; justify-content: flex-end; gap: 8px;
background: transparent; border: none; padding: 0; cursor: pointer;
max-width: 22px; pointer-events: auto; transition: max-width .18s ease;
.conversation-rail-caption {
display: inline-flex; align-items: center; gap: 4px; min-height: 20px;
padding: 2px 6px; border: 0; border-radius: 6px; background: var(--panel-muted);
color: var(--muted); font-size: 10px; font-weight: 600; line-height: 1;
white-space: nowrap; pointer-events: auto;
}
#msg-outline-rail:hover .ol-dot { max-width: 240px; }
.conversation-rail-caption::after {
content: ""; width: 4px; height: 4px; border-right: 1.5px solid currentColor;
border-bottom: 1.5px solid currentColor; transform: rotate(45deg) translateY(-1px);
transition: transform .15s ease;
}
.conversation-rail-caption:hover, .conversation-rail-caption:focus-visible,
.pinned > .conversation-rail-caption {
color: var(--accent); background: var(--accent-soft);
}
.pinned > .conversation-rail-caption::after {
transform: rotate(225deg) translate(-1px, -1px);
}
.ol-dot {
display: flex; align-items: center; justify-content: flex-end; gap: 6px;
min-height: 16px; max-width: 18px; padding: 0; border: 0; border-radius: 7px;
background: transparent; cursor: pointer; pointer-events: auto;
transition: max-width .18s ease, background .15s ease;
}
#msg-outline-rail:hover .ol-dot, #msg-outline-rail:focus-within .ol-dot,
#msg-outline-rail.pinned .ol-dot { max-width: 246px; }
.ol-dot .ol-num { display: none; }
.ol-dot .ol-label {
font-size: 12px; line-height: 1.3; color: var(--muted); white-space: nowrap;
overflow: hidden; text-overflow: ellipsis; max-width: 0; opacity: 0;
background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
padding: 2px 8px; box-shadow: 0 1px 4px rgba(0,0,0,0.06);
transition: max-width .18s ease, opacity .18s ease;
padding: 1px 0; transition: max-width .18s ease, opacity .18s ease, padding .18s ease;
}
#msg-outline-rail:hover .ol-label, #msg-outline-rail:focus-within .ol-label,
#msg-outline-rail.pinned .ol-label {
max-width: 210px; opacity: 1; padding-left: 7px;
}
#msg-outline-rail:hover .ol-label { max-width: 210px; opacity: 1; }
.ol-dot::after {
content: ""; flex: none; width: 16px; height: 4px; border-radius: 3px;
content: ""; flex: none; width: 12px; height: 3px; border-radius: 3px;
background: var(--border); transition: background .15s ease, width .15s ease;
}
.ol-dot:hover::after { background: var(--muted); }
.ol-dot.active::after { background: var(--accent); width: 20px; }
.ol-dot:hover, .ol-dot:focus-visible { background: var(--panel-muted); }
.ol-dot:hover::after { background: #9aa0a6; }
.ol-dot.active .ol-label { color: var(--text-strong); font-weight: 600; }
.ol-dot.active::after { background: var(--accent); width: 16px; }
/* 专业软件结果轨:与右侧消息目录对称,按 Job 独立堆叠。折叠态只露状态点,
hover/focus 展开标题;结果与运行状态来自 software_jobs不复制进聊天消息。 */
#software-result-rail {
position: absolute; left: 4px; top: 50%; transform: translateY(-50%);
position: absolute; left: 6px; top: 50%; transform: translateY(-50%);
max-height: 72%; display: flex; flex-direction: column; align-items: flex-start;
gap: 7px; padding: 8px 4px; overflow-y: auto; overflow-x: hidden; z-index: 6;
pointer-events: none; scrollbar-width: none;
gap: 3px; padding: 5px 3px; overflow-y: auto; overflow-x: hidden; z-index: 6;
border: 1px solid rgba(225, 227, 229, .82); border-radius: 10px;
background: rgba(255, 255, 255, .84); box-shadow: 0 2px 10px rgba(24, 32, 41, .06);
backdrop-filter: blur(8px); pointer-events: none; scrollbar-width: none;
transition: border-color .15s ease, box-shadow .15s ease;
}
#software-result-rail:hover, #software-result-rail:focus-within {
border-color: var(--border); box-shadow: 0 6px 20px rgba(24, 32, 41, .10);
}
#software-result-rail::-webkit-scrollbar { display: none; }
#mobile-software-results { display: none; }
.sr-item {
display: flex; align-items: center; gap: 8px; max-width: 24px; min-height: 24px;
padding: 0; border: 0; background: transparent; color: var(--text); cursor: pointer;
pointer-events: auto; transition: max-width .18s ease;
display: flex; align-items: center; gap: 6px; max-width: 18px; min-height: 16px;
padding: 0; border: 0; border-radius: 7px; background: transparent;
color: var(--text); cursor: pointer; pointer-events: auto;
transition: max-width .18s ease, background .15s ease;
}
#software-result-rail:hover .sr-item,
#software-result-rail:focus-within .sr-item { max-width: 260px; }
#software-result-rail:focus-within .sr-item,
#software-result-rail.pinned .sr-item { max-width: 278px; }
.sr-item:hover, .sr-item:focus-visible { background: var(--panel-muted); }
.sr-item::before {
content: ""; flex: none; width: 10px; height: 10px; margin-left: 3px;
border: 2px solid var(--panel); border-radius: 50%; background: var(--border);
content: ""; flex: none; width: 8px; height: 8px; margin-left: 2px;
border: 1.5px solid var(--panel); border-radius: 50%; background: var(--border);
box-shadow: 0 0 0 1px var(--border);
}
.sr-item.running::before, .sr-item.queued::before, .sr-item.dispatched::before,
@ -905,18 +943,18 @@
.sr-item.cancelled::before { background: #8a929b; box-shadow: 0 0 0 1px #8a929b; }
@keyframes sr-pulse { 50% { opacity: .45; transform: scale(.82); } }
.sr-label {
min-width: 0; max-width: 0; opacity: 0; overflow: hidden; white-space: nowrap;
border: 1px solid transparent; border-radius: 10px; padding: 4px 0;
background: var(--panel); box-shadow: 0 2px 8px rgba(0,0,0,.08);
display: flex; align-items: baseline; gap: 6px; min-width: 0; max-width: 0;
opacity: 0; overflow: hidden; white-space: nowrap; padding: 1px 0;
text-align: left; transition: max-width .18s ease, opacity .18s ease, padding .18s ease;
}
#software-result-rail:hover .sr-label,
#software-result-rail:focus-within .sr-label {
max-width: 228px; opacity: 1; padding: 4px 8px; border-color: var(--border);
#software-result-rail:focus-within .sr-label,
#software-result-rail.pinned .sr-label {
max-width: 250px; opacity: 1; padding-right: 7px;
}
.sr-label strong, .sr-label small { display: block; overflow: hidden; text-overflow: ellipsis; }
.sr-label strong { font-size: 12px; font-weight: 600; }
.sr-label small { margin-top: 1px; color: var(--muted); font-size: 10px; }
.sr-label strong, .sr-label small { overflow: hidden; text-overflow: ellipsis; }
.sr-label strong { max-width: 142px; font-size: 12px; font-weight: 600; }
.sr-label small { max-width: 100px; color: var(--muted); font-size: 10px; }
/* 阅读宽度:assistant/system/tool 限到 ~48rem(约 60-80 字/行,长文不至于满屏铺开难回扫);
user 气泡更窄(36rem)。宽屏下提升可读性,窄屏 92% 仍生效(min 取小者) */
.msg { border: 1px solid var(--border); border-radius: var(--r-md); padding: 8px 12px; max-width: min(92%, 48rem); animation: msg-in .22s cubic-bezier(.2,.7,.2,1); scroll-margin-top: 16px; }

View File

@ -1257,12 +1257,17 @@ function renderOutlineRail() {
if (items.length < 2) { // 0/1 轮没必要显示目录
rail.style.display = "none";
rail.innerHTML = "";
rail.classList.remove("pinned");
return;
}
rail.style.display = "";
rail.innerHTML = items.map((it, i) => {
const pinned = rail.classList.contains("pinned");
const caption = `<button type="button" class="conversation-rail-caption"`
+ ` aria-expanded="${pinned}" title="点击固定展开或收起对话导航">导航 ${items.length}</button>`;
rail.innerHTML = caption + items.map((it, i) => {
const label = it.snippet || `${i + 1}`;
return `<button type="button" class="ol-dot" data-idx="${it.idx}" title="${escapeHtml(label)}">`
return `<button type="button" class="ol-dot" data-idx="${it.idx}" title="${escapeHtml(label)}"`
+ ` aria-label="跳转到第 ${i + 1} 轮:${escapeHtml(label)}">`
+ `<span class="ol-num">${i + 1}</span>`
+ `<span class="ol-label">${escapeHtml(label)}</span></button>`;
}).join("");
@ -1273,7 +1278,10 @@ function setActiveOutlineIdx(idx) {
const rail = $("msg-outline-rail");
if (!rail) return;
rail.querySelectorAll(".ol-dot").forEach((d) => {
d.classList.toggle("active", Number(d.dataset.idx) === Number(idx));
const active = Number(d.dataset.idx) === Number(idx);
d.classList.toggle("active", active);
if (active) d.setAttribute("aria-current", "step");
else d.removeAttribute("aria-current");
});
}
@ -1335,6 +1343,12 @@ $("chat-stream").addEventListener("scroll", () => {
const _outlineRailEl = $("msg-outline-rail");
if (_outlineRailEl) {
_outlineRailEl.addEventListener("click", (e) => {
const caption = e.target.closest(".conversation-rail-caption");
if (caption) {
const pinned = _outlineRailEl.classList.toggle("pinned");
caption.setAttribute("aria-expanded", String(pinned));
return;
}
const dot = e.target.closest(".ol-dot");
if (dot) jumpToMessage(Number(dot.dataset.idx));
});

View File

@ -153,13 +153,18 @@ function renderTaskResultRail() {
if (!state.taskId || taskJobsTaskId !== state.taskId || !taskJobs.length) {
rail.innerHTML = "";
rail.style.display = "none";
rail.classList.remove("pinned");
if (mobileResults) mobileResults.hidden = true;
return;
}
rail.innerHTML = taskJobs.map((job) => `
const pinned = rail.classList.contains("pinned");
rail.innerHTML = `<button type="button" class="conversation-rail-caption"`
+ ` aria-expanded="${pinned}" title="点击固定展开或收起软件作业">作业 ${taskJobs.length}</button>`
+ taskJobs.map((job) => `
<button type="button" class="sr-item ${escapeHtml(job.status)}"
data-result-job-id="${escapeHtml(job.job_id)}"
title="${escapeHtml(taskResultLabel(job))} · ${escapeHtml(taskResultMeta(job))}">
title="${escapeHtml(taskResultLabel(job))} · ${escapeHtml(taskResultMeta(job))}"
aria-label="打开软件作业:${escapeHtml(taskResultLabel(job))}${escapeHtml(taskResultMeta(job))}">
<span class="sr-label"><strong>${escapeHtml(taskResultLabel(job))}</strong>
<small>${escapeHtml(taskResultMeta(job))}</small></span>
</button>`).join("");
@ -168,6 +173,13 @@ function renderTaskResultRail() {
mobileResults.hidden = false;
mobileResults.textContent = `软件结果 ${taskJobs.length}`;
}
const caption = rail.querySelector(".conversation-rail-caption");
if (caption) {
caption.onclick = () => {
const next = rail.classList.toggle("pinned");
caption.setAttribute("aria-expanded", String(next));
};
}
rail.querySelectorAll("[data-result-job-id]").forEach((button) => {
button.onclick = () => void openTaskResult(button.dataset.resultJobId);
});