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
10 changes: 10 additions & 0 deletions emrg/gui/main.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -382,6 +382,16 @@ vision = false
return { ok: true };
});

// 升级横幅重启(rant 2026-08-27T21:54:51):仅重启 GUI 进程本身,绝不触碰
// daemon —— 服务端是 EMRG 生命本体(MANIFESTO 最高原则),不得走上面的
// restartDaemon stop 链。新 GUI 进程启动后复用已在运行的 daemon;若 daemon
// 本身也需用新代码重启,由 heartbeat 的 installed≠current 驱动用户再次点击。
ipcMain.handle("emrg:relaunchGui", async () => {
app.relaunch();
app.exit(0);
return { ok: true };
});

ipcMain.handle("emrg:switchSession", async (_e, { sessionId, projectPath } = {}) => {
logger.info(`[gui:switch] REQUEST sid=${sessionId} projectPath=${projectPath}`);
if (!validateSessionId(sessionId)) {
Expand Down
1 change: 1 addition & 0 deletions emrg/gui/preload.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,7 @@ const api = {
sendMessage: (payload) => ipcRenderer.invoke("emrg:sendMessage", payload),
listSessions: () => ipcRenderer.invoke("emrg:listSessions"),
restartDaemon: () => ipcRenderer.invoke("emrg:restartDaemon"),
relaunchGui: () => ipcRenderer.invoke("emrg:relaunchGui"),
switchSession: (payload) => ipcRenderer.invoke("emrg:switchSession", payload),
newSession: (payload) => ipcRenderer.invoke("emrg:newSession", payload),
deleteSession: (payload) => ipcRenderer.invoke("emrg:deleteSession", payload),
Expand Down
1 change: 1 addition & 0 deletions emrg/gui/renderer/src/components/DialogHost.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,6 +30,7 @@ function appState(over: Partial<DaemonAppState> = {}): DaemonAppState {
busyBySid: {},
ownStreamRidBySid: {},
disconnectedBySid: {},
upgradeBanner: null,
...over,
};
}
Expand Down
29 changes: 29 additions & 0 deletions emrg/gui/renderer/src/components/Shell.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,6 +36,7 @@ function mockEmrg() {
const taskDelete = vi.fn().mockResolvedValue({ ok: true });
const taskTemplateList = vi.fn().mockResolvedValue([{ name: "journal" }]);
const sendRant = vi.fn().mockResolvedValue({ ok: true, count: 11 });
const relaunchGui = vi.fn().mockResolvedValue({ ok: true });
(window as unknown as { emrg?: unknown }).emrg = {
onEvent,
sendMessage,
Expand All@@ -52,6 +53,7 @@ function mockEmrg() {
taskDelete,
taskTemplateList,
sendRant,
relaunchGui,
};
return {
listeners,
Expand All@@ -66,6 +68,7 @@ function mockEmrg() {
taskDelete,
taskTemplateList,
sendRant,
relaunchGui,
emit: (evt: DaemonEventFrame) => listeners.forEach((cb) => cb(evt)),
};
}
Expand DownExpand Up@@ -421,4 +424,30 @@ describe("Shell (Batch 5 slice 3 chat wiring)", () => {
await waitFor(() => expect(m.listRants).toHaveBeenCalledTimes(2));
expect(screen.queryByTestId("rant-dialog")).not.toBeInTheDocument();
});

it("upgrade 事件 → 渲染升级横幅;重启按钮调 relaunchGui(仅 GUI,不碰 daemon);dismiss 后同版本不重现", async () => {
const m = mockEmrg();
render(wrapper(<Shell />));
await waitFor(() => expect(m.onEvent).toHaveBeenCalledTimes(1));
// 初始无横幅
expect(screen.queryByTestId("upgrade-banner")).not.toBeInTheDocument();
// 心跳 upgrade 帧(installed 0.2.84 ≠ current 0.2.83)
act(() => {
m.emit({ type: "upgrade", data: { current_version: "0.2.83", installed_version: "0.2.84" } });
});
const banner = await screen.findByTestId("upgrade-banner");
expect(banner).toBeInTheDocument();
// from→to 文案(en 词典)
expect(screen.getByText(/upgraded from 0\.2\.83 to 0\.2\.84/i)).toBeInTheDocument();
// 重启按钮 → relaunchGui(仅 GUI 进程重启,绝不走 restartDaemon stop 链)
fireEvent.click(screen.getByTestId("upgrade-banner-restart"));
await waitFor(() => expect(m.relaunchGui).toHaveBeenCalledTimes(1));
// dismiss → 隐藏;同版本心跳重发不重现(vanilla lastKnownVersion 语义)
fireEvent.click(screen.getByTestId("upgrade-banner-dismiss"));
expect(screen.queryByTestId("upgrade-banner")).not.toBeInTheDocument();
act(() => {
m.emit({ type: "upgrade", data: { current_version: "0.2.83", installed_version: "0.2.84" } });
});
expect(screen.queryByTestId("upgrade-banner")).not.toBeInTheDocument();
});
});
58 changes: 58 additions & 0 deletions emrg/gui/renderer/src/components/Shell.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,6 +61,8 @@ interface WorkspaceBridge {
taskDelete(p: { name: string }): Promise<unknown>;
taskTemplateList(): Promise<{ name: string }[]>;
sendRant(p: { message: string; project?: string }): Promise<{ ok?: boolean; count?: number }>;
/** 升级横幅重启:仅重启 GUI 进程本身(emrg:relaunchGui),绝不触碰 daemon */
relaunchGui?(): Promise<unknown>;
}

function wsBridge(): WorkspaceBridge | undefined {
Expand All@@ -85,6 +87,11 @@ export function Shell() {
const [taskForm, setTaskForm] = useState<{ task: TaskRec | null } | null>(null);
const [rantOpen, setRantOpen] = useState(false);
const [taskTypes, setTaskTypes] = useState<string[]>([]);
// 升级横幅(rant 2026-08-27T21:54:51:React 迁移丢 upgrade 事件):dismissed 记录
// 已提示过的 installed 版本(vanilla state.lastKnownVersion 语义——心跳每 15s 重发
// 同一版本,不重复弹);restarting 防重复点击(relaunch 后进程即退出)。
const [dismissedUpgrade, setDismissedUpgrade] = useState<string | null>(null);
const [restarting, setRestarting] = useState(false);

// open_sessions 广播到达且尚无激活会话 → 自动选第一个(vanilla 同语义)
useEffect(() => {
Expand DownExpand Up@@ -358,8 +365,59 @@ export function Shell() {
}
}

/** 升级横幅重启:调 emrg:relaunchGui(仅重启 GUI 进程,不碰 daemon ——
* 服务端是 EMRG 生命本体,不得走 restartDaemon 的 stop 链)。失败 → 系统消息。 */
async function handleUpgradeRestart() {
const b = wsBridge();
if (!b?.relaunchGui) return;
setRestarting(true);
try {
await b.relaunchGui();
// 成功:进程即将退出重启,无需清理(restarting 状态随进程消亡)
} catch (e) {
setRestarting(false);
transcript.addSystemMessage(
t("app.upgradeRestartFailed", { msg: e instanceof Error ? e.message : String(e) }),
activeSid,
);
}
}

const upgradeBanner = appState.upgradeBanner;
const showUpgradeBanner =
upgradeBanner && upgradeBanner.installed !== "" && upgradeBanner.installed !== dismissedUpgrade;

return (
<div className="react-shell" data-testid="react-shell">
{showUpgradeBanner ? (
<div id="upgrade-banner" role="status" data-testid="upgrade-banner">
<span id="upgrade-banner-msg">
{upgradeBanner.current && upgradeBanner.current !== upgradeBanner.installed
? t("app.upgradeBannerMsgFromTo", { from: upgradeBanner.current, to: upgradeBanner.installed })
: t("app.upgradeBannerMsg", { version: upgradeBanner.installed })}
</span>
<button
type="button"
id="upgrade-banner-restart"
className="btn btn-primary"
data-testid="upgrade-banner-restart"
disabled={restarting}
onClick={() => void handleUpgradeRestart()}
>
{t("app.upgradeRestartBtn")}
</button>
<button
type="button"
id="upgrade-banner-dismiss"
className="btn btn-ghost"
title="✕"
data-testid="upgrade-banner-dismiss"
onClick={() => setDismissedUpgrade(upgradeBanner.installed)}
>
</button>
</div>
) : null}
<header className="react-shell-header">
<span className="react-shell-brand">✦ EMRG</span>
<span className="react-shell-conn" data-testid="conn-status" title={t("sidebar.statusTitle")}>
Expand Down
16 changes: 16 additions & 0 deletions emrg/gui/renderer/src/lib/daemonBridge.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -175,4 +175,20 @@ describe("createDaemonBridge", () => {
expect(st.connected).toBe(false);
expect(st.sessions).toHaveLength(0);
});

it("upgrade → store.upgradeBanner;同 installed 心跳重发不重建(dedupe)", () => {
const { emit, bridge } = setup();
emit({ type: "upgrade", data: { current_version: "0.2.83", installed_version: "0.2.84" } });
expect(bridge.store.get().upgradeBanner).toEqual({ current: "0.2.83", installed: "0.2.84" });
// 心跳每 15s 重发同一版本 → 引用不变(Object.is no-op,不触发 re-render)
const before = bridge.store.get();
emit({ type: "upgrade", data: { current_version: "0.2.83", installed_version: "0.2.84" } });
expect(bridge.store.get()).toBe(before);
// 新 installed 版本 → 更新
emit({ type: "upgrade", data: { current_version: "0.2.84", installed_version: "0.2.85" } });
expect(bridge.store.get().upgradeBanner).toEqual({ current: "0.2.84", installed: "0.2.85" });
// 空 installed → 忽略(dev 运行无版本数据)
emit({ type: "upgrade", data: { current_version: "0.2.85", installed_version: "" } });
expect(bridge.store.get().upgradeBanner).toEqual({ current: "0.2.84", installed: "0.2.85" });
});
});
24 changes: 23 additions & 1 deletion emrg/gui/renderer/src/lib/daemonBridge.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,10 +58,16 @@ export interface ErrorData {
message?: string;
}

/** upgrade 事件载荷(main.js 心跳检测 installed_version ≠ current_version → 专用事件) */
export interface UpgradeData {
current_version?: string;
installed_version?: string;
}

/** 统一事件帧(onEvent 回调入参) */
export interface DaemonEventFrame {
type: string;
data: Record<string, unknown> & { chunks?: DeltaChunk[] } & Partial<DoneData & ToolStartData & ToolEndData & StatusData & PongData & SessionsData & QueuedData & ErrorData>;
data: Record<string, unknown> & { chunks?: DeltaChunk[] } & Partial<DoneData & ToolStartData & ToolEndData & StatusData & PongData & SessionsData & QueuedData & ErrorData & UpgradeData>;
sid?: string | null;
}

Expand All@@ -86,6 +92,8 @@ export interface DaemonAppState {
ownStreamRidBySid: Record<string, string | null>;
/** 每会话断线标记(P3 finalize:后台会话断线不触发全局 UI) */
disconnectedBySid: Record<string, boolean>;
/** upgrade 事件(心跳检测 installed ≠ current → "重启生效"横幅;null=无待重启提示) */
upgradeBanner: { current: string; installed: string } | null;
}

const SID_NULL = "__emrg_null_sid__";
Expand All@@ -106,6 +114,7 @@ export function createDaemonAppStore(): SnapshotStore<DaemonAppState> {
busyBySid: {},
ownStreamRidBySid: {},
disconnectedBySid: {},
upgradeBanner: null,
});
}

Expand DownExpand Up@@ -316,6 +325,19 @@ export function createDaemonBridge(deps: DaemonBridgeDeps): DaemonBridge {
}
break;
}
case "upgrade": {
// 心跳每 15s 检测到 installed ≠ current 都会重发;同一 installed 版本只
// 写一次 store(vanilla lastKnownVersion 语义:不重复弹,dismiss 后不再出现)。
const up = data as UpgradeData;
const installed = up.installed_version || "";
if (installed && installed !== store.get().upgradeBanner?.installed) {
store.update((s) => ({
...s,
upgradeBanner: { current: up.current_version || s.currentVersion, installed },
}));
}
break;
}
default:
// 未知事件类型:忽略(vanilla 同语义,未来类型静默兼容)
break;
Expand Down
2 changes: 2 additions & 0 deletions emrg/gui/renderer/src/shell.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,8 @@
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text-1);
/* 升级横幅 #upgrade-banner 用 absolute 定位(vanilla #app 为 positioned ancestor) */
position: relative;
}

