agent-loft 0.2.36 → 0.2.38

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.
package/README.md CHANGED
@@ -48,7 +48,7 @@ Logo 就是这个意思:一大两小几颗星(多个 agent)站在同一张
48
48
  - 输入框里的小圆环显示上下文窗口占用;模型按钮显示实际的模型和思考程度(如「Opus 5.5 high」)。
49
49
  - 滚动和 ChatGPT 一样:发出的问题顶到最上面、下面留白,回复长到屏幕底部才跟着往下滚;回复过程中自己滚动了就不再自动滚,下次发消息再恢复。
50
50
  - 定时发送:右上角「更多」→「定时发送」,默认排在 5 小时额度重置后 5 分钟(弹窗里提示 5 小时 / 7 天额度的重置时间),由服务端到点发出,关掉页面也会发。适合额度用完、要等刷新才能接着干活的时候。
51
- - 侧栏分「最近」和「项目」两组:Web 会话按工作目录归类(目录可以拖动排序,右键可置顶),飞书机器人各占一行(右键看历史会话);分叉出来的会话用虚线挂在原会话下面;会话标题由 agent 自动生成,双击重命名。
51
+ - 侧栏分「最近」和「项目」两组:Web 会话按工作目录归类(目录可以拖动排序,右键可置顶;排好的顺序跟着账号走、各设备一致,新消息不会打乱),飞书机器人各占一行(右键看历史会话);分叉出来的会话用虚线挂在原会话下面;会话标题由 agent 自动生成,双击重命名。
52
52
  - 回复过程中接着发的消息排队,标着「排队中」,上一条回复完自动接着处理;点「打断并发送」停掉正在进行的回复、马上处理这条;点停止则连排着的一起取消(标「没有发出」)。
53
53
  - 太长的消息先收起,点「展开显示」看全文;对话里有 3 条以上消息时,右边有一列快速定位,鼠标移上去列出每条消息的开头,点一下跳过去。
54
54
  - 侧栏按页加载:「最近」先显示 10 条、每个项目目录先显示 5 条,点「展开显示」每次再拉 10 条。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-loft",
3
- "version": "0.2.36",
3
+ "version": "0.2.38",
4
4
  "type": "module",
5
5
  "description": "多个 agent 的工作台:把本机 Claude Code / Codex 放到一处统一管理,用飞书和 Web 页面随时驱动——会话、审批、模型切换都共用",
6
6
  "keywords": [
@@ -26,6 +26,7 @@ import {
26
26
  } from "../../core/users.js";
27
27
  import type { SavedAttachment } from "../../core/types.js";
28
28
  import { dailyUsage } from "../../core/usage.js";
29
+ import { appendDirOrder, getDirOrder, setDirOrder } from "../../core/ui-prefs.js";
29
30
  import {
30
31
  addMemoryItem,
31
32
  clearMemory,
@@ -558,6 +559,8 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
558
559
  // 展开着的目录带上翻页游标(没带 next 的目录前端还没拉过)
559
560
  dirs: dirsOf(all).map((d) => (dirPages.has(d.dir) ? { ...d, next: dirPages.get(d.dir)!.next } : d)),
560
561
  },
562
+ // 「项目」目录的顺序(拖拽排的),跟着账号走;null = 还没存过
563
+ dirOrder: getDirOrder(user.id) ?? null,
561
564
  pending,
562
565
  user: publicMe(user),
563
566
  remote: remoteStatus(user),
@@ -566,6 +569,14 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
566
569
  return;
567
570
  }
568
571
 
572
+ // ----- 侧栏「项目」目录顺序:order 整份换掉,append 只把新目录接在最后 -----
573
+ if (p === "/api/ui/dir-order" && method === "PUT") {
574
+ const body = await readJsonBody<{ order?: unknown; append?: unknown }>(req);
575
+ const order = body.order !== undefined ? setDirOrder(user.id, body.order) : appendDirOrder(user.id, body.append);
576
+ sendJson(res, 200, { order });
577
+ return;
578
+ }
579
+
569
580
  // ----- 系统通知(Web Push) -----
