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
2 changes: 1 addition & 1 deletion Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -121,7 +121,7 @@ pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg

Python: `uv run pytest tests/ -v` (1146) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (95: 45 daemon_client + 20 conn-manager + 8 integration + 6 build-config + 7 gui-state + 3 preload-api + 4 boot-contract + 2 theme-guard) — syntax: `node --check main.js preload.js daemon_client.js`
Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (467: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 11 markdown + 15 transcript + 8 TranscriptView + 15 history + 22 composer + 31 Composer + 12 sidebar + 17 Sidebar + 9 fileTree + 9 FileTree + 16 resultPanel + 8 ResultPanel + 27 workspaceView + 10 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 9 openSession + 6 WelcomeDialog + 8 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 15 daemonBridge + 7 DaemonBridgeProvider + 26 Shell + 15 DialogHost + 20 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown) + `npm run build` → `renderer/dist/`
Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (473: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 11 markdown + 15 transcript + 8 TranscriptView + 15 history + 22 composer + 31 Composer + 6 LinkDialog + 12 sidebar + 17 Sidebar + 9 fileTree + 9 FileTree + 16 resultPanel + 8 ResultPanel + 27 workspaceView + 10 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 9 openSession + 6 WelcomeDialog + 8 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 15 daemonBridge + 7 DaemonBridgeProvider + 26 Shell + 15 DialogHost + 20 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown) + `npm run build` → `renderer/dist/`
CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文)
Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响)
Git-over-https 兜底: `python scripts/sync-master-from-api.py [--repo owner/name] [--ref master]` — 受限网络下 github.com:443 不可达而 api.github.com 可达时,用 Git Data API 的 verification payload + signature 字节级重建上游 commit(含 web-flow GPG 签名 squash merge,reconstruct_commit 经 hermetic 测试验证 sha 一致)并推进本地 refs;内容对象缺失时 fail-loud 提示改用 git fetch(10+ 周期实证的恢复路径)
Expand Down
5 changes: 5 additions & 0 deletions emrg/gui/main.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -1010,7 +1010,12 @@ vision = false

// 记录/刷新一个打开会话(复用连接、恢复重开、recoverAll 均刷新)
function touchOpenSession(sid, projectPath) {
// ⚠️ (rant 2026-08-28T22:28:47) 保留已有 v 字段(含 title):
// 每次 message_delta 重写时若丢弃 title,侧边栏会在「名字→id」间抖动;
// 异步 listSessions 拉回的 title 必须跨调用保留。
const v = openSessions.get(sid) || {};
openSessions.set(sid, {
...v,
projectName: projectNameOf(projectPath),
projectPath,
lastActive: new Date().toISOString(),
Expand Down
20 changes: 15 additions & 5 deletions emrg/gui/renderer/css/layout.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -367,24 +367,34 @@
outline: none;
}

/* tiptap 内容可编辑区内部段落:默认 p 有 margin,输入框内收紧 */
.tiptap-input .ProseMirror {
/* tiptap 内容可编辑区内部段落:默认 p 有 margin,输入框内收紧。
⚠️ (rant 2026-08-28T22:26:29) DOM 中 .tiptap-input 与 .ProseMirror 是同一元素
(class="tiptap ProseMirror tiptap-input"),非嵌套关系——
旧选择器 .tiptap-input .ProseMirror 永不命中 → placeholder 不显示。 */
.tiptap-input {
min-height: 1.6em;
}
.tiptap-input .ProseMirror p {
.tiptap-input p {
margin: 0;
}
.tiptap-input .ProseMirror p.is-editor-empty:first-child {
.tiptap-input p.is-editor-empty:first-child {
position: relative;
}
.tiptap-input .ProseMirror p.is-editor-empty:first-child::before {
.tiptap-input p.is-editor-empty:first-child::before {
position: absolute;
left: 0;
top: 0;
content: attr(data-placeholder);
color: var(--text-3);
pointer-events: none;
}
/* 编辑器外层(EditorContent className="composer-editor")——
⚠️ (rant 2026-08-28T22:26:29) 无 CSS 定义时编辑器只占内容宽度,
send-btn 被文本推着走;须 flex:1 撑满 .composer-card。 */
.composer-editor {
flex: 1;
min-width: 0;
}

.composer-hint {
text-align: center;
Expand Down
28 changes: 17 additions & 11 deletions emrg/gui/renderer/src/components/Composer.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -375,23 +375,26 @@ describe("Composer — 格式栏与快捷键(Stage 2, rant 14:07:29)", () =>
expect(mdOf(s, editor)).toContain("`const x`");
});

it("链接按钮(window.prompt 输入 URL)→ [label](url);再次点击解除链接", async () => {
it("链接按钮(LinkDialog 输入 URL)→ [label](url);再次点击解除链接", async () => {
const store = createTranscriptStore();
const s = setup(store);
const editor = await waitEditor(s);
const promptSpy = vi.spyOn(window, "prompt").mockReturnValue("https://example.com");
act(() => {
editor.commands.insertContent("my link");
editor.commands.selectAll();
});
// 点击链接按钮 → 弹出应用内 LinkDialog(非 window.prompt;Electron 禁用 prompt)
await userEvent.click(screen.getByTestId("fmt-link"));
const urlInput = screen.getByTestId("link-url");
expect(urlInput).toBeInTheDocument();
await userEvent.clear(urlInput);
await userEvent.type(urlInput, "https://example.com");
await userEvent.click(screen.getByTestId("link-ok"));
expect(mdOf(s, editor)).toContain("[my link](https://example.com)");
expect(promptSpy).toHaveBeenCalledTimes(1);
// 已激活链接 → 再点解除(不再弹 prompt)
// 已激活链接 → 再点解除(LinkDialog 关闭)
await userEvent.click(screen.getByTestId("fmt-link"));
expect(mdOf(s, editor)).not.toContain("[my link]");
expect(promptSpy).toHaveBeenCalledTimes(1);
promptSpy.mockRestore();
expect(screen.queryByTestId("link-dialog")).not.toBeInTheDocument();
});

it("无序列表按钮 → - item", async () => {
Expand DownExpand Up@@ -472,19 +475,22 @@ describe("Composer — 格式栏与快捷键(Stage 2, rant 14:07:29)", () =>
expect(mdOf(s, editor)).toContain("**shortcut**");
});

it("⌘K 快捷键(无内置绑定,显式补挂)→ 链接 prompt", async () => {
it("⌘K 快捷键(无内置绑定,显式补挂)→ 打开 LinkDialog", async () => {
const store = createTranscriptStore();
const s = setup(store);
const editor = await waitEditor(s);
const promptSpy = vi.spyOn(window, "prompt").mockReturnValue("https://shortcut.dev");
act(() => {
editor.commands.insertContent("kb");
editor.commands.selectAll();
});
s.press("k", { ctrlKey: true });
await waitFor(() => expect(mdOf(s, editor)).toContain("[kb](https://shortcut.dev)"));
expect(promptSpy).toHaveBeenCalledTimes(1);
promptSpy.mockRestore();
// ⌘K 打开应用内 LinkDialog(非 window.prompt);输入 URL 后确认
const urlInput = await screen.findByTestId("link-url");
expect(urlInput).toBeInTheDocument();
await userEvent.clear(urlInput);
await userEvent.type(urlInput, "https://shortcut.dev");
await userEvent.click(screen.getByTestId("link-ok"));
expect(mdOf(s, editor)).toContain("[kb](https://shortcut.dev)");
});

it("⌘⇧8 快捷键(无内置绑定)→ 无序列表", async () => {
Expand Down
52 changes: 39 additions & 13 deletions emrg/gui/renderer/src/components/Composer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,9 +12,10 @@ import {
queueSend,
type CmdMenuState,
} from "../lib/composer";
import { genRequestId, type TranslateFn } from "../lib/utils";
import { genRequestId } from "../lib/utils";
import { useI18n } from "../lib/i18n";
import type { TranscriptStore } from "../lib/transcript";
import { LinkDialog } from "./LinkDialog";

/**
* Composer — 输入框 + / 补全菜单 + 发送流(tiptap 版,rant 2026-08-28T14:07:29 Stage 1)。
Expand DownExpand Up@@ -79,17 +80,6 @@ function cycleHeading(ed: Editor | null): void {
else ed.chain().focus().toggleHeading({ level: next as 1 | 2 | 3 }).run();
}

function toggleLink(ed: Editor | null, t: TranslateFn): void {
if (!ed) return;
if (ed.isActive("link")) {
ed.chain().focus().extendMarkRange("link").unsetLink().run();
return;
}
const href = window.prompt(t("composer.linkPrompt"), "https://");
if (!href) return;
ed.chain().focus().extendMarkRange("link").setLink({ href }).run();
}

/** 格式栏按钮定义(id → 命令 + 标签类 + i18n key) */
const FMT_BUTTONS = [
{ id: "bold", label: "B", labelClass: "fmt-label-bold" },
Expand DownExpand Up@@ -118,6 +108,9 @@ export function Composer({
const [hasText, setHasText] = useState(false);
const [internalBusy, setInternalBusy] = useState(false);
const busy = busyProp ?? internalBusy;
// ⚠️ (rant 2026-08-28T22:27:01) 链接改用应用内对话框收集 URL(Electron 禁用 window.prompt)
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
const [linkHref, setLinkHref] = useState("");
const queuedRef = useRef<Map<string, Array<{ requestId: string; text: string; sandbox?: string | null }>>>(new Map());
// tiptap 选项闭包只创建一次 → 变化值走 ref 桥接(menu/submit/selectCmd/t)
const menuRef = useRef(menu);
Expand All@@ -135,7 +128,7 @@ export function Composer({
ordered: () => editor?.chain().focus().toggleOrderedList().run(),
quote: () => editor?.chain().focus().toggleBlockquote().run(),
heading: () => cycleHeading(editor),
link: () => toggleLink(editor, tRef.current),
link: () => openLinkDialog(),
};

// 发送函数解析(默认走 preload 桥)
Expand DownExpand Up@@ -263,6 +256,33 @@ export function Composer({
heading: false,
};

// ⚠️ (rant 2026-08-28T22:27:01) 链接用应用内对话框收集 URL(Electron 禁用 window.prompt)
function openLinkDialog(): void {
if (!editor) return;
if (editor.isActive("link")) {
// 已激活链接 → 直接解除(不在对话框内处理,避免歧义)
editor.chain().focus().extendMarkRange("link").unsetLink().run();
return;
}
// 预填当前选区 href(若有)或默认 https://
let cur = "";
try {
cur = editor.getAttributes("link").href ?? "";
} catch {
cur = "";
}
setLinkHref(cur || "https://");
setLinkDialogOpen(true);
}
function applyLink(href: string): void {
if (!editor) return;
editor.chain().focus().extendMarkRange("link").setLink({ href }).run();
setLinkDialogOpen(false);
}
function cancelLink(): void {
setLinkDialogOpen(false);
}

async function submit(): Promise<void> {
if (!editor) return;
// tiptap-markdown 的 storage 类型不并入 Editor.storage(Storage 泛型)——运行时存在,显式取值
Expand DownExpand Up@@ -375,6 +395,12 @@ export function Composer({
</button>
</div>
<LinkDialog
open={linkDialogOpen}
currentHref={linkHref}
onApply={applyLink}
onCancel={cancelLink}
/>
</div>
);
}
84 changes: 84 additions & 0 deletions emrg/gui/renderer/src/components/LinkDialog.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LinkDialog } from "./LinkDialog";
import { I18nProvider } from "../lib/i18n";

/** LinkDialog — 链接 URL 输入对话框(rant 2026-08-28T22:27:01) */
describe("LinkDialog", () => {
it("关闭时不渲染", () => {
render(
<I18nProvider lang="zh">
<LinkDialog open={false} onApply={() => {}} onCancel={() => {}} />
</I18nProvider>,
);
expect(screen.queryByTestId("link-dialog")).not.toBeInTheDocument();
});

it("打开时预填 currentHref//https:// 并聚焦", () => {
render(
<I18nProvider lang="zh">
<LinkDialog open currentHref="" onApply={() => {}} onCancel={() => {}} />
</I18nProvider>,
);
const input = screen.getByTestId("link-url") as HTMLInputElement;
expect(input).toBeInTheDocument();
expect(input.value).toBe("https://");
});

it("输入 URL → 点确定 → onApply(href)", async () => {
const apply = vi.fn();
render(
<I18nProvider lang="zh">
<LinkDialog open onApply={apply} onCancel={() => {}} />
</I18nProvider>,
);
const input = screen.getByTestId("link-url") as HTMLInputElement;
await userEvent.clear(input);
await userEvent.type(input, "https://example.com");
await userEvent.click(screen.getByTestId("link-ok"));
expect(apply).toHaveBeenCalledWith("https://example.com");
});

it("Enter 提交 URL", async () => {
const apply = vi.fn();
render(
<I18nProvider lang="zh">
<LinkDialog open onApply={apply} onCancel={() => {}} />
</I18nProvider>,
);
const input = screen.getByTestId("link-url") as HTMLInputElement;
await userEvent.clear(input);
await userEvent.type(input, "https://enter.dev{enter}");
expect(apply).toHaveBeenCalledWith("https://enter.dev");
});

it("空 URL 不提交", async () => {
const apply = vi.fn();
render(
<I18nProvider lang="zh">
<LinkDialog open onApply={apply} onCancel={() => {}} />
</I18nProvider>,
);
const input = screen.getByTestId("link-url") as HTMLInputElement;
await userEvent.clear(input);
await userEvent.click(screen.getByTestId("link-ok"));
expect(apply).not.toHaveBeenCalled();
});

it("取消 → onCancel,不提交", async () => {
const apply = vi.fn();
const cancel = vi.fn();
render(
<I18nProvider lang="zh">
<LinkDialog open onApply={apply} onCancel={cancel} />
</I18nProvider>,
);
const input = screen.getByTestId("link-url") as HTMLInputElement;
await userEvent.clear(input);
await userEvent.type(input, "https://example.com");
await userEvent.click(screen.getByTestId("link-cancel"));
expect(cancel).toHaveBeenCalled();
expect(apply).not.toHaveBeenCalled();
});
});
74 changes: 74 additions & 0 deletions emrg/gui/renderer/src/components/LinkDialog.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
import { useEffect, useRef } from "react";
import { useI18n } from "../lib/i18n";
import { Dialog } from "./Dialog";

/**
* LinkDialog — 链接 URL 输入对话框(rant 2026-08-28T22:27:01)。
* Electron 的 Chromium 禁用了 window.prompt()(安全考虑,直接抛错),
* tiptap 默认 Link 扩展的 prompt 路径必然报错。此对话框用应用内 UI
* 收集 URL:输入 → Enter/确定 → onApply(href);取消/ESC → onCancel。
*/
export interface LinkDialogProps {
open: boolean;
/** 已激活链接的当前 href(编辑时预填);无则空 */
currentHref?: string;
onApply?: (href: string) => void;
onCancel?: () => void;
}

export function LinkDialog({ open, currentHref = "", onApply, onCancel }: LinkDialogProps) {
const { t } = useI18n();
const inputRef = useRef<HTMLInputElement>(null);

useEffect(() => {
if (open && inputRef.current) {
inputRef.current.value = currentHref || "https://";
inputRef.current.focus();
inputRef.current.select();
}
}, [open, currentHref]);

const submit = () => {
const input = inputRef.current;
if (!input) return;
const href = input.value.trim();
if (!href) return; // 空 URL 不提交
onApply?.(href);
};

if (!open) return null;
return (
<Dialog
open
title={t("composer.linkDialogTitle")}
onClose={onCancel}
testId="link-dialog"
actions={
<>
<button type="button" className="btn btn-ghost" data-testid="link-cancel" onClick={onCancel}>
{t("settings.cancel")}
</button>
<button type="button" className="btn btn-primary" data-testid="link-ok" onClick={submit}>
{t("settings.save")}
</button>
</>
}
>
<label>
<span>{t("composer.linkPrompt")}</span>
<input
ref={inputRef}
type="text"
placeholder="https://"
data-testid="link-url"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
submit();
}
}}
/>
</label>
</Dialog>
);
}
Loading
Loading