feat(web): 优化项目导航滚动
This commit is contained in:
parent
c754afcb7b
commit
4709e031f3
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
- 项目管理集中到左侧:支持直接创建、重命名、查看文件和安全移除空项目;右侧入口改为“当前项目 / 全部文件”,专注项目内及跨项目文件浏览。
|
||||
- 项目管理集中到左侧:支持直接创建、重命名、查看文件和安全移除空项目;最近 3 条对话保持可见,项目树独立滚动,右侧入口改为“当前项目 / 全部文件”。
|
||||
|
||||
## 0.72.1 — 2026-09-04
|
||||
|
||||
|
|
|
|||
|
|
@ -40,6 +40,10 @@ test("task navigation groups chats by project with restrained icon sizes", () =>
|
|||
assert.match(chatJs, /working_dir: projectName/);
|
||||
assert.match(chatJs, /state\.taskHasMore = filtered &&/);
|
||||
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.doesNotMatch(chatJs, /if \(!folder\.n_tasks\) continue/);
|
||||
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(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-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", () => {
|
||||
|
|
|
|||
|
|
@ -561,10 +561,12 @@
|
|||
#hd-admin:hover { background: var(--accent-soft); }
|
||||
|
||||
.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 > .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 顶出) */
|
||||
#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 .task-menu { width: 28px; height: 28px; padding: 0; align-items: center; justify-content: center; }
|
||||
.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;
|
||||
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-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; }
|
||||
.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-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;
|
||||
|
|
@ -715,7 +723,7 @@
|
|||
text-align: left; cursor: pointer; border-radius: 4px; }
|
||||
.task-project-more:hover, .task-project-retry:hover { background: var(--hover); color: var(--text); }
|
||||
.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;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
/* meta 行:flex nowrap + 每个子项 nowrap,防 CJK 字符在窄 pane(320px)被 shrink 后断行 */
|
||||
|
|
|
|||
|
|
@ -224,6 +224,8 @@ function syncTaskRowRunIndicator(tid) {
|
|||
}
|
||||
|
||||
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) {
|
||||
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) {
|
||||
const loadedGroups = new Map();
|
||||
for (const task of tasks) {
|
||||
|
|
@ -338,11 +345,15 @@ function groupedTaskListHtml(tasks) {
|
|||
const expanded = expandedTaskProjects();
|
||||
const activeProject = state.taskId && state.tasksById[state.taskId]
|
||||
? 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 ? `
|
||||
<section class="task-nav-recent">
|
||||
<div class="task-nav-section-title">${taskNavIcon("recent")}<span>最近对话</span></div>
|
||||
${recent.map((t) => taskRowHtml(t, { compact: true })).join("")}
|
||||
<section class="task-nav-recent${recentCollapsed ? " collapsed" : ""}">
|
||||
<button class="task-nav-section-title task-recent-toggle" type="button" aria-expanded="${!recentCollapsed}">
|
||||
${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>` : "";
|
||||
const projectsHtml = groups.map((group, index) => {
|
||||
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>
|
||||
</section>`;
|
||||
}).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>
|
||||
<button id="task-project-create" class="task-project-create" type="button" title="创建项目" aria-label="创建项目">+</button>
|
||||
</div>`;
|
||||
|
|
@ -426,6 +437,7 @@ async function loadProjectTasks(projectName, { append = false } = {}) {
|
|||
|
||||
function renderTaskNavigation() {
|
||||
const listEl = $("task-list");
|
||||
const previousFilterScroll = listEl.querySelector(".task-filter-results")?.scrollTop || 0;
|
||||
if (isTaskListFiltered()) {
|
||||
const results = _globalTaskResults.length
|
||||
? _globalTaskResults.map((t) => taskRowHtml(t)).join("")
|
||||
|
|
@ -435,13 +447,29 @@ function renderTaskNavigation() {
|
|||
listEl.innerHTML = groupedTaskListHtml(_globalTaskResults);
|
||||
}
|
||||
bindTaskListInteractions(listEl);
|
||||
const filterScroller = listEl.querySelector(".task-filter-results");
|
||||
if (filterScroller) filterScroller.scrollTop = previousFilterScroll;
|
||||
}
|
||||
|
||||
export function refreshTaskProjectIndex() {
|
||||
if (!isTaskListFiltered()) renderTaskNavigation();
|
||||
}
|
||||
|
||||
_compactRecentMedia.addEventListener?.("change", () => {
|
||||
if (localStorage.getItem(TASK_RECENT_COLLAPSED_KEY) === null && !isTaskListFiltered()) {
|
||||
renderTaskNavigation();
|
||||
}
|
||||
});
|
||||
|
||||
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");
|
||||
if (createButton) createButton.onclick = () => createProjectFolder();
|
||||
root.querySelectorAll(".task-project-menu").forEach((button) => {
|
||||
|
|
@ -508,6 +536,16 @@ function bindTaskListInteractions(root) {
|
|||
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);
|
||||
});
|
||||
|
||||
// 滚动加载:只让 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 ─────
|
||||
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() {
|
||||
return (state.draftWorkingDir || "").trim();
|
||||
}
|
||||
|
|
@ -1057,6 +1113,7 @@ export async function selectTask(tid) {
|
|||
document.querySelectorAll(".task-row").forEach((el) => {
|
||||
el.classList.toggle("active", el.dataset.tid === tid);
|
||||
});
|
||||
revealTaskInProject(tid);
|
||||
syncChannelCardActive(tid); // 渠道卡片 task 不在 .task-row 列表,单独同步高亮
|
||||
// 手机视图:选中任务自动切到对话面板(桌面 mqPhone 不命中 → no-op)
|
||||
if (mqPhone.matches) setMobileView("mv-mid");
|
||||
|
|
|
|||
Loading…
Reference in New Issue