dashboard (#1034)
* 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:
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
@@ -260,6 +260,26 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
echo "Found $BINARY_COUNT binaries"
|
echo "Found $BINARY_COUNT binaries"
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 9
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build dashboard
|
||||||
|
run: pnpm --filter dashboard build
|
||||||
|
|
||||||
|
- name: Create dashboard.zip
|
||||||
|
run: cd packages/dashboard/out && zip -r ../../../dashboard.zip .
|
||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Create GitHub Release
|
||||||
run: |
|
run: |
|
||||||
VERSION=$(node -p "require('./package.json').version")
|
VERSION=$(node -p "require('./package.json').version")
|
||||||
@@ -267,14 +287,14 @@ jobs:
|
|||||||
|
|
||||||
# Check if release already exists
|
# Check if release already exists
|
||||||
if gh release view "$TAG" &>/dev/null; then
|
if gh release view "$TAG" &>/dev/null; then
|
||||||
echo "Release $TAG already exists, uploading binaries..."
|
echo "Release $TAG already exists, uploading assets..."
|
||||||
gh release upload "$TAG" bin/agent-browser-* --clobber
|
gh release upload "$TAG" bin/agent-browser-* dashboard.zip --clobber
|
||||||
else
|
else
|
||||||
echo "Creating release $TAG..."
|
echo "Creating release $TAG..."
|
||||||
gh release create "$TAG" \
|
gh release create "$TAG" \
|
||||||
--title "$TAG" \
|
--title "$TAG" \
|
||||||
--generate-notes \
|
--generate-notes \
|
||||||
bin/agent-browser-*
|
bin/agent-browser-* dashboard.zip
|
||||||
fi
|
fi
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -57,3 +57,7 @@ docs/package-lock.json
|
|||||||
|
|
||||||
# pnpm
|
# pnpm
|
||||||
.pnpm-store/
|
.pnpm-store/
|
||||||
|
|
||||||
|
# next
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ This applies to changes that either human users or AI agents would need to know
|
|||||||
|
|
||||||
In the `docs/src/app/` MDX files, always use HTML `<table>` syntax for tables (not markdown pipe tables). This matches the existing convention across the docs site.
|
In the `docs/src/app/` MDX files, always use HTML `<table>` syntax for tables (not markdown pipe tables). This matches the existing convention across the docs site.
|
||||||
|
|
||||||
|
## Dashboard (packages/dashboard)
|
||||||
|
|
||||||
|
- Never use native browser dialogs (`alert`, `confirm`, `prompt`). Use shadcn/ui components (`Dialog`, `AlertDialog`, etc.) instead.
|
||||||
|
- Use param-case (kebab-case) for all file and folder names (e.g., `session-tree.tsx`, not `SessionTree.tsx`). The `ui/` directory follows shadcn conventions which already uses param-case.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
This is a Rust codebase. The browser automation daemon lives in `cli/src/native/` (daemon, actions, browser, CDP client, snapshot, state). The `--engine` flag selects Chrome vs Lightpanda. The `install` command downloads Chrome from Chrome for Testing directly.
|
This is a Rust codebase. The browser automation daemon lives in `cli/src/native/` (daemon, actions, browser, CDP client, snapshot, state). The `--engine` flag selects Chrome vs Lightpanda. The `install` command downloads Chrome from Chrome for Testing directly.
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ agent-browser stream enable [--port <port>] # Start runtime WebSocket streaming
|
|||||||
agent-browser stream status # Show runtime streaming state and bound port
|
agent-browser stream status # Show runtime streaming state and bound port
|
||||||
agent-browser stream disable # Stop runtime WebSocket streaming
|
agent-browser stream disable # Stop runtime WebSocket streaming
|
||||||
agent-browser close # Close browser (aliases: quit, exit)
|
agent-browser close # Close browser (aliases: quit, exit)
|
||||||
|
agent-browser close --all # Close all active sessions
|
||||||
```
|
```
|
||||||
|
|
||||||
### Get Info
|
### Get Info
|
||||||
@@ -596,6 +597,32 @@ This is useful for multimodal AI models that can reason about visual layout, unl
|
|||||||
| `--config <path>` | Use a custom config file (or `AGENT_BROWSER_CONFIG` env) |
|
| `--config <path>` | Use a custom config file (or `AGENT_BROWSER_CONFIG` env) |
|
||||||
| `--debug` | Debug output |
|
| `--debug` | Debug output |
|
||||||
|
|
||||||
|
## Observability Dashboard
|
||||||
|
|
||||||
|
Monitor agent-browser sessions in real time with a local web dashboard showing a live viewport and command activity feed.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install the dashboard (one time)
|
||||||
|
agent-browser dashboard install
|
||||||
|
|
||||||
|
# Start the dashboard server (runs in background on port 4848)
|
||||||
|
agent-browser dashboard start
|
||||||
|
agent-browser dashboard start --port 8080 # Custom port
|
||||||
|
|
||||||
|
# All sessions are automatically visible in the dashboard
|
||||||
|
agent-browser open example.com
|
||||||
|
|
||||||
|
# Stop the dashboard
|
||||||
|
agent-browser dashboard stop
|
||||||
|
```
|
||||||
|
|
||||||
|
The dashboard runs as a standalone background process on port 4848, independent of browser sessions. It stays available even when no sessions are running. All sessions automatically stream to the dashboard.
|
||||||
|
|
||||||
|
The dashboard displays:
|
||||||
|
- **Live viewport** -- real-time JPEG frames from the browser
|
||||||
|
- **Activity feed** -- chronological command/result stream with timing and expandable details
|
||||||
|
- **Console output** -- browser console messages (log, warn, error)
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Create an `agent-browser.json` file to set persistent defaults instead of repeating flags on every command.
|
Create an `agent-browser.json` file to set persistent defaults instead of repeating flags on every command.
|
||||||
@@ -926,28 +953,28 @@ This is useful when:
|
|||||||
|
|
||||||
Stream the browser viewport via WebSocket for live preview or "pair browsing" where a human can watch and interact alongside an AI agent.
|
Stream the browser viewport via WebSocket for live preview or "pair browsing" where a human can watch and interact alongside an AI agent.
|
||||||
|
|
||||||
### Enable Streaming
|
### Streaming
|
||||||
|
|
||||||
For an already-running session, enable streaming at runtime:
|
Every session automatically starts a WebSocket stream server on an OS-assigned port. Use `stream status` to see the bound port and connection state:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
agent-browser stream enable
|
|
||||||
agent-browser stream status
|
agent-browser stream status
|
||||||
agent-browser stream disable
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`stream enable` binds an available localhost port automatically unless you pass `--port <port>`.
|
To bind to a specific port, set `AGENT_BROWSER_STREAM_PORT`:
|
||||||
Use `stream status` to inspect whether streaming is enabled, which port is active, whether a browser is attached, and whether screencasting is active.
|
|
||||||
|
|
||||||
If you want streaming to be available immediately when the daemon starts, set `AGENT_BROWSER_STREAM_PORT` before the first command in that session:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
AGENT_BROWSER_STREAM_PORT=9223 agent-browser open example.com
|
AGENT_BROWSER_STREAM_PORT=9223 agent-browser open example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
The environment variable only affects daemon startup. For sessions that are already running, use `agent-browser stream enable` instead.
|
You can also manage streaming at runtime with `stream enable`, `stream disable`, and `stream status`:
|
||||||
|
|
||||||
Once enabled, the WebSocket server streams the browser viewport and accepts input events.
|
```bash
|
||||||
|
agent-browser stream enable --port 9223 # Re-enable on a specific port
|
||||||
|
agent-browser stream disable # Stop streaming for the session
|
||||||
|
```
|
||||||
|
|
||||||
|
The WebSocket server streams the browser viewport and accepts input events.
|
||||||
|
|
||||||
### WebSocket Protocol
|
### WebSocket Protocol
|
||||||
|
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ fn get_port_for_session(session: &str) -> u16 {
|
|||||||
49152 + ((hash.unsigned_abs() as u32 % 16383) as u16)
|
49152 + ((hash.unsigned_abs() as u32 % 16383) as u16)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn daemon_ready(session: &str) -> bool {
|
pub fn daemon_ready(session: &str) -> bool {
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
let socket_path = get_socket_path(session);
|
let socket_path = get_socket_path(session);
|
||||||
|
|||||||
@@ -643,3 +643,124 @@ fn package_exists_apt(pkg: &str) -> bool {
|
|||||||
.map(|s| s.success())
|
.map(|s| s.success())
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Dashboard install
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub fn get_dashboard_dir() -> PathBuf {
|
||||||
|
dirs::home_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join(".agent-browser")
|
||||||
|
.join("dashboard")
|
||||||
|
}
|
||||||
|
|
||||||
|
const DASHBOARD_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
|
||||||
|
fn dashboard_download_url() -> String {
|
||||||
|
format!(
|
||||||
|
"https://github.com/vercel-labs/agent-browser/releases/download/v{}/dashboard.zip",
|
||||||
|
DASHBOARD_VERSION
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_dashboard_install() {
|
||||||
|
println!("{}", color::cyan("Installing dashboard..."));
|
||||||
|
|
||||||
|
let dest = get_dashboard_dir();
|
||||||
|
|
||||||
|
if dest.join("index.html").exists() {
|
||||||
|
println!(
|
||||||
|
"{} Dashboard is already installed at {}",
|
||||||
|
color::success_indicator(),
|
||||||
|
dest.display()
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = dashboard_download_url();
|
||||||
|
println!(" Downloading dashboard v{}", DASHBOARD_VERSION);
|
||||||
|
println!(" {}", url);
|
||||||
|
|
||||||
|
let rt = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.unwrap_or_else(|e| {
|
||||||
|
eprintln!(
|
||||||
|
"{} Failed to create runtime: {}",
|
||||||
|
color::error_indicator(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
let bytes = match rt.block_on(download_bytes(&url)) {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("{} {}", color::error_indicator(), e);
|
||||||
|
eprintln!(" The dashboard may not be available for this version yet.");
|
||||||
|
eprintln!(" You can build it locally: cd packages/dashboard && pnpm build");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match extract_dashboard_zip(bytes, &dest) {
|
||||||
|
Ok(()) => {
|
||||||
|
println!(
|
||||||
|
"{} Dashboard v{} installed successfully",
|
||||||
|
color::success_indicator(),
|
||||||
|
DASHBOARD_VERSION
|
||||||
|
);
|
||||||
|
println!(" Location: {}", dest.display());
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = fs::remove_dir_all(&dest);
|
||||||
|
eprintln!("{} {}", color::error_indicator(), e);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_dashboard_zip(bytes: Vec<u8>, dest: &Path) -> Result<(), String> {
|
||||||
|
fs::create_dir_all(dest).map_err(|e| format!("Failed to create directory: {}", e))?;
|
||||||
|
|
||||||
|
let cursor = io::Cursor::new(bytes);
|
||||||
|
let mut archive =
|
||||||
|
zip::ZipArchive::new(cursor).map_err(|e| format!("Failed to read zip archive: {}", e))?;
|
||||||
|
|
||||||
|
for i in 0..archive.len() {
|
||||||
|
let mut file = archive
|
||||||
|
.by_index(i)
|
||||||
|
.map_err(|e| format!("Failed to read zip entry: {}", e))?;
|
||||||
|
|
||||||
|
let enclosed = match file.enclosed_name() {
|
||||||
|
Some(name) => name.to_owned(),
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
let rel_path = enclosed.to_string_lossy().to_string();
|
||||||
|
|
||||||
|
if rel_path.is_empty() || file.is_dir() {
|
||||||
|
if file.is_dir() {
|
||||||
|
let out_dir = dest.join(&rel_path);
|
||||||
|
let _ = fs::create_dir_all(&out_dir);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let out_path = dest.join(&rel_path);
|
||||||
|
if !out_path.starts_with(dest) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(parent) = out_path.parent() {
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.map_err(|e| format!("Failed to create parent dir {}: {}", parent.display(), e))?;
|
||||||
|
}
|
||||||
|
let mut out_file = fs::File::create(&out_path)
|
||||||
|
.map_err(|e| format!("Failed to create file {}: {}", out_path.display(), e))?;
|
||||||
|
io::copy(&mut file, &mut out_file)
|
||||||
|
.map_err(|e| format!("Failed to write {}: {}", out_path.display(), e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
+326
@@ -198,6 +198,278 @@ fn run_session(args: &[String], session: &str, json_mode: bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn get_dashboard_pid_path() -> std::path::PathBuf {
|
||||||
|
get_socket_dir().join("dashboard.pid")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_pid_alive(pid: u32) -> bool {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
unsafe { libc::kill(pid as i32, 0) == 0 }
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
unsafe {
|
||||||
|
let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
|
||||||
|
if handle != 0 {
|
||||||
|
CloseHandle(handle);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_dashboard_start(port: u16, json_mode: bool) {
|
||||||
|
let pid_path = get_dashboard_pid_path();
|
||||||
|
|
||||||
|
// Check if already running
|
||||||
|
if let Ok(pid_str) = fs::read_to_string(&pid_path) {
|
||||||
|
if let Ok(pid) = pid_str.trim().parse::<u32>() {
|
||||||
|
if is_pid_alive(pid) {
|
||||||
|
if json_mode {
|
||||||
|
print_json_value(json!({
|
||||||
|
"success": true,
|
||||||
|
"data": { "port": port, "pid": pid, "already_running": true },
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
println!("Dashboard already running at http://localhost:{}", port);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = fs::remove_file(&pid_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let socket_dir = get_socket_dir();
|
||||||
|
if !socket_dir.exists() {
|
||||||
|
let _ = fs::create_dir_all(&socket_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
let exe_path = match env::current_exe() {
|
||||||
|
Ok(p) => p.canonicalize().unwrap_or(p),
|
||||||
|
Err(e) => {
|
||||||
|
if json_mode {
|
||||||
|
print_json_error(format!("Failed to get executable path: {}", e));
|
||||||
|
} else {
|
||||||
|
eprintln!(
|
||||||
|
"{} Failed to get executable path: {}",
|
||||||
|
color::error_indicator(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut cmd = std::process::Command::new(&exe_path);
|
||||||
|
cmd.env("AGENT_BROWSER_DASHBOARD", "1")
|
||||||
|
.env("AGENT_BROWSER_DASHBOARD_PORT", port.to_string());
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::process::CommandExt;
|
||||||
|
unsafe {
|
||||||
|
cmd.pre_exec(|| {
|
||||||
|
libc::setsid();
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
|
||||||
|
const DETACHED_PROCESS: u32 = 0x00000008;
|
||||||
|
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS);
|
||||||
|
}
|
||||||
|
|
||||||
|
match cmd
|
||||||
|
.stdin(std::process::Stdio::null())
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.spawn()
|
||||||
|
{
|
||||||
|
Ok(child) => {
|
||||||
|
let pid = child.id();
|
||||||
|
let _ = fs::write(&pid_path, pid.to_string());
|
||||||
|
|
||||||
|
if json_mode {
|
||||||
|
print_json_value(json!({
|
||||||
|
"success": true,
|
||||||
|
"data": { "port": port, "pid": pid },
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
println!("Dashboard started at http://localhost:{}", port);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
if json_mode {
|
||||||
|
print_json_error(format!("Failed to start dashboard: {}", e));
|
||||||
|
} else {
|
||||||
|
eprintln!(
|
||||||
|
"{} Failed to start dashboard: {}",
|
||||||
|
color::error_indicator(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_dashboard_stop(json_mode: bool) {
|
||||||
|
let pid_path = get_dashboard_pid_path();
|
||||||
|
|
||||||
|
let pid_str = match fs::read_to_string(&pid_path) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => {
|
||||||
|
if json_mode {
|
||||||
|
print_json_value(
|
||||||
|
json!({ "success": true, "data": { "stopped": false, "reason": "not running" } }),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
println!("Dashboard is not running");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let pid: u32 = match pid_str.trim().parse() {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(_) => {
|
||||||
|
let _ = fs::remove_file(&pid_path);
|
||||||
|
if json_mode {
|
||||||
|
print_json_value(
|
||||||
|
json!({ "success": true, "data": { "stopped": false, "reason": "invalid pid" } }),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
println!("Dashboard is not running");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
unsafe {
|
||||||
|
libc::kill(pid as i32, libc::SIGTERM);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
unsafe {
|
||||||
|
let handle = OpenProcess(1, 0, pid); // PROCESS_TERMINATE = 1
|
||||||
|
if handle != 0 {
|
||||||
|
windows_sys::Win32::System::Threading::TerminateProcess(handle, 0);
|
||||||
|
CloseHandle(handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = fs::remove_file(&pid_path);
|
||||||
|
|
||||||
|
if json_mode {
|
||||||
|
print_json_value(json!({ "success": true, "data": { "stopped": true } }));
|
||||||
|
} else {
|
||||||
|
println!("{} Dashboard stopped", color::green("✓"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_close_all(flags: &Flags) {
|
||||||
|
let socket_dir = get_socket_dir();
|
||||||
|
let mut sessions: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
if let Ok(entries) = fs::read_dir(&socket_dir) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
if let Some(session_name) = name.strip_suffix(".pid") {
|
||||||
|
if session_name.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let pid_path = socket_dir.join(&name);
|
||||||
|
if let Ok(pid_str) = fs::read_to_string(&pid_path) {
|
||||||
|
if let Ok(pid) = pid_str.trim().parse::<u32>() {
|
||||||
|
#[cfg(unix)]
|
||||||
|
let running = unsafe {
|
||||||
|
libc::kill(pid as i32, 0) == 0
|
||||||
|
|| std::io::Error::last_os_error().raw_os_error()
|
||||||
|
!= Some(libc::ESRCH)
|
||||||
|
};
|
||||||
|
#[cfg(windows)]
|
||||||
|
let running = unsafe {
|
||||||
|
let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
|
||||||
|
if handle != 0 {
|
||||||
|
CloseHandle(handle);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if running {
|
||||||
|
sessions.push(session_name.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if sessions.is_empty() {
|
||||||
|
if flags.json {
|
||||||
|
print_json_value(json!({
|
||||||
|
"success": true,
|
||||||
|
"data": { "closed": 0, "sessions": [] },
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
println!("No active sessions");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut closed: Vec<String> = Vec::new();
|
||||||
|
let mut failed: Vec<(String, String)> = Vec::new();
|
||||||
|
|
||||||
|
for session in &sessions {
|
||||||
|
let cmd = json!({ "id": gen_id(), "action": "close" });
|
||||||
|
match send_command(cmd, session) {
|
||||||
|
Ok(resp) if resp.success => closed.push(session.clone()),
|
||||||
|
Ok(resp) => {
|
||||||
|
let err = resp.error.unwrap_or_else(|| "Unknown error".to_string());
|
||||||
|
failed.push((session.clone(), err));
|
||||||
|
}
|
||||||
|
Err(e) => failed.push((session.clone(), e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if flags.json {
|
||||||
|
print_json_value(json!({
|
||||||
|
"success": failed.is_empty(),
|
||||||
|
"data": {
|
||||||
|
"closed": closed.len(),
|
||||||
|
"sessions": closed,
|
||||||
|
"failed": failed.iter().map(|(s, e)| json!({"session": s, "error": e})).collect::<Vec<_>>(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
for s in &closed {
|
||||||
|
println!("{} Closed session: {}", color::green("✓"), s);
|
||||||
|
}
|
||||||
|
for (s, e) in &failed {
|
||||||
|
eprintln!("{} Failed to close {}: {}", color::error_indicator(), s, e);
|
||||||
|
}
|
||||||
|
if closed.is_empty() && !failed.is_empty() {
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !failed.is_empty() {
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
// Rust ignores SIGPIPE by default, causing println! to panic on broken pipes.
|
// Rust ignores SIGPIPE by default, causing println! to panic on broken pipes.
|
||||||
// Reset to SIG_DFL so the OS terminates the process cleanly instead.
|
// Reset to SIG_DFL so the OS terminates the process cleanly instead.
|
||||||
@@ -227,6 +499,17 @@ fn main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Standalone dashboard server mode
|
||||||
|
if env::var("AGENT_BROWSER_DASHBOARD").is_ok() {
|
||||||
|
let port: u16 = env::var("AGENT_BROWSER_DASHBOARD_PORT")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(4848);
|
||||||
|
let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
|
||||||
|
rt.block_on(native::stream::run_dashboard_server(port));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let args: Vec<String> = env::args().skip(1).collect();
|
let args: Vec<String> = env::args().skip(1).collect();
|
||||||
let flags = parse_flags(&args);
|
let flags = parse_flags(&args);
|
||||||
let clean = clean_args(&args);
|
let clean = clean_args(&args);
|
||||||
@@ -267,12 +550,54 @@ fn main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle dashboard subcommand
|
||||||
|
if clean.first().map(|s| s.as_str()) == Some("dashboard") {
|
||||||
|
match clean.get(1).map(|s| s.as_str()) {
|
||||||
|
Some("install") => {
|
||||||
|
install::run_dashboard_install();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Some("start") | None => {
|
||||||
|
let port = clean
|
||||||
|
.iter()
|
||||||
|
.position(|a| a == "--port")
|
||||||
|
.and_then(|i| clean.get(i + 1))
|
||||||
|
.and_then(|s| s.parse::<u16>().ok())
|
||||||
|
.unwrap_or(4848);
|
||||||
|
run_dashboard_start(port, flags.json);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Some("stop") => {
|
||||||
|
run_dashboard_stop(flags.json);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Some(unknown) => {
|
||||||
|
eprintln!(
|
||||||
|
"{} Unknown dashboard subcommand: {}",
|
||||||
|
color::error_indicator(),
|
||||||
|
unknown
|
||||||
|
);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle session separately (doesn't need daemon)
|
// Handle session separately (doesn't need daemon)
|
||||||
if clean.first().map(|s| s.as_str()) == Some("session") {
|
if clean.first().map(|s| s.as_str()) == Some("session") {
|
||||||
run_session(&clean, &flags.session, flags.json);
|
run_session(&clean, &flags.session, flags.json);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle close --all: close all active sessions
|
||||||
|
if matches!(
|
||||||
|
clean.first().map(|s| s.as_str()),
|
||||||
|
Some("close") | Some("quit") | Some("exit")
|
||||||
|
) && clean.iter().any(|a| a == "--all")
|
||||||
|
{
|
||||||
|
run_close_all(&flags);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let mut cmd = match parse_command(&clean, &flags) {
|
let mut cmd = match parse_command(&clean, &flags) {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -397,6 +722,7 @@ fn main() {
|
|||||||
idle_timeout: flags.idle_timeout.as_deref(),
|
idle_timeout: flags.idle_timeout.as_deref(),
|
||||||
cdp: flags.cdp.as_deref(),
|
cdp: flags.cdp.as_deref(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let daemon_result = match ensure_daemon(&flags.session, &daemon_opts) {
|
let daemon_result = match ensure_daemon(&flags.session, &daemon_opts) {
|
||||||
Ok(result) => result,
|
Ok(result) => result,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
+180
-15
@@ -212,6 +212,8 @@ pub struct DaemonState {
|
|||||||
pub stream_client: Option<Arc<RwLock<Option<Arc<CdpClient>>>>>,
|
pub stream_client: Option<Arc<RwLock<Option<Arc<CdpClient>>>>>,
|
||||||
/// Stream server instance kept alive so the broadcast channel remains open.
|
/// Stream server instance kept alive so the broadcast channel remains open.
|
||||||
pub stream_server: Option<Arc<StreamServer>>,
|
pub stream_server: Option<Arc<StreamServer>>,
|
||||||
|
/// Browser engine name (e.g. "chrome", "lightpanda") for observability.
|
||||||
|
pub engine: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DaemonState {
|
impl DaemonState {
|
||||||
@@ -254,6 +256,7 @@ impl DaemonState {
|
|||||||
pending_dialog: None,
|
pending_dialog: None,
|
||||||
stream_client: None,
|
stream_client: None,
|
||||||
stream_server: None,
|
stream_server: None,
|
||||||
|
engine: env::var("AGENT_BROWSER_ENGINE").unwrap_or_else(|_| "chrome".to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,6 +271,9 @@ impl DaemonState {
|
|||||||
stream_server: Option<Arc<StreamServer>>,
|
stream_server: Option<Arc<StreamServer>>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let mut s = Self::new();
|
let mut s = Self::new();
|
||||||
|
if stream_server.is_some() {
|
||||||
|
s.request_tracking = true;
|
||||||
|
}
|
||||||
s.stream_client = stream_client;
|
s.stream_client = stream_client;
|
||||||
s.stream_server = stream_server;
|
s.stream_server = stream_server;
|
||||||
s
|
s
|
||||||
@@ -410,7 +416,14 @@ impl DaemonState {
|
|||||||
let connected = self.browser.is_some();
|
let connected = self.browser.is_some();
|
||||||
let sc = server.is_screencasting().await;
|
let sc = server.is_screencasting().await;
|
||||||
let (vw, vh) = server.viewport().await;
|
let (vw, vh) = server.viewport().await;
|
||||||
server.broadcast_status(connected, sc, vw, vh);
|
server
|
||||||
|
.broadcast_status(connected, sc, vw, vh, &self.engine)
|
||||||
|
.await;
|
||||||
|
if let Some(ref mgr) = self.browser {
|
||||||
|
server.broadcast_tabs(&mgr.tab_list()).await;
|
||||||
|
} else {
|
||||||
|
server.broadcast_tabs(&[]).await;
|
||||||
|
}
|
||||||
// Notify the background CDP event loop that the client changed
|
// Notify the background CDP event loop that the client changed
|
||||||
server.notify_client_changed();
|
server.notify_client_changed();
|
||||||
}
|
}
|
||||||
@@ -441,6 +454,10 @@ impl DaemonState {
|
|||||||
recording::stop_recording_task(&mut self.recording_state).await
|
recording::stop_recording_task(&mut self.recording_state).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn drain_cdp_events_background(&mut self) {
|
||||||
|
let _ = self.drain_cdp_events();
|
||||||
|
}
|
||||||
|
|
||||||
fn drain_cdp_events(&mut self) -> DrainedEvents {
|
fn drain_cdp_events(&mut self) -> DrainedEvents {
|
||||||
let rx = match self.event_rx.as_mut() {
|
let rx = match self.event_rx.as_mut() {
|
||||||
Some(rx) => rx,
|
Some(rx) => rx,
|
||||||
@@ -557,6 +574,9 @@ impl DaemonState {
|
|||||||
.join(" ");
|
.join(" ");
|
||||||
self.event_tracker
|
self.event_tracker
|
||||||
.add_console(&console_event.call_type, &text);
|
.add_console(&console_event.call_type, &text);
|
||||||
|
if let Some(ref server) = self.stream_server {
|
||||||
|
server.broadcast_console(&console_event.call_type, &text);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"Runtime.exceptionThrown" => {
|
"Runtime.exceptionThrown" => {
|
||||||
@@ -575,6 +595,13 @@ impl DaemonState {
|
|||||||
details.line_number,
|
details.line_number,
|
||||||
details.column_number,
|
details.column_number,
|
||||||
);
|
);
|
||||||
|
if let Some(ref server) = self.stream_server {
|
||||||
|
server.broadcast_page_error(
|
||||||
|
text,
|
||||||
|
details.line_number,
|
||||||
|
details.column_number,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"Network.requestWillBeSent"
|
"Network.requestWillBeSent"
|
||||||
@@ -837,6 +864,12 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
|
|||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
|
let cmd_start = std::time::Instant::now();
|
||||||
|
|
||||||
|
if let Some(ref server) = state.stream_server {
|
||||||
|
server.broadcast_command(action, &id, cmd);
|
||||||
|
}
|
||||||
|
|
||||||
// Drain pending CDP events (console, errors, screencast frames, target lifecycle)
|
// Drain pending CDP events (console, errors, screencast frames, target lifecycle)
|
||||||
let DrainedEvents {
|
let DrainedEvents {
|
||||||
pending_acks,
|
pending_acks,
|
||||||
@@ -1221,6 +1254,31 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(ref server) = state.stream_server {
|
||||||
|
let duration_ms = cmd_start.elapsed().as_millis() as u64;
|
||||||
|
let success = resp
|
||||||
|
.get("status")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.is_some_and(|s| s == "success");
|
||||||
|
let data = resp.get("data").cloned().unwrap_or(Value::Null);
|
||||||
|
server.broadcast_result(&id, action, success, &data, duration_ms);
|
||||||
|
|
||||||
|
if let Some(ref mgr) = state.browser {
|
||||||
|
server.broadcast_tabs(&mgr.tab_list()).await;
|
||||||
|
|
||||||
|
// Keep the stream server's CDP session in sync with the active tab
|
||||||
|
// so screencasting always targets the correct page.
|
||||||
|
if matches!(
|
||||||
|
action,
|
||||||
|
"tab_new" | "tab_switch" | "tab_close" | "open" | "navigate"
|
||||||
|
) {
|
||||||
|
let session_id = mgr.active_session_id().ok().map(|s| s.to_string());
|
||||||
|
server.set_cdp_session_id(session_id).await;
|
||||||
|
server.notify_client_changed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
resp
|
resp
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1255,6 +1313,10 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state.engine = engine.as_deref().unwrap_or("chrome").to_string();
|
||||||
|
write_engine_file(&state.session_id, &state.engine);
|
||||||
|
write_extensions_file(&state.session_id);
|
||||||
|
|
||||||
if let Ok(cdp) = env::var("AGENT_BROWSER_CDP") {
|
if let Ok(cdp) = env::var("AGENT_BROWSER_CDP") {
|
||||||
let mgr = BrowserManager::connect_cdp(&cdp).await?;
|
let mgr = BrowserManager::connect_cdp(&cdp).await?;
|
||||||
state.reset_input_state();
|
state.reset_input_state();
|
||||||
@@ -1569,6 +1631,9 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
|
|||||||
*df = Some(DomainFilter::new(domains));
|
*df = Some(DomainFilter::new(domains));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state.engine = engine.as_deref().unwrap_or("chrome").to_string();
|
||||||
|
write_engine_file(&state.session_id, &state.engine);
|
||||||
|
write_extensions_file(&state.session_id);
|
||||||
state.reset_input_state();
|
state.reset_input_state();
|
||||||
state.browser = Some(BrowserManager::launch(options, engine.as_deref()).await?);
|
state.browser = Some(BrowserManager::launch(options, engine.as_deref()).await?);
|
||||||
state.subscribe_to_browser_events();
|
state.subscribe_to_browser_events();
|
||||||
@@ -1640,6 +1705,9 @@ async fn launch_ios(cmd: &Value, state: &mut DaemonState) -> Result<Value, Strin
|
|||||||
|
|
||||||
state.appium = Some(appium);
|
state.appium = Some(appium);
|
||||||
state.backend_type = BackendType::WebDriver;
|
state.backend_type = BackendType::WebDriver;
|
||||||
|
state.engine = "safari".to_string();
|
||||||
|
write_engine_file(&state.session_id, &state.engine);
|
||||||
|
write_extensions_file(&state.session_id);
|
||||||
state.reset_input_state();
|
state.reset_input_state();
|
||||||
|
|
||||||
Ok(json!({
|
Ok(json!({
|
||||||
@@ -1684,6 +1752,9 @@ async fn launch_safari(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
|
|||||||
state.safari_driver = Some(driver);
|
state.safari_driver = Some(driver);
|
||||||
state.webdriver_backend = Some(WebDriverBackend::new(client));
|
state.webdriver_backend = Some(WebDriverBackend::new(client));
|
||||||
state.backend_type = BackendType::WebDriver;
|
state.backend_type = BackendType::WebDriver;
|
||||||
|
state.engine = "safari".to_string();
|
||||||
|
write_engine_file(&state.session_id, &state.engine);
|
||||||
|
write_extensions_file(&state.session_id);
|
||||||
state.reset_input_state();
|
state.reset_input_state();
|
||||||
|
|
||||||
Ok(json!({
|
Ok(json!({
|
||||||
@@ -3215,7 +3286,27 @@ async fn handle_tab_switch(cmd: &Value, state: &mut DaemonState) -> Result<Value
|
|||||||
state.ref_map.clear();
|
state.ref_map.clear();
|
||||||
state.iframe_sessions.clear();
|
state.iframe_sessions.clear();
|
||||||
state.active_frame_id = None;
|
state.active_frame_id = None;
|
||||||
mgr.tab_switch(index).await
|
let result = mgr.tab_switch(index).await?;
|
||||||
|
|
||||||
|
if let Some(ref server) = state.stream_server {
|
||||||
|
if let Ok(dims) = mgr
|
||||||
|
.evaluate(
|
||||||
|
"JSON.stringify([window.innerWidth,window.innerHeight])",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
if let Some(s) = dims.get("result").and_then(|v| v.as_str()) {
|
||||||
|
if let Ok(arr) = serde_json::from_str::<Vec<u32>>(s) {
|
||||||
|
if arr.len() == 2 && arr[0] > 0 && arr[1] > 0 {
|
||||||
|
server.set_viewport(arr[0], arr[1]).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_tab_close(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
async fn handle_tab_close(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
||||||
@@ -3264,11 +3355,26 @@ async fn handle_set_media(cmd: &Value, state: &DaemonState) -> Result<Value, Str
|
|||||||
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
||||||
let media = cmd.get("media").and_then(|v| v.as_str());
|
let media = cmd.get("media").and_then(|v| v.as_str());
|
||||||
|
|
||||||
let features = cmd.get("features").and_then(|v| v.as_object()).map(|m| {
|
let mut feat_list: Vec<(String, String)> = Vec::new();
|
||||||
m.iter()
|
|
||||||
.map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string()))
|
if let Some(scheme) = cmd.get("colorScheme").and_then(|v| v.as_str()) {
|
||||||
.collect::<Vec<(String, String)>>()
|
feat_list.push(("prefers-color-scheme".to_string(), scheme.to_string()));
|
||||||
});
|
}
|
||||||
|
if let Some(motion) = cmd.get("reducedMotion").and_then(|v| v.as_str()) {
|
||||||
|
feat_list.push(("prefers-reduced-motion".to_string(), motion.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(obj) = cmd.get("features").and_then(|v| v.as_object()) {
|
||||||
|
for (k, v) in obj {
|
||||||
|
feat_list.push((k.clone(), v.as_str().unwrap_or("").to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let features = if feat_list.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(feat_list)
|
||||||
|
};
|
||||||
|
|
||||||
mgr.set_emulated_media(media, features).await?;
|
mgr.set_emulated_media(media, features).await?;
|
||||||
Ok(json!({ "set": true }))
|
Ok(json!({ "set": true }))
|
||||||
@@ -3601,12 +3707,22 @@ async fn handle_recording_start(cmd: &Value, state: &mut DaemonState) -> Result<
|
|||||||
let result = recording::recording_start(&mut state.recording_state, path)?;
|
let result = recording::recording_start(&mut state.recording_state, path)?;
|
||||||
state.start_recording_task(client, new_session_id).await?;
|
state.start_recording_task(client, new_session_id).await?;
|
||||||
|
|
||||||
|
if let Some(ref server) = state.stream_server {
|
||||||
|
server.set_recording(true, &state.engine).await;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_recording_stop(state: &mut DaemonState) -> Result<Value, String> {
|
async fn handle_recording_stop(state: &mut DaemonState) -> Result<Value, String> {
|
||||||
state.stop_recording_task().await?;
|
state.stop_recording_task().await?;
|
||||||
recording::recording_stop(&mut state.recording_state)
|
let result = recording::recording_stop(&mut state.recording_state);
|
||||||
|
|
||||||
|
if let Some(ref server) = state.stream_server {
|
||||||
|
server.set_recording(false, &state.engine).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_recording_restart(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
async fn handle_recording_restart(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
||||||
@@ -4256,15 +4372,21 @@ async fn handle_device(cmd: &Value, state: &DaemonState) -> Result<Value, String
|
|||||||
.ok_or("Missing 'name' parameter")?;
|
.ok_or("Missing 'name' parameter")?;
|
||||||
|
|
||||||
let (width, height, scale, mobile, ua) = match name.to_lowercase().as_str() {
|
let (width, height, scale, mobile, ua) = match name.to_lowercase().as_str() {
|
||||||
|
"iphone 15" | "iphone15" => (393, 852, 3.0, true, "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"),
|
||||||
|
"iphone 16" | "iphone16" => (393, 852, 3.0, true, "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1"),
|
||||||
|
"iphone 16 pro" | "iphone16pro" => (402, 874, 3.0, true, "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1"),
|
||||||
|
"iphone 17" | "iphone17" => (402, 874, 3.0, true, "Mozilla/5.0 (iPhone; CPU iPhone OS 19_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/19.0 Mobile/15E148 Safari/604.1"),
|
||||||
|
"ipad" | "ipad air" => (820, 1180, 2.0, true, "Mozilla/5.0 (iPad; CPU OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/604.1"),
|
||||||
|
"ipad pro" => (1024, 1366, 2.0, true, "Mozilla/5.0 (iPad; CPU OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/604.1"),
|
||||||
|
"pixel 9" | "pixel9" => (412, 923, 2.625, true, "Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36"),
|
||||||
|
"galaxy s25" | "galaxys25" => (360, 800, 3.0, true, "Mozilla/5.0 (Linux; Android 15; SM-S931B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36"),
|
||||||
|
// Legacy aliases
|
||||||
"iphone 12" | "iphone12" => (390, 844, 3.0, true, "Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1"),
|
"iphone 12" | "iphone12" => (390, 844, 3.0, true, "Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1"),
|
||||||
"iphone 14" | "iphone14" => (390, 844, 3.0, true, "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1"),
|
"iphone 14" | "iphone14" => (390, 844, 3.0, true, "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1"),
|
||||||
"iphone 15" | "iphone15" => (393, 852, 3.0, true, "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"),
|
|
||||||
"ipad" | "ipad air" => (820, 1180, 2.0, true, "Mozilla/5.0 (iPad; CPU OS 14_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Safari/604.1"),
|
|
||||||
"ipad pro" => (1024, 1366, 2.0, true, "Mozilla/5.0 (iPad; CPU OS 14_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Safari/604.1"),
|
|
||||||
"pixel 5" | "pixel5" => (393, 851, 2.75, true, "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.91 Mobile Safari/537.36"),
|
"pixel 5" | "pixel5" => (393, 851, 2.75, true, "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.91 Mobile Safari/537.36"),
|
||||||
"pixel 7" | "pixel7" => (412, 915, 2.625, true, "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Mobile Safari/537.36"),
|
"pixel 7" | "pixel7" => (412, 915, 2.625, true, "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Mobile Safari/537.36"),
|
||||||
"galaxy s21" | "galaxys21" => (360, 800, 3.0, true, "Mozilla/5.0 (Linux; Android 11; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.91 Mobile Safari/537.36"),
|
"galaxy s21" | "galaxys21" => (360, 800, 3.0, true, "Mozilla/5.0 (Linux; Android 11; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.91 Mobile Safari/537.36"),
|
||||||
_ => return Err(format!("Unknown device: {}. Supported: iPhone 12, iPhone 14, iPhone 15, iPad, iPad Pro, Pixel 5, Pixel 7, Galaxy S21", name)),
|
_ => return Err(format!("Unknown device: {}. Supported: iPhone 15, iPhone 16, iPhone 16 Pro, iPhone 17, iPad, iPad Pro, Pixel 9, Galaxy S25", name)),
|
||||||
};
|
};
|
||||||
|
|
||||||
mgr.set_viewport(width, height, scale, mobile).await?;
|
mgr.set_viewport(width, height, scale, mobile).await?;
|
||||||
@@ -4316,6 +4438,37 @@ fn remove_stream_file(session_id: &str) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn engine_file_path(session_id: &str) -> PathBuf {
|
||||||
|
get_socket_dir().join(format!("{}.engine", session_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_engine_file(session_id: &str, engine: &str) {
|
||||||
|
let _ = fs::write(engine_file_path(session_id), engine);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_engine_file(session_id: &str) {
|
||||||
|
let _ = fs::remove_file(engine_file_path(session_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extensions_file_path(session_id: &str) -> PathBuf {
|
||||||
|
get_socket_dir().join(format!("{}.extensions", session_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_extensions_file(session_id: &str) {
|
||||||
|
if let Ok(val) = env::var("AGENT_BROWSER_EXTENSIONS") {
|
||||||
|
let trimmed = val.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
let _ = fs::write(extensions_file_path(session_id), trimmed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = fs::remove_file(extensions_file_path(session_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_extensions_file(session_id: &str) {
|
||||||
|
let _ = fs::remove_file(extensions_file_path(session_id));
|
||||||
|
}
|
||||||
|
|
||||||
async fn current_stream_status(state: &DaemonState) -> Value {
|
async fn current_stream_status(state: &DaemonState) -> Value {
|
||||||
debug_assert_eq!(
|
debug_assert_eq!(
|
||||||
state.stream_server.is_some(),
|
state.stream_server.is_some(),
|
||||||
@@ -4356,7 +4509,7 @@ async fn handle_stream_enable(cmd: &Value, state: &mut DaemonState) -> Result<Va
|
|||||||
};
|
};
|
||||||
|
|
||||||
let (server, client_slot) =
|
let (server, client_slot) =
|
||||||
StreamServer::start_without_client(requested_port, state.session_id.clone()).await?;
|
StreamServer::start_without_client(requested_port, state.session_id.clone(), false).await?;
|
||||||
let port = server.port();
|
let port = server.port();
|
||||||
if let Err(err) = write_stream_file(&state.session_id, port) {
|
if let Err(err) = write_stream_file(&state.session_id, port) {
|
||||||
server.shutdown().await;
|
server.shutdown().await;
|
||||||
@@ -4365,6 +4518,7 @@ async fn handle_stream_enable(cmd: &Value, state: &mut DaemonState) -> Result<Va
|
|||||||
|
|
||||||
state.stream_client = Some(client_slot);
|
state.stream_client = Some(client_slot);
|
||||||
state.stream_server = Some(Arc::new(server));
|
state.stream_server = Some(Arc::new(server));
|
||||||
|
state.request_tracking = true;
|
||||||
if state.screencasting {
|
if state.screencasting {
|
||||||
if let Some(ref server) = state.stream_server {
|
if let Some(ref server) = state.stream_server {
|
||||||
server.set_screencasting(true).await;
|
server.set_screencasting(true).await;
|
||||||
@@ -4384,6 +4538,7 @@ async fn handle_stream_disable(state: &mut DaemonState) -> Result<Value, String>
|
|||||||
state.stream_server = None;
|
state.stream_server = None;
|
||||||
state.stream_client = None;
|
state.stream_client = None;
|
||||||
remove_stream_file(&state.session_id)?;
|
remove_stream_file(&state.session_id)?;
|
||||||
|
remove_engine_file(&state.session_id);
|
||||||
|
|
||||||
Ok(json!({ "disabled": true }))
|
Ok(json!({ "disabled": true }))
|
||||||
}
|
}
|
||||||
@@ -4434,7 +4589,15 @@ async fn handle_screencast_start(cmd: &Value, state: &mut DaemonState) -> Result
|
|||||||
|
|
||||||
if let Some(ref server) = state.stream_server {
|
if let Some(ref server) = state.stream_server {
|
||||||
server.set_screencasting(true).await;
|
server.set_screencasting(true).await;
|
||||||
server.broadcast_status(true, true, max_width as u32, max_height as u32);
|
server
|
||||||
|
.broadcast_status(
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
max_width as u32,
|
||||||
|
max_height as u32,
|
||||||
|
&state.engine,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(json!({ "started": true }))
|
Ok(json!({ "started": true }))
|
||||||
@@ -4454,7 +4617,9 @@ async fn handle_screencast_stop(state: &mut DaemonState) -> Result<Value, String
|
|||||||
if let Some(ref server) = state.stream_server {
|
if let Some(ref server) = state.stream_server {
|
||||||
server.set_screencasting(false).await;
|
server.set_screencasting(false).await;
|
||||||
let (vw, vh) = server.viewport().await;
|
let (vw, vh) = server.viewport().await;
|
||||||
server.broadcast_status(true, false, vw, vh);
|
server
|
||||||
|
.broadcast_status(true, false, vw, vh, &state.engine)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(json!({ "stopped": true }))
|
Ok(json!({ "stopped": true }))
|
||||||
|
|||||||
+48
-22
@@ -33,6 +33,8 @@ pub async fn run_daemon(session: &str) {
|
|||||||
|
|
||||||
let stream_path = socket_dir.join(format!("{}.stream", session));
|
let stream_path = socket_dir.join(format!("{}.stream", session));
|
||||||
let _ = fs::remove_file(&stream_path);
|
let _ = fs::remove_file(&stream_path);
|
||||||
|
let _ = fs::remove_file(socket_dir.join(format!("{}.engine", session)));
|
||||||
|
let _ = fs::remove_file(socket_dir.join(format!("{}.extensions", session)));
|
||||||
|
|
||||||
if let Ok(days_str) = env::var("AGENT_BROWSER_STATE_EXPIRE_DAYS") {
|
if let Ok(days_str) = env::var("AGENT_BROWSER_STATE_EXPIRE_DAYS") {
|
||||||
if let Ok(days) = days_str.parse::<u64>() {
|
if let Ok(days) = days_str.parse::<u64>() {
|
||||||
@@ -44,23 +46,20 @@ pub async fn run_daemon(session: &str) {
|
|||||||
|
|
||||||
let mut stream_client: Option<Arc<RwLock<Option<Arc<CdpClient>>>>> = None;
|
let mut stream_client: Option<Arc<RwLock<Option<Arc<CdpClient>>>>> = None;
|
||||||
let mut stream_server_instance: Option<Arc<StreamServer>> = None;
|
let mut stream_server_instance: Option<Arc<StreamServer>> = None;
|
||||||
if let Ok(port_str) = env::var("AGENT_BROWSER_STREAM_PORT") {
|
let preferred_port = env::var("AGENT_BROWSER_STREAM_PORT")
|
||||||
if let Ok(port) = port_str.parse::<u16>() {
|
.ok()
|
||||||
if port > 0 {
|
.and_then(|s| s.parse::<u16>().ok())
|
||||||
match StreamServer::start_without_client(port, session.to_string()).await {
|
.unwrap_or(0);
|
||||||
Ok((stream_server, client_slot)) => {
|
match StreamServer::start_without_client(preferred_port, session.to_string(), true).await {
|
||||||
stream_client = Some(client_slot.clone());
|
Ok((stream_server, client_slot)) => {
|
||||||
if let Err(e) = fs::write(&stream_path, stream_server.port().to_string()) {
|
stream_client = Some(client_slot.clone());
|
||||||
let _ =
|
if let Err(e) = fs::write(&stream_path, stream_server.port().to_string()) {
|
||||||
writeln!(std::io::stderr(), "Failed to write .stream file: {}", e);
|
let _ = writeln!(std::io::stderr(), "Failed to write .stream file: {}", e);
|
||||||
}
|
|
||||||
stream_server_instance = Some(Arc::new(stream_server));
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
let _ = writeln!(std::io::stderr(), "Stream server failed to start: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
stream_server_instance = Some(Arc::new(stream_server));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = writeln!(std::io::stderr(), "Stream server failed to start: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +82,8 @@ pub async fn run_daemon(session: &str) {
|
|||||||
let _ = fs::remove_file(&socket_path);
|
let _ = fs::remove_file(&socket_path);
|
||||||
let _ = fs::remove_file(&pid_path);
|
let _ = fs::remove_file(&pid_path);
|
||||||
let _ = fs::remove_file(&stream_path);
|
let _ = fs::remove_file(&stream_path);
|
||||||
|
let _ = fs::remove_file(socket_dir.join(format!("{}.engine", session)));
|
||||||
|
let _ = fs::remove_file(socket_dir.join(format!("{}.extensions", session)));
|
||||||
|
|
||||||
if let Err(e) = result {
|
if let Err(e) = result {
|
||||||
let _ = writeln!(std::io::stderr(), "Daemon error: {}", e);
|
let _ = writeln!(std::io::stderr(), "Daemon error: {}", e);
|
||||||
@@ -93,7 +94,7 @@ pub async fn run_daemon(session: &str) {
|
|||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
async fn run_socket_server(
|
async fn run_socket_server(
|
||||||
socket_path: &PathBuf,
|
socket_path: &PathBuf,
|
||||||
_session: &str,
|
session: &str,
|
||||||
stream_client: Option<Arc<RwLock<Option<Arc<CdpClient>>>>>,
|
stream_client: Option<Arc<RwLock<Option<Arc<CdpClient>>>>>,
|
||||||
stream_server: Option<Arc<StreamServer>>,
|
stream_server: Option<Arc<StreamServer>>,
|
||||||
idle_timeout_ms: Option<u64>,
|
idle_timeout_ms: Option<u64>,
|
||||||
@@ -103,6 +104,13 @@ async fn run_socket_server(
|
|||||||
let listener =
|
let listener =
|
||||||
UnixListener::bind(socket_path).map_err(|e| format!("Failed to bind socket: {}", e))?;
|
UnixListener::bind(socket_path).map_err(|e| format!("Failed to bind socket: {}", e))?;
|
||||||
|
|
||||||
|
let stream_file: Option<PathBuf> = if stream_server.is_some() {
|
||||||
|
let dir = socket_path.parent().unwrap_or(std::path::Path::new("."));
|
||||||
|
Some(dir.join(format!("{}.stream", session)))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
let state: std::sync::Arc<tokio::sync::Mutex<DaemonState>> = std::sync::Arc::new(
|
let state: std::sync::Arc<tokio::sync::Mutex<DaemonState>> = std::sync::Arc::new(
|
||||||
tokio::sync::Mutex::new(DaemonState::new_with_stream(stream_client, stream_server)),
|
tokio::sync::Mutex::new(DaemonState::new_with_stream(stream_client, stream_server)),
|
||||||
);
|
);
|
||||||
@@ -116,6 +124,9 @@ async fn run_socket_server(
|
|||||||
let mut sigchld = signal::unix::signal(signal::unix::SignalKind::child())
|
let mut sigchld = signal::unix::signal(signal::unix::SignalKind::child())
|
||||||
.map_err(|e| format!("Failed to install SIGCHLD handler: {}", e))?;
|
.map_err(|e| format!("Failed to install SIGCHLD handler: {}", e))?;
|
||||||
|
|
||||||
|
let mut drain_interval = tokio::time::interval(Duration::from_millis(500));
|
||||||
|
drain_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let sleep_future = idle_timeout_ms.map(|ms| tokio::time::sleep(Duration::from_millis(ms)));
|
let sleep_future = idle_timeout_ms.map(|ms| tokio::time::sleep(Duration::from_millis(ms)));
|
||||||
let mut sleep_pin = sleep_future.map(Box::pin);
|
let mut sleep_pin = sleep_future.map(Box::pin);
|
||||||
@@ -126,8 +137,9 @@ async fn run_socket_server(
|
|||||||
Ok((stream, _)) => {
|
Ok((stream, _)) => {
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
let reset_tx = reset_tx.clone();
|
let reset_tx = reset_tx.clone();
|
||||||
|
let sf = stream_file.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
handle_connection(stream, state, reset_tx).await;
|
handle_connection(stream, state, reset_tx, sf).await;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -136,11 +148,14 @@ async fn run_socket_server(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = sigchld.recv() => {
|
_ = sigchld.recv() => {
|
||||||
// Reap all zombie children. The browser will be re-launched
|
|
||||||
// automatically on the next command via the has_process_exited()
|
|
||||||
// check in execute_command.
|
|
||||||
reap_children();
|
reap_children();
|
||||||
}
|
}
|
||||||
|
_ = drain_interval.tick() => {
|
||||||
|
let mut s = state.lock().await;
|
||||||
|
if s.request_tracking || s.har_recording {
|
||||||
|
s.drain_cdp_events_background();
|
||||||
|
}
|
||||||
|
}
|
||||||
_ = async {
|
_ = async {
|
||||||
if let Some(ref mut s) = sleep_pin {
|
if let Some(ref mut s) = sleep_pin {
|
||||||
s.as_mut().await
|
s.as_mut().await
|
||||||
@@ -200,6 +215,12 @@ async fn run_socket_server(
|
|||||||
let port_path = socket_dir.join(format!("{}.port", session));
|
let port_path = socket_dir.join(format!("{}.port", session));
|
||||||
let _ = fs::write(&port_path, port.to_string());
|
let _ = fs::write(&port_path, port.to_string());
|
||||||
|
|
||||||
|
let stream_file: Option<PathBuf> = if stream_server.is_some() {
|
||||||
|
Some(socket_dir.join(format!("{}.stream", session)))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
let state: std::sync::Arc<tokio::sync::Mutex<DaemonState>> = std::sync::Arc::new(
|
let state: std::sync::Arc<tokio::sync::Mutex<DaemonState>> = std::sync::Arc::new(
|
||||||
tokio::sync::Mutex::new(DaemonState::new_with_stream(stream_client, stream_server)),
|
tokio::sync::Mutex::new(DaemonState::new_with_stream(stream_client, stream_server)),
|
||||||
);
|
);
|
||||||
@@ -217,8 +238,9 @@ async fn run_socket_server(
|
|||||||
Ok((stream, _)) => {
|
Ok((stream, _)) => {
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
let reset_tx = reset_tx.clone();
|
let reset_tx = reset_tx.clone();
|
||||||
|
let sf = stream_file.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
handle_connection(stream, state, reset_tx).await;
|
handle_connection(stream, state, reset_tx, sf).await;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -261,6 +283,7 @@ async fn handle_connection<S>(
|
|||||||
stream: S,
|
stream: S,
|
||||||
state: std::sync::Arc<tokio::sync::Mutex<DaemonState>>,
|
state: std::sync::Arc<tokio::sync::Mutex<DaemonState>>,
|
||||||
idle_reset_tx: Option<Arc<mpsc::Sender<()>>>,
|
idle_reset_tx: Option<Arc<mpsc::Sender<()>>>,
|
||||||
|
stream_file_cleanup: Option<PathBuf>,
|
||||||
) where
|
) where
|
||||||
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
|
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
|
||||||
{
|
{
|
||||||
@@ -314,6 +337,9 @@ async fn handle_connection<S>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if is_close {
|
if is_close {
|
||||||
|
if let Some(ref path) = stream_file_cleanup {
|
||||||
|
let _ = fs::remove_file(path);
|
||||||
|
}
|
||||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||||
process::exit(0);
|
process::exit(0);
|
||||||
}
|
}
|
||||||
|
|||||||
+1000
-24
File diff suppressed because it is too large
Load Diff
+49
-5
@@ -1596,12 +1596,15 @@ Examples:
|
|||||||
r##"
|
r##"
|
||||||
agent-browser close - Close the browser
|
agent-browser close - Close the browser
|
||||||
|
|
||||||
Usage: agent-browser close
|
Usage: agent-browser close [options]
|
||||||
|
|
||||||
Closes the browser instance for the current session.
|
Closes the browser instance for the current session.
|
||||||
|
|
||||||
Aliases: quit, exit
|
Aliases: quit, exit
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--all Close all active sessions
|
||||||
|
|
||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
@@ -1609,6 +1612,7 @@ Global Options:
|
|||||||
Examples:
|
Examples:
|
||||||
agent-browser close
|
agent-browser close
|
||||||
agent-browser close --session mysession
|
agent-browser close --session mysession
|
||||||
|
agent-browser close --all
|
||||||
"##
|
"##
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2388,6 +2392,40 @@ Examples:
|
|||||||
"##
|
"##
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === Dashboard ===
|
||||||
|
"dashboard" => {
|
||||||
|
r##"
|
||||||
|
agent-browser dashboard - Observability dashboard
|
||||||
|
|
||||||
|
Usage: agent-browser dashboard [start|stop|install] [options]
|
||||||
|
|
||||||
|
Manage the observability dashboard, a local web UI that shows live
|
||||||
|
browser viewports and command activity feeds for all sessions.
|
||||||
|
|
||||||
|
Subcommands:
|
||||||
|
start [--port <n>] Start the dashboard server (default port: 4848)
|
||||||
|
stop Stop the dashboard server
|
||||||
|
install Download and install the dashboard to ~/.agent-browser/dashboard/
|
||||||
|
|
||||||
|
Running 'agent-browser dashboard' with no subcommand is equivalent to 'dashboard start'.
|
||||||
|
|
||||||
|
The dashboard runs as a standalone background process, independent of
|
||||||
|
browser sessions. All sessions automatically stream to the dashboard.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--port <n> Port for the dashboard server (default: 4848)
|
||||||
|
|
||||||
|
Global Options:
|
||||||
|
--json Output as JSON
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
agent-browser dashboard install
|
||||||
|
agent-browser dashboard start
|
||||||
|
agent-browser dashboard start --port 8080
|
||||||
|
agent-browser dashboard stop
|
||||||
|
"##
|
||||||
|
}
|
||||||
|
|
||||||
// === Connect ===
|
// === Connect ===
|
||||||
"connect" => {
|
"connect" => {
|
||||||
r##"
|
r##"
|
||||||
@@ -2446,8 +2484,8 @@ Notes:
|
|||||||
- 'stream enable' creates the WebSocket server.
|
- 'stream enable' creates the WebSocket server.
|
||||||
- WebSocket clients trigger frame streaming automatically.
|
- WebSocket clients trigger frame streaming automatically.
|
||||||
- 'screencast_start' and 'screencast_stop' still control explicit CDP screencasts.
|
- 'screencast_start' and 'screencast_stop' still control explicit CDP screencasts.
|
||||||
- AGENT_BROWSER_STREAM_PORT only affects daemon startup; use 'stream enable'
|
- Streaming is always enabled. Set AGENT_BROWSER_STREAM_PORT to bind to a
|
||||||
for sessions that are already running.
|
specific port instead of the default OS-assigned port.
|
||||||
|
|
||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
@@ -2652,7 +2690,7 @@ Core Commands:
|
|||||||
snapshot Accessibility tree with refs (for AI)
|
snapshot Accessibility tree with refs (for AI)
|
||||||
eval <js> Run JavaScript
|
eval <js> Run JavaScript
|
||||||
connect <port|url> Connect to browser via CDP
|
connect <port|url> Connect to browser via CDP
|
||||||
close Close browser
|
close [--all] Close browser (--all closes every session)
|
||||||
|
|
||||||
Navigation:
|
Navigation:
|
||||||
back Go back
|
back Go back
|
||||||
@@ -2729,10 +2767,16 @@ Sessions:
|
|||||||
session Show current session name
|
session Show current session name
|
||||||
session list List active sessions
|
session list List active sessions
|
||||||
|
|
||||||
|
Dashboard:
|
||||||
|
dashboard [start] Start the dashboard server (default port: 4848)
|
||||||
|
dashboard start --port <n> Start on a specific port
|
||||||
|
dashboard stop Stop the dashboard server
|
||||||
|
|
||||||
Setup:
|
Setup:
|
||||||
install Install browser binaries
|
install Install browser binaries
|
||||||
install --with-deps Also install system dependencies (Linux)
|
install --with-deps Also install system dependencies (Linux)
|
||||||
upgrade Upgrade to the latest version
|
upgrade Upgrade to the latest version
|
||||||
|
dashboard install Install the observability dashboard
|
||||||
|
|
||||||
Snapshot Options:
|
Snapshot Options:
|
||||||
-i, --interactive Only interactive elements
|
-i, --interactive Only interactive elements
|
||||||
@@ -2827,7 +2871,7 @@ Environment:
|
|||||||
AGENT_BROWSER_SESSION_NAME Auto-save/load state persistence name
|
AGENT_BROWSER_SESSION_NAME Auto-save/load state persistence name
|
||||||
AGENT_BROWSER_STATE_EXPIRE_DAYS Auto-delete saved states older than N days (default: 30)
|
AGENT_BROWSER_STATE_EXPIRE_DAYS Auto-delete saved states older than N days (default: 30)
|
||||||
AGENT_BROWSER_ENCRYPTION_KEY 64-char hex key for AES-256-GCM session encryption
|
AGENT_BROWSER_ENCRYPTION_KEY 64-char hex key for AES-256-GCM session encryption
|
||||||
AGENT_BROWSER_STREAM_PORT Enable WebSocket streaming on port (e.g., 9223)
|
AGENT_BROWSER_STREAM_PORT Override WebSocket streaming port (default: OS-assigned)
|
||||||
AGENT_BROWSER_IDLE_TIMEOUT_MS Auto-shutdown daemon after N ms of inactivity (disabled by default)
|
AGENT_BROWSER_IDLE_TIMEOUT_MS Auto-shutdown daemon after N ms of inactivity (disabled by default)
|
||||||
AGENT_BROWSER_IOS_DEVICE Default iOS device name
|
AGENT_BROWSER_IOS_DEVICE Default iOS device name
|
||||||
AGENT_BROWSER_IOS_UDID Default iOS device UDID
|
AGENT_BROWSER_IOS_UDID Default iOS device UDID
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ agent-browser stream enable [--port <port>] # Start runtime WebSocket streaming
|
|||||||
agent-browser stream status # Show runtime streaming state and bound port
|
agent-browser stream status # Show runtime streaming state and bound port
|
||||||
agent-browser stream disable # Stop runtime WebSocket streaming
|
agent-browser stream disable # Stop runtime WebSocket streaming
|
||||||
agent-browser close # Close browser (aliases: quit, exit)
|
agent-browser close # Close browser (aliases: quit, exit)
|
||||||
|
agent-browser close --all # Close all active sessions
|
||||||
```
|
```
|
||||||
|
|
||||||
## Get info
|
## Get info
|
||||||
@@ -240,7 +241,7 @@ agent-browser stream status # Show enabled state, port, browser connec
|
|||||||
agent-browser stream disable # Stop runtime streaming and remove the .stream metadata file
|
agent-browser stream disable # Stop runtime streaming and remove the .stream metadata file
|
||||||
```
|
```
|
||||||
|
|
||||||
Use `stream enable` for sessions that are already running. If you need streaming from daemon startup, set `AGENT_BROWSER_STREAM_PORT` before the first command in that session.
|
Streaming is enabled automatically for all sessions. Use these commands to check status, re-enable on a specific port, or disable streaming.
|
||||||
|
|
||||||
## Debug
|
## Debug
|
||||||
|
|
||||||
@@ -325,6 +326,15 @@ agent-browser session # Show current session name
|
|||||||
agent-browser session list # List active sessions
|
agent-browser session list # List active sessions
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Dashboard
|
||||||
|
|
||||||
|
```bash
|
||||||
|
agent-browser dashboard [start] # Start the dashboard server (default port: 4848)
|
||||||
|
agent-browser dashboard start --port <n> # Start on a specific port
|
||||||
|
agent-browser dashboard stop # Stop the dashboard server
|
||||||
|
agent-browser dashboard install # Install the dashboard files
|
||||||
|
```
|
||||||
|
|
||||||
## Navigation
|
## Navigation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ These environment variables configure additional daemon and runtime behavior:
|
|||||||
<tr><td><code>AGENT_BROWSER_ENCRYPTION_KEY</code></td><td>64-char hex key for AES-256-GCM session encryption.</td><td>(none)</td></tr>
|
<tr><td><code>AGENT_BROWSER_ENCRYPTION_KEY</code></td><td>64-char hex key for AES-256-GCM session encryption.</td><td>(none)</td></tr>
|
||||||
<tr><td><code>AGENT_BROWSER_EXTENSIONS</code></td><td>Comma-separated browser extension paths. Extensions work in both headed and headless mode.</td><td>(none)</td></tr>
|
<tr><td><code>AGENT_BROWSER_EXTENSIONS</code></td><td>Comma-separated browser extension paths. Extensions work in both headed and headless mode.</td><td>(none)</td></tr>
|
||||||
<tr><td><code>AGENT_BROWSER_HEADED</code></td><td>Show browser window instead of running headless (<code>1</code> to enable).</td><td>(disabled)</td></tr>
|
<tr><td><code>AGENT_BROWSER_HEADED</code></td><td>Show browser window instead of running headless (<code>1</code> to enable).</td><td>(disabled)</td></tr>
|
||||||
<tr><td><code>AGENT_BROWSER_STREAM_PORT</code></td><td>Enable WebSocket streaming at daemon startup on the specified port (e.g., <code>9223</code>). For an already-running session, use <code>agent-browser stream enable</code>.</td><td>(disabled)</td></tr>
|
<tr><td><code>AGENT_BROWSER_STREAM_PORT</code></td><td>Override the WebSocket streaming port. By default, an OS-assigned port is used. Set this to bind to a specific port (e.g., <code>9223</code>).</td><td>OS-assigned</td></tr>
|
||||||
<tr><td><code>AGENT_BROWSER_IDLE_TIMEOUT_MS</code></td><td>Auto-shutdown the daemon after N ms of inactivity (no commands received). Useful for ephemeral environments.</td><td>(disabled)</td></tr>
|
<tr><td><code>AGENT_BROWSER_IDLE_TIMEOUT_MS</code></td><td>Auto-shutdown the daemon after N ms of inactivity (no commands received). Useful for ephemeral environments.</td><td>(disabled)</td></tr>
|
||||||
<tr><td><code>AGENT_BROWSER_IOS_DEVICE</code></td><td>Default iOS device name for the <code>ios</code> provider.</td><td>(none)</td></tr>
|
<tr><td><code>AGENT_BROWSER_IOS_DEVICE</code></td><td>Default iOS device name for the <code>ios</code> provider.</td><td>(none)</td></tr>
|
||||||
<tr><td><code>AGENT_BROWSER_IOS_UDID</code></td><td>Default iOS device UDID for the <code>ios</code> provider.</td><td>(none)</td></tr>
|
<tr><td><code>AGENT_BROWSER_IOS_UDID</code></td><td>Default iOS device UDID for the <code>ios</code> provider.</td><td>(none)</td></tr>
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# Observability Dashboard
|
||||||
|
|
||||||
|
Monitor agent-browser sessions in real time with a local web dashboard showing a live browser viewport and command activity feed.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
Download the dashboard once:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
agent-browser dashboard install
|
||||||
|
```
|
||||||
|
|
||||||
|
This downloads the dashboard to `~/.agent-browser/dashboard/` and is served directly by the daemon when streaming is enabled.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Start the dashboard server and open any session -- it appears automatically:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
agent-browser dashboard start
|
||||||
|
agent-browser open example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open `http://localhost:4848` in your browser to see the live dashboard.
|
||||||
|
|
||||||
|
All sessions automatically stream to the dashboard. No extra flags are needed.
|
||||||
|
|
||||||
|
### Custom stream port
|
||||||
|
|
||||||
|
By default each session binds its WebSocket stream server to an OS-assigned port. To use a specific port, set the `AGENT_BROWSER_STREAM_PORT` environment variable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AGENT_BROWSER_STREAM_PORT=9223 agent-browser open example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also use the runtime commands to control streaming on a running session:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
agent-browser stream enable --port 9223
|
||||||
|
agent-browser stream status
|
||||||
|
agent-browser stream disable
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dashboard features
|
||||||
|
|
||||||
|
The dashboard is a single-page web app with three areas:
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Area</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Live viewport</strong></td>
|
||||||
|
<td>Real-time JPEG frames from the browser, rendered to a canvas element</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Activity feed</strong></td>
|
||||||
|
<td>Chronological stream of commands, results, and console messages with expandable details</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Status bar</strong></td>
|
||||||
|
<td>Connection status, viewport dimensions, and WebSocket endpoint</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
## WebSocket protocol
|
||||||
|
|
||||||
|
The dashboard connects to the same WebSocket endpoint used by [Streaming](/streaming), with additional message types for observability:
|
||||||
|
|
||||||
|
### Command events
|
||||||
|
|
||||||
|
Sent when a command begins executing:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"action": "click",
|
||||||
|
"id": "r123",
|
||||||
|
"params": { "selector": "@e5" },
|
||||||
|
"timestamp": 1711367000000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Result events
|
||||||
|
|
||||||
|
Sent when a command finishes:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "result",
|
||||||
|
"id": "r123",
|
||||||
|
"action": "click",
|
||||||
|
"success": true,
|
||||||
|
"data": {},
|
||||||
|
"duration_ms": 45,
|
||||||
|
"timestamp": 1711367000045
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Console events
|
||||||
|
|
||||||
|
Sent when the browser logs to the console:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "console",
|
||||||
|
"level": "log",
|
||||||
|
"text": "Page loaded",
|
||||||
|
"timestamp": 1711367000100
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
These are in addition to the existing `frame`, `status`, and `error` message types documented on the [Streaming](/streaming) page.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The dashboard is a Next.js static export (`output: 'export'`) that produces plain HTML, CSS, and JS. It lives at `packages/dashboard/` in the monorepo and is built with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm build:dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
The built files are served by the daemon's stream server on the same port used for WebSocket connections. Plain HTTP requests serve the dashboard, while WebSocket upgrade requests are handled as before.
|
||||||
|
|
||||||
|
When the dashboard is not installed, visiting the HTTP endpoint shows instructions to run `agent-browser dashboard install`.
|
||||||
@@ -3,27 +3,25 @@
|
|||||||
Stream the browser viewport via WebSocket for live preview or "pair browsing"
|
Stream the browser viewport via WebSocket for live preview or "pair browsing"
|
||||||
where a human can watch and interact alongside an AI agent.
|
where a human can watch and interact alongside an AI agent.
|
||||||
|
|
||||||
## Enable streaming
|
## Streaming
|
||||||
|
|
||||||
For an already-running session, enable streaming at runtime:
|
Every session automatically starts a WebSocket stream server on an OS-assigned port. The server streams viewport frames and accepts input events (mouse, keyboard, touch).
|
||||||
|
|
||||||
```bash
|
To bind to a specific port, set `AGENT_BROWSER_STREAM_PORT`:
|
||||||
agent-browser stream enable
|
|
||||||
agent-browser stream status
|
|
||||||
agent-browser stream disable
|
|
||||||
```
|
|
||||||
|
|
||||||
`stream enable` binds an available localhost port automatically unless you pass `--port <port>`. `stream status` returns the enabled state, active port, browser connection state, and whether screencasting is active. `stream disable` tears the server down and removes the session's `.stream` metadata file.
|
|
||||||
|
|
||||||
If you want the WebSocket server to exist from daemon startup, set `AGENT_BROWSER_STREAM_PORT` before the first command in that session:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
AGENT_BROWSER_STREAM_PORT=9223 agent-browser open example.com
|
AGENT_BROWSER_STREAM_PORT=9223 agent-browser open example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
The environment variable only affects daemon startup. For sessions that are already running, use `agent-browser stream enable` instead.
|
You can also manage streaming at runtime:
|
||||||
|
|
||||||
Once enabled, the server streams viewport frames and accepts input events (mouse, keyboard, touch).
|
```bash
|
||||||
|
agent-browser stream status # Show streaming state and bound port
|
||||||
|
agent-browser stream enable --port 9223 # Re-enable on a specific port
|
||||||
|
agent-browser stream disable # Stop streaming for the session
|
||||||
|
```
|
||||||
|
|
||||||
|
`stream status` returns the enabled state, active port, browser connection state, and whether screencasting is active. `stream disable` tears the server down and removes the session's `.stream` metadata file.
|
||||||
|
|
||||||
## Runtime status response
|
## Runtime status response
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -24,7 +24,8 @@
|
|||||||
"postinstall": "node scripts/postinstall.js",
|
"postinstall": "node scripts/postinstall.js",
|
||||||
"changeset": "changeset",
|
"changeset": "changeset",
|
||||||
"ci:version": "changeset version && pnpm run version:sync && pnpm install --no-frozen-lockfile",
|
"ci:version": "changeset version && pnpm run version:sync && pnpm install --no-frozen-lockfile",
|
||||||
"ci:publish": "pnpm run version:sync && changeset publish"
|
"ci:publish": "pnpm run version:sync && changeset publish",
|
||||||
|
"build:dashboard": "cd packages/dashboard && pnpm build"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"browser",
|
"browser",
|
||||||
|
|||||||
@@ -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": {}
|
||||||
|
}
|
||||||
Vendored
+6
@@ -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.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const config: NextConfig = {
|
||||||
|
output: "export",
|
||||||
|
images: { unoptimized: true },
|
||||||
|
devIndicators: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">></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">"{value}"</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">"{k}"</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">●</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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -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 "";
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
Generated
+10789
-6
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
|||||||
|
packages:
|
||||||
|
- 'packages/*'
|
||||||
|
- 'docs'
|
||||||
@@ -110,6 +110,7 @@ See [references/authentication.md](references/authentication.md) for OAuth, 2FA,
|
|||||||
# Navigation
|
# Navigation
|
||||||
agent-browser open <url> # Navigate (aliases: goto, navigate)
|
agent-browser open <url> # Navigate (aliases: goto, navigate)
|
||||||
agent-browser close # Close browser
|
agent-browser close # Close browser
|
||||||
|
agent-browser close --all # Close all active sessions
|
||||||
|
|
||||||
# Snapshot
|
# Snapshot
|
||||||
agent-browser snapshot -i # Interactive elements with refs (recommended)
|
agent-browser snapshot -i # Interactive elements with refs (recommended)
|
||||||
@@ -198,11 +199,9 @@ agent-browser diff url <url1> <url2> --wait-until networkidle # Custom wait str
|
|||||||
agent-browser diff url <url1> <url2> --selector "#main" # Scope to element
|
agent-browser diff url <url1> <url2> --selector "#main" # Scope to element
|
||||||
```
|
```
|
||||||
|
|
||||||
## Runtime Streaming
|
## Streaming
|
||||||
|
|
||||||
Use `agent-browser stream enable` when you need a live WebSocket preview for an already-running session. This is the preferred runtime path because it does not require restarting the daemon. `stream enable` creates the server, `stream status` reports the bound port and connection state, and `stream disable` tears it down cleanly.
|
Every session automatically starts a WebSocket stream server on an OS-assigned port. Use `agent-browser stream status` to see the bound port and connection state. Use `stream disable` to tear it down, and `stream enable --port <port>` to re-enable on a specific port.
|
||||||
|
|
||||||
If streaming must be present from the first daemon command, `AGENT_BROWSER_STREAM_PORT` still works at daemon startup, but that environment variable is not retroactive for sessions that are already running.
|
|
||||||
|
|
||||||
## Batch Execution
|
## Batch Execution
|
||||||
|
|
||||||
@@ -578,9 +577,10 @@ Always close your browser session when done to avoid leaked processes:
|
|||||||
```bash
|
```bash
|
||||||
agent-browser close # Close default session
|
agent-browser close # Close default session
|
||||||
agent-browser --session agent1 close # Close specific session
|
agent-browser --session agent1 close # Close specific session
|
||||||
|
agent-browser close --all # Close all active sessions
|
||||||
```
|
```
|
||||||
|
|
||||||
If a previous session was not closed properly, the daemon may still be running. Use `agent-browser close` to clean it up before starting new work.
|
If a previous session was not closed properly, the daemon may still be running. Use `agent-browser close` to clean it up, or `agent-browser close --all` to shut down every session at once.
|
||||||
|
|
||||||
To auto-shutdown the daemon after a period of inactivity (useful for ephemeral/CI environments):
|
To auto-shutdown the daemon after a period of inactivity (useful for ephemeral/CI environments):
|
||||||
|
|
||||||
@@ -712,6 +712,26 @@ Supported engines:
|
|||||||
|
|
||||||
Lightpanda does not support `--extension`, `--profile`, `--state`, or `--allow-file-access`. Install Lightpanda from https://lightpanda.io/docs/open-source/installation.
|
Lightpanda does not support `--extension`, `--profile`, `--state`, or `--allow-file-access`. Install Lightpanda from https://lightpanda.io/docs/open-source/installation.
|
||||||
|
|
||||||
|
## Observability Dashboard
|
||||||
|
|
||||||
|
The dashboard is a standalone background server that shows live browser viewports, command activity, and console output for all sessions.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install the dashboard once
|
||||||
|
agent-browser dashboard install
|
||||||
|
|
||||||
|
# Start the dashboard server (background, port 4848)
|
||||||
|
agent-browser dashboard start
|
||||||
|
|
||||||
|
# All sessions are automatically visible in the dashboard
|
||||||
|
agent-browser open example.com
|
||||||
|
|
||||||
|
# Stop the dashboard
|
||||||
|
agent-browser dashboard stop
|
||||||
|
```
|
||||||
|
|
||||||
|
The dashboard runs independently of browser sessions on port 4848 (configurable with `--port`). All sessions automatically stream to the dashboard.
|
||||||
|
|
||||||
## Ready-to-Use Templates
|
## Ready-to-Use Templates
|
||||||
|
|
||||||
| Template | Description |
|
| Template | Description |
|
||||||
|
|||||||
@@ -287,6 +287,6 @@ AGENT_BROWSER_SESSION="mysession" # Default session name
|
|||||||
AGENT_BROWSER_EXECUTABLE_PATH="/path/chrome" # Custom browser path
|
AGENT_BROWSER_EXECUTABLE_PATH="/path/chrome" # Custom browser path
|
||||||
AGENT_BROWSER_EXTENSIONS="/ext1,/ext2" # Comma-separated extension paths
|
AGENT_BROWSER_EXTENSIONS="/ext1,/ext2" # Comma-separated extension paths
|
||||||
AGENT_BROWSER_PROVIDER="browserbase" # Cloud browser provider
|
AGENT_BROWSER_PROVIDER="browserbase" # Cloud browser provider
|
||||||
AGENT_BROWSER_STREAM_PORT="9223" # WebSocket streaming port
|
AGENT_BROWSER_STREAM_PORT="9223" # Override WebSocket streaming port (default: OS-assigned)
|
||||||
AGENT_BROWSER_HOME="/path/to/agent-browser" # Custom install location
|
AGENT_BROWSER_HOME="/path/to/agent-browser" # Custom install location
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user