From f94ca5bd116ab74dba732c0acff75f711e6a2cc3 Mon Sep 17 00:00:00 2001 From: argszero Date: Thu, 27 Aug 2026 06:47:07 +0800 Subject: [PATCH 1/2] =?UTF-8?q?emrg:=20gui=20renderer=20=E2=80=94=20settin?= =?UTF-8?q?gs:=20add=20custom=20task-type=20(template)=20management=20tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Agent.md | 4 +- .../src/components/SettingsPanel.test.tsx | 101 +++++++++ .../renderer/src/components/SettingsPanel.tsx | 200 +++++++++++++++++- 3 files changed, 301 insertions(+), 4 deletions(-) diff --git a/Agent.md b/Agent.md index 53ceeb02..2ced4d0f 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`: five tabs model/github/appearance/language/about — 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) + - 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) - **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` (411: 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 + 13 SettingsPanel) + `npm run build` → `renderer/dist/` +Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (417: 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) + `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/SettingsPanel.test.tsx b/emrg/gui/renderer/src/components/SettingsPanel.test.tsx index 7541920d..b10b4040 100644 --- a/emrg/gui/renderer/src/components/SettingsPanel.test.tsx +++ b/emrg/gui/renderer/src/components/SettingsPanel.test.tsx @@ -271,4 +271,105 @@ describe("SettingsPanel", () => { fireEvent.click(screen.getByTestId("settings-save")); await waitFor(() => expect(screen.getByTestId("settings-msg").className).toContain("ok")); }); + + // ── templates tab(自定义任务类型管理,vanilla renderTemplateList 对等) ── + + it("renders the templates tab and loads the type list", async () => { + const tpls = [ + { name: "journal", prompt: "Phase A…", builtin: true }, + { name: "my-type", template: "custom prompt", builtin: false }, + ]; + const listMock = vi.fn().mockResolvedValue(tpls); + setup({ taskTemplateList: listMock }); + await screen.findByTestId("settings-tab-templates"); + fireEvent.click(screen.getByTestId("settings-tab-templates")); + await waitFor(() => expect(listMock).toHaveBeenCalledTimes(1)); + const rows = screen.getAllByTestId("template-row"); + expect(rows.length).toBe(2); + // builtin → view only; custom → view + edit + delete + expect(screen.queryByTestId("template-edit-journal")).toBeNull(); + expect(screen.queryByTestId("template-delete-journal")).toBeNull(); + expect(screen.getByTestId("template-edit-my-type")).toBeTruthy(); + expect(screen.getByTestId("template-delete-my-type")).toBeTruthy(); + // badges + expect(rows[0].textContent).toContain("builtin"); + expect(rows[1].textContent).toContain("custom"); + }); + + it("shows the empty state when no templates exist", async () => { + setup({ taskTemplateList: vi.fn().mockResolvedValue([]) }); + await screen.findByTestId("settings-tab-templates"); + fireEvent.click(screen.getByTestId("settings-tab-templates")); + await waitFor(() => expect(screen.getByTestId("template-empty")).toBeTruthy()); + }); + + it("creates a custom type via the form", async () => { + const listMock = vi.fn().mockResolvedValue([]); + const createMock = vi.fn().mockResolvedValue({}); + setup({ + taskTemplateList: listMock, + taskTemplateCreate: createMock, + taskTemplateUpdate: vi.fn().mockResolvedValue({}), + }); + await screen.findByTestId("settings-tab-templates"); + fireEvent.click(screen.getByTestId("settings-tab-templates")); + await waitFor(() => screen.getByTestId("add-template-btn")); + fireEvent.click(screen.getByTestId("add-template-btn")); + expect(screen.getByTestId("template-form")).toBeTruthy(); + fireEvent.change(screen.getByTestId("template-form-name"), { target: { value: "report" } }); + fireEvent.change(screen.getByTestId("template-form-prompt"), { target: { value: "Write a report…" } }); + fireEvent.click(screen.getByTestId("template-form-save")); + await waitFor(() => expect(createMock).toHaveBeenCalledTimes(1)); + expect(createMock).toHaveBeenCalledWith({ name: "report", prompt: "Write a report…" }); + await waitFor(() => expect(listMock.mock.calls.length).toBeGreaterThan(1)); // reloaded + }); + + it("validates empty name/prompt on save", async () => { + const listMock = vi.fn().mockResolvedValue([]); + const createMock = vi.fn().mockResolvedValue({}); + setup({ taskTemplateList: listMock, taskTemplateCreate: createMock }); + await screen.findByTestId("settings-tab-templates"); + fireEvent.click(screen.getByTestId("settings-tab-templates")); + await waitFor(() => screen.getByTestId("add-template-btn")); + fireEvent.click(screen.getByTestId("add-template-btn")); + fireEvent.click(screen.getByTestId("template-form-save")); + await waitFor(() => expect(screen.getByTestId("settings-msg").textContent).toContain("must not be empty")); + expect(createMock).not.toHaveBeenCalled(); + }); + + it("deletes a custom type after confirm", async () => { + const listMock = vi.fn().mockResolvedValue([ + { name: "my-type", prompt: "x", builtin: false }, + ]); + const deleteMock = vi.fn().mockResolvedValue({}); + setup({ taskTemplateList: listMock, taskTemplateDelete: deleteMock }); + await screen.findByTestId("settings-tab-templates"); + fireEvent.click(screen.getByTestId("settings-tab-templates")); + await waitFor(() => screen.getByTestId("template-delete-my-type")); + fireEvent.click(screen.getByTestId("template-delete-my-type")); + // confirm dialog appears; confirm it + await waitFor(() => expect(screen.getByTestId("confirm-ok")).toBeTruthy()); + fireEvent.click(screen.getByTestId("confirm-ok")); + await waitFor(() => expect(deleteMock).toHaveBeenCalledTimes(1)); + expect(deleteMock).toHaveBeenCalledWith({ name: "my-type" }); + await waitFor(() => expect(listMock.mock.calls.length).toBeGreaterThan(1)); // reloaded + }); + + it("shows the daemon rejection message when deleting a referenced type", async () => { + setup({ + taskTemplateList: vi.fn().mockResolvedValue([ + { name: "journal", prompt: "x", builtin: false }, + ]), + taskTemplateDelete: vi.fn().mockRejectedValue(new Error("referenced by 2 tasks")), + }); + await screen.findByTestId("settings-tab-templates"); + fireEvent.click(screen.getByTestId("settings-tab-templates")); + await waitFor(() => screen.getByTestId("template-delete-journal")); + fireEvent.click(screen.getByTestId("template-delete-journal")); + await waitFor(() => screen.getByTestId("confirm-ok")); + fireEvent.click(screen.getByTestId("confirm-ok")); + await waitFor(() => + expect(screen.getByTestId("settings-msg").textContent).toContain("referenced by 2 tasks"), + ); + }); }); diff --git a/emrg/gui/renderer/src/components/SettingsPanel.tsx b/emrg/gui/renderer/src/components/SettingsPanel.tsx index e7ffcdf4..5aaaad3c 100644 --- a/emrg/gui/renderer/src/components/SettingsPanel.tsx +++ b/emrg/gui/renderer/src/components/SettingsPanel.tsx @@ -1,5 +1,7 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useReducer, useRef, useState } from "react"; import { useI18n, setLocale } from "../lib/i18n"; +import { dialogReducer, initialDialogState } from "../lib/dialog"; +import { ConfirmDialog } from "./ConfirmDialog"; /** * SettingsPanel — Batch 5 slice 6:设置面板接线(vanilla dialogs.js showSettings/ @@ -13,7 +15,7 @@ import { useI18n, setLocale } from "../lib/i18n"; * 默认模型 + [[llm.models]] 增删改设默认(vanilla renderModelList/saveModelForm 对等)。 * - 主题三选(vanilla applyTheme 对等:data-theme attr);语言选择(i18n setLocale)。 */ -export type SettingsTab = "model" | "github" | "appearance" | "language" | "about"; +export type SettingsTab = "model" | "github" | "appearance" | "language" | "about" | "templates"; export interface SettingsPanelProps { /** 版本号(Shell appState.currentVersion 注入) */ @@ -28,6 +30,14 @@ interface ModelRec { vision: boolean; } +/** 自定义任务类型(daemon task_template_* 对等:name 为定位键,builtin 只读) */ +interface TemplateRec { + name: string; + prompt?: string; + template?: string; + builtin?: boolean; +} + interface GetSettingsResult { apiKey?: string; baseUrl?: string; @@ -43,6 +53,10 @@ interface EmrgBridge { githubStatus(): Promise<{ authenticated?: boolean; user?: string | null }>; githubConnect(p: { token: string }): Promise<{ ok?: boolean; user?: string; error?: string }>; githubDisconnect(): Promise; + taskTemplateList(): Promise; + taskTemplateCreate(p: { name: string; prompt: string }): Promise; + taskTemplateUpdate(p: { name: string; prompt: string }): Promise; + taskTemplateDelete(p: { name: string }): Promise; } function bridge(): EmrgBridge | undefined { @@ -55,6 +69,7 @@ const TABS: { id: SettingsTab; labelKey: string }[] = [ { id: "appearance", labelKey: "settings.groupAppearance" }, { id: "language", labelKey: "settings.groupLanguage" }, { id: "about", labelKey: "settings.aboutTitle" }, + { id: "templates", labelKey: "settings.taskTemplates" }, ]; export function SettingsPanel({ version = "", evolutionCount = null }: SettingsPanelProps) { @@ -78,6 +93,15 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP const [formId, setFormId] = useState(""); const [formVision, setFormVision] = useState(false); const nameRef = useRef(null); + // templates tab 状态(vanilla renderTemplateList/openTemplateForm/saveTemplateForm) + const [templates, setTemplates] = useState(null); + const [tplFormOpen, setTplFormOpen] = useState(false); + const [editingTpl, setEditingTpl] = useState(null); + const [tplBuiltinView, setTplBuiltinView] = useState(false); + const [tplName, setTplName] = useState(""); + const [tplPrompt, setTplPrompt] = useState(""); + // 删除确认(lib/dialog reducer + ConfirmDialog,vanilla showConfirm 对等) + const [dialogState, dispatch] = useReducer(dialogReducer, initialDialogState); // 打开 settings 面板 → 加载设置(vanilla showSettings) useEffect(() => { @@ -103,6 +127,12 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // templates tab 激活 → 加载类型列表(vanilla initTaskManagement 每次打开刷新) + useEffect(() => { + if (tab === "templates") void loadTemplates(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tab]); + async function refreshGithubStatus() { const b = bridge(); if (!b?.githubStatus) return; @@ -253,6 +283,79 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP } } + // ── templates tab(自定义任务类型管理,vanilla dialogs.js 语义) ── + async function loadTemplates() { + const b = bridge(); + if (!b?.taskTemplateList) return; + try { + setTemplates(await b.taskTemplateList()); + } catch { + setTemplates([]); // 列表加载失败 → 空态(vanilla 同款 templateEmpty 兜底) + } + } + + function openTplForm(tpl: TemplateRec | null) { + setEditingTpl(tpl ? tpl.name : null); + setTplBuiltinView(Boolean(tpl && tpl.builtin)); // 内置只读查看(隐藏保存) + setTplName(tpl ? tpl.name : ""); + setTplPrompt(tpl ? tpl.prompt || tpl.template || "" : ""); + setTplFormOpen(true); + } + + function closeTplForm() { + setTplFormOpen(false); + setEditingTpl(null); + setTplBuiltinView(false); + setTplName(""); + setTplPrompt(""); + } + + async function saveTplForm() { + const name = tplName.trim(); + const prompt = tplPrompt.trim(); + if (!name || !prompt) { + setMsg({ kind: "err", text: t("settings.templateInvalid") }); + return; + } + const b = bridge(); + if (!b?.taskTemplateCreate || !b?.taskTemplateUpdate) return; + try { + if (editingTpl === null) await b.taskTemplateCreate({ name, prompt }); + else await b.taskTemplateUpdate({ name, prompt }); + setMsg({ kind: "ok", text: t("settings.templateSaved") }); + closeTplForm(); + await loadTemplates(); + } catch (e: unknown) { + setMsg({ kind: "err", text: t("settings.templateSaveFailed", { msg: (e as Error).message }) }); + } + } + + function requestDeleteTpl(tpl: TemplateRec) { + dispatch({ + type: "open-confirm", + payload: { + title: t("settings.templateDelete"), + message: t("settings.templateDeleteConfirm", { name: tpl.name }), + okText: t("settings.templateDelete"), + danger: true, + onOk: () => doDeleteTpl(tpl.name), + }, + }); + } + + async function doDeleteTpl(name: string) { + const b = bridge(); + if (!b?.taskTemplateDelete) return; + try { + await b.taskTemplateDelete({ name }); + setMsg({ kind: "ok", text: t("settings.templateDeleted") }); + await loadTemplates(); + } catch (e: unknown) { + // 决策点②:被任务引用的类型 daemon 拒绝删除(错误信息含任务数) + setMsg({ kind: "err", text: t("settings.templateDeleteFailed", { msg: (e as Error).message }) }); + } + } + // ── 渲染 ───────────────────────────────── const seen = new Set([defaultName]); @@ -525,12 +628,105 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP )} + {/* ── 自定义类型(task templates,vanilla renderTemplateList 对等) ── */} + {tab === "templates" && ( +
+
+
{t("settings.taskTemplates")}
+
+ {!templates || templates.length === 0 ? ( +
{t("settings.templateEmpty")}
+ ) : ( + templates.map((tpl) => { + const body = tpl.prompt || tpl.template || ""; + return ( +
+ {tpl.name} + {tpl.builtin ? t("settings.taskBuiltin") : t("settings.taskCustom")} + {body.length > 60 ? `${body.slice(0, 60)}…` : body} + + + {!tpl.builtin && ( + <> + + + + )} + +
+ ); + }) + )} +
+ + {tplFormOpen && ( +
+
+ +
+