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
4 changes: 4 additions & 0 deletions crates/agent-gateway/web/src/app/GatewayApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3285,6 +3285,9 @@ export default function GatewayApp() {
composerRef.current?.insertGitFileMention(file);
composerRef.current?.focus();
}, []);
const handleEmptyStateSuggestion = useCallback((text: string) => {
composerRef.current?.typeText(text);
}, []);
const handleRightDockClose = useCallback(() => {
setRightDockOpen(false);
}, []);
Expand Down Expand Up @@ -3768,6 +3771,7 @@ export default function GatewayApp() {
gitClient={gitClient}
onLoadUploadedImagePreview={handleLoadUploadedImagePreview}
onResendFromEdit={handleResendFromEdit}
onSuggestionSelect={handleEmptyStateSuggestion}
/>
</ScrollArea>
{conversationOpenState.showOverlay ? (
Expand Down
53 changes: 8 additions & 45 deletions crates/agent-gateway/web/src/components/GatewayTranscript.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import type { TranscriptRow } from "../lib/chat/transcript/types";

import type { GatewayTranscriptRound } from "../lib/chatUi";
import type { SectionId } from "../pages/settings/types";
import { ChatEmptyState } from "./chat/ChatEmptyState";
import {
Check,
CheckCircle2,
Expand All @@ -54,7 +55,6 @@ import {
FileText,
Loader2,
Pencil,
Settings,
X,
} from "./icons";

Expand Down Expand Up @@ -89,6 +89,7 @@ type GatewayTranscriptProps = {
text: string,
uploadedFiles: PendingUploadedFile[],
) => void;
onSuggestionSelect?: (text: string) => void;
readOnly?: boolean;
redactToolContent?: boolean;
};
Expand Down Expand Up @@ -1339,10 +1340,10 @@ export function GatewayTranscript({
gitClient,
onLoadUploadedImagePreview,
onResendFromEdit,
onSuggestionSelect,
readOnly = false,
redactToolContent = false,
}: GatewayTranscriptProps) {
const { t } = useLocale();
const transcriptListRef = useRef<HTMLDivElement | null>(null);
const [transcriptScrollViewport, setTranscriptScrollViewport] = useState<HTMLDivElement | null>(
null,
Expand Down Expand Up @@ -1373,49 +1374,11 @@ export function GatewayTranscript({
return (
<div className="gateway-transcript-shell">
<div className="gateway-chat-column gateway-empty-state">
<div className="relative flex flex-col items-center">
<div className="hero-entrance hero-icon-float mb-5 flex h-24 w-24 items-center justify-center">
<img
src="/icon-simple.png"
alt=""
aria-hidden="true"
draggable={false}
className="h-[72px] w-[72px] select-none object-contain"
/>
</div>

<h2
className="hero-entrance-delay-1 bg-gradient-to-b from-foreground to-foreground/65 bg-clip-text text-center text-2xl font-semibold leading-tight tracking-tight text-transparent"
style={{ margin: `0 0 ${showNoModelsState ? 16 : 25}px` }}
>
{showNoModelsState ? t("chat.welcome") : t("chat.startChat")}
</h2>

{showNoModelsState ? (
<>
<p className="hero-entrance-delay-2 mb-1.5 text-center text-sm leading-relaxed text-muted-foreground">
{t("chat.noModelSelected")}
</p>
<p className="hero-entrance-delay-2 mb-8 text-center text-sm leading-relaxed text-muted-foreground">
{t("chat.configureModel")}
</p>
{onOpenSettings ? (
<button
type="button"
onClick={() => onOpenSettings("providers")}
className="hero-entrance-delay-3 group inline-flex items-center gap-2 rounded-full border border-white/70 bg-white/65 px-5 py-2 text-sm font-medium text-foreground/85 shadow-[0_1px_2px_rgba(0,0,0,0.04),0_8px_24px_rgba(0,0,0,0.06),inset_0_1px_0_rgba(255,255,255,0.85)] backdrop-blur-xl transition-all duration-200 hover:-translate-y-[1px] hover:bg-white/80 hover:text-foreground hover:shadow-[0_2px_4px_rgba(0,0,0,0.05),0_12px_32px_rgba(0,0,0,0.08),inset_0_1px_0_rgba(255,255,255,0.9)] active:translate-y-0 active:shadow-[0_1px_2px_rgba(0,0,0,0.04)] dark:border-white/[0.1] dark:bg-white/[0.06] dark:text-foreground/90 dark:shadow-[0_1px_2px_rgba(0,0,0,0.2),0_8px_24px_rgba(0,0,0,0.25),inset_0_1px_0_rgba(255,255,255,0.06)] dark:hover:bg-white/[0.1]"
>
<Settings className="h-3.5 w-3.5 text-foreground/55 transition-colors group-hover:text-foreground/80" />
{t("chat.goToSettings")}
</button>
) : null}
</>
) : (
<p className="hero-entrance-delay-2 max-w-[300px] text-center text-[13px] leading-relaxed text-muted-foreground/85">
{t("chat.startChatDesc")}
</p>
)}
</div>
<ChatEmptyState
variant={showNoModelsState ? "no-models" : "start-chat"}
onOpenSettings={onOpenSettings}
onSuggestionSelect={readOnly ? undefined : onSuggestionSelect}
/>
</div>
</div>
);
Expand Down
187 changes: 187 additions & 0 deletions crates/agent-gateway/web/src/components/chat/ChatEmptyState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import {
type CSSProperties,
type PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
useState,
} from "react";

import { FolderTree, Lightbulb, Settings, Wrench } from "@/components/icons";
import { useLocale } from "@/i18n/LocaleContext";
import type { SectionId } from "@/pages/settings/types";

type GreetingPeriod = "morning" | "noon" | "afternoon" | "evening" | "night";

const GREETING_KEYS: Record<GreetingPeriod, string> = {
morning: "chat.greetingMorning",
noon: "chat.greetingNoon",
afternoon: "chat.greetingAfternoon",
evening: "chat.greetingEvening",
night: "chat.greetingNight",
};

function resolveGreetingPeriod(hour: number): GreetingPeriod {
if (hour >= 5 && hour < 12) return "morning";
if (hour >= 12 && hour < 14) return "noon";
if (hour >= 14 && hour < 18) return "afternoon";
if (hour >= 18 && hour < 23) return "evening";
return "night";
}

function useGreetingPeriod() {
const [period, setPeriod] = useState<GreetingPeriod>(() =>
resolveGreetingPeriod(new Date().getHours()),
);

useEffect(() => {
const timer = window.setInterval(() => {
setPeriod(resolveGreetingPeriod(new Date().getHours()));
}, 60_000);
return () => window.clearInterval(timer);
}, []);

return period;
}

const SUGGESTION_CARDS = [
{
key: "explore",
icon: FolderTree,
accent: "199 89% 48%",
chipClassName:
"bg-sky-500/10 text-sky-600 group-hover:bg-sky-500/20 dark:bg-sky-400/10 dark:text-sky-400 dark:group-hover:bg-sky-400/20",
titleKey: "chat.suggestExploreTitle",
hintKey: "chat.suggestExploreHint",
promptKey: "chat.suggestExplorePrompt",
},
{
key: "fix",
icon: Wrench,
accent: "38 92% 50%",
chipClassName:
"bg-amber-500/10 text-amber-600 group-hover:bg-amber-500/20 dark:bg-amber-400/10 dark:text-amber-400 dark:group-hover:bg-amber-400/20",
titleKey: "chat.suggestFixTitle",
hintKey: "chat.suggestFixHint",
promptKey: "chat.suggestFixPrompt",
},
{
key: "ideate",
icon: Lightbulb,
accent: "160 84% 39%",
chipClassName:
"bg-emerald-500/10 text-emerald-600 group-hover:bg-emerald-500/20 dark:bg-emerald-400/10 dark:text-emerald-400 dark:group-hover:bg-emerald-400/20",
titleKey: "chat.suggestIdeateTitle",
hintKey: "chat.suggestIdeateHint",
promptKey: "chat.suggestIdeatePrompt",
},
] as const;

export type ChatEmptyStateProps = {
variant: "no-models" | "start-chat";
onOpenSettings?: (section?: SectionId) => void;
onSuggestionSelect?: (text: string) => void;
};

export function ChatEmptyState({
variant,
onOpenSettings,
onSuggestionSelect,
}: ChatEmptyStateProps) {
const { t } = useLocale();
const period = useGreetingPeriod();

// Drives the accent spotlight that follows the cursor inside each card.
const handleCardPointerMove = useCallback((event: ReactPointerEvent<HTMLButtonElement>) => {
const card = event.currentTarget;
const rect = card.getBoundingClientRect();
card.style.setProperty("--spot-x", `${event.clientX - rect.left}px`);
card.style.setProperty("--spot-y", `${event.clientY - rect.top}px`);
}, []);

return (
<div className="relative flex w-full flex-col items-center">
<div className="hero-entrance relative mb-6 flex h-[88px] w-[88px] items-center justify-center">
<div className="hero-aura" aria-hidden="true" />
<div className="hero-icon-float relative z-[1] flex items-center justify-center">
<img
src="/icon-simple.png"
alt=""
aria-hidden="true"
draggable={false}
className="h-16 w-16 select-none object-contain"
/>
</div>
</div>

{variant === "no-models" ? (
<>
<div className="hero-entrance-delay-1 mb-2 bg-gradient-to-b from-foreground to-foreground/65 bg-clip-text text-center text-[26px] font-semibold leading-tight tracking-tight text-transparent">
{t("chat.welcome")}
</div>
<div className="hero-entrance-delay-2 mb-1 text-center text-sm leading-relaxed text-muted-foreground">
{t("chat.noModelSelected")}
</div>
<div className="hero-entrance-delay-2 mb-7 text-center text-sm leading-relaxed text-muted-foreground">
{t("chat.configureModel")}
</div>
{onOpenSettings ? (
<button
type="button"
onClick={() => onOpenSettings("providers")}
className="hero-entrance-delay-3 group inline-flex items-center gap-2 rounded-full border border-white/70 bg-white/65 px-5 py-2 text-sm font-medium text-foreground/85 shadow-[0_1px_2px_rgba(0,0,0,0.04),0_8px_24px_rgba(0,0,0,0.06),inset_0_1px_0_rgba(255,255,255,0.85)] backdrop-blur-xl transition-all duration-200 hover:-translate-y-[1px] hover:bg-white/80 hover:text-foreground hover:shadow-[0_2px_4px_rgba(0,0,0,0.05),0_12px_32px_rgba(0,0,0,0.08),inset_0_1px_0_rgba(255,255,255,0.9)] active:translate-y-0 active:shadow-[0_1px_2px_rgba(0,0,0,0.04)] dark:border-white/[0.1] dark:bg-white/[0.06] dark:text-foreground/90 dark:shadow-[0_1px_2px_rgba(0,0,0,0.2),0_8px_24px_rgba(0,0,0,0.25),inset_0_1px_0_rgba(255,255,255,0.06)] dark:hover:bg-white/[0.1]"
>
<Settings className="h-3.5 w-3.5 text-foreground/55 transition-colors group-hover:text-foreground/80" />
{t("chat.goToSettings")}
</button>
) : null}
</>
) : (
<>
<div className="hero-entrance-delay-1 mb-2.5 bg-gradient-to-b from-foreground to-foreground/65 bg-clip-text text-center text-[26px] font-semibold leading-tight tracking-tight text-transparent">
{t(GREETING_KEYS[period])}
</div>
<div className="hero-entrance-delay-2 flex items-center justify-center gap-1.5 text-center text-sm leading-relaxed text-muted-foreground">
<Lightbulb
aria-hidden="true"
className="h-3.5 w-3.5 shrink-0 text-amber-500 dark:text-amber-400"
/>
{t("chat.greetingSubtitle")}
</div>
{onSuggestionSelect ? (
<div className="mt-9 grid w-full max-w-[640px] grid-cols-1 gap-2.5 px-6 sm:grid-cols-3 sm:px-4">
{SUGGESTION_CARDS.map((card, index) => (
<button
key={card.key}
type="button"
onClick={() => onSuggestionSelect(t(card.promptKey))}
onPointerMove={handleCardPointerMove}
style={
{
"--hero-delay": `${0.26 + index * 0.06}s`,
"--card-accent": card.accent,
} as CSSProperties
}
className="hero-card-entrance hero-suggest-card group flex items-center gap-3 rounded-xl px-3.5 py-3 text-left backdrop-blur-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
>
<span
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg transition-all duration-200 group-hover:-rotate-3 group-hover:scale-110 ${card.chipClassName}`}
>
<card.icon className="h-4 w-4" />
</span>
<span className="flex min-w-0 flex-col gap-0.5">
<span className="truncate text-[13px] font-medium leading-tight text-foreground/90">
{t(card.titleKey)}
</span>
<span className="truncate text-xs leading-tight text-muted-foreground">
{t(card.hintKey)}
</span>
</span>
</button>
))}
</div>
) : null}
</>
)}
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -1798,14 +1798,14 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi
<div
ref={recentHeaderRef}
className={cn(
"flex items-center justify-between px-3 pb-2",
"grid grid-cols-[minmax(0,1fr)_auto] items-center gap-2 px-3 pb-2",
showProjects ? "border-t border-border/35 pt-0.5" : "pt-3",
)}
>
<button
type="button"
aria-expanded={!recentCollapsed}
className="flex min-w-0 flex-1 items-center gap-1.5 rounded-md px-1 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground outline-hidden hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
className="flex min-w-0 items-center gap-1.5 rounded-md px-1 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground outline-hidden hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => onRecentCollapsedChange?.(!recentCollapsed)}
>
<ChevronRight
Expand Down
Loading
Loading