agent-loft 0.2.29 → 0.2.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +5 -0
  2. package/package.json +1 -1
  3. package/src/agents/claude/driver.ts +3 -2
  4. package/src/agents/claude/session.ts +11 -4
  5. package/src/agents/codex/session.ts +9 -3
  6. package/src/channels/feishu/bot.ts +8 -4
  7. package/src/channels/feishu/turn-stream.ts +3 -2
  8. package/src/channels/web/api.ts +4 -0
  9. package/src/channels/web/server.ts +14 -2
  10. package/src/channels/web/tasks-api.ts +94 -0
  11. package/src/core/chat-mode.ts +14 -14
  12. package/src/core/hub.ts +95 -16
  13. package/src/core/memory-mcp.ts +1 -1
  14. package/src/core/personal.ts +3 -1
  15. package/src/core/store.ts +2 -0
  16. package/src/core/task-prompt.ts +38 -0
  17. package/src/core/task-runner.ts +232 -0
  18. package/src/core/task-schedule.ts +250 -0
  19. package/src/core/tasks-bridge.ts +252 -0
  20. package/src/core/tasks-mcp.ts +55 -0
  21. package/src/core/tasks.ts +281 -0
  22. package/src/core/types.ts +2 -0
  23. package/src/main.ts +8 -0
  24. package/src/mcp/tasks-server.ts +206 -0
  25. package/web/app.js +8 -5560
  26. package/web/css/base.css +291 -0
  27. package/web/css/composer.css +506 -0
  28. package/web/css/loader.css +196 -0
  29. package/web/css/messages.css +734 -0
  30. package/web/css/mobile.css +100 -0
  31. package/web/css/overlay.css +437 -0
  32. package/web/css/schedule.css +93 -0
  33. package/web/css/settings.css +335 -0
  34. package/web/css/sidebar.css +593 -0
  35. package/web/css/tasks.css +455 -0
  36. package/web/css/topbar.css +205 -0
  37. package/web/index.html +12 -1
  38. package/web/lib/api.js +26 -0
  39. package/web/lib/dom.js +101 -0
  40. package/web/lib/markdown.js +52 -0
  41. package/web/lib/overlay.js +144 -0
  42. package/web/lib/push.js +70 -0
  43. package/web/lib/time.js +60 -0
  44. package/web/lib/ws.js +236 -0
  45. package/web/settings/agents.js +210 -0
  46. package/web/settings/commands.js +411 -0
  47. package/web/settings/common.js +42 -0
  48. package/web/settings/feishu.js +181 -0
  49. package/web/settings/general.js +184 -0
  50. package/web/settings/index.js +63 -0
  51. package/web/settings/memory.js +366 -0
  52. package/web/settings/notify.js +80 -0
  53. package/web/settings/security.js +188 -0
  54. package/web/settings/users.js +189 -0
  55. package/web/state.js +46 -0
  56. package/web/views/auth.js +61 -0
  57. package/web/views/composer.js +324 -0
  58. package/web/views/conv-actions.js +412 -0
  59. package/web/views/interaction.js +140 -0
  60. package/web/views/layout.js +197 -0
  61. package/web/views/messages.js +531 -0
  62. package/web/views/quota.js +145 -0
  63. package/web/views/schedule.js +120 -0
  64. package/web/views/sidebar.js +458 -0
  65. package/web/views/slash.js +185 -0
  66. package/web/views/tasks.js +460 -0
  67. package/web/views/topbar.js +400 -0
  68. package/web/views/voice.js +189 -0
  69. package/web/style.css +0 -3500
