diff --git a/emrg/gui/preload.js b/emrg/gui/preload.js
index b3485800..5c676cd9 100644
--- a/emrg/gui/preload.js
+++ b/emrg/gui/preload.js
@@ -13,6 +13,7 @@ const api = {
switchSession: (payload) => ipcRenderer.invoke("emrg:switchSession", payload),
newSession: () => ipcRenderer.invoke("emrg:newSession"),
deleteSession: (payload) => ipcRenderer.invoke("emrg:deleteSession", payload),
+ renameSession: (payload) => ipcRenderer.invoke("emrg:renameSession", payload),
setModel: (payload) => ipcRenderer.invoke("emrg:setModel", payload),
listModels: () => ipcRenderer.invoke("emrg:listModels"),
saveSettings: (payload) => ipcRenderer.invoke("emrg:saveSettings", payload),
diff --git a/emrg/gui/renderer/css/components.css b/emrg/gui/renderer/css/components.css
index 9bc21362..f6496fcd 100644
--- a/emrg/gui/renderer/css/components.css
+++ b/emrg/gui/renderer/css/components.css
@@ -741,3 +741,32 @@ dialog::backdrop {
color: var(--text-2);
margin-bottom: var(--sp-2);
}
+
+/* ── 右键菜单(对话列表:重命名/删除) ─────────── */
+.ctx-menu {
+ position: fixed;
+ z-index: 9999;
+ min-width: 140px;
+ background: var(--bg-panel);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-card);
+ box-shadow: var(--shadow-lg);
+ padding: 4px;
+ animation: fade-in var(--dur-fast) var(--ease);
+}
+.ctx-item {
+ display: block;
+ width: 100%;
+ text-align: left;
+ padding: 8px 12px;
+ border: none;
+ background: none;
+ border-radius: 8px;
+ font-size: var(--fs-secondary);
+ color: var(--text-1);
+ cursor: pointer;
+ transition: background-color var(--dur-fast) var(--ease);
+}
+.ctx-item:hover { background-color: var(--bg-soft); }
+.ctx-item.danger { color: var(--danger, #e5484d); }
+.ctx-item.danger:hover { background-color: color-mix(in srgb, var(--danger, #e5484d) 10%, transparent); }
diff --git a/emrg/gui/renderer/index.html b/emrg/gui/renderer/index.html
index fb1bfbc9..b052d8bd 100644
--- a/emrg/gui/renderer/index.html
+++ b/emrg/gui/renderer/index.html
@@ -162,6 +162,23 @@
确认
+
+
+
+
+
+
diff --git a/emrg/gui/renderer/js/app.js b/emrg/gui/renderer/js/app.js
index 2998140f..fc09b305 100644
--- a/emrg/gui/renderer/js/app.js
+++ b/emrg/gui/renderer/js/app.js
@@ -173,12 +173,34 @@ const App = (() => {
// ── 右键菜单(重命名 / 删除) ───────────
function showConvMenu(item, sid, title) {
- // 简单版:确认删除(P3 完善重命名)。右键菜单容器复用 conv-item 定位。
- Dialogs.showConfirm(EMRG_Copy.COPY.deleteConfirmTitle, EMRG_Copy.COPY.deleteConfirmBody, {
+ // 设计 §3.2:右键菜单 = 重命名 / 删除(删除有友好确认)
+ const menu = $("ctx-menu");
+ menu.innerHTML = "";
+ const mk = (label, danger, action) => {
+ const b = el("button", { class: "ctx-item" + (danger ? " danger" : "") }, label);
+ b.addEventListener("click", () => {
+ hideCtxMenu();
+ action();
+ });
+ menu.appendChild(b);
+ };
+ mk("✏️ 重命名", false, () => Dialogs.showRename(sid, title));
+ mk("🗑 删除对话", true, () => Dialogs.showConfirm(EMRG_Copy.COPY.deleteConfirmTitle, EMRG_Copy.COPY.deleteConfirmBody, {
okText: "删除",
danger: true,
onOk: () => deleteSession(sid),
- });
+ }));
+ menu.hidden = false;
+ // 定位在右键处,超出视口则上移/左移
+ const rect = item.getBoundingClientRect();
+ menu.style.left = Math.min(rect.right, window.innerWidth - 160) + "px";
+ menu.style.top = Math.min(rect.bottom, window.innerHeight - 80) + "px";
+ }
+
+ function hideCtxMenu() {
+ const menu = $("ctx-menu");
+ menu.hidden = true;
+ menu.innerHTML = "";
}
// ── 模型切换器 ─────────────────────────
@@ -439,6 +461,11 @@ const App = (() => {
input.style.height = Math.min(input.scrollHeight, 150) + "px";
});
+ // 右键菜单:点击别处隐藏(showConvMenu 内已绑定选项点击)
+ document.addEventListener("click", (e) => {
+ if (!$("ctx-menu").hidden && !e.target.closest("#ctx-menu")) hideCtxMenu();
+ });
+
// 快捷键:⌘N 新对话 / ⌘B 折叠侧边栏 / ⌘, 设置 / ESC 停止或关弹窗
document.addEventListener("keydown", (e) => {
if (e.metaKey || e.ctrlKey) {
@@ -455,6 +482,7 @@ const App = (() => {
return;
}
if (e.key === "Escape") {
+ hideCtxMenu(); // 右键菜单优先关闭
const dlgs = document.querySelectorAll("dialog[open]");
if (dlgs.length) return; // dialog 原生 ESC 处理
if (state.busy) {
@@ -466,6 +494,7 @@ const App = (() => {
Dialogs.initThemeButtons();
Dialogs.initModelForm();
+ Dialogs.initRenameDialog();
initModelSwitcher();
}
diff --git a/emrg/gui/renderer/js/dialogs.js b/emrg/gui/renderer/js/dialogs.js
index 7d21f832..74c24a9c 100644
--- a/emrg/gui/renderer/js/dialogs.js
+++ b/emrg/gui/renderer/js/dialogs.js
@@ -274,6 +274,46 @@ const Dialogs = (() => {
}
}
+ // ── 重命名对话(右键菜单 → 重命名) ────
+ let renameSid = null;
+ function showRename(sid, currentTitle) {
+ renameSid = sid;
+ const input = $("rename-input");
+ input.value = (currentTitle && currentTitle !== sid) ? currentTitle : "";
+ $("rename-dialog").showModal();
+ input.focus();
+ input.select();
+ }
+
+ async function submitRename() {
+ if (!renameSid) return;
+ const input = $("rename-input");
+ const title = input.value.trim();
+ if (!title) return; // 空名不提交
+ try {
+ await window.emrg.renameSession({ sessionId: renameSid, title });
+ renameSid = null;
+ $("rename-dialog").close();
+ await App.refreshSessions();
+ } catch (e) {
+ Chat.addSystemMessage(`重命名失败了:${e.message}`);
+ }
+ }
+
+ function initRenameDialog() {
+ $("rename-cancel").addEventListener("click", () => {
+ renameSid = null;
+ $("rename-dialog").close();
+ });
+ $("rename-ok").addEventListener("click", submitRename);
+ $("rename-input").addEventListener("keydown", (e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ submitRename();
+ }
+ });
+ }
+
// ── 确认对话框(替代 confirm/alert) ────
let confirmCb = null;
function showConfirm(title, message, opts = {}) {
@@ -302,6 +342,9 @@ const Dialogs = (() => {
return {
initThemeButtons,
initModelForm,
+ initRenameDialog,
+ showRename,
+ submitRename,
showSettings,
saveSettings,
showWelcome,
diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js
index 79dc97ca..b244f83a 100644
--- a/emrg/gui/test/renderer.smoke.test.js
+++ b/emrg/gui/test/renderer.smoke.test.js
@@ -55,6 +55,7 @@ function makeEl(id) {
insertBefore(c) { this.children.unshift(c); return c; },
remove() {},
focus() {},
+ select() {},
};
return node;
}
@@ -66,6 +67,7 @@ const ELEMENT_IDS = [
"set-model", "pick-dir-btn", "theme-options", "welcome-dialog", "welcome-api-key", "welcome-base-url",
"welcome-model", "welcome-project-dir", "welcome-pick-btn", "welcome-save", "confirm-dialog",
"confirm-title", "confirm-message", "confirm-cancel", "confirm-ok", "main",
+ "rename-dialog", "rename-input", "rename-cancel", "rename-ok", "ctx-menu",
"model-list", "add-model-btn", "model-form", "model-form-name", "model-form-id",
"model-form-vision", "model-form-save", "model-form-cancel", "back-to-bottom",
];
@@ -234,3 +236,38 @@ test("多模型管理:modelDetails 加载渲染 + saveSettings 传 models 数
const gpt = saved.models.find((m) => m.name === "gpt-4o");
assert.strictEqual(gpt.vision, true);
});
+
+test("右键菜单:重命名对话框 → renameSession 调用(设计 §3.2)", async () => {
+ let renamed = null;
+ const { ctx, els } = makeSandbox({
+ renameSession: async (payload) => {
+ renamed = payload;
+ return { ok: true, title: payload.title };
+ },
+ listSessions: async () => [
+ { session_id: "s1", title: "旧标题", created_at: "2026-08-06T00:00:00" },
+ ],
+ });
+ // 模拟 App.showConvMenu(右键菜单构建)
+ await tick();
+ vm.runInContext('App.showConvMenu({ getBoundingClientRect: () => ({ right: 100, bottom: 100 }) }, "s1", "旧标题")', ctx);
+ const menu = els["ctx-menu"];
+ assert.strictEqual(menu.hidden, false, "右键菜单应显示");
+ assert.ok(menu.children.length >= 2, "菜单应有 重命名 + 删除 两项");
+ const labels = menu.children.map((c) => c.textContent);
+ assert.ok(labels.some((l) => l.includes("重命名")), `菜单应含重命名,实际 ${labels}`);
+ assert.ok(labels.some((l) => l.includes("删除")), `菜单应含删除,实际 ${labels}`);
+
+ // 点击重命名 → 对话框打开
+ await vm.runInContext('Dialogs.showRename("s1", "旧标题")', ctx);
+ assert.strictEqual(els["rename-dialog"].open, true, "重命名对话框应打开");
+ assert.strictEqual(els["rename-input"].value, "旧标题", "输入框预填当前标题");
+
+ // 提交新标题 → renameSession 调用
+ els["rename-input"].value = "新标题";
+ await vm.runInContext("Dialogs.submitRename()", ctx);
+ await tick();
+ assert.ok(renamed, "renameSession 应被调用");
+ assert.strictEqual(renamed.sessionId, "s1");
+ assert.strictEqual(renamed.title, "新标题");
+});