diff --git a/emrg/gui/renderer/js/app.js b/emrg/gui/renderer/js/app.js
index fcf8dcc6..177fab8d 100644
--- a/emrg/gui/renderer/js/app.js
+++ b/emrg/gui/renderer/js/app.js
@@ -206,6 +206,10 @@ const App = (() => {
showSessionsDialog();
}
break;
+ case "/open":
+ // P5(rant 15:07:19):打开会话对话框(两步:项目 → 会话,跨项目多开)
+ Dialogs.showOpenSessionDialog();
+ break;
case "/rename":
// P2:复用现有重命名对话框(右键菜单同款)
if (!state.sessionId) {
@@ -1273,6 +1277,7 @@ const App = (() => {
$("send-btn").addEventListener("click", sendMessage);
$("stop-btn").addEventListener("click", () => window.emrg.cancel().catch(() => {}));
$("new-chat-btn").addEventListener("click", newSession);
+ Dialogs.initOpenSessionDialog(); // P5:打开会话对话框绑定
$("settings-btn").addEventListener("click", () => {
loadEvolutionSummary(); // WorkBuddy P3(#502):打开设置时加载最近改进
Dialogs.showSettings();
diff --git a/emrg/gui/renderer/js/commands.js b/emrg/gui/renderer/js/commands.js
index 8e5cdd6d..4c852521 100644
--- a/emrg/gui/renderer/js/commands.js
+++ b/emrg/gui/renderer/js/commands.js
@@ -24,6 +24,7 @@ const Commands = (() => {
"/resume": { hint: "cmd.resume.hint", phase: 2 },
"/rewind": { hint: "cmd.rewind.hint", phase: 2 },
"/sessions": { hint: "cmd.sessions.hint", phase: 2 },
+ "/open": { hint: "cmd.open.hint", phase: 4 }, // P5:打开会话(跨项目)
"/model": { hint: "cmd.model.hint", phase: 3 },
"/memory": { hint: "cmd.memory.hint", phase: 3 },
"/skills": { hint: "cmd.skills.hint", phase: 3 },
diff --git a/emrg/gui/renderer/js/dialogs.js b/emrg/gui/renderer/js/dialogs.js
index 91f07dbc..91ac4395 100644
--- a/emrg/gui/renderer/js/dialogs.js
+++ b/emrg/gui/renderer/js/dialogs.js
@@ -533,6 +533,82 @@ const Dialogs = (() => {
$("confirm-dialog").showModal();
}
+ // ── P5(rant 15:07:19):打开会话对话框(两步:选项目 → 选会话,跨项目) ──
+ async function showOpenSessionDialog() {
+ const list = $("open-session-list");
+ const dialog = $("open-session-dialog");
+ if (!list || !dialog) return;
+ list.innerHTML = `
${_t("dlg.loading")}
`;
+ dialog.showModal();
+ try {
+ const projects = await window.emrg.listProjects();
+ list.innerHTML = "";
+ if (!projects || projects.length === 0) {
+ list.innerHTML = `
${_t("openSession.noProjects")}
`;
+ return;
+ }
+ // 第一步:项目列表(按最近活跃倒序——daemon 已排;底部"新建项目…"按钮)
+ projects.forEach((p) => {
+ const row = el("button", { class: "help-row", type: "button", style: "width:100%;text-align:left;cursor:pointer;background:none;border:none;" });
+ const name = el("span", { class: "help-cmd" }, p.name || p.path || "");
+ const hint = el("span", { class: "help-hint" }, p.path || "");
+ row.appendChild(name);
+ row.appendChild(hint);
+ row.addEventListener("click", () => showProjectSessions(p));
+ list.appendChild(row);
+ });
+ } catch (e) {
+ list.innerHTML = `
${_t("openSession.loadFailed", { msg: e.message })}
`;
+ }
+ }
+
+ // 第二步:该项目会话列表(created_at 倒序)→ 点击打开(switchSession 复用连接)
+ async function showProjectSessions(project) {
+ const list = $("open-session-list");
+ list.innerHTML = `
${_t("dlg.loading")}
`;
+ $("open-session-title").textContent = _t("openSession.titleProject", { project: project.name || project.path || "" });
+ try {
+ const frame = await window.emrg.listProjectSessions({ projectPath: project.path });
+ const sessions = frame.sessions || [];
+ list.innerHTML = "";
+ if (sessions.length === 0) {
+ list.innerHTML = `
${_t("openSession.noSessions")}
`;
+ return;
+ }
+ sessions.forEach((s) => {
+ const row = el("button", { class: "help-row", type: "button", style: "width:100%;text-align:left;cursor:pointer;background:none;border:none;" });
+ const name = el("span", { class: "help-cmd" }, s.title || _t("app.unnamed"));
+ const hint = el("span", { class: "help-hint" }, s.session_id === App.state.sessionId ? _t("app.current") : "");
+ row.appendChild(name);
+ row.appendChild(hint);
+ row.addEventListener("click", async () => {
+ $("open-session-dialog").close();
+ await App.switchSession(s.session_id);
+ });
+ list.appendChild(row);
+ });
+ } catch (e) {
+ list.innerHTML = `
${_t("openSession.loadFailed", { msg: e.message })}
`;
+ }
+ }
+
+ function initOpenSessionDialog() {
+ $("open-session-cancel").addEventListener("click", () => $("open-session-dialog").close());
+ $("open-session-new").addEventListener("click", async () => {
+ // P5:新建项目 = 选目录 → 轻量命令注册(daemon 隐式 _touch_project)
+ try {
+ const res = await window.emrg.pickProjectDir();
+ if (res && res.path) {
+ await window.emrg.registerProject({ path: res.path });
+ Chat.addSystemMessage(_t("openSession.projectCreated", { path: res.path }));
+ showOpenSessionDialog(); // 刷新项目列表
+ }
+ } catch (e) {
+ Chat.addSystemMessage(_t("openSession.projectFailed", { msg: e.message }));
+ }
+ });
+ }
+
function closeConfirm() {
$("confirm-dialog").close();
confirmCb = null;
@@ -555,6 +631,8 @@ const Dialogs = (() => {
initGithubSection,
initDeviceDialog,
refreshGithubStatus,
+ initOpenSessionDialog, // P5:打开会话对话框初始化
+ showOpenSessionDialog, // P5:两步打开会话
showRename,
submitRename,
showSettings,
diff --git a/emrg/gui/renderer/js/i18n.js b/emrg/gui/renderer/js/i18n.js
index 8a39114b..9331f589 100644
--- a/emrg/gui/renderer/js/i18n.js
+++ b/emrg/gui/renderer/js/i18n.js
@@ -135,6 +135,15 @@ const I18N = (() => {
// 会话 / 回退 / 记忆 / 技能对话框
"sessions.title": "切换对话",
"sessions.desc": "点击切换,或输入 /resume
直接切换。",
+ "openSession.title": "打开会话",
+ "openSession.desc": "选择项目后选择要打开的会话(跨项目多开)。",
+ "openSession.titleProject": "打开会话 — {project}",
+ "openSession.noProjects": "还没有项目。点下方「新建项目…」选择一个文件夹。",
+ "openSession.noSessions": "该项目还没有会话,发送第一条消息会自动创建。",
+ "openSession.loadFailed": "加载失败:{msg}",
+ "openSession.newProject": "+ 新建项目…",
+ "openSession.projectCreated": "项目已注册:{path}",
+ "openSession.projectFailed": "新建项目失败:{msg}",
"rewind.title": "回退到历史消息点",
"rewind.desc": "选择要保留到的消息点,之后的对话将被移除。",
"rewind.cancel": "取消",
@@ -164,6 +173,7 @@ const I18N = (() => {
"cmd.resume.hint": "切换/恢复对话",
"cmd.rewind.hint": "回退到历史消息点",
"cmd.sessions.hint": "查看全部对话",
+ "cmd.open.hint": "打开会话(跨项目)",
"cmd.model.hint": "切换模型",
"cmd.memory.hint": "浏览记忆",
"cmd.skills.hint": "查看已加载技能",
@@ -433,6 +443,15 @@ const I18N = (() => {
// Sessions / rewind / memory / skills dialogs
"sessions.title": "Switch conversation",
"sessions.desc": "Click to switch, or type /resume to switch directly.",
+ "openSession.title": "Open session",
+ "openSession.desc": "Pick a project, then pick a session to open (multi-project tabs).",
+ "openSession.titleProject": "Open session — {project}",
+ "openSession.noProjects": "No projects yet. Use \"+ New project…\" below to pick a folder.",
+ "openSession.noSessions": "No sessions in this project yet — the first message creates one.",
+ "openSession.loadFailed": "Failed to load: {msg}",
+ "openSession.newProject": "+ New project…",
+ "openSession.projectCreated": "Project registered: {path}",
+ "openSession.projectFailed": "Failed to create project: {msg}",
"rewind.title": "Rewind to a history point",
"rewind.desc": "Choose the message point to keep — later messages will be removed.",
"rewind.cancel": "Cancel",
@@ -462,6 +481,7 @@ const I18N = (() => {
"cmd.resume.hint": "Switch / resume a conversation",
"cmd.rewind.hint": "Rewind to a history point",
"cmd.sessions.hint": "View all conversations",
+ "cmd.open.hint": "Open session (cross-project)",
"cmd.model.hint": "Switch model",
"cmd.memory.hint": "Browse memory",
"cmd.skills.hint": "View loaded skills",
diff --git a/emrg/gui/test/commands.test.js b/emrg/gui/test/commands.test.js
index c98cc48e..54e65a64 100644
--- a/emrg/gui/test/commands.test.js
+++ b/emrg/gui/test/commands.test.js
@@ -22,16 +22,16 @@ function loadCommands() {
return vm.runInContext("EMRG_Commands", ctx);
}
-test("注册表含 TUI 全部 15 个 / 指令(rant 19:44 验收)", () => {
+test("注册表含 TUI 全部 15 个 / 指令 + /open(rant 19:44 验收 + P5 扩展)", () => {
const Commands = loadCommands();
const expected = [
"/clear", "/compact", "/delete", "/help", "/image", "/memory", "/model",
- "/rant", "/rename", "/resume", "/rewind", "/sessions", "/skills", "/trigger", "/version",
+ "/open", "/rant", "/rename", "/resume", "/rewind", "/sessions", "/skills", "/trigger", "/version",
];
for (const cmd of expected) {
assert.ok(Commands.COMMANDS[cmd], `缺指令 ${cmd}`);
}
- assert.strictEqual(Object.keys(Commands.COMMANDS).length, 15);
+ assert.strictEqual(Object.keys(Commands.COMMANDS).length, 16);
// 每条指令都有 hint(补全菜单展示用)
for (const [cmd, meta] of Object.entries(Commands.COMMANDS)) {
assert.ok(meta.hint && meta.hint.length > 0, `${cmd} 缺 hint`);
@@ -68,8 +68,8 @@ test("parseInput:普通消息 / 已知指令(含参数)/ 未知指令", ()
test("getCompletions:前缀过滤 + 排序 + hint 透传", () => {
const Commands = loadCommands();
- // 空前缀 → 全部 15 条
- assert.strictEqual(Commands.getCompletions("").length, 15);
+ // 空前缀 → 全部 16 条
+ assert.strictEqual(Commands.getCompletions("").length, 16);
// /r 前缀 → /rant /rename /resume /rewind(spread 转宿主 Realm 数组再比较)
const r = [...Commands.getCompletions("/r")].map((i) => String(i.cmd)).sort();
assert.deepStrictEqual(r, ["/rant", "/rename", "/resume", "/rewind"].sort());
diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js
index e23705f4..e6d691ae 100644
--- a/emrg/gui/test/renderer.smoke.test.js
+++ b/emrg/gui/test/renderer.smoke.test.js
@@ -59,7 +59,8 @@ function makeEl(id) {
clientHeight: 100,
open: false,
appendChild(c) { c.parentNode = this; this.children.push(c); return c; },
- addEventListener() {},
+ addEventListener(type, fn) { this._listeners = this._listeners || {}; (this._listeners[type] = this._listeners[type] || []).push(fn); },
+ click() { (this._listeners && this._listeners.click || []).forEach((fn) => fn({ preventDefault() {} })); },
querySelector(sel) {
// 最小类选择器搜索(chat.js 用 ".msg-body"/".tool-spinner"):DFS 子节点
if (!sel || !sel.startsWith(".")) return null;
@@ -106,6 +107,7 @@ function makeEl(id) {
const ELEMENT_IDS = [
"chat-view", "input", "send-btn", "stop-btn", "conv-list", "open-sessions", "open-sessions-label", "status-dot", "settings-btn",
+ "open-session-dialog", "open-session-list", "open-session-title", "open-session-desc", "open-session-new", "open-session-cancel",
"conn-banner", "empty-state", "model-switcher", "model-switcher-label", "brand-star", "new-chat-btn",
"settings-dialog", "settings-cancel", "settings-save", "set-api-key", "set-base-url", "set-project-dir",
"set-model", "pick-dir-btn", "theme-options", "welcome-dialog", "welcome-api-key", "welcome-base-url",
@@ -1335,3 +1337,46 @@ test("P4 s2: boot init 携带 open_sessions + active_sid → 采用恢复的激
assert.strictEqual(va.classList.contains("active"), true, "restored session view activated");
assert.strictEqual(els["input"].disabled, false, "composer enabled after boot");
});
+
+// ── P5(rant 15:07:19):打开会话对话框(两步:项目 → 会话) ────────
+
+test("P5: showOpenSessionDialog 列项目(第一步)→ 点项目列会话(第二步)", async () => {
+ const projects = [
+ { name: "emrg", path: "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/p/emrg" },
+ { name: "mem", path: "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/p/mem" },
+ ];
+ const { ctx, els } = makeSandbox({
+ listProjects: async () => projects,
+ listProjectSessions: async ({ projectPath }) => {
+ if (projectPath === "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/p/emrg") return { sessions: [{ session_id: "s1", title: "S1" }, { session_id: "s2", title: "S2" }] };
+ return { sessions: [] };
+ },
+ });
+ await tick();
+ await vm.runInContext("EMRG_Dialogs.showOpenSessionDialog()", ctx);
+ await tick();
+ assert.strictEqual(els["open-session-dialog"].open, true, "dialog opened");
+ assert.strictEqual(els["open-session-list"].children.length, 2, "two projects listed");
+ // 点击 emrg 项目 → 第二步列出会话(按钮行子节点 [name, hint])
+ els["open-session-list"].children[0].click();
+ await tick();
+ const rows = els["open-session-list"].children;
+ assert.strictEqual(rows.length, 2, "two sessions listed for project");
+ const nameSpan = rows[0].children[0] || rows[0];
+ assert.ok((nameSpan.textContent || "").includes("S1"), "session title shown");
+});
+
+test("P5: /open 指令 → 打开会话对话框;无项目 → 提示新建", async () => {
+ const { ctx, els } = makeSandbox({
+ listProjects: async () => [],
+ });
+ await tick();
+ await vm.runInContext('App.handleCommand({ cmd: "/open", args: [] });', ctx);
+ await tick();
+ assert.strictEqual(els["open-session-dialog"].open, true, "dialog opened via /open");
+ // 无项目 → innerHTML 字符串呈现提示(mock innerHTML 赋值不建子节点)
+ assert.ok(
+ (els["open-session-list"].innerHTML || "").includes("新建项目") || (els["open-session-list"].innerHTML || "").includes("New project"),
+ "no-projects hint"
+ );
+});