diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bf81a46..9bfc112a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ ## Unreleased -- 项目管理集中到左侧:支持直接创建、重命名、查看文件和安全移除空项目;右侧入口改为“当前项目 / 全部文件”,专注项目内及跨项目文件浏览。 +- 项目管理集中到左侧:支持直接创建、重命名、查看文件和安全移除空项目;最近 3 条对话保持可见,项目树独立滚动,右侧入口改为“当前项目 / 全部文件”。 ## 0.72.1 — 2026-09-04 diff --git a/tests/frontend_files.test.mjs b/tests/frontend_files.test.mjs index 8cc88d32..6f36add4 100644 --- a/tests/frontend_files.test.mjs +++ b/tests/frontend_files.test.mjs @@ -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", () => { diff --git a/web/static/dev.html b/web/static/dev.html index 4ca460ab..e2d4a6fa 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -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 后断行 */ diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 8052c79a..4b5fcf61 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -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 ? ` -
-
${taskNavIcon("recent")}最近对话
- ${recent.map((t) => taskRowHtml(t, { compact: true })).join("")} +
+ +
${recent.map((t) => taskRowHtml(t, { compact: true })).join("")}
` : ""; 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) {
${projectItemsHtml(group)}
`; }).join(""); - const projectTitle = `
+ const projectTitle = `
项目
`; @@ -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");