Skip to content

Commit 57a050e

Browse files
committed
Run Query in Control Center
1 parent 823733e commit 57a050e

9 files changed

Lines changed: 1001 additions & 162 deletions

File tree

apps/dashboard/src/app/layout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ export default function RootLayout({
6666
throw new Error(`STACK_DEVELOPMENT_TRANSLATION_LOCALE can only be used in development mode (found: ${JSON.stringify(translationLocale)})`);
6767
}
6868

69-
const enableReactScanInDevelopment = false as boolean;
69+
const enableReactScanInDevelopment = getPublicEnvVar('NEXT_PUBLIC_STACK_ENABLE_REACT_SCAN_IN_DEVELOPMENT') === 'true';
7070

7171
return (
7272
<html suppressHydrationWarning lang="en" className={`${GeistSans.variable} ${GeistMono.variable}`}>

apps/dashboard/src/components/cmdk-commands.tsx

Lines changed: 16 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@ import { AppIcon } from "@/components/app-square";
44
import { Badge, Button, ScrollArea } from "@/components/ui";
55
import { ALL_APPS_FRONTEND, getAppPath, getItemPath } from "@/lib/apps-frontend";
66
import { getUninstalledAppIds } from "@/lib/apps-utils";
7+
import { classifyClickHouseSqlVsPrompt } from "@/lib/classify-query";
78
import { cn } from "@/lib/utils";
89
import { CheckIcon, CubeIcon, DownloadSimpleIcon, GearIcon, GlobeIcon, InfoIcon, KeyIcon, LayoutIcon, LightningIcon, PlayIcon, ShieldCheckIcon, SparkleIcon } from "@phosphor-icons/react";
910
import { ALL_APPS, ALL_APP_TAGS, type AppId } from "@stackframe/stack-shared/dist/apps/apps-config";
1011
import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
1112
import Image from "next/image";
1213
import React, { memo, useEffect, useMemo } from "react";
1314
import { AIChatPreview } from "./commands/ask-ai";
15+
import { RunQueryPreview } from "./commands/run-query";
1416

1517
export type CmdKPreviewProps = {
1618
isSelected: boolean,
@@ -29,38 +31,6 @@ export type CmdKPreviewProps = {
2931
pathname: string,
3032
};
3133

32-
// Run Query Preview Component - shows a TODO message for now
33-
const RunQueryPreview = memo(function RunQueryPreview({
34-
query,
35-
}: CmdKPreviewProps) {
36-
return (
37-
<div className="flex flex-col h-full w-full items-center justify-center p-6">
38-
<div className="flex flex-col items-center gap-4 max-w-md text-center">
39-
<div className="w-16 h-16 rounded-2xl bg-amber-500/10 flex items-center justify-center">
40-
<PlayIcon className="h-8 w-8 text-amber-500" />
41-
</div>
42-
<div>
43-
<h3 className="text-lg font-semibold text-foreground mb-2">Run Query</h3>
44-
<p className="text-sm text-muted-foreground mb-4">
45-
Execute actions using natural language commands.
46-
</p>
47-
</div>
48-
<div className="w-full p-4 rounded-xl bg-amber-500/5 border border-amber-500/20">
49-
<p className="text-xs text-amber-600 dark:text-amber-400 font-medium mb-2">Your query:</p>
50-
<p className="text-sm text-foreground italic">&ldquo;{query}&rdquo;</p>
51-
</div>
52-
<div className="mt-4 p-4 rounded-xl bg-muted/50 border border-border">
53-
<p className="text-xs text-muted-foreground">
54-
🚧 <span className="font-medium">Coming Soon</span> — This feature is under development.
55-
Soon you&apos;ll be able to run queries like &ldquo;create a new user&rdquo;,
56-
&ldquo;list all teams&rdquo;, or &ldquo;update project settings&rdquo;.
57-
</p>
58-
</div>
59-
</div>
60-
</div>
61-
);
62-
});
63-
6434
// Create Dashboard Preview Component - shows a TODO message for now
6535
const CreateDashboardPreview = memo(function CreateDashboardPreview({
6636
query,
@@ -351,6 +321,8 @@ export function useCmdKCommands({
351321
}): CmdKCommand[] {
352322
return useMemo(() => {
353323
const commands: CmdKCommand[] = [];
324+
const queryClassification = classifyClickHouseSqlVsPrompt(query, { readonlyOnly: true });
325+
const shouldPrioritizeRunQuery = queryClassification.kind === "sql";
354326

355327
// Overview
356328
commands.push({
@@ -453,7 +425,7 @@ export function useCmdKCommands({
453425

454426
// AI-powered options (only when there's a query)
455427
if (query.trim()) {
456-
commands.push({
428+
const askAiCommand: CmdKCommand = {
457429
id: "ai/ask",
458430
icon: <SparkleIcon className="h-3.5 w-3.5 text-purple-400" />,
459431
label: `Ask AI`,
@@ -463,19 +435,25 @@ export function useCmdKCommands({
463435
preview: AIChatPreview,
464436
hasVisualPreview: true,
465437
highlightColor: "purple",
466-
});
438+
};
467439

468-
commands.push({
440+
const runQueryCommand: CmdKCommand = {
469441
id: "query/run",
470442
icon: <PlayIcon className="h-3.5 w-3.5 text-amber-500" />,
471443
label: `Run Query`,
472-
description: "Execute actions using natural language",
473-
keywords: ["run", "execute", "query", "action", "command", "vibecode"],
444+
description: "Execute ClickHouse SQL analytics queries",
445+
keywords: ["run", "execute", "query", "action", "command", "vibecode", "sql", "clickhouse", "analytics"],
474446
onAction: { type: "focus" },
475447
preview: RunQueryPreview,
476448
hasVisualPreview: true,
477449
highlightColor: "gold",
478-
});
450+
};
451+
452+
const orderedQueryCommands = shouldPrioritizeRunQuery
453+
? [runQueryCommand, askAiCommand]
454+
: [askAiCommand, runQueryCommand];
455+
456+
commands.push(...orderedQueryCommands);
479457

480458
commands.push({
481459
id: "create/dashboard",

apps/dashboard/src/components/cmdk-search.tsx

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,9 @@ export function CmdKSearch({
407407
const pathname = usePathname();
408408
const inputRef = useRef<HTMLInputElement>(null);
409409
const columnsContainerRef = useRef<HTMLDivElement>(null);
410+
// Track previous selection to handle reordering
411+
const prevSelectedIdRef = useRef<string | null>(null);
412+
const prevSelectedIndexRef = useRef<number>(0);
410413

411414
// Handle keyboard shortcut and custom event
412415
useEffect(() => {
@@ -475,14 +478,47 @@ export function CmdKSearch({
475478
return selectedIndices[activeDepth] ?? 0;
476479
}, [activeDepth, selectedIndices]);
477480

478-
// Reset selection and close nested columns when results change
481+
// Handle selection when commands reorder
482+
// - If currently selected item moved UP, keep it selected at new index
483+
// - If currently selected item moved DOWN or is gone, reset to first item
479484
useEffect(() => {
480-
setSelectedIndex(0);
481-
setSelectedIndices([0]);
485+
const prevId = prevSelectedIdRef.current;
486+
const prevIndex = prevSelectedIndexRef.current;
487+
488+
if (prevId && filteredCommands.length > 0) {
489+
const newIndex = filteredCommands.findIndex((cmd) => cmd.id === prevId);
490+
491+
if (newIndex !== -1 && newIndex <= prevIndex) {
492+
// Item moved up or stayed same - keep it selected
493+
setSelectedIndex(newIndex);
494+
setSelectedIndices([newIndex]);
495+
} else {
496+
// Item moved down or is no longer in list - reset to first
497+
setSelectedIndex(0);
498+
setSelectedIndices([0]);
499+
}
500+
} else {
501+
// No previous selection or empty list - reset to first
502+
setSelectedIndex(0);
503+
setSelectedIndices([0]);
504+
}
505+
506+
// Always clear nested state when commands change
482507
setNestedColumns([]);
483508
setActiveDepth(0);
484509
setNestedBlurHandlers([]);
485-
}, [filteredCommands.length]);
510+
}, [filteredCommands]);
511+
512+
// Keep track of currently selected command for reorder detection
513+
useEffect(() => {
514+
if (filteredCommands.length > 0 && selectedIndex < filteredCommands.length) {
515+
prevSelectedIdRef.current = filteredCommands[selectedIndex].id;
516+
prevSelectedIndexRef.current = selectedIndex;
517+
} else {
518+
prevSelectedIdRef.current = null;
519+
prevSelectedIndexRef.current = 0;
520+
}
521+
}, [selectedIndex, filteredCommands]);
486522

487523
const registerOnFocus = useCallback((onFocus: () => void) => {
488524
setPreviewFocusHandlers((prev) => new Set(prev).add(onFocus));
@@ -740,7 +776,7 @@ export function CmdKSearch({
740776
className="fixed inset-0 flex items-center justify-center z-50 px-4 pointer-events-none"
741777
style={{ animation: "spotlight-slide-in 150ms cubic-bezier(0.16, 1, 0.3, 1)" }}
742778
>
743-
<div className="relative rounded-2xl ring-2 ring-inset ring-foreground/[0.08] h-[76vh] min-h-[320px] w-full max-w-[min(max(540px,75vw),1000px)] pointer-events-auto">
779+
<div className="relative rounded-2xl ring-2 ring-inset ring-foreground/[0.08] h-[76vh] min-h-[320px] w-full max-w-[min(max(540px,75vw),1500px)] pointer-events-auto">
744780
{/* Background layer */}
745781
<div className="absolute inset-[2px] rounded-[14px] -z-10 backdrop-blur-xl bg-gray-100/80 dark:bg-[#161616]/80" />
746782
<div

apps/dashboard/src/components/commands/ask-ai.tsx

Lines changed: 11 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { ArrowSquareOutIcon, CheckIcon, CopyIcon, PaperPlaneTiltIcon, SparkleIcon, SpinnerGapIcon, UserIcon } from "@phosphor-icons/react";
2-
import { runAsynchronously, wait } from "@stackframe/stack-shared/dist/utils/promises";
31
import { cn } from "@/components/ui";
2+
import { useDebouncedAction } from "@/hooks/use-debounced-action";
3+
import { ArrowSquareOutIcon, CheckIcon, CopyIcon, PaperPlaneTiltIcon, SparkleIcon, SpinnerGapIcon, UserIcon } from "@phosphor-icons/react";
4+
import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises";
45
import { useChat } from "ai/react";
56
import { memo, useCallback, useEffect, useRef, useState } from "react";
67
import ReactMarkdown from "react-markdown";
@@ -334,7 +335,6 @@ const AIChatPreviewInner = memo(function AIChatPreview({
334335
const followUpInputRef = useRef<HTMLInputElement>(null);
335336
const lastMessageCountRef = useRef(0);
336337
const isNearBottomRef = useRef(true);
337-
const hasSentInitialQuery = useRef(false);
338338

339339
const trimmedQuery = query.trim();
340340

@@ -347,21 +347,14 @@ const AIChatPreviewInner = memo(function AIChatPreview({
347347
api: "/api/ai-search",
348348
});
349349

350-
// Send initial query on mount (once)
351-
useEffect(() => {
352-
let cancelled = false;
353-
runAsynchronously(async () => {
354-
await wait(400);
355-
if (cancelled) return;
356-
if (trimmedQuery && !hasSentInitialQuery.current) {
357-
hasSentInitialQuery.current = true;
358-
await append({ role: "user", content: trimmedQuery });
359-
}
360-
});
361-
return () => {
362-
cancelled = true;
363-
};
364-
}, [trimmedQuery, append]);
350+
// Send initial query on mount (once) with debounce
351+
useDebouncedAction({
352+
action: async () => {
353+
await append({ role: "user", content: trimmedQuery });
354+
},
355+
delayMs: 400,
356+
skip: !trimmedQuery,
357+
});
365358

366359
// Word streaming for the last assistant message
367360
const lastAssistantMessage = messages.slice(1).findLast(m => m.role === "assistant");
@@ -521,7 +514,6 @@ const AIChatPreviewInner = memo(function AIChatPreview({
521514
autoComplete="off"
522515
autoCorrect="off"
523516
spellCheck={false}
524-
disabled={aiLoading}
525517
/>
526518
<button
527519
onClick={() => runAsynchronously(handleFollowUp())}

0 commit comments

Comments
 (0)