Skip to content
Closed
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
103 changes: 69 additions & 34 deletions templates/_shared/nextjs-ui/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {
CardHeader,
CardTitle,
} from "@arcadeai/design-system";
import { Info, Loader2, ShieldAlert, AlertTriangle, RotateCcw } from "lucide-react";
import { Info, Loader2, ShieldAlert, AlertTriangle, RotateCcw, X } from "lucide-react";

// --- Config health warnings ---

Expand Down Expand Up @@ -127,12 +127,31 @@ function DashboardContent() {
if (toolName) markSourceAuthRequired(toolName);
addAuthUrl(url, toolName);
},
onElicitation: (_elicitationId, authUrl, message) => {
addAuthUrl(authUrl, message);
},
onSourcesDone: markAllCheckingAsConnected,
});

// --- Config health check ---
const [configWarnings, setConfigWarnings] = useState<ConfigWarning[]>([]);

// --- Dismissible callout state ---
const [showAgentWorkingCallout, setShowAgentWorkingCallout] = useState(() =>
typeof window !== "undefined" ? localStorage.getItem("callout-agent-working") !== "1" : true
);
const dismissAgentWorkingCallout = () => {
localStorage.setItem("callout-agent-working", "1");
setShowAgentWorkingCallout(false);
};
const [showCustomizeCallout, setShowCustomizeCallout] = useState(() =>
typeof window !== "undefined" ? localStorage.getItem("callout-customize-agent") !== "1" : true
);
const dismissCustomizeCallout = () => {
localStorage.setItem("callout-customize-agent", "1");
setShowCustomizeCallout(false);
};