@@ -0,0 +1,120 @@
1
+ import { fill, h, icon, toast } from "../lib/dom.js";
2
+ import { api } from "../lib/api.js";
3
+ import { S, ui } from "../state.js";
4
+ import { quotaState } from "./quota.js";
5
+ import { modal } from "../lib/overlay.js";
6
+ import { autosize, ensureConversation, updateSendBtn } from "./composer.js";
7
+ import { currentAgentId, oneLine } from "./slash.js";
8
+
9
+ // ---------- 定时发送 ----------
10
+ // 额度用完、要等刷新才能接着干活时:先把下一条排好,到点由服务端发出(页面关了也会发)。只支持文字。
11
+
12
+ function fmtWhen(ms) {
13
+ const d = new Date(ms);
14
+ const now = new Date();
15
+ const hm = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
16
+ const day = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
17
+ const diff = Math.round((day(d) - day(now)) / 86400000);
18
+ if (diff === 0) return `今天 ${hm}`;
19
+ if (diff === 1) return `明天 ${hm}`;
20
+ if (diff === 2) return `后天 ${hm}`;
21
+ return `${d.getMonth() + 1}月${d.getDate()}日 ${hm}`;
22
+ }
23
+
24
+ /** <input type="datetime-local"> 要的本地时间格式 */
25
+ function toLocalInput(ms) {
26
+ const d = new Date(ms);
27
+ const p = (n) => String(n).padStart(2, "0");
28
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
29
+ }
30
+
31
+ /** 输入框上方:这条对话排着的定时消息,可以取消 */
32
+ export function renderScheduled() {
33
+ if (!ui.schedBar) return;
34
+ const list = (S.current && S.convs.get(S.current)?.scheduled) || [];
35
+ ui.schedBar.hidden = list.length === 0;
36
+ fill(ui.schedBar,
37
+ list.map((m) =>
38
+ h(
39
+ "div",
40
+ { class: "sched-item", title: m.text },
41
+ icon("clock"),
42
+ h("span", { class: "sched-when" }, fmtWhen(m.at)),
43
+ h("span", { class: "sched-text" }, oneLine(m.text, 200)),
44
+ h("button", { class: "sched-act", title: "改时间或内容", onclick: () => openSchedule(m) }, "编辑"),
45
+ h("button", { class: "sched-act", title: "取消这条定时消息", onclick: () => cancelScheduled(m.id) }, "取消"),
46
+ ),
47
+ ),
48
+ );
49
+ }
50
+
51
+ async function cancelScheduled(id, quiet) {
52
+ const key = S.current;
53
+ if (!key) return false;
54
+ try {
55
+ const info = await api(`/api/conversations/${encodeURIComponent(key)}/scheduled?id=${encodeURIComponent(id)}`, { method: "DELETE" });
56
+ S.convs.set(info.key, info);
57
+ renderScheduled();
58
+ return true;
59
+ } catch (err) {
60
+ if (!quiet) toast(err.message);
61
+ return false;
62
+ }
63
+ }
64
+
65
+ /** 选时间的弹窗。edit:改已经排着的那条(先删旧的再排新的) */
66
+ export function openSchedule(edit) {
67
+ if (!edit && S.uploads.length) return toast("定时发送暂不支持附件,先移除附件");
68
+ const text = h("textarea", { class: "sched-input", rows: 4, placeholder: "到点要发的内容" });
69
+ text.value = edit ? edit.text : ui.input.value;
70
+ const when = h("input", { type: "datetime-local", class: "sched-time" });
71
+ // 当前 agent 的额度窗口(5 小时 / 7 天)什么时候重置:小字提示,默认排在 5 小时窗口重置后 5 分钟
72
+ const agent = currentAgentId();
73
+ const windows = (quotaState.data ?? []).find((q) => q.agent === agent)?.windows ?? [];
74
+ const resets = windows.filter((w) => /^(5h|7d)$/.test(w.short) && w.resetsAt > Date.now());
75
+ const session = resets.find((w) => w.short === "5h");
76
+ when.value = toLocalInput(edit ? edit.at : session ? session.resetsAt + 5 * 60_000 : Date.now() + 5 * 3600_000);
77
+ const body = h(
78
+ "div",
79
+ { class: "modal-body sched-body" },
80
+ text,
81
+ h("label", { class: "sched-row" }, h("span", {}, "发送时间"), when),
82
+ h(
83
+ "div",
84
+ { class: "muted sched-hint" },
85
+ resets.length
86
+ ? `额度重置:${resets.map((w) => `${w.short === "5h" ? "5 小时" : "7 天"}额度 ${fmtWhen(w.resetsAt)}`).join(",")}。`
87
+ : "",
88
+ "到点由服务端发出,关掉页面也会发。",
89
+ ),
90
+ );
91
+ const go = h("button", { class: "btn primary" }, edit ? "保存" : "定时发送");
92
+ const close = modal({ title: edit ? "编辑定时消息" : "定时发送", body, foot: h("div", { class: "modal-foot" }, go) });
93
+ text.focus();
94
+ go.onclick = async () => {
95
+ const at = new Date(when.value).getTime();
96
+ if (!text.value.trim()) return toast("内容不能为空");
97
+ if (!Number.isFinite(at)) return toast("选一下发送时间");
98
+ if (at <= Date.now()) return toast("发送时间要在现在之后");
99
+ go.disabled = true;
100
+ try {
101
+ const key = await ensureConversation();
102
+ // 先排新的再删旧的:中途失败也不会把原来那条弄丢
103
+ const info = await api(`/api/conversations/${encodeURIComponent(key)}/scheduled`, { method: "POST", body: { text: text.value, at } });
104
+ S.convs.set(info.key, info);
105
+ if (edit) await cancelScheduled(edit.id, true);
106
+ renderScheduled();
107
+ // 用的是输入框里的草稿:排好了就清掉
108
+ if (!edit && text.value === ui.input.value) {
109
+ ui.input.value = "";
110
+ autosize();
111
+ updateSendBtn();
112
+ }
113
+ close();
114
+ toast(`已排好,${fmtWhen(at)} 发送`);
115
+ } catch (err) {
116
+ go.disabled = false;
117
+ toast(err.message);
118
+ }
119
+ };
120
+ }
@@ -0,0 +1,458 @@
1
+ import { fill, h, icon, ICON, toast } from "../lib/dom.js";
2
+ import { desktop, S, ui } from "../state.js";
3
+ import { baseName } from "./topbar.js";
4
+ import { newDraft, openConversation } from "./messages.js";
5
+
6
+ // ---------- 侧栏标题跑马灯:名字被省略时,hover 来回滚动显示全名 ----------
7
+
8
+ export const marquee = { el: null, raf: 0 };
9
+
10
+ export function startMarquee(el) {
11
+ stopMarquee();
12
+ const dist = el.scrollWidth - el.clientWidth;
13
+ if (dist <= 2 || matchMedia("(prefers-reduced-motion: reduce)").matches) return;
14
+ marquee.el = el;
15
+ el.classList.add("marquee");
16
+ const speed = 45; // px/s
17
+ const run = dist / speed * 1000;
18
+ const pause = 700;
19
+ const cycle = (pause + run) * 2;
20
+ let t0 = null;
21
+ const step = (now) => {
22
+ if (marquee.el !== el) return;
23
+ t0 ??= now;
24
+ const t = (now - t0) % cycle;
25
+ // 停 → 滚到末尾 → 停 → 滚回开头
26
+ let x;
27
+ if (t < pause) x = 0;
28
+ else if (t < pause + run) x = ((t - pause) / run) * dist;
29
+ else if (t < pause * 2 + run) x = dist;
30
+ else x = dist - ((t - pause * 2 - run) / run) * dist;
31
+ el.scrollLeft = x;
32
+ marquee.raf = requestAnimationFrame(step);
33
+ };
34
+ marquee.raf = requestAnimationFrame(step);
35
+ }
36
+
37
+ export function stopMarquee() {
38
+ cancelAnimationFrame(marquee.raf);
39
+ if (marquee.el) {
40
+ marquee.el.scrollLeft = 0;
41
+ marquee.el.classList.remove("marquee");
42
+ }
43
+ marquee.el = null;
44
+ }
45
+
46
+ // ---------- 侧栏宽度(桌面端可拖动) ----------
47
+
48
+ const SIDE_MIN = 200;
49
+ const SIDE_MAX = 520;
50
+
51
+ export function setSideWidth(w) {
52
+ if (w == null) {
53
+ document.documentElement.style.removeProperty("--side-w");
54
+ localStorage.removeItem("aim.sideW");
55
+ return;
56
+ }
57
+ const px = Math.round(Math.max(SIDE_MIN, Math.min(SIDE_MAX, w, innerWidth - 360)));
58
+ document.documentElement.style.setProperty("--side-w", `${px}px`);
59
+ return px;
60
+ }
61
+
62
+ {
63
+ const saved = Number(localStorage.getItem("aim.sideW"));
64
+ if (saved) setSideWidth(saved);
65
+ }
66
+
67
+ export function startSideResize(e) {
68
+ if (e.button !== 0) return;
69
+ e.preventDefault();
70
+ const handle = e.currentTarget;
71
+ const left = ui.sidebar.getBoundingClientRect().left;
72
+ let last = null;
73
+ handle.setPointerCapture(e.pointerId);
74
+ document.body.classList.add("side-resizing");
75
+ const move = (ev) => (last = setSideWidth(ev.clientX - left));
76
+ const up = () => {
77
+ handle.removeEventListener("pointermove", move);
78
+ handle.removeEventListener("pointerup", up);
79
+ handle.removeEventListener("pointercancel", up);
80
+ document.body.classList.remove("side-resizing");
81
+ if (last) localStorage.setItem("aim.sideW", String(last));
82
+ };
83
+ handle.addEventListener("pointermove", move);
84
+ handle.addEventListener("pointerup", up);
85
+ handle.addEventListener("pointercancel", up);
86
+ }
87
+
88
+ export function openSide() {
89
+ ui.layout.classList.add("side-open");
90
+ nudgeStatusBar();
91
+ }
92
+
93
+ export function closeSide() {
94
+ if (!ui.layout?.classList.contains("side-open")) return;
95
+ ui.layout.classList.remove("side-open");
96
+ nudgeStatusBar();
97
+ }
98
+
99
+ /**
100
+ * iOS 26 的 PWA 会按页面顶部的颜色给状态栏上色,而且元素变了不一定重算,
101
+ * 侧栏遮罩关掉后状态栏还是暗的。重设一遍 theme-color 逼它刷新。
102
+ */
103
+ function nudgeStatusBar() {
104
+ requestAnimationFrame(() => {
105
+ for (const meta of document.querySelectorAll('meta[name="theme-color"]')) {
106
+ const v = meta.getAttribute("content");
107
+ meta.setAttribute("content", "");
108
+ meta.setAttribute("content", v);
109
+ }
110
+ });
111
+ }
112
+
113
+ // ---------- 侧边栏 ----------
114
+
115
+ let sidebarScheduled = false;
116
+ export function scheduleSidebar() {
117
+ if (sidebarScheduled) return;
118
+ sidebarScheduled = true;
119
+ requestAnimationFrame(() => {
120
+ sidebarScheduled = false;
121
+ renderSidebar();
122
+ reportDesktop();
123
+ });
124
+ }
125
+
126
+ /**
127
+ * 把一组会话按分叉关系排成树:父对话也在这一组里的,挂到父对话下面;其余的是根。
128
+ * 节点 { c, kids, at },at 是整棵子树里最近的活跃时间。根和同级都按 at 从新到旧。
129
+ */
130
+ function forkForest(convs) {
131
+ const byKey = new Map(convs.map((c) => [c.key, c]));
132
+ const nodes = new Map(convs.map((c) => [c.key, { c, kids: [], at: c.activeAt }]));
133
+ // 父链里绕回自己(数据坏了)就当根处理,免得整串都不显示
134
+ const parentOf = (c) => {
135
+ if (!c.forkOf || !byKey.has(c.forkOf)) return null;
136
+ for (let p = byKey.get(c.forkOf), n = 0; p && n < 50; p = byKey.get(p.forkOf), n++) {
137
+ if (p.key === c.key) return null;
138
+ }
139
+ return c.forkOf;
140
+ };
141
+ const roots = [];
142
+ for (const c of convs) {
143
+ const parent = parentOf(c);
144
+ (parent ? nodes.get(parent).kids : roots).push(nodes.get(c.key));
145
+ }
146
+ const settle = (node) => {
147
+ for (const k of node.kids) node.at = Math.max(node.at, settle(k));
148
+ node.kids.sort((a, b) => b.at - a.at);
149
+ return node.at;
150
+ };
151
+ for (const r of roots) settle(r);
152
+ return roots.sort((a, b) => b.at - a.at);
153
+ }
154
+
155
+ /** 画一棵分叉树:主对话一行,分叉缩进挂在下面,用虚线连起来(样式见 .fork-node) */
156
+ function renderForkNode(node, row, cls = "") {
157
+ const self = row(node.c, !!node.c.forkOf && !node.parentShown);
158
+ if (node.kids.length === 0) return self;
159
+ return h(
160
+ "div",
161
+ { class: "fork-group" },
162
+ self,
163
+ h(
164
+ "div",
165
+ { class: `fork-children${cls}` },
166
+ node.kids.map((k) => h("div", { class: "fork-node" }, renderForkNode({ ...k, parentShown: true }, row))),
167
+ ),
168
+ );
169
+ }
170
+
171
+ /** 分叉出来的对话:提示里写上从哪条来的 */
172
+ function forkNote(c) {
173
+ if (!c.forkOf) return "";
174
+ const parent = S.convs.get(c.forkOf);
175
+ return `\n分叉自「${parent ? convTitle(parent) : "已删除的对话"}」`;
176
+ }
177
+
178
+ export function convTitle(c) {
179
+ if (!c) return "新对话";
180
+ if (c.title) return c.title;
181
+ if (c.channel === "feishu") return `飞书会话 ${c.key.split(":")[2]?.slice(-6) ?? ""}`;
182
+ return "新对话";
183
+ }
184
+
185
+ export function agentLabel(id) {
186
+ return S.agents.find((a) => a.id === id)?.label ?? id;
187
+ }
188
+
189
+ function agentShort(id) {
190
+ return id === "claude" ? "Claude" : id === "codex" ? "Codex" : id;
191
+ }
192
+
193
+ /** 侧栏里展开 / 收起的状态:哪些项目目录展开了、「最近」和各目录是否显示全部 */
194
+ export const sideState = { open: [], more: [], folded: [], order: [], ...JSON.parse(localStorage.getItem("aim.side") || "{}") };
195
+
196
+ /**
197
+ * 「项目」目录的顺序:没手动排过时按最近活跃;拖过之后按 sideState.order,
198
+ * 之后新出现的目录排在已排好的后面(同样按活跃),不会挤掉用户置顶的目录
199
+ */
200
+ function orderProjectDirs(dirs) {
201
+ const rank = new Map(sideState.order.map((d, i) => [d, i]));
202
+ const ranked = dirs.filter((d) => rank.has(d)).sort((a, b) => rank.get(a) - rank.get(b));
203
+ return [...ranked, ...dirs.filter((d) => !rank.has(d))];
204
+ }
205
+
206
+ /** 当前侧栏里显示的目录顺序(renderSidebar 时记下,拖拽 / 右键菜单用) */
207
+ export let projDirs = [];
208
+
209
+ /** 把目录 dir 挪到 target 前面(after 为 true 时挪到后面);target 为 null 表示挪到最前 */
210
+ export function moveProjectDir(dir, target, after = false) {
211
+ if (dir === target) return;
212
+ const list = projDirs.filter((d) => d !== dir);
213
+ const idx = target === null ? 0 : list.indexOf(target);
214
+ if (idx < 0) return;
215
+ list.splice(idx + (after ? 1 : 0), 0, dir);
216
+ // 暂时没显示出来的目录(会话都被隐藏了之类)保留原来的名次,排在后面
217
+ sideState.order = [...list, ...sideState.order.filter((d) => !list.includes(d))];
218
+ localStorage.setItem("aim.side", JSON.stringify(sideState));
219
+ renderSidebar();
220
+ }
221
+
222
+ export function resetProjectOrder() {
223
+ sideState.order = [];
224
+ localStorage.setItem("aim.side", JSON.stringify(sideState));
225
+ renderSidebar();
226
+ }
227
+
228
+ /** 正在拖的目录。拖的过程中侧栏不重绘(重绘会换掉被拖的元素,拖拽就断了),松手后补一次 */
229
+ let dirDrag = null;
230
+ let sidebarStale = false;
231
+
232
+ /** 「最近」「项目」分组标题:点击折叠 / 展开,hover 时显示箭头 */
233
+ function groupHead(label, id, folded) {
234
+ return h(
235
+ "button",
236
+ { class: `conv-group${folded ? " collapsed" : ""}`, title: folded ? "展开" : "折叠", onclick: () => toggleSide("folded", id) },
237
+ label,
238
+ h("span", { class: "chev", html: ICON.down }),
239
+ );
240
+ }
241
+ function toggleSide(list, id) {
242
+ const set = new Set(sideState[list]);
243
+ set.has(id) ? set.delete(id) : set.add(id);
244
+ sideState[list] = [...set];
245
+ localStorage.setItem("aim.side", JSON.stringify(sideState));
246
+ renderSidebar();
247
+ }
248
+
249
+ let autoOpenedFor = null;
250
+ const RECENT_LIMIT = 10;
251
+ const PROJECT_LIMIT = 5;
252
+
253
+ /** 待处理的授权 / 提问和正在跑的会话,报给桌面端显示在菜单栏里 */
254
+ export function reportDesktop() {
255
+ if (!desktop) return;
256
+ const pending = [...S.pending.values()]
257
+ .filter((i) => !i.settled)
258
+ .map((i) => ({ id: i.id, key: i.convKey, title: i.spec.title, conv: convTitle(S.convs.get(i.convKey)) }));
259
+ const busy = [...S.convs.values()].filter((c) => c.busy).length;
260
+ desktop.setState({ pending, busy });
261
+ }
262
+
263
+ export function renderSidebar() {
264
+ if (!ui.convList) return;
265
+ if (dirDrag) {
266
+ sidebarStale = true;
267
+ return;
268
+ }
269
+ sidebarStale = false;
270
+ ui.tasksNav?.classList.toggle("active", S.view === "tasks");
271
+ const askKeys = new Set([...S.pending.values()].filter((i) => !i.settled).map((i) => i.convKey));
272
+ const visible = [...S.convs.values()].filter((c) => !c.hidden).sort((a, b) => b.activeAt - a.activeAt);
273
+ const cur = S.current ? S.convs.get(S.current) : null;
274
+ const status = (keys) =>
275
+ keys.some((k) => askKeys.has(k))
276
+ ? h("span", { class: "dot ask", title: "等你处理" })
277
+ : keys.some((k) => S.convs.get(k)?.busy)
278
+ ? h("span", { class: "conv-spin", title: "进行中" })
279
+ : null;
280
+ const open = (key) => {
281
+ closeSide();
282
+ if (key !== S.current) void openConversation(key);
283
+ };
284
+ const more = (list, id, expanded) =>
285
+ h("button", { class: "conv-more", onclick: () => toggleSide(list, id) }, expanded ? "收起" : "展开显示");
286
+
287
+ // 一行会话。orphan:是分叉出来的、但父对话不在这一组里(删了 / 在别的目录),没有连线可画,用图标标一下
288
+ const convRow = (c, cls, detail, orphan) =>
289
+ h(
290
+ "button",
291
+ {
292
+ class: `conv-item${cls}${c.key === S.current ? " active" : ""}`,
293
+ "data-key": c.key,
294
+ title: `${convTitle(c)}${forkNote(c)}\n${detail}\n双击重命名,右键更多操作`,
295
+ onclick: () => open(c.key),
296
+ },
297
+ orphan ? h("span", { class: "brand-icon lead fork-mark", html: ICON.fork }) : null,
298
+ // 定时任务的专用对话
299
+ c.taskId ? h("span", { class: "brand-icon lead task-mark", title: "定时任务的对话", html: ICON.clock }) : null,
300
+ h("span", { class: "title" }, convTitle(c)),
301
+ status([c.key]),
302
+ );
303
+
304
+ // ---- 最近:Web 会话 + 每个飞书机器人一行(点开它最近活跃的聊天),按活跃时间混排 ----
305
+ // 分叉出来的对话挂在主对话下面,整棵树按里面最近活跃的那条排
306
+ const recent = [];
307
+ for (const node of forkForest(visible.filter((c) => c.channel !== "feishu"))) {
308
+ recent.push({ at: node.at, el: renderForkNode(node, (c, orphan) => convRow(c, "", `${agentLabel(c.agent)} · ${c.cwd}`, orphan)) });
309
+ }
310
+ for (const bot of S.bots) {
311
+ const convs = visible.filter((c) => c.channel === "feishu" && c.channelId === bot.id);
312
+ if (bot.enabled === false && convs.length === 0) continue;
313
+ const latest = convs[0];
314
+ recent.push({
315
+ at: latest?.activeAt ?? 0,
316
+ el: h(
317
+ "button",
318
+ {
319
+ class: `conv-item${cur?.channel === "feishu" && cur.channelId === bot.id ? " active" : ""}`,
320
+ "data-bot": bot.id,
321
+ title: latest ? `飞书 · ${bot.name}\n${agentLabel(latest.agent)} · ${latest.cwd}` : `飞书 · ${bot.name}\n还没有会话`,
322
+ onclick: () => (latest ? open(latest.key) : toast("这个机器人还没有会话,先在飞书里给它发条消息")),
323
+ },
324
+ h("span", { class: "brand-icon lead", title: "飞书", html: ICON.feishu }),
325
+ h("span", { class: "title" }, bot.name),
326
+ status(convs.map((c) => c.key)),
327
+ ),
328
+ });
329
+ }
330
+ recent.sort((a, b) => b.at - a.at);
331
+ const recentAll = sideState.more.includes("@recent");
332
+ const recentFolded = sideState.folded.includes("@recent");
333
+ const nodes = [groupHead("最近", "@recent", recentFolded)];
334
+ if (!recentFolded) {
335
+ if (recent.length === 0) nodes.push(h("div", { class: "conv-empty" }, "还没有会话"));
336
+ nodes.push(...recent.slice(0, recentAll ? recent.length : RECENT_LIMIT).map((r) => r.el));
337
+ if (recent.length > RECENT_LIMIT) nodes.push(more("more", "@recent", recentAll));
338
+ }
339
+
340
+ // ---- 项目:Web 会话按工作目录分组,目录按最近活跃排序 ----
341
+ const byDir = new Map();
342
+ for (const c of visible.filter((c) => c.channel === "web")) {
343
+ if (!byDir.has(c.cwd)) byDir.set(c.cwd, []);
344
+ byDir.get(c.cwd).push(c);
345
+ }
346
+ // 切到某个会话时,把它所在的目录展开一次(之后用户可以自己收起)
347
+ if (cur?.channel === "web" && cur.key !== autoOpenedFor) {
348
+ autoOpenedFor = cur.key;
349
+ if (!sideState.open.includes(cur.cwd)) {
350
+ sideState.open.push(cur.cwd);
351
+ localStorage.setItem("aim.side", JSON.stringify(sideState));
352
+ }
353
+ }
354
+ const projFolded = sideState.folded.includes("@projects");
355
+ if (byDir.size) nodes.push(groupHead("项目", "@projects", projFolded));
356
+ projDirs = orderProjectDirs([...byDir.keys()]);
357
+ if (byDir.size && !projFolded) {
358
+ const clearDrop = () => ui.convList.querySelectorAll(".drop-before, .drop-after").forEach((el) => el.classList.remove("drop-before", "drop-after"));
359
+ const endDrag = () => {
360
+ dirDrag = null;
361
+ clearDrop();
362
+ if (sidebarStale) renderSidebar();
363
+ };
364
+ for (const dir of projDirs) {
365
+ const convs = byDir.get(dir);
366
+ const expanded = sideState.open.includes(dir);
367
+ const showAll = sideState.more.includes(dir);
368
+ // 鼠标在目标行的上半截就插到它前面,下半截插到后面
369
+ const isAfter = (e, row) => {
370
+ const r = row.getBoundingClientRect();
371
+ return e.clientY > r.top + r.height / 2;
372
+ };
373
+ nodes.push(
374
+ h(
375
+ "div",
376
+ {
377
+ class: "proj-row",
378
+ "data-dir": dir,
379
+ draggable: "true",
380
+ ondragstart: (e) => {
381
+ dirDrag = dir;
382
+ e.dataTransfer.effectAllowed = "move";
383
+ e.dataTransfer.setData("text/plain", dir);
384
+ e.currentTarget.classList.add("dragging");
385
+ stopMarquee();
386
+ },
387
+ ondragend: (e) => {
388
+ e.currentTarget.classList.remove("dragging");
389
+ endDrag();
390
+ },
391
+ ondragover: (e) => {
392
+ if (!dirDrag) return;
393
+ e.preventDefault();
394
+ e.dataTransfer.dropEffect = "move";
395
+ const after = isAfter(e, e.currentTarget);
396
+ if (!e.currentTarget.classList.contains(after ? "drop-after" : "drop-before")) {
397
+ clearDrop();
398
+ if (dirDrag !== dir) e.currentTarget.classList.add(after ? "drop-after" : "drop-before");
399
+ }
400
+ },
401
+ ondragleave: (e) => {
402
+ if (!e.currentTarget.contains(e.relatedTarget)) e.currentTarget.classList.remove("drop-before", "drop-after");
403
+ },
404
+ ondrop: (e) => {
405
+ if (!dirDrag) return;
406
+ e.preventDefault();
407
+ const from = dirDrag;
408
+ const after = isAfter(e, e.currentTarget);
409
+ dirDrag = null;
410
+ clearDrop();
411
+ if (from !== dir) moveProjectDir(from, dir, after);
412
+ else if (sidebarStale) renderSidebar();
413
+ },
414
+ },
415
+ h(
416
+ "button",
417
+ { class: "conv-item proj", title: `${dir}\n拖动可调整顺序,右键可置顶`, onclick: () => toggleSide("open", dir) },
418
+ h("span", { class: "proj-icon", html: expanded ? ICON.folderOpen : ICON.folder }),
419
+ h("span", { class: "title" }, baseName(dir)),
420
+ expanded ? null : status(convs.map((c) => c.key)),
421
+ ),
422
+ h(
423
+ "button",
424
+ {
425
+ class: "proj-new",
426
+ title: `在 ${baseName(dir)} 中开新对话`,
427
+ onclick: () => {
428
+ S.draftCwd = dir === S.workspaceRoot ? null : dir;
429
+ newDraft();
430
+ closeSide();
431
+ },
432
+ },
433
+ icon("edit"),
434
+ ),
435
+ ),
436
+ );
437
+ if (!expanded) continue;
438
+ // 「展开显示」按主对话算条数,分叉跟着主对话一起出现
439
+ const forest = forkForest(convs);
440
+ for (const node of forest.slice(0, showAll ? forest.length : PROJECT_LIMIT)) {
441
+ nodes.push(renderForkNode(node, (c, orphan) => convRow(c, " sub", agentLabel(c.agent), orphan), " in-proj"));
442
+ }
443
+ if (forest.length > PROJECT_LIMIT) nodes.push(h("div", { class: "sub-more" }, more("more", dir, showAll)));
444
+ }
445
+ }
446
+ // 重绘会换掉 DOM:如果鼠标正停在某个标题上,重绘后接着滚
447
+ const hovered = marquee.el ? marquee.el.textContent : null;
448
+ stopMarquee();
449
+ fill(ui.convList, ...nodes);
450
+ if (hovered) {
451
+ requestAnimationFrame(() => {
452
+ const el = ui.convList.querySelector(".conv-item:hover .title");
453
+ if (el?.textContent === hovered) startMarquee(el);
454
+ });
455
+ }
456
+ const asks = askKeys.size;
457
+ document.title = asks ? `(${asks}) 待处理 · Agent Loft` : "Agent Loft";
458
+ }