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>
This commit is contained in:
David Kaya
2026-04-13 12:14:29 +02:00
co-authored by Copilot
parent fb5970cfa7
commit 952e69da9f
9 changed files with 413 additions and 245 deletions
@@ -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<string, { label: string; icon: typeof Crown; color: string; bg: string }> = {
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<string, ModelDefinition[]>();
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 (
<div
ref={containerRef}
className="qp-dropdown-enter absolute bottom-0 left-4 right-4 z-10 translate-y-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-1)] shadow-xl shadow-black/40"
className="qp-dropdown-enter absolute bottom-full left-3 right-3 z-10 mb-1 overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-1)] shadow-2xl shadow-black/50"
role="listbox"
aria-label="Select model"
>
{/* Model list */}
<div className="max-h-[240px] overflow-y-auto p-1.5">
{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 */}
<div className="max-h-[280px] overflow-y-auto overscroll-contain p-1.5">
{groups.map((group, gi) => {
const meta = tierMeta[group.tier];
const TierIcon = meta?.icon;
return (
<button
key={model.id}
onClick={() => onSelect(model)}
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left transition ${
isSelected
? 'bg-[var(--color-accent-muted)] text-[var(--color-text-primary)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-2)] hover:text-[var(--color-text-primary)]'
}`}
type="button"
role="option"
aria-selected={isSelected}
>
<span className="flex-1 text-[12px] font-medium">{model.name}</span>
<div key={group.tier}>
{gi > 0 && <div className="mx-2 my-1 border-t border-[var(--color-border-subtle)]/50" />}
{tier && TierIcon && (
<span className={`flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium ${tier.className}`}>
<TierIcon className="size-2.5" />
{tier.label}
{/* Group header */}
<div className="flex items-center gap-1.5 px-2.5 pt-2 pb-1">
{TierIcon && <TierIcon className={`size-2.5 ${meta.color}`} />}
<span className={`text-[10px] font-semibold tracking-wider uppercase ${meta?.color ?? 'text-[var(--color-text-muted)]'}`}>
{group.label}
</span>
)}
</div>
{isSelected && <Check className="size-3.5 flex-none text-[var(--color-accent)]" />}
</button>
{/* Models in this group */}
{group.models.map((model) => {
const isSelected = model.id === selectedModelId;
return (
<button
key={model.id}
onClick={() => onSelect(model)}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-[7px] text-left transition-colors ${
isSelected
? 'bg-[var(--color-accent-muted)] text-[var(--color-text-primary)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-2)] hover:text-[var(--color-text-primary)]'
}`}
type="button"
role="option"
aria-selected={isSelected}
>
<span className="flex-1 truncate text-[12px] font-medium">{model.name}</span>
{model.supportedReasoningEfforts?.length ? (
<Brain className="size-3 flex-none text-[var(--color-text-muted)]/60" aria-label="Supports reasoning" />
) : null}
{isSelected && <Check className="size-3.5 flex-none text-[var(--color-accent)]" />}
</button>
);
})}
</div>
);
})}
</div>
{/* Reasoning effort selector */}
{supportedReasoningEfforts && supportedReasoningEfforts.length > 0 && (
<div className="border-t border-[var(--color-border-subtle)] p-3">
<p className="mb-2 text-[10px] font-medium tracking-wide text-[var(--color-text-muted)] uppercase">
Reasoning Effort
</p>
{/* Reasoning effort — only shown when selected model supports it */}
{supportedEfforts && supportedEfforts.length > 0 && (
<div className="border-t border-[var(--color-border-subtle)] px-3 py-2.5">
<div className="mb-2 flex items-center gap-1.5">
<Brain className="size-3 text-[var(--color-text-muted)]" />
<span className="text-[10px] font-semibold tracking-wider text-[var(--color-text-muted)] uppercase">
Reasoning Effort
</span>
</div>
<div className="flex gap-1">
{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 (
<button
key={opt.value}
onClick={() => onReasoningChange(isActive ? undefined : opt.value)}
className={`flex-1 rounded-md py-1 text-[11px] font-medium transition ${
key={lvl.value}
onClick={() => onReasoningChange(isActive ? undefined : lvl.value)}
className={`group flex-1 rounded-lg py-1.5 text-center transition-all ${
isActive
? 'bg-[var(--color-accent)] text-white'
? 'bg-[var(--color-accent)] text-white shadow-md shadow-[var(--color-accent)]/20'
: 'bg-[var(--color-surface-2)] text-[var(--color-text-muted)] hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]'
}`}
type="button"
title={lvl.description}
>
{opt.label}
<span className="text-[11px] font-semibold">{lvl.label}</span>
</button>
);
})}