* dashboard

* fix: re-apply download behavior on recording context (#1019)

* fix: re-apply download behavior on recording context

record start creates a new browser context via Target.createBrowserContext.
Browser.setDownloadBehavior called at launch only applies to the default
context, so downloads in the recording context are silently dropped.

Fix:
1. Store download_path on BrowserManager (from LaunchOptions)
2. After creating the recording context, call Browser.setDownloadBehavior
   with the new browserContextId

This ensures downloads work during recording.

Fixes #1018

* fix: add download_path to third BrowserManager constructor (auto_connect_cdp)

* fix: reap zombie Chrome process and fast-detect crash for auto-restart (#1023)

When Chrome crashes (e.g. SIGTRAP from CHECK() assertion), the daemon
now:

1. Reaps the zombie immediately via a SIGCHLD handler in the event loop
   that calls waitpid(-1, WNOHANG)
2. Detects the crash instantly on the next command via a non-blocking
   try_wait() check (has_process_exited), avoiding the 3-second CDP
   timeout that is_connection_alive() would incur
3. Auto-relaunches Chrome transparently for the caller

Fixes #1017

Co-authored-by: ctate <366502+ctate@users.noreply.github.com>

* fix: route keyboard type through text input (#1014)

* fix: handle --clear flag in console command (#1015)

The console and errors commands parsed --clear from CLI args but the
action handlers silently ignored the flag. The handlers did not accept
the cmd parameter so they had no way to read the clear field.

Changes:
- Add clear_console() method to EventTracker in network.rs
- Update handle_console to accept cmd, read the clear field, and clear
  the buffer when --clear is passed (returns {cleared: true})
- Update call site in execute_command to pass cmd

Co-authored-by: xuyongliang <yongliang.xyl@alibaba-inc.com>

* chore: patch release - ### Bug Fixes

- **Re-apply download behavior on r... (#1025)

* Add runtime stream enable/disable/status commands (#951)

* Add runtime stream management commands

* Run rustfmt and satisfy clippy

* Fix stream disable cleanup semantics

* Format stream disable regression tests

* fix: retain radio/checkbox elements in compact snapshot tree (#1008)

compact_tree() checked for "[ref=" to identify lines worth keeping, but
radio and checkbox elements render as e.g. [checked=false, ref=e1] where
the "[" opens before "checked=", not "ref=". Dropping the leading bracket
so the check is just "ref=" fixes the match for all elements with refs.

Fixes #1006

Co-authored-by: ctate <366502+ctate@users.noreply.github.com>

* chore: version packages (#1027)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* fixes

* dashboard

* fixes

* remove observe

* fmt

* fixes

* fixes

* jotai

* fmt

* upload dashboard

---------

Co-authored-by: Stefan Smiljkovic <stefan@vanila.io>
Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
Co-authored-by: zhanba <c5e1856@gmail.com>
Co-authored-by: xuyongliang <478439790@qq.com>
Co-authored-by: xuyongliang <yongliang.xyl@alibaba-inc.com>
Co-authored-by: Thomas Kosiewski <thoma471@googlemail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Chris Tate
2026-03-26 08:43:35 -07:00
committed by GitHub
co-authored by ctate github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Stefan Smiljkovic zhanba xuyongliang xuyongliang Thomas Kosiewski
parent 63f03b8e06
commit f9174513c2
58 changed files with 17881 additions and 108 deletions
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+9
View File
@@ -0,0 +1,9 @@
import type { NextConfig } from "next";
const config: NextConfig = {
output: "export",
images: { unoptimized: true },
devIndicators: false,
};
export default config;
+32
View File
@@ -0,0 +1,32 @@
{
"name": "dashboard",
"version": "0.22.2",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"jotai": "^2.19.0",
"lucide-react": "^1.7.0",
"next": "16.1.1",
"radix-ui": "^1.4.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-resizable-panels": "^4.7.6",
"shadcn": "^4.1.0",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.3",
"@types/node": "^22.14.0",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"tailwindcss": "^4.1.3",
"typescript": "^5.8.3"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.3 KiB

+188
View File
@@ -0,0 +1,188 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--success: #22c55e;
--warning: #eab308;
}
.dark {
--background: #0a0a0a;
--foreground: #e5e5e5;
--card: #141414;
--card-foreground: #e5e5e5;
--popover: #141414;
--popover-foreground: #e5e5e5;
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: #1a1a1a;
--secondary-foreground: #e5e5e5;
--muted: #1a1a1a;
--muted-foreground: #737373;
--accent: #1a1a1a;
--accent-foreground: #e5e5e5;
--destructive: #ef4444;
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: #141414;
--sidebar-foreground: #e5e5e5;
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: #1a1a1a;
--sidebar-accent-foreground: #e5e5e5;
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--success: #22c55e;
--warning: #eab308;
}
body {
font-family: system-ui, -apple-system, sans-serif;
margin: 0;
overflow: hidden;
}
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
@theme inline {
--font-heading: var(--font-sans);
--font-sans: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--color-success: var(--success);
--color-warning: var(--warning);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
button {
cursor: pointer;
}
.json-key {
color: #d6409f;
}
.json-string {
color: #067a6e;
}
.json-number,
.json-bool,
.json-null {
color: #0070c0;
}
.json-punct {
color: #6b7280;
}
:is(.dark *).json-key {
color: #ff4d8d;
}
:is(.dark *).json-string {
color: #00ca50;
}
:is(.dark *).json-number,
:is(.dark *).json-bool,
:is(.dark *).json-null {
color: #47a8ff;
}
:is(.dark *).json-punct {
color: #a1a1a1;
}
+29
View File
@@ -0,0 +1,29 @@
import type { Metadata } from "next";
import "./globals.css";
import { Geist } from "next/font/google";
import { cn } from "@/lib/utils";
import { TooltipProvider } from "@/components/ui/tooltip";
import { JotaiProvider } from "@/store/provider";
const geist = Geist({ subsets: ["latin"], variable: "--font-sans" });
export const metadata: Metadata = {
title: "agent-browser",
description: "Observability dashboard for agent-browser",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className={cn("dark font-sans antialiased", geist.variable)}>
<body>
<JotaiProvider>
<TooltipProvider>{children}</TooltipProvider>
</JotaiProvider>
</body>
</html>
);
}
+118
View File
@@ -0,0 +1,118 @@
"use client";
import { useAtomValue } from "jotai/react";
import { activePortAtom } from "@/store/sessions";
import { useSessionsSync } from "@/store/sessions";
import { useStreamSync, hasConsoleErrorsAtom, consoleLogsAtom } from "@/store/stream";
import { useActivitySync } from "@/store/activity";
import { activeExtensionsAtom } from "@/store/sessions";
import { useMediaQuery } from "@/hooks/use-media-query";
import { Viewport } from "@/components/viewport";
import { ActivityFeed } from "@/components/activity-feed";
import { ConsolePanel } from "@/components/console-panel";
import { StoragePanel } from "@/components/storage-panel";
import { ExtensionsPanel } from "@/components/extensions-panel";
import { NetworkPanel } from "@/components/network-panel";
import { SessionTree } from "@/components/session-tree";
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
export default function DashboardPage() {
const activePort = useAtomValue(activePortAtom);
useStreamSync(activePort);
useSessionsSync();
useActivitySync();
const isDesktop = useMediaQuery("(min-width: 768px)");
const hasConsoleErrors = useAtomValue(hasConsoleErrorsAtom);
const activeExtensions = useAtomValue(activeExtensionsAtom);
const sidePanel = (
<Tabs defaultValue="activity" className="flex h-full flex-col">
<div className="shrink-0 px-2 pt-1">
<TabsList variant="line" className="h-7 w-full">
<TabsTrigger value="activity" className="text-[11px]">Activity</TabsTrigger>
<TabsTrigger value="console" className="text-[11px]">
Console
{hasConsoleErrors && (
<span className="ml-1 inline-flex size-1.5 rounded-full bg-destructive" />
)}
</TabsTrigger>
<TabsTrigger value="network" className="text-[11px]">Network</TabsTrigger>
<TabsTrigger value="storage" className="text-[11px]">Storage</TabsTrigger>
<TabsTrigger value="extensions" className="text-[11px]">
Extensions
{activeExtensions.length > 0 && (
<span className="ml-1 text-[9px] tabular-nums text-muted-foreground">{activeExtensions.length}</span>
)}
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="activity" className="min-h-0 flex-1 overflow-hidden">
<ActivityFeed />
</TabsContent>
<TabsContent value="console" className="min-h-0 flex-1 overflow-hidden">
<ConsolePanel />
</TabsContent>
<TabsContent value="network" className="min-h-0 flex-1 overflow-hidden">
<NetworkPanel />
</TabsContent>
<TabsContent value="storage" className="min-h-0 flex-1 overflow-hidden">
<StoragePanel />
</TabsContent>
<TabsContent value="extensions" className="min-h-0 flex-1 overflow-hidden">
<ExtensionsPanel />
</TabsContent>
</Tabs>
);
if (isDesktop) {
return (
<div className="flex h-screen flex-col bg-background">
<ResizablePanelGroup
orientation="horizontal"
className="min-h-0 flex-1"
>
<ResizablePanel id="sessions" defaultSize="15%" minSize="10%" maxSize="30%">
<SessionTree />
</ResizablePanel>
<ResizableHandle />
<ResizablePanel id="viewport" defaultSize="55%" minSize="30%">
<Viewport />
</ResizablePanel>
<ResizableHandle />
<ResizablePanel id="activity" defaultSize="30%" minSize="15%" maxSize="50%">
{sidePanel}
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
}
return (
<div className="flex h-screen flex-col bg-background">
<Tabs defaultValue="viewport" className="min-h-0 flex-1">
<div className="shrink-0 px-2 pt-2">
<TabsList className="w-full">
<TabsTrigger value="sessions">Sessions</TabsTrigger>
<TabsTrigger value="viewport">Viewport</TabsTrigger>
<TabsTrigger value="activity">Activity</TabsTrigger>
</TabsList>
</div>
<TabsContent value="sessions" className="min-h-0 overflow-hidden">
<SessionTree />
</TabsContent>
<TabsContent value="viewport" className="min-h-0 overflow-hidden">
<Viewport />
</TabsContent>
<TabsContent value="activity" className="min-h-0 overflow-hidden">
{sidePanel}
</TabsContent>
</Tabs>
</div>
);
}
@@ -0,0 +1,247 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useAtomValue, useSetAtom } from "jotai/react";
import type { ActivityEvent } from "@/types";
import { combinedEventsAtom, persistActivityAtom, togglePersistAtom, clearActivityAtom } from "@/store/activity";
import { Bookmark, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { JsonSyntax } from "@/components/json-syntax";
function formatTime(ts: number): string {
const d = new Date(ts);
return d.toLocaleTimeString("en-US", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
function highlightRefs(text: string): React.ReactNode {
const parts = text.split(/(@e\d+)/g);
return parts.map((part, i) =>
part.match(/^@e\d+$/) ? (
<span key={i} className="font-mono font-semibold text-accent-foreground">
{part}
</span>
) : (
part
),
);
}
function CommandEntry({
event,
}: {
event: ActivityEvent & { type: "command" };
}) {
const [expanded, setExpanded] = useState(false);
const label = event.action;
const hasParams =
event.params &&
Object.keys(event.params).filter((k) => k !== "action" && k !== "id")
.length > 0;
return (
<Collapsible open={expanded} onOpenChange={setExpanded}>
<div className="py-1.5 px-3">
<CollapsibleTrigger className="flex w-full items-center gap-2 text-left text-xs">
<span className="shrink-0 font-mono text-muted-foreground">
{formatTime(event.timestamp)}
</span>
<span className="truncate font-mono font-semibold">
{highlightRefs(label)}
</span>
{hasParams && (
<span className="ml-auto shrink-0 text-muted-foreground">
{expanded ? "-" : "+"}
</span>
)}
</CollapsibleTrigger>
<CollapsibleContent>
{hasParams && (
<pre className="mt-1 max-h-32 overflow-x-auto overflow-y-auto text-[10px]">
<JsonSyntax
value={Object.fromEntries(
Object.entries(event.params).filter(
([k]) => k !== "action" && k !== "id",
),
)}
/>
</pre>
)}
</CollapsibleContent>
</div>
<Separator />
</Collapsible>
);
}
function ResultEntry({
event,
}: {
event: ActivityEvent & { type: "result" };
}) {
const [expanded, setExpanded] = useState(false);
return (
<Collapsible open={expanded} onOpenChange={setExpanded}>
<div className="py-1.5 px-3">
<CollapsibleTrigger className="flex w-full items-center gap-2 text-left text-xs">
<span className="shrink-0 font-mono text-muted-foreground">
{formatTime(event.timestamp)}
</span>
<span className="truncate font-mono">
{event.action}
<span className="ml-1 text-muted-foreground">
{event.duration_ms}ms
</span>
</span>
<span className="ml-auto shrink-0 text-muted-foreground">
{expanded ? "-" : "+"}
</span>
</CollapsibleTrigger>
<CollapsibleContent>
{event.data != null && (
<pre className="mt-1 max-h-48 overflow-x-auto overflow-y-auto text-[10px]">
{typeof event.data === "string"
? event.data
: <JsonSyntax value={event.data} />}
</pre>
)}
</CollapsibleContent>
</div>
<Separator />
</Collapsible>
);
}
const LEVEL_STYLES: Record<string, string> = {
error: "text-destructive",
warn: "text-warning",
warning: "text-warning",
info: "text-accent-foreground",
log: "text-muted-foreground",
};
function ConsoleEntry({
event,
}: {
event: ActivityEvent & { type: "console" };
}) {
return (
<>
<div className="flex items-start gap-2 py-1.5 px-3 text-xs">
<span className="shrink-0 font-mono text-muted-foreground">
{formatTime(event.timestamp)}
</span>
<Badge
variant="outline"
className={cn(
"h-4 px-1 text-[10px]",
LEVEL_STYLES[event.level] ?? "text-muted-foreground",
)}
>
{event.level}
</Badge>
<span className="truncate font-mono">{event.text}</span>
</div>
<Separator />
</>
);
}
export function ActivityFeed() {
const events = useAtomValue(combinedEventsAtom);
const persist = useAtomValue(persistActivityAtom);
const togglePersist = useSetAtom(togglePersistAtom);
const clearActivity = useSetAtom(clearActivityAtom);
const bottomRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const autoScrollRef = useRef(true);
useEffect(() => {
if (autoScrollRef.current) {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}
}, [events.length]);
const handleScroll = () => {
const el = containerRef.current;
if (!el) return;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
autoScrollRef.current = atBottom;
};
return (
<div className="flex h-full flex-col">
<div className="flex shrink-0 items-center gap-2 px-3 py-2">
<span className="text-xs text-muted-foreground">Activity</span>
<Badge variant="secondary" className="ml-auto h-4 px-1.5 text-[10px]">
{events.length}
</Badge>
<button
type="button"
onClick={() => togglePersist()}
className={cn(
"flex h-5 items-center gap-1 rounded border px-1.5 text-[10px] transition-colors",
persist
? "border-accent-foreground/30 bg-accent text-accent-foreground"
: "border-border text-muted-foreground hover:text-foreground",
)}
title={persist ? "Activity is persisted across reloads. Click to disable." : "Persist activity across reloads"}
>
<Bookmark className="size-3" />
{persist ? "On" : "Off"}
</button>
<button
type="button"
onClick={() => clearActivity()}
className="flex h-5 items-center gap-1 rounded border border-border px-1.5 text-[10px] text-muted-foreground transition-colors hover:text-foreground"
title="Clear activity"
>
<Trash2 className="size-3" />
</button>
</div>
<Separator />
<div
ref={containerRef}
onScroll={handleScroll}
className="min-h-0 flex-1 overflow-y-auto"
>
{events.length === 0 ? (
<div className="py-8 text-center text-xs text-muted-foreground">
Waiting for events...
</div>
) : (
events.map((event, i) => {
const key = event.type === "console"
? `console-${event.timestamp}-${i}`
: `${event.type}-${event.id}`;
switch (event.type) {
case "command":
return <CommandEntry key={key} event={event} />;
case "result":
return <ResultEntry key={key} event={event} />;
case "console":
return <ConsoleEntry key={key} event={event} />;
}
})
)}
<div ref={bottomRef} />
</div>
</div>
);
}
@@ -0,0 +1,306 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useAtomValue, useSetAtom } from "jotai/react";
import type { ConsoleEntry } from "@/types";
import { consoleLogsAtom, clearConsoleLogsAtom } from "@/store/stream";
import { activeSessionNameAtom } from "@/store/sessions";
import { execCommand, sessionArgs } from "@/lib/exec";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { CornerDownLeft, Loader2, Trash2 } from "lucide-react";
type FilterLevel = "all" | "errors" | "warnings" | "info" | "log";
const FILTER_MATCH: Record<FilterLevel, (e: ConsoleEntry) => boolean> = {
all: () => true,
errors: (e) => e.type === "page_error" || (e.type === "console" && e.level === "error"),
warnings: (e) => e.type === "console" && (e.level === "warn" || e.level === "warning"),
info: (e) => e.type === "console" && e.level === "info",
log: (e) => e.type === "console" && (e.level === "log" || e.level === "debug"),
};
const LEVEL_COLORS: Record<string, string> = {
error: "text-destructive",
page_error: "text-destructive",
warn: "text-warning",
warning: "text-warning",
info: "text-blue-400",
log: "text-muted-foreground",
debug: "text-muted-foreground/60",
};
function formatTime(ts: number): string {
const d = new Date(ts);
return d.toLocaleTimeString("en-US", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
function entryLevel(e: ConsoleEntry): string {
return e.type === "page_error" ? "error" : e.level;
}
function entryText(e: ConsoleEntry): string {
if (e.type === "page_error") {
let text = e.text;
if (e.line != null) {
text += ` (${e.line}`;
if (e.column != null) text += `:${e.column}`;
text += ")";
}
return text;
}
return e.text;
}
interface EvalEntry {
id: number;
expression: string;
result?: string;
error?: string;
pending: boolean;
timestamp: number;
}
let evalIdCounter = 0;
export function ConsolePanel() {
const entries = useAtomValue(consoleLogsAtom);
const clearConsoleLogs = useSetAtom(clearConsoleLogsAtom);
const sessionName = useAtomValue(activeSessionNameAtom);
const [filter, setFilter] = useState<FilterLevel>("all");
const bottomRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const autoScrollRef = useRef(true);
const [evalInput, setEvalInput] = useState("");
const [evalEntries, setEvalEntries] = useState<EvalEntry[]>([]);
const [evaluating, setEvaluating] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const filtered = entries.filter(FILTER_MATCH[filter]);
const errorCount = entries.filter(FILTER_MATCH.errors).length;
const warnCount = entries.filter(FILTER_MATCH.warnings).length;
useEffect(() => {
if (autoScrollRef.current) {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}
}, [filtered.length, evalEntries.length]);
const handleScroll = () => {
const el = containerRef.current;
if (!el) return;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
autoScrollRef.current = atBottom;
};
const handleEval = useCallback(async () => {
const expr = evalInput.trim();
if (!expr || !sessionName || evaluating) return;
const id = ++evalIdCounter;
const entry: EvalEntry = { id, expression: expr, pending: true, timestamp: Date.now() };
setEvalEntries((prev) => [...prev, entry]);
setEvalInput("");
setEvaluating(true);
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
}
try {
const res = await execCommand(sessionArgs(sessionName, "eval", expr));
setEvalEntries((prev) =>
prev.map((e) =>
e.id === id
? { ...e, pending: false, result: res.stdout.trim(), error: res.stderr.trim() || undefined }
: e,
),
);
} catch {
setEvalEntries((prev) =>
prev.map((e) =>
e.id === id ? { ...e, pending: false, error: "Failed to execute" } : e,
),
);
} finally {
setEvaluating(false);
}
}, [evalInput, sessionName, evaluating]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleEval();
}
},
[handleEval],
);
const filters: { key: FilterLevel; label: string; count?: number }[] = [
{ key: "all", label: "All" },
{ key: "errors", label: "Errors", count: errorCount },
{ key: "warnings", label: "Warnings", count: warnCount },
{ key: "info", label: "Info" },
{ key: "log", label: "Log" },
];
return (
<div className="flex h-full flex-col">
<div className="flex shrink-0 items-center gap-1.5 px-3 py-2">
{filters.map((f) => (
<button
key={f.key}
type="button"
onClick={() => setFilter(f.key)}
className={cn(
"flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] transition-colors",
filter === f.key
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{f.label}
{f.count != null && f.count > 0 && (
<Badge
variant="secondary"
className={cn(
"h-3.5 min-w-4 px-1 text-[9px] tabular-nums",
f.key === "errors" && "bg-destructive/20 text-destructive",
f.key === "warnings" && "bg-warning/20 text-warning",
)}
>
{f.count}
</Badge>
)}
</button>
))}
<button
type="button"
onClick={() => clearConsoleLogs()}
className="ml-auto flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:text-foreground"
title="Clear console"
>
<Trash2 className="size-3" />
</button>
</div>
<Separator />
<div
ref={containerRef}
onScroll={handleScroll}
className="min-h-0 flex-1 overflow-y-auto font-mono"
>
{filtered.length === 0 && evalEntries.length === 0 ? (
<div className="py-8 text-center text-xs text-muted-foreground">
No console output
</div>
) : (
<>
{filtered.map((entry, i) => {
const level = entryLevel(entry);
const color = LEVEL_COLORS[level] ?? "text-muted-foreground";
return (
<div
key={`c-${i}`}
className={cn(
"flex items-start gap-2 border-b border-border/50 px-3 py-1 text-[11px]",
level === "error" || entry.type === "page_error"
? "bg-destructive/5"
: level === "warn" || level === "warning"
? "bg-warning/5"
: "",
)}
>
<span className="shrink-0 text-muted-foreground/60">
{formatTime(entry.timestamp)}
</span>
<span className={cn("shrink-0 w-10 uppercase", color)}>
{entry.type === "page_error" ? "error" : entry.level}
</span>
<span className={cn("min-w-0 flex-1 break-all whitespace-pre-wrap", color)}>
{entryText(entry)}
</span>
</div>
);
})}
{evalEntries.map((entry) => (
<div key={`e-${entry.id}`} className="border-b border-border/50 text-[11px]">
<div className="flex items-start gap-2 bg-muted/30 px-3 py-1">
<span className="shrink-0 text-muted-foreground/60">
{formatTime(entry.timestamp)}
</span>
<span className="shrink-0 w-10 text-violet-400">&gt;</span>
<span className="min-w-0 flex-1 whitespace-pre-wrap break-all text-violet-400">
{entry.expression}
</span>
</div>
{entry.pending ? (
<div className="flex items-center gap-2 px-3 py-1">
<Loader2 className="size-3 animate-spin text-muted-foreground" />
</div>
) : entry.error ? (
<div className="bg-destructive/5 px-3 py-1 pl-[76px]">
<span className="whitespace-pre-wrap break-all text-destructive">
{entry.error}
</span>
</div>
) : null}
{entry.result && (
<div className="px-3 py-1 pl-[76px]">
<span className="whitespace-pre-wrap break-all text-emerald-400">
{entry.result}
</span>
</div>
)}
</div>
))}
</>
)}
<div ref={bottomRef} />
</div>
<Separator />
<div className="shrink-0 flex items-end gap-1.5 px-3 py-2">
<textarea
ref={textareaRef}
value={evalInput}
onChange={(e) => {
setEvalInput(e.target.value);
e.target.style.height = "auto";
e.target.style.height = `${Math.min(e.target.scrollHeight, 120)}px`;
}}
onKeyDown={handleKeyDown}
placeholder={sessionName ? "Evaluate JavaScript..." : "No active session"}
disabled={!sessionName}
rows={1}
className={cn(
"min-h-[28px] max-h-[120px] flex-1 resize-none rounded border border-border bg-background px-2 py-1.5 font-mono text-[11px] text-foreground placeholder:text-muted-foreground/60 focus:outline-none focus:ring-1 focus:ring-ring",
!sessionName && "opacity-50",
)}
/>
<button
type="button"
onClick={handleEval}
disabled={!sessionName || !evalInput.trim() || evaluating}
className="flex size-7 shrink-0 items-center justify-center rounded border border-border text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:pointer-events-none"
title="Run (Enter)"
>
{evaluating ? (
<Loader2 className="size-3 animate-spin" />
) : (
<CornerDownLeft className="size-3" />
)}
</button>
</div>
</div>
);
}
@@ -0,0 +1,110 @@
"use client";
import { useAtomValue } from "jotai/react";
import { activeExtensionsAtom, activeSessionNameAtom } from "@/store/sessions";
import { cn } from "@/lib/utils";
import { Puzzle } from "lucide-react";
import { Separator } from "@/components/ui/separator";
import { Badge } from "@/components/ui/badge";
import { useState } from "react";
export function ExtensionsPanel() {
const extensions = useAtomValue(activeExtensionsAtom);
const sessionName = useAtomValue(activeSessionNameAtom);
const [expanded, setExpanded] = useState<string | null>(null);
if (!sessionName) {
return (
<div className="flex h-full flex-col">
<Header count={0} />
<Separator />
<div className="py-8 text-center text-xs text-muted-foreground">
No active session
</div>
</div>
);
}
return (
<div className="flex h-full flex-col">
<Header count={extensions.length} />
<Separator />
<div className="min-h-0 flex-1 overflow-y-auto">
{extensions.length === 0 ? (
<div className="py-8 text-center text-xs text-muted-foreground">
No extensions loaded
</div>
) : (
extensions.map((ext) => {
const isExpanded = expanded === ext.path;
return (
<div key={ext.path} className="border-b border-border/50">
<button
type="button"
onClick={() => setExpanded(isExpanded ? null : ext.path)}
className="flex w-full items-start gap-2.5 px-3 py-2 text-left text-xs hover:bg-muted/50"
>
<Puzzle className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-semibold text-foreground">
{ext.name}
</span>
{ext.version && (
<Badge
variant="secondary"
className="h-4 px-1.5 text-[10px] tabular-nums"
>
v{ext.version}
</Badge>
)}
</div>
{ext.description && !isExpanded && (
<p className="mt-0.5 truncate text-[11px] text-muted-foreground">
{ext.description}
</p>
)}
</div>
</button>
{isExpanded && (
<div className="space-y-1 bg-muted/30 px-3 py-2 text-[11px]">
{ext.description && (
<div>
<span className="text-muted-foreground">Description: </span>
<span className="text-foreground">{ext.description}</span>
</div>
)}
<div>
<span className="text-muted-foreground">Path: </span>
<span className={cn("break-all font-mono text-foreground")}>
{ext.path}
</span>
</div>
</div>
)}
</div>
);
})
)}
</div>
</div>
);
}
function Header({ count }: { count: number }) {
return (
<div className="flex shrink-0 items-center gap-1.5 px-3 py-2">
<span className="text-[10px] text-muted-foreground">
Chrome Extensions
</span>
{count > 0 && (
<Badge
variant="secondary"
className="ml-auto h-4 px-1.5 text-[10px] tabular-nums"
>
{count}
</Badge>
)}
</div>
);
}
@@ -0,0 +1,70 @@
"use client";
import type { ReactNode } from "react";
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
function renderValue(value: JsonValue, indent: number): ReactNode {
if (value === null) {
return <span className="json-null">null</span>;
}
if (typeof value === "boolean") {
return <span className="json-bool">{String(value)}</span>;
}
if (typeof value === "number") {
return <span className="json-number">{String(value)}</span>;
}
if (typeof value === "string") {
return <span className="json-string">&quot;{value}&quot;</span>;
}
if (Array.isArray(value)) {
if (value.length === 0) return <span className="json-punct">[]</span>;
const pad = " ".repeat(indent + 1);
const closePad = " ".repeat(indent);
return (
<>
<span className="json-punct">[</span>
{"\n"}
{value.map((item, i) => (
<span key={i}>
{pad}
{renderValue(item, indent + 1)}
{i < value.length - 1 && <span className="json-punct">,</span>}
{"\n"}
</span>
))}
{closePad}
<span className="json-punct">]</span>
</>
);
}
if (typeof value === "object") {
const entries = Object.entries(value);
if (entries.length === 0) return <span className="json-punct">{"{}"}</span>;
const pad = " ".repeat(indent + 1);
const closePad = " ".repeat(indent);
return (
<>
<span className="json-punct">{"{"}</span>
{"\n"}
{entries.map(([k, v], i) => (
<span key={k}>
{pad}
<span className="json-key">&quot;{k}&quot;</span>
<span className="json-punct">: </span>
{renderValue(v, indent + 1)}
{i < entries.length - 1 && <span className="json-punct">,</span>}
{"\n"}
</span>
))}
{closePad}
<span className="json-punct">{"}"}</span>
</>
);
}
return String(value);
}
export function JsonSyntax({ value }: { value: unknown }) {
return <code>{renderValue(value as JsonValue, 0)}</code>;
}
@@ -0,0 +1,427 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useAtomValue } from "jotai/react";
import { activeSessionNameAtom } from "@/store/sessions";
import { execCommand, sessionArgs } from "@/lib/exec";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Circle, Loader2, RefreshCw, Square, Trash2 } from "lucide-react";
interface NetworkRequest {
url: string;
method: string;
status?: number;
resourceType: string;
requestId: string;
mimeType?: string;
timestamp: number;
}
type TypeFilter = "all" | "xhr" | "doc" | "css" | "js" | "img" | "font" | "other";
const TYPE_FILTERS: { key: TypeFilter; label: string; cliType?: string }[] = [
{ key: "all", label: "All" },
{ key: "xhr", label: "XHR", cliType: "xhr,fetch" },
{ key: "doc", label: "Doc", cliType: "document" },
{ key: "css", label: "CSS", cliType: "stylesheet" },
{ key: "js", label: "JS", cliType: "script" },
{ key: "img", label: "Img", cliType: "image" },
{ key: "font", label: "Font", cliType: "font" },
{ key: "other", label: "Other", cliType: "other,websocket,media,manifest,texttrack,eventsource,signedexchange,ping,cspviolationreport,preflight" },
];
const STATUS_COLOR: Record<string, string> = {
"2": "text-emerald-500",
"3": "text-blue-400",
"4": "text-warning",
"5": "text-destructive",
};
function statusColor(status?: number): string {
if (status == null) return "text-muted-foreground";
const prefix = String(status)[0];
return STATUS_COLOR[prefix] ?? "text-muted-foreground";
}
function truncateUrl(url: string, max: number): string {
try {
const u = new URL(url);
const path = u.pathname + u.search;
return path.length > max ? path.slice(0, max) + "..." : path;
} catch {
return url.length > max ? url.slice(0, max) + "..." : url;
}
}
function urlHost(url: string): string {
try {
return new URL(url).host;
} catch {
return "";
}
}
export function NetworkPanel() {
const sessionName = useAtomValue(activeSessionNameAtom);
const [requests, setRequests] = useState<NetworkRequest[]>([]);
const [loading, setLoading] = useState(false);
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all");
const [expanded, setExpanded] = useState<string | null>(null);
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [harRecording, setHarRecording] = useState(false);
const [harDialogOpen, setHarDialogOpen] = useState(false);
const [harPath, setHarPath] = useState("capture.har");
const harInputRef = useRef<HTMLInputElement>(null);
const lastSessionRef = useRef(sessionName);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const doFetch = useCallback(async (showSpinner: boolean) => {
if (!sessionName) return;
if (showSpinner) setLoading(true);
try {
const args = sessionArgs(sessionName, "network", "requests");
if (typeFilter !== "all") {
const filter = TYPE_FILTERS.find((f) => f.key === typeFilter);
if (filter?.cliType) {
args.push("--type", filter.cliType);
}
}
const res = await execCommand(args);
if (res.success && res.stdout) {
try {
const parsed = JSON.parse(res.stdout);
const data = parsed.data ?? parsed;
setRequests(data.requests ?? []);
} catch {
setRequests([]);
}
}
} finally {
if (showSpinner) setLoading(false);
}
}, [sessionName, typeFilter]);
const fetchRequests = useCallback(() => doFetch(true), [doFetch]);
useEffect(() => {
if (sessionName && sessionName !== lastSessionRef.current) {
lastSessionRef.current = sessionName;
setRequests([]);
setExpanded(null);
setDetail(null);
setHarRecording(false);
}
doFetch(true);
}, [sessionName, typeFilter, doFetch]);
useEffect(() => {
if (!sessionName) return;
pollRef.current = setInterval(() => {
if (document.visibilityState === "visible") doFetch(false);
}, 5000);
return () => {
if (pollRef.current) clearInterval(pollRef.current);
};
}, [sessionName, doFetch]);
useEffect(() => {
if (harDialogOpen) {
requestAnimationFrame(() => harInputRef.current?.select());
}
}, [harDialogOpen]);
const handleClear = useCallback(async () => {
if (!sessionName) return;
await execCommand(sessionArgs(sessionName, "network", "requests", "--clear"));
setRequests([]);
setExpanded(null);
setDetail(null);
}, [sessionName]);
const handleExpand = useCallback(async (requestId: string) => {
if (expanded === requestId) {
setExpanded(null);
setDetail(null);
return;
}
setExpanded(requestId);
setDetail(null);
setDetailLoading(true);
try {
const res = await execCommand(sessionArgs(sessionName, "network", "request", requestId));
if (res.success && res.stdout) {
try {
const parsed = JSON.parse(res.stdout);
setDetail(parsed.data ?? parsed);
} catch {
setDetail(null);
}
}
} finally {
setDetailLoading(false);
}
}, [expanded, sessionName]);
const handleHarStart = useCallback(async () => {
if (!sessionName) return;
await execCommand(sessionArgs(sessionName, "network", "har", "start"));
setHarRecording(true);
}, [sessionName]);
const handleHarStop = useCallback(async () => {
if (!sessionName) return;
const path = harPath.trim() || "capture.har";
setHarDialogOpen(false);
await execCommand(sessionArgs(sessionName, "network", "har", "stop", path));
setHarRecording(false);
}, [sessionName, harPath]);
return (
<div className="flex h-full flex-col">
<div className="flex shrink-0 items-center gap-1.5 px-3 py-2">
{TYPE_FILTERS.map((f) => (
<button
key={f.key}
type="button"
onClick={() => {
setTypeFilter(f.key);
setExpanded(null);
setDetail(null);
}}
className={cn(
"rounded px-1.5 py-0.5 text-[10px] transition-colors",
typeFilter === f.key
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{f.label}
</button>
))}
<div className="ml-auto flex items-center gap-1">
<button
type="button"
onClick={harRecording ? () => setHarDialogOpen(true) : handleHarStart}
disabled={!sessionName}
className={cn(
"flex size-5 items-center justify-center rounded transition-colors disabled:opacity-40",
harRecording
? "text-destructive hover:bg-destructive/10"
: "text-muted-foreground hover:text-foreground",
)}
title={harRecording ? "Stop HAR recording" : "Start HAR recording"}
>
{harRecording ? (
<Square className="size-2.5 fill-current" />
) : (
<Circle className="size-3" />
)}
</button>
{harRecording && (
<Badge variant="secondary" className="h-3.5 px-1 text-[9px] text-destructive">
HAR
</Badge>
)}
<button
type="button"
onClick={handleClear}
disabled={!sessionName}
className="flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:text-foreground disabled:opacity-40"
title="Clear requests"
>
<Trash2 className="size-3" />
</button>
<button
type="button"
onClick={fetchRequests}
disabled={loading || !sessionName}
className="flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:text-foreground disabled:opacity-40"
title="Refresh"
>
{loading ? (
<Loader2 className="size-3 animate-spin" />
) : (
<RefreshCw className="size-3" />
)}
</button>
</div>
</div>
<Separator />
<div className="min-h-0 flex-1 overflow-y-auto font-mono">
{!sessionName ? (
<div className="py-8 text-center text-xs text-muted-foreground">
No active session
</div>
) : requests.length === 0 ? (
<div className="py-8 text-center text-xs text-muted-foreground">
{loading ? (
<Loader2 className="mx-auto size-4 animate-spin text-muted-foreground" />
) : (
"No requests captured"
)}
</div>
) : (
requests.map((r) => {
const isExpanded = expanded === r.requestId;
return (
<div key={r.requestId} className="border-b border-border/50">
<button
type="button"
onClick={() => handleExpand(r.requestId)}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] hover:bg-muted/50"
>
<span className={cn("w-7 shrink-0 text-right tabular-nums", statusColor(r.status))}>
{r.status ?? "..."}
</span>
<span className="w-8 shrink-0 text-muted-foreground">{r.method}</span>
<span className="min-w-0 flex-1 truncate text-foreground" title={r.url}>
{truncateUrl(r.url, 80)}
</span>
<span className="shrink-0 text-[10px] text-muted-foreground/60">
{r.resourceType}
</span>
</button>
{isExpanded && (
<div className="space-y-1.5 bg-muted/30 px-3 py-2 text-[10px]">
{detailLoading ? (
<div className="flex items-center gap-2 py-2">
<Loader2 className="size-3 animate-spin text-muted-foreground" />
</div>
) : detail ? (
<RequestDetail detail={detail} url={r.url} />
) : (
<div className="text-muted-foreground">
URL: {r.url}
</div>
)}
</div>
)}
</div>
);
})
)}
</div>
<Dialog open={harDialogOpen} onOpenChange={setHarDialogOpen}>
<DialogContent className="max-w-xs">
<DialogHeader>
<DialogTitle>Save HAR</DialogTitle>
</DialogHeader>
<input
ref={harInputRef}
type="text"
value={harPath}
onChange={(e) => setHarPath(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleHarStop();
}
}}
placeholder="capture.har"
className="h-9 w-full rounded-md border border-input bg-transparent px-3 font-mono text-sm outline-none focus:ring-1 focus:ring-ring"
/>
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => setHarDialogOpen(false)}>
Cancel
</Button>
<Button size="sm" onClick={handleHarStop} disabled={!harPath.trim()}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function RequestDetail({ detail, url }: { detail: Record<string, unknown>; url: string }) {
const host = urlHost(url);
const headers = detail.headers as Record<string, string> | undefined;
const responseHeaders = detail.responseHeaders as Record<string, string> | undefined;
const body = detail.body as string | undefined;
const postData = detail.postData as string | undefined;
return (
<>
<DetailRow label="URL" value={url} wrap />
{host && <DetailRow label="Host" value={host} />}
{detail.method && <DetailRow label="Method" value={String(detail.method)} />}
{detail.status != null && <DetailRow label="Status" value={String(detail.status)} />}
{detail.mimeType && <DetailRow label="Type" value={String(detail.mimeType)} />}
{headers && Object.keys(headers).length > 0 && (
<HeadersSection title="Request Headers" headers={headers} />
)}
{postData && (
<div className="mt-1">
<span className="text-muted-foreground">Request Body</span>
<pre className="mt-0.5 max-h-32 overflow-auto whitespace-pre-wrap break-all text-foreground">
{formatBody(postData)}
</pre>
</div>
)}
{responseHeaders && Object.keys(responseHeaders).length > 0 && (
<HeadersSection title="Response Headers" headers={responseHeaders} />
)}
{body && (
<div className="mt-1">
<span className="text-muted-foreground">Response Body</span>
<pre className="mt-0.5 max-h-48 overflow-auto whitespace-pre-wrap break-all text-foreground">
{formatBody(body)}
</pre>
</div>
)}
</>
);
}
function HeadersSection({ title, headers }: { title: string; headers: Record<string, string> }) {
return (
<div className="mt-1">
<span className="text-muted-foreground">{title}</span>
<div className="mt-0.5 space-y-px">
{Object.entries(headers).map(([k, v]) => (
<div key={k} className="flex gap-2">
<span className="shrink-0 text-muted-foreground">{k}:</span>
<span className="min-w-0 flex-1 break-all text-foreground">{String(v)}</span>
</div>
))}
</div>
</div>
);
}
function DetailRow({ label, value, wrap }: { label: string; value: string; wrap?: boolean }) {
return (
<div className="flex gap-2">
<span className="w-12 shrink-0 text-muted-foreground">{label}</span>
<span className={cn("min-w-0 flex-1 text-foreground", wrap ? "break-all whitespace-pre-wrap" : "truncate")}>
{value}
</span>
</div>
);
}
function formatBody(raw: string): string {
try {
return JSON.stringify(JSON.parse(raw), null, 2);
} catch {
return raw;
}
}
@@ -0,0 +1,484 @@
"use client";
import { useCallback, useRef, useState, type SyntheticEvent } from "react";
import { useAtomValue, useSetAtom } from "jotai/react";
import type { SessionInfo, TabInfo } from "@/types";
import {
sessionsAtom,
activePortAtom,
createSessionAtom,
closeSessionAtom,
killSessionAtom,
closeAllSessionsAtom,
closeTabAtom,
addTabAtom,
switchTabAtom,
} from "@/store/sessions";
import { tabsForPortAtom, engineForPortAtom } from "@/store/tabs";
import { ChevronRight, Loader2, Plus, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Separator } from "@/components/ui/separator";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
const ENGINE_LOGOS: Record<string, string> = {
chrome: "https://svgl.app/library/chrome.svg",
firefox: "https://svgl.app/library/firefox.svg",
safari: "https://svgl.app/library/safari.svg",
lightpanda: "/lightpanda.svg",
};
const SUPPORTED_ENGINES = ["chrome", "lightpanda"] as const;
function EngineLogo({ engine }: { engine: string }) {
const src = ENGINE_LOGOS[engine];
if (!src) {
if (!engine) {
return <span className="size-4 shrink-0" />;
}
return (
<span className="flex size-4 shrink-0 items-center justify-center rounded bg-muted text-[8px] font-bold text-muted-foreground uppercase">
{engine.charAt(0)}
</span>
);
}
return (
<img
src={src}
alt={engine}
width={16}
height={16}
className="size-4 shrink-0"
/>
);
}
function getFaviconUrl(url: string): string | null {
try {
const { hostname } = new URL(url);
if (!hostname || hostname === "localhost") return null;
return `https://www.google.com/s2/favicons?domain=${hostname}&sz=32`;
} catch {
return null;
}
}
function TabFavicon({ url }: { url: string }) {
const src = getFaviconUrl(url);
if (!src) {
return <span className="flex size-3.5 shrink-0 items-center justify-center rounded-sm bg-muted text-[8px] text-muted-foreground">&#9679;</span>;
}
const handleError = (e: SyntheticEvent<HTMLImageElement>) => {
(e.target as HTMLImageElement).style.display = "none";
};
return (
<img
src={src}
alt=""
width={14}
height={14}
className="size-3.5 shrink-0 rounded-sm"
onError={handleError}
/>
);
}
function TabNode({ tab, isViewed, isSessionActive, onClose, onSwitch, onSelectSession }: { tab: TabInfo; isViewed: boolean; isSessionActive: boolean; onClose: () => void; onSwitch: () => void; onSelectSession: () => void }) {
const handleClick = () => {
if (!isSessionActive) {
onSelectSession();
}
if (!tab.active) {
onSwitch();
}
};
const isClickable = !isViewed;
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<button
onClick={isClickable ? handleClick : undefined}
className={cn(
"flex w-full min-w-0 items-center gap-1.5 py-1 pr-1 pl-7 text-left text-xs",
isViewed
? "bg-card text-foreground"
: "text-muted-foreground cursor-pointer hover:text-foreground",
)}
>
<TabFavicon url={tab.url} />
<span className="min-w-0 flex-1 truncate">
{tab.title || tab.url || `Tab ${tab.index}`}
</span>
{tab.active && (
<span className="shrink-0 rounded border border-border px-1 py-px text-[9px] leading-none text-muted-foreground">
active
</span>
)}
</button>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={onClose}>Close tab</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
function SessionNode({
session,
isActive,
tabs,
engine,
expanded,
onSelect,
onToggle,
onCloseTab,
onAddTab,
onSwitchTab,
onClose,
onKill,
}: {
session: SessionInfo;
isActive: boolean;
tabs: TabInfo[];
engine: string;
expanded: boolean;
onSelect: () => void;
onToggle: () => void;
onCloseTab: (tabIndex: number) => void;
onAddTab: () => void;
onSwitchTab: (tabIndex: number) => void;
onClose: () => void;
onKill: () => void;
}) {
const [confirmClose, setConfirmClose] = useState(false);
const [confirmKill, setConfirmKill] = useState(false);
if (session.pending || session.closing) {
return (
<div className="flex w-full items-center text-xs text-muted-foreground">
<span className="flex size-6 shrink-0 items-center justify-center">
<Loader2 className="size-3 animate-spin" />
</span>
<span className="flex flex-1 min-w-0 items-center gap-2 py-1.5 pr-3 pl-1">
<EngineLogo engine={session.engine ?? engine} />
<span className="truncate font-mono font-semibold">
{session.session}
</span>
<span className="ml-auto text-[10px]">
{session.closing ? "Closing..." : "Starting..."}
</span>
</span>
</div>
);
}
return (
<Collapsible open={expanded} onOpenChange={() => onToggle()}>
<ContextMenu>
<ContextMenuTrigger asChild>
<CollapsibleTrigger
className={cn(
"flex w-full items-center text-xs transition-colors",
isActive
? "text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
<span className="flex size-6 shrink-0 items-center justify-center">
<ChevronRight className={cn("size-3 transition-transform", expanded && "rotate-90")} />
</span>
<span className="flex flex-1 min-w-0 items-center gap-2 py-1.5 pr-3 pl-1 text-left">
<EngineLogo engine={engine} />
<span className="truncate font-mono font-semibold">
{session.session}
</span>
<Badge
variant="secondary"
className="ml-auto h-4 px-1.5 text-[10px] tabular-nums"
>
{tabs.length}
</Badge>
</span>
</CollapsibleTrigger>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => setConfirmClose(true)}>Close session</ContextMenuItem>
<ContextMenuItem className="text-destructive focus:text-destructive" onClick={() => setConfirmKill(true)}>Kill session</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
<Dialog open={confirmClose} onOpenChange={setConfirmClose}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Close session</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Close <span className="font-mono font-semibold text-foreground">{session.session}</span> and its browser? This action cannot be undone.
</p>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={() => setConfirmClose(false)}>
Cancel
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => {
setConfirmClose(false);
onClose();
}}
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={confirmKill} onOpenChange={setConfirmKill}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Kill session</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Force-kill <span className="font-mono font-semibold text-foreground">{session.session}</span>? This immediately terminates the process without cleanup.
</p>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={() => setConfirmKill(false)}>
Cancel
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => {
setConfirmKill(false);
onKill();
}}
>
Kill
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<CollapsibleContent>
<div className="overflow-hidden pb-1">
{tabs.map((tab) => (
<TabNode key={tab.index} tab={tab} isViewed={isActive && tab.active} isSessionActive={isActive} onClose={() => onCloseTab(tab.index)} onSwitch={() => onSwitchTab(tab.index)} onSelectSession={onSelect} />
))}
<button
onClick={onAddTab}
className="flex w-full items-center gap-1.5 py-1 pr-1 pl-7 text-xs text-muted-foreground hover:text-foreground"
>
<Plus className="size-3.5" />
Add tab
</button>
</div>
</CollapsibleContent>
</Collapsible>
);
}
export function SessionTree() {
const sessions = useAtomValue(sessionsAtom);
const activePort = useAtomValue(activePortAtom);
const setActivePort = useSetAtom(activePortAtom);
const getTabsForSession = useAtomValue(tabsForPortAtom);
const getEngineForSession = useAtomValue(engineForPortAtom);
const dispatchCreateSession = useSetAtom(createSessionAtom);
const dispatchCloseSession = useSetAtom(closeSessionAtom);
const dispatchKillSession = useSetAtom(killSessionAtom);
const dispatchCloseAllSessions = useSetAtom(closeAllSessionsAtom);
const dispatchCloseTab = useSetAtom(closeTabAtom);
const dispatchAddTab = useSetAtom(addTabAtom);
const dispatchSwitchTab = useSetAtom(switchTabAtom);
const [expandedMap, setExpandedMap] = useState<Record<number, boolean>>({});
const [newSessionOpen, setNewSessionOpen] = useState(false);
const [closeAllOpen, setCloseAllOpen] = useState(false);
const [newSessionName, setNewSessionName] = useState("");
const [newSessionEngine, setNewSessionEngine] = useState("chrome");
const nameInputRef = useRef<HTMLInputElement>(null);
const isExpanded = useCallback(
(port: number) => expandedMap[port] ?? true,
[expandedMap],
);
const toggleExpanded = useCallback((port: number) => {
setExpandedMap((prev) => ({ ...prev, [port]: !(prev[port] ?? true) }));
}, []);
const handleCreateSubmit = useCallback(() => {
const name = newSessionName.trim();
if (name) {
dispatchCreateSession({ name, engine: newSessionEngine });
setNewSessionName("");
setNewSessionOpen(false);
}
}, [newSessionName, newSessionEngine, dispatchCreateSession]);
return (
<div className="flex h-full flex-col">
<div className="flex shrink-0 items-center px-3 py-2">
<span className="text-xs text-muted-foreground">Sessions</span>
<div className="ml-auto flex items-center gap-0.5">
{sessions.some((s) => !s.pending) && (
<button
type="button"
onClick={() => setCloseAllOpen(true)}
className="flex size-5 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
title="Close all sessions"
>
<Trash2 className="size-3" />
</button>
)}
<button
type="button"
onClick={() => setNewSessionOpen(true)}
className="flex size-5 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
title="New session"
>
<Plus className="size-3.5" />
</button>
</div>
</div>
<Separator />
<ScrollArea className="flex-1">
<div className="w-full py-1">
{sessions.length === 0 ? (
<div className="py-4 text-center text-xs text-muted-foreground">
No sessions
</div>
) : (
sessions.map((s) => (
<SessionNode
key={s.pending ? `pending-${s.session}` : s.port}
session={s}
isActive={s.port === activePort}
tabs={getTabsForSession(s.port)}
engine={getEngineForSession(s.port)}
expanded={isExpanded(s.port)}
onSelect={() => setActivePort(s.port)}
onToggle={() => toggleExpanded(s.port)}
onCloseTab={(tabIndex) => dispatchCloseTab({ port: s.port, tabIndex })}
onAddTab={() => dispatchAddTab(s.port)}
onSwitchTab={(tabIndex) => dispatchSwitchTab({ port: s.port, tabIndex })}
onClose={() => dispatchCloseSession(s.port)}
onKill={() => dispatchKillSession(s.port)}
/>
))
)}
</div>
</ScrollArea>
<Dialog open={newSessionOpen} onOpenChange={setNewSessionOpen}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>New session</DialogTitle>
</DialogHeader>
<input
ref={nameInputRef}
type="text"
value={newSessionName}
onChange={(e) => setNewSessionName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleCreateSubmit();
}
}}
placeholder="Session name"
autoFocus
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring"
/>
<div className="flex gap-2">
{SUPPORTED_ENGINES.map((eng) => (
<button
key={eng}
type="button"
onClick={() => setNewSessionEngine(eng)}
className={cn(
"flex flex-1 items-center justify-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors",
newSessionEngine === eng
? "border-ring bg-muted text-foreground"
: "border-border text-muted-foreground hover:text-foreground",
)}
>
<EngineLogo engine={eng} />
{eng}
</button>
))}
</div>
<DialogFooter>
<Button
variant="ghost"
size="sm"
onClick={() => setNewSessionOpen(false)}
>
Cancel
</Button>
<Button
size="sm"
onClick={handleCreateSubmit}
disabled={!newSessionName.trim()}
>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={closeAllOpen} onOpenChange={setCloseAllOpen}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Close all sessions</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
This will close {sessions.filter((s) => !s.pending).length} active {sessions.filter((s) => !s.pending).length === 1 ? "session" : "sessions"} and their browsers. This action cannot be undone.
</p>
<DialogFooter>
<Button
variant="ghost"
size="sm"
onClick={() => setCloseAllOpen(false)}
>
Cancel
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => {
setCloseAllOpen(false);
dispatchCloseAllSessions();
}}
>
Close all
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,344 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useAtomValue } from "jotai/react";
import { activeSessionNameAtom } from "@/store/sessions";
import { execCommand, sessionArgs } from "@/lib/exec";
import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator";
import { Loader2, RefreshCw } from "lucide-react";
type StorageTab = "cookies" | "localStorage" | "sessionStorage";
interface CookieEntry {
name: string;
value: string;
domain?: string;
path?: string;
expires?: number;
httpOnly?: boolean;
secure?: boolean;
sameSite?: string;
}
interface StorageEntry {
key: string;
value: string;
}
const TABS: { key: StorageTab; label: string }[] = [
{ key: "cookies", label: "Cookies" },
{ key: "localStorage", label: "Local" },
{ key: "sessionStorage", label: "Session" },
];
function truncate(s: string, max: number): string {
return s.length > max ? s.slice(0, max) + "..." : s;
}
function formatExpiry(expires: number | undefined): string {
if (expires == null || expires <= 0) return "Session";
const d = new Date(expires * 1000);
return d.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
export function StoragePanel() {
const sessionName = useAtomValue(activeSessionNameAtom);
const [tab, setTab] = useState<StorageTab>("cookies");
const [cookies, setCookies] = useState<CookieEntry[]>([]);
const [local, setLocal] = useState<StorageEntry[]>([]);
const [session, setSession] = useState<StorageEntry[]>([]);
const [loading, setLoading] = useState(false);
const [expanded, setExpanded] = useState<string | null>(null);
const lastSessionRef = useRef(sessionName);
const fetchData = useCallback(
async (which: StorageTab) => {
if (!sessionName) return;
setLoading(true);
try {
if (which === "cookies") {
const res = await execCommand(sessionArgs(sessionName, "cookies"));
if (res.success && res.stdout) {
try {
const parsed = JSON.parse(res.stdout);
setCookies(parsed.cookies ?? []);
} catch {
setCookies([]);
}
}
} else {
const storageType = which === "localStorage" ? "local" : "session";
const res = await execCommand(
sessionArgs(sessionName, "storage", storageType),
);
if (res.success && res.stdout) {
try {
const parsed = JSON.parse(res.stdout);
const entries: StorageEntry[] = [];
if (parsed.entries && typeof parsed.entries === "object") {
for (const [k, v] of Object.entries(parsed.entries)) {
entries.push({ key: k, value: String(v) });
}
} else if (typeof parsed === "object") {
for (const [k, v] of Object.entries(parsed)) {
if (k !== "length") {
entries.push({ key: k, value: String(v) });
}
}
}
if (which === "localStorage") setLocal(entries);
else setSession(entries);
} catch {
if (which === "localStorage") setLocal([]);
else setSession([]);
}
}
}
} finally {
setLoading(false);
}
},
[sessionName],
);
useEffect(() => {
if (sessionName && sessionName !== lastSessionRef.current) {
lastSessionRef.current = sessionName;
setCookies([]);
setLocal([]);
setSession([]);
}
fetchData(tab);
}, [tab, sessionName, fetchData]);
const handleRefresh = () => fetchData(tab);
return (
<div className="flex h-full flex-col">
<div className="flex shrink-0 items-center gap-1.5 px-3 py-2">
{TABS.map((t) => (
<button
key={t.key}
type="button"
onClick={() => {
setTab(t.key);
setExpanded(null);
}}
className={cn(
"rounded px-1.5 py-0.5 text-[10px] transition-colors",
tab === t.key
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{t.label}
</button>
))}
<button
type="button"
onClick={handleRefresh}
disabled={loading || !sessionName}
className="ml-auto flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:text-foreground disabled:opacity-40"
title="Refresh"
>
{loading ? (
<Loader2 className="size-3 animate-spin" />
) : (
<RefreshCw className="size-3" />
)}
</button>
</div>
<Separator />
<div className="min-h-0 flex-1 overflow-y-auto font-mono">
{!sessionName ? (
<div className="py-8 text-center text-xs text-muted-foreground">
No active session
</div>
) : tab === "cookies" ? (
<CookiesView
cookies={cookies}
loading={loading}
expanded={expanded}
onExpand={setExpanded}
/>
) : (
<KeyValueView
entries={tab === "localStorage" ? local : session}
loading={loading}
expanded={expanded}
onExpand={setExpanded}
/>
)}
</div>
</div>
);
}
function CookiesView({
cookies,
loading,
expanded,
onExpand,
}: {
cookies: CookieEntry[];
loading: boolean;
expanded: string | null;
onExpand: (key: string | null) => void;
}) {
if (loading && cookies.length === 0) {
return (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
);
}
if (cookies.length === 0) {
return (
<div className="py-8 text-center text-xs text-muted-foreground">
No cookies
</div>
);
}
return (
<>
{cookies.map((c) => {
const id = `${c.domain ?? ""}::${c.name}`;
const isExpanded = expanded === id;
return (
<div key={id} className="border-b border-border/50">
<button
type="button"
onClick={() => onExpand(isExpanded ? null : id)}
className="flex w-full items-start gap-2 px-3 py-1.5 text-left text-[11px] hover:bg-muted/50"
>
<span className="shrink-0 font-semibold text-foreground">
{c.name}
</span>
<span className="min-w-0 flex-1 truncate text-muted-foreground">
{truncate(c.value, 60)}
</span>
</button>
{isExpanded && (
<div className="space-y-0.5 bg-muted/30 px-3 py-1.5 text-[10px]">
<DetailRow label="Value" value={c.value} wrap />
{c.domain && <DetailRow label="Domain" value={c.domain} />}
{c.path && <DetailRow label="Path" value={c.path} />}
<DetailRow label="Expires" value={formatExpiry(c.expires)} />
<DetailRow
label="Flags"
value={[
c.httpOnly && "HttpOnly",
c.secure && "Secure",
c.sameSite && `SameSite=${c.sameSite}`,
]
.filter(Boolean)
.join(", ") || "None"}
/>
</div>
)}
</div>
);
})}
</>
);
}
function KeyValueView({
entries,
loading,
expanded,
onExpand,
}: {
entries: StorageEntry[];
loading: boolean;
expanded: string | null;
onExpand: (key: string | null) => void;
}) {
if (loading && entries.length === 0) {
return (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
);
}
if (entries.length === 0) {
return (
<div className="py-8 text-center text-xs text-muted-foreground">
No entries
</div>
);
}
return (
<>
{entries.map((e) => {
const isExpanded = expanded === e.key;
return (
<div key={e.key} className="border-b border-border/50">
<button
type="button"
onClick={() => onExpand(isExpanded ? null : e.key)}
className="flex w-full items-start gap-2 px-3 py-1.5 text-left text-[11px] hover:bg-muted/50"
>
<span className="shrink-0 font-semibold text-foreground">
{e.key}
</span>
<span className="min-w-0 flex-1 truncate text-muted-foreground">
{truncate(e.value, 80)}
</span>
</button>
{isExpanded && (
<div className="bg-muted/30 px-3 py-1.5 text-[10px]">
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-all text-foreground">
{formatValue(e.value)}
</pre>
</div>
)}
</div>
);
})}
</>
);
}
function DetailRow({
label,
value,
wrap,
}: {
label: string;
value: string;
wrap?: boolean;
}) {
return (
<div className="flex gap-2">
<span className="w-14 shrink-0 text-muted-foreground">{label}</span>
<span
className={cn(
"min-w-0 flex-1 text-foreground",
wrap ? "break-all whitespace-pre-wrap" : "truncate",
)}
>
{value}
</span>
</div>
);
}
function formatValue(raw: string): string {
try {
return JSON.stringify(JSON.parse(raw), null, 2);
} catch {
return raw;
}
}
@@ -0,0 +1,49 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
@@ -0,0 +1,67 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
@@ -0,0 +1,33 @@
"use client"
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
@@ -0,0 +1,263 @@
"use client"
import * as React from "react"
import { ContextMenu as ContextMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuTrigger({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger
data-slot="context-menu-trigger"
className={cn("select-none", className)}
{...props}
/>
)
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
)
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
)
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
)
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn("z-50 max-h-(--radix-context-menu-content-available-height) min-w-36 origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/context-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus:*:[svg]:text-accent-foreground data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
)
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn("z-50 min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-lg border bg-popover p-1 text-popover-foreground shadow-lg duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
)
}
function ContextMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute right-2">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
}
function ContextMenuRadioItem({
className,
children,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute right-2">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/context-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}
@@ -0,0 +1,168 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
>
<XIcon
/>
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
@@ -0,0 +1,269 @@
"use client"
import * as React from "react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { CheckIcon, ChevronRightIcon } from "lucide-react"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
align = "start",
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
align={align}
className={cn("z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn("z-50 min-w-[96px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
@@ -0,0 +1,50 @@
"use client"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
function ResizablePanelGroup({
className,
...props
}: ResizablePrimitive.GroupProps) {
return (
<ResizablePrimitive.Group
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full aria-[orientation=vertical]:flex-col",
className
)}
{...props}
/>
)
}
function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
}
function ResizableHandle({
withHandle,
className,
...props
}: ResizablePrimitive.SeparatorProps & {
withHandle?: boolean
}) {
return (
<ResizablePrimitive.Separator
data-slot="resizable-handle"
className={cn(
"relative flex w-px items-center justify-center bg-border ring-offset-background after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-6 w-1 shrink-0 rounded-lg bg-border" />
)}
</ResizablePrimitive.Separator>
)
}
export { ResizableHandle, ResizablePanel, ResizablePanelGroup }
@@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 [&>div]:!block"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }
@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }
@@ -0,0 +1,90 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Tabs as TabsPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
@@ -0,0 +1,57 @@
"use client"
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
@@ -0,0 +1,724 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useAtomValue, useSetAtom } from "jotai/react";
import { ArrowLeft, ArrowRight, Camera, Circle, FileCode, Maximize, Moon, RotateCw, Smartphone, Square, Sun, Wifi, WifiOff } from "lucide-react";
import { cn } from "@/lib/utils";
import { execCommand, sessionArgs } from "@/lib/exec";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import {
currentFrameAtom,
viewportWidthAtom,
viewportHeightAtom,
browserConnectedAtom,
screencastingAtom,
recordingAtom,
streamEngineAtom,
activeUrlAtom,
sendInputAtom,
} from "@/store/stream";
import { activeSessionNameAtom, activePortAtom } from "@/store/sessions";
const SCREENCAST_ENGINES = new Set(["chrome"]);
function cdpModifiers(e: React.MouseEvent | React.WheelEvent): number {
let m = 0;
if (e.altKey) m |= 1;
if (e.ctrlKey) m |= 2;
if (e.metaKey) m |= 4;
if (e.shiftKey) m |= 8;
return m;
}
const KEY_INFO: Record<string, { text?: string; keyCode: number }> = {
Enter: { text: "\r", keyCode: 13 },
Tab: { text: "\t", keyCode: 9 },
Backspace: { text: "\b", keyCode: 8 },
Escape: { keyCode: 27 },
ArrowLeft: { keyCode: 37 },
ArrowUp: { keyCode: 38 },
ArrowRight: { keyCode: 39 },
ArrowDown: { keyCode: 40 },
Delete: { keyCode: 46 },
Home: { keyCode: 36 },
End: { keyCode: 35 },
PageUp: { keyCode: 33 },
PageDown: { keyCode: 34 },
};
function cdpButton(btn: number): string {
switch (btn) {
case 0: return "left";
case 1: return "middle";
case 2: return "right";
default: return "none";
}
}
const DIMENSION_PRESETS: { label: string; ratio?: [number, number] }[] = [
{ label: "1:1", ratio: [1, 1] },
{ label: "4:3", ratio: [4, 3] },
{ label: "16:9", ratio: [16, 9] },
{ label: "9:16", ratio: [9, 16] },
{ label: "21:9", ratio: [21, 9] },
];
const DEVICE_PRESETS = [
{ label: "iPhone 15", value: "iPhone 15" },
{ label: "iPhone 16", value: "iPhone 16" },
{ label: "iPhone 16 Pro", value: "iPhone 16 Pro" },
{ label: "iPhone 17", value: "iPhone 17" },
{ label: "iPad", value: "iPad" },
{ label: "iPad Pro", value: "iPad Pro" },
{ label: "Pixel 9", value: "Pixel 9" },
{ label: "Galaxy S25", value: "Galaxy S25" },
];
type ColorScheme = "light" | "dark" | "no-preference";
function computePresetSize(
ratio: [number, number],
availableWidth: number,
availableHeight: number,
): { w: number; h: number } {
const [rw, rh] = ratio;
let w = availableWidth;
let h = Math.round(w * rh / rw);
if (h > availableHeight) {
h = availableHeight;
w = Math.round(h * rw / rh);
}
return { w: Math.max(w, 1), h: Math.max(h, 1) };
}
function normalizeUrl(input: string): string {
const trimmed = input.trim();
if (/^https?:\/\//i.test(trimmed)) return trimmed;
return `https://${trimmed}`;
}
export function Viewport() {
const frame = useAtomValue(currentFrameAtom);
const viewportWidth = useAtomValue(viewportWidthAtom);
const viewportHeight = useAtomValue(viewportHeightAtom);
const browserConnected = useAtomValue(browserConnectedAtom);
const screencasting = useAtomValue(screencastingAtom);
const recording = useAtomValue(recordingAtom);
const engine = useAtomValue(streamEngineAtom);
const url = useAtomValue(activeUrlAtom);
const sessionName = useAtomValue(activeSessionNameAtom);
const streamPort = useAtomValue(activePortAtom);
const sendInput = useSetAtom(sendInputAtom);
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const canvasAreaRef = useRef<HTMLDivElement>(null);
const addressRef = useRef<HTMLInputElement>(null);
const [addressValue, setAddressValue] = useState(url);
const [navigating, setNavigating] = useState(false);
const [canvasArea, setCanvasArea] = useState({ width: 0, height: 0 });
const [customDialogOpen, setCustomDialogOpen] = useState(false);
const [customValue, setCustomValue] = useState("");
const customInputRef = useRef<HTMLInputElement>(null);
const [recordDialogOpen, setRecordDialogOpen] = useState(false);
const [recordPath, setRecordPath] = useState("recording.webm");
const recordInputRef = useRef<HTMLInputElement>(null);
const [activeDevice, setActiveDevice] = useState<string | null>(null);
const [colorScheme, setColorScheme] = useState<ColorScheme>("no-preference");
const [offline, setOffline] = useState(false);
useEffect(() => {
if (customDialogOpen) {
requestAnimationFrame(() => customInputRef.current?.select());
}
}, [customDialogOpen]);
useEffect(() => {
if (recordDialogOpen) {
requestAnimationFrame(() => recordInputRef.current?.select());
}
}, [recordDialogOpen]);
useEffect(() => {
setAddressValue(url);
}, [url]);
useEffect(() => {
setActiveDevice(null);
setColorScheme("no-preference");
setOffline(false);
}, [sessionName]);
useEffect(() => {
const el = canvasAreaRef.current;
if (!el) return;
const ro = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect;
setCanvasArea({ width: Math.floor(width), height: Math.floor(height) });
});
ro.observe(el);
return () => ro.disconnect();
}, []);
const runCmd = useCallback(
(...args: string[]) => execCommand(sessionArgs(sessionName, ...args)),
[sessionName],
);
const handleNavigate = useCallback(async () => {
if (!addressValue.trim() || navigating) return;
addressRef.current?.blur();
const target = normalizeUrl(addressValue);
setAddressValue(target);
setNavigating(true);
try {
await runCmd("navigate", target);
} finally {
setNavigating(false);
}
}, [addressValue, navigating, runCmd]);
const drawFrame = useCallback((base64: string) => {
const canvas = canvasRef.current;
if (!canvas) return;
const bin = atob(base64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
createImageBitmap(new Blob([bytes], { type: "image/jpeg" })).then((bmp) => {
canvas.width = bmp.width;
canvas.height = bmp.height;
const ctx = canvas.getContext("2d");
if (ctx) ctx.drawImage(bmp, 0, 0);
bmp.close();
});
}, []);
useEffect(() => {
if (frame) {
drawFrame(frame);
}
}, [frame, drawFrame]);
const isFit =
canvasArea.width > 0 &&
viewportWidth === canvasArea.width &&
viewportHeight === canvasArea.height;
const handleFit = useCallback(() => {
if (canvasArea.width <= 0) return;
const w = canvasArea.width;
const h = canvasArea.height;
if (w > 0 && h > 0) {
runCmd("set", "viewport", String(w), String(h));
}
}, [canvasArea, runCmd]);
const handlePreset = useCallback(
(ratio: [number, number]) => {
if (canvasArea.width <= 0) return;
const avail = { w: canvasArea.width, h: canvasArea.height };
const { w, h } = computePresetSize(ratio, avail.w, avail.h);
runCmd("set", "viewport", String(w), String(h));
},
[canvasArea, runCmd],
);
const submitCustomDimensions = useCallback(() => {
setCustomDialogOpen(false);
const match = customValue.trim().match(/^(\d+)\s*[x,\s]\s*(\d+)$/);
if (!match) return;
const w = parseInt(match[1], 10);
const h = parseInt(match[2], 10);
if (w > 0 && h > 0) {
runCmd("set", "viewport", String(w), String(h));
}
}, [customValue, runCmd]);
const handleRecordStart = useCallback(async () => {
const path = recordPath.trim();
if (!path) return;
setRecordDialogOpen(false);
await execCommand(sessionArgs(sessionName, "record", "start", path));
}, [recordPath, sessionName]);
const handleRecordStop = useCallback(async () => {
await execCommand(sessionArgs(sessionName, "record", "stop"));
}, [sessionName]);
const handleSetDevice = useCallback(async (device: string) => {
setActiveDevice(device);
await execCommand(sessionArgs(sessionName, "set", "device", device));
}, [sessionName]);
const handleResetDevice = useCallback(async () => {
setActiveDevice(null);
if (canvasArea.width > 0 && canvasArea.height > 0) {
await runCmd("set", "viewport", String(canvasArea.width), String(canvasArea.height));
}
}, [canvasArea.width, canvasArea.height, runCmd]);
const handleSetColorScheme = useCallback(async (scheme: ColorScheme) => {
setColorScheme(scheme);
const args = sessionArgs(sessionName, "set", "media");
if (scheme !== "no-preference") args.push(scheme);
await execCommand(args);
}, [sessionName]);
const handleToggleOffline = useCallback(async () => {
const next = !offline;
setOffline(next);
const args = sessionArgs(sessionName, "set", "offline");
if (!next) args.push("off");
await execCommand(args);
}, [offline, sessionName]);
const toViewport = useCallback(
(e: React.MouseEvent): { x: number; y: number } | null => {
const canvas = canvasRef.current;
if (!canvas) return null;
const rect = canvas.getBoundingClientRect();
const scaleX = viewportWidth / rect.width;
const scaleY = viewportHeight / rect.height;
return {
x: Math.round((e.clientX - rect.left) * scaleX),
y: Math.round((e.clientY - rect.top) * scaleY),
};
},
[viewportWidth, viewportHeight],
);
const handleMouseEvent = useCallback(
(e: React.MouseEvent, eventType: string) => {
const pos = toViewport(e);
if (!pos) return;
sendInput({
type: "input_mouse",
eventType,
x: pos.x,
y: pos.y,
button: cdpButton(e.button),
clickCount: eventType === "mousePressed" ? 1 : 0,
modifiers: cdpModifiers(e),
});
},
[toViewport, sendInput],
);
const handleWheel = useCallback(
(e: React.WheelEvent) => {
const pos = toViewport(e);
if (!pos) return;
sendInput({
type: "input_mouse",
eventType: "mouseWheel",
x: pos.x,
y: pos.y,
button: "none",
clickCount: 0,
deltaX: e.deltaX,
deltaY: e.deltaY,
modifiers: cdpModifiers(e),
});
},
[toViewport, sendInput],
);
const dispatchKey = useCallback(
(e: KeyboardEvent, eventType: string) => {
const info = KEY_INFO[e.key];
const text = eventType === "keyDown"
? (info?.text ?? (e.key.length === 1 ? e.key : undefined))
: undefined;
const keyCode = info?.keyCode ?? (e.key.length === 1 ? e.key.charCodeAt(0) : 0);
let m = 0;
if (e.altKey) m |= 1;
if (e.ctrlKey) m |= 2;
if (e.metaKey) m |= 4;
if (e.shiftKey) m |= 8;
sendInput({
type: "input_keyboard",
eventType,
key: e.key,
code: e.code,
text,
windowsVirtualKeyCode: keyCode,
modifiers: m,
});
},
[sendInput],
);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (document.activeElement !== canvasRef.current) return;
e.preventDefault();
e.stopPropagation();
dispatchKey(e, e.type === "keydown" ? "keyDown" : "keyUp");
};
window.addEventListener("keydown", handler, true);
window.addEventListener("keyup", handler, true);
return () => {
window.removeEventListener("keydown", handler, true);
window.removeEventListener("keyup", handler, true);
};
}, [dispatchKey]);
return (
<div ref={containerRef} className="flex h-full flex-col">
{browserConnected && (
<>
<div className="flex shrink-0 items-center gap-1.5 px-2 py-1.5">
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => runCmd("back")}
className="shrink-0 rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<ArrowLeft className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom"><p>Back</p></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => runCmd("forward")}
className="shrink-0 rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<ArrowRight className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom"><p>Forward</p></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => runCmd("reload")}
className="shrink-0 rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<RotateCw className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom"><p>Refresh</p></TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="flex min-w-0 flex-1 items-center rounded-md bg-muted px-2.5 py-1">
<input
ref={addressRef}
type="text"
value={addressValue}
onChange={(e) => setAddressValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleNavigate();
}
}}
className={cn(
"w-full bg-transparent font-mono text-xs text-muted-foreground outline-none placeholder:text-muted-foreground/50",
navigating && "opacity-50",
)}
placeholder="Enter URL..."
spellCheck={false}
/>
</div>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => runCmd("snapshot")}
className="shrink-0 rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<FileCode className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom"><p>Snapshot</p></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => runCmd("screenshot")}
className="shrink-0 rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<Camera className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom"><p>Screenshot</p></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={recording ? handleRecordStop : () => setRecordDialogOpen(true)}
className={cn(
"shrink-0 rounded p-1 transition-colors",
recording
? "text-destructive hover:bg-destructive/10"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
{recording ? <Square className="size-3.5" /> : <Circle className="size-4" />}
</button>
</TooltipTrigger>
<TooltipContent side="bottom"><p>{recording ? "Stop recording" : "Record"}</p></TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Separator />
</>
)}
<div ref={canvasAreaRef} className="flex min-h-0 flex-1 items-center justify-center">
{frame ? (
<canvas
ref={canvasRef}
tabIndex={0}
className="max-h-full max-w-full object-contain outline-none"
onMouseMove={(e) => handleMouseEvent(e, "mouseMoved")}
onMouseDown={(e) => {
canvasRef.current?.focus();
handleMouseEvent(e, "mousePressed");
}}
onMouseUp={(e) => handleMouseEvent(e, "mouseReleased")}
onWheel={handleWheel}
onContextMenu={(e) => e.preventDefault()}
/>
) : (
<div className="text-center text-sm text-muted-foreground">
{browserConnected
? SCREENCAST_ENGINES.has(engine)
? "Waiting for frames..."
: `Screencast not available for ${engine}`
: "No browser connected"}
</div>
)}
</div>
<Separator />
<div className="flex shrink-0 items-center gap-2 px-3 py-2">
<div
className={cn(
"size-2 rounded-full",
browserConnected ? "bg-success" : "bg-destructive",
)}
/>
<span className="text-xs text-muted-foreground">
{browserConnected
? screencasting
? "Live"
: "Connected"
: "Disconnected"}
</span>
{browserConnected && (
<span className="text-xs text-muted-foreground/60 font-mono">
ws://localhost:{streamPort}
</span>
)}
<div className="ml-auto flex items-center gap-2">
{browserConnected && (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Badge
variant="outline"
className="flex h-4 cursor-pointer items-center gap-1 px-1.5 text-[10px] hover:bg-muted"
>
{colorScheme === "dark" ? (
<Moon className="size-2.5" />
) : (
<Sun className="size-2.5" />
)}
{colorScheme === "dark" ? "Dark" : colorScheme === "light" ? "Light" : "System"}
</Badge>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="top">
<DropdownMenuLabel className="text-xs">Color Scheme</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => handleSetColorScheme("no-preference")}
className={cn("text-xs", colorScheme === "no-preference" && "font-semibold")}
>
System
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleSetColorScheme("light")}
className={cn("text-xs", colorScheme === "light" && "font-semibold")}
>
<Sun className="mr-1.5 size-3" />
Light
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleSetColorScheme("dark")}
className={cn("text-xs", colorScheme === "dark" && "font-semibold")}
>
<Moon className="mr-1.5 size-3" />
Dark
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Badge
variant="outline"
onClick={handleToggleOffline}
className={cn(
"flex h-4 cursor-pointer items-center gap-1 px-1.5 text-[10px] hover:bg-muted",
offline && "border-destructive/50 bg-destructive/10 text-destructive hover:bg-destructive/20",
)}
>
{offline ? <WifiOff className="size-2.5" /> : <Wifi className="size-2.5" />}
{offline ? "Offline" : "Online"}
</Badge>
</>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Badge
variant="outline"
className="flex h-4 cursor-pointer items-center gap-1 px-1.5 text-[10px] tabular-nums hover:bg-muted"
>
{activeDevice && <Smartphone className="size-2.5" />}
{activeDevice ?? `${viewportWidth} x ${viewportHeight}`}
</Badge>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="top">
<DropdownMenuLabel className="text-xs">Aspect Ratio</DropdownMenuLabel>
{DIMENSION_PRESETS.map((p) => (
<DropdownMenuItem
key={p.label}
onClick={() => handlePreset(p.ratio!)}
className="text-xs"
>
{p.label}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs">Devices</DropdownMenuLabel>
{DEVICE_PRESETS.map((d) => (
<DropdownMenuItem
key={d.value}
onClick={() => handleSetDevice(d.value)}
className={cn("text-xs", activeDevice === d.value && "font-semibold")}
>
<Smartphone className="mr-1.5 size-3" />
{d.label}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{!isFit && (
<DropdownMenuItem onClick={handleFit} className="text-xs">
<Maximize className="mr-1.5 size-3" />
Fit
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => {
setCustomValue(`${viewportWidth} x ${viewportHeight}`);
setCustomDialogOpen(true);
}}
className="text-xs"
>
Custom...
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<Dialog open={customDialogOpen} onOpenChange={setCustomDialogOpen}>
<DialogContent className="max-w-xs">
<DialogHeader>
<DialogTitle>Custom Dimensions</DialogTitle>
</DialogHeader>
<input
ref={customInputRef}
type="text"
value={customValue}
onChange={(e) => setCustomValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
submitCustomDimensions();
}
}}
placeholder="1280 x 720"
className="h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm outline-none focus:ring-1 focus:ring-ring"
/>
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => setCustomDialogOpen(false)}>
Cancel
</Button>
<Button size="sm" onClick={submitCustomDimensions}>
Apply
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={recordDialogOpen} onOpenChange={setRecordDialogOpen}>
<DialogContent className="max-w-xs">
<DialogHeader>
<DialogTitle>Start Recording</DialogTitle>
</DialogHeader>
<input
ref={recordInputRef}
type="text"
value={recordPath}
onChange={(e) => setRecordPath(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleRecordStart();
}
}}
placeholder="recording.webm"
className="h-9 w-full rounded-md border border-input bg-transparent px-3 font-mono text-sm outline-none focus:ring-1 focus:ring-ring"
/>
<p className="text-xs text-muted-foreground">
Output path for the WebM video file.
</p>
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => setRecordDialogOpen(false)}>
Cancel
</Button>
<Button size="sm" onClick={handleRecordStart} disabled={!recordPath.trim()}>
Record
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,17 @@
"use client";
import { useEffect, useState } from "react";
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(false);
useEffect(() => {
const mql = window.matchMedia(query);
setMatches(mql.matches);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, [query]);
return matches;
}
+30
View File
@@ -0,0 +1,30 @@
const DASHBOARD_PORT = 4848;
export interface ExecResult {
success: boolean;
exit_code: number | null;
stdout: string;
stderr: string;
}
export async function execCommand(args: string[]): Promise<ExecResult> {
const resp = await fetch(`http://localhost:${DASHBOARD_PORT}/api/exec`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ args }),
});
return resp.json();
}
export function sessionArgs(session: string, ...args: string[]): string[] {
return ["--session", session, ...args];
}
export async function killSession(session: string): Promise<{ success: boolean; killed_pid?: number }> {
const resp = await fetch(`http://localhost:${DASHBOARD_PORT}/api/kill`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session }),
});
return resp.json();
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+120
View File
@@ -0,0 +1,120 @@
"use client";
import { atom } from "jotai";
import { useEffect } from "react";
import { useAtomValue, useSetAtom } from "jotai/react";
import type { ActivityEvent } from "@/types";
import { streamEventsAtom } from "@/store/stream";
import { activeSessionNameAtom } from "@/store/sessions";
const PERSIST_KEY = "ab-persist-activity";
const MAX_PERSISTED = 500;
function activityStorageKey(session: string) {
return `ab-activity-${session}`;
}
function loadPersistedEvents(session: string): ActivityEvent[] {
try {
const raw = localStorage.getItem(activityStorageKey(session));
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function savePersistedEvents(session: string, events: ActivityEvent[]) {
try {
const capped = events.slice(-MAX_PERSISTED);
localStorage.setItem(activityStorageKey(session), JSON.stringify(capped));
} catch {
// Storage full or unavailable
}
}
function clearPersistedEvents(session: string) {
try {
localStorage.removeItem(activityStorageKey(session));
} catch {
// Ignore
}
}
// ---------------------------------------------------------------------------
// Primitive atoms
// ---------------------------------------------------------------------------
export const persistActivityAtom = atom(
typeof window !== "undefined"
? localStorage.getItem(PERSIST_KEY) === "true"
: false,
);
export const restoredEventsAtom = atom<ActivityEvent[]>([]);
// ---------------------------------------------------------------------------
// Derived atoms
// ---------------------------------------------------------------------------
export const combinedEventsAtom = atom((get) => {
const persist = get(persistActivityAtom);
const restored = get(restoredEventsAtom);
const streamEvents = get(streamEventsAtom);
if (persist && restored.length > 0) {
return [...restored, ...streamEvents].slice(-MAX_PERSISTED);
}
return streamEvents;
});
// ---------------------------------------------------------------------------
// Action atoms
// ---------------------------------------------------------------------------
export const togglePersistAtom = atom(null, (get, set) => {
const next = !get(persistActivityAtom);
set(persistActivityAtom, next);
localStorage.setItem(PERSIST_KEY, String(next));
if (!next) {
const session = get(activeSessionNameAtom);
if (session) clearPersistedEvents(session);
set(restoredEventsAtom, []);
}
});
export const clearActivityAtom = atom(null, (get, set) => {
set(streamEventsAtom, []);
set(restoredEventsAtom, []);
const session = get(activeSessionNameAtom);
if (session) clearPersistedEvents(session);
});
// ---------------------------------------------------------------------------
// Sync hook -- call once to keep localStorage in sync with atoms
// ---------------------------------------------------------------------------
export function useActivitySync() {
const persist = useAtomValue(persistActivityAtom);
const session = useAtomValue(activeSessionNameAtom);
const combinedEvents = useAtomValue(combinedEventsAtom);
const setRestored = useSetAtom(restoredEventsAtom);
// Load persisted events when session changes
useEffect(() => {
if (persist && session) {
setRestored(loadPersistedEvents(session));
} else {
setRestored([]);
}
}, [persist, session, setRestored]);
// Save combined events to localStorage when persist is on
useEffect(() => {
if (persist && session && combinedEvents.length > 0) {
savePersistedEvents(session, combinedEvents);
}
}, [persist, session, combinedEvents]);
}
@@ -0,0 +1,7 @@
"use client";
import { Provider } from "jotai";
export function JotaiProvider({ children }: { children: React.ReactNode }) {
return <Provider>{children}</Provider>;
}
+258
View File
@@ -0,0 +1,258 @@
"use client";
import { atom } from "jotai";
import { useCallback, useEffect, useRef } from "react";
import { useAtomCallback } from "jotai/utils";
import type { SessionInfo } from "@/types";
import { execCommand, killSession, sessionArgs } from "@/lib/exec";
import { tabCacheAtom, engineCacheAtom } from "@/store/tabs";
import { streamTabsAtom, streamEngineAtom } from "@/store/stream";
function getPort(): number {
if (typeof window === "undefined") return 9223;
const params = new URLSearchParams(window.location.search);
const p = params.get("port");
return p ? parseInt(p, 10) || 9223 : 9223;
}
const DASHBOARD_PORT = 4848;
function getSessionsUrl(): string {
if (typeof window !== "undefined") {
const origin = window.location.origin;
if (origin.includes(`:${DASHBOARD_PORT}`)) {
return "/api/sessions";
}
}
return `http://localhost:${DASHBOARD_PORT}/api/sessions`;
}
// ---------------------------------------------------------------------------
// Primitive atoms
// ---------------------------------------------------------------------------
export const activePortAtom = atom(getPort());
export const polledSessionsAtom = atom<SessionInfo[]>([]);
export const pendingSessionsAtom = atom<{ session: string; engine: string }[]>(
[],
);
export const closingSessionsAtom = atom<Set<string>>(new Set<string>());
// ---------------------------------------------------------------------------
// Derived atoms
// ---------------------------------------------------------------------------
export const sessionsAtom = atom((get) => {
const polled = get(polledSessionsAtom);
const pending = get(pendingSessionsAtom);
const closing = get(closingSessionsAtom);
const polledNames = new Set(polled.map((s) => s.session));
const pendingEntries = pending
.filter((p) => !polledNames.has(p.session))
.map((p) => ({
session: p.session,
port: 0,
engine: p.engine,
pending: true as const,
}));
const merged = polled.map((s) =>
closing.has(s.session) ? { ...s, closing: true as const } : s,
);
return [...merged, ...pendingEntries];
});
export const activeSessionInfoAtom = atom((get) => {
const sessions = get(sessionsAtom);
const port = get(activePortAtom);
return sessions.find((s) => s.port === port);
});
export const activeSessionNameAtom = atom(
(get) => get(activeSessionInfoAtom)?.session ?? "",
);
export const activeExtensionsAtom = atom((get) => {
const info = get(activeSessionInfoAtom);
return (
(info && "extensions" in info ? info.extensions : undefined) ?? []
);
});
// ---------------------------------------------------------------------------
// Action atoms
// ---------------------------------------------------------------------------
export const createSessionAtom = atom(
null,
(
_get,
set,
{ name, engine }: { name: string; engine: string },
) => {
set(pendingSessionsAtom, (prev) => [...prev, { session: name, engine }]);
execCommand(["--session", name, "--engine", engine, "open", "about:blank"]);
},
);
export const closeSessionAtom = atom(null, (get, set, port: number) => {
const sessions = get(sessionsAtom);
const s = sessions.find((x) => x.port === port)?.session;
if (s) {
set(closingSessionsAtom, (prev) => new Set(prev).add(s));
execCommand(sessionArgs(s, "close"));
}
});
export const killSessionAtom = atom(null, (get, set, port: number) => {
const sessions = get(sessionsAtom);
const s = sessions.find((x) => x.port === port)?.session;
if (s) {
set(closingSessionsAtom, (prev) => new Set(prev).add(s));
killSession(s);
}
});
export const closeAllSessionsAtom = atom(null, (get, set) => {
const sessions = get(sessionsAtom);
for (const s of sessions) {
if (!s.pending && !s.closing) {
set(closingSessionsAtom, (prev) => new Set(prev).add(s.session));
execCommand(sessionArgs(s.session, "close"));
}
}
});
export const closeTabAtom = atom(
null,
(get, _set, { port, tabIndex }: { port: number; tabIndex: number }) => {
const sessions = get(sessionsAtom);
const s = sessions.find((x) => x.port === port)?.session;
if (s) execCommand(sessionArgs(s, "tab", "close", String(tabIndex)));
},
);
export const addTabAtom = atom(null, (get, _set, port: number) => {
const sessions = get(sessionsAtom);
const s = sessions.find((x) => x.port === port)?.session;
if (s) execCommand(sessionArgs(s, "tab", "new"));
});
export const switchTabAtom = atom(
null,
(get, _set, { port, tabIndex }: { port: number; tabIndex: number }) => {
const sessions = get(sessionsAtom);
const s = sessions.find((x) => x.port === port)?.session;
if (s) execCommand(sessionArgs(s, "tab", String(tabIndex)));
},
);
/** Prune pending/closing once the polled list confirms them */
const reconcileSessionsAtom = atom(
null,
(get, set) => {
const polled = get(polledSessionsAtom);
const polledNames = new Set(polled.map((s) => s.session));
set(pendingSessionsAtom, (prev) => {
const next = prev.filter((p) => !polledNames.has(p.session));
return next.length === prev.length ? prev : next;
});
set(closingSessionsAtom, (prev) => {
const next = new Set(prev);
for (const name of prev) {
if (!polledNames.has(name)) next.delete(name);
}
return next.size === prev.size ? prev : next;
});
},
);
// ---------------------------------------------------------------------------
// Sync hook
// ---------------------------------------------------------------------------
export function useSessionsSync(pollInterval = 5000) {
const failCountRef = useRef(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const reconcile = useAtomCallback(
useCallback((_get, set) => {
set(reconcileSessionsAtom);
}, []),
);
const fetchSessions = useAtomCallback(
useCallback(
async (get, set) => {
try {
const resp = await fetch(getSessionsUrl());
if (resp.ok) {
failCountRef.current = 0;
const data: SessionInfo[] = await resp.json();
data.sort((a, b) => a.session.localeCompare(b.session));
set(polledSessionsAtom, data);
// Reconcile pending/closing
reconcile();
// Seed engine cache from session list
const engineCache = get(engineCacheAtom);
const nextEngine = { ...engineCache };
let engineChanged = false;
for (const s of data) {
if (s.engine && !nextEngine[s.port]) {
nextEngine[s.port] = s.engine;
engineChanged = true;
}
}
if (engineChanged) set(engineCacheAtom, nextEngine);
// Auto-select first session if current port is not in list
const activePort = get(activePortAtom);
const sessions = get(sessionsAtom);
if (sessions.length > 0 && !sessions.some((s) => s.port === activePort)) {
set(activePortAtom, sessions[0].port);
}
// Poll tabs for all sessions
for (const s of data) {
try {
const tabsResp = await fetch(
`http://localhost:${s.port}/api/tabs`,
).catch(() => null);
if (tabsResp?.ok) {
const tabs = await tabsResp.json();
if (tabs.length > 0) {
set(tabCacheAtom, (prev) => ({ ...prev, [s.port]: tabs }));
}
}
} catch {
// Session unreachable
}
}
return;
}
} catch {
// Server unreachable
}
failCountRef.current++;
if (failCountRef.current >= 2) set(polledSessionsAtom, []);
},
[reconcile],
),
);
useEffect(() => {
fetchSessions();
timerRef.current = setInterval(fetchSessions, pollInterval);
return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
}, [fetchSessions, pollInterval]);
}
+230
View File
@@ -0,0 +1,230 @@
"use client";
import { atom } from "jotai";
import { useCallback, useEffect, useRef } from "react";
import { useSetAtom } from "jotai/react";
import type {
ActivityEvent,
ConsoleEntry,
StreamMessage,
TabInfo,
} from "@/types";
import { activePortAtom } from "@/store/sessions";
import { tabCacheAtom, engineCacheAtom } from "@/store/tabs";
const MAX_EVENTS = 500;
// ---------------------------------------------------------------------------
// Primitive atoms
// ---------------------------------------------------------------------------
export const streamConnectedAtom = atom(false);
export const browserConnectedAtom = atom(false);
export const screencastingAtom = atom(false);
export const recordingAtom = atom(false);
export const viewportWidthAtom = atom(1280);
export const viewportHeightAtom = atom(720);
export const currentFrameAtom = atom<string | null>(null);
export const streamEventsAtom = atom<ActivityEvent[]>([]);
export const consoleLogsAtom = atom<ConsoleEntry[]>([]);
export const streamTabsAtom = atom<TabInfo[]>([]);
export const streamEngineAtom = atom("");
export const wsRefAtom = atom<WebSocket | null>(null);
// ---------------------------------------------------------------------------
// Derived atoms
// ---------------------------------------------------------------------------
export const activeUrlAtom = atom(
(get) => get(streamTabsAtom).find((t) => t.active)?.url ?? "",
);
export const hasConsoleErrorsAtom = atom((get) =>
get(consoleLogsAtom).some(
(e) =>
e.type === "page_error" ||
(e.type === "console" && e.level === "error"),
),
);
// ---------------------------------------------------------------------------
// Action atoms
// ---------------------------------------------------------------------------
export const sendInputAtom = atom(
null,
(get, _set, msg: Record<string, unknown>) => {
const ws = get(wsRefAtom);
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg));
}
},
);
export const clearEventsAtom = atom(null, (_get, set) => {
set(streamEventsAtom, []);
});
export const clearConsoleLogsAtom = atom(null, (_get, set) => {
set(consoleLogsAtom, []);
});
// ---------------------------------------------------------------------------
// Sync hook
// ---------------------------------------------------------------------------
export function useStreamSync(port: number) {
const setConnected = useSetAtom(streamConnectedAtom);
const setBrowserConnected = useSetAtom(browserConnectedAtom);
const setScreencasting = useSetAtom(screencastingAtom);
const setRecording = useSetAtom(recordingAtom);
const setVpWidth = useSetAtom(viewportWidthAtom);
const setVpHeight = useSetAtom(viewportHeightAtom);
const setFrame = useSetAtom(currentFrameAtom);
const setEvents = useSetAtom(streamEventsAtom);
const setConsoleLogs = useSetAtom(consoleLogsAtom);
const setTabs = useSetAtom(streamTabsAtom);
const setEngine = useSetAtom(streamEngineAtom);
const setWsRef = useSetAtom(wsRefAtom);
const setTabCache = useSetAtom(tabCacheAtom);
const setEngineCache = useSetAtom(engineCacheAtom);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const retryCountRef = useRef(0);
const eventsRef = useRef<ActivityEvent[]>([]);
const consoleRef = useRef<ConsoleEntry[]>([]);
const portRef = useRef(port);
// Reset all stream state when port changes
useEffect(() => {
if (portRef.current !== port) {
portRef.current = port;
eventsRef.current = [];
consoleRef.current = [];
setConnected(false);
setBrowserConnected(false);
setScreencasting(false);
setRecording(false);
setVpWidth(1280);
setVpHeight(720);
setFrame(null);
setEvents([]);
setConsoleLogs([]);
setTabs([]);
setEngine("");
}
}, [port, setConnected, setBrowserConnected, setScreencasting, setRecording, setVpWidth, setVpHeight, setFrame, setEvents, setConsoleLogs, setTabs, setEngine]);
const connect = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) return;
const ws = new WebSocket(`ws://localhost:${port}`);
wsRef.current = ws;
setWsRef(ws);
ws.onopen = () => {
retryCountRef.current = 0;
setConnected(true);
};
ws.onclose = () => {
setConnected(false);
const delay = Math.min(2000 * 2 ** retryCountRef.current, 30000);
retryCountRef.current++;
reconnectTimerRef.current = setTimeout(connect, delay);
};
ws.onerror = () => {
ws.close();
};
ws.onmessage = (event) => {
let msg: StreamMessage;
try {
msg = JSON.parse(event.data);
} catch {
return;
}
switch (msg.type) {
case "frame":
setFrame(msg.data);
break;
case "status":
setBrowserConnected(msg.connected);
setScreencasting(msg.screencasting);
if (msg.recording != null) setRecording(msg.recording);
setVpWidth(msg.viewportWidth);
setVpHeight(msg.viewportHeight);
if (msg.engine) {
setEngine(msg.engine);
setEngineCache((prev) => ({ ...prev, [port]: msg.engine! }));
}
break;
case "command": {
const updated = [...eventsRef.current, msg].slice(-MAX_EVENTS);
eventsRef.current = updated;
setEvents(updated);
break;
}
case "console": {
const conUpdated = [...consoleRef.current, msg].slice(-MAX_EVENTS);
consoleRef.current = conUpdated;
setConsoleLogs(conUpdated);
break;
}
case "page_error": {
const conUpdated = [...consoleRef.current, msg].slice(-MAX_EVENTS);
consoleRef.current = conUpdated;
setConsoleLogs(conUpdated);
break;
}
case "result": {
const cmdIdx = eventsRef.current.findIndex(
(e) => e.type === "command" && e.id === msg.id,
);
const base =
cmdIdx >= 0
? [
...eventsRef.current.slice(0, cmdIdx),
...eventsRef.current.slice(cmdIdx + 1),
]
: eventsRef.current;
const updated = [...base, msg].slice(-MAX_EVENTS);
eventsRef.current = updated;
setEvents(updated);
break;
}
case "tabs":
setTabs(msg.tabs);
setTabCache((prev) => ({ ...prev, [port]: msg.tabs }));
break;
case "url":
setTabs((prev) =>
prev.map((t) => (t.active ? { ...t, url: msg.url } : t)),
);
break;
case "error":
break;
}
};
}, [port, setWsRef, setConnected, setBrowserConnected, setScreencasting, setRecording, setVpWidth, setVpHeight, setFrame, setEvents, setConsoleLogs, setTabs, setEngine, setTabCache, setEngineCache]);
useEffect(() => {
connect();
return () => {
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
wsRef.current?.close();
setWsRef(null);
};
}, [connect, setWsRef]);
}
+38
View File
@@ -0,0 +1,38 @@
import { atom } from "jotai";
import type { TabInfo } from "@/types";
import { activePortAtom } from "@/store/sessions";
import { streamTabsAtom, streamEngineAtom } from "@/store/stream";
// ---------------------------------------------------------------------------
// Primitive atoms
// ---------------------------------------------------------------------------
export const tabCacheAtom = atom<Record<number, TabInfo[]>>({});
export const engineCacheAtom = atom<Record<number, string>>({});
// ---------------------------------------------------------------------------
// Derived atoms (used by SessionTree to get tabs/engine for any port)
// ---------------------------------------------------------------------------
export const tabsForPortAtom = atom((get) => {
const activePort = get(activePortAtom);
const streamTabs = get(streamTabsAtom);
const cache = get(tabCacheAtom);
return (port: number): TabInfo[] => {
if (port === activePort && streamTabs.length > 0) return streamTabs;
return cache[port] ?? [];
};
});
export const engineForPortAtom = atom((get) => {
const activePort = get(activePortAtom);
const streamEngine = get(streamEngineAtom);
const cache = get(engineCacheAtom);
return (port: number): string => {
if (cache[port]) return cache[port];
if (port === activePort && streamEngine) return streamEngine;
return "";
};
});
+111
View File
@@ -0,0 +1,111 @@
export interface FrameMessage {
type: "frame";
data: string;
metadata: {
offsetTop: number;
pageScaleFactor: number;
deviceWidth: number;
deviceHeight: number;
scrollOffsetX: number;
scrollOffsetY: number;
timestamp: number;
};
}
export interface StatusMessage {
type: "status";
connected: boolean;
screencasting: boolean;
viewportWidth: number;
viewportHeight: number;
engine?: string;
recording?: boolean;
}
export interface CommandMessage {
type: "command";
action: string;
id: string;
params: Record<string, unknown>;
timestamp: number;
}
export interface ResultMessage {
type: "result";
id: string;
action: string;
success: boolean;
data: unknown;
duration_ms: number;
timestamp: number;
}
export interface ConsoleMessage {
type: "console";
level: string;
text: string;
timestamp: number;
}
export interface UrlMessage {
type: "url";
url: string;
timestamp: number;
}
export interface PageErrorMessage {
type: "page_error";
text: string;
line: number | null;
column: number | null;
timestamp: number;
}
export interface ErrorMessage {
type: "error";
message: string;
}
export interface TabInfo {
index: number;
title: string;
url: string;
type: string;
active: boolean;
}
export interface TabsMessage {
type: "tabs";
tabs: TabInfo[];
timestamp: number;
}
export type StreamMessage =
| FrameMessage
| StatusMessage
| CommandMessage
| ResultMessage
| ConsoleMessage
| PageErrorMessage
| ErrorMessage
| UrlMessage
| TabsMessage;
export type ActivityEvent = CommandMessage | ResultMessage | ConsoleMessage;
export type ConsoleEntry = ConsoleMessage | PageErrorMessage;
export interface ExtensionInfo {
name: string;
version: string;
description?: string;
path: string;
}
export interface SessionInfo {
session: string;
port: number;
engine?: string;
extensions?: ExtensionInfo[];
pending?: boolean;
closing?: boolean;
}
+41
View File
@@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}