diff --git a/README.md b/README.md
index 3f9e8bf4..d9a1ce92 100644
--- a/README.md
+++ b/README.md
@@ -60,7 +60,7 @@ Unlike simple wrappers, Codex Mate acts as a **Local Agent Bridge**:
- **OpenCode Provider Control**: Manage OpenCode provider/model selection with a CodexMate-owned provider store under `~/.codexmate`, projecting only the active provider into native OpenCode config to avoid polluting or deleting user-owned settings.
- **Provider Health Cleanup**: Probe local Codex and Claude provider routes, surface failed configs in one modal, and bulk-clean selected broken providers without touching healthy or protected entries.
- **Skills Marketplace**: A local-first market to share and import skills between different agent apps.
-- **Prompt File Editor**: Unified editor for global and project-level `CLAUDE.md` and `AGENTS.md` with auto-detection of project paths.
+- **Prompt File Editor**: Unified editor for global and project-level `CLAUDE.md` and `AGENTS.md` with auto-detection of project paths, plus a shared preset pool for reusable prompts.
- **Task Orchestrator**: Plan and execute complex tasks with dependency tracking.
---
@@ -80,7 +80,7 @@ Unlike simple wrappers, Codex Mate acts as a **Local Agent Bridge**:
| **OpenCode Provider Store** | ✅ | Keep multiple OpenCode providers in `~/.codexmate` while projecting only the selected provider to native OpenCode config |
| **Provider Health Check** | ✅ | Probe local Codex/Claude provider routes, highlight failed configs, and bulk-remove selected broken providers safely |
| **Prompt Templates** | ✅ | Reusable prompt plugins with variables |
-| **Prompt File Editor** | ✅ | Edit global and project-level CLAUDE.md / AGENTS.md with auto-detect and path switching |
+| **Prompt File Editor** | ✅ | Edit global and project-level CLAUDE.md / AGENTS.md with auto-detect, path switching, and a shared preset pool. Applying a preset only updates the editor; save manually to write the file. |
| **MCP Integration** | ✅ | Expose local tools and resources via MCP stdio |
| **Auto Update** | ✅ | Quick update CLI via `codexmate update` |
diff --git a/README.vi.md b/README.vi.md
index ddfbf777..3ea59828 100644
--- a/README.vi.md
+++ b/README.vi.md
@@ -67,6 +67,7 @@ Khác với các wrapper đơn giản, Codex Mate hoạt động như một **Ag
| **Bridge OpenAI** | ✅ | Chuyển đổi Codex Responses API sang định dạng OpenAI chuẩn |
| **Kiểm tra sức khỏe Provider** | ✅ | Kiểm tra route provider Codex/Claude, đánh dấu cấu hình lỗi và xóa hàng loạt các provider hỏng đã chọn một cách an toàn |
| **Mẫu Prompt** | ✅ | Plugin prompt tái sử dụng được với hỗ trợ biến |
+| **Trình sửa tệp prompt** | ✅ | Chỉnh sửa CLAUDE.md / AGENTS.md và dùng kho preset chung. Áp dụng preset chỉ cập nhật editor; cần lưu thủ công để ghi vào tệp. |
| **Tích hợp MCP** | ✅ | Expose tool và resource cục bộ qua MCP stdio |
| **Tự động cập nhật** | ✅ | Cập nhật nhanh qua `codexmate update` |
diff --git a/README.zh.md b/README.zh.md
index f692b554..d5a1b634 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -59,7 +59,7 @@
- **OpenCode Provider 控制**:在 `~/.codexmate` 下维护 CodexMate 自有的 OpenCode 多 provider 存储,只将当前选中的 provider 投影到 OpenCode 原生配置,避免污染或误删用户已有配置。
- **Provider 健康清理**:探测本地 Codex 与 Claude provider 路由,在同一个弹窗里集中展示失败配置,并批量清理已选坏配置,不影响正常或受保护项。
- **Skills 市场**:本地优先的市场,支持在不同的智能体应用之间共享和导入 Skills。
-- **提示词文件编辑器**:统一编辑全局和项目级 `CLAUDE.md` 与 `AGENTS.md`,支持项目路径自动检测。
+- **提示词文件编辑器**:统一编辑全局和项目级 `CLAUDE.md` 与 `AGENTS.md`,支持项目路径自动检测,并提供通用预设池复用 prompt。
- **任务编排器**:支持带有依赖跟踪的复杂任务规划与执行。
---
@@ -79,7 +79,7 @@
| **OpenCode Provider 存储** | ✅ | 在 `~/.codexmate` 中保留多个 OpenCode provider,只将当前选中的 provider 投影到 OpenCode 原生配置 |
| **Provider 健康检查** | ✅ | 探测本地 Codex/Claude provider 路由,标记失败配置,并安全批量移除已选坏配置 |
| **提示词模板** | ✅ | 支持变量的可复用提示词插件 |
-| **提示词文件编辑器** | ✅ | 编辑全局和项目级 CLAUDE.md / AGENTS.md,支持自动检测与路径切换 |
+| **提示词文件编辑器** | ✅ | 编辑全局和项目级 CLAUDE.md / AGENTS.md,支持自动检测、路径切换与通用预设池。应用预设只更新编辑器,需手动保存才会写入文件。 |
| **MCP 集成** | ✅ | 通过 MCP stdio 暴露本地工具与资源 |
| **自动更新** | ✅ | 通过 `codexmate update` 快速更新 CLI |
diff --git a/cli/openai-bridge.js b/cli/openai-bridge.js
index c82cdd9c..f56e3101 100644
--- a/cli/openai-bridge.js
+++ b/cli/openai-bridge.js
@@ -425,54 +425,14 @@ function createOpenaiBridgeHttpHandler(options = {}) {
const acceptHeader = req && req.headers ? (req.headers.accept || req.headers.Accept || '') : '';
const wantsSse = /text\/event-stream/i.test(String(acceptHeader || ''));
- if (streamRequested && wantsSse) {
- const upstreamResponsesUrl = joinApiUrl(upstream.baseUrl, 'responses');
- const skipResponsesProbe = isResponsesKnownUnsupported(upstream.baseUrl);
- const streamedResponses = skipResponsesProbe
- ? { ok: false, status: 404, bodyText: '' }
- : await retryTransientRequest(() => streamResponsesSse(upstreamResponsesUrl, {
- method: 'POST',
- body: normalizeResponsesPayloadForUpstream(responsesRequest, true),
- headers: codexHeaders,
- timeoutMs: streamTimeoutMs,
- maxBytes: maxUpstreamBytes,
- httpAgent,
- httpsAgent,
- res
- }), { maxRetries: upstream.maxRetries });
-
- if (streamedResponses.ok) {
- clearResponsesUnsupported(upstream.baseUrl);
- return;
- }
-
- const canFallbackToChat = streamedResponses.status
- ? shouldFallbackFromUpstreamResponses(streamedResponses.status, streamedResponses.bodyText)
- : false;
- if (!canFallbackToChat) {
- if (res.writableEnded || res.destroyed) return;
- const status = streamedResponses.status && streamedResponses.status >= 400 ? streamedResponses.status : 502;
- if (!res.headersSent) {
- res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
- res.end(streamedResponses.bodyText || JSON.stringify({ error: streamedResponses.error || 'Upstream request failed' }));
- } else {
- writeSse(res, 'response.failed', { type: 'response.failed', error: streamedResponses.error || streamedResponses.bodyText || 'Upstream request failed' });
- writeSse(res, 'done', '[DONE]');
- res.end();
- }
- return;
- }
-
- if (!skipResponsesProbe && isResponsesEndpointUnsupported(streamedResponses.status, streamedResponses.bodyText)) {
- markResponsesUnsupported(upstream.baseUrl);
- }
+ const converted = convertResponsesRequestToChatCompletions(responsesRequest);
+ if (converted.error) {
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
+ res.end(JSON.stringify({ error: converted.error }));
+ return;
+ }
- const converted = convertResponsesRequestToChatCompletions(responsesRequest);
- if (converted.error) {
- res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
- res.end(JSON.stringify({ error: converted.error }));
- return;
- }
+ if (streamRequested && wantsSse) {
const upstreamUrl = joinApiUrl(upstream.baseUrl, 'chat/completions');
const chatBody = { ...converted.chat, stream: true };
const streamed = await retryTransientRequest(() => streamChatCompletionsAsResponsesSse(upstreamUrl, {
@@ -503,74 +463,6 @@ function createOpenaiBridgeHttpHandler(options = {}) {
return;
}
- // Maxx-style behavior: prefer upstream /responses if supported.
- // Fallback to /chat/completions conversion when upstream does not implement /responses (404/405).
- // 已知不支持的上游:直接跳过探测,节省一次 round-trip。
- const skipResponsesProbe = isResponsesKnownUnsupported(upstream.baseUrl);
- const upstreamResponsesUrl = joinApiUrl(upstream.baseUrl, 'responses');
- const upstreamResponsesResult = skipResponsesProbe
- ? { ok: true, status: 404, bodyText: '' }
- : await retryTransientRequest(() => proxyRequestJson(upstreamResponsesUrl, {
- method: 'POST',
- body: toUpstreamNonStreamingResponsesPayload(responsesRequest),
- headers: codexHeaders,
- maxBytes: maxUpstreamBytes,
- httpAgent,
- httpsAgent
- }), { maxRetries: upstream.maxRetries });
-
- if (upstreamResponsesResult.ok && upstreamResponsesResult.status >= 200 && upstreamResponsesResult.status < 300) {
- clearResponsesUnsupported(upstream.baseUrl);
- const upstreamJson = parseJsonOrError(upstreamResponsesResult.bodyText);
- if (upstreamJson.error) {
- res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
- res.end(JSON.stringify({ error: `Upstream JSON parse failed: ${upstreamJson.error}` }));
- return;
- }
- const upstreamPayload = upstreamJson.value;
- if (streamRequested && wantsSse) {
- res.writeHead(200, {
- 'Content-Type': 'text/event-stream; charset=utf-8',
- 'Cache-Control': 'no-cache',
- 'Connection': 'keep-alive',
- 'X-Accel-Buffering': 'no'
- });
- if (typeof res.flushHeaders === 'function') res.flushHeaders();
- sendResponsesSse(res, upstreamPayload);
- res.end();
- return;
- }
-
- res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
- res.end(JSON.stringify(ensureResponseMetadata(upstreamPayload)));
- return;
- }
-
- if (upstreamResponsesResult.ok && upstreamResponsesResult.status >= 400) {
- if (!shouldFallbackFromUpstreamResponses(upstreamResponsesResult.status, upstreamResponsesResult.bodyText)) {
- res.writeHead(upstreamResponsesResult.status, { 'Content-Type': 'application/json; charset=utf-8' });
- res.end(upstreamResponsesResult.bodyText || JSON.stringify({ error: 'Upstream error' }));
- return;
- }
- if (!skipResponsesProbe && isResponsesEndpointUnsupported(upstreamResponsesResult.status, upstreamResponsesResult.bodyText)) {
- markResponsesUnsupported(upstream.baseUrl);
- }
- // fallthrough to chat/completions conversion
- }
-
- if (!upstreamResponsesResult.ok) {
- res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
- res.end(JSON.stringify({ error: `Upstream request failed: ${upstreamResponsesResult.error}` }));
- return;
- }
-
- const converted = convertResponsesRequestToChatCompletions(responsesRequest);
- if (converted.error) {
- res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
- res.end(JSON.stringify({ error: converted.error }));
- return;
- }
-
const upstreamUrl = joinApiUrl(upstream.baseUrl, 'chat/completions');
const upstreamResult = await retryTransientRequest(() => proxyRequestJson(upstreamUrl, {
method: 'POST',
diff --git a/package-lock.json b/package-lock.json
index a6bbe5a6..aac2688a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "codexmate",
- "version": "0.1.0",
+ "version": "0.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codexmate",
- "version": "0.1.0",
+ "version": "0.1.1",
"license": "Apache-2.0",
"dependencies": {
"@iarna/toml": "^2.2.5",
diff --git a/package.json b/package.json
index 38cf12e4..1cc10ada 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "codexmate",
- "version": "0.1.0",
+ "version": "0.1.1",
"description": "Codex/Claude Code/OpenClaw 配置、会话与任务编排 CLI + Web 工具",
"main": "cli.js",
"bin": {
diff --git a/tests/unit/config-tabs-ui.test.mjs b/tests/unit/config-tabs-ui.test.mjs
index 3b6294ab..a312d9b7 100644
--- a/tests/unit/config-tabs-ui.test.mjs
+++ b/tests/unit/config-tabs-ui.test.mjs
@@ -83,6 +83,29 @@ test('config template keeps expected config tabs in top and side navigation', ()
assert.match(webUiRedirectBlock, /url\.pathname\s*=\s*'\/'/);
assert.doesNotMatch(webUiRedirectBlock, /url\.search\s*=/);
assert.doesNotMatch(webUiRedirectBlock, /url\.hash\s*=/);
+ assert.match(html, /id="side-tab-prompts"/);
+ assert.doesNotMatch(html, /
diff --git a/web-ui/res/web-ui-render.precompiled.js b/web-ui/res/web-ui-render.precompiled.js
index 8716d5f0..7e2e5d95 100644
--- a/web-ui/res/web-ui-render.precompiled.js
+++ b/web-ui/res/web-ui-render.precompiled.js
@@ -362,48 +362,22 @@ return function render(_ctx, _cache) {
? (_openBlock(), _createElementBlock("span", { key: 0 }, _toDisplayString(_ctx.t('common.current', { value: _ctx.opencodeModel })), 1 /* TEXT */))
: _createCommentVNode("v-if", true)
])
- ], 42 /* CLASS, PROPS, NEED_HYDRATION */, ["aria-current", "onPointerdown", "onClick"])
- ], 8 /* PROPS */, ["aria-label"]),
- _createElementVNode("div", {
- class: "side-section",
- role: "navigation",
- "aria-label": _ctx.t('side.prompts')
- }, [
- _createElementVNode("div", { class: "side-section-title" }, _toDisplayString(_ctx.t('side.prompts')), 1 /* TEXT */),
- _createElementVNode("button", {
- id: "side-tab-prompts-agents",
- "data-main-tab": "prompts",
- "data-prompts-sub-tab": "codex",
- "aria-current": _ctx.mainTab === 'prompts' && _ctx.promptsSubTab === 'codex' ? 'page' : null,
- class: _normalizeClass(['side-item', { active: _ctx.isMainTabNavActive('prompts') && _ctx.promptsSubTab === 'codex' }]),
- onPointerdown: $event => (_ctx.onMainTabPointerDown('prompts', $event)),
- onClick: $event => {_ctx.switchPromptsSubTab('codex'); _ctx.onMainTabClick('prompts')}
- }, [
- _createElementVNode("span", {
- class: "side-item-icon",
- "aria-hidden": "true"
- }, "P"),
- _createElementVNode("div", { class: "side-item-title" }, _toDisplayString(_ctx.t('side.prompts.agents')), 1 /* TEXT */),
- _createElementVNode("div", { class: "side-item-meta" }, [
- _createElementVNode("span", null, _toDisplayString(_ctx.t('side.prompts.agents.meta')), 1 /* TEXT */)
- ])
], 42 /* CLASS, PROPS, NEED_HYDRATION */, ["aria-current", "onPointerdown", "onClick"]),
_createElementVNode("button", {
- id: "side-tab-prompts-project",
+ id: "side-tab-prompts",
"data-main-tab": "prompts",
- "data-prompts-sub-tab": "claude-project",
- "aria-current": _ctx.mainTab === 'prompts' && _ctx.promptsSubTab === 'claude-project' ? 'page' : null,
- class: _normalizeClass(['side-item', { active: _ctx.isMainTabNavActive('prompts') && _ctx.promptsSubTab === 'claude-project' }]),
+ "aria-current": _ctx.mainTab === 'prompts' ? 'page' : null,
+ class: _normalizeClass(['side-item', { active: _ctx.isMainTabNavActive('prompts') }]),
onPointerdown: $event => (_ctx.onMainTabPointerDown('prompts', $event)),
- onClick: $event => {_ctx.switchPromptsSubTab('claude-project'); _ctx.onMainTabClick('prompts')}
+ onClick: $event => (_ctx.onMainTabClick('prompts', $event))
}, [
_createElementVNode("span", {
class: "side-item-icon",
"aria-hidden": "true"
- }, "M"),
- _createElementVNode("div", { class: "side-item-title" }, _toDisplayString(_ctx.t('side.prompts.claude')), 1 /* TEXT */),
+ }, "P"),
+ _createElementVNode("div", { class: "side-item-title" }, _toDisplayString(_ctx.t('side.prompts')), 1 /* TEXT */),
_createElementVNode("div", { class: "side-item-meta" }, [
- _createElementVNode("span", null, _toDisplayString(_ctx.t('side.prompts.claude.meta')), 1 /* TEXT */)
+ _createElementVNode("span", null, _toDisplayString(_ctx.t('side.prompts.meta')), 1 /* TEXT */)
])
], 42 /* CLASS, PROPS, NEED_HYDRATION */, ["aria-current", "onPointerdown", "onClick"])
], 8 /* PROPS */, ["aria-label"]),
@@ -6857,6 +6831,100 @@ return function render(_ctx, _cache) {
])
])
]),
+ _createElementVNode("div", { class: "prompt-presets-inline-row" }, [
+ _createElementVNode("div", { class: "prompt-presets-inline-group" }, [
+ _createElementVNode("label", { class: "prompt-presets-inline-label" }, _toDisplayString(_ctx.t('prompts.presets.title')), 1 /* TEXT */),
+ _createElementVNode("select", {
+ class: "form-input prompt-presets-select",
+ disabled: _ctx.agentsLoading || _ctx.agentsSaving || _ctx.agentsDiffVisible || !_ctx.promptPresets.length,
+ title: _ctx.t('prompts.presets.title'),
+ onChange: $event => (_ctx.applyPromptPresetSelection($event))
+ }, [
+ _createElementVNode("option", { value: "" }, _toDisplayString(_ctx.t('prompts.presets.selectPlaceholder')), 1 /* TEXT */),
+ (_openBlock(true), _createElementBlock(_Fragment, null, _renderList(_ctx.promptPresets, (preset) => {
+ return (_openBlock(), _createElementBlock("option", {
+ key: preset.id,
+ value: preset.id
+ }, _toDisplayString(preset.name), 9 /* TEXT, PROPS */, ["value"]))
+ }), 128 /* KEYED_FRAGMENT */))
+ ], 40 /* PROPS, NEED_HYDRATION */, ["disabled", "title", "onChange"])
+ ]),
+ _createElementVNode("div", { class: "prompt-presets-inline-group prompt-presets-inline-group--save" }, [
+ _withDirectives(_createElementVNode("input", {
+ type: "text",
+ class: "form-input prompt-presets-name-input",
+ "onUpdate:modelValue": $event => ((_ctx.promptPresetNameDraft) = $event),
+ placeholder: _ctx.t('prompts.presets.namePlaceholder'),
+ disabled: _ctx.agentsLoading || _ctx.agentsSaving || _ctx.agentsDiffVisible,
+ onKeydown: _withKeys(_withModifiers(_ctx.saveCurrentPromptAsPreset, ["prevent"]), ["enter"])
+ }, null, 40 /* PROPS, NEED_HYDRATION */, ["onUpdate:modelValue", "placeholder", "disabled", "onKeydown"]), [
+ [_vModelText, _ctx.promptPresetNameDraft]
+ ]),
+ _createElementVNode("button", {
+ type: "button",
+ class: "btn-mini btn-confirm-mini",
+ onClick: _ctx.saveCurrentPromptAsPreset,
+ disabled: _ctx.promptPresetSaving || _ctx.agentsLoading || _ctx.agentsSaving || _ctx.agentsDiffVisible,
+ title: _ctx.t('prompts.presets.saveCurrent')
+ }, _toDisplayString(_ctx.t('prompts.presets.saveCurrent')), 9 /* TEXT, PROPS */, ["onClick", "disabled", "title"])
+ ])
+ ]),
+ _createElementVNode("details", { class: "prompt-presets-panel" }, [
+ _createElementVNode("summary", { class: "prompt-presets-summary" }, [
+ _createElementVNode("span", null, _toDisplayString(_ctx.t('prompts.presets.title')), 1 /* TEXT */),
+ _createElementVNode("small", null, _toDisplayString(_ctx.t('prompts.presets.hint')), 1 /* TEXT */)
+ ]),
+ _createElementVNode("div", { class: "form-group prompt-presets-body" }, [
+ _createElementVNode("div", { class: "editor-frame prompt-presets-frame" }, [
+ (!_ctx.promptPresets.length)
+ ? (_openBlock(), _createElementBlock("div", {
+ key: 0,
+ class: "state-message prompt-presets-empty"
+ }, _toDisplayString(_ctx.t('prompts.presets.empty')), 1 /* TEXT */))
+ : (_openBlock(), _createElementBlock("div", {
+ key: 1,
+ class: "prompt-presets-list"
+ }, [
+ (_openBlock(true), _createElementBlock(_Fragment, null, _renderList(_ctx.promptPresets, (preset) => {
+ return (_openBlock(), _createElementBlock("article", {
+ key: preset.id,
+ class: "prompt-preset-row"
+ }, [
+ _createElementVNode("div", { class: "prompt-preset-main" }, [
+ _createElementVNode("input", {
+ type: "text",
+ class: "form-input prompt-preset-name",
+ value: _ctx.getPromptPresetRenameDraft(preset),
+ onInput: $event => (_ctx.setPromptPresetRenameDraft(preset.id, $event.target.value)),
+ onKeydown: _withKeys(_withModifiers($event => (_ctx.renamePromptPreset(preset)), ["prevent"]), ["enter"]),
+ "aria-label": _ctx.t('prompts.presets.renameAria')
+ }, null, 40 /* PROPS, NEED_HYDRATION */, ["value", "onInput", "onKeydown", "aria-label"]),
+ _createElementVNode("div", { class: "form-hint" }, _toDisplayString(_ctx.t('prompts.presets.updatedAt', { time: _ctx.formatPromptPresetTime(preset.updatedAt) })), 1 /* TEXT */),
+ _createElementVNode("pre", { class: "prompt-preset-preview" }, _toDisplayString(preset.content), 1 /* TEXT */)
+ ]),
+ _createElementVNode("div", { class: "prompts-editor-actions prompt-preset-actions" }, [
+ _createElementVNode("button", {
+ type: "button",
+ class: "btn-mini",
+ onClick: $event => (_ctx.renamePromptPreset(preset))
+ }, _toDisplayString(_ctx.t('common.save')), 9 /* TEXT, PROPS */, ["onClick"]),
+ _createElementVNode("button", {
+ type: "button",
+ class: "btn-mini",
+ onClick: $event => (_ctx.applyPromptPresetToEditor(preset))
+ }, _toDisplayString(_ctx.t('common.paste')), 9 /* TEXT, PROPS */, ["onClick"]),
+ _createElementVNode("button", {
+ type: "button",
+ class: "btn-mini btn-danger-mini",
+ onClick: $event => (_ctx.deletePromptPreset(preset))
+ }, _toDisplayString(_ctx.t('common.delete')), 9 /* TEXT, PROPS */, ["onClick"])
+ ])
+ ]))
+ }), 128 /* KEYED_FRAGMENT */))
+ ]))
+ ])
+ ])
+ ]),
_createElementVNode("div", { class: "form-group" }, [
(_ctx.agentsDiffVisible)
? (_openBlock(), _createElementBlock("div", { key: 0 }, [
diff --git a/web-ui/styles/modals-core.css b/web-ui/styles/modals-core.css
index dcb14712..29820cd3 100644
--- a/web-ui/styles/modals-core.css
+++ b/web-ui/styles/modals-core.css
@@ -725,3 +725,144 @@
background: transparent;
box-shadow: none;
}
+
+.prompt-presets-inline-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin: 8px 0 12px;
+ padding: 10px 12px;
+ border: 1px solid var(--color-border-soft);
+ border-radius: var(--radius-md);
+ background: var(--color-bg-card);
+}
+
+.prompt-presets-inline-group {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+}
+
+.prompt-presets-inline-group--save {
+ flex: 1;
+ justify-content: flex-end;
+}
+
+.prompt-presets-inline-label {
+ flex-shrink: 0;
+ color: var(--color-text-secondary);
+ font-size: var(--font-size-small);
+}
+
+.prompt-presets-select {
+ min-width: 180px;
+}
+
+.prompt-presets-panel {
+ margin: 12px 0;
+ border: 1px solid var(--color-border-soft);
+ border-radius: var(--radius-md);
+ background: var(--color-bg-card);
+}
+
+.prompt-presets-summary {
+ display: flex;
+ align-items: baseline;
+ gap: 10px;
+ padding: 10px 12px;
+ cursor: pointer;
+ color: var(--color-text-primary);
+ font-weight: var(--font-weight-primary);
+}
+
+.prompt-presets-summary small {
+ color: var(--color-text-secondary);
+ font-weight: 400;
+}
+
+.prompt-presets-toolbar {
+ padding: 0 12px 10px;
+}
+
+.prompt-presets-save-row {
+ gap: 8px;
+}
+
+.prompt-presets-name-input {
+ width: min(340px, 34vw);
+}
+
+.prompt-presets-body {
+ min-height: 0;
+ margin: 0;
+ padding: 0 12px 12px;
+}
+
+.prompt-presets-frame {
+ max-height: 320px;
+ min-height: 0;
+ overflow: auto;
+ background: var(--color-bg-card);
+}
+
+.prompt-presets-empty {
+ margin: 0;
+ min-height: 120px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: none;
+ background: transparent;
+}
+
+.prompt-presets-list {
+ display: flex;
+ flex-direction: column;
+}
+
+.prompt-preset-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 12px;
+ padding: 12px;
+ border-bottom: 1px solid var(--color-border-soft);
+ background: transparent;
+}
+
+.prompt-preset-row:last-child {
+ border-bottom: none;
+}
+
+.prompt-preset-main {
+ min-width: 0;
+}
+
+.prompt-preset-name {
+ font-weight: var(--font-weight-primary);
+}
+
+.prompt-preset-preview {
+ max-height: 180px;
+ margin: 8px 0 0;
+ padding: 10px;
+ overflow: auto;
+ white-space: pre-wrap;
+ word-break: break-word;
+ border-radius: var(--radius-sm);
+ background: var(--color-bg-subtle);
+ color: var(--color-text-secondary);
+ font-family: var(--font-family-mono);
+ font-size: 11px;
+}
+
+.prompt-preset-actions {
+ align-self: start;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ max-width: 260px;
+}
+
+.btn-danger-mini {
+ color: var(--color-danger);
+}
diff --git a/web-ui/styles/responsive.css b/web-ui/styles/responsive.css
index 16d891df..e744a403 100644
--- a/web-ui/styles/responsive.css
+++ b/web-ui/styles/responsive.css
@@ -199,6 +199,22 @@ textarea:focus-visible {
flex-wrap: wrap;
}
+ .prompt-presets-inline-row {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .prompt-presets-inline-group,
+ .prompt-presets-inline-group--save {
+ width: 100%;
+ justify-content: flex-start;
+ }
+
+ .prompt-presets-select,
+ .prompt-presets-name-input {
+ width: 100%;
+ }
+
.template-editor,
.agents-diff-editor {
min-height: min(50vh, 400px);
@@ -604,6 +620,22 @@ textarea:focus-visible {
width: 100%;
flex-wrap: wrap;
}
+
+ .prompt-presets-inline-row {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .prompt-presets-inline-group,
+ .prompt-presets-inline-group--save {
+ width: 100%;
+ justify-content: flex-start;
+ }
+
+ .prompt-presets-select,
+ .prompt-presets-name-input {
+ width: 100%;
+ }
}
@media (max-width: 820px) {