Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Suspense, lazy, useState } from "react";
import { Suspense, lazy } from "react";
import { AppProvider, useApp } from "./context/AppContext";
import { ToastProvider } from "./components/ui/Toast";
import { ErrorBoundary } from "./components/ui/ErrorBoundary";
Expand All @@ -11,9 +11,9 @@ import { AskView } from "./views/AskView";
import { SkeletonStack } from "./components/ui/Skeleton";
import { apiBase } from "./api/client";
import { resolveApiKey } from "./lib/auth";
import { hasCompletedOnboarding } from "./lib/onboarding";
import { BugReportSection } from "./components/layout/BugReportSection";
import { useApiHealth } from "./hooks/useApiHealth";
import { IconSpark } from "./components/ui/icons";

const ExploreView = lazy(() =>
import("./views/ExploreView").then((m) => ({ default: m.ExploreView })),
Expand Down Expand Up @@ -102,7 +102,7 @@ function TopbarActions() {
onClick={() => setAssistantOpen(true)}
aria-label="Open Cortex Assist"
>
Assist
<IconSpark size={16} aria-hidden /> Assist
</button>
<span
className={`topbar__badge ${secured ? "topbar__badge--secured" : ""}`}
Expand All @@ -122,10 +122,8 @@ function TopbarActions() {
}

function AppChrome() {
const { setAssistantOpen } = useApp();
const [showOnboarding, setShowOnboarding] = useState(
() => typeof window !== "undefined" && !hasCompletedOnboarding(),
);
const { setAssistantOpen, setView, setWorkspaceId, showOnboarding, setShowOnboarding } =
useApp();

return (
<div className="app">
Expand All @@ -136,6 +134,10 @@ function AppChrome() {
<OnboardingModal
onComplete={() => setShowOnboarding(false)}
onOpenCopilot={() => setAssistantOpen(true)}
onFinishAsk={(workspace, query) => {
setWorkspaceId(workspace);
setView("ask", { q: query });
}}
/>
) : null}
<ApiHealthBanner />
Expand Down
14 changes: 8 additions & 6 deletions frontend/src/components/layout/WorkspaceBar.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { useId, useState } from "react";
import { useApp } from "../../context/AppContext";
import { fetchHealth, hasApiKeyConfigured } from "../../api/client";
import { persistApiKey } from "../../lib/auth";
import { setClientApiKey } from "../../api/client";
import { useToast } from "../ui/Toast";
import { IconLock } from "../ui/icons";

const PRESETS = ["local-dev", "acme-demo"] as const;

export function WorkspaceBar() {
const { workspaceId, setWorkspaceId, apiKey, setApiKey, saveApiKey } = useApp();
const { workspaceId, setWorkspaceId, apiKey, setApiKey, saveApiKey, clearApiKey, replayOnboarding } =
useApp();
const { showToast } = useToast();
const [expanded, setExpanded] = useState(false);
const [showKey, setShowKey] = useState(false);
Expand Down Expand Up @@ -44,8 +44,7 @@ export function WorkspaceBar() {

function handleClear(): void {
setApiKey("");
persistApiKey("");
setClientApiKey("");
clearApiKey();
setTestOk(null);
showToast("API key cleared");
}
Expand Down Expand Up @@ -89,7 +88,7 @@ export function WorkspaceBar() {
className={`connection-bar__status ${secured ? "connection-bar__status--secured" : ""}`}
aria-hidden
>
{secured ? "🔒" : "◇"}
{secured ? <IconLock size={14} /> : "◇"}
</span>
Connection
</button>
Expand Down Expand Up @@ -139,6 +138,9 @@ export function WorkspaceBar() {
>
{testing ? "Testing…" : "Test connection"}
</button>
<button type="button" className="btn btn--ghost" onClick={replayOnboarding}>
Replay tour
</button>
</div>
{secured ? (
<p className="connection-bar__saved text-ok" role="status">
Expand Down
175 changes: 140 additions & 35 deletions frontend/src/components/onboarding/OnboardingModal.tsx
Original file line number Diff line number Diff line change
@@ -1,69 +1,174 @@
import { useState } from "react";
import { markOnboardingComplete } from "../../lib/onboarding";
import { useApp } from "../../context/AppContext";
import { IconSpark } from "../ui/icons";

type Props = {
onComplete: () => void;
onOpenCopilot: () => void;
onFinishAsk: (workspace: string, query: string) => void;
};

const STEPS = [
{
title: "Your organization's living memory",
body: "Cortex captures decisions — not documents — from Slack, GitHub, Jira, and more. Ask natural questions and get answers with who decided, why, and what's affected.",
icon: "◈",
},
{
title: "Ask in plain language",
body: 'Try questions like "Why did we choose CockroachDB for payments?" or "What affects checkout?" Cortex searches structured memory, not scattered threads.',
icon: "?",
},
{
title: "Meet Assist",
body: "Cortex Assist guides you, runs searches, and explains results. Use the memory map to see how people, systems, and decisions connect over time.",
icon: "✦",
},
const WORKSPACE_PRESETS = [
{ id: "local-dev", label: "Demo workspace", hint: "Pre-seeded decisions for demos" },
{ id: "oss-tiangolo-fastapi", label: "OSS: FastAPI", hint: "Open-source graph import" },
{ id: "oss-adr", label: "OSS: ADRs", hint: "Architecture decision records" },
] as const;

export function OnboardingModal({ onComplete, onOpenCopilot }: Props) {
const DEMO_QUERY = "Why CockroachDB for payments?";

export function OnboardingModal({ onComplete, onOpenCopilot, onFinishAsk }: Props) {
const { setWorkspaceId, apiKey, setApiKey, saveApiKey } = useApp();
const [step, setStep] = useState(0);
const current = STEPS[step];
const isLast = step === STEPS.length - 1;
const [workspace, setWorkspace] = useState("local-dev");
const isLast = step === 3;

function finish() {
markOnboardingComplete();
function finishToAsk(): void {
markOnboardingComplete(workspace);
setWorkspaceId(workspace);
onComplete();
if (isLast) onOpenCopilot();
onFinishAsk(workspace, DEMO_QUERY);
}

function finishToAssist(): void {
markOnboardingComplete(workspace);
setWorkspaceId(workspace);
onComplete();
onOpenCopilot();
}

return (
<div className="onboarding-overlay" role="dialog" aria-modal="true" aria-labelledby="onboarding-title">
<div
className="onboarding-overlay"
role="dialog"
aria-modal="true"
aria-labelledby="onboarding-title"
>
<div className="onboarding-modal">
<div className="onboarding-modal__progress" aria-hidden>
{STEPS.map((_, i) => (
{[0, 1, 2, 3].map((i) => (
<span key={i} className={`onboarding-dot ${i <= step ? "onboarding-dot--active" : ""}`} />
))}
</div>
<span className="onboarding-modal__icon" aria-hidden>
{current.icon}
</span>
<h2 id="onboarding-title" className="onboarding-modal__title">
{current.title}
</h2>
<p className="onboarding-modal__body">{current.body}</p>

{step === 0 ? (
<>
<span className="onboarding-modal__icon" aria-hidden>
</span>
<h2 id="onboarding-title" className="onboarding-modal__title">
Your organization's living memory
</h2>
<p className="onboarding-modal__body">
Cortex captures <strong>decisions</strong> — not documents — from Slack, GitHub, Jira,
and more. Ask natural questions and get answers with who decided, why, and what's
affected.
</p>
</>
) : null}

{step === 1 ? (
<>
<h2 id="onboarding-title" className="onboarding-modal__title">
Choose a workspace
</h2>
<p className="onboarding-modal__body">
Workspaces isolate organizational memory. Start with the demo workspace or pick an OSS
preset.
</p>
<div className="onboarding-workspaces">
{WORKSPACE_PRESETS.map((w) => (
<button
key={w.id}
type="button"
className={`onboarding-workspace ${workspace === w.id ? "onboarding-workspace--active" : ""}`}
onClick={() => setWorkspace(w.id)}
>
<span className="onboarding-workspace__label">{w.label}</span>
<span className="onboarding-workspace__hint">{w.hint}</span>
</button>
))}
</div>
</>
) : null}

{step === 2 ? (
<>
<h2 id="onboarding-title" className="onboarding-modal__title">
Try a demo search
</h2>
<p className="onboarding-modal__body">
We'll open Search with a sample question so you can see trust scores, coverage, and
decision stories in seconds.
</p>
<blockquote className="onboarding-query">{DEMO_QUERY}</blockquote>
</>
) : null}

{step === 3 ? (
<>
<h2 id="onboarding-title" className="onboarding-modal__title">
Connection (optional)
</h2>
<p className="onboarding-modal__body">
The public demo works without a key. If your deployment requires authentication, add
your API key now — you can always change it under Connection later.
</p>
<label className="field-label" htmlFor="onboard-api-key">
API key
</label>
<input
id="onboard-api-key"
className="input"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="Optional"
autoComplete="off"
/>
</>
) : null}

<footer className="onboarding-modal__actions">
{step > 0 ? (
<button type="button" className="btn btn--ghost" onClick={() => setStep((s) => s - 1)}>
Back
</button>
) : (
<button type="button" className="btn btn--ghost" onClick={finish}>
<button
type="button"
className="btn btn--ghost"
onClick={() => {
markOnboardingComplete(workspace);
onComplete();
}}
>
Skip tour
</button>
)}
{isLast ? (
<button type="button" className="btn btn--primary" onClick={finish}>
Open Assist
</button>
<>
<button
type="button"
className="btn btn--secondary"
onClick={() => {
if (apiKey.trim()) saveApiKey();
finishToAsk();
}}
>
Search memory
</button>
<button
type="button"
className="btn btn--primary"
onClick={() => {
if (apiKey.trim()) saveApiKey();
finishToAssist();
}}
>
<IconSpark size={16} aria-hidden /> Open Assist
</button>
</>
) : (
<button type="button" className="btn btn--primary" onClick={() => setStep((s) => s + 1)}>
Continue
Expand Down
Loading
Loading