From 666a924acf729c242223fd95637bcf3e5026dc9c Mon Sep 17 00:00:00 2001 From: argszero Date: Thu, 27 Aug 2026 07:29:45 +0800 Subject: [PATCH] =?UTF-8?q?emrg:=20gui=20renderer=20=E2=80=94=20wire=20wor?= =?UTF-8?q?kspace=20panels=20to=20real=20daemon=20data=20(projects/tasks/r?= =?UTF-8?q?ants=20+=20project=20sessions)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Agent.md | 4 +- .../renderer/src/components/Shell.test.tsx | 48 ++++++ emrg/gui/renderer/src/components/Shell.tsx | 153 +++++++++++++++++- .../src/components/WorkspaceView.test.tsx | 68 ++++++++ .../renderer/src/components/WorkspaceView.tsx | 43 ++++- 5 files changed, 311 insertions(+), 5 deletions(-) diff --git a/Agent.md b/Agent.md index 24b8752f..cfca5a97 100644 --- a/Agent.md +++ b/Agent.md @@ -86,7 +86,7 @@ Usage: say "tool loop" for the whole process, "round N" for a single LLM request - Session list/switch/new/delete + right-click rename (context menu, #423) synced with daemon; own-stream busy lock (G65); broadcast streams from other clients tagged "来自其他客户端" - Disconnect/reconnect: red status dot, auto daemon respawn (stale-port detection), session resume, input bar restored on disconnect (no 30s fake-timeout) - Unit tests `npm test` (89: 45 daemon_client + 20 conn-manager + 8 integration + 6 build-config + 7 gui-state + 3 preload-api); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` - - React migration Batch 0 + Batch 1 (design `~/.emrg/designs/gui-react-migration-design.md`): `emrg/gui/renderer/` scaffold — Vite 8 + React 19 + TypeScript strict + Vitest + self-developed SnapshotStore (useSyncExternalStore); standalone build → `renderer/dist/index.html` + `assets/*` (CSP `script-src 'self'`-safe, no inline); `window.emrg` bridge (52 invoke + onEvent, API-surface guard test) + main.js + daemon protocol untouched; vanilla renderer stays live until Batch 5 switch (D3); Batch 1 ported commands.js → `lib/commands.ts` + copywriting.js → `lib/copywriting.ts` (pure logic, injectable t); Batch 1 remainder: i18n.js full dictionary (373 keys zh/en parity, auto-generated `lib/i18n-dicts.ts` + detectLocale/getLocale/setLocale/t) + markdown.js → `lib/markdown.ts` (marked/DOMPurify/hljs injectable, fence-closed heuristic + block-projection streaming); Batch 2: chat.js → `lib/transcript.ts` (pure per-session state machine — delta/done/toolStart/toolEnd/merge-group/clearTyping, sid buckets P3, 15 tests) + `components/TranscriptView.tsx` (React chat area: streaming plain-text → markdown-on-done via injectable renderer, tool rows running→done/failed with elapsed + expandable output, merge-group bar summary + collapse/user-expanded, remote label, 7 tests); Batch 3: Sidebar/ResultPanel/WorkspaceView/FileTree components; Batch 4: dialog components (help/memory/skills/rename/confirm/rewind/open-session/new-session/welcome/github-device) + `lib/dialog` reducer + dialogLists/rewind/openSession pure logic; Batch 5: `lib/daemonBridge.ts` event bridge (multi-subscriber onEvent → transcript/app stores, sid-routed #977, queue-injection #655) + `DaemonBridgeProvider` (mount-once/dispose, tRef) + Shell chat-loop wiring (live daemon data: transcript/composer/sidebar/result-panel, #1016-#1018) + `DialogHost` (command routing → dialogs: /help /memory /skills /rewind /rename /delete /sessions /open /resume + direct /clear /compact /version /image; sidebar new-chat/open-chat buttons, #1019) + workspace view switching (Sidebar `#side-nav` rail: sessions/projects/tasks/rants/settings with active highlight; Shell `activeView` state per vanilla `switchView` toggle semantics — panel hides chat chrome, sessions restores it; #1020) + SettingsDialog wiring (`components/SettingsPanel.tsx`: six tabs model/github/appearance/language/about/templates — task-template CRUD (list/create/update/delete custom types, builtin read-only view, delete-confirm, textarea prompt editor; bridge taskTemplateList/Create/Update/Delete) — model CRUD + set-default via getSettings/saveSettings, github connect/disconnect status, theme data-theme apply, i18n setLocale, about version/evolution-count from Shell appState; #1021) + final switch part 1 (#1023): main.js `loadFile` → `renderer/dist/index.html` (both initial + render-process-gone reload) — production GUI now runs the React app; vanilla renderer files (renderer/js, css, index.html) + vm.runInContext smoke tests retained for revert, removed in follow-up cleanup (vanilla renderer/js + css + index.html and the vm.runInContext legacy tests deleted — React vitest 411 + GUI 89 cover the migrated logic); gui `npm start` pre-builds dist via `prestart` (fresh-clone dev flow) + post-switch production wiring: `lib/vendorMarkdown.ts` restores real vendor marked/DOMPurify/hljs into the bundle (chat markdown rendering + ResultPanel `window.hljs` code highlight — previously degraded to escaped plain text) and re-imports the vanilla `css/` suite (tokens/base/components/layout/animations, deleted with the vanilla renderer — React components reference `var(--token)` vars and vanilla class names; one pre-existing stray brace in components.css fixed for lightningcss) + - React migration Batch 0 + Batch 1 (design `~/.emrg/designs/gui-react-migration-design.md`): `emrg/gui/renderer/` scaffold — Vite 8 + React 19 + TypeScript strict + Vitest + self-developed SnapshotStore (useSyncExternalStore); standalone build → `renderer/dist/index.html` + `assets/*` (CSP `script-src 'self'`-safe, no inline); `window.emrg` bridge (52 invoke + onEvent, API-surface guard test) + main.js + daemon protocol untouched; vanilla renderer stays live until Batch 5 switch (D3); Batch 1 ported commands.js → `lib/commands.ts` + copywriting.js → `lib/copywriting.ts` (pure logic, injectable t); Batch 1 remainder: i18n.js full dictionary (373 keys zh/en parity, auto-generated `lib/i18n-dicts.ts` + detectLocale/getLocale/setLocale/t) + markdown.js → `lib/markdown.ts` (marked/DOMPurify/hljs injectable, fence-closed heuristic + block-projection streaming); Batch 2: chat.js → `lib/transcript.ts` (pure per-session state machine — delta/done/toolStart/toolEnd/merge-group/clearTyping, sid buckets P3, 15 tests) + `components/TranscriptView.tsx` (React chat area: streaming plain-text → markdown-on-done via injectable renderer, tool rows running→done/failed with elapsed + expandable output, merge-group bar summary + collapse/user-expanded, remote label, 7 tests); Batch 3: Sidebar/ResultPanel/WorkspaceView/FileTree components; Batch 4: dialog components (help/memory/skills/rename/confirm/rewind/open-session/new-session/welcome/github-device) + `lib/dialog` reducer + dialogLists/rewind/openSession pure logic; Batch 5: `lib/daemonBridge.ts` event bridge (multi-subscriber onEvent → transcript/app stores, sid-routed #977, queue-injection #655) + `DaemonBridgeProvider` (mount-once/dispose, tRef) + Shell chat-loop wiring (live daemon data: transcript/composer/sidebar/result-panel, #1016-#1018) + `DialogHost` (command routing → dialogs: /help /memory /skills /rewind /rename /delete /sessions /open /resume + direct /clear /compact /version /image; sidebar new-chat/open-chat buttons, #1019) + workspace view switching (Sidebar `#side-nav` rail: sessions/projects/tasks/rants/settings with active highlight; Shell `activeView` state per vanilla `switchView` toggle semantics — panel hides chat chrome, sessions restores it; #1020) + workspace panels wired to real daemon data (Shell loads listProjects/listTasks/listRants on panel activation, project-sessions sub-view via listProjectSessions with current-sid mark, actions: switch-session / add-project (pickProjectDir+register) / delete-project (confirm) / trigger-task; Shell owns a ConfirmDialog for delete) + SettingsDialog wiring (`components/SettingsPanel.tsx`: six tabs model/github/appearance/language/about/templates — task-template CRUD (list/create/update/delete custom types, builtin read-only view, delete-confirm, textarea prompt editor; bridge taskTemplateList/Create/Update/Delete) — model CRUD + set-default via getSettings/saveSettings, github connect/disconnect status, theme data-theme apply, i18n setLocale, about version/evolution-count from Shell appState; #1021) + final switch part 1 (#1023): main.js `loadFile` → `renderer/dist/index.html` (both initial + render-process-gone reload) — production GUI now runs the React app; vanilla renderer files (renderer/js, css, index.html) + vm.runInContext smoke tests retained for revert, removed in follow-up cleanup (vanilla renderer/js + css + index.html and the vm.runInContext legacy tests deleted — React vitest 411 + GUI 89 cover the migrated logic); gui `npm start` pre-builds dist via `prestart` (fresh-clone dev flow) + post-switch production wiring: `lib/vendorMarkdown.ts` restores real vendor marked/DOMPurify/hljs into the bundle (chat markdown rendering + ResultPanel `window.hljs` code highlight — previously degraded to escaped plain text) and re-imports the vanilla `css/` suite (tokens/base/components/layout/animations, deleted with the vanilla renderer — React components reference `var(--token)` vars and vanilla class names; one pre-existing stray brace in components.css fixed for lightningcss) - **Scheduled tasks** — Task generalization + CRUD (rant 2026-08-12T18:23:15, #709/#710/#711) - Task handler generalized: `TaskHandler` (renamed from `EvolutionHandler`), repo-configured self-heal for any project, template lookup builtin → `~/.emrg/task-templates/.md` → fallback - Daemon commands: `task_create/update/delete` + `task_template_create/list/update/delete` (tasks stored in `~/.emrg/tasks.yml`, custom type templates in `~/.emrg/task-templates/`) @@ -121,7 +121,7 @@ pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg Python: `uv run pytest tests/ -v` (1107) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (89: 45 daemon_client + 20 conn-manager + 8 integration + 6 build-config + 7 gui-state + 3 preload-api) — syntax: `node --check main.js preload.js daemon_client.js` -Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (421: 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 + 12 sidebar + 17 Sidebar + 9 fileTree + 9 FileTree + 16 resultPanel + 8 ResultPanel + 27 workspaceView + 8 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 + 12 daemonBridge + 6 DaemonBridgeProvider + 16 Shell + 15 DialogHost + 19 SettingsPanel + 4 vendorMarkdown) + `npm run build` → `renderer/dist/` +Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (426: 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 + 12 sidebar + 17 Sidebar + 9 fileTree + 9 FileTree + 16 resultPanel + 8 ResultPanel + 29 workspaceView + 8 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 + 12 daemonBridge + 6 DaemonBridgeProvider + 19 Shell + 15 DialogHost + 19 SettingsPanel + 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+ 周期实证的恢复路径) diff --git a/emrg/gui/renderer/src/components/Shell.test.tsx b/emrg/gui/renderer/src/components/Shell.test.tsx index 161722bf..3daee3be 100644 --- a/emrg/gui/renderer/src/components/Shell.test.tsx +++ b/emrg/gui/renderer/src/components/Shell.test.tsx @@ -25,6 +25,8 @@ function mockEmrg() { const clearSession = vi.fn().mockResolvedValue({ ok: true }); const compactSession = vi.fn().mockResolvedValue({ ok: true }); const listProjects = vi.fn().mockResolvedValue([{ name: "emrg", path: "/p/emrg" }]); + const listTasks = vi.fn().mockResolvedValue([{ name: "evo", type: "evolution", enabled: true }]); + const listRants = vi.fn().mockResolvedValue([{ timestamp: "2026-08-26T12:00:00+08:00", project: "emrg", status: "pending", message: "x" }]); const listMemories = vi.fn().mockResolvedValue([]); const listSkills = vi.fn().mockResolvedValue([]); const listHistory = vi.fn().mockResolvedValue({ messages: [] }); @@ -34,6 +36,8 @@ function mockEmrg() { clearSession, compactSession, listProjects, + listTasks, + listRants, listMemories, listSkills, listHistory, @@ -43,6 +47,9 @@ function mockEmrg() { onEvent, sendMessage, clearSession, + listProjects, + listTasks, + listRants, emit: (evt: DaemonEventFrame) => listeners.forEach((cb) => cb(evt)), }; } @@ -255,4 +262,45 @@ describe("Shell (Batch 5 slice 3 chat wiring)", () => { expect((screen.getAllByTestId("open-session-item").find((el) => el.dataset.sid === "s2"))?.className).toContain("active"); expect(screen.getByTestId("nav-sessions").className).toContain("active"); }); + + // ── Batch 5 slice 7:workspace 面板数据接线(真实 listProjects/listTasks/listRants) ── + + it("进入任务面板 → 加载 listTasks 并渲染任务行", async () => { + const m = mockEmrg(); + render(wrapper()); + await waitFor(() => expect(screen.getByTestId("composer")).toBeInTheDocument()); + fireEvent.click(screen.getByTestId("nav-tasks")); + await waitFor(() => expect(m.listTasks).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(screen.getByTestId("task-row")).toBeInTheDocument()); + expect(screen.getByTestId("task-row")).toHaveTextContent("evo"); + }); + + it("进入 Rant 面板 → 加载 listRants 并渲染 rant 行", async () => { + const m = mockEmrg(); + render(wrapper()); + await waitFor(() => expect(screen.getByTestId("composer")).toBeInTheDocument()); + fireEvent.click(screen.getByTestId("nav-rants")); + await waitFor(() => expect(m.listRants).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(screen.getByTestId("rant-row")).toBeInTheDocument()); + }); + + it("进入项目面板 → 加载 listProjects;查看会话 → listProjectSessions 加载会话行", async () => { + const m = mockEmrg(); + m.listProjects.mockResolvedValue([{ name: "p1", path: "/p/p1" }]); + (window as unknown as { emrg: { listProjectSessions?: unknown } }).emrg.listProjectSessions = + vi.fn().mockResolvedValue({ sessions: [{ session_id: "s1", title: "会话一" }] }); + render(wrapper()); + await waitFor(() => expect(screen.getByTestId("composer")).toBeInTheDocument()); + fireEvent.click(screen.getByTestId("nav-projects")); + await waitFor(() => expect(m.listProjects).toHaveBeenCalled()); + await waitFor(() => expect(screen.getByTestId("project-row")).toBeInTheDocument()); + // 查看会话 → 会话子视图加载(en 标题 "Sessions";限定在项目行内避免与侧栏撞名) + const row = screen.getByTestId("project-row"); + fireEvent.click(row.querySelector('button[title="Sessions"]') as HTMLButtonElement); + await waitFor(() => + expect((window as unknown as { emrg: { listProjectSessions?: unknown } }).emrg.listProjectSessions).toHaveBeenCalled(), + ); + await waitFor(() => expect(screen.getByTestId("project-session-row")).toBeInTheDocument()); + expect(screen.getByTestId("project-session-row")).toHaveTextContent("会话一"); + }); }); diff --git a/emrg/gui/renderer/src/components/Shell.tsx b/emrg/gui/renderer/src/components/Shell.tsx index 0b69be39..dfcdc72a 100644 --- a/emrg/gui/renderer/src/components/Shell.tsx +++ b/emrg/gui/renderer/src/components/Shell.tsx @@ -1,8 +1,12 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useReducer, useRef, useState } from "react"; import { useI18n } from "../lib/i18n"; import { useSnapshotStore } from "../hooks/useSnapshotStore"; import { useDaemonBridge } from "./DaemonBridgeProvider"; import { createProdMarkdownRenderer } from "../lib/vendorMarkdown"; +import { dialogReducer, initialDialogState } from "../lib/dialog"; +import { ConfirmDialog } from "./ConfirmDialog"; +import type { ProjectRec, RantRec, TaskRec } from "../lib/workspaceView"; +import type { SessionRow } from "../lib/openSession"; import { Sidebar, type SidebarViewId } from "./Sidebar"; import { ResultPanel } from "./ResultPanel"; import { WorkspaceView, type WorkspaceViewId } from "./WorkspaceView"; @@ -36,8 +40,26 @@ import { DialogHost, type DialogHostHandle } from "./DialogHost"; * - 面板视图激活时隐藏会话 chrome(transcript/composer/result-panel —— vanilla * setWorkspaceChrome("panel")),sessions 视图显示完整会话区; * - Sidebar nav rail(#side-nav)高亮当前视图 + onSwitchView 接线; - * - WorkspaceView 数据仍为空数组注入(Batch 5 后续 slice 接 listProjects 等 IPC)。 + * - WorkspaceView 数据接线:面板激活加载 listProjects/listTasks/listRants,项目会话子视图 + * listProjectSessions,动作 switch-session / add-project / delete-project / trigger-task。 */ +/** Shell 用到的 workspace 数据桥方法(与 preload.js 通道对齐) */ +interface WorkspaceBridge { + listProjects(): Promise; + listProjectSessions(p: { projectPath: string }): Promise<{ sessions: SessionRow[] }>; + listTasks(): Promise; + listRants(p: { status?: string }): Promise; + switchSession(p: { sessionId: string }): Promise; + pickProjectDir(): Promise<{ path?: string } | null>; + registerProject(p: { path: string }): Promise<{ ok?: boolean; path?: string }>; + removeProject(p: { name: string; path?: string }): Promise<{ ok?: boolean; error?: string; protected?: boolean }>; + triggerTask(p: { name: string }): Promise; +} + +function wsBridge(): WorkspaceBridge | undefined { + return (window as unknown as { emrg?: WorkspaceBridge }).emrg; +} + export function Shell() { const { t } = useI18n(); const { bridge, transcript } = useDaemonBridge(); @@ -45,6 +67,13 @@ export function Shell() { const [activeSid, setActiveSid] = useState(null); const [activeView, setActiveView] = useState<"sessions" | WorkspaceViewId>("sessions"); const dialogHost = useRef(null); + // workspace 面板数据(Batch 5 接线:项目/任务/Rant 真实列表 + 项目会话) + const [projects, setProjects] = useState([]); + const [tasks, setTasks] = useState([]); + const [rants, setRants] = useState([]); + const [projectSessions, setProjectSessions] = useState(null); + const [projectSessionsError, setProjectSessionsError] = useState(null); + const [dialogState, dispatch] = useReducer(dialogReducer, initialDialogState); // open_sessions 广播到达且尚无激活会话 → 自动选第一个(vanilla 同语义) useEffect(() => { @@ -53,6 +82,113 @@ export function Shell() { } }, [appState.openSessions, activeSid]); + // ── workspace 面板数据加载(vanilla loadTaskMeta/renderRantList 语义) ── + async function loadProjects() { + const b = wsBridge(); + if (!b?.listProjects) return; + try { + setProjects(await b.listProjects()); + } catch { + setProjects([]); + } + } + + async function loadTasks() { + const b = wsBridge(); + if (!b?.listTasks) return; + try { + setTasks(await b.listTasks()); + } catch { + setTasks([]); + } + } + + async function loadRants() { + const b = wsBridge(); + if (!b?.listRants) return; + try { + setRants(await b.listRants({})); + } catch { + setRants([]); + } + } + + async function loadProjectSessions(p: ProjectRec) { + const b = wsBridge(); + if (!b?.listProjectSessions || !p.path) return; + setProjectSessions(null); + setProjectSessionsError(null); + try { + const res = await b.listProjectSessions({ projectPath: p.path }); + setProjectSessions(res.sessions ?? []); + } catch (e) { + setProjectSessionsError((e as Error)?.message ?? String(e)); + } + } + + // 面板激活 → 加载对应数据(每次进入刷新,vanilla 面板激活时拉取) + useEffect(() => { + if (activeView === "projects") void loadProjects(); + else if (activeView === "tasks") void loadTasks(); + else if (activeView === "rants") void loadRants(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeView]); + + // ── workspace 面板动作(vanilla dialogs.js 语义) ── + async function addProject() { + const b = wsBridge(); + if (!b?.pickProjectDir || !b?.registerProject) return; + try { + const res = await b.pickProjectDir(); + if (!res?.path) return; + await b.registerProject({ path: res.path }); + await loadProjects(); + } catch (e) { + transcript.addSystemMessage(t("deleteProject.failed", { msg: (e as Error)?.message ?? String(e) }), activeSid); + } + } + + function deleteProject(p: ProjectRec) { + dispatch({ + type: "open-confirm", + payload: { + title: t("deleteProject.title"), + message: t("deleteProject.body", { name: String(p.name || p.path || "") }), + okText: "dlg.delete", + danger: true, + onOk: async () => { + const b = wsBridge(); + if (!b?.removeProject) return; + try { + await b.removeProject({ name: p.name || "", path: p.path || "" }); + await loadProjects(); + } catch (e) { + transcript.addSystemMessage(t("deleteProject.failed", { msg: (e as Error)?.message ?? String(e) }), activeSid); + } + }, + }, + }); + } + + async function selectProjectSession(p: ProjectRec, sid: string) { + const b = wsBridge(); + if (b?.switchSession) await b.switchSession({ sessionId: sid }); + setActiveSid(sid); + setActiveView("sessions"); + } + + async function triggerTask(task: TaskRec) { + const b = wsBridge(); + if (!b?.triggerTask || !task.name) return; + try { + await b.triggerTask({ name: task.name }); + transcript.addSystemMessage(t("app.triggered", { n: task.name }), activeSid); + await loadTasks(); + } catch (e) { + transcript.addSystemMessage(t("app.triggerFailed", { msg: (e as Error)?.message ?? String(e) }), activeSid); + } + } + const busy = activeSid ? (appState.busyBySid[activeSid] ?? false) : false; const disconnected = activeSid ? (appState.disconnectedBySid[activeSid] ?? false) : false; @@ -150,8 +286,20 @@ export function Shell() { void loadProjectSessions(p)} + onSelectProjectSession={(p, sid) => void selectProjectSession(p, sid)} + onAddProject={() => void addProject()} + onDeleteProject={deleteProject} + onTriggerTask={(task) => void triggerTask(task)} + // onEditTask / onNewRant:需要任务表单与 rant 对话框 UI(后续 slice) /> ) : ( <> @@ -174,6 +322,7 @@ export function Shell() { appState={appState} onSwitchSession={selectSession} /> + dispatch({ type: "close-confirm" })} /> ); diff --git a/emrg/gui/renderer/src/components/WorkspaceView.test.tsx b/emrg/gui/renderer/src/components/WorkspaceView.test.tsx index 447fc7d4..19f2f5ea 100644 --- a/emrg/gui/renderer/src/components/WorkspaceView.test.tsx +++ b/emrg/gui/renderer/src/components/WorkspaceView.test.tsx @@ -185,4 +185,72 @@ describe("WorkspaceView", () => { await userEvent.click(screen.getByText("待处理")); expect(screen.getByTestId("rants-empty")).toHaveTextContent("该状态下暂无 Rant"); }); + + // ── 项目会话子视图(Batch 5 接线:真实 listProjectSessions 数据) ── + + it("项目会话:loading → 会话行(title||id + 当前标记)→ 点击选择", async () => { + const onSelect = vi.fn(); + const { rerender } = render( + + + , + ); + await userEvent.click(screen.getByTitle("查看会话")); + // loading 态 + expect(screen.getByTestId("project-sessions-loading")).toBeTruthy(); + // 数据到达 → 会话行 + rerender( + + + , + ); + const rows = screen.getAllByTestId("project-session-row"); + expect(rows.length).toBe(2); + expect(rows[0]).toHaveTextContent("会话甲"); // title 优先 + expect(rows[0]).toHaveTextContent("当前"); // currentSid 标记 + expect(rows[1]).toHaveTextContent("s2"); // 无 title → 完整 id + await userEvent.click(rows[1]); + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onSelect.mock.calls[0][0]).toMatchObject({ name: "p1", path: "/proj/p1" }); + expect(onSelect.mock.calls[0][1]).toBe("s2"); + }); + + it("项目会话:空列表 → noSessions;错误 → loadFailed", async () => { + const { rerender } = render( + + + , + ); + await userEvent.click(screen.getByTitle("查看会话")); + expect(screen.getByTestId("project-sessions-empty")).toHaveTextContent("暂无会话"); + rerender( + + + , + ); + expect(screen.getByTestId("project-sessions-error")).toHaveTextContent("boom"); + }); }); diff --git a/emrg/gui/renderer/src/components/WorkspaceView.tsx b/emrg/gui/renderer/src/components/WorkspaceView.tsx index 3faa64da..1a99adf8 100644 --- a/emrg/gui/renderer/src/components/WorkspaceView.tsx +++ b/emrg/gui/renderer/src/components/WorkspaceView.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from "react"; import { useI18n } from "../lib/i18n"; import { SettingsPanel } from "./SettingsPanel"; +import { sessionRowView, type SessionRow } from "../lib/openSession"; import { buildEvolveProjects, preprocessRantMarkdown, @@ -33,6 +34,11 @@ export interface WorkspaceViewProps { projects?: ProjectRec[]; tasks?: TaskRec[]; rants?: RantRec[]; + /** 当前项目会话列表(Batch 5 接线:Shell 经 listProjectSessions 加载) */ + projectSessions?: SessionRow[] | null; + projectSessionsError?: string | null; + /** 激活会话(会话行 "当前" 标记,vanilla App.state.sessionId 语义) */ + currentSid?: string | null; activeView: WorkspaceViewId; onSwitch?: (view: WorkspaceViewId) => void; /** 设置面板关于 tab 数据(Shell appState 注入,Batch 5 slice 6) */ @@ -59,6 +65,9 @@ export function WorkspaceView({ projects = [], tasks = [], rants = [], + projectSessions = null, + projectSessionsError = null, + currentSid = null, activeView, onSwitch, version, @@ -106,6 +115,9 @@ export function WorkspaceView({ {sessionsProject ? ( setSessionsProject(null)} onSelect={(sid) => onSelectProjectSession?.(sessionsProject, sid)} /> @@ -263,10 +275,16 @@ function ProjectList({ /** 项目会话子视图(点击项目"查看会话"后展示) */ function ProjectSessions({ project, + sessions, + error, + currentSid, onBack, onSelect, }: { project: ProjectRec; + sessions: SessionRow[] | null; + error?: string | null; + currentSid?: string | null; onBack: () => void; onSelect: (sid: string) => void; }) { @@ -279,7 +297,30 @@ function ProjectSessions({ {t("projects.sessionsOf", { project: project.name || project.path || "" })} -
{t("projects.noSessions")}
+ {error ? ( +
{t("openSession.loadFailed", { msg: error })}
+ ) : sessions === null ? ( +
{t("dlg.loading")}
+ ) : sessions.length === 0 ? ( +
{t("projects.noSessions")}
+ ) : ( + sessions.map((s) => { + const v = sessionRowView(s, currentSid ?? null, t); + return ( + + ); + }) + )} ); }