feat(web): consolidate conversation navigation

This commit is contained in:
caoqianming 2026-09-01 10:04:00 +08:00
parent 37aa83f8fb
commit 7917331e5a
6 changed files with 139 additions and 101 deletions

View File

@ -8,7 +8,7 @@
## Unreleased
- 对话两侧的软件作业与轮次导航改为更紧凑的一致样式,并增加常显的名称和数量;既可悬停速览,也可点击固定展开,状态和当前位置更清楚
- 软件作业与轮次导航统一收在对话右侧,并增加醒目的数量徽章;既可悬停速览,也可互斥固定展开,进行中或未读作业会直接通过数字提醒
- DeepSeek Flash 的思考过程恢复实时显示,不再等到正文开始后才集中出现。

View File

@ -242,9 +242,13 @@ class StaticVendorTests(unittest.TestCase):
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)
self.assertIn('id="conversation-side-rail"', html)
self.assertLess(html.index('id="msg-outline-rail"'), html.index('id="software-result-rail"'))
self.assertIn(".rail-count.attention", html)
self.assertIn("toggleConversationRailPinned", frontend)
self.assertIn("syncConversationSideRail", frontend)
self.assertIn("点击固定展开或收起导航", frontend)
self.assertIn("个新结果", frontend)
def test_frontend_surfaces_context_and_cache_stats(self) -> None:
src = _frontend_source()

View File

