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:
Nathan Brake
2026-04-11 21:25:49 -04:00
committed by GitHub
parent ba70912803
commit 91d34bb9b2
39 changed files with 1707 additions and 2265 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
[build]
jobs = 4
jobs = 8
[alias]
xtask = "run -p xtask --"
+1
View File
@@ -57,3 +57,4 @@ debug.log
website/.next/
.worktrees/
.gstack/
.playwright-mcp/
Generated
+1
View File
@@ -1709,6 +1709,7 @@ dependencies = [
"cfg-if",
"cfg_aliases 0.2.1",
"libc",
"memoffset",
]
[[package]]
+1 -1
View File
@@ -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"
+14 -13
View File
@@ -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
View File
@@ -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
View File
@@ -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();
+49
View File
@@ -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
View File
@@ -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
View File
@@ -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>
);
}
-52
View File
@@ -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>
);
}
+130
View File
@@ -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"
>
&times;
</button>
</div>
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
{right}
</div>
</div>
</>
)}
</div>
);
}
-245
View File
@@ -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"
>
&times;
</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>
);
}
+184
View File
@@ -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>
);
}
-131
View File
@@ -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"
>
&larr; 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>
);
}
-28
View File
@@ -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 -5
View File
@@ -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" },
];
-77
View File
@@ -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">&#9632;</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>
);
}
-137
View File
@@ -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"
>
&times;
</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>
);
}
-77
View File
@@ -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>
);
}
+144
View File
@@ -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>
);
}
-46
View File
@@ -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"
>
&times;
</button>
)}
</div>
</div>
);
}
-52
View File
@@ -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">&middot;</span>
<span className="truncate text-accent-600">{session.branch}</span>
</>
)}
</div>
</button>
);
});
+1 -1
View File
@@ -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>
)}
-236
View File
@@ -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 &ldquo;{searchQuery}&rdquo;</>
) : (
<>
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>
);
}
-38
View File
@@ -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>
);
}
+23 -40
View File
@@ -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"
>
&larr;
</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"
+39
View File
@@ -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>
);
}
+277
View File
@@ -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"
>
&times;
</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"
/>
</>
);
}
-89
View File
@@ -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"
>
&larr; 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 &rarr;
</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>
);
}
-19
View File
@@ -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 -16
View File
@@ -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
View File
@@ -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 };
}
+63
View File
@@ -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
View File
@@ -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
View File
@@ -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 [];
}
}
+18
View File
@@ -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
View File
@@ -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
View File
@@ -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("");
});
});