570
581
  if (p === "/api/push" && method === "GET") {
571
582
  sendJson(res, 200, { publicKey: vapidPublicKey(), subscriptions: listSubscriptions(pushOwnedBy(user.id)) });
@@ -6,6 +6,7 @@ import { deleteUserTasks } from "./tasks.js";
6
6
  import { purgeUserSkills } from "./skills.js";
7
7
  import type { Conversation } from "./store.js";
8
8
  import { getUser, legacyOwnerId } from "./users.js";
9
+ import { deleteUiPrefs } from "./ui-prefs.js";
9
10
 
10
11
  /**
11
12
  * 这条对话该用谁的记忆。比会话列表的归属规则(users.ts 的 ownedBy)更严:
@@ -45,4 +46,5 @@ export function purgeUserData(userId: string): void {
45
46
  unlinkUser(userId);
46
47
  deleteUserTasks(userId);
47
48
  purgeUserSkills(userId);
49
+ deleteUiPrefs(userId);
48
50
  }
@@ -0,0 +1,65 @@
1
+ // 每个用户的界面偏好,跟着账号走(换手机、换浏览器、桌面端都一样)。
2
+ // 现在只有侧栏「项目」目录的顺序:用户拖过的顺序是固定的,新消息不会把目录顶到前面。
3
+ import path from "node:path";
4
+ import { dataDir, readJson, writeJsonAtomic } from "../home.js";
5
+
6
+ type Prefs = { dirOrder?: string[] };
7
+
8
+ const prefsPath = path.join(dataDir, "ui-prefs.json");
9
+ let cache: Record<string, Prefs> | null = null;
10
+
11
+ function load(): Record<string, Prefs> {
12
+ if (!cache) {
13
+ const raw = readJson<unknown>(prefsPath, {});
14
+ cache = raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Record<string, Prefs>) : {};
15
+ }
16
+ return cache;
17
+ }
18
+
19
+ function save(): void {
20
+ writeJsonAtomic(prefsPath, cache ?? {});
21
+ }
22
+
23
+ const MAX_DIRS = 2000;
24
+
25
+ function cleanDirs(list: unknown): string[] {
26
+ if (!Array.isArray(list)) return [];
27
+ return [...new Set(list.filter((d): d is string => typeof d === "string" && d.length > 0 && d.length < 4096))].slice(0, MAX_DIRS);
28
+ }
29
+
30
+ /** 目录顺序;undefined = 这个用户还没在服务端存过(前端可以把本地的迁上来) */
31
+ export function getDirOrder(userId: string): string[] | undefined {
32
+ return load()[userId]?.dirOrder;
33
+ }
34
+
35
+ /** 整份换掉(拖拽、置顶、按活跃重排) */
36
+ export function setDirOrder(userId: string, order: unknown): string[] {
37
+ const all = load();
38
+ const dirOrder = cleanDirs(order);
39
+ all[userId] = { ...all[userId], dirOrder };
40
+ save();
41
+ return dirOrder;
42
+ }
43
+
44
+ /** 新出现的目录接在最后。只追加没有的,不动已有的名次 —— 别的设备同时在拖也不会被盖掉 */
45
+ export function appendDirOrder(userId: string, dirs: unknown): string[] {
46
+ const all = load();
47
+ const cur = all[userId]?.dirOrder ?? [];
48
+ const add = cleanDirs(dirs).filter((d) => !cur.includes(d));
49
+ if (add.length === 0 && all[userId]?.dirOrder) return cur;
50
+ const dirOrder = [...cur, ...add].slice(0, MAX_DIRS);
51
+ all[userId] = { ...all[userId], dirOrder };
52
+ save();
53
+ return dirOrder;
54
+ }
55
+
56
+ export function deleteUiPrefs(userId: string): void {
57
+ const all = load();
58
+ if (!(userId in all)) return;
59
+ delete all[userId];
60
+ save();
61
+ }
62
+
63
+ export function resetUiPrefsForTest(): void {
64
+ cache = null;
65
+ }
@@ -49,6 +49,35 @@
49
49
  display: none;
50
50
  }
51
51
 
