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, /
\{\{ t\('side\.prompts'\) \}\}<\/div>/); + assert.doesNotMatch(html, / -
- - + +
@@ -83,6 +84,68 @@
+
+
+ + +
+
+ + +
+
+
+ + {{ t('prompts.presets.title') }} + {{ t('prompts.presets.hint') }} + +
+
+
{{ t('prompts.presets.empty') }}
+
+
+
+ +
{{ t('prompts.presets.updatedAt', { time: formatPromptPresetTime(preset.updatedAt) }) }}
+
{{ preset.content }}
+
+
+ + + +
+
+
+
+
+
+
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) {