mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-07 04:08:45 +02:00
feat: add Quick Prompt global hotkey popup for one-off AI questions
Add a system-wide global hotkey (Win+Shift+A / Cmd+Shift+A) that summons a floating, frameless popup window for quick AI interactions from any app. Main process: - GlobalHotkeyService for registering/unregistering system-wide shortcuts - Frameless, transparent, always-on-top BrowserWindow factory - IPC handlers for send, discard, close, continue-in-aryx, cancel - Session event routing from sidecar to quick prompt window - Settings persistence for default model, hotkey, and reasoning effort Renderer (separate lightweight entry): - QuickPromptApp with session state, streaming, keyboard shortcuts - QuickPromptInput with model selector trigger and cancel support - QuickPromptResponse with streamed markdown and thinking blocks - QuickPromptActions (Discard / Close / Continue in Aryx) - ModelSelector dropdown with tier badges and reasoning effort - Glass Command Bar aesthetic with animated gradient border Settings: - Quick Prompt section in SettingsPanel with enable toggle, hotkey display, default model selector, and reasoning effort picker Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { ChevronLeft, ChevronRight, CircleCheck, Code, Cpu, FolderOpen, GitBranch, Palette, Plus, RefreshCw, Server, TriangleAlert, UserCircle, Wrench } from 'lucide-react';
|
||||
import { ChevronLeft, ChevronRight, CircleCheck, Code, Cpu, FolderOpen, GitBranch, Palette, Plus, RefreshCw, Server, Sparkles, TriangleAlert, UserCircle, Wrench } from 'lucide-react';
|
||||
|
||||
import { CopilotStatusCard } from '@renderer/components/CopilotStatusCard';
|
||||
import { WorkflowEditor } from '@renderer/components/WorkflowEditor';
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
type AppearanceTheme,
|
||||
type LspProfileDefinition,
|
||||
type McpServerDefinition,
|
||||
type QuickPromptSettings,
|
||||
type WorkspaceToolingSettings,
|
||||
} from '@shared/domain/tooling';
|
||||
import { normalizeWorkspaceAgentDefinition, findWorkspaceAgentUsages, type WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
|
||||
@@ -62,9 +63,11 @@ interface SettingsPanelProps {
|
||||
onGetQuota?: () => Promise<Record<string, QuotaSnapshot>>;
|
||||
workflowTemplates?: WorkflowTemplateDefinition[];
|
||||
onCreateWorkflowFromTemplate?: (templateId: string, name?: string) => Promise<void>;
|
||||
quickPromptSettings?: QuickPromptSettings;
|
||||
onSetQuickPromptSettings?: (patch: Partial<QuickPromptSettings>) => void;
|
||||
}
|
||||
|
||||
export type SettingsSection = 'appearance' | 'connection' | 'workflows' | 'agents' | 'mcp-servers' | 'lsp-profiles' | 'troubleshooting';
|
||||
export type SettingsSection = 'appearance' | 'connection' | 'workflows' | 'agents' | 'mcp-servers' | 'lsp-profiles' | 'quick-prompt' | 'troubleshooting';
|
||||
|
||||
interface NavItem {
|
||||
id: SettingsSection;
|
||||
@@ -82,6 +85,7 @@ const navGroups: NavGroup[] = [
|
||||
label: 'General',
|
||||
items: [
|
||||
{ id: 'appearance', label: 'Appearance', icon: <Palette className="size-3.5" /> },
|
||||
{ id: 'quick-prompt', label: 'Quick Prompt', icon: <Sparkles className="size-3.5" /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -149,6 +153,8 @@ export function SettingsPanel({
|
||||
onGetQuota,
|
||||
workflowTemplates,
|
||||
onCreateWorkflowFromTemplate,
|
||||
quickPromptSettings,
|
||||
onSetQuickPromptSettings,
|
||||
}: SettingsPanelProps) {
|
||||
const [activeSection, setActiveSection] = useState<SettingsSection>(initialSection ?? 'appearance');
|
||||
const [editingWorkflow, setEditingWorkflow] = useState<WorkflowDefinition | null>(null);
|
||||
@@ -380,6 +386,13 @@ export function SettingsPanel({
|
||||
profiles={toolingSettings.lspProfiles}
|
||||
/>
|
||||
)}
|
||||
{activeSection === 'quick-prompt' && (
|
||||
<QuickPromptSettingsSection
|
||||
settings={quickPromptSettings}
|
||||
availableModels={availableModels}
|
||||
onUpdate={onSetQuickPromptSettings}
|
||||
/>
|
||||
)}
|
||||
{activeSection === 'troubleshooting' && (
|
||||
<TroubleshootingSection
|
||||
onOpenAppDataFolder={onOpenAppDataFolder}
|
||||
@@ -1304,3 +1317,176 @@ function TroubleshootingAction({
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickPromptSettingsSection({
|
||||
settings,
|
||||
availableModels,
|
||||
onUpdate,
|
||||
}: {
|
||||
settings?: QuickPromptSettings;
|
||||
availableModels: ReadonlyArray<ModelDefinition>;
|
||||
onUpdate?: (patch: Partial<QuickPromptSettings>) => void;
|
||||
}) {
|
||||
const enabled = settings?.enabled ?? true;
|
||||
const hotkey = settings?.hotkey ?? 'Super+Shift+A';
|
||||
const defaultModel = settings?.defaultModel;
|
||||
const defaultReasoning = settings?.defaultReasoningEffort;
|
||||
|
||||
const hotkeyDisplay = hotkey
|
||||
.replace('Super', process.platform === 'darwin' ? '⌘' : 'Win')
|
||||
.replace('Shift', '⇧')
|
||||
.replace('+', ' + ');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1">
|
||||
<h3 className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">Quick Prompt</h3>
|
||||
<p className="mt-0.5 text-[12px] text-[var(--color-text-muted)]">
|
||||
Press a global hotkey to ask the AI a quick question from anywhere
|
||||
</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 */}
|
||||
<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 })}
|
||||
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
|
||||
</span>
|
||||
<p className="text-[12px] text-[var(--color-text-muted)]">
|
||||
Use whichever model is configured in the scratchpad workflow
|
||||
</p>
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
|
||||
<div className="mt-4 flex gap-2">
|
||||
{([undefined, 'low', 'medium', 'high'] as const).map((effort) => {
|
||||
const isActive = defaultReasoning === effort;
|
||||
const label = effort ?? 'Default';
|
||||
return (
|
||||
<button
|
||||
key={label}
|
||||
onClick={() => onUpdate?.({ defaultReasoningEffort: effort })}
|
||||
className={`flex-1 rounded-lg border py-2.5 text-[13px] font-medium transition-all duration-200 ${
|
||||
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'
|
||||
}`}
|
||||
type="button"
|
||||
>
|
||||
{label.charAt(0).toUpperCase() + label.slice(1)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Check, Crown, Zap } from 'lucide-react';
|
||||
|
||||
import type { ModelDefinition } from '@shared/domain/models';
|
||||
import type { ReasoningEffort } from '@shared/domain/workflow';
|
||||
|
||||
interface ModelSelectorProps {
|
||||
models: ReadonlyArray<ModelDefinition>;
|
||||
selectedModelId?: string;
|
||||
selectedReasoning?: ReasoningEffort;
|
||||
onSelect: (model: ModelDefinition) => void;
|
||||
onReasoningChange: (effort: ReasoningEffort | undefined) => void;
|
||||
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 reasoningOptions: { value: ReasoningEffort; label: string }[] = [
|
||||
{ value: 'low', label: 'Low' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'high', label: 'High' },
|
||||
{ value: 'xhigh', label: 'Extra High' },
|
||||
];
|
||||
|
||||
export function ModelSelector({
|
||||
models,
|
||||
selectedModelId,
|
||||
selectedReasoning,
|
||||
onSelect,
|
||||
onReasoningChange,
|
||||
onClose,
|
||||
}: ModelSelectorProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('keydown', handleEscape, true);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleEscape, true);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const selectedModel = models.find((m) => m.id === selectedModelId);
|
||||
const supportedReasoningEfforts = selectedModel?.supportedReasoningEfforts;
|
||||
|
||||
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"
|
||||
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;
|
||||
|
||||
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>
|
||||
|
||||
{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}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{isSelected && <Check className="size-3.5 flex-none text-[var(--color-accent)]" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
<div className="flex gap-1">
|
||||
{reasoningOptions
|
||||
.filter((opt) => supportedReasoningEfforts.includes(opt.value))
|
||||
.map((opt) => {
|
||||
const isActive = selectedReasoning === opt.value;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => onReasoningChange(isActive ? undefined : opt.value)}
|
||||
className={`flex-1 rounded-md py-1 text-[11px] font-medium transition ${
|
||||
isActive
|
||||
? 'bg-[var(--color-accent)] text-white'
|
||||
: 'bg-[var(--color-surface-2)] text-[var(--color-text-muted)] hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]'
|
||||
}`}
|
||||
type="button"
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ArrowRight, Trash2, X } from 'lucide-react';
|
||||
|
||||
interface QuickPromptActionsProps {
|
||||
onDiscard: () => void;
|
||||
onClose: () => void;
|
||||
onContinueInAryx: () => void;
|
||||
}
|
||||
|
||||
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 */}
|
||||
<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)]"
|
||||
type="button"
|
||||
title="Delete this session"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
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)]"
|
||||
type="button"
|
||||
title="Close and keep session"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
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"
|
||||
type="button"
|
||||
>
|
||||
Continue in Aryx
|
||||
<ArrowRight className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { QuickPromptElectronApi, QuickPromptCapabilities } from '@shared/contracts/ipc';
|
||||
import type { SessionEventRecord } from '@shared/domain/event';
|
||||
import type { ReasoningEffort } from '@shared/domain/workflow';
|
||||
import type { ModelDefinition } from '@shared/domain/models';
|
||||
|
||||
import { QuickPromptInput } from '@renderer/components/quick-prompt/QuickPromptInput';
|
||||
import { QuickPromptResponse } from '@renderer/components/quick-prompt/QuickPromptResponse';
|
||||
import { QuickPromptActions } from '@renderer/components/quick-prompt/QuickPromptActions';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
quickPromptApi: QuickPromptElectronApi;
|
||||
}
|
||||
}
|
||||
|
||||
type PromptPhase = 'idle' | 'streaming' | 'complete' | 'error';
|
||||
|
||||
interface StreamedMessage {
|
||||
content: string;
|
||||
thinkingContent: string;
|
||||
authorName: string;
|
||||
}
|
||||
|
||||
export function QuickPromptApp() {
|
||||
const [phase, setPhase] = useState<PromptPhase>('idle');
|
||||
const [response, setResponse] = useState<StreamedMessage>({ content: '', thinkingContent: '', authorName: '' });
|
||||
const [errorMessage, setErrorMessage] = useState<string>();
|
||||
const [capabilities, setCapabilities] = useState<QuickPromptCapabilities>();
|
||||
const [selectedModel, setSelectedModel] = useState<string>();
|
||||
const [selectedReasoning, setSelectedReasoning] = useState<ReasoningEffort>();
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
const sessionIdRef = useRef<string | null>(null);
|
||||
const api = window.quickPromptApi;
|
||||
|
||||
// Load capabilities on mount
|
||||
useEffect(() => {
|
||||
api.getCapabilities().then((caps) => {
|
||||
setCapabilities(caps);
|
||||
setSelectedModel(caps.defaultModel);
|
||||
setSelectedReasoning(caps.defaultReasoningEffort);
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
// Subscribe to show/hide events from main process
|
||||
useEffect(() => {
|
||||
const offShow = api.onShow(() => {
|
||||
setVisible(true);
|
||||
resetState();
|
||||
});
|
||||
const offHide = api.onHide(() => setVisible(false));
|
||||
return () => {
|
||||
offShow();
|
||||
offHide();
|
||||
};
|
||||
}, [api]);
|
||||
|
||||
// Subscribe to session events (streaming)
|
||||
useEffect(() => {
|
||||
const off = api.onSessionEvent((event: SessionEventRecord) => {
|
||||
if (event.kind === 'message-delta' && event.contentDelta) {
|
||||
if (event.messageKind === 'thinking') {
|
||||
setResponse((prev) => ({ ...prev, thinkingContent: prev.thinkingContent + event.contentDelta! }));
|
||||
} else {
|
||||
setResponse((prev) => ({
|
||||
...prev,
|
||||
content: prev.content + event.contentDelta!,
|
||||
authorName: event.authorName ?? prev.authorName,
|
||||
}));
|
||||
}
|
||||
setPhase('streaming');
|
||||
} else if (event.kind === 'status' && event.status === 'idle') {
|
||||
setPhase((prev) => (prev === 'streaming' ? 'complete' : prev));
|
||||
} else if (event.kind === 'error') {
|
||||
setErrorMessage(event.error ?? 'An unexpected error occurred.');
|
||||
setPhase('error');
|
||||
}
|
||||
});
|
||||
return off;
|
||||
}, [api]);
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setPhase('idle');
|
||||
setResponse({ content: '', thinkingContent: '', authorName: '' });
|
||||
setErrorMessage(undefined);
|
||||
sessionIdRef.current = null;
|
||||
// Refresh capabilities in case models changed
|
||||
api.getCapabilities().then((caps) => {
|
||||
setCapabilities(caps);
|
||||
if (!selectedModel) setSelectedModel(caps.defaultModel);
|
||||
if (!selectedReasoning) setSelectedReasoning(caps.defaultReasoningEffort);
|
||||
});
|
||||
}, [api, selectedModel, selectedReasoning]);
|
||||
|
||||
const handleSend = useCallback(async (content: string) => {
|
||||
if (!content.trim() || phase === 'streaming') return;
|
||||
|
||||
setPhase('streaming');
|
||||
setResponse({ content: '', thinkingContent: '', authorName: '' });
|
||||
setErrorMessage(undefined);
|
||||
|
||||
try {
|
||||
const result = await api.send({
|
||||
content,
|
||||
model: selectedModel,
|
||||
reasoningEffort: selectedReasoning,
|
||||
});
|
||||
sessionIdRef.current = result.sessionId;
|
||||
} catch (err) {
|
||||
setErrorMessage(err instanceof Error ? err.message : 'Failed to send message.');
|
||||
setPhase('error');
|
||||
}
|
||||
}, [api, phase, selectedModel, selectedReasoning]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
api.cancelTurn();
|
||||
setPhase('complete');
|
||||
}, [api]);
|
||||
|
||||
const handleDiscard = useCallback(() => {
|
||||
api.discard();
|
||||
resetState();
|
||||
}, [api, resetState]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
api.close();
|
||||
resetState();
|
||||
}, [api, resetState]);
|
||||
|
||||
const handleContinueInAryx = useCallback(() => {
|
||||
api.continueInAryx();
|
||||
resetState();
|
||||
}, [api, resetState]);
|
||||
|
||||
const handleModelChange = useCallback((model: ModelDefinition) => {
|
||||
setSelectedModel(model.id);
|
||||
}, []);
|
||||
|
||||
const handleReasoningChange = useCallback((effort: ReasoningEffort | undefined) => {
|
||||
setSelectedReasoning(effort);
|
||||
}, []);
|
||||
|
||||
// Global keyboard handler
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
if (phase === 'streaming') {
|
||||
handleCancel();
|
||||
} else if (phase === 'complete' || phase === 'error') {
|
||||
handleClose();
|
||||
} else {
|
||||
api.close();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [phase, handleCancel, handleClose, api]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const hasResponse = phase !== 'idle';
|
||||
const resolvedModel = capabilities?.models.find((m) => m.id === selectedModel);
|
||||
|
||||
return (
|
||||
<div className="qp-container flex h-screen w-screen items-start justify-center pt-0">
|
||||
<div
|
||||
className={`qp-panel qp-panel-enter flex w-full max-w-[680px] flex-col overflow-hidden rounded-2xl ${
|
||||
phase === 'streaming' ? 'qp-border-streaming' : hasResponse ? 'qp-border-complete' : 'qp-border-idle'
|
||||
}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Quick Prompt"
|
||||
>
|
||||
{/* Input area */}
|
||||
<QuickPromptInput
|
||||
onSend={handleSend}
|
||||
onCancel={handleCancel}
|
||||
phase={phase}
|
||||
models={capabilities?.models}
|
||||
selectedModel={resolvedModel}
|
||||
selectedReasoning={selectedReasoning}
|
||||
onModelChange={handleModelChange}
|
||||
onReasoningChange={handleReasoningChange}
|
||||
/>
|
||||
|
||||
{/* Response area — grows dynamically */}
|
||||
{hasResponse && (
|
||||
<QuickPromptResponse
|
||||
content={response.content}
|
||||
thinkingContent={response.thinkingContent}
|
||||
authorName={response.authorName}
|
||||
phase={phase}
|
||||
error={errorMessage}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Action bar */}
|
||||
{(phase === 'complete' || phase === 'error') && (
|
||||
<QuickPromptActions
|
||||
onDiscard={handleDiscard}
|
||||
onClose={handleClose}
|
||||
onContinueInAryx={handleContinueInAryx}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ChevronDown, Loader2, Zap } from 'lucide-react';
|
||||
|
||||
import type { ModelDefinition } from '@shared/domain/models';
|
||||
import type { ReasoningEffort } from '@shared/domain/workflow';
|
||||
|
||||
import { ModelSelector } from '@renderer/components/quick-prompt/ModelSelector';
|
||||
|
||||
type PromptPhase = 'idle' | 'streaming' | 'complete' | 'error';
|
||||
|
||||
interface QuickPromptInputProps {
|
||||
onSend: (content: string) => void;
|
||||
onCancel: () => void;
|
||||
phase: PromptPhase;
|
||||
models?: ReadonlyArray<ModelDefinition>;
|
||||
selectedModel?: ModelDefinition;
|
||||
selectedReasoning?: ReasoningEffort;
|
||||
onModelChange: (model: ModelDefinition) => void;
|
||||
onReasoningChange: (effort: ReasoningEffort | undefined) => void;
|
||||
}
|
||||
|
||||
export function QuickPromptInput({
|
||||
onSend,
|
||||
onCancel,
|
||||
phase,
|
||||
models,
|
||||
selectedModel,
|
||||
selectedReasoning,
|
||||
onModelChange,
|
||||
onReasoningChange,
|
||||
}: QuickPromptInputProps) {
|
||||
const [value, setValue] = useState('');
|
||||
const [modelSelectorOpen, setModelSelectorOpen] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Auto-focus on mount and when phase resets to idle
|
||||
useEffect(() => {
|
||||
if (phase === 'idle') {
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
}, [phase]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (phase === 'idle' && value.trim()) {
|
||||
onSend(value);
|
||||
}
|
||||
}
|
||||
},
|
||||
[onSend, value, phase],
|
||||
);
|
||||
|
||||
const isDisabled = phase === 'streaming';
|
||||
|
||||
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">
|
||||
{phase === 'streaming' ? (
|
||||
<Loader2 className="size-[18px] animate-spin text-[var(--color-accent)]" />
|
||||
) : (
|
||||
<Zap 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}
|
||||
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"
|
||||
/>
|
||||
|
||||
{/* 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)]"
|
||||
type="button"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Model selector row */}
|
||||
<div className="flex items-center gap-2 border-t border-[var(--color-border-subtle)] 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)]"
|
||||
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>
|
||||
)}
|
||||
<ChevronDown className="size-3" />
|
||||
</button>
|
||||
|
||||
<span className="ml-auto text-[10px] text-[var(--color-text-muted)] select-none opacity-60">
|
||||
Enter ↵ to send · Esc to dismiss
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Model selector dropdown */}
|
||||
{modelSelectorOpen && models && (
|
||||
<ModelSelector
|
||||
models={models}
|
||||
selectedModelId={selectedModel?.id}
|
||||
selectedReasoning={selectedReasoning}
|
||||
onSelect={(model) => {
|
||||
onModelChange(model);
|
||||
setModelSelectorOpen(false);
|
||||
}}
|
||||
onReasoningChange={onReasoningChange}
|
||||
onClose={() => setModelSelectorOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { AlertCircle, Brain } from 'lucide-react';
|
||||
|
||||
type PromptPhase = 'idle' | 'streaming' | 'complete' | 'error';
|
||||
|
||||
interface QuickPromptResponseProps {
|
||||
content: string;
|
||||
thinkingContent: string;
|
||||
authorName: string;
|
||||
phase: PromptPhase;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function QuickPromptResponse({
|
||||
content,
|
||||
thinkingContent,
|
||||
phase,
|
||||
error,
|
||||
}: QuickPromptResponseProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-scroll to bottom during streaming
|
||||
useEffect(() => {
|
||||
if (phase === 'streaming' && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [content, thinkingContent, phase]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="qp-response-enter max-h-[min(55vh,520px)] overflow-y-auto border-t border-[var(--color-border-subtle)]"
|
||||
>
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* Thinking block — 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>
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1.5 text-[12px] leading-relaxed text-[var(--color-text-muted)] line-clamp-3">
|
||||
{thinkingContent.slice(-300)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main response content */}
|
||||
{content && (
|
||||
<div className="px-5 py-4">
|
||||
<div className="markdown-content text-[13.5px] text-[var(--color-text-primary)]">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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)]" />
|
||||
</span>
|
||||
<span className="text-[12px] text-[var(--color-text-muted)]">Generating response…</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user