') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); emrg: GUI 右键菜单补全重命名 — 对话管理闭环(设计 §3.2) by argszero · Pull Request #423 · argszero/emrg · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions emrg/gui/preload.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -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),
Expand Down
29 changes: 29 additions & 0 deletions emrg/gui/renderer/css/components.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -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); }
17 changes: 17 additions & 0 deletions emrg/gui/renderer/index.html
Original file line numberDiff line numberDiff line change
Expand Up@@ -162,6 +162,23 @@ <h2 id="confirm-title">确认</h2>
</div>
</dialog>

<!-- 重命名对话框(对话列表右键 → 重命名) -->
<dialog id="rename-dialog">
<div class="dialog-card" style="min-width:320px;">
<h2>重命名对话</h2>
<label>给这段对话起个好记的名字
<input type="text" id="rename-input" maxlength="80" placeholder="例如:周报草稿" />
</label>
<div class="dialog-actions">
<button type="button" id="rename-cancel" class="btn btn-ghost">取消</button>
<button type="button" id="rename-ok" class="btn btn-primary">保存</button>
</div>
</div>
</dialog>

<!-- 右键菜单(对话列表:重命名 / 删除) -->
<div id="ctx-menu" class="ctx-menu" hidden></div>

<script src="../vendor/marked.min.js"></script>
<script src="../vendor/dompurify.min.js"></script>
<script src="../vendor/highlight.custom.js"></script>
Expand Down
35 changes: 32 additions & 3 deletions emrg/gui/renderer/js/app.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 = "";
}

// ── 模型切换器 ─────────────────────────
Expand DownExpand Up@@ -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) {
Expand All@@ -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) {
Expand All@@ -466,6 +494,7 @@ const App = (() => {

Dialogs.initThemeButtons();
Dialogs.initModelForm();
Dialogs.initRenameDialog();
initModelSwitcher();
}

Expand Down
43 changes: 43 additions & 0 deletions emrg/gui/renderer/js/dialogs.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 = {}) {
Expand DownExpand Up@@ -302,6 +342,9 @@ const Dialogs = (() => {
return {
initThemeButtons,
initModelForm,
initRenameDialog,
showRename,
submitRename,
showSettings,
saveSettings,
showWelcome,
Expand Down
37 changes: 37 additions & 0 deletions emrg/gui/test/renderer.smoke.test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,6 +55,7 @@ function makeEl(id) {
insertBefore(c) { this.children.unshift(c); return c; },
remove() {},
focus() {},
select() {},
};
return node;
}
Expand All@@ -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",
];
Expand DownExpand Up@@ -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, "新标题");
});
Loading