feat: redesign web dashboard with workspace-centric layout (#607)
* feat: redesign web dashboard with workspace-centric layout Replaces the flat session-list web UI with a Conductor-inspired workspace layout: - Sidebar groups sessions by project directory with expandable tree - Split content pane: terminal left, inline diff right (resizable, collapsible) - Workspace header with lifecycle badges and action buttons - New session creation modal with project combobox and agent grid - Terminal auto-reconnect with countdown (3 retries, then manual) - Mobile: collapsible sidebar overlay, responsive font sizes, touch targets Backend changes: - Print all network interface IPs (including Tailscale) on aoe serve - Persist auth token for 24 hours so URLs survive restarts - Unset TMUX env in PTY spawn so serve works inside tmux - Remove unused API endpoints (groups, profiles, worktrees, delete, update) - Bump cargo build parallelism from 4 to 8 Also removes 12 dead component files and consolidates duplicated status color mappings and session-active checks into shared lib/session.ts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: default sidebar closed on mobile, remove duplicate empty state - Sidebar defaults to closed on viewports < 768px so mobile users see the main content immediately instead of a full-screen overlay - Removed duplicate "No sessions yet" message from sidebar; the main content area already shows the CTA. Sidebar only shows "No matches" when actively searching. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: rewrite Playwright tests for workspace-centric dashboard Rewrites all 34 e2e tests for the new UI layout: - Dashboard layout: header, empty state, offline indicator - Sidebar: toggle, desktop default open, search - Create session modal: all fields, submit states, close behaviors - Settings: gear button, keyboard shortcut - Keyboard shortcuts: n, D, ?, s, Escape - Mobile: sidebar closed by default, overlay, touch targets - Design system: warm navy background, DM Sans font, focus rings Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: group worktree sessions by main repo path in sidebar Worktree sessions have project_path pointing to the worktree directory (e.g., ~/.agent-of-empires/worktrees/feat-auth), not the original repo. This caused the sidebar to show the worktree dir name instead of the repo name. Now uses main_repo_path from the API (the actual repo root) for grouping when available, falling back to project_path for non-worktree sessions. So all sessions for agent-of-empires show under "agent-of-empires" regardless of whether they use worktrees. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: remove screenshot artifacts Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: show static title in header instead of selected session name Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove header title text entirely Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: flatten sidebar to simple session list, remove nesting Removes project directory grouping, expand/collapse state, and project status aggregation. Sessions are now a flat list with status dot, name, and agent badge. Simpler foundation to build on. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: remove multi-agent session tabs from content area Each session gets its own sidebar entry now, no need for tabs within the terminal pane. Removes handleSelectSessionTab dead code. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: remove search box from sidebar Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: rebuild web frontend on source changes instead of skipping build.rs previously short-circuited when web/dist/ existed, silently ignoring source changes. Now it always runs npm run build when cargo detects web source file changes via rerun-if-changed directives. Also skips npm install when node_modules already exists to keep incremental rebuilds fast. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: check .package-lock.json for npm install completeness, remove unused var Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add non-null assertion for groupSessions[0] in useWorkspaces Groups are always created with at least one session, so the first element is never undefined. TypeScript strict mode flagged it. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: replace sun icon with gear, move settings to sidebar footer The SVG was a sun/brightness icon, not a gear. Replaced with a proper gear icon (Lucide settings path). Moved from the header to the sidebar footer so it's accessible whether sidebar is open or closed on mobile. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: replace hamburger icon with sidebar panel toggle icon Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: swap blue-tinted navy surfaces to neutral slate Replaces the warm navy palette (#0f172a etc) with neutral zinc tones (#18181b etc) for a cleaner, more professional dark background. Updates terminal theme to match. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: resizable sidebar, filter button, and diff panel toggle - Sidebar is draggable to resize (200-480px), width persisted in localStorage - Filter button (funnel icon) next to New Session toggles a search input that filters sessions by name, branch, agent, or title - Right sidebar toggle (mirrored panel icon) in header to show/hide the diff panel, highlights when panel is open Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: split right panel into diff viewer (top) and shell (bottom) The right sidebar is now vertically split: git diff viewer in the upper half, paired terminal shell in the lower half. The shell section has a host/container toggle that appears when the session is sandboxed. Shell terminals are placeholder for now (marked "coming soon"). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: allow right panel to open even with no session selected ContentSplit and RightPanel now always render. When no session is selected, the left pane shows the empty state and the right pane shows empty diff/shell placeholders. The toggle button in the header works regardless. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove unused TerminalView import from RightPanel Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: replace loud New Session button with subtle header + icon Sidebar header now shows "SESSIONS" label with a gray + icon button and filter icon. Much less visually dominant than the full-width amber button. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: diff panel stuck on loading, sessions header casing The diff panel got stuck on "Loading changes..." when the API returned an empty diff (raw === ""). The early return skipped setLoading(false). Fixed by moving setLoading outside the cache comparison. Also changed "SESSIONS" header to normal case "Sessions". Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: wire paired host/container terminals in right panel Adds two new WebSocket endpoints: - /sessions/{id}/terminal/ws - attaches to the TerminalSession (host shell) - /sessions/{id}/container-terminal/ws - attaches to ContainerTerminalSession The right panel's lower half now connects to the actual paired tmux terminal sessions instead of showing a placeholder. Host/Container toggle switches between the two WebSocket paths. Only shows the container toggle when the session is sandboxed. useTerminal now accepts a wsPath parameter to support different WebSocket endpoints while reusing all the xterm/reconnect logic. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: auto-create paired terminal on demand from web dashboard Adds POST /api/sessions/{id}/terminal endpoint that calls instance.start_terminal() to create the paired tmux session if it doesn't already exist. The frontend calls this automatically when the right panel's shell section mounts, so users don't need to pre-create terminals from the TUI. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: auto-create container terminal on demand Adds POST /api/sessions/{id}/container-terminal endpoint that calls start_container_terminal_with_size() to create the container tmux session. The frontend now calls the appropriate endpoint (host or container) based on the shell mode toggle before connecting the WebSocket. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: match header background to sidebar (surface-900) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: switch fonts from DM Sans/Satoshi to Inter Inter is the standard developer tool font (Linear, Vercel, Raycast). Replaces DM Sans (body) and Satoshi (display) with Inter for both, giving the UI a cleaner, more IDE-like feel. Removes the Fontshare CDN dependency since Satoshi is no longer loaded. Also updates theme-color meta tag to match the neutral slate palette. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: lighten sidebar/header from near-black to slate gray surface-900 (#18181b -> #1e1e21) and surface-850 (#202023 -> #232326) are now noticeably lighter, giving the sidebar and header a slate feel rather than pure black. Terminal background (surface-950) stays dark. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: invisible resize handles, only show cursor and highlight on hover Removed the visible bg-surface-700 border from both sidebar and content split drag handles. Now invisible by default, cursor changes to col-resize on hover, and a subtle highlight appears while hovering. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: remove stop/restart/review/archive buttons and dead code Removes: - Stop and Restart buttons from WorkspaceHeader - Review, Archive, Unarchive lifecycle buttons - handleStop, handleRestart, handleLifecycleChange from App.tsx - stopSession, restartSession API functions - stop_session, restart_session Rust handlers and routes - setLifecycleOverride, getLifecycleOverrides, localStorage lifecycle system - WorkspaceStatus "reviewing" and "archived" variants (now just active/idle) - Unused Status import from api.rs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove Settings text label, keep just the gear icon Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: match terminal background to Ghostty default (#282c34) Swaps the near-black terminal background to Ghostty's default warm dark gray (#282c34). Feels more like a real terminal and less like a void. Updates both the xterm.js theme and the CSS surface-950 token. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: sidebar lighter than content, subtler borders (Conductor-style) Restructures the surface hierarchy: - Sidebar + header: surface-800 (#2c2c30) - lightest, like Conductor - Main content/workspace header: surface-900 (#1c1c1f) - darker - Terminal: surface-950 (#17171a) - darkest Softens all borders to border-surface-700/20 (barely visible) instead of solid borders, matching Conductor's refined restraint. The visual depth now comes from surface color differences, not hard lines. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: subtle filter icon active state instead of orange highlight Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: subtle active state for right sidebar toggle too Both header panel toggle buttons now use dim/secondary gray states instead of orange highlights. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: left sidebar toggle highlights when sidebar is open Matches the right panel toggle behavior: dim when closed, slightly brighter when open. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: cargo fmt Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: replace create session modal with "not supported yet" dialog Removes CreateWorkspaceModal, createSession API function, fetchAgents, AgentInfo type, pendingSelectRef, knownPaths memo, and handleCreate. The + button now shows a simple dialog directing users to the CLI. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: hide right panel on mobile viewports The right pane (diff + shell) and its drag handle are now hidden below md breakpoint. On mobile the terminal gets the full width. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: right panel toggle visible on mobile, opens as overlay The right panel toggle button is now always visible in the header. On mobile, tapping it opens the diff/shell panel as a full-screen overlay with a close button. On desktop, behavior is unchanged (inline split pane with drag handle). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -1,5 +1,5 @@
|
||||
[build]
|
||||
jobs = 4
|
||||
jobs = 8
|
||||
|
||||
[alias]
|
||||
xtask = "run -p xtask --"
|
||||
|
||||
@@ -57,3 +57,4 @@ debug.log
|
||||
website/.next/
|
||||
.worktrees/
|
||||
.gstack/
|
||||
.playwright-mcp/
|
||||
|
||||
Generated
+1
@@ -1709,6 +1709,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"cfg_aliases 0.2.1",
|
||||
"libc",
|
||||
"memoffset",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+1
-1
@@ -72,7 +72,7 @@ tokio-util = { version = "0.7", features = ["codec"] }
|
||||
cfg-if = "1.0"
|
||||
|
||||
# Process handling
|
||||
nix = { version = "0.31", features = ["signal", "process"] }
|
||||
nix = { version = "0.31", features = ["signal", "process", "net"] }
|
||||
|
||||
# Unicode width
|
||||
unicode-width = "0.2"
|
||||
|
||||
@@ -69,18 +69,16 @@ fn build_frontend() {
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
let web_dist = Path::new("web/dist");
|
||||
|
||||
// Only rebuild frontend if dist/ is missing or source files changed
|
||||
println!("cargo:rerun-if-changed=web/src");
|
||||
println!("cargo:rerun-if-changed=web/index.html");
|
||||
println!("cargo:rerun-if-changed=web/package.json");
|
||||
println!("cargo:rerun-if-changed=web/vite.config.ts");
|
||||
println!("cargo:rerun-if-changed=web/tsconfig.json");
|
||||
|
||||
if web_dist.exists() && web_dist.join("index.html").exists() {
|
||||
return;
|
||||
}
|
||||
// Always rebuild: the rerun-if-changed directives above ensure this
|
||||
// function only runs when web source files actually changed.
|
||||
// Previously this short-circuited when dist/ existed, which meant
|
||||
// source changes were silently ignored.
|
||||
|
||||
eprintln!("Building web frontend...");
|
||||
|
||||
@@ -89,14 +87,17 @@ fn build_frontend() {
|
||||
"npm is required to build with --features serve. Install Node.js: https://nodejs.org/"
|
||||
);
|
||||
|
||||
let status = Command::new("npm")
|
||||
.args(["install"])
|
||||
.current_dir("web")
|
||||
.status()
|
||||
.expect("Failed to run npm install");
|
||||
// Run npm install when node_modules is missing or incomplete
|
||||
if !Path::new("web/node_modules/.package-lock.json").exists() {
|
||||
let status = Command::new("npm")
|
||||
.args(["install"])
|
||||
.current_dir("web")
|
||||
.status()
|
||||
.expect("Failed to run npm install");
|
||||
|
||||
if !status.success() {
|
||||
panic!("npm install failed in web/. Run `cd web && npm install` to debug.");
|
||||
if !status.success() {
|
||||
panic!("npm install failed in web/. Run `cd web && npm install` to debug.");
|
||||
}
|
||||
}
|
||||
|
||||
let status = Command::new("npm")
|
||||
|
||||
+78
-356
@@ -1,4 +1,4 @@
|
||||
//! REST API handlers for session management, groups, profiles, and agents.
|
||||
//! REST API handlers for session management and agents.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -10,7 +10,10 @@ use axum::{
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::session::{Instance, Status, Storage};
|
||||
use crate::session::{Instance, Storage};
|
||||
|
||||
#[cfg(test)]
|
||||
use crate::session::Status;
|
||||
|
||||
use super::AppState;
|
||||
|
||||
@@ -29,6 +32,7 @@ pub struct SessionResponse {
|
||||
pub last_accessed_at: Option<String>,
|
||||
pub last_error: Option<String>,
|
||||
pub branch: Option<String>,
|
||||
pub main_repo_path: Option<String>,
|
||||
pub is_sandboxed: bool,
|
||||
pub has_terminal: bool,
|
||||
}
|
||||
@@ -47,6 +51,10 @@ impl From<&Instance> for SessionResponse {
|
||||
last_accessed_at: inst.last_accessed_at.map(|t| t.to_rfc3339()),
|
||||
last_error: inst.last_error.clone(),
|
||||
branch: inst.worktree_info.as_ref().map(|w| w.branch.clone()),
|
||||
main_repo_path: inst
|
||||
.worktree_info
|
||||
.as_ref()
|
||||
.map(|w| w.main_repo_path.clone()),
|
||||
is_sandboxed: inst.is_sandboxed(),
|
||||
has_terminal: inst.terminal_info.is_some(),
|
||||
}
|
||||
@@ -59,140 +67,6 @@ pub async fn list_sessions(State(state): State<Arc<AppState>>) -> Json<Vec<Sessi
|
||||
Json(sessions)
|
||||
}
|
||||
|
||||
pub async fn get_session(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let instances = state.instances.read().await;
|
||||
match instances.iter().find(|i| i.id == id) {
|
||||
Some(inst) => (
|
||||
StatusCode::OK,
|
||||
Json(
|
||||
serde_json::to_value(SessionResponse::from(inst))
|
||||
.expect("SessionResponse is always serializable"),
|
||||
),
|
||||
)
|
||||
.into_response(),
|
||||
None => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"error": "not_found", "message": "Session not found"})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn stop_session(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
if state.read_only {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(
|
||||
serde_json::json!({"error": "read_only", "message": "Server is in read-only mode"}),
|
||||
),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let instances = state.instances.read().await;
|
||||
let inst = match instances.iter().find(|i| i.id == id) {
|
||||
Some(i) => i.clone(),
|
||||
None => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"error": "not_found", "message": "Session not found"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
drop(instances);
|
||||
|
||||
// Run the blocking stop operation in a dedicated thread
|
||||
let result = tokio::task::spawn_blocking(move || inst.stop()).await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(())) => {
|
||||
// Update status in our cache
|
||||
let mut instances = state.instances.write().await;
|
||||
if let Some(inst) = instances.iter_mut().find(|i| i.id == id) {
|
||||
inst.status = Status::Stopped;
|
||||
}
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({"status": "stopped"})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Ok(Err(e)) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": "stop_failed", "message": e.to_string()})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": "internal", "message": e.to_string()})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn restart_session(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
if state.read_only {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(
|
||||
serde_json::json!({"error": "read_only", "message": "Server is in read-only mode"}),
|
||||
),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let mut instances = state.instances.write().await;
|
||||
let inst = match instances.iter_mut().find(|i| i.id == id) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"error": "not_found", "message": "Session not found"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let mut inst_clone = inst.clone();
|
||||
drop(instances);
|
||||
|
||||
let result = tokio::task::spawn_blocking(move || inst_clone.start()).await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(())) => {
|
||||
let mut instances = state.instances.write().await;
|
||||
if let Some(inst) = instances.iter_mut().find(|i| i.id == id) {
|
||||
inst.status = Status::Starting;
|
||||
}
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({"status": "starting"})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Ok(Err(e)) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": "restart_failed", "message": e.to_string()})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": "internal", "message": e.to_string()})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Create session ---
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -279,7 +153,6 @@ pub async fn create_session(
|
||||
match result {
|
||||
Ok(Ok(instance)) => {
|
||||
let resp = SessionResponse::from(&instance);
|
||||
// Update in-memory cache
|
||||
let mut instances = state.instances.write().await;
|
||||
instances.push(instance);
|
||||
(
|
||||
@@ -301,73 +174,56 @@ pub async fn create_session(
|
||||
}
|
||||
}
|
||||
|
||||
// --- Delete session ---
|
||||
// --- Paired terminal ---
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DeleteOptions {
|
||||
#[serde(default)]
|
||||
pub delete_worktree: bool,
|
||||
#[serde(default)]
|
||||
pub delete_branch: bool,
|
||||
#[serde(default)]
|
||||
pub delete_sandbox: bool,
|
||||
}
|
||||
|
||||
pub async fn delete_session(
|
||||
pub async fn ensure_terminal(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
body: Option<Json<DeleteOptions>>,
|
||||
) -> impl IntoResponse {
|
||||
if state.read_only {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(
|
||||
serde_json::json!({"error": "read_only", "message": "Server is in read-only mode"}),
|
||||
),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let mut instances = state.instances.write().await;
|
||||
let inst = match instances.iter().find(|i| i.id == id) {
|
||||
Some(i) => i.clone(),
|
||||
let inst = match instances.iter_mut().find(|i| i.id == id) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"error": "not_found", "message": "Session not found"})),
|
||||
Json(serde_json::json!({"error": "not_found"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let profile = inst.source_profile.clone();
|
||||
let _opts = body.map(|b| b.0);
|
||||
if inst.has_terminal() {
|
||||
return (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({"status": "exists"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Remove from in-memory cache
|
||||
instances.retain(|i| i.id != id);
|
||||
let remaining: Vec<Instance> = instances
|
||||
.iter()
|
||||
.filter(|i| i.source_profile == profile)
|
||||
.cloned()
|
||||
.collect();
|
||||
let mut inst_clone = inst.clone();
|
||||
drop(instances);
|
||||
|
||||
// Persist removal to disk
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
if let Ok(storage) = Storage::new(&profile) {
|
||||
storage.save(&remaining)?;
|
||||
}
|
||||
// Kill tmux session
|
||||
let _ = inst.stop();
|
||||
Ok::<_, anyhow::Error>(())
|
||||
})
|
||||
.await;
|
||||
let result = tokio::task::spawn_blocking(move || inst_clone.start_terminal()).await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(())) => (StatusCode::OK, Json(serde_json::json!({"deleted": true}))).into_response(),
|
||||
Ok(Ok(())) => {
|
||||
// Update in-memory cache
|
||||
let mut instances = state.instances.write().await;
|
||||
if let Some(inst) = instances.iter_mut().find(|i| i.id == id) {
|
||||
inst.terminal_info = Some(crate::session::TerminalInfo {
|
||||
created: true,
|
||||
created_at: Some(chrono::Utc::now()),
|
||||
});
|
||||
}
|
||||
(
|
||||
StatusCode::CREATED,
|
||||
Json(serde_json::json!({"status": "created"})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Ok(Err(e)) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": "delete_failed", "message": e.to_string()})),
|
||||
Json(serde_json::json!({"error": "create_failed", "message": e.to_string()})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => (
|
||||
@@ -378,77 +234,54 @@ pub async fn delete_session(
|
||||
}
|
||||
}
|
||||
|
||||
// --- Update (rename/move) session ---
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateSessionBody {
|
||||
pub title: Option<String>,
|
||||
pub group_path: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn update_session(
|
||||
pub async fn ensure_container_terminal(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
Json(body): Json<UpdateSessionBody>,
|
||||
) -> impl IntoResponse {
|
||||
if state.read_only {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(
|
||||
serde_json::json!({"error": "read_only", "message": "Server is in read-only mode"}),
|
||||
),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let mut instances = state.instances.write().await;
|
||||
let found = instances.iter().any(|i| i.id == id);
|
||||
if !found {
|
||||
let inst = match instances.iter_mut().find(|i| i.id == id) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"error": "not_found"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if inst.has_container_terminal() {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"error": "not_found", "message": "Session not found"})),
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({"status": "exists"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Apply updates
|
||||
for inst in instances.iter_mut() {
|
||||
if inst.id == id {
|
||||
if let Some(title) = &body.title {
|
||||
inst.title = title.clone();
|
||||
}
|
||||
if let Some(group) = &body.group_path {
|
||||
inst.group_path = group.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let inst = instances
|
||||
.iter()
|
||||
.find(|i| i.id == id)
|
||||
.expect("instance must exist after any() check above");
|
||||
let profile = inst.source_profile.clone();
|
||||
let resp = SessionResponse::from(inst);
|
||||
let all_for_profile: Vec<Instance> = instances
|
||||
.iter()
|
||||
.filter(|i| i.source_profile == profile)
|
||||
.cloned()
|
||||
.collect();
|
||||
let mut inst_clone = inst.clone();
|
||||
drop(instances);
|
||||
|
||||
// Persist to disk
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
if let Ok(storage) = Storage::new(&profile) {
|
||||
let _ = storage.save(&all_for_profile);
|
||||
}
|
||||
})
|
||||
.await;
|
||||
let result =
|
||||
tokio::task::spawn_blocking(move || inst_clone.start_container_terminal_with_size(None))
|
||||
.await;
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::to_value(resp).expect("SessionResponse is always serializable")),
|
||||
)
|
||||
.into_response()
|
||||
match result {
|
||||
Ok(Ok(())) => (
|
||||
StatusCode::CREATED,
|
||||
Json(serde_json::json!({"status": "created"})),
|
||||
)
|
||||
.into_response(),
|
||||
Ok(Err(e)) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": "create_failed", "message": e.to_string()})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": "internal", "message": e.to_string()})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Diff ---
|
||||
@@ -489,7 +322,6 @@ pub async fn session_diff(
|
||||
.output()?;
|
||||
let raw = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
|
||||
// Get changed file list
|
||||
let status_output = std::process::Command::new("git")
|
||||
.args(["diff", "HEAD", "--name-status"])
|
||||
.current_dir(&project_path)
|
||||
@@ -551,85 +383,6 @@ pub async fn list_agents() -> Json<Vec<AgentInfo>> {
|
||||
Json(agents)
|
||||
}
|
||||
|
||||
// --- Groups ---
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct GroupInfo {
|
||||
pub path: String,
|
||||
pub session_count: usize,
|
||||
}
|
||||
|
||||
pub async fn list_groups(State(state): State<Arc<AppState>>) -> Json<Vec<GroupInfo>> {
|
||||
let instances = state.instances.read().await;
|
||||
let mut groups: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
||||
|
||||
for inst in instances.iter() {
|
||||
if !inst.group_path.is_empty() {
|
||||
*groups.entry(inst.group_path.clone()).or_default() += 1;
|
||||
// Also count parent paths so hierarchy is visible
|
||||
let parts: Vec<&str> = inst.group_path.split('/').collect();
|
||||
for i in 1..parts.len() {
|
||||
let parent = parts[..i].join("/");
|
||||
groups.entry(parent).or_default();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut result: Vec<GroupInfo> = groups
|
||||
.into_iter()
|
||||
.map(|(path, session_count)| GroupInfo {
|
||||
path,
|
||||
session_count,
|
||||
})
|
||||
.collect();
|
||||
result.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
Json(result)
|
||||
}
|
||||
|
||||
// --- Profiles ---
|
||||
|
||||
pub async fn list_profiles() -> impl IntoResponse {
|
||||
match crate::session::list_profiles() {
|
||||
Ok(profiles) => (StatusCode::OK, Json(serde_json::json!(profiles))).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": "list_failed", "message": e.to_string()})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateProfileBody {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
pub async fn create_profile(Json(body): Json<CreateProfileBody>) -> impl IntoResponse {
|
||||
match crate::session::create_profile(&body.name) {
|
||||
Ok(()) => (
|
||||
StatusCode::CREATED,
|
||||
Json(serde_json::json!({"created": true, "name": body.name})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"error": "create_failed", "message": e.to_string()})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_profile(Path(name): Path<String>) -> impl IntoResponse {
|
||||
match crate::session::delete_profile(&name) {
|
||||
Ok(()) => (StatusCode::OK, Json(serde_json::json!({"deleted": true}))).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"error": "delete_failed", "message": e.to_string()})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Settings ---
|
||||
|
||||
pub async fn get_settings() -> impl IntoResponse {
|
||||
@@ -651,18 +404,15 @@ pub async fn get_settings() -> impl IntoResponse {
|
||||
}
|
||||
|
||||
pub async fn update_settings(Json(body): Json<serde_json::Value>) -> impl IntoResponse {
|
||||
// Load current config, merge updates, save
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let mut config = crate::session::Config::load().unwrap_or_default();
|
||||
|
||||
// Merge the incoming JSON into the existing config
|
||||
let config = crate::session::Config::load().unwrap_or_default();
|
||||
let mut current = serde_json::to_value(&config)?;
|
||||
if let (Some(current_obj), Some(update_obj)) = (current.as_object_mut(), body.as_object()) {
|
||||
for (key, value) in update_obj {
|
||||
current_obj.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
config = serde_json::from_value(current)?;
|
||||
let config: crate::session::Config = serde_json::from_value(current)?;
|
||||
crate::session::save_config(&config)?;
|
||||
Ok::<_, anyhow::Error>(config)
|
||||
})
|
||||
@@ -701,34 +451,6 @@ pub async fn list_themes() -> Json<Vec<String>> {
|
||||
)
|
||||
}
|
||||
|
||||
// --- Worktrees ---
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct WorktreeInfo {
|
||||
pub session_id: String,
|
||||
pub session_title: String,
|
||||
pub branch: String,
|
||||
pub main_repo_path: String,
|
||||
pub managed_by_aoe: bool,
|
||||
}
|
||||
|
||||
pub async fn list_worktrees(State(state): State<Arc<AppState>>) -> Json<Vec<WorktreeInfo>> {
|
||||
let instances = state.instances.read().await;
|
||||
let worktrees: Vec<WorktreeInfo> = instances
|
||||
.iter()
|
||||
.filter_map(|inst| {
|
||||
inst.worktree_info.as_ref().map(|wt| WorktreeInfo {
|
||||
session_id: inst.id.clone(),
|
||||
session_title: inst.title.clone(),
|
||||
branch: wt.branch.clone(),
|
||||
main_repo_path: wt.main_repo_path.clone(),
|
||||
managed_by_aoe: wt.managed_by_aoe,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Json(worktrees)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+97
-38
@@ -38,7 +38,7 @@ pub async fn start_server(
|
||||
// Load initial session data from all profiles
|
||||
let instances = load_all_instances()?;
|
||||
|
||||
// Generate auth token
|
||||
// Load or generate auth token (reused for 24 hours so bookmarked URLs keep working)
|
||||
let auth_token = if no_auth {
|
||||
eprintln!(
|
||||
"WARNING: Running without authentication. \
|
||||
@@ -46,19 +46,7 @@ pub async fn start_server(
|
||||
);
|
||||
None
|
||||
} else {
|
||||
use rand::RngExt;
|
||||
let mut rng = rand::rng();
|
||||
let token: String = (0..32)
|
||||
.map(|_| {
|
||||
let idx = rng.random_range(0..36u8);
|
||||
if idx < 10 {
|
||||
(b'0' + idx) as char
|
||||
} else {
|
||||
(b'a' + idx - 10) as char
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Some(token)
|
||||
Some(load_or_generate_token()?)
|
||||
};
|
||||
|
||||
let state = Arc::new(AppState {
|
||||
@@ -74,23 +62,34 @@ pub async fn start_server(
|
||||
let addr = format!("{}:{}", host, port);
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||
|
||||
// Build and print access URL
|
||||
let display_host = if host == "0.0.0.0" { "localhost" } else { host };
|
||||
let url = if let Some(ref token) = auth_token {
|
||||
format!("http://{}:{}/?token={}", display_host, port, token)
|
||||
} else {
|
||||
format!("http://{}:{}/", display_host, port)
|
||||
// Build and print access URLs
|
||||
let make_url = |h: &str| {
|
||||
if let Some(ref token) = auth_token {
|
||||
format!("http://{}:{}/?token={}", h, port, token)
|
||||
} else {
|
||||
format!("http://{}:{}/", h, port)
|
||||
}
|
||||
};
|
||||
|
||||
println!("aoe web dashboard running at:");
|
||||
println!(" {}", url);
|
||||
if host == "0.0.0.0" {
|
||||
println!(" {}", make_url("localhost"));
|
||||
// Discover and print all network interface addresses
|
||||
for addr in discover_local_ips() {
|
||||
println!(" {}", make_url(&addr));
|
||||
}
|
||||
} else {
|
||||
println!(" {}", make_url(host));
|
||||
}
|
||||
if auth_token.is_some() {
|
||||
println!();
|
||||
println!(
|
||||
"Open this URL in any browser. Share it to access from other devices on your network."
|
||||
"Open any URL above in a browser. Share it to access from other devices on your network."
|
||||
);
|
||||
}
|
||||
|
||||
let url = make_url(if host == "0.0.0.0" { "localhost" } else { host });
|
||||
|
||||
// Write URL to file so daemon users can retrieve it with `cat ~/.agent-of-empires/serve.url`
|
||||
if let Ok(app_dir) = crate::session::get_app_dir() {
|
||||
let _ = std::fs::write(app_dir.join("serve.url"), &url);
|
||||
@@ -107,38 +106,35 @@ pub async fn start_server(
|
||||
}
|
||||
|
||||
fn build_router(state: Arc<AppState>) -> Router {
|
||||
use axum::routing::{delete, get, patch, post};
|
||||
use axum::routing::{get, post};
|
||||
|
||||
Router::new()
|
||||
// Session CRUD
|
||||
// Sessions
|
||||
.route(
|
||||
"/api/sessions",
|
||||
get(api::list_sessions).post(api::create_session),
|
||||
)
|
||||
.route("/api/sessions/{id}", get(api::get_session))
|
||||
.route("/api/sessions/{id}/stop", post(api::stop_session))
|
||||
.route("/api/sessions/{id}/restart", post(api::restart_session))
|
||||
.route("/api/sessions/{id}", delete(api::delete_session))
|
||||
.route("/api/sessions/{id}", patch(api::update_session))
|
||||
.route("/api/sessions/{id}/diff", get(api::session_diff))
|
||||
.route("/api/sessions/{id}/terminal", post(api::ensure_terminal))
|
||||
.route(
|
||||
"/api/sessions/{id}/container-terminal",
|
||||
post(api::ensure_container_terminal),
|
||||
)
|
||||
// Agents
|
||||
.route("/api/agents", get(api::list_agents))
|
||||
// Groups
|
||||
.route("/api/groups", get(api::list_groups))
|
||||
// Profiles
|
||||
.route("/api/profiles", get(api::list_profiles))
|
||||
.route("/api/profiles", post(api::create_profile))
|
||||
.route("/api/profiles/{name}", delete(api::delete_profile))
|
||||
// Settings + themes
|
||||
.route(
|
||||
"/api/settings",
|
||||
get(api::get_settings).patch(api::update_settings),
|
||||
)
|
||||
.route("/api/themes", get(api::list_themes))
|
||||
// Worktrees
|
||||
.route("/api/worktrees", get(api::list_worktrees))
|
||||
// Terminal
|
||||
// Terminal WebSockets
|
||||
.route("/sessions/{id}/ws", get(ws::terminal_ws))
|
||||
.route("/sessions/{id}/terminal/ws", get(ws::paired_terminal_ws))
|
||||
.route(
|
||||
"/sessions/{id}/container-terminal/ws",
|
||||
get(ws::container_terminal_ws),
|
||||
)
|
||||
// Static assets (Vite build output: assets/, manifest.json, sw.js, icons)
|
||||
.route("/assets/{*path}", get(serve_asset))
|
||||
.route("/manifest.json", get(serve_public_file))
|
||||
@@ -188,6 +184,69 @@ fn serve_embedded_file(path: &str) -> axum::response::Response {
|
||||
}
|
||||
}
|
||||
|
||||
/// Discover non-loopback IPv4 addresses on all network interfaces.
|
||||
/// Catches LAN (192.168.x, 10.x), Tailscale (100.x), WireGuard, etc.
|
||||
fn discover_local_ips() -> Vec<String> {
|
||||
let mut ips = Vec::new();
|
||||
if let Ok(addrs) = nix::ifaddrs::getifaddrs() {
|
||||
for ifaddr in addrs {
|
||||
if let Some(addr) = ifaddr.address {
|
||||
if let Some(sockaddr) = addr.as_sockaddr_in() {
|
||||
let ip = sockaddr.ip();
|
||||
if !ip.is_loopback() {
|
||||
let s = ip.to_string();
|
||||
if !ips.contains(&s) {
|
||||
ips.push(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ips
|
||||
}
|
||||
|
||||
/// Load an existing auth token from disk if it's less than 24 hours old,
|
||||
/// otherwise generate a fresh one and persist it.
|
||||
fn load_or_generate_token() -> anyhow::Result<String> {
|
||||
let app_dir = crate::session::get_app_dir()?;
|
||||
let token_path = app_dir.join("serve.token");
|
||||
|
||||
// Try to reuse existing token if fresh enough
|
||||
if let Ok(metadata) = std::fs::metadata(&token_path) {
|
||||
if let Ok(modified) = metadata.modified() {
|
||||
let age = std::time::SystemTime::now()
|
||||
.duration_since(modified)
|
||||
.unwrap_or_default();
|
||||
if age < std::time::Duration::from_secs(24 * 60 * 60) {
|
||||
if let Ok(token) = std::fs::read_to_string(&token_path) {
|
||||
let token = token.trim().to_string();
|
||||
if !token.is_empty() {
|
||||
return Ok(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate new token
|
||||
use rand::RngExt;
|
||||
let mut rng = rand::rng();
|
||||
let token: String = (0..32)
|
||||
.map(|_| {
|
||||
let idx = rng.random_range(0..36u8);
|
||||
if idx < 10 {
|
||||
(b'0' + idx) as char
|
||||
} else {
|
||||
(b'a' + idx - 10) as char
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let _ = std::fs::write(&token_path, &token);
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// Load sessions from all profiles, matching the TUI's "all profiles" view.
|
||||
fn load_all_instances() -> anyhow::Result<Vec<Instance>> {
|
||||
let profiles = crate::session::list_profiles().unwrap_or_default();
|
||||
|
||||
@@ -19,6 +19,53 @@ use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem};
|
||||
|
||||
use super::AppState;
|
||||
|
||||
/// WebSocket for the paired host terminal (TerminalSession tmux session)
|
||||
pub async fn paired_terminal_ws(
|
||||
ws: WebSocketUpgrade,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let instances = state.instances.read().await;
|
||||
let session_info = instances
|
||||
.iter()
|
||||
.find(|i| i.id == id)
|
||||
.map(|inst| crate::tmux::TerminalSession::generate_name(&inst.id, &inst.title));
|
||||
drop(instances);
|
||||
|
||||
let read_only = state.read_only;
|
||||
|
||||
match session_info {
|
||||
Some(tmux_name) => ws
|
||||
.on_upgrade(move |socket| handle_terminal_ws(socket, tmux_name, read_only))
|
||||
.into_response(),
|
||||
None => (axum::http::StatusCode::NOT_FOUND, "Session not found").into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// WebSocket for the paired container terminal (ContainerTerminalSession tmux session)
|
||||
pub async fn container_terminal_ws(
|
||||
ws: WebSocketUpgrade,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let instances = state.instances.read().await;
|
||||
let session_info = instances
|
||||
.iter()
|
||||
.find(|i| i.id == id)
|
||||
.map(|inst| crate::tmux::ContainerTerminalSession::generate_name(&inst.id, &inst.title));
|
||||
drop(instances);
|
||||
|
||||
let read_only = state.read_only;
|
||||
|
||||
match session_info {
|
||||
Some(tmux_name) => ws
|
||||
.on_upgrade(move |socket| handle_terminal_ws(socket, tmux_name, read_only))
|
||||
.into_response(),
|
||||
None => (axum::http::StatusCode::NOT_FOUND, "Session not found").into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// WebSocket for the agent's main tmux session
|
||||
pub async fn terminal_ws(
|
||||
ws: WebSocketUpgrade,
|
||||
State(state): State<Arc<AppState>>,
|
||||
@@ -63,6 +110,8 @@ async fn handle_terminal_ws(socket: WebSocket, tmux_name: String, read_only: boo
|
||||
let mut cmd = CommandBuilder::new("tmux");
|
||||
cmd.args(["attach-session", "-t", &tmux_name]);
|
||||
cmd.env("TERM", "xterm-256color");
|
||||
// Allow nesting: unset TMUX so the attach works when aoe serve runs inside tmux
|
||||
cmd.env_remove("TMUX");
|
||||
|
||||
let mut child = match pair.slave.spawn_command(cmd) {
|
||||
Ok(child) => child,
|
||||
|
||||
+3
-5
@@ -2,19 +2,17 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<title>Agent of Empires</title>
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<meta name="theme-color" content="#18181b" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<link rel="icon" type="image/png" href="/icon-192.png" />
|
||||
<link rel="apple-touch-icon" href="/icon-192.png" />
|
||||
<link rel="preconnect" href="https://api.fontshare.com" crossorigin />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://api.fontshare.com/v2/css?f[]=satoshi@400,500,600,700&display=swap" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+194
-227
@@ -1,280 +1,247 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useSessions } from "./hooks/useSessions";
|
||||
import { useWorkspaces } from "./hooks/useWorkspaces";
|
||||
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
|
||||
import { updateSession, createSession, deleteSession } from "./lib/api";
|
||||
import type { SessionResponse } from "./lib/types";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { isSessionActive } from "./lib/session";
|
||||
import { WorkspaceSidebar } from "./components/WorkspaceSidebar";
|
||||
import { WorkspaceHeader } from "./components/WorkspaceHeader";
|
||||
import { ContentSplit } from "./components/ContentSplit";
|
||||
import { TerminalView } from "./components/TerminalView";
|
||||
import { DiffView } from "./components/DiffView";
|
||||
import { EmptyState } from "./components/EmptyState";
|
||||
import { RenameDialog } from "./components/RenameDialog";
|
||||
import { ProfileSelector } from "./components/ProfileSelector";
|
||||
import { HelpOverlay } from "./components/HelpOverlay";
|
||||
import { RightPanel } from "./components/RightPanel";
|
||||
import { SettingsView } from "./components/SettingsView";
|
||||
import { WorktreeList } from "./components/WorktreeList";
|
||||
import { ConfirmDialog } from "./components/ConfirmDialog";
|
||||
import { MobileNav } from "./components/MobileNav";
|
||||
import {
|
||||
CreateSessionPanel,
|
||||
type CreateSessionData,
|
||||
} from "./components/CreateSessionPanel";
|
||||
|
||||
type ContentView = "terminal" | "diff" | "settings" | "worktrees";
|
||||
import { HelpOverlay } from "./components/HelpOverlay";
|
||||
|
||||
export default function App() {
|
||||
const { sessions, error, refresh } = useSessions();
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [mobileShowTerminal, setMobileShowTerminal] = useState(false);
|
||||
const [contentView, setContentView] = useState<ContentView>("terminal");
|
||||
const [renameTarget, setRenameTarget] = useState<SessionResponse | null>(
|
||||
const { sessions, error } = useSessions();
|
||||
const workspaces = useWorkspaces(sessions);
|
||||
|
||||
const [activeWorkspaceId, setActiveWorkspaceId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [deleteTarget, setDeleteTarget] = useState<SessionResponse | null>(
|
||||
null,
|
||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
||||
const [diffCollapsed, setDiffCollapsed] = useState(
|
||||
() => window.innerWidth < 768,
|
||||
);
|
||||
const [activeProfile, setActiveProfile] = useState<string | null>(null);
|
||||
const [diffFileCount, setDiffFileCount] = useState(0);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const [sidebarSearchOpen, setSidebarSearchOpen] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(
|
||||
() => window.innerWidth >= 768,
|
||||
);
|
||||
|
||||
const filteredSessions = activeProfile
|
||||
? sessions.filter(
|
||||
(s) =>
|
||||
s.group_path.startsWith(activeProfile) ||
|
||||
s.project_path.includes(activeProfile),
|
||||
)
|
||||
: sessions;
|
||||
const activeWorkspace = workspaces.find((w) => w.id === activeWorkspaceId);
|
||||
const activeSession = activeWorkspace?.sessions.find(
|
||||
(s) => s.id === activeSessionId,
|
||||
);
|
||||
|
||||
const activeSession = sessions.find((s) => s.id === activeId);
|
||||
const alertCounts = useMemo(() => {
|
||||
let errors = 0;
|
||||
let waiting = 0;
|
||||
for (const s of sessions) {
|
||||
if (s.status === "Error") errors++;
|
||||
if (s.status === "Waiting") waiting++;
|
||||
}
|
||||
return { errors, waiting };
|
||||
}, [sessions]);
|
||||
|
||||
const handleSelect = (id: string) => {
|
||||
setActiveId(id);
|
||||
setContentView("terminal");
|
||||
setMobileShowTerminal(true);
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setMobileShowTerminal(false);
|
||||
};
|
||||
|
||||
const handleRename = async (title: string, group: string) => {
|
||||
if (!renameTarget) return;
|
||||
await updateSession(renameTarget.id, {
|
||||
title: title !== renameTarget.title ? title : undefined,
|
||||
group_path: group !== renameTarget.group_path ? group : undefined,
|
||||
});
|
||||
setRenameTarget(null);
|
||||
refresh();
|
||||
};
|
||||
|
||||
const handleDiff = (session: SessionResponse) => {
|
||||
setActiveId(session.id);
|
||||
setContentView("diff");
|
||||
};
|
||||
|
||||
const handleCreate = async (data: CreateSessionData) => {
|
||||
const result = await createSession(data);
|
||||
if (result) {
|
||||
setShowCreate(false);
|
||||
setActiveId(result.id);
|
||||
setContentView("terminal");
|
||||
refresh();
|
||||
const handleSelectWorkspace = (workspaceId: string) => {
|
||||
setActiveWorkspaceId(workspaceId);
|
||||
const ws = workspaces.find((w) => w.id === workspaceId);
|
||||
if (ws) {
|
||||
const running = ws.sessions.find((s) => isSessionActive(s.status));
|
||||
setActiveSessionId(running?.id ?? ws.sessions[0]?.id ?? null);
|
||||
}
|
||||
if (window.innerWidth < 768) {
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
await deleteSession(deleteTarget.id);
|
||||
setDeleteTarget(null);
|
||||
if (activeId === deleteTarget.id) setActiveId(null);
|
||||
refresh();
|
||||
};
|
||||
const toggleDiff = () => setDiffCollapsed((c) => !c);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useKeyboardShortcuts(
|
||||
useCallback(
|
||||
() => ({
|
||||
onSearch: () => setSidebarSearchOpen((v) => !v),
|
||||
onNew: () => setShowCreate(true),
|
||||
onDelete: () => {
|
||||
if (activeSession) setDeleteTarget(activeSession);
|
||||
},
|
||||
onRename: () => {
|
||||
if (activeSession) setRenameTarget(activeSession);
|
||||
},
|
||||
onDiff: () => {
|
||||
if (activeSession) handleDiff(activeSession);
|
||||
},
|
||||
onDiff: () => toggleDiff(),
|
||||
onEscape: () => {
|
||||
setShowCreate(false);
|
||||
setShowHelp(false);
|
||||
setRenameTarget(null);
|
||||
setDeleteTarget(null);
|
||||
setShowSettings(false);
|
||||
},
|
||||
onHelp: () => setShowHelp((h) => !h),
|
||||
onSettings: () =>
|
||||
setContentView((v) => (v === "settings" ? "terminal" : "settings")),
|
||||
onSettings: () => setShowSettings((s) => !s),
|
||||
}),
|
||||
[activeSession],
|
||||
[],
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-surface-900 text-text-primary">
|
||||
{/* Header */}
|
||||
<header className="h-14 bg-surface-850 border-b border-surface-700/30 flex items-center px-5 shrink-0">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-6 h-6 rounded-md bg-brand-600/20 flex items-center justify-center">
|
||||
<span className="font-display text-xs font-bold text-brand-500">
|
||||
A
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="font-display text-base font-semibold tracking-tight text-text-bright">
|
||||
Agent of Empires
|
||||
</h1>
|
||||
const renderContent = () => {
|
||||
if (showSettings) {
|
||||
return <SettingsView onClose={() => setShowSettings(false)} />;
|
||||
}
|
||||
|
||||
if (!activeWorkspace || !activeSession) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center bg-surface-950 px-4">
|
||||
<p className="font-body text-sm text-text-dim text-center">
|
||||
{workspaces.length === 0
|
||||
? "No sessions yet"
|
||||
: "Select a session"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<WorkspaceHeader
|
||||
workspace={activeWorkspace}
|
||||
activeSession={activeSession}
|
||||
diffCollapsed={diffCollapsed}
|
||||
diffFileCount={diffFileCount}
|
||||
onToggleDiff={toggleDiff}
|
||||
/>
|
||||
|
||||
<ContentSplit
|
||||
collapsed={diffCollapsed}
|
||||
onToggleCollapse={toggleDiff}
|
||||
left={
|
||||
<TerminalView key={activeSessionId} session={activeSession} />
|
||||
}
|
||||
right={
|
||||
<RightPanel
|
||||
session={activeSession ?? null}
|
||||
sessionId={activeSessionId}
|
||||
expanded={!diffCollapsed}
|
||||
onFileCountChange={setDiffFileCount}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-dvh flex flex-col bg-surface-900 text-text-primary overflow-hidden">
|
||||
|
||||
{/* Header */}
|
||||
<header className="h-10 bg-surface-800 border-b border-surface-700/20 flex items-center px-2 shrink-0 gap-1.5">
|
||||
<button
|
||||
onClick={() => setSidebarOpen((o) => !o)}
|
||||
className={`w-10 h-10 flex items-center justify-center cursor-pointer rounded-md transition-colors -ml-1 ${
|
||||
sidebarOpen
|
||||
? "text-text-secondary"
|
||||
: "text-text-dim hover:text-text-secondary"
|
||||
}`}
|
||||
title="Toggle sidebar"
|
||||
aria-label="Toggle sidebar"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<line x1="9" y1="3" x2="9" y2="21" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{alertCounts.errors > 0 && (
|
||||
<span className="font-mono text-[11px] px-1.5 py-0.5 rounded-full bg-status-error/15 text-status-error">
|
||||
{alertCounts.errors} error{alertCounts.errors !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
{alertCounts.waiting > 0 && (
|
||||
<span className="font-mono text-[11px] px-1.5 py-0.5 rounded-full bg-status-waiting/15 text-status-waiting">
|
||||
{alertCounts.waiting} waiting
|
||||
</span>
|
||||
)}
|
||||
{error && (
|
||||
<span className="font-mono text-xs text-status-error">
|
||||
offline
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setContentView("worktrees")}
|
||||
className="hidden md:flex items-center gap-1.5 font-body text-xs text-text-dim hover:text-text-secondary hover:bg-surface-700/30 cursor-pointer px-2.5 py-1.5 rounded-md transition-colors"
|
||||
title="Worktrees"
|
||||
onClick={toggleDiff}
|
||||
className={`flex w-10 h-10 items-center justify-center cursor-pointer rounded-md transition-colors ${
|
||||
diffCollapsed
|
||||
? "text-text-dim hover:text-text-secondary"
|
||||
: "text-text-secondary"
|
||||
}`}
|
||||
title="Toggle diff panel"
|
||||
aria-label="Toggle diff panel"
|
||||
>
|
||||
Worktrees
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<line x1="15" y1="3" x2="15" y2="21" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setContentView((v) =>
|
||||
v === "settings" ? "terminal" : "settings",
|
||||
)
|
||||
}
|
||||
className="hidden md:flex items-center gap-1.5 font-body text-xs text-text-dim hover:text-text-secondary hover:bg-surface-700/30 cursor-pointer px-2.5 py-1.5 rounded-md transition-colors"
|
||||
title="Settings (s)"
|
||||
>
|
||||
Settings
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowHelp(true)}
|
||||
className="hidden md:flex items-center justify-center w-8 h-8 font-mono text-sm text-text-dim hover:text-text-secondary hover:bg-surface-700/30 cursor-pointer rounded-md transition-colors"
|
||||
title="Help (?)"
|
||||
>
|
||||
?
|
||||
</button>
|
||||
<div className="hidden md:block w-px h-5 bg-surface-700/50 mx-1" />
|
||||
<ProfileSelector
|
||||
activeProfile={activeProfile}
|
||||
onSelect={setActiveProfile}
|
||||
/>
|
||||
<div className="hidden md:block w-px h-5 bg-surface-700/50 mx-1" />
|
||||
<span className="font-mono text-xs text-text-dim tabular-nums">
|
||||
{error
|
||||
? "offline"
|
||||
: `${filteredSessions.length} session${filteredSessions.length !== 1 ? "s" : ""}`}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main area -- sidebar and content side by side, full remaining height */}
|
||||
{/* Main: sidebar + content */}
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{contentView !== "settings" && contentView !== "worktrees" && (
|
||||
<div
|
||||
className={`flex shrink-0 ${mobileShowTerminal ? "max-md:hidden" : ""}`}
|
||||
>
|
||||
<Sidebar
|
||||
sessions={filteredSessions}
|
||||
activeId={activeId}
|
||||
onSelect={handleSelect}
|
||||
onRefresh={refresh}
|
||||
onRename={setRenameTarget}
|
||||
onDiff={handleDiff}
|
||||
onNew={() => setShowCreate(true)}
|
||||
searchOpen={sidebarSearchOpen}
|
||||
onSearchToggle={setSidebarSearchOpen}
|
||||
/>
|
||||
</div>
|
||||
{sidebarOpen && (
|
||||
<WorkspaceSidebar
|
||||
workspaces={workspaces}
|
||||
activeId={activeWorkspaceId}
|
||||
onToggle={() => setSidebarOpen(false)}
|
||||
onSelect={handleSelectWorkspace}
|
||||
onNew={() => setShowCreate(true)}
|
||||
onSettings={() => setShowSettings((s) => !s)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`flex-1 flex flex-col min-h-0 ${!mobileShowTerminal && contentView !== "settings" && contentView !== "worktrees" ? "max-md:hidden" : ""}`}
|
||||
>
|
||||
{contentView === "settings" ? (
|
||||
<SettingsView onClose={() => setContentView("terminal")} />
|
||||
) : contentView === "worktrees" ? (
|
||||
<WorktreeList
|
||||
onClose={() => setContentView("terminal")}
|
||||
onNavigateToSession={(id) => {
|
||||
setActiveId(id);
|
||||
setContentView("terminal");
|
||||
}}
|
||||
/>
|
||||
) : activeSession ? (
|
||||
contentView === "diff" ? (
|
||||
<DiffView
|
||||
sessionId={activeSession.id}
|
||||
onClose={() => setContentView("terminal")}
|
||||
/>
|
||||
) : (
|
||||
<TerminalView
|
||||
key={activeSession.id}
|
||||
session={activeSession}
|
||||
onBack={handleBack}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<EmptyState />
|
||||
)}
|
||||
<div className="flex-1 flex flex-col min-h-0 min-w-0">
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overlays */}
|
||||
{/* Not supported dialog */}
|
||||
{showCreate && (
|
||||
<CreateSessionPanel
|
||||
onSubmit={handleCreate}
|
||||
onCancel={() => setShowCreate(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{renameTarget && (
|
||||
<RenameDialog
|
||||
currentTitle={renameTarget.title}
|
||||
currentGroup={renameTarget.group_path}
|
||||
onSave={handleRename}
|
||||
onCancel={() => setRenameTarget(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleteTarget && (
|
||||
<ConfirmDialog
|
||||
title="Delete Session"
|
||||
message={`Delete "${deleteTarget.title}"? This will stop the session and remove it.`}
|
||||
confirmLabel="Delete"
|
||||
danger
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 animate-fade-in"
|
||||
onClick={() => setShowCreate(false)}
|
||||
>
|
||||
<div
|
||||
className="bg-surface-800 border border-surface-700/30 rounded-xl px-6 py-5 max-w-sm text-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<p className="font-body text-sm text-text-primary mb-1">
|
||||
Not supported yet
|
||||
</p>
|
||||
<p className="font-body text-xs text-text-dim mb-4">
|
||||
Create sessions from the terminal with the aoe CLI.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowCreate(false)}
|
||||
className="font-body text-xs text-text-muted hover:text-text-secondary cursor-pointer"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showHelp && <HelpOverlay onClose={() => setShowHelp(false)} />}
|
||||
|
||||
{/* Mobile bottom nav */}
|
||||
<MobileNav
|
||||
sessionCount={filteredSessions.length}
|
||||
activeSessionTitle={activeSession?.title ?? null}
|
||||
activeStatus={activeSession?.status ?? null}
|
||||
activeTab={
|
||||
contentView === "settings"
|
||||
? "settings"
|
||||
: contentView === "worktrees"
|
||||
? "worktrees"
|
||||
: "sessions"
|
||||
}
|
||||
onSessionsTab={() => {
|
||||
setContentView("terminal");
|
||||
setMobileShowTerminal(false);
|
||||
}}
|
||||
onSettingsTab={() => setContentView("settings")}
|
||||
onWorktreesTab={() => setContentView("worktrees")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
interface Props {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
danger?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
title,
|
||||
message,
|
||||
confirmLabel = "Confirm",
|
||||
cancelLabel = "Cancel",
|
||||
danger = false,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 animate-fade-in">
|
||||
<div className="bg-surface-800 border border-surface-700/50 rounded-xl w-dialog max-w-[90vw] shadow-2xl animate-slide-up">
|
||||
<div className="px-6 pt-5 pb-4">
|
||||
<h3 className="font-display text-base font-semibold text-text-primary">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="font-body text-sm text-text-secondary mt-2 leading-relaxed">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 px-6 py-4 border-t border-surface-700/30">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 font-body text-sm rounded-lg text-text-secondary hover:bg-surface-700/30 transition-colors cursor-pointer"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className={`px-4 py-2 font-body text-sm font-medium rounded-lg transition-colors cursor-pointer ${
|
||||
danger
|
||||
? "bg-status-error text-white hover:bg-red-600"
|
||||
: "bg-brand-600 text-white hover:bg-brand-700"
|
||||
}`}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
const SPLIT_STORAGE_KEY = "aoe-split-ratio";
|
||||
const DEFAULT_DIFF_WIDTH = 380;
|
||||
const MIN_TERMINAL_WIDTH = 400;
|
||||
const MIN_DIFF_WIDTH = 280;
|
||||
|
||||
interface Props {
|
||||
left: React.ReactNode;
|
||||
right: React.ReactNode;
|
||||
collapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
}
|
||||
|
||||
function loadSavedWidth(): number {
|
||||
try {
|
||||
const saved = localStorage.getItem(SPLIT_STORAGE_KEY);
|
||||
if (saved) {
|
||||
const w = parseInt(saved, 10);
|
||||
if (w >= MIN_DIFF_WIDTH) return w;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return DEFAULT_DIFF_WIDTH;
|
||||
}
|
||||
|
||||
export function ContentSplit({
|
||||
left,
|
||||
right,
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
}: Props) {
|
||||
const [diffWidth, setDiffWidth] = useState(loadSavedWidth);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const dragging = useRef(false);
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
dragging.current = true;
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!dragging.current || !containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const newDiffWidth = rect.right - e.clientX;
|
||||
const terminalWidth = rect.width - newDiffWidth;
|
||||
|
||||
if (
|
||||
newDiffWidth >= MIN_DIFF_WIDTH &&
|
||||
terminalWidth >= MIN_TERMINAL_WIDTH
|
||||
) {
|
||||
setDiffWidth(newDiffWidth);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (!dragging.current) return;
|
||||
dragging.current = false;
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
// Persist
|
||||
setDiffWidth((w) => {
|
||||
localStorage.setItem(SPLIT_STORAGE_KEY, String(w));
|
||||
return w;
|
||||
});
|
||||
// Trigger resize for xterm fit
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Re-fit terminal when collapsed state changes
|
||||
useEffect(() => {
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
}, [collapsed]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="flex-1 flex min-h-0 overflow-hidden relative">
|
||||
{/* Terminal pane */}
|
||||
<div className="flex-1 flex flex-col min-w-0 min-h-0">{left}</div>
|
||||
|
||||
{!collapsed && (
|
||||
<>
|
||||
{/* Drag handle (desktop) */}
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
onDoubleClick={onToggleCollapse}
|
||||
className="hidden md:block w-1 cursor-col-resize shrink-0 hover:bg-brand-600/50 transition-colors duration-75"
|
||||
/>
|
||||
|
||||
{/* Right pane: inline on desktop, overlay on mobile */}
|
||||
<div
|
||||
style={{ width: diffWidth }}
|
||||
className="hidden md:flex shrink-0 flex-col min-h-0 overflow-hidden"
|
||||
>
|
||||
{right}
|
||||
</div>
|
||||
|
||||
{/* Mobile: full-screen overlay */}
|
||||
<div className="md:hidden fixed inset-0 z-40 flex flex-col bg-surface-900">
|
||||
<div className="h-10 flex items-center px-3 border-b border-surface-700/20 shrink-0">
|
||||
<span className="font-body text-sm text-text-muted flex-1">
|
||||
Diff & Shell
|
||||
</span>
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
className="w-10 h-10 flex items-center justify-center text-text-dim hover:text-text-secondary cursor-pointer"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
{right}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { fetchAgents } from "../lib/api";
|
||||
import type { AgentInfo } from "../lib/types";
|
||||
|
||||
interface Props {
|
||||
onSubmit: (data: CreateSessionData) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export interface CreateSessionData {
|
||||
title?: string;
|
||||
path: string;
|
||||
tool: string;
|
||||
group: string;
|
||||
yolo_mode: boolean;
|
||||
worktree_branch?: string;
|
||||
create_new_branch: boolean;
|
||||
sandbox: boolean;
|
||||
extra_args: string;
|
||||
}
|
||||
|
||||
export function CreateSessionPanel({ onSubmit, onCancel }: Props) {
|
||||
const [agents, setAgents] = useState<AgentInfo[]>([]);
|
||||
const [path, setPath] = useState("");
|
||||
const [title, setTitle] = useState("");
|
||||
const [tool, setTool] = useState("claude");
|
||||
const [group, setGroup] = useState("");
|
||||
const [yolo, setYolo] = useState(false);
|
||||
const [branch, setBranch] = useState("");
|
||||
const [newBranch, setNewBranch] = useState(false);
|
||||
const [sandbox, setSandbox] = useState(false);
|
||||
const [extraArgs, setExtraArgs] = useState("");
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAgents().then((a) => {
|
||||
setAgents(a);
|
||||
const first = a[0];
|
||||
if (first) setTool(first.name);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!path.trim()) return;
|
||||
onSubmit({
|
||||
title: title.trim() || undefined,
|
||||
path: path.trim(),
|
||||
tool,
|
||||
group: group.trim(),
|
||||
yolo_mode: yolo,
|
||||
worktree_branch: branch.trim() || undefined,
|
||||
create_new_branch: newBranch,
|
||||
sandbox,
|
||||
extra_args: extraArgs.trim(),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 flex justify-end z-50 animate-fade-in">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="w-panel max-w-full bg-surface-800 border-l border-surface-700/30 h-full overflow-y-auto shadow-2xl animate-slide-in-right"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-5 border-b border-surface-700/30">
|
||||
<h2 className="font-display text-base font-semibold text-text-bright">
|
||||
New Session
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="text-text-muted hover:text-text-secondary cursor-pointer text-lg"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-5 space-y-4">
|
||||
{/* Project Path -- required */}
|
||||
<label className="block">
|
||||
<span className="font-mono text-sm uppercase tracking-wider text-text-muted block mb-1">
|
||||
Project Path *
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={path}
|
||||
onChange={(e) => setPath(e.target.value)}
|
||||
autoFocus
|
||||
placeholder="/path/to/your/project"
|
||||
className="w-full bg-surface-900 border border-surface-700 rounded px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-dim focus:border-brand-600 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Agent Tool */}
|
||||
<label className="block">
|
||||
<span className="font-mono text-sm uppercase tracking-wider text-text-muted block mb-1">
|
||||
Agent
|
||||
</span>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{agents.map((a) => (
|
||||
<button
|
||||
key={a.name}
|
||||
type="button"
|
||||
onClick={() => setTool(a.name)}
|
||||
className={`px-2 py-1.5 rounded text-xs font-body cursor-pointer transition-colors ${
|
||||
tool === a.name
|
||||
? "bg-brand-600/20 text-brand-500 border border-brand-600/40"
|
||||
: "bg-surface-900 text-text-secondary border border-surface-700 hover:border-surface-700/80"
|
||||
}`}
|
||||
>
|
||||
{a.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Title */}
|
||||
<label className="block">
|
||||
<span className="font-mono text-sm uppercase tracking-wider text-text-muted block mb-1">
|
||||
Title
|
||||
<span className="text-text-dim ml-1 normal-case tracking-normal">
|
||||
(auto-generated if empty)
|
||||
</span>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="My Session"
|
||||
className="w-full bg-surface-900 border border-surface-700 rounded px-3 py-1.5 font-body text-sm text-text-primary placeholder:text-text-dim focus:border-brand-600 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Group */}
|
||||
<label className="block">
|
||||
<span className="font-mono text-sm uppercase tracking-wider text-text-muted block mb-1">
|
||||
Group
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={group}
|
||||
onChange={(e) => setGroup(e.target.value)}
|
||||
placeholder="work/projects"
|
||||
className="w-full bg-surface-900 border border-surface-700 rounded px-3 py-1.5 font-body text-sm text-text-primary placeholder:text-text-dim focus:border-brand-600 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Toggles */}
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={yolo}
|
||||
onChange={(e) => setYolo(e.target.checked)}
|
||||
className="accent-brand-600"
|
||||
/>
|
||||
<span className="font-body text-xs text-text-secondary">
|
||||
YOLO mode
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sandbox}
|
||||
onChange={(e) => setSandbox(e.target.checked)}
|
||||
className="accent-brand-600"
|
||||
/>
|
||||
<span className="font-body text-xs text-text-secondary">Sandbox</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Advanced */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="font-body text-xs text-text-muted hover:text-text-secondary cursor-pointer"
|
||||
>
|
||||
{showAdvanced ? "Hide" : "Show"} advanced options
|
||||
</button>
|
||||
|
||||
{showAdvanced && (
|
||||
<div className="space-y-3 border-t border-surface-700 pt-3">
|
||||
<label className="block">
|
||||
<span className="font-mono text-sm uppercase tracking-wider text-text-muted block mb-1">
|
||||
Worktree Branch
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={branch}
|
||||
onChange={(e) => setBranch(e.target.value)}
|
||||
placeholder="feature/my-branch"
|
||||
className="w-full bg-surface-900 border border-surface-700 rounded px-3 py-1.5 font-body text-sm text-text-primary placeholder:text-text-dim focus:border-brand-600 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
{branch && (
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newBranch}
|
||||
onChange={(e) => setNewBranch(e.target.checked)}
|
||||
className="accent-brand-600"
|
||||
/>
|
||||
<span className="font-body text-xs text-text-secondary">
|
||||
Create new branch
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
<label className="block">
|
||||
<span className="font-mono text-sm uppercase tracking-wider text-text-muted block mb-1">
|
||||
Extra Args
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={extraArgs}
|
||||
onChange={(e) => setExtraArgs(e.target.value)}
|
||||
placeholder="--resume abc123"
|
||||
className="w-full bg-surface-900 border border-surface-700 rounded px-3 py-1.5 font-mono text-sm text-text-primary placeholder:text-text-dim focus:border-brand-600 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="sticky bottom-0 flex justify-end gap-2 px-5 py-4 border-t border-surface-700 bg-surface-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 font-body text-xs rounded-md text-text-secondary hover:bg-surface-700 transition-colors cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!path.trim()}
|
||||
className="px-4 py-2 font-body text-xs rounded-md bg-brand-600 text-white hover:bg-brand-700 transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Create Session
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { getSessionDiff } from "../lib/api";
|
||||
import type { DiffResponse } from "../lib/types";
|
||||
|
||||
const POLL_INTERVAL = 10_000;
|
||||
|
||||
interface Props {
|
||||
sessionId: string | null;
|
||||
expanded: boolean;
|
||||
onFileCountChange?: (count: number) => void;
|
||||
}
|
||||
|
||||
export function DiffPanel({ sessionId, expanded, onFileCountChange }: Props) {
|
||||
const [diff, setDiff] = useState<DiffResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<number>(0);
|
||||
const lastRawRef = useRef<string>("");
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const fetchDiff = useCallback(async () => {
|
||||
if (!sessionId) return;
|
||||
const d = await getSessionDiff(sessionId);
|
||||
if (d) {
|
||||
if (d.raw !== lastRawRef.current) {
|
||||
lastRawRef.current = d.raw;
|
||||
setDiff(d);
|
||||
onFileCountChange?.(d.files.length);
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
}, [sessionId, onFileCountChange]);
|
||||
|
||||
// Fetch on session change
|
||||
useEffect(() => {
|
||||
if (!sessionId) {
|
||||
setDiff(null);
|
||||
lastRawRef.current = "";
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setSelectedFile(0);
|
||||
lastRawRef.current = "";
|
||||
void fetchDiff();
|
||||
}, [sessionId, fetchDiff]);
|
||||
|
||||
// Poll only when expanded
|
||||
useEffect(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
if (expanded && sessionId) {
|
||||
intervalRef.current = setInterval(() => {
|
||||
void fetchDiff();
|
||||
}, POLL_INTERVAL);
|
||||
}
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [expanded, sessionId, fetchDiff]);
|
||||
|
||||
if (!sessionId) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center bg-surface-900 text-text-dim">
|
||||
<p className="font-body text-sm">Select a session to see changes</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col bg-surface-900">
|
||||
<div className="px-3 py-2 border-b border-surface-700 flex items-center gap-2">
|
||||
<span className="font-mono text-[11px] uppercase tracking-wider text-text-dim">
|
||||
Changes
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center text-text-dim">
|
||||
<span className="font-body text-sm">Loading changes...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!diff || diff.files.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col bg-surface-900">
|
||||
<div className="px-3 py-2 border-b border-surface-700 flex items-center gap-2">
|
||||
<span className="font-mono text-[11px] uppercase tracking-wider text-text-dim">
|
||||
Changes
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center text-text-dim">
|
||||
<div className="text-center">
|
||||
<div className="font-mono text-xl text-surface-700 mb-1">0</div>
|
||||
<p className="font-body text-xs">No changes yet</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col bg-surface-900 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-3 py-2 border-b border-surface-700 flex items-center gap-2 shrink-0">
|
||||
<span className="font-mono text-[11px] uppercase tracking-wider text-text-dim">
|
||||
Changes
|
||||
</span>
|
||||
<span className="font-mono text-[11px] text-text-muted bg-surface-800 px-1.5 py-px rounded-full">
|
||||
{diff.files.length}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={() => {
|
||||
setLoading(true);
|
||||
void fetchDiff();
|
||||
}}
|
||||
className="font-body text-[10px] text-text-dim hover:text-text-muted cursor-pointer"
|
||||
title="Refresh diff"
|
||||
aria-label="Refresh diff"
|
||||
>
|
||||
↻
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* File list */}
|
||||
<div className="border-b border-surface-700 shrink-0 max-h-32 overflow-y-auto">
|
||||
{diff.files.map((file, i) => (
|
||||
<button
|
||||
key={file.path}
|
||||
onClick={() => setSelectedFile(i)}
|
||||
className={`w-full text-left px-3 py-1 font-mono text-[12px] truncate cursor-pointer transition-colors flex items-center gap-2 ${
|
||||
i === selectedFile
|
||||
? "bg-surface-850 text-text-primary"
|
||||
: "text-text-secondary hover:bg-surface-800/50"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`shrink-0 ${
|
||||
file.status === "M"
|
||||
? "text-status-waiting"
|
||||
: file.status === "A"
|
||||
? "text-status-running"
|
||||
: file.status === "D"
|
||||
? "text-status-error"
|
||||
: "text-text-muted"
|
||||
}`}
|
||||
>
|
||||
{file.status}
|
||||
</span>
|
||||
<span className="truncate">{file.path.split("/").pop()}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Diff content */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<pre className="font-mono text-[12px] leading-[1.6] px-3 py-2 text-text-secondary">
|
||||
{diff.raw.split("\n").map((line, i) => {
|
||||
let color = "text-text-secondary";
|
||||
let bg = "";
|
||||
if (line.startsWith("+") && !line.startsWith("+++")) {
|
||||
color = "text-status-running";
|
||||
bg = "bg-status-running/5";
|
||||
}
|
||||
if (line.startsWith("-") && !line.startsWith("---")) {
|
||||
color = "text-status-error";
|
||||
bg = "bg-status-error/5";
|
||||
}
|
||||
if (line.startsWith("@@")) color = "text-accent-600";
|
||||
if (line.startsWith("diff "))
|
||||
color = "text-text-primary font-semibold";
|
||||
return (
|
||||
<div key={i} className={`${color} ${bg}`}>
|
||||
{line || "\u00a0"}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getSessionDiff } from "../lib/api";
|
||||
import type { DiffResponse } from "../lib/types";
|
||||
|
||||
interface Props {
|
||||
sessionId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function DiffView({ sessionId, onClose }: Props) {
|
||||
const [diff, setDiff] = useState<DiffResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedFile, setSelectedFile] = useState<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void getSessionDiff(sessionId).then((d) => {
|
||||
if (!cancelled) {
|
||||
setDiff(d);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center bg-surface-900 text-text-muted font-mono text-sm">
|
||||
Loading diff...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!diff || diff.files.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center bg-surface-900 text-text-muted">
|
||||
<div className="font-mono text-2xl text-surface-700 mb-3">0</div>
|
||||
<p className="font-body text-sm">No changes detected</p>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="mt-4 px-3 py-1.5 font-body text-xs rounded-md text-brand-500 border border-brand-600/30 hover:bg-brand-600/10 cursor-pointer"
|
||||
>
|
||||
Back to terminal
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden bg-surface-900">
|
||||
{/* Header */}
|
||||
<div className="h-10 bg-surface-850 border-b border-surface-700 flex items-center px-4 shrink-0">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-brand-500 mr-3 cursor-pointer font-body text-sm"
|
||||
>
|
||||
← Terminal
|
||||
</button>
|
||||
<span className="font-mono text-sm uppercase tracking-wider text-text-muted">
|
||||
Diff
|
||||
</span>
|
||||
<span className="font-mono text-sm text-text-dim ml-2">
|
||||
{diff.files.length} file{diff.files.length !== 1 ? "s" : ""} changed
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* File list */}
|
||||
<div className="w-sidebar-sm min-w-sidebar-sm border-r border-surface-700 overflow-y-auto">
|
||||
{diff.files.map((file, i) => (
|
||||
<button
|
||||
key={file.path}
|
||||
onClick={() => setSelectedFile(i)}
|
||||
className={`w-full text-left px-3 py-1.5 font-mono text-sm truncate cursor-pointer transition-colors ${
|
||||
i === selectedFile
|
||||
? "bg-surface-800 text-text-primary border-l-2 border-brand-600 pl-2.5"
|
||||
: "text-text-secondary hover:bg-surface-800/50"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block w-3 mr-1.5 text-center ${
|
||||
file.status === "M"
|
||||
? "text-status-waiting"
|
||||
: file.status === "A"
|
||||
? "text-status-running"
|
||||
: file.status === "D"
|
||||
? "text-status-error"
|
||||
: "text-text-muted"
|
||||
}`}
|
||||
>
|
||||
{file.status}
|
||||
</span>
|
||||
{file.path.split("/").pop()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Diff content */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<pre className="font-mono text-sm leading-[1.5] p-4 text-text-secondary">
|
||||
{diff.raw.split("\n").map((line, i) => {
|
||||
let color = "text-text-secondary";
|
||||
if (line.startsWith("+") && !line.startsWith("+++"))
|
||||
color = "text-status-running";
|
||||
if (line.startsWith("-") && !line.startsWith("---"))
|
||||
color = "text-status-error";
|
||||
if (line.startsWith("@@")) color = "text-accent-600";
|
||||
if (line.startsWith("diff ")) color = "text-text-primary font-semibold";
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`${color} ${
|
||||
line.startsWith("+") && !line.startsWith("+++")
|
||||
? "bg-status-running/5"
|
||||
: line.startsWith("-") && !line.startsWith("---")
|
||||
? "bg-status-error/5"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{line || "\u00a0"}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
export function EmptyState() {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center bg-surface-900 px-8">
|
||||
{/* Decorative terminal icon */}
|
||||
<div className="w-16 h-12 rounded-lg bg-surface-800 border border-surface-700/50 flex items-end justify-start p-2 mb-6">
|
||||
<span className="font-mono text-brand-500 text-sm animate-pulse">
|
||||
_
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2 className="font-display text-lg font-semibold text-text-primary mb-2">
|
||||
Select a session
|
||||
</h2>
|
||||
<p className="font-body text-sm text-text-muted text-center max-w-xs leading-relaxed">
|
||||
Choose a session from the sidebar to open a live terminal connection
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex items-center gap-3">
|
||||
<kbd className="font-mono text-xs bg-surface-800 border border-surface-700/50 rounded px-2 py-1 text-text-dim">
|
||||
n
|
||||
</kbd>
|
||||
<span className="font-body text-xs text-text-dim">
|
||||
to create a new session
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,12 +3,10 @@ interface Props {
|
||||
}
|
||||
|
||||
const SHORTCUTS = [
|
||||
{ key: "/", desc: "Search sessions" },
|
||||
{ key: "n", desc: "New session" },
|
||||
{ key: "d", desc: "Delete selected session" },
|
||||
{ key: "r", desc: "Rename selected session" },
|
||||
{ key: "D", desc: "View diff for selected session" },
|
||||
{ key: "Esc", desc: "Close dialog / clear search" },
|
||||
{ key: "D", desc: "Toggle diff panel" },
|
||||
{ key: "s", desc: "Toggle settings" },
|
||||
{ key: "Esc", desc: "Close dialog" },
|
||||
{ key: "?", desc: "Toggle this help" },
|
||||
];
|
||||
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import type { SessionStatus } from "../lib/types";
|
||||
|
||||
interface Props {
|
||||
sessionCount: number;
|
||||
activeSessionTitle: string | null;
|
||||
activeStatus: SessionStatus | null;
|
||||
onSessionsTab: () => void;
|
||||
onSettingsTab: () => void;
|
||||
onWorktreesTab: () => void;
|
||||
activeTab: "sessions" | "settings" | "worktrees";
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<SessionStatus, string> = {
|
||||
Running: "bg-status-running",
|
||||
Waiting: "bg-status-waiting",
|
||||
Idle: "bg-status-idle",
|
||||
Error: "bg-status-error",
|
||||
Starting: "bg-status-starting",
|
||||
Stopped: "bg-status-stopped",
|
||||
Unknown: "bg-status-idle",
|
||||
Deleting: "bg-status-error",
|
||||
};
|
||||
|
||||
export function MobileNav({
|
||||
sessionCount,
|
||||
activeSessionTitle,
|
||||
activeStatus,
|
||||
onSessionsTab,
|
||||
onSettingsTab,
|
||||
onWorktreesTab,
|
||||
activeTab,
|
||||
}: Props) {
|
||||
return (
|
||||
<nav className="md:hidden h-12 bg-surface-850 border-t border-surface-700 flex items-center justify-around shrink-0 safe-area-bottom">
|
||||
<button
|
||||
onClick={onSessionsTab}
|
||||
className={`flex flex-col items-center gap-0.5 px-4 py-1 cursor-pointer ${
|
||||
activeTab === "sessions" ? "text-brand-500" : "text-text-muted"
|
||||
}`}
|
||||
>
|
||||
<span className="text-lg">■</span>
|
||||
<span className="font-mono text-xs">
|
||||
{activeSessionTitle ? (
|
||||
<span className="flex items-center gap-1">
|
||||
{activeStatus && (
|
||||
<span
|
||||
className={`w-1 h-1 rounded-full inline-block ${STATUS_COLORS[activeStatus]}`}
|
||||
/>
|
||||
)}
|
||||
{activeSessionTitle.slice(0, 8)}
|
||||
</span>
|
||||
) : (
|
||||
`${sessionCount} sessions`
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onWorktreesTab}
|
||||
className={`flex flex-col items-center gap-0.5 px-4 py-1 cursor-pointer ${
|
||||
activeTab === "worktrees" ? "text-brand-500" : "text-text-muted"
|
||||
}`}
|
||||
>
|
||||
<span className="font-mono text-sm">wt</span>
|
||||
<span className="font-mono text-xs">Worktrees</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onSettingsTab}
|
||||
className={`flex flex-col items-center gap-0.5 px-4 py-1 cursor-pointer ${
|
||||
activeTab === "settings" ? "text-brand-500" : "text-text-muted"
|
||||
}`}
|
||||
>
|
||||
<span className="font-mono text-sm">cfg</span>
|
||||
<span className="font-mono text-xs">Settings</span>
|
||||
</button>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { fetchProfiles, createProfile, deleteProfile } from "../lib/api";
|
||||
|
||||
interface Props {
|
||||
activeProfile: string | null;
|
||||
onSelect: (profile: string | null) => void;
|
||||
}
|
||||
|
||||
export function ProfileSelector({ activeProfile, onSelect }: Props) {
|
||||
const [profiles, setProfiles] = useState<string[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProfiles().then(setProfiles);
|
||||
}, []);
|
||||
|
||||
// Close on click outside
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
setShowCreate(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, []);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newName.trim()) return;
|
||||
const ok = await createProfile(newName.trim());
|
||||
if (ok) {
|
||||
setProfiles((p) => [...p, newName.trim()]);
|
||||
onSelect(newName.trim());
|
||||
setNewName("");
|
||||
setShowCreate(false);
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (name: string) => {
|
||||
if (name === "default") return;
|
||||
const ok = await deleteProfile(name);
|
||||
if (ok) {
|
||||
setProfiles((p) => p.filter((n) => n !== name));
|
||||
if (activeProfile === name) onSelect(null);
|
||||
}
|
||||
};
|
||||
|
||||
const display = activeProfile || "all profiles";
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="font-mono text-sm text-text-secondary hover:text-text-primary cursor-pointer px-2 py-1 rounded hover:bg-surface-800 transition-colors"
|
||||
>
|
||||
[{display}]
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute top-full right-0 mt-1 w-48 bg-surface-800 border border-surface-700 rounded-md shadow-xl z-50">
|
||||
<button
|
||||
onClick={() => {
|
||||
onSelect(null);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-1.5 font-body text-xs cursor-pointer transition-colors ${
|
||||
!activeProfile
|
||||
? "text-brand-500 bg-brand-600/10"
|
||||
: "text-text-secondary hover:bg-surface-700"
|
||||
}`}
|
||||
>
|
||||
All profiles
|
||||
</button>
|
||||
{profiles.map((p) => (
|
||||
<div key={p} className="flex items-center group">
|
||||
<button
|
||||
onClick={() => {
|
||||
onSelect(p);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`flex-1 text-left px-3 py-1.5 font-body text-xs cursor-pointer transition-colors ${
|
||||
activeProfile === p
|
||||
? "text-brand-500 bg-brand-600/10"
|
||||
: "text-text-secondary hover:bg-surface-700"
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
{p !== "default" && (
|
||||
<button
|
||||
onClick={() => handleDelete(p)}
|
||||
className="px-2 py-1 text-xs text-status-error opacity-0 group-hover:opacity-100 cursor-pointer"
|
||||
title="Delete profile"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="border-t border-surface-700">
|
||||
{showCreate ? (
|
||||
<div className="flex gap-1 p-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
|
||||
autoFocus
|
||||
placeholder="profile name"
|
||||
className="flex-1 bg-surface-900 border border-surface-700 rounded px-2 py-1 font-body text-xs text-text-primary placeholder:text-text-dim focus:border-brand-600 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className="px-2 py-1 font-body text-xs text-brand-500 hover:bg-brand-600/10 rounded cursor-pointer"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="w-full text-left px-3 py-1.5 font-body text-xs text-text-muted hover:text-text-secondary hover:bg-surface-700 cursor-pointer"
|
||||
>
|
||||
+ New profile
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { useState } from "react";
|
||||
|
||||
interface Props {
|
||||
currentTitle: string;
|
||||
currentGroup: string;
|
||||
onSave: (title: string, group: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function RenameDialog({
|
||||
currentTitle,
|
||||
currentGroup,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: Props) {
|
||||
const [title, setTitle] = useState(currentTitle);
|
||||
const [group, setGroup] = useState(currentGroup);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSave(title, group);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-surface-800 border border-surface-700 rounded-md w-dialog max-w-[90vw] shadow-xl"
|
||||
>
|
||||
<div className="px-5 pt-4 pb-3">
|
||||
<h3 className="font-body text-sm font-semibold text-text-primary mb-3">
|
||||
Rename Session
|
||||
</h3>
|
||||
<label className="block mb-3">
|
||||
<span className="font-mono text-sm uppercase tracking-wider text-text-muted block mb-1">
|
||||
Title
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
autoFocus
|
||||
className="w-full bg-surface-900 border border-surface-700 rounded px-3 py-1.5 font-body text-sm text-text-primary focus:border-brand-600 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="font-mono text-sm uppercase tracking-wider text-text-muted block mb-1">
|
||||
Group
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={group}
|
||||
onChange={(e) => setGroup(e.target.value)}
|
||||
placeholder="e.g. work/projects"
|
||||
className="w-full bg-surface-900 border border-surface-700 rounded px-3 py-1.5 font-body text-sm text-text-primary placeholder:text-text-dim focus:border-brand-600 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 px-5 py-3 border-t border-surface-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-3 py-1.5 font-body text-xs rounded-md text-text-secondary hover:bg-surface-700 transition-colors cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-3 py-1.5 font-body text-xs rounded-md bg-brand-600/20 text-brand-500 border border-brand-600/30 hover:bg-brand-600/30 transition-colors cursor-pointer"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { DiffPanel } from "./DiffPanel";
|
||||
import { useTerminal } from "../hooks/useTerminal";
|
||||
import { ensureTerminal } from "../lib/api";
|
||||
import type { SessionResponse } from "../lib/types";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
|
||||
interface Props {
|
||||
session: SessionResponse | null;
|
||||
sessionId: string | null;
|
||||
expanded: boolean;
|
||||
onFileCountChange: (count: number) => void;
|
||||
}
|
||||
|
||||
type ShellMode = "host" | "container";
|
||||
|
||||
function PairedTerminal({
|
||||
sessionId,
|
||||
mode,
|
||||
}: {
|
||||
sessionId: string;
|
||||
mode: ShellMode;
|
||||
}) {
|
||||
const [ready, setReady] = useState(false);
|
||||
const wsPath =
|
||||
mode === "container" ? "container-terminal/ws" : "terminal/ws";
|
||||
const { containerRef, state, manualReconnect } = useTerminal(
|
||||
ready ? sessionId : null,
|
||||
wsPath,
|
||||
);
|
||||
|
||||
// Auto-create the paired terminal if it doesn't exist
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setReady(false);
|
||||
ensureTerminal(sessionId, mode === "container").then((ok) => {
|
||||
if (!cancelled && ok) setReady(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [sessionId, mode]);
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center bg-surface-950 text-text-dim">
|
||||
<span className="font-body text-xs">Starting terminal...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
{!state.connected && state.reconnecting && (
|
||||
<div className="bg-status-waiting/15 border-b border-status-waiting/30 px-3 py-1 shrink-0">
|
||||
<span className="font-body text-xs text-status-waiting">
|
||||
Reconnecting... ({state.retryCount}/3)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!state.connected && !state.reconnecting && state.retryCount >= 3 && (
|
||||
<div className="bg-status-error/10 border-b border-status-error/30 px-3 py-1 flex items-center gap-2 shrink-0">
|
||||
<span className="font-body text-xs text-status-error">
|
||||
Disconnected
|
||||
</span>
|
||||
<button
|
||||
onClick={manualReconnect}
|
||||
className="font-body text-xs text-brand-500 cursor-pointer underline"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex-1 overflow-hidden bg-surface-950"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RightPanel({
|
||||
session,
|
||||
sessionId,
|
||||
expanded,
|
||||
onFileCountChange,
|
||||
}: Props) {
|
||||
const [shellMode, setShellMode] = useState<ShellMode>("host");
|
||||
const isSandboxed = session?.is_sandboxed ?? false;
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
{/* Upper: diff */}
|
||||
<div className="flex-1 flex flex-col min-h-0 border-b border-surface-700/20">
|
||||
<DiffPanel
|
||||
sessionId={sessionId}
|
||||
expanded={expanded}
|
||||
onFileCountChange={onFileCountChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Lower: paired terminal */}
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-surface-900 border-b border-surface-700/20 shrink-0">
|
||||
<span className="font-body text-xs text-text-dim mr-1">Shell</span>
|
||||
<button
|
||||
onClick={() => setShellMode("host")}
|
||||
className={`font-body text-[12px] px-2 py-0.5 rounded cursor-pointer transition-colors ${
|
||||
shellMode === "host"
|
||||
? "text-brand-500 bg-brand-600/10"
|
||||
: "text-text-dim hover:text-text-muted"
|
||||
}`}
|
||||
>
|
||||
Host
|
||||
</button>
|
||||
{isSandboxed && (
|
||||
<button
|
||||
onClick={() => setShellMode("container")}
|
||||
className={`font-body text-[12px] px-2 py-0.5 rounded cursor-pointer transition-colors ${
|
||||
shellMode === "container"
|
||||
? "text-brand-500 bg-brand-600/10"
|
||||
: "text-text-dim hover:text-text-muted"
|
||||
}`}
|
||||
>
|
||||
Container
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sessionId ? (
|
||||
<PairedTerminal
|
||||
key={`${sessionId}-${shellMode}`}
|
||||
sessionId={sessionId}
|
||||
mode={shellMode}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center bg-surface-950 text-text-dim">
|
||||
<p className="font-body text-xs">Select a session</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { useRef, useEffect } from "react";
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SearchBar({ value, onChange, onClose }: Props) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-2 pb-2">
|
||||
<div className="flex items-center bg-surface-900 border border-surface-700 rounded px-2 py-1">
|
||||
<span className="font-mono text-sm text-text-muted mr-1.5">/</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search sessions..."
|
||||
className="flex-1 bg-transparent font-body text-xs text-text-primary placeholder:text-text-dim focus:outline-none"
|
||||
/>
|
||||
{value && (
|
||||
<button
|
||||
onClick={() => onChange("")}
|
||||
className="text-text-muted hover:text-text-secondary text-xs cursor-pointer ml-1"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { memo } from "react";
|
||||
import type { SessionResponse, SessionStatus } from "../lib/types";
|
||||
|
||||
const STATUS_COLORS: Record<SessionStatus, string> = {
|
||||
Running: "bg-status-running",
|
||||
Waiting: "bg-status-waiting",
|
||||
Idle: "bg-status-idle",
|
||||
Error: "bg-status-error",
|
||||
Starting: "bg-status-starting",
|
||||
Stopped: "bg-status-stopped opacity-50",
|
||||
Unknown: "bg-status-idle opacity-50",
|
||||
Deleting: "bg-status-error opacity-50",
|
||||
};
|
||||
|
||||
interface Props {
|
||||
session: SessionResponse;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export const SessionItem = memo(function SessionItem({
|
||||
session,
|
||||
isActive,
|
||||
onClick,
|
||||
}: Props) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`w-full text-left px-3 py-2.5 rounded-lg cursor-pointer transition-all duration-100 mb-1 ${
|
||||
isActive
|
||||
? "bg-surface-900 shadow-sm shadow-black/20 border-l-2 border-brand-500 pl-2.5"
|
||||
: "hover:bg-surface-900/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 font-body text-sm font-medium text-text-primary truncate">
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full shrink-0 ${STATUS_COLORS[session.status]}`}
|
||||
/>
|
||||
{session.title}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 font-body text-xs text-text-muted mt-1 pl-4">
|
||||
<span className="capitalize">{session.tool}</span>
|
||||
{session.branch && (
|
||||
<>
|
||||
<span className="text-surface-700">·</span>
|
||||
<span className="truncate text-accent-600">{session.branch}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
@@ -86,7 +86,7 @@ export function SettingsView({ onClose }: Props) {
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{dirty && (
|
||||
<span className="font-mono text-sm text-status-warning">
|
||||
<span className="font-mono text-sm text-status-waiting">
|
||||
unsaved
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import type { SessionResponse } from "../lib/types";
|
||||
import { stopSession, restartSession, deleteSession } from "../lib/api";
|
||||
import { SessionItem } from "./SessionItem";
|
||||
import { SearchBar } from "./SearchBar";
|
||||
import { ConfirmDialog } from "./ConfirmDialog";
|
||||
import { SortSelect, type SortOrder } from "./SortSelect";
|
||||
|
||||
interface Props {
|
||||
sessions: SessionResponse[];
|
||||
activeId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
onRefresh: () => void;
|
||||
onRename: (session: SessionResponse) => void;
|
||||
onDiff: (session: SessionResponse) => void;
|
||||
onNew?: () => void;
|
||||
searchOpen?: boolean;
|
||||
onSearchToggle?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
sessions,
|
||||
activeId,
|
||||
onSelect,
|
||||
onRefresh,
|
||||
onRename,
|
||||
onDiff,
|
||||
onNew,
|
||||
searchOpen: controlledSearchOpen,
|
||||
onSearchToggle,
|
||||
}: Props) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [internalShowSearch, setInternalShowSearch] = useState(false);
|
||||
|
||||
// Support both controlled (from parent/keyboard) and internal toggle
|
||||
const showSearch = controlledSearchOpen ?? internalShowSearch;
|
||||
const setShowSearch = (open: boolean) => {
|
||||
setInternalShowSearch(open);
|
||||
onSearchToggle?.(open);
|
||||
};
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>("created-desc");
|
||||
const [deleteTarget, setDeleteTarget] = useState<SessionResponse | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const activeSession = sessions.find((s) => s.id === activeId);
|
||||
|
||||
const searched = searchQuery
|
||||
? sessions.filter(
|
||||
(s) =>
|
||||
s.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
s.project_path.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
s.tool.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(s.branch || "").toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
: sessions;
|
||||
|
||||
const filtered = [...searched].sort((a, b) => {
|
||||
switch (sortOrder) {
|
||||
case "created-desc":
|
||||
return b.created_at.localeCompare(a.created_at);
|
||||
case "created-asc":
|
||||
return a.created_at.localeCompare(b.created_at);
|
||||
case "accessed-desc":
|
||||
return (b.last_accessed_at || "").localeCompare(
|
||||
a.last_accessed_at || "",
|
||||
);
|
||||
case "accessed-asc":
|
||||
return (a.last_accessed_at || "").localeCompare(
|
||||
b.last_accessed_at || "",
|
||||
);
|
||||
case "title-asc":
|
||||
return a.title.localeCompare(b.title);
|
||||
case "title-desc":
|
||||
return b.title.localeCompare(a.title);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
// Group sessions by group_path
|
||||
const grouped = new Map<string, SessionResponse[]>();
|
||||
for (const s of filtered) {
|
||||
const group = s.group_path || "";
|
||||
if (!grouped.has(group)) grouped.set(group, []);
|
||||
grouped.get(group)!.push(s);
|
||||
}
|
||||
|
||||
const handleStop = async (id: string) => {
|
||||
await stopSession(id);
|
||||
onRefresh();
|
||||
};
|
||||
|
||||
const handleRestart = async (id: string) => {
|
||||
await restartSession(id);
|
||||
onRefresh();
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
await deleteSession(deleteTarget.id);
|
||||
setDeleteTarget(null);
|
||||
onRefresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="w-sidebar min-w-sidebar bg-surface-800 border-r border-surface-700/50 flex flex-col h-full overflow-hidden max-md:w-full max-md:min-w-full max-md:max-h-[40vh] max-md:border-r-0 max-md:border-b max-md:border-surface-700/50">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 pt-4 pb-3">
|
||||
<span className="font-mono text-xs font-medium uppercase tracking-widest text-text-muted">
|
||||
Sessions
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<SortSelect value={sortOrder} onChange={setSortOrder} />
|
||||
{onNew && (
|
||||
<button
|
||||
onClick={onNew}
|
||||
className="flex items-center justify-center w-7 h-7 rounded-md text-brand-500 hover:bg-brand-600/10 cursor-pointer transition-colors"
|
||||
title="New session (n)"
|
||||
>
|
||||
<span className="text-lg leading-none">+</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowSearch(!showSearch)}
|
||||
className="flex items-center justify-center w-7 h-7 rounded-md font-mono text-sm text-text-dim hover:text-text-secondary hover:bg-surface-700/30 cursor-pointer transition-colors"
|
||||
title="Search (/)"
|
||||
>
|
||||
/
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showSearch && (
|
||||
<SearchBar
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
onClose={() => {
|
||||
setShowSearch(false);
|
||||
setSearchQuery("");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Session list with groups */}
|
||||
<div className="flex-1 overflow-y-auto px-2 pb-2">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="px-3.5 py-5 text-center text-text-dim text-xs font-body">
|
||||
{searchQuery ? (
|
||||
<>No sessions match “{searchQuery}”</>
|
||||
) : (
|
||||
<>
|
||||
No sessions found.
|
||||
<br />
|
||||
<code className="font-mono text-brand-600 text-sm">
|
||||
aoe add /path/to/project
|
||||
</code>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
Array.from(grouped.entries()).map(([group, groupSessions]) => (
|
||||
<div key={group || "__ungrouped__"}>
|
||||
{group && (
|
||||
<div className="font-mono text-xs uppercase tracking-wider text-text-dim px-3 pt-3 pb-1">
|
||||
{group}
|
||||
</div>
|
||||
)}
|
||||
{groupSessions.map((s) => (
|
||||
<SessionItem
|
||||
key={s.id}
|
||||
session={s}
|
||||
isActive={s.id === activeId}
|
||||
onClick={() => onSelect(s.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions for selected session */}
|
||||
{activeSession && (
|
||||
<div className="px-3.5 py-2.5 border-t border-surface-700/50 flex gap-1.5 flex-wrap">
|
||||
{activeSession.status !== "Stopped" && (
|
||||
<button
|
||||
onClick={() => handleStop(activeSession.id)}
|
||||
className="px-3 py-1 font-body text-xs rounded-md border border-status-error/40 text-status-error hover:bg-status-error/10 transition-colors cursor-pointer"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
)}
|
||||
{(activeSession.status === "Stopped" ||
|
||||
activeSession.status === "Error") && (
|
||||
<button
|
||||
onClick={() => handleRestart(activeSession.id)}
|
||||
className="px-3 py-1 font-body text-xs rounded-md border border-brand-600/40 text-brand-500 hover:bg-brand-600/10 transition-colors cursor-pointer"
|
||||
>
|
||||
Restart
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onRename(activeSession)}
|
||||
className="px-3 py-1 font-body text-xs rounded-md border border-surface-700 text-text-secondary hover:bg-surface-700/30 transition-colors cursor-pointer"
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDiff(activeSession)}
|
||||
className="px-3 py-1 font-body text-xs rounded-md border border-accent-600/40 text-accent-600 hover:bg-accent-600/10 transition-colors cursor-pointer"
|
||||
>
|
||||
Diff
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteTarget(activeSession)}
|
||||
className="px-3 py-1 font-body text-xs rounded-md border border-status-error/20 text-text-muted hover:text-status-error hover:bg-status-error/10 transition-colors cursor-pointer"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation */}
|
||||
{deleteTarget && (
|
||||
<ConfirmDialog
|
||||
title="Delete Session"
|
||||
message={`Delete "${deleteTarget.title}"? This will stop the session and remove it from the list.`}
|
||||
confirmLabel="Delete"
|
||||
danger
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
export type SortOrder =
|
||||
| "created-desc"
|
||||
| "created-asc"
|
||||
| "accessed-desc"
|
||||
| "accessed-asc"
|
||||
| "title-asc"
|
||||
| "title-desc";
|
||||
|
||||
const SORT_LABELS: Record<SortOrder, string> = {
|
||||
"created-desc": "Newest",
|
||||
"created-asc": "Oldest",
|
||||
"accessed-desc": "Recent",
|
||||
"accessed-asc": "Least recent",
|
||||
"title-asc": "A-Z",
|
||||
"title-desc": "Z-A",
|
||||
};
|
||||
|
||||
interface Props {
|
||||
value: SortOrder;
|
||||
onChange: (value: SortOrder) => void;
|
||||
}
|
||||
|
||||
export function SortSelect({ value, onChange }: Props) {
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value as SortOrder)}
|
||||
className="bg-transparent font-mono text-xs text-text-dim hover:text-text-secondary cursor-pointer border-none focus:outline-none appearance-none"
|
||||
title="Sort sessions"
|
||||
>
|
||||
{Object.entries(SORT_LABELS).map(([key, label]) => (
|
||||
<option key={key} value={key} className="bg-surface-800 text-text-secondary">
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -1,54 +1,37 @@
|
||||
import { useTerminal } from "../hooks/useTerminal";
|
||||
import type { SessionResponse, SessionStatus } from "../lib/types";
|
||||
import type { SessionResponse } from "../lib/types";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
|
||||
const STATUS_DOT: Record<SessionStatus, string> = {
|
||||
Running: "bg-status-running",
|
||||
Waiting: "bg-status-waiting",
|
||||
Idle: "bg-status-idle",
|
||||
Error: "bg-status-error",
|
||||
Starting: "bg-status-starting",
|
||||
Stopped: "bg-status-stopped",
|
||||
Unknown: "bg-status-idle",
|
||||
Deleting: "bg-status-error",
|
||||
};
|
||||
|
||||
interface Props {
|
||||
session: SessionResponse;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
export function TerminalView({ session, onBack }: Props) {
|
||||
const containerRef = useTerminal(session.id);
|
||||
export function TerminalView({ session }: Props) {
|
||||
const { containerRef, state, manualReconnect } = useTerminal(session.id);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="h-11 bg-surface-850 border-b border-surface-700/30 flex items-center px-5 shrink-0">
|
||||
{onBack && (
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="text-brand-500 mr-3 md:hidden cursor-pointer font-body text-sm"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
)}
|
||||
<span className="font-display text-sm font-semibold text-text-primary">
|
||||
{session.title}
|
||||
</span>
|
||||
<span className="font-body text-text-muted ml-3 text-xs">
|
||||
{[session.tool, session.branch, session.is_sandboxed && "sandboxed"]
|
||||
.filter(Boolean)
|
||||
.join(" \u00b7 ")}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${STATUS_DOT[session.status]}`}
|
||||
/>
|
||||
<span className="font-mono text-xs text-text-dim">
|
||||
{session.status}
|
||||
<div className="flex-1 flex flex-col overflow-hidden relative">
|
||||
{!state.connected && state.reconnecting && (
|
||||
<div className="bg-status-waiting/15 border-b border-status-waiting/30 px-4 py-1.5 flex items-center gap-2 shrink-0">
|
||||
<span className="font-body text-xs text-status-waiting">
|
||||
Reconnecting in {state.retryCountdown}s... ({state.retryCount}/3)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!state.connected && !state.reconnecting && state.retryCount >= 3 && (
|
||||
<div className="bg-status-error/10 border-b border-status-error/30 px-4 py-1.5 flex items-center gap-2 shrink-0">
|
||||
<span className="font-body text-xs text-status-error">
|
||||
Connection lost
|
||||
</span>
|
||||
<button
|
||||
onClick={manualReconnect}
|
||||
className="font-body text-xs text-brand-500 hover:text-brand-400 cursor-pointer underline"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex-1 overflow-hidden bg-surface-950"
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Workspace, SessionResponse } from "../lib/types";
|
||||
|
||||
interface Props {
|
||||
workspace: Workspace;
|
||||
activeSession: SessionResponse | null;
|
||||
diffCollapsed: boolean;
|
||||
diffFileCount: number;
|
||||
onToggleDiff: () => void;
|
||||
}
|
||||
|
||||
export function WorkspaceHeader({
|
||||
workspace,
|
||||
activeSession,
|
||||
diffCollapsed,
|
||||
diffFileCount,
|
||||
onToggleDiff,
|
||||
}: Props) {
|
||||
const agentLabel = activeSession?.tool ?? workspace.primaryAgent;
|
||||
|
||||
return (
|
||||
<div className="h-10 bg-surface-900 border-b border-surface-700/20 flex items-center px-3 gap-2 shrink-0">
|
||||
<span className="font-mono text-sm font-semibold text-accent-600 truncate">
|
||||
{workspace.displayName}
|
||||
</span>
|
||||
<span className="hidden sm:inline font-body text-xs text-text-dim truncate">
|
||||
{agentLabel}
|
||||
</span>
|
||||
|
||||
{diffCollapsed && diffFileCount > 0 && (
|
||||
<button
|
||||
onClick={onToggleDiff}
|
||||
className="font-mono text-xs px-2 py-0.5 rounded-full bg-accent-600/15 text-accent-600 cursor-pointer hover:bg-accent-600/25 transition-colors"
|
||||
>
|
||||
{diffFileCount} change{diffFileCount !== 1 ? "s" : ""}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { Workspace, SessionStatus } from "../lib/types";
|
||||
import { STATUS_DOT_CLASS, isSessionActive } from "../lib/session";
|
||||
|
||||
const SIDEBAR_WIDTH_KEY = "aoe-sidebar-width";
|
||||
const DEFAULT_WIDTH = 280;
|
||||
const MIN_WIDTH = 200;
|
||||
const MAX_WIDTH = 480;
|
||||
|
||||
interface Props {
|
||||
workspaces: Workspace[];
|
||||
activeId: string | null;
|
||||
onToggle: () => void;
|
||||
onSelect: (workspaceId: string) => void;
|
||||
onNew: () => void;
|
||||
onSettings: () => void;
|
||||
}
|
||||
|
||||
function bestSessionStatus(ws: Workspace): SessionStatus {
|
||||
const running = ws.sessions.find((s) => isSessionActive(s.status));
|
||||
if (running) return running.status;
|
||||
const error = ws.sessions.find((s) => s.status === "Error");
|
||||
if (error) return "Error";
|
||||
return ws.sessions[0]?.status ?? "Unknown";
|
||||
}
|
||||
|
||||
function loadSavedWidth(): number {
|
||||
try {
|
||||
const saved = localStorage.getItem(SIDEBAR_WIDTH_KEY);
|
||||
if (saved) {
|
||||
const w = parseInt(saved, 10);
|
||||
if (w >= MIN_WIDTH && w <= MAX_WIDTH) return w;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return DEFAULT_WIDTH;
|
||||
}
|
||||
|
||||
const SessionRow = memo(function SessionRow({
|
||||
workspace,
|
||||
isActive,
|
||||
onClick,
|
||||
}: {
|
||||
workspace: Workspace;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const sessionStatus = bestSessionStatus(workspace);
|
||||
const dotClass = STATUS_DOT_CLASS[sessionStatus] ?? "bg-status-idle";
|
||||
const label =
|
||||
workspace.branch ?? workspace.sessions[0]?.title ?? "default";
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`w-full text-left flex items-center gap-2.5 px-3 py-2.5 cursor-pointer transition-colors duration-75 ${
|
||||
isActive
|
||||
? "bg-surface-850 text-text-primary"
|
||||
: "text-text-secondary hover:bg-surface-800/50"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full shrink-0 ${dotClass} ${
|
||||
sessionStatus === "Waiting" ? "animate-pulse" : ""
|
||||
}`}
|
||||
/>
|
||||
<span className="font-body text-[13px] truncate flex-1" title={label}>
|
||||
{label}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-accent-600 shrink-0">
|
||||
{workspace.primaryAgent}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
export function WorkspaceSidebar({
|
||||
workspaces,
|
||||
activeId,
|
||||
onToggle,
|
||||
onSelect,
|
||||
onNew,
|
||||
onSettings,
|
||||
}: Props) {
|
||||
const [width, setWidth] = useState(loadSavedWidth);
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
const [filterQuery, setFilterQuery] = useState("");
|
||||
const filterRef = useRef<HTMLInputElement>(null);
|
||||
const dragging = useRef(false);
|
||||
|
||||
const filtered = filterQuery.trim()
|
||||
? workspaces.filter((ws) => {
|
||||
const q = filterQuery.toLowerCase();
|
||||
return (
|
||||
ws.displayName.toLowerCase().includes(q) ||
|
||||
ws.projectPath.toLowerCase().includes(q) ||
|
||||
ws.agents.some((a) => a.toLowerCase().includes(q)) ||
|
||||
ws.sessions.some((s) => s.title.toLowerCase().includes(q))
|
||||
);
|
||||
})
|
||||
: workspaces;
|
||||
|
||||
const toggleFilter = () => {
|
||||
setFilterOpen((o) => {
|
||||
if (o) setFilterQuery("");
|
||||
return !o;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (filterOpen) filterRef.current?.focus();
|
||||
}, [filterOpen]);
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
dragging.current = true;
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!dragging.current) return;
|
||||
const newWidth = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, e.clientX));
|
||||
setWidth(newWidth);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (!dragging.current) return;
|
||||
dragging.current = false;
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
setWidth((w) => {
|
||||
localStorage.setItem(SIDEBAR_WIDTH_KEY, String(w));
|
||||
return w;
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-30 md:hidden"
|
||||
onClick={onToggle}
|
||||
/>
|
||||
<div
|
||||
style={{ width }}
|
||||
className="fixed inset-y-0 left-0 z-40 md:static md:z-auto bg-surface-800 flex flex-col h-full shrink-0"
|
||||
>
|
||||
<div className="px-3 pt-3 pb-1 flex items-center">
|
||||
<span className="font-body text-sm text-text-muted flex-1">
|
||||
Sessions
|
||||
</span>
|
||||
<button
|
||||
onClick={toggleFilter}
|
||||
className={`w-8 h-8 flex items-center justify-center cursor-pointer rounded-md transition-colors ${
|
||||
filterOpen
|
||||
? "text-text-secondary"
|
||||
: "text-text-dim hover:text-text-secondary"
|
||||
}`}
|
||||
title="Filter sessions"
|
||||
aria-label="Filter sessions"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={onNew}
|
||||
className="w-8 h-8 flex items-center justify-center text-text-muted hover:text-text-secondary hover:bg-surface-800 cursor-pointer rounded-md transition-colors"
|
||||
title="New session"
|
||||
aria-label="New session"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="md:hidden w-8 h-8 flex items-center justify-center text-text-dim hover:text-text-secondary cursor-pointer rounded-md hover:bg-surface-800 ml-1"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{filterOpen && (
|
||||
<div className="px-3 pb-2">
|
||||
<input
|
||||
ref={filterRef}
|
||||
type="text"
|
||||
value={filterQuery}
|
||||
onChange={(e) => setFilterQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") toggleFilter();
|
||||
}}
|
||||
placeholder="Filter by name, branch, agent..."
|
||||
className="w-full bg-surface-800 border border-surface-700 rounded-md px-2.5 py-1.5 font-body text-[13px] text-text-primary placeholder:text-text-dim focus:border-brand-600 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{filtered.map((ws) => (
|
||||
<SessionRow
|
||||
key={ws.id}
|
||||
workspace={ws}
|
||||
isActive={ws.id === activeId}
|
||||
onClick={() => onSelect(ws.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{filtered.length === 0 && filterQuery && (
|
||||
<div className="px-4 py-8 text-center">
|
||||
<p className="font-body text-sm text-text-muted">
|
||||
No matches for "{filterQuery}"
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-surface-700/20 p-2">
|
||||
<button
|
||||
onClick={onSettings}
|
||||
className="w-8 h-8 flex items-center justify-center text-text-dim hover:text-text-secondary hover:bg-surface-800/50 cursor-pointer rounded-md transition-colors"
|
||||
title="Settings"
|
||||
aria-label="Settings"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Resize handle (desktop only) */}
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
className="hidden md:block w-1 cursor-col-resize shrink-0 hover:bg-brand-600/50 transition-colors duration-75"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { fetchWorktrees, type WorktreeInfo } from "../lib/api";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onNavigateToSession: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
export function WorktreeList({ onClose, onNavigateToSession }: Props) {
|
||||
const [worktrees, setWorktrees] = useState<WorktreeInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchWorktrees().then((wt) => {
|
||||
setWorktrees(wt);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center bg-surface-900 text-text-muted font-mono text-sm">
|
||||
Loading worktrees...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden bg-surface-900">
|
||||
<div className="h-10 bg-surface-850 border-b border-surface-700 flex items-center px-4 shrink-0">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-brand-500 mr-3 cursor-pointer font-body text-sm"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<span className="font-mono text-sm uppercase tracking-wider text-text-muted">
|
||||
Worktrees
|
||||
</span>
|
||||
<span className="font-mono text-sm text-text-dim ml-2">
|
||||
{worktrees.length} active
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{worktrees.length === 0 ? (
|
||||
<div className="text-center py-12 text-text-dim font-body text-sm">
|
||||
No active worktrees. Create a session with a worktree branch to see
|
||||
them here.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-w-[700px]">
|
||||
{worktrees.map((wt) => (
|
||||
<div
|
||||
key={`${wt.session_id}-${wt.branch}`}
|
||||
className="bg-surface-800 border border-surface-700 rounded-md p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="font-body text-sm font-medium text-text-primary">
|
||||
{wt.branch}
|
||||
</span>
|
||||
{wt.managed_by_aoe && (
|
||||
<span className="font-mono text-xs text-accent-600 ml-2">
|
||||
managed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onNavigateToSession(wt.session_id)}
|
||||
className="font-body text-xs text-brand-500 hover:text-brand-400 cursor-pointer"
|
||||
>
|
||||
Go to session →
|
||||
</button>
|
||||
</div>
|
||||
<div className="font-mono text-sm text-text-muted mt-1">
|
||||
{wt.session_title}
|
||||
</div>
|
||||
<div className="font-mono text-sm text-text-dim mt-0.5 truncate">
|
||||
{wt.main_repo_path}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { clsx } from "clsx";
|
||||
|
||||
interface Props extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
mono?: boolean;
|
||||
}
|
||||
|
||||
/** Shared text input styled per DESIGN-WEB.md */
|
||||
export function Input({ className, mono, ...props }: Props) {
|
||||
return (
|
||||
<input
|
||||
{...props}
|
||||
className={clsx(
|
||||
"w-full bg-surface-900 border border-surface-700 rounded px-3 py-1.5 text-sm text-text-primary placeholder:text-text-dim focus:border-brand-600 focus:outline-none",
|
||||
mono ? "font-mono" : "font-body",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
interface ShortcutActions {
|
||||
onSearch: () => void;
|
||||
onNew: () => void;
|
||||
onDelete: () => void;
|
||||
onRename: () => void;
|
||||
onDiff: () => void;
|
||||
onEscape: () => void;
|
||||
onHelp: () => void;
|
||||
@@ -13,7 +10,7 @@ interface ShortcutActions {
|
||||
|
||||
/**
|
||||
* Global keyboard shortcuts for the dashboard.
|
||||
* Only fires when no input/textarea is focused (to avoid conflicts with typing).
|
||||
* Only fires when no input/textarea/terminal is focused.
|
||||
*/
|
||||
export function useKeyboardShortcuts(getActions: () => ShortcutActions) {
|
||||
useEffect(() => {
|
||||
@@ -26,29 +23,17 @@ export function useKeyboardShortcuts(getActions: () => ShortcutActions) {
|
||||
|
||||
const actions = getActions();
|
||||
|
||||
// Escape always works
|
||||
if (e.key === "Escape") {
|
||||
actions.onEscape();
|
||||
return;
|
||||
}
|
||||
|
||||
// Other shortcuts only when not typing in an input
|
||||
if (isInput) return;
|
||||
|
||||
switch (e.key) {
|
||||
case "/":
|
||||
e.preventDefault();
|
||||
actions.onSearch();
|
||||
break;
|
||||
case "n":
|
||||
actions.onNew();
|
||||
break;
|
||||
case "d":
|
||||
actions.onDelete();
|
||||
break;
|
||||
case "r":
|
||||
actions.onRename();
|
||||
break;
|
||||
case "D":
|
||||
actions.onDiff();
|
||||
break;
|
||||
|
||||
+151
-58
@@ -1,17 +1,39 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import type { ResizeMessage } from "../lib/types";
|
||||
|
||||
const MAX_RETRIES = 3;
|
||||
const RETRY_DELAY = 5000;
|
||||
|
||||
export interface TerminalState {
|
||||
connected: boolean;
|
||||
reconnecting: boolean;
|
||||
retryCount: number;
|
||||
retryCountdown: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages an xterm.js terminal connected to a PTY-relayed WebSocket.
|
||||
* Returns a ref to attach to a container div.
|
||||
* Returns a ref to attach to a container div, plus connection state.
|
||||
*/
|
||||
export function useTerminal(sessionId: string | null) {
|
||||
export function useTerminal(
|
||||
sessionId: string | null,
|
||||
wsPath: string = "ws",
|
||||
) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const termRef = useRef<Terminal | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const fitRef = useRef<FitAddon | null>(null);
|
||||
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const retryCountRef = useRef(0);
|
||||
const [state, setState] = useState<TerminalState>({
|
||||
connected: false,
|
||||
reconnecting: false,
|
||||
retryCount: 0,
|
||||
retryCountdown: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId || !containerRef.current) return;
|
||||
@@ -19,21 +41,26 @@ export function useTerminal(sessionId: string | null) {
|
||||
// Clean up previous instance
|
||||
wsRef.current?.close();
|
||||
termRef.current?.dispose();
|
||||
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
|
||||
if (countdownRef.current) clearInterval(countdownRef.current);
|
||||
retryCountRef.current = 0;
|
||||
|
||||
const container = containerRef.current;
|
||||
container.innerHTML = "";
|
||||
|
||||
const fontSize = window.innerWidth < 768 ? 12 : 14;
|
||||
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontSize,
|
||||
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
|
||||
theme: {
|
||||
background: "#020617",
|
||||
background: "#17171a",
|
||||
foreground: "#e2e8f0",
|
||||
cursor: "#d97706",
|
||||
cursorAccent: "#020617",
|
||||
cursorAccent: "#17171a",
|
||||
selectionBackground: "rgba(217, 119, 6, 0.2)",
|
||||
black: "#0f172a",
|
||||
black: "#1c1c1f",
|
||||
red: "#ef4444",
|
||||
green: "#22c55e",
|
||||
yellow: "#fbbf24",
|
||||
@@ -59,60 +86,112 @@ export function useTerminal(sessionId: string | null) {
|
||||
termRef.current = term;
|
||||
fitRef.current = fitAddon;
|
||||
|
||||
// Fit after DOM settles
|
||||
requestAnimationFrame(() => fitAddon.fit());
|
||||
|
||||
// WebSocket for PTY relay
|
||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const ws = new WebSocket(
|
||||
`${proto}//${location.host}/sessions/${sessionId}/ws`,
|
||||
);
|
||||
ws.binaryType = "arraybuffer";
|
||||
wsRef.current = ws;
|
||||
let dataDisposable: { dispose: () => void } | null = null;
|
||||
let resizeDisposable: { dispose: () => void } | null = null;
|
||||
|
||||
ws.onopen = () => {
|
||||
term.focus();
|
||||
const dims = fitAddon.proposeDimensions();
|
||||
if (dims) {
|
||||
const msg: ResizeMessage = {
|
||||
type: "resize",
|
||||
cols: dims.cols,
|
||||
rows: dims.rows,
|
||||
};
|
||||
ws.send(JSON.stringify(msg));
|
||||
}
|
||||
};
|
||||
function connect() {
|
||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const ws = new WebSocket(
|
||||
`${proto}//${location.host}/sessions/${sessionId}/${wsPath}`,
|
||||
);
|
||||
ws.binaryType = "arraybuffer";
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onmessage = (event: MessageEvent) => {
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
term.write(new Uint8Array(event.data));
|
||||
} else {
|
||||
term.write(event.data as string);
|
||||
}
|
||||
};
|
||||
ws.onopen = () => {
|
||||
retryCountRef.current = 0;
|
||||
setState({
|
||||
connected: true,
|
||||
reconnecting: false,
|
||||
retryCount: 0,
|
||||
retryCountdown: 0,
|
||||
});
|
||||
term.focus();
|
||||
const dims = fitAddon.proposeDimensions();
|
||||
if (dims) {
|
||||
const msg: ResizeMessage = {
|
||||
type: "resize",
|
||||
cols: dims.cols,
|
||||
rows: dims.rows,
|
||||
};
|
||||
ws.send(JSON.stringify(msg));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
term.write("\r\n\x1b[33m[Connection closed]\x1b[0m\r\n");
|
||||
};
|
||||
ws.onmessage = (event: MessageEvent) => {
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
term.write(new Uint8Array(event.data));
|
||||
} else {
|
||||
term.write(event.data as string);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
term.write("\r\n\x1b[31m[WebSocket error]\x1b[0m\r\n");
|
||||
};
|
||||
ws.onclose = () => {
|
||||
setState((prev) => ({ ...prev, connected: false }));
|
||||
if (retryCountRef.current < MAX_RETRIES) {
|
||||
retryCountRef.current += 1;
|
||||
const count = retryCountRef.current;
|
||||
let countdown = RETRY_DELAY / 1000;
|
||||
|
||||
// Relay keystrokes as binary
|
||||
const dataDisposable = term.onData((data: string) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(new TextEncoder().encode(data));
|
||||
}
|
||||
});
|
||||
setState({
|
||||
connected: false,
|
||||
reconnecting: true,
|
||||
retryCount: count,
|
||||
retryCountdown: countdown,
|
||||
});
|
||||
|
||||
// Relay resize
|
||||
const resizeDisposable = term.onResize(({ cols, rows }) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
const msg: ResizeMessage = { type: "resize", cols, rows };
|
||||
ws.send(JSON.stringify(msg));
|
||||
}
|
||||
});
|
||||
term.write(
|
||||
`\r\n\x1b[33m[Disconnected, reconnecting in ${countdown}s... (${count}/${MAX_RETRIES})]\x1b[0m\r\n`,
|
||||
);
|
||||
|
||||
countdownRef.current = setInterval(() => {
|
||||
countdown -= 1;
|
||||
if (countdown > 0) {
|
||||
setState((prev) => ({ ...prev, retryCountdown: countdown }));
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
retryTimerRef.current = setTimeout(() => {
|
||||
if (countdownRef.current) clearInterval(countdownRef.current);
|
||||
connect();
|
||||
}, RETRY_DELAY);
|
||||
} else {
|
||||
term.write(
|
||||
"\r\n\x1b[31m[Connection lost. Click retry or press Enter to reconnect.]\x1b[0m\r\n",
|
||||
);
|
||||
setState({
|
||||
connected: false,
|
||||
reconnecting: false,
|
||||
retryCount: retryCountRef.current,
|
||||
retryCountdown: 0,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
// onclose will fire after onerror
|
||||
};
|
||||
|
||||
// Relay keystrokes as binary
|
||||
dataDisposable?.dispose();
|
||||
dataDisposable = term.onData((data: string) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(new TextEncoder().encode(data));
|
||||
}
|
||||
});
|
||||
|
||||
// Relay resize
|
||||
resizeDisposable?.dispose();
|
||||
resizeDisposable = term.onResize(({ cols, rows }) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
const msg: ResizeMessage = { type: "resize", cols, rows };
|
||||
ws.send(JSON.stringify(msg));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
connect();
|
||||
|
||||
// Window resize -> fit terminal
|
||||
const handleResize = () => fitAddon.fit();
|
||||
@@ -120,15 +199,29 @@ export function useTerminal(sessionId: string | null) {
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
dataDisposable.dispose();
|
||||
resizeDisposable.dispose();
|
||||
ws.close();
|
||||
dataDisposable?.dispose();
|
||||
resizeDisposable?.dispose();
|
||||
wsRef.current?.close();
|
||||
term.dispose();
|
||||
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
|
||||
if (countdownRef.current) clearInterval(countdownRef.current);
|
||||
termRef.current = null;
|
||||
wsRef.current = null;
|
||||
fitRef.current = null;
|
||||
};
|
||||
}, [sessionId]);
|
||||
}, [sessionId, wsPath]);
|
||||
|
||||
return containerRef;
|
||||
const manualReconnect = () => {
|
||||
retryCountRef.current = 0;
|
||||
setState({
|
||||
connected: false,
|
||||
reconnecting: true,
|
||||
retryCount: 0,
|
||||
retryCountdown: 0,
|
||||
});
|
||||
// Trigger effect by disconnecting current WS
|
||||
wsRef.current?.close();
|
||||
};
|
||||
|
||||
return { containerRef, state, manualReconnect };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useMemo } from "react";
|
||||
import type { SessionResponse, Workspace } from "../lib/types";
|
||||
import { isSessionActive } from "../lib/session";
|
||||
|
||||
/** Strip trailing slashes for consistent grouping */
|
||||
function normalizePath(p: string): string {
|
||||
return p.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export function useWorkspaces(sessions: SessionResponse[]): Workspace[] {
|
||||
return useMemo(() => {
|
||||
const groups = new Map<string, SessionResponse[]>();
|
||||
|
||||
for (const session of sessions) {
|
||||
const repoPath = normalizePath(
|
||||
session.main_repo_path ?? session.project_path,
|
||||
);
|
||||
const key = `${repoPath}::${session.branch ?? "__default__"}`;
|
||||
const existing = groups.get(key);
|
||||
if (existing) {
|
||||
existing.push(session);
|
||||
} else {
|
||||
groups.set(key, [session]);
|
||||
}
|
||||
}
|
||||
|
||||
const workspaces: Workspace[] = [];
|
||||
|
||||
for (const [id, groupSessions] of groups) {
|
||||
const first = groupSessions[0]!;
|
||||
const agents = [...new Set(groupSessions.map((s) => s.tool))];
|
||||
const status = groupSessions.some((s) => isSessionActive(s.status))
|
||||
? "active"
|
||||
: "idle";
|
||||
|
||||
const branch = first.branch;
|
||||
const projectPath = normalizePath(
|
||||
first.main_repo_path ?? first.project_path,
|
||||
);
|
||||
const displayName =
|
||||
branch ?? projectPath.split("/").pop() ?? projectPath;
|
||||
|
||||
workspaces.push({
|
||||
id,
|
||||
branch,
|
||||
projectPath,
|
||||
displayName,
|
||||
agents,
|
||||
primaryAgent: agents[0] ?? "",
|
||||
status,
|
||||
sessions: groupSessions,
|
||||
});
|
||||
}
|
||||
|
||||
workspaces.sort((a, b) => {
|
||||
if (a.status === "active" && b.status !== "active") return -1;
|
||||
if (a.status !== "active" && b.status === "active") return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return workspaces;
|
||||
}, [sessions]);
|
||||
}
|
||||
+18
-8
@@ -12,12 +12,12 @@
|
||||
--color-accent-600: #0d9488;
|
||||
--color-accent-700: #0f766e;
|
||||
|
||||
/* Surfaces -- Warm Navy */
|
||||
--color-surface-700: #334155;
|
||||
--color-surface-800: #1e293b;
|
||||
--color-surface-850: #172033;
|
||||
--color-surface-900: #0f172a;
|
||||
--color-surface-950: #020617;
|
||||
/* Surfaces -- light sidebar, dark content, darkest terminal */
|
||||
--color-surface-700: #3f3f46;
|
||||
--color-surface-800: #2c2c30;
|
||||
--color-surface-850: #262629;
|
||||
--color-surface-900: #1c1c1f;
|
||||
--color-surface-950: #17171a;
|
||||
|
||||
/* Semantic Text */
|
||||
--color-text-primary: #e2e8f0;
|
||||
@@ -35,8 +35,8 @@
|
||||
--color-status-stopped: #475569;
|
||||
|
||||
/* Typography */
|
||||
--font-display: 'Satoshi', system-ui, sans-serif;
|
||||
--font-body: 'DM Sans', system-ui, sans-serif;
|
||||
--font-display: 'Inter', system-ui, sans-serif;
|
||||
--font-body: 'Inter', system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
|
||||
|
||||
/* Layout widths */
|
||||
@@ -101,6 +101,16 @@ body {
|
||||
animation: slide-in-right 0.2s ease-out;
|
||||
}
|
||||
|
||||
/* Focus indicators for keyboard navigation */
|
||||
button:focus-visible,
|
||||
a:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
[role="button"]:focus-visible {
|
||||
outline: 2px solid var(--color-brand-600);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
button,
|
||||
[role="button"],
|
||||
|
||||
+6
-157
@@ -1,9 +1,4 @@
|
||||
import type {
|
||||
SessionResponse,
|
||||
AgentInfo,
|
||||
GroupInfo,
|
||||
DiffResponse,
|
||||
} from "./types";
|
||||
import type { SessionResponse, DiffResponse } from "./types";
|
||||
|
||||
// --- Sessions ---
|
||||
|
||||
@@ -17,89 +12,21 @@ export async function fetchSessions(): Promise<SessionResponse[] | null> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSession(
|
||||
export async function ensureTerminal(
|
||||
id: string,
|
||||
): Promise<SessionResponse | null> {
|
||||
container = false,
|
||||
): Promise<boolean> {
|
||||
const path = container ? "container-terminal" : "terminal";
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${id}`);
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSession(data: {
|
||||
title?: string;
|
||||
path: string;
|
||||
tool: string;
|
||||
group?: string;
|
||||
yolo_mode?: boolean;
|
||||
worktree_branch?: string;
|
||||
create_new_branch?: boolean;
|
||||
sandbox?: boolean;
|
||||
extra_args?: string;
|
||||
}): Promise<SessionResponse | null> {
|
||||
try {
|
||||
const res = await fetch("/api/sessions", {
|
||||
const res = await fetch(`/api/sessions/${id}/${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => null);
|
||||
throw new Error(err?.message || `HTTP ${res.status}`);
|
||||
}
|
||||
return await res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopSession(id: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${id}/stop`, { method: "POST" });
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function restartSession(id: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${id}/restart`, { method: "POST" });
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSession(id: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${id}`, { method: "DELETE" });
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSession(
|
||||
id: string,
|
||||
updates: { title?: string; group_path?: string },
|
||||
): Promise<SessionResponse | null> {
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSessionDiff(
|
||||
id: string,
|
||||
): Promise<DiffResponse | null> {
|
||||
@@ -112,64 +39,6 @@ export async function getSessionDiff(
|
||||
}
|
||||
}
|
||||
|
||||
// --- Agents ---
|
||||
|
||||
export async function fetchAgents(): Promise<AgentInfo[]> {
|
||||
try {
|
||||
const res = await fetch("/api/agents");
|
||||
if (!res.ok) return [];
|
||||
return await res.json();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// --- Groups ---
|
||||
|
||||
export async function fetchGroups(): Promise<GroupInfo[]> {
|
||||
try {
|
||||
const res = await fetch("/api/groups");
|
||||
if (!res.ok) return [];
|
||||
return await res.json();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// --- Profiles ---
|
||||
|
||||
export async function fetchProfiles(): Promise<string[]> {
|
||||
try {
|
||||
const res = await fetch("/api/profiles");
|
||||
if (!res.ok) return [];
|
||||
return await res.json();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function createProfile(name: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch("/api/profiles", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteProfile(name: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`/api/profiles/${name}`, { method: "DELETE" });
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Settings ---
|
||||
|
||||
export async function getSettings(): Promise<Record<string, unknown> | null> {
|
||||
@@ -208,23 +77,3 @@ export async function fetchThemes(): Promise<string[]> {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// --- Worktrees ---
|
||||
|
||||
export interface WorktreeInfo {
|
||||
session_id: string;
|
||||
session_title: string;
|
||||
branch: string;
|
||||
main_repo_path: string;
|
||||
managed_by_aoe: boolean;
|
||||
}
|
||||
|
||||
export async function fetchWorktrees(): Promise<WorktreeInfo[]> {
|
||||
try {
|
||||
const res = await fetch("/api/worktrees");
|
||||
if (!res.ok) return [];
|
||||
return await res.json();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { SessionStatus } from "./types";
|
||||
|
||||
/** Tailwind class for status dot color by session status */
|
||||
export const STATUS_DOT_CLASS: Record<SessionStatus, string> = {
|
||||
Running: "bg-status-running",
|
||||
Waiting: "bg-status-waiting",
|
||||
Idle: "bg-status-idle",
|
||||
Error: "bg-status-error",
|
||||
Starting: "bg-status-starting",
|
||||
Stopped: "bg-status-stopped",
|
||||
Unknown: "bg-status-idle",
|
||||
Deleting: "bg-status-error",
|
||||
};
|
||||
|
||||
/** Whether a session status means the agent is actively doing something */
|
||||
export function isSessionActive(status: SessionStatus): boolean {
|
||||
return status === "Running" || status === "Waiting" || status === "Starting";
|
||||
}
|
||||
+17
-12
@@ -11,6 +11,7 @@ export interface SessionResponse {
|
||||
last_accessed_at: string | null;
|
||||
last_error: string | null;
|
||||
branch: string | null;
|
||||
main_repo_path: string | null;
|
||||
is_sandboxed: boolean;
|
||||
has_terminal: boolean;
|
||||
}
|
||||
@@ -32,18 +33,6 @@ export interface ResizeMessage {
|
||||
rows: number;
|
||||
}
|
||||
|
||||
/** Agent tool info */
|
||||
export interface AgentInfo {
|
||||
name: string;
|
||||
binary: string;
|
||||
}
|
||||
|
||||
/** Group info */
|
||||
export interface GroupInfo {
|
||||
path: string;
|
||||
session_count: number;
|
||||
}
|
||||
|
||||
/** Diff response */
|
||||
export interface DiffResponse {
|
||||
files: DiffFileInfo[];
|
||||
@@ -54,3 +43,19 @@ export interface DiffFileInfo {
|
||||
path: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
/** Workspace status derived from session states */
|
||||
export type WorkspaceStatus = "active" | "idle";
|
||||
|
||||
/** Workspace: a group of sessions sharing the same project + branch */
|
||||
export interface Workspace {
|
||||
id: string;
|
||||
branch: string | null;
|
||||
projectPath: string;
|
||||
displayName: string;
|
||||
agents: string[];
|
||||
primaryAgent: string;
|
||||
status: WorkspaceStatus;
|
||||
sessions: SessionResponse[];
|
||||
diff?: DiffResponse;
|
||||
}
|
||||
|
||||
+193
-100
@@ -1,172 +1,257 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Dashboard layout", () => {
|
||||
test("loads and shows header with title", async ({ page }) => {
|
||||
test("loads and shows header", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.locator("header")).toBeVisible();
|
||||
await expect(page.locator("header h1")).toContainText("Agent of Empires");
|
||||
});
|
||||
|
||||
test("shows sidebar with Sessions label", async ({ page }) => {
|
||||
test("shows fallback title when no workspace selected", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.locator("aside")).toBeVisible();
|
||||
await expect(page.locator("aside")).toContainText("Sessions");
|
||||
await expect(page.getByText("Agent of Empires")).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows empty state when no session selected", async ({ page }) => {
|
||||
test("shows empty state when no sessions exist", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.locator("text=Select a session")).toBeVisible();
|
||||
await expect(page.getByText("No sessions yet")).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows session count or connection error in header", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("shows create session CTA in empty state", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(
|
||||
page.locator("header").locator("text=/session|error/i"),
|
||||
).toBeVisible();
|
||||
const cta = page.getByRole("button", { name: "Create session" });
|
||||
await expect(cta).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows offline indicator when API unreachable", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByText("offline")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Sidebar features", () => {
|
||||
test("search toggle shows search input", async ({ page }) => {
|
||||
test.describe("Sidebar", () => {
|
||||
test("sidebar visible on desktop by default", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
await page.goto("/");
|
||||
const searchBtn = page.locator('button[title="Search"]');
|
||||
if (await searchBtn.isVisible()) {
|
||||
await searchBtn.click();
|
||||
await expect(
|
||||
page.locator('input[placeholder="Search sessions..."]'),
|
||||
).toBeVisible();
|
||||
}
|
||||
await expect(page.getByRole("button", { name: "+ New Session" })).toBeVisible();
|
||||
await expect(page.getByPlaceholder("Search... (/)")).toBeVisible();
|
||||
});
|
||||
|
||||
test("new session button exists and opens panel", async ({ page }) => {
|
||||
test("sidebar toggle button exists", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const newBtn = page.locator('button[title="New session (n)"]');
|
||||
await expect(page.getByRole("button", { name: "Toggle sidebar" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("sidebar can be toggled closed and open on desktop", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
await page.goto("/");
|
||||
const newBtn = page.getByRole("button", { name: "+ New Session" });
|
||||
await expect(newBtn).toBeVisible();
|
||||
await newBtn.click();
|
||||
await expect(page.locator("h2:has-text('New Session')")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Toggle sidebar" }).click();
|
||||
await expect(newBtn).not.toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Toggle sidebar" }).click();
|
||||
await expect(newBtn).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Create session modal", () => {
|
||||
test("opens from empty state CTA", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Create session" }).click();
|
||||
await expect(page.getByRole("heading", { name: "New Session" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("create panel has path field and agent selector", async ({ page }) => {
|
||||
test("opens from sidebar button", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
await page.goto("/");
|
||||
await page.locator('button[title="New session (n)"]').click();
|
||||
await expect(
|
||||
page.locator('input[placeholder="/path/to/your/project"]'),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("Agent", { exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "+ New Session" }).click();
|
||||
await expect(page.getByRole("heading", { name: "New Session" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("create panel submit disabled without path", async ({ page }) => {
|
||||
test("opens with keyboard shortcut n", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
await page.goto("/");
|
||||
await page.locator('button[title="New session (n)"]').click();
|
||||
const submit = page.locator('button:has-text("Create Session")');
|
||||
// Click body first to ensure keyboard events reach the app
|
||||
await page.locator("body").click();
|
||||
await page.keyboard.press("n");
|
||||
await expect(page.getByRole("heading", { name: "New Session" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("has project path field", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Create session" }).click();
|
||||
await expect(page.getByPlaceholder("/path/to/your/project")).toBeVisible();
|
||||
});
|
||||
|
||||
test("has branch field", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Create session" }).click();
|
||||
await expect(page.getByPlaceholder("feat/my-feature")).toBeVisible();
|
||||
});
|
||||
|
||||
test("has agent section", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Create session" }).click();
|
||||
// The modal has an "AGENT" label
|
||||
await expect(page.locator("text=Agent").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("submit disabled without path", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Create session" }).click();
|
||||
// The submit button inside the modal (not the CTA)
|
||||
const submit = page.locator("form button[type='submit']");
|
||||
await expect(submit).toBeDisabled();
|
||||
});
|
||||
|
||||
test("create panel submit enables with path", async ({ page }) => {
|
||||
test("submit enables with path", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.locator('button[title="New session (n)"]').click();
|
||||
await page
|
||||
.locator('input[placeholder="/path/to/your/project"]')
|
||||
.fill("/tmp/test");
|
||||
const submit = page.locator('button:has-text("Create Session")');
|
||||
await page.getByRole("button", { name: "Create session" }).click();
|
||||
await page.getByPlaceholder("/path/to/your/project").fill("/tmp/test");
|
||||
const submit = page.locator("form button[type='submit']");
|
||||
await expect(submit).toBeEnabled();
|
||||
});
|
||||
|
||||
test("create panel advanced options toggle", async ({ page }) => {
|
||||
test("advanced options toggle", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.locator('button[title="New session (n)"]').click();
|
||||
await expect(
|
||||
page.locator('input[placeholder="feature/my-branch"]'),
|
||||
).not.toBeVisible();
|
||||
await page.getByRole("button", { name: "Create session" }).click();
|
||||
await expect(page.getByPlaceholder("Auto-generated if empty")).not.toBeVisible();
|
||||
await page.getByText("Show advanced options").click();
|
||||
await expect(
|
||||
page.locator('input[placeholder="feature/my-branch"]'),
|
||||
).toBeVisible();
|
||||
await expect(page.getByPlaceholder("Auto-generated if empty")).toBeVisible();
|
||||
});
|
||||
|
||||
test("create panel closes on cancel", async ({ page }) => {
|
||||
test("closes on cancel", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.locator('button[title="New session (n)"]').click();
|
||||
await expect(page.locator("h2:has-text('New Session')")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Create session" }).click();
|
||||
await expect(page.getByRole("heading", { name: "New Session" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Cancel" }).click();
|
||||
await expect(page.locator("h2:has-text('New Session')")).not.toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "New Session" })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("closes on escape", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Create session" }).click();
|
||||
await expect(page.getByRole("heading", { name: "New Session" })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("heading", { name: "New Session" })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("closes on backdrop click", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Create session" }).click();
|
||||
await expect(page.getByRole("heading", { name: "New Session" })).toBeVisible();
|
||||
// Click the backdrop (top-left corner, outside the modal)
|
||||
await page.mouse.click(10, 10);
|
||||
await expect(page.getByRole("heading", { name: "New Session" })).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Header navigation", () => {
|
||||
test("profile selector shows all profiles", async ({ page }) => {
|
||||
test.describe("Settings", () => {
|
||||
test("settings gear button visible", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByText("[all profiles]")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Settings" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("settings button opens settings view (desktop)", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
test("settings opens on click", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.locator('button[title="Settings (s)"]').click();
|
||||
// Settings view shows loading state (no backend) or the actual settings
|
||||
await page.getByRole("button", { name: "Settings" }).click();
|
||||
// Settings view shows loading state (no backend in test)
|
||||
await expect(page.getByText("Loading settings...")).toBeVisible();
|
||||
});
|
||||
|
||||
test("help button exists on desktop", async ({ page }) => {
|
||||
test("settings opens with keyboard shortcut s", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
await page.goto("/");
|
||||
await expect(page.locator('button[title="Help (?)"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("help button opens overlay", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
await page.goto("/");
|
||||
await page.locator('button[title="Help (?)"]').click();
|
||||
await expect(
|
||||
page.locator("h2:has-text('Keyboard Shortcuts')"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("worktrees button exists on desktop", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
await page.goto("/");
|
||||
await expect(page.locator('button[title="Worktrees"]')).toBeVisible();
|
||||
await page.locator("body").click();
|
||||
await page.keyboard.press("s");
|
||||
await expect(page.getByText("Loading settings...")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Responsive / mobile", () => {
|
||||
test("mobile nav bar visible on small viewport", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
test.describe("Keyboard shortcuts", () => {
|
||||
test("D toggles diff pane (no-op when no session, no crash)", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
await page.goto("/");
|
||||
await expect(page.locator("nav")).toBeVisible();
|
||||
// Should not crash even with no session selected
|
||||
await page.keyboard.press("Shift+d");
|
||||
await expect(page.getByText("No sessions yet")).toBeVisible();
|
||||
});
|
||||
|
||||
test("desktop nav buttons hidden on mobile", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
test("? opens help overlay", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
await page.goto("/");
|
||||
await expect(
|
||||
page.locator('button[title="Settings (s)"]'),
|
||||
).not.toBeVisible();
|
||||
await expect(page.locator('button[title="Help (?)"]')).not.toBeVisible();
|
||||
await page.locator("body").click();
|
||||
// Dispatch a ? keydown event directly since Shift+/ handling varies by layout
|
||||
await page.evaluate(() => {
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "?", bubbles: true }));
|
||||
});
|
||||
await expect(page.getByRole("heading", { name: "Keyboard Shortcuts" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("mobile nav has sessions, worktrees, settings tabs", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
test("escape closes help overlay", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
await page.goto("/");
|
||||
const nav = page.locator("nav");
|
||||
await expect(nav.getByText("Worktrees")).toBeVisible();
|
||||
await expect(nav.getByText("Settings")).toBeVisible();
|
||||
await page.locator("body").click();
|
||||
await page.evaluate(() => {
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "?", bubbles: true }));
|
||||
});
|
||||
await expect(page.getByRole("heading", { name: "Keyboard Shortcuts" })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("heading", { name: "Keyboard Shortcuts" })).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Design system verification", () => {
|
||||
test("uses warm navy background, not cold gray", async ({ page }) => {
|
||||
test.describe("Mobile responsive", () => {
|
||||
test("sidebar closed by default on mobile", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await page.goto("/");
|
||||
// Sidebar button should not be visible (sidebar closed)
|
||||
await expect(page.getByRole("button", { name: "+ New Session" })).not.toBeVisible();
|
||||
// Main content visible
|
||||
await expect(page.getByText("No sessions yet")).toBeVisible();
|
||||
});
|
||||
|
||||
test("hamburger opens sidebar overlay on mobile", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Toggle sidebar" }).click();
|
||||
await expect(page.getByRole("button", { name: "+ New Session" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("sidebar has close button on mobile", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Toggle sidebar" }).click();
|
||||
const closeBtn = page.getByRole("button", { name: "×" });
|
||||
await expect(closeBtn).toBeVisible();
|
||||
await closeBtn.click();
|
||||
await expect(page.getByRole("button", { name: "+ New Session" })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("settings gear accessible on mobile", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("button", { name: "Settings" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("create modal works on mobile", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Create session" }).click();
|
||||
await expect(page.getByRole("heading", { name: "New Session" })).toBeVisible();
|
||||
await expect(page.getByPlaceholder("/path/to/your/project")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Design system", () => {
|
||||
test("uses warm navy background", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const bg = await page.evaluate(() =>
|
||||
getComputedStyle(document.body).backgroundColor,
|
||||
);
|
||||
// #0f172a = rgb(15, 23, 42) -- warm navy
|
||||
// #0f172a = rgb(15, 23, 42)
|
||||
expect(bg).toContain("15");
|
||||
// Not #0d1117 = rgb(13, 17, 23) -- cold GitHub gray
|
||||
expect(bg).not.toBe("rgb(13, 17, 23)");
|
||||
});
|
||||
|
||||
@@ -178,8 +263,16 @@ test.describe("Design system verification", () => {
|
||||
expect(fonts.toLowerCase()).toContain("dm sans");
|
||||
});
|
||||
|
||||
test("empty state shows select a session message", async ({ page }) => {
|
||||
test("focus-visible ring appears on keyboard navigation", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
await page.goto("/");
|
||||
await expect(page.getByText("Select a session")).toBeVisible();
|
||||
// Tab to the first button
|
||||
await page.keyboard.press("Tab");
|
||||
const outline = await page.evaluate(() => {
|
||||
const el = document.activeElement;
|
||||
return el ? getComputedStyle(el).outlineColor : "";
|
||||
});
|
||||
// Should have a brand-colored outline
|
||||
expect(outline).not.toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user