feat(web): 优化项目导航滚动

This commit is contained in:
caoqianming 2026-09-04 15:03:52 +08:00
parent c754afcb7b
commit 4709e031f3
4 changed files with 90 additions and 19 deletions

View File

@ -8,7 +8,7 @@
## Unreleased ## Unreleased
- 项目管理集中到左侧:支持直接创建、重命名、查看文件和安全移除空项目;右侧入口改为“当前项目 / 全部文件”,专注项目内及跨项目文件浏览 - 项目管理集中到左侧:支持直接创建、重命名、查看文件和安全移除空项目;最近 3 条对话保持可见,项目树独立滚动,右侧入口改为“当前项目 / 全部文件”。
## 0.72.1 — 2026-09-04 ## 0.72.1 — 2026-09-04

View File

@ -40,6 +40,10 @@ test("task navigation groups chats by project with restrained icon sizes", () =>
assert.match(chatJs, /working_dir: projectName/); assert.match(chatJs, /working_dir: projectName/);
assert.match(chatJs, /state\.taskHasMore = filtered &&/); assert.match(chatJs, /state\.taskHasMore = filtered &&/);
assert.match(chatJs, /task-project-more/); assert.match(chatJs, /task-project-more/);
assert.match(chatJs, /Math\.min\(3, tasks\.length\)/);
assert.match(chatJs, /task-recent-toggle/);
assert.match(chatJs, /revealTaskInProject/);
assert.match(chatJs, /scroller\.onscroll = maybeLoadMore/);
assert.match(chatJs, /task-project-create/); assert.match(chatJs, /task-project-create/);
assert.doesNotMatch(chatJs, /if \(!folder\.n_tasks\) continue/); assert.doesNotMatch(chatJs, /if \(!folder\.n_tasks\) continue/);
assert.match(chatJs, /renameProjectFolder/); assert.match(chatJs, /renameProjectFolder/);
@ -53,6 +57,8 @@ test("task navigation groups chats by project with restrained icon sizes", () =>
assert.match(filesJs, /recursive: entries\.length > 0/); assert.match(filesJs, /recursive: entries\.length > 0/);
assert.match(pageHtml, /\.task-nav-icon \{ width: 15px; height: 15px;/); assert.match(pageHtml, /\.task-nav-icon \{ width: 15px; height: 15px;/);
assert.match(pageHtml, /\.task-project-head \.task-nav-icon \{ width: 16px; height: 16px;/); assert.match(pageHtml, /\.task-project-head \.task-nav-icon \{ width: 16px; height: 16px;/);
assert.match(pageHtml, /\.task-projects \{ flex: 1; min-height: 0; overflow-y: auto;/);
assert.match(pageHtml, /\.task-nav-recent \{ flex: none;/);
}); });
test("file rows render restrained type-specific svg icons", () => { test("file rows render restrained type-specific svg icons", () => {

View File

@ -561,10 +561,12 @@
#hd-admin:hover { background: var(--accent-soft); } #hd-admin:hover { background: var(--accent-soft); }
.pane { border-right: 1px solid var(--border); background: var(--panel); overflow: auto; min-height: 0; } .pane { border-right: 1px solid var(--border); background: var(--panel); overflow: auto; min-height: 0; }
/* 左 pane:flex column,顶部多行 pane-head 固定,只让 #task-scroll 滚 — 滚动条不再覆盖顶栏 */ /* 左 pane:flex column;最近对话固定,只有项目树或筛选结果拥有滚动条。 */
#pane-left { grid-area: left; display: flex; flex-direction: column; overflow: hidden; } #pane-left { grid-area: left; display: flex; flex-direction: column; overflow: hidden; }
#pane-left > .pane-head { flex-shrink: 0; } #pane-left > .pane-head { flex-shrink: 0; }
#task-scroll { flex: 1; min-height: 0; overflow: auto; } #task-scroll { flex: 1; min-height: 0; overflow: hidden; }
#task-list { height: 100%; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
#task-sentinel { display: none; }
/* min-height: 0 + overflow: hidden 让内部 flex 子项的 overflow: auto 真正生效(否则被默认 min-height: auto 顶出) */ /* min-height: 0 + overflow: hidden 让内部 flex 子项的 overflow: auto 真正生效(否则被默认 min-height: auto 顶出) */
#pane-mid { grid-area: mid; display: flex; flex-direction: column; border-right: 1px solid var(--border); background: var(--panel); min-height: 0; min-width: 0; overflow: hidden; position: relative; } #pane-mid { grid-area: mid; display: flex; flex-direction: column; border-right: 1px solid var(--border); background: var(--panel); min-height: 0; min-width: 0; overflow: hidden; position: relative; }
/* 右侧工作区:页签头固定,文件 / 作业面板各自管理内部滚动 */ /* 右侧工作区:页签头固定,文件 / 作业面板各自管理内部滚动 */
@ -682,14 +684,20 @@
.task-row-compact .meta.stats { justify-content: flex-end; margin-top: 1px; } .task-row-compact .meta.stats { justify-content: flex-end; margin-top: 1px; }
.task-row-compact .task-menu { width: 28px; height: 28px; padding: 0; align-items: center; justify-content: center; } .task-row-compact .task-menu { width: 28px; height: 28px; padding: 0; align-items: center; justify-content: center; }
.task-row-nested { margin-left: 22px; } .task-row-nested { margin-left: 22px; }
.task-nav-recent { padding: 8px 0 6px; border-bottom: 1px solid var(--border-soft); } .task-nav-recent { flex: none; padding: 8px 0 6px; border-bottom: 1px solid var(--border-soft); }
.task-nav-section-title { min-height: 28px; padding: 4px 12px; display: flex; align-items: center; gap: 7px; .task-nav-section-title { min-height: 28px; padding: 4px 12px; display: flex; align-items: center; gap: 7px;
color: var(--muted); font-size: 11px; font-weight: 600; letter-spacing: .02em; } color: var(--muted); font-size: 11px; font-weight: 600; letter-spacing: .02em; }
.task-recent-toggle { width: 100%; border: 0; background: transparent; text-align: left; cursor: pointer; }
.task-recent-toggle:hover { color: var(--text); }
.task-recent-chevron { font-size: 14px; transition: transform .14s ease; }
.task-nav-recent.collapsed .task-recent-chevron { transform: rotate(-90deg); }
.task-nav-recent.collapsed .task-recent-items { display: none; }
.task-nav-section-title .spacer { flex: 1; } .task-nav-section-title .spacer { flex: 1; }
.task-project-create { width: 24px; height: 24px; padding: 0; border: 0; border-radius: 5px; .task-project-create { width: 24px; height: 24px; padding: 0; border: 0; border-radius: 5px;
background: transparent; color: var(--muted); font-size: 17px; line-height: 1; cursor: pointer; } background: transparent; color: var(--muted); font-size: 17px; line-height: 1; cursor: pointer; }
.task-project-create:hover { background: var(--hover); color: var(--text); } .task-project-create:hover { background: var(--hover); color: var(--text); }
.task-projects { padding: 6px 0 10px; } .task-project-section-title { flex: none; }
.task-projects { flex: 1; min-height: 0; overflow-y: auto; padding: 6px 0 10px; scrollbar-gutter: stable; }
.task-project { margin: 1px 0; } .task-project { margin: 1px 0; }
.task-project-row { display: flex; align-items: center; } .task-project-row { display: flex; align-items: center; }
.task-project-head { flex: 1; min-width: 0; min-height: 34px; padding: 4px 4px 4px 8px; border: 0; border-radius: 0; .task-project-head { flex: 1; min-width: 0; min-height: 34px; padding: 4px 4px 4px 8px; border: 0; border-radius: 0;
@ -715,7 +723,7 @@
text-align: left; cursor: pointer; border-radius: 4px; } text-align: left; cursor: pointer; border-radius: 4px; }
.task-project-more:hover, .task-project-retry:hover { background: var(--hover); color: var(--text); } .task-project-more:hover, .task-project-retry:hover { background: var(--hover); color: var(--text); }
.task-project-retry { color: var(--danger); } .task-project-retry { color: var(--danger); }
.task-filter-results { padding: 5px 0 10px; } .task-filter-results { flex: 1; min-height: 0; overflow-y: auto; padding: 5px 0 10px; scrollbar-gutter: stable; }
.task-row .desc { font-weight: 500; color: var(--text); margin-bottom: 2px; .task-row .desc { font-weight: 500; color: var(--text); margin-bottom: 2px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* meta 行:flex nowrap + 每个子项 nowrap,防 CJK 字符在窄 pane(320px)被 shrink 后断行 */ /* meta 行:flex nowrap + 每个子项 nowrap,防 CJK 字符在窄 pane(320px)被 shrink 后断行 */

View File

@ -224,6 +224,8 @@ function syncTaskRowRunIndicator(tid) {
} }
const TASK_PROJECTS_EXPANDED_KEY = "zcbot-task-projects-expanded-v2"; const TASK_PROJECTS_EXPANDED_KEY = "zcbot-task-projects-expanded-v2";
const TASK_RECENT_COLLAPSED_KEY = "zcbot-task-recent-collapsed-v1";
const _compactRecentMedia = window.matchMedia("(max-height: 700px)");
function taskProject(t) { function taskProject(t) {
const path = t.working_dir || ""; const path = t.working_dir || "";
@ -289,6 +291,11 @@ function expandedTaskProjects() {
} }
} }
function recentTasksCollapsed() {
const saved = localStorage.getItem(TASK_RECENT_COLLAPSED_KEY);
return saved === null ? _compactRecentMedia.matches : saved === "1";
}
function taskProjectGroups(tasks) { function taskProjectGroups(tasks) {
const loadedGroups = new Map(); const loadedGroups = new Map();
for (const task of tasks) { for (const task of tasks) {
@ -338,11 +345,15 @@ function groupedTaskListHtml(tasks) {
const expanded = expandedTaskProjects(); const expanded = expandedTaskProjects();
const activeProject = state.taskId && state.tasksById[state.taskId] const activeProject = state.taskId && state.tasksById[state.taskId]
? taskProject(state.tasksById[state.taskId]).key : ""; ? taskProject(state.tasksById[state.taskId]).key : "";
const recent = tasks.slice(0, Math.min(4, tasks.length)); const recent = tasks.slice(0, Math.min(3, tasks.length));
const recentCollapsed = recentTasksCollapsed();
const recentHtml = recent.length ? ` const recentHtml = recent.length ? `
<section class="task-nav-recent"> <section class="task-nav-recent${recentCollapsed ? " collapsed" : ""}">
<div class="task-nav-section-title">${taskNavIcon("recent")}<span>最近对话</span></div> <button class="task-nav-section-title task-recent-toggle" type="button" aria-expanded="${!recentCollapsed}">
${recent.map((t) => taskRowHtml(t, { compact: true })).join("")} ${taskNavIcon("recent")}<span>最近对话</span><span class="spacer"></span>
<span class="task-recent-chevron" aria-hidden="true"></span>
</button>
<div class="task-recent-items">${recent.map((t) => taskRowHtml(t, { compact: true })).join("")}</div>
</section>` : ""; </section>` : "";
const projectsHtml = groups.map((group, index) => { const projectsHtml = groups.map((group, index) => {
const open = expanded === null ? (group.key === activeProject || index === 0) : expanded.has(group.key); const open = expanded === null ? (group.key === activeProject || index === 0) : expanded.has(group.key);
@ -363,7 +374,7 @@ function groupedTaskListHtml(tasks) {
<div class="task-project-items">${projectItemsHtml(group)}</div> <div class="task-project-items">${projectItemsHtml(group)}</div>
</section>`; </section>`;
}).join(""); }).join("");
const projectTitle = `<div class="task-nav-section-title"> const projectTitle = `<div class="task-nav-section-title task-project-section-title">
<span>项目</span><span class="spacer"></span> <span>项目</span><span class="spacer"></span>
<button id="task-project-create" class="task-project-create" type="button" title="创建项目" aria-label="创建项目"></button> <button id="task-project-create" class="task-project-create" type="button" title="创建项目" aria-label="创建项目"></button>
</div>`; </div>`;
@ -426,6 +437,7 @@ async function loadProjectTasks(projectName, { append = false } = {}) {
function renderTaskNavigation() { function renderTaskNavigation() {
const listEl = $("task-list"); const listEl = $("task-list");
const previousFilterScroll = listEl.querySelector(".task-filter-results")?.scrollTop || 0;
if (isTaskListFiltered()) { if (isTaskListFiltered()) {
const results = _globalTaskResults.length const results = _globalTaskResults.length
? _globalTaskResults.map((t) => taskRowHtml(t)).join("") ? _globalTaskResults.map((t) => taskRowHtml(t)).join("")
@ -435,13 +447,29 @@ function renderTaskNavigation() {
listEl.innerHTML = groupedTaskListHtml(_globalTaskResults); listEl.innerHTML = groupedTaskListHtml(_globalTaskResults);
} }
bindTaskListInteractions(listEl); bindTaskListInteractions(listEl);
const filterScroller = listEl.querySelector(".task-filter-results");
if (filterScroller) filterScroller.scrollTop = previousFilterScroll;
} }
export function refreshTaskProjectIndex() { export function refreshTaskProjectIndex() {
if (!isTaskListFiltered()) renderTaskNavigation(); if (!isTaskListFiltered()) renderTaskNavigation();
} }
_compactRecentMedia.addEventListener?.("change", () => {
if (localStorage.getItem(TASK_RECENT_COLLAPSED_KEY) === null && !isTaskListFiltered()) {
renderTaskNavigation();
}
});
function bindTaskListInteractions(root) { function bindTaskListInteractions(root) {
const recentToggle = root.querySelector(".task-recent-toggle");
if (recentToggle) recentToggle.onclick = () => {
const section = recentToggle.closest(".task-nav-recent");
const collapsed = !section.classList.contains("collapsed");
section.classList.toggle("collapsed", collapsed);
recentToggle.setAttribute("aria-expanded", String(!collapsed));
localStorage.setItem(TASK_RECENT_COLLAPSED_KEY, collapsed ? "1" : "0");
};
const createButton = root.querySelector("#task-project-create"); const createButton = root.querySelector("#task-project-create");
if (createButton) createButton.onclick = () => createProjectFolder(); if (createButton) createButton.onclick = () => createProjectFolder();
root.querySelectorAll(".task-project-menu").forEach((button) => { root.querySelectorAll(".task-project-menu").forEach((button) => {
@ -508,6 +536,16 @@ function bindTaskListInteractions(root) {
queueMicrotask(() => loadProjectTasks(section.dataset.projectKey)); queueMicrotask(() => loadProjectTasks(section.dataset.projectKey));
} }
} }
} else {
const scroller = root.querySelector(".task-filter-results");
if (scroller) {
const maybeLoadMore = () => {
const nearBottom = scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 200;
if (nearBottom && state.taskHasMore && !state.taskLoading) loadTaskList({ append: true });
};
scroller.onscroll = maybeLoadMore;
queueMicrotask(maybeLoadMore);
}
} }
} }
@ -670,16 +708,34 @@ $("filter-q").addEventListener("input", () => {
_filterDebounce = setTimeout(() => loadTaskList(), 300); _filterDebounce = setTimeout(() => loadTaskList(), 300);
}); });
// 滚动加载:只让 task 列表区域滚,顶部标题 / 新建 / 筛选 / 排序固定。 // 默认项目导航由各项目独立分页;筛选结果的续页监听绑定在其自身滚动容器上。
// rootMargin 提前 200px 触发,体感更顺;阈值 0 即可(刚进入即触发,append 期间 taskLoading 自带防抖)
const _taskScrollObserver = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && state.taskHasMore && !state.taskLoading) {
loadTaskList({ append: true });
}
}, { root: $("task-scroll"), rootMargin: "200px 0px" });
_taskScrollObserver.observe($("task-sentinel"));
// ───── select task ───── // ───── select task ─────
function revealTaskInProject(tid) {
if (isTaskListFiltered()) return;
const task = state.tasksById[tid];
if (!task) return;
const projectKey = taskProject(task).key;
const section = Array.from(document.querySelectorAll(".task-project"))
.find((item) => item.dataset.projectKey === projectKey);
if (!section) return;
if (!section.classList.contains("open")) {
section.classList.add("open");
section.querySelector(".task-project-head")?.setAttribute("aria-expanded", "true");
const expanded = expandedTaskProjects() || new Set();
expanded.add(projectKey);
localStorage.setItem(TASK_PROJECTS_EXPANDED_KEY, JSON.stringify(Array.from(expanded)));
if (projectKey !== "__ungrouped__" && !_projectTaskPages.has(projectKey)) {
void loadProjectTasks(projectKey);
}
}
requestAnimationFrame(() => {
const active = Array.from(document.querySelectorAll(".task-projects .task-row"))
.find((row) => row.dataset.tid === tid);
active?.scrollIntoView({ block: "nearest" });
});
}
function draftWorkingDir() { function draftWorkingDir() {
return (state.draftWorkingDir || "").trim(); return (state.draftWorkingDir || "").trim();
} }
@ -1057,6 +1113,7 @@ export async function selectTask(tid) {
document.querySelectorAll(".task-row").forEach((el) => { document.querySelectorAll(".task-row").forEach((el) => {
el.classList.toggle("active", el.dataset.tid === tid); el.classList.toggle("active", el.dataset.tid === tid);
}); });
revealTaskInProject(tid);
syncChannelCardActive(tid); // 渠道卡片 task 不在 .task-row 列表,单独同步高亮 syncChannelCardActive(tid); // 渠道卡片 task 不在 .task-row 列表,单独同步高亮
// 手机视图:选中任务自动切到对话面板(桌面 mqPhone 不命中 → no-op) // 手机视图:选中任务自动切到对话面板(桌面 mqPhone 不命中 → no-op)
if (mqPhone.matches) setMobileView("mv-mid"); if (mqPhone.matches) setMobileView("mv-mid");