diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b30355..5f6f5a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,52 @@ All notable changes to Faber will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.9.2] - 2026-03-xx WIP + +### Added + +- **Settings View** — Dedicated full-screen settings page with master-detail layout replacing the old sidebar modals. Eight organized tabs split into App-scoped (General, Terminal, Agents, Prompts) and Project-scoped (Project, Git & Worktrees, ACP Permissions, GitHub) sections. Open with **Ctrl+,** or from the command palette +- **Status Bar** — New bottom bar showing MCP status and port, GitHub auth status, top agent usage percentage, context-sensitive keyboard shortcuts, and app version +- **File Search** — File browser now preloads a project file index in the background and supports client-side filtering with highlighted search matches. Re-indexes automatically when files change +- **File Context Menu** — Right-click any file in the tree for quick actions: copy relative path, copy absolute path, reveal in file explorer, or open in an external editor (auto-detects VS Code, Cursor, Zed, Windsurf, Fleet, Sublime, Vim, Neovim) +- **Task Progress Ring** — In-progress task cards on the Kanban board now show a circular progress indicator (SVG ring) with percentage text, driven by MCP `report_progress` step data. Research/exploring activities display in amber; regular work in blue +- **Task Dependency Indicators** — Task cards show per-dependency met/unmet dots (filled green for met, outlined amber for unmet) and a dependents badge showing how many other tasks depend on this one +- **Epic Dependency Connectors** — Kanban columns now render small vertical connector arrows between epic children that have dependency relationships, making chains visible directly on the board +- **Searchable Filter Dropdowns** — Unbounded filter lists (Labels, Agents, Epics) in the Dashboard filter bar now use searchable dropdown popovers instead of inline chips, with count badges on the trigger buttons and active filter pills displayed below the bar +- **Session History Sidebar** — Session history is now a global tab in the right sidebar (Files | Session History), accessible from any view instead of being buried inside Chat and Sessions +- **Skeleton Loaders** — Views now show skeleton placeholders that match content layout while loading (commit graph, rules, agent activity, Kanban cards) instead of bare spinners. Archive actions show inline loading spinners with disabled states +- **Empty States** — Contextual hints, icons, and call-to-action buttons throughout the app when views have no content — Kanban columns, dependency graph, commit graph, file list, review panel, and more +- **Motion & Accessibility** — Respects the OS "reduce motion" preference. Icon-only buttons now have screen-reader labels, custom buttons show focus-visible outlines, and error/warning banners are announced to assistive technology +- **Success Toasts** — Green flash notifications (3-second auto-dismiss) for confirming actions like ACP adapter installs and session renames + +### Changed + +- **Settings Architecture** — Moved all settings from sidebar dialog modals into the new dedicated Settings view. Sidebar gear icon and Ctrl+, both navigate to the settings page. Git & Worktrees settings (branch naming, instruction files) now have their own tab instead of being buried in the Project tab +- **ACP Adapter Updates** — Install command now pins to the exact registry version (e.g., `npm install -g @package@0.24.1`), invalidates npm and registry caches after install, and extracts user-friendly error messages from npm stderr instead of dumping raw output +- **Focus-Within Accessibility** — Action buttons on task cards, chat messages, dependency graph rows, quick action bar, and task body now appear on focus-within (not just hover) for keyboard accessibility +- **Session Grid Resize Handles** — Column and row resize handles now show centered dot indicators on hover for better discoverability +- **Session Pane** — Removed reorder arrows (drag-and-drop is the primary method); added brief "Saved" indicator after session rename; wider rename input field +- **Permission Dialog Urgency** — Timeout bar is thicker and the urgent state (last 30 seconds) now pulses with an animation +- **Command Palette** — Added "Go to Settings" navigation command +- **Typography Scale** — Standardized all text sizes across 100+ components to a canonical scale (`text-micro` 8px, `text-2xs` 10px, `text-xs` 12px, `text-sm` 14px, `text-base` 16px), replacing ad-hoc `text-[Npx]` values with new custom Tailwind utilities +- **Filter Bar Architecture** — Status and Priority filters remain as inline toggle chips; Labels, Agents, and Epics now use collapsible searchable dropdowns for better usability in large projects +- **DnD Visual Feedback** — Dragged Kanban cards now fully hide (opacity-0) during drag instead of showing a faded ghost, for cleaner drag-and-drop +- **GitHub View Polish** — Resizable detail panels, two-line PR/Issue rows with overflow-safe label capping, retry buttons on error banners, and improved commit graph merge indicator contrast +- **Responsive Layout** — Toolbars wrap gracefully on narrower windows (1024px+), filter bar sections stay grouped, and detail panels clamp to a sensible max width +- **ACP Adapter Package** — Claude Code ACP adapter moved from `@zed-industries/claude-agent-acp` to `@agentclientprotocol/claude-agent-acp` — install commands and error hints updated automatically +- **Launch Dialog Toggles** — "Create worktree" option in Launch Task and Launch Session dialogs now uses a Switch toggle instead of a checkbox +- **Project Creation** — Improved validation and welcome screen polish +- **Priority Badge Styling** — Compact inline priority badges with smaller font +- **Task Card Layout** — Increased padding, improved visual hierarchy, and inline action buttons hidden by default (revealed on hover/focus) + +### Fixed + +- **ACP Adapter Install on Windows** — npm install now hides the console window (CREATE_NO_WINDOW flag) to prevent a flash of a terminal window +- **GITHUB_TOKEN on macOS** — Faber now picks up GITHUB_TOKEN/GH_TOKEN from shell profiles, fixing GitHub auth failures in GUI-launched apps +- **Stale ACP Working Indicator** — Cancelling and resending a prompt no longer causes the working indicator to disappear prematurely +- **Task Card Context Menu Position** — Context menu now appears at the cursor instead of the top-left corner of the card +- **Branch Display Sync** — Sidebar and chat branch indicators now stay in sync when branches change outside the app (e.g., via terminal) + ## [0.9.1] - 2026-03-xx WIP ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 38d913c..c16c9ab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -148,6 +148,7 @@ error!(session_id = %id, error = %e, "PTY spawn failed"); - **Custom semantic tokens:** `text-dim-foreground` (between foreground and muted), `text-success` / `bg-success`, `text-warning` / `bg-warning` - **Glass/solid switching:** Use `useTheme()` → `isGlass` boolean. For panels: `` (orecus.io Card). For shell containers (sidebar, status bar, tab bar): `glassStyles[isGlass ? "subtle" : "solid"]` from `color-utils.ts` - **Panel borders:** Use `ring-1 ring-border/40` for subtle panel containers, `border-border` for structural dividers (border-b, border-l, etc.) +- **Typography scale:** Use only these sizes — `text-micro` (8px, reserved), `text-2xs` (10px, badges/counters/metadata), `text-xs` (12px, labels/hints/secondary), `text-sm` (14px, primary UI text), `text-base` (16px, headings). Do **not** introduce arbitrary `text-[Xpx]` values without justification. - Tailwind `animate-spin` for spinners; use `` from lucide-react - Theme selectors: `[data-theme^="dark"]`, `[data-theme^="light"]` - Main CSS file: `src/styles/main.css` diff --git a/docs/chat.md b/docs/chat.md index b506e86..c7fe08e 100644 --- a/docs/chat.md +++ b/docs/chat.md @@ -11,15 +11,11 @@ Project Chat is a conversational interface for talking to an ACP-capable agent a ## Starting a Chat -Open the **Chat** tab in the top bar. If no chat session is active, you'll see a two-column layout: +Open the **Chat** tab in the top bar. If no chat session is active, you'll see a centered launcher: -**Left column — New Chat:** 1. **Pick an agent** — Only agents with both the CLI and ACP adapter installed are selectable. Each card shows "CLI" and "ACP" status badges. 2. **Click "New Chat"** — This launches a fresh ACP session scoped to your current project. -**Right column — Session History:** -If the selected agent supports session listing, a sidebar shows your previous conversations (see [Resuming Sessions](#resuming-sessions) below). - > **Note:** Chat requires an ACP-capable agent (e.g. Claude Code with the ACP adapter). If none are installed, a warning banner explains what's needed. Only one chat session can be active per project at a time. If you switch away and come back, the existing session is automatically resumed. @@ -135,16 +131,16 @@ Hover over any of your sent messages to reveal a pencil icon. Click it to copy t When an agent supports the ACP `session/list` and `session/load` protocol, you can resume past conversations across app restarts. -### Session History Sidebar +### Session History (Right Sidebar) -The right column of the Chat empty state shows a **Previous Sessions** list fetched from the agent: +Session history is accessible globally from the **right sidebar**. Click the **Session History** tab (next to the Files tab) to view a **Previous Sessions** list fetched from the agent. The right sidebar can be toggled with **Ctrl+B** and the active tab persists across restarts. - **Search** — Filter sessions by title using the search bar at the top. - **Refresh** — Click the refresh icon to re-fetch the list from the agent. -- **Resume** — Opens the session in the Chat view, replaying conversation history so you can pick up where you left off. -- **Session** — Opens the session as a pane in the Sessions view, then navigates you there. +- **Resume in Chat** — Opens the session in the Chat view, replaying conversation history so you can pick up where you left off. This button is disabled when a chat session is already active. +- **Open as Session** — Opens the session as a pane in the Sessions view, then navigates you there. -Each row shows the session title (or "Untitled session") and a relative timestamp (e.g. "2h ago", "yesterday"). +Each row shows the session title (or "Untitled session") and a relative timestamp (e.g. "2h ago", "yesterday"). The session list auto-fetches when you switch to the Session History tab. ### Capability Detection @@ -152,7 +148,7 @@ Not all agents support session persistence. If an agent doesn't support listing: - The sidebar shows "This agent doesn't support session history" with a **Retry** button. - The "not supported" result is cached so Faber won't re-probe on every visit. Click **Retry** to clear the cache and check again (useful after an agent update). -If the agent supports listing but not loading (resume), sessions appear in the list but the Resume and Session buttons are disabled with a tooltip explanation. +If the agent supports listing but not loading (resume), sessions appear in the list but the Resume and Open as Session buttons are disabled with a tooltip explanation. ### Error Handling diff --git a/docs/general.md b/docs/general.md index 3be6fc9..bdf17b9 100644 --- a/docs/general.md +++ b/docs/general.md @@ -70,7 +70,7 @@ Faber runs a local MCP (Model Context Protocol) server that agents use to report ## Views -Navigate between views using the top bar tabs or the command palette. +Navigate between views using the top bar tabs, the command palette, or keyboard shortcuts. ### Dashboard (Tasks) @@ -120,10 +120,11 @@ The Tree view is especially useful when you have many tasks with dependency rela A multi-pane terminal grid showing all active agent sessions. Features: -- Resize the grid layout (1×1, 2×1, 2×2, 3×2, etc.) +- Resize the grid layout (1×1, 2×1, 2×2, 3×2, etc.) — resize handles show dot indicators on hover - Maximize a single pane to full size - Drag-and-drop panes to reorder - Each pane shows the agent name and MCP status overlay +- Rename sessions inline — a brief "Saved" confirmation appears after renaming - **Quick Action Bar** — hover over an active agent session to reveal floating action buttons (Commit, Fix Errors, Summarize, etc.) that send one-click prompts to the agent. Configure actions in Settings > Prompts. - Terminal output is buffered so you can switch views and come back without losing output @@ -158,6 +159,7 @@ Press **Ctrl+K** (or **Cmd+K** on macOS) to open the command palette. It provide - **Go to Sessions** — Switch to the session grid - **Go to GitHub** — Switch to the GitHub view - **Go to Review** — Switch to the review/diff view +- **Go to Settings** — Open the settings page (also available via **Ctrl+,**) ### Projects @@ -179,18 +181,34 @@ All active sessions are listed. Select one to focus its pane in the session grid The palette shows your **recent commands** when the search field is empty. Use the arrow keys to navigate and Enter to select. +## Status Bar + +A thin bar at the bottom of the app window provides at-a-glance system information: + +- **MCP Status** — Shows the MCP server port. Click to copy the sidecar binary path. +- **GitHub Auth** — Shows authentication status. Warning icons appear for missing auth or insufficient token scopes. +- **Agent Usage** — Displays the top utilization percentage across all agents. +- **Keyboard Shortcuts** — Context-sensitive hints for the current view (e.g., Ctrl+K for command palette, Ctrl+, for settings). +- **App Version** — Current Faber version number. + ## Settings -Open settings from the gear icon in the sidebar. Settings are organized into tabs: +Open settings with **Ctrl+,** (or **Cmd+,** on macOS), the gear icon in the sidebar, the status bar settings button, or from the command palette. Settings open as a dedicated full-screen view with a navigation sidebar on the left and content area on the right. Press **Escape** to close and return to your previous view. + +Settings are organized into **App** (global) and **Project** (per-project) sections: + +### App Settings -### General +#### General - **Color Mode** — Switch between Dark and Light themes - **Glass Effect** — Toggle the translucent glass UI style (not available on macOS) - **Show Project Icons** — Show or hide project icons in the sidebar +- **Reduce Motion** — Respects the OS "prefer reduced motion" setting. When enabled, animations and transitions throughout the app are minimized +- **Notifications** — Master toggle plus per-event toggles (Session Complete, Session Error, Input Needed). Clicking a notification takes you directly to the relevant session. - **Updates** — Check for app updates, enable auto-checking, and set the check frequency (hourly to daily). An advanced option lets you point to a custom update endpoint. -### Terminal +#### Terminal - **Default Shell** — Choose which shell to use for sessions (system default or a specific installed shell) - **Font Family** — Pick a terminal font from embedded fonts (JetBrains Mono), installed Nerd Fonts, or system fonts @@ -199,16 +217,7 @@ Open settings from the gear icon in the sidebar. Settings are organized into tab - **Line Height** — Adjust line spacing (1.0–2.0) - **Reset to Defaults** — Restore all terminal settings to their defaults -### Notifications - -- **Enable Notifications** — Master toggle for all OS notifications -- **Session Complete** — Notify when an agent finishes its work -- **Session Error** — Notify when an agent encounters an error -- **Input Needed** — Notify when an agent is waiting for your input - -Clicking a notification takes you directly to the relevant session. - -### Agents +#### Agents - **Default Agent** — Choose which AI agent to use by default (Claude Code, Codex CLI, Gemini CLI, OpenCode, or Cursor) - **Per-agent settings** (for installed agents): @@ -216,7 +225,7 @@ Clicking a notification takes you directly to the relevant session. - **Custom Flags** — Add extra CLI flags to the agent command - **Command Preview** — See the exact command that will be executed -### Prompts +#### Prompts Manage prompt templates and quick actions: @@ -224,15 +233,27 @@ Manage prompt templates and quick actions: - **Quick Actions** — Action buttons that appear on active session panes when you hover over them. Click a quick action to send the prompt directly to the agent. Built-in actions include "Commit", "Fix Errors", and "Summarize". You can add, edit, and delete custom actions with configurable labels, icons, and prompts. - **Reset to Defaults** — Restore all templates and actions to their built-in defaults. -### Projects +### Project Settings -Per-project configuration: +#### Project - **Project Icon** — Set an SVG icon for the project - **Tab Color** — Choose a color for the project's sidebar tab - **Default Agent / Model** — Override the global default for this project -- **Branch Naming Pattern** — Customize the worktree branch format using `{{task_id}}` and `{{task_slug}}` variables -- **Instruction File** — Point to a custom instruction file (relative to project root) for agent system prompts +- **Default Transport** — Choose PTY (terminal) or ACP (chat) as the default session transport - **Priorities** — Define custom priority levels for the project. Each priority has an ID (stored in task files), a display label, a color (from the ThemeColor palette), and a sort order. Add, remove, and reorder priorities as needed. Defaults to P0/P1/P2 for new projects. - **GitHub Sync** — Configure automatic syncing between task statuses and GitHub issues/PRs (see the [GitHub Workflow](github_workflow) guide for details) - **Delete Project** — Remove the project from Faber (does not delete files on disk) + +#### Git & Worktrees + +- **Branch Naming Pattern** — Customize the worktree branch format using `{{task_id}}` and `{{task_slug}}` variables +- **Instruction File** — Point to a custom instruction file (relative to project root) for agent system prompts + +#### ACP Permissions + +See the [ACP Permissions](acp_permissions) guide for details on configuring permission rules, trust mode, and timeout policies. + +#### GitHub + +GitHub CLI authentication status and configuration. See the [GitHub Workflow](github_workflow) guide for details. diff --git a/docs/github_workflow.md b/docs/github_workflow.md index 60be617..c35113e 100644 --- a/docs/github_workflow.md +++ b/docs/github_workflow.md @@ -193,7 +193,7 @@ This gives each task its own working directory, so agents can make changes witho ## GitHub Sync Settings -All sync behavior is controlled per-project in **Settings > Projects > [Your Project] > GitHub Sync**. +All sync behavior is controlled per-project in **Settings > Project > GitHub Sync** (the Project tab in the Settings view). The master toggle defaults to **OFF**. Nothing is written to GitHub until you explicitly enable it. diff --git a/docs/supported_agents.md b/docs/supported_agents.md index 07c3a1b..7be156b 100644 --- a/docs/supported_agents.md +++ b/docs/supported_agents.md @@ -167,7 +167,7 @@ Breakdown, Vibe, and Chat sessions have no completion tool — the user drives t ### Per-Project Defaults -In **Settings > Projects > [Your Project]**, you can set a default agent and model. All new sessions will use this agent unless overridden at launch time. +In **Settings > Project**, you can set a default agent and model. All new sessions will use this agent unless overridden at launch time. ### Per-Task Overrides diff --git a/src-tauri/src/agent/claude.rs b/src-tauri/src/agent/claude.rs index 4d6b9aa..0d9f949 100644 --- a/src-tauri/src/agent/claude.rs +++ b/src-tauri/src/agent/claude.rs @@ -3,8 +3,8 @@ use super::{is_command_in_path, AgentAdapter, AgentLaunchConfig, AgentLaunchSpec pub struct ClaudeCodeAdapter; /// The external ACP adapter binary from Zed. -/// Install via: `npm install -g @zed-industries/claude-agent-acp` -/// Or download from: https://github.com/zed-industries/claude-agent-acp/releases +/// Install via: `npm install -g @agentclientprotocol/claude-agent-acp` +/// Or download from: https://github.com/agentclientprotocol/claude-agent-acp/releases pub const CLAUDE_ACP_ADAPTER_COMMAND: &str = "claude-agent-acp"; impl AgentAdapter for ClaudeCodeAdapter { @@ -75,11 +75,11 @@ impl AgentAdapter for ClaudeCodeAdapter { } fn acp_install_command(&self) -> Option<&str> { - Some("npm install -g @zed-industries/claude-agent-acp") + Some("npm install -g @agentclientprotocol/claude-agent-acp") } fn acp_adapter_package(&self) -> Option<&str> { - Some("@zed-industries/claude-agent-acp") + Some("@agentclientprotocol/claude-agent-acp") } fn cli_install_url(&self) -> Option<&str> { diff --git a/src-tauri/src/agent/mod.rs b/src-tauri/src/agent/mod.rs index ed4a2ad..7df0bc0 100644 --- a/src-tauri/src/agent/mod.rs +++ b/src-tauri/src/agent/mod.rs @@ -55,10 +55,10 @@ pub struct AgentInfo { pub acp_command: Option, /// Additional args needed to launch in ACP mode (e.g., ["--acp"]). pub acp_args: Vec, - /// The shell command to install the ACP adapter (e.g., "npm install -g @zed-industries/claude-agent-acp"). + /// The shell command to install the ACP adapter (e.g., "npm install -g @agentclientprotocol/claude-agent-acp"). /// `None` for agents with native ACP support. pub acp_install_command: Option, - /// The npm package name for the ACP adapter (e.g., "@zed-industries/claude-agent-acp"). + /// The npm package name for the ACP adapter (e.g., "@agentclientprotocol/claude-agent-acp"). /// `None` for agents with native ACP support. pub acp_adapter_package: Option, /// URL to the official install/download page for this agent's CLI tool. diff --git a/src-tauri/src/agent/registry.rs b/src-tauri/src/agent/registry.rs index bd4d672..c8b3993 100644 --- a/src-tauri/src/agent/registry.rs +++ b/src-tauri/src/agent/registry.rs @@ -32,29 +32,75 @@ fn get_global_npm_versions() -> HashMap { if let Ok(guard) = NPM_VERSION_CACHE.lock() { if let Some(ref entry) = *guard { if entry.fetched_at.elapsed() < NPM_CACHE_TTL { + tracing::debug!( + age_secs = entry.fetched_at.elapsed().as_secs(), + count = entry.versions.len(), + "npm version cache hit" + ); return entry.versions.clone(); } } } + tracing::debug!("npm version cache miss — querying npm list -g"); + let mut versions = HashMap::new(); - let output = crate::cmd_no_window(if cfg!(windows) { "npm.cmd" } else { "npm" }) + let npm_cmd = if cfg!(windows) { "npm.cmd" } else { "npm" }; + let output = crate::cmd_no_window(npm_cmd) .args(["list", "-g", "--json", "--depth=0"]) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::null()) .output(); - if let Ok(output) = output { - if let Ok(json) = serde_json::from_slice::(&output.stdout) { - if let Some(deps) = json.get("dependencies").and_then(|d| d.as_object()) { - for (pkg_name, pkg_info) in deps { - if let Some(ver) = pkg_info.get("version").and_then(|v| v.as_str()) { - versions.insert(pkg_name.clone(), ver.to_string()); + match output { + Ok(ref result) if result.status.success() => { + match serde_json::from_slice::(&result.stdout) { + Ok(json) => { + if let Some(deps) = json.get("dependencies").and_then(|d| d.as_object()) { + for (pkg_name, pkg_info) in deps { + if let Some(ver) = pkg_info.get("version").and_then(|v| v.as_str()) { + versions.insert(pkg_name.clone(), ver.to_string()); + } + } + } + tracing::debug!(count = versions.len(), "Parsed global npm packages"); + } + Err(e) => { + tracing::warn!(%e, "Failed to parse npm list JSON output"); + } + } + } + Ok(ref result) => { + let stderr = String::from_utf8_lossy(&result.stderr); + tracing::warn!( + exit_code = ?result.status.code(), + stderr = %stderr.trim(), + "npm list -g exited with non-zero status" + ); + // npm list returns exit code 1 when there are peer dep warnings + // but still outputs valid JSON — try parsing anyway + if let Ok(json) = serde_json::from_slice::(&result.stdout) { + if let Some(deps) = json.get("dependencies").and_then(|d| d.as_object()) { + for (pkg_name, pkg_info) in deps { + if let Some(ver) = pkg_info.get("version").and_then(|v| v.as_str()) { + versions.insert(pkg_name.clone(), ver.to_string()); + } } } + tracing::debug!( + count = versions.len(), + "Parsed global npm packages from non-zero exit output" + ); } } + Err(e) => { + tracing::warn!( + error = %e, + command = %npm_cmd, + "Failed to run npm list -g — npm may not be installed" + ); + } } // Update cache @@ -72,14 +118,47 @@ fn get_global_npm_versions() -> HashMap { /// Returns `true` if the registry version is strictly newer. fn is_update_available(installed_version: &str, registry_version: &str) -> bool { // Try parsing as semver - if let (Ok(installed), Ok(registry)) = ( + match ( semver::Version::parse(installed_version), semver::Version::parse(registry_version), ) { - return registry > installed; + (Ok(installed), Ok(registry)) => { + let has_update = registry > installed; + tracing::debug!( + %installed_version, + %registry_version, + has_update, + "Semver version comparison" + ); + has_update + } + (installed_result, registry_result) => { + // Log parse failures for diagnostics + if let Err(ref e) = installed_result { + tracing::debug!( + version = %installed_version, + error = %e, + "Failed to parse installed version as semver" + ); + } + if let Err(ref e) = registry_result { + tracing::debug!( + version = %registry_version, + error = %e, + "Failed to parse registry version as semver" + ); + } + // Fallback: simple string comparison — only flag if they differ + let has_update = installed_version != registry_version; + tracing::debug!( + %installed_version, + %registry_version, + has_update, + "Fallback string version comparison" + ); + has_update + } } - // Fallback: simple string comparison — only flag if they differ - installed_version != registry_version } // ── Constants ── @@ -228,6 +307,24 @@ fn registry_id_to_faber() -> HashMap<&'static str, &'static str> { // ── Public API ── +/// Invalidate the npm version cache so the next `fetch_registry` call re-queries +/// globally installed packages. Called after an adapter install/update. +pub fn invalidate_npm_cache() { + if let Ok(mut guard) = NPM_VERSION_CACHE.lock() { + *guard = None; + tracing::debug!("NPM version cache invalidated"); + } +} + +/// Invalidate the registry cache so the next `fetch_registry` call re-fetches +/// from the CDN and re-checks installed versions. +pub fn invalidate_registry_cache() { + if let Ok(mut guard) = REGISTRY_CACHE.lock() { + *guard = None; + tracing::debug!("ACP registry cache invalidated"); + } +} + /// Fetch the ACP registry, filter to Faber-supported agents, and enrich /// with local installation status. Uses a 1-hour in-memory cache. pub async fn fetch_registry(force_refresh: bool) -> Result, String> { @@ -308,10 +405,24 @@ pub async fn fetch_registry(force_refresh: bool) -> Result, let npm_versions = get_global_npm_versions(); if let Some(installed_ver) = npm_versions.get(local_pkg.as_str()) { let has_update = is_update_available(installed_ver, &agent.version); + tracing::info!( + agent = %faber_name, + package = %local_pkg, + installed = %installed_ver, + registry = %agent.version, + update_available = has_update, + "Version check for ACP adapter" + ); (Some(installed_ver.clone()), has_update) } else { // Package is installed (detected by file existence) but npm doesn't report it. // Don't flag as update available — could be a non-npm install method. + tracing::info!( + agent = %faber_name, + package = %local_pkg, + registry = %agent.version, + "ACP adapter detected but not found in npm list — skipping version check" + ); (None, false) } } else { diff --git a/src-tauri/src/commands/agents.rs b/src-tauri/src/commands/agents.rs index 94c4067..3d11502 100644 --- a/src-tauri/src/commands/agents.rs +++ b/src-tauri/src/commands/agents.rs @@ -15,6 +15,7 @@ pub fn list_agents() -> Vec { pub async fn install_acp_adapter( app: AppHandle, agent_name: String, + target_version: Option, ) -> Result, AppError> { let adapter = agent::get_adapter(&agent_name) .ok_or_else(|| AppError::NotFound(format!("Agent {agent_name}")))?; @@ -26,7 +27,7 @@ pub async fn install_acp_adapter( ))); } - let install_cmd = adapter.acp_install_command().ok_or_else(|| { + let base_install_cmd = adapter.acp_install_command().ok_or_else(|| { AppError::Validation(format!( "{} has native ACP support — no adapter to install", adapter.display_name() @@ -39,10 +40,23 @@ pub async fn install_acp_adapter( .unwrap_or("unknown") .to_string(); + // When a target version is specified (update), pin the install to that exact version. + // e.g. "npm install -g @agentclientprotocol/claude-agent-acp" → "npm install -g @agentclientprotocol/claude-agent-acp@0.24.1" + let install_cmd = if let Some(ref version) = target_version { + if !package.is_empty() && package != "unknown" { + base_install_cmd.replace(&package, &format!("{package}@{version}")) + } else { + base_install_cmd.to_string() + } + } else { + base_install_cmd.to_string() + }; + tracing::info!( agent = %agent_name, command = %install_cmd, package = %package, + target_version = ?target_version, "Starting ACP adapter installation" ); @@ -56,17 +70,23 @@ pub async fn install_acp_adapter( }), ); - // Run the install command - let output = if cfg!(windows) { - tokio::process::Command::new("cmd") - .args(["/C", install_cmd]) - .output() - .await - } else { - tokio::process::Command::new("sh") - .args(["-c", install_cmd]) - .output() - .await + // Run the install command (hide console window on Windows) + let output = { + let mut cmd = if cfg!(windows) { + let mut c = tokio::process::Command::new("cmd"); + c.args(["/C", &install_cmd]); + c + } else { + let mut c = tokio::process::Command::new("sh"); + c.args(["-c", &install_cmd]); + c + }; + #[cfg(windows)] + { + const CREATE_NO_WINDOW: u32 = 0x08000000; + cmd.creation_flags(CREATE_NO_WINDOW); + } + cmd.output().await }; match output { @@ -79,6 +99,10 @@ pub async fn install_acp_adapter( "ACP adapter installed successfully" ); + // Invalidate caches so the next registry fetch picks up the new version + agent::registry::invalidate_npm_cache(); + agent::registry::invalidate_registry_cache(); + // Re-detect all agents to pick up the new adapter let agents = agent::list_agent_info(); @@ -105,18 +129,21 @@ pub async fn install_acp_adapter( "ACP adapter installation failed" ); + // Extract a clean, user-friendly message from npm's verbose stderr. + // Prioritise "npm error notarget ..." lines, then any "npm error ..." line, + // falling back to a generic message. Full output is already in the logs. + let user_message = extract_npm_error(&stderr) + .unwrap_or_else(|| format!("Installation failed (exit code {}). Check logs for details.", result.status.code().unwrap_or(-1))); + let _ = app.emit( "acp-adapter-install-progress", serde_json::json!({ "agent_name": agent_name, "status": "failed", - "message": format!("Failed to install ACP adapter: {}", stderr.trim()), + "message": user_message, }), ); - Err(AppError::Io(format!( - "ACP adapter installation failed: {}", - stderr.trim() - ))) + Err(AppError::Io(user_message)) } Err(e) => { let msg = if e.kind() == std::io::ErrorKind::NotFound { @@ -210,3 +237,47 @@ pub fn delete_agent_config( db::agent_configs::delete(&conn, &scope, scope_id.as_deref(), &agent_name) .map_err(AppError::from) } + +// ── Helpers ── + +/// Extract a clean, user-facing error message from npm's stderr output. +/// +/// npm stderr contains warnings, error codes, and multi-line explanations. +/// This extracts the most relevant line for display in the UI — the full +/// output is already captured in the structured log. +fn extract_npm_error(stderr: &str) -> Option { + let mut best: Option<&str> = None; + + for line in stderr.lines() { + let trimmed = line.trim(); + + // "npm error notarget No matching version found for ..." — most specific + if trimmed.starts_with("npm error notarget") || trimmed.starts_with("npm ERR! notarget") { + let msg = trimmed + .trim_start_matches("npm error notarget") + .trim_start_matches("npm ERR! notarget") + .trim(); + if !msg.is_empty() { + // Return the first meaningful notarget line + return Some(msg.to_string()); + } + } + + // Any "npm error " / "npm ERR! " that isn't a code/log path + if (trimmed.starts_with("npm error") || trimmed.starts_with("npm ERR!")) + && !trimmed.contains("A complete log of this run") + && !trimmed.starts_with("npm error code") + && !trimmed.starts_with("npm ERR! code") + { + let msg = trimmed + .trim_start_matches("npm error") + .trim_start_matches("npm ERR!") + .trim(); + if !msg.is_empty() && best.is_none() { + best = Some(msg); + } + } + } + + best.map(|s| s.to_string()) +} diff --git a/src-tauri/src/commands/files.rs b/src-tauri/src/commands/files.rs index 3b7d51c..9535a24 100644 --- a/src-tauri/src/commands/files.rs +++ b/src-tauri/src/commands/files.rs @@ -1,5 +1,6 @@ use crate::db::models::FileEntry; use crate::error::AppError; +use serde::Serialize; use std::path::Path; /// Open a file using the OS default application. @@ -43,6 +44,199 @@ pub async fn open_file_in_os(path: String) -> Result<(), AppError> { Ok(()) } +#[derive(Debug, Clone, Serialize)] +pub struct EditorInfo { + pub id: String, + pub label: String, + pub command: String, +} + +/// Known editors to probe for in PATH. +const KNOWN_EDITORS: &[(&str, &str, &str)] = &[ + ("vscode", "VS Code", "code"), + ("cursor", "Cursor", "cursor"), + ("zed", "Zed", "zed"), + ("windsurf", "Windsurf", "windsurf"), + ("fleet", "Fleet", "fleet"), + ("sublime", "Sublime Text", "subl"), + ("vim", "Vim", "vim"), + ("neovim", "Neovim", "nvim"), +]; + +/// Check if a command is available on PATH. +fn command_exists(cmd: &str) -> bool { + #[cfg(target_os = "windows")] + { + // On Windows, check for cmd, cmd.exe, and cmd.cmd variants + use std::process::Command; + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x08000000; + Command::new("where") + .arg(cmd) + .creation_flags(CREATE_NO_WINDOW) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + } + #[cfg(not(target_os = "windows"))] + { + use std::process::Command; + Command::new("which") + .arg(cmd) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + } +} + +/// Detect which code editors are available on the system PATH. +#[tauri::command] +pub async fn detect_editors() -> Result, AppError> { + let editors: Vec = KNOWN_EDITORS + .iter() + .filter(|(_, _, cmd)| command_exists(cmd)) + .map(|(id, label, cmd)| EditorInfo { + id: id.to_string(), + label: label.to_string(), + command: cmd.to_string(), + }) + .collect(); + + Ok(editors) +} + +/// Open a file or directory in a specific editor. +#[tauri::command] +pub async fn open_in_editor(path: String, editor_id: String) -> Result<(), AppError> { + let file_path = Path::new(&path); + if !file_path.exists() { + return Err(AppError::Validation(format!( + "Path does not exist: {}", + path + ))); + } + + let cmd = KNOWN_EDITORS + .iter() + .find(|(id, _, _)| *id == editor_id.as_str()) + .map(|(_, _, cmd)| *cmd) + .ok_or_else(|| AppError::Validation(format!("Unknown editor: {}", editor_id)))?; + + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x08000000; + std::process::Command::new("cmd") + .args(["/c", cmd, &path]) + .creation_flags(CREATE_NO_WINDOW) + .spawn() + .map_err(|e| AppError::Io(format!("Failed to open in editor: {}", e)))?; + } + + #[cfg(not(target_os = "windows"))] + { + std::process::Command::new(cmd) + .arg(&path) + .spawn() + .map_err(|e| AppError::Io(format!("Failed to open in editor: {}", e)))?; + } + + Ok(()) +} + +/// Directories to skip during file listing/search. +const IGNORED_DIRS: &[&str] = &[ + "node_modules", + "target", + "__pycache__", + ".git", + "dist", + "build", + ".next", + ".nuxt", + ".output", + "out", + ".turbo", + ".cache", +]; + +/// Check if a directory name should be skipped. +fn should_skip_dir(name: &str) -> bool { + IGNORED_DIRS.contains(&name) +} + +/// Recursively index all files in a project directory. +/// Returns a flat list of all FileEntry items, sorted alphabetically by path. +/// Skips hidden files/dirs and common noisy directories. +#[tauri::command] +pub async fn index_project_files( + project_root: String, +) -> Result, AppError> { + let root = Path::new(&project_root); + let canonical_root = root + .canonicalize() + .map_err(|e| AppError::Io(format!("Cannot resolve project root '{}': {}", project_root, e)))?; + + let mut results = Vec::new(); + + fn walk(dir: &Path, canonical_root: &Path, results: &mut Vec) { + let read_dir = match std::fs::read_dir(dir) { + Ok(rd) => rd, + Err(_) => return, + }; + + for entry in read_dir { + let entry = match entry { + Ok(e) => e, + Err(_) => continue, + }; + let metadata = match entry.metadata() { + Ok(m) => m, + Err(_) => continue, + }; + let file_name = entry.file_name().to_string_lossy().to_string(); + + if file_name.starts_with('.') { + continue; + } + + if metadata.is_dir() { + if should_skip_dir(&file_name) { + continue; + } + walk(&entry.path(), canonical_root, results); + } else { + let rel_path = entry + .path() + .strip_prefix(canonical_root) + .unwrap_or(&entry.path()) + .to_string_lossy() + .replace('\\', "/"); + + let extension = entry + .path() + .extension() + .map(|e| e.to_string_lossy().to_string()); + + results.push(FileEntry { + name: file_name, + path: rel_path, + is_dir: false, + size: Some(metadata.len()), + extension, + }); + } + } + } + + walk(&canonical_root, &canonical_root, &mut results); + + // Sort alphabetically by path + results.sort_by(|a, b| a.path.to_lowercase().cmp(&b.path.to_lowercase())); + + Ok(results) +} + /// List entries in a directory, sorted: directories first, then alphabetically. /// The `path` must be an absolute path. Returns relative paths from `project_root`. #[tauri::command] @@ -84,12 +278,8 @@ pub async fn list_directory( } // Skip common noisy directories - if metadata.is_dir() { - match file_name.as_str() { - "node_modules" | "target" | "__pycache__" | ".git" | "dist" | "build" - | ".next" | ".nuxt" | ".output" | "out" | ".turbo" | ".cache" => continue, - _ => {} - } + if metadata.is_dir() && should_skip_dir(&file_name) { + continue; } let is_dir = metadata.is_dir(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1cfc725..5ef7820 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -28,40 +28,74 @@ pub(crate) struct LogDir(pub std::path::PathBuf); /// On macOS, GUI apps launched from Finder/Dock inherit a minimal system PATH /// (`/usr/bin:/bin:/usr/sbin:/sbin`) that doesn't include directories where /// CLI tools are typically installed (Homebrew, npm globals, cargo, etc.). +/// They also miss environment variables set in shell profiles (e.g. GITHUB_TOKEN). /// /// This function runs the user's default login shell to resolve their full PATH -/// and applies it to the current process so that `is_command_in_path()`, PTY -/// spawns, and any other child processes see the same tools as a terminal. +/// and important environment variables, then applies them to the current process +/// so that `is_command_in_path()`, PTY spawns, `gh auth`, and any other child +/// processes see the same environment as a terminal. #[cfg(target_os = "macos")] fn fix_path_env() { use std::process::Command; let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string()); - // Run a login+interactive shell that just prints PATH, then exits. + // Environment variables to capture from the user's login shell. + // PATH is essential for finding CLI tools. + // GitHub/GH tokens are needed for `gh` CLI authentication when set as env vars. + // EDITOR/VISUAL are used by git and other tools. + const VARS_TO_CAPTURE: &[&str] = &[ + "PATH", + "GITHUB_TOKEN", + "GH_TOKEN", + "GH_HOST", + "EDITOR", + "VISUAL", + ]; + + // Build a shell command that prints each var with a unique marker prefix. + // Using a marker avoids capturing MOTD or shell greeting output. + let print_commands: Vec = VARS_TO_CAPTURE + .iter() + .map(|var| format!("echo __FABER_{var}__=${{{var}}}")) + .collect(); + let shell_cmd = print_commands.join("; "); + + // Run a login+interactive shell that prints the vars, then exits. // `-l` sources profile files (.zprofile, .bash_profile, etc.). // `-i` sources rc files (.zshrc, .bashrc) where tools like nvm/volta add // themselves. `-c` runs a command and exits. let output = Command::new(&shell) - .args(["-l", "-i", "-c", "echo __FABER_PATH__=$PATH"]) + .args(["-l", "-i", "-c", &shell_cmd]) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::null()) .output(); if let Ok(output) = output { let stdout = String::from_utf8_lossy(&output.stdout); - // Extract the PATH value from the marker line to avoid capturing - // any MOTD or shell greeting output. - if let Some(line) = stdout.lines().find(|l| l.starts_with("__FABER_PATH__=")) { - let path = line.trim_start_matches("__FABER_PATH__="); - if !path.is_empty() { - tracing::info!(entries = path.matches(':').count() + 1, "macOS: Resolved shell PATH"); - std::env::set_var("PATH", path); - return; + let mut resolved_count = 0u32; + + for var in VARS_TO_CAPTURE { + let marker = format!("__FABER_{var}__="); + if let Some(line) = stdout.lines().find(|l| l.starts_with(&marker)) { + let value = line.trim_start_matches(&marker); + if !value.is_empty() { + if *var == "PATH" { + tracing::info!(entries = value.matches(':').count() + 1, "macOS: Resolved shell PATH"); + } else { + tracing::info!(var, "macOS: Resolved shell env var"); + } + std::env::set_var(var, value); + resolved_count += 1; + } } } + + if resolved_count > 0 { + return; + } } - tracing::warn!("macOS: Could not resolve shell PATH, using system default"); + tracing::warn!("macOS: Could not resolve shell environment, using system defaults"); } /// Create a `Command` that won't spawn a visible console window on Windows. @@ -337,7 +371,10 @@ pub fn run() { commands::continuous::get_continuous_mode_status, commands::usage::get_agent_usage, commands::files::list_directory, + commands::files::index_project_files, commands::files::open_file_in_os, + commands::files::detect_editors, + commands::files::open_in_editor, commands::plugins::list_plugins, commands::plugins::get_plugin_readme, commands::plugins::install_plugin, diff --git a/src-tauri/src/session.rs b/src-tauri/src/session.rs index c3ad546..6b4df11 100644 --- a/src-tauri/src/session.rs +++ b/src-tauri/src/session.rs @@ -924,9 +924,11 @@ fn validate_acp_agent(adapter: &dyn agent::AgentAdapter, agent_name: &str) -> Re )))?; if !adapter.detect_acp_adapter() { + let install_hint = adapter.acp_install_command() + .unwrap_or("npm install -g "); return Err(AppError::Validation(format!( "Agent '{agent_name}' requires the ACP adapter '{acp_command}' which is not installed. \ - Install it via: npm install -g @zed-industries/{acp_command}" + Install it via: {install_hint}" ))); } diff --git a/src/components/Chat/ActivityBar.tsx b/src/components/Chat/ActivityBar.tsx index 2a7ba10..2676f92 100644 --- a/src/components/Chat/ActivityBar.tsx +++ b/src/components/Chat/ActivityBar.tsx @@ -203,10 +203,10 @@ export default React.memo(function ActivityBar({ {promptPending ? (
- Processing… + Processing…
) : ( - + Waiting for input )} @@ -230,12 +230,12 @@ function PlanSummarySection({ return ( - + Plan - + {completedCount}/{totalCount} @@ -261,7 +261,7 @@ function PlanSummarySection({ - + Files - + {fileEdits.length} @@ -321,19 +321,19 @@ function FileEditSection({ fileEdits }: { fileEdits: FileEditEntry[] }) { {/* Summary counts */}
{createdCount > 0 && ( - + {createdCount} created )} {modifiedCount > 0 && ( - + {modifiedCount} modified )} {deletedCount > 0 && ( - + {deletedCount} deleted @@ -343,16 +343,16 @@ function FileEditSection({ fileEdits }: { fileEdits: FileEditEntry[] }) { {/* Total diff stats */} {(totalStats.added > 0 || totalStats.removed > 0) && (
- + Total: {totalStats.added > 0 && ( - + +{totalStats.added} )} {totalStats.removed > 0 && ( - + −{totalStats.removed} )} @@ -375,7 +375,7 @@ function FileEditRow({ file }: { file: FileEditEntry }) { return (
{/* File type icon with status color overlay */} @@ -395,17 +395,17 @@ function FileEditRow({ file }: { file: FileEditEntry }) { {/* Per-file diff stats */} {file.linesAdded > 0 && ( - + +{file.linesAdded} )} {file.linesRemoved > 0 && ( - + −{file.linesRemoved} )} {file.linesAdded === 0 && file.linesRemoved === 0 && file.edits > 1 && ( - + ×{file.edits} )} diff --git a/src/components/Chat/AgentPlanView.tsx b/src/components/Chat/AgentPlanView.tsx index 5b31f51..6820d6a 100644 --- a/src/components/Chat/AgentPlanView.tsx +++ b/src/components/Chat/AgentPlanView.tsx @@ -19,9 +19,9 @@ import type { AcpPlanEntry } from "../../types"; function PlanStatusIcon({ status }: { status: string }) { switch (status) { case "in_progress": - return ; + return ; case "completed": - return ; + return ; default: return ; } diff --git a/src/components/Chat/AgentTurnBlock.tsx b/src/components/Chat/AgentTurnBlock.tsx index 09a6291..718f4ef 100644 --- a/src/components/Chat/AgentTurnBlock.tsx +++ b/src/components/Chat/AgentTurnBlock.tsx @@ -361,12 +361,12 @@ function CollapsibleToolStep({ tc, sessionId, hasNext }: { {getStepLabel(tc)} {tc.status === "in_progress" && ( - + running )} - {tc.status === "failed" && failed} + {tc.status === "failed" && failed} ); @@ -516,11 +516,11 @@ function ProgressIndicator({ progress }: { progress: ClassifiedTools["progress"] return (
- {progress.currentStep}/{progress.totalSteps} + {progress.currentStep}/{progress.totalSteps}
- {progress.description && {progress.description}} + {progress.description && {progress.description}}
); } @@ -542,7 +542,7 @@ function FilesChangedIndicator({ files }: { files: ClassifiedTools["filesChanged
{files.length} file{files.length !== 1 ? "s" : ""} changed {files.map((f, i) => ( - + {f.path.split("/").pop()} {f.action} ))} @@ -571,7 +571,7 @@ function ErrorIndicator({ errors, hasNext }: { errors: ClassifiedTools["errors"]

{err.error}

- {err.details &&

{err.details}

} + {err.details &&

{err.details}

}
} @@ -597,7 +597,7 @@ function WaitingIndicator({ waiting, hasNext }: { waiting: ClassifiedTools["wait
- Waiting for input + Waiting for input

{waiting.question}

@@ -662,7 +662,7 @@ function TaskCreatedIndicator({ tasks, hasNext }: { tasks: ClassifiedTools["task
Priority Labels
{task.labels.map((label) => ( - + {label} ))} @@ -747,7 +747,7 @@ function TaskUpdatedIndicator({ updates, hasNext }: { updates: ClassifiedTools[" {Object.entries(update.fields).map(([key, value]) => (
{fieldLabels[key] ?? key} - + {Array.isArray(value) ? value.join(", ") : String(value)}
@@ -949,7 +949,7 @@ export default React.memo(function AgentTurnBlock({ {hasResponse && ( - + {copied ? : } diff --git a/src/components/Chat/ChatInput.tsx b/src/components/Chat/ChatInput.tsx index 2d052a4..f0257f9 100644 --- a/src/components/Chat/ChatInput.tsx +++ b/src/components/Chat/ChatInput.tsx @@ -505,13 +505,14 @@ export default React.memo(function ChatInput({ console.error("Failed to cancel ACP session:", e); } - // Wait for promptPending to clear (cancel triggers acp-prompt-complete/error event) - // Poll briefly — the event usually fires within a few hundred ms + // Wait for promptPending to clear (cancel triggers acp-prompt-complete/error event). + // Timeout after 5s to avoid polling forever if the cancel event is lost. const waitForIdle = () => new Promise((resolve) => { + const deadline = Date.now() + 5000; const check = () => { const pending = useAppStore.getState().acpPromptPending[sessionId] ?? false; - if (!pending) { + if (!pending || Date.now() >= deadline) { resolve(); } else { setTimeout(check, 50); @@ -933,11 +934,11 @@ function SuggestionOverlay({ > {cmd.icon} {cmd.label} - + {cmd.description} {cmd.isAgentCommand && ( - + agent @@ -964,7 +965,7 @@ function SuggestionOverlay({ )} {file.path} {file.is_dir && ( - + dir )} diff --git a/src/components/Chat/ChatMessage.tsx b/src/components/Chat/ChatMessage.tsx index 5e92ca6..0bbd2c9 100644 --- a/src/components/Chat/ChatMessage.tsx +++ b/src/components/Chat/ChatMessage.tsx @@ -60,7 +60,7 @@ export default React.memo(function ChatMessage({
{/* Edit & resend — inline beside the bubble */} {onEditResend && ( -
+
0 && ( - + = startOfToday) return "Today"; - if (date >= startOfYesterday) return "Yesterday"; - return "Older"; -} - -/** Faber metadata matched from active sessions by acp_session_id. */ -interface FaberSessionMeta { - mode: SessionMode; - taskId: string | null; - isActive: boolean; -} - -const MODE_CONFIG: Record = { - chat: { label: "Chat", className: "bg-primary/15 text-primary" }, - vibe: { label: "Vibe", className: "bg-violet-500/15 text-violet-400" }, - task: { label: "Task", className: "bg-success/15 text-success" }, - research: { label: "Research", className: "bg-warning/15 text-warning" }, - shell: { label: "Shell", className: "bg-muted text-muted-foreground" }, -}; +import type { Session } from "../../types"; /** * ChatView — project-scoped chat view. @@ -87,41 +32,9 @@ const ChatView = memo(function ChatView() { const agents = useAppStore((s) => s.agents); const addBackgroundTask = useAppStore((s) => s.addBackgroundTask); const removeBackgroundTask = useAppStore((s) => s.removeBackgroundTask); - const setActiveView = useAppStore((s) => s.setActiveView); - const fetchAgentSessionList = useAppStore((s) => s.fetchAgentSessionList); - const retryAgentSessionList = useAppStore((s) => s.retryAgentSessionList); - const removeAgentSession = useAppStore((s) => s.removeAgentSession); - const agentSessionList = useAppStore((s) => s.agentSessionList); - const agentSessionListSupported = useAppStore( - (s) => s.agentSessionListSupported, - ); - const agentLoadSessionSupported = useAppStore( - (s) => s.agentLoadSessionSupported, - ); - const agentSessionListLoading = useAppStore((s) => s.agentSessionListLoading); - const agentSessionListFetchedAt = useAppStore( - (s) => s.agentSessionListFetchedAt, - ); - const [selectedAgentName, setSelectedAgentName] = useState(""); - const [launching, setLaunching] = useState(false); - const [resuming, setResuming] = useState(null); + const [selectedAgentName, setSelectedAgentName] = useState(""); const [error, setError] = useState(null); - const [showCloseConfirm, setShowCloseConfirm] = useState(false); - const [searchFilter, setSearchFilter] = useState(""); - - // Find the active chat session for this project - const chatSession: Session | undefined = useMemo( - () => - sessions.find( - (s) => - s.project_id === activeProjectId && - s.mode === "chat" && - s.transport === "acp" && - (s.status === "running" || s.status === "starting"), - ), - [sessions, activeProjectId], - ); // ACP-capable agents only const acpAgents = useMemo( @@ -136,75 +49,25 @@ const ChatView = memo(function ChatView() { } }, [acpAgents, selectedAgentName]); - // Session list key and data - const sessionListKey = - selectedAgentName && activeProjectId - ? `${selectedAgentName}:${activeProjectId}` - : null; - const sessionHistory = sessionListKey - ? (agentSessionList[sessionListKey] ?? null) - : null; - const isListLoading = sessionListKey - ? (agentSessionListLoading[sessionListKey] ?? false) - : false; - const isListSupported = selectedAgentName - ? (agentSessionListSupported[selectedAgentName] ?? true) - : true; - const isLoadSupported = selectedAgentName - ? (agentLoadSessionSupported[selectedAgentName] ?? true) - : true; - - // Auto-fetch session list when empty state is shown or cache is stale (>60s) - const SESSION_LIST_TTL_MS = 60_000; - useEffect(() => { - if ( - !chatSession && - selectedAgentName && - activeProjectId && - !isListLoading - ) { - const fetchedAt = sessionListKey - ? (agentSessionListFetchedAt[sessionListKey] ?? 0) - : 0; - const isStale = - sessionHistory === null || Date.now() - fetchedAt > SESSION_LIST_TTL_MS; - if (isStale) { - fetchAgentSessionList(selectedAgentName, activeProjectId); - } - } - }, [ - chatSession, - selectedAgentName, - activeProjectId, - sessionHistory, - sessionListKey, - agentSessionListFetchedAt, - isListLoading, - fetchAgentSessionList, - ]); + const [launching, setLaunching] = useState(false); + const [showCloseConfirm, setShowCloseConfirm] = useState(false); - // Re-fetch when agent changes - const handleAgentSelect = useCallback( - (name: string) => { - setSelectedAgentName(name); - if (activeProjectId) { - fetchAgentSessionList(name, activeProjectId); - } - }, - [activeProjectId, fetchAgentSessionList], + // Find the active chat session for this project + const chatSession: Session | undefined = useMemo( + () => + sessions.find( + (s) => + s.project_id === activeProjectId && + s.mode === "chat" && + s.transport === "acp" && + (s.status === "running" || s.status === "starting"), + ), + [sessions, activeProjectId], ); - // Filter sessions by search - const filteredSessions = useMemo(() => { - if (!sessionHistory) return []; - if (!searchFilter.trim()) return sessionHistory; - const q = searchFilter.toLowerCase(); - return sessionHistory.filter( - (s) => - s.title?.toLowerCase().includes(q) || - s.session_id.toLowerCase().includes(q), - ); - }, [sessionHistory, searchFilter]); + const handleAgentSelect = useCallback((name: string) => { + setSelectedAgentName(name); + }, []); const handleStartChat = useCallback(async () => { if (!activeProjectId || !selectedAgentName || launching) return; @@ -229,78 +92,9 @@ const ChatView = memo(function ChatView() { launching, addBackgroundTask, removeBackgroundTask, + setError, ]); - /** Resume into ChatView (stays on chat tab). */ - const handleResumeInChat = useCallback( - async (agentSessionId: string) => { - if (!activeProjectId || !selectedAgentName || resuming) return; - setError(null); - setResuming(agentSessionId); - const taskLabel = "Resuming chat session"; - addBackgroundTask(taskLabel); - try { - await invoke("resume_acp_session", { - projectId: activeProjectId, - agentName: selectedAgentName, - agentSessionId, - }); - } catch (err) { - setError(formatErrorWithHint(err, "agent-launch")); - // Remove the failed session from cache (expired/deleted by agent) - removeAgentSession(selectedAgentName, activeProjectId, agentSessionId); - } finally { - setResuming(null); - removeBackgroundTask(taskLabel); - } - }, - [ - activeProjectId, - selectedAgentName, - resuming, - addBackgroundTask, - removeBackgroundTask, - removeAgentSession, - ], - ); - - /** Resume and open in Sessions view as a session pane. */ - const handleLaunchAsSession = useCallback( - async (agentSessionId: string) => { - if (!activeProjectId || !selectedAgentName || resuming) return; - setError(null); - setResuming(agentSessionId); - const taskLabel = "Launching session"; - addBackgroundTask(taskLabel); - try { - await invoke("resume_acp_session", { - projectId: activeProjectId, - agentName: selectedAgentName, - agentSessionId, - target: "session", - }); - // Navigate to Sessions view so it appears as a session pane - setActiveView("sessions"); - } catch (err) { - setError(formatErrorWithHint(err, "agent-launch")); - // Remove the failed session from cache (expired/deleted by agent) - removeAgentSession(selectedAgentName, activeProjectId, agentSessionId); - } finally { - setResuming(null); - removeBackgroundTask(taskLabel); - } - }, - [ - activeProjectId, - selectedAgentName, - resuming, - addBackgroundTask, - removeBackgroundTask, - removeAgentSession, - setActiveView, - ], - ); - const handleCloseChat = useCallback(async () => { if (!chatSession) return; try { @@ -316,40 +110,6 @@ const ChatView = memo(function ChatView() { useAppStore.getState().cleanupSessionAcp(chatSession.id); }, [chatSession]); - const handleRefreshList = useCallback(() => { - if (selectedAgentName && activeProjectId) { - fetchAgentSessionList(selectedAgentName, activeProjectId); - } - }, [selectedAgentName, activeProjectId, fetchAgentSessionList]); - - /** Retry after "not supported" — clears persisted flag and re-probes. */ - const handleRetry = useCallback(() => { - if (selectedAgentName && activeProjectId) { - retryAgentSessionList(selectedAgentName, activeProjectId); - } - }, [selectedAgentName, activeProjectId, retryAgentSessionList]); - - // Cross-reference: map agent session IDs to Faber session metadata for enrichment. - // Only active sessions are in the store (DB deletes on close), so this enriches - // currently-running sessions. Historical enrichment requires T-102 (history table). - const acpSessionMap = useMemo(() => { - const map = new Map(); - for (const s of sessions) { - if (s.acp_session_id && s.project_id === activeProjectId) { - map.set(s.acp_session_id, { - mode: s.mode, - taskId: s.task_id, - isActive: s.status === "running" || s.status === "starting", - }); - } - } - return map; - }, [sessions, activeProjectId]); - - // Whether to show the session history sidebar - const showHistory = - acpAgents.length > 0 && (sessionHistory !== null || isListLoading); - // Active chat session → render ChatPane if (chatSession) { return ( @@ -412,20 +172,14 @@ const ChatView = memo(function ChatView() { ); } - // No active session → two-column layout: new chat (left) + session history (right) + // No active session → centered new chat launcher return (
- {/* Left column — new chat launcher */}
-
+
{/* Header */}
- {/* Right column — session history sidebar */} - {showHistory && ( - - )} -
- ); -}); - -/** Skeleton placeholder row matching SessionHistoryItem shape. */ -const SessionItemSkeleton = memo(function SessionItemSkeleton({ - widthClass, -}: { - widthClass: string; -}) { - return ( -
-
-
-
-
-
- ); -}); - -/** Date group header between session items. */ -const DateGroupHeader = memo(function DateGroupHeader({ - label, - collapsed, - onToggle, - count, -}: { - label: string; - collapsed: boolean; - onToggle: () => void; - count: number; -}) { - return ( - - ); -}); - -/** Right-side session history panel with search, skeletons, and date-grouped list. */ -const SessionHistorySidebar = memo(function SessionHistorySidebar({ - sessions, - acpSessionMap, - isLoading, - isSupported, - isLoadSupported, - searchFilter, - onSearchChange, - onResumeInChat, - onLaunchAsSession, - onRefresh, - onRetry, - resumingId, - hasData, -}: { - sessions: AgentSessionInfo[]; - acpSessionMap: Map; - isLoading: boolean; - isSupported: boolean; - isLoadSupported: boolean; - searchFilter: string; - onSearchChange: (value: string) => void; - onResumeInChat: (sessionId: string) => void; - onLaunchAsSession: (sessionId: string) => void; - onRefresh: () => void; - onRetry: () => void; - resumingId: string | null; - hasData: boolean; -}) { - const searchRef = useRef(null); - const [collapsedGroups, setCollapsedGroups] = useState< - Record - >({ - Yesterday: true, - Older: true, - }); - - const toggleGroup = useCallback((label: string) => { - setCollapsedGroups((prev) => ({ ...prev, [label]: !prev[label] })); - }, []); - - // Group sessions by date (skip grouping when search is active) - const groupedSessions = useMemo(() => { - if (!sessions.length || searchFilter) return null; - const groups: { label: string; items: AgentSessionInfo[] }[] = []; - let currentLabel = ""; - for (const s of sessions) { - const label = getDateGroup(s.updated_at); - if (label !== currentLabel) { - currentLabel = label; - groups.push({ label, items: [s] }); - } else { - groups[groups.length - 1].items.push(s); - } - } - return groups; - }, [sessions, searchFilter]); - - return ( -
- {/* Header */} -
- - - Previous Sessions - - {hasData && sessions.length > 0 && ( - - {sessions.length} - - )} -
- -
- - {/* Search bar — always visible when supported (shown during loading for layout stability) */} - {(hasData || isLoading) && isSupported && ( -
- - onSearchChange(e.target.value)} - placeholder="Search sessions..." - className="w-full bg-transparent text-xs text-foreground placeholder:text-muted-foreground/50 outline-none" - /> - {searchFilter && ( - - )} -
- )} - - {/* Content area — scrollable */} -
- {/* Skeleton loading state */} - {isLoading && !hasData && ( -
- - - - - -
- )} - - {/* Not supported */} - {!isLoading && !isSupported && ( -
-

- This agent doesn't support session history. -

- -
- )} - - {/* Empty list */} - {!isLoading && - isSupported && - hasData && - sessions.length === 0 && - !searchFilter && ( -
-
- -
-

- No previous sessions yet. -
- - Start a new chat to get going. - -

-
- )} - - {/* No search results */} - {hasData && isSupported && searchFilter && sessions.length === 0 && ( -
-

- No sessions matching -

-

- “{searchFilter}” -

-
- )} - - {/* Date-grouped session list */} - {hasData && isSupported && sessions.length > 0 && groupedSessions - ? groupedSessions.map((group) => ( -
- toggleGroup(group.label)} - count={group.items.length} - /> - {!collapsedGroups[group.label] && - group.items.map((session) => ( - - ))} -
- )) - : hasData && - isSupported && - sessions.length > 0 && - sessions.map((session) => ( - - ))} -
-
- ); -}); - -/** Individual session row with mode badge, task link, active indicator, and actions. */ -const SessionHistoryItem = memo(function SessionHistoryItem({ - session, - faberMeta, - onResumeInChat, - onLaunchAsSession, - isResuming, - isDisabled, - isLoadSupported, -}: { - session: AgentSessionInfo; - faberMeta: FaberSessionMeta | undefined; - onResumeInChat: (sessionId: string) => void; - onLaunchAsSession: (sessionId: string) => void; - isResuming: boolean; - isDisabled: boolean; - isLoadSupported: boolean; -}) { - const actionsDisabled = isDisabled || !isLoadSupported; - const modeConfig = faberMeta ? MODE_CONFIG[faberMeta.mode] : null; - - return ( -
- {/* Content */} -
- {/* Title row with badges */} -
- {/* Active indicator */} - {faberMeta?.isActive && ( - - )} -

- {session.title || "Untitled session"} -

-
- - {/* Meta row: time, mode badge, task ID */} -
- {session.updated_at && ( - - - {formatRelativeTime(session.updated_at)} - - )} - {modeConfig && ( - - {modeConfig.label} - - )} - {faberMeta?.taskId && ( - - {faberMeta.taskId} - - )} -
-
- - {/* Action buttons — always visible, compact icon buttons */} -
- {isResuming ? ( - - ) : ( - <> - - - - )} -
); }); diff --git a/src/components/Chat/ConfigOptionsPopover.tsx b/src/components/Chat/ConfigOptionsPopover.tsx index 71a9456..d750bf6 100644 --- a/src/components/Chat/ConfigOptionsPopover.tsx +++ b/src/components/Chat/ConfigOptionsPopover.tsx @@ -124,11 +124,11 @@ function ConfigOptionSection({ option, sessionId, disabled }: ConfigOptionSectio return (
{/* Section header */} -
+
{option.name}
{option.description && ( -

+

{option.description}

)} @@ -137,7 +137,7 @@ function ConfigOptionSection({ option, sessionId, disabled }: ConfigOptionSectio {grps.length > 0 ? grps.map((group) => (
-
+
{group.name}
{group.options.map((o) => ( @@ -188,7 +188,7 @@ function OptionButton({ option, isActive, disabled, onSelect }: OptionButtonProp
{option.name} {option.description && ( - + {option.description} )} diff --git a/src/components/Chat/ContextCrease.tsx b/src/components/Chat/ContextCrease.tsx index 7d52ed3..85f29ad 100644 --- a/src/components/Chat/ContextCrease.tsx +++ b/src/components/Chat/ContextCrease.tsx @@ -104,7 +104,7 @@ function FilePath({ path }: { path: string }) { + ); +}); + +/** Individual session row with mode badge, task link, active indicator, and actions. */ +const SessionHistoryItem = memo(function SessionHistoryItem({ + session, + faberMeta, + onResumeInChat, + onLaunchAsSession, + isResuming, + isDisabled, + isLoadSupported, + chatSessionActive = false, +}: { + session: AgentSessionInfo; + faberMeta: FaberSessionMeta | undefined; + onResumeInChat: (sessionId: string) => void; + onLaunchAsSession: (sessionId: string) => void; + isResuming: boolean; + isDisabled: boolean; + isLoadSupported: boolean; + chatSessionActive?: boolean; +}) { + const actionsDisabled = isDisabled || !isLoadSupported; + const resumeInChatDisabled = actionsDisabled || chatSessionActive; + const modeConfig = faberMeta ? MODE_CONFIG[faberMeta.mode] : null; + + return ( +
+ {/* Content */} +
+ {/* Title row with badges */} +
+ {/* Active indicator */} + {faberMeta?.isActive && ( + + )} +

+ {session.title || "Untitled session"} +

+
+ + {/* Meta row: time, mode badge, task ID */} +
+ {session.updated_at && ( + + + {formatRelativeTime(session.updated_at)} + + )} + {modeConfig && ( + + {modeConfig.label} + + )} + {faberMeta?.taskId && ( + + {faberMeta.taskId} + + )} +
+
+ + {/* Action buttons — always visible, compact icon buttons */} +
+ {isResuming ? ( + + ) : ( + <> + + + + )} +
+
+ ); +}); + +// ============================================================================ +// SessionHistorySidebar +// ============================================================================ + +export interface SessionHistoryContentProps { + sessions: AgentSessionInfo[]; + acpSessionMap: Map; + isLoading: boolean; + isSupported: boolean; + isLoadSupported: boolean; + searchFilter: string; + onSearchChange: (value: string) => void; + onResumeInChat: (sessionId: string) => void; + onLaunchAsSession: (sessionId: string) => void; + onRefresh: () => void; + onRetry: () => void; + resumingId: string | null; + hasData: boolean; + /** Whether "Resume in Chat" should be disabled (e.g. a chat session is already active) */ + chatSessionActive?: boolean; +} + +/** + * Session history content — renders search, list, skeletons, and empty states. + * Designed to be embedded inside any container (right sidebar tab, standalone panel, etc.). + * Does NOT render its own panel wrapper — the parent provides the container. + */ +export const SessionHistoryContent = memo(function SessionHistoryContent({ + sessions, + acpSessionMap, + isLoading, + isSupported, + isLoadSupported, + searchFilter, + onSearchChange, + onResumeInChat, + onLaunchAsSession, + onRefresh, + onRetry, + resumingId, + hasData, + chatSessionActive = false, +}: SessionHistoryContentProps) { + const searchRef = useRef(null); + const [collapsedGroups, setCollapsedGroups] = useState< + Record + >({ + Yesterday: true, + Older: true, + }); + + const toggleGroup = useCallback((label: string) => { + setCollapsedGroups((prev) => ({ ...prev, [label]: !prev[label] })); + }, []); + + // Group sessions by date (skip grouping when search is active) + const groupedSessions = useMemo(() => { + if (!sessions.length || searchFilter) return null; + const groups: { label: string; items: AgentSessionInfo[] }[] = []; + let currentLabel = ""; + for (const s of sessions) { + const label = getDateGroup(s.updated_at); + if (label !== currentLabel) { + currentLabel = label; + groups.push({ label, items: [s] }); + } else { + groups[groups.length - 1].items.push(s); + } + } + return groups; + }, [sessions, searchFilter]); + + return ( + <> + {/* Toolbar: count + refresh */} +
+ + {hasData && sessions.length > 0 + ? `${sessions.length} session${sessions.length !== 1 ? "s" : ""}` + : "Previous Sessions"} + +
+ +
+ + {/* Search bar — always visible when supported (shown during loading for layout stability) */} + {(hasData || isLoading) && isSupported && ( +
+
+ + onSearchChange(e.target.value)} + placeholder="Search sessions..." + className="w-full h-6 pl-6 pr-6 rounded bg-muted/50 border border-border text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring" + /> + {searchFilter && ( + + )} +
+
+ )} + + {/* Content area — scrollable */} +
+ {/* Skeleton loading state */} + {isLoading && !hasData && ( +
+ + + + + +
+ )} + + {/* Not supported */} + {!isLoading && !isSupported && ( +
+

+ This agent doesn't support session history. +

+ +
+ )} + + {/* Empty list */} + {!isLoading && + isSupported && + hasData && + sessions.length === 0 && + !searchFilter && ( +
+
+ +
+

+ No previous sessions yet. +
+ + Start a new chat to get going. + +

+
+ )} + + {/* No search results */} + {hasData && isSupported && searchFilter && sessions.length === 0 && ( +
+

+ No sessions matching +

+

+ “{searchFilter}” +

+
+ )} + + {/* Date-grouped session list */} + {hasData && isSupported && sessions.length > 0 && groupedSessions + ? groupedSessions.map((group) => ( +
+ toggleGroup(group.label)} + count={group.items.length} + /> + {!collapsedGroups[group.label] && + group.items.map((session) => ( + + ))} +
+ )) + : hasData && + isSupported && + sessions.length > 0 && + sessions.map((session) => ( + + ))} +
+ + ); +}); diff --git a/src/components/Chat/ThoughtLevelSelector.tsx b/src/components/Chat/ThoughtLevelSelector.tsx index 1fc01f5..d11836e 100644 --- a/src/components/Chat/ThoughtLevelSelector.tsx +++ b/src/components/Chat/ThoughtLevelSelector.tsx @@ -69,7 +69,7 @@ export default React.memo(function ThoughtLevelSelector({ return ( @@ -84,7 +84,7 @@ export default React.memo(function ThoughtLevelSelector({ className="w-52 p-1 gap-0" > {thoughtOption.description && ( -
+
{thoughtOption.description}
)} @@ -104,7 +104,7 @@ export default React.memo(function ThoughtLevelSelector({
{level.name} {level.description && ( - + {level.description} )} diff --git a/src/components/Chat/ThreadStatusBadge.tsx b/src/components/Chat/ThreadStatusBadge.tsx index a187c6c..672ced9 100644 --- a/src/components/Chat/ThreadStatusBadge.tsx +++ b/src/components/Chat/ThreadStatusBadge.tsx @@ -104,7 +104,7 @@ export default React.memo(function ThreadStatusBadge({ return ( diff --git a/src/components/Chat/ToolCallCard.tsx b/src/components/Chat/ToolCallCard.tsx index 6b8493f..1c98e9d 100644 --- a/src/components/Chat/ToolCallCard.tsx +++ b/src/components/Chat/ToolCallCard.tsx @@ -410,7 +410,7 @@ export default React.memo(function ToolCallCard({ toolCall, sessionId }: ToolCal ) : ( )} - {displayLabel} + {displayLabel}
); @@ -484,13 +484,13 @@ function ReadCard({
{meta.label} {lineCount !== null && ( - + {lineCount} lines )} @@ -523,24 +523,24 @@ function EditCard({ )} {meta.label} {/* Diff stats */} {(meta.linesAdded !== undefined && meta.linesAdded > 0) && ( - + +{meta.linesAdded} )} {(meta.linesRemoved !== undefined && meta.linesRemoved > 0) && ( - + −{meta.linesRemoved} )} {meta.isNewFile && ( - + new )} @@ -586,12 +586,12 @@ function DeleteCard({ {meta.label} - + deleted @@ -648,7 +648,7 @@ function ExecuteCard({ > - + $ {fullCommand.length > 80 ? fullCommand.slice(0, 77) + "…" : fullCommand} @@ -656,7 +656,7 @@ function ExecuteCard({ {exitCode !== undefined && ( {exitCode} @@ -671,11 +671,11 @@ function ExecuteCard({ {/* Snippet command display */}
- + - +
@@ -721,11 +721,11 @@ function SearchCard({ - + {meta.query ?? meta.label} {meta.resultCount !== undefined && meta.resultCount > 0 && ( - + {meta.resultCount} result{meta.resultCount !== 1 ? "s" : ""} )} @@ -810,7 +810,7 @@ function FetchCard({ {displayUrl} @@ -859,10 +859,10 @@ function GenericCard({
- + {label} - + {statusCfg.icon} {statusCfg.label} @@ -920,7 +920,7 @@ function FallbackMeta({ return (

{toolCall.title}

-

+

{mcpToolName ?? toolCall.kind} · {toolCall.tool_call_id}

diff --git a/src/components/Chat/WaitingCard.tsx b/src/components/Chat/WaitingCard.tsx index 86dfeb6..700ece6 100644 --- a/src/components/Chat/WaitingCard.tsx +++ b/src/components/Chat/WaitingCard.tsx @@ -49,7 +49,7 @@ export default React.memo(function WaitingCard({ }`} >
-
+
{/* Icon */}
@@ -57,7 +57,7 @@ export default React.memo(function WaitingCard({ {/* Question text */}
-

+

Waiting for input

@@ -68,7 +68,8 @@ export default React.memo(function WaitingCard({ {/* Dismiss button */}

@@ -111,7 +111,7 @@ export default function CommandPalette() { {recentCommands.length > 0 && ( {recentCommands.map((cmd) => ( {items.map((cmd) => ( {/* Footer hint */} -
+
- + - + navigate - + select @@ -180,7 +180,7 @@ function CommandRow({ onSelect(cmd.id)} - className="flex cursor-pointer items-center gap-2.5 rounded-[var(--radius-element)] px-2.5 py-2 text-[13px] text-dim-foreground aria-selected:bg-accent" + className="flex cursor-pointer items-center gap-2.5 rounded-[var(--radius-element)] px-2.5 py-2 text-sm text-dim-foreground aria-selected:bg-accent" > {Icon && ( @@ -189,7 +189,7 @@ function CommandRow({ )} {cmd.label} {cmd.shortcut && ( - + {cmd.shortcut} )} diff --git a/src/components/CommandPalette/useCommands.ts b/src/components/CommandPalette/useCommands.ts index f54a6ba..a4afe77 100644 --- a/src/components/CommandPalette/useCommands.ts +++ b/src/components/CommandPalette/useCommands.ts @@ -9,6 +9,7 @@ import { Monitor, PanelRight, Blocks, + Settings, } from "lucide-react"; import { useAppStore } from "../../store/appStore"; import type { Command } from "./commandRegistry"; @@ -43,6 +44,7 @@ export function useCommands(onExecuted: () => void): Command[] { nav("github", "Go to GitHub", Github, "github"); nav("skills-rules", "Go to Extensions", Blocks, "skills-rules"); nav("review", "Go to Review", GitCompare, "review"); + nav("settings", "Go to Settings", Settings, "settings"); // ── Projects ── for (const p of projects) { diff --git a/src/components/Dashboard/ArchivedTaskList.tsx b/src/components/Dashboard/ArchivedTaskList.tsx index 85db3d7..4ca51f9 100644 --- a/src/components/Dashboard/ArchivedTaskList.tsx +++ b/src/components/Dashboard/ArchivedTaskList.tsx @@ -1,5 +1,5 @@ import { useCallback, useState } from "react"; -import { Archive, ArchiveRestore, Trash2 } from "lucide-react"; +import { Archive, ArchiveRestore, Loader2, Trash2 } from "lucide-react"; import type { Task } from "../../types"; import { Button } from "../ui/orecus.io/components/enhanced-button"; @@ -29,14 +29,30 @@ export default function ArchivedTaskList({ onDelete, }: ArchivedTaskListProps) { const [deleteTaskId, setDeleteTaskId] = useState(null); + const [restoringId, setRestoringId] = useState(null); + const [deletingId, setDeletingId] = useState(null); const deleteTask = deleteTaskId ? tasks.find((t) => t.id === deleteTaskId) : null; - const handleConfirmDelete = useCallback(() => { + const handleRestore = useCallback(async (taskId: string) => { + setRestoringId(taskId); + try { + await onRestore(taskId); + } finally { + setRestoringId(null); + } + }, [onRestore]); + + const handleConfirmDelete = useCallback(async () => { if (deleteTaskId) { - onDelete(deleteTaskId); + setDeletingId(deleteTaskId); setDeleteTaskId(null); + try { + await onDelete(deleteTaskId); + } finally { + setDeletingId(null); + } } }, [deleteTaskId, onDelete]); @@ -44,7 +60,7 @@ export default function ArchivedTaskList({ if (tasks.length === 0) { return (
- +

No archived tasks

); @@ -61,7 +77,7 @@ export default function ArchivedTaskList({ onClick={() => onTaskClick(task.id)} > {/* Task ID */} - + {task.id} @@ -79,13 +95,13 @@ export default function ArchivedTaskList({ {task.labels.slice(0, 3).map((label) => ( {label} ))} {task.labels.length > 3 && ( - + +{task.labels.length - 3} )} @@ -93,24 +109,29 @@ export default function ArchivedTaskList({ )} {/* Archived date */} - + {formatDate(task.updated_at)} - {/* Actions — visible on hover */} -
+ {/* Actions — visible on hover, with inline loading */} +
diff --git a/src/components/Dashboard/DashboardView.tsx b/src/components/Dashboard/DashboardView.tsx index 1139e1e..1890b09 100644 --- a/src/components/Dashboard/DashboardView.tsx +++ b/src/components/Dashboard/DashboardView.tsx @@ -400,7 +400,7 @@ export default function DashboardView() { {toolbar} -
+
{activeProjectId && hasContinuousRun && ( )} diff --git a/src/components/Dashboard/DependencyBadge.tsx b/src/components/Dashboard/DependencyBadge.tsx index b8facdf..efa9622 100644 --- a/src/components/Dashboard/DependencyBadge.tsx +++ b/src/components/Dashboard/DependencyBadge.tsx @@ -1,25 +1,8 @@ import React, { useState, useRef, useEffect, useCallback } from "react"; import { createPortal } from "react-dom"; import { Link, Lock, Check, ChevronRight } from "lucide-react"; -import type { Task, TaskStatus } from "../../types"; - -const STATUS_COLORS: Record = { - backlog: "bg-muted-foreground/60", - ready: "bg-blue-500", - "in-progress": "bg-amber-500", - "in-review": "bg-purple-500", - done: "bg-emerald-500", - archived: "bg-muted-foreground/30", -}; - -const STATUS_LABELS: Record = { - backlog: "Backlog", - ready: "Ready", - "in-progress": "In Progress", - "in-review": "In Review", - done: "Done", - archived: "Archived", -}; +import type { Task } from "../../types"; +import { TASK_STATUS_DOT_COLORS, TASK_STATUS_LABELS } from "../../lib/taskStatusColors"; interface DependencyBadgeProps { task: Task; @@ -136,7 +119,7 @@ export default React.memo(function DependencyBadge({ ref={triggerRef} onClick={handleClick} onPointerDown={(e) => e.stopPropagation()} - className={`flex items-center gap-0.5 px-1 py-px rounded text-[10px] font-medium transition-colors cursor-pointer ${ + className={`flex items-center gap-0.5 px-1 py-px rounded text-2xs font-medium transition-colors cursor-pointer ${ isBlocked ? "text-warning bg-warning/10 hover:bg-warning/20" : "text-muted-foreground hover:bg-accent hover:text-foreground" @@ -168,7 +151,7 @@ export default React.memo(function DependencyBadge({ {/* Dependencies section */} {totalDeps > 0 && (
-
+
Depends on
{task.depends_on.map((depId) => { @@ -183,14 +166,14 @@ export default React.memo(function DependencyBadge({ {isDone ? ( ) : ( -
+
)} - + {dep?.title ?? depId} {dep && ( - - {STATUS_LABELS[dep.status]} + + {TASK_STATUS_LABELS[dep.status]} )} @@ -203,7 +186,7 @@ export default React.memo(function DependencyBadge({ {/* Dependents section */} {totalDependents > 0 && (
-
+
Depended by
{dependents.map((depId) => { @@ -214,13 +197,13 @@ export default React.memo(function DependencyBadge({ onClick={(e) => handleTaskClick(e, depId)} className="w-full flex items-center gap-1.5 px-2 py-1 text-left hover:bg-accent/60 transition-colors cursor-pointer" > -
- +
+ {dep?.title ?? depId} {dep && ( - - {STATUS_LABELS[dep.status]} + + {TASK_STATUS_LABELS[dep.status]} )} diff --git a/src/components/Dashboard/DependencyGraph.tsx b/src/components/Dashboard/DependencyGraph.tsx index 6d69b0c..579039d 100644 --- a/src/components/Dashboard/DependencyGraph.tsx +++ b/src/components/Dashboard/DependencyGraph.tsx @@ -11,33 +11,15 @@ import { AlertTriangle, CirclePause, Minus, + Network, } from "lucide-react"; -import type { Task, Session, TaskStatus } from "../../types"; +import type { Task, Session } from "../../types"; import { useAppStore } from "../../store/appStore"; import { isTaskBlocked, buildDependentsMap } from "../../lib/taskSort"; +import { TASK_STATUS_DOT_COLORS, TASK_STATUS_LABELS } from "../../lib/taskStatusColors"; import PriorityBadge from "./PriorityBadge"; import { Button } from "../ui/orecus.io/components/enhanced-button"; -// ── Status display ── - -const STATUS_COLORS: Record = { - backlog: "bg-muted-foreground/50", - ready: "bg-blue-500", - "in-progress": "bg-amber-500", - "in-review": "bg-purple-500", - done: "bg-emerald-500", - archived: "bg-muted-foreground/30", -}; - -const STATUS_LABELS: Record = { - backlog: "Backlog", - ready: "Ready", - "in-progress": "In Progress", - "in-review": "In Review", - done: "Done", - archived: "Archived", -}; - // ── Build tree structure ── interface TreeNode { @@ -214,7 +196,7 @@ function TreeRow({ {/* Status dot */} -
+
{/* Priority */}
@@ -222,26 +204,26 @@ function TreeRow({
{/* Title */} - + {task.title} {/* Blocked indicator */} {blocked && ( - + blocked )} {/* Status label */} - - {STATUS_LABELS[task.status]} + + {TASK_STATUS_LABELS[task.status]} {/* Agent */} {task.agent && ( - + {task.agent} )} @@ -259,7 +241,7 @@ function TreeRow({ ) : ( )} - + {mcpData.current_step != null && mcpData.total_steps != null ? `${mcpData.current_step}/${mcpData.total_steps}` : mcpData.message || "Working"} @@ -268,13 +250,13 @@ function TreeRow({ ) : isActive ? ( <> - Starting + Starting ) : null}
{/* Action buttons */} -
+
{!isActive && (task.status === "backlog" || task.status === "ready") && onResearchSession && ( diff --git a/src/components/Dashboard/EmptyState.tsx b/src/components/Dashboard/EmptyState.tsx index 296459b..1bbc30b 100644 --- a/src/components/Dashboard/EmptyState.tsx +++ b/src/components/Dashboard/EmptyState.tsx @@ -9,13 +9,13 @@ export default function EmptyState({ onNewTask }: EmptyStateProps) { return (
-
+

No tasks yet

-

+

Create a task file in your project's .agents/tasks/{" "} directory or create one from the UI.

diff --git a/src/components/Dashboard/FilterBar.tsx b/src/components/Dashboard/FilterBar.tsx index 0afd7fb..a898762 100644 --- a/src/components/Dashboard/FilterBar.tsx +++ b/src/components/Dashboard/FilterBar.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { Search, X } from "lucide-react"; +import { Check, ChevronDown, Layers, Search, Tag, Terminal, X } from "lucide-react"; import type { TaskStatus } from "../../types"; import type { FilterState, FilterAction } from "../../hooks/useDashboardFilters"; import { useProjectAccentColor } from "../../hooks/useProjectAccentColor"; @@ -8,6 +8,19 @@ import { DEFAULT_PRIORITIES, getPriorityCssVar } from "../../lib/priorities"; import { Button } from "../ui/orecus.io/components/enhanced-button"; import { gradientHexColors } from "../ui/orecus.io/lib/color-utils"; import { Separator } from "../ui/separator"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "../ui/popover"; +import { + Command, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, +} from "../ui/command"; const STATUSES: { value: TaskStatus; label: string }[] = [ { value: "backlog", label: "Backlog" }, @@ -41,7 +54,7 @@ function ToggleChip({ return ( + + ); +} + export default function FilterBar({ filters, dispatchFilter, @@ -99,128 +218,164 @@ export default function FilterBar({ } }, [filters.searchQuery]); + // Build dropdown items + const labelItems = allLabels.map((l) => ({ value: l, label: l })); + const agentItems = allAgents.map((a) => ({ value: a, label: a })); + const epicItems = allEpics.map((e) => ({ value: e.id, label: e.title })); + + // Collect active dropdown filters for pill display + const activePills: { key: string; label: string; onRemove: () => void }[] = []; + for (const label of filters.labels) { + activePills.push({ + key: `label:${label}`, + label, + onRemove: () => dispatchFilter({ type: "TOGGLE_LABEL", label }), + }); + } + for (const agent of filters.agents) { + activePills.push({ + key: `agent:${agent}`, + label: agent, + onRemove: () => dispatchFilter({ type: "TOGGLE_AGENT", agent }), + }); + } + for (const epicId of filters.epics) { + const epic = allEpics.find((e) => e.id === epicId); + activePills.push({ + key: `epic:${epicId}`, + label: epic?.title ?? epicId, + onRemove: () => dispatchFilter({ type: "TOGGLE_EPIC", epicId }), + }); + } + + const hasDropdownFilters = activePills.length > 0; + return ( -
- {/* Search input */} -
- - handleSearchChange(e.target.value)} - placeholder="Search tasks…" - className="h-6 w-44 pl-7 pr-6 text-[11px] rounded-[var(--radius-element)] bg-transparent border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary/60 transition-colors" - /> - {localSearch && ( - - )} -
+
+ {/* Main filter row */} +
+ {/* Search input */} +
+ + handleSearchChange(e.target.value)} + placeholder="Search tasks…" + className="h-6 w-44 pl-7 pr-6 text-xs rounded-[var(--radius-element)] bg-transparent border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary/60 transition-colors" + /> + {localSearch && ( + + )} +
- - - {/* Priority toggles */} - - Priority: - - {priorities.map((p) => ( - dispatchFilter({ type: "TOGGLE_PRIORITY", priority: p.id })} - /> - ))} - - {/* Status toggles */} - - - Status: - - {STATUSES.map((s) => ( - dispatchFilter({ type: "TOGGLE_STATUS", status: s.value })} - /> - ))} - - {/* Label toggles */} - {allLabels.length > 0 && ( - <> - - - Label: + {/* Priority toggles — grouped with separator so they wrap together */} +
+ + + Priority: - {allLabels.map((label) => ( + {priorities.map((p) => ( dispatchFilter({ type: "TOGGLE_LABEL", label })} + key={p.id} + label={p.id} + active={filters.priorities.has(p.id)} + color={getPriorityCssVar(p.id, priorities)} + onClick={() => dispatchFilter({ type: "TOGGLE_PRIORITY", priority: p.id })} /> ))} - - )} +
- {/* Agent toggles */} - {allAgents.length > 0 && ( - <> - - - Agent: + {/* Status toggles — grouped with separator */} +
+ + + Status: - {allAgents.map((agent) => ( + {STATUSES.map((s) => ( dispatchFilter({ type: "TOGGLE_AGENT", agent })} + onClick={() => dispatchFilter({ type: "TOGGLE_STATUS", status: s.value })} /> ))} - - )} +
- {/* Epic toggles */} - {allEpics.length > 0 && ( - <> - - - Epic: - - {allEpics.map((epic) => ( - dispatchFilter({ type: "TOGGLE_EPIC", epicId: epic.id })} + {/* Dropdown filters — grouped with separator */} + {(allLabels.length > 0 || allAgents.length > 0 || allEpics.length > 0) && ( +
+ + + {allLabels.length > 0 && ( + } + label="Labels" + items={labelItems} + selected={filters.labels} + onToggle={(label) => dispatchFilter({ type: "TOGGLE_LABEL", label })} + placeholder="Search labels…" + /> + )} + + {allAgents.length > 0 && ( + } + label="Agents" + items={agentItems} + selected={filters.agents} + onToggle={(agent) => dispatchFilter({ type: "TOGGLE_AGENT", agent })} + placeholder="Search agents…" + /> + )} + + {allEpics.length > 0 && ( + } + label="Epics" + items={epicItems} + selected={filters.epics} + onToggle={(epicId) => dispatchFilter({ type: "TOGGLE_EPIC", epicId })} + placeholder="Search epics…" + /> + )} +
+ )} + + {/* Clear all */} + +
+ + {/* Active dropdown filter pills — shown below main row when any are active */} + {hasDropdownFilters && ( +
+ Active: + {activePills.map((pill) => ( + ))} - +
)} - - {/* Clear all */} -
); } diff --git a/src/components/Dashboard/GhostParentCard.tsx b/src/components/Dashboard/GhostParentCard.tsx index 0a60e81..a109d85 100644 --- a/src/components/Dashboard/GhostParentCard.tsx +++ b/src/components/Dashboard/GhostParentCard.tsx @@ -1,24 +1,7 @@ import { memo } from "react"; -import { GitBranch } from "lucide-react"; -import type { Task, TaskStatus } from "../../types"; - -const STATUS_LABELS: Record = { - backlog: "Backlog", - ready: "Ready", - "in-progress": "In Progress", - "in-review": "In Review", - done: "Done", - archived: "Archived", -}; - -const STATUS_COLORS: Record = { - backlog: "bg-muted-foreground/30", - ready: "bg-blue-500/60", - "in-progress": "bg-amber-500/60", - "in-review": "bg-purple-500/60", - done: "bg-success/60", - archived: "bg-muted-foreground/20", -}; +import { ExternalLink } from "lucide-react"; +import type { Task } from "../../types"; +import { TASK_STATUS_DOT_COLORS, TASK_STATUS_LABELS } from "../../lib/taskStatusColors"; interface GhostParentCardProps { parentTask: Task; @@ -36,24 +19,26 @@ const GhostParentCard = memo(function GhostParentCard({ }: GhostParentCardProps) { return (
{ e.stopPropagation(); onClick?.(parentTask.id); }} - title={`Depends on ${parentTask.id} (${STATUS_LABELS[parentTask.status]})`} + title={`Depends on ${parentTask.id} (${TASK_STATUS_LABELS[parentTask.status]})`} > - - +
+ +
+ {parentTask.id} - + {parentTask.title} - {STATUS_LABELS[parentTask.status]} + {TASK_STATUS_LABELS[parentTask.status]}
); diff --git a/src/components/Dashboard/KanbanBoard.tsx b/src/components/Dashboard/KanbanBoard.tsx index 975709f..b4a9675 100644 --- a/src/components/Dashboard/KanbanBoard.tsx +++ b/src/components/Dashboard/KanbanBoard.tsx @@ -169,12 +169,20 @@ export default function KanbanBoard({ {activeTask && ( -
+
{}} isDragOverlay + variant={activeTask.status === "done" ? "compact" : activeTask.status === "in-progress" ? "detailed" : activeTask.status === "backlog" ? "tree-node" : "default"} + taskMap={taskMap} + allTasks={tasks} + dependents={dependentsMap[activeTask.id] ?? []} + isBlocked={activeTask.depends_on.some((depId) => { + const dep = taskMap.get(depId); + return dep != null && dep.status !== "done" && dep.status !== "archived"; + })} />
)} diff --git a/src/components/Dashboard/KanbanColumn.tsx b/src/components/Dashboard/KanbanColumn.tsx index 21b3a6f..9f65e78 100644 --- a/src/components/Dashboard/KanbanColumn.tsx +++ b/src/components/Dashboard/KanbanColumn.tsx @@ -1,6 +1,6 @@ import { memo, useMemo, useState, useCallback, useRef, useEffect } from "react"; -import { useDroppable } from "@dnd-kit/core"; -import { ArrowUpDown, PanelLeftClose, PanelLeftOpen, Check } from "lucide-react"; +import { useDroppable, useDndContext } from "@dnd-kit/core"; +import { ArrowUpDown, PanelLeftClose, PanelLeftOpen, Check, ChevronDown } from "lucide-react"; import { useProjectAccentColor } from "../../hooks/useProjectAccentColor"; import TaskCard from "./TaskCard"; @@ -14,6 +14,16 @@ import type { Session, Task, TaskStatus } from "../../types"; import { useAppStore } from "../../store/appStore"; import { DEFAULT_PRIORITIES } from "../../lib/priorities"; +/** Small vertical connector arrow shown between epic children that depend on each other */ +function EpicDepConnector() { + return ( +
+
+ +
+ ); +} + const COLUMN_LABELS: Record = { backlog: "Backlog", ready: "Ready", @@ -83,6 +93,8 @@ const KanbanColumn = memo(function KanbanColumn({ activeProjectId ? (s.projectPriorities[activeProjectId] ?? DEFAULT_PRIORITIES) : DEFAULT_PRIORITIES ); const { isOver, setNodeRef } = useDroppable({ id: status }); + const { active } = useDndContext(); + const activeDragId = active?.id as string | undefined; const [showSortMenu, setShowSortMenu] = useState(false); const sortMenuRef = useRef(null); @@ -160,14 +172,14 @@ const KanbanColumn = memo(function KanbanColumn({
- 0 ? "text-dim-foreground bg-accent" : "text-muted-foreground" }`}> {tasks.length}
{COLUMN_LABELS[status]} @@ -180,7 +192,7 @@ const KanbanColumn = memo(function KanbanColumn({ return (
- + {COLUMN_LABELS[status]} {blockedCount > 0 && ( - + {blockedCount} blocked )} @@ -216,7 +228,7 @@ const KanbanColumn = memo(function KanbanColumn({ {/* Task count */} - 0 ? "text-dim-foreground bg-accent" : "text-muted-foreground" }`}> {tasks.length} @@ -246,7 +258,18 @@ const KanbanColumn = memo(function KanbanColumn({ {/* Card list */}
- {columnItems.map((item) => { + {columnItems.length === 0 && ( +

+ {status === "done" + ? "Completed tasks appear here" + : status === "in-review" + ? "Tasks awaiting review appear here" + : status === "in-progress" + ? "Start a session to move tasks here" + : "Drop tasks here"} +

+ )} + {columnItems.map((item, idx) => { if (item.type === "ghost") { return ( 0 ? columnItems[idx - 1] : null; + const showConnector = + depth > 0 && + prevItem?.type === "task" && + prevItem.depth > 0 && + task.depends_on.includes(prevItem.task.id); + + const isBeingDragged = activeDragId === task.id; + return ( - - {(menuProps) => ( - +
+ {showConnector && ( +
+ +
)} - + + {(menuProps) => ( + + )} + +
); })}
diff --git a/src/components/Dashboard/PriorityBadge.tsx b/src/components/Dashboard/PriorityBadge.tsx index f670ae9..7d5be22 100644 --- a/src/components/Dashboard/PriorityBadge.tsx +++ b/src/components/Dashboard/PriorityBadge.tsx @@ -14,7 +14,7 @@ export default function PriorityBadge({ priority }: { priority: Priority }) { {priority} diff --git a/src/components/Dashboard/SummaryHeader.tsx b/src/components/Dashboard/SummaryHeader.tsx index 4489021..5aa5009 100644 --- a/src/components/Dashboard/SummaryHeader.tsx +++ b/src/components/Dashboard/SummaryHeader.tsx @@ -81,7 +81,7 @@ const SummaryHeader = memo(function SummaryHeader({ return ( <> {/* Stats */} - + Dashboard @@ -126,7 +126,7 @@ const SummaryHeader = memo(function SummaryHeader({ {archivedCount > 0 && onToggleArchived && ( )} @@ -144,7 +144,7 @@ const SummaryHeader = memo(function SummaryHeader({
- )} - {!isEpic && (task.status === "backlog" || task.status === "ready") && onResearchSession && ( - + {/* Card layout: content left, optional ring right */} +
0 ? "items-start" : ""}`}> + {/* Left content */} +
+ {/* ── Top row: priority + ID + deps + badges ── */} +
+ {isEpic && } + + {task.priority} + + {task.id} + {task.github_issue && } + + {/* Spacer */} +
+ + {/* Dependency dots */} + {taskMap && task.depends_on.length > 0 && ( +
+ {depAnalysis.deps.map((d) => ( + + ))} +
)} - {!isEpic && task.status !== "in-review" && task.status !== "done" && onStartSession && ( - + + {/* Blocked badge */} + {isBlocked && !isDragOverlay && ( + + + blocked + )} - {isEpic && (task.status === "backlog" || task.status === "ready") && onBreakdownEpic && ( - + + {/* Dependents badge */} + {dependents.length > 0 && ( + + + {dependents.length} + )} - {onContextMenu && ( - + + {/* Context menu button (hover only, replaces old inline action buttons) */} + {!isDragOverlay && !isSessionActive && onContextMenu && ( +
+ +
)}
- )} -
- {/* Title — inline editable */} - {isEditingTitle ? ( - setEditValue(e.target.value)} - onKeyDown={handleTitleKeyDown} - onBlur={() => onTitleSave?.(editValue)} - onClick={(e) => e.stopPropagation()} - onPointerDown={(e) => e.stopPropagation()} - /> - ) : ( -
- {task.title} -
- )} + {/* ── Title ── */} + {isEditingTitle ? ( + setEditValue(e.target.value)} + onKeyDown={handleTitleKeyDown} + onBlur={() => onTitleSave?.(editValue)} + onClick={(e) => e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + /> + ) : ( +
+ {task.title} +
+ )} - {/* Labels */} - {task.labels.length > 0 && ( -
- {task.labels.slice(0, 3).map((label) => ( - - {label} - - ))} - {task.labels.length > 3 && ( - - +{task.labels.length - 3} - + {/* ── Labels ── */} + {task.labels.length > 0 && ( +
+ {task.labels.slice(0, 3).map((label) => ( + + {label} + + ))} + {task.labels.length > 3 && ( + +{task.labels.length - 3} + )} +
+ )} + + {/* ── Epic progress ── */} + {isEpic && epicProgress && ( +
+
+ + {epicProgress.done}/{epicProgress.total} subtasks done + + + {Math.round((epicProgress.done / epicProgress.total) * 100)}% + +
+
+
+
+
+ )} + + {/* ── Agent row (only when no active session) ── */} + {task.agent && !showActivityStrip && !isEpic && ( +
+ + {task.agent} +
+ )} + + {/* ── Dependency detail row (for blocked cards) ── */} + {isBlocked && !isDragOverlay && depAnalysis.unmetCount > 0 && ( +
+ {depAnalysis.deps.filter((d) => !d.isMet).map((d) => ( +
+ waits on + + {d.task?.title ?? d.id} + {d.task && ( + + {TASK_STATUS_LABELS[d.task.status]} + + )} +
+ ))} +
)}
- )} - {/* Epic progress */} - {isEpic && epicProgress && ( -
-
- - {epicProgress.done}/{epicProgress.total} subtasks done - - - {Math.round((epicProgress.done / epicProgress.total) * 100)}% + {/* ── Progress ring (active cards only) ── */} + {isSessionActive && progressPercent > 0 && ( + + )} +
+ + {/* ── Activity strip (replaces old MCP footer) ── */} + {showActivityStrip && ( +
+ {/* Pulse dot + activity label */} +
+ + + {mcpData?.completed + ? "Done" + : (mcpData?.error || mcpData?.status === "error") + ? "Error" + : (mcpData?.waiting || mcpData?.status === "waiting") + ? "Waiting" + : getActivityLabel(activity, !!isResearch)}
-
+ + {/* Progress bar */} +
-
- )} - {/* Agent row */} - {task.agent && !showMcpFooter && !isEpic && ( -
- {task.agent} + {/* Step label */} + {mcpData?.current_step != null && mcpData?.total_steps != null && mcpData.total_steps > 0 && ( + + {mcpData.current_step}/{mcpData.total_steps} + + )}
)} +
+ ); +}); - {/* Detailed variant: progress bar */} - {isDetailed && isSessionActive && mcpData?.current_step != null && mcpData?.total_steps != null && mcpData.total_steps > 0 && ( -
-
-
-
+// ── Skeleton variant for loading states ── + +export function TaskCardSkeleton({ variant = "default" }: { variant?: TaskCardVariant }) { + if (variant === "compact") { + return ( +
+
+ +
- )} +
+ ); + } - {/* MCP status footer — only shown when session is active */} - {showMcpFooter && ( - <> - -
- {mcpData ? ( - <> - {mcpData.completed ? ( - - ) : (mcpData.error || mcpData.status === "error") ? ( - - ) : (mcpData.waiting || mcpData.status === "waiting") ? ( - - ) : activity === "researching" || activity === "exploring" ? ( - - ) : activity === "planning" ? ( - - ) : activity === "testing" ? ( - - ) : activity === "debugging" ? ( - - ) : activity === "reviewing" ? ( - - ) : activity === "coding" ? ( - - ) : isResearch ? ( - - ) : ( - - )} - - {(mcpData.error || mcpData.status === "error") - ? mcpData.error_message || mcpData.message || "Error" - : (mcpData.waiting || mcpData.status === "waiting") - ? "Waiting for input" - : mcpData.completed - ? "Done" - : mcpData.current_step != null && mcpData.total_steps != null - ? `Step ${mcpData.current_step}/${mcpData.total_steps}` - : mcpData.message || (activity ? activity.charAt(0).toUpperCase() + activity.slice(1) : isResearch ? "Researching" : "Working")} - - - ) : isResearchActivity ? ( - <> - - Researching - - ) : ( - <> - - Starting - - )} -
- - )} + return ( +
+ {/* Top row: priority + ID */} +
+ + +
+
+ {/* Title */} + + + {/* Labels */} +
+ + +
); -}); +} diff --git a/src/components/Dashboard/TaskCardContextMenu.tsx b/src/components/Dashboard/TaskCardContextMenu.tsx index 1e0cb51..4fa7e7c 100644 --- a/src/components/Dashboard/TaskCardContextMenu.tsx +++ b/src/components/Dashboard/TaskCardContextMenu.tsx @@ -89,19 +89,19 @@ export default function TaskCardContextMenu({ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [isEditingTitle, setIsEditingTitle] = useState(false); - // Hidden trigger element — positioned at right-click coordinates - const triggerRef = useRef(null); + // Virtual anchor positioned at right-click coordinates + const cursorPos = useRef({ x: 0, y: 0 }); + const getAnchor = useCallback(() => ({ + getBoundingClientRect: () => new DOMRect(cursorPos.current.x, cursorPos.current.y, 0, 0), + }), []); // Prevent context menu when editing title const handleContextMenu = useCallback((e: React.MouseEvent) => { if (isEditingTitle) return; e.preventDefault(); e.stopPropagation(); - // Position hidden trigger at cursor - if (triggerRef.current) { - triggerRef.current.style.left = `${e.clientX}px`; - triggerRef.current.style.top = `${e.clientY}px`; - } + // Update cursor position for virtual anchor + cursorPos.current = { x: e.clientX, y: e.clientY }; setMenuOpen(true); }, [isEditingTitle]); @@ -288,9 +288,8 @@ export default function TaskCardContextMenu({ {/* Context menu */} - {/* Hidden trigger — must be a real MenuPrimitive.Trigger for submenu focus tracking */} + {/* Hidden trigger — required by Base UI for submenu focus tracking */} } tabIndex={-1} style={{ position: "fixed", left: 0, top: 0, width: 0, height: 0, pointerEvents: "none", opacity: 0 }} @@ -298,6 +297,7 @@ export default function TaskCardContextMenu({ void; +} + +interface FileContextMenuProps { + fullPath: string; + relativePath: string; + isDir: boolean; + children: (props: FileContextMenuRenderProps) => React.ReactNode; +} + +export default function FileContextMenu({ + fullPath, + relativePath, + isDir, + children, +}: FileContextMenuProps) { + const [open, setOpen] = useState(false); + const positionRef = useRef({ x: 0, y: 0 }); + const [editors, setEditors] = useState([]); + const editorsLoaded = useRef(false); + + // Load available editors once + useEffect(() => { + if (editorsLoaded.current) return; + editorsLoaded.current = true; + invoke("detect_editors") + .then(setEditors) + .catch(() => setEditors([])); + }, []); + + const handleContextMenu = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + positionRef.current = { x: e.clientX, y: e.clientY }; + setOpen(true); + }, []); + + // Close on scroll + useEffect(() => { + if (!open) return; + const handleScroll = () => setOpen(false); + window.addEventListener("scroll", handleScroll, true); + return () => window.removeEventListener("scroll", handleScroll, true); + }, [open]); + + const handleCopyPath = useCallback(() => { + navigator.clipboard.writeText(relativePath); + setOpen(false); + }, [relativePath]); + + const handleCopyAbsolutePath = useCallback(() => { + navigator.clipboard.writeText(fullPath); + setOpen(false); + }, [fullPath]); + + const handleRevealInExplorer = useCallback(() => { + // For files, reveal the parent dir; for dirs, reveal the dir itself + invoke("open_file_in_os", { path: fullPath }); + setOpen(false); + }, [fullPath]); + + const handleOpenInEditor = useCallback( + (editorId: string) => { + invoke("open_in_editor", { path: fullPath, editorId }); + setOpen(false); + }, + [fullPath], + ); + + return ( + + + + {children({ onContextMenu: handleContextMenu })} + + + ({ + x: positionRef.current.x, + y: positionRef.current.y, + width: 0, + height: 0, + top: positionRef.current.y, + right: positionRef.current.x, + bottom: positionRef.current.y, + left: positionRef.current.x, + toJSON: () => {}, + }), + }} + > + + {/* Open in Editor — single item or submenu */} + {editors.length === 1 && ( + handleOpenInEditor(editors[0].id)} + > + + Open in {editors[0].label} + + )} + {editors.length > 1 && ( + + + + Open in Editor + + + + + + {editors.map((editor) => ( + handleOpenInEditor(editor.id)} + > + {editor.label} + + ))} + + + + + )} + + {editors.length > 0 && ( + + )} + + {/* Reveal in Explorer */} + + {isDir ? ( + + ) : ( + + )} + {isDir ? "Open in File Manager" : "Reveal in File Manager"} + + + + + {/* Copy path options */} + + + Copy Relative Path + + + + + Copy Absolute Path + + + + + + ); +} diff --git a/src/components/Files/FileTree.tsx b/src/components/Files/FileTree.tsx index 63dbe5b..8ff5217 100644 --- a/src/components/Files/FileTree.tsx +++ b/src/components/Files/FileTree.tsx @@ -1,7 +1,7 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { Loader2 } from "lucide-react"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import FileTreeItem from "./FileTreeItem"; import type { ChangedFile, FileEntry } from "../../types"; @@ -9,6 +9,7 @@ import type { ChangedFile, FileEntry } from "../../types"; interface FileTreeProps { projectPath: string; projectId: string; + filterText?: string; } /** Priority for propagating git status to parent directories. Higher = more important. */ @@ -51,7 +52,10 @@ function buildGitStatusMaps( return [fileMap, dirMap]; } -export default function FileTree({ projectPath, projectId }: FileTreeProps) { +/** Max search results to show */ +const MAX_SEARCH_RESULTS = 100; + +export default function FileTree({ projectPath, projectId, filterText = "" }: FileTreeProps) { // Directory contents cache: path → entries const [dirCache, setDirCache] = useState>({}); const [expandedDirs, setExpandedDirs] = useState>(new Set()); @@ -64,6 +68,40 @@ export default function FileTree({ projectPath, projectId }: FileTreeProps) { const [gitDirStatus, setGitDirStatus] = useState>({}); const prevProjectPath = useRef(projectPath); + // File index for search — preloaded flat list of all project files + const [fileIndex, setFileIndex] = useState(null); + const [indexing, setIndexing] = useState(false); + + const filter = filterText.trim().toLowerCase(); + const isFiltering = filter.length > 0; + + // Preload file index in the background after root directory loads + useEffect(() => { + setIndexing(true); + invoke("index_project_files", { projectRoot: projectPath }) + .then((files) => { + setFileIndex(files); + setIndexing(false); + }) + .catch(() => { + setFileIndex([]); + setIndexing(false); + }); + }, [projectPath]); + + // Client-side filtered results from the preloaded index + const searchResults = useMemo(() => { + if (!isFiltering || !fileIndex) return null; + const results: FileEntry[] = []; + for (const entry of fileIndex) { + if (entry.name.toLowerCase().includes(filter)) { + results.push(entry); + if (results.length >= MAX_SEARCH_RESULTS) break; + } + } + return results; + }, [filter, isFiltering, fileIndex]); + // Fetch git status for the project const fetchGitStatus = useCallback(async () => { try { @@ -120,6 +158,7 @@ export default function FileTree({ projectPath, projectId }: FileTreeProps) { setSelectedPath(null); setGitFileStatus({}); setGitDirStatus({}); + setFileIndex(null); prevProjectPath.current = projectPath; } // Always load root @@ -137,15 +176,19 @@ export default function FileTree({ projectPath, projectId }: FileTreeProps) { fetchGitStatus(); }, [projectPath, fetchGitStatus]); - // Auto-refresh git status on mcp-files-changed events + // Auto-refresh git status and re-index on mcp-files-changed events useEffect(() => { const unlisten = listen("mcp-files-changed", () => { fetchGitStatus(); + // Re-index when files change + invoke("index_project_files", { projectRoot: projectPath }) + .then(setFileIndex) + .catch(() => {}); }); return () => { unlisten.then((fn) => fn()); }; - }, [fetchGitStatus]); + }, [fetchGitStatus, projectPath]); const toggleDir = useCallback( (dirFullPath: string) => { @@ -168,7 +211,7 @@ export default function FileTree({ projectPath, projectId }: FileTreeProps) { setSelectedPath((prev) => (prev === filePath ? null : filePath)); }, []); - // Render a directory's entries recursively + // Render a directory's entries recursively (normal tree mode) const renderEntries = ( parentPath: string, depth: number, @@ -245,6 +288,52 @@ export default function FileTree({ projectPath, projectId }: FileTreeProps) { ); } + // Search results mode — flat list filtered from preloaded index + if (isFiltering) { + // Index still loading + if (indexing || !fileIndex) { + return ( +
+ + Indexing project files… +
+ ); + } + + if (!searchResults || searchResults.length === 0) { + return ( +
+

No matching files

+
+ ); + } + + return ( +
+ {searchResults.map((entry) => { + const fullPath = projectPath + "/" + entry.path; + const gitStatus = gitFileStatus[entry.path]; + + return ( + {}} + onSelect={() => handleSelect(fullPath)} + showRelativePath + /> + ); + })} +
+ ); + } + + // Normal tree mode if (!dirCache[projectPath]) { return (
diff --git a/src/components/Files/FileTreeItem.tsx b/src/components/Files/FileTreeItem.tsx index fc1d449..a067dee 100644 --- a/src/components/Files/FileTreeItem.tsx +++ b/src/components/Files/FileTreeItem.tsx @@ -9,6 +9,7 @@ import React, { useCallback } from "react"; import { getFileIcon } from "./fileIcons"; import { useAppStore } from "../../store/appStore"; +import FileContextMenu from "./FileContextMenu"; import type { FileEntry } from "../../types"; interface FileTreeItemProps { @@ -20,6 +21,8 @@ interface FileTreeItemProps { gitStatus?: string; onToggle: () => void; onSelect: () => void; + /** When true, show the relative path below the filename (used in search results). */ + showRelativePath?: boolean; } /** Map git status to a Tailwind text color class for files. */ @@ -65,6 +68,7 @@ const FileTreeItem = React.memo(function FileTreeItem({ gitStatus, onToggle, onSelect, + showRelativePath, }: FileTreeItemProps) { const FileIcon = getFileIcon(entry.extension); const addBackgroundTask = useAppStore((s) => s.addBackgroundTask); @@ -92,58 +96,72 @@ const FileTreeItem = React.memo(function FileTreeItem({ const nameColorClass = statusColor || ""; return ( -
- {entry.is_dir ? ( - <> - - {isExpanded ? ( - - ) : ( - + {({ onContextMenu }) => ( +
+ {entry.is_dir ? ( + <> + + {isExpanded ? ( + + ) : ( + + )} + + + {isExpanded ? : } + + + ) : ( + <> + + + + + + )} + + {entry.name} + {showRelativePath && entry.path.includes("/") && ( + + {entry.path.slice(0, entry.path.lastIndexOf("/"))} + )} - - {isExpanded ? : } - - - ) : ( - <> - - - - - - )} - - {entry.name} - - {/* Git status dot indicator for directories */} - {entry.is_dir && gitStatus && ( - + {/* Git status dot indicator for directories */} + {entry.is_dir && gitStatus && ( + + )} +
)} -
+ ); }); diff --git a/src/components/GitHub/BranchFilter.tsx b/src/components/GitHub/BranchFilter.tsx index 81d55a0..a298a8a 100644 --- a/src/components/GitHub/BranchFilter.tsx +++ b/src/components/GitHub/BranchFilter.tsx @@ -32,7 +32,7 @@ export default function BranchFilter({ -
+ {loading && !detail && (
@@ -119,14 +129,14 @@ export default function CommitDetailPanel({ )} {detail && ( -
+ {/* Hash + dot */}
- + {detail.hash}
-
+
{detail.author_email}
-
+
{formatTimestamp(detail.timestamp)}
@@ -157,7 +167,7 @@ export default function CommitDetailPanel({ {detail.subject}
{detail.body && ( -
+
{detail.body}
)} @@ -166,14 +176,14 @@ export default function CommitDetailPanel({ {/* Parents */} {detail.parent_hashes.length > 0 && (
-
+
{detail.parent_hashes.length > 1 ? "Parents (merge)" : "Parent"}
{detail.parent_hashes.map((ph) => ( {ph.slice(0, 12)} @@ -185,13 +195,13 @@ export default function CommitDetailPanel({ {/* Changed files */} {detail.files.length > 0 && (
-
+
Files changed ({detail.files.length})
{Array.from(groupByDirectory(detail.files)).map( ([dir, files]) => (
-
+
{dir}/
{files.map((f) => { @@ -208,7 +218,7 @@ export default function CommitDetailPanel({ className="shrink-0" style={{ color: cfg.color }} /> - + {fileName}
@@ -219,8 +229,8 @@ export default function CommitDetailPanel({ )}
)} -
+ )} -
+ ); } diff --git a/src/components/GitHub/CommitGraph.tsx b/src/components/GitHub/CommitGraph.tsx index 20368e9..3040166 100644 --- a/src/components/GitHub/CommitGraph.tsx +++ b/src/components/GitHub/CommitGraph.tsx @@ -6,10 +6,11 @@ import { RAIL_WIDTH, maxColumn, } from "../../lib/graphLayout"; +import { GitCommitHorizontal } from "lucide-react"; import type { RefInfo } from "../../types"; import GraphCanvas from "./GraphCanvas"; import CommitRow from "./CommitRow"; -import { Loader2 } from "lucide-react"; +import { Skeleton } from "../ui/skeleton"; interface CommitGraphProps { nodes: GraphNode[]; @@ -81,10 +82,40 @@ export default function CommitGraph({ handleScroll(); }, [handleScroll]); + if (!loading && nodes.length === 0) { + return ( +
+ +

No commits found

+

Commits will appear here once the repository has history

+
+ ); + } + if (loading && nodes.length === 0) { return ( -
- +
+ {Array.from({ length: 20 }).map((_, i) => ( +
+ {/* Graph dot placeholder */} +
+ +
+ {/* Message placeholder */} +
+ +
+ {/* Hash + time placeholder */} +
+ + +
+
+ ))}
); } @@ -118,13 +149,27 @@ export default function CommitGraph({ ))}
- {/* Load more indicator */} + {/* Load more indicator — skeleton rows for infinite scroll */} {loadingMore && ( -
- - - Loading more commits... - +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+ +
+
+ +
+
+ + +
+
+ ))}
)} diff --git a/src/components/GitHub/CommitRow.tsx b/src/components/GitHub/CommitRow.tsx index 3fc01d3..5f68555 100644 --- a/src/components/GitHub/CommitRow.tsx +++ b/src/components/GitHub/CommitRow.tsx @@ -52,16 +52,27 @@ function CommitRowInner({ {/* Graph dot area */}
- {/* Merge inner circle */} + {/* Merge inner circle — thicker stroke + slight fill for dark theme contrast */} {isMerge && ( - + <> + + + )} {/* Main dot */} {!isMerge && ( @@ -76,7 +87,7 @@ function CommitRowInner({ fill="none" stroke="var(--foreground)" strokeWidth={1.5} - opacity={0.8} + opacity={0.9} /> )} @@ -88,7 +99,7 @@ function CommitRowInner({ {refs?.branches.map((b) => ( {b} @@ -97,7 +108,7 @@ function CommitRowInner({ {refs?.tags.map((t) => ( {t} @@ -113,13 +124,13 @@ function CommitRowInner({ {isMerge && ( )} - + {commit.short_hash} - + {formatRelativeTime(commit.timestamp)}
diff --git a/src/components/GitHub/GitHubAuthGate.tsx b/src/components/GitHub/GitHubAuthGate.tsx index 4e69910..dc4a420 100644 --- a/src/components/GitHub/GitHubAuthGate.tsx +++ b/src/components/GitHub/GitHubAuthGate.tsx @@ -66,7 +66,7 @@ export default function GitHubAuthGate({ if (authBroken) { return (
- +

GitHub authentication issue

diff --git a/src/components/GitHub/GitHubView.tsx b/src/components/GitHub/GitHubView.tsx index d40651e..c45b90e 100644 --- a/src/components/GitHub/GitHubView.tsx +++ b/src/components/GitHub/GitHubView.tsx @@ -9,7 +9,7 @@ import { Github, Loader2, RefreshCw, - Settings, + RotateCw, } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; @@ -23,8 +23,6 @@ import { Button } from "../ui/orecus.io/components/enhanced-button"; import { glassStyles } from "../ui/orecus.io/lib/color-utils"; import { Tabs } from "../ui/orecus.io/navigation/tabs"; import BranchSelect from "../ui/BranchSelect"; -import { GitHubTab as GitHubSettingsTab } from "../Settings/GitHubTab"; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../ui/dialog"; import BranchFilter from "./BranchFilter"; import ChangesTab from "./ChangesTab"; import CommitDetailPanel from "./CommitDetailPanel"; @@ -35,16 +33,19 @@ import { useGitHubData } from "./useGitHubData"; type GitHubTab = "changes" | "commits" | "pull-requests" | "issues"; +const DEFAULT_DETAIL_WIDTH = 350; + export default function GitHubView() { const { isGlass } = useTheme(); const accentColor = useProjectAccentColor(); const activeProjectId = useAppStore((s) => s.activeProjectId); const projectInfo = useAppStore((s) => s.projectInfo); const setProjectInfo = useAppStore((s) => s.setProjectInfo); + const setActiveView = useAppStore((s) => s.setActiveView); const [activeTab, setActiveTab] = useState("changes"); - const [settingsOpen, setSettingsOpen] = useState(false); + const [commitDetailWidth, setCommitDetailWidth] = useState(DEFAULT_DETAIL_WIDTH); - const handleOpenSettings = useCallback(() => setSettingsOpen(true), []); + const handleOpenSettings = useCallback(() => setActiveView("settings"), [setActiveView]); // Sync status const [syncStatus, setSyncStatus] = useState(null); @@ -182,7 +183,7 @@ export default function GitHubView() { >

Select a project to view git history

-

+

Open a project tab to get started

@@ -193,7 +194,7 @@ export default function GitHubView() { {/* Header */} - + Git @@ -209,6 +210,7 @@ export default function GitHubView() { barRadius="md" tabRadius="md" fullWidth={false} + className="p-0" > }> Changes @@ -219,7 +221,7 @@ export default function GitHubView() { }> Issues - + Pull Requests - + @@ -247,7 +249,7 @@ export default function GitHubView() { {/* Pull button */} - {/* GitHub settings */} - - {/* GitHub Settings Dialog */} - - - - GitHub Settings - -
- -
-
-
- {/* Content card */}
- {error} +
+ {error} +
)} @@ -378,6 +364,8 @@ export default function GitHubView() { detail={selectedDetail} node={selectedNode} loading={!selectedDetail} + panelWidth={commitDetailWidth} + onResize={setCommitDetailWidth} onClose={() => selectCommit(null)} /> )} diff --git a/src/components/GitHub/GraphCanvas.tsx b/src/components/GitHub/GraphCanvas.tsx index 4dd0dfd..e3a46a4 100644 --- a/src/components/GitHub/GraphCanvas.tsx +++ b/src/components/GitHub/GraphCanvas.tsx @@ -43,7 +43,9 @@ function GraphCanvasInner({ ? node.railColor : RAIL_COLORS[conn.parentColumn % RAIL_COLORS.length]; - if (conn.connectionType === "straight") { + const isMerge = conn.connectionType !== "straight"; + + if (!isMerge) { paths.push( , diff --git a/src/components/GitHub/IssueDetailPanel.tsx b/src/components/GitHub/IssueDetailPanel.tsx index 608a651..d0e1187 100644 --- a/src/components/GitHub/IssueDetailPanel.tsx +++ b/src/components/GitHub/IssueDetailPanel.tsx @@ -12,15 +12,16 @@ import { Check, } from "lucide-react"; -import { useTheme } from "../../contexts/ThemeContext"; import type { GitHubIssueDetail } from "../../types"; import { Button } from "../ui/orecus.io/components/enhanced-button"; -import { glassStyles } from "../ui/orecus.io/lib/color-utils"; +import SidePanel from "../ui/SidePanel"; interface IssueDetailPanelProps { detail: GitHubIssueDetail | null; loading: boolean; importing: boolean; + panelWidth: number; + onResize: (width: number) => void; onClose: () => void; onImport: (issueNumber: number) => void; } @@ -54,11 +55,11 @@ export default function IssueDetailPanel({ detail, loading, importing, + panelWidth, + onResize, onClose, onImport, }: IssueDetailPanelProps) { - const { isGlass } = useTheme(); - const handleOpenInGitHub = useCallback(() => { if (detail?.issue.url) { open(detail.issue.url); @@ -72,22 +73,28 @@ export default function IssueDetailPanel({ }, [detail, onImport]); return ( -
{/* Header */} -
+ Issue Detail -
+ {loading && !detail && (
@@ -96,12 +103,12 @@ export default function IssueDetailPanel({ )} {detail && ( -
+ {/* State + title + number */}
{detail.already_imported && detail.existing_task_id && ( {detail.issue.title}
-
+
#{detail.issue.number}
{/* Author + date */} {detail.issue.assignees.length > 0 && ( -
+
{detail.issue.assignees.map((a) => a.login).join(", ")} @@ -145,7 +152,7 @@ export default function IssueDetailPanel({
)} -
+
Opened {formatRelativeTime(detail.issue.created_at)} {detail.issue.updated_at !== detail.issue.created_at && ( @@ -160,7 +167,7 @@ export default function IssueDetailPanel({ {detail.issue.labels.map((label) => ( -
+
Description
-
+
{detail.issue.body}
@@ -187,10 +194,10 @@ export default function IssueDetailPanel({ {!detail.issue.body && (
-
+
Description
-
+
No description provided
@@ -198,13 +205,13 @@ export default function IssueDetailPanel({ {/* Comments */}
-
+
Comments ({detail.comments.length})
{detail.comments.length === 0 && ( -
+
No comments yet
)} @@ -225,26 +232,26 @@ export default function IssueDetailPanel({ ) : ( )} - + {comment.author} - + {formatRelativeTime(comment.created_at)}
{/* Comment body */} -
+
{comment.body}
))}
-
+ )} {/* Action buttons */} {detail && ( -
+ {/* Import button (if not already imported) */} {!detail.already_imported && ( -
+ )} -
+ ); } diff --git a/src/components/GitHub/IssuesTab.tsx b/src/components/GitHub/IssuesTab.tsx index e97f8e9..c295319 100644 --- a/src/components/GitHub/IssuesTab.tsx +++ b/src/components/GitHub/IssuesTab.tsx @@ -1,4 +1,4 @@ -import { useEffect, useCallback } from "react"; +import { useEffect, useCallback, useState } from "react"; import { CircleDot, ListFilter, @@ -9,6 +9,7 @@ import { CircleCheck, CircleX, User, + RotateCw, } from "lucide-react"; import { invoke } from "@tauri-apps/api/core"; import { Badge } from "../ui/badge"; @@ -19,6 +20,8 @@ import IssueDetailPanel from "./IssueDetailPanel"; import GitHubAuthGate from "./GitHubAuthGate"; import type { GitHubIssue, ImportResult, Task } from "../../types"; +const DEFAULT_DETAIL_WIDTH = 350; + interface IssuesTabProps { projectId: string | null; hasRemote: boolean; @@ -27,6 +30,7 @@ interface IssuesTabProps { export default function IssuesTab({ projectId, hasRemote, onOpenSettings }: IssuesTabProps) { const refreshGhAuth = useAppStore((s) => s.refreshGhAuth); + const [detailWidth, setDetailWidth] = useState(DEFAULT_DETAIL_WIDTH); const { issues, loading, @@ -134,7 +138,7 @@ export default function IssuesTab({ projectId, hasRemote, onOpenSettings }: Issu
)} @@ -240,9 +251,9 @@ export default function IssuesTab({ projectId, hasRemote, onOpenSettings }: Issu
handleRowClick(issue.number)} - className={`flex items-center gap-2.5 px-3 py-2 border-b border-border/40 hover:bg-accent transition-colors cursor-pointer ${ + className={`flex items-center gap-2 px-3 py-1.5 border-b border-border/40 hover:bg-accent transition-colors cursor-pointer ${ selectedIssue === issue.number - ? "bg-[color-mix(in_oklch,var(--primary)_6%,transparent)]" + ? "bg-primary/6" : "" }`} > @@ -265,11 +276,6 @@ export default function IssuesTab({ projectId, hasRemote, onOpenSettings }: Issu )}
- {/* Issue number */} - - #{issue.number} - - {/* State icon */}
{issue.state === "OPEN" ? ( @@ -285,43 +291,54 @@ export default function IssuesTab({ projectId, hasRemote, onOpenSettings }: Issu )}
- {/* Title + labels */} -
- - {issue.title} - + {/* Issue number */} + + #{issue.number} + - {/* GitHub labels */} - {issue.labels.map((label) => ( - - {label.name} + {/* Title + labels */} +
+
+ + {issue.title} - ))} -
- {/* Assignees */} - {issue.assignees.length > 0 && ( -
- - - {issue.assignees.map((a) => a.login).join(", ")} - + {/* GitHub labels — show max 2 */} + {issue.labels.slice(0, 2).map((label) => ( + + {label.name} + + ))} + {issue.labels.length > 2 && ( + + +{issue.labels.length - 2} + + )}
- )} + {/* Secondary line: assignees */} + {issue.assignees.length > 0 && ( +
+ + + {issue.assignees.map((a) => a.login).join(", ")} + +
+ )} +
{/* Import status badge */} {already_imported && existing_task_id && ( {existing_task_id} @@ -337,6 +354,8 @@ export default function IssuesTab({ projectId, hasRemote, onOpenSettings }: Issu detail={issueDetail} loading={detailLoading} importing={importing} + panelWidth={detailWidth} + onResize={setDetailWidth} onClose={() => selectIssue(null)} onImport={handleImportSingle} /> diff --git a/src/components/GitHub/PullRequestDetailPanel.tsx b/src/components/GitHub/PullRequestDetailPanel.tsx index 0a2d87f..dc1e2d3 100644 --- a/src/components/GitHub/PullRequestDetailPanel.tsx +++ b/src/components/GitHub/PullRequestDetailPanel.tsx @@ -12,16 +12,17 @@ import { } from "lucide-react"; import { useState, useCallback, useRef, useEffect } from "react"; -import { useTheme } from "../../contexts/ThemeContext"; import type { GitHubPRDetail } from "../../types"; import { Button } from "../ui/orecus.io/components/enhanced-button"; -import { glassStyles } from "../ui/orecus.io/lib/color-utils"; +import SidePanel from "../ui/SidePanel"; interface PullRequestDetailPanelProps { detail: GitHubPRDetail | null; loading: boolean; merging: boolean; closing: boolean; + panelWidth: number; + onResize: (width: number) => void; onClose: () => void; onMerge: (number: number, method: string) => Promise; onClosePR: (number: number) => Promise; @@ -94,11 +95,12 @@ export default function PullRequestDetailPanel({ loading, merging, closing, + panelWidth, + onResize, onClose, onMerge, onClosePR, }: PullRequestDetailPanelProps) { - const { isGlass } = useTheme(); const [mergeMethod, setMergeMethod] = useState("squash"); const [showMergeOptions, setShowMergeOptions] = useState(false); const [confirmClose, setConfirmClose] = useState(false); @@ -134,22 +136,28 @@ export default function PullRequestDetailPanel({ }, [detail]); return ( -
{/* Header */} -
+ Pull Request Detail -
+ {loading && !detail && (
@@ -158,12 +166,12 @@ export default function PullRequestDetailPanel({ )} {detail && ( -
+ {/* Title + number + state */}
{detail.is_draft && ( - + Draft )} @@ -180,18 +188,18 @@ export default function PullRequestDetailPanel({
{detail.title}
-
+
#{detail.number}
{/* Author + date */}
-
+
{detail.author.login} opened{" "} {formatRelativeTime(detail.created_at)}
-
+
{detail.head_ref_name} {detail.base_ref_name} @@ -204,7 +212,7 @@ export default function PullRequestDetailPanel({ if (!rd) return null; return (
{rd.label} @@ -215,19 +223,19 @@ export default function PullRequestDetailPanel({ {/* Body */} {detail.body && (
-
+
Description
-
+
{detail.body}
)} {/* Stats */} -
- +{detail.additions} - -{detail.deletions} +
+ + {detail.changed_files} file {detail.changed_files !== 1 ? "s" : ""} @@ -237,17 +245,17 @@ export default function PullRequestDetailPanel({ {/* Reviews */} {detail.reviews.length > 0 && (
-
+
Reviews
{detail.reviews.map((r, i) => (
{r.author} ( 0 && (
-
+
Files changed ({detail.files.length})
{Array.from(groupByDirectory(detail.files)).map( ([dir, files]) => (
-
+
{dir}/
{files.map((f) => { @@ -321,13 +329,13 @@ export default function PullRequestDetailPanel({ className="shrink-0" style={{ color: iconColor }} /> - + {fileName} - + +{f.additions} - + -{f.deletions}
@@ -338,12 +346,12 @@ export default function PullRequestDetailPanel({ )}
)} -
+ )} {/* Action buttons */} {detail && detail.state.toUpperCase() === "OPEN" && ( -
+ {/* Merge with method selector */}
-
+ )} -
+ ); } diff --git a/src/components/GitHub/PullRequestsTab.tsx b/src/components/GitHub/PullRequestsTab.tsx index d00b535..c495dd2 100644 --- a/src/components/GitHub/PullRequestsTab.tsx +++ b/src/components/GitHub/PullRequestsTab.tsx @@ -1,4 +1,4 @@ -import { useEffect, useCallback } from "react"; +import { useEffect, useCallback, useState } from "react"; import { GitPullRequestArrow, GitMerge, @@ -10,6 +10,7 @@ import { Eye, FileEdit, Check, + RotateCw, } from "lucide-react"; import { Button } from "../ui/orecus.io/components/enhanced-button"; import { useAppStore } from "../../store/appStore"; @@ -17,6 +18,8 @@ import { usePullRequests, type PRStateFilter } from "./usePullRequests"; import PullRequestDetailPanel from "./PullRequestDetailPanel"; import GitHubAuthGate from "./GitHubAuthGate"; +const DEFAULT_DETAIL_WIDTH = 350; + interface PullRequestsTabProps { projectId: string | null; hasRemote: boolean; @@ -70,6 +73,7 @@ export default function PullRequestsTab({ onOpenSettings, }: PullRequestsTabProps) { const refreshGhAuth = useAppStore((s) => s.refreshGhAuth); + const [detailWidth, setDetailWidth] = useState(DEFAULT_DETAIL_WIDTH); const { prs, loading, @@ -129,7 +133,7 @@ export default function PullRequestsTab({
)} @@ -212,9 +224,9 @@ export default function PullRequestsTab({
handleRowClick(pr.number)} - className={`flex items-center gap-2.5 px-3 py-2 border-b border-border/40 hover:bg-accent transition-colors cursor-pointer ${ + className={`flex items-center gap-2 px-3 py-1.5 border-b border-border/40 hover:bg-accent transition-colors cursor-pointer ${ selectedPR === pr.number - ? "bg-[color-mix(in_oklch,var(--primary)_6%,transparent)]" + ? "bg-primary/6" : "" }`} > @@ -222,62 +234,68 @@ export default function PullRequestsTab({
{stateIcon(pr.state)}
{/* PR number */} - + #{pr.number} {/* Title + draft badge + labels */} -
- - {pr.title} - - - {pr.is_draft && ( - - Draft +
+
+ + {pr.title} - )} - {pr.labels.map((label) => ( - - {label.name} + {pr.is_draft && ( + + Draft + + )} + + {pr.labels.slice(0, 2).map((label) => ( + + {label.name} + + ))} + {pr.labels.length > 2 && ( + + +{pr.labels.length - 2} + + )} +
+ {/* Secondary line: branch + diff stats */} +
+ + {pr.head_ref_name} + + {pr.base_ref_name} - ))} + +{pr.additions} + -{pr.deletions} +
- {/* Branch pill */} - - {pr.head_ref_name} - - {pr.base_ref_name} - - {/* Review status */} -
- {reviewIcon(pr.review_decision)} -
- - {/* Diff stats */} -
- +{pr.additions} - -{pr.deletions} -
+ {pr.review_decision && ( +
+ {reviewIcon(pr.review_decision)} +
+ )} {/* Author */} - + {pr.author.login} {/* Time */} - + {formatRelativeTime(pr.updated_at)}
@@ -291,6 +309,8 @@ export default function PullRequestsTab({ loading={detailLoading} merging={merging} closing={closing} + panelWidth={detailWidth} + onResize={setDetailWidth} onClose={() => selectPR(null)} onMerge={mergePR} onClosePR={closePR} diff --git a/src/components/Help/HelpView.tsx b/src/components/Help/HelpView.tsx index 699000d..4919cc1 100644 --- a/src/components/Help/HelpView.tsx +++ b/src/components/Help/HelpView.tsx @@ -22,6 +22,8 @@ import { streamdownTheme, } from "../../lib/markdown"; import { ViewLayout } from "../Shell/ViewLayout"; +import SidePanel from "../ui/SidePanel"; +import { glassStyles } from "../ui/orecus.io/lib/color-utils"; import type { DocContent, DocEntry } from "../../types"; import type { LucideIcon } from "lucide-react"; @@ -103,17 +105,13 @@ export default function HelpView() {
{/* Left panel — doc list */} -
-
+ + Guides -
-
+ + {loading ? (
{doc.description && ( -
+
{doc.description}
)} @@ -154,15 +152,11 @@ export default function HelpView() { )) )} -
-
+
+ {/* Right panel — markdown reader */} -
+
{contentLoading ? (
+ {label} {loading ? ( diff --git a/src/components/Launchers/LaunchBreakdownDialog.tsx b/src/components/Launchers/LaunchBreakdownDialog.tsx index 5fb6082..134be76 100644 --- a/src/components/Launchers/LaunchBreakdownDialog.tsx +++ b/src/components/Launchers/LaunchBreakdownDialog.tsx @@ -214,7 +214,7 @@ export default function LaunchBreakdownDialog({ Chat
-

+

{selectedTransport === "acp" ? "Structured chat UI with tool calls and permission management" : "Classic terminal session with PTY output"} @@ -279,7 +279,7 @@ export default function LaunchBreakdownDialog({ onChange={(e) => setUserPrompt(e.target.value)} placeholder="How should this epic be broken down?" rows={4} - className="text-[13px]" + className="text-sm" />

diff --git a/src/components/Launchers/LaunchContinuousDialog.tsx b/src/components/Launchers/LaunchContinuousDialog.tsx index 15189eb..0c09c82 100644 --- a/src/components/Launchers/LaunchContinuousDialog.tsx +++ b/src/components/Launchers/LaunchContinuousDialog.tsx @@ -355,14 +355,14 @@ export default function LaunchContinuousDialog({ checked={item.selected} onCheckedChange={() => handleToggleTask(index)} /> - + {item.selected ? selectedIndex : "-"} {item.task.title} {item.task.depends_on.length > 0 && ( (deps: {item.task.depends_on.filter((d) => orderedTasks.some((t) => t.task.id === d)).length}) @@ -371,14 +371,14 @@ export default function LaunchContinuousDialog({ {item.task.agent && item.task.agent !== selectedAgentName && ( {item.task.agent} )} {item.task.priority} @@ -405,7 +405,7 @@ export default function LaunchContinuousDialog({ })}
{selectedTaskIds.length < 2 && ( -

+

Select at least 2 tasks to start continuous mode

)} @@ -432,7 +432,7 @@ export default function LaunchContinuousDialog({ > Independent -
+
All tasks run in parallel, each branching from base
@@ -452,14 +452,14 @@ export default function LaunchContinuousDialog({ > Chained -
+
Each branches from the previous
{dependencyAnalysis.hasDeps && ( -
+
{dependencyAnalysis.reason} @@ -516,7 +516,7 @@ export default function LaunchContinuousDialog({ Chat
-

+

{selectedTransport === "acp" ? "Structured chat UI with tool calls and permission management" : "Classic terminal session with PTY output"} @@ -534,7 +534,7 @@ export default function LaunchContinuousDialog({ onSelect={handleAgentSelect} accentColor={accentColor} /> -

+

Tasks with their own agent set will use that agent instead of the one selected here.

diff --git a/src/components/Launchers/LaunchResearchDialog.tsx b/src/components/Launchers/LaunchResearchDialog.tsx index 600be6a..52f966d 100644 --- a/src/components/Launchers/LaunchResearchDialog.tsx +++ b/src/components/Launchers/LaunchResearchDialog.tsx @@ -214,7 +214,7 @@ export default function LaunchResearchDialog({ Chat
-

+

{selectedTransport === "acp" ? "Structured chat UI with tool calls and permission management" : "Classic terminal session with PTY output"} @@ -279,7 +279,7 @@ export default function LaunchResearchDialog({ onChange={(e) => setUserPrompt(e.target.value)} placeholder="What would you like to research about this task?" rows={4} - className="text-[13px]" + className="text-sm" />

diff --git a/src/components/Launchers/LaunchSessionDialog.tsx b/src/components/Launchers/LaunchSessionDialog.tsx index 1f81513..fd27136 100644 --- a/src/components/Launchers/LaunchSessionDialog.tsx +++ b/src/components/Launchers/LaunchSessionDialog.tsx @@ -7,7 +7,7 @@ import type { SessionTransport } from "../../types"; import { useProjectAccentColor } from "../../hooks/useProjectAccentColor"; import { formatErrorWithHint } from "../../lib/errorMessages"; import { useAppStore } from "../../store/appStore"; -import { Checkbox } from "../ui/checkbox"; +import { ToggleRow } from "../Settings/shared"; import { Dialog, DialogClose, @@ -185,7 +185,7 @@ export default function LaunchSessionDialog({ Chat
-

+

{selectedTransport === "acp" ? "Structured chat UI with tool calls and permission management" : "Classic terminal session with PTY output"} @@ -241,18 +241,12 @@ export default function LaunchSessionDialog({ )} {/* Worktree toggle */} -

- -

- Isolates work in a separate git worktree with its own branch -

-
+ {/* Base branch (only when worktree enabled) */} {createWorktree && ( @@ -268,7 +262,7 @@ export default function LaunchSessionDialog({ onChange={setSelectedBranch} triggerVariant="select" /> -

+

The worktree branch will be created from this branch

@@ -284,7 +278,7 @@ export default function LaunchSessionDialog({ onChange={(e) => setUserPrompt(e.target.value)} placeholder="What should the agent work on..." rows={3} - className="text-[13px]" + className="text-sm" />
diff --git a/src/components/Launchers/LaunchTaskDialog.tsx b/src/components/Launchers/LaunchTaskDialog.tsx index 928ebe4..5b9e957 100644 --- a/src/components/Launchers/LaunchTaskDialog.tsx +++ b/src/components/Launchers/LaunchTaskDialog.tsx @@ -8,7 +8,7 @@ import { useProjectAccentColor } from "../../hooks/useProjectAccentColor"; import { formatErrorWithHint } from "../../lib/errorMessages"; import { useAppStore } from "../../store/appStore"; -import { Checkbox } from "../ui/checkbox"; +import { ToggleRow } from "../Settings/shared"; import { Dialog, DialogClose, @@ -223,7 +223,7 @@ export default function LaunchTaskDialog({ Chat
-

+

{selectedTransport === "acp" ? "Structured chat UI with tool calls and permission management" : "Classic terminal session with PTY output"} @@ -279,18 +279,12 @@ export default function LaunchTaskDialog({ )} {/* Worktree toggle */} -

- -

- Isolates work in a separate git worktree with its own branch -

-
+ {/* Base branch (only when worktree enabled) */} {createWorktree && ( @@ -306,7 +300,7 @@ export default function LaunchTaskDialog({ onChange={setBaseBranch} triggerVariant="select" /> -

+

The worktree branch will be created from this branch

@@ -322,7 +316,7 @@ export default function LaunchTaskDialog({ onChange={(e) => setUserPrompt(e.target.value)} placeholder="Instructions for the agent..." rows={4} - className="text-[13px]" + className="text-sm" />
diff --git a/src/components/Review/CreatePRDialog.tsx b/src/components/Review/CreatePRDialog.tsx index faedd0f..13ba33e 100644 --- a/src/components/Review/CreatePRDialog.tsx +++ b/src/components/Review/CreatePRDialog.tsx @@ -163,7 +163,7 @@ export default function CreatePRDialog({ {stage === "done" && result ? ( /* Success state */
-
+

PR #{result.number} created

diff --git a/src/components/Review/DiffToolbar.tsx b/src/components/Review/DiffToolbar.tsx index 05f2c8e..c3ae7cd 100644 --- a/src/components/Review/DiffToolbar.tsx +++ b/src/components/Review/DiffToolbar.tsx @@ -228,7 +228,7 @@ export default function DiffToolbar({ onClick={onDelete} hoverEffect="scale" clickEffect="scale" - className="text-destructive hover:bg-[color-mix(in_oklch,var(--destructive)_10%,transparent)]" + className="text-destructive hover:bg-destructive/10" leftIcon={} title="Delete worktree" > diff --git a/src/components/Review/DiffView.tsx b/src/components/Review/DiffView.tsx index a1d66eb..f5bacaa 100644 --- a/src/components/Review/DiffView.tsx +++ b/src/components/Review/DiffView.tsx @@ -111,16 +111,18 @@ export default function DiffView({ // Feedback banner const feedbackBanner = feedback && (
{feedback.text} diff --git a/src/components/Review/FileList.tsx b/src/components/Review/FileList.tsx index 257afe0..d61f544 100644 --- a/src/components/Review/FileList.tsx +++ b/src/components/Review/FileList.tsx @@ -12,11 +12,11 @@ import { Loader2, } from "lucide-react"; import { useCallback, useState } from "react"; +import { useProjectAccentColor } from "../../hooks/useProjectAccentColor"; -import { useTheme } from "../../contexts/ThemeContext"; import { Checkbox } from "../ui/checkbox"; import { Button } from "../ui/orecus.io/components/enhanced-button"; -import { glassStyles } from "../ui/orecus.io/lib/color-utils"; +import SidePanel from "../ui/SidePanel"; import type { ChangedFile } from "../../types"; import type { FileSection } from "./useDiffData"; @@ -111,9 +111,9 @@ function FileRow({ onClick={onSelect} className="flex min-w-0 flex-1 items-baseline gap-1 truncate text-left" > - {fileName} + {fileName} {dirPath && ( - + {dirPath} )} @@ -121,7 +121,7 @@ function FileRow({ {/* Status badge */} (null); @@ -197,11 +197,9 @@ export default function FileList({ const hasChanges = changedFiles.length > 0; return ( -
+ {/* Scrollable file sections */} -
+ {/* Committed section */} {hasCommitted && ( <> @@ -215,10 +213,10 @@ export default function FileList({ )} - + Committed - + {committedFiles.length}
@@ -260,10 +258,10 @@ export default function FileList({ className="size-3.5" /> )} - + Changes - + {someStaged && `${stagedFiles.length}/`} {changedFiles.length} @@ -271,8 +269,9 @@ export default function FileList({ {changesExpanded && (
{!hasChanges ? ( -
- No uncommitted changes +
+ + No uncommitted changes
) : ( changedFiles.map((file) => ( @@ -291,10 +290,10 @@ export default function FileList({ )}
)} -
+ {/* Commit bar */} -
+
{commitError && ( -

{commitError}

+

{commitError}

)} -
-
+ + ); } diff --git a/src/components/Review/MergeBranchDialog.tsx b/src/components/Review/MergeBranchDialog.tsx index 445cbee..3fd3371 100644 --- a/src/components/Review/MergeBranchDialog.tsx +++ b/src/components/Review/MergeBranchDialog.tsx @@ -117,7 +117,7 @@ export default function MergeBranchDialog({
- + into
@@ -159,7 +159,7 @@ export default function MergeBranchDialog({
{/* Info note */} -

+

This is a local merge operation. The worktree branch will be merged into the selected target branch in your main repository.

diff --git a/src/components/Review/ReviewPanel.tsx b/src/components/Review/ReviewPanel.tsx index 6418c33..a8a4b60 100644 --- a/src/components/Review/ReviewPanel.tsx +++ b/src/components/Review/ReviewPanel.tsx @@ -55,7 +55,7 @@ export default function ReviewPanel({ if (!rawDiff) { return (
- +

Select a file to view its diff

diff --git a/src/components/Sessions/QuickActionBar.tsx b/src/components/Sessions/QuickActionBar.tsx index 83a47ab..e679713 100644 --- a/src/components/Sessions/QuickActionBar.tsx +++ b/src/components/Sessions/QuickActionBar.tsx @@ -67,7 +67,7 @@ export default React.memo(function QuickActionBar({ if (!isActive || !isAgent || actions.length === 0) return null; return ( -
+
{actions.map((action) => { const Icon = getIcon(action.icon); return ( @@ -81,7 +81,7 @@ export default React.memo(function QuickActionBar({ className="flex items-center gap-1.5 px-2 py-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent/60 transition-colors duration-150 cursor-pointer" > - + {action.label} diff --git a/src/components/Sessions/ResearchCompleteBar.tsx b/src/components/Sessions/ResearchCompleteBar.tsx index ca173b5..8dc2b10 100644 --- a/src/components/Sessions/ResearchCompleteBar.tsx +++ b/src/components/Sessions/ResearchCompleteBar.tsx @@ -122,11 +122,11 @@ export default React.memo(function ResearchCompleteBar({ Research complete {mcpSummary ? ( -

+

{mcpSummary}

) : task ? ( -

+

{task.title}

) : null} @@ -143,7 +143,7 @@ export default React.memo(function ResearchCompleteBar({ leftIcon={} hoverEffect="scale-glow" clickEffect="scale" - className="h-7 text-[12px]" + className="h-7 text-xs" > Continue to Implementation @@ -152,7 +152,7 @@ export default React.memo(function ResearchCompleteBar({ size="sm" onClick={handleCloseSession} leftIcon={} - className="h-7 text-[12px] text-muted-foreground" + className="h-7 text-xs text-muted-foreground" title="Close session" > Close diff --git a/src/components/Sessions/SessionDragOverlay.tsx b/src/components/Sessions/SessionDragOverlay.tsx index 1043cd1..aeb1cdb 100644 --- a/src/components/Sessions/SessionDragOverlay.tsx +++ b/src/components/Sessions/SessionDragOverlay.tsx @@ -11,7 +11,7 @@ export default function SessionDragOverlay({ session }: { session: Session }) {
{MODE_LABEL[session.mode] ?? "?"} diff --git a/src/components/Sessions/SessionGrid.tsx b/src/components/Sessions/SessionGrid.tsx index 932c236..f8a0ed4 100644 --- a/src/components/Sessions/SessionGrid.tsx +++ b/src/components/Sessions/SessionGrid.tsx @@ -282,12 +282,18 @@ const SessionGrid = memo(function SessionGrid({
handleColumnResize(c, e)} - className="cursor-col-resize bg-transparent z-5" + className="group cursor-col-resize z-5 relative" style={{ gridColumn, gridRow: `1 / -1`, }} - />, + > +
+
+
+
+
+
, ); } @@ -299,12 +305,18 @@ const SessionGrid = memo(function SessionGrid({
handleRowResize(r, e)} - className="cursor-row-resize bg-transparent z-5" + className="group cursor-row-resize z-5 relative" style={{ gridColumn: `1 / -1`, gridRow, }} - />, + > +
+
+
+
+
+
, ); } } diff --git a/src/components/Sessions/SessionPane.tsx b/src/components/Sessions/SessionPane.tsx index 786b699..85c3f8a 100644 --- a/src/components/Sessions/SessionPane.tsx +++ b/src/components/Sessions/SessionPane.tsx @@ -1,6 +1,6 @@ import { useDraggable, useDroppable } from "@dnd-kit/core"; import { invoke } from "@tauri-apps/api/core"; -import { ChevronLeft, ChevronRight, Pencil, RotateCcw, X } from "lucide-react"; +import { Pencil, RotateCcw, X } from "lucide-react"; import React, { useCallback, useEffect, useRef, useState } from "react"; import { useTheme } from "../../contexts/ThemeContext"; @@ -42,10 +42,6 @@ interface SessionPaneProps { onDismiss: (sessionId: string) => void; onStop: (sessionId: string) => void; onRelaunch: (sessionId: string) => void; - index: number; - totalCount: number; - onMoveLeft: (sessionId: string) => void; - onMoveRight: (sessionId: string) => void; dragDisabled?: boolean; } @@ -57,10 +53,6 @@ export default React.memo(function SessionPane({ onDismiss, onStop, onRelaunch, - index, - totalCount, - onMoveLeft, - onMoveRight, dragDisabled, }: SessionPaneProps) { const { isGlass } = useTheme(); @@ -82,6 +74,7 @@ export default React.memo(function SessionPane({ const [isEditing, setIsEditing] = useState(false); const [editValue, setEditValue] = useState(""); + const [showSaved, setShowSaved] = useState(false); const inputRef = useRef(null); const { @@ -129,6 +122,9 @@ export default React.memo(function SessionPane({ projSessions.map((s) => (s.id === updated.id ? updated : s)), ); } + // Flash "Saved" indicator + setShowSaved(true); + setTimeout(() => setShowSaved(false), 1500); } catch { // Ignore rename errors } @@ -150,7 +146,6 @@ export default React.memo(function SessionPane({ }, [isEditing]); const displayName = session.name || session.agent; - const showArrows = totalCount > 1; return (
e.stopPropagation()} placeholder={session.agent} - className="text-xs bg-transparent border border-border rounded px-1 py-0 text-foreground outline-none focus:border-primary min-w-0 w-24" + className="text-xs bg-transparent border border-border rounded px-1 py-0 text-foreground outline-none focus:border-primary min-w-0 w-auto min-w-24 max-w-48" /> ) : ( - - {displayName} + + + {displayName} + + {showSaved && ( + + Saved + + )} )} @@ -233,7 +235,7 @@ export default React.memo(function SessionPane({ startEditing(); }} title="Rename session" - className="text-muted-foreground opacity-0 group-hover/header:opacity-100 hover:!opacity-100 shrink-0" + className="text-muted-foreground opacity-30 group-hover/header:opacity-100 group-focus-within/header:opacity-100 hover:!opacity-100 shrink-0" > @@ -241,14 +243,14 @@ export default React.memo(function SessionPane({ {/* MCP Progress + Status Message */} {mcpData?.current_step != null && mcpData.total_steps != null && ( - + Step {mcpData.current_step}/{mcpData.total_steps} {mcpData.description ? `: ${mcpData.description}` : ""} {mcpData.message ? ` — ${mcpData.message}` : ""} )} {mcpData && mcpData.current_step == null && mcpData.message && !isMcpError && !isMcpWaiting && ( - + {mcpData.message} )} @@ -258,7 +260,7 @@ export default React.memo(function SessionPane({ {/* Permission badge (when ACP permission requests are pending) */} {showPermissionState && ( - + Approval needed )} @@ -287,57 +289,17 @@ export default React.memo(function SessionPane({ }} /> {showErrorState && mcpData?.error_message && ( - + {mcpData.error_message} )} {showWaitingState && mcpData?.waiting_question && ( - + {mcpData.waiting_question} )} - {/* Arrow reorder buttons */} - {showArrows && ( - <> - - - - )} - {/* Action buttons */}
+ + {/* Session mode hints */} +
+
+ + Vibe + Freeform coding directly on the current branch +
+
+ + Task + Started from a task in the dashboard with its own worktree +
+
+ + Research + Started from a task to explore and plan without changes +
+
); diff --git a/src/components/Sessions/SessionsToolbar.tsx b/src/components/Sessions/SessionsToolbar.tsx index 766884a..d379e66 100644 --- a/src/components/Sessions/SessionsToolbar.tsx +++ b/src/components/Sessions/SessionsToolbar.tsx @@ -23,7 +23,6 @@ import type { GridLayoutState } from "../../store/appStore"; interface SessionsToolbarProps { layout: GridLayoutState; onLayoutChange: (update: Partial) => void; - activeProjectId: string | null; onNewSession: () => void; } @@ -64,7 +63,7 @@ const SessionsToolbar = memo(function SessionsToolbar({ return ( - + Sessions @@ -85,6 +84,7 @@ const SessionsToolbar = memo(function SessionsToolbar({ align="start" barRadius="md" tabRadius="md" + className="p-0" > {MODES.map((m) => { const Icon = m.icon; @@ -103,7 +103,7 @@ const SessionsToolbar = memo(function SessionsToolbar({ )}
+

+ Rules with patterns (e.g. src/**) + take priority over capability-wide rules. First match wins. +

{/* Existing rules */} {rules.length > 0 ? ( @@ -321,19 +299,19 @@ export function AcpPermissionsTab() { >
- + {capCfg?.label ?? rule.capability} - + {pattern || "*"} - + {actionCfg.label}
@@ -347,20 +325,25 @@ export function AcpPermissionsTab() {

)} - {/* Add rule form — two-row grid: labels on top, controls on bottom */} + {/* Add rule form */}
{/* Row 1: labels */} - Capability - - Path pattern (optional) + Capability + + {patternConfig.label} (optional) - Action + Action - {/* Row 2: controls — all h-8 */} + {/* Row 2: controls */} v && updateDefaultPolicy(v)} + items={[ + { value: "ask", label: "Ask \u2014 prompt for approval (safest)" }, + { value: "auto_approve", label: "Auto-Approve \u2014 allow without prompt" }, + { value: "deny", label: "Deny \u2014 block without prompt" }, + ]} + > + + + + + Ask — prompt for approval (safest) + Auto-Approve — allow without prompt + Deny — block without prompt + + + + + {/* ── Autonomous Sessions (Trust Mode) ── */} +
+

Autonomous Sessions

+

+ Override policy for sessions launched automatically by continuous mode. + This takes priority over rules and the default policy. +

+ + {/* Description for selected option */} +

+ {TRUST_MODE_OPTIONS.find((o) => o.value === trustModePolicy)?.description} +

+
+ + {/* ── Permission Timeout ── */} +
+

Prompt Timeout

+

+ When a permission dialog appears, how long to wait for your response before auto-denying. +

+
+ updatePermissionTimeout(parseInt(e.target.value, 10) || 120)} + className={`${inputClass} w-24 h-8`} + /> + seconds +
+
+ {/* ── Recent Permission Log ── */}

Recent Decisions

{log.length > 0 ? ( -
- {log.slice(0, 5).map((entry) => { +
+ {log.map((entry) => { const isApproved = entry.decision === "approved" || entry.decision === "auto_approved"; const isAuto = entry.decision === "auto_approved" || entry.decision === "auto_denied"; + const capCfg = CAPABILITIES.find((c) => c.value === entry.capability); return (
- - {entry.capability} - - - {entry.detail || "—"} + + {capCfg?.label ?? entry.capability} - + + {entry.detail || "\u2014"} + + {isAuto ? "auto-" : ""} {isApproved ? "approved" : "denied"} @@ -455,7 +514,7 @@ export function AcpPermissionsTab() {
) : (

- No permission decisions recorded yet. + No permission decisions recorded yet. Decisions will appear here once an ACP session runs.

)}
diff --git a/src/components/Settings/AgentsTab.tsx b/src/components/Settings/AgentsTab.tsx index 81c5cc5..9e4112f 100644 --- a/src/components/Settings/AgentsTab.tsx +++ b/src/components/Settings/AgentsTab.tsx @@ -4,13 +4,11 @@ import { useCallback, useEffect, useState } from "react"; import { AgentIcon } from "../../lib/agentIcons"; import { Badge } from "../ui/badge"; -import { Checkbox } from "../ui/checkbox"; import { InputGroup, InputGroupAddon, InputGroupInput, } from "../ui/input-group"; -import { Card, CardContent } from "../ui/orecus.io/cards/card"; import { Button } from "../ui/orecus.io/components/enhanced-button"; import { type ThemeColor, @@ -23,7 +21,7 @@ import { SelectTrigger, SelectValue, } from "../ui/select"; -import { sectionHeadingClass } from "./shared"; +import { sectionHeadingClass, ToggleRow } from "./shared"; import type { AgentInfo } from "../../types"; @@ -37,19 +35,19 @@ const PERMISSION_FLAGS: Record< flag: "--dangerously-skip-permissions", label: "Skip Permission Prompts", description: - "Adds --dangerously-skip-permissions flag. The CLI will not ask for confirmation before running commands.", + "The CLI will not ask for confirmation before running commands.", }, codex: { flag: "--dangerously-bypass-approvals-and-sandbox", label: "Bypass Approvals & Sandbox", description: - "Adds --dangerously-bypass-approvals-and-sandbox flag. The CLI will execute all actions without confirmation or sandboxing.", + "The CLI will execute all actions without confirmation or sandboxing.", }, gemini: { flag: "--yolo", label: "YOLO Mode", description: - "Adds --yolo flag. The CLI will execute all actions without confirmation.", + "The CLI will execute all actions without confirmation.", }, }; @@ -114,7 +112,6 @@ function AgentCard({ agent }: { agent: AgentInfo }) { if (permFlag && config.flags.includes(permFlag)) { setSkipPerms(true); } - // Custom flags = all flags except the known permission flag const custom = config.flags.filter((f) => f !== permFlag); if (custom.length > 0) setCustomFlags(custom.join(" ")); } @@ -160,76 +157,69 @@ function AgentCard({ agent }: { agent: AgentInfo }) { if (!loaded) return null; return ( - - {/* Card header */} - + {/* Header */} + {/* CLI install hint — shown when agent is NOT installed */} {!agent.installed && agent.cli_install_hint && ( -
-
+
+
- + {agent.cli_install_hint} {agent.cli_install_url && ( @@ -247,49 +237,40 @@ function AgentCard({ agent }: { agent: AgentInfo }) {
)} - {/* Card body — only shown when expanded */} + {/* Expanded body */} {expanded && agent.installed && ( -
- {/* Permissions section */} +
+ {/* Permissions toggle */} {permInfo && ( -
-
- +
+
+ Permissions Security
- -
+ +
)} - {/* Custom flags section */} -
-
Custom Flags
+ {/* Custom flags */} +
+ + Custom Flags + - + -
- Additional flags to pass to the {agent.display_name} CLI. Separate - multiple flags with spaces. -
-
- - {/* Command preview section */} -
-
- Command Preview -
- - - - - - -
- This is the base command used when launching a new session. The - session prompt and project-specific overrides will be appended - automatically. +
+ Additional flags appended to every {agent.display_name} session.
-
+
- {/* Reset button */} -
+ {/* Command preview + reset */} +
+ + + {commandPreview} +
)} - +
); } @@ -355,7 +320,6 @@ export function AgentsTab({ agents }: { agents: AgentInfo[] }) { if (v) { setDefaultAgent(v); } else if (firstInstalled) { - // No persisted setting — auto-select first detected CLI and persist it setDefaultAgent(firstInstalled); invoke("set_setting", { key: "default_agent", @@ -408,7 +372,7 @@ export function AgentsTab({ agents }: { agents: AgentInfo[] }) { {/* Agent cards */}
Agent Configuration
-
+
{agents.map((agent) => ( ))} diff --git a/src/components/Settings/GeneralTab.tsx b/src/components/Settings/GeneralTab.tsx index 1e3f306..0ca6ed7 100644 --- a/src/components/Settings/GeneralTab.tsx +++ b/src/components/Settings/GeneralTab.tsx @@ -15,10 +15,16 @@ import { type Theme, useTheme } from "../../contexts/ThemeContext"; import { usePersistedBoolean } from "../../hooks/usePersistedState"; import { updateNotificationSettings } from "../../lib/notifications"; import { useUpdateStore } from "../../store/updateStore"; -import { Checkbox } from "../ui/checkbox"; import { Card, CardContent } from "../ui/orecus.io/cards/card"; import { Tabs } from "../ui/orecus.io/navigation/tabs"; -import { sectionHeadingClass, inputClass } from "./shared"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../ui/select"; +import { sectionHeadingClass, inputClass, ToggleRow } from "./shared"; type GeneralTabId = "appearance" | "notifications" | "updates" | "system"; @@ -125,7 +131,7 @@ function AppearancePanel() { className="w-full h-8 rounded-[var(--radius-element)] border border-border" style={{ background: m.gradient }} /> -
+
{m.label}
{active && ( @@ -138,43 +144,24 @@ function AppearancePanel() {
{/* Glass effect toggle */} - +
{/* Display */}
Display
- +
); @@ -221,48 +208,27 @@ function NotificationsPanel() { {/* Master toggle */}
Notifications
- +
{/* Per-event toggles */}
Event Types
-
+
{toggles.map((t) => ( - + label={t.label} + description={t.description} + checked={t.value} + onChange={t.setter} + disabled={!enabled} + /> ))}
@@ -279,22 +245,12 @@ function AcpAutoCheckToggle() { ); return ( - + ); } @@ -340,17 +296,17 @@ function UpdatesPanel() { return (
{/* Version + Check button */} -
+
-
+
Current version
-
+
{appVersion || "..."}
- + {formatLastChecked(lastCheckedAt)} {advancedOpen && ( -
-
+
+
Custom update endpoint URL
@@ -434,7 +389,7 @@ function UpdatesPanel() { /> @@ -458,12 +413,12 @@ function SystemPanel() { return (
-
+
-
+
Log files
-
+
Open the folder containing backend log files for debugging.
diff --git a/src/components/Settings/GitHubTab.tsx b/src/components/Settings/GitHubTab.tsx index 3212f3e..6406f2b 100644 --- a/src/components/Settings/GitHubTab.tsx +++ b/src/components/Settings/GitHubTab.tsx @@ -25,57 +25,10 @@ import { SelectValue, } from "../ui/select"; import { Separator } from "../ui/separator"; -import { sectionHeadingClass } from "./shared"; +import { sectionHeadingClass, ToggleRow } from "./shared"; import type { GhAuthStatus, GitHubLabelFull, GitHubLabelMapping, TaskStatus } from "../../types"; -// ── Toggle Row ── - -function ToggleRow({ - label, - description, - checked, - onChange, - disabled, -}: { - label: string; - description?: string; - checked: boolean; - onChange: (checked: boolean) => void; - disabled?: boolean; -}) { - return ( - - ); -} - // ── Auth Status Card ── function AuthStatusCard({ @@ -95,10 +48,10 @@ function AuthStatusCard({
-
+
Checking authentication...
-
+
Verifying GitHub CLI status
@@ -117,10 +70,10 @@ function AuthStatusCard({
{isOk ? ( @@ -132,7 +85,7 @@ function AuthStatusCard({ )}
-
+
{isOk ? "Authenticated" : hasWarning @@ -141,7 +94,7 @@ function AuthStatusCard({ ? "GitHub CLI not installed" : "Not authenticated"}
-
+
{isOk && authStatus.username && ( @@ -161,7 +114,7 @@ function AuthStatusCard({ {!authStatus.installed && ( Install the{" "} - + gh {" "} CLI to enable GitHub features @@ -170,7 +123,7 @@ function AuthStatusCard({ {authStatus.installed && !authStatus.authenticated && ( Run{" "} - + gh auth login {" "} to authenticate @@ -274,7 +227,7 @@ function LabelMappingTable({ {repoLabels.length > 0 ? "Refresh Labels" : "Fetch Labels"} {repoLabels.length > 0 && ( - + {repoLabels.length} labels available )} @@ -307,7 +260,7 @@ function LabelMappingTable({ return (
- + {status}
@@ -356,7 +309,7 @@ function LabelMappingTable({ )} {repoLabels.length === 0 && ( -
+
Click "Fetch Labels" to load available labels from the repository, or "Create default labels" to set up a standard label set. @@ -574,10 +527,10 @@ export function GitHubTab() {
{/* Project context */}
- + Settings for - + {activeProject.name}
@@ -671,7 +624,7 @@ export function GitHubTab() { Manual Sync Defaults
-

+

Pre-checked options when opening the Sync to GitHub dialog.

@@ -743,7 +696,7 @@ export function GitHubTab() { )} {!syncEnabled && ( -
+
Enable GitHub Sync above to configure label mapping.
)} diff --git a/src/components/Settings/GitWorktreesTab.tsx b/src/components/Settings/GitWorktreesTab.tsx new file mode 100644 index 0000000..aeabf78 --- /dev/null +++ b/src/components/Settings/GitWorktreesTab.tsx @@ -0,0 +1,186 @@ +import { invoke } from "@tauri-apps/api/core"; +import { FileText, FolderCode, GitBranch } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; + +import { formatError } from "../../lib/errorMessages"; +import { useAppStore } from "../../store/appStore"; +import { + InputGroup, + InputGroupAddon, + InputGroupInput, +} from "../ui/input-group"; +import { sectionHeadingClass, ToggleRow } from "./shared"; + +import type { Project } from "../../types"; + +// ── Git & Worktrees Tab ── + +export function GitWorktreesTab() { + const activeProjectId = useAppStore((s) => s.activeProjectId); + const project = useAppStore( + (s) => s.projects.find((p) => p.id === activeProjectId), + ); + const updateProjectInStore = useAppStore((s) => s.updateProject); + + const [branchPattern, setBranchPattern] = useState( + project?.branch_naming_pattern ?? "feat/{{task_id}}-{{task_slug}}", + ); + const [instructionFile, setInstructionFile] = useState( + (project?.instruction_file_path ?? "").replace(/\\/g, "/"), + ); + const [worktreeAutoCleanup, setWorktreeAutoCleanup] = useState(false); + + // Sync local state when project changes + useEffect(() => { + setBranchPattern( + project?.branch_naming_pattern ?? "feat/{{task_id}}-{{task_slug}}", + ); + setInstructionFile( + (project?.instruction_file_path ?? "").replace(/\\/g, "/"), + ); + }, [ + project?.id, + project?.branch_naming_pattern, + project?.instruction_file_path, + ]); + + // Load per-project settings + useEffect(() => { + if (!activeProjectId) return; + invoke("get_project_setting", { + projectId: activeProjectId, + key: "worktree_auto_cleanup", + }) + .then((val) => setWorktreeAutoCleanup(val === "true")) + .catch(() => {}); + }, [activeProjectId]); + + const handleUpdate = useCallback( + async (updates: Record) => { + if (!activeProjectId) return; + try { + const result = await invoke("update_project", { + id: activeProjectId, + ...updates, + }); + updateProjectInStore(result); + } catch (e) { + console.error("Failed to update project:", e); + useAppStore + .getState() + .flashError(`Failed to update project: ${formatError(e)}`); + } + }, + [activeProjectId, updateProjectInStore], + ); + + const handleBranchPatternBlur = useCallback(() => { + handleUpdate({ + branchNamingPattern: branchPattern ? branchPattern : null, + }); + }, [branchPattern, handleUpdate]); + + const handleInstructionFileBlur = useCallback(() => { + const normalized = instructionFile.replace(/\\/g, "/"); + handleUpdate({ + instructionFilePath: normalized ? normalized : null, + }); + }, [instructionFile, handleUpdate]); + + const handleWorktreeAutoCleanupChange = useCallback( + (value: boolean) => { + setWorktreeAutoCleanup(value); + invoke("set_project_setting", { + projectId: activeProjectId, + key: "worktree_auto_cleanup", + value: value ? "true" : "false", + }).catch(() => {}); + }, + [activeProjectId], + ); + + if (!activeProjectId || !project) { + return ( +
+ +

+ No project selected +

+

+ Open a project to configure git and worktree settings. +

+
+ ); + } + + const panelClass = + "rounded-lg bg-muted/20 ring-1 ring-border/30 p-4 flex flex-col gap-4"; + + return ( +
+ {/* ── Branch Naming ── */} +
+
Branch Naming
+
+ + Branch pattern + + + + + + setBranchPattern(e.target.value)} + onBlur={handleBranchPatternBlur} + placeholder="feat/{{task_id}}-{{task_slug}}" + /> + + + Template for branch names when creating worktrees. Variables:{" "} + {"{{task_id}}"}, {"{{task_slug}}"} + +
+
+ + {/* ── Session Configuration ── */} +
+
Session Configuration
+
+ + Instruction file + + + + + + + setInstructionFile(e.target.value.replace(/\\/g, "/")) + } + onBlur={handleInstructionFileBlur} + placeholder="CLAUDE.md (auto-detected)" + /> + + + Relative path from project root. Injected into agent session prompts. + +
+
+ + {/* ── Worktree Management ── */} +
+
Worktree Management
+ +
+
+ ); +} diff --git a/src/components/Settings/ProjectSettingsDialog.tsx b/src/components/Settings/ProjectSettingsDialog.tsx index 40b207f..4513efd 100644 --- a/src/components/Settings/ProjectSettingsDialog.tsx +++ b/src/components/Settings/ProjectSettingsDialog.tsx @@ -120,9 +120,9 @@ function ToggleRow({ className={`flex items-center justify-between gap-3 py-1.5 ${disabled ? "opacity-40 pointer-events-none" : "cursor-pointer"}`} >
- {label} + {label} {description && ( - + {description} )} @@ -148,7 +148,7 @@ function ToggleRow({ } const sectionHeadingClass = - "text-[11px] font-medium uppercase tracking-wider text-muted-foreground"; + "text-xs font-medium uppercase tracking-wider text-muted-foreground"; // ── Project Settings Dialog ── @@ -434,7 +434,7 @@ export function ProjectSettingsDialog({
{/* Icon */}
- + Icon
@@ -451,7 +451,7 @@ export function ProjectSettingsDialog({ size="sm" onClick={handlePickIcon} leftIcon={} - className="h-6 px-2 text-[11px]" + className="h-6 px-2 text-xs" > Choose SVG @@ -461,13 +461,13 @@ export function ProjectSettingsDialog({ size="sm" onClick={handleClearIcon} leftIcon={} - className="h-6 px-1.5 text-[11px]" + className="h-6 px-1.5 text-xs" > Reset )}
- + {project.icon_path ? project.icon_path.split(/[\\/]/).pop() : "Auto-detected from project"} @@ -478,7 +478,7 @@ export function ProjectSettingsDialog({ {/* Color */}
- + Color
@@ -514,7 +514,7 @@ export function ProjectSettingsDialog({ {/* Agent + Model row */}
- + Agent - + Transport
@@ -610,7 +610,7 @@ export function ProjectSettingsDialog({ Chat
- + Pre-selects the transport mode when launching new sessions
@@ -623,7 +623,7 @@ export function ProjectSettingsDialog({ {/* Branch + Instruction in 2-col */}
- + Branch pattern @@ -638,13 +638,13 @@ export function ProjectSettingsDialog({ placeholder="feat/{{task_id}}-{{task_slug}}" /> - + Variables: {"{{task_id}}"}, {"{{task_slug}}"}
- + Instruction file @@ -661,7 +661,7 @@ export function ProjectSettingsDialog({ placeholder="CLAUDE.md (auto-detected)" /> - + Relative path from project root
@@ -693,7 +693,7 @@ export function ProjectSettingsDialog({
- + Define priority levels for this project. ID is stored in task files, label is shown in the UI.
@@ -800,10 +800,10 @@ export function ProjectSettingsDialog({ {/* ── Danger Zone ── */}
-
+
Delete Project
-
+
Remove from Faber. Files on disk are not affected.
diff --git a/src/components/Settings/ProjectTab.tsx b/src/components/Settings/ProjectTab.tsx new file mode 100644 index 0000000..d94e100 --- /dev/null +++ b/src/components/Settings/ProjectTab.tsx @@ -0,0 +1,693 @@ +import { invoke } from "@tauri-apps/api/core"; +import { open } from "@tauri-apps/plugin-dialog"; +import { formatError } from "../../lib/errorMessages"; +import { + Bot, + Cpu, + FolderCode, + Flag, + Image, + MessageSquare, + Plus, + Terminal, + Trash2, + X, +} from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; + +import { clearIconCache, useProjectIcon } from "../../hooks/useProjectIcon"; +import { useAppStore } from "../../store/appStore"; +import { Button } from "../ui/orecus.io/components/enhanced-button"; +import { + colorStyles, + gradientHexColors, + solidColorGradients, +} from "../ui/orecus.io/lib/color-utils"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../ui/select"; +import { TaskFileConflictDialog } from "./TaskFileConflictDialog"; +import { ToggleRow, sectionHeadingClass } from "./shared"; + +import type { PriorityLevel, Project, SessionTransport, TaskConflict } from "../../types"; +import type { ThemeColor } from "../ui/orecus.io/lib/color-utils"; +import { DEFAULT_PRIORITIES, PRIORITY_COLORS } from "../../lib/priorities"; +import { Input } from "../ui/input"; + +const TAB_COLORS: { value: ThemeColor; label: string }[] = [ + { value: "blue", label: "Blue" }, + { value: "purple", label: "Purple" }, + { value: "violet", label: "Violet" }, + { value: "indigo", label: "Indigo" }, + { value: "cyan", label: "Cyan" }, + { value: "teal", label: "Teal" }, + { value: "green", label: "Green" }, + { value: "emerald", label: "Emerald" }, + { value: "lime", label: "Lime" }, + { value: "yellow", label: "Yellow" }, + { value: "amber", label: "Amber" }, + { value: "orange", label: "Orange" }, + { value: "red", label: "Red" }, + { value: "rose", label: "Rose" }, + { value: "pink", label: "Pink" }, + { value: "fuchsia", label: "Fuchsia" }, +]; + +// ── Project Icon Preview ── + +function ProjectIconPreview({ + project, + accentHex, +}: { + project: Project; + accentHex: string; +}) { + const svgMarkup = useProjectIcon(project.id, project.path, project.icon_path); + + if (svgMarkup) { + return ( + + ); + } + return ( + + ); +} + +// ── Project Tab ── + +export function ProjectTab() { + const activeProjectId = useAppStore((s) => s.activeProjectId); + const project = useAppStore((s) => s.projects.find((p) => p.id === activeProjectId)); + const agents = useAppStore((s) => s.agents); + const updateProjectInStore = useAppStore((s) => s.updateProject); + const removeProjectFromStore = useAppStore((s) => s.removeProject); + + const [agent, setAgent] = useState(project?.default_agent ?? ""); + const [model, setModel] = useState(project?.default_model ?? ""); + const [defaultTransport, setDefaultTransport] = + useState("pty"); + const [taskFilesToDisk, setTaskFilesToDisk] = useState(true); + const [conflictDialogOpen, setConflictDialogOpen] = useState(false); + const [taskConflicts, setTaskConflicts] = useState([]); + const [confirmDelete, setConfirmDelete] = useState(false); + const storePriorities = useAppStore((s) => + activeProjectId ? (s.projectPriorities[activeProjectId] ?? DEFAULT_PRIORITIES) : DEFAULT_PRIORITIES + ); + const [priorities, setPriorities] = useState(storePriorities); + + // Sync local state when project changes + useEffect(() => { + setAgent(project?.default_agent ?? ""); + setModel(project?.default_model ?? ""); + setConfirmDelete(false); + }, [project?.id, project?.default_agent, project?.default_model]); + + // Load per-project settings + useEffect(() => { + if (!activeProjectId) return; + invoke("get_project_setting", { + projectId: activeProjectId, + key: "default_transport", + }) + .then((val) => setDefaultTransport((val as SessionTransport) || "pty")) + .catch(() => {}); + invoke("get_project_setting", { + projectId: activeProjectId, + key: "task_files_to_disk", + }) + .then((val) => setTaskFilesToDisk(val !== "false")) + .catch(() => {}); + }, [activeProjectId]); + + const handleUpdate = useCallback( + async (updates: Record) => { + if (!activeProjectId) return; + try { + const result = await invoke("update_project", { + id: activeProjectId, + ...updates, + }); + updateProjectInStore(result); + } catch (e) { + console.error("Failed to update project:", e); + useAppStore + .getState() + .flashError(`Failed to update project: ${formatError(e)}`); + } + }, + [activeProjectId, updateProjectInStore], + ); + + const handleDelete = useCallback(async () => { + if (!activeProjectId) return; + try { + await invoke("remove_project", { id: activeProjectId }); + removeProjectFromStore(activeProjectId); + } catch (e) { + console.error("Failed to remove project:", e); + useAppStore + .getState() + .flashError(`Failed to remove project: ${formatError(e)}`); + } + }, [activeProjectId, removeProjectFromStore]); + + const handleTransportChange = useCallback( + (value: SessionTransport) => { + setDefaultTransport(value); + invoke("set_project_setting", { + projectId: activeProjectId, + key: "default_transport", + value, + }).catch(() => {}); + }, + [activeProjectId], + ); + + const handleTaskFilesToDiskChange = useCallback( + async (value: boolean) => { + if (value) { + try { + const detected = await invoke( + "detect_task_conflicts", + { projectId: activeProjectId }, + ); + if (detected.length > 0) { + setTaskConflicts(detected); + setConflictDialogOpen(true); + return; + } + } catch (e) { + console.error("Failed to detect conflicts:", e); + } + } + setTaskFilesToDisk(value); + invoke("set_project_setting", { + projectId: activeProjectId, + key: "task_files_to_disk", + value: value ? "true" : "false", + }).catch(() => {}); + }, + [activeProjectId], + ); + + // ── Priority management ── + + const savePriorities = useCallback( + (updated: PriorityLevel[]) => { + setPriorities(updated); + invoke("set_project_setting", { + projectId: activeProjectId, + key: "priorities", + value: JSON.stringify(updated), + }).catch(() => {}); + }, + [activeProjectId], + ); + + const addPriority = useCallback(() => { + const nextOrder = priorities.length > 0 ? Math.max(...priorities.map((p) => p.order)) + 1 : 0; + const id = `P${priorities.length}`; + savePriorities([...priorities, { id, label: "New", color: "gray", order: nextOrder }]); + }, [priorities, savePriorities]); + + const removePriority = useCallback( + (index: number) => { + if (priorities.length <= 1) return; + savePriorities(priorities.filter((_, i) => i !== index)); + }, + [priorities, savePriorities], + ); + + const updatePriority = useCallback( + (index: number, field: keyof PriorityLevel, value: string | number) => { + const updated = priorities.map((p, i) => (i === index ? { ...p, [field]: value } : p)); + savePriorities(updated); + }, + [priorities, savePriorities], + ); + + const movePriority = useCallback( + (index: number, direction: -1 | 1) => { + const targetIndex = index + direction; + if (targetIndex < 0 || targetIndex >= priorities.length) return; + const updated = [...priorities]; + [updated[index], updated[targetIndex]] = [updated[targetIndex], updated[index]]; + const reordered = updated.map((p, i) => ({ ...p, order: i })); + savePriorities(reordered); + }, + [priorities, savePriorities], + ); + + const selectedAgent = agents.find((a) => a.name === agent); + const availableModels = selectedAgent?.supported_models ?? []; + + const handleAgentChange = useCallback( + (value: string) => { + setAgent(value); + setModel(""); + handleUpdate({ + defaultAgent: value ? value : null, + defaultModel: null, + }); + }, + [handleUpdate], + ); + + const handleModelChange = useCallback( + (value: string) => { + setModel(value); + handleUpdate({ defaultModel: value ? value : null }); + }, + [handleUpdate], + ); + + const handlePickIcon = useCallback(async () => { + if (!activeProjectId) return; + try { + const selected = await open({ + multiple: false, + filters: [{ name: "SVG", extensions: ["svg"] }], + }); + if (selected) { + clearIconCache(activeProjectId); + handleUpdate({ iconPath: selected }); + } + } catch { + // User cancelled + } + }, [activeProjectId, handleUpdate]); + + const handleClearIcon = useCallback(() => { + if (!activeProjectId) return; + clearIconCache(activeProjectId); + handleUpdate({ iconPath: null }); + }, [activeProjectId, handleUpdate]); + + if (!activeProjectId || !project) { + return ( +
+ +

+ No project selected +

+

+ Open a project to configure its settings. +

+
+ ); + } + + const themeColor = (project.color as ThemeColor) || "primary"; + const accentHex = + gradientHexColors[themeColor]?.start ?? gradientHexColors.primary.start; + + const panelClass = + "rounded-lg bg-muted/20 ring-1 ring-border/30 p-4 flex flex-col gap-4"; + + return ( +
+ {/* ── Appearance ── */} +
+
Appearance
+
+ {/* Icon */} +
+ + Icon + +
+
+ +
+
+
+ + {project.icon_path && ( + + )} +
+ + {project.icon_path + ? project.icon_path.split(/[\\/]/).pop() + : "Auto-detected from project"} + +
+
+
+ + {/* Color */} +
+ + Color + +
+ {TAB_COLORS.map((c) => { + const hex = gradientHexColors[c.value]; + const isActive = project.color === c.value; + return ( +
+
+
+
+ + {/* ── Agent Defaults ── */} +
+
Agent Defaults
+ + {/* Agent + Model row */} +
+
+ + Agent + + +
+ +
+ + Model + + +
+
+ + {/* Transport */} +
+ + Transport + +
+ + +
+ + Pre-selects the transport mode when launching new sessions + +
+
+ + {/* ── Task Storage ── */} +
+
Task Storage
+ +
+ + {/* ── Priorities ── */} +
+
+
+ + Priorities +
+ +
+ +
+ {priorities.map((p, i) => { + const hex = gradientHexColors[(p.color as ThemeColor) || "gray"] ?? gradientHexColors.gray; + return ( +
+ {/* Reorder buttons */} +
+ + +
+ + {/* Color dot */} + + + {/* ID */} + updatePriority(i, "id", e.target.value)} + placeholder="ID" + /> + + {/* Label */} + updatePriority(i, "label", e.target.value)} + placeholder="Label" + /> + + {/* Color select */} + + + {/* Delete */} + +
+ ); + })} +
+ + + Define priority levels for this project. ID is stored in task files, label is shown in the UI. + +
+ + {/* ── Danger Zone ── */} +
+
+
+ Delete Project +
+
+ Remove from Faber. Files on disk are not affected. +
+
+
+ {confirmDelete && ( + + )} + +
+
+ + {activeProjectId && ( + setConflictDialogOpen(false)} + onResolved={() => { + setTaskFilesToDisk(true); + invoke("set_project_setting", { + projectId: activeProjectId, + key: "task_files_to_disk", + value: "true", + }).catch(() => {}); + setConflictDialogOpen(false); + }} + projectId={activeProjectId} + conflicts={taskConflicts} + /> + )} +
+ ); +} diff --git a/src/components/Settings/ProjectsTab.tsx b/src/components/Settings/ProjectsTab.tsx index b67dead..9d27420 100644 --- a/src/components/Settings/ProjectsTab.tsx +++ b/src/components/Settings/ProjectsTab.tsx @@ -80,10 +80,10 @@ function ProjectRow({ {/* Name + path */}
- + {project.name} - + {project.path}
@@ -94,6 +94,7 @@ function ProjectRow({
-