@ -843,95 +843,88 @@
0%, 100% { box-shadow: none; }
18% { box-shadow: 0 0 0 2px var(--accent), 0 0 0 6px var(--accent-soft); }
}
/* 对话两侧索引轨共用紧凑视觉收起时只占一条窄边轨hover / 键盘聚焦后
展开可读标签。轨道本身不拦截正文滚动,只有条目可交互。 */
#msg-outline-rail {
/* 对话辅助导航统一收在正文右缘:轮次在上、作业在下。默认只显示带数量的
文字入口;悬停可速览,点击后固定展开,且两组固定态互斥。 */
#conversation-side-rail {
position: absolute; right: 6px; top: 50%; transform: translateY(-50%);
max-height: 72%; display: flex; flex-direction: column; align-items: flex-end;
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;
z-index: 6; display: flex; flex-direction: column; width: max-content; min-width: 72px;
max-width: min(280px, calc(100% - 24px)); max-height: 72%; padding: 4px;
overflow-y: auto; overflow-x: hidden; border: 1px solid rgba(225, 227, 229, .86);
border-radius: 11px; background: rgba(255, 255, 255, .92);
box-shadow: 0 4px 16px rgba(24, 32, 41, .09); backdrop-filter: blur(8px);
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);
#conversation-side-rail[hidden], .conversation-rail-section[hidden],
#conversation-side-divider[hidden] { display: none !important; }
#conversation-side-rail:hover, #conversation-side-rail:focus-within {
border-color: var(--border); box-shadow: 0 7px 22px rgba(24, 32, 41, .12);
}
#conversation-side-rail::-webkit-scrollbar { display: none; }
#conversation-side-divider { height: 1px; margin: 4px 3px; background: var(--border-soft); }
.conversation-rail-section {
display: flex; flex-direction: column; align-items: stretch; gap: 2px; min-width: 62px;
}
#msg-outline-rail::-webkit-scrollbar { display: none; }
.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;
display: grid; grid-template-columns: 1fr auto 8px; align-items: center; gap: 4px;
width: 100%; min-height: 28px; padding: 3px 5px; border: 0; border-radius: 7px;
background: var(--panel-muted); color: var(--muted); font-size: 11px;
font-weight: 600; line-height: 1; white-space: nowrap;
}
.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;
.rail-caption-label { text-align: left; }
.rail-count {
display: inline-flex; align-items: center; justify-content: center; min-width: 20px; height: 20px;
padding: 0 4px;
border-radius: 7px; background: #eceeef; color: #60656b; font-size: 12px;
font-weight: 700; line-height: 1; font-variant-numeric: tabular-nums;
}
.conversation-rail-caption:hover, .conversation-rail-caption:focus-visible,
.pinned > .conversation-rail-caption {
#msg-outline-rail > .conversation-rail-caption,
.conversation-rail-section.pinned > .conversation-rail-caption {
color: var(--accent); background: var(--accent-soft);
}
.pinned > .conversation-rail-caption::after {
transform: rotate(225deg) translate(-1px, -1px);
#msg-outline-rail > .conversation-rail-caption .rail-count,
.conversation-rail-section.pinned > .conversation-rail-caption .rail-count:not(.attention) {
color: var(--accent); background: #fff; box-shadow: 0 0 0 1px rgba(184, 58, 49, .16);
}
.rail-count.attention {
color: #fff; background: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft);
}
.rail-chevron { color: currentColor; font-size: 9px; text-align: right; transition: transform .15s ease; }
.conversation-rail-section.pinned > .conversation-rail-caption .rail-chevron { transform: rotate(180deg); }
.ol-dot, .sr-item { display: none; }
#conversation-side-rail:not(.has-pinned) .conversation-rail-section:hover > .ol-dot,
#conversation-side-rail:not(.has-pinned) .conversation-rail-section:focus-within > .ol-dot,
#conversation-side-rail:not(.has-pinned) .conversation-rail-section:hover > .sr-item,
#conversation-side-rail:not(.has-pinned) .conversation-rail-section:focus-within > .sr-item,
.conversation-rail-section.pinned > .ol-dot,
.conversation-rail-section.pinned > .sr-item { display: flex; }
.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;
align-items: center; justify-content: flex-end; gap: 6px; min-height: 20px;
max-width: 246px; padding: 2px 5px; border: 0; border-radius: 5px;
background: transparent; cursor: pointer;
}
#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;
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;
max-width: 210px; overflow: hidden; color: var(--muted); font-size: 11px;
line-height: 1.3; text-overflow: ellipsis; white-space: nowrap;
}
.ol-dot::after {
content: ""; flex: none; width: 12px; height: 3px; border-radius: 3px;
background: var(--border); transition: background .15s ease, width .15s ease;
}
.ol-dot:hover, .ol-dot:focus-visible { background: var(--panel-muted); }
.ol-dot:hover, .ol-dot:focus-visible, .sr-item:hover, .sr-item: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: 6px; top: 50%; transform: translateY(-50%);
max-height: 72%; display: flex; flex-direction: column; align-items: flex-start;
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; }
.ol-dot.active::after { width: 16px; background: var(--accent); }
#mobile-software-results { display: none; }
.sr-item {
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;
align-items: center; gap: 6px; min-height: 20px; max-width: 278px; padding: 2px 5px;
border: 0; border-radius: 5px; background: transparent; color: var(--text); cursor: pointer;
}
#software-result-rail:hover .sr-item,
#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: 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);
content: ""; flex: none; width: 8px; height: 8px; 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,
.sr-item.offered::before, .sr-item.disconnected::before, .sr-item.cancelling::before {
@ -943,17 +936,11 @@
.sr-item.cancelled::before { background: #8a929b; box-shadow: 0 0 0 1px #8a929b; }
@keyframes sr-pulse { 50% { opacity: .45; transform: scale(.82); } }
.sr-label {
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,
#software-result-rail.pinned .sr-label {
max-width: 250px; opacity: 1; padding-right: 7px;
display: flex; align-items: baseline; gap: 6px; min-width: 0; max-width: 250px;
overflow: hidden; white-space: nowrap; text-align: left;
}
.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 strong { max-width: 142px; font-size: 11px; 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 取小者) */
@ -1790,10 +1777,8 @@
/* chat / 文件 微调 */
.msg { max-width: 96%; }
/* 手机端窄屏:目录轨道会挡正文 / 滚动,直接藏(覆盖 JS 的 inline display) */
#msg-outline-rail { display: none !important; }
/* 软件结果仍可从右侧“作业”页查看;窄屏不让悬浮轨遮挡正文。 */
#software-result-rail { display: none !important; }
/* 手机端窄屏:统一辅助导航会挡正文,直接藏;软件结果仍从“作业”页查看。 */
#conversation-side-rail { display: none !important; }
#chat-meta { padding: 6px 10px; gap: 6px; font-size: 11px; }
#chat-meta .tid { display: none; }
#chat-meta .desc {
@ -2263,10 +2248,12 @@
<div id="wd-concurrent-warn" style="display:none;"></div>
<div id="task-progress-dock"></div>
<div id="chat-stream"><div class="empty">请在左侧选一个任务</div></div>
<!-- 当前对话的软件结果轨Job 账本的轻量投影,不进入主消息流 -->
<div id="software-result-rail" aria-label="当前对话的软件结果" style="display:none;"></div>
<!-- 消息目录:悬浮在对话右缘的圆点轨道,每点 = 一轮提问;hover 出标题,点击定位 -->
<div id="msg-outline-rail" style="display:none;"></div>
<!-- 当前对话右缘的统一辅助导航:对话轮次在上、软件作业在下;两组互斥固定展开。 -->
<div id="conversation-side-rail" aria-label="当前对话辅助导航" hidden>
<div id="msg-outline-rail" class="conversation-rail-section" aria-label="对话导航" hidden></div>
<div id="conversation-side-divider" hidden></div>
<div id="software-result-rail" class="conversation-rail-section" aria-label="当前对话的软件作业" hidden></div>
</div>
<form id="chat-form" style="display:none;">
<div id="voice-rec-panel">
<div class="vr-row">

View File

@ -5,7 +5,7 @@
// 各入口模块顶层自绑;对外导出 loadTaskList / loadModels / selectTask
// (供 main enterApp、embed、files、newtask)。
import { state, LS_TASK_FILTERS_COLLAPSED } from "./state.js";
import { $, showMenu, showMenuAtPoint } from "./dom.js";
import { $, showMenu, showMenuAtPoint, syncConversationSideRail, toggleConversationRailPinned } from "./dom.js";
import { api } from "./api.js";
import { escapeHtml, fmtTime, fmtTokens, fmtTimeAgo, taskUsageTooltip, formatTaskUsage, formatContextStats, formatUsageStats, ctxRingHtml } from "./format.js";
import { renderMd, highlightIn, renderMermaidIn } from "./markdown.js";
@ -1255,15 +1255,18 @@ function renderOutlineRail() {
if (!rail) return; // embed 等精简页无此元素 → no-op
const items = state.outline || [];
if (items.length < 2) { // 0/1 轮没必要显示目录
rail.style.display = "none";
rail.hidden = true;
rail.innerHTML = "";
rail.classList.remove("pinned");
syncConversationSideRail();
return;
}
rail.style.display = "";
rail.hidden = false;
const pinned = rail.classList.contains("pinned");
const caption = `<button type="button" class="conversation-rail-caption"`
+ ` aria-expanded="${pinned}" title="点击固定展开或收起对话导航">导航 ${items.length}</button>`;
+ ` aria-expanded="${pinned}" title="${items.length} 轮对话 · 点击固定展开或收起导航">`
+ `<span class="rail-caption-label">导航</span><b class="rail-count">${items.length}</b>`
+ `<span class="rail-chevron" aria-hidden="true">⌄</span></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)}"`
@ -1271,6 +1274,7 @@ function renderOutlineRail() {
+ `<span class="ol-num">${i + 1}</span>`
+ `<span class="ol-label">${escapeHtml(label)}</span></button>`;
}).join("");
syncConversationSideRail();
updateActiveOutlineDot();
}
@ -1303,7 +1307,7 @@ function lockOutlineDuringJump() {
// 视口顶线以上的最后一个已加载 user 卡 = 当前轮,高亮对应圆点
function updateActiveOutlineDot() {
const rail = $("msg-outline-rail");
if (!rail || rail.style.display === "none") return;
if (!rail || rail.hidden) return;
if (_outlineJumpLock) return; // 显式跳转动画期间不抢
const wrap = $("chat-stream");
const items = state.outline || [];
@ -1345,8 +1349,8 @@ 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));
const pinned = toggleConversationRailPinned(_outlineRailEl);
if (!pinned) caption.blur();
return;
}
const dot = e.target.closest(".ol-dot");

