diff --git a/PROGRESS.md b/PROGRESS.md
index 321876d..aa72c4d 100644
--- a/PROGRESS.md
+++ b/PROGRESS.md
@@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 2-4 句:做了啥 + 关键判断 + 没动什么;细节查 `git log` / `git diff`。
-最后更新:2026-05-20(dev SPA 任务列表行 meta 加最近操作时间相对显示 + 新建弹框工作目录改下拉)
+最后更新:2026-05-20(dev SPA 任务列表换滚动加载 — 删 pager bar / 加 IntersectionObserver sentinel / 共 N 个 count badge)
---
@@ -23,6 +23,7 @@
### 2026-05-20
+- **dev SPA 左侧任务列表 pager bar → 滚动加载(ChatGPT/DeepSeek 范式)**:用户嫌底部分页 chrome 别扭。删 `#task-pager`(prev/next/info bar)+ `renderPager` + `resetPageAndReload`,改 `IntersectionObserver` on `#task-sentinel`(`#task-list` 后兄弟,`min-height:1px`),root = `#pane-left`(整 pane 是 scroll 容器,`.pane{overflow:auto}`)+ `rootMargin: 200px 0px` 提前 200px 触发体感更顺。`loadTaskList({append=false})` 双语义:reset 抢占式(filters / refresh / 写操作后,page=1 替换);`append=true` 仅 sentinel 触发,page+1 拼到底,受 `taskLoading || !taskHasMore` 互斥。**并发模型**:用 `_taskLoadSeq` token 让 reset 永远抢占 — 收到响应时若 `mySeq !== _taskLoadSeq` 整段 short-circuit return(也含 finally 的 `taskLoading=false`,避免 reset 在途时被 stale append 错误解锁),解决"append 在途时改筛选被丢"的旧 bug。**新增**:① 首 pane-head 加 `共 N 个` muted 小字补偿总数显示;② sentinel 文案三态(加载中… / — 已加载全部 — / 空字符串);③ `renderTaskList(tasks, append)` append 走 `
.innerHTML` 临时容器 + `appendChild` 不 clobber 已渲染行,事件 handler 只挂新行。**没动**:`/v1/tasks` 后端(本来就是标准分页 `{page,page_size,count,results}`)、page_size=20 默认、所有 7 处 `loadTaskList()` 旧调用点(默认 reset 语义与原行为等价)。**Tradeoff**:失"跳到第 N 页"但筛选 / 搜索 / 排序 + 滚动覆盖所有导航场景;失"当前页位置"但写操作后跳回顶端在 zcbot 任务规模(几十~几百)体感自然。
- **dev SPA 左侧任务列表行加「最近操作时间」**:用户要"显示最新操作时间"。`renderTaskList` 行 meta 区(badge / skill / N 条 / N tok / id-slice)在 id-slice 之前插一个 `
`,文案用新加的 `fmtTimeAgo(iso)` 相对时间 helper:`<60s`→刚刚 / `<1h`→N 分钟前 / 同日→N 小时前 / 昨日→昨天 HH:MM / 同年→MM-DD HH:MM / 跨年→YYYY-MM-DD,`title=` hover 出完整 `fmtTime` locale 串。`margin-left:auto` 从 id-slice 挪到时间 span(让两者一起靠右,中间 8px `.meta gap` 自然分隔)。字段用 `updated_at`(任务任何写操作 — 改名 / 新消息 / 状态切 — 都会更新,贴合"最新操作"语义),`/v1/tasks` payload 早已包含,后端零改。**没动**:左 pane 列表默认排序仍 `-created_at`(用户改排序顺序时另说);id-slice 保留(调试参考)。
- **dev SPA 新建任务弹框「工作目录」从 input + datalist 改 `
`;
}).join("");
- $("task-list").innerHTML = html;
- $("task-list").querySelectorAll(".task-row").forEach((el) => {
+ const listEl = $("task-list");
+ let newRows;
+ if (append) {
+ const tmp = document.createElement("div");
+ tmp.innerHTML = html;
+ newRows = Array.from(tmp.children);
+ newRows.forEach((el) => listEl.appendChild(el));
+ } else {
+ listEl.innerHTML = html;
+ newRows = Array.from(listEl.querySelectorAll(".task-row"));
+ }
+ newRows.forEach((el) => {
+ if (!el.classList || !el.classList.contains("task-row")) return;
el.onclick = (e) => {
if (e.target.closest(".dd-toggle")) return; // 菜单按钮点击不触发选中
selectTask(el.dataset.tid);
};
- });
- $("task-list").querySelectorAll(".task-menu").forEach((btn) => {
- btn.onclick = (e) => {
- e.stopPropagation();
- const t = state.tasksById[btn.dataset.tid];
- if (!t) return;
- showMenu(btn, taskMenuItems(t));
- };
+ const btn = el.querySelector(".task-menu");
+ if (btn) {
+ btn.onclick = (e) => {
+ e.stopPropagation();
+ const t = state.tasksById[btn.dataset.tid];
+ if (!t) return;
+ showMenu(btn, taskMenuItems(t));
+ };
+ }
});
}
@@ -1077,24 +1096,28 @@ function taskMenuItems(t) {
];
}
-// 任何筛选 / 排序变化都 reset page=1 重拉;刷新按钮保持当前页;翻页只动 page
-$("filter-status").onchange = resetPageAndReload;
-$("filter-order").onchange = resetPageAndReload;
-$("btn-refresh-tasks").onclick = loadTaskList;
-$("btn-prev-page").onclick = () => { if (state.taskPage > 1) { state.taskPage--; loadTaskList(); } };
-$("btn-next-page").onclick = () => {
- const lastPage = Math.max(1, Math.ceil(state.taskTotal / state.taskPageSize));
- if (state.taskPage < lastPage) { state.taskPage++; loadTaskList(); }
-};
+// 筛选 / 排序 / 刷新 一律 reset(loadTaskList 默认 append=false);追加由 sentinel observer 触发
+$("filter-status").onchange = () => loadTaskList();
+$("filter-order").onchange = () => loadTaskList();
+$("btn-refresh-tasks").onclick = () => loadTaskList();
// 搜索 / 工作目录筛选:debounce 300ms,避免每个字符都打 API
let _filterDebounce = null;
function scheduleFilter() {
clearTimeout(_filterDebounce);
- _filterDebounce = setTimeout(resetPageAndReload, 300);
+ _filterDebounce = setTimeout(() => loadTaskList(), 300);
}
$("filter-q").addEventListener("input", scheduleFilter);
$("filter-wd").addEventListener("input", scheduleFilter);
+
+// 滚动加载:左 pane 整体是 scroll 容器(.pane{overflow:auto}),用 #pane-left 作 root
+// rootMargin 提前 200px 触发,体感更顺;阈值 0 即可(刚进入即触发,append 期间 taskLoading 自带防抖)
+const _taskScrollObserver = new IntersectionObserver((entries) => {
+ if (entries[0].isIntersecting && state.taskHasMore && !state.taskLoading) {
+ loadTaskList({ append: true });
+ }
+}, { root: $("pane-left"), rootMargin: "200px 0px" });
+_taskScrollObserver.observe($("task-sentinel"));
// 工作目录输入框打开 enterApp 时拉一次 datalist(modal 也复用同一 list)
async function ensureFoldersLoaded() {
if ($("folders-datalist").children.length === 0) await loadFolderSuggestions();