mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-29 07:58:47 +02:00
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:
@@ -1328,16 +1328,39 @@ function QuickPromptSettingsSection({
|
||||
availableModels: ReadonlyArray<ModelDefinition>;
|
||||
onUpdate?: (patch: Partial<QuickPromptSettings>) => 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<string, string> = { 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 (
|
||||
<div>
|
||||
@@ -1348,147 +1371,168 @@ function QuickPromptSettingsSection({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Enable / Disable */}
|
||||
<button
|
||||
className="mt-5 flex w-full items-center justify-between rounded-lg border border-[var(--color-border)] px-4 py-3 text-left transition hover:bg-[var(--color-surface-3)]/40"
|
||||
onClick={() => onUpdate?.({ enabled: !enabled })}
|
||||
type="button"
|
||||
>
|
||||
<div>
|
||||
<span className="text-[13px] font-medium text-[var(--color-text-primary)]">
|
||||
Enable global hotkey
|
||||
</span>
|
||||
<p className="text-[12px] text-[var(--color-text-muted)]">
|
||||
Register a system-wide keyboard shortcut to summon the Quick Prompt overlay
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch enabled={enabled} />
|
||||
</button>
|
||||
|
||||
{/* Hotkey display */}
|
||||
<div className="mt-6 mb-1">
|
||||
<h3 className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">Keyboard Shortcut</h3>
|
||||
<p className="mt-0.5 text-[12px] text-[var(--color-text-muted)]">
|
||||
The key combination that opens the Quick Prompt overlay
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-3 rounded-lg border border-[var(--color-border)] px-4 py-3">
|
||||
<div className="flex gap-1.5">
|
||||
{hotkeyDisplay.split(' + ').map((key) => (
|
||||
<kbd
|
||||
key={key}
|
||||
className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface-2)] px-2 py-0.5 font-mono text-[12px] text-[var(--color-text-secondary)]"
|
||||
>
|
||||
{key.trim()}
|
||||
</kbd>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Default Model */}
|
||||
<div className="mt-8 mb-1">
|
||||
<h3 className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">Default Model</h3>
|
||||
<p className="mt-0.5 text-[12px] text-[var(--color-text-muted)]">
|
||||
The model used by Quick Prompt sessions. Can be overridden per-prompt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-1.5">
|
||||
{/* "Use workflow default" option */}
|
||||
{/* Enable / Disable + Keyboard Shortcut — compact row */}
|
||||
<div className="mt-5 flex items-center gap-3 rounded-lg border border-[var(--color-border)] px-4 py-3">
|
||||
<button
|
||||
className={`flex w-full items-center gap-3 rounded-lg border px-4 py-3 text-left transition-all duration-200 ${
|
||||
!defaultModel
|
||||
? 'border-[var(--color-border-glow)] bg-[var(--color-accent-muted)]'
|
||||
: 'border-[var(--color-border)] hover:bg-[var(--color-surface-3)]/40'
|
||||
}`}
|
||||
onClick={() => onUpdate?.({ defaultModel: undefined })}
|
||||
className="flex flex-1 items-start gap-0 text-left"
|
||||
onClick={() => onUpdate?.({ enabled: !enabled })}
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
className={`flex size-4 shrink-0 items-center justify-center rounded-full border-2 transition-all duration-200 ${
|
||||
!defaultModel ? 'border-[var(--color-accent)]' : 'border-[var(--color-border)]'
|
||||
}`}
|
||||
>
|
||||
{!defaultModel && <div className="size-2 rounded-full bg-[var(--color-accent)]" />}
|
||||
</div>
|
||||
<div>
|
||||
<span className={`text-[13px] font-medium ${!defaultModel ? 'text-[var(--color-text-primary)]' : 'text-[var(--color-text-secondary)]'}`}>
|
||||
Use workflow default
|
||||
<div className="flex-1">
|
||||
<span className="text-[13px] font-medium text-[var(--color-text-primary)]">
|
||||
Global hotkey
|
||||
</span>
|
||||
<p className="text-[12px] text-[var(--color-text-muted)]">
|
||||
Use whichever model is configured in the scratchpad workflow
|
||||
</p>
|
||||
<div className="mt-1.5 flex items-center gap-1">
|
||||
{hotkeyKeys.map((key) => (
|
||||
<kbd
|
||||
key={key}
|
||||
className="rounded-[5px] border border-[var(--color-border)] bg-[var(--color-surface-2)] px-2 py-[3px] font-mono text-[11px] font-medium leading-none text-[var(--color-text-secondary)] shadow-sm shadow-black/20"
|
||||
>
|
||||
{key}
|
||||
</kbd>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{availableModels.map((model) => {
|
||||
const isSelected = defaultModel === model.id;
|
||||
return (
|
||||
<button
|
||||
className={`flex w-full items-center gap-3 rounded-lg border px-4 py-3 text-left transition-all duration-200 ${
|
||||
isSelected
|
||||
? 'border-[var(--color-border-glow)] bg-[var(--color-accent-muted)]'
|
||||
: 'border-[var(--color-border)] hover:bg-[var(--color-surface-3)]/40'
|
||||
}`}
|
||||
key={model.id}
|
||||
onClick={() => onUpdate?.({ defaultModel: model.id })}
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
className={`flex size-4 shrink-0 items-center justify-center rounded-full border-2 transition-all duration-200 ${
|
||||
isSelected ? 'border-[var(--color-accent)]' : 'border-[var(--color-border)]'
|
||||
}`}
|
||||
>
|
||||
{isSelected && <div className="size-2 rounded-full bg-[var(--color-accent)]" />}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<span className={`text-[13px] font-medium ${isSelected ? 'text-[var(--color-text-primary)]' : 'text-[var(--color-text-secondary)]'}`}>
|
||||
{model.name}
|
||||
</span>
|
||||
{model.tier && (
|
||||
<span className="ml-2 rounded bg-[var(--color-surface-3)] px-1.5 py-px text-[10px] text-[var(--color-text-muted)]">
|
||||
{model.tier}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button onClick={() => onUpdate?.({ enabled: !enabled })} type="button">
|
||||
<ToggleSwitch enabled={enabled} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Reasoning Effort */}
|
||||
{availableModels.some((m) => m.supportedReasoningEfforts?.length) && (
|
||||
<>
|
||||
<div className="mt-8 mb-1">
|
||||
<h3 className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">Reasoning Effort</h3>
|
||||
<p className="mt-0.5 text-[12px] text-[var(--color-text-muted)]">
|
||||
Default reasoning effort for Quick Prompt. Higher effort produces more thorough answers.
|
||||
</p>
|
||||
</div>
|
||||
{/* Default Model — compact dropdown selector */}
|
||||
<div className="mt-6">
|
||||
<h3 className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">Default Model</h3>
|
||||
<p className="mt-0.5 text-[12px] text-[var(--color-text-muted)]">
|
||||
Override the workflow model for Quick Prompt sessions
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex gap-2">
|
||||
<div className="relative mt-3">
|
||||
{/* Trigger button */}
|
||||
<button
|
||||
onClick={() => setModelDropdownOpen((prev) => !prev)}
|
||||
className={`flex w-full items-center gap-3 rounded-lg border px-4 py-3 text-left transition ${
|
||||
modelDropdownOpen
|
||||
? 'border-[var(--color-accent)]/40 bg-[var(--color-accent-muted)]'
|
||||
: 'border-[var(--color-border)] hover:bg-[var(--color-surface-3)]/40'
|
||||
}`}
|
||||
type="button"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={modelDropdownOpen}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<span className="text-[13px] font-medium text-[var(--color-text-primary)]">
|
||||
{resolvedModel?.name ?? 'Workflow default'}
|
||||
</span>
|
||||
{resolvedModel?.tier && (
|
||||
<span className={`ml-2 inline-flex items-center gap-1 rounded-[4px] px-1.5 py-px text-[10px] font-medium ${
|
||||
resolvedModel.tier === 'premium' ? 'bg-amber-400/10 text-amber-400' :
|
||||
resolvedModel.tier === 'fast' ? 'bg-emerald-400/10 text-emerald-400' :
|
||||
'bg-[var(--color-accent-muted)] text-[var(--color-text-accent)]'
|
||||
}`}>
|
||||
{resolvedModel.tier}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight className={`size-4 text-[var(--color-text-muted)] transition-transform ${modelDropdownOpen ? 'rotate-90' : ''}`} />
|
||||
</button>
|
||||
|
||||
{/* Dropdown */}
|
||||
{modelDropdownOpen && (
|
||||
<div className="absolute top-full left-0 right-0 z-10 mt-1 overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-1)] shadow-xl shadow-black/40">
|
||||
<div className="max-h-[280px] overflow-y-auto p-1.5">
|
||||
{/* Workflow default option */}
|
||||
<button
|
||||
onClick={() => { onUpdate?.({ defaultModel: undefined }); setModelDropdownOpen(false); }}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left transition ${
|
||||
!defaultModel
|
||||
? '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={!defaultModel}
|
||||
>
|
||||
<span className="flex-1 text-[12px] font-medium">Workflow default</span>
|
||||
{!defaultModel && <CircleCheck className="size-3.5 text-[var(--color-accent)]" />}
|
||||
</button>
|
||||
|
||||
{/* Grouped models */}
|
||||
{groupedModels.map((group) => (
|
||||
<div key={group.tier}>
|
||||
<div className="mx-2 mt-2 mb-1 border-t border-[var(--color-border-subtle)]/50" />
|
||||
<div className="px-2.5 pt-1.5 pb-0.5 text-[10px] font-semibold tracking-wider text-[var(--color-text-muted)] uppercase">
|
||||
{group.label}
|
||||
</div>
|
||||
{group.models.map((model) => {
|
||||
const isSelected = defaultModel === model.id;
|
||||
return (
|
||||
<button
|
||||
key={model.id}
|
||||
onClick={() => { onUpdate?.({ defaultModel: model.id }); setModelDropdownOpen(false); }}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-3 py-[7px] 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 truncate text-[12px] font-medium">{model.name}</span>
|
||||
{model.supportedReasoningEfforts?.length ? (
|
||||
<span className="text-[9px] font-semibold tracking-wide text-[var(--color-text-muted)] uppercase">reasoning</span>
|
||||
) : null}
|
||||
{isSelected && <CircleCheck className="size-3.5 flex-none text-[var(--color-accent)]" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reasoning Effort — only shown when selected model supports it */}
|
||||
{modelSupportsReasoning ? (
|
||||
<div className="mt-6">
|
||||
<h3 className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">Reasoning Effort</h3>
|
||||
<p className="mt-0.5 text-[12px] text-[var(--color-text-muted)]">
|
||||
Higher effort produces more thorough answers but takes longer
|
||||
</p>
|
||||
|
||||
<div className="mt-3 flex gap-1.5">
|
||||
{([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 (
|
||||
<button
|
||||
key={label}
|
||||
key={displayLabel}
|
||||
onClick={() => onUpdate?.({ defaultReasoningEffort: effort })}
|
||||
className={`flex-1 rounded-lg border py-2.5 text-[13px] font-medium transition-all duration-200 ${
|
||||
className={`flex-1 rounded-lg border py-2 text-[12px] font-medium transition-all duration-150 ${
|
||||
isActive
|
||||
? 'border-[var(--color-border-glow)] bg-[var(--color-accent-muted)] text-[var(--color-text-primary)]'
|
||||
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-3)]/40'
|
||||
? 'border-[var(--color-accent)]/40 bg-[var(--color-accent)]/12 text-[var(--color-text-accent)]'
|
||||
: 'border-[var(--color-border)] text-[var(--color-text-muted)] hover:border-[var(--color-border-glow)] hover:text-[var(--color-text-secondary)]'
|
||||
}`}
|
||||
type="button"
|
||||
>
|
||||
{label.charAt(0).toUpperCase() + label.slice(1)}
|
||||
{displayLabel}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : defaultModel ? (
|
||||
<p className="mt-4 text-[11px] text-[var(--color-text-muted)]">
|
||||
{resolvedModel?.name ?? 'Selected model'} does not support configurable reasoning effort.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -8,39 +8,36 @@ interface QuickPromptActionsProps {
|
||||
|
||||
export function QuickPromptActions({ onDiscard, onClose, onContinueInAryx }: QuickPromptActionsProps) {
|
||||
return (
|
||||
<div className="qp-actions-enter flex items-center gap-2 border-t border-[var(--color-border-subtle)] px-5 py-3">
|
||||
{/* Discard — destructive, muted */}
|
||||
<div className="qp-actions-enter flex items-center gap-1.5 border-t border-[var(--color-border-subtle)]/60 px-4 py-2.5">
|
||||
<button
|
||||
onClick={onDiscard}
|
||||
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-[12px] font-medium text-[var(--color-text-muted)] transition hover:bg-[var(--color-status-error)]/10 hover:text-[var(--color-status-error)]"
|
||||
className="flex items-center gap-1.5 rounded-lg px-3 py-[6px] text-[11px] font-medium text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-status-error)]/8 hover:text-[var(--color-status-error)]"
|
||||
type="button"
|
||||
title="Delete this session"
|
||||
title="Delete this session permanently"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
<Trash2 className="size-3" />
|
||||
Discard
|
||||
</button>
|
||||
|
||||
{/* Close — neutral, preserves session */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-[12px] font-medium text-[var(--color-text-secondary)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
|
||||
className="flex items-center gap-1.5 rounded-lg px-3 py-[6px] text-[11px] font-medium text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
|
||||
type="button"
|
||||
title="Close and keep session"
|
||||
title="Close and keep session for later"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
<X className="size-3" />
|
||||
Close
|
||||
</button>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Continue in Aryx — primary action */}
|
||||
<button
|
||||
onClick={onContinueInAryx}
|
||||
className="brand-gradient-bg flex items-center gap-1.5 rounded-lg px-4 py-1.5 text-[12px] font-semibold text-white shadow-md shadow-[var(--color-accent)]/15 transition hover:shadow-lg hover:shadow-[var(--color-accent)]/25"
|
||||
className="brand-gradient-bg flex items-center gap-1.5 rounded-lg px-4 py-[6px] text-[11px] font-semibold text-white shadow-sm shadow-[var(--color-accent)]/15 transition-all hover:shadow-md hover:shadow-[var(--color-accent)]/25 hover:brightness-110"
|
||||
type="button"
|
||||
>
|
||||
Continue in Aryx
|
||||
<ArrowRight className="size-3.5" />
|
||||
<ArrowRight className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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<HTMLTextAreaElement>(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 (
|
||||
<div className="relative flex flex-col">
|
||||
{/* Text input row */}
|
||||
<div className="flex items-start gap-3 px-5 pt-4 pb-3">
|
||||
{/* Spark icon */}
|
||||
<div className="mt-0.5 flex-none">
|
||||
{/* Primary input area */}
|
||||
<div className="flex items-start gap-3 px-5 pt-4 pb-2">
|
||||
<div className="mt-1 flex-none">
|
||||
{phase === 'streaming' ? (
|
||||
<Loader2 className="size-[18px] animate-spin text-[var(--color-accent)]" />
|
||||
<Loader2 className="size-[18px] animate-spin text-[var(--color-accent)]" aria-label="Processing" />
|
||||
) : (
|
||||
<Zap className="size-[18px] text-[var(--color-text-muted)]" />
|
||||
<Sparkles className="size-[18px] text-[var(--color-text-muted)]" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Textarea */}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask anything..."
|
||||
disabled={isDisabled}
|
||||
placeholder="Ask anything…"
|
||||
disabled={phase === 'streaming'}
|
||||
rows={1}
|
||||
className="auto-resize-textarea min-h-[28px] max-h-[120px] flex-1 resize-none bg-transparent font-[var(--font-body)] text-[14px] leading-[1.6] text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-muted)] disabled:opacity-40"
|
||||
className="min-h-[28px] max-h-[120px] flex-1 resize-none bg-transparent font-[var(--font-body)] text-[14px] leading-[1.6] text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-muted)]/60 disabled:opacity-40"
|
||||
aria-label="Quick prompt input"
|
||||
/>
|
||||
|
||||
{/* Cancel button during streaming */}
|
||||
{phase === 'streaming' && (
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="mt-0.5 flex-none rounded-md px-2.5 py-1 text-[12px] font-medium text-[var(--color-text-secondary)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
|
||||
className="mt-0.5 flex flex-none items-center gap-1.5 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-2)] px-2.5 py-1 text-[11px] font-medium text-[var(--color-text-secondary)] transition hover:border-[var(--color-status-error)]/40 hover:bg-[var(--color-status-error)]/10 hover:text-[var(--color-status-error)]"
|
||||
type="button"
|
||||
aria-label="Stop generating"
|
||||
>
|
||||
<Square className="size-2.5 fill-current" />
|
||||
Stop
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Model selector row */}
|
||||
<div className="flex items-center gap-2 border-t border-[var(--color-border-subtle)] px-5 py-2">
|
||||
{/* Footer bar: model selector + shortcuts */}
|
||||
<div className="flex items-center gap-2 border-t border-[var(--color-border-subtle)]/60 px-5 py-2">
|
||||
<button
|
||||
onClick={() => setModelSelectorOpen((prev) => !prev)}
|
||||
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]"
|
||||
className={`flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-[11px] transition ${
|
||||
modelSelectorOpen
|
||||
? 'bg-[var(--color-accent-muted)] text-[var(--color-text-accent)]'
|
||||
: 'text-[var(--color-text-muted)] hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]'
|
||||
}`}
|
||||
type="button"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={modelSelectorOpen}
|
||||
>
|
||||
<span className="font-medium">{selectedModel?.name ?? 'Select model'}</span>
|
||||
{selectedReasoning && (
|
||||
<span className="rounded bg-[var(--color-accent-muted)] px-1.5 py-px text-[10px] text-[var(--color-text-accent)]">
|
||||
{selectedReasoning}
|
||||
<span className="max-w-[160px] truncate font-medium">{selectedModel?.name ?? 'Select model'}</span>
|
||||
{reasoningLabel && (
|
||||
<span className="rounded-[4px] bg-[var(--color-accent)]/15 px-1.5 py-px text-[9px] font-semibold tracking-wide text-[var(--color-text-accent)] uppercase">
|
||||
{reasoningLabel}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown className="size-3" />
|
||||
<ChevronDown className={`size-3 transition-transform ${modelSelectorOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
<span className="ml-auto text-[10px] text-[var(--color-text-muted)] select-none opacity-60">
|
||||
Enter ↵ to send · Esc to dismiss
|
||||
<span className="ml-auto flex items-center gap-3 text-[10px] text-[var(--color-text-muted)] select-none opacity-50">
|
||||
<kbd className="rounded border border-[var(--color-border-subtle)] bg-[var(--color-surface-2)] px-1 py-px font-mono text-[9px]">
|
||||
↵
|
||||
</kbd>
|
||||
<span>Send</span>
|
||||
<kbd className="rounded border border-[var(--color-border-subtle)] bg-[var(--color-surface-2)] px-1 py-px font-mono text-[9px]">
|
||||
Esc
|
||||
</kbd>
|
||||
<span>Dismiss</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -31,40 +31,43 @@ export function QuickPromptResponse({
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="qp-response-enter max-h-[min(55vh,520px)] overflow-y-auto border-t border-[var(--color-border-subtle)]"
|
||||
className="qp-response-enter max-h-[min(50vh,480px)] overflow-y-auto overscroll-contain border-t border-[var(--color-border-subtle)]/60"
|
||||
>
|
||||
{/* Error state */}
|
||||
{phase === 'error' && error && (
|
||||
<div className="flex items-start gap-3 px-5 py-4">
|
||||
<AlertCircle className="mt-0.5 size-4 flex-none text-[var(--color-status-error)]" />
|
||||
<p className="text-[13px] leading-relaxed text-[var(--color-status-error)]">{error}</p>
|
||||
<div>
|
||||
<p className="text-[12px] font-semibold text-[var(--color-status-error)]">Something went wrong</p>
|
||||
<p className="mt-1 text-[12px] leading-relaxed text-[var(--color-status-error)]/80">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Thinking block — collapsed visualization */}
|
||||
{/* Thinking block — compact collapsed visualization */}
|
||||
{thinkingContent && (
|
||||
<div className="mx-5 mt-4 mb-2 rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-surface-0)]/50 px-4 py-3">
|
||||
<div className="flex items-center gap-2 text-[11px] font-medium text-[var(--color-text-muted)]">
|
||||
<Brain className="size-3.5" />
|
||||
<span>Thinking</span>
|
||||
<div className="mx-5 mt-3 mb-1 rounded-lg border border-[var(--color-border-subtle)]/50 bg-[var(--color-surface-0)]/40 px-3.5 py-2.5">
|
||||
<div className="flex items-center gap-2 text-[10px] font-semibold tracking-wide text-[var(--color-text-muted)] uppercase">
|
||||
<Brain className="size-3" />
|
||||
<span>Reasoning</span>
|
||||
{phase === 'streaming' && !content && (
|
||||
<span className="flex gap-0.5 ml-1">
|
||||
<span className="thinking-dot inline-block size-1 rounded-full bg-[var(--color-text-muted)]" />
|
||||
<span className="thinking-dot inline-block size-1 rounded-full bg-[var(--color-text-muted)]" />
|
||||
<span className="thinking-dot inline-block size-1 rounded-full bg-[var(--color-text-muted)]" />
|
||||
<span className="ml-0.5 flex gap-[3px]">
|
||||
<span className="thinking-dot inline-block size-[3px] rounded-full bg-[var(--color-text-muted)]" />
|
||||
<span className="thinking-dot inline-block size-[3px] rounded-full bg-[var(--color-text-muted)]" />
|
||||
<span className="thinking-dot inline-block size-[3px] rounded-full bg-[var(--color-text-muted)]" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1.5 text-[12px] leading-relaxed text-[var(--color-text-muted)] line-clamp-3">
|
||||
{thinkingContent.slice(-300)}
|
||||
<p className="mt-1 text-[11px] leading-relaxed text-[var(--color-text-muted)]/70 italic line-clamp-2">
|
||||
{thinkingContent.slice(-200)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main response content */}
|
||||
{content && (
|
||||
<div className="px-5 py-4">
|
||||
<div className="markdown-content text-[13.5px] text-[var(--color-text-primary)]">
|
||||
<div className="px-5 py-3.5">
|
||||
<div className="markdown-content text-[13px] leading-[1.7] text-[var(--color-text-primary)]">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
@@ -72,13 +75,13 @@ export function QuickPromptResponse({
|
||||
|
||||
{/* Streaming indicator when no content yet */}
|
||||
{phase === 'streaming' && !content && !thinkingContent && (
|
||||
<div className="flex items-center gap-3 px-5 py-5">
|
||||
<span className="flex gap-1">
|
||||
<span className="thinking-dot inline-block size-1.5 rounded-full bg-[var(--color-accent)]" />
|
||||
<span className="thinking-dot inline-block size-1.5 rounded-full bg-[var(--color-accent)]" />
|
||||
<span className="thinking-dot inline-block size-1.5 rounded-full bg-[var(--color-accent)]" />
|
||||
<div className="flex items-center gap-3 px-5 py-4">
|
||||
<span className="flex gap-[3px]">
|
||||
<span className="thinking-dot inline-block size-[5px] rounded-full bg-[var(--color-accent)]" />
|
||||
<span className="thinking-dot inline-block size-[5px] rounded-full bg-[var(--color-accent)]" />
|
||||
<span className="thinking-dot inline-block size-[5px] rounded-full bg-[var(--color-accent)]" />
|
||||
</span>
|
||||
<span className="text-[12px] text-[var(--color-text-muted)]">Generating response…</span>
|
||||
<span className="text-[12px] text-[var(--color-text-muted)]">Generating…</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+42
-3
@@ -795,15 +795,15 @@ body {
|
||||
animation: qp-actions-in 0.2s cubic-bezier(0.16, 1, 0.3, 1) 0.05s both;
|
||||
}
|
||||
|
||||
/* Dropdown entrance */
|
||||
/* Dropdown entrance — opens upward */
|
||||
@keyframes qp-dropdown-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(calc(100% - 4px)) scale(0.98);
|
||||
transform: translateY(6px) scale(0.97);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(100%);
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -917,3 +917,42 @@ body {
|
||||
max-width: 100%;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.markdown-content code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85em;
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.15em 0.35em;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.markdown-content pre {
|
||||
background: var(--color-surface-0);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.875em 1em;
|
||||
overflow-x: auto;
|
||||
margin-top: 0.75em;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
|
||||
.markdown-content pre code {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.markdown-content a {
|
||||
color: var(--color-text-accent);
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--color-text-accent);
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.markdown-content a:hover {
|
||||
color: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user