.react-shell-header {
Expand Down
11 changes: 6 additions & 5 deletions emrg/gui/test/preload-api.test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,12 +3,12 @@
* preload-api.test.js — window.emrg preload 桥 API 面守卫测试(React 迁移红线 #1)。
*
* 背景(cycle cyc20260826-131007 发现):React 迁移设计文档 §2.2 声称 preload
* API 46 个方法,实际 preload.js 已增长到 52 个 invoke 方法 + onEvent 订阅
* API 46 个方法,实际 preload.js 已增长到 53 个 invoke 方法 + onEvent 订阅
* (设计文档口径过时)。此前 build-config.test.js 只抽查 listFiles/readFile 两个
* API——没有完整 API 面的回归守卫。React 化迁移(Batch 0-5)的红线就是
* "window.emrg 方法集合原样保留",本测试把整个契约钉死:
*
* 1. 全部 52 个 invoke 方法 + onEvent 必须存在(防误删/改名)
* 1. 全部 53 个 invoke 方法 + onEvent 必须存在(防误删/改名)
* 2. 每个方法必须调用 ipcRenderer.invoke("emrg:<同名频道>")(防频道漂移)
* 3. onEvent 必须订阅 emrg:event 广播(防事件通道漂移)
*
Expand All@@ -23,12 +23,13 @@ const path = require("node:path");

const PRELOAD = fs.readFileSync(path.join(__dirname, "..", "preload.js"), "utf8");

/** 冻结的期望 API 面:52 invoke 方法(按 preload.js 实际顺序)+ onEvent */
/** 冻结的期望 API 面:53 invoke 方法(按 preload.js 实际顺序)+ onEvent */
const EXPECTED_INVOKE_METHODS = [
"init",
"sendMessage",
"listSessions",
"restartDaemon",
"relaunchGui",
"switchSession",
"newSession",
"deleteSession",
Expand DownExpand Up@@ -115,8 +116,8 @@ function parseApiBlock(block) {

const api = parseApiBlock(extractApiBlock());

test("window.emrg exposes the full 53-member contract (52 invoke + onEvent)", () => {
// 52 invoke 方法全部存在(防误删/改名)
test("window.emrg exposes the full 54-member contract (53 invoke + onEvent)", () => {
// 53 invoke 方法全部存在(防误删/改名)
for (const name of EXPECTED_INVOKE_METHODS) {
assert.ok(api.invoke.has(name), `缺少 invoke 方法 ${name}`);
}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
10 changes: 10 additions & 0 deletions emrg/gui/main.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -382,6 +382,16 @@ vision = false
return { ok: true };
});

// 升级横幅重启(rant 2026-08-27T21:54:51):仅重启 GUI 进程本身,绝不触碰
// daemon —— 服务端是 EMRG 生命本体(MANIFESTO 最高原则),不得走上面的
// restartDaemon stop 链。新 GUI 进程启动后复用已在运行的 daemon;若 daemon
// 本身也需用新代码重启,由 heartbeat 的 installed≠current 驱动用户再次点击。
ipcMain.handle("emrg:relaunchGui", async () => {
app.relaunch();
app.exit(0);
return { ok: true };
});

ipcMain.handle("emrg:switchSession", async (_e, { sessionId, projectPath } = {}) => {
logger.info(`[gui:switch] REQUEST sid=${sessionId} projectPath=${projectPath}`);
if (!validateSessionId(sessionId)) {
Expand Down
1 change: 1 addition & 0 deletions emrg/gui/preload.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,7 @@ const api = {
sendMessage: (payload) => ipcRenderer.invoke("emrg:sendMessage", payload),
listSessions: () => ipcRenderer.invoke("emrg:listSessions"),
restartDaemon: () => ipcRenderer.invoke("emrg:restartDaemon"),
relaunchGui: () => ipcRenderer.invoke("emrg:relaunchGui"),
switchSession: (payload) => ipcRenderer.invoke("emrg:switchSession", payload),
newSession: (payload) => ipcRenderer.invoke("emrg:newSession", payload),
deleteSession: (payload) => ipcRenderer.invoke("emrg:deleteSession", payload),
Expand Down
1 change: 1 addition & 0 deletions emrg/gui/renderer/src/components/DialogHost.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,6 +30,7 @@ function appState(over: Partial<DaemonAppState> = {}): DaemonAppState {
busyBySid: {},
ownStreamRidBySid: {},
disconnectedBySid: {},
upgradeBanner: null,
...over,
};
}
Expand Down
29 changes: 29 additions & 0 deletions emrg/gui/renderer/src/components/Shell.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,6 +36,7 @@ function mockEmrg() {
const taskDelete = vi.fn().mockResolvedValue({ ok: true });
const taskTemplateList = vi.fn().mockResolvedValue([{ name: "journal" }]);
const sendRant = vi.fn().mockResolvedValue({ ok: true, count: 11 });
const relaunchGui = vi.fn().mockResolvedValue({ ok: true });
(window as unknown as { emrg?: unknown }).emrg = {
onEvent,
sendMessage,
Expand All@@ -52,6 +53,7 @@ function mockEmrg() {
taskDelete,
taskTemplateList,
sendRant,
relaunchGui,
};
return {
listeners,
Expand All@@ -66,6 +68,7 @@ function mockEmrg() {
taskDelete,
taskTemplateList,
sendRant,
relaunchGui,
emit: (evt: DaemonEventFrame) => listeners.forEach((cb) => cb(evt)),
};
}
Expand DownExpand Up@@ -421,4 +424,30 @@ describe("Shell (Batch 5 slice 3 chat wiring)", () => {
await waitFor(() => expect(m.listRants).toHaveBeenCalledTimes(2));
expect(screen.queryByTestId("rant-dialog")).not.toBeInTheDocument();
});

it("upgrade 事件 → 渲染升级横幅;重启按钮调 relaunchGui(仅 GUI,不碰 daemon);dismiss 后同版本不重现", async () => {
const m = mockEmrg();
render(wrapper(<Shell />));
await waitFor(() => expect(m.onEvent).toHaveBeenCalledTimes(1));
// 初始无横幅
expect(screen.queryByTestId("upgrade-banner")).not.toBeInTheDocument();
// 心跳 upgrade 帧(installed 0.2.84 ≠ current 0.2.83)
act(() => {
m.emit({ type: "upgrade", data: { current_version: "0.2.83", installed_version: "0.2.84" } });
});
const banner = await screen.findByTestId("upgrade-banner");
expect(banner).toBeInTheDocument();
// from→to 文案(en 词典)
expect(screen.getByText(/upgraded from 0\.2\.83 to 0\.2\.84/i)).toBeInTheDocument();
// 重启按钮 → relaunchGui(仅 GUI 进程重启,绝不走 restartDaemon stop 链)
fireEvent.click(screen.getByTestId("upgrade-banner-restart"));
await waitFor(() => expect(m.relaunchGui).toHaveBeenCalledTimes(1));
// dismiss → 隐藏;同版本心跳重发不重现(vanilla lastKnownVersion 语义)
fireEvent.click(screen.getByTestId("upgrade-banner-dismiss"));
expect(screen.queryByTestId("upgrade-banner")).not.toBeInTheDocument();
act(() => {
m.emit({ type: "upgrade", data: { current_version: "0.2.83", installed_version: "0.2.84" } });
});
expect(screen.queryByTestId("upgrade-banner")).not.toBeInTheDocument();
});
});
58 changes: 58 additions & 0 deletions emrg/gui/renderer/src/components/Shell.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,6 +61,8 @@ interface WorkspaceBridge {
taskDelete(p: { name: string }): Promise<unknown>;
taskTemplateList(): Promise<{ name: string }[]>;
sendRant(p: { message: string; project?: string }): Promise<{ ok?: boolean; count?: number }>;
/** 升级横幅重启:仅重启 GUI 进程本身(emrg:relaunchGui),绝不触碰 daemon */
relaunchGui?(): Promise<unknown>;
}

function wsBridge(): WorkspaceBridge | undefined {
Expand All@@ -85,6 +87,11 @@ export function Shell() {
const [taskForm, setTaskForm] = useState<{ task: TaskRec | null } | null>(null);
const [rantOpen, setRantOpen] = useState(false);
const [taskTypes, setTaskTypes] = useState<string[]>([]);
// 升级横幅(rant 2026-08-27T21:54:51:React 迁移丢 upgrade 事件):dismissed 记录
// 已提示过的 installed 版本(vanilla state.lastKnownVersion 语义——心跳每 15s 重发
// 同一版本,不重复弹);restarting 防重复点击(relaunch 后进程即退出)。
const [dismissedUpgrade, setDismissedUpgrade] = useState<string | null>(null);
const [restarting, setRestarting] = useState(false);

// open_sessions 广播到达且尚无激活会话 → 自动选第一个(vanilla 同语义)
useEffect(() => {
Expand DownExpand Up@@ -358,8 +365,59 @@ export function Shell() {
}
}

/** 升级横幅重启:调 emrg:relaunchGui(仅重启 GUI 进程,不碰 daemon ——
* 服务端是 EMRG 生命本体,不得走 restartDaemon 的 stop 链)。失败 → 系统消息。 */
async function handleUpgradeRestart() {
const b = wsBridge();
if (!b?.relaunchGui) return;
setRestarting(true);
try {
await b.relaunchGui();
// 成功:进程即将退出重启,无需清理(restarting 状态随进程消亡)
} catch (e) {
setRestarting(false);
transcript.addSystemMessage(
t("app.upgradeRestartFailed", { msg: e instanceof Error ? e.message : String(e) }),
activeSid,
);
}
}

const upgradeBanner = appState.upgradeBanner;
const showUpgradeBanner =
upgradeBanner && upgradeBanner.installed !== "" && upgradeBanner.installed !== dismissedUpgrade;

return (
<div className="react-shell" data-testid="react-shell">
{showUpgradeBanner ? (
<div id="upgrade-banner" role="status" data-testid="upgrade-banner">
<span id="upgrade-banner-msg">
{upgradeBanner.current && upgradeBanner.current !== upgradeBanner.installed
? t("app.upgradeBannerMsgFromTo", { from: upgradeBanner.current, to: upgradeBanner.installed })
: t("app.upgradeBannerMsg", { version: upgradeBanner.installed })}
</span>
<button
type="button"
id="upgrade-banner-restart"
className="btn btn-primary"
data-testid="upgrade-banner-restart"
disabled={restarting}
onClick={() => void handleUpgradeRestart()}
>
{t("app.upgradeRestartBtn")}
</button>
<button
type="button"
id="upgrade-banner-dismiss"
className="btn btn-ghost"
title="✕"
data-testid="upgrade-banner-dismiss"
onClick={() => setDismissedUpgrade(upgradeBanner.installed)}
>
</button>
</div>
) : null}
<header className="react-shell-header">
<span className="react-shell-brand">✦ EMRG</span>
<span className="react-shell-conn" data-testid="conn-status" title={t("sidebar.statusTitle")}>
Expand Down
16 changes: 16 additions & 0 deletions emrg/gui/renderer/src/lib/daemonBridge.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -175,4 +175,20 @@ describe("createDaemonBridge", () => {
expect(st.connected).toBe(false);
expect(st.sessions).toHaveLength(0);
});

it("upgrade → store.upgradeBanner;同 installed 心跳重发不重建(dedupe)", () => {
const { emit, bridge } = setup();
emit({ type: "upgrade", data: { current_version: "0.2.83", installed_version: "0.2.84" } });
expect(bridge.store.get().upgradeBanner).toEqual({ current: "0.2.83", installed: "0.2.84" });
// 心跳每 15s 重发同一版本 → 引用不变(Object.is no-op,不触发 re-render)
const before = bridge.store.get();
emit({ type: "upgrade", data: { current_version: "0.2.83", installed_version: "0.2.84" } });
expect(bridge.store.get()).toBe(before);
// 新 installed 版本 → 更新
emit({ type: "upgrade", data: { current_version: "0.2.84", installed_version: "0.2.85" } });
expect(bridge.store.get().upgradeBanner).toEqual({ current: "0.2.84", installed: "0.2.85" });
// 空 installed → 忽略(dev 运行无版本数据)
emit({ type: "upgrade", data: { current_version: "0.2.85", installed_version: "" } });
expect(bridge.store.get().upgradeBanner).toEqual({ current: "0.2.84", installed: "0.2.85" });
});
});
24 changes: 23 additions & 1 deletion emrg/gui/renderer/src/lib/daemonBridge.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,10 +58,16 @@ export interface ErrorData {
message?: string;
}

/** upgrade 事件载荷(main.js 心跳检测 installed_version ≠ current_version → 专用事件) */
export interface UpgradeData {
current_version?: string;
installed_version?: string;
}

/** 统一事件帧(onEvent 回调入参) */
export interface DaemonEventFrame {
type: string;
data: Record<string, unknown> & { chunks?: DeltaChunk[] } & Partial<DoneData & ToolStartData & ToolEndData & StatusData & PongData & SessionsData & QueuedData & ErrorData>;
data: Record<string, unknown> & { chunks?: DeltaChunk[] } & Partial<DoneData & ToolStartData & ToolEndData & StatusData & PongData & SessionsData & QueuedData & ErrorData & UpgradeData>;
sid?: string | null;
}

Expand All@@ -86,6 +92,8 @@ export interface DaemonAppState {
ownStreamRidBySid: Record<string, string | null>;
/** 每会话断线标记(P3 finalize:后台会话断线不触发全局 UI) */
disconnectedBySid: Record<string, boolean>;
/** upgrade 事件(心跳检测 installed ≠ current → "重启生效"横幅;null=无待重启提示) */
upgradeBanner: { current: string; installed: string } | null;
}

const SID_NULL = "__emrg_null_sid__";
Expand All@@ -106,6 +114,7 @@ export function createDaemonAppStore(): SnapshotStore<DaemonAppState> {
busyBySid: {},
ownStreamRidBySid: {},
disconnectedBySid: {},
upgradeBanner: null,
});
}

Expand DownExpand Up@@ -316,6 +325,19 @@ export function createDaemonBridge(deps: DaemonBridgeDeps): DaemonBridge {
}
break;
}
case "upgrade": {
// 心跳每 15s 检测到 installed ≠ current 都会重发;同一 installed 版本只
// 写一次 store(vanilla lastKnownVersion 语义:不重复弹,dismiss 后不再出现)。
const up = data as UpgradeData;
const installed = up.installed_version || "";
if (installed && installed !== store.get().upgradeBanner?.installed) {
store.update((s) => ({
...s,
upgradeBanner: { current: up.current_version || s.currentVersion, installed },
}));
}
break;
}
default:
// 未知事件类型:忽略(vanilla 同语义,未来类型静默兼容)
break;
Expand Down
2 changes: 2 additions & 0 deletions emrg/gui/renderer/src/shell.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,8 @@
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text-1);
/* 升级横幅 #upgrade-banner 用 absolute 定位(vanilla #app 为 positioned ancestor) */
position: relative;
}

.react-shell-header {
Expand Down
11 changes: 6 additions & 5 deletions emrg/gui/test/preload-api.test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,12 +3,12 @@
* preload-api.test.js — window.emrg preload 桥 API 面守卫测试(React 迁移红线 #1)。
*
* 背景(cycle cyc20260826-131007 发现):React 迁移设计文档 §2.2 声称 preload
* API 46 个方法,实际 preload.js 已增长到 52 个 invoke 方法 + onEvent 订阅
* API 46 个方法,实际 preload.js 已增长到 53 个 invoke 方法 + onEvent 订阅
* (设计文档口径过时)。此前 build-config.test.js 只抽查 listFiles/readFile 两个
* API——没有完整 API 面的回归守卫。React 化迁移(Batch 0-5)的红线就是
* "window.emrg 方法集合原样保留",本测试把整个契约钉死:
*
* 1. 全部 52 个 invoke 方法 + onEvent 必须存在(防误删/改名)
* 1. 全部 53 个 invoke 方法 + onEvent 必须存在(防误删/改名)
* 2. 每个方法必须调用 ipcRenderer.invoke("emrg:<同名频道>")(防频道漂移)
* 3. onEvent 必须订阅 emrg:event 广播(防事件通道漂移)
*
Expand All@@ -23,12 +23,13 @@ const path = require("node:path");

const PRELOAD = fs.readFileSync(path.join(__dirname, "..", "preload.js"), "utf8");

/** 冻结的期望 API 面:52 invoke 方法(按 preload.js 实际顺序)+ onEvent */
/** 冻结的期望 API 面:53 invoke 方法(按 preload.js 实际顺序)+ onEvent */
const EXPECTED_INVOKE_METHODS = [
"init",
"sendMessage",
"listSessions",
"restartDaemon",
"relaunchGui",
"switchSession",
"newSession",
"deleteSession",
Expand DownExpand Up@@ -115,8 +116,8 @@ function parseApiBlock(block) {

const api = parseApiBlock(extractApiBlock());

test("window.emrg exposes the full 53-member contract (52 invoke + onEvent)", () => {
// 52 invoke 方法全部存在(防误删/改名)
test("window.emrg exposes the full 54-member contract (53 invoke + onEvent)", () => {
// 53 invoke 方法全部存在(防误删/改名)
for (const name of EXPECTED_INVOKE_METHODS) {
assert.ok(api.invoke.has(name), `缺少 invoke 方法 ${name}`);
}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
10 changes: 10 additions & 0 deletions emrg/gui/main.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -382,6 +382,16 @@ vision = false
return { ok: true };
});

// 升级横幅重启(rant 2026-08-27T21:54:51):仅重启 GUI 进程本身,绝不触碰
// daemon —— 服务端是 EMRG 生命本体(MANIFESTO 最高原则),不得走上面的
// restartDaemon stop 链。新 GUI 进程启动后复用已在运行的 daemon;若 daemon
// 本身也需用新代码重启,由 heartbeat 的 installed≠current 驱动用户再次点击。
ipcMain.handle("emrg:relaunchGui", async () => {
app.relaunch();
app.exit(0);
return { ok: true };
});

ipcMain.handle("emrg:switchSession", async (_e, { sessionId, projectPath } = {}) => {
logger.info(`[gui:switch] REQUEST sid=${sessionId} projectPath=${projectPath}`);
if (!validateSessionId(sessionId)) {
Expand Down
1 change: 1 addition & 0 deletions emrg/gui/preload.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,7 @@ const api = {
sendMessage: (payload) => ipcRenderer.invoke("emrg:sendMessage", payload),
listSessions: () => ipcRenderer.invoke("emrg:listSessions"),
restartDaemon: () => ipcRenderer.invoke("emrg:restartDaemon"),
relaunchGui: () => ipcRenderer.invoke("emrg:relaunchGui"),
switchSession: (payload) => ipcRenderer.invoke("emrg:switchSession", payload),
newSession: (payload) => ipcRenderer.invoke("emrg:newSession", payload),
deleteSession: (payload) => ipcRenderer.invoke("emrg:deleteSession", payload),
Expand Down
1 change: 1 addition & 0 deletions emrg/gui/renderer/src/components/DialogHost.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,6 +30,7 @@ function appState(over: Partial<DaemonAppState> = {}): DaemonAppState {
busyBySid: {},
ownStreamRidBySid: {},
disconnectedBySid: {},
upgradeBanner: null,
...over,
};
}
Expand Down
29 changes: 29 additions & 0 deletions emrg/gui/renderer/src/components/Shell.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,6 +36,7 @@ function mockEmrg() {
const taskDelete = vi.fn().mockResolvedValue({ ok: true });
const taskTemplateList = vi.fn().mockResolvedValue([{ name: "journal" }]);
const sendRant = vi.fn().mockResolvedValue({ ok: true, count: 11 });
const relaunchGui = vi.fn().mockResolvedValue({ ok: true });
(window as unknown as { emrg?: unknown }).emrg = {
onEvent,
sendMessage,
Expand All@@ -52,6 +53,7 @@ function mockEmrg() {
taskDelete,
taskTemplateList,
sendRant,
relaunchGui,
};
return {
listeners,
Expand All@@ -66,6 +68,7 @@ function mockEmrg() {
taskDelete,
taskTemplateList,
sendRant,
relaunchGui,
emit: (evt: DaemonEventFrame) => listeners.forEach((cb) => cb(evt)),
};
}
Expand DownExpand Up@@ -421,4 +424,30 @@ describe("Shell (Batch 5 slice 3 chat wiring)", () => {
await waitFor(() => expect(m.listRants).toHaveBeenCalledTimes(2));
expect(screen.queryByTestId("rant-dialog")).not.toBeInTheDocument();
});

it("upgrade 事件 → 渲染升级横幅;重启按钮调 relaunchGui(仅 GUI,不碰 daemon);dismiss 后同版本不重现", async () => {
const m = mockEmrg();
render(wrapper(<Shell />));
await waitFor(() => expect(m.onEvent).toHaveBeenCalledTimes(1));
// 初始无横幅
expect(screen.queryByTestId("upgrade-banner")).not.toBeInTheDocument();
// 心跳 upgrade 帧(installed 0.2.84 ≠ current 0.2.83)
act(() => {
m.emit({ type: "upgrade", data: { current_version: "0.2.83", installed_version: "0.2.84" } });
});
const banner = await screen.findByTestId("upgrade-banner");
expect(banner).toBeInTheDocument();
// from→to 文案(en 词典)
expect(screen.getByText(/upgraded from 0\.2\.83 to 0\.2\.84/i)).toBeInTheDocument();
// 重启按钮 → relaunchGui(仅 GUI 进程重启,绝不走 restartDaemon stop 链)
fireEvent.click(screen.getByTestId("upgrade-banner-restart"));
await waitFor(() => expect(m.relaunchGui).toHaveBeenCalledTimes(1));
// dismiss → 隐藏;同版本心跳重发不重现(vanilla lastKnownVersion 语义)
fireEvent.click(screen.getByTestId("upgrade-banner-dismiss"));
expect(screen.queryByTestId("upgrade-banner")).not.toBeInTheDocument();
act(() => {
m.emit({ type: "upgrade", data: { current_version: "0.2.83", installed_version: "0.2.84" } });
});
expect(screen.queryByTestId("upgrade-banner")).not.toBeInTheDocument();
});
});
58 changes: 58 additions & 0 deletions emrg/gui/renderer/src/components/Shell.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,6 +61,8 @@ interface WorkspaceBridge {
taskDelete(p: { name: string }): Promise<unknown>;
taskTemplateList(): Promise<{ name: string }[]>;
sendRant(p: { message: string; project?: string }): Promise<{ ok?: boolean; count?: number }>;
/** 升级横幅重启:仅重启 GUI 进程本身(emrg:relaunchGui),绝不触碰 daemon */
relaunchGui?(): Promise<unknown>;
}

function wsBridge(): WorkspaceBridge | undefined {
Expand All@@ -85,6 +87,11 @@ export function Shell() {
const [taskForm, setTaskForm] = useState<{ task: TaskRec | null } | null>(null);
const [rantOpen, setRantOpen] = useState(false);
const [taskTypes, setTaskTypes] = useState<string[]>([]);
// 升级横幅(rant 2026-08-27T21:54:51:React 迁移丢 upgrade 事件):dismissed 记录
// 已提示过的 installed 版本(vanilla state.lastKnownVersion 语义——心跳每 15s 重发
// 同一版本,不重复弹);restarting 防重复点击(relaunch 后进程即退出)。
const [dismissedUpgrade, setDismissedUpgrade] = useState<string | null>(null);
const [restarting, setRestarting] = useState(false);

// open_sessions 广播到达且尚无激活会话 → 自动选第一个(vanilla 同语义)
useEffect(() => {
Expand DownExpand Up@@ -358,8 +365,59 @@ export function Shell() {
}
}

/** 升级横幅重启:调 emrg:relaunchGui(仅重启 GUI 进程,不碰 daemon ——
* 服务端是 EMRG 生命本体,不得走 restartDaemon 的 stop 链)。失败 → 系统消息。 */
async function handleUpgradeRestart() {
const b = wsBridge();
if (!b?.relaunchGui) return;
setRestarting(true);
try {
await b.relaunchGui();
// 成功:进程即将退出重启,无需清理(restarting 状态随进程消亡)
} catch (e) {
setRestarting(false);
transcript.addSystemMessage(
t("app.upgradeRestartFailed", { msg: e instanceof Error ? e.message : String(e) }),
activeSid,
);
}
}

const upgradeBanner = appState.upgradeBanner;
const showUpgradeBanner =
upgradeBanner && upgradeBanner.installed !== "" && upgradeBanner.installed !== dismissedUpgrade;

return (
<div className="react-shell" data-testid="react-shell">
{showUpgradeBanner ? (
<div id="upgrade-banner" role="status" data-testid="upgrade-banner">
<span id="upgrade-banner-msg">
{upgradeBanner.current && upgradeBanner.current !== upgradeBanner.installed
? t("app.upgradeBannerMsgFromTo", { from: upgradeBanner.current, to: upgradeBanner.installed })
: t("app.upgradeBannerMsg", { version: upgradeBanner.installed })}
</span>
<button
type="button"
id="upgrade-banner-restart"
className="btn btn-primary"
data-testid="upgrade-banner-restart"
disabled={restarting}
onClick={() => void handleUpgradeRestart()}
>
{t("app.upgradeRestartBtn")}
</button>
<button
type="button"
id="upgrade-banner-dismiss"
className="btn btn-ghost"
title="✕"
data-testid="upgrade-banner-dismiss"
onClick={() => setDismissedUpgrade(upgradeBanner.installed)}
>
</button>
</div>
) : null}
<header className="react-shell-header">
<span className="react-shell-brand">✦ EMRG</span>
<span className="react-shell-conn" data-testid="conn-status" title={t("sidebar.statusTitle")}>
Expand Down
16 changes: 16 additions & 0 deletions emrg/gui/renderer/src/lib/daemonBridge.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -175,4 +175,20 @@ describe("createDaemonBridge", () => {
expect(st.connected).toBe(false);
expect(st.sessions).toHaveLength(0);
});

it("upgrade → store.upgradeBanner;同 installed 心跳重发不重建(dedupe)", () => {
const { emit, bridge } = setup();
emit({ type: "upgrade", data: { current_version: "0.2.83", installed_version: "0.2.84" } });
expect(bridge.store.get().upgradeBanner).toEqual({ current: "0.2.83", installed: "0.2.84" });
// 心跳每 15s 重发同一版本 → 引用不变(Object.is no-op,不触发 re-render)
const before = bridge.store.get();
emit({ type: "upgrade", data: { current_version: "0.2.83", installed_version: "0.2.84" } });
expect(bridge.store.get()).toBe(before);
// 新 installed 版本 → 更新
emit({ type: "upgrade", data: { current_version: "0.2.84", installed_version: "0.2.85" } });
expect(bridge.store.get().upgradeBanner).toEqual({ current: "0.2.84", installed: "0.2.85" });
// 空 installed → 忽略(dev 运行无版本数据)
emit({ type: "upgrade", data: { current_version: "0.2.85", installed_version: "" } });
expect(bridge.store.get().upgradeBanner).toEqual({ current: "0.2.84", installed: "0.2.85" });
});
});
24 changes: 23 additions & 1 deletion emrg/gui/renderer/src/lib/daemonBridge.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,10 +58,16 @@ export interface ErrorData {
message?: string;
}

/** upgrade 事件载荷(main.js 心跳检测 installed_version ≠ current_version → 专用事件) */
export interface UpgradeData {
current_version?: string;
installed_version?: string;
}

/** 统一事件帧(onEvent 回调入参) */
export interface DaemonEventFrame {
type: string;
data: Record<string, unknown> & { chunks?: DeltaChunk[] } & Partial<DoneData & ToolStartData & ToolEndData & StatusData & PongData & SessionsData & QueuedData & ErrorData>;
data: Record<string, unknown> & { chunks?: DeltaChunk[] } & Partial<DoneData & ToolStartData & ToolEndData & StatusData & PongData & SessionsData & QueuedData & ErrorData & UpgradeData>;
sid?: string | null;
}

Expand All@@ -86,6 +92,8 @@ export interface DaemonAppState {
ownStreamRidBySid: Record<string, string | null>;
/** 每会话断线标记(P3 finalize:后台会话断线不触发全局 UI) */
disconnectedBySid: Record<string, boolean>;
/** upgrade 事件(心跳检测 installed ≠ current → "重启生效"横幅;null=无待重启提示) */
upgradeBanner: { current: string; installed: string } | null;
}

const SID_NULL = "__emrg_null_sid__";
Expand All@@ -106,6 +114,7 @@ export function createDaemonAppStore(): SnapshotStore<DaemonAppState> {
busyBySid: {},
ownStreamRidBySid: {},
disconnectedBySid: {},
upgradeBanner: null,
});
}

Expand DownExpand Up@@ -316,6 +325,19 @@ export function createDaemonBridge(deps: DaemonBridgeDeps): DaemonBridge {
}
break;
}
case "upgrade": {
// 心跳每 15s 检测到 installed ≠ current 都会重发;同一 installed 版本只
// 写一次 store(vanilla lastKnownVersion 语义:不重复弹,dismiss 后不再出现)。
const up = data as UpgradeData;
const installed = up.installed_version || "";
if (installed && installed !== store.get().upgradeBanner?.installed) {
store.update((s) => ({
...s,
upgradeBanner: { current: up.current_version || s.currentVersion, installed },
}));
}
break;
}
default:
// 未知事件类型:忽略(vanilla 同语义,未来类型静默兼容)
break;
Expand Down
2 changes: 2 additions & 0 deletions emrg/gui/renderer/src/shell.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,8 @@
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text-1);
/* 升级横幅 #upgrade-banner 用 absolute 定位(vanilla #app 为 positioned ancestor) */
position: relative;
}

.react-shell-header {
Expand Down
11 changes: 6 additions & 5 deletions emrg/gui/test/preload-api.test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,12 +3,12 @@
* preload-api.test.js — window.emrg preload 桥 API 面守卫测试(React 迁移红线 #1)。
*
* 背景(cycle cyc20260826-131007 发现):React 迁移设计文档 §2.2 声称 preload
* API 46 个方法,实际 preload.js 已增长到 52 个 invoke 方法 + onEvent 订阅
* API 46 个方法,实际 preload.js 已增长到 53 个 invoke 方法 + onEvent 订阅
* (设计文档口径过时)。此前 build-config.test.js 只抽查 listFiles/readFile 两个
* API——没有完整 API 面的回归守卫。React 化迁移(Batch 0-5)的红线就是
* "window.emrg 方法集合原样保留",本测试把整个契约钉死:
*
* 1. 全部 52 个 invoke 方法 + onEvent 必须存在(防误删/改名)
* 1. 全部 53 个 invoke 方法 + onEvent 必须存在(防误删/改名)
* 2. 每个方法必须调用 ipcRenderer.invoke("emrg:<同名频道>")(防频道漂移)
* 3. onEvent 必须订阅 emrg:event 广播(防事件通道漂移)
*
Expand All@@ -23,12 +23,13 @@ const path = require("node:path");

const PRELOAD = fs.readFileSync(path.join(__dirname, "..", "preload.js"), "utf8");

/** 冻结的期望 API 面:52 invoke 方法(按 preload.js 实际顺序)+ onEvent */
/** 冻结的期望 API 面:53 invoke 方法(按 preload.js 实际顺序)+ onEvent */
const EXPECTED_INVOKE_METHODS = [
"init",
"sendMessage",
"listSessions",
"restartDaemon",
"relaunchGui",
"switchSession",
"newSession",
"deleteSession",
Expand DownExpand Up@@ -115,8 +116,8 @@ function parseApiBlock(block) {

const api = parseApiBlock(extractApiBlock());

test("window.emrg exposes the full 53-member contract (52 invoke + onEvent)", () => {
// 52 invoke 方法全部存在(防误删/改名)
test("window.emrg exposes the full 54-member contract (53 invoke + onEvent)", () => {
// 53 invoke 方法全部存在(防误删/改名)
for (const name of EXPECTED_INVOKE_METHODS) {
assert.ok(api.invoke.has(name), `缺少 invoke 方法 ${name}`);
}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
10 changes: 10 additions & 0 deletions emrg/gui/main.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -382,6 +382,16 @@ vision = false
return { ok: true };
});

// 升级横幅重启(rant 2026-08-27T21:54:51):仅重启 GUI 进程本身,绝不触碰
// daemon —— 服务端是 EMRG 生命本体(MANIFESTO 最高原则),不得走上面的
// restartDaemon stop 链。新 GUI 进程启动后复用已在运行的 daemon;若 daemon
// 本身也需用新代码重启,由 heartbeat 的 installed≠current 驱动用户再次点击。
ipcMain.handle("emrg:relaunchGui", async () => {
app.relaunch();
app.exit(0);
return { ok: true };
});

ipcMain.handle("emrg:switchSession", async (_e, { sessionId, projectPath } = {}) => {
logger.info(`[gui:switch] REQUEST sid=${sessionId} projectPath=${projectPath}`);
if (!validateSessionId(sessionId)) {
Expand Down
1 change: 1 addition & 0 deletions emrg/gui/preload.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,7 @@ const api = {
sendMessage: (payload) => ipcRenderer.invoke("emrg:sendMessage", payload),
listSessions: () => ipcRenderer.invoke("emrg:listSessions"),
restartDaemon: () => ipcRenderer.invoke("emrg:restartDaemon"),
relaunchGui: () => ipcRenderer.invoke("emrg:relaunchGui"),
switchSession: (payload) => ipcRenderer.invoke("emrg:switchSession", payload),
newSession: (payload) => ipcRenderer.invoke("emrg:newSession", payload),
deleteSession: (payload) => ipcRenderer.invoke("emrg:deleteSession", payload),
Expand Down
1 change: 1 addition & 0 deletions emrg/gui/renderer/src/components/DialogHost.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,6 +30,7 @@ function appState(over: Partial<DaemonAppState> = {}): DaemonAppState {
busyBySid: {},
ownStreamRidBySid: {},
disconnectedBySid: {},
upgradeBanner: null,
...over,
};
}
Expand Down
29 changes: 29 additions & 0 deletions emrg/gui/renderer/src/components/Shell.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,6 +36,7 @@ function mockEmrg() {
const taskDelete = vi.fn().mockResolvedValue({ ok: true });
const taskTemplateList = vi.fn().mockResolvedValue([{ name: "journal" }]);
const sendRant = vi.fn().mockResolvedValue({ ok: true, count: 11 });
const relaunchGui = vi.fn().mockResolvedValue({ ok: true });
(window as unknown as { emrg?: unknown }).emrg = {
onEvent,
sendMessage,
Expand All@@ -52,6 +53,7 @@ function mockEmrg() {
taskDelete,
taskTemplateList,
sendRant,
relaunchGui,
};
return {
listeners,
Expand All@@ -66,6 +68,7 @@ function mockEmrg() {
taskDelete,
taskTemplateList,
sendRant,
relaunchGui,
emit: (evt: DaemonEventFrame) => listeners.forEach((cb) => cb(evt)),
};
}
Expand DownExpand Up@@ -421,4 +424,30 @@ describe("Shell (Batch 5 slice 3 chat wiring)", () => {
await waitFor(() => expect(m.listRants).toHaveBeenCalledTimes(2));
expect(screen.queryByTestId("rant-dialog")).not.toBeInTheDocument();
});

it("upgrade 事件 → 渲染升级横幅;重启按钮调 relaunchGui(仅 GUI,不碰 daemon);dismiss 后同版本不重现", async () => {
const m = mockEmrg();
render(wrapper(<Shell />));
await waitFor(() => expect(m.onEvent).toHaveBeenCalledTimes(1));
// 初始无横幅
expect(screen.queryByTestId("upgrade-banner")).not.toBeInTheDocument();
// 心跳 upgrade 帧(installed 0.2.84 ≠ current 0.2.83)
act(() => {
m.emit({ type: "upgrade", data: { current_version: "0.2.83", installed_version: "0.2.84" } });
});
const banner = await screen.findByTestId("upgrade-banner");
expect(banner).toBeInTheDocument();
// from→to 文案(en 词典)
expect(screen.getByText(/upgraded from 0\.2\.83 to 0\.2\.84/i)).toBeInTheDocument();
// 重启按钮 → relaunchGui(仅 GUI 进程重启,绝不走 restartDaemon stop 链)
fireEvent.click(screen.getByTestId("upgrade-banner-restart"));
await waitFor(() => expect(m.relaunchGui).toHaveBeenCalledTimes(1));
// dismiss → 隐藏;同版本心跳重发不重现(vanilla lastKnownVersion 语义)
fireEvent.click(screen.getByTestId("upgrade-banner-dismiss"));
expect(screen.queryByTestId("upgrade-banner")).not.toBeInTheDocument();
act(() => {
m.emit({ type: "upgrade", data: { current_version: "0.2.83", installed_version: "0.2.84" } });
});
expect(screen.queryByTestId("upgrade-banner")).not.toBeInTheDocument();
});
});
58 changes: 58 additions & 0 deletions emrg/gui/renderer/src/components/Shell.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,6 +61,8 @@ interface WorkspaceBridge {
taskDelete(p: { name: string }): Promise<unknown>;
taskTemplateList(): Promise<{ name: string }[]>;
sendRant(p: { message: string; project?: string }): Promise<{ ok?: boolean; count?: number }>;
/** 升级横幅重启:仅重启 GUI 进程本身(emrg:relaunchGui),绝不触碰 daemon */
relaunchGui?(): Promise<unknown>;
}

function wsBridge(): WorkspaceBridge | undefined {
Expand All@@ -85,6 +87,11 @@ export function Shell() {
const [taskForm, setTaskForm] = useState<{ task: TaskRec | null } | null>(null);
const [rantOpen, setRantOpen] = useState(false);
const [taskTypes, setTaskTypes] = useState<string[]>([]);
// 升级横幅(rant 2026-08-27T21:54:51:React 迁移丢 upgrade 事件):dismissed 记录
// 已提示过的 installed 版本(vanilla state.lastKnownVersion 语义——心跳每 15s 重发
// 同一版本,不重复弹);restarting 防重复点击(relaunch 后进程即退出)。
const [dismissedUpgrade, setDismissedUpgrade] = useState<string | null>(null);
const [restarting, setRestarting] = useState(false);

// open_sessions 广播到达且尚无激活会话 → 自动选第一个(vanilla 同语义)
useEffect(() => {
Expand DownExpand Up@@ -358,8 +365,59 @@ export function Shell() {
}
}

/** 升级横幅重启:调 emrg:relaunchGui(仅重启 GUI 进程,不碰 daemon ——
* 服务端是 EMRG 生命本体,不得走 restartDaemon 的 stop 链)。失败 → 系统消息。 */
async function handleUpgradeRestart() {
const b = wsBridge();
if (!b?.relaunchGui) return;
setRestarting(true);
try {
await b.relaunchGui();
// 成功:进程即将退出重启,无需清理(restarting 状态随进程消亡)
} catch (e) {
setRestarting(false);
transcript.addSystemMessage(
t("app.upgradeRestartFailed", { msg: e instanceof Error ? e.message : String(e) }),
activeSid,
);
}
}

const upgradeBanner = appState.upgradeBanner;
const showUpgradeBanner =
upgradeBanner && upgradeBanner.installed !== "" && upgradeBanner.installed !== dismissedUpgrade;

return (
<div className="react-shell" data-testid="react-shell">
{showUpgradeBanner ? (
<div id="upgrade-banner" role="status" data-testid="upgrade-banner">
<span id="upgrade-banner-msg">
{upgradeBanner.current && upgradeBanner.current !== upgradeBanner.installed
? t("app.upgradeBannerMsgFromTo", { from: upgradeBanner.current, to: upgradeBanner.installed })
: t("app.upgradeBannerMsg", { version: upgradeBanner.installed })}
</span>
<button
type="button"
id="upgrade-banner-restart"
className="btn btn-primary"
data-testid="upgrade-banner-restart"
disabled={restarting}
onClick={() => void handleUpgradeRestart()}
>
{t("app.upgradeRestartBtn")}
</button>
<button
type="button"
id="upgrade-banner-dismiss"
className="btn btn-ghost"
title="✕"
data-testid="upgrade-banner-dismiss"
onClick={() => setDismissedUpgrade(upgradeBanner.installed)}
>
</button>
</div>
) : null}
<header className="react-shell-header">
<span className="react-shell-brand">✦ EMRG</span>
<span className="react-shell-conn" data-testid="conn-status" title={t("sidebar.statusTitle")}>
Expand Down
16 changes: 16 additions & 0 deletions emrg/gui/renderer/src/lib/daemonBridge.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -175,4 +175,20 @@ describe("createDaemonBridge", () => {
expect(st.connected).toBe(false);
expect(st.sessions).toHaveLength(0);
});

it("upgrade → store.upgradeBanner;同 installed 心跳重发不重建(dedupe)", () => {
const { emit, bridge } = setup();
emit({ type: "upgrade", data: { current_version: "0.2.83", installed_version: "0.2.84" } });
expect(bridge.store.get().upgradeBanner).toEqual({ current: "0.2.83", installed: "0.2.84" });
// 心跳每 15s 重发同一版本 → 引用不变(Object.is no-op,不触发 re-render)
const before = bridge.store.get();
emit({ type: "upgrade", data: { current_version: "0.2.83", installed_version: "0.2.84" } });
expect(bridge.store.get()).toBe(before);
// 新 installed 版本 → 更新
emit({ type: "upgrade", data: { current_version: "0.2.84", installed_version: "0.2.85" } });
expect(bridge.store.get().upgradeBanner).toEqual({ current: "0.2.84", installed: "0.2.85" });
// 空 installed → 忽略(dev 运行无版本数据)
emit({ type: "upgrade", data: { current_version: "0.2.85", installed_version: "" } });
expect(bridge.store.get().upgradeBanner).toEqual({ current: "0.2.84", installed: "0.2.85" });
});
});
24 changes: 23 additions & 1 deletion emrg/gui/renderer/src/lib/daemonBridge.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,10 +58,16 @@ export interface ErrorData {
message?: string;
}

/** upgrade 事件载荷(main.js 心跳检测 installed_version ≠ current_version → 专用事件) */
export interface UpgradeData {
current_version?: string;
installed_version?: string;
}

/** 统一事件帧(onEvent 回调入参) */
export interface DaemonEventFrame {
type: string;
data: Record<string, unknown> & { chunks?: DeltaChunk[] } & Partial<DoneData & ToolStartData & ToolEndData & StatusData & PongData & SessionsData & QueuedData & ErrorData>;
data: Record<string, unknown> & { chunks?: DeltaChunk[] } & Partial<DoneData & ToolStartData & ToolEndData & StatusData & PongData & SessionsData & QueuedData & ErrorData & UpgradeData>;
sid?: string | null;
}

Expand All@@ -86,6 +92,8 @@ export interface DaemonAppState {
ownStreamRidBySid: Record<string, string | null>;
/** 每会话断线标记(P3 finalize:后台会话断线不触发全局 UI) */
disconnectedBySid: Record<string, boolean>;
/** upgrade 事件(心跳检测 installed ≠ current → "重启生效"横幅;null=无待重启提示) */
upgradeBanner: { current: string; installed: string } | null;
}

const SID_NULL = "__emrg_null_sid__";
Expand All@@ -106,6 +114,7 @@ export function createDaemonAppStore(): SnapshotStore<DaemonAppState> {
busyBySid: {},
ownStreamRidBySid: {},
disconnectedBySid: {},
upgradeBanner: null,
});
}

Expand DownExpand Up@@ -316,6 +325,19 @@ export function createDaemonBridge(deps: DaemonBridgeDeps): DaemonBridge {
}
break;
}
case "upgrade": {
// 心跳每 15s 检测到 installed ≠ current 都会重发;同一 installed 版本只
// 写一次 store(vanilla lastKnownVersion 语义:不重复弹,dismiss 后不再出现)。
const up = data as UpgradeData;
const installed = up.installed_version || "";
if (installed && installed !== store.get().upgradeBanner?.installed) {
store.update((s) => ({
...s,
upgradeBanner: { current: up.current_version || s.currentVersion, installed },
}));
}
break;
}
default:
// 未知事件类型:忽略(vanilla 同语义,未来类型静默兼容)
break;
Expand Down
2 changes: 2 additions & 0 deletions emrg/gui/renderer/src/shell.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,8 @@
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text-1);
/* 升级横幅 #upgrade-banner 用 absolute 定位(vanilla #app 为 positioned ancestor) */
position: relative;
}

.react-shell-header {
Expand Down
11 changes: 6 additions & 5 deletions emrg/gui/test/preload-api.test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,12 +3,12 @@
* preload-api.test.js — window.emrg preload 桥 API 面守卫测试(React 迁移红线 #1)。
*
* 背景(cycle cyc20260826-131007 发现):React 迁移设计文档 §2.2 声称 preload
* API 46 个方法,实际 preload.js 已增长到 52 个 invoke 方法 + onEvent 订阅
* API 46 个方法,实际 preload.js 已增长到 53 个 invoke 方法 + onEvent 订阅
* (设计文档口径过时)。此前 build-config.test.js 只抽查 listFiles/readFile 两个
* API——没有完整 API 面的回归守卫。React 化迁移(Batch 0-5)的红线就是
* "window.emrg 方法集合原样保留",本测试把整个契约钉死:
*
* 1. 全部 52 个 invoke 方法 + onEvent 必须存在(防误删/改名)
* 1. 全部 53 个 invoke 方法 + onEvent 必须存在(防误删/改名)
* 2. 每个方法必须调用 ipcRenderer.invoke("emrg:<同名频道>")(防频道漂移)
* 3. onEvent 必须订阅 emrg:event 广播(防事件通道漂移)
*
Expand All@@ -23,12 +23,13 @@ const path = require("node:path");

const PRELOAD = fs.readFileSync(path.join(__dirname, "..", "preload.js"), "utf8");

/** 冻结的期望 API 面:52 invoke 方法(按 preload.js 实际顺序)+ onEvent */
/** 冻结的期望 API 面:53 invoke 方法(按 preload.js 实际顺序)+ onEvent */
const EXPECTED_INVOKE_METHODS = [
"init",
"sendMessage",
"listSessions",
"restartDaemon",
"relaunchGui",
"switchSession",
"newSession",
"deleteSession",
Expand DownExpand Up@@ -115,8 +116,8 @@ function parseApiBlock(block) {

const api = parseApiBlock(extractApiBlock());

test("window.emrg exposes the full 53-member contract (52 invoke + onEvent)", () => {
// 52 invoke 方法全部存在(防误删/改名)
test("window.emrg exposes the full 54-member contract (53 invoke + onEvent)", () => {
// 53 invoke 方法全部存在(防误删/改名)
for (const name of EXPECTED_INVOKE_METHODS) {
assert.ok(api.invoke.has(name), `缺少 invoke 方法 ${name}`);
}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
10 changes: 10 additions & 0 deletions emrg/gui/main.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -382,6 +382,16 @@ vision = false
return { ok: true };
});

// 升级横幅重启(rant 2026-08-27T21:54:51):仅重启 GUI 进程本身,绝不触碰
// daemon —— 服务端是 EMRG 生命本体(MANIFESTO 最高原则),不得走上面的
// restartDaemon stop 链。新 GUI 进程启动后复用已在运行的 daemon;若 daemon
// 本身也需用新代码重启,由 heartbeat 的 installed≠current 驱动用户再次点击。
ipcMain.handle("emrg:relaunchGui", async () => {
app.relaunch();
app.exit(0);
return { ok: true };
});

ipcMain.handle("emrg:switchSession", async (_e, { sessionId, projectPath } = {}) => {
logger.info(`[gui:switch] REQUEST sid=${sessionId} projectPath=${projectPath}`);
if (!validateSessionId(sessionId)) {
Expand Down
1 change: 1 addition & 0 deletions emrg/gui/preload.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,7 @@ const api = {
sendMessage: (payload) => ipcRenderer.invoke("emrg:sendMessage", payload),
listSessions: () => ipcRenderer.invoke("emrg:listSessions"),
restartDaemon: () => ipcRenderer.invoke("emrg:restartDaemon"),
relaunchGui: () => ipcRenderer.invoke("emrg:relaunchGui"),
switchSession: (payload) => ipcRenderer.invoke("emrg:switchSession", payload),
newSession: (payload) => ipcRenderer.invoke("emrg:newSession", payload),
deleteSession: (payload) => ipcRenderer.invoke("emrg:deleteSession", payload),
Expand Down
1 change: 1 addition & 0 deletions emrg/gui/renderer/src/components/DialogHost.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,6 +30,7 @@ function appState(over: Partial<DaemonAppState> = {}): DaemonAppState {
busyBySid: {},
ownStreamRidBySid: {},
disconnectedBySid: {},
upgradeBanner: null,
...over,
};
}
Expand Down
29 changes: 29 additions & 0 deletions emrg/gui/renderer/src/components/Shell.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,6 +36,7 @@ function mockEmrg() {
const taskDelete = vi.fn().mockResolvedValue({ ok: true });
const taskTemplateList = vi.fn().mockResolvedValue([{ name: "journal" }]);
const sendRant = vi.fn().mockResolvedValue({ ok: true, count: 11 });
const relaunchGui = vi.fn().mockResolvedValue({ ok: true });
(window as unknown as { emrg?: unknown }).emrg = {
onEvent,
sendMessage,
Expand All@@ -52,6 +53,7 @@ function mockEmrg() {
taskDelete,
taskTemplateList,
sendRant,
relaunchGui,
};
return {
listeners,
Expand All@@ -66,6 +68,7 @@ function mockEmrg() {
taskDelete,
taskTemplateList,
sendRant,
relaunchGui,
emit: (evt: DaemonEventFrame) => listeners.forEach((cb) => cb(evt)),
};
}
Expand DownExpand Up@@ -421,4 +424,30 @@ describe("Shell (Batch 5 slice 3 chat wiring)", () => {
await waitFor(() => expect(m.listRants).toHaveBeenCalledTimes(2));
expect(screen.queryByTestId("rant-dialog")).not.toBeInTheDocument();
});

it("upgrade 事件 → 渲染升级横幅;重启按钮调 relaunchGui(仅 GUI,不碰 daemon);dismiss 后同版本不重现", async () => {
const m = mockEmrg();
render(wrapper(<Shell />));
await waitFor(() => expect(m.onEvent).toHaveBeenCalledTimes(1));
// 初始无横幅
expect(screen.queryByTestId("upgrade-banner")).not.toBeInTheDocument();
// 心跳 upgrade 帧(installed 0.2.84 ≠ current 0.2.83)
act(() => {
m.emit({ type: "upgrade", data: { current_version: "0.2.83", installed_version: "0.2.84" } });
});
const banner = await screen.findByTestId("upgrade-banner");
expect(banner).toBeInTheDocument();
// from→to 文案(en 词典)
expect(screen.getByText(/upgraded from 0\.2\.83 to 0\.2\.84/i)).toBeInTheDocument();
// 重启按钮 → relaunchGui(仅 GUI 进程重启,绝不走 restartDaemon stop 链)
fireEvent.click(screen.getByTestId("upgrade-banner-restart"));
await waitFor(() => expect(m.relaunchGui).toHaveBeenCalledTimes(1));
// dismiss → 隐藏;同版本心跳重发不重现(vanilla lastKnownVersion 语义)
fireEvent.click(screen.getByTestId("upgrade-banner-dismiss"));
expect(screen.queryByTestId("upgrade-banner")).not.toBeInTheDocument();
act(() => {
m.emit({ type: "upgrade", data: { current_version: "0.2.83", installed_version: "0.2.84" } });
});
expect(screen.queryByTestId("upgrade-banner")).not.toBeInTheDocument();
});
});
58 changes: 58 additions & 0 deletions emrg/gui/renderer/src/components/Shell.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,6 +61,8 @@ interface WorkspaceBridge {
taskDelete(p: { name: string }): Promise<unknown>;
taskTemplateList(): Promise<{ name: string }[]>;
sendRant(p: { message: string; project?: string }): Promise<{ ok?: boolean; count?: number }>;
/** 升级横幅重启:仅重启 GUI 进程本身(emrg:relaunchGui),绝不触碰 daemon */
relaunchGui?(): Promise<unknown>;
}

function wsBridge(): WorkspaceBridge | undefined {
Expand All@@ -85,6 +87,11 @@ export function Shell() {
const [taskForm, setTaskForm] = useState<{ task: TaskRec | null } | null>(null);
const [rantOpen, setRantOpen] = useState(false);
const [taskTypes, setTaskTypes] = useState<string[]>([]);
// 升级横幅(rant 2026-08-27T21:54:51:React 迁移丢 upgrade 事件):dismissed 记录
// 已提示过的 installed 版本(vanilla state.lastKnownVersion 语义——心跳每 15s 重发
// 同一版本,不重复弹);restarting 防重复点击(relaunch 后进程即退出)。
const [dismissedUpgrade, setDismissedUpgrade] = useState<string | null>(null);
const [restarting, setRestarting] = useState(false);

// open_sessions 广播到达且尚无激活会话 → 自动选第一个(vanilla 同语义)
useEffect(() => {
Expand DownExpand Up@@ -358,8 +365,59 @@ export function Shell() {
}
}

/** 升级横幅重启:调 emrg:relaunchGui(仅重启 GUI 进程,不碰 daemon ——
* 服务端是 EMRG 生命本体,不得走 restartDaemon 的 stop 链)。失败 → 系统消息。 */
async function handleUpgradeRestart() {
const b = wsBridge();
if (!b?.relaunchGui) return;
setRestarting(true);
try {
await b.relaunchGui();
// 成功:进程即将退出重启,无需清理(restarting 状态随进程消亡)
} catch (e) {
setRestarting(false);
transcript.addSystemMessage(
t("app.upgradeRestartFailed", { msg: e instanceof Error ? e.message : String(e) }),
activeSid,
);
}
}

const upgradeBanner = appState.upgradeBanner;
const showUpgradeBanner =
upgradeBanner && upgradeBanner.installed !== "" && upgradeBanner.installed !== dismissedUpgrade;

return (
<div className="react-shell" data-testid="react-shell">
{showUpgradeBanner ? (
<div id="upgrade-banner" role="status" data-testid="upgrade-banner">
<span id="upgrade-banner-msg">
{upgradeBanner.current && upgradeBanner.current !== upgradeBanner.installed
? t("app.upgradeBannerMsgFromTo", { from: upgradeBanner.current, to: upgradeBanner.installed })
: t("app.upgradeBannerMsg", { version: upgradeBanner.installed })}
</span>
<button
type="button"
id="upgrade-banner-restart"
className="btn btn-primary"
data-testid="upgrade-banner-restart"
disabled={restarting}
onClick={() => void handleUpgradeRestart()}
>
{t("app.upgradeRestartBtn")}
</button>
<button
type="button"
id="upgrade-banner-dismiss"
className="btn btn-ghost"
title="✕"
data-testid="upgrade-banner-dismiss"
onClick={() => setDismissedUpgrade(upgradeBanner.installed)}
>
</button>
</div>
) : null}
<header className="react-shell-header">
<span className="react-shell-brand">✦ EMRG</span>
<span className="react-shell-conn" data-testid="conn-status" title={t("sidebar.statusTitle")}>
Expand Down
16 changes: 16 additions & 0 deletions emrg/gui/renderer/src/lib/daemonBridge.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -175,4 +175,20 @@ describe("createDaemonBridge", () => {
expect(st.connected).toBe(false);
expect(st.sessions).toHaveLength(0);
});

it("upgrade → store.upgradeBanner;同 installed 心跳重发不重建(dedupe)", () => {
const { emit, bridge } = setup();
emit({ type: "upgrade", data: { current_version: "0.2.83", installed_version: "0.2.84" } });
expect(bridge.store.get().upgradeBanner).toEqual({ current: "0.2.83", installed: "0.2.84" });
// 心跳每 15s 重发同一版本 → 引用不变(Object.is no-op,不触发 re-render)
const before = bridge.store.get();
emit({ type: "upgrade", data: { current_version: "0.2.83", installed_version: "0.2.84" } });
expect(bridge.store.get()).toBe(before);
// 新 installed 版本 → 更新
emit({ type: "upgrade", data: { current_version: "0.2.84", installed_version: "0.2.85" } });
expect(bridge.store.get().upgradeBanner).toEqual({ current: "0.2.84", installed: "0.2.85" });
// 空 installed → 忽略(dev 运行无版本数据)
emit({ type: "upgrade", data: { current_version: "0.2.85", installed_version: "" } });
expect(bridge.store.get().upgradeBanner).toEqual({ current: "0.2.84", installed: "0.2.85" });
});
});
24 changes: 23 additions & 1 deletion emrg/gui/renderer/src/lib/daemonBridge.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,10 +58,16 @@ export interface ErrorData {
message?: string;
}

/** upgrade 事件载荷(main.js 心跳检测 installed_version ≠ current_version → 专用事件) */
export interface UpgradeData {
current_version?: string;
installed_version?: string;
}

/** 统一事件帧(onEvent 回调入参) */
export interface DaemonEventFrame {
type: string;
data: Record<string, unknown> & { chunks?: DeltaChunk[] } & Partial<DoneData & ToolStartData & ToolEndData & StatusData & PongData & SessionsData & QueuedData & ErrorData>;
data: Record<string, unknown> & { chunks?: DeltaChunk[] } & Partial<DoneData & ToolStartData & ToolEndData & StatusData & PongData & SessionsData & QueuedData & ErrorData & UpgradeData>;
sid?: string | null;
}

Expand All@@ -86,6 +92,8 @@ export interface DaemonAppState {
ownStreamRidBySid: Record<string, string | null>;
/** 每会话断线标记(P3 finalize:后台会话断线不触发全局 UI) */
disconnectedBySid: Record<string, boolean>;
/** upgrade 事件(心跳检测 installed ≠ current → "重启生效"横幅;null=无待重启提示) */
upgradeBanner: { current: string; installed: string } | null;
}

const SID_NULL = "__emrg_null_sid__";
Expand All@@ -106,6 +114,7 @@ export function createDaemonAppStore(): SnapshotStore<DaemonAppState> {
busyBySid: {},
ownStreamRidBySid: {},
disconnectedBySid: {},
upgradeBanner: null,
});
}

Expand DownExpand Up@@ -316,6 +325,19 @@ export function createDaemonBridge(deps: DaemonBridgeDeps): DaemonBridge {
}
break;
}
case "upgrade": {
// 心跳每 15s 检测到 installed ≠ current 都会重发;同一 installed 版本只
// 写一次 store(vanilla lastKnownVersion 语义:不重复弹,dismiss 后不再出现)。
const up = data as UpgradeData;
const installed = up.installed_version || "";
if (installed && installed !== store.get().upgradeBanner?.installed) {
store.update((s) => ({
...s,
upgradeBanner: { current: up.current_version || s.currentVersion, installed },
}));
}
break;
}
default:
// 未知事件类型:忽略(vanilla 同语义,未来类型静默兼容)
break;
Expand Down
2 changes: 2 additions & 0 deletions emrg/gui/renderer/src/shell.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,8 @@
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text-1);
/* 升级横幅 #upgrade-banner 用 absolute 定位(vanilla #app 为 positioned ancestor) */
position: relative;
}

.react-shell-header {
Expand Down
11 changes: 6 additions & 5 deletions emrg/gui/test/preload-api.test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,12 +3,12 @@
* preload-api.test.js — window.emrg preload 桥 API 面守卫测试(React 迁移红线 #1)。
*
* 背景(cycle cyc20260826-131007 发现):React 迁移设计文档 §2.2 声称 preload
* API 46 个方法,实际 preload.js 已增长到 52 个 invoke 方法 + onEvent 订阅
* API 46 个方法,实际 preload.js 已增长到 53 个 invoke 方法 + onEvent 订阅
* (设计文档口径过时)。此前 build-config.test.js 只抽查 listFiles/readFile 两个
* API——没有完整 API 面的回归守卫。React 化迁移(Batch 0-5)的红线就是
* "window.emrg 方法集合原样保留",本测试把整个契约钉死:
*
* 1. 全部 52 个 invoke 方法 + onEvent 必须存在(防误删/改名)
* 1. 全部 53 个 invoke 方法 + onEvent 必须存在(防误删/改名)
* 2. 每个方法必须调用 ipcRenderer.invoke("emrg:<同名频道>")(防频道漂移)
* 3. onEvent 必须订阅 emrg:event 广播(防事件通道漂移)
*
Expand All@@ -23,12 +23,13 @@ const path = require("node:path");

const PRELOAD = fs.readFileSync(path.join(__dirname, "..", "preload.js"), "utf8");

/** 冻结的期望 API 面:52 invoke 方法(按 preload.js 实际顺序)+ onEvent */
/** 冻结的期望 API 面:53 invoke 方法(按 preload.js 实际顺序)+ onEvent */
const EXPECTED_INVOKE_METHODS = [
"init",
"sendMessage",
"listSessions",
"restartDaemon",
"relaunchGui",
"switchSession",
"newSession",
"deleteSession",
Expand DownExpand Up@@ -115,8 +116,8 @@ function parseApiBlock(block) {

const api = parseApiBlock(extractApiBlock());

test("window.emrg exposes the full 53-member contract (52 invoke + onEvent)", () => {
// 52 invoke 方法全部存在(防误删/改名)
test("window.emrg exposes the full 54-member contract (53 invoke + onEvent)", () => {
// 53 invoke 方法全部存在(防误删/改名)
for (const name of EXPECTED_INVOKE_METHODS) {
assert.ok(api.invoke.has(name), `缺少 invoke 方法 ${name}`);
}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
10 changes: 10 additions & 0 deletions emrg/gui/main.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -382,6 +382,16 @@ vision = false
return { ok: true };
});

// 升级横幅重启(rant 2026-08-27T21:54:51):仅重启 GUI 进程本身,绝不触碰
// daemon —— 服务端是 EMRG 生命本体(MANIFESTO 最高原则),不得走上面的
// restartDaemon stop 链。新 GUI 进程启动后复用已在运行的 daemon;若 daemon
// 本身也需用新代码重启,由 heartbeat 的 installed≠current 驱动用户再次点击。
ipcMain.handle("emrg:relaunchGui", async () => {
app.relaunch();
app.exit(0);
return { ok: true };
});

ipcMain.handle("emrg:switchSession", async (_e, { sessionId, projectPath } = {}) => {
logger.info(`[gui:switch] REQUEST sid=${sessionId} projectPath=${projectPath}`);
if (!validateSessionId(sessionId)) {
Expand Down
1 change: 1 addition & 0 deletions emrg/gui/preload.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,7 @@ const api = {
sendMessage: (payload) => ipcRenderer.invoke("emrg:sendMessage", payload),
listSessions: () => ipcRenderer.invoke("emrg:listSessions"),
restartDaemon: () => ipcRenderer.invoke("emrg:restartDaemon"),
relaunchGui: () => ipcRenderer.invoke("emrg:relaunchGui"),
switchSession: (payload) => ipcRenderer.invoke("emrg:switchSession", payload),
newSession: (payload) => ipcRenderer.invoke("emrg:newSession", payload),
deleteSession: (payload) => ipcRenderer.invoke("emrg:deleteSession", payload),
Expand Down
1 change: 1 addition & 0 deletions emrg/gui/renderer/src/components/DialogHost.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,6 +30,7 @@ function appState(over: Partial<DaemonAppState> = {}): DaemonAppState {
busyBySid: {},
ownStreamRidBySid: {},
disconnectedBySid: {},
upgradeBanner: null,
...over,
};
}
Expand Down
29 changes: 29 additions & 0 deletions emrg/gui/renderer/src/components/Shell.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,6 +36,7 @@ function mockEmrg() {
const taskDelete = vi.fn().mockResolvedValue({ ok: true });
const taskTemplateList = vi.fn().mockResolvedValue([{ name: "journal" }]);
const sendRant = vi.fn().mockResolvedValue({ ok: true, count: 11 });
const relaunchGui = vi.fn().mockResolvedValue({ ok: true });
(window as unknown as { emrg?: unknown }).emrg = {
onEvent,
sendMessage,
Expand All@@ -52,6 +53,7 @@ function mockEmrg() {
taskDelete,
taskTemplateList,
sendRant,
relaunchGui,
};
return {
listeners,
Expand All@@ -66,6 +68,7 @@ function mockEmrg() {
taskDelete,
taskTemplateList,
sendRant,
relaunchGui,
emit: (evt: DaemonEventFrame) => listeners.forEach((cb) => cb(evt)),
};
}
Expand DownExpand Up@@ -421,4 +424,30 @@ describe("Shell (Batch 5 slice 3 chat wiring)", () => {
await waitFor(() => expect(m.listRants).toHaveBeenCalledTimes(2));
expect(screen.queryByTestId("rant-dialog")).not.toBeInTheDocument();
});

it("upgrade 事件 → 渲染升级横幅;重启按钮调 relaunchGui(仅 GUI,不碰 daemon);dismiss 后同版本不重现", async () => {
const m = mockEmrg();
render(wrapper(<Shell />));
await waitFor(() => expect(m.onEvent).toHaveBeenCalledTimes(1));
// 初始无横幅
expect(screen.queryByTestId("upgrade-banner")).not.toBeInTheDocument();
// 心跳 upgrade 帧(installed 0.2.84 ≠ current 0.2.83)
act(() => {
m.emit({ type: "upgrade", data: { current_version: "0.2.83", installed_version: "0.2.84" } });
});
const banner = await screen.findByTestId("upgrade-banner");
expect(banner).toBeInTheDocument();
// from→to 文案(en 词典)
expect(screen.getByText(/upgraded from 0\.2\.83 to 0\.2\.84/i)).toBeInTheDocument();
// 重启按钮 → relaunchGui(仅 GUI 进程重启,绝不走 restartDaemon stop 链)
fireEvent.click(screen.getByTestId("upgrade-banner-restart"));
await waitFor(() => expect(m.relaunchGui).toHaveBeenCalledTimes(1));
// dismiss → 隐藏;同版本心跳重发不重现(vanilla lastKnownVersion 语义)
fireEvent.click(screen.getByTestId("upgrade-banner-dismiss"));
expect(screen.queryByTestId("upgrade-banner")).not.toBeInTheDocument();
act(() => {
m.emit({ type: "upgrade", data: { current_version: "0.2.83", installed_version: "0.2.84" } });
});
expect(screen.queryByTestId("upgrade-banner")).not.toBeInTheDocument();
});
});
58 changes: 58 additions & 0 deletions emrg/gui/renderer/src/components/Shell.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,6 +61,8 @@ interface WorkspaceBridge {
taskDelete(p: { name: string }): Promise<unknown>;
taskTemplateList(): Promise<{ name: string }[]>;
sendRant(p: { message: string; project?: string }): Promise<{ ok?: boolean; count?: number }>;
/** 升级横幅重启:仅重启 GUI 进程本身(emrg:relaunchGui),绝不触碰 daemon */
relaunchGui?(): Promise<unknown>;
}

function wsBridge(): WorkspaceBridge | undefined {
Expand All@@ -85,6 +87,11 @@ export function Shell() {
const [taskForm, setTaskForm] = useState<{ task: TaskRec | null } | null>(null);
const [rantOpen, setRantOpen] = useState(false);
const [taskTypes, setTaskTypes] = useState<string[]>([]);
// 升级横幅(rant 2026-08-27T21:54:51:React 迁移丢 upgrade 事件):dismissed 记录
// 已提示过的 installed 版本(vanilla state.lastKnownVersion 语义——心跳每 15s 重发
// 同一版本,不重复弹);restarting 防重复点击(relaunch 后进程即退出)。
const [dismissedUpgrade, setDismissedUpgrade] = useState<string | null>(null);
const [restarting, setRestarting] = useState(false);

// open_sessions 广播到达且尚无激活会话 → 自动选第一个(vanilla 同语义)
useEffect(() => {
Expand DownExpand Up@@ -358,8 +365,59 @@ export function Shell() {
}
}

/** 升级横幅重启:调 emrg:relaunchGui(仅重启 GUI 进程,不碰 daemon ——
* 服务端是 EMRG 生命本体,不得走 restartDaemon 的 stop 链)。失败 → 系统消息。 */
async function handleUpgradeRestart() {
const b = wsBridge();
if (!b?.relaunchGui) return;
setRestarting(true);
try {
await b.relaunchGui();
// 成功:进程即将退出重启,无需清理(restarting 状态随进程消亡)
} catch (e) {
setRestarting(false);
transcript.addSystemMessage(
t("app.upgradeRestartFailed", { msg: e instanceof Error ? e.message : String(e) }),
activeSid,
);
}
}

const upgradeBanner = appState.upgradeBanner;
const showUpgradeBanner =
upgradeBanner && upgradeBanner.installed !== "" && upgradeBanner.installed !== dismissedUpgrade;

return (
<div className="react-shell" data-testid="react-shell">
{showUpgradeBanner ? (
<div id="upgrade-banner" role="status" data-testid="upgrade-banner">
<span id="upgrade-banner-msg">
{upgradeBanner.current && upgradeBanner.current !== upgradeBanner.installed
? t("app.upgradeBannerMsgFromTo", { from: upgradeBanner.current, to: upgradeBanner.installed })
: t("app.upgradeBannerMsg", { version: upgradeBanner.installed })}
</span>
<button
type="button"
id="upgrade-banner-restart"
className="btn btn-primary"
data-testid="upgrade-banner-restart"
disabled={restarting}
onClick={() => void handleUpgradeRestart()}
>
{t("app.upgradeRestartBtn")}
</button>
<button
type="button"
id="upgrade-banner-dismiss"
className="btn btn-ghost"
title="✕"
data-testid="upgrade-banner-dismiss"
onClick={() => setDismissedUpgrade(upgradeBanner.installed)}
>
</button>
</div>
) : null}
<header className="react-shell-header">
<span className="react-shell-brand">✦ EMRG</span>
<span className="react-shell-conn" data-testid="conn-status" title={t("sidebar.statusTitle")}>
Expand Down
16 changes: 16 additions & 0 deletions emrg/gui/renderer/src/lib/daemonBridge.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -175,4 +175,20 @@ describe("createDaemonBridge", () => {
expect(st.connected).toBe(false);
expect(st.sessions).toHaveLength(0);
});

it("upgrade → store.upgradeBanner;同 installed 心跳重发不重建(dedupe)", () => {
const { emit, bridge } = setup();
emit({ type: "upgrade", data: { current_version: "0.2.83", installed_version: "0.2.84" } });
expect(bridge.store.get().upgradeBanner).toEqual({ current: "0.2.83", installed: "0.2.84" });
// 心跳每 15s 重发同一版本 → 引用不变(Object.is no-op,不触发 re-render)
const before = bridge.store.get();
emit({ type: "upgrade", data: { current_version: "0.2.83", installed_version: "0.2.84" } });
expect(bridge.store.get()).toBe(before);
// 新 installed 版本 → 更新
emit({ type: "upgrade", data: { current_version: "0.2.84", installed_version: "0.2.85" } });
expect(bridge.store.get().upgradeBanner).toEqual({ current: "0.2.84", installed: "0.2.85" });
// 空 installed → 忽略(dev 运行无版本数据)
emit({ type: "upgrade", data: { current_version: "0.2.85", installed_version: "" } });
expect(bridge.store.get().upgradeBanner).toEqual({ current: "0.2.84", installed: "0.2.85" });
});
});
24 changes: 23 additions & 1 deletion emrg/gui/renderer/src/lib/daemonBridge.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,10 +58,16 @@ export interface ErrorData {
message?: string;
}

/** upgrade 事件载荷(main.js 心跳检测 installed_version ≠ current_version → 专用事件) */
export interface UpgradeData {
current_version?: string;
installed_version?: string;
}

/** 统一事件帧(onEvent 回调入参) */
export interface DaemonEventFrame {
type: string;
data: Record<string, unknown> & { chunks?: DeltaChunk[] } & Partial<DoneData & ToolStartData & ToolEndData & StatusData & PongData & SessionsData & QueuedData & ErrorData>;
data: Record<string, unknown> & { chunks?: DeltaChunk[] } & Partial<DoneData & ToolStartData & ToolEndData & StatusData & PongData & SessionsData & QueuedData & ErrorData & UpgradeData>;
sid?: string | null;
}

Expand All@@ -86,6 +92,8 @@ export interface DaemonAppState {
ownStreamRidBySid: Record<string, string | null>;
/** 每会话断线标记(P3 finalize:后台会话断线不触发全局 UI) */
disconnectedBySid: Record<string, boolean>;
/** upgrade 事件(心跳检测 installed ≠ current → "重启生效"横幅;null=无待重启提示) */
upgradeBanner: { current: string; installed: string } | null;
}

const SID_NULL = "__emrg_null_sid__";
Expand All@@ -106,6 +114,7 @@ export function createDaemonAppStore(): SnapshotStore<DaemonAppState> {
busyBySid: {},
ownStreamRidBySid: {},
disconnectedBySid: {},
upgradeBanner: null,
});
}

Expand DownExpand Up@@ -316,6 +325,19 @@ export function createDaemonBridge(deps: DaemonBridgeDeps): DaemonBridge {
}
break;
}
case "upgrade": {
// 心跳每 15s 检测到 installed ≠ current 都会重发;同一 installed 版本只
// 写一次 store(vanilla lastKnownVersion 语义:不重复弹,dismiss 后不再出现)。
const up = data as UpgradeData;
const installed = up.installed_version || "";
if (installed && installed !== store.get().upgradeBanner?.installed) {
store.update((s) => ({
...s,
upgradeBanner: { current: up.current_version || s.currentVersion, installed },
}));
}
break;
}
default:
// 未知事件类型:忽略(vanilla 同语义,未来类型静默兼容)
break;
Expand Down
2 changes: 2 additions & 0 deletions emrg/gui/renderer/src/shell.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,8 @@
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text-1);
/* 升级横幅 #upgrade-banner 用 absolute 定位(vanilla #app 为 positioned ancestor) */
position: relative;
}

.react-shell-header {
Expand Down
11 changes: 6 additions & 5 deletions emrg/gui/test/preload-api.test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,12 +3,12 @@
* preload-api.test.js — window.emrg preload 桥 API 面守卫测试(React 迁移红线 #1)。
*
* 背景(cycle cyc20260826-131007 发现):React 迁移设计文档 §2.2 声称 preload
* API 46 个方法,实际 preload.js 已增长到 52 个 invoke 方法 + onEvent 订阅
* API 46 个方法,实际 preload.js 已增长到 53 个 invoke 方法 + onEvent 订阅
* (设计文档口径过时)。此前 build-config.test.js 只抽查 listFiles/readFile 两个
* API——没有完整 API 面的回归守卫。React 化迁移(Batch 0-5)的红线就是
* "window.emrg 方法集合原样保留",本测试把整个契约钉死:
*
* 1. 全部 52 个 invoke 方法 + onEvent 必须存在(防误删/改名)
* 1. 全部 53 个 invoke 方法 + onEvent 必须存在(防误删/改名)
* 2. 每个方法必须调用 ipcRenderer.invoke("emrg:<同名频道>")(防频道漂移)
* 3. onEvent 必须订阅 emrg:event 广播(防事件通道漂移)
*
Expand All@@ -23,12 +23,13 @@ const path = require("node:path");

const PRELOAD = fs.readFileSync(path.join(__dirname, "..", "preload.js"), "utf8");

/** 冻结的期望 API 面:52 invoke 方法(按 preload.js 实际顺序)+ onEvent */
/** 冻结的期望 API 面:53 invoke 方法(按 preload.js 实际顺序)+ onEvent */
const EXPECTED_INVOKE_METHODS = [
"init",
"sendMessage",
"listSessions",
"restartDaemon",
"relaunchGui",
"switchSession",
"newSession",
"deleteSession",
Expand DownExpand Up@@ -115,8 +116,8 @@ function parseApiBlock(block) {

const api = parseApiBlock(extractApiBlock());

test("window.emrg exposes the full 53-member contract (52 invoke + onEvent)", () => {
// 52 invoke 方法全部存在(防误删/改名)
test("window.emrg exposes the full 54-member contract (53 invoke + onEvent)", () => {
// 53 invoke 方法全部存在(防误删/改名)
for (const name of EXPECTED_INVOKE_METHODS) {
assert.ok(api.invoke.has(name), `缺少 invoke 方法 ${name}`);
}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
10 changes: 10 additions & 0 deletions emrg/gui/main.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -382,6 +382,16 @@ vision = false
return { ok: true };
});

// 升级横幅重启(rant 2026-08-27T21:54:51):仅重启 GUI 进程本身,绝不触碰
// daemon —— 服务端是 EMRG 生命本体(MANIFESTO 最高原则),不得走上面的
// restartDaemon stop 链。新 GUI 进程启动后复用已在运行的 daemon;若 daemon
// 本身也需用新代码重启,由 heartbeat 的 installed≠current 驱动用户再次点击。
ipcMain.handle("emrg:relaunchGui", async () => {
app.relaunch();
app.exit(0);
return { ok: true };
});

ipcMain.handle("emrg:switchSession", async (_e, { sessionId, projectPath } = {}) => {
logger.info(`[gui:switch] REQUEST sid=${sessionId} projectPath=${projectPath}`);
if (!validateSessionId(sessionId)) {
Expand Down
1 change: 1 addition & 0 deletions emrg/gui/preload.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,7 @@ const api = {
sendMessage: (payload) => ipcRenderer.invoke("emrg:sendMessage", payload),
listSessions: () => ipcRenderer.invoke("emrg:listSessions"),
restartDaemon: () => ipcRenderer.invoke("emrg:restartDaemon"),
relaunchGui: () => ipcRenderer.invoke("emrg:relaunchGui"),
switchSession: (payload) => ipcRenderer.invoke("emrg:switchSession", payload),
newSession: (payload) => ipcRenderer.invoke("emrg:newSession", payload),
deleteSession: (payload) => ipcRenderer.invoke("emrg:deleteSession", payload),
Expand Down
1 change: 1 addition & 0 deletions emrg/gui/renderer/src/components/DialogHost.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,6 +30,7 @@ function appState(over: Partial<DaemonAppState> = {}): DaemonAppState {
busyBySid: {},
ownStreamRidBySid: {},
disconnectedBySid: {},
upgradeBanner: null,
...over,
};
}
Expand Down
29 changes: 29 additions & 0 deletions emrg/gui/renderer/src/components/Shell.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,6 +36,7 @@ function mockEmrg() {
const taskDelete = vi.fn().mockResolvedValue({ ok: true });
const taskTemplateList = vi.fn().mockResolvedValue([{ name: "journal" }]);
const sendRant = vi.fn().mockResolvedValue({ ok: true, count: 11 });
const relaunchGui = vi.fn().mockResolvedValue({ ok: true });
(window as unknown as { emrg?: unknown }).emrg = {
onEvent,
sendMessage,
Expand All@@ -52,6 +53,7 @@ function mockEmrg() {
taskDelete,
taskTemplateList,
sendRant,
relaunchGui,
};
return {
listeners,
Expand All@@ -66,6 +68,7 @@ function mockEmrg() {
taskDelete,
taskTemplateList,
sendRant,
relaunchGui,
emit: (evt: DaemonEventFrame) => listeners.forEach((cb) => cb(evt)),
};
}
Expand DownExpand Up@@ -421,4 +424,30 @@ describe("Shell (Batch 5 slice 3 chat wiring)", () => {
await waitFor(() => expect(m.listRants).toHaveBeenCalledTimes(2));
expect(screen.queryByTestId("rant-dialog")).not.toBeInTheDocument();
});

it("upgrade 事件 → 渲染升级横幅;重启按钮调 relaunchGui(仅 GUI,不碰 daemon);dismiss 后同版本不重现", async () => {
const m = mockEmrg();
render(wrapper(<Shell />));
await waitFor(() => expect(m.onEvent).toHaveBeenCalledTimes(1));
// 初始无横幅
expect(screen.queryByTestId("upgrade-banner")).not.toBeInTheDocument();
// 心跳 upgrade 帧(installed 0.2.84 ≠ current 0.2.83)
act(() => {
m.emit({ type: "upgrade", data: { current_version: "0.2.83", installed_version: "0.2.84" } });
});
const banner = await screen.findByTestId("upgrade-banner");
expect(banner).toBeInTheDocument();
// from→to 文案(en 词典)
expect(screen.getByText(/upgraded from 0\.2\.83 to 0\.2\.84/i)).toBeInTheDocument();
// 重启按钮 → relaunchGui(仅 GUI 进程重启,绝不走 restartDaemon stop 链)
fireEvent.click(screen.getByTestId("upgrade-banner-restart"));
await waitFor(() => expect(m.relaunchGui).toHaveBeenCalledTimes(1));
// dismiss → 隐藏;同版本心跳重发不重现(vanilla lastKnownVersion 语义)
fireEvent.click(screen.getByTestId("upgrade-banner-dismiss"));
expect(screen.queryByTestId("upgrade-banner")).not.toBeInTheDocument();
act(() => {
m.emit({ type: "upgrade", data: { current_version: "0.2.83", installed_version: "0.2.84" } });
});
expect(screen.queryByTestId("upgrade-banner")).not.toBeInTheDocument();
});
});
58 changes: 58 additions & 0 deletions emrg/gui/renderer/src/components/Shell.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,6 +61,8 @@ interface WorkspaceBridge {
taskDelete(p: { name: string }): Promise<unknown>;
taskTemplateList(): Promise<{ name: string }[]>;
sendRant(p: { message: string; project?: string }): Promise<{ ok?: boolean; count?: number }>;
/** 升级横幅重启:仅重启 GUI 进程本身(emrg:relaunchGui),绝不触碰 daemon */
relaunchGui?(): Promise<unknown>;
}

function wsBridge(): WorkspaceBridge | undefined {
Expand All@@ -85,6 +87,11 @@ export function Shell() {
const [taskForm, setTaskForm] = useState<{ task: TaskRec | null } | null>(null);
const [rantOpen, setRantOpen] = useState(false);
const [taskTypes, setTaskTypes] = useState<string[]>([]);
// 升级横幅(rant 2026-08-27T21:54:51:React 迁移丢 upgrade 事件):dismissed 记录
// 已提示过的 installed 版本(vanilla state.lastKnownVersion 语义——心跳每 15s 重发
// 同一版本,不重复弹);restarting 防重复点击(relaunch 后进程即退出)。
const [dismissedUpgrade, setDismissedUpgrade] = useState<string | null>(null);
const [restarting, setRestarting] = useState(false);

// open_sessions 广播到达且尚无激活会话 → 自动选第一个(vanilla 同语义)
useEffect(() => {
Expand DownExpand Up@@ -358,8 +365,59 @@ export function Shell() {
}
}

/** 升级横幅重启:调 emrg:relaunchGui(仅重启 GUI 进程,不碰 daemon ——
* 服务端是 EMRG 生命本体,不得走 restartDaemon 的 stop 链)。失败 → 系统消息。 */
async function handleUpgradeRestart() {
const b = wsBridge();
if (!b?.relaunchGui) return;
setRestarting(true);
try {
await b.relaunchGui();
// 成功:进程即将退出重启,无需清理(restarting 状态随进程消亡)
} catch (e) {
setRestarting(false);
transcript.addSystemMessage(
t("app.upgradeRestartFailed", { msg: e instanceof Error ? e.message : String(e) }),
activeSid,
);
}
}

const upgradeBanner = appState.upgradeBanner;
const showUpgradeBanner =
upgradeBanner && upgradeBanner.installed !== "" && upgradeBanner.installed !== dismissedUpgrade;

return (
<div className="react-shell" data-testid="react-shell">
{showUpgradeBanner ? (
<div id="upgrade-banner" role="status" data-testid="upgrade-banner">
<span id="upgrade-banner-msg">
{upgradeBanner.current && upgradeBanner.current !== upgradeBanner.installed
? t("app.upgradeBannerMsgFromTo", { from: upgradeBanner.current, to: upgradeBanner.installed })
: t("app.upgradeBannerMsg", { version: upgradeBanner.installed })}
</span>
<button
type="button"
id="upgrade-banner-restart"
className="btn btn-primary"
data-testid="upgrade-banner-restart"
disabled={restarting}
onClick={() => void handleUpgradeRestart()}
>
{t("app.upgradeRestartBtn")}
</button>
<button
type="button"
id="upgrade-banner-dismiss"
className="btn btn-ghost"
title="✕"
data-testid="upgrade-banner-dismiss"
onClick={() => setDismissedUpgrade(upgradeBanner.installed)}
>
</button>
</div>
) : null}
<header className="react-shell-header">
<span className="react-shell-brand">✦ EMRG</span>
<span className="react-shell-conn" data-testid="conn-status" title={t("sidebar.statusTitle")}>
Expand Down
16 changes: 16 additions & 0 deletions emrg/gui/renderer/src/lib/daemonBridge.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -175,4 +175,20 @@ describe("createDaemonBridge", () => {
expect(st.connected).toBe(false);
expect(st.sessions).toHaveLength(0);
});

it("upgrade → store.upgradeBanner;同 installed 心跳重发不重建(dedupe)", () => {
const { emit, bridge } = setup();
emit({ type: "upgrade", data: { current_version: "0.2.83", installed_version: "0.2.84" } });
expect(bridge.store.get().upgradeBanner).toEqual({ current: "0.2.83", installed: "0.2.84" });
// 心跳每 15s 重发同一版本 → 引用不变(Object.is no-op,不触发 re-render)
const before = bridge.store.get();
emit({ type: "upgrade", data: { current_version: "0.2.83", installed_version: "0.2.84" } });
expect(bridge.store.get()).toBe(before);
// 新 installed 版本 → 更新
emit({ type: "upgrade", data: { current_version: "0.2.84", installed_version: "0.2.85" } });
expect(bridge.store.get().upgradeBanner).toEqual({ current: "0.2.84", installed: "0.2.85" });
// 空 installed → 忽略(dev 运行无版本数据)
emit({ type: "upgrade", data: { current_version: "0.2.85", installed_version: "" } });
expect(bridge.store.get().upgradeBanner).toEqual({ current: "0.2.84", installed: "0.2.85" });
});
});
24 changes: 23 additions & 1 deletion emrg/gui/renderer/src/lib/daemonBridge.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,10 +58,16 @@ export interface ErrorData {
message?: string;
}

/** upgrade 事件载荷(main.js 心跳检测 installed_version ≠ current_version → 专用事件) */
export interface UpgradeData {
current_version?: string;
installed_version?: string;
}

/** 统一事件帧(onEvent 回调入参) */
export interface DaemonEventFrame {
type: string;
data: Record<string, unknown> & { chunks?: DeltaChunk[] } & Partial<DoneData & ToolStartData & ToolEndData & StatusData & PongData & SessionsData & QueuedData & ErrorData>;
data: Record<string, unknown> & { chunks?: DeltaChunk[] } & Partial<DoneData & ToolStartData & ToolEndData & StatusData & PongData & SessionsData & QueuedData & ErrorData & UpgradeData>;
sid?: string | null;
}

Expand All@@ -86,6 +92,8 @@ export interface DaemonAppState {
ownStreamRidBySid: Record<string, string | null>;
/** 每会话断线标记(P3 finalize:后台会话断线不触发全局 UI) */
disconnectedBySid: Record<string, boolean>;
/** upgrade 事件(心跳检测 installed ≠ current → "重启生效"横幅;null=无待重启提示) */
upgradeBanner: { current: string; installed: string } | null;
}

const SID_NULL = "__emrg_null_sid__";
Expand All@@ -106,6 +114,7 @@ export function createDaemonAppStore(): SnapshotStore<DaemonAppState> {
busyBySid: {},
ownStreamRidBySid: {},
disconnectedBySid: {},
upgradeBanner: null,
});
}

Expand DownExpand Up@@ -316,6 +325,19 @@ export function createDaemonBridge(deps: DaemonBridgeDeps): DaemonBridge {
}
break;
}
case "upgrade": {
// 心跳每 15s 检测到 installed ≠ current 都会重发;同一 installed 版本只
// 写一次 store(vanilla lastKnownVersion 语义:不重复弹,dismiss 后不再出现)。
const up = data as UpgradeData;
const installed = up.installed_version || "";
if (installed && installed !== store.get().upgradeBanner?.installed) {
store.update((s) => ({
...s,
upgradeBanner: { current: up.current_version || s.currentVersion, installed },
}));
}
break;
}
default:
// 未知事件类型:忽略(vanilla 同语义,未来类型静默兼容)
break;
Expand Down
2 changes: 2 additions & 0 deletions emrg/gui/renderer/src/shell.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,8 @@
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text-1);
/* 升级横幅 #upgrade-banner 用 absolute 定位(vanilla #app 为 positioned ancestor) */
position: relative;
}

.react-shell-header {
Expand Down
11 changes: 6 additions & 5 deletions emrg/gui/test/preload-api.test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,12 +3,12 @@
* preload-api.test.js — window.emrg preload 桥 API 面守卫测试(React 迁移红线 #1)。
*
* 背景(cycle cyc20260826-131007 发现):React 迁移设计文档 §2.2 声称 preload
* API 46 个方法,实际 preload.js 已增长到 52 个 invoke 方法 + onEvent 订阅
* API 46 个方法,实际 preload.js 已增长到 53 个 invoke 方法 + onEvent 订阅
* (设计文档口径过时)。此前 build-config.test.js 只抽查 listFiles/readFile 两个
* API——没有完整 API 面的回归守卫。React 化迁移(Batch 0-5)的红线就是
* "window.emrg 方法集合原样保留",本测试把整个契约钉死:
*
* 1. 全部 52 个 invoke 方法 + onEvent 必须存在(防误删/改名)
* 1. 全部 53 个 invoke 方法 + onEvent 必须存在(防误删/改名)
* 2. 每个方法必须调用 ipcRenderer.invoke("emrg:<同名频道>")(防频道漂移)
* 3. onEvent 必须订阅 emrg:event 广播(防事件通道漂移)
*
Expand All@@ -23,12 +23,13 @@ const path = require("node:path");

const PRELOAD = fs.readFileSync(path.join(__dirname, "..", "preload.js"), "utf8");

/** 冻结的期望 API 面:52 invoke 方法(按 preload.js 实际顺序)+ onEvent */
/** 冻结的期望 API 面:53 invoke 方法(按 preload.js 实际顺序)+ onEvent */
const EXPECTED_INVOKE_METHODS = [
"init",
"sendMessage",
"listSessions",
"restartDaemon",
"relaunchGui",
"switchSession",
"newSession",
"deleteSession",
Expand DownExpand Up@@ -115,8 +116,8 @@ function parseApiBlock(block) {

const api = parseApiBlock(extractApiBlock());

test("window.emrg exposes the full 53-member contract (52 invoke + onEvent)", () => {
// 52 invoke 方法全部存在(防误删/改名)
test("window.emrg exposes the full 54-member contract (53 invoke + onEvent)", () => {
// 53 invoke 方法全部存在(防误删/改名)
for (const name of EXPECTED_INVOKE_METHODS) {
assert.ok(api.invoke.has(name), `缺少 invoke 方法 ${name}`);
}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
10 changes: 10 additions & 0 deletions emrg/gui/main.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -382,6 +382,16 @@ vision = false
return { ok: true };
});

// 升级横幅重启(rant 2026-08-27T21:54:51):仅重启 GUI 进程本身,绝不触碰
// daemon —— 服务端是 EMRG 生命本体(MANIFESTO 最高原则),不得走上面的
// restartDaemon stop 链。新 GUI 进程启动后复用已在运行的 daemon;若 daemon
// 本身也需用新代码重启,由 heartbeat 的 installed≠current 驱动用户再次点击。
ipcMain.handle("emrg:relaunchGui", async () => {
app.relaunch();
app.exit(0);
return { ok: true };
});

ipcMain.handle("emrg:switchSession", async (_e, { sessionId, projectPath } = {}) => {
logger.info(`[gui:switch] REQUEST sid=${sessionId} projectPath=${projectPath}`);
if (!validateSessionId(sessionId)) {
Expand Down
1 change: 1 addition & 0 deletions emrg/gui/preload.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,7 @@ const api = {
sendMessage: (payload) => ipcRenderer.invoke("emrg:sendMessage", payload),
listSessions: () => ipcRenderer.invoke("emrg:listSessions"),
restartDaemon: () => ipcRenderer.invoke("emrg:restartDaemon"),
relaunchGui: () => ipcRenderer.invoke("emrg:relaunchGui"),
switchSession: (payload) => ipcRenderer.invoke("emrg:switchSession", payload),
newSession: (payload) => ipcRenderer.invoke("emrg:newSession", payload),
deleteSession: (payload) => ipcRenderer.invoke("emrg:deleteSession", payload),
Expand Down
1 change: 1 addition & 0 deletions emrg/gui/renderer/src/components/DialogHost.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,6 +30,7 @@ function appState(over: Partial<DaemonAppState> = {}): DaemonAppState {
busyBySid: {},
ownStreamRidBySid: {},
disconnectedBySid: {},
upgradeBanner: null,
...over,
};
}
Expand Down
29 changes: 29 additions & 0 deletions emrg/gui/renderer/src/components/Shell.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,6 +36,7 @@ function mockEmrg() {
const taskDelete = vi.fn().mockResolvedValue({ ok: true });
const taskTemplateList = vi.fn().mockResolvedValue([{ name: "journal" }]);
const sendRant = vi.fn().mockResolvedValue({ ok: true, count: 11 });
const relaunchGui = vi.fn().mockResolvedValue({ ok: true });
(window as unknown as { emrg?: unknown }).emrg = {
onEvent,
sendMessage,
Expand All@@ -52,6 +53,7 @@ function mockEmrg() {
taskDelete,
taskTemplateList,
sendRant,
relaunchGui,
};
return {
listeners,
Expand All@@ -66,6 +68,7 @@ function mockEmrg() {
taskDelete,
taskTemplateList,
sendRant,
relaunchGui,
emit: (evt: DaemonEventFrame) => listeners.forEach((cb) => cb(evt)),
};
}
Expand DownExpand Up@@ -421,4 +424,30 @@ describe("Shell (Batch 5 slice 3 chat wiring)", () => {
await waitFor(() => expect(m.listRants).toHaveBeenCalledTimes(2));
expect(screen.queryByTestId("rant-dialog")).not.toBeInTheDocument();
});

it("upgrade 事件 → 渲染升级横幅;重启按钮调 relaunchGui(仅 GUI,不碰 daemon);dismiss 后同版本不重现", async () => {
const m = mockEmrg();
render(wrapper(<Shell />));
await waitFor(() => expect(m.onEvent).toHaveBeenCalledTimes(1));
// 初始无横幅
expect(screen.queryByTestId("upgrade-banner")).not.toBeInTheDocument();
// 心跳 upgrade 帧(installed 0.2.84 ≠ current 0.2.83)
act(() => {
m.emit({ type: "upgrade", data: { current_version: "0.2.83", installed_version: "0.2.84" } });
});
const banner = await screen.findByTestId("upgrade-banner");
expect(banner).toBeInTheDocument();
// from→to 文案(en 词典)
expect(screen.getByText(/upgraded from 0\.2\.83 to 0\.2\.84/i)).toBeInTheDocument();
// 重启按钮 → relaunchGui(仅 GUI 进程重启,绝不走 restartDaemon stop 链)
fireEvent.click(screen.getByTestId("upgrade-banner-restart"));
await waitFor(() => expect(m.relaunchGui).toHaveBeenCalledTimes(1));
// dismiss → 隐藏;同版本心跳重发不重现(vanilla lastKnownVersion 语义)
fireEvent.click(screen.getByTestId("upgrade-banner-dismiss"));
expect(screen.queryByTestId("upgrade-banner")).not.toBeInTheDocument();
act(() => {
m.emit({ type: "upgrade", data: { current_version: "0.2.83", installed_version: "0.2.84" } });
});
expect(screen.queryByTestId("upgrade-banner")).not.toBeInTheDocument();
});
});
58 changes: 58 additions & 0 deletions emrg/gui/renderer/src/components/Shell.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,6 +61,8 @@ interface WorkspaceBridge {
taskDelete(p: { name: string }): Promise<unknown>;
taskTemplateList(): Promise<{ name: string }[]>;
sendRant(p: { message: string; project?: string }): Promise<{ ok?: boolean; count?: number }>;
/** 升级横幅重启:仅重启 GUI 进程本身(emrg:relaunchGui),绝不触碰 daemon */
relaunchGui?(): Promise<unknown>;
}

function wsBridge(): WorkspaceBridge | undefined {
Expand All@@ -85,6 +87,11 @@ export function Shell() {
const [taskForm, setTaskForm] = useState<{ task: TaskRec | null } | null>(null);
const [rantOpen, setRantOpen] = useState(false);
const [taskTypes, setTaskTypes] = useState<string[]>([]);
// 升级横幅(rant 2026-08-27T21:54:51:React 迁移丢 upgrade 事件):dismissed 记录
// 已提示过的 installed 版本(vanilla state.lastKnownVersion 语义——心跳每 15s 重发
// 同一版本,不重复弹);restarting 防重复点击(relaunch 后进程即退出)。
const [dismissedUpgrade, setDismissedUpgrade] = useState<string | null>(null);
const [restarting, setRestarting] = useState(false);

// open_sessions 广播到达且尚无激活会话 → 自动选第一个(vanilla 同语义)
useEffect(() => {
Expand DownExpand Up@@ -358,8 +365,59 @@ export function Shell() {
}
}

/** 升级横幅重启:调 emrg:relaunchGui(仅重启 GUI 进程,不碰 daemon ——
* 服务端是 EMRG 生命本体,不得走 restartDaemon 的 stop 链)。失败 → 系统消息。 */
async function handleUpgradeRestart() {
const b = wsBridge();
if (!b?.relaunchGui) return;
setRestarting(true);
try {
await b.relaunchGui();
// 成功:进程即将退出重启,无需清理(restarting 状态随进程消亡)
} catch (e) {
setRestarting(false);
transcript.addSystemMessage(
t("app.upgradeRestartFailed", { msg: e instanceof Error ? e.message : String(e) }),
activeSid,
);
}
}

const upgradeBanner = appState.upgradeBanner;
const showUpgradeBanner =
upgradeBanner && upgradeBanner.installed !== "" && upgradeBanner.installed !== dismissedUpgrade;

return (
<div className="react-shell" data-testid="react-shell">
{showUpgradeBanner ? (
<div id="upgrade-banner" role="status" data-testid="upgrade-banner">
<span id="upgrade-banner-msg">
{upgradeBanner.current && upgradeBanner.current !== upgradeBanner.installed
? t("app.upgradeBannerMsgFromTo", { from: upgradeBanner.current, to: upgradeBanner.installed })
: t("app.upgradeBannerMsg", { version: upgradeBanner.installed })}
</span>
<button
type="button"
id="upgrade-banner-restart"
className="btn btn-primary"
data-testid="upgrade-banner-restart"
disabled={restarting}
onClick={() => void handleUpgradeRestart()}
>
{t("app.upgradeRestartBtn")}
</button>
<button
type="button"
id="upgrade-banner-dismiss"
className="btn btn-ghost"
title="✕"
data-testid="upgrade-banner-dismiss"
onClick={() => setDismissedUpgrade(upgradeBanner.installed)}
>
</button>
</div>
) : null}
<header className="react-shell-header">
<span className="react-shell-brand">✦ EMRG</span>
<span className="react-shell-conn" data-testid="conn-status" title={t("sidebar.statusTitle")}>
Expand Down
16 changes: 16 additions & 0 deletions emrg/gui/renderer/src/lib/daemonBridge.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -175,4 +175,20 @@ describe("createDaemonBridge", () => {
expect(st.connected).toBe(false);
expect(st.sessions).toHaveLength(0);
});

it("upgrade → store.upgradeBanner;同 installed 心跳重发不重建(dedupe)", () => {
const { emit, bridge } = setup();
emit({ type: "upgrade", data: { current_version: "0.2.83", installed_version: "0.2.84" } });
expect(bridge.store.get().upgradeBanner).toEqual({ current: "0.2.83", installed: "0.2.84" });
// 心跳每 15s 重发同一版本 → 引用不变(Object.is no-op,不触发 re-render)
const before = bridge.store.get();
emit({ type: "upgrade", data: { current_version: "0.2.83", installed_version: "0.2.84" } });
expect(bridge.store.get()).toBe(before);
// 新 installed 版本 → 更新
emit({ type: "upgrade", data: { current_version: "0.2.84", installed_version: "0.2.85" } });
expect(bridge.store.get().upgradeBanner).toEqual({ current: "0.2.84", installed: "0.2.85" });
// 空 installed → 忽略(dev 运行无版本数据)
emit({ type: "upgrade", data: { current_version: "0.2.85", installed_version: "" } });
expect(bridge.store.get().upgradeBanner).toEqual({ current: "0.2.84", installed: "0.2.85" });
});
});
24 changes: 23 additions & 1 deletion emrg/gui/renderer/src/lib/daemonBridge.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,10 +58,16 @@ export interface ErrorData {
message?: string;
}

/** upgrade 事件载荷(main.js 心跳检测 installed_version ≠ current_version → 专用事件) */
export interface UpgradeData {
current_version?: string;
installed_version?: string;
}

/** 统一事件帧(onEvent 回调入参) */
export interface DaemonEventFrame {
type: string;
data: Record<string, unknown> & { chunks?: DeltaChunk[] } & Partial<DoneData & ToolStartData & ToolEndData & StatusData & PongData & SessionsData & QueuedData & ErrorData>;
data: Record<string, unknown> & { chunks?: DeltaChunk[] } & Partial<DoneData & ToolStartData & ToolEndData & StatusData & PongData & SessionsData & QueuedData & ErrorData & UpgradeData>;
sid?: string | null;
}

Expand All@@ -86,6 +92,8 @@ export interface DaemonAppState {
ownStreamRidBySid: Record<string, string | null>;
/** 每会话断线标记(P3 finalize:后台会话断线不触发全局 UI) */
disconnectedBySid: Record<string, boolean>;
/** upgrade 事件(心跳检测 installed ≠ current → "重启生效"横幅;null=无待重启提示) */
upgradeBanner: { current: string; installed: string } | null;
}

const SID_NULL = "__emrg_null_sid__";
Expand All@@ -106,6 +114,7 @@ export function createDaemonAppStore(): SnapshotStore<DaemonAppState> {
busyBySid: {},
ownStreamRidBySid: {},
disconnectedBySid: {},
upgradeBanner: null,
});
}

Expand DownExpand Up@@ -316,6 +325,19 @@ export function createDaemonBridge(deps: DaemonBridgeDeps): DaemonBridge {
}
break;
}
case "upgrade": {
// 心跳每 15s 检测到 installed ≠ current 都会重发;同一 installed 版本只
// 写一次 store(vanilla lastKnownVersion 语义:不重复弹,dismiss 后不再出现)。
const up = data as UpgradeData;
const installed = up.installed_version || "";
if (installed && installed !== store.get().upgradeBanner?.installed) {
store.update((s) => ({
...s,
upgradeBanner: { current: up.current_version || s.currentVersion, installed },
}));
}
break;
}
default:
// 未知事件类型:忽略(vanilla 同语义,未来类型静默兼容)
break;
Expand Down
2 changes: 2 additions & 0 deletions emrg/gui/renderer/src/shell.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,8 @@
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text-1);
/* 升级横幅 #upgrade-banner 用 absolute 定位(vanilla #app 为 positioned ancestor) */
position: relative;
}

.react-shell-header {
Expand Down
11 changes: 6 additions & 5 deletions emrg/gui/test/preload-api.test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,12 +3,12 @@
* preload-api.test.js — window.emrg preload 桥 API 面守卫测试(React 迁移红线 #1)。
*
* 背景(cycle cyc20260826-131007 发现):React 迁移设计文档 §2.2 声称 preload
* API 46 个方法,实际 preload.js 已增长到 52 个 invoke 方法 + onEvent 订阅
* API 46 个方法,实际 preload.js 已增长到 53 个 invoke 方法 + onEvent 订阅
* (设计文档口径过时)。此前 build-config.test.js 只抽查 listFiles/readFile 两个
* API——没有完整 API 面的回归守卫。React 化迁移(Batch 0-5)的红线就是
* "window.emrg 方法集合原样保留",本测试把整个契约钉死:
*
* 1. 全部 52 个 invoke 方法 + onEvent 必须存在(防误删/改名)
* 1. 全部 53 个 invoke 方法 + onEvent 必须存在(防误删/改名)
* 2. 每个方法必须调用 ipcRenderer.invoke("emrg:<同名频道>")(防频道漂移)
* 3. onEvent 必须订阅 emrg:event 广播(防事件通道漂移)
*
Expand All@@ -23,12 +23,13 @@ const path = require("node:path");

const PRELOAD = fs.readFileSync(path.join(__dirname, "..", "preload.js"), "utf8");

/** 冻结的期望 API 面:52 invoke 方法(按 preload.js 实际顺序)+ onEvent */
/** 冻结的期望 API 面:53 invoke 方法(按 preload.js 实际顺序)+ onEvent */
const EXPECTED_INVOKE_METHODS = [
"init",
"sendMessage",
"listSessions",
"restartDaemon",
"relaunchGui",
"switchSession",
"newSession",
"deleteSession",
Expand DownExpand Up@@ -115,8 +116,8 @@ function parseApiBlock(block) {

const api = parseApiBlock(extractApiBlock());

test("window.emrg exposes the full 53-member contract (52 invoke + onEvent)", () => {
// 52 invoke 方法全部存在(防误删/改名)
test("window.emrg exposes the full 54-member contract (53 invoke + onEvent)", () => {
// 53 invoke 方法全部存在(防误删/改名)
for (const name of EXPECTED_INVOKE_METHODS) {
assert.ok(api.invoke.has(name), `缺少 invoke 方法 ${name}`);
}
Expand Down
Loading