52
+ /* 「/」「@」补全:键盘弹出后屏幕只剩一半,菜单改成一行一条、最多露出五条多一点,
53
+ 露出半条好让人知道还能往下滑 */
54
+ .slash-menu {
55
+ bottom: calc(100% + 4px);
56
+ max-height: min(196px, 30vh);
57
+ padding: 4px;
58
+ }
59
+
60
+ .slash-item {
61
+ display: flex;
62
+ align-items: baseline;
63
+ gap: 8px;
64
+ padding: 7px 8px;
65
+ }
66
+
67
+ .slash-head {
68
+ flex: none;
69
+ max-width: 60%;
70
+ }
71
+
72
+ .slash-args {
73
+ display: none;
74
+ }
75
+
76
+ .slash-desc {
77
+ flex: 1;
78
+ min-width: 0;
79
+ }
80
+
52
81
  .ctx-bar {
53
82
  margin: 0 10px -22px;
54
83
  font-size: 13px;
@@ -53,6 +53,17 @@
53
53
  }
54
54
  }
55
55
 
56
+ /* 顶栏下沿的渐变、模糊是给滚动的聊天内容用的:远程控制页下面是工具栏,会被它盖住一截 */
57
+ .main.remote-view .topbar {
58
+ padding-bottom: 8px;
59
+ background: var(--bg);
60
+ }
61
+
62
+ .main.remote-view .topbar::before,
63
+ .main.remote-view .topbar::after {
64
+ display: none;
65
+ }
66
+
56
67
  .remote-head {
57
68
  display: flex;
58
69
  align-items: center;
@@ -313,18 +324,48 @@
313
324
  opacity: 0.4;
314
325
  }
315
326
 
316
- /* 浏览器全屏时只剩画面 */
317
- .remote-body:fullscreen .desk-status {
327
+ /* 全屏时只剩画面 */
328
+ .remote-body.full .desk-status {
318
329
  display: none;
319
330
  }
320
331
 
321
- /* 不支持元素全屏(iPhone):铺满整个页面,盖住顶栏、侧栏,弹窗和提示还在上面 */
332
+ /* 不支持元素全屏(iPhone):铺满整个页面,盖住顶栏、侧栏,弹窗和提示还在上面。
333
+ 左右不留安全区:画面本来就居中、两边是黑边,刘海落在黑边里 */
322
334
  .remote-body.desk-full {
323
335
  position: fixed;
324
336
  inset: 0;
325
337
  z-index: 35;
326
338
  background: #111;
327
- padding: env(safe-area-inset-top) env(safe-area-inset-right) calc(env(safe-area-inset-bottom) + var(--kb-inset, 0px)) env(safe-area-inset-left);
339
+ padding: env(safe-area-inset-top) 0 var(--kb-inset, 0px);
340
+ }
341
+
342
+ /* 全屏时下面那排键默认收起,左上角的按钮拉出来;拉出来时是深色、矮一点的一条 */
343
+ .remote-body.full .desk-keys {
344
+ padding: 4px max(8px, env(safe-area-inset-left)) calc(4px + env(safe-area-inset-bottom)) max(8px, env(safe-area-inset-right));
345
+ border-top: none;
346
+ background: #111;
347
+ }
348
+
349
+ .remote-body.full:not(.keys-open) .desk-keys {
350
+ display: none;
351
+ }
352
+
353
+ /* 有软键盘垫着时不用再让出底部安全区 */
354
+ .remote-body.desk-full .desk-keys {
355
+ padding-bottom: calc(4px + max(0px, env(safe-area-inset-bottom) - var(--kb-inset, 0px)));
356
+ }
357
+
358
+ .remote-body.full .desk-keys .term-key {
359
+ height: 30px;
360
+ border-color: #333;
361
+ background: #262626;
362
+ color: #eee;
363
+ }
364
+
365
+ .remote-body.full .desk-keys .term-key.on {
366
+ background: var(--accent);
367
+ border-color: var(--accent);
368
+ color: var(--accent-fg);
328
369
  }
329
370
 
330
371
  /* 没连着时画面中间的「连接桌面」 */
@@ -341,7 +382,7 @@
341
382
  }
342
383
 
343
384
  .desk-idle[hidden],
344
- .desk-exitfull[hidden] {
385
+ .desk-fbtn[hidden] {
345
386
  display: none;
346
387
  }
347
388
 
@@ -362,12 +403,26 @@
362
403
  height: 16px;
363
404
  }