View File

@ -3,6 +3,36 @@ import { escapeHtml } from "./format.js";
export const $ = (id) => document.getElementById(id);
// 对话区右缘的「导航 / 作业」共用一只容器。两个业务模块各自渲染内容,
// 这里仅负责可见性与互斥固定展开,避免 chat ↔ software_jobs 形成新的循环依赖。
export function syncConversationSideRail() {
const wrap = $("conversation-side-rail");
if (!wrap) return;
const sections = Array.from(wrap.querySelectorAll(".conversation-rail-section"));
const visible = sections.filter((section) => !section.hidden);
wrap.hidden = visible.length === 0;
const divider = $("conversation-side-divider");
if (divider) divider.hidden = visible.length < 2;
sections.filter((section) => section.hidden).forEach((section) => section.classList.remove("pinned"));
wrap.classList.toggle("has-pinned", visible.some((section) => section.classList.contains("pinned")));
}
export function toggleConversationRailPinned(section) {
const wrap = $("conversation-side-rail");
if (!wrap || !section) return false;
const wasPinned = section.classList.contains("pinned");
wrap.querySelectorAll(".conversation-rail-section").forEach((item) => {
item.classList.remove("pinned");
item.querySelector(".conversation-rail-caption")?.setAttribute("aria-expanded", "false");
});
if (!wasPinned) {
section.classList.add("pinned");
section.querySelector(".conversation-rail-caption")?.setAttribute("aria-expanded", "true");
}
wrap.classList.toggle("has-pinned", !wasPinned);
return !wasPinned;
}
// ───── floating dropdown menu (single instance) ─────
// 用 position: fixed 单例避免被 pane overflow 裁剪;按位置算出右上角对齐
let _menuItems = null;