useEffect(() => {
fetch("/api/health")
.then((r) => r.json())
Expand All @@ -159,6 +178,8 @@ function DashboardContent() {

// --- Handlers ---
const handleLogout = useCallback(async () => {
// Clear Arcade tokens so the next user who signs in has to re-authenticate.
await fetch("/api/auth/arcade/disconnect", { method: "POST" }).catch(() => {});
await authClient.signOut();
router.push("/");
}, [router]);
Expand Down Expand Up @@ -206,15 +227,9 @@ function DashboardContent() {
</CardHeader>
<CardContent className="space-y-3">
<ArcadeSignInButton authUrl={arcadeStatus.authUrl} />
<button
onClick={retryConnection}
className="block w-full text-sm text-muted-foreground hover:text-foreground"
>
I&apos;ve already signed in &mdash; retry
</button>
<Alert className="text-left">
<Info className="size-4" />
<AlertTitle>Why Arcade?</AlertTitle>
<AlertTitle>Why sign into Arcade?</AlertTitle>
<AlertDescription>
The agent uses Arcade as an MCP Gateway to read from your tools on your behalf.
Signing in here links your Arcade identity so the gateway knows which
Expand Down Expand Up @@ -325,19 +340,29 @@ function DashboardContent() {

{loading && !hasItems && authUrls.length === 0 && (
<div className="space-y-6">
<Alert>
<Info className="size-4" />
<AlertTitle>What&apos;s happening</AlertTitle>
<AlertDescription>
The agent is reading from your connected sources and classifying what it finds.
This behavior is driven by the system prompt in{" "}
<code className="rounded bg-muted px-1 py-0.5 text-xs">
app/api/plan/route.ts
</code>{" "}
— edit it to change what gets fetched, how items are prioritized, or what the
agent focuses on.
</AlertDescription>
</Alert>
{showAgentWorkingCallout && (
<Alert>
<Info className="size-4" />
<AlertTitle className="flex items-center justify-between">
What&apos;s happening
<button
onClick={dismissAgentWorkingCallout}
className="ml-2 rounded-sm opacity-70 hover:opacity-100"
>
<X className="size-3.5" />
</button>
</AlertTitle>
<AlertDescription>
The agent is reading from your connected sources and classifying what it finds.
This behavior is driven by the system prompt in{" "}
<code className="rounded bg-muted px-1 py-0.5 text-xs">
app/api/plan/route.ts
</code>{" "}
— edit it to change what gets fetched, how items are prioritized, or what the
agent focuses on.
</AlertDescription>
</Alert>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-24 rounded-xl" />
Expand Down Expand Up @@ -368,19 +393,29 @@ function DashboardContent() {
)}
</Button>
</div>
<Alert>
<Info className="size-4" />
<AlertTitle>Make it yours</AlertTitle>
<AlertDescription>
Start with the system prompt in{" "}
<code className="rounded bg-muted px-1 py-0.5 text-xs">
app/api/plan/route.ts
</code>{" "}
— that&apos;s where the agent&apos;s behavior is defined. From there, check out{" "}
<code className="rounded bg-muted px-1 py-0.5 text-xs">AGENT_PLAYBOOK.md</code>{" "}
for a full walkthrough of customization points.
</AlertDescription>
</Alert>
{showCustomizeCallout && (
<Alert>
<Info className="size-4" />
<AlertTitle className="flex items-center justify-between">
Make it yours
<button
onClick={dismissCustomizeCallout}
className="ml-2 rounded-sm opacity-70 hover:opacity-100"
>
<X className="size-3.5" />
</button>
</AlertTitle>
<AlertDescription>
Start with the system prompt in{" "}
<code className="rounded bg-muted px-1 py-0.5 text-xs">
app/api/plan/route.ts
</code>{" "}
— that&apos;s where the agent&apos;s behavior is defined. From there, check out{" "}
<code className="rounded bg-muted px-1 py-0.5 text-xs">AGENT_PLAYBOOK.md</code>{" "}
for a full walkthrough of customization points.
</AlertDescription>
</Alert>
)}
<StatsBar
stats={stats}
activeSource={activeSource}
Expand Down
36 changes: 22 additions & 14 deletions templates/_shared/nextjs-ui/components/dashboard/auth-prompt.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { ShieldAlert } from "lucide-react";
import { useState } from "react";
import { ShieldAlert, Loader2 } from "lucide-react";
import { Button } from "@arcadeai/design-system";

interface AuthPromptProps {
Expand All @@ -10,25 +11,32 @@ interface AuthPromptProps {
}

export function AuthPrompt({ toolName, authUrl, onContinue }: AuthPromptProps) {
const [clicked, setClicked] = useState(false);

return (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-900 dark:bg-amber-950">
<div className="flex items-center gap-2 mb-2">
<ShieldAlert className="size-5 text-amber-600 dark:text-amber-400" />
<h3 className="font-semibold text-sm">Authorization required</h3>
</div>
<p className="text-muted-foreground mb-3 text-xs">
The tool <span className="font-mono">{toolName}</span> needs permission to continue.
</p>
<div className="flex gap-2">
<Button size="sm" asChild>
<a href={authUrl} target="_blank" rel="noopener noreferrer">
Authorize
</a>
</Button>
<Button size="sm" variant="outline" onClick={onContinue}>
Continue
</Button>
</div>
<p className="text-muted-foreground mb-3 text-xs">{toolName} needs permission to continue.</p>
{clicked ? (
<div className="flex items-center gap-2 text-xs text-amber-700 dark:text-amber-300">
<Loader2 className="size-3.5 animate-spin" />
Waiting for authorization&hellip; the agent will resume automatically.
</div>
) : (
<div className="flex gap-2">
<Button size="sm" asChild onClick={() => setClicked(true)}>
<a href={authUrl} target="_blank" rel="noopener noreferrer">
Authorize
</a>
</Button>
<Button size="sm" variant="outline" onClick={onContinue}>
Dismiss
</Button>
</div>
)}
</div>
);
}
47 changes: 35 additions & 12 deletions templates/_shared/nextjs-ui/components/dashboard/empty-state.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,25 @@
import { Inbox, Info, Loader2 } from "lucide-react";
"use client";

import { useState } from "react";
import { Inbox, Info, Loader2, X } from "lucide-react";
import { Alert, AlertTitle, AlertDescription, Button } from "@arcadeai/design-system";

const CALLOUT_KEY = "callout-how-planning-works";

interface EmptyStateProps {
onPlan: () => void;
loading: boolean;
}

export function EmptyState({ onPlan, loading }: EmptyStateProps) {
const [showCallout, setShowCallout] = useState(() =>
typeof window !== "undefined" ? localStorage.getItem(CALLOUT_KEY) !== "1" : true
);
const dismissCallout = () => {
localStorage.setItem(CALLOUT_KEY, "1");
setShowCallout(false);
};

return (
<div className="flex flex-1 flex-col items-center justify-center gap-6 p-8">
<Inbox size={48} className="text-muted-foreground/50" />
Expand All @@ -19,17 +32,27 @@ export function EmptyState({ onPlan, loading }: EmptyStateProps) {
build your action plan.
</p>
</div>
<Alert className="max-w-md text-left">
<Info className="size-4" />
<AlertTitle>How this works</AlertTitle>
<AlertDescription>
When you click &ldquo;Plan my day&rdquo;, the agent connects to your Slack, Gmail, Google
Calendar, Linear, and GitHub through Arcade&apos;s MCP Gateway. It will first check if any
of those need authentication — if so, you&apos;ll be prompted to authorize before the run
starts. Then it reads your recent messages, emails, events, issues, and pull requests, and
classifies each item by priority and suggests next steps.
</AlertDescription>
</Alert>
{showCallout && (
<Alert className="max-w-md text-left">
<Info className="size-4" />
<AlertTitle className="flex items-center justify-between">
How this works
<button
onClick={dismissCallout}
className="ml-2 rounded-sm opacity-70 hover:opacity-100"
>
<X className="size-3.5" />
</button>
</AlertTitle>
<AlertDescription>
When you click &ldquo;Plan my day&rdquo;, the agent connects to your Slack, Gmail,
Google Calendar, Linear, and GitHub through Arcade&apos;s MCP Gateway. It will first
check if any of those need authentication — if so, you&apos;ll be prompted to authorize
before the run starts. Then it reads your recent messages, emails, events, issues, and
pull requests, and classifies each item by priority and suggests next steps.
</AlertDescription>
</Alert>
)}
<Button size="lg" disabled={loading} onClick={onPlan}>
{loading ? (
<>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { Check, ArrowUpRight, Info } from "lucide-react";
"use client";

import { useState } from "react";
import { Check, ArrowUpRight, Info, X } from "lucide-react";
import { Alert, AlertTitle, AlertDescription, Button } from "@arcadeai/design-system";
import type { SourceStatus } from "@/types/inbox";
import { getSource } from "@/lib/sources";

const CALLOUT_KEY = "callout-why-authorize";

interface SourceAuthGateProps {
sourceStatuses: Record<string, SourceStatus>;
authUrls: { url: string; toolName?: string }[];
Expand All @@ -18,6 +23,14 @@ export function SourceAuthGate({
onSkip,
onContinue,
}: SourceAuthGateProps) {
const [showCallout, setShowCallout] = useState(() =>
typeof window !== "undefined" ? localStorage.getItem(CALLOUT_KEY) !== "1" : true
);
const dismissCallout = () => {
localStorage.setItem(CALLOUT_KEY, "1");
setShowCallout(false);
};

const pendingCount = Object.entries(sourceStatuses).filter(
([source, status]) => status === "auth_required" && !skippedSources.has(source)
).length;
Expand All @@ -34,15 +47,25 @@ export function SourceAuthGate({
</p>
</div>

<Alert className="mb-6">
<Info className="size-4" />
<AlertTitle>Why authorize?</AlertTitle>
<AlertDescription>
Each tool connects on your behalf using OAuth — the agent only gets read access to scan
for items to triage. You can skip any source you don&apos;t use, and revoke access anytime
from your Arcade dashboard.
</AlertDescription>
</Alert>
{showCallout && (
<Alert className="mb-6">
<Info className="size-4" />
<AlertTitle className="flex items-center justify-between">
Why authorize?
<button
onClick={dismissCallout}
className="ml-2 rounded-sm opacity-70 hover:opacity-100"
>
<X className="size-3.5" />
</button>
</AlertTitle>
<AlertDescription>
Each tool connects on your behalf using OAuth — the agent only gets read access to scan
for items to triage. You can skip any source you don&apos;t use, and revoke access
anytime from your Arcade dashboard.
</AlertDescription>
</Alert>
)}

<div className="mb-6 space-y-2">
{Object.entries(sourceStatuses).map(([source, status]) => {
Expand Down
10 changes: 7 additions & 3 deletions templates/_shared/nextjs-ui/hooks/use-arcade-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,17 @@ export function useArcadeConnection(): {

useEffect(() => {
checkConnection();
const onFocus = () => {
const onFocusOrVisible = () => {
if (Date.now() - lastCheckRef.current < 2000) return;
authInProgress.current = false; // User returned from OAuth tab — re-check
checkConnection();
};
window.addEventListener("focus", onFocus);
return () => window.removeEventListener("focus", onFocus);
window.addEventListener("focus", onFocusOrVisible);
document.addEventListener("visibilitychange", onFocusOrVisible);
return () => {
window.removeEventListener("focus", onFocusOrVisible);
document.removeEventListener("visibilitychange", onFocusOrVisible);
};
}, [checkConnection]);

const retryConnection = useCallback(() => checkConnection({ isRetry: true }), [checkConnection]);
Expand Down
8 changes: 8 additions & 0 deletions templates/_shared/nextjs-ui/hooks/use-plan-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { InboxItem, PlanEvent } from "@/types/inbox";

interface PlanStreamCallbacks {
onAuthRequired?: (authUrl: string, toolName?: string) => void;
onElicitation?: (elicitationId: string, authUrl: string, message: string) => void;
onSourcesDone?: () => void;
}

Expand Down Expand Up @@ -92,6 +93,13 @@ export function usePlanStream(callbacks?: PlanStreamCallbacks): {
case "auth_required":
callbacksRef.current?.onAuthRequired?.(event.authUrl, event.toolName);
break;
case "elicitation":
callbacksRef.current?.onElicitation?.(
event.elicitationId,
event.authUrl,
event.message
);
break;
case "status":
setStatusMessage(event.message);
break;
Expand Down
Loading