arona-agent 1.2.1 → 1.2.3

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/gui/main.cjs CHANGED
@@ -1,7 +1,10 @@
1
1
  // ARONA GUI Electron 主进程:单窗口,与 Node 后端父进程经 stdin/stdout JSON lines 通信
2
2
  // (协议行前缀 ###GUI### 过滤 Electron 日志;与桌宠桥同模式)。
3
- const { app, BrowserWindow, Menu, ipcMain, nativeImage } = require("electron");
3
+ const { app, BrowserWindow, Menu, ipcMain, nativeImage, dialog } = require("electron");
4
+ const crypto = require("crypto");
4
5
  const fs = require("fs");
6
+ const http = require("http");
7
+ const os = require("os");
5
8
  const path = require("path");
6
9
 
7
10
  // Windows:GUI 是纯 HTML/CSS(无 WebGL),默认硬件加速下页面加载/脚本均正常但不 paint(白屏,
@@ -16,6 +19,7 @@ if (process.platform === "win32" && process.env.ARONA_GUI_GPU !== "1") {
16
19
  app.setPath("userData", path.join(app.getPath("appData"), "arona-agent-gui"));
17
20
 
18
21
  const APP_TITLE = "Arona Agent";
22
+ const DEMO_CAPTURE_PATH = path.join(os.tmpdir(), "arona_gui_demo.png");
19
23
  const ICON_PATH = path.join(__dirname, "renderer", "assets", "icon.png");
20
24
 
21
25
  const PREFIX = "###GUI###";
@@ -110,7 +114,7 @@ function smokeProbeUI() {
110
114
  }
111
115
 
112
116
  // ARONA_GUI_DEMO=1:不调用 LLM,向前端注入一段脚本化 agent_event 序列(思考 / 工具行 /
113
- // 编码子代理实时过程 / 总结文本),预览渲染效果;结束时 capturePage 截图到 /tmp/arona_gui_demo.png。
117
+ // 编码子代理实时过程 / 总结文本),预览渲染效果;结束时 capturePage 截图到系统临时目录(os.tmpdir(),跨平台)。
114
118
  function demoScenario() {
115
119
  const send = (m) => { if (win && !win.isDestroyed()) win.webContents.send("gui-event", m); };
116
120
  const ev = (agentId, type, extra = {}) => send({ type: "agent_event", agentId, event: { type, ...extra } });
@@ -165,8 +169,8 @@ function demoScenario() {
165
169
  ).catch(() => {});
166
170
  await sleep(300);
167
171
  const imgTop = await win.webContents.capturePage();
168
- fs.writeFileSync("/tmp/arona_gui_demo.png", imgTop.toPNG());
169
- console.error("DEMO_CAPTURE /tmp/arona_gui_demo.png");
172
+ fs.writeFileSync(DEMO_CAPTURE_PATH, imgTop.toPNG());
173
+ console.error("DEMO_CAPTURE " + DEMO_CAPTURE_PATH);
170
174
  return;
171
175
  } catch (e) {
172
176
  console.error("DEMO_SESSION_ERROR " + e);
@@ -232,8 +236,8 @@ function demoScenario() {
232
236
  await sleep(600);
233
237
  try {
234
238
  const img = await win.webContents.capturePage();
235
- fs.writeFileSync("/tmp/arona_gui_demo.png", img.toPNG());
236
- console.error("DEMO_CAPTURE /tmp/arona_gui_demo.png");
239
+ fs.writeFileSync(DEMO_CAPTURE_PATH, img.toPNG());
240
+ console.error("DEMO_CAPTURE " + DEMO_CAPTURE_PATH);
237
241
  } catch (e) {
238
242
  console.error("DEMO_CAPTURE_ERROR " + e);
239
243
  }
@@ -287,10 +291,32 @@ function createWindow() {
287
291
 
288
292
  // renderer → backend
289
293
  ipcMain.on("gui-send", (_event, msg) => {
294
+ // 工作区目录选择:Electron 原生对话框只能由本进程弹(contextIsolation,渲染层无 Node),
295
+ // 结果回给渲染层(ws_folder_picked),由渲染层决定驱动后端 set_workspace 还是 move_session
296
+ if (msg && msg.type === "pick_workspace_folder") {
297
+ pickWorkspaceFolder();
298
+ return;
299
+ }
290
300
  send(msg);
291
301
  });
292
302
 
293
- // backend renderer(行缓冲解析)
303
+ async function pickWorkspaceFolder() {
304
+ try {
305
+ const result = await dialog.showOpenDialog(win, {
306
+ title: "选择工作区文件夹",
307
+ properties: ["openDirectory"],
308
+ });
309
+ const picked = result.canceled ? null : (result.filePaths || [])[0];
310
+ if (picked && win && !win.isDestroyed()) {
311
+ win.webContents.send("gui-event", { type: "ws_folder_picked", path: picked });
312
+ }
313
+ } catch (e) {
314
+ console.error("[gui] pick folder failed:", e);
315
+ }
316
+ }
317
+
318
+ // backend → renderer(行缓冲解析;Windows 下此路不通——GUI 子系统 Electron 的 stdin 数据不可达,
319
+ // 见下方 HTTP 通道。保留作非 Windows 平台的备用通道)
294
320
  let buffer = "";
295
321
  process.stdin.on("data", (data) => {
296
322
  buffer += data.toString();
@@ -307,10 +333,41 @@ process.stdin.on("data", (data) => {
307
333
  }
308
334
  });
309
335
 
310
- // 握手:告知 backend 本进程的 stdin 解析器已就绪,可以开始下发 ###GUI### 事件。
311
- // Windows spawn 后立刻写 stdin 会丢数据(GUI 白屏根因:mode 事件从未到达本进程),
312
- // backend 收到 hello 前把事件全部入队,收到后按序下发。
313
- send({ type: "hello" });
336
+ // 本地 HTTP 通道(backend GUI 主方向):Windows 下 Electron 子进程 stdin 完全不可达(hello 握手
337
+ // 证明进程与 stdout 均正常、数据仍不到达),事件改走 127.0.0.1 随机端口 + 随机 token(随 hello 行
338
+ // 告知 backend,防本机其他进程伪造)。stdout(GUI backend)方向不受影响,继续走 ###GUI### 行。
339
+ const HTTP_TOKEN = crypto.randomBytes(16).toString("hex");
340
+ let helloSent = false;
341
+ function sendHello(httpPort) {
342
+ if (helloSent) return;
343
+ helloSent = true;
344
+ send({ type: "hello", httpPort: httpPort || 0, token: httpPort ? HTTP_TOKEN : "" });
345
+ }
346
+ const httpServer = http.createServer((req, res) => {
347
+ if (req.method !== "POST" || req.headers["x-arona-token"] !== HTTP_TOKEN) {
348
+ res.writeHead(403);
349
+ res.end();
350
+ return;
351
+ }
352
+ let body = "";
353
+ req.on("data", (c) => (body += c));
354
+ req.on("end", () => {
355
+ res.writeHead(204);
356
+ res.end();
357
+ try {
358
+ forward(JSON.parse(body));
359
+ } catch {
360
+ // 非 JSON,忽略
361
+ }
362
+ });
363
+ });
364
+ httpServer.on("error", (e) => {
365
+ console.error("[gui] http server error:", e.message, "→ 退回 stdin 通道");
366
+ sendHello(0); // HTTP 起不来也要发 hello(不带端口,backend 退回 stdin 写入)
367
+ });
368
+ httpServer.listen(0, "127.0.0.1", () => {
369
+ sendHello(httpServer.address().port);
370
+ });
314
371
 
315
372
  // 窗口全关:先发 exit 请求让后端走完整清理,再退出(延迟让协议行先 flush)
316
373
  app.on("window-all-closed", () => {
@@ -34,7 +34,17 @@ window.addEventListener("unhandledrejection", (e) => {
34
34
  let codingRunsByCall = new Map();
35
35
  const menu = { open: false, items: [], index: 0 };
36
36
  let currentMsg = null;
37
- let sessionsData = { currentPath: null, sessions: [] };
37
+ let sessionsData = { currentPath: null, currentWorkspace: "", homeDir: "", knownWorkspaces: [], sessions: [] };
38
+ // 工作区分组展示状态(key = 工作区路径,"" = 未分组旧会话):
39
+ // wsCollapsed 折叠的组;wsShown 各组展开显示的条数(默认 WS_PAGE 条)
40
+ const WS_PAGE = 5;
41
+ const wsCollapsed = new Set();
42
+ const wsShown = new Map();
43
+ // 「移动到工作区」弹窗里点了「选择其他文件夹…」:暂存目标会话,
44
+ // 原生目录框回传(ws_folder_picked)时按此分流为 move_session 而非 set_workspace
45
+ let moveTargetSession = null;
46
+ // 已上报过的活动工作区(null = 尚未收到首个 sessions 事件;用于区分「切换」与「首次推送」)
47
+ let activeWsSeen = null;
38
48
  let settingsModalOpen = false;
39
49
  let settingsInfoEl = null;
40
50
  // 录音中手动停止(再点麦克风):stt_result 空串时静默(不发"未检测到语音")
@@ -80,9 +90,18 @@ window.addEventListener("unhandledrejection", (e) => {
80
90
  } catch { return null; }
81
91
  })();
82
92
 
83
- /** 富文本渲染:Markdown → HTML(回复正文、工具输出共用)。 */
93
+ /** 富文本渲染:Markdown → HTML(回复正文、工具输出共用)。
94
+ * 例外:完整 HTML 文档(read index.html 等)不交给 marked——marked 会把源码透传成
95
+ * 真实 DOM(script 被净化、meta/link 不可见、空 div),视觉上整块空白;按源码块显示。 */
84
96
  function richRender(el, text) {
85
97
  const src = String(text);
98
+ if (/^\s*(<!DOCTYPE\s+html|<html[\s>])/i.test(src)) {
99
+ el.innerHTML = "";
100
+ const pre = document.createElement("pre");
101
+ pre.textContent = src;
102
+ el.appendChild(pre);
103
+ return;
104
+ }
86
105
  if (mdParse) {
87
106
  // 不做预转义:&/< 的转义交给 marked,防注入由 mdParse 的 DOM 净化负责(否则实体被二次转义)
88
107
  el.innerHTML = mdParse(src);
@@ -130,6 +149,35 @@ window.addEventListener("unhandledrejection", (e) => {
130
149
  document.body.classList.toggle("has-msg", started);
131
150
  }
132
151
 
152
+ // ── 工作区分组 ────────────────────────────────
153
+ /** 工作区显示名:取目录名;家目录「用户目录」;根目录 /;空值 = 旧会话未记录工作区(「未分组」)。 */
154
+ function wsLabel(ws) {
155
+ if (!ws) return "未分组";
156
+ if (ws === "/") return "/";
157
+ if (sessionsData.homeDir && ws === sessionsData.homeDir) return "用户目录";
158
+ const base = String(ws).replace(/\/+$/, "").split("/").pop();
159
+ return base || ws;
160
+ }
161
+ /** 按工作区分组:组内/组间按最新会话时间倒序;未分组恒排最后。与后端 workspace.ts 同构。 */
162
+ function groupSessions(sessions) {
163
+ const byKey = new Map();
164
+ for (const s of sessions) {
165
+ const k = s.workspace || "";
166
+ if (!byKey.has(k)) byKey.set(k, []);
167
+ byKey.get(k).push(s);
168
+ }
169
+ const groups = [...byKey.entries()].map(([workspace, list]) => {
170
+ list.sort((a, b) => String(b.timestamp || "").localeCompare(String(a.timestamp || "")));
171
+ return { workspace: workspace || null, label: wsLabel(workspace), sessions: list };
172
+ });
173
+ groups.sort((a, b) => {
174
+ if (!a.workspace) return 1;
175
+ if (!b.workspace) return -1;
176
+ return String(b.sessions[0]?.timestamp || "").localeCompare(String(a.sessions[0]?.timestamp || ""));
177
+ });
178
+ return groups;
179
+ }
180
+
133
181
  // ── SVG 图标库(stroke 风格;工具行 / 思考块共用,须先于 THINK_SVG 初始化)──
134
182
  const ICONS = {
135
183
  terminal: '<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>',
@@ -142,6 +190,7 @@ window.addEventListener("unhandledrejection", (e) => {
142
190
  task: '<polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>',
143
191
  box: '<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>',
144
192
  wrench: '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>',
193
+ folder: '<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>',
145
194
  chev: '<polyline points="6 9 12 15 18 9"/>',
146
195
  };
147
196
  function svgIcon(name, size) {
@@ -612,7 +661,49 @@ window.addEventListener("unhandledrejection", (e) => {
612
661
  if (settingsModalOpen) refreshSettingsModal();
613
662
  }
614
663
 
615
- // ── 侧栏会话列表 ──────────────────────────────
664
+ // ── 侧栏会话列表(工作区嵌套会话分组)──────────
665
+ /** 单条会话行(点击 resume;右键重命名/删除/多选;多选模式为勾选行)。 */
666
+ function buildSessionRow(s) {
667
+ const row = document.createElement("button");
668
+ row.className = "session-item" + (s.path === sessionsData.currentPath ? " current" : "");
669
+ row.title = multiSelect ? "" : s.preview;
670
+ if (multiSelect) {
671
+ row.classList.toggle("selected", multiSelected.has(s.path));
672
+ const check = document.createElement("span");
673
+ check.className = "sess-check";
674
+ check.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
675
+ row.appendChild(check);
676
+ }
677
+ const title = document.createElement("span");
678
+ title.className = "s-title";
679
+ title.textContent = s.preview || "(untitled)";
680
+ const time = document.createElement("span");
681
+ time.className = "s-time";
682
+ time.textContent = relTime(s.timestamp);
683
+ row.appendChild(title);
684
+ row.appendChild(time);
685
+ if (multiSelect) {
686
+ row.addEventListener("click", () => {
687
+ if (multiSelected.has(s.path)) multiSelected.delete(s.path);
688
+ else multiSelected.add(s.path);
689
+ row.classList.toggle("selected", multiSelected.has(s.path));
690
+ updateMultiBar();
691
+ });
692
+ } else {
693
+ row.addEventListener("click", () => api.send({ type: "resume_session", path: s.path }));
694
+ row.addEventListener("contextmenu", (e) => {
695
+ e.preventDefault();
696
+ openContextMenu(e.clientX, e.clientY, [
697
+ { label: "重命名", onClick: () => openRenameModal(s) },
698
+ { label: "移动到工作区", onClick: () => openMoveWsModal(s) },
699
+ { label: "删除", danger: true, onClick: () => confirmDeleteSessions([s]) },
700
+ { label: "多选", onClick: () => setMultiSelect(true) },
701
+ ]);
702
+ });
703
+ }
704
+ return row;
705
+ }
706
+
616
707
  function renderSessions() {
617
708
  sessionListEl.innerHTML = "";
618
709
  sessionListEl.classList.toggle("multi", multiSelect);
@@ -624,44 +715,67 @@ window.addEventListener("unhandledrejection", (e) => {
624
715
  updateMultiBar();
625
716
  return;
626
717
  }
627
- for (const s of sessionsData.sessions) {
628
- const row = document.createElement("button");
629
- row.className = "session-item" + (s.path === sessionsData.currentPath ? " current" : "");
630
- row.title = multiSelect ? "" : s.preview;
631
- if (multiSelect) {
632
- row.classList.toggle("selected", multiSelected.has(s.path));
633
- const check = document.createElement("span");
634
- check.className = "sess-check";
635
- check.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
636
- row.appendChild(check);
718
+ const groups = groupSessions(sessionsData.sessions);
719
+ // 当前会话所在组始终展开(组头折叠状态由用户点击驱动)
720
+ const curGroup = groups.find((g) => g.sessions.some((s) => s.path === sessionsData.currentPath));
721
+ if (curGroup) wsCollapsed.delete(curGroup.workspace || "");
722
+ for (const g of groups) {
723
+ const key = g.workspace || "";
724
+ const collapsed = wsCollapsed.has(key);
725
+ const groupEl = document.createElement("div");
726
+ groupEl.className = "ws-group" + (collapsed ? " collapsed" : "");
727
+ groupEl.dataset.ws = key;
728
+
729
+ // 组头:文件夹图标 + 工作区名 + 数量 + 折叠箭头(多选模式下仅展示)
730
+ const head = document.createElement(multiSelect ? "div" : "button");
731
+ head.className = "ws-head";
732
+ head.title = g.workspace || "";
733
+ head.innerHTML = `<span class="ws-folder">${svgIcon("folder", 14)}</span>`
734
+ + `<span class="ws-name">${esc(g.label)}</span>`
735
+ + `<span class="ws-count">${g.sessions.length}</span>`
736
+ + `<span class="ws-chev">${CHEV_SVG}</span>`;
737
+ if (!multiSelect) {
738
+ head.addEventListener("click", () => {
739
+ // 只切状态类:collapse-body 的 grid 过渡自动播放(不重建 DOM,重建会跳过动画)
740
+ const next = !wsCollapsed.has(key);
741
+ if (next) wsCollapsed.add(key);
742
+ else wsCollapsed.delete(key);
743
+ groupEl.classList.toggle("collapsed", next);
744
+ body.classList.toggle("collapsed", next);
745
+ });
637
746
  }
638
- const title = document.createElement("span");
639
- title.className = "s-title";
640
- title.textContent = s.preview || "(untitled)";
641
- const time = document.createElement("span");
642
- time.className = "s-time";
643
- time.textContent = relTime(s.timestamp);
644
- row.appendChild(title);
645
- row.appendChild(time);
646
- if (multiSelect) {
647
- row.addEventListener("click", () => {
648
- if (multiSelected.has(s.path)) multiSelected.delete(s.path);
649
- else multiSelected.add(s.path);
650
- row.classList.toggle("selected", multiSelected.has(s.path));
651
- updateMultiBar();
747
+ groupEl.appendChild(head);
748
+
749
+ // 组体:collapse-body 工具类包裹(折叠/展开过渡动画,见 style.css 工具类说明)
750
+ const body = document.createElement("div");
751
+ body.className = "collapse-body" + (collapsed ? " collapsed" : "");
752
+ const bodyInner = document.createElement("div");
753
+ bodyInner.className = "collapse-inner ws-body";
754
+
755
+ const shown = Math.min(wsShown.get(key) ?? WS_PAGE, g.sessions.length);
756
+ for (const s of g.sessions.slice(0, shown)) bodyInner.appendChild(buildSessionRow(s));
757
+ if (shown < g.sessions.length) {
758
+ const more = document.createElement("button");
759
+ more.className = "ws-more";
760
+ more.textContent = `显示更多(${g.sessions.length - shown})`;
761
+ more.addEventListener("click", () => {
762
+ wsShown.set(key, shown + WS_PAGE);
763
+ renderSessions();
652
764
  });
653
- } else {
654
- row.addEventListener("click", () => api.send({ type: "resume_session", path: s.path }));
655
- row.addEventListener("contextmenu", (e) => {
656
- e.preventDefault();
657
- openContextMenu(e.clientX, e.clientY, [
658
- { label: "重命名", onClick: () => openRenameModal(s) },
659
- { label: "删除", danger: true, onClick: () => confirmDeleteSessions([s]) },
660
- { label: "多选", onClick: () => setMultiSelect(true) },
661
- ]);
765
+ bodyInner.appendChild(more);
766
+ } else if (shown > WS_PAGE) {
767
+ const less = document.createElement("button");
768
+ less.className = "ws-more";
769
+ less.textContent = "收起";
770
+ less.addEventListener("click", () => {
771
+ wsShown.delete(key);
772
+ renderSessions();
662
773
  });
774
+ bodyInner.appendChild(less);
663
775
  }
664
- sessionListEl.appendChild(row);
776
+ body.appendChild(bodyInner);
777
+ groupEl.appendChild(body);
778
+ sessionListEl.appendChild(groupEl);
665
779
  }
666
780
  updateMultiBar();
667
781
  }
@@ -723,6 +837,7 @@ window.addEventListener("unhandledrejection", (e) => {
723
837
  if (e.key === "Escape") {
724
838
  closeConfirm();
725
839
  closeContextMenu();
840
+ closeWsMenu();
726
841
  if (multiSelect) setMultiSelect(false);
727
842
  }
728
843
  });
@@ -786,6 +901,24 @@ window.addEventListener("unhandledrejection", (e) => {
786
901
  inputEl.select();
787
902
  }
788
903
 
904
+ /** 移动会话到工作区:列已知工作区 + 「选择其他文件夹…」(原生目录框,ws_folder_picked 分流)。 */
905
+ function openMoveWsModal(s) {
906
+ const items = (sessionsData.knownWorkspaces || []).map((ws) => ({
907
+ label: wsLabel(ws),
908
+ meta: ws,
909
+ onPick: () => api.send({ type: "move_session", path: s.path, workspace: ws }),
910
+ }));
911
+ items.push({
912
+ label: "选择其他文件夹…",
913
+ meta: "",
914
+ onPick: () => {
915
+ moveTargetSession = s.path;
916
+ api.send({ type: "pick_workspace_folder" });
917
+ },
918
+ });
919
+ openModal("移动到工作区", pickList(items, (i) => i.meta || ""));
920
+ }
921
+
789
922
  // ── 斜杠菜单(combobox:Enter 只补全不执行)─────
790
923
  function menuRefresh() {
791
924
  const v = input.value;
@@ -917,6 +1050,100 @@ window.addEventListener("unhandledrejection", (e) => {
917
1050
  });
918
1051
  $("#btn-settings").addEventListener("click", openSettingsModal);
919
1052
 
1053
+ // ── 欢迎页工作区选择器(初始页输入框左下角,麦克风左侧)──
1054
+ $("#ws-picker-icon").innerHTML = svgIcon("folder", 14);
1055
+ $("#ws-picker-chev").innerHTML = CHEV_SVG;
1056
+
1057
+ /** 同步选择器文案:当前工作区名(GUI 后端启动目录)。 */
1058
+ function updateWsPicker() {
1059
+ $("#ws-picker-label").textContent = sessionsData.currentWorkspace
1060
+ ? wsLabel(sessionsData.currentWorkspace)
1061
+ : "会话";
1062
+ $("#ws-picker").title = sessionsData.currentWorkspace || "";
1063
+ }
1064
+
1065
+ /** 侧栏定位展开某工作区分组:全量显示并滚动到组头(下拉「显示更多」/ 组头点击联动)。 */
1066
+ function revealGroupInSidebar(key) {
1067
+ wsCollapsed.delete(key);
1068
+ wsShown.set(key, Infinity);
1069
+ document.body.classList.remove("sb-collapsed");
1070
+ document.body.classList.add("sb-expanded");
1071
+ $("#btn-expand").classList.add("hidden");
1072
+ $("#btn-collapse").classList.remove("hidden");
1073
+ renderSessions();
1074
+ sessionListEl.querySelector(`.ws-group[data-ws="${CSS.escape(key)}"]`)?.scrollIntoView({ block: "start" });
1075
+ }
1076
+
1077
+ function buildWsMenu() {
1078
+ const menu = $("#ws-menu");
1079
+ menu.innerHTML = "";
1080
+ // 各工作区的会话数(knownWorkspaces 可能含无会话的历史选择项)
1081
+ const countByWs = new Map();
1082
+ for (const s of sessionsData.sessions) {
1083
+ const k = s.workspace || "";
1084
+ countByWs.set(k, (countByWs.get(k) || 0) + 1);
1085
+ }
1086
+
1087
+ // 工作区列表(settings 选择历史 ∪ 会话推导,后端已合并去重;当前项高亮)
1088
+ for (const ws of sessionsData.knownWorkspaces || []) {
1089
+ const row = document.createElement("button");
1090
+ row.className = "ws-all" + (ws === sessionsData.currentWorkspace ? " current" : "");
1091
+ row.title = ws;
1092
+ row.innerHTML = `<span class="ws-folder">${svgIcon("folder", 14)}</span>`
1093
+ + `<span class="ws-name">${esc(wsLabel(ws))}</span>`
1094
+ + (countByWs.has(ws) ? `<span class="ws-count">${countByWs.get(ws)}</span>` : "");
1095
+ row.addEventListener("click", () => {
1096
+ closeWsMenu();
1097
+ api.send({ type: "set_workspace", path: ws });
1098
+ });
1099
+ menu.appendChild(row);
1100
+ }
1101
+
1102
+ if (!(sessionsData.knownWorkspaces || []).length) {
1103
+ const empty = document.createElement("div");
1104
+ empty.className = "ws-empty";
1105
+ empty.textContent = "暂无工作区";
1106
+ menu.appendChild(empty);
1107
+ }
1108
+
1109
+ // 选择其他位置的文件夹:Electron 原生目录对话框(main.cjs 弹出后经 ws_folder_picked 回传)
1110
+ const pick = document.createElement("button");
1111
+ pick.className = "ws-all ws-pick";
1112
+ pick.innerHTML = `<span class="ws-folder">${svgIcon("folder", 14)}</span><span class="ws-name">选择本地文件夹…</span>`;
1113
+ pick.addEventListener("click", () => {
1114
+ moveTargetSession = null;
1115
+ closeWsMenu();
1116
+ api.send({ type: "pick_workspace_folder" });
1117
+ });
1118
+ menu.appendChild(pick);
1119
+ }
1120
+
1121
+ function toggleWsMenu() {
1122
+ const menu = $("#ws-menu");
1123
+ if (menu.classList.contains("hidden")) {
1124
+ buildWsMenu();
1125
+ glassClear(menu);
1126
+ menu.classList.remove("hidden");
1127
+ $("#ws-picker").classList.add("open");
1128
+ } else {
1129
+ closeWsMenu();
1130
+ }
1131
+ }
1132
+ function closeWsMenu() {
1133
+ const menu = $("#ws-menu");
1134
+ if (menu.classList.contains("hidden")) return;
1135
+ glassHide(menu);
1136
+ $("#ws-picker").classList.remove("open");
1137
+ }
1138
+ $("#ws-picker").addEventListener("click", toggleWsMenu);
1139
+ document.addEventListener("pointerdown", (e) => {
1140
+ const menu = $("#ws-menu");
1141
+ if (!menu.classList.contains("hidden")
1142
+ && !menu.contains(e.target) && !$("#ws-picker").contains(e.target)) {
1143
+ closeWsMenu();
1144
+ }
1145
+ });
1146
+
920
1147
  // ── 弹窗 ──────────────────────────────────────
921
1148
  function openModal(title, bodyNode, footerNode) {
922
1149
  $("#modal-title-text").textContent = title;
@@ -1194,9 +1421,32 @@ window.addEventListener("unhandledrejection", (e) => {
1194
1421
  case "notice":
1195
1422
  statusLine(msg.text, msg.level === "info" ? "" : msg.level);
1196
1423
  break;
1197
- case "sessions":
1198
- sessionsData = { currentPath: msg.currentPath, sessions: msg.sessions };
1424
+ case "sessions": {
1425
+ const prevWs = sessionsData.currentWorkspace;
1426
+ sessionsData = {
1427
+ currentPath: msg.currentPath,
1428
+ currentWorkspace: msg.currentWorkspace || "",
1429
+ homeDir: msg.homeDir || "",
1430
+ knownWorkspaces: msg.knownWorkspaces || [],
1431
+ sessions: msg.sessions || [],
1432
+ };
1199
1433
  renderSessions();
1434
+ updateWsPicker();
1435
+ // 工作区被切换(选择器操作后端确认回推):侧栏展开定位到新工作区分组
1436
+ if (activeWsSeen !== null && prevWs !== sessionsData.currentWorkspace) {
1437
+ revealGroupInSidebar(sessionsData.currentWorkspace);
1438
+ }
1439
+ activeWsSeen = sessionsData.currentWorkspace;
1440
+ break;
1441
+ }
1442
+ case "ws_folder_picked":
1443
+ // 原生目录框回传:移动会话弹窗发起 → move_session;选择器发起 → set_workspace
1444
+ if (moveTargetSession) {
1445
+ api.send({ type: "move_session", path: moveTargetSession, workspace: msg.path });
1446
+ moveTargetSession = null;
1447
+ } else {
1448
+ api.send({ type: "set_workspace", path: msg.path });
1449
+ }
1200
1450
  break;
1201
1451
  case "skills":
1202
1452
  openSkillsModal(msg.skills);
@@ -52,10 +52,16 @@
52
52
  </div>
53
53
  </div>
54
54
  <div id="composer-wrap">
55
+ <div id="ws-menu" class="hidden"></div>
55
56
  <div id="slash-menu" class="hidden"></div>
56
57
  <div id="composer">
57
58
  <textarea id="input" rows="1" placeholder="输入消息,/ 为命令,Enter 发送"></textarea>
58
59
  <div id="composer-bar">
60
+ <button id="ws-picker" class="ws-picker" title="工作区">
61
+ <span class="ws-folder" id="ws-picker-icon"></span>
62
+ <span class="ws-label" id="ws-picker-label"></span>
63
+ <span class="ws-chev" id="ws-picker-chev"></span>
64
+ </button>
59
65
  <button id="btn-mic" class="icon-btn" title="录音">
60
66
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/></svg>
61
67
  </button>