diff --git a/skyvern-frontend/server.js b/skyvern-frontend/artifactServer.js
similarity index 54%
rename from skyvern-frontend/server.js
rename to skyvern-frontend/artifactServer.js
index c2337779..5fa0d545 100644
--- a/skyvern-frontend/server.js
+++ b/skyvern-frontend/artifactServer.js
@@ -1,9 +1,12 @@
import express from "express";
import fs from "fs";
+import cors from "cors";
const app = express();
-app.get("/artifact", (req, res) => {
+app.use(cors());
+
+app.get("/artifact/recording", (req, res) => {
const range = req.headers.range;
const path = req.query.path;
const videoSize = fs.statSync(path).size;
@@ -25,4 +28,26 @@ app.get("/artifact", (req, res) => {
stream.pipe(res);
});
+app.get("/artifact/image", (req, res) => {
+ const path = req.query.path;
+ res.sendFile(path);
+});
+
+app.get("/artifact/json", (req, res) => {
+ const path = req.query.path;
+ const contents = fs.readFileSync(path);
+ try {
+ const data = JSON.parse(contents);
+ res.json(data);
+ } catch (err) {
+ res.status(500).send(err);
+ }
+});
+
+app.get("/artifact/text", (req, res) => {
+ const path = req.query.path;
+ const contents = fs.readFileSync(path);
+ res.send(contents);
+});
+
app.listen(9090);
diff --git a/skyvern-frontend/index.html b/skyvern-frontend/index.html
index 7dbc1a9f..99d0f255 100644
--- a/skyvern-frontend/index.html
+++ b/skyvern-frontend/index.html
@@ -2,7 +2,7 @@
-
+
Skyvern
diff --git a/skyvern-frontend/package-lock.json b/skyvern-frontend/package-lock.json
index 7a69fcc0..720348dd 100644
--- a/skyvern-frontend/package-lock.json
+++ b/skyvern-frontend/package-lock.json
@@ -10,22 +10,28 @@
"dependencies": {
"@hookform/resolvers": "^3.3.4",
"@radix-ui/react-accordion": "^1.1.2",
+ "@radix-ui/react-aspect-ratio": "^1.0.3",
"@radix-ui/react-dialog": "^1.0.5",
+ "@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slot": "^1.0.2",
+ "@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-tooltip": "^1.0.7",
"@tanstack/react-query": "^5.28.6",
"axios": "^1.6.8",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
+ "cors": "^2.8.5",
+ "embla-carousel-react": "^8.0.0",
"express": "^4.19.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.51.1",
+ "react-medium-image-zoom": "^5.1.11",
"react-router-dom": "^6.22.3",
"tailwind-merge": "^2.2.2",
"tailwindcss-animate": "^1.0.7",
@@ -883,6 +889,29 @@
}
}
},
+ "node_modules/@radix-ui/react-aspect-ratio": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.0.3.tgz",
+ "integrity": "sha512-fXR5kbMan9oQqMuacfzlGG/SQMcmMlZ4wrvpckv8SgUulD0MMpspxJrxg/Gp/ISV3JfV1AeSWTYK9GvxA4ySwA==",
+ "dependencies": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/react-primitive": "1.0.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0",
+ "react-dom": "^16.8 || ^17.0 || ^18.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-collapsible": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.0.3.tgz",
@@ -1053,6 +1082,35 @@
}
}
},
+ "node_modules/@radix-ui/react-dropdown-menu": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.0.6.tgz",
+ "integrity": "sha512-i6TuFOoWmLWq+M/eCLGd/bQ2HfAX1RJgvrBQ6AQLmzfvsLdefxbWu8G9zczcPFfcSPehz9GcpF6K9QYreFV8hA==",
+ "dependencies": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/primitive": "1.0.1",
+ "@radix-ui/react-compose-refs": "1.0.1",
+ "@radix-ui/react-context": "1.0.1",
+ "@radix-ui/react-id": "1.0.1",
+ "@radix-ui/react-menu": "2.0.6",
+ "@radix-ui/react-primitive": "1.0.3",
+ "@radix-ui/react-use-controllable-state": "1.0.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0",
+ "react-dom": "^16.8 || ^17.0 || ^18.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-focus-guards": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.0.1.tgz",
@@ -1144,6 +1202,46 @@
}
}
},
+ "node_modules/@radix-ui/react-menu": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.0.6.tgz",
+ "integrity": "sha512-BVkFLS+bUC8HcImkRKPSiVumA1VPOOEC5WBMiT+QAVsPzW1FJzI9KnqgGxVDPBcql5xXrHkD3JOVoXWEXD8SYA==",
+ "dependencies": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/primitive": "1.0.1",
+ "@radix-ui/react-collection": "1.0.3",
+ "@radix-ui/react-compose-refs": "1.0.1",
+ "@radix-ui/react-context": "1.0.1",
+ "@radix-ui/react-direction": "1.0.1",
+ "@radix-ui/react-dismissable-layer": "1.0.5",
+ "@radix-ui/react-focus-guards": "1.0.1",
+ "@radix-ui/react-focus-scope": "1.0.4",
+ "@radix-ui/react-id": "1.0.1",
+ "@radix-ui/react-popper": "1.1.3",
+ "@radix-ui/react-portal": "1.0.4",
+ "@radix-ui/react-presence": "1.0.1",
+ "@radix-ui/react-primitive": "1.0.3",
+ "@radix-ui/react-roving-focus": "1.0.4",
+ "@radix-ui/react-slot": "1.0.2",
+ "@radix-ui/react-use-callback-ref": "1.0.1",
+ "aria-hidden": "^1.1.1",
+ "react-remove-scroll": "2.5.5"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0",
+ "react-dom": "^16.8 || ^17.0 || ^18.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-popper": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.1.3.tgz",
@@ -1370,6 +1468,36 @@
}
}
},
+ "node_modules/@radix-ui/react-tabs": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.0.4.tgz",
+ "integrity": "sha512-egZfYY/+wRNCflXNHx+dePvnz9FbmssDTJBtgRfDY7e8SE5oIo3Py2eCB1ckAbh1Q7cQ/6yJZThJ++sgbxibog==",
+ "dependencies": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/primitive": "1.0.1",
+ "@radix-ui/react-context": "1.0.1",
+ "@radix-ui/react-direction": "1.0.1",
+ "@radix-ui/react-id": "1.0.1",
+ "@radix-ui/react-presence": "1.0.1",
+ "@radix-ui/react-primitive": "1.0.3",
+ "@radix-ui/react-roving-focus": "1.0.4",
+ "@radix-ui/react-use-controllable-state": "1.0.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0",
+ "react-dom": "^16.8 || ^17.0 || ^18.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-toast": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.1.5.tgz",
@@ -2661,6 +2789,18 @@
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
},
+ "node_modules/cors": {
+ "version": "2.8.5",
+ "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
+ "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
+ "dependencies": {
+ "object-assign": "^4",
+ "vary": "^1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
"node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
@@ -2810,6 +2950,31 @@
"integrity": "sha512-ncfPC8UnGIyGFrPE03J5Xn6yTZ6R+clkcZbuG1PJbjAaZBFS4Kn3UKfzu8eilzru6SfC8TPsHuwv0p0eYVu+ww==",
"dev": true
},
+ "node_modules/embla-carousel": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.0.0.tgz",
+ "integrity": "sha512-ecixcyqS6oKD2nh5Nj5MObcgoSILWNI/GtBxkidn5ytFaCCmwVHo2SecksaQZHcARMMpIR2dWOlSIdA1LkZFUA=="
+ },
+ "node_modules/embla-carousel-react": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.0.0.tgz",
+ "integrity": "sha512-qT0dii8ZwoCtEIBE6ogjqU2+5IwnGfdt2teKjCzW88JRErflhlCpz8KjWnW8xoRZOP8g0clRtsMEFoAgS/elfA==",
+ "dependencies": {
+ "embla-carousel": "8.0.0",
+ "embla-carousel-reactive-utils": "8.0.0"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.1 || ^18.0.0"
+ }
+ },
+ "node_modules/embla-carousel-reactive-utils": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.0.0.tgz",
+ "integrity": "sha512-JCw0CqCXI7tbHDRogBb9PoeMLyjEC1vpN0lDOzUjmlfVgtfF+ffLaOK8bVtXVUEbNs/3guGe3NSzA5J5aYzLzw==",
+ "peerDependencies": {
+ "embla-carousel": "8.0.0"
+ }
+ },
"node_modules/emoji-regex": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.3.0.tgz",
@@ -4900,6 +5065,21 @@
"react": "^16.8.0 || ^17 || ^18"
}
},
+ "node_modules/react-medium-image-zoom": {
+ "version": "5.1.11",
+ "resolved": "https://registry.npmjs.org/react-medium-image-zoom/-/react-medium-image-zoom-5.1.11.tgz",
+ "integrity": "sha512-7rHECk0nRY+Uwmd02HKPt0L0Lxv2/km24ztcxgUaGR8TD0ikK+OOJyGZAMg/SeQzJDzmNv0yW6U1K8jqnta5MQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/rpearce"
+ }
+ ],
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
"node_modules/react-remove-scroll": {
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz",
diff --git a/skyvern-frontend/package.json b/skyvern-frontend/package.json
index 7cb8d6b2..acd89727 100644
--- a/skyvern-frontend/package.json
+++ b/skyvern-frontend/package.json
@@ -4,34 +4,40 @@
"version": "0.0.0",
"type": "module",
"scripts": {
- "dev": "vite & npm run run:server",
+ "dev": "vite & npm run run-artifact-server",
"build": "tsc --noEmit && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"format": "prettier --write .",
"preview": "vite preview",
"prepare": "cd .. && husky skyvern-frontend/.husky",
"precommit": "lint-staged",
- "run:server": "node server.js"
+ "run-artifact-server": "node artifactServer.js"
},
"dependencies": {
"@hookform/resolvers": "^3.3.4",
"@radix-ui/react-accordion": "^1.1.2",
+ "@radix-ui/react-aspect-ratio": "^1.0.3",
"@radix-ui/react-dialog": "^1.0.5",
+ "@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slot": "^1.0.2",
+ "@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-tooltip": "^1.0.7",
"@tanstack/react-query": "^5.28.6",
"axios": "^1.6.8",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
+ "cors": "^2.8.5",
+ "embla-carousel-react": "^8.0.0",
"express": "^4.19.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.51.1",
+ "react-medium-image-zoom": "^5.1.11",
"react-router-dom": "^6.22.3",
"tailwind-merge": "^2.2.2",
"tailwindcss-animate": "^1.0.7",
diff --git a/skyvern-frontend/public/favicon.png b/skyvern-frontend/public/favicon.png
new file mode 100644
index 00000000..fe3d46ca
Binary files /dev/null and b/skyvern-frontend/public/favicon.png differ
diff --git a/skyvern-frontend/public/logo.png b/skyvern-frontend/public/logo.png
new file mode 100644
index 00000000..c93ed9c5
Binary files /dev/null and b/skyvern-frontend/public/logo.png differ
diff --git a/skyvern-frontend/public/skyvern-logo-text.png b/skyvern-frontend/public/skyvern-logo-text.png
deleted file mode 100644
index c7b5ab29..00000000
Binary files a/skyvern-frontend/public/skyvern-logo-text.png and /dev/null differ
diff --git a/skyvern-frontend/public/skyvern-logo.png b/skyvern-frontend/public/skyvern-logo.png
deleted file mode 100644
index de7d6de7..00000000
Binary files a/skyvern-frontend/public/skyvern-logo.png and /dev/null differ
diff --git a/skyvern-frontend/src/api/AxiosClient.ts b/skyvern-frontend/src/api/AxiosClient.ts
index c332cab2..2df24272 100644
--- a/skyvern-frontend/src/api/AxiosClient.ts
+++ b/skyvern-frontend/src/api/AxiosClient.ts
@@ -1,4 +1,4 @@
-import { apiBaseUrl, credential } from "@/util/env";
+import { apiBaseUrl, artifactApiBaseUrl, credential } from "@/util/env";
import axios from "axios";
const client = axios.create({
@@ -9,4 +9,8 @@ const client = axios.create({
},
});
-export { client };
+const artifactApiClient = axios.create({
+ baseURL: artifactApiBaseUrl,
+});
+
+export { client, artifactApiClient };
diff --git a/skyvern-frontend/src/api/types.ts b/skyvern-frontend/src/api/types.ts
index 6c4a59be..8f82fef3 100644
--- a/skyvern-frontend/src/api/types.ts
+++ b/skyvern-frontend/src/api/types.ts
@@ -1,6 +1,14 @@
export const ArtifactType = {
Recording: "recording",
ActionScreenshot: "screenshot_action",
+ LLMScreenshot: "screenshot_llm",
+ LLMResponseRaw: "llm_response",
+ LLMResponseParsed: "llm_response_parsed",
+ VisibleElementsTree: "visible_elements_tree",
+ VisibleElementsTreeTrimmed: "visible_elements_tree_trimmed",
+ LLMPrompt: "llm_prompt",
+ LLMRequest: "llm_request",
+ HTMLScrape: "html_scrape",
} as const;
export const Status = {
diff --git a/skyvern-frontend/src/components/Logo.tsx b/skyvern-frontend/src/components/Logo.tsx
new file mode 100644
index 00000000..ff3234d0
--- /dev/null
+++ b/skyvern-frontend/src/components/Logo.tsx
@@ -0,0 +1,6 @@
+function Logo() {
+ const src = "/logo.png";
+ return
;
+}
+
+export { Logo };
diff --git a/skyvern-frontend/src/components/TaskStatusBadge.tsx b/skyvern-frontend/src/components/StatusBadge.tsx
similarity index 86%
rename from skyvern-frontend/src/components/TaskStatusBadge.tsx
rename to skyvern-frontend/src/components/StatusBadge.tsx
index 8021bf1f..b3c78a0f 100644
--- a/skyvern-frontend/src/components/TaskStatusBadge.tsx
+++ b/skyvern-frontend/src/components/StatusBadge.tsx
@@ -5,7 +5,7 @@ type Props = {
status: Status;
};
-function TaskStatusBadge({ status }: Props) {
+function StatusBadge({ status }: Props) {
let variant: "default" | "success" | "destructive" | "warning" = "default";
if (status === "completed") {
variant = "success";
@@ -18,4 +18,4 @@ function TaskStatusBadge({ status }: Props) {
return {status};
}
-export { TaskStatusBadge };
+export { StatusBadge };
diff --git a/skyvern-frontend/src/components/ThemeSwitch.tsx b/skyvern-frontend/src/components/ThemeSwitch.tsx
new file mode 100644
index 00000000..05e9d20c
--- /dev/null
+++ b/skyvern-frontend/src/components/ThemeSwitch.tsx
@@ -0,0 +1,39 @@
+import { MoonIcon, SunIcon } from "@radix-ui/react-icons";
+
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { useTheme } from "@/components/useTheme";
+
+function ThemeToggle() {
+ const { setTheme } = useTheme();
+
+ return (
+
+
+
+
+
+ setTheme("light")}>
+ Light
+
+ setTheme("dark")}>
+ Dark
+
+ setTheme("system")}>
+ System
+
+
+
+ );
+}
+
+export { ThemeToggle };
diff --git a/skyvern-frontend/src/components/ZoomableImage.tsx b/skyvern-frontend/src/components/ZoomableImage.tsx
new file mode 100644
index 00000000..3d0d3ebb
--- /dev/null
+++ b/skyvern-frontend/src/components/ZoomableImage.tsx
@@ -0,0 +1,16 @@
+import Zoom from "react-medium-image-zoom";
+import { AspectRatio } from "@/components/ui/aspect-ratio";
+
+type HTMLImageElementProps = React.ComponentProps<"img">;
+
+function ZoomableImage(props: HTMLImageElementProps) {
+ return (
+
+
+
+
+
+ );
+}
+
+export { ZoomableImage };
diff --git a/skyvern-frontend/src/components/ui/aspect-ratio.tsx b/skyvern-frontend/src/components/ui/aspect-ratio.tsx
new file mode 100644
index 00000000..c9e6f4bf
--- /dev/null
+++ b/skyvern-frontend/src/components/ui/aspect-ratio.tsx
@@ -0,0 +1,5 @@
+import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
+
+const AspectRatio = AspectRatioPrimitive.Root;
+
+export { AspectRatio };
diff --git a/skyvern-frontend/src/components/ui/carousel.tsx b/skyvern-frontend/src/components/ui/carousel.tsx
new file mode 100644
index 00000000..b1b8c4b8
--- /dev/null
+++ b/skyvern-frontend/src/components/ui/carousel.tsx
@@ -0,0 +1,260 @@
+import * as React from "react";
+import { ArrowLeftIcon, ArrowRightIcon } from "@radix-ui/react-icons";
+import useEmblaCarousel, {
+ type UseEmblaCarouselType,
+} from "embla-carousel-react";
+
+import { cn } from "@/util/utils";
+import { Button } from "@/components/ui/button";
+
+type CarouselApi = UseEmblaCarouselType[1];
+type UseCarouselParameters = Parameters;
+type CarouselOptions = UseCarouselParameters[0];
+type CarouselPlugin = UseCarouselParameters[1];
+
+type CarouselProps = {
+ opts?: CarouselOptions;
+ plugins?: CarouselPlugin;
+ orientation?: "horizontal" | "vertical";
+ setApi?: (api: CarouselApi) => void;
+};
+
+type CarouselContextProps = {
+ carouselRef: ReturnType[0];
+ api: ReturnType[1];
+ scrollPrev: () => void;
+ scrollNext: () => void;
+ canScrollPrev: boolean;
+ canScrollNext: boolean;
+} & CarouselProps;
+
+const CarouselContext = React.createContext(null);
+
+function useCarousel() {
+ const context = React.useContext(CarouselContext);
+
+ if (!context) {
+ throw new Error("useCarousel must be used within a ");
+ }
+
+ return context;
+}
+
+const Carousel = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes & CarouselProps
+>(
+ (
+ {
+ orientation = "horizontal",
+ opts,
+ setApi,
+ plugins,
+ className,
+ children,
+ ...props
+ },
+ ref,
+ ) => {
+ const [carouselRef, api] = useEmblaCarousel(
+ {
+ ...opts,
+ axis: orientation === "horizontal" ? "x" : "y",
+ },
+ plugins,
+ );
+ const [canScrollPrev, setCanScrollPrev] = React.useState(false);
+ const [canScrollNext, setCanScrollNext] = React.useState(false);
+
+ const onSelect = React.useCallback((api: CarouselApi) => {
+ if (!api) {
+ return;
+ }
+
+ setCanScrollPrev(api.canScrollPrev());
+ setCanScrollNext(api.canScrollNext());
+ }, []);
+
+ const scrollPrev = React.useCallback(() => {
+ api?.scrollPrev();
+ }, [api]);
+
+ const scrollNext = React.useCallback(() => {
+ api?.scrollNext();
+ }, [api]);
+
+ const handleKeyDown = React.useCallback(
+ (event: React.KeyboardEvent) => {
+ if (event.key === "ArrowLeft") {
+ event.preventDefault();
+ scrollPrev();
+ } else if (event.key === "ArrowRight") {
+ event.preventDefault();
+ scrollNext();
+ }
+ },
+ [scrollPrev, scrollNext],
+ );
+
+ React.useEffect(() => {
+ if (!api || !setApi) {
+ return;
+ }
+
+ setApi(api);
+ }, [api, setApi]);
+
+ React.useEffect(() => {
+ if (!api) {
+ return;
+ }
+
+ onSelect(api);
+ api.on("reInit", onSelect);
+ api.on("select", onSelect);
+
+ return () => {
+ api?.off("select", onSelect);
+ };
+ }, [api, onSelect]);
+
+ return (
+
+
+ {children}
+
+
+ );
+ },
+);
+Carousel.displayName = "Carousel";
+
+const CarouselContent = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
+ const { carouselRef, orientation } = useCarousel();
+
+ return (
+
+ );
+});
+CarouselContent.displayName = "CarouselContent";
+
+const CarouselItem = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
+ const { orientation } = useCarousel();
+
+ return (
+
+ );
+});
+CarouselItem.displayName = "CarouselItem";
+
+const CarouselPrevious = React.forwardRef<
+ HTMLButtonElement,
+ React.ComponentProps
+>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
+ const { orientation, scrollPrev, canScrollPrev } = useCarousel();
+
+ return (
+
+ );
+});
+CarouselPrevious.displayName = "CarouselPrevious";
+
+const CarouselNext = React.forwardRef<
+ HTMLButtonElement,
+ React.ComponentProps
+>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
+ const { orientation, scrollNext, canScrollNext } = useCarousel();
+
+ return (
+
+ );
+});
+CarouselNext.displayName = "CarouselNext";
+
+export {
+ type CarouselApi,
+ Carousel,
+ CarouselContent,
+ CarouselItem,
+ CarouselPrevious,
+ CarouselNext,
+};
diff --git a/skyvern-frontend/src/components/ui/dropdown-menu.tsx b/skyvern-frontend/src/components/ui/dropdown-menu.tsx
new file mode 100644
index 00000000..60e73e35
--- /dev/null
+++ b/skyvern-frontend/src/components/ui/dropdown-menu.tsx
@@ -0,0 +1,203 @@
+import * as React from "react";
+import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
+import {
+ CheckIcon,
+ ChevronRightIcon,
+ DotFilledIcon,
+} from "@radix-ui/react-icons";
+
+import { cn } from "@/util/utils";
+
+const DropdownMenu = DropdownMenuPrimitive.Root;
+
+const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
+
+const DropdownMenuGroup = DropdownMenuPrimitive.Group;
+
+const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
+
+const DropdownMenuSub = DropdownMenuPrimitive.Sub;
+
+const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
+
+const DropdownMenuSubTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean;
+ }
+>(({ className, inset, children, ...props }, ref) => (
+
+ {children}
+
+
+));
+DropdownMenuSubTrigger.displayName =
+ DropdownMenuPrimitive.SubTrigger.displayName;
+
+const DropdownMenuSubContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DropdownMenuSubContent.displayName =
+ DropdownMenuPrimitive.SubContent.displayName;
+
+const DropdownMenuContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, sideOffset = 4, ...props }, ref) => (
+
+
+
+));
+DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
+
+const DropdownMenuItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean;
+ }
+>(({ className, inset, ...props }, ref) => (
+
+));
+DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
+
+const DropdownMenuCheckboxItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, checked, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+));
+DropdownMenuCheckboxItem.displayName =
+ DropdownMenuPrimitive.CheckboxItem.displayName;
+
+const DropdownMenuRadioItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+));
+DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
+
+const DropdownMenuLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean;
+ }
+>(({ className, inset, ...props }, ref) => (
+
+));
+DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
+
+const DropdownMenuSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
+
+const DropdownMenuShortcut = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => {
+ return (
+
+ );
+};
+DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
+
+export {
+ DropdownMenu,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuGroup,
+ DropdownMenuPortal,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuRadioGroup,
+};
diff --git a/skyvern-frontend/src/components/ui/tabs.tsx b/skyvern-frontend/src/components/ui/tabs.tsx
new file mode 100644
index 00000000..3b34c87e
--- /dev/null
+++ b/skyvern-frontend/src/components/ui/tabs.tsx
@@ -0,0 +1,53 @@
+import * as React from "react";
+import * as TabsPrimitive from "@radix-ui/react-tabs";
+
+import { cn } from "@/util/utils";
+
+const Tabs = TabsPrimitive.Root;
+
+const TabsList = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+TabsList.displayName = TabsPrimitive.List.displayName;
+
+const TabsTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
+
+const TabsContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+TabsContent.displayName = TabsPrimitive.Content.displayName;
+
+export { Tabs, TabsList, TabsTrigger, TabsContent };
diff --git a/skyvern-frontend/src/index.css b/skyvern-frontend/src/index.css
index b16ead87..2c2aead2 100644
--- a/skyvern-frontend/src/index.css
+++ b/skyvern-frontend/src/index.css
@@ -73,6 +73,104 @@
body {
@apply bg-background text-foreground;
}
+
+ [data-rmiz] {
+ position: relative;
+ }
+ [data-rmiz-ghost] {
+ position: absolute;
+ pointer-events: none;
+ }
+ [data-rmiz-btn-zoom],
+ [data-rmiz-btn-unzoom] {
+ background-color: rgba(0, 0, 0, 0.7);
+ border-radius: 50%;
+ border: none;
+ box-shadow: 0 0 1px rgba(255, 255, 255, 0.5);
+ color: #fff;
+ height: 40px;
+ margin: 0;
+ outline-offset: 2px;
+ padding: 9px;
+ touch-action: manipulation;
+ width: 40px;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none;
+ }
+ [data-rmiz-btn-zoom]:not(:focus):not(:active) {
+ position: absolute;
+ clip: rect(0 0 0 0);
+ clip-path: inset(50%);
+ height: 1px;
+ overflow: hidden;
+ pointer-events: none;
+ white-space: nowrap;
+ width: 1px;
+ }
+ [data-rmiz-btn-zoom] {
+ position: absolute;
+ inset: 10px 10px auto auto;
+ cursor: zoom-in;
+ }
+ [data-rmiz-btn-unzoom] {
+ position: absolute;
+ inset: 20px 20px auto auto;
+ cursor: zoom-out;
+ z-index: 1;
+ }
+ [data-rmiz-content="found"] img,
+ [data-rmiz-content="found"] svg,
+ [data-rmiz-content="found"] [role="img"],
+ [data-rmiz-content="found"] [data-zoom] {
+ cursor: zoom-in;
+ }
+ [data-rmiz-modal]::backdrop {
+ display: none;
+ }
+ [data-rmiz-modal][open] {
+ position: fixed;
+ width: 100vw;
+ width: 100dvw;
+ height: 100vh;
+ height: 100dvh;
+ max-width: none;
+ max-height: none;
+ margin: 0;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ overflow: hidden;
+ }
+ [data-rmiz-modal-overlay] {
+ position: absolute;
+ inset: 0;
+ transition: background-color 0.3s;
+ }
+ [data-rmiz-modal-overlay="hidden"] {
+ background-color: rgba(255, 255, 255, 0);
+ }
+ [data-rmiz-modal-overlay="visible"] {
+ @apply bg-background;
+ }
+ [data-rmiz-modal-content] {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ }
+ [data-rmiz-modal-img] {
+ position: absolute;
+ cursor: zoom-out;
+ image-rendering: high-quality;
+ transform-origin: top left;
+ transition: transform 0.3s;
+ }
+ @media (prefers-reduced-motion: reduce) {
+ [data-rmiz-modal-overlay],
+ [data-rmiz-modal-img] {
+ transition-duration: 0.01ms !important;
+ }
+ }
}
body,
diff --git a/skyvern-frontend/src/routes/root/RootLayout.tsx b/skyvern-frontend/src/routes/root/RootLayout.tsx
index 9f053a12..68409967 100644
--- a/skyvern-frontend/src/routes/root/RootLayout.tsx
+++ b/skyvern-frontend/src/routes/root/RootLayout.tsx
@@ -2,20 +2,21 @@ import { Link, Outlet } from "react-router-dom";
import { Toaster } from "@/components/ui/toaster";
import { SideNav } from "./SideNav";
import { DiscordLogoIcon, GitHubLogoIcon } from "@radix-ui/react-icons";
+import { Logo } from "@/components/Logo";
+import { ThemeToggle } from "@/components/ThemeSwitch";
function RootLayout() {
return (
<>
-
+
>
diff --git a/skyvern-frontend/src/routes/tasks/detail/JSONArtifact.tsx b/skyvern-frontend/src/routes/tasks/detail/JSONArtifact.tsx
new file mode 100644
index 00000000..128f5fc9
--- /dev/null
+++ b/skyvern-frontend/src/routes/tasks/detail/JSONArtifact.tsx
@@ -0,0 +1,40 @@
+import { artifactApiClient } from "@/api/AxiosClient";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Textarea } from "@/components/ui/textarea";
+import { useQuery } from "@tanstack/react-query";
+
+type Props = {
+ uri: string;
+};
+
+function JSONArtifact({ uri }: Props) {
+ const { data, isFetching, isError, error } = useQuery<
+ Record
+ >({
+ queryKey: ["artifact", uri],
+ queryFn: async () => {
+ return artifactApiClient
+ .get(`/artifact/json`, {
+ params: {
+ path: uri.slice(7),
+ },
+ })
+ .then((response) => response.data);
+ },
+ });
+
+ if (isFetching) {
+ return ;
+ }
+
+ return (
+
+ );
+}
+
+export { JSONArtifact };
diff --git a/skyvern-frontend/src/routes/tasks/detail/StepArtifacts.tsx b/skyvern-frontend/src/routes/tasks/detail/StepArtifacts.tsx
new file mode 100644
index 00000000..20da68bd
--- /dev/null
+++ b/skyvern-frontend/src/routes/tasks/detail/StepArtifacts.tsx
@@ -0,0 +1,205 @@
+import { client } from "@/api/AxiosClient";
+import {
+ ArtifactApiResponse,
+ ArtifactType,
+ StepApiResponse,
+} from "@/api/types";
+import { StatusBadge } from "@/components/StatusBadge";
+import { Label } from "@/components/ui/label";
+import { useQuery } from "@tanstack/react-query";
+import { useParams } from "react-router-dom";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { artifactApiBaseUrl } from "@/util/env";
+import { ZoomableImage } from "@/components/ZoomableImage";
+import { Skeleton } from "@/components/ui/skeleton";
+import { JSONArtifact } from "./JSONArtifact";
+import { TextArtifact } from "./TextArtifact";
+
+type Props = {
+ id: string;
+ stepProps: StepApiResponse;
+};
+
+function StepArtifacts({ id, stepProps }: Props) {
+ const { taskId } = useParams();
+ const {
+ data: artifacts,
+ isFetching,
+ isError,
+ error,
+ } = useQuery>({
+ queryKey: ["task", taskId, "steps", id, "artifacts"],
+ queryFn: async () => {
+ return client
+ .get(`/tasks/${taskId}/steps/${id}/artifacts`)
+ .then((response) => response.data);
+ },
+ });
+
+ if (isError) {
+ return Error: {error?.message}
;
+ }
+
+ const llmScreenshotUris = artifacts
+ ?.filter(
+ (artifact) => artifact.artifact_type === ArtifactType.LLMScreenshot,
+ )
+ .map((artifact) => artifact.uri);
+
+ const actionScreenshotUris = artifacts
+ ?.filter(
+ (artifact) => artifact.artifact_type === ArtifactType.ActionScreenshot,
+ )
+ .map((artifact) => artifact.uri);
+
+ const visibleElementsTreeUri = artifacts?.find(
+ (artifact) => artifact.artifact_type === ArtifactType.VisibleElementsTree,
+ )?.uri;
+
+ const visibleElementsTreeTrimmedUri = artifacts?.find(
+ (artifact) =>
+ artifact.artifact_type === ArtifactType.VisibleElementsTreeTrimmed,
+ )?.uri;
+
+ const llmPromptUri = artifacts?.find(
+ (artifact) => artifact.artifact_type === ArtifactType.LLMPrompt,
+ )?.uri;
+
+ const llmRequestUri = artifacts?.find(
+ (artifact) => artifact.artifact_type === ArtifactType.LLMRequest,
+ )?.uri;
+
+ const llmResponseRawUri = artifacts?.find(
+ (artifact) => artifact.artifact_type === ArtifactType.LLMResponseRaw,
+ )?.uri;
+
+ const llmResponseParsedUri = artifacts?.find(
+ (artifact) => artifact.artifact_type === ArtifactType.LLMResponseParsed,
+ )?.uri;
+
+ const htmlRawUri = artifacts?.find(
+ (artifact) => artifact.artifact_type === ArtifactType.HTMLScrape,
+ )?.uri;
+
+ return (
+
+
+ Info
+ LLM Screenshots
+ Action Screenshots
+ Element Tree
+
+ Element Tree (Trimmed)
+
+ LLM Prompt
+ LLM Request
+ LLM Response (Raw)
+
+ LLM Response (Parsed)
+
+ HTML (Raw)
+
+
+
+
+
+ {isFetching ? (
+
+ ) : (
+ {stepProps?.step_id}
+ )}
+
+
+
+ {isFetching ? (
+
+ ) : stepProps ? (
+
+ ) : null}
+
+
+
+ {isFetching ? (
+
+ ) : stepProps ? (
+ {stepProps.created_at}
+ ) : null}
+
+
+
+
+ {llmScreenshotUris && llmScreenshotUris.length > 0 ? (
+
+ {llmScreenshotUris.map((uri, index) => (
+
+ ))}
+
+ ) : isFetching ? (
+
+
+
+
+
+ ) : (
+ No screenshots found
+ )}
+
+
+ {actionScreenshotUris && actionScreenshotUris.length > 0 ? (
+
+ {actionScreenshotUris.map((uri, index) => (
+
+ ))}
+
+ ) : isFetching ? (
+
+
+
+
+
+ ) : (
+ No screenshots found
+ )}
+
+
+ {visibleElementsTreeUri ? (
+
+ ) : null}
+
+
+ {visibleElementsTreeTrimmedUri ? (
+
+ ) : null}
+
+
+ {llmPromptUri ? : null}
+
+
+ {llmRequestUri ? : null}
+
+
+ {llmResponseRawUri ? : null}
+
+
+ {llmResponseParsedUri ? (
+
+ ) : null}
+
+
+ {htmlRawUri ? : null}
+
+
+ );
+}
+
+export { StepArtifacts };
diff --git a/skyvern-frontend/src/routes/tasks/detail/StepArtifactsLayout.tsx b/skyvern-frontend/src/routes/tasks/detail/StepArtifactsLayout.tsx
new file mode 100644
index 00000000..02d0d972
--- /dev/null
+++ b/skyvern-frontend/src/routes/tasks/detail/StepArtifactsLayout.tsx
@@ -0,0 +1,58 @@
+import { useState } from "react";
+import { StepNavigation } from "./StepNavigation";
+import { StepArtifacts } from "./StepArtifacts";
+import { useQuery } from "@tanstack/react-query";
+import { StepApiResponse } from "@/api/types";
+import { useParams } from "react-router-dom";
+import { client } from "@/api/AxiosClient";
+
+function StepArtifactsLayout() {
+ const [activeIndex, setActiveIndex] = useState(0);
+ const { taskId } = useParams();
+
+ const {
+ data: steps,
+ isFetching,
+ isError,
+ error,
+ } = useQuery>({
+ queryKey: ["task", taskId, "steps"],
+ queryFn: async () => {
+ return client
+ .get(`/tasks/${taskId}/steps`)
+ .then((response) => response.data);
+ },
+ });
+
+ if (isFetching) {
+ return Loading...
;
+ }
+
+ if (isError) {
+ return Error: {error?.message}
;
+ }
+
+ if (!steps) {
+ return No steps found
;
+ }
+
+ const activeStep = steps[activeIndex];
+
+ return (
+
+
+
+ {activeStep ? (
+
+ ) : null}
+
+
+ );
+}
+
+export { StepArtifactsLayout };
diff --git a/skyvern-frontend/src/routes/tasks/detail/StepInfo.tsx b/skyvern-frontend/src/routes/tasks/detail/StepInfo.tsx
new file mode 100644
index 00000000..2a8068d6
--- /dev/null
+++ b/skyvern-frontend/src/routes/tasks/detail/StepInfo.tsx
@@ -0,0 +1,42 @@
+import { StepApiResponse } from "@/api/types";
+import { StatusBadge } from "@/components/StatusBadge";
+import { Label } from "@/components/ui/label";
+import { Skeleton } from "@/components/ui/skeleton";
+
+type Props = {
+ isFetching: boolean;
+ stepProps?: StepApiResponse;
+};
+
+function StepInfo({ isFetching, stepProps }: Props) {
+ return (
+
+
+
+ {isFetching ? (
+
+ ) : (
+ {stepProps?.step_id}
+ )}
+
+
+
+ {isFetching ? (
+
+ ) : stepProps ? (
+
+ ) : null}
+
+
+
+ {isFetching ? (
+
+ ) : stepProps ? (
+ {stepProps.created_at}
+ ) : null}
+
+
+ );
+}
+
+export { StepInfo };
diff --git a/skyvern-frontend/src/routes/tasks/detail/StepList.tsx b/skyvern-frontend/src/routes/tasks/detail/StepList.tsx
deleted file mode 100644
index 9a949c72..00000000
--- a/skyvern-frontend/src/routes/tasks/detail/StepList.tsx
+++ /dev/null
@@ -1,123 +0,0 @@
-import { client } from "@/api/AxiosClient";
-import { StepApiResponse } from "@/api/types";
-import {
- Pagination,
- PaginationContent,
- PaginationItem,
- PaginationLink,
- PaginationNext,
- PaginationPrevious,
-} from "@/components/ui/pagination";
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@/components/ui/table";
-import { useQuery } from "@tanstack/react-query";
-import { useParams, useSearchParams } from "react-router-dom";
-import { StepListSkeleton } from "./StepListSkeleton";
-import { TaskStatusBadge } from "@/components/TaskStatusBadge";
-import { basicTimeFormat } from "@/util/timeFormat";
-
-function StepList() {
- const { taskId } = useParams();
- const [searchParams, setSearchParams] = useSearchParams();
- const page = searchParams.get("page") ? Number(searchParams.get("page")) : 1;
-
- const {
- data: steps,
- isFetching,
- isError,
- error,
- } = useQuery>({
- queryKey: ["task", taskId, "steps", page],
- queryFn: async () => {
- return client
- .get(`/tasks/${taskId}/steps`, {
- params: {
- page,
- },
- })
- .then((response) => response.data);
- },
- });
-
- if (isFetching) {
- return ;
- }
-
- if (isError) {
- return Error: {error?.message}
;
- }
-
- if (!steps) {
- return No steps found
;
- }
-
- return (
- <>
-
-
-
- Order
- Status
- Created At
-
-
-
- {steps.length === 0 ? (
-
- No tasks found
-
- ) : (
- steps.map((step) => {
- return (
-
- {step.order}
-
-
-
-
- {basicTimeFormat(step.created_at)}
-
-
- );
- })
- )}
-
-
-
-
-
- {
- const params = new URLSearchParams();
- params.set("page", String(Math.max(1, page - 1)));
- setSearchParams(params);
- }}
- />
-
-
- {page}
-
-
- {
- const params = new URLSearchParams();
- params.set("page", String(page + 1));
- setSearchParams(params);
- }}
- />
-
-
-
- >
- );
-}
-
-export { StepList };
diff --git a/skyvern-frontend/src/routes/tasks/detail/StepListSkeleton.tsx b/skyvern-frontend/src/routes/tasks/detail/StepListSkeleton.tsx
deleted file mode 100644
index 2b3a9f55..00000000
--- a/skyvern-frontend/src/routes/tasks/detail/StepListSkeleton.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-import { Skeleton } from "@/components/ui/skeleton";
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@/components/ui/table";
-
-const pageSizeArray = new Array(15).fill(null); // doesn't matter the value
-
-function StepListSkeleton() {
- return (
-
-
-
-
- Order
- Status
- Created At
-
-
-
- {pageSizeArray.map((_, index) => {
- return (
-
-
-
-
-
-
-
-
-
-
-
- );
- })}
-
-
-
- );
-}
-
-export { StepListSkeleton };
diff --git a/skyvern-frontend/src/routes/tasks/detail/StepNavigation.tsx b/skyvern-frontend/src/routes/tasks/detail/StepNavigation.tsx
new file mode 100644
index 00000000..4ae7225c
--- /dev/null
+++ b/skyvern-frontend/src/routes/tasks/detail/StepNavigation.tsx
@@ -0,0 +1,78 @@
+import { client } from "@/api/AxiosClient";
+import { StepApiResponse } from "@/api/types";
+import { cn } from "@/util/utils";
+import { useQuery } from "@tanstack/react-query";
+import { useParams, useSearchParams } from "react-router-dom";
+import { PAGE_SIZE } from "../constants";
+
+type Props = {
+ activeIndex: number;
+ onActiveIndexChange: (index: number) => void;
+};
+
+function StepNavigation({ activeIndex, onActiveIndexChange }: Props) {
+ const { taskId } = useParams();
+ const [searchParams] = useSearchParams();
+ const page = searchParams.get("page") ? Number(searchParams.get("page")) : 1;
+
+ const {
+ data: steps,
+ isFetching,
+ isError,
+ error,
+ } = useQuery>({
+ queryKey: ["task", taskId, "steps", page],
+ queryFn: async () => {
+ return client
+ .get(`/tasks/${taskId}/steps`, {
+ params: {
+ page,
+ page_size: PAGE_SIZE,
+ },
+ })
+ .then((response) => response.data);
+ },
+ });
+
+ if (isFetching) {
+ return Loading...
;
+ }
+
+ if (isError) {
+ return Error: {error?.message}
;
+ }
+
+ if (!steps) {
+ return No steps found
;
+ }
+
+ return (
+
+ );
+}
+
+export { StepNavigation };
diff --git a/skyvern-frontend/src/routes/tasks/detail/TaskDetails.tsx b/skyvern-frontend/src/routes/tasks/detail/TaskDetails.tsx
index 0a5a415e..b78519eb 100644
--- a/skyvern-frontend/src/routes/tasks/detail/TaskDetails.tsx
+++ b/skyvern-frontend/src/routes/tasks/detail/TaskDetails.tsx
@@ -2,20 +2,22 @@ import { client } from "@/api/AxiosClient";
import { Status, TaskApiResponse } from "@/api/types";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
-import { keepPreviousData, useQuery } from "@tanstack/react-query";
+import { useQuery } from "@tanstack/react-query";
import { useParams } from "react-router-dom";
-import { StepList } from "./StepList";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
-import { TaskStatusBadge } from "@/components/TaskStatusBadge";
+import { StatusBadge } from "@/components/StatusBadge";
import { artifactApiBaseUrl } from "@/util/env";
import { Button } from "@/components/ui/button";
import { ReloadIcon } from "@radix-ui/react-icons";
import { basicTimeFormat } from "@/util/timeFormat";
+import { StepArtifactsLayout } from "./StepArtifactsLayout";
+import Zoom from "react-medium-image-zoom";
+import { AspectRatio } from "@/components/ui/aspect-ratio";
function TaskDetails() {
const { taskId } = useParams();
@@ -27,11 +29,10 @@ function TaskDetails() {
error: taskError,
refetch,
} = useQuery({
- queryKey: ["task", taskId],
+ queryKey: ["task", taskId, "details"],
queryFn: async () => {
return client.get(`/tasks/${taskId}`).then((response) => response.data);
},
- placeholderData: keepPreviousData,
});
if (isTaskError) {
@@ -63,14 +64,14 @@ function TaskDetails() {
) : null}
-
+
{task.status === Status.Completed ? (
@@ -93,8 +94,8 @@ function TaskDetails() {
) : null}
-
-
+
+
Task Parameters
@@ -102,7 +103,9 @@ function TaskDetails() {
Task ID: {taskId}
URL: {task.request.url}
-
{basicTimeFormat(task.created_at)}
+
+ Created: {basicTimeFormat(task.created_at)}
+
);
}
diff --git a/skyvern-frontend/src/routes/tasks/detail/TextArtifact.tsx b/skyvern-frontend/src/routes/tasks/detail/TextArtifact.tsx
new file mode 100644
index 00000000..f65764df
--- /dev/null
+++ b/skyvern-frontend/src/routes/tasks/detail/TextArtifact.tsx
@@ -0,0 +1,38 @@
+import { artifactApiClient } from "@/api/AxiosClient";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Textarea } from "@/components/ui/textarea";
+import { useQuery } from "@tanstack/react-query";
+
+type Props = {
+ uri: string;
+};
+
+function TextArtifact({ uri }: Props) {
+ const { data, isFetching, isError, error } = useQuery
({
+ queryKey: ["artifact", uri],
+ queryFn: async () => {
+ return artifactApiClient
+ .get(`/artifact/text`, {
+ params: {
+ path: uri.slice(7),
+ },
+ })
+ .then((response) => response.data);
+ },
+ });
+
+ if (isFetching) {
+ return ;
+ }
+
+ return (
+
+ );
+}
+
+export { TextArtifact };
diff --git a/skyvern-frontend/src/routes/tasks/list/TaskList.tsx b/skyvern-frontend/src/routes/tasks/list/TaskList.tsx
index c2676bba..53e2d00f 100644
--- a/skyvern-frontend/src/routes/tasks/list/TaskList.tsx
+++ b/skyvern-frontend/src/routes/tasks/list/TaskList.tsx
@@ -22,7 +22,7 @@ import { TaskListSkeleton } from "./TaskListSkeleton";
import { RunningTasks } from "../running/RunningTasks";
import { cn } from "@/util/utils";
import { PAGE_SIZE } from "../constants";
-import { TaskStatusBadge } from "@/components/TaskStatusBadge";
+import { StatusBadge } from "@/components/StatusBadge";
import { basicTimeFormat } from "@/util/timeFormat";
function TaskList() {
@@ -102,7 +102,7 @@ function TaskList() {
>
{task.request.url}
-
+
{basicTimeFormat(task.created_at)}
diff --git a/skyvern-frontend/src/routes/tasks/running/LatestScreenshot.tsx b/skyvern-frontend/src/routes/tasks/running/LatestScreenshot.tsx
new file mode 100644
index 00000000..86207758
--- /dev/null
+++ b/skyvern-frontend/src/routes/tasks/running/LatestScreenshot.tsx
@@ -0,0 +1,84 @@
+import { client } from "@/api/AxiosClient";
+import {
+ ArtifactApiResponse,
+ ArtifactType,
+ StepApiResponse,
+} from "@/api/types";
+import { Skeleton } from "@/components/ui/skeleton";
+import { artifactApiBaseUrl } from "@/util/env";
+import { useQuery } from "@tanstack/react-query";
+
+type Props = {
+ id: string;
+};
+
+function LatestScreenshot({ id }: Props) {
+ const {
+ data: screenshotUri,
+ isFetching,
+ isError,
+ } = useQuery({
+ queryKey: ["task", id, "latestScreenshot"],
+ queryFn: async () => {
+ const steps: StepApiResponse[] = await client
+ .get(`/tasks/${id}/steps`)
+ .then((response) => response.data);
+
+ if (steps.length === 0) {
+ return;
+ }
+
+ const latestStep = steps[steps.length - 1];
+
+ if (!latestStep) {
+ return;
+ }
+
+ const artifacts: ArtifactApiResponse[] = await client
+ .get(`/tasks/${id}/steps/${latestStep.step_id}/artifacts`)
+ .then((response) => response.data);
+
+ const actionScreenshotUris = artifacts
+ ?.filter(
+ (artifact) =>
+ artifact.artifact_type === ArtifactType.ActionScreenshot,
+ )
+ .map((artifact) => artifact.uri);
+
+ if (actionScreenshotUris.length > 0) {
+ return actionScreenshotUris[0];
+ }
+
+ const llmScreenshotUris = artifacts
+ ?.filter(
+ (artifact) => artifact.artifact_type === ArtifactType.LLMScreenshot,
+ )
+ .map((artifact) => artifact.uri);
+
+ if (llmScreenshotUris.length > 0) {
+ return llmScreenshotUris[0];
+ }
+
+ return Promise.reject("No screenshots found");
+ },
+ refetchInterval: 2000,
+ });
+
+ if (isFetching) {
+ return ;
+ }
+
+ if (isError || !screenshotUri || typeof screenshotUri !== "string") {
+ return null;
+ }
+
+ return (
+
+ );
+}
+
+export { LatestScreenshot };
diff --git a/skyvern-frontend/src/routes/tasks/running/RunningTasks.tsx b/skyvern-frontend/src/routes/tasks/running/RunningTasks.tsx
index 83902eab..6831706b 100644
--- a/skyvern-frontend/src/routes/tasks/running/RunningTasks.tsx
+++ b/skyvern-frontend/src/routes/tasks/running/RunningTasks.tsx
@@ -11,20 +11,15 @@ import {
CardTitle,
} from "@/components/ui/card";
import { PAGE_SIZE } from "../constants";
-import { RunningTaskSkeleton } from "./RunningTaskSkeleton";
import { basicTimeFormat } from "@/util/timeFormat";
+import { LatestScreenshot } from "./LatestScreenshot";
function RunningTasks() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const page = searchParams.get("page") ? Number(searchParams.get("page")) : 1;
- const {
- data: tasks,
- isPending,
- isError,
- error,
- } = useQuery>({
+ const { data: tasks } = useQuery>({
queryKey: ["tasks", page],
queryFn: async () => {
return client
@@ -40,25 +35,13 @@ function RunningTasks() {
placeholderData: keepPreviousData,
});
- if (isPending) {
- return ;
- }
+ const runningTasks = tasks?.filter((task) => task.status === Status.Running);
- if (isError) {
- return Error: {error?.message}
;
- }
-
- if (!tasks) {
- return null;
- }
-
- const runningTasks = tasks.filter((task) => task.status === Status.Running);
-
- if (runningTasks.length === 0) {
+ if (runningTasks?.length === 0) {
return No running tasks
;
}
- return runningTasks.map((task) => {
+ return runningTasks?.map((task) => {
return (
- {task.request.url}
-
+ {task.task_id}
+
+ {task.request.url}
+
- Goal: {task.request.navigation_goal}
+
+ Latest screenshot:
+
+
+
+
Created: {basicTimeFormat(task.created_at)}
);