View File

@ -1,7 +1,7 @@
// 软件作业面板:右侧工作区列表、游标分页、活动数与终态未读通知。
import { api } from "./api.js";
import { state } from "./state.js";
import { $ } from "./dom.js";
import { $, syncConversationSideRail, toggleConversationRailPinned } from "./dom.js";
import { escapeHtml } from "./format.js";
import {
openSoftwareJobResults,
@ -94,9 +94,9 @@ export function initSoftwareJobs() {
async function refreshTaskSoftwareResults(taskId) {
const seq = ++taskJobsLoadSeq;
taskJobsTaskId = taskId || "";
taskJobs = [];
renderTaskResultRail();
if (!taskJobsTaskId) {
taskJobs = [];
renderTaskResultRail();
return;
}
try {
@ -152,14 +152,25 @@ function renderTaskResultRail() {
if (!rail) return;
if (!state.taskId || taskJobsTaskId !== state.taskId || !taskJobs.length) {
rail.innerHTML = "";
rail.style.display = "none";
rail.hidden = true;
rail.classList.remove("pinned");
syncConversationSideRail();
if (mobileResults) mobileResults.hidden = true;
return;
}
const activeCount = taskJobs.filter((job) => ACTIVE.has(job.status)).length;
const unreadCount = taskJobs.filter((job) => unreadTerminal.has(job.job_id)).length;
const attention = activeCount > 0 || unreadCount > 0;
const attentionText = [
activeCount ? `${activeCount} 个进行中` : "",
unreadCount ? `${unreadCount} 个新结果` : "",
].filter(Boolean).join("");
const pinned = rail.classList.contains("pinned");
rail.innerHTML = `<button type="button" class="conversation-rail-caption"`
+ ` aria-expanded="${pinned}" title="点击固定展开或收起软件作业">作业 ${taskJobs.length}</button>`
+ ` aria-expanded="${pinned}" title="${taskJobs.length} 个作业${attentionText ? `${attentionText}` : ""} · 点击固定展开或收起">`
+ `<span class="rail-caption-label">作业</span>`
+ `<b class="rail-count${attention ? " attention" : ""}">${taskJobs.length}</b>`
+ `<span class="rail-chevron" aria-hidden="true">⌄</span></button>`
+ taskJobs.map((job) => `
<button type="button" class="sr-item ${escapeHtml(job.status)}"
data-result-job-id="${escapeHtml(job.job_id)}"
@ -168,7 +179,8 @@ function renderTaskResultRail() {
<span class="sr-label"><strong>${escapeHtml(taskResultLabel(job))}</strong>
<small>${escapeHtml(taskResultMeta(job))}</small></span>
</button>`).join("");
rail.style.display = "flex";
rail.hidden = false;
syncConversationSideRail();
if (mobileResults) {
mobileResults.hidden = false;
mobileResults.textContent = `软件结果 ${taskJobs.length}`;
@ -176,8 +188,8 @@ function renderTaskResultRail() {
const caption = rail.querySelector(".conversation-rail-caption");
if (caption) {
caption.onclick = () => {
const next = rail.classList.toggle("pinned");
caption.setAttribute("aria-expanded", String(next));
const pinned = toggleConversationRailPinned(rail);
if (!pinned) caption.blur();
};
}
rail.querySelectorAll("[data-result-job-id]").forEach((button) => {
@ -189,6 +201,7 @@ async function openTaskResult(jobId) {
const job = taskJobs.find((item) => item.job_id === jobId)
|| jobs.find((item) => item.job_id === jobId);
if (!job) return;
if (unreadTerminal.delete(jobId)) renderTaskResultRail();
const previews = Array.isArray(job.preview_manifest) ? job.preview_manifest : [];
if (job.status === "succeeded" && previews.length) {
try {