diff options
| -rw-r--r-- | .rules/changelog/2026-03/24/10.md | 51 | ||||
| -rw-r--r-- | src/chat-view.ts | 56 | ||||
| -rw-r--r-- | src/ollama-client.ts | 127 | ||||
| -rw-r--r-- | src/settings-modal.ts | 123 | ||||
| -rw-r--r-- | src/settings.ts | 6 | ||||
| -rw-r--r-- | src/tools.ts | 70 | ||||
| -rw-r--r-- | styles.css | 133 |
7 files changed, 558 insertions, 8 deletions
diff --git a/.rules/changelog/2026-03/24/10.md b/.rules/changelog/2026-03/24/10.md new file mode 100644 index 0000000..81d22b7 --- /dev/null +++ b/.rules/changelog/2026-03/24/10.md @@ -0,0 +1,51 @@ +# Changelog — 2026-03-24 #10 + +## Mobile "Load failed" Fix (`src/ollama-client.ts`) + +- Imported `Platform` from Obsidian for runtime mobile detection. +- Split `sendChatMessageStreaming` into a dispatcher + mobile/desktop implementations: + - Mobile: uses `requestUrl()` (non-streaming) to bypass WebView sandbox. + - Desktop: keeps native `fetch()` for real token-by-token streaming. +- Enhanced error messages with mobile-specific hints. +- Added `"load failed"` to caught network error patterns in `testConnection`. + +## UI: DaisyUI-inspired Collapse (`src/chat-view.ts`, `styles.css`) + +- Replaced native `<details>/<summary>` with a checkbox-driven CSS grid collapse. +- Uses `grid-template-rows: 0fr → 1fr` transition with a rotating arrow indicator. +- Tightened padding and margins around the collapse for compact layout. + +## UI: FAB / Speed Dial (`src/chat-view.ts`, `styles.css`) + +- Replaced inline settings/tools buttons with a FAB in the top-right of the messages area. +- Main trigger: gear icon, rotates 90° on open. +- Three actions fan downward with staggered animations: + - **AI Settings** (sliders icon) — opens the settings modal. + - **Tools** (wrench icon) — opens the tools modal. + - **Clear Chat** (trash icon) — clears message history and UI. +- Removed old inline button styles and tools-active coloring. + +## UI: Text Selection (`styles.css`) + +- Enabled `user-select: text` on messages and tool call bubbles. + +## UI: Settings Modal Rename (`src/settings-modal.ts`) + +- Changed modal title to "AI Settings". + +## Generation Parameters (`src/settings.ts`, `src/settings-modal.ts`, `src/ollama-client.ts`, `src/chat-view.ts`) + +- Added `temperature` (default 0.7), `numCtx` (default 4096), `numPredict` (default -1) to settings. +- Added `ModelOptions` interface and `options` field to `StreamingChatOptions`. +- Options are now passed to Ollama in the request body for all chat paths. +- Settings modal shows: + - **Temperature** — slider 0–2 with live value display. + - **Context Window** — number input with model max shown below. + - **Max Output Tokens** — number input (-1 = unlimited). +- Added `showModel()` function querying `/api/show` to extract the model's context length. +- Model max label turns red when context window exceeds model limit. +- Clicking the model max label sets context window to the model's max. + +## Disabled GitHub Action (`.github/workflows/lint.yml`) + +- Commented out push/PR triggers; added `workflow_dispatch` for manual runs. diff --git a/src/chat-view.ts b/src/chat-view.ts index 524c731..3355689 100644 --- a/src/chat-view.ts +++ b/src/chat-view.ts @@ -1,6 +1,6 @@ import { ItemView, Notice, WorkspaceLeaf, setIcon } from "obsidian"; import type AIOrganizer from "./main"; -import type { ChatMessage, ToolCallEvent } from "./ollama-client"; +import type { ChatMessage, ToolCallEvent, ApprovalRequestEvent } from "./ollama-client"; import { sendChatMessageStreaming } from "./ollama-client"; import { SettingsModal } from "./settings-modal"; import { ToolModal } from "./tool-modal"; @@ -204,6 +204,10 @@ export class ChatView extends ItemView { this.scrollToBottom(); }; + const onApprovalRequest = (event: ApprovalRequestEvent): Promise<boolean> => { + return this.showApprovalRequest(event); + }; + const onCreateBubble = (): void => { // Finalize any previous bubble before creating a new one if (currentBubble !== null) { @@ -234,8 +238,14 @@ export class ChatView extends ItemView { messages: this.messages, tools: hasTools ? enabledTools : undefined, app: hasTools ? this.plugin.app : undefined, + options: { + temperature: this.plugin.settings.temperature, + num_ctx: this.plugin.settings.numCtx, + num_predict: this.plugin.settings.numPredict, + }, onChunk, onToolCall: hasTools ? onToolCall : undefined, + onApprovalRequest: hasTools ? onApprovalRequest : undefined, onCreateBubble, abortSignal: this.abortController.signal, }); @@ -349,6 +359,50 @@ export class ChatView extends ItemView { contentInner.createEl("pre", { text: resultPreview, cls: "ai-organizer-tool-call-result" }); } + private showApprovalRequest(event: ApprovalRequestEvent): Promise<boolean> { + return new Promise<boolean>((resolve) => { + if (this.messageContainer === null) { + resolve(false); + return; + } + + const container = this.messageContainer.createDiv({ cls: "ai-organizer-approval" }); + + const header = container.createDiv({ cls: "ai-organizer-approval-header" }); + setIcon(header.createSpan({ cls: "ai-organizer-approval-icon" }), "shield-alert"); + header.createSpan({ text: event.friendlyName, cls: "ai-organizer-approval-name" }); + + container.createDiv({ text: event.message, cls: "ai-organizer-approval-message" }); + + const buttonRow = container.createDiv({ cls: "ai-organizer-approval-buttons" }); + + const approveBtn = buttonRow.createEl("button", { + text: "Approve", + cls: "ai-organizer-approval-approve", + }); + + const declineBtn = buttonRow.createEl("button", { + text: "Decline", + cls: "ai-organizer-approval-decline", + }); + + const finalize = (approved: boolean): void => { + approveBtn.disabled = true; + declineBtn.disabled = true; + container.addClass(approved ? "ai-organizer-approval-approved" : "ai-organizer-approval-declined"); + const statusEl = container.createDiv({ cls: "ai-organizer-approval-status" }); + statusEl.setText(approved ? "Approved" : "Declined"); + this.scrollToBottom(); + resolve(approved); + }; + + approveBtn.addEventListener("click", () => finalize(true)); + declineBtn.addEventListener("click", () => finalize(false)); + + this.scrollToBottom(); + }); + } + private scrollToBottom(): void { if (this.messageContainer !== null) { this.messageContainer.scrollTop = this.messageContainer.scrollHeight; diff --git a/src/ollama-client.ts b/src/ollama-client.ts index 6ada18a..30e4d41 100644 --- a/src/ollama-client.ts +++ b/src/ollama-client.ts @@ -94,6 +94,54 @@ export async function listModels(ollamaUrl: string): Promise<string[]> { } /** + * Model info returned by /api/show. + */ +export interface ModelInfo { + contextLength: number; +} + +/** + * Query Ollama for model details, extracting the context length. + * The context length is found in model_info under keys like + * "<family>.context_length" or "context_length". + */ +export async function showModel(ollamaUrl: string, model: string): Promise<ModelInfo> { + try { + const response = await requestUrl({ + url: `${ollamaUrl}/api/show`, + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model }), + }); + + const json = response.json as Record<string, unknown>; + let contextLength = 4096; // fallback default + + const modelInfo = json.model_info as Record<string, unknown> | undefined; + if (modelInfo !== undefined && modelInfo !== null) { + // Look for context_length in model_info + // Keys are typically "<family>.context_length" e.g. "llama.context_length" + for (const key of Object.keys(modelInfo)) { + if (key.endsWith(".context_length") || key === "context_length") { + const val = modelInfo[key]; + if (typeof val === "number" && val > 0) { + contextLength = val; + break; + } + } + } + } + + return { contextLength }; + } catch (err: unknown) { + if (err instanceof Error) { + throw new Error(`Failed to get model info: ${err.message}`); + } + throw new Error("Failed to get model info: unknown error."); + } +} + +/** * Send a chat message with optional tool-calling agent loop. * When tools are provided, the function handles the multi-turn tool * execution loop automatically and calls onToolCall for each invocation. @@ -105,6 +153,7 @@ export async function sendChatMessage( tools?: OllamaToolDefinition[], app?: App, onToolCall?: (event: ToolCallEvent) => void, + onApprovalRequest?: (event: ApprovalRequestEvent) => Promise<boolean>, ): Promise<string> { const maxIterations = 10; let iterations = 0; @@ -180,6 +229,22 @@ export async function sendChatMessage( let result: string; if (toolEntry === undefined) { result = `Error: Unknown tool "${fnName}".`; + } else if (toolEntry.requiresApproval) { + let approved = false; + if (onApprovalRequest !== undefined) { + const message = toolEntry.approvalMessage !== undefined + ? toolEntry.approvalMessage(fnArgs) + : `Allow ${toolEntry.friendlyName}?`; + approved = await onApprovalRequest({ + toolName: fnName, + friendlyName: toolEntry.friendlyName, + message, + args: fnArgs, + }); + } + result = approved + ? await toolEntry.execute(app, fnArgs) + : `Action declined by user: ${toolEntry.friendlyName} was not approved.`; } else { result = await toolEntry.execute(app, fnArgs); } @@ -211,6 +276,22 @@ export async function sendChatMessage( } /** + * Approval request event for tools that require user confirmation. + */ +export interface ApprovalRequestEvent { + toolName: string; + friendlyName: string; + message: string; + args: Record<string, unknown>; +} + +export interface ModelOptions { + temperature?: number; + num_ctx?: number; + num_predict?: number; +} + +/** * Streaming chat options. */ export interface StreamingChatOptions { @@ -219,8 +300,10 @@ export interface StreamingChatOptions { messages: ChatMessage[]; tools?: OllamaToolDefinition[]; app?: App; + options?: ModelOptions; onChunk: (text: string) => void; onToolCall?: (event: ToolCallEvent) => void; + onApprovalRequest?: (event: ApprovalRequestEvent) => Promise<boolean>; onCreateBubble: () => void; abortSignal?: AbortSignal; } @@ -284,7 +367,7 @@ export async function sendChatMessageStreaming( async function sendChatMessageStreamingMobile( opts: StreamingChatOptions, ): Promise<string> { - const { ollamaUrl, model, messages, tools, app, onChunk, onToolCall, onCreateBubble } = opts; + const { ollamaUrl, model, messages, tools, app, options, onChunk, onToolCall, onApprovalRequest, onCreateBubble } = opts; const maxIterations = 10; let iterations = 0; @@ -317,6 +400,10 @@ async function sendChatMessageStreamingMobile( body.tools = tools; } + if (options !== undefined) { + body.options = options; + } + try { const response = await requestUrl({ url: `${ollamaUrl}/api/chat`, @@ -362,6 +449,22 @@ async function sendChatMessageStreamingMobile( let result: string; if (toolEntry === undefined) { result = `Error: Unknown tool "${fnName}".`; + } else if (toolEntry.requiresApproval) { + let approved = false; + if (onApprovalRequest !== undefined) { + const message = toolEntry.approvalMessage !== undefined + ? toolEntry.approvalMessage(fnArgs) + : `Allow ${toolEntry.friendlyName}?`; + approved = await onApprovalRequest({ + toolName: fnName, + friendlyName: toolEntry.friendlyName, + message, + args: fnArgs, + }); + } + result = approved + ? await toolEntry.execute(app, fnArgs) + : `Action declined by user: ${toolEntry.friendlyName} was not approved.`; } else { result = await toolEntry.execute(app, fnArgs); } @@ -404,7 +507,7 @@ async function sendChatMessageStreamingMobile( async function sendChatMessageStreamingDesktop( opts: StreamingChatOptions, ): Promise<string> { - const { ollamaUrl, model, messages, tools, app, onChunk, onToolCall, onCreateBubble, abortSignal } = opts; + const { ollamaUrl, model, messages, tools, app, options, onChunk, onToolCall, onApprovalRequest, onCreateBubble, abortSignal } = opts; const maxIterations = 10; let iterations = 0; @@ -437,6 +540,10 @@ async function sendChatMessageStreamingDesktop( body.tools = tools; } + if (options !== undefined) { + body.options = options; + } + const response = await fetch(`${ollamaUrl}/api/chat`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -501,6 +608,22 @@ async function sendChatMessageStreamingDesktop( let result: string; if (toolEntry === undefined) { result = `Error: Unknown tool "${fnName}".`; + } else if (toolEntry.requiresApproval) { + let approved = false; + if (onApprovalRequest !== undefined) { + const message = toolEntry.approvalMessage !== undefined + ? toolEntry.approvalMessage(fnArgs) + : `Allow ${toolEntry.friendlyName}?`; + approved = await onApprovalRequest({ + toolName: fnName, + friendlyName: toolEntry.friendlyName, + message, + args: fnArgs, + }); + } + result = approved + ? await toolEntry.execute(app, fnArgs) + : `Action declined by user: ${toolEntry.friendlyName} was not approved.`; } else { result = await toolEntry.execute(app, fnArgs); } diff --git a/src/settings-modal.ts b/src/settings-modal.ts index 4fb089f..5a90649 100644 --- a/src/settings-modal.ts +++ b/src/settings-modal.ts @@ -1,8 +1,13 @@ import { Modal, Setting } from "obsidian"; import type AIOrganizer from "./main"; +import { showModel } from "./ollama-client"; +import type { ModelInfo } from "./ollama-client"; export class SettingsModal extends Modal { private plugin: AIOrganizer; + private modelInfo: ModelInfo | null = null; + private ctxMaxEl: HTMLElement | null = null; + private ctxInputEl: HTMLInputElement | null = null; constructor(plugin: AIOrganizer) { super(plugin.app); @@ -40,6 +45,7 @@ export class SettingsModal extends Modal { dropdown.onChange(async (value) => { this.plugin.settings.model = value; await this.plugin.saveSettings(); + void this.fetchAndApplyModelInfo(value); }); modelDropdownSelectEl = dropdown.selectEl; }); @@ -66,12 +72,129 @@ export class SettingsModal extends Modal { // Move connect above model in the DOM contentEl.insertBefore(connectSetting.settingEl, modelSetting.settingEl); + + // --- Generation Parameters --- + + const paramHeader = contentEl.createEl("h4", { text: "Generation Parameters" }); + paramHeader.style.marginTop = "16px"; + paramHeader.style.marginBottom = "4px"; + + // Temperature + const tempSetting = new Setting(contentEl) + .setName("Temperature") + .setDesc("Controls randomness. Lower = more focused, higher = more creative."); + + const tempValueEl = tempSetting.descEl.createSpan({ + cls: "ai-organizer-param-value", + text: ` (${this.plugin.settings.temperature.toFixed(2)})`, + }); + + tempSetting.addSlider((slider) => + slider + .setLimits(0, 2, 0.05) + .setValue(this.plugin.settings.temperature) + .setDynamicTooltip() + .onChange(async (value) => { + this.plugin.settings.temperature = value; + tempValueEl.setText(` (${value.toFixed(2)})`); + await this.plugin.saveSettings(); + }), + ); + + // Context Window + const ctxSetting = new Setting(contentEl) + .setName("Context Window") + .setDesc("Max tokens the model sees (prompt + response)."); + + let ctxInputEl: HTMLInputElement | null = null; + + ctxSetting.addText((text) => { + text.inputEl.type = "number"; + text.inputEl.min = "256"; + text.inputEl.max = "1048576"; + text.inputEl.step = "256"; + text.setValue(String(this.plugin.settings.numCtx)); + text.onChange(async (value) => { + const num = parseInt(value, 10); + if (!isNaN(num) && num >= 256) { + this.plugin.settings.numCtx = num; + await this.plugin.saveSettings(); + } + this.updateCtxMaxWarning(); + }); + text.inputEl.style.width = "80px"; + ctxInputEl = text.inputEl; + }); + + // Model max label placed below the input + const ctxControlEl = ctxSetting.controlEl; + ctxControlEl.style.flexDirection = "column"; + ctxControlEl.style.alignItems = "flex-end"; + this.ctxMaxEl = ctxControlEl.createDiv({ cls: "ai-organizer-ctx-max" }); + this.ctxMaxEl.style.cursor = "pointer"; + this.ctxMaxEl.addEventListener("click", async () => { + if (this.modelInfo !== null && this.ctxInputEl !== null) { + this.plugin.settings.numCtx = this.modelInfo.contextLength; + this.ctxInputEl.value = String(this.modelInfo.contextLength); + await this.plugin.saveSettings(); + this.updateCtxMaxWarning(); + } + }); + this.ctxInputEl = ctxInputEl; + + // Max Output Tokens + const predictSetting = new Setting(contentEl) + .setName("Max Output Tokens") + .setDesc("Maximum tokens to generate. -1 = unlimited."); + + predictSetting.addText((text) => { + text.inputEl.type = "number"; + text.inputEl.min = "-1"; + text.inputEl.max = "1048576"; + text.inputEl.step = "256"; + text.setValue(String(this.plugin.settings.numPredict)); + text.onChange(async (value) => { + const num = parseInt(value, 10); + if (!isNaN(num) && num >= -1) { + this.plugin.settings.numPredict = num; + await this.plugin.saveSettings(); + } + }); + text.inputEl.style.width = "80px"; + }); + + // Fetch model info if a model is already selected + if (this.plugin.settings.model !== "") { + void this.fetchAndApplyModelInfo(this.plugin.settings.model); + } } onClose(): void { this.contentEl.empty(); } + private async fetchAndApplyModelInfo(model: string): Promise<void> { + if (model === "") return; + try { + this.modelInfo = await showModel(this.plugin.settings.ollamaUrl, model); + if (this.modelInfo !== null && this.ctxMaxEl !== null) { + this.ctxMaxEl.setText(`Model max: ${this.modelInfo.contextLength.toLocaleString()}`); + this.updateCtxMaxWarning(); + } + } catch { + // Silently ignore — model info is optional enhancement + if (this.ctxMaxEl !== null) { + this.ctxMaxEl.setText(""); + } + } + } + + private updateCtxMaxWarning(): void { + if (this.ctxMaxEl === null || this.modelInfo === null) return; + const exceeds = this.plugin.settings.numCtx > this.modelInfo.contextLength; + this.ctxMaxEl.toggleClass("ai-organizer-ctx-max-warn", exceeds); + } + private populateModelDropdown(selectEl: HTMLSelectElement): void { const models = this.plugin.availableModels; diff --git a/src/settings.ts b/src/settings.ts index 209ff98..ff61c89 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -4,10 +4,16 @@ export interface AIOrganizerSettings { ollamaUrl: string; model: string; enabledTools: Record<string, boolean>; + temperature: number; + numCtx: number; + numPredict: number; } export const DEFAULT_SETTINGS: AIOrganizerSettings = { ollamaUrl: "http://localhost:11434", model: "", enabledTools: getDefaultToolStates(), + temperature: 0.7, + numCtx: 4096, + numPredict: -1, }; diff --git a/src/tools.ts b/src/tools.ts index 7e31fb1..ca85091 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -25,6 +25,8 @@ export interface ToolEntry { label: string; description: string; friendlyName: string; + requiresApproval: boolean; + approvalMessage?: (args: Record<string, unknown>) => string; summarize: (args: Record<string, unknown>) => string; summarizeResult: (result: string) => string; definition: OllamaToolDefinition; @@ -89,6 +91,30 @@ async function executeReadFile(app: App, args: Record<string, unknown>): Promise } /** + * Execute the "delete_file" tool. + * Deletes a file by its vault path (moves to trash). + */ +async function executeDeleteFile(app: App, args: Record<string, unknown>): Promise<string> { + const filePath = typeof args.file_path === "string" ? args.file_path : ""; + if (filePath === "") { + return "Error: file_path parameter is required."; + } + + const file = app.vault.getAbstractFileByPath(filePath); + if (file === null || !(file instanceof TFile)) { + return `Error: File not found at path "${filePath}".`; + } + + try { + await app.vault.trash(file, true); + return `File "${filePath}" has been deleted (moved to system trash).`; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : "Unknown error"; + return `Error deleting file: ${msg}`; + } +} + +/** * All available tools for the plugin. */ export const TOOL_REGISTRY: ToolEntry[] = [ @@ -97,6 +123,7 @@ export const TOOL_REGISTRY: ToolEntry[] = [ label: "Search File Names", description: "Search for files in the vault by name or path.", friendlyName: "Search Files", + requiresApproval: false, summarize: (args) => { const query = typeof args.query === "string" ? args.query : ""; return `"${query}"`; @@ -135,6 +162,7 @@ export const TOOL_REGISTRY: ToolEntry[] = [ label: "Read File Contents", description: "Read the full text content of a file in the vault.", friendlyName: "Read File", + requiresApproval: false, summarize: (args) => { const filePath = typeof args.file_path === "string" ? args.file_path : ""; return `"/${filePath}"`; @@ -165,6 +193,48 @@ export const TOOL_REGISTRY: ToolEntry[] = [ }, execute: executeReadFile, }, + { + id: "delete_file", + label: "Delete File", + description: "Delete a file from the vault (requires approval).", + friendlyName: "Delete File", + requiresApproval: true, + approvalMessage: (args) => { + const filePath = typeof args.file_path === "string" ? args.file_path : "unknown"; + return `Delete "${filePath}"?`; + }, + summarize: (args) => { + const filePath = typeof args.file_path === "string" ? args.file_path : ""; + return `"/${filePath}"`; + }, + summarizeResult: (result) => { + if (result.startsWith("Error")) { + return result; + } + if (result.includes("declined")) { + return "Declined by user"; + } + return "File deleted"; + }, + definition: { + type: "function", + function: { + name: "delete_file", + description: "Delete a file from the Obsidian vault. The file is moved to the system trash. The file_path must be an exact path as returned by search_files. This action requires user approval.", + parameters: { + type: "object", + required: ["file_path"], + properties: { + file_path: { + type: "string", + description: "The vault-relative path to the file to delete (e.g. 'folder/note.md').", + }, + }, + }, + }, + }, + execute: executeDeleteFile, + }, ]; /** @@ -17,7 +17,7 @@ flex: 1; overflow-y: auto; padding: 8px; - padding-top: 52px; + padding-top: 56px; display: flex; flex-direction: column; gap: 6px; @@ -29,6 +29,8 @@ max-width: 85%; word-wrap: break-word; white-space: pre-wrap; + user-select: text; + -webkit-user-select: text; } .ai-organizer-message.user { @@ -218,6 +220,8 @@ border-left: 3px solid var(--interactive-accent); font-size: 0.85em; margin: 2px 0; + user-select: text; + -webkit-user-select: text; } .ai-organizer-tool-call-header { @@ -249,7 +253,7 @@ } .ai-organizer-tool-call-result-summary { - margin: 0 0 4px 0; + margin: 0 0 0 0; color: var(--text-muted); font-size: 0.9em; } @@ -265,7 +269,7 @@ grid-template-rows: max-content 0fr; grid-template-columns: minmax(0, 1fr); transition: grid-template-rows 0.2s ease-out; - margin-top: 4px; + margin-top: 2px; } .ai-organizer-collapse-toggle { @@ -281,7 +285,7 @@ grid-row-start: 1; position: relative; width: 100%; - padding: 4px 28px 4px 0; + padding: 2px 28px 2px 0; cursor: pointer; color: var(--text-muted); font-size: 0.9em; @@ -332,7 +336,7 @@ } .ai-organizer-collapse-content-inner { - padding: 4px 0 6px 0; + padding: 2px 0 4px 0; } .ai-organizer-tool-call-args, @@ -365,3 +369,122 @@ color: var(--text-on-accent) !important; border-color: var(--text-error) !important; } + +.ai-organizer-param-value { + font-weight: 600; + color: var(--text-normal); +} + +.ai-organizer-ctx-max { + font-size: 0.8em; + color: var(--text-muted); + margin-top: 4px; + text-align: right; +} + +.ai-organizer-ctx-max-warn { + color: var(--text-error); +} + +/* ===== Tool Approval Prompt ===== */ + +.ai-organizer-approval { + align-self: flex-start; + max-width: 85%; + padding: 8px 12px; + border-radius: 6px; + background-color: var(--background-secondary-alt); + border-left: 3px solid var(--text-warning); + margin: 2px 0; +} + +.ai-organizer-approval-header { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 4px; + color: var(--text-warning); +} + +.ai-organizer-approval-icon { + display: flex; + align-items: center; +} + +.ai-organizer-approval-icon svg { + width: 16px; + height: 16px; +} + +.ai-organizer-approval-name { + font-weight: 600; + font-size: 0.9em; +} + +.ai-organizer-approval-message { + margin: 4px 0 8px 0; + color: var(--text-normal); + font-size: 0.9em; +} + +.ai-organizer-approval-buttons { + display: flex; + gap: 8px; +} + +.ai-organizer-approval-approve, +.ai-organizer-approval-decline { + padding: 4px 16px; + border-radius: 4px; + border: none; + cursor: pointer; + font-size: 0.85em; + font-weight: 500; + transition: opacity 0.15s; +} + +.ai-organizer-approval-approve { + background-color: var(--interactive-accent); + color: var(--text-on-accent); +} + +.ai-organizer-approval-approve:hover { + opacity: 0.85; +} + +.ai-organizer-approval-decline { + background-color: var(--background-modifier-border); + color: var(--text-normal); +} + +.ai-organizer-approval-decline:hover { + opacity: 0.85; +} + +.ai-organizer-approval-approve:disabled, +.ai-organizer-approval-decline:disabled { + opacity: 0.5; + cursor: default; +} + +.ai-organizer-approval-status { + margin-top: 6px; + font-size: 0.8em; + font-weight: 600; +} + +.ai-organizer-approval-approved .ai-organizer-approval-status { + color: var(--interactive-accent); +} + +.ai-organizer-approval-declined .ai-organizer-approval-status { + color: var(--text-error); +} + +.ai-organizer-approval-approved { + border-left-color: var(--interactive-accent); +} + +.ai-organizer-approval-declined { + border-left-color: var(--text-error); +} |
