+
+
+
}
title="No artifact selected"
diff --git a/frontend/src/app/workspace/layout.tsx b/frontend/src/app/workspace/layout.tsx
index 04e5960..9243e01 100644
--- a/frontend/src/app/workspace/layout.tsx
+++ b/frontend/src/app/workspace/layout.tsx
@@ -2,6 +2,7 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useCallback, useEffect, useState } from "react";
+import { Toaster } from "sonner";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { Overscroll } from "@/components/workspace/overscroll";
@@ -41,6 +42,7 @@ export default function WorkspaceLayout({
{children}
+
);
}
diff --git a/frontend/src/components/ui/sonner.tsx b/frontend/src/components/ui/sonner.tsx
new file mode 100644
index 0000000..9b20afe
--- /dev/null
+++ b/frontend/src/components/ui/sonner.tsx
@@ -0,0 +1,40 @@
+"use client"
+
+import {
+ CircleCheckIcon,
+ InfoIcon,
+ Loader2Icon,
+ OctagonXIcon,
+ TriangleAlertIcon,
+} from "lucide-react"
+import { useTheme } from "next-themes"
+import { Toaster as Sonner, type ToasterProps } from "sonner"
+
+const Toaster = ({ ...props }: ToasterProps) => {
+ const { theme = "system" } = useTheme()
+
+ return (
+
,
+ info:
,
+ warning:
,
+ error:
,
+ loading:
,
+ }}
+ style={
+ {
+ "--normal-bg": "var(--popover)",
+ "--normal-text": "var(--popover-foreground)",
+ "--normal-border": "var(--border)",
+ "--border-radius": "var(--radius)",
+ } as React.CSSProperties
+ }
+ {...props}
+ />
+ )
+}
+
+export { Toaster }
diff --git a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx
index 72bedb5..2fc8e87 100644
--- a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx
+++ b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx
@@ -1,27 +1,99 @@
-import { FileIcon } from "lucide-react";
+import { CopyIcon, DownloadIcon, XIcon } from "lucide-react";
+import { useMemo } from "react";
+import { toast } from "sonner";
+import {
+ Artifact,
+ ArtifactAction,
+ ArtifactActions,
+ ArtifactContent,
+ ArtifactDescription,
+ ArtifactHeader,
+ ArtifactTitle,
+} from "@/components/ai-elements/artifact";
+import { useArtifactContent } from "@/core/artifacts/hooks";
+import { urlOfArtifact } from "@/core/artifacts/utils";
+import {
+ checkCodeFile,
+ getFileExtensionDisplayName,
+ getFileName,
+} from "@/core/utils/files";
import { cn } from "@/lib/utils";
+import { useArtifacts } from "./context";
+import { FileViewer } from "./file-viewer";
+
export function ArtifactFileDetail({
className,
filepath,
+ threadId,
}: {
className?: string;
filepath: string;
+ threadId: string;
}) {
+ const { setOpen } = useArtifacts();
+ const { isCodeFile } = useMemo(() => checkCodeFile(filepath), [filepath]);
+ const { content } = useArtifactContent({
+ threadId,
+ filepath,
+ enabled: isCodeFile,
+ });
return (
-
-
+
+
-
+
{getFileName(filepath)}
+
+ {getFileExtensionDisplayName(filepath)} file
+
- {filepath}
-
-
+
+
+ {isCodeFile && (
+ {
+ try {
+ await navigator.clipboard.writeText(content ?? "");
+ toast.success("Copied to clipboard");
+ } catch (error) {
+ toast.error("Failed to copy to clipboard");
+ console.error(error);
+ }
+ }}
+ tooltip="Copy content to clipboard"
+ />
+ )}
+
+ console.log("Download")}
+ tooltip="Download file"
+ />
+
+ setOpen(false)}
+ tooltip="Close"
+ />
+
+
+
+
+
+
+
);
}
diff --git a/frontend/src/components/workspace/artifacts/artifact-file-list.tsx b/frontend/src/components/workspace/artifacts/artifact-file-list.tsx
index 44309cb..0730eac 100644
--- a/frontend/src/components/workspace/artifacts/artifact-file-list.tsx
+++ b/frontend/src/components/workspace/artifacts/artifact-file-list.tsx
@@ -9,7 +9,8 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
-import { getFileExtension, getFileName } from "@/core/utils/files";
+import { urlOfArtifact } from "@/core/artifacts/utils";
+import { getFileExtensionDisplayName, getFileName } from "@/core/utils/files";
import { cn } from "@/lib/utils";
import { useArtifacts } from "./context";
@@ -17,9 +18,11 @@ import { useArtifacts } from "./context";
export function ArtifactFileList({
className,
files,
+ threadId,
}: {
className?: string;
files: string[];
+ threadId: string;
}) {
const { openArtifact } = useArtifacts();
const handleClick = useCallback(
@@ -38,12 +41,24 @@ export function ArtifactFileList({
>
{getFileName(file)}
- {getFileExtension(file)} file
+
+ {getFileExtensionDisplayName(file)} file
+
-
+ e.stopPropagation()}
+ >
+
+
diff --git a/frontend/src/components/workspace/artifacts/file-viewer.tsx b/frontend/src/components/workspace/artifacts/file-viewer.tsx
new file mode 100644
index 0000000..97604f7
--- /dev/null
+++ b/frontend/src/components/workspace/artifacts/file-viewer.tsx
@@ -0,0 +1,64 @@
+import { useMemo } from "react";
+import type { BundledLanguage } from "shiki";
+
+import { CodeBlock } from "@/components/ai-elements/code-block";
+import { useArtifactContent } from "@/core/artifacts/hooks";
+import { urlOfArtifact } from "@/core/artifacts/utils";
+import { checkCodeFile } from "@/core/utils/files";
+import { cn } from "@/lib/utils";
+
+export function FileViewer({
+ className,
+ filepath,
+ threadId,
+}: {
+ className?: string;
+ filepath: string;
+ threadId: string;
+}) {
+ const { isCodeFile, language } = useMemo(
+ () => checkCodeFile(filepath),
+ [filepath],
+ );
+ if (isCodeFile && language !== "html") {
+ return (
+
+ );
+ }
+ return (
+
+
+
+ );
+}
+
+function CodeFileView({
+ language,
+ filepath,
+ threadId,
+}: {
+ language: BundledLanguage;
+ filepath: string;
+ threadId: string;
+}) {
+ const { content: code } = useArtifactContent({
+ filepath,
+ threadId,
+ });
+ if (code) {
+ return (
+
+ );
+ }
+}
diff --git a/frontend/src/components/workspace/messages/message-list.tsx b/frontend/src/components/workspace/messages/message-list.tsx
index 7bc16b2..1c0354f 100644
--- a/frontend/src/components/workspace/messages/message-list.tsx
+++ b/frontend/src/components/workspace/messages/message-list.tsx
@@ -22,9 +22,11 @@ import { MessageListSkeleton } from "./skeleton";
export function MessageList({
className,
+ threadId,
thread,
}: {
className?: string;
+ threadId: string;
thread: UseStream
;
}) {
if (thread.isThreadLoading) {
@@ -57,7 +59,11 @@ export function MessageList({
}
}
return (
-
+
);
}
return (
diff --git a/frontend/src/core/artifacts/hooks.ts b/frontend/src/core/artifacts/hooks.ts
new file mode 100644
index 0000000..e7a6cd4
--- /dev/null
+++ b/frontend/src/core/artifacts/hooks.ts
@@ -0,0 +1,20 @@
+import { useQuery } from "@tanstack/react-query";
+
+import { loadArtifactContent } from "./loader";
+
+export function useArtifactContent({
+ filepath,
+ threadId,
+ enabled,
+}: {
+ filepath: string;
+ threadId: string;
+ enabled?: boolean;
+}) {
+ const { data, isLoading, error } = useQuery({
+ queryKey: ["artifact", filepath, threadId],
+ queryFn: () => loadArtifactContent({ filepath, threadId }),
+ enabled,
+ });
+ return { content: data, isLoading, error };
+}
diff --git a/frontend/src/core/artifacts/index.ts b/frontend/src/core/artifacts/index.ts
new file mode 100644
index 0000000..ee5286f
--- /dev/null
+++ b/frontend/src/core/artifacts/index.ts
@@ -0,0 +1 @@
+export * from "./loader";
diff --git a/frontend/src/core/artifacts/loader.ts b/frontend/src/core/artifacts/loader.ts
new file mode 100644
index 0000000..d55b1b4
--- /dev/null
+++ b/frontend/src/core/artifacts/loader.ts
@@ -0,0 +1,14 @@
+import { urlOfArtifact } from "./utils";
+
+export async function loadArtifactContent({
+ filepath,
+ threadId,
+}: {
+ filepath: string;
+ threadId: string;
+}) {
+ const url = urlOfArtifact({ filepath, threadId });
+ const response = await fetch(url);
+ const text = await response.text();
+ return text;
+}
diff --git a/frontend/src/core/artifacts/utils.ts b/frontend/src/core/artifacts/utils.ts
new file mode 100644
index 0000000..1975522
--- /dev/null
+++ b/frontend/src/core/artifacts/utils.ts
@@ -0,0 +1,11 @@
+export function urlOfArtifact({
+ filepath,
+ threadId,
+ download = false,
+}: {
+ filepath: string;
+ threadId: string;
+ download?: boolean;
+}) {
+ return `http://localhost:8000/api/threads/${threadId}/artifacts${filepath}${download ? "?download=true" : ""}`;
+}
diff --git a/frontend/src/core/utils/files.ts b/frontend/src/core/utils/files.ts
index ec65b10..f3923d0 100644
--- a/frontend/src/core/utils/files.ts
+++ b/frontend/src/core/utils/files.ts
@@ -1,8 +1,159 @@
+import type { BundledLanguage } from "shiki";
+
+const extensionMap: Record = {
+ // JavaScript/TypeScript ecosystem
+ js: "javascript",
+ jsx: "jsx",
+ ts: "typescript",
+ tsx: "tsx",
+ mjs: "javascript",
+ cjs: "javascript",
+ mts: "typescript",
+ cts: "typescript",
+
+ // Web
+ html: "html",
+ htm: "html",
+ css: "css",
+ scss: "scss",
+ sass: "sass",
+ less: "less",
+ vue: "vue",
+ svelte: "svelte",
+ astro: "astro",
+
+ // Python
+ py: "python",
+ pyi: "python",
+ pyw: "python",
+
+ // Java/JVM
+ java: "java",
+ kt: "kotlin",
+ kts: "kotlin",
+ scala: "scala",
+ groovy: "groovy",
+
+ // C/C++
+ c: "c",
+ h: "c",
+ cpp: "cpp",
+ cc: "cpp",
+ cxx: "cpp",
+ hpp: "cpp",
+ hxx: "cpp",
+ hh: "cpp",
+
+ // C#
+ cs: "csharp",
+
+ // Go
+ go: "go",
+
+ // Rust
+ rs: "rust",
+
+ // Ruby
+ rb: "ruby",
+ rake: "ruby",
+
+ // PHP
+ php: "php",
+
+ // Shell/Bash
+ sh: "bash",
+ bash: "bash",
+ zsh: "zsh",
+ fish: "fish",
+
+ // Config & Data
+ json: "json",
+ jsonc: "jsonc",
+ json5: "json5",
+ yaml: "yaml",
+ yml: "yaml",
+ toml: "toml",
+ xml: "xml",
+ ini: "ini",
+ env: "dotenv",
+
+ // Markdown & Docs
+ md: "markdown",
+ mdx: "mdx",
+ rst: "rst",
+
+ // SQL
+ sql: "sql",
+
+ // Other languages
+ swift: "swift",
+ dart: "dart",
+ lua: "lua",
+ r: "r",
+ matlab: "matlab",
+ julia: "jl",
+ elm: "elm",
+ haskell: "haskell",
+ hs: "haskell",
+ elixir: "elixir",
+ ex: "elixir",
+ clj: "clojure",
+ cljs: "clojure",
+
+ // Infrastructure
+ dockerfile: "dockerfile",
+ docker: "docker",
+ tf: "terraform",
+ tfvars: "terraform",
+ hcl: "hcl",
+
+ // Build & Config
+ makefile: "makefile",
+ cmake: "cmake",
+ gradle: "groovy",
+
+ // Git
+ gitignore: "git-commit",
+ gitattributes: "git-commit",
+
+ // Misc
+ graphql: "graphql",
+ gql: "graphql",
+ proto: "protobuf",
+ prisma: "prisma",
+ wasm: "wasm",
+ zig: "zig",
+ v: "v",
+};
+
export function getFileName(filepath: string) {
return filepath.split("/").pop()!;
}
export function getFileExtension(filepath: string) {
+ return filepath.split(".").pop()!.toLocaleLowerCase();
+}
+
+export function checkCodeFile(
+ filepath: string,
+):
+ | { isCodeFile: true; language: BundledLanguage }
+ | { isCodeFile: false; language: null } {
+ const extension = getFileExtension(filepath);
+ const isCodeFile = extension in extensionMap;
+ if (isCodeFile) {
+ return {
+ isCodeFile: true,
+ language: extensionMap[extension] as unknown as BundledLanguage,
+ };
+ }
+ return {
+ isCodeFile: false,
+ language: null,
+ };
+}
+
+export function getFileExtensionDisplayName(filepath: string) {
const fileName = getFileName(filepath);
const extension = fileName.split(".").pop()!.toLocaleLowerCase();
switch (extension) {