364
405
 
365
- /* 全屏时左上角「退出全屏」 */
366
- .desk-exitfull {
406
+ /* 全屏时左上角「退出全屏」「按键」 */
407
+ .desk-fullbar {
367
408
  position: absolute;
368
- left: 10px;
409
+ left: max(10px, env(safe-area-inset-left));
369
410
  top: 10px;
370
411
  z-index: 3;
412
+ display: flex;
413
+ gap: 8px;
414
+ }
415
+
416
+ .desk-fullbar[hidden] {
417
+ display: none;
418
+ }
419
+
420
+ .desk-fbtn.on {
421
+ background: var(--accent);
422
+ color: var(--accent-fg);
423
+ }
424
+
425
+ .desk-fbtn {
371
426
  display: grid;
372
427
  place-items: center;
373
428
  width: 34px;
@@ -378,7 +433,7 @@
378
433
  color: #fff;
379
434
  }
380
435
 
381
- .desk-exitfull svg {
436
+ .desk-fbtn svg {
382
437
  width: 18px;
383
438
  height: 18px;
384
439
  }
@@ -57,8 +57,12 @@ export function showDesktop(pane, tools) {
57
57
  h("div", { class: "muted" }, "远程桌面未连接"),
58
58
  h("button", { class: "btn primary", onclick: reconnect }, icon("plug"), "连接桌面"),
59
59
  );
60
- D.exitFull = h("button", { class: "desk-exitfull", hidden: true, title: "退出全屏", "aria-label": "退出全屏", onclick: () => setFull(false) }, icon("shrink"));
61
- D.viewport = h("div", { class: "desk-viewport" }, D.screen, D.zoomBadge, D.hint, D.idle, D.exitFull);
60
+ D.keysBtn = isTouch
61
+ ? h("button", { class: "desk-fbtn", title: "按键", "aria-label": "按键", onclick: () => toggleFullKeys() }, icon("keyboard"))
62
+ : null;
63
+ D.exitFull = h("button", { class: "desk-fbtn", title: "退出全屏", "aria-label": "退出全屏", onclick: () => setFull(false) }, icon("shrink"));
64
+ D.fullBar = h("div", { class: "desk-fullbar", hidden: true }, D.exitFull, D.keysBtn);
65
+ D.viewport = h("div", { class: "desk-viewport" }, D.screen, D.zoomBadge, D.hint, D.idle, D.fullBar);
62
66
  D.keyInput = isTouch ? mobileKeyInput() : null;
63
67
  D.keyRow = isTouch ? keyRow() : null;
64
68
  fill(pane, D.status, D.viewport, D.keyRow, D.keyInput);
@@ -104,6 +108,8 @@ async function connect() {
104
108
  rfb.resizeSession = false;
105
109
  rfb.viewOnly = D.viewOnly;
106
110
  rfb.showDotCursor = true;
111
+ // 画面没铺满时的边:和外框一个颜色,别再多出一圈灰
112
+ rfb.background = "#111";
107
113
  rfb.qualityLevel = 6;
108
114
  rfb.compressionLevel = 2;
109
115
  rfb.addEventListener("connect", () => {
@@ -282,11 +288,25 @@ function setFull(on) {
282
288
  syncFull();
283
289
  }
284
290
 
285
- /** 全屏时画面上一个「退出全屏」(手机上没有 Esc;真全屏在电脑上按 Esc 就行,不显示) */
291
+ /**
292
+ * 全屏时画面左上角「退出全屏」(手机上没有 Esc;真全屏在电脑上按 Esc 就行,不显示)。
293
+ * 手机上全屏时下面那排键先收起来,左上角的「按键」拉出来
294
+ */
286
295
  function syncFull() {
287
296
  if (!D.pane) return;
288
297
  const pseudo = D.pane.classList.contains("desk-full");
289
- D.exitFull.hidden = !(pseudo || (isTouch && fsElement() === D.pane));
298
+ const full = pseudo || fsElement() === D.pane;
299
+ if (full !== D.pane.classList.contains("full")) {
300
+ D.pane.classList.toggle("full", full);
301
+ toggleFullKeys(false);
302
+ }
303
+ D.fullBar.hidden = !(pseudo || (isTouch && full));
304
+ }
305
+
306
+ function toggleFullKeys(open = !D.pane.classList.contains("keys-open")) {
307
+ D.pane.classList.toggle("keys-open", open);
308
+ D.keysBtn?.classList.toggle("on", open);
309
+ if (!open && document.activeElement === D.keyInput) D.keyInput.blur();
290
310
  }
291
311
  document.addEventListener("fullscreenchange", syncFull);
292
312
  document.addEventListener("webkitfullscreenchange", syncFull);
@@ -14,7 +14,6 @@ import {
14
14
  reportDesktop,
15
15
  resetProjectOrder,
16
16
  setSideWidth,
17
- sideState,
18
17
  startMarquee,
19
18
  startSideResize,
20
19
  stopMarquee,
@@ -197,7 +196,7 @@ function renderLayout() {
197
196
  ...(i > 0 ? [{ label: "置顶", onclick: () => moveProjectDir(dir, null) }, { label: "上移", onclick: () => moveProjectDir(dir, projDirs[i - 1]) }] : []),
198
197
  ...(i >= 0 && i < projDirs.length - 1 ? [{ label: "下移", onclick: () => moveProjectDir(dir, projDirs[i + 1], true) }] : []),
199
198
  ];
200
- const reset = sideState.order.length ? [{ label: "恢复按最近活跃排序", onclick: resetProjectOrder }] : [];
199
+ const reset = projDirs.length > 1 ? [{ label: "按最近活跃重排", onclick: resetProjectOrder }] : [];
201
200
  const items = moves.length && reset.length ? [...moves, "sep", ...reset] : [...moves, ...reset];
202
201
  if (!items.length) return;
203
202
  e.preventDefault();
@@ -195,17 +195,47 @@ function agentShort(id) {
195
195
  export const sideState = { open: [], more: [], folded: [], order: [], ...JSON.parse(localStorage.getItem("aim.side") || "{}") };
196
196
 
197
197
  /**
198
- * 「项目」目录的顺序:没手动排过时按最近活跃;拖过之后按 sideState.order,
199
- * 之后新出现的目录排在已排好的后面(同样按活跃),不会挤掉用户置顶的目录
198
+ * 「项目」目录的顺序固定按 sideState.order,新消息不会把目录顶到前面。
199
+ * 顺序存在服务端、跟着账号走(换设备也一样);新出现的目录接在最后(几个一起出现时按活跃排),
200
+ * 接上之后名次也就定了
200
201
  */
201
202
  function orderProjectDirs(dirs) {
202
203
  const rank = new Map(sideState.order.map((d, i) => [d, i]));
203
204
  const ranked = dirs.filter((d) => rank.has(d)).sort((a, b) => rank.get(a) - rank.get(b));
204
- return [...ranked, ...dirs.filter((d) => !rank.has(d))];
205
+ const fresh = dirs.filter((d) => !rank.has(d));
206
+ if (fresh.length && orderSynced) {
207
+ sideState.order = [...sideState.order, ...fresh];
208
+ saveSideState();
209
+ void putDirOrder({ append: fresh });
210
+ }
211
+ return [...ranked, ...fresh];
212
+ }
213
+
214
+ /** 拿到服务端存的顺序之前不往上写,免得这台设备本地的旧顺序盖掉别处排好的 */
215
+ let orderSynced = false;
216
+
217
+ function saveSideState() {
218
+ localStorage.setItem("aim.side", JSON.stringify(sideState));
219
+ }
220
+
221
+ async function putDirOrder(body) {
222
+ try {
223
+ const data = await api("/api/ui/dir-order", { method: "PUT", body });
224
+ // 追加时以服务端的为准(别的设备可能刚排过)
225
+ if (body.append && data?.order && data.order.join("\n") !== sideState.order.join("\n")) {
226
+ sideState.order = data.order;
227
+ saveSideState();
228
+ scheduleSidebar();
229
+ }
230
+ } catch (err) {
231
+ toast(`保存目录顺序失败:${err.message}`);
232
+ }
205
233
  }
206
234
 
207
235
  /** 当前侧栏里显示的目录顺序(renderSidebar 时记下,拖拽 / 右键菜单用) */
208
236
  export let projDirs = [];
237
+ /** 同一批目录按最近活跃的顺序(「按最近活跃重排」用) */
238
+ let projDirsByActivity = [];
209
239
 
210
240
  /** 把目录 dir 挪到 target 前面(after 为 true 时挪到后面);target 为 null 表示挪到最前 */
211
241
  export function moveProjectDir(dir, target, after = false) {
@@ -215,14 +245,18 @@ export function moveProjectDir(dir, target, after = false) {
215
245
  if (idx < 0) return;
216
246
  list.splice(idx + (after ? 1 : 0), 0, dir);
217
247
  // 暂时没显示出来的目录(会话都被隐藏了之类)保留原来的名次,排在后面
218
- sideState.order = [...list, ...sideState.order.filter((d) => !list.includes(d))];
219
- localStorage.setItem("aim.side", JSON.stringify(sideState));
220
- renderSidebar();
248
+ setProjectOrder([...list, ...sideState.order.filter((d) => !list.includes(d))]);
221
249
  }
222
250
 
251
+ /** 按最近活跃重排一次,之后照样固定 */
223
252
  export function resetProjectOrder() {
224
- sideState.order = [];
225
- localStorage.setItem("aim.side", JSON.stringify(sideState));
253
+ setProjectOrder([...projDirsByActivity, ...sideState.order.filter((d) => !projDirsByActivity.includes(d))]);
254
+ }
255
+
256
+ function setProjectOrder(order) {
257
+ sideState.order = order;
258
+ saveSideState();
259
+ if (orderSynced) void putDirOrder({ order });
226
260
  renderSidebar();
227
261
  }
228
262
 
@@ -288,6 +322,17 @@ export function applyBootstrap(data) {
288
322
  st.next = pages.full ? null : sideState.open.includes(dir) ? (fetched.get(dir) ?? null) : undefined;
289
323
  }
290
324
  for (const [dir, next] of fetched) dirState(dir).next = next;
325
+ // 老版本服务端没有这个字段:顺序只存在本地
326
+ if ("dirOrder" in data) {
327
+ if (data.dirOrder) {
328
+ sideState.order = data.dirOrder;
329
+ saveSideState();
330
+ } else if (sideState.order.length) {
331
+ // 以前只存在这台设备上的顺序,第一次搬到服务端
332
+ void putDirOrder({ order: sideState.order });
333
+ }
334
+ orderSynced = true;
335
+ }
291
336
  }
292
337
 
293
338
  /** 拉下一页,合进 S.convs。失败了不自动重试(否则每次重绘都去拉),点「展开显示」再试 */
@@ -433,7 +478,7 @@ export function renderSidebar() {
433
478
  if (btn) nodes.push(btn);
434
479
  }
435
480
 
436
- // ---- 项目:Web 会话按工作目录分组,目录按最近活跃排序 ----
481
+ // ---- 项目:Web 会话按工作目录分组,目录按用户排的顺序(见 orderProjectDirs) ----
437
482
  const byDir = new Map();
438
483
  for (const c of visible.filter((c) => c.channel === "web")) {
439
484
  if (!byDir.has(c.cwd)) byDir.set(c.cwd, []);
@@ -452,7 +497,8 @@ export function renderSidebar() {
452
497
  for (const [dir, convs] of byDir) dirAt.set(dir, Math.max(dirAt.get(dir) ?? 0, convs[0].activeAt));
453
498
  const projFolded = sideState.folded.includes("@projects");
454
499
  if (dirAt.size) nodes.push(groupHead("项目", "@projects", projFolded));
455
- projDirs = orderProjectDirs([...dirAt.keys()].sort((a, b) => dirAt.get(b) - dirAt.get(a)));
500
+ projDirsByActivity = [...dirAt.keys()].sort((a, b) => dirAt.get(b) - dirAt.get(a));
501
+ projDirs = orderProjectDirs(projDirsByActivity);
456
502
  if (dirAt.size && !projFolded) {
457
503
  const clearDrop = () => ui.convList.querySelectorAll(".drop-before, .drop-after").forEach((el) => el.classList.remove("drop-before", "drop-after"));
458
504
  const endDrag = () => {