diff --git a/src/hooks/useAgentMessages.ts b/src/hooks/useAgentMessages.ts index 76cc9236..1767e487 100644 --- a/src/hooks/useAgentMessages.ts +++ b/src/hooks/useAgentMessages.ts @@ -108,7 +108,7 @@ export function useAgentMessages( const [isSending, setIsSending] = useState(false); const [lastUserMessage, setLastUserMessage] = useState(null); - // Tool call index: toolCallId → message index for O(1) lookup + // Tool call index: toolCallId to message index for O(1) lookup const toolCallIndexRef = useRef>(new Map()); // Ignore updates flag (used during session/load to skip history replay) @@ -256,7 +256,11 @@ export function useAgentMessages( // Wait for any in-flight send to settle (e.g. after cancel/stop) // before starting a new one to avoid interleaved state updates. if (sendPromiseRef.current) { - try { await sendPromiseRef.current; } catch { /* ignore */ } + try { + await sendPromiseRef.current; + } catch { + /* ignore */ + } } const currentSessionId = session.sessionId; diff --git a/src/hooks/useChatActions.ts b/src/hooks/useChatActions.ts index bda8a48d..df4f98cb 100644 --- a/src/hooks/useChatActions.ts +++ b/src/hooks/useChatActions.ts @@ -78,6 +78,7 @@ export function useChatActions( messages: ChatMessage[], settings: AgentClientPluginSettings, vaultPath: string, + persistentSourcePath?: string, ): UseChatActionsReturn { const logger = getLogger(); @@ -193,6 +194,7 @@ export function useChatActions( await sessionHistory.saveSessionLocally( session.sessionId, content, + persistentSourcePath, ); logger.log( `[ChatPanel] Session saved locally: ${session.sessionId}`, @@ -205,6 +207,7 @@ export function useChatActions( messages.length, session.sessionId, sessionHistory.saveSessionLocally, + persistentSourcePath, logger, suggestions.mentions.activeNote, suggestions.mentions.isAutoMentionDisabled, diff --git a/src/hooks/useSessionHistory.ts b/src/hooks/useSessionHistory.ts index 3aff8ea6..dba73afe 100644 --- a/src/hooks/useSessionHistory.ts +++ b/src/hooks/useSessionHistory.ts @@ -188,6 +188,7 @@ export interface UseSessionHistoryReturn { saveSessionLocally: ( sessionId: string, messageContent: string, + sourcePath?: string, ) => Promise; /** @@ -779,7 +780,11 @@ export function useSessionHistory( * Called when the first message is sent in a new session. */ const saveSessionLocally = useCallback( - async (sessionId: string, messageContent: string) => { + async ( + sessionId: string, + messageContent: string, + sourcePath?: string, + ) => { if (!session.agentId) return; const title = truncateTitle(messageContent); @@ -789,6 +794,7 @@ export function useSessionHistory( agentId: session.agentId, cwd: agentCwd, title, + sourcePath, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); diff --git a/src/hooks/useSuggestions.ts b/src/hooks/useSuggestions.ts index 05598ddb..2e8aa397 100644 --- a/src/hooks/useSuggestions.ts +++ b/src/hooks/useSuggestions.ts @@ -91,6 +91,7 @@ export function useSuggestions( plugin: AgentClientPlugin, availableCommands: SlashCommand[], autoMentionDefault: boolean, + pinnedActiveNote?: NoteMetadata | null, ): UseSuggestionsReturn { // ============================================================ // Mention State @@ -103,7 +104,9 @@ export function useSuggestions( const [mentionContext, setMentionContext] = useState( null, ); - const [activeNote, setActiveNote] = useState(null); + const [activeNote, setActiveNote] = useState( + pinnedActiveNote ?? null, + ); const [isAutoMentionDisabled, setIsAutoMentionDisabled] = useState( !autoMentionDefault, ); @@ -206,9 +209,13 @@ export function useSuggestions( }, []); const updateActiveNote = useCallback(async () => { + if (pinnedActiveNote) { + setActiveNote(pinnedActiveNote); + return; + } const note = await vaultAccess.getActiveNote(); setActiveNote(note); - }, [vaultAccess]); + }, [vaultAccess, pinnedActiveNote]); // ============================================================ // Command Callbacks diff --git a/src/plugin.ts b/src/plugin.ts index 9b1851c4..823aec68 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -3,9 +3,19 @@ import { WorkspaceLeaf, Notice, requestUrl, + MarkdownRenderChild, + SuggestModal, + setIcon, + Menu, + type App, + type MenuItem, + type MarkdownPostProcessorContext, } from "obsidian"; import * as semver from "semver"; import { ChatView, VIEW_TYPE_CHAT } from "./ui/ChatView"; +import { mountCodeBlockChat } from "./ui/CodeBlockChatView"; +import { mountAgentButtonBlock } from "./ui/AgentButtonBlock"; +import { parseAgentBlock } from "./utils/agent-block-parser"; import { SessionManagerView, VIEW_TYPE_SESSION_MANAGER, @@ -44,11 +54,31 @@ import { CustomAgentSettings, } from "./types/agent"; import type { SavedSessionInfo } from "./types/session"; +import { classifyIconRef, resolveImageSrc } from "./utils/resolve-image-src"; import { initializeLogger, getLogger } from "./utils/logger"; // Re-export for backward compatibility export type { AgentEnvVar, CustomAgentSettings }; +export interface QuickPrompt { + name: string; + prompt: string; + agentId?: string; + usageCount: number; + /** + * Icon shown on the quick-prompt chip. Either a Lucide icon id + * (e.g. "sparkles", "wand-2") or an image reference (http(s)/data URL + * or vault-relative path). Falls back to "sparkles" when unset. + */ + icon?: string; + /** + * When true, the quick-prompt chip hides itself after it is clicked, + * for the lifetime of the current chat view. Reopening the chat + * restores it. Non-persistent and non-destructive. + */ + hideAfterClick?: boolean; +} + /** * Send message shortcut configuration. * - 'enter': Enter to send, Shift+Enter for newline (default) @@ -62,12 +92,31 @@ export type SendMessageShortcut = "enter" | "cmd-enter"; * - 'right-split': Open in right pane with vertical split * - 'editor-tab': Open in editor area as tabs * - 'editor-split': Open in editor area with right split + * - 'floating': Open as a floating chat window */ export type ChatViewLocation = | "right-tab" | "right-split" | "editor-tab" - | "editor-split"; + | "editor-split" + | "floating"; + +export interface EmbeddedChatRegistration { + viewId: string; + sourcePath: string; + lineStart: number; + containerEl: HTMLElement; +} + +type ActiveChatKind = "sidebar" | "floating" | "embedded"; + +interface ActiveChatMenuEntry { + viewId: string; + type: ActiveChatKind; + label: string; + icon: string; + focus: () => void; +} export interface AgentClientPluginSettings { gemini: GeminiAgentSettings; @@ -109,6 +158,10 @@ export interface AgentClientPluginSettings { windowsWslDistribution?: string; // Input behavior sendMessageShortcut: SendMessageShortcut; + showQuickPromptsInChat: boolean; + showRecentChatsInChat: boolean; + showAgentImagesInChatInterfaces: boolean; + quickPrompts: QuickPrompt[]; // View settings chatViewLocation: ChatViewLocation; // Display settings @@ -186,6 +239,10 @@ const DEFAULT_SETTINGS: AgentClientPluginSettings = { windowsWslMode: false, windowsWslDistribution: undefined, sendMessageShortcut: "enter", + showQuickPromptsInChat: true, + showRecentChatsInChat: false, + showAgentImagesInChatInterfaces: true, + quickPrompts: [], chatViewLocation: "right-tab", displaySettings: { autoCollapseDiffs: false, @@ -218,6 +275,10 @@ export default class AgentClientPlugin extends Plugin { private floatingButton: FloatingButtonContainer | null = null; /** Counter for generating unique floating chat instance IDs */ private floatingChatCounter = 0; + /** Embedded chat instances mounted from markdown code blocks. */ + private embeddedChats = new Map(); + private readonly activeChatsChangedEvent = + "agent-client:active-chats-changed"; async onload() { await this.loadSettings(); @@ -290,6 +351,29 @@ export default class AgentClientPlugin extends Plugin { }, }); + this.addCommand({ + id: "open-new-side-chat-view", + name: "Open new side chat view", + callback: () => { + void this.openNewChatViewWithAgent( + this.settings.defaultAgentId, + "right-tab", + ); + }, + }); + + this.addCommand({ + id: "run-quick-prompt", + name: "Run quick prompt", + checkCallback: (checking) => { + if (this.settings.quickPrompts.length === 0) return false; + if (checking) return true; + new QuickPromptSuggestModal(this.app, this, (prompt) => { + void this.runQuickPrompt(prompt); + }).open(); + }, + }); + // Register agent-specific commands this.registerAgentCommands(); this.registerPermissionCommands(); @@ -356,6 +440,14 @@ export default class AgentClientPlugin extends Plugin { this.addSettingTab(new AgentClientSettingTab(this.app, this)); + this.registerMarkdownCodeBlockProcessor( + "agent-client", + (source, el, ctx) => this.renderAgentBlock(source, el, ctx), + ); + this.registerMarkdownCodeBlockProcessor("agent", (source, el, ctx) => + this.renderAgentBlock(source, el, ctx), + ); + // Mount floating button (always present; visibility controlled by settings inside component) this.floatingButton = new FloatingButtonContainer(this); this.floatingButton.mount(); @@ -469,6 +561,16 @@ export default class AgentClientPlugin extends Plugin { async activateView() { const { workspace } = this.app; + if (this.settings.chatViewLocation === "floating") { + const instances = this.getFloatingChatInstances(); + if (instances.length === 0) { + this.openNewFloatingChat(true); + } else { + this.expandFloatingChat(instances[instances.length - 1]); + } + return; + } + let leaf: WorkspaceLeaf | null = null; const leaves = workspace.getLeavesOfType(VIEW_TYPE_CHAT); @@ -560,11 +662,15 @@ export default class AgentClientPlugin extends Plugin { * Create a new leaf for ChatView based on the configured location setting. * @param isAdditional - true when opening additional views (e.g., Open New View) */ - private createNewChatLeaf(isAdditional: boolean): WorkspaceLeaf | null { + private createNewChatLeaf( + isAdditional: boolean, + location: ChatViewLocation = this.settings.chatViewLocation, + ): WorkspaceLeaf | null { const { workspace } = this.app; - const location = this.settings.chatViewLocation; switch (location) { + case "floating": + return null; case "right-tab": if (isAdditional) { return this.createSidebarTab("right"); @@ -616,11 +722,20 @@ export default class AgentClientPlugin extends Plugin { * Open a new chat view with a specific agent. * Always creates a new view (doesn't reuse existing). */ - async openNewChatViewWithAgent(agentId: string): Promise { - const leaf = this.createNewChatLeaf(true); + async openNewChatViewWithAgent( + agentId: string, + location: ChatViewLocation = this.settings.chatViewLocation, + ): Promise { + if (location === "floating") { + const counterBefore = this.floatingChatCounter; + this.openNewFloatingChat(true); + return `floating-chat-${counterBefore}`; + } + + const leaf = this.createNewChatLeaf(true, location); if (!leaf) { getLogger().warn("[AgentClient] Failed to create new leaf"); - return; + return null; } await leaf.setViewState({ @@ -630,6 +745,8 @@ export default class AgentClientPlugin extends Plugin { }); await this.app.workspace.revealLeaf(leaf); + const view = leaf.view as ChatView | null; + const viewId = view?.viewId ?? null; // Focus textarea after revealing the leaf const viewContainerEl = leaf.view?.containerEl; @@ -643,6 +760,7 @@ export default class AgentClientPlugin extends Plugin { } }, 0); } + return viewId; } /** @@ -659,6 +777,170 @@ export default class AgentClientPlugin extends Plugin { createFloatingChat(this, instanceId, initialExpanded, initialPosition); } + registerEmbeddedChat(registration: EmbeddedChatRegistration): () => void { + this.embeddedChats.set(registration.viewId, registration); + this.notifyActiveChatsChanged(); + return () => { + const current = this.embeddedChats.get(registration.viewId); + if (current === registration) { + this.embeddedChats.delete(registration.viewId); + this.notifyActiveChatsChanged(); + } + }; + } + + notifyActiveChatsChanged(): void { + activeDocument.dispatchEvent( + new CustomEvent(this.activeChatsChangedEvent), + ); + } + + onActiveChatsChanged(callback: () => void): () => void { + activeDocument.addEventListener(this.activeChatsChangedEvent, callback); + return () => { + activeDocument.removeEventListener( + this.activeChatsChangedEvent, + callback, + ); + }; + } + + hasVisibleChatSurfaces(): boolean { + const hasVisibleRegisteredView = this.viewRegistry + .getAll() + .some((view) => { + if (view instanceof FloatingViewContainer) { + return view.isExpanded(); + } + return true; + }); + return hasVisibleRegisteredView || this.embeddedChats.size > 0; + } + + getActiveChatMenuEntries(): ActiveChatMenuEntry[] { + const entries: ActiveChatMenuEntry[] = []; + + for (const view of this.viewRegistry.getAll()) { + const type = view.viewType; + entries.push({ + viewId: view.viewId, + type, + label: view.getDisplayName(), + icon: type === "floating" ? "panel-top-open" : "panel-right", + focus: () => { + this.viewRegistry.setFocused(view.viewId); + view.focus(); + }, + }); + } + + for (const registration of this.embeddedChats.values()) { + const fileName = + registration.sourcePath.split(/[\\/]/).pop() || + registration.sourcePath || + "Current note"; + entries.push({ + viewId: registration.viewId, + type: "embedded", + label: `${fileName}:${registration.lineStart + 1}`, + icon: "file-text", + focus: () => { + registration.containerEl.scrollIntoView({ + block: "center", + behavior: "smooth", + }); + window.requestAnimationFrame(() => { + const textarea = + registration.containerEl.querySelector( + "textarea.agent-client-chat-input-textarea", + ); + if (textarea instanceof HTMLTextAreaElement) { + textarea.focus(); + } + }); + }, + }); + } + + return entries; + } + + addActiveChatsToMenu(menu: Menu, currentViewId?: string): void { + const entries = this.getActiveChatMenuEntries(); + if (entries.length === 0) return; + + menu.addItem((item: MenuItem) => { + item.setTitle("Active chats") + .setIcon("messages-square") + .onClick((evt) => { + this.showActiveChatsMenu(evt, currentViewId); + }); + }); + } + + private showActiveChatsMenu( + evt: MouseEvent | KeyboardEvent, + currentViewId?: string, + ): void { + const entries = this.getActiveChatMenuEntries(); + const menu = new Menu(); + + menu.addItem((item: MenuItem) => { + item.setTitle("Active chats").setIsLabel(true); + }); + + for (const entry of entries) { + menu.addItem((item: MenuItem) => { + item.setTitle(this.getActiveChatMenuTitle(entry)) + .setIcon(entry.icon) + .setChecked(entry.viewId === currentViewId) + .onClick(() => { + entry.focus(); + }); + }); + } + + if (evt instanceof MouseEvent) { + menu.showAtMouseEvent(evt); + return; + } + + menu.showAtPosition({ + x: window.innerWidth / 2, + y: window.innerHeight / 2, + }); + } + + private getActiveChatMenuTitle(entry: ActiveChatMenuEntry): string { + switch (entry.type) { + case "floating": + return `Floating: ${entry.label}`; + case "sidebar": + return `Side chat: ${entry.label}`; + case "embedded": + return `Embedded: ${entry.label}`; + } + } + + findNearestEmbeddedChat( + sourcePath: string, + lineStart: number, + ): string | null { + let nearest: EmbeddedChatRegistration | null = null; + let nearestDistance = Number.POSITIVE_INFINITY; + + for (const registration of this.embeddedChats.values()) { + if (registration.sourcePath !== sourcePath) continue; + const distance = Math.abs(registration.lineStart - lineStart); + if (distance < nearestDistance) { + nearest = registration; + nearestDistance = distance; + } + } + + return nearest?.viewId ?? null; + } + /** * Close a specific floating chat window. * @param viewId - The viewId in "floating-chat-{id}" format (from getFloatingChatInstances()) @@ -689,6 +971,170 @@ export default class AgentClientPlugin extends Plugin { } } + /** + * Render an `agent-client` code block. Dispatches to embedded chat or + * quick-action button based on the parsed `type` field. + */ + private renderAgentBlock( + source: string, + el: HTMLElement, + ctx: MarkdownPostProcessorContext, + ): void { + const child = new MarkdownRenderChild(el); + const parsed = parseAgentBlock(source); + + if (!parsed.ok) { + const errorEl = el.createDiv({ + cls: "agent-client-code-block-error", + }); + errorEl.createSpan({ + cls: "agent-client-code-block-error-label", + text: "agent-client block error: ", + }); + errorEl.createSpan({ text: parsed.error }); + const sourceEl = errorEl.createEl("pre", { + cls: "agent-client-code-block-error-source", + }); + sourceEl.setText(source); + ctx.addChild(child); + return; + } + + const sectionInfo = ctx.getSectionInfo(el); + const sourcePath = ctx.sourcePath || ""; + const lineStart = sectionInfo?.lineStart ?? 0; + const blockId = `${sourcePath || "untitled"}:${lineStart}`; + + if (parsed.config.type === "chat") { + const root = mountCodeBlockChat(this, el, parsed.config, { + sourcePath, + blockId, + lineStart, + }); + child.onunload = () => root.unmount(); + } else { + const root = mountAgentButtonBlock(this, el, parsed.config, { + sourcePath, + lineStart, + }); + child.onunload = () => root.unmount(); + } + ctx.addChild(child); + } + + /** + * Open a chat view and inject a prompt into it. Used by quick-action + * buttons (embedded code blocks, command palette entries, etc.). + * + * Fires `agent-client:run-prompt` shortly after the open call so the + * target ChatPanel can populate its input box, optionally auto-sending + * once the session is ready. + */ + async runPromptInChat(options: { + agentId: string; + prompt: string; + autoSend: boolean; + viewType: "right-pane" | "floating" | "editor-tab" | "embedded"; + sourcePath?: string; + lineStart?: number; + }): Promise { + const { agentId, prompt, autoSend, viewType, sourcePath, lineStart } = + options; + let targetViewId: string | null = null; + + if (viewType === "embedded") { + targetViewId = + sourcePath && typeof lineStart === "number" + ? this.findNearestEmbeddedChat(sourcePath, lineStart) + : null; + if (!targetViewId) { + new Notice("No embedded chat block found in this note."); + return; + } + } else if (viewType === "floating") { + const counterBefore = this.floatingChatCounter; + this.openNewFloatingChat(true); + targetViewId = `floating-chat-${counterBefore}`; + } else if (viewType === "editor-tab") { + targetViewId = await this.openNewChatViewWithAgent( + agentId, + "editor-tab", + ); + } else { + targetViewId = await this.openNewChatViewWithAgent( + agentId, + "right-tab", + ); + } + + if (!targetViewId) return; + + this.dispatchPromptToChat(targetViewId, prompt, autoSend); + } + + private dispatchPromptToChat( + targetViewId: string, + prompt: string, + autoSend: boolean, + ): void { + // Defer slightly so the React root has mounted and registered its + // `agent-client:run-prompt` listener. + window.setTimeout(() => { + this.app.workspace.trigger( + "agent-client:run-prompt", + targetViewId, + prompt, + autoSend, + ); + }, 100); + } + + getQuickPrompts(): QuickPrompt[] { + return this.settings.quickPrompts + .filter((prompt) => prompt.name.trim() && prompt.prompt.trim()) + .sort((a, b) => { + if (b.usageCount !== a.usageCount) { + return b.usageCount - a.usageCount; + } + return a.name.localeCompare(b.name); + }); + } + + findQuickPrompt(name: string | undefined): QuickPrompt | null { + const key = name?.trim(); + if (!key) return null; + const exact = this.settings.quickPrompts.find((p) => p.name === key); + if (exact) return exact; + const lower = key.toLowerCase(); + return ( + this.settings.quickPrompts.find( + (p) => p.name.toLowerCase() === lower, + ) ?? null + ); + } + + async incrementQuickPromptUsage(name: string): Promise { + const next = this.settings.quickPrompts.map((prompt) => + prompt.name === name + ? { ...prompt, usageCount: prompt.usageCount + 1 } + : prompt, + ); + await this.settingsService.updateSettings({ quickPrompts: next }); + } + + async runQuickPrompt(prompt: QuickPrompt): Promise { + const agentId = + prompt.agentId && + this.collectAvailableAgentIds().includes(prompt.agentId) + ? prompt.agentId + : this.settings.defaultAgentId; + await this.incrementQuickPromptUsage(prompt.name); + const targetViewId = await this.openNewChatViewWithAgent(agentId); + if (targetViewId) { + this.dispatchPromptToChat(targetViewId, prompt.prompt, false); + } + } + /** * Get all available agents (claude, codex, gemini, custom) */ @@ -938,6 +1384,12 @@ export default class AgentClientPlugin extends Plugin { ? rawDefaultId : availableAgentIds[0] || D.claude.id; + const pickAvatarImage = (value: unknown): string | undefined => { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; + }; + this.settings = { claude: { id: D.claude.id, // Fixed — never from raw @@ -959,6 +1411,7 @@ export default class AgentClientPlugin extends Plugin { D.claude.command, args: sanitizeArgs(rc.args), env: normalizeEnvVars(rc.env), + avatarImage: pickAvatarImage(rc.avatarImage), }, codex: { id: D.codex.id, @@ -976,6 +1429,7 @@ export default class AgentClientPlugin extends Plugin { command: str(rk.command, "") || D.codex.command, args: sanitizeArgs(rk.args), env: normalizeEnvVars(rk.env), + avatarImage: pickAvatarImage(rk.avatarImage), }, gemini: { id: D.gemini.id, @@ -1000,6 +1454,7 @@ export default class AgentClientPlugin extends Plugin { ? sanitizeArgs(rg.args) : D.gemini.args, env: normalizeEnvVars(rg.env), + avatarImage: pickAvatarImage(rg.avatarImage), }, customAgents, defaultAgentId, @@ -1075,9 +1530,57 @@ export default class AgentClientPlugin extends Plugin { ["enter", "cmd-enter"], D.sendMessageShortcut, ), + showQuickPromptsInChat: bool( + raw.showQuickPromptsInChat, + D.showQuickPromptsInChat, + ), + showRecentChatsInChat: bool( + raw.showRecentChatsInChat, + D.showRecentChatsInChat, + ), + showAgentImagesInChatInterfaces: bool( + raw.showAgentImagesInChatInterfaces, + D.showAgentImagesInChatInterfaces, + ), + quickPrompts: (() => { + if (!Array.isArray(raw.quickPrompts)) return D.quickPrompts; + const seen = new Set(); + const prompts: QuickPrompt[] = []; + for (const entry of raw.quickPrompts) { + const item = obj(entry); + if (!item) continue; + const name = str(item.name, "").trim(); + const prompt = str(item.prompt, "").trim(); + if (!name || !prompt || seen.has(name)) continue; + seen.add(name); + const agentId = str(item.agentId, "").trim(); + prompts.push({ + name, + prompt, + agentId: + agentId.length > 0 && + availableAgentIds.includes(agentId) + ? agentId + : undefined, + usageCount: num(item.usageCount, 0, 0), + icon: str(item.icon, "").trim() || undefined, + hideAfterClick: + typeof item.hideAfterClick === "boolean" + ? item.hideAfterClick + : undefined, + }); + } + return prompts; + })(), chatViewLocation: enumVal( raw.chatViewLocation, - ["right-tab", "right-split", "editor-tab", "editor-split"], + [ + "right-tab", + "right-split", + "editor-tab", + "editor-split", + "floating", + ], D.chatViewLocation, ), displaySettings: { @@ -1316,3 +1819,79 @@ export default class AgentClientPlugin extends Plugin { return Array.from(ids); } } + +class QuickPromptSuggestModal extends SuggestModal { + constructor( + app: App, + private plugin: AgentClientPlugin, + private onChoose: (prompt: QuickPrompt) => void, + ) { + super(app); + this.setPlaceholder("Choose a quick prompt"); + } + + getSuggestions(query: string): QuickPrompt[] { + const needle = query.trim().toLowerCase(); + const prompts = this.plugin.getQuickPrompts(); + if (!needle) return prompts; + return prompts.filter((prompt) => { + return ( + prompt.name.toLowerCase().includes(needle) || + prompt.prompt.toLowerCase().includes(needle) + ); + }); + } + + renderSuggestion(prompt: QuickPrompt, el: HTMLElement): void { + const row = el.createDiv({ + cls: "agent-client-quick-prompt-suggestion", + }); + this.renderPromptIcon(row, prompt); + + const text = row.createDiv({ + cls: "agent-client-quick-prompt-suggestion-text", + }); + text.createDiv({ + cls: "agent-client-quick-prompt-suggestion-name", + text: prompt.name, + }); + text.createDiv({ + cls: "agent-client-quick-prompt-suggestion-preview", + text: + prompt.prompt.length > 80 + ? prompt.prompt.slice(0, 80) + "\u2026" + : prompt.prompt, + }); + } + + /** + * Render the prompt icon (Lucide id or image) into the row, mirroring the + * chat-input chip. Falls back to "sparkles" when unset or unresolvable. + */ + private renderPromptIcon(parent: HTMLElement, prompt: QuickPrompt): void { + const classified = classifyIconRef(prompt.icon); + + if (classified?.kind === "image") { + const src = resolveImageSrc(this.plugin, classified.value); + if (src) { + parent.createEl("img", { + cls: "agent-client-quick-prompt-suggestion-image", + attr: { src, alt: "" }, + }); + return; + } + } + + const iconEl = parent.createSpan({ + cls: "agent-client-quick-prompt-suggestion-icon", + }); + setIcon( + iconEl, + classified?.kind === "lucide" ? classified.value : "sparkles", + ); + } + + onChooseSuggestion(prompt: QuickPrompt): void { + this.onChoose(prompt); + } +} diff --git a/src/services/settings-normalizer.ts b/src/services/settings-normalizer.ts index eefded95..64a3ba0f 100644 --- a/src/services/settings-normalizer.ts +++ b/src/services/settings-normalizer.ts @@ -128,6 +128,12 @@ export const normalizeCustomAgent = ( agent.displayName.trim().length > 0 ? agent.displayName.trim() : rawId; + const rawAvatar = + agent && + typeof agent.avatarImage === "string" && + agent.avatarImage.trim().length > 0 + ? agent.avatarImage.trim() + : undefined; return { id: rawId, displayName: rawDisplayName, @@ -139,6 +145,7 @@ export const normalizeCustomAgent = ( : "", args: sanitizeArgs(agent?.args), env: normalizeEnvVars(agent?.env), + avatarImage: rawAvatar, }; }; diff --git a/src/types/agent.ts b/src/types/agent.ts index 3064deb1..2a9592eb 100644 --- a/src/types/agent.ts +++ b/src/types/agent.ts @@ -51,6 +51,12 @@ export interface BaseAgentSettings { /** Environment variables for the agent process */ env: AgentEnvVar[]; + + /** + * Per-agent avatar image used by embedded chat and quick-prompt buttons. + * Accepts an http(s) URL, a data URL, or a vault-relative path. + */ + avatarImage?: string; } /** diff --git a/src/types/session.ts b/src/types/session.ts index 4b572af4..9ce5de50 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -593,6 +593,8 @@ export interface SavedSessionInfo { cwd: string; /** Human-readable session title (first 50 chars of first user message) */ title?: string; + /** Note path that owns a persistent embedded chat session. */ + sourcePath?: string; /** ISO 8601 timestamp of session creation */ createdAt: string; /** ISO 8601 timestamp of last activity */ diff --git a/src/ui/AgentButtonBlock.tsx b/src/ui/AgentButtonBlock.tsx new file mode 100644 index 00000000..3538c61c --- /dev/null +++ b/src/ui/AgentButtonBlock.tsx @@ -0,0 +1,143 @@ +import * as React from "react"; +const { useCallback, useMemo, useState } = React; +import { createRoot, type Root } from "react-dom/client"; +import { Notice } from "obsidian"; + +import type AgentClientPlugin from "../plugin"; +import { + getAgentAvatarImage, + resolveImageSrc, +} from "../utils/resolve-image-src"; +import type { AgentButtonBlockConfig } from "../utils/agent-block-parser"; + +interface AgentButtonBlockProps { + plugin: AgentClientPlugin; + config: AgentButtonBlockConfig; + mountCtx: AgentButtonMountContext; +} + +export interface AgentButtonMountContext { + sourcePath: string; + lineStart: number; +} + +function resolveAgentId( + plugin: AgentClientPlugin, + preferred: string | undefined, +): string { + const available = plugin.getAvailableAgents().map((a) => a.id); + if (preferred && available.includes(preferred)) return preferred; + return plugin.settings.defaultAgentId; +} + +function AgentButtonBlockComponent({ + plugin, + config, + mountCtx, +}: AgentButtonBlockProps) { + // Whether this rendered button has been clicked. Used to hide it when + // hideAfterClick is set; resets when the note is re-rendered. + const [dismissed, setDismissed] = useState(false); + + const quickPrompt = useMemo(() => { + return plugin.findQuickPrompt(config.promptName); + }, [plugin, config.promptName]); + + const resolvedAgentId = useMemo(() => { + return resolveAgentId(plugin, config.agent ?? quickPrompt?.agentId); + }, [plugin, config.agent, quickPrompt?.agentId]); + + const avatarSrc = useMemo(() => { + if (!plugin.settings.showAgentImagesInChatInterfaces) return null; + if (config.showImage === false) return null; + return resolveImageSrc( + plugin, + getAgentAvatarImage(plugin, resolvedAgentId), + ); + }, [ + plugin, + resolvedAgentId, + config.showImage, + plugin.settings.showAgentImagesInChatInterfaces, + ]); + + const handleClick = useCallback(async () => { + const promptText = config.prompt ?? quickPrompt?.prompt; + if (!promptText) { + new Notice( + config.promptName + ? `Quick prompt "${config.promptName}" was not found.` + : "Button block has no prompt.", + ); + return; + } + + try { + if (quickPrompt && !config.prompt) { + await plugin.incrementQuickPromptUsage(quickPrompt.name); + } + await plugin.runPromptInChat({ + agentId: resolvedAgentId, + prompt: promptText, + autoSend: config.autoSend ?? false, + viewType: config.viewType ?? "right-pane", + sourcePath: mountCtx.sourcePath, + lineStart: mountCtx.lineStart, + }); + + // Hide the button after a successful click when requested. The + // YAML field wins; otherwise fall back to the quick prompt's setting. + const shouldHide = + config.hideAfterClick ?? quickPrompt?.hideAfterClick ?? false; + if (shouldHide) { + setDismissed(true); + } + } catch (error) { + console.error("[Agent Client] runPromptInChat failed:", error); + new Notice("Failed to open chat with prompt."); + } + }, [plugin, resolvedAgentId, config, quickPrompt, mountCtx]); + + if (dismissed) return null; + + return ( +
+ +
+ ); +} + +export function mountAgentButtonBlock( + plugin: AgentClientPlugin, + el: HTMLElement, + config: AgentButtonBlockConfig, + mountCtx: AgentButtonMountContext, +): Root { + const container = el.createDiv(); + const root = createRoot(container); + root.render( + , + ); + return root; +} diff --git a/src/ui/ChatHeader.tsx b/src/ui/ChatHeader.tsx index 30513f93..919dd314 100644 --- a/src/ui/ChatHeader.tsx +++ b/src/ui/ChatHeader.tsx @@ -1,8 +1,14 @@ import * as React from "react"; -const { useRef, useEffect } = React; -import { setIcon, DropdownComponent } from "obsidian"; +const { useRef, useEffect, useMemo } = React; +import { setIcon } from "obsidian"; import { HeaderButton } from "./shared/IconButton"; import type { AgentDisplayInfo } from "../services/session-helpers"; +import type AgentClientPlugin from "../plugin"; +import { + AgentAvatar, + getResolvedAgentAvatarSrc, +} from "./shared/AgentAvatar"; +import { ImageSelect, type ImageSelectOption } from "./shared/ImageSelect"; // ============================================================================ // Props Types @@ -15,6 +21,10 @@ export interface SidebarHeaderProps { variant: "sidebar"; /** Display name of the active agent */ agentLabel: string; + /** Active agent ID */ + agentId: string; + /** Plugin instance for resolving configured agent images */ + plugin: AgentClientPlugin; /** Whether a plugin update is available */ isUpdateAvailable: boolean; /** Callback to create a new chat session */ @@ -34,6 +44,8 @@ export interface FloatingHeaderProps { variant: "floating"; /** Display name of the active agent */ agentLabel: string; + /** Plugin instance for resolving configured agent images */ + plugin: AgentClientPlugin; /** Available agents for switching */ availableAgents: AgentDisplayInfo[]; /** Current agent ID */ @@ -90,6 +102,55 @@ function NavActionButton({ ); } +function AgentSelector({ + agentLabel, + plugin, + availableAgents, + currentAgentId, + onAgentChange, +}: { + agentLabel: string; + plugin: AgentClientPlugin; + availableAgents: AgentDisplayInfo[]; + currentAgentId: string; + onAgentChange: (agentId: string) => void; +}) { + const agentOptions = useMemo( + () => + availableAgents.map((agent) => ({ + value: agent.id, + label: agent.displayName, + imageSrc: getResolvedAgentAvatarSrc(plugin, agent.id), + })), + [availableAgents, plugin], + ); + + if (availableAgents.length > 1) { + return ( +
+ +
+ ); + } + + return ( + + + {agentLabel} + + ); +} + // ============================================================================ // Sidebar Header // ============================================================================ @@ -102,6 +163,8 @@ function NavActionButton({ */ function SidebarHeader({ agentLabel, + agentId, + plugin, isUpdateAvailable, onNewChat, onExportChat, @@ -112,6 +175,11 @@ function SidebarHeader({
+ {agentLabel} {isUpdateAvailable && ( @@ -161,6 +229,7 @@ function SidebarHeader({ */ function FloatingHeader({ agentLabel, + plugin, availableAgents, currentAgentId, isUpdateAvailable, @@ -169,85 +238,18 @@ function FloatingHeader({ onMinimize, onClose, }: FloatingHeaderProps) { - // Refs for agent dropdown - const agentDropdownRef = useRef(null); - const agentDropdownInstance = useRef(null); - - // Stable ref for onAgentChange callback - const onAgentChangeRef = useRef(onAgentChange); - onAgentChangeRef.current = onAgentChange; - - // Initialize agent dropdown - useEffect(() => { - const containerEl = agentDropdownRef.current; - if (!containerEl) return; - - // Only show dropdown if there are multiple agents - if (availableAgents.length <= 1) { - if (agentDropdownInstance.current) { - containerEl.empty(); - agentDropdownInstance.current = null; - } - return; - } - - // Create dropdown if not exists - if (!agentDropdownInstance.current) { - const dropdown = new DropdownComponent(containerEl); - agentDropdownInstance.current = dropdown; - - // Add options - for (const agent of availableAgents) { - dropdown.addOption(agent.id, agent.displayName); - } - - // Set initial value - if (currentAgentId) { - dropdown.setValue(currentAgentId); - } - - // Handle change - dropdown.onChange((value) => { - onAgentChangeRef.current?.(value); - }); - } - - // Cleanup on unmount or when availableAgents change - return () => { - if (agentDropdownInstance.current) { - containerEl.empty(); - agentDropdownInstance.current = null; - } - }; - }, [availableAgents]); - - // Update dropdown value when currentAgentId changes - useEffect(() => { - if (agentDropdownInstance.current && currentAgentId) { - agentDropdownInstance.current.setValue(currentAgentId); - } - }, [currentAgentId]); - return (
- {availableAgents.length > 1 ? ( -
-
- { - if (el) setIcon(el, "chevron-down"); - }} - /> -
- ) : ( - - {agentLabel} - - )} +
{isUpdateAvailable && (

diff --git a/src/ui/ChatPanel.tsx b/src/ui/ChatPanel.tsx index e7eed3e7..ffbc6744 100644 --- a/src/ui/ChatPanel.tsx +++ b/src/ui/ChatPanel.tsx @@ -6,10 +6,12 @@ import { Platform, Menu, setIcon, + TFile, type MenuItem, } from "obsidian"; import type { AttachedFile, ChatInputState } from "../types/chat"; +import type { NoteMetadata } from "../services/vault-service"; import { isSameDirectory } from "../utils/platform"; import { computeSessionTitle } from "../services/session-helpers"; import { useHistoryModal } from "../hooks/useHistoryModal"; @@ -39,6 +41,7 @@ import { type SessionModeState, type SessionModelState, type SessionConfigOption, + type SessionInfo, } from "../types/session"; import { checkAgentUpdate } from "../services/update-checker"; import type { SessionStatus } from "../services/view-registry"; @@ -79,13 +82,19 @@ export interface ChatPanelCallbacks { // ============================================================================ export interface ChatPanelProps { - variant: "sidebar" | "floating"; + variant: "sidebar" | "floating" | "embedded"; viewId: string; workingDirectory?: string; initialAgentId?: string; - config?: { agent?: string; model?: string }; + config?: { + agent?: string; + model?: string; + persist?: boolean; + noteContext?: "hosting"; + sourcePath?: string; + }; onRegisterCallbacks?: (callbacks: ChatPanelCallbacks) => void; - /** Called when agent ID changes (sidebar only — persists in Obsidian state) */ + /** Called when agent ID changes (sidebar only; persists in Obsidian state) */ onAgentIdChanged?: (agentId: string) => void; /** * Called when the derived session title may have changed (sidebar only — @@ -173,7 +182,7 @@ export function ChatPanel({ return process.cwd(); }, [plugin, workingDirectory]); - // Agent working directory — defaults to vault path. + // Agent working directory; defaults to vault path. // Can be changed independently via "New chat in directory..." action. const [agentCwd, setAgentCwd] = useState(vaultPath); @@ -198,11 +207,27 @@ export function ChatPanel({ errorInfo, } = agent; + const pinnedActiveNote = useMemo(() => { + if (config?.noteContext !== "hosting" || !config.sourcePath) { + return null; + } + const file = plugin.app.vault.getAbstractFileByPath(config.sourcePath); + if (!(file instanceof TFile)) return null; + return { + path: file.path, + name: file.basename, + extension: file.extension, + created: file.stat.ctime, + modified: file.stat.mtime, + }; + }, [plugin, config?.noteContext, config?.sourcePath]); + const suggestions = useSuggestions( vaultService, plugin, session.availableCommands || EMPTY_COMMANDS, settings.autoMentionActiveNote, + pinnedActiveNote, ); // Session history hook with callback for session load @@ -243,6 +268,18 @@ export function ChatPanel({ onClearMessages: agent.clearMessages, }); + useEffect(() => { + if (!settings.showRecentChatsInChat) return; + if (!isSessionReady || messages.length > 0) return; + void sessionHistory.fetchSessions(vaultPath); + }, [ + settings.showRecentChatsInChat, + isSessionReady, + messages.length, + sessionHistory.fetchSessions, + vaultPath, + ]); + // ============================================================ // Local State // ============================================================ @@ -252,6 +289,10 @@ export function ChatPanel({ const [inputValue, setInputValue] = useState(""); const [attachedFiles, setAttachedFiles] = useState([]); + // Pending auto-send queued by `agent-client:run-prompt` (drained when ready) + const [pendingAutoSend, setPendingAutoSend] = useState(null); + const persistRestoreAttemptedRef = useRef(false); + // ============================================================ // Refs // ============================================================ @@ -299,6 +340,7 @@ export function ChatPanel({ messages, settings, vaultPath, + config?.persist ? config.sourcePath : undefined, ); const { @@ -360,6 +402,17 @@ export function ChatPanel({ [handleSendMessage], ); + // Switch the view to a quick-prompt's assigned agent (fresh chat) and then + // auto-send the prompt once the new session is ready, reusing the same + // pending-auto-send drain as `agent-client:run-prompt`. + const handleSwitchAgentAndRun = useCallback( + async (nextAgentId: string, prompt: string) => { + await handleSwitchAgent(nextAgentId); + setPendingAutoSend(prompt); + }, + [handleSwitchAgent], + ); + const { handleOpenHistory } = useHistoryModal( plugin, agent, @@ -370,6 +423,27 @@ export function ChatPanel({ setAgentCwd, ); + const recentChatSessions = useMemo(() => { + if (!settings.showRecentChatsInChat) return []; + if (messages.length > 0) return []; + return sessionHistory.sessions + .filter((item) => item.sessionId !== session.sessionId) + .slice(0, 5); + }, [ + settings.showRecentChatsInChat, + messages.length, + sessionHistory.sessions, + session.sessionId, + ]); + + const handleRestoreRecentSession = useCallback( + async (sessionId: string, cwd: string) => { + setInputValue(""); + await sessionHistory.restoreSession(sessionId, cwd); + }, + [sessionHistory.restoreSession], + ); + // ============================================================ // Sidebar-specific: handleNewChat wrapper that persists agent ID // ============================================================ @@ -456,6 +530,8 @@ export function ChatPanel({ }); }); + plugin.addActiveChatsToMenu(menu, viewId); + menu.addItem((item: MenuItem) => { item.setTitle("Restart agent") .setIcon("refresh-cw") @@ -508,6 +584,7 @@ export function ChatPanel({ agentCwd, handleNewChatInDirectory, handleOpenSettings, + viewId, ], ); @@ -561,6 +638,8 @@ export function ChatPanel({ }); } + plugin.addActiveChatsToMenu(menu, viewId); + menu.addItem((item: MenuItem) => { item.setTitle("Restart agent") .setIcon("refresh-cw") @@ -613,6 +692,7 @@ export function ChatPanel({ agentCwd, handleNewChatInDirectory, handleOpenSettings, + viewId, ], ); @@ -636,14 +716,14 @@ export function ChatPanel({ // Floating: create a shim with listener tracking return { app: plugin.app, - registerDomEvent: (( + registerDomEvent: ( target: Window | Document | HTMLElement, type: string, callback: EventListenerOrEventListenerObject, ) => { target.addEventListener(type, callback); registeredListenersRef.current.push({ target, type, callback }); - }), + }, }; }, [viewHostProp, plugin.app]); @@ -670,6 +750,35 @@ export function ChatPanel({ void agent.createSession(config?.agent || initialAgentId); }, [agent.createSession, config?.agent, initialAgentId]); + useEffect(() => { + if (variant !== "embedded") return; + if (!config?.persist || !config.sourcePath) return; + if (!isSessionReady || !session.sessionId || !session.agentId) return; + if (!sessionHistory.canRestore) return; + if (persistRestoreAttemptedRef.current) return; + persistRestoreAttemptedRef.current = true; + + const savedSession = plugin.settingsService + .getSavedSessions(session.agentId, agentCwd) + .find((item) => item.sourcePath === config.sourcePath); + if (!savedSession || savedSession.sessionId === session.sessionId) { + return; + } + + void sessionHistory.restoreSession(savedSession.sessionId, agentCwd); + }, [ + variant, + config?.persist, + config?.sourcePath, + isSessionReady, + session.sessionId, + session.agentId, + sessionHistory.canRestore, + sessionHistory.restoreSession, + plugin.settingsService, + agentCwd, + ]); + // Apply configured model when session is ready useEffect(() => { if (!config?.model || !isSessionReady) return; @@ -793,7 +902,10 @@ export function ChatPanel({ ); // System notification on response completion - if (settings.enableSystemNotifications && !activeDocument.hasFocus()) { + if ( + settings.enableSystemNotifications && + !activeDocument.hasFocus() + ) { new Notification("Agent Client", { body: `${activeAgentLabel} has completed the response.`, }); @@ -990,6 +1102,27 @@ export function ChatPanel({ if (targetViewId && targetViewId !== viewId) return; void handleExportChatRef.current(); }), + + // Run prompt injected by quick-action button or code block. + // `targetViewId` filter: null/empty matches any view (used when the + // caller created the leaf via openNewChatViewWithAgent and cannot + // know the new viewId synchronously). + ws.on( + "agent-client:run-prompt", + ( + targetViewId: string | null, + prompt: string, + autoSend?: boolean, + ) => { + if (targetViewId && targetViewId !== viewId) return; + if (typeof prompt !== "string" || prompt.length === 0) + return; + setInputValue(prompt); + if (autoSend) { + setPendingAutoSend(prompt); + } + }, + ), ]; return () => { @@ -1005,6 +1138,27 @@ export function ChatPanel({ suggestions.mentions.toggleAutoMention, ]); + // ============================================================ + // Effects - Drain pending auto-send when session becomes ready + // ============================================================ + useEffect(() => { + if (!pendingAutoSend) return; + if (!isSessionReady) return; + if (isSending) return; + if (sessionHistory.loading) return; + + const prompt = pendingAutoSend; + setPendingAutoSend(null); + setInputValue(""); + void handleSendMessage(prompt); + }, [ + pendingAutoSend, + isSessionReady, + isSending, + sessionHistory.loading, + handleSendMessage, + ]); + // ============================================================ // Effects - Focus Tracking // ============================================================ @@ -1135,11 +1289,15 @@ export function ChatPanel({ } as React.CSSProperties) : undefined; + const shouldShowAgentSelector = variant !== "embedded" || !config?.agent; + const headerElement = variant === "sidebar" ? ( void handleNewChatWithPersist()} onExportChat={() => void handleExportChat()} @@ -1150,7 +1308,8 @@ export function ChatPanel({ void handleSwitchAgent(agentId)} @@ -1214,6 +1373,7 @@ export function ChatPanel({ usage={session.usage} supportsImages={session.promptCapabilities?.image ?? false} agentId={session.agentId} + onSwitchAgentAndRun={handleSwitchAgentAndRun} // Controlled component props (for broadcast commands) inputValue={inputValue} onInputChange={setInputValue} @@ -1229,6 +1389,12 @@ export function ChatPanel({ geminiNotice={effectiveGeminiNotice} onClearGeminiNotice={handleClearGeminiNotice} messages={messages} + recentChatSessions={recentChatSessions} + onRestoreRecentSession={ + sessionHistory.canRestore + ? handleRestoreRecentSession + : undefined + } /> ); @@ -1254,6 +1420,25 @@ export function ChatPanel({ ); } + if (variant === "embedded") { + return ( +

+
+ {headerElement} +
+ {cwdBanner} +
+ {messageListElement} +
+ {inputAreaElement} +
+ ); + } + // Sidebar layout return (
(null); + + const acpClient = useMemo( + () => plugin.getOrCreateAcpClient(viewId), + [plugin, viewId], + ); + + const vaultService = useMemo(() => new VaultService(plugin), [plugin]); + + useEffect(() => { + const rootEl = rootElRef.current; + if (!rootEl) return; + + const unregisterEmbeddedChat = plugin.registerEmbeddedChat({ + viewId, + sourcePath: mountCtx.sourcePath, + lineStart: mountCtx.lineStart, + containerEl: rootEl, + }); + return () => { + unregisterEmbeddedChat(); + vaultService.destroy(); + void plugin.removeAcpClient(viewId); + }; + }, [plugin, viewId, vaultService, mountCtx.sourcePath, mountCtx.lineStart]); + + const contextValue = useMemo( + () => ({ + plugin, + acpClient, + vaultService, + settingsService: plugin.settingsService, + }), + [plugin, acpClient, vaultService], + ); + + const avatarSrc = useMemo(() => { + if (!plugin.settings.showAgentImagesInChatInterfaces) return null; + if (config.showImage === false) return null; + return ( + resolveImageSrc(plugin, config.image) ?? + resolveImageSrc( + plugin, + getAgentAvatarImage(plugin, config.agent), + ) ?? + resolveImageSrc(plugin, plugin.settings.floatingButtonImage) + ); + }, [ + plugin, + config.image, + config.agent, + config.showImage, + plugin.settings.showAgentImagesInChatInterfaces, + ]); + + const heightStyle = config.height + ? ({ "--ac-embedded-max-height": config.height } as React.CSSProperties) + : undefined; + + return ( +
+ {avatarSrc && ( +
+ +
+ )} + + + +
+ ); +} + +export function mountCodeBlockChat( + plugin: AgentClientPlugin, + el: HTMLElement, + config: AgentChatBlockConfig, + mountCtx: CodeBlockMountContext, +): Root { + const container = el.createDiv({ cls: "agent-client-code-block-host" }); + const root = createRoot(container); + root.render( + , + ); + return root; +} diff --git a/src/ui/FloatingButton.tsx b/src/ui/FloatingButton.tsx index db35debc..c81db965 100644 --- a/src/ui/FloatingButton.tsx +++ b/src/ui/FloatingButton.tsx @@ -5,6 +5,7 @@ import { createRoot, type Root } from "react-dom/client"; import { setIcon } from "obsidian"; import type AgentClientPlugin from "../plugin"; import { useSettings } from "../hooks/useSettings"; + function clampPosition( x: number, y: number, @@ -63,13 +64,11 @@ interface FloatingButtonProps { function FloatingButtonComponent({ plugin }: FloatingButtonProps) { const settings = useSettings(plugin); + const [hasVisibleChatSurfaces, setHasVisibleChatSurfaces] = useState(() => + plugin.hasVisibleChatSurfaces(), + ); - const [showInstanceMenu, setShowInstanceMenu] = useState(false); - const instanceMenuRef = useRef(null); - - // Button / menu size constants const BUTTON_SIZE = 48; - const MENU_MIN_WIDTH = 220; // Dragging state const [position, setPosition] = useState<{ x: number; y: number } | null>( @@ -104,33 +103,6 @@ function FloatingButtonComponent({ plugin }: FloatingButtonProps) { ).getResourcePath?.(img); }, [settings.floatingButtonImage, plugin.app.vault.adapter]); - // Build display labels with duplicate numbering - const allInstances = plugin.getFloatingChatInstances(); - - const instanceLabels = useMemo(() => { - const views = plugin.viewRegistry.getByType("floating"); - const entries = views.map((v) => ({ - viewId: v.viewId, - label: v.getDisplayName(), - })); - const countMap = new Map(); - for (const e of entries) { - countMap.set(e.label, (countMap.get(e.label) ?? 0) + 1); - } - const indexMap = new Map(); - return entries.map((e) => { - if ((countMap.get(e.label) ?? 0) > 1) { - const idx = (indexMap.get(e.label) ?? 0) + 1; - indexMap.set(e.label, idx); - return { - viewId: e.viewId, - label: idx === 1 ? e.label : `${e.label} ${idx}`, - }; - } - return e; - }); - }, [plugin.viewRegistry, allInstances]); - // ============================================================ // Dragging Logic // ============================================================ @@ -210,43 +182,34 @@ function FloatingButtonComponent({ plugin }: FloatingButtonProps) { return () => window.clearTimeout(timer); }, [position, plugin, settings.floatingButtonPosition]); + useEffect(() => { + const updateActiveChatState = () => { + setHasVisibleChatSurfaces(plugin.hasVisibleChatSurfaces()); + }; + + updateActiveChatState(); + return plugin.onActiveChatsChanged(updateActiveChatState); + }, [plugin]); + // Button click handler const handleButtonClick = useCallback(() => { if (wasDragged.current) return; const instances = plugin.getFloatingChatInstances(); if (instances.length === 0) { - // No instances, create one and expand plugin.openNewFloatingChat(true); - } else if (instances.length === 1) { - // Single instance, just expand - plugin.expandFloatingChat(instances[0]); - } else { - // Multiple instances, show menu - setShowInstanceMenu(true); + return; } - }, [plugin]); - - // Close instance menu on outside click - useEffect(() => { - if (!showInstanceMenu) return; - const handleClickOutside = (event: MouseEvent) => { - if ( - instanceMenuRef.current && - !instanceMenuRef.current.contains(event.target as Node) - ) { - setShowInstanceMenu(false); - } - }; + const focused = plugin.viewRegistry.getFocused(); + if (focused?.viewType === "floating") { + plugin.expandFloatingChat(focused.viewId); + return; + } - const doc = activeDocument; - doc.addEventListener("mousedown", handleClickOutside); - return () => { - doc.removeEventListener("mousedown", handleClickOutside); - }; - }, [showInstanceMenu]); + plugin.expandFloatingChat(instances[instances.length - 1]); + }, [plugin]); - if (!settings.enableFloatingChat) return null; + if (!settings.enableFloatingChat || hasVisibleChatSurfaces) return null; const buttonClassName = [ "agent-client-floating-button", @@ -257,95 +220,31 @@ function FloatingButtonComponent({ plugin }: FloatingButtonProps) { .join(" "); return ( - <> -
- {floatingButtonImageSrc ? ( - Open chat - ) : ( -
{ - if (el) setIcon(el, "bot-message-square"); - }} - /> - )} -
- {showInstanceMenu && ( +
+ {floatingButtonImageSrc ? ( + Open chat + ) : (
- window.innerWidth - ? { - right: - window.innerWidth - - (position.x + BUTTON_SIZE), - left: "auto", - top: "auto", - } - : { - left: position.x, - right: "auto", - top: "auto", - }), - } - : undefined - } - > -
- Select session to open -
- {instanceLabels.map(({ viewId: id, label }) => ( -
{ - plugin.expandFloatingChat(id); - plugin.viewRegistry.setFocused(id); - setShowInstanceMenu(false); - }} - > - - {label} - - {instanceLabels.length > 1 && ( - - )} -
- ))} -
+ className="agent-client-floating-button-fallback" + ref={(el) => { + if (el) setIcon(el, "bot-message-square"); + }} + /> )} - +
); } diff --git a/src/ui/FloatingChatView.tsx b/src/ui/FloatingChatView.tsx index 457a5925..0ce469fc 100644 --- a/src/ui/FloatingChatView.tsx +++ b/src/ui/FloatingChatView.tsx @@ -111,6 +111,7 @@ export class FloatingViewContainer implements IChatViewContainer { }} onExpandedChange={(expanded) => { this.isExpandedState = expanded; + this.plugin.notifyActiveChatsChanged(); }} onContainerRef={(el) => { this.containerRefEl = el; @@ -120,6 +121,7 @@ export class FloatingViewContainer implements IChatViewContainer { // Register with plugin's view registry this.plugin.viewRegistry.register(this); + this.plugin.notifyActiveChatsChanged(); } /** @@ -127,6 +129,7 @@ export class FloatingViewContainer implements IChatViewContainer { */ unmount(): void { this.plugin.viewRegistry.unregister(this.viewId); + this.plugin.notifyActiveChatsChanged(); if (this.root) { this.root.unmount(); @@ -198,6 +201,10 @@ export class FloatingViewContainer implements IChatViewContainer { } } + isExpanded(): boolean { + return this.isExpandedState; + } + collapse(): void { if (this.isExpandedState) { this.isExpandedState = false; diff --git a/src/ui/InputArea.tsx b/src/ui/InputArea.tsx index f9706f4b..551f6f21 100644 --- a/src/ui/InputArea.tsx +++ b/src/ui/InputArea.tsx @@ -3,6 +3,7 @@ const { useRef, useState, useEffect, useCallback, useMemo } = React; import { setIcon, Notice } from "obsidian"; import type AgentClientPlugin from "../plugin"; +import type { QuickPrompt } from "../plugin"; import type { IChatViewHost } from "./view-host"; import type { NoteMetadata } from "../services/vault-service"; import type { @@ -11,6 +12,7 @@ import type { SessionModelState, SessionUsage, SessionConfigOption, + SessionInfo, } from "../types/session"; import type { AttachedFile, ChatMessage } from "../types/chat"; import type { UseSuggestionsReturn } from "../hooks/useSuggestions"; @@ -19,6 +21,7 @@ import { ErrorBanner } from "./ErrorBanner"; import { AttachmentStrip } from "./shared/AttachmentStrip"; import { InputToolbar } from "./InputToolbar"; import { getLogger } from "../utils/logger"; +import { classifyIconRef, resolveImageSrc } from "../utils/resolve-image-src"; import type { ErrorInfo } from "../types/errors"; import type { AgentUpdateNotification } from "../services/update-checker"; import { useSettings } from "../hooks/useSettings"; @@ -174,6 +177,49 @@ function useInputHistory( return { handleHistoryKeyDown, resetHistory }; } +// ============================================================================ +// Quick Prompt Chip Icon +// ============================================================================ + +/** + * Renders a quick-prompt chip icon: an when the configured icon is an + * image reference, otherwise a Lucide icon. Falls back to "sparkles" when the + * icon is unset or an image reference cannot be resolved. + */ +function QuickPromptChipIcon({ + plugin, + icon, +}: { + plugin: AgentClientPlugin; + icon?: string; +}) { + const classified = useMemo(() => classifyIconRef(icon), [icon]); + + if (classified?.kind === "image") { + const src = resolveImageSrc(plugin, classified.value); + if (src) { + return ( + + ); + } + } + + const lucideName = + classified?.kind === "lucide" ? classified.value : "sparkles"; + return ( + { + if (el) setIcon(el, lucideName); + }} + /> + ); +} + // ============================================================================ // InputArea Component // ============================================================================ @@ -226,6 +272,12 @@ export interface InputAreaProps { supportsImages?: boolean; /** Current agent ID (used to clear images on agent switch) */ agentId: string; + /** + * Switch the view to `agentId` (starting a fresh chat on that agent) and + * then auto-send `prompt` once the new session is ready. Used by quick + * prompts that carry an assigned agent so clicking the chip honors it. + */ + onSwitchAgentAndRun?: (agentId: string, prompt: string) => Promise; // Controlled component props (for broadcast commands) /** Current input text value */ inputValue: string; @@ -249,6 +301,10 @@ export interface InputAreaProps { onClearGeminiNotice: () => void; /** Messages array for input history navigation */ messages: ChatMessage[]; + /** Recent chats to render as restore shortcuts on empty chats */ + recentChatSessions?: SessionInfo[]; + /** Restore a recent chat by session ID and cwd */ + onRestoreRecentSession?: (sessionId: string, cwd: string) => Promise; } /** @@ -286,6 +342,7 @@ export function InputArea({ usage, supportsImages = false, agentId, + onSwitchAgentAndRun, // Controlled component props inputValue, onInputChange, @@ -302,15 +359,34 @@ export function InputArea({ onClearGeminiNotice, // Input history messages, + recentChatSessions, + onRestoreRecentSession, }: InputAreaProps) { const { mentions, commands: slashCommands } = suggestions; const logger = getLogger(); const settings = useSettings(plugin); const showEmojis = plugin.settings.displaySettings.showEmojis; + // Quick-prompt chips dismissed by clicking (when hideAfterClick is set). + // View-local and non-persistent: cleared on remount / chat reopen. + const [dismissedQuickPrompts, setDismissedQuickPrompts] = useState< + Set + >(() => new Set()); + const quickPrompts = useMemo(() => { + if (!settings.showQuickPromptsInChat) return []; + return plugin + .getQuickPrompts() + .filter((prompt) => !dismissedQuickPrompts.has(prompt.name)); + }, [ + plugin, + settings.showQuickPromptsInChat, + settings.quickPrompts, + dismissedQuickPrompts, + ]); // Unofficial Obsidian API (see src/types/obsidian-internals.d.ts) const obsidianSpellcheck = - (plugin.app.vault.getConfig("spellcheck") as boolean | undefined) ?? true; + (plugin.app.vault.getConfig("spellcheck") as boolean | undefined) ?? + true; // Local state (hint and command are still local - not needed for broadcast) const [hintText, setHintText] = useState(null); @@ -646,6 +722,71 @@ export function InputArea({ [mentions, inputValue, setTextAndFocus], ); + const handleSelectQuickPrompt = useCallback( + (prompt: QuickPrompt) => { + void (async () => { + if (prompt.hideAfterClick) { + setDismissedQuickPrompts((prev) => { + const next = new Set(prev); + next.add(prompt.name); + return next; + }); + } + await plugin.incrementQuickPromptUsage(prompt.name); + + // If the prompt is assigned to a different (valid) agent, switch + // the view to that agent first and let it auto-send once the + // fresh session is ready. Mirrors the code-block/command path. + const targetAgentId = + prompt.agentId && + prompt.agentId !== agentId && + plugin + .getAvailableAgents() + .some((a) => a.id === prompt.agentId) + ? prompt.agentId + : null; + if (targetAgentId && onSwitchAgentAndRun) { + onInputChange(""); + await onSwitchAgentAndRun(targetAgentId, prompt.prompt); + return; + } + + if (isSessionReady && !isSending && !isRestoringSession) { + onInputChange(""); + await onSendMessage(prompt.prompt); + return; + } + + onInputChange(prompt.prompt); + window.setTimeout(() => { + const textarea = textareaRef.current; + if (textarea) { + textarea.focus(); + textarea.selectionStart = prompt.prompt.length; + textarea.selectionEnd = prompt.prompt.length; + } + }, 0); + })(); + }, + [ + agentId, + isRestoringSession, + isSending, + isSessionReady, + onInputChange, + onSendMessage, + onSwitchAgentAndRun, + plugin, + ], + ); + + const handleRestoreRecentChat = useCallback( + (session: SessionInfo) => { + void onRestoreRecentSession?.(session.sessionId, session.cwd); + }, + [onRestoreRecentSession], + ); + /** * Handle slash command selection from dropdown. */ @@ -981,6 +1122,52 @@ export function InputArea({ /> )} + {((settings.showRecentChatsInChat && + recentChatSessions && + recentChatSessions.length > 0) || + quickPrompts.length > 0) && ( +
+ {settings.showRecentChatsInChat && + recentChatSessions?.map((session) => ( + + ))} + {quickPrompts.map((prompt) => ( + + ))} +
+ )} + {/* Mention Dropdown */} {mentions.isOpen && ( + toggle + .setValue(this.plugin.settings.showQuickPromptsInChat) + .onChange(async (value) => { + await this.plugin.settingsService.updateSettings({ + showQuickPromptsInChat: value, + }); + }), + ); + + new Setting(containerEl) + .setName("Show recent chats in chat") + .setDesc( + "Show recent chat shortcuts above the message box on empty chats.", + ) + .addToggle((toggle) => + toggle + .setValue(this.plugin.settings.showRecentChatsInChat) + .onChange(async (value) => { + await this.plugin.settingsService.updateSettings({ + showRecentChatsInChat: value, + }); + }), + ); + + new Setting(containerEl) + .setName("Show agent images") + .setDesc( + "Show configured agent avatar images in chat headers, selectors, embedded chats, buttons, and recent chat shortcuts.", + ) + .addToggle((toggle) => + toggle + .setValue( + this.plugin.settings.showAgentImagesInChatInterfaces, + ) + .onChange(async (value) => { + await this.plugin.settingsService.updateSettings({ + showAgentImagesInChatInterfaces: value, + }); + }), + ); + new Setting(containerEl).setName("Mentions").setHeading(); new Setting(containerEl) @@ -202,6 +251,7 @@ export class AgentClientSettingTab extends PluginSettingTab { .addOption("right-split", "Right pane (split)") .addOption("editor-tab", "Editor area (tabs)") .addOption("editor-split", "Editor area (split)") + .addOption("floating", "Floating chat") .setValue(this.plugin.settings.chatViewLocation) .onChange(async (value) => { await this.plugin.settingsService.updateSettings({ @@ -434,6 +484,12 @@ export class AgentClientSettingTab extends PluginSettingTab { // Permissions // ───────────────────────────────────────────────────────────────────── + new Setting(containerEl).setName("Quick prompts").setHeading(); + new Setting(containerEl).setDesc( + "Saved prompts can be shown in chat views, run from the command palette, or referenced from `agent-client` button blocks with `promptName:`.", + ); + this.renderQuickPrompts(containerEl); + new Setting(containerEl).setName("Permissions").setHeading(); new Setting(containerEl) @@ -1027,6 +1083,11 @@ export class AgentClientSettingTab extends PluginSettingTab { }); text.inputEl.rows = 3; }); + + this.renderAvatarSetting(sectionEl, gemini.avatarImage, async (v) => { + this.plugin.settings.gemini.avatarImage = v; + await this.plugin.saveSettings(); + }); } private renderClaudeSettings(sectionEl: HTMLElement) { @@ -1122,6 +1183,11 @@ export class AgentClientSettingTab extends PluginSettingTab { }); text.inputEl.rows = 3; }); + + this.renderAvatarSetting(sectionEl, claude.avatarImage, async (v) => { + this.plugin.settings.claude.avatarImage = v; + await this.plugin.saveSettings(); + }); } private renderCodexSettings(sectionEl: HTMLElement) { @@ -1217,6 +1283,11 @@ export class AgentClientSettingTab extends PluginSettingTab { }); text.inputEl.rows = 3; }); + + this.renderAvatarSetting(sectionEl, codex.avatarImage, async (v) => { + this.plugin.settings.codex.avatarImage = v; + await this.plugin.saveSettings(); + }); } private renderCustomAgents(containerEl: HTMLElement) { @@ -1363,6 +1434,11 @@ export class AgentClientSettingTab extends PluginSettingTab { }); text.inputEl.rows = 3; }); + + this.renderAvatarSetting(blockEl, agent.avatarImage, async (v) => { + this.plugin.settings.customAgents[index].avatarImage = v; + await this.plugin.saveSettings(); + }); } /** @@ -1539,4 +1615,210 @@ export class AgentClientSettingTab extends PluginSettingTab { return normalizeEnvVars(envVars); } + + private renderQuickPrompts(containerEl: HTMLElement): void { + const prompts = this.plugin.settings.quickPrompts; + if (prompts.length === 0) { + containerEl.createEl("p", { + cls: "setting-item-description", + text: "No quick prompts saved yet.", + }); + } else { + prompts.forEach((prompt, index) => { + this.renderQuickPrompt(containerEl, prompt, index); + }); + } + + new Setting(containerEl).addButton((button) => { + button + .setButtonText("Add quick prompt") + .setCta() + .onClick(async () => { + const next = [ + ...this.plugin.settings.quickPrompts, + { + name: this.generateQuickPromptName(), + prompt: "", + usageCount: 0, + }, + ]; + await this.plugin.settingsService.updateSettings({ + quickPrompts: next, + }); + this.display(); + }); + }); + } + + private renderQuickPrompt( + containerEl: HTMLElement, + prompt: QuickPrompt, + index: number, + ): void { + const blockEl = containerEl.createDiv({ + cls: "agent-client-quick-prompt-setting", + }); + + const nameSetting = new Setting(blockEl) + .setName("Name") + .setDesc("Use this value in button blocks as `promptName:`.") + .addText((text) => { + text.setPlaceholder("Summarize note") + .setValue(prompt.name) + .onChange(async (value) => { + const name = value.trim(); + if (!name) return; + const duplicate = + this.plugin.settings.quickPrompts.some( + (item, itemIndex) => + itemIndex !== index && item.name === name, + ); + if (duplicate) { + new Notice( + `[Agent Client] A quick prompt named "${name}" already exists.`, + ); + text.setValue(prompt.name); + return; + } + const next = [...this.plugin.settings.quickPrompts]; + next[index] = { ...next[index], name }; + await this.plugin.settingsService.updateSettings({ + quickPrompts: next, + }); + }); + }); + + nameSetting.addExtraButton((button) => { + button + .setIcon("trash") + .setTooltip("Delete quick prompt") + .onClick(async () => { + const next = [...this.plugin.settings.quickPrompts]; + next.splice(index, 1); + await this.plugin.settingsService.updateSettings({ + quickPrompts: next, + }); + this.display(); + }); + }); + + new Setting(blockEl) + .setName("Prompt") + .setDesc("The text inserted into chat or sent by a button block.") + .addTextArea((text) => { + text.setPlaceholder("Summarize the active note in 3 bullets.") + .setValue(prompt.prompt) + .onChange(async (value) => { + const next = [...this.plugin.settings.quickPrompts]; + next[index] = { ...next[index], prompt: value }; + await this.plugin.settingsService.updateSettings({ + quickPrompts: next, + }); + }); + text.inputEl.rows = 4; + }); + + new Setting(blockEl) + .setName("Icon") + .setDesc( + "Lucide icon name (e.g. sparkles, wand-2) or an image URL / vault path. Leave blank for the default sparkles icon.", + ) + .addText((text) => { + // eslint-disable-next-line obsidianmd/ui/sentence-case -- Lucide icon id, not UI prose + text.setPlaceholder("sparkles") + .setValue(prompt.icon ?? "") + .onChange(async (value) => { + const icon = value.trim(); + const next = [...this.plugin.settings.quickPrompts]; + next[index] = { + ...next[index], + icon: icon.length > 0 ? icon : undefined, + }; + await this.plugin.settingsService.updateSettings({ + quickPrompts: next, + }); + }); + }); + + new Setting(blockEl) + .setName("Default agent") + .setDesc("Used when a button block does not specify `agent:`.") + .addDropdown((dropdown) => { + dropdown.addOption("", "(plugin default)"); + for (const agent of this.plugin.getAvailableAgents()) { + dropdown.addOption(agent.id, agent.displayName); + } + dropdown + .setValue(prompt.agentId ?? "") + .onChange(async (value) => { + const next = [...this.plugin.settings.quickPrompts]; + next[index] = { + ...next[index], + agentId: value.length > 0 ? value : undefined, + }; + await this.plugin.settingsService.updateSettings({ + quickPrompts: next, + }); + }); + }); + new Setting(blockEl) + .setName("Hide after click") + .setDesc( + "Hide this chip once it is used, for the current chat view. Reopening the chat restores it.", + ) + .addToggle((toggle) => { + toggle + .setValue(prompt.hideAfterClick ?? false) + .onChange(async (value) => { + const next = [...this.plugin.settings.quickPrompts]; + next[index] = { + ...next[index], + hideAfterClick: value || undefined, + }; + await this.plugin.settingsService.updateSettings({ + quickPrompts: next, + }); + }); + }); + } + + private generateQuickPromptName(): string { + const base = "New prompt"; + const existing = new Set( + this.plugin.settings.quickPrompts.map((prompt) => prompt.name), + ); + if (!existing.has(base)) return base; + let counter = 2; + let candidate = `${base} ${counter}`; + while (existing.has(candidate)) { + counter += 1; + candidate = `${base} ${counter}`; + } + return candidate; + } + + /** + * Shared avatar field rendered at the bottom of every agent section. + */ + private renderAvatarSetting( + sectionEl: HTMLElement, + currentValue: string | undefined, + onChange: (value: string | undefined) => Promise, + ): void { + new Setting(sectionEl) + .setName("Avatar image") + .setDesc( + "URL or vault path to an image. Used by embedded `agent-client` chat blocks and quick-action buttons.", + ) + .addText((text) => { + text.setPlaceholder("https://example.com/avatar.png") + .setValue(currentValue ?? "") + .onChange(async (value) => { + const trimmed = value.trim(); + await onChange( + trimmed.length > 0 ? trimmed : undefined, + ); + }); + }); + } } diff --git a/src/ui/shared/AgentAvatar.tsx b/src/ui/shared/AgentAvatar.tsx new file mode 100644 index 00000000..725e627e --- /dev/null +++ b/src/ui/shared/AgentAvatar.tsx @@ -0,0 +1,50 @@ +import * as React from "react"; +const { useMemo } = React; +import { setIcon } from "obsidian"; + +import type AgentClientPlugin from "../../plugin"; +import { + getAgentAvatarImage, + resolveImageSrc, +} from "../../utils/resolve-image-src"; + +export function getResolvedAgentAvatarSrc( + plugin: AgentClientPlugin, + agentId: string | undefined, +): string | null { + if (!plugin.settings.showAgentImagesInChatInterfaces) return null; + return resolveImageSrc(plugin, getAgentAvatarImage(plugin, agentId)); +} + +export function AgentAvatar({ + plugin, + agentId, + className = "agent-client-agent-avatar", + fallbackIcon = "bot", +}: { + plugin: AgentClientPlugin; + agentId: string | undefined; + className?: string; + fallbackIcon?: string; +}) { + const showAgentImages = plugin.settings.showAgentImagesInChatInterfaces; + const src = useMemo( + () => getResolvedAgentAvatarSrc(plugin, agentId), + [plugin, agentId, showAgentImages], + ); + + if (!showAgentImages) return null; + + if (src) { + return ; + } + + return ( + { + if (el) setIcon(el, fallbackIcon); + }} + /> + ); +} diff --git a/src/ui/shared/ImageSelect.tsx b/src/ui/shared/ImageSelect.tsx new file mode 100644 index 00000000..7edacff9 --- /dev/null +++ b/src/ui/shared/ImageSelect.tsx @@ -0,0 +1,173 @@ +import * as React from "react"; +const { useCallback, useEffect, useMemo, useRef, useState } = React; +import { createPortal } from "react-dom"; +import { setIcon } from "obsidian"; + +export interface ImageSelectOption { + value: string; + label: string; + description?: string; + imageSrc?: string | null; +} + +export function ImageSelect({ + options, + value, + onChange, + className, + placeholder = "Select", +}: { + options: ImageSelectOption[]; + value: string | undefined; + onChange: (value: string) => void; + className?: string; + placeholder?: string; +}) { + const [open, setOpen] = useState(false); + const [menuStyle, setMenuStyle] = useState({}); + const rootRef = useRef(null); + const triggerRef = useRef(null); + const menuRef = useRef(null); + const selected = useMemo( + () => options.find((option) => option.value === value) ?? options[0], + [options, value], + ); + + useEffect(() => { + const handlePointerDown = (event: PointerEvent) => { + const target = event.target as Node; + if ( + !rootRef.current?.contains(target) && + !menuRef.current?.contains(target) + ) { + setOpen(false); + } + }; + document.addEventListener("pointerdown", handlePointerDown); + return () => { + document.removeEventListener("pointerdown", handlePointerDown); + }; + }, []); + + const updateMenuPosition = useCallback(() => { + const trigger = triggerRef.current; + if (!trigger) return; + + const rect = trigger.getBoundingClientRect(); + setMenuStyle({ + position: "fixed", + top: rect.bottom + 4, + left: rect.left, + minWidth: Math.max(rect.width, 220), + maxWidth: 320, + }); + }, []); + + useEffect(() => { + if (!open) return; + + updateMenuPosition(); + window.addEventListener("resize", updateMenuPosition); + window.addEventListener("scroll", updateMenuPosition, true); + return () => { + window.removeEventListener("resize", updateMenuPosition); + window.removeEventListener("scroll", updateMenuPosition, true); + }; + }, [open, updateMenuPosition]); + + const choose = useCallback( + (nextValue: string) => { + setOpen(false); + if (nextValue !== value) onChange(nextValue); + }, + [onChange, value], + ); + + const stopPointerPropagation = useCallback( + (event: React.PointerEvent | React.MouseEvent) => { + event.stopPropagation(); + }, + [], + ); + + if (!selected) return null; + + return ( +
+ + {open && + createPortal( +
+ {options.map((option) => ( + + ))} +
, + document.body, + )} +
+ ); +} + +function SelectImage({ option }: { option: ImageSelectOption }) { + if (option.imageSrc) { + return ( + + ); + } + return null; +} diff --git a/src/utils/agent-block-parser.ts b/src/utils/agent-block-parser.ts new file mode 100644 index 00000000..0ad4de83 --- /dev/null +++ b/src/utils/agent-block-parser.ts @@ -0,0 +1,243 @@ +/** + * Parser for the `agent-client` markdown code block. + * + * Pure function. No React, no Obsidian view APIs (parseYaml from obsidian + * is the only Obsidian import, used as a YAML utility). + * + * Fence body is parsed as YAML and dispatched by a `type` discriminator: + * - `chat` (default): embedded chat view + * - `button`: quick-action button that opens a chat with a prompt + */ + +import { parseYaml } from "obsidian"; + +export type AgentChatBlockConfig = { + type: "chat"; + agent?: string; + model?: string; + /** Max height of the messages area, e.g. "400px". */ + height?: string; + /** Restore the latest saved session for this note + agent. */ + persist?: boolean; + /** Pin auto-mention context to the note hosting this block. */ + noteContext?: "hosting"; + /** + * Per-block avatar override. + * Accepts http(s) URL, data URL, or vault-relative path. + * Falls back to the configured agent's avatarImage, then the + * global floatingButtonImage. + */ + image?: string; + /** Whether to show the resolved agent image above the embedded chat. */ + showImage?: boolean; +}; + +export type AgentButtonBlockConfig = { + type: "button"; + text: string; + /** Prompt sent to the opened chat. */ + prompt?: string; + /** Name of a saved quick prompt to resolve at click time. */ + promptName?: string; + agent?: string; + /** Where to open the chat when clicked. */ + viewType?: "right-pane" | "floating" | "editor-tab" | "embedded"; + /** Send immediately on open. */ + autoSend?: boolean; + /** Hide the button after it is clicked (until the note is re-rendered). */ + hideAfterClick?: boolean; + /** Whether to show the resolved agent image inside the button. */ + showImage?: boolean; + /** Alignment of the rendered button block within the note. */ + align?: "left" | "center" | "right"; +}; + +export type AgentBlockConfig = AgentChatBlockConfig | AgentButtonBlockConfig; + +export type AgentBlockParseResult = + | { ok: true; config: AgentBlockConfig } + | { ok: false; error: string }; + +const VALID_TYPES = new Set(["chat", "button"]); +const VALID_VIEW_TYPES = new Set([ + "right-pane", + "floating", + "editor-tab", + "embedded", +]); +const VALID_NOTE_CONTEXTS = new Set(["hosting"]); +const VALID_ALIGNMENTS = new Set(["left", "center", "right"]); + +function normalizeViewType( + value: string | undefined, +): AgentButtonBlockConfig["viewType"] | undefined { + if (!value) return undefined; + if (value === "right" || value === "right-tab") return "right-pane"; + if (value === "float" || value === "floating-chat") return "floating"; + if (value === "tab") return "editor-tab"; + if (value === "embed" || value === "embeddable") return "embedded"; + return VALID_VIEW_TYPES.has(value) + ? (value as AgentButtonBlockConfig["viewType"]) + : undefined; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : undefined; +} + +function asBoolean(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + +function showImageValue(obj: Record): boolean | undefined { + const explicit = asBoolean(obj.showImage); + if (typeof explicit === "boolean") return explicit; + const hideImage = asBoolean(obj.hideImage); + if (typeof hideImage === "boolean") return !hideImage; + return obj.image === false ? false : undefined; +} + +function dedent(source: string): string { + const normalized = source.replace(/\r\n?/g, "\n"); + const lines = normalized.split("\n"); + const indents = lines + .filter((line) => line.trim().length > 0) + .map((line) => line.match(/^[ \t]*/)?.[0].length ?? 0); + const minIndent = indents.length > 0 ? Math.min(...indents) : 0; + + if (minIndent === 0) return normalized.trim(); + + return lines + .map((line) => (line.trim().length > 0 ? line.slice(minIndent) : line)) + .join("\n") + .trim(); +} + +function normalizeCssLength(value: string | undefined): string | undefined { + if (!value) return undefined; + return value.replace(/^(-?\d+(?:\.\d+)?)\s+(px|em|rem|vh|vw|%)$/i, "$1$2"); +} + +/** + * Parse the fence body. An empty body yields a default `chat` block. + * + * Returns a discriminated result. Callers should render an inline error + * (createDiv/createSpan, never innerHTML) when ok is false. + */ +export function parseAgentBlock(source: string): AgentBlockParseResult { + const trimmed = dedent(source); + + let raw: unknown; + if (trimmed.length === 0) { + raw = {}; + } else { + try { + raw = parseYaml(trimmed); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + return { ok: false, error: `Invalid YAML: ${message}` }; + } + } + + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { + return { + ok: false, + error: "Block body must be a YAML mapping (key: value pairs).", + }; + } + + const obj = raw as Record; + const typeValue = asString(obj.type) ?? "chat"; + + if (!VALID_TYPES.has(typeValue)) { + return { + ok: false, + error: `Unknown type: "${typeValue}". Expected "chat" or "button".`, + }; + } + + if (typeValue === "chat") { + const rawNoteContext = asString(obj.noteContext); + const noteContext = rawNoteContext + ? VALID_NOTE_CONTEXTS.has(rawNoteContext) + ? (rawNoteContext as AgentChatBlockConfig["noteContext"]) + : undefined + : undefined; + if (rawNoteContext && !noteContext) { + return { + ok: false, + error: `Unknown noteContext: "${rawNoteContext}". Expected "hosting".`, + }; + } + + const config: AgentChatBlockConfig = { + type: "chat", + agent: asString(obj.agent), + model: asString(obj.model), + height: normalizeCssLength(asString(obj.height)), + persist: asBoolean(obj.persist) ?? false, + noteContext, + image: asString(obj.image), + showImage: showImageValue(obj), + }; + return { ok: true, config }; + } + + const text = asString(obj.text); + if (!text) { + return { + ok: false, + error: 'Button block requires a non-empty "text" field.', + }; + } + + const prompt = asString(obj.prompt); + const promptName = asString(obj.promptName); + if (!prompt && !promptName) { + return { + ok: false, + error: 'Button block requires a non-empty "prompt" or "promptName" field.', + }; + } + + const rawViewType = asString(obj.viewType); + const viewType = normalizeViewType(rawViewType); + if (rawViewType && !viewType) { + return { + ok: false, + error: `Unknown viewType: "${rawViewType}". Expected "right-pane", "floating", "editor-tab", or "embedded".`, + }; + } + + const rawAlign = asString(obj.align); + const align = rawAlign + ? VALID_ALIGNMENTS.has(rawAlign) + ? (rawAlign as AgentButtonBlockConfig["align"]) + : undefined + : undefined; + if (rawAlign && !align) { + return { + ok: false, + error: `Unknown align: "${rawAlign}". Expected "left", "center", or "right".`, + }; + } + + const config: AgentButtonBlockConfig = { + type: "button", + text, + prompt, + promptName, + agent: asString(obj.agent), + viewType: viewType ?? "right-pane", + autoSend: asBoolean(obj.autoSend) ?? false, + // Left undefined when unset so it can fall back to the referenced + // quick prompt's hideAfterClick setting at click time. + hideAfterClick: asBoolean(obj.hideAfterClick), + showImage: showImageValue(obj), + align: align ?? "left", + }; + return { ok: true, config }; +} diff --git a/src/utils/resolve-image-src.ts b/src/utils/resolve-image-src.ts new file mode 100644 index 00000000..35105727 --- /dev/null +++ b/src/utils/resolve-image-src.ts @@ -0,0 +1,76 @@ +/** + * Resolve a user-supplied image reference to a value usable in an . + * + * Accepts: + * - http:// or https:// URLs (returned unchanged) + * - data: URLs (returned unchanged) + * - vault-relative paths (resolved via FileSystemAdapter.getResourcePath) + * + * Returns null for empty/missing input or non-FileSystemAdapter vaults. + */ + +import { FileSystemAdapter } from "obsidian"; +import type AgentClientPlugin from "../plugin"; + +export function resolveImageSrc( + plugin: AgentClientPlugin, + value: string | undefined | null, +): string | null { + if (!value) return null; + const trimmed = value.trim(); + if (trimmed.length === 0) return null; + + if (/^(https?:|data:)/i.test(trimmed)) { + return trimmed; + } + + const adapter = plugin.app.vault.adapter; + if (adapter instanceof FileSystemAdapter) { + return adapter.getResourcePath(trimmed); + } + return null; +} + +/** Image file extensions recognized when classifying an icon reference. */ +const IMAGE_EXTENSION_RE = /\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)$/i; + +/** + * Classify an icon reference as either a Lucide icon id or an image reference. + * + * Heuristic: a value that looks like an http(s)/data URL, contains a path + * separator, or ends in a known image extension is treated as an image; + * otherwise it is treated as a Lucide icon id (a kebab-case slug like + * "sparkles" or "wand-2"). Returns null for empty/missing input. + */ +export function classifyIconRef( + value: string | undefined | null, +): + | { kind: "image"; value: string } + | { kind: "lucide"; value: string } + | null { + if (!value) return null; + const trimmed = value.trim(); + if (trimmed.length === 0) return null; + + if ( + /^(https?:|data:)/i.test(trimmed) || + /[/\\]/.test(trimmed) || + IMAGE_EXTENSION_RE.test(trimmed) + ) { + return { kind: "image", value: trimmed }; + } + return { kind: "lucide", value: trimmed }; +} + +export function getAgentAvatarImage( + plugin: AgentClientPlugin, + agentId: string | undefined, +): string | undefined { + if (!agentId) return undefined; + const settings = plugin.settings; + if (agentId === settings.claude.id) return settings.claude.avatarImage; + if (agentId === settings.codex.id) return settings.codex.avatarImage; + if (agentId === settings.gemini.id) return settings.gemini.avatarImage; + return settings.customAgents.find((agent) => agent.id === agentId) + ?.avatarImage; +} diff --git a/styles.css b/styles.css index a1100646..6d7f1cd2 100644 --- a/styles.css +++ b/styles.css @@ -143,6 +143,14 @@ If your plugin does not need CSS, delete this file. border-radius: 8px; } +.agent-client-quick-prompt-setting { + padding: 12px 16px; + margin-bottom: 16px; + background-color: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + border-radius: 8px; +} + /* ===== Loading Indicator ===== */ .agent-client-loading-indicator { display: flex; @@ -523,6 +531,9 @@ If your plugin does not need CSS, delete this file. } .agent-client-chat-view-header-title { + display: inline-flex; + align-items: center; + gap: 6px; flex: 1 1 0; min-width: 0; padding-left: var(--size-4-1); @@ -533,6 +544,31 @@ If your plugin does not need CSS, delete this file. white-space: nowrap; } +.agent-client-header-agent-avatar, +.agent-client-toolbar-agent-avatar, +.agent-client-agent-avatar { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 18px; + width: 18px !important; + height: 18px !important; + border-radius: 5px; + object-fit: cover; + color: var(--text-muted); +} + +.agent-client-header-agent-avatar svg, +.agent-client-toolbar-agent-avatar svg, +.agent-client-agent-avatar svg { + width: 16px; + height: 16px; +} + +.agent-client-toolbar-agent-avatar { + margin-right: 2px; +} + .agent-client-chat-view-header-update { flex: 0 1 auto; padding: 2px 8px; @@ -811,6 +847,139 @@ If your plugin does not need CSS, delete this file. background-color: var(--background-secondary); } +.agent-client-quick-prompt-strip { + display: flex; + gap: 6px; + /* "safe" prevents the leftmost chip being clipped/unreachable when the + row overflows: centers when chips fit, falls back to start otherwise. */ + justify-content: safe center; + margin-bottom: 6px; + overflow-x: auto; + overflow-y: hidden; + padding: 2px 4px 4px; + /* Scrollable but with no visible scrollbar (still scrolls via wheel/trackpad). */ + scrollbar-width: none; + -ms-overflow-style: none; +} + +.agent-client-quick-prompt-strip::-webkit-scrollbar { + display: none; +} + +.agent-client-quick-prompt-chip { + display: inline-flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; + max-width: 260px; + min-height: 34px; + padding: 6px 12px !important; + border: 1px solid var(--background-modifier-border) !important; + border-radius: 6px !important; + background: var(--background-secondary) !important; + color: var(--text-normal) !important; + box-shadow: none !important; + font-size: var(--font-ui-medium); + font-weight: 500; + cursor: pointer; +} + +.agent-client-quick-prompt-chip:hover { + background: var(--background-modifier-hover) !important; +} + +.agent-client-quick-prompt-chip-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 16px; + width: 16px; + height: 16px; + color: var(--text-muted); +} + +.agent-client-quick-prompt-chip-icon svg { + width: 16px; + height: 16px; +} + +.agent-client-quick-prompt-chip-image { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 16px; + width: 16px; + height: 16px; + border-radius: 4px; + object-fit: cover; + color: var(--text-muted); +} + +.agent-client-quick-prompt-chip-image svg { + width: 14px; + height: 14px; +} + +.agent-client-quick-prompt-chip-text { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.agent-client-recent-chat-chip:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.agent-client-quick-prompt-suggestion { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.agent-client-quick-prompt-suggestion-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 18px; + width: 18px; + height: 18px; + color: var(--text-muted); +} + +.agent-client-quick-prompt-suggestion-icon svg { + width: 18px; + height: 18px; +} + +.agent-client-quick-prompt-suggestion-image { + flex: 0 0 18px; + width: 18px; + height: 18px; + border-radius: 4px; + object-fit: cover; +} + +.agent-client-quick-prompt-suggestion-text { + min-width: 0; + flex: 1 1 auto; +} + +.agent-client-quick-prompt-suggestion-name { + font-weight: 600; + color: var(--text-normal); +} + +.agent-client-quick-prompt-suggestion-preview { + font-size: var(--font-ui-smaller); + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + /* Auto-mention inline display */ .agent-client-auto-mention-inline { display: flex; @@ -2134,174 +2303,321 @@ If your plugin does not need CSS, delete this file. min-height: 0; } -/* Floating chat instance selector menu */ -.agent-client-floating-instance-menu { - position: fixed; - bottom: 100px; - right: 20px; - min-width: 220px; - background: var(--background-primary); - border: 1px solid var(--background-modifier-border); - border-radius: 6px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); - overflow: hidden; +/* ===== Agent Selector (InlineHeader) ===== */ +.agent-client-agent-selector { + display: inline-flex; + align-items: center; + justify-content: flex-start; + padding: 0 4px; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.1s ease; + height: 20px; + max-width: 160px; + width: 160px; } -.agent-client-floating-instance-menu-header { - padding: 12px 16px; - font-weight: 600; - font-size: 13px; - color: var(--text-muted); - background: var(--background-secondary); - border-bottom: 1px solid var(--background-modifier-border); +.agent-client-agent-selector:hover { + background-color: var(--background-modifier-hover); } -.agent-client-floating-instance-menu-item { - padding: 10px 16px; - cursor: pointer; - font-size: 13px; - color: var(--text-normal); - transition: background-color 0.1s; - display: flex; +/* Agent label (single agent) */ +.agent-client-agent-label { + display: inline-flex; align-items: center; - gap: 8px; + gap: 6px; + color: var(--text-normal); + font-size: 12px; } -.agent-client-floating-instance-menu-label { - flex: 1; +/* Image-capable selectors used where native select options cannot render avatars. */ +.agent-client-image-select { + position: relative; + min-width: 0; } -.agent-client-floating-instance-menu-item:hover { - background: var(--background-modifier-hover); +.agent-client-agent-image-select { + width: 100%; } -.agent-client-floating-instance-menu-close { - width: 20px; - height: 20px; - padding: 0 !important; +.agent-client-image-select-trigger { + display: inline-flex; + align-items: center; + justify-content: flex-start !important; + gap: 6px; + width: 100%; + max-width: 180px; + min-height: 22px; + padding: 0 2px !important; margin: 0 !important; border: none !important; - border-radius: 0 !important; - background-color: transparent !important; + border-radius: 4px !important; + background: transparent !important; box-shadow: none !important; - color: var(--text-muted); + color: var(--text-normal) !important; + font: inherit; + font-size: var(--font-ui-smaller); + text-align: left; cursor: pointer; - display: flex; +} + +.agent-client-agent-image-select .agent-client-image-select-trigger { + max-width: none; +} + +.agent-client-image-select-trigger:hover { + background: var(--background-modifier-hover) !important; +} + +.agent-client-image-select-label { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: left; +} + +.agent-client-image-select-chevron { + display: inline-flex; align-items: center; justify-content: center; - font-size: 16px; - outline: none !important; - appearance: none; + flex: 0 0 12px; + margin-left: auto; + color: var(--text-faint); } -.agent-client-floating-instance-menu-close:hover { - background-color: var(--background-modifier-hover) !important; - color: var(--interactive-accent); +.agent-client-image-select-chevron svg { + width: 12px; + height: 12px; +} + +.agent-client-image-select-image { + flex: 0 0 18px; + width: 18px; + height: 18px; + border-radius: 5px; + object-fit: cover; } -/* ===== Code Block Chat View ===== */ -/* TODO(code-block): Styles for future code block chat view */ -.agent-client-code-block-container { +.agent-client-image-select-menu { + z-index: 1000; + max-height: 260px; + overflow-y: auto; + padding: 4px; + border: 1px solid var(--background-modifier-border); + border-radius: 6px; + background: var(--background-primary); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18); +} + +.agent-client-image-select-option { + display: flex; + align-items: center; + justify-content: flex-start !important; + gap: 8px; + width: 100%; + padding: 6px 8px !important; + border: none !important; + border-radius: 4px !important; + background: transparent !important; + box-shadow: none !important; + color: var(--text-normal) !important; + text-align: left; + cursor: pointer; +} + +.agent-client-image-select-option:hover, +.agent-client-image-select-option.is-selected { + background: var(--background-modifier-hover) !important; +} + +.agent-client-image-select-option-text { display: flex; flex-direction: column; + align-items: flex-start; + min-width: 0; + text-align: left; +} + +.agent-client-image-select-description { + color: var(--text-muted); + font-size: var(--font-ui-smaller); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.agent-client-config-options-container { + position: relative; +} + +/* ============================================================ + Embedded agent-client code blocks + ============================================================ */ + +.agent-client-code-block-error { + border: 1px solid var(--background-modifier-error-border, var(--text-error)); + background: var(--background-modifier-error); + color: var(--text-on-accent, #fff); + padding: 10px 14px; + border-radius: 6px; + font-size: 0.95em; + margin: 4px 0; + font-family: var(--font-interface); +} + +.agent-client-code-block-error-label { + font-weight: 700; + margin-right: 4px; + color: var(--text-on-accent, #fff); +} + +.agent-client-code-block-error-source { + margin-top: 8px; + padding: 6px 10px; + background: rgba(0, 0, 0, 0.25); + border-radius: 4px; + font-family: var(--font-monospace); + font-size: 0.85em; + color: var(--text-on-accent, #fff); + white-space: pre-wrap; + word-break: break-word; + max-height: 200px; + overflow: auto; +} + +.agent-client-code-block-host { + margin: 8px 0; border: 1px solid var(--background-modifier-border); border-radius: 8px; - background: var(--background-primary); overflow: hidden; + background: var(--background-primary); } -.agent-client-code-block-content { +.agent-client-code-block-chat { display: flex; flex-direction: column; - padding: 8px; + height: var(--ac-embedded-max-height, 520px); + min-height: 320px; + max-height: min(var(--ac-embedded-max-height, 520px), 80vh); + overflow: hidden; } -.agent-client-code-block-status { +.agent-client-code-block-chat-avatar-row { display: flex; align-items: center; - justify-content: space-between; - padding: 4px 8px; + justify-content: center; + min-height: 68px; + padding: 10px 12px; + border-bottom: 1px solid var(--background-modifier-border); background: var(--background-secondary); - border-radius: 4px; - margin-bottom: 4px; - flex-wrap: wrap; - gap: 8px; } -.agent-client-code-block-status-actions { +.agent-client-code-block-chat-avatar { + display: block; + flex: 0 0 48px; + width: 48px !important; + height: 48px !important; + min-width: 48px; + min-height: 48px; + max-width: 48px; + max-height: 48px; + aspect-ratio: 1 / 1; + border-radius: 50%; + object-fit: cover; + overflow: hidden; + box-shadow: 0 0 0 2px var(--background-primary); +} + +.agent-client-embedded-chat-panel { + flex: 1 1 auto; + height: 100%; + min-height: 0; + overflow: hidden; + background: var(--background-primary); +} + +.agent-client-embedded-header { + flex: 0 0 auto; + border-bottom: 1px solid var(--background-modifier-border); + background: var(--background-primary); +} + +.agent-client-embedded-header .agent-client-inline-header { + min-height: 34px; + padding: 6px 10px; +} + +.agent-client-embedded-messages-container { + flex: 1 1 auto; display: flex; - align-items: center; - gap: 4px; - flex-wrap: wrap; + flex-direction: column; + min-height: 0; + overflow: hidden; } -.agent-client-code-block-messages { - max-height: 400px; +.agent-client-embedded-messages-container .agent-client-chat-view-messages { + flex: 1 1 auto; + min-height: 0; + padding: 12px 10px; overflow-y: auto; - margin-bottom: 8px; + overscroll-behavior: contain; } -/* ===== Agent Selector (InlineHeader) ===== */ -.agent-client-agent-selector { - display: inline-flex; - align-items: center; - padding: 0 4px; - border-radius: 4px; - cursor: pointer; - transition: background-color 0.1s ease; - height: 20px; - max-width: 160px; +.agent-client-embedded-chat-panel .agent-client-chat-input-container { + flex: 0 0 auto; + padding: 8px 10px 10px; + border-top: 1px solid var(--background-modifier-border); + background: var(--background-primary); } -.agent-client-agent-selector:hover { - background-color: var(--background-modifier-hover); -} +/* ============================================================ + Quick-action button blocks + ============================================================ */ -.agent-client-agent-selector-icon { +.agent-client-button-block { display: flex; - align-items: center; - pointer-events: none; - color: var(--text-faint); - margin-left: 2px; - order: 2; + margin: 4px 0; } -.agent-client-agent-selector-icon svg { - width: 12px; - height: 12px; +.agent-client-button-block-align-left { + justify-content: flex-start; } -.agent-client-agent-selector select.dropdown { - padding: 0 !important; - margin: 0 !important; - border: none !important; - border-radius: 0 !important; - background: none !important; - background-color: transparent !important; - background-image: none !important; - box-shadow: none !important; - color: var(--text-normal) !important; - font-size: 12px !important; - cursor: pointer !important; - outline: none !important; - appearance: none !important; - -webkit-appearance: none !important; - -moz-appearance: none !important; - max-width: 140px; - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; +.agent-client-button-block-align-center { + justify-content: center; } -.agent-client-agent-selector select.dropdown:hover, -.agent-client-agent-selector select.dropdown:focus { - color: var(--text-normal) !important; - background-color: transparent !important; - box-shadow: none !important; +.agent-client-button-block-align-right { + justify-content: flex-end; } -/* Agent label (single agent) */ -.agent-client-agent-label { - color: var(--text-normal); - font-size: 12px; +.agent-client-button-block-button { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 14px; + border-radius: 6px; + cursor: pointer; + font-weight: 500; +} + +.agent-client-button-block-avatar { + display: block; + flex: 0 0 18px; + width: 18px !important; + height: 18px !important; + min-width: 18px; + min-height: 18px; + max-width: 18px; + max-height: 18px; + aspect-ratio: 1 / 1; + border-radius: 50%; + object-fit: cover; + overflow: hidden; +} + +.agent-client-button-block-text { + white-space: nowrap; }