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
12 changes: 12 additions & 0 deletions apps/apollo-vertex/registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,18 @@
"type": "registry:ui",
"target": "components/ui/ai-chat/components/ai-chat-loading.tsx"
},
{
"path": "registry/ai-chat/components/ai-chat-thinking.tsx",
"type": "registry:component"
},
{
"path": "registry/ai-chat/components/ai-chat-code-block.tsx",
"type": "registry:component"
},
{
"path": "registry/ai-chat/components/ai-chat-empty-state.tsx",
"type": "registry:component"
},
{
"path": "registry/ai-chat/components/ai-chat-input.tsx",
"type": "registry:ui",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
"use client";

import "highlight.js/styles/github.min.css";

// Dark-mode override: github-dark-dimmed palette scoped to `.dark`
const DARK_HLJS_STYLE = `
.dark .hljs {
color: #adbac7;
background: transparent;
}
.dark .hljs-doctag,.dark .hljs-keyword,.dark .hljs-meta .hljs-keyword,.dark .hljs-template-tag,.dark .hljs-template-variable,.dark .hljs-type,.dark .hljs-variable.language_ {
color: #f47067;
}
.dark .hljs-title,.dark .hljs-title.class_,.dark .hljs-title.class_.inherited__,.dark .hljs-title.function_ {
color: #dcbdfb;
}
.dark .hljs-attr,.dark .hljs-attribute,.dark .hljs-literal,.dark .hljs-meta,.dark .hljs-number,.dark .hljs-operator,.dark .hljs-variable,.dark .hljs-selector-attr,.dark .hljs-selector-class,.dark .hljs-selector-id {
color: #6cb6ff;
}
.dark .hljs-regexp,.dark .hljs-string,.dark .hljs-meta .hljs-string {
color: #96d0ff;
}
.dark .hljs-built_in,.dark .hljs-symbol {
color: #f69d50;
}
.dark .hljs-comment,.dark .hljs-code,.dark .hljs-formula {
color: #768390;
}
.dark .hljs-name,.dark .hljs-quote,.dark .hljs-selector-tag,.dark .hljs-selector-pseudo {
color: #8ddb8c;
}
.dark .hljs-subst {
color: #adbac7;
}
.dark .hljs-section {
color: #316dca;
font-weight: bold;
}
.dark .hljs-bullet {
color: #eac55f;
}
.dark .hljs-emphasis {
color: #adbac7;
font-style: italic;
}
.dark .hljs-strong {
color: #adbac7;
font-weight: bold;
}
.dark .hljs-addition {
color: #b4f1b4;
background-color: #1b4721;
}
.dark .hljs-deletion {
color: #ffd8d3;
background-color: #78191b;
}
`;

import hljs from "highlight.js/lib/core";
import bash from "highlight.js/lib/languages/bash";
import css from "highlight.js/lib/languages/css";
import javascript from "highlight.js/lib/languages/javascript";
import json from "highlight.js/lib/languages/json";
import python from "highlight.js/lib/languages/python";
import sql from "highlight.js/lib/languages/sql";
import typescript from "highlight.js/lib/languages/typescript";
import xml from "highlight.js/lib/languages/xml";
import { Check, Copy } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/registry/tooltip/tooltip";

hljs.registerLanguage("javascript", javascript);
hljs.registerLanguage("js", javascript);
hljs.registerLanguage("typescript", typescript);
hljs.registerLanguage("ts", typescript);
hljs.registerLanguage("tsx", typescript);
hljs.registerLanguage("jsx", javascript);
hljs.registerLanguage("python", python);
hljs.registerLanguage("py", python);
hljs.registerLanguage("bash", bash);
hljs.registerLanguage("sh", bash);
hljs.registerLanguage("shell", bash);
hljs.registerLanguage("json", json);
hljs.registerLanguage("css", css);
hljs.registerLanguage("html", xml);
hljs.registerLanguage("xml", xml);
hljs.registerLanguage("sql", sql);

const COPY_LABEL = "Copy code";
const COPIED_LABEL = "Copied!";

interface AiChatCodeBlockProps {
children: string;
language?: string;
}

export function AiChatCodeBlock({ children, language }: AiChatCodeBlockProps) {
const [copied, setCopied] = useState(false);
const codeRef = useRef<HTMLElement>(null);

const highlightedHtml =
language && hljs.getLanguage(language)
? hljs.highlight(children, { language }).value
: hljs.highlightAuto(children).value;

useEffect(() => {
if (codeRef.current) {
codeRef.current.innerHTML = highlightedHtml;
}
}, [highlightedHtml]);

const handleCopy = async () => {
await navigator.clipboard.writeText(children);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};

const copyLabel = copied ? COPIED_LABEL : COPY_LABEL;

return (
<>
<style>{DARK_HLJS_STYLE}</style>
<div className="relative group/codeblock mb-2 last:mb-0 rounded-lg bg-ai-chat-muted/50 overflow-hidden">
<div className="flex items-center justify-between px-3 py-1.5 border-b border-ai-chat-border">
{language && (
<span className="text-[11px] font-mono text-ai-chat-muted-foreground uppercase tracking-wider">
{language}
</span>
)}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => {
void handleCopy();
}}
className="ml-auto size-6 inline-flex items-center justify-center rounded-md opacity-0 group-hover/codeblock:opacity-100 transition-opacity hover:bg-ai-chat-border"
aria-label={copyLabel}
>
{copied ? (
<Check className="size-3 text-success" aria-hidden="true" />
) : (
<Copy
className="size-3 text-ai-chat-muted-foreground"
aria-hidden="true"
/>
)}
</button>
</TooltipTrigger>
<TooltipContent>{copyLabel}</TooltipContent>
</Tooltip>
</div>
<pre className="p-3 overflow-x-auto">
<code ref={codeRef} className="hljs text-xs font-mono" />
</pre>
</div>
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"use client";

import type { ReactNode } from "react";

interface AiChatEmptyStateProps {
title?: string;
description?: string;
icon?: ReactNode;
}

export function AiChatEmptyState({
title = "How can I help you?",
description,
icon,
}: AiChatEmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center h-full text-center text-ai-chat-muted-foreground gap-4">
{icon}
<div className="flex flex-col items-center gap-1">
<h2 className="text-2xl font-semibold leading-tight tracking-tight text-foreground">
{title}
</h2>
{description && (
<p className="text-sm text-muted-foreground max-w-sm">
{description}
</p>
)}
</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -1,33 +1,73 @@
"use client";

import { Sparkles } from "lucide-react";
import { useTranslation } from "react-i18next";
import { motion } from "framer-motion";
import { AiChatThinking } from "./ai-chat-thinking";

interface AiChatLoadingProps {
assistantName?: string;
}
// Quartic ease-out — same curve used inside AiChatThinking for consistency
const ENTRANCE_EASE = [0.22, 1, 0.36, 1] as const;
// Container slide-up + fade-in duration
const ENTRANCE_DURATION = 0.5;
// Text appears after the icon's morph completes (FORWARD_DURATION in AiChatThinking is 0.8s) plus a small gap
const TEXT_DELAY = 0.9;
const TEXT_DURATION = 0.3;

export function AiChatLoading({ assistantName }: AiChatLoadingProps) {
const { t } = useTranslation();
const displayName = assistantName ?? t("ai_assistant");
const shimmerStyle = {
display: "inline-block",
whiteSpace: "nowrap",
lineHeight: 1.3,
fontSize: "14px",
fontWeight: 500,
backgroundImage:
"linear-gradient(90deg, var(--muted-foreground) 0%, var(--muted-foreground) 30%, #6C5AEF 42%, var(--foreground) 50%, #69C7DD 58%, var(--muted-foreground) 70%, var(--muted-foreground) 100%)",
backgroundSize: "200% 100%",
backgroundClip: "text",
WebkitBackgroundClip: "text",
color: "transparent",
animation: "ap-chat-loading-shimmer 2.4s linear infinite",
} as const;

// TODO: Progressive thinking states
// The indicator should become more informative as latency grows, and should
// reflect what the agent is actually doing (not just that it is busy):
//
// < 500ms No indicator — avoids flicker on fast responses
// 500ms–2s Static "Thinking…" + shimmer (current)
// 2s+ Contextual label — text reflects the current operation
// (e.g. "Reading document…", "Running automation…")
// 4s+ Step-level UI — surfaces discrete agent actions as they occur,
// so the user understands what the agent is doing on their behalf
//
// Labels will be caller-supplied once the state model is defined, so the
// component stays agnostic to the specific agent and its toolset.
export function AiChatLoading() {
return (
<div className="flex justify-start gap-3">
<div className="size-8 flex items-center justify-center flex-shrink-0 rounded-full bg-primary">
<Sparkles className="size-4 text-primary-foreground" />
</div>
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground font-medium">
{displayName}
</span>
<div className="px-4 py-3 rounded-lg bg-primary/10">
<div className="flex gap-1">
<span className="size-2 bg-primary rounded-full animate-bounce [animation-delay:-0.3s]" />
<span className="size-2 bg-primary rounded-full animate-bounce [animation-delay:-0.15s]" />
<span className="size-2 bg-primary rounded-full animate-bounce" />
</div>
</div>
<motion.div
className="flex justify-start py-2"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: ENTRANCE_DURATION, ease: ENTRANCE_EASE }}
>
<style>{`
@keyframes ap-chat-loading-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
`}</style>
<div className="flex items-center gap-0">
<AiChatThinking size={40} isThinking />
<motion.div
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{
duration: TEXT_DURATION,
delay: TEXT_DELAY,
ease: ENTRANCE_EASE,
}}
style={{ marginLeft: "-7px", display: "flex", alignItems: "center" }}
>
<span style={shimmerStyle}>{"Thinking\u2026"}</span>
</motion.div>
</div>
</div>
</motion.div>
);
}
Loading
Loading