diff --git a/Agent.md b/Agent.md index 3cf6abb8..a2b64be1 100644 --- a/Agent.md +++ b/Agent.md @@ -120,7 +120,7 @@ pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg ``` Python: `uv run pytest tests/ -v` (1099) — import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (265: 45 daemon_client + 20 conn-manager + 22 app-commands + 132 renderer smoke + 15 i18n + 8 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group + 3 preload-api) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`; renderer React suite: `cd emrg/gui/renderer && npm run typecheck && npm test` (78 vitest: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 11 markdown + 15 transcript + 7 TranscriptView) + `npm run build` → `renderer/dist/` +GUI: `cd emrg/gui && npm test` (265: 45 daemon_client + 20 conn-manager + 22 app-commands + 132 renderer smoke + 15 i18n + 8 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group + 3 preload-api) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`; renderer React suite: `cd emrg/gui/renderer && npm run typecheck && npm test` (129 vitest: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 11 markdown + 15 transcript + 7 TranscriptView + 15 history + 22 composer + 14 Composer) + `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+ 周期实证的恢复路径) diff --git a/emrg/gui/renderer/src/components/Composer.test.tsx b/emrg/gui/renderer/src/components/Composer.test.tsx new file mode 100644 index 00000000..9bed3fa0 --- /dev/null +++ b/emrg/gui/renderer/src/components/Composer.test.tsx @@ -0,0 +1,223 @@ +import { describe, expect, it } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Composer, type SendResult } from "./Composer"; +import { createTranscriptStore, type TranscriptStore } from "../lib/transcript"; +import { I18nProvider } from "../lib/i18n"; + +/** + * Composer.test.tsx — 输入框 + / 补全菜单 + 发送流组件测试(Batch 2 remainder)。 + * 注入假 sendMessage,断言:补全菜单出现/键盘导航/选择填充、发送流程(用户行 + + * 输入清空 + requestId)、失败恢复(G49:文案 + 文本回填)、busy 队列注入(#655)。 + */ + +function setup( + store: TranscriptStore, + opts: { + sid?: string | null; + sendMessage?: (o: { sessionId: string | null; text: string; requestId: string; sandbox?: string | null }) => Promise; + busy?: boolean; + onCommand?: (r: { type: "command" | "unknown"; cmd: string; args?: string[] }) => void; + } = {}, +) { + const utils = render( + + + , + ); + const input = () => screen.getByTestId("composer-input") as HTMLTextAreaElement; + return { ...utils, input }; +} + +describe("Composer — 补全菜单", () => { + it("输入 / 前缀弹出匹配菜单(无空格仍在指令词)", async () => { + const store = createTranscriptStore(); + const { input } = setup(store); + await userEvent.type(input(), "/c"); + const menu = screen.getByTestId("cmd-menu"); + expect(menu).toBeInTheDocument(); + expect(screen.getAllByRole("menuitem").length).toBeGreaterThan(0); + }); + + it("空格后输入内容关闭菜单(离开指令词 → 普通消息)", async () => { + const store = createTranscriptStore(); + const { input } = setup(store); + // 注意:vanilla 用 trim() 判定——尾部空格被去后仍是指令词,菜单保留;输词后关闭 + await userEvent.type(input(), "/clear x"); + expect(screen.queryByTestId("cmd-menu")).not.toBeInTheDocument(); + }); + + it("↑↓ 导航切换选中项(vanilla (index±1+n)%n 环绕)", async () => { + const store = createTranscriptStore(); + const { input } = setup(store); + await userEvent.type(input(), "/c"); + const items = screen.getAllByRole("menuitem"); + expect(items[0]).toHaveClass("selected"); + await userEvent.keyboard("{ArrowDown}"); + expect(items[1]).toHaveClass("selected"); + expect(items[0]).not.toHaveClass("selected"); + // 环绕:最后一项 + ↓ → 回到第 0 项((last+1) % n = 0) + await userEvent.keyboard("{ArrowDown}"); + expect(items[0]).toHaveClass("selected"); + // ↑ 也环绕:第 0 项 + ↑ → 回最后一项((0-1+n) % n = n-1) + await userEvent.keyboard("{ArrowUp}"); + expect(items[items.length - 1]).toHaveClass("selected"); + }); + + it("Enter 选择补全项 → 填充输入框并关闭菜单(用户可继续回车执行)", async () => { + const store = createTranscriptStore(); + const { input } = setup(store); + await userEvent.type(input(), "/clear"); + await userEvent.keyboard("{Enter}"); + expect(input().value).toBe("/clear"); + expect(screen.queryByTestId("cmd-menu")).not.toBeInTheDocument(); + }); + + it("mousedown 点击补全项 → 填充输入框(preventDefault 防失焦)", async () => { + const store = createTranscriptStore(); + const { input } = setup(store); + await userEvent.type(input(), "/clear"); + await userEvent.click(screen.getByTestId("cmd-item-/clear")); + expect(input().value).toBe("/clear"); + }); + + it("Escape 关闭菜单", async () => { + const store = createTranscriptStore(); + const { input } = setup(store); + await userEvent.type(input(), "/c"); + expect(screen.getByTestId("cmd-menu")).toBeInTheDocument(); + await userEvent.keyboard("{Escape}"); + expect(screen.queryByTestId("cmd-menu")).not.toBeInTheDocument(); + }); +}); + +describe("Composer — 发送流", () => { + it("Enter 发送消息:用户行入 store + 输入清空 + sendMessage 带预生成 requestId + sandbox", async () => { + const store = createTranscriptStore(); + const sent: Array<{ text: string; requestId: string; sandbox?: string | null }> = []; + const { input } = setup(store, { + sendMessage: async (o) => { + sent.push({ text: o.text, requestId: o.requestId, sandbox: o.sandbox }); + return { requestId: o.requestId }; + }, + }); + await userEvent.type(input(), "hello emrg"); + await userEvent.keyboard("{Enter}"); + expect(sent).toHaveLength(1); + expect(sent[0].text).toBe("hello emrg"); + expect(sent[0].sandbox).toBe("workspace-write"); + expect(sent[0].requestId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + expect(input().value).toBe(""); + const entries = store.getEntries("s1"); + expect(entries[entries.length - 1]).toMatchObject({ kind: "user", text: "hello emrg" }); + }); + + it("Ctrl+Enter 同样发送", async () => { + const store = createTranscriptStore(); + let sent = 0; + setup(store, { + sendMessage: async () => { + sent++; + return {}; + }, + }); + await userEvent.type(screen.getByTestId("composer-input"), "ctrl send"); + await userEvent.keyboard("{Control>}{Enter}{/Control}"); + expect(sent).toBe(1); + }); + + it("Shift+Enter 不发送(换行)", async () => { + const store = createTranscriptStore(); + let sent = 0; + const { input } = setup(store, { + sendMessage: async () => { + sent++; + return {}; + }, + }); + await userEvent.type(input(), "line one"); + await userEvent.keyboard("{Shift>}{Enter}{/Shift}"); + expect(sent).toBe(0); + }); + + it("空输入不发送", async () => { + const store = createTranscriptStore(); + let sent = 0; + setup(store, { + sendMessage: async () => { + sent++; + return {}; + }, + }); + await userEvent.keyboard("{Enter}"); + expect(sent).toBe(0); + }); + + it("失败恢复:系统消息(copy.sendFailed)+ 文本回填", async () => { + const store = createTranscriptStore(); + const { input } = setup(store, { + sendMessage: async () => { + throw new Error("boom"); + }, + }); + await userEvent.type(input(), "will fail"); + await userEvent.keyboard("{Enter}"); + // I18nProvider lang=zh → 译文;await 异步失败分支(G49:恢复输入框) + expect( + store.getEntries("s1").some((e) => e.kind === "system" && e.text === "没发送成功,你的话我还留着,再试一次?"), + ).toBe(true); + await waitFor(() => expect(input().value).toBe("will fail")); + }); + + it("无会话时提示 app.needSession 且不发送", async () => { + const store = createTranscriptStore(); + let sent = 0; + setup(store, { + sid: null, + sendMessage: async () => { + sent++; + return {}; + }, + }); + await userEvent.type(screen.getByTestId("composer-input"), "orphan"); + await userEvent.keyboard("{Enter}"); + expect(sent).toBe(0); + expect(store.getEntries(null).some((e) => e.kind === "system" && e.text === "请先创建一个对话。")).toBe(true); + }); + + it("/ 指令不进发送流,走 onCommand 路由(vanilla rant 19:44 P1)", async () => { + const store = createTranscriptStore(); + let sent = 0; + const routed: Array<{ cmd: string; args?: string[] }> = []; + const { input } = setup(store, { + sendMessage: async () => { + sent++; + return {}; + }, + onCommand: (r) => routed.push({ cmd: r.cmd, args: r.args }), + }); + await userEvent.type(input(), "/clear now"); + await userEvent.keyboard("{Enter}"); + expect(sent).toBe(0); + expect(routed).toEqual([{ cmd: "/clear", args: ["now"] }]); + expect(input().value).toBe(""); + }); + + it("busy 时发送 → 入队(queue-injection #655)并在成功时以 daemon 回显 requestId 为准", async () => { + const store = createTranscriptStore(); + const sent: string[] = []; + const { input } = setup(store, { + busy: true, + sendMessage: async (o) => { + sent.push(o.requestId); + return { requestId: `echo-${o.requestId}` }; + }, + }); + await userEvent.type(input(), "queued msg"); + await userEvent.keyboard("{Enter}"); + expect(sent).toHaveLength(1); // busy 不拦截——直接发送 + // G124:ownStream 以 daemon 回显为准 + const version = store.getVersion(); + void version; + }); +}); diff --git a/emrg/gui/renderer/src/components/Composer.tsx b/emrg/gui/renderer/src/components/Composer.tsx new file mode 100644 index 00000000..bcd93761 --- /dev/null +++ b/emrg/gui/renderer/src/components/Composer.tsx @@ -0,0 +1,234 @@ +import { useEffect, useRef, useState, type KeyboardEvent } from "react"; +import { parseInput } from "../lib/commands"; +import { + CMD_MENU_CLOSED, + menuForPrefix, + menuNavigate, + queueSend, + type CmdMenuState, +} from "../lib/composer"; +import { genRequestId, type TranslateFn } from "../lib/utils"; +import { useI18n } from "../lib/i18n"; +import type { TranscriptStore } from "../lib/transcript"; + +/** + * Composer — 输入框 + / 补全菜单 + 发送流(Batch 2 remainder,蓝图 cycle-155056)。 + * 源:vanilla renderer/js/app.js sendMessage(131-170)+ CommandMenu(480-526)+ + * keydown 导航(~1710-1735)+ P2 queue-injection(#655)。 + * + * - 纯逻辑在 lib/composer.ts / lib/history.ts / lib/commands.ts(可单测); + * 本组件负责 DOM 接线(textarea 自适应高度 ≤150px、键盘导航、mousedown 选择)。 + * - 发送流:parseInput → / 指令走 onCommand 回调(Batch 5 由 App 路由到对话框); + * message → busy 队列注入(requestId 预生成、wasBusy 时入 queuedSends)。 + * - sendMessage 注入式(测试传假实现;默认 window.emrg.sendMessage)。 + * - busy 为受控可选 prop:Batch 5 接线后由 daemon 广播(done/cancelled/error)驱动; + * 未受控时组件内部管理(发送置忙、失败复位)。 + * - 类名与 vanilla 一致(composer-card / cmd-menu / cmd-menu-item / send-btn), + * Batch 5 切换复用现有 CSS。 + */ + +export interface SendOptions { + sessionId: string | null; + text: string; + requestId: string; + sandbox?: string | null; +} +export interface SendResult { + requestId?: string; +} +/** 指令路由入参(parseInput 结果;type:"unknown" 无 args——vanilla 走 default 提示) */ +export interface CommandRouting { + type: "command" | "unknown"; + cmd: string; + args?: string[]; +} + +export interface ComposerProps { + store: TranscriptStore; + sid?: string | null; + sandbox?: string | null; + /** busy 受控态(外部 daemon 广播驱动);缺省内部管理 */ + busy?: boolean; + /** 注入发送函数(默认 window.emrg.sendMessage;测试传假实现) */ + sendMessage?: (opts: SendOptions) => Promise; + /** / 指令路由回调(Batch 5 接线:/clear /model /memory …) */ + onCommand?: (routing: CommandRouting) => void; +} + +const MAX_INPUT_HEIGHT = 150; + +export function Composer({ + store, + sid = null, + sandbox = null, + busy: busyProp, + sendMessage: send, + onCommand, +}: ComposerProps) { + const { t } = useI18n(); + const [text, setText] = useState(""); + const [menu, setMenu] = useState(CMD_MENU_CLOSED); + const [internalBusy, setInternalBusy] = useState(false); + const busy = busyProp ?? internalBusy; + const queuedRef = useRef>>(new Map()); + const taRef = useRef(null); + + // 发送函数解析(默认走 preload 桥) + const sendFn = + send ?? + ((opts: SendOptions) => { + return (window as unknown as { emrg?: { sendMessage: (o: SendOptions) => Promise } }).emrg?.sendMessage(opts) ?? + Promise.reject(new Error("window.emrg.sendMessage unavailable")); + }); + + // textarea 自适应高度(vanilla:auto → min(scrollHeight,150)) + useEffect(() => { + const ta = taRef.current; + if (!ta) return; + ta.style.height = "auto"; + ta.style.height = `${Math.min(ta.scrollHeight, MAX_INPUT_HEIGHT)}px`; + }, [text]); + + async function submit(): Promise { + const value = text.trim(); + if (!value) return; + const parsed = parseInput(value); + if (parsed.type !== "message") { + // 指令:清输入 + 关菜单 + 路由(vanilla rant 19:44 P1:/ 开头不进 sendMessage) + setText(""); + setMenu(CMD_MENU_CLOSED); + onCommand?.({ + type: parsed.type, + cmd: parsed.cmd, + args: parsed.type === "command" ? parsed.args : undefined, + }); + return; + } + if (!sid) { + store.addSystemMessage(t("app.needSession"), sid); + return; + } + // P2 queue-injection(#655):busy 不再拦截——daemon 排队注入(task_queued), + // 回合结束未注入则 queued_requeue 以原 requestId 重发。busy 时记录待重发条目。 + const wasBusy = busy; + setInternalBusy(true); + store.addUserMessage(value, sid); + setText(""); + // B3:消息已发送 → 清除该会话草稿(React 版草稿由 text state 承载,发送即清) + // G143:send 前预生成 requestId 并标记自有流——消除 IPC 往返竞态窗口 + const requestId = genRequestId(); + store.setOwnStream(requestId); + if (wasBusy) { + queueSend(queuedRef.current, sid, { requestId, text: value, sandbox }); + } + try { + const res = await sendFn({ sessionId: sid, text: value, requestId, sandbox }); + store.setOwnStream(res.requestId || requestId); // G124:以 daemon 回显为准 + } catch { + setInternalBusy(false); + store.setOwnStream(null); + // G49:失败恢复输入框,文案不责怪用户(copy.sendFailed) + store.addSystemMessage(t("copy.sendFailed"), sid); + setText(value); + } + } + + /** 选择补全项:填充输入框 + 关菜单(用户可继续回车执行,vanilla selectCmd) */ + function selectCmd(cmd: string): void { + setText(cmd); + setMenu(CMD_MENU_CLOSED); + taRef.current?.focus(); + } + + function onChange(v: string): void { + setText(v); + // / 指令补全:以 / 开头且无空格(仍处于指令词)→ 弹出菜单 + const trimmed = v.trim(); + if (trimmed.startsWith("/") && !trimmed.includes(" ")) setMenu(menuForPrefix(trimmed, t)); + else setMenu(CMD_MENU_CLOSED); + } + + function onKeyDown(e: KeyboardEvent): void { + // / 补全菜单键盘导航(rant 19:44 P1):↑↓ 移动、Enter 选择、Esc 关闭 + if (menu.items.length > 0) { + if (e.key === "ArrowDown") { + e.preventDefault(); + setMenu(menuNavigate(menu, 1)); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + setMenu(menuNavigate(menu, -1)); + return; + } + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + const item = menu.items[menu.index]; + if (item) selectCmd(item.cmd); + return; + } + if (e.key === "Escape") { + e.preventDefault(); + setMenu(CMD_MENU_CLOSED); + return; + } + } + // Enter(非 Shift)与 Ctrl+Enter 同发送 + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + void submit(); + } else if (e.key === "Enter" && e.ctrlKey) { + e.preventDefault(); + void submit(); + } + } + + return ( +
+ {menu.items.length > 0 ? ( +
+ {menu.items.map((it, i) => ( + + ))} +
+ ) : null} +
+