From 952e69da9f12e78f63905fe12df21a15724fca6c Mon Sep 17 00:00:00 2001 From: David Kaya Date: Mon, 13 Apr 2026 12:14:29 +0200 Subject: [PATCH] fix: improve Quick Prompt popup UX and settings design - Fix window lifecycle: await renderer load before showing, debounce blur handler to prevent dropdown-induced dismiss - Increase window height from 72px to 520px for response display - Rewrite ModelSelector with tier-grouped upward-opening dropdown, reasoning effort controls, and Brain icon for reasoning models - Rewrite QuickPromptInput with visible model trigger, animated chevron, auto-resize textarea, and proper stop button - Improve QuickPromptResponse with compact thinking block, markdown code/pre/link styling, and refined spacing - Polish QuickPromptActions with consistent sizing and hover states - Redesign settings QuickPromptSettingsSection: replace flat radio list with compact grouped dropdown selector, only show reasoning effort when selected model supports it, combine hotkey toggle into single row - Add markdown code block, inline code, and link CSS styles - Fix dropdown animation direction for upward-opening selector - Handle message-complete event in QuickPromptApp Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/main/index.ts | 2 +- src/main/windows/createQuickPromptWindow.ts | 28 +- src/renderer/components/SettingsPanel.tsx | 286 ++++++++++-------- .../components/quick-prompt/ModelSelector.tsx | 156 ++++++---- .../quick-prompt/QuickPromptActions.tsx | 21 +- .../quick-prompt/QuickPromptApp.tsx | 2 + .../quick-prompt/QuickPromptInput.tsx | 73 +++-- .../quick-prompt/QuickPromptResponse.tsx | 45 +-- src/renderer/styles.css | 45 ++- 9 files changed, 413 insertions(+), 245 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 2f6e8b6..6307b0b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -83,7 +83,7 @@ async function bootstrap(): Promise { const hotkeySettings = workspace.settings.quickPrompt ?? createDefaultQuickPromptSettings(); globalHotkeyService.register(hotkeySettings, () => { const win = ensureQuickPromptWindow(); - if (win) toggleQuickPromptWindow(win); + if (win) void toggleQuickPromptWindow(win); }); // Re-register hotkey when settings change diff --git a/src/main/windows/createQuickPromptWindow.ts b/src/main/windows/createQuickPromptWindow.ts index 8556dcc..5a9edb4 100644 --- a/src/main/windows/createQuickPromptWindow.ts +++ b/src/main/windows/createQuickPromptWindow.ts @@ -9,7 +9,8 @@ const { app, BrowserWindow, screen } = electron; export function createQuickPromptWindow(): BrowserWindowType { const window = new BrowserWindow({ width: 680, - height: 72, + height: 520, + minHeight: 72, show: false, frame: false, transparent: true, @@ -19,6 +20,7 @@ export function createQuickPromptWindow(): BrowserWindowType { maximizable: false, minimizable: false, fullscreenable: false, + focusable: true, title: 'Aryx Quick Prompt', icon: resolveWindowIconPath({ appPath: app.getAppPath(), @@ -39,23 +41,37 @@ export function createQuickPromptWindow(): BrowserWindowType { void window.loadFile(join(__dirname, '../../dist/renderer/quickprompt.html')); } + // Hide on blur — but only if the window itself loses focus (not from + // devtools or transient focus changes during show/hide). window.on('blur', () => { - if (window.isVisible()) { - window.webContents.send('quick-prompt:hide'); - window.hide(); - } + // Small delay: avoid hiding during transient focus shifts (e.g. model + // selector dropdown causing a brief blur → refocus cycle). + setTimeout(() => { + if (window.isVisible() && !window.isFocused()) { + window.webContents.send('quick-prompt:hide'); + window.hide(); + } + }, 100); }); return window; } -export function toggleQuickPromptWindow(window: BrowserWindowType): void { +export async function toggleQuickPromptWindow(window: BrowserWindowType): Promise { if (window.isVisible()) { window.webContents.send('quick-prompt:hide'); window.hide(); return; } + // Wait for the renderer to finish loading before showing — on the very + // first activation the page may still be loading from disk/dev-server. + if (window.webContents.isLoading()) { + await new Promise((resolve) => { + window.webContents.once('did-finish-load', () => resolve()); + }); + } + centerOnActiveDisplay(window); window.webContents.send('quick-prompt:show'); window.show(); diff --git a/src/renderer/components/SettingsPanel.tsx b/src/renderer/components/SettingsPanel.tsx index 52b6d12..0162976 100644 --- a/src/renderer/components/SettingsPanel.tsx +++ b/src/renderer/components/SettingsPanel.tsx @@ -1328,16 +1328,39 @@ function QuickPromptSettingsSection({ availableModels: ReadonlyArray; onUpdate?: (patch: Partial) => void; }) { + const [modelDropdownOpen, setModelDropdownOpen] = useState(false); + const enabled = settings?.enabled ?? true; const hotkey = settings?.hotkey ?? 'Alt+Shift+C'; const defaultModel = settings?.defaultModel; const defaultReasoning = settings?.defaultReasoningEffort; - const hotkeyDisplay = hotkey + const resolvedModel = defaultModel ? availableModels.find((m) => m.id === defaultModel) : undefined; + const modelSupportsReasoning = resolvedModel?.supportedReasoningEfforts?.length; + + const hotkeyKeys = hotkey .replace('Super', isMac ? '⌘' : 'Win') .replace('Alt', isMac ? '⌥' : 'Alt') .replace('Shift', '⇧') - .replace(/\+/g, ' + '); + .split('+') + .map((k) => k.trim()); + + // Group models by tier for the dropdown + const tierOrder = ['premium', 'standard', 'fast'] as const; + const tierLabels: Record = { premium: 'Premium', standard: 'Standard', fast: 'Fast' }; + const groupedModels = tierOrder + .map((tier) => ({ + tier, + label: tierLabels[tier], + models: availableModels.filter((m) => m.tier === tier), + })) + .filter((g) => g.models.length > 0); + + // Models without a tier + const untypedModels = availableModels.filter((m) => !m.tier); + if (untypedModels.length > 0) { + groupedModels.push({ tier: 'other' as never, label: 'Other', models: untypedModels }); + } return (
@@ -1348,147 +1371,168 @@ function QuickPromptSettingsSection({

- {/* Enable / Disable */} - - - {/* Hotkey display */} -
-

Keyboard Shortcut

-

- The key combination that opens the Quick Prompt overlay -

-
- -
-
- {hotkeyDisplay.split(' + ').map((key) => ( - - {key.trim()} - - ))} -
-
- - {/* Default Model */} -
-

Default Model

-

- The model used by Quick Prompt sessions. Can be overridden per-prompt. -

-
- -
- {/* "Use workflow default" option */} + {/* Enable / Disable + Keyboard Shortcut — compact row */} +
- - {availableModels.map((model) => { - const isSelected = defaultModel === model.id; - return ( - - ); - })} +
- {/* Reasoning Effort */} - {availableModels.some((m) => m.supportedReasoningEfforts?.length) && ( - <> -
-

Reasoning Effort

-

- Default reasoning effort for Quick Prompt. Higher effort produces more thorough answers. -

-
+ {/* Default Model — compact dropdown selector */} +
+

Default Model

+

+ Override the workflow model for Quick Prompt sessions +

-
+
+ {/* Trigger button */} + + + {/* Dropdown */} + {modelDropdownOpen && ( +
+
+ {/* Workflow default option */} + + + {/* Grouped models */} + {groupedModels.map((group) => ( +
+
+
+ {group.label} +
+ {group.models.map((model) => { + const isSelected = defaultModel === model.id; + return ( + + ); + })} +
+ ))} +
+
+ )} +
+
+ + {/* Reasoning Effort — only shown when selected model supports it */} + {modelSupportsReasoning ? ( +
+

Reasoning Effort

+

+ Higher effort produces more thorough answers but takes longer +

+ +
{([undefined, 'low', 'medium', 'high'] as const).map((effort) => { const isActive = defaultReasoning === effort; - const label = effort ?? 'Default'; + const label = effort ?? 'Auto'; + const displayLabel = label.charAt(0).toUpperCase() + label.slice(1); + + // Only show efforts the model actually supports (plus "Auto") + if (effort && !resolvedModel?.supportedReasoningEfforts?.includes(effort)) return null; + return ( ); })}
- - )} +
+ ) : defaultModel ? ( +

+ {resolvedModel?.name ?? 'Selected model'} does not support configurable reasoning effort. +

+ ) : null}
); } diff --git a/src/renderer/components/quick-prompt/ModelSelector.tsx b/src/renderer/components/quick-prompt/ModelSelector.tsx index 25d155e..676b280 100644 --- a/src/renderer/components/quick-prompt/ModelSelector.tsx +++ b/src/renderer/components/quick-prompt/ModelSelector.tsx @@ -1,5 +1,5 @@ -import { useEffect, useRef } from 'react'; -import { Check, Crown, Zap } from 'lucide-react'; +import { useEffect, useRef, useMemo } from 'react'; +import { Check, Crown, Zap, Brain } from 'lucide-react'; import type { ModelDefinition } from '@shared/domain/models'; import type { ReasoningEffort } from '@shared/domain/workflow'; @@ -13,19 +13,25 @@ interface ModelSelectorProps { onClose: () => void; } -const tierConfig = { - premium: { label: 'Premium', icon: Crown, className: 'text-amber-400 bg-amber-400/10' }, - standard: { label: 'Standard', icon: Zap, className: 'text-[var(--color-text-accent)] bg-[var(--color-accent-muted)]' }, - fast: { label: 'Fast', icon: Zap, className: 'text-emerald-400 bg-emerald-400/10' }, -} as const; +const tierMeta: Record = { + premium: { label: 'Premium', icon: Crown, color: 'text-amber-400', bg: 'bg-amber-400/10' }, + standard: { label: 'Standard', icon: Zap, color: 'text-[var(--color-text-accent)]', bg: 'bg-[var(--color-accent-muted)]' }, + fast: { label: 'Fast', icon: Zap, color: 'text-emerald-400', bg: 'bg-emerald-400/10' }, +}; -const reasoningOptions: { value: ReasoningEffort; label: string }[] = [ - { value: 'low', label: 'Low' }, - { value: 'medium', label: 'Medium' }, - { value: 'high', label: 'High' }, - { value: 'xhigh', label: 'Extra High' }, +const reasoningLevels: { value: ReasoningEffort; label: string; description: string }[] = [ + { value: 'low', label: 'Low', description: 'Fast, concise' }, + { value: 'medium', label: 'Med', description: 'Balanced' }, + { value: 'high', label: 'High', description: 'Thorough' }, + { value: 'xhigh', label: 'Max', description: 'Exhaustive' }, ]; +interface ModelGroup { + tier: string; + label: string; + models: ModelDefinition[]; +} + export function ModelSelector({ models, selectedModelId, @@ -58,73 +64,113 @@ export function ModelSelector({ }, [onClose]); const selectedModel = models.find((m) => m.id === selectedModelId); - const supportedReasoningEfforts = selectedModel?.supportedReasoningEfforts; + const supportedEfforts = selectedModel?.supportedReasoningEfforts; + + // Group models by tier + const groups = useMemo((): ModelGroup[] => { + const tierOrder = ['premium', 'standard', 'fast', 'other']; + const grouped = new Map(); + + for (const model of models) { + const tier = model.tier ?? 'other'; + const list = grouped.get(tier) ?? []; + list.push(model); + grouped.set(tier, list); + } + + return tierOrder + .filter((t) => grouped.has(t)) + .map((tier) => ({ + tier, + label: tier === 'other' ? 'Other' : tierMeta[tier]?.label ?? tier, + models: grouped.get(tier)!, + })); + }, [models]); return (
- {/* Model list */} -
- {models.map((model) => { - const isSelected = model.id === selectedModelId; - const tier = model.tier ? tierConfig[model.tier] : undefined; - const TierIcon = tier?.icon; + {/* Model list — grouped by tier */} +
+ {groups.map((group, gi) => { + const meta = tierMeta[group.tier]; + const TierIcon = meta?.icon; return ( - + {/* Models in this group */} + {group.models.map((model) => { + const isSelected = model.id === selectedModelId; + + return ( + + ); + })} +
); })}
- {/* Reasoning effort selector */} - {supportedReasoningEfforts && supportedReasoningEfforts.length > 0 && ( -
-

- Reasoning Effort -

+ {/* Reasoning effort — only shown when selected model supports it */} + {supportedEfforts && supportedEfforts.length > 0 && ( +
+
+ + + Reasoning Effort + +
- {reasoningOptions - .filter((opt) => supportedReasoningEfforts.includes(opt.value)) - .map((opt) => { - const isActive = selectedReasoning === opt.value; + {reasoningLevels + .filter((lvl) => supportedEfforts.includes(lvl.value)) + .map((lvl) => { + const isActive = selectedReasoning === lvl.value; return ( ); })} diff --git a/src/renderer/components/quick-prompt/QuickPromptActions.tsx b/src/renderer/components/quick-prompt/QuickPromptActions.tsx index 6a5ed08..10591d2 100644 --- a/src/renderer/components/quick-prompt/QuickPromptActions.tsx +++ b/src/renderer/components/quick-prompt/QuickPromptActions.tsx @@ -8,39 +8,36 @@ interface QuickPromptActionsProps { export function QuickPromptActions({ onDiscard, onClose, onContinueInAryx }: QuickPromptActionsProps) { return ( -
- {/* Discard — destructive, muted */} +
- {/* Close — neutral, preserves session */}
- {/* Continue in Aryx — primary action */}
); diff --git a/src/renderer/components/quick-prompt/QuickPromptApp.tsx b/src/renderer/components/quick-prompt/QuickPromptApp.tsx index 6b47cbc..21a8bc8 100644 --- a/src/renderer/components/quick-prompt/QuickPromptApp.tsx +++ b/src/renderer/components/quick-prompt/QuickPromptApp.tsx @@ -71,6 +71,8 @@ export function QuickPromptApp() { })); } setPhase('streaming'); + } else if (event.kind === 'message-complete') { + setPhase('complete'); } else if (event.kind === 'status' && event.status === 'idle') { setPhase((prev) => (prev === 'streaming' ? 'complete' : prev)); } else if (event.kind === 'error') { diff --git a/src/renderer/components/quick-prompt/QuickPromptInput.tsx b/src/renderer/components/quick-prompt/QuickPromptInput.tsx index eb465fe..d9a10de 100644 --- a/src/renderer/components/quick-prompt/QuickPromptInput.tsx +++ b/src/renderer/components/quick-prompt/QuickPromptInput.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { ChevronDown, Loader2, Zap } from 'lucide-react'; +import { ChevronDown, Loader2, Square, Sparkles } from 'lucide-react'; import type { ModelDefinition } from '@shared/domain/models'; import type { ReasoningEffort } from '@shared/domain/workflow'; @@ -33,13 +33,21 @@ export function QuickPromptInput({ const [modelSelectorOpen, setModelSelectorOpen] = useState(false); const textareaRef = useRef(null); - // Auto-focus on mount and when phase resets to idle + // Auto-focus when phase resets to idle useEffect(() => { if (phase === 'idle') { - textareaRef.current?.focus(); + setTimeout(() => textareaRef.current?.focus(), 50); } }, [phase]); + // Auto-resize textarea + useEffect(() => { + const el = textareaRef.current; + if (!el) return; + el.style.height = 'auto'; + el.style.height = `${Math.min(el.scrollHeight, 120)}px`; + }, [value]); + const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { @@ -52,65 +60,78 @@ export function QuickPromptInput({ [onSend, value, phase], ); - const isDisabled = phase === 'streaming'; + const reasoningLabel = selectedReasoning + ? selectedReasoning === 'xhigh' ? 'xHigh' : selectedReasoning.charAt(0).toUpperCase() + selectedReasoning.slice(1) + : undefined; return (
- {/* Text input row */} -
- {/* Spark icon */} -
+ {/* Primary input area */} +
+
{phase === 'streaming' ? ( - + ) : ( - + )}
- {/* Textarea */}