From f7b6b5b938843007cf40556317259c4119edbdfa Mon Sep 17 00:00:00 2001 From: Martin Karlsson Date: Wed, 25 Mar 2026 19:11:20 +0100 Subject: [PATCH 01/16] feat: improve project creation validation and welcome screen polish Add inline validation to CreateProjectDialog with character checks and debounced directory existence warnings. Show capabilities section only for first-time users on the welcome screen and display agent detection summary. Fix open-project restoration to correctly handle first launch vs returning users. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/Shell/CreateProjectDialog.tsx | 92 +++++++++++++++++-- src/components/Shell/WelcomeScreen.tsx | 95 +++++++++++++------- src/store/appStore.ts | 16 ++-- 3 files changed, 157 insertions(+), 46 deletions(-) diff --git a/src/components/Shell/CreateProjectDialog.tsx b/src/components/Shell/CreateProjectDialog.tsx index 667776f..2fc874a 100644 --- a/src/components/Shell/CreateProjectDialog.tsx +++ b/src/components/Shell/CreateProjectDialog.tsx @@ -1,6 +1,7 @@ import { open } from "@tauri-apps/plugin-dialog"; +import { exists, readDir } from "@tauri-apps/plugin-fs"; import { FolderOpen, Plus, X } from "lucide-react"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAppStore } from "../../store/appStore"; import { @@ -14,6 +15,9 @@ import { import { Input } from "../ui/input"; import { Button } from "../ui/orecus.io/components/enhanced-button"; +const INVALID_CHARS = /[/\\:*?"<>|]/; +const INVALID_CHARS_DISPLAY = '/ \\ : * ? " < > |'; + interface CreateProjectDialogProps { onDismiss: () => void; } @@ -30,7 +34,72 @@ export default function CreateProjectDialog({ const [creating, setCreating] = useState(false); const [error, setError] = useState(null); - const canCreate = name.trim().length > 0 && parentPath.length > 0 && !creating; + // Inline validation state + const [nameError, setNameError] = useState(null); + const [pathWarning, setPathWarning] = useState(null); + + // Validate name on change + const trimmedName = name.trim(); + const hasInvalidChars = useMemo( + () => INVALID_CHARS.test(trimmedName), + [trimmedName], + ); + + useEffect(() => { + if (!trimmedName) { + setNameError(null); + } else if (hasInvalidChars) { + setNameError(`Invalid characters: ${INVALID_CHARS_DISPLAY}`); + } else { + setNameError(null); + } + }, [trimmedName, hasInvalidChars]); + + // Debounced directory existence check + const checkTimerRef = useRef | null>(null); + + useEffect(() => { + if (checkTimerRef.current) clearTimeout(checkTimerRef.current); + setPathWarning(null); + + if (!parentPath || !trimmedName || hasInvalidChars) return; + + const targetPath = `${parentPath}/${trimmedName}`; + checkTimerRef.current = setTimeout(async () => { + try { + const pathExists = await exists(targetPath); + if (!pathExists) { + setPathWarning(null); + return; + } + // Check if it's a non-empty directory + try { + const entries = await readDir(targetPath); + if (entries.length > 0) { + setPathWarning("Directory already exists and is not empty"); + } else { + setPathWarning(null); // Empty dir is OK + } + } catch { + // If readDir fails it might be a file, not a directory + setPathWarning("A file already exists at this path"); + } + } catch { + // exists() failed — ignore, backend will catch it + } + }, 300); + + return () => { + if (checkTimerRef.current) clearTimeout(checkTimerRef.current); + }; + }, [parentPath, trimmedName, hasInvalidChars]); + + const canCreate = + trimmedName.length > 0 && + parentPath.length > 0 && + !creating && + !hasInvalidChars && + !pathWarning; const handlePickLocation = useCallback(async () => { const selected = await open({ @@ -47,7 +116,7 @@ export default function CreateProjectDialog({ setCreating(true); addBackgroundTask("Creating project"); try { - await createProject(parentPath, name.trim()); + await createProject(parentPath, trimmedName); onDismiss(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); @@ -55,7 +124,7 @@ export default function CreateProjectDialog({ setCreating(false); removeBackgroundTask("Creating project"); } - }, [canCreate, parentPath, name, createProject, addBackgroundTask, removeBackgroundTask, onDismiss]); + }, [canCreate, parentPath, trimmedName, createProject, addBackgroundTask, removeBackgroundTask, onDismiss]); const handleNameKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -69,8 +138,8 @@ export default function CreateProjectDialog({ // Build path preview const pathPreview = - parentPath && name.trim() - ? `${parentPath}/${name.trim()}` + parentPath && trimmedName + ? `${parentPath}/${trimmedName}` : null; return ( @@ -99,7 +168,11 @@ export default function CreateProjectDialog({ onKeyDown={handleNameKeyDown} placeholder="my-project" autoFocus + className={nameError ? "border-destructive" : ""} /> + {nameError && ( +

{nameError}

+ )} {/* Location picker */} @@ -130,17 +203,20 @@ export default function CreateProjectDialog({ {/* Path preview */} {pathPreview && ( -
+

Will be created at

{pathPreview}

+ {pathWarning && ( +

{pathWarning}

+ )}
)} - {/* Error */} + {/* Error from backend */} {error &&

{error}

} {/* Actions */} diff --git a/src/components/Shell/WelcomeScreen.tsx b/src/components/Shell/WelcomeScreen.tsx index 2f5a2b4..0580294 100644 --- a/src/components/Shell/WelcomeScreen.tsx +++ b/src/components/Shell/WelcomeScreen.tsx @@ -1,5 +1,6 @@ import { ChevronRight, + CircleCheck, FolderCode, FolderOpen, FolderPlus, @@ -58,6 +59,7 @@ export default function WelcomeScreen() { const projects = useAppStore((s) => s.projects); const openProject = useAppStore((s) => s.openProject); const backgroundTasks = useAppStore((s) => s.backgroundTasks); + const agents = useAppStore((s) => s.agents); const isLoading = useMemo( () => backgroundTasks.some((t) => LOADING_LABELS.includes(t)), @@ -88,6 +90,17 @@ export default function WelcomeScreen() { const hasProjects = projects.length > 0; const showProjects = hasProjects && !isLoading; + const isDetectingAgents = useMemo( + () => backgroundTasks.includes("Detecting agents"), + [backgroundTasks], + ); + const agentSummary = useMemo(() => { + if (isDetectingAgents || agents.length === 0) return null; + const installed = agents.filter((a) => a.installed).length; + if (installed === agents.length) return { text: "All agents ready", allReady: true }; + return { text: `${installed} of ${agents.length} agents detected`, allReady: false }; + }, [agents, isDetectingAgents]); + const handleOpenProject = useCallback( (id: string) => openProject(id), [openProject], @@ -214,35 +227,37 @@ export default function WelcomeScreen() { - {/* Capabilities */} -
- {CAPABILITIES.map((cap, i) => { - const Icon = cap.icon; - return ( - - -
- -
-
- {cap.label} -
-
- {cap.description} -
-
-
- ); - })} -
+ {/* Capabilities — only shown for first-time users */} + {!hasProjects && ( +
+ {CAPABILITIES.map((cap, i) => { + const Icon = cap.icon; + return ( + + +
+ +
+
+ {cap.label} +
+
+ {cap.description} +
+
+
+ ); + })} +
+ )} {/* Supported agents — 3-per-row card grid */} - - Supported agents - +
+ + Supported agents + + {agentSummary && ( + + {agentSummary.allReady && ( + + )} + · {agentSummary.text} + + )} +
()( const sorted = [...projects].sort((a, b) => a.name.localeCompare(b.name)); const allIds = sorted.map((p) => p.id); - // Restore persisted open projects, falling back to all - let openProjectIds = allIds; + // Restore persisted open projects, or open all on first launch + let openProjectIds: string[]; if (savedOpenIds) { try { const parsed: string[] = JSON.parse(savedOpenIds); - // Filter to only valid project IDs - const valid = parsed.filter((id) => allIds.includes(id)); - if (valid.length > 0) openProjectIds = valid; - } catch { /* fall back to all */ } + openProjectIds = parsed.filter((id) => allIds.includes(id)); + } catch { + openProjectIds = allIds; + } + } else { + // No saved state (first launch) — auto-open all projects + // (if only 1 project exists, it skips the welcome screen automatically) + openProjectIds = allIds; } set({ From 4f29e4f403cb8258f9217f3987e0f82a3691bf38 Mon Sep 17 00:00:00 2001 From: Martin Karlsson Date: Thu, 26 Mar 2026 07:19:27 +0100 Subject: [PATCH 02/16] fix: keep sidebar and chat branch display in sync with external git operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Branch name was only refreshed at init, project switch, and session completion — external git checkout/branch commands were never picked up. Add 15s polling, window focus listener, and session start/stop refresh. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/store/appStore.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/store/appStore.ts b/src/store/appStore.ts index 2f9046d..6b91152 100644 --- a/src/store/appStore.ts +++ b/src/store/appStore.ts @@ -1332,6 +1332,17 @@ export const useAppStore = create()( }, 300_000); cleanups.push(() => clearInterval(usageInterval)); + // Poll branch names every 15s so external git operations are reflected + const branchInterval = setInterval(() => { + get().refreshProjectBranches(); + }, 15_000); + cleanups.push(() => clearInterval(branchInterval)); + + // Refresh branches immediately when window regains focus + const handleFocus = () => get().refreshProjectBranches(); + window.addEventListener("focus", handleFocus); + cleanups.push(() => window.removeEventListener("focus", handleFocus)); + // Check GitHub CLI auth status (tracked as background task) addBackgroundTask("Checking GitHub auth"); invoke("check_gh_auth") @@ -1528,6 +1539,8 @@ export const useAppStore = create()( if (pid) { refreshProject(pid); } + // Refresh branch names — session start/stop may involve branch operations + get().refreshProjectBranches(); // For ACP sessions with an initial prompt (task/research), set // promptPending so the chat UI shows a thinking indicator immediately. // Chat and vibe sessions start without a prompt — they wait for user input. From 23a1e4ec1f478ab47d72140ea9d8ec009c1fde78 Mon Sep 17 00:00:00 2001 From: Martin Karlsson Date: Thu, 26 Mar 2026 19:49:08 +0100 Subject: [PATCH 03/16] feat: v0.9.2 settings view, status bar, file search, and UI polish New dedicated Settings view with 8 organized tabs replacing sidebar modals, bottom status bar with MCP/GitHub/usage indicators, file browser search with highlighting and right-click context menu (open in editor, copy path, reveal), success toast notifications, focus-within accessibility for action buttons, improved ACP adapter update flow with pinned versions and cache invalidation, and session grid resize handle indicators. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 28 + docs/general.md | 60 +- src-tauri/src/agent/registry.rs | 133 +++- src-tauri/src/commands/agents.rs | 105 ++- src-tauri/src/commands/files.rs | 202 ++++- src-tauri/src/lib.rs | 3 + src/components/Chat/AgentTurnBlock.tsx | 2 +- src/components/Chat/ChatMessage.tsx | 4 +- src/components/Chat/PermissionDialog.tsx | 4 +- src/components/CommandPalette/useCommands.ts | 2 + src/components/Dashboard/ArchivedTaskList.tsx | 2 +- src/components/Dashboard/DependencyGraph.tsx | 2 +- src/components/Dashboard/TaskCard.tsx | 2 +- src/components/Files/FileContextMenu.tsx | 205 ++++++ src/components/Files/FileTree.tsx | 99 ++- src/components/Files/FileTreeItem.tsx | 112 +-- src/components/Sessions/QuickActionBar.tsx | 2 +- src/components/Sessions/SessionGrid.tsx | 20 +- src/components/Sessions/SessionPane.tsx | 86 +-- src/components/Sessions/SessionsView.tsx | 71 +- src/components/Settings/AcpPermissionsTab.tsx | 247 ++++--- src/components/Settings/AgentsTab.tsx | 202 +++-- src/components/Settings/GeneralTab.tsx | 181 ++--- src/components/Settings/GitHubTab.tsx | 49 +- src/components/Settings/GitWorktreesTab.tsx | 186 +++++ src/components/Settings/ProjectTab.tsx | 693 ++++++++++++++++++ src/components/Settings/PromptsTab.tsx | 12 +- src/components/Settings/SettingsView.tsx | 218 ++++++ src/components/Settings/TerminalTab.tsx | 42 +- src/components/Settings/shared.ts | 5 - src/components/Settings/shared.tsx | 44 ++ src/components/Shell/AppShell.tsx | 37 +- src/components/Shell/RightSidebar.tsx | 62 +- .../Shell/RightSidebarResizeHandle.tsx | 19 +- src/components/Shell/Sidebar.tsx | 218 +----- src/components/Shell/SidebarResizeHandle.tsx | 19 +- src/components/Shell/SidebarStatusPanel.tsx | 2 +- src/components/Shell/StatusBar.tsx | 413 +++++++++++ src/components/Shell/UsagePanel.tsx | 2 +- .../SkillsRules/AgentExtensionCard.tsx | 94 +-- .../SkillsRules/AgentsExtensionTab.tsx | 17 +- .../SkillsRules/InstalledSkillsList.tsx | 32 +- src/components/SkillsRules/PluginsTab.tsx | 100 ++- src/components/SkillsRules/SkillsTab.tsx | 6 +- src/components/TaskDetail/TaskBody.tsx | 2 +- src/components/TaskDetail/TaskTitle.tsx | 2 +- src/components/ai-elements/attachments.tsx | 4 +- src/lib/highlightMatch.tsx | 26 + src/store/appStore.ts | 12 + src/types.ts | 2 +- 50 files changed, 3180 insertions(+), 912 deletions(-) create mode 100644 src/components/Files/FileContextMenu.tsx create mode 100644 src/components/Settings/GitWorktreesTab.tsx create mode 100644 src/components/Settings/ProjectTab.tsx create mode 100644 src/components/Settings/SettingsView.tsx delete mode 100644 src/components/Settings/shared.ts create mode 100644 src/components/Settings/shared.tsx create mode 100644 src/components/Shell/StatusBar.tsx create mode 100644 src/lib/highlightMatch.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b30355..4419117 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,34 @@ 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) +- **Success Toasts** — Green flash notifications (3-second auto-dismiss) for confirming actions like ACP adapter installs and session renames +- **Editor Detection** — Backend probes PATH for 8 known editors and exposes `detect_editors` / `open_in_editor` IPC commands +- **Project File Indexing** — New `index_project_files` Rust command recursively indexes project files (skipping hidden dirs, node_modules, target, .git) for fast client-side search + +### 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 +- **ToggleRow Component** — Replaced repetitive inline Checkbox + label patterns across settings tabs with a shared `ToggleRow` component using the Switch primitive +- **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 +- **Agent Registry Logging** — Enhanced diagnostic logging throughout the registry (debug for cache hits, info for version checks) with improved error handling for non-semver formats and npm non-zero exit codes +- **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 + +### 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 + ## [0.9.1] - 2026-03-xx WIP ### Added diff --git a/docs/general.md b/docs/general.md index 3be6fc9..07431c6 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,33 @@ 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 +- **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 +216,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 +224,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 +232,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/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..5838111 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 @zed-industries/claude-agent-acp" → "npm install -g @zed-industries/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..0516f2c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -337,7 +337,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/components/Chat/AgentTurnBlock.tsx b/src/components/Chat/AgentTurnBlock.tsx index 09a6291..2a01274 100644 --- a/src/components/Chat/AgentTurnBlock.tsx +++ b/src/components/Chat/AgentTurnBlock.tsx @@ -949,7 +949,7 @@ export default React.memo(function AgentTurnBlock({ {hasResponse && ( - + {copied ? : } 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 && ( - + {/* Timeout progress bar — prominent strip at top */} -
+
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..3bc81f1 100644 --- a/src/components/Dashboard/ArchivedTaskList.tsx +++ b/src/components/Dashboard/ArchivedTaskList.tsx @@ -98,7 +98,7 @@ export default function ArchivedTaskList({ {/* Actions — visible on hover */} -
+
@@ -298,46 +300,6 @@ export default React.memo(function SessionPane({ )} - {/* Arrow reorder buttons */} - {showArrows && ( - <> - - - - )} - {/* Action buttons */} )}
+

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

{/* Existing rules */} {rules.length > 0 ? ( @@ -333,7 +311,7 @@ export function AcpPermissionsTab() {
@@ -342,25 +320,30 @@ export function AcpPermissionsTab() { })}
) : ( -

+

No rules configured. The default policy will be used for all requests.

)} - {/* Add rule form — two-row grid: labels on top, controls on bottom */} + {/* Add rule form */}
{/* Row 1: labels */} Capability - Path pattern (optional) + {patternConfig.label} (optional) 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"} @@ -454,8 +513,8 @@ 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..c5dece8 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,74 +157,67 @@ 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} @@ -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..68ff682 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"; @@ -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,7 +296,7 @@ function UpdatesPanel() { return (
{/* Version + Check button */} -
+
Current version @@ -369,37 +325,36 @@ function UpdatesPanel() {
{/* Auto-check toggle */} - + {/* Check interval */} {autoCheckEnabled && ( -
+
Check frequency
- v && setCheckIntervalHours(Number(v))} + items={CHECK_INTERVALS.map((opt) => ({ + value: String(opt.value), + label: opt.label, + }))} > - {CHECK_INTERVALS.map((opt) => ( - - ))} - + + + + + {CHECK_INTERVALS.map((opt) => ( + + {opt.label} + + ))} + +
)} @@ -419,7 +374,7 @@ function UpdatesPanel() { {advancedOpen && ( -
+
Custom update endpoint URL
@@ -458,7 +413,7 @@ function SystemPanel() { return (
-
+
Log files diff --git a/src/components/Settings/GitHubTab.tsx b/src/components/Settings/GitHubTab.tsx index 3212f3e..0192a7b 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({ diff --git a/src/components/Settings/GitWorktreesTab.tsx b/src/components/Settings/GitWorktreesTab.tsx new file mode 100644 index 0000000..f049ad3 --- /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/ProjectTab.tsx b/src/components/Settings/ProjectTab.tsx new file mode 100644 index 0000000..655dcc0 --- /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/PromptsTab.tsx b/src/components/Settings/PromptsTab.tsx index 4687c55..433d85f 100644 --- a/src/components/Settings/PromptsTab.tsx +++ b/src/components/Settings/PromptsTab.tsx @@ -72,7 +72,7 @@ function IconPicker({ className={`flex items-center justify-center size-8 rounded-[var(--radius-element)] border transition-colors cursor-pointer ${ isSelected ? "border-primary bg-primary/10 text-primary" - : "border-border bg-background text-muted-foreground hover:text-foreground hover:border-foreground/30" + : "border-border bg-transparent text-muted-foreground hover:text-foreground hover:border-foreground/30" }`} > @@ -121,7 +121,7 @@ function SessionPromptRow({ template, onSave }: { template: PromptTemplate; onSa }, []); return ( -
+
@@ -366,7 +366,7 @@ function AddActionForm({ onAdd }: { onAdd: (t: Omit +
{/* Label */}
@@ -374,7 +374,7 @@ function AddActionForm({ onAdd }: { onAdd: (t: Omit setLabel(e.target.value)} placeholder="e.g., Run Tests" - className="w-full px-2.5 py-1.5 text-[13px] bg-background border border-border rounded-[var(--radius-element)] text-foreground outline-none" + className="w-full px-2.5 py-1.5 text-[13px] bg-transparent border border-border rounded-md text-foreground outline-none transition-[color,box-shadow] focus:ring-2 focus:ring-ring/50 focus:border-ring" />
diff --git a/src/components/Settings/SettingsView.tsx b/src/components/Settings/SettingsView.tsx new file mode 100644 index 0000000..c4c269a --- /dev/null +++ b/src/components/Settings/SettingsView.tsx @@ -0,0 +1,218 @@ +import { + Bot, + FolderCode, + GitBranch, + Github, + MessageSquare, + Palette, + Shield, + SlidersHorizontal, + TerminalSquare, +} from "lucide-react"; +import { memo, useCallback, useEffect, useState } from "react"; + +import { useAppStore } from "../../store/appStore"; +import { AcpPermissionsTab } from "./AcpPermissionsTab"; +import { AgentsTab } from "./AgentsTab"; +import { GeneralTab } from "./GeneralTab"; +import { GitHubTab } from "./GitHubTab"; +import { GitWorktreesTab } from "./GitWorktreesTab"; +import { ProjectTab } from "./ProjectTab"; +import { PromptsTab } from "./PromptsTab"; +import { TerminalTab } from "./TerminalTab"; + +import type { LucideIcon } from "lucide-react"; + +// ── Tab definitions ── + +export type SettingsTabId = + | "general" + | "terminal" + | "agents" + | "prompts" + | "project" + | "git-worktrees" + | "acp-permissions" + | "github"; + +interface SettingsTabDef { + id: SettingsTabId; + label: string; + icon: LucideIcon; + group: "app" | "project"; +} + +const SETTINGS_TABS: SettingsTabDef[] = [ + { id: "general", label: "General", icon: SlidersHorizontal, group: "app" }, + { id: "terminal", label: "Terminal", icon: TerminalSquare, group: "app" }, + { id: "agents", label: "Agents", icon: Bot, group: "app" }, + { id: "prompts", label: "Prompts", icon: MessageSquare, group: "app" }, + { id: "project", label: "Project", icon: FolderCode, group: "project" }, + { id: "git-worktrees", label: "Git & Worktrees", icon: GitBranch, group: "project" }, + { id: "acp-permissions", label: "ACP Permissions", icon: Shield, group: "project" }, + { id: "github", label: "GitHub", icon: Github, group: "project" }, +]; + +const APP_TABS = SETTINGS_TABS.filter((t) => t.group === "app"); +const PROJECT_TABS = SETTINGS_TABS.filter((t) => t.group === "project"); + +// ── Settings Nav ── + +function SettingsNav({ + activeTab, + onTabChange, + hasProject, +}: { + activeTab: SettingsTabId; + onTabChange: (tab: SettingsTabId) => void; + hasProject: boolean; +}) { + return ( + + ); +} + +// ── Settings Content ── + +function SettingsContent({ + activeTab, +}: { + activeTab: SettingsTabId; +}) { + const agents = useAppStore((s) => s.agents); + + return ( +
+
+ {activeTab === "general" && } + {activeTab === "terminal" && } + {activeTab === "agents" && } + {activeTab === "prompts" && } + {activeTab === "project" && } + {activeTab === "git-worktrees" && } + {activeTab === "acp-permissions" && } + {activeTab === "github" && } +
+
+ ); +} + +// ── Settings View ── + +export default memo(function SettingsView() { + const activeProjectId = useAppStore((s) => s.activeProjectId); + const previousView = useAppStore((s) => s.previousView); + const setActiveView = useAppStore((s) => s.setActiveView); + const [activeTab, setActiveTab] = useState("general"); + + const hasProject = !!activeProjectId; + + // If on a project tab but no project is active, fall back to general + useEffect(() => { + const projectTabs: SettingsTabId[] = ["project", "git-worktrees", "acp-permissions", "github"]; + if (!hasProject && projectTabs.includes(activeTab)) { + setActiveTab("general"); + } + }, [hasProject, activeTab]); + + // Escape to go back + useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") { + e.preventDefault(); + setActiveView(previousView ?? "dashboard"); + } + } + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [previousView, setActiveView]); + + const handleTabChange = useCallback((tab: SettingsTabId) => { + setActiveTab(tab); + }, []); + + return ( +
+ {/* Header */} +
+ +

Settings

+ + Esc + +
+ + {/* Master-detail layout */} +
+ + +
+
+ ); +}); diff --git a/src/components/Settings/TerminalTab.tsx b/src/components/Settings/TerminalTab.tsx index 9d0e2fd..d011648 100644 --- a/src/components/Settings/TerminalTab.tsx +++ b/src/components/Settings/TerminalTab.tsx @@ -1,6 +1,7 @@ import { invoke } from "@tauri-apps/api/core"; import { Loader2, RefreshCw } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + import { usePersistedNumber, usePersistedString, @@ -37,14 +38,20 @@ const EMBEDDED_FONT = "JetBrains Mono"; function buildFontList(detected: AvailableFont[]): FontEntry[] { const entries: FontEntry[] = [ // Embedded font is always available - { name: EMBEDDED_FONT, family: `'${EMBEDDED_FONT}', monospace`, category: "embedded" }, + { + name: EMBEDDED_FONT, + family: `'${EMBEDDED_FONT}', monospace`, + category: "embedded", + }, ]; for (const font of detected) { // Skip the embedded font (already added) if (font.family === EMBEDDED_FONT) continue; - const category: FontEntry["category"] = font.is_nerd_font ? "nerd" : "system"; + const category: FontEntry["category"] = font.is_nerd_font + ? "nerd" + : "system"; entries.push({ name: font.family, family: `'${font.family}', monospace`, @@ -126,10 +133,22 @@ function SettingsSlider({ export function TerminalTab() { const shells = useAppStore((s) => s.shells); - const [terminalShell, setTerminalShell] = usePersistedString("terminal_shell", ""); - const [fontFamily, setFontFamily] = usePersistedString("terminal_font_family", DEFAULTS.fontFamily); - const [fontSize, setFontSize] = usePersistedNumber("terminal_font_size", DEFAULTS.fontSize); - const [lineHeight, setLineHeight] = usePersistedNumber("terminal_line_height", DEFAULTS.lineHeight); + const [terminalShell, setTerminalShell] = usePersistedString( + "terminal_shell", + "", + ); + const [fontFamily, setFontFamily] = usePersistedString( + "terminal_font_family", + DEFAULTS.fontFamily, + ); + const [fontSize, setFontSize] = usePersistedNumber( + "terminal_font_size", + DEFAULTS.fontSize, + ); + const [lineHeight, setLineHeight] = usePersistedNumber( + "terminal_line_height", + DEFAULTS.lineHeight, + ); const [zoom, setZoom] = usePersistedNumber("terminal_zoom", DEFAULTS.zoom); const [fontFilter, setFontFilter] = useState(""); @@ -137,7 +156,11 @@ export function TerminalTab() { // Detect which fonts are actually installed via the Rust backend const [fontList, setFontList] = useState([ - { name: EMBEDDED_FONT, family: `'${EMBEDDED_FONT}', monospace`, category: "embedded" }, + { + name: EMBEDDED_FONT, + family: `'${EMBEDDED_FONT}', monospace`, + category: "embedded", + }, ]); const [fontsLoading, setFontsLoading] = useState(true); @@ -229,7 +252,8 @@ export function TerminalTab() {
- Takes effect on new sessions. Existing sessions keep their current shell. + Takes effect on new sessions. Existing sessions keep their current + shell.
@@ -250,7 +274,7 @@ export function TerminalTab() { )}
-
+
{/* Search / filter */}
void; + disabled?: boolean; +}) { + return ( + + ); +} diff --git a/src/components/Shell/AppShell.tsx b/src/components/Shell/AppShell.tsx index 4bce28d..c505d49 100644 --- a/src/components/Shell/AppShell.tsx +++ b/src/components/Shell/AppShell.tsx @@ -1,4 +1,4 @@ -import { AlertTriangle, Loader2 } from "lucide-react"; +import { AlertTriangle, CheckCircle2, Loader2 } from "lucide-react"; import { memo, useMemo } from "react"; import { TooltipProvider } from "@/components/ui/tooltip"; @@ -13,6 +13,7 @@ import GitHubView from "../GitHub/GitHubView"; import HelpView from "../Help/HelpView"; import ReviewView from "../Review/ReviewView"; import SessionsView from "../Sessions/SessionsView"; +import SettingsView from "../Settings/SettingsView"; import SkillsRulesView from "../SkillsRules/SkillsRulesView"; import TaskDetailView from "../TaskDetail/TaskDetailView"; import UpdateNotification from "../Update/UpdateNotification"; @@ -20,6 +21,7 @@ import ApplicationBar from "./ApplicationBar"; import ErrorBoundary from "./ErrorBoundary"; import RightSidebar from "./RightSidebar"; import Sidebar from "./Sidebar"; +import StatusBar from "./StatusBar"; import WelcomeScreen from "./WelcomeScreen"; import type { ReactNode } from "react"; @@ -51,6 +53,8 @@ const ViewRouter = memo(function ViewRouter({ otherView = ; } else if (activeView === "help") { otherView = ; + } else if (activeView === "settings") { + otherView = ; } return ( @@ -70,23 +74,32 @@ const ViewRouter = memo(function ViewRouter({ function FloatingStatusToast() { const backgroundTasks = useAppStore((s) => s.backgroundTasks); const errorFlash = useAppStore((s) => s.errorFlash); + const successFlash = useAppStore((s) => s.successFlash); const isBusy = backgroundTasks.length > 0; const currentTask = backgroundTasks[backgroundTasks.length - 1]; - const isVisible = isBusy || errorFlash; + const isVisible = isBusy || errorFlash || successFlash; + + const variant = errorFlash + ? "error" + : successFlash + ? "success" + : "default"; return (
- {errorFlash ? ( + {variant === "error" ? ( <> + ) : variant === "success" ? ( + <> + + + {successFlash} + + ) : ( <> @@ -132,11 +152,11 @@ export default function AppShell() { const gridStyle = useMemo( () => ({ display: "grid" as const, - gridTemplateRows: "auto 1fr", + gridTemplateRows: "auto 1fr auto", gridTemplateColumns: `${leftCol} 1fr ${rightCol}`, gridTemplateAreas: rightSidebarOpen - ? `"sidebar topbar rightsidebar" "sidebar content rightsidebar"` - : `"sidebar topbar ." "sidebar content ."`, + ? `"sidebar topbar rightsidebar" "sidebar content rightsidebar" "statusbar statusbar statusbar"` + : `"sidebar topbar ." "sidebar content ." "statusbar statusbar statusbar"`, height: "100vh", overflow: "hidden" as const, }), @@ -161,6 +181,7 @@ export default function AppShell() { {rightSidebarOpen && } +
diff --git a/src/components/Shell/RightSidebar.tsx b/src/components/Shell/RightSidebar.tsx index 5892c3d..cc3a61d 100644 --- a/src/components/Shell/RightSidebar.tsx +++ b/src/components/Shell/RightSidebar.tsx @@ -1,6 +1,6 @@ import { invoke } from "@tauri-apps/api/core"; -import { FolderOpen, FolderTree } from "lucide-react"; -import { useCallback } from "react"; +import { ExternalLink, FolderTree, Search, X } from "lucide-react"; +import { useCallback, useRef, useState } from "react"; import { useAppStore } from "../../store/appStore"; import FileTree from "../Files/FileTree"; @@ -9,17 +9,24 @@ import RightSidebarResizeHandle from "./RightSidebarResizeHandle"; export default function RightSidebar() { const activeProjectId = useAppStore((s) => s.activeProjectId); const projects = useAppStore((s) => s.projects); + const [filterText, setFilterText] = useState(""); + const filterInputRef = useRef(null); const activeProject = activeProjectId ? projects.find((p) => p.id === activeProjectId) : null; - const handleOpenFolder = useCallback(() => { + const handleRevealInExplorer = useCallback(() => { if (activeProject?.path) { invoke("open_file_in_os", { path: activeProject.path }); } }, [activeProject?.path]); + const handleClearFilter = useCallback(() => { + setFilterText(""); + filterInputRef.current?.focus(); + }, []); + return (
+ {/* Search/filter input */} + {activeProject && ( +
+
+ + setFilterText(e.target.value)} + placeholder="Filter files..." + 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" + /> + {filterText && ( + + )} +
+
+ )} + {/* Body */}
{activeProject ? ( ) : ( -
-

- Select a project to browse files +

+ +

+ No project open +

+

+ Open a project to browse its files

)}
- {/* Footer — Open Folder button */} + {/* Footer — Reveal in Explorer button */} {activeProject && (
)} diff --git a/src/components/Shell/RightSidebarResizeHandle.tsx b/src/components/Shell/RightSidebarResizeHandle.tsx index a0737ca..b3290ca 100644 --- a/src/components/Shell/RightSidebarResizeHandle.tsx +++ b/src/components/Shell/RightSidebarResizeHandle.tsx @@ -40,15 +40,14 @@ export default function RightSidebarResizeHandle() { return (
+ className="group absolute top-0 -left-[3px] w-1.5 h-full cursor-col-resize z-10" + > + {/* Grip dots — appear on hover */} +
+
+
+
+
+
); } diff --git a/src/components/Shell/Sidebar.tsx b/src/components/Shell/Sidebar.tsx index bd5d20b..b29669d 100644 --- a/src/components/Shell/Sidebar.tsx +++ b/src/components/Shell/Sidebar.tsx @@ -1,11 +1,9 @@ import { AlertCircle, - Bot, Bug, CheckCircle2, ChevronDown, ChevronRight, - CircleHelp, CirclePause, CirclePlay, ClipboardList, @@ -19,11 +17,8 @@ import { Lightbulb, Loader2, MessageCircle, - MessageSquare, Plus, Settings, - Shield, - SlidersHorizontal, TerminalSquare, } from "lucide-react"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -35,14 +30,7 @@ import { useProjectIcon } from "../../hooks/useProjectIcon"; import { AgentIcon } from "../../lib/agentIcons"; import { useAppStore } from "../../store/appStore"; import { pickProjectFolder } from "../../utils/pickProjectFolder"; -import { AcpPermissionsTab } from "../Settings/AcpPermissionsTab"; -import { AgentsTab } from "../Settings/AgentsTab"; -import { GeneralTab } from "../Settings/GeneralTab"; - import { ManageProjectsTab } from "../Settings/ProjectsTab"; -import { ProjectSettingsDialog } from "../Settings/ProjectSettingsDialog"; -import { PromptsTab } from "../Settings/PromptsTab"; -import { TerminalTab } from "../Settings/TerminalTab"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../ui/dialog"; import { DropdownMenu, @@ -56,10 +44,7 @@ import { Button } from "../ui/orecus.io/components/enhanced-button"; import { gradientHexColors } from "../ui/orecus.io/lib/color-utils"; import { FaberLogo } from "../ui/FaberLogo"; import SidebarResizeHandle from "./SidebarResizeHandle"; -import SidebarStatusPanel from "./SidebarStatusPanel"; -import UsagePanel from "./UsagePanel"; -import type { LucideIcon } from "lucide-react"; import type { ChangedFile, McpSessionState, SessionStatus, WorktreeInfo } from "../../types"; import type { ThemeColor } from "../ui/orecus.io/lib/color-utils"; @@ -67,139 +52,6 @@ import type { ThemeColor } from "../ui/orecus.io/lib/color-utils"; const EMPTY_SESSIONS: never[] = []; const EMPTY_WORKTREES: WorktreeInfo[] = []; -// ── Settings Bar ── - -type SettingsDialogId = - | "general" - | "terminal" - | "agents" - | "prompts" - | "manage-projects" - | "acp-permissions"; - -const SETTINGS_ITEMS: { - id: SettingsDialogId; - icon: LucideIcon; - title: string; - tooltip: string; - maxWidth: string; -}[] = [ - { - id: "general", - icon: SlidersHorizontal, - title: "General Settings", - tooltip: "General", - maxWidth: "sm:max-w-xl", - }, - { - id: "terminal", - icon: TerminalSquare, - title: "Terminal Settings", - tooltip: "Terminal", - maxWidth: "sm:max-w-md", - }, - { - id: "agents", - icon: Bot, - title: "Agent Configuration", - tooltip: "Agents", - maxWidth: "sm:max-w-2xl", - }, - { - id: "prompts", - icon: MessageSquare, - title: "Prompt Templates & Quick Actions", - tooltip: "Prompts", - maxWidth: "sm:max-w-2xl", - }, - { - id: "acp-permissions", - icon: Shield, - title: "ACP Permissions", - tooltip: "ACP Permissions", - maxWidth: "sm:max-w-2xl", - }, -]; - -// Dialog config for items not in the settings bar (opened externally) -const EXTRA_DIALOG_CONFIG: Record = - { - "manage-projects": { title: "Manage Projects", maxWidth: "sm:max-w-xl" }, - }; - -function SettingsBar({ - openDialog, - setOpenDialog, -}: { - openDialog: SettingsDialogId | null; - setOpenDialog: (id: SettingsDialogId | null) => void; -}) { - const agents = useAppStore((s) => s.agents); - const setActiveView = useAppStore((s) => s.setActiveView); - const config = openDialog - ? (SETTINGS_ITEMS.find((i) => i.id === openDialog) ?? - (EXTRA_DIALOG_CONFIG[openDialog] - ? { ...EXTRA_DIALOG_CONFIG[openDialog], id: openDialog } - : null)) - : null; - - return ( - <> -
- {SETTINGS_ITEMS.map((item) => { - const Icon = item.icon; - return ( - - ); - })} - -
- - {openDialog && config && ( - { - if (!open) setOpenDialog(null); - }} - > - - - {config.title} - -
- {openDialog === "general" && } - {openDialog === "terminal" && } - {openDialog === "agents" && } - {openDialog === "prompts" && } - {openDialog === "manage-projects" && } - {openDialog === "acp-permissions" && } -
-
-
- )} - - ); -} - // ── Helpers ── const STATUS_COLOR: Record = { @@ -400,7 +252,7 @@ const ProjectSessionList = React.memo(function ProjectSessionList({ return ( <> -
+
Sessions
@@ -444,12 +296,12 @@ const ProjectWorktreeList = React.memo(function ProjectWorktreeList({ return ( <> -
+
Worktrees
{nonMainWorktrees.length === 0 ? ( -
+
No worktrees
@@ -464,7 +316,7 @@ const ProjectWorktreeList = React.memo(function ProjectWorktreeList({ onSelect(); navigateToReview(w.path); }} - className={`flex items-center gap-1.5 px-1 py-1 text-xs rounded-[var(--radius-element)] cursor-pointer hover:bg-accent ${isActiveWorktree ? "bg-accent text-foreground" : "text-dim-foreground"}`} + className={`flex items-center gap-1.5 px-1 h-7 text-xs rounded-[var(--radius-element)] cursor-pointer hover:bg-accent ${isActiveWorktree ? "bg-accent text-foreground" : "text-dim-foreground"}`} > @@ -591,7 +443,7 @@ const ProjectItem = React.memo(function ProjectItem({ render={ e.stopPropagation()} - className="cursor-pointer text-muted-foreground hover:text-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0 inline-flex items-center justify-center size-5 rounded-sm hover:bg-accent/50" + className="cursor-pointer text-muted-foreground hover:text-foreground opacity-30 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity shrink-0 inline-flex items-center justify-center size-5 rounded-sm hover:bg-accent/50" /> } > @@ -645,21 +497,12 @@ export default function Sidebar() { const openProjectIds = useAppStore((s) => s.openProjectIds); const activeProjectId = useAppStore((s) => s.activeProjectId); const setActiveProject = useAppStore((s) => s.setActiveProject); + const setActiveView = useAppStore((s) => s.setActiveView); const closeProject = useAppStore((s) => s.closeProject); const addProjectFromPath = useAppStore((s) => s.addProjectFromPath); - const agents = useAppStore((s) => s.agents); const [showIcons] = usePersistedBoolean("show_project_icons", true); - const [settingsDialog, setSettingsDialog] = useState( - null, - ); - const [projectSettingsId, setProjectSettingsId] = useState(null); + const [manageProjectsOpen, setManageProjectsOpen] = useState(false); const [showCreateProject, setShowCreateProject] = useState(false); - const [version, setVersion] = useState(""); - - useEffect(() => { - invoke("get_app_version").then(setVersion).catch(() => {}); - }, []); - const openProjects = useMemo( () => projects.filter((p) => openProjectIds.includes(p.id)), [projects, openProjectIds], @@ -674,6 +517,15 @@ export default function Sidebar() { [closeProject], ); + const handleOpenProjectSettings = useCallback( + (projectId: string) => { + // Ensure the project is active, then navigate to project settings + setActiveProject(projectId); + setActiveView("settings"); + }, + [setActiveProject, setActiveView], + ); + async function handleAddProject() { try { const selected = await pickProjectFolder(); @@ -694,16 +546,13 @@ export default function Sidebar() {
Faber - {version && ( - v{version} - )}
+
+
+ {agentUsage.map((agent) => ( + + ))} +
+ + + ); +}); + +// ── MCP status segment ── + +const McpStatus = React.memo(function McpStatus() { + const mcpActiveCount = useAppStore((s) => Object.keys(s.mcpStatus).length); + const [mcpInfo, setMcpInfo] = useState(null); + const [copied, setCopied] = useState(false); + + const refreshMcpInfo = useCallback(() => { + invoke("get_mcp_info") + .then(setMcpInfo) + .catch(() => setMcpInfo(null)); + }, []); + + useEffect(() => { + refreshMcpInfo(); + }, [refreshMcpInfo]); + + const mcpOnline = mcpInfo != null && mcpInfo.port > 0; + + const handleCopy = useCallback(() => { + if (!mcpInfo?.sidecar_path) return; + navigator.clipboard.writeText(mcpInfo.sidecar_path).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }, [mcpInfo]); + + return ( + + ); +}); + +// ── GitHub auth segment ── + +const GitHubStatus = React.memo(function GitHubStatus() { + const ghAuthStatus = useAppStore((s) => s.ghAuthStatus); + + if (!ghAuthStatus) return null; + + const notInstalled = !ghAuthStatus.installed; + const notAuthenticated = + ghAuthStatus.installed && !ghAuthStatus.authenticated; + const scopeWarning = ghAuthStatus.has_scope_warnings; + const hasWarning = notInstalled || notAuthenticated || scopeWarning; + + if (!hasWarning && ghAuthStatus.authenticated) { + // All good — show subtle checkmark + return ( + + + GitHub + + ); + } + + if (!hasWarning) return null; + + const warningText = notInstalled + ? "gh missing" + : notAuthenticated + ? "gh unauthed" + : "gh scopes"; + + const tooltip = notInstalled + ? "GitHub CLI (gh) is not installed" + : notAuthenticated + ? "GitHub CLI is not authenticated. Run `gh auth login`" + : `Missing scopes: ${ghAuthStatus.missing_scopes.join(", ")}`; + + const isScopeOnly = scopeWarning && !notInstalled && !notAuthenticated; + const colorClass = isScopeOnly ? "text-warning" : "text-destructive"; + + return ( + + + {warningText} + + ); +}); + +// ── Status Bar ── + +export default function StatusBar() { + const activeView = useAppStore((s) => s.activeView); + const setActiveView = useAppStore((s) => s.setActiveView); + + const [version, setVersion] = useState(""); + useEffect(() => { + invoke("get_app_version") + .then(setVersion) + .catch(() => {}); + }, []); + + const keyCommands = VIEW_KEY_COMMANDS[activeView] ?? []; + + return ( +
+ {/* ── Left zone ── */} +
+ +
+ +
+ +
+ + {/* ── Right zone ── */} +
+ {/* Contextual key commands */} + {keyCommands.length > 0 && ( +
+ {keyCommands.map((cmd) => ( + + ))} +
+ )} + + {keyCommands.length > 0 && ( +
+ )} + + {/* Settings */} + + + {/* Version */} + {version && ( + + v{version} + + )} +
+
+ ); +} diff --git a/src/components/Shell/UsagePanel.tsx b/src/components/Shell/UsagePanel.tsx index 8a02a69..7c0d301 100644 --- a/src/components/Shell/UsagePanel.tsx +++ b/src/components/Shell/UsagePanel.tsx @@ -107,7 +107,7 @@ const UsagePanel = React.memo(function UsagePanel() { e.stopPropagation(); handleRefresh(); }} - className="shrink-0 opacity-0 group-hover/usage:opacity-100 transition-opacity text-muted-foreground hover:text-foreground cursor-pointer" + className="shrink-0 opacity-30 group-hover/usage:opacity-100 group-focus-within/usage:opacity-100 transition-opacity text-muted-foreground hover:text-foreground cursor-pointer" /> )}
diff --git a/src/components/SkillsRules/AgentExtensionCard.tsx b/src/components/SkillsRules/AgentExtensionCard.tsx index 2363db5..8451519 100644 --- a/src/components/SkillsRules/AgentExtensionCard.tsx +++ b/src/components/SkillsRules/AgentExtensionCard.tsx @@ -5,7 +5,6 @@ import { Download, ExternalLink, Globe, - RotateCcw, Terminal, X, } from "lucide-react"; @@ -25,8 +24,6 @@ interface AgentExtensionCardProps { agent: AgentInfo; installing: boolean; onInstallAdapter: (name: string, isUpdate: boolean) => void; - /** Whether this agent was just updated in the current session. */ - justUpdated: boolean; /** Registry entry for this agent (if fetched). */ registryEntry?: AcpRegistryEntry; } @@ -35,7 +32,6 @@ const AgentExtensionCard = React.memo(function AgentExtensionCard({ agent, installing, onInstallAdapter, - justUpdated, registryEntry, }: AgentExtensionCardProps) { const { isGlass } = useTheme(); @@ -111,38 +107,29 @@ const AgentExtensionCard = React.memo(function AgentExtensionCard({ {agent.default_model} )} - {justUpdated && registryEntry ? ( - - + {registryEntry?.installed_version && ( + + + v{registryEntry.installed_version} + + )} + {registryEntry?.update_available && ( + + + v{registryEntry.registry_version} + + )} + {registryEntry && !registryEntry.installed_version && ( + + v{registryEntry.registry_version} - ) : ( - <> - {registryEntry?.installed_version && ( - - - v{registryEntry.installed_version} - - )} - {registryEntry?.update_available && ( - - - v{registryEntry.registry_version} - - )} - {registryEntry && !registryEntry.installed_version && ( - - - v{registryEntry.registry_version} - - )} - )}
@@ -246,32 +233,25 @@ const AgentExtensionCard = React.memo(function AgentExtensionCard({
- {/* Update available / just updated */} - {justUpdated ? ( - - - Restart app to apply - - ) : ( - registryEntry?.update_available && agent.acp_installed && ( - - ) + {/* Update available */} + {registryEntry?.update_available && agent.acp_installed && ( + )} {/* Install adapter button */} - {needsAdapter && !agent.acp_installed && !justUpdated && ( + {needsAdapter && !agent.acp_installed && (
); } diff --git a/src/components/SkillsRules/PluginsTab.tsx b/src/components/SkillsRules/PluginsTab.tsx index a646f01..4eb5546 100644 --- a/src/components/SkillsRules/PluginsTab.tsx +++ b/src/components/SkillsRules/PluginsTab.tsx @@ -7,6 +7,7 @@ import { Check, ChevronDown, ChevronRight, + ChevronUp, Code2, Download, ExternalLink, @@ -29,10 +30,12 @@ import { X, } from "lucide-react"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import ConfirmDialog from "../Review/ConfirmDialog"; import { open } from "@tauri-apps/plugin-shell"; import { Streamdown } from "streamdown"; import { useTheme } from "../../contexts/ThemeContext"; +import { highlightMatch } from "../../lib/highlightMatch"; import { streamdownControls, streamdownPlugins, @@ -40,6 +43,7 @@ import { } from "../../lib/markdown"; import { useAppStore } from "../../store/appStore"; import { Button } from "../ui/orecus.io/components/enhanced-button"; +import { CardSkeleton } from "../ui/orecus.io/cards/card/skeleton"; import { glassStyles } from "../ui/orecus.io/lib/color-utils"; // ── Types matching Rust backend ── @@ -269,6 +273,7 @@ const PluginCard = React.memo(function PluginCard({ onInstall, onUninstall, actionLoading, + searchQuery, }: { plugin: AvailablePlugin | InstalledPlugin; isInstalled: boolean; @@ -278,6 +283,7 @@ const PluginCard = React.memo(function PluginCard({ onInstall: () => void; onUninstall: () => void; actionLoading: string | null; + searchQuery?: string; }) { const key = `${plugin.name}@${plugin.marketplace}`; const installKey = `install:${key}`; @@ -316,7 +322,7 @@ const PluginCard = React.memo(function PluginCard({
- {plugin.name} + {searchQuery ? highlightMatch(plugin.name, searchQuery) : plugin.name} {isInstalled && ( @@ -336,7 +342,7 @@ const PluginCard = React.memo(function PluginCard({ {/* Action button — only show on hover or when loading */}
e.stopPropagation()} > {isInstalled ? ( @@ -378,7 +384,7 @@ const PluginCard = React.memo(function PluginCard({ {/* Description */} {plugin.description && (

- {plugin.description} + {searchQuery ? highlightMatch(plugin.description, searchQuery) : plugin.description}

)} @@ -424,6 +430,7 @@ function PluginDetailDrawer({ }) { const [readme, setReadme] = useState(null); const [readmeLoading, setReadmeLoading] = useState(false); + const [showScrollTop, setShowScrollTop] = useState(false); const panelRef = useRef(null); const isInstalled = selected.kind === "installed"; @@ -594,7 +601,11 @@ function PluginDetailDrawer({
{/* README */} -
+
setShowScrollTop(e.currentTarget.scrollTop > 200)} + > {readmeLoading && (
@@ -628,6 +639,16 @@ function PluginDetailDrawer({
)} + + {showScrollTop && ( + + )}
); @@ -685,7 +706,7 @@ function MarketplaceSources({
)} + + {pendingUninstall && ( + { + const { name, marketplace, scope } = pendingUninstall; + setPendingUninstall(null); + handleUninstall(name, marketplace, scope); + }} + onCancel={() => setPendingUninstall(null)} + /> + )} + + {pendingRemoveMkt && ( + { + const name = pendingRemoveMkt; + setPendingRemoveMkt(null); + handleRemoveMarketplace(name); + }} + onCancel={() => setPendingRemoveMkt(null)} + /> + )}
); } diff --git a/src/components/SkillsRules/SkillsTab.tsx b/src/components/SkillsRules/SkillsTab.tsx index 4f809f7..9f1becd 100644 --- a/src/components/SkillsRules/SkillsTab.tsx +++ b/src/components/SkillsRules/SkillsTab.tsx @@ -7,6 +7,7 @@ import { Search, } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; +import { highlightMatch } from "../../lib/highlightMatch"; import { open } from "@tauri-apps/plugin-shell"; @@ -108,6 +109,7 @@ export default function SkillsTab({ projectId }: Props) { global, }); setRefreshKey((k) => k + 1); + useAppStore.getState().flashSuccess(`Removed ${skillName}`); } catch (e) { console.error("Skill remove failed:", e); useAppStore.getState().flashError(`Remove failed: ${formatError(e)}`); @@ -180,7 +182,7 @@ export default function SkillsTab({ projectId }: Props) { className="text-sm font-medium text-foreground hover:text-primary truncate transition-colors inline-flex items-center gap-1" title={`View on skills.sh`} > - {skill.name} + {highlightMatch(skill.name, query)}
- {skill.source} + {highlightMatch(skill.source, query)}
diff --git a/src/components/TaskDetail/TaskBody.tsx b/src/components/TaskDetail/TaskBody.tsx index 24e68f2..d91ae96 100644 --- a/src/components/TaskDetail/TaskBody.tsx +++ b/src/components/TaskDetail/TaskBody.tsx @@ -62,7 +62,7 @@ export default function TaskBody({ body, onChange, onSave }: TaskBodyProps) { {/* Edit button overlay */} ); } diff --git a/src/components/ai-elements/attachments.tsx b/src/components/ai-elements/attachments.tsx index baf2855..b6bd9a4 100644 --- a/src/components/ai-elements/attachments.tsx +++ b/src/components/ai-elements/attachments.tsx @@ -343,13 +343,13 @@ export const AttachmentRemove = ({ variant === "grid" && [ "absolute top-2 right-2 size-6 rounded-full p-0", "bg-background/80 backdrop-blur-sm", - "opacity-0 transition-opacity group-hover:opacity-100", + "opacity-30 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100", "hover:bg-background", "[&>svg]:size-3", ], variant === "inline" && [ "size-5 rounded p-0", - "opacity-0 transition-opacity group-hover:opacity-100", + "opacity-30 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100", "[&>svg]:size-2.5", ], variant === "list" && ["size-8 shrink-0 rounded p-0", "[&>svg]:size-4"], diff --git a/src/lib/highlightMatch.tsx b/src/lib/highlightMatch.tsx new file mode 100644 index 0000000..1611b7a --- /dev/null +++ b/src/lib/highlightMatch.tsx @@ -0,0 +1,26 @@ +import React from "react"; + +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function highlightMatch( + text: string, + query: string, +): React.ReactNode { + if (!query.trim()) return text; + const regex = new RegExp(`(${escapeRegex(query)})`, "gi"); + const parts = text.split(regex); + return parts.map((part, i) => + regex.test(part) ? ( + + {part} + + ) : ( + part + ), + ); +} diff --git a/src/store/appStore.ts b/src/store/appStore.ts index 6b91152..9537d38 100644 --- a/src/store/appStore.ts +++ b/src/store/appStore.ts @@ -155,6 +155,7 @@ interface AppState { backgroundTasks: string[]; errorFlash: string | null; + successFlash: string | null; sidebarCollapsed: boolean; sidebarWidth: number; rightSidebarOpen: boolean; @@ -240,6 +241,7 @@ interface AppState { addBackgroundTask: (label: string) => void; removeBackgroundTask: (label: string) => void; flashError: (message: string) => void; + flashSuccess: (message: string) => void; // Research → Implementation flow /** Session IDs of research sessions that have completed (for showing the "Continue to Implementation" bar). */ @@ -349,6 +351,7 @@ export const useAppStore = create()( agentSessionListFetchedAt: {}, backgroundTasks: [], errorFlash: null, + successFlash: null, sidebarCollapsed: false, sidebarWidth: 260, rightSidebarOpen: false, @@ -997,6 +1000,11 @@ export const useAppStore = create()( setTimeout(() => set({ errorFlash: null }), 4000); }, + flashSuccess: (message) => { + set({ successFlash: message }); + setTimeout(() => set({ successFlash: null }), 3000); + }, + // ── Research → Implementation flow ── researchCompleteSessionIds: [], @@ -1500,6 +1508,10 @@ export const useAppStore = create()( e.preventDefault(); get().toggleRightSidebar(); } + if ((e.ctrlKey || e.metaKey) && e.key === ",") { + e.preventDefault(); + get().setActiveView("settings"); + } if (e.key === "Escape" && get().commandPaletteOpen) { get().closeCommandPalette(); } diff --git a/src/types.ts b/src/types.ts index 9498b42..825192e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,7 +14,7 @@ export interface PriorityLevel { export type SessionMode = "task" | "vibe" | "shell" | "research" | "chat" | "breakdown"; export type SessionTransport = "pty" | "acp"; export type SessionStatus = "starting" | "running" | "paused" | "stopped" | "finished" | "error"; -export type ViewId = "dashboard" | "sessions" | "chat" | "task-detail" | "review" | "github" | "skills-rules" | "help"; +export type ViewId = "dashboard" | "sessions" | "chat" | "task-detail" | "review" | "github" | "skills-rules" | "help" | "settings"; export interface Project { id: string; From b1848d59cd84613da9e217304d5faaed81ff42a6 Mon Sep 17 00:00:00 2001 From: Martin Karlsson Date: Thu, 26 Mar 2026 20:05:42 +0100 Subject: [PATCH 04/16] fix: resolve GITHUB_TOKEN from shell env on macOS for gh auth macOS GUI apps don't inherit env vars from shell profiles, so GITHUB_TOKEN/GH_TOKEN set in .zshrc were invisible to Faber. Expand fix_path_env() to capture important env vars beyond just PATH. Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/lib.rs | 60 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 47 insertions(+), 13 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0516f2c..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. From 255bab14f9684d733d5bb0bcfd222722a9022bab Mon Sep 17 00:00:00 2001 From: Martin Karlsson Date: Fri, 27 Mar 2026 20:32:40 +0100 Subject: [PATCH 05/16] feat: task progress rings, dependency indicators, typography standardization, and dashboard UX improvements Add progress ring visualization on in-progress task cards, per-dependency met/unmet indicators, epic dependency connectors in Kanban columns, and searchable filter dropdowns for large label/agent/epic lists. Standardize typography across 100+ components to a canonical scale (text-micro/text-2xs/text-xs/text-sm/text-base), extract centralized taskStatusColors module, and update CHANGELOG + docs with accurate settings paths. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 10 + CLAUDE.md | 1 + docs/github_workflow.md | 2 +- docs/supported_agents.md | 2 +- src/components/Chat/ActivityBar.tsx | 34 +- src/components/Chat/AgentPlanView.tsx | 4 +- src/components/Chat/AgentTurnBlock.tsx | 20 +- src/components/Chat/ChatInput.tsx | 6 +- src/components/Chat/ChatView.tsx | 14 +- src/components/Chat/ConfigOptionsPopover.tsx | 8 +- src/components/Chat/ContextCrease.tsx | 46 +- src/components/Chat/ContextUsageIndicator.tsx | 2 +- src/components/Chat/CreateWorktreePopover.tsx | 12 +- src/components/Chat/GitContextBar.tsx | 8 +- src/components/Chat/ModeSelector.tsx | 6 +- src/components/Chat/ModelSelector.tsx | 8 +- src/components/Chat/PermissionDialog.tsx | 4 +- src/components/Chat/ThoughtLevelSelector.tsx | 6 +- src/components/Chat/ThreadStatusBadge.tsx | 2 +- src/components/Chat/ToolCallCard.tsx | 38 +- src/components/Chat/WaitingCard.tsx | 2 +- .../CommandPalette/CommandPalette.tsx | 18 +- src/components/Dashboard/ArchivedTaskList.tsx | 8 +- src/components/Dashboard/DashboardView.tsx | 2 +- src/components/Dashboard/DependencyBadge.tsx | 43 +- src/components/Dashboard/DependencyGraph.tsx | 49 +- src/components/Dashboard/EmptyState.tsx | 2 +- src/components/Dashboard/FilterBar.tsx | 384 ++++++++---- src/components/Dashboard/GhostParentCard.tsx | 39 +- src/components/Dashboard/KanbanBoard.tsx | 10 +- src/components/Dashboard/KanbanColumn.tsx | 112 ++-- src/components/Dashboard/PriorityBadge.tsx | 2 +- src/components/Dashboard/SummaryHeader.tsx | 14 +- src/components/Dashboard/TaskCard.tsx | 582 +++++++++--------- src/components/GitHub/BranchFilter.tsx | 4 +- src/components/GitHub/CommitDetailPanel.tsx | 18 +- src/components/GitHub/CommitRow.tsx | 8 +- src/components/GitHub/GitHubView.tsx | 45 +- src/components/GitHub/IssueDetailPanel.tsx | 30 +- src/components/GitHub/IssuesTab.tsx | 18 +- .../GitHub/PullRequestDetailPanel.tsx | 38 +- src/components/GitHub/PullRequestsTab.tsx | 24 +- src/components/Help/HelpView.tsx | 2 +- src/components/Launchers/AgentCardGrid.tsx | 2 +- .../Launchers/LaunchBreakdownDialog.tsx | 4 +- .../Launchers/LaunchContinuousDialog.tsx | 20 +- .../Launchers/LaunchResearchDialog.tsx | 4 +- .../Launchers/LaunchSessionDialog.tsx | 10 +- src/components/Launchers/LaunchTaskDialog.tsx | 10 +- src/components/Review/CreatePRDialog.tsx | 2 +- src/components/Review/DiffToolbar.tsx | 2 +- src/components/Review/DiffView.tsx | 4 +- src/components/Review/FileList.tsx | 26 +- src/components/Review/MergeBranchDialog.tsx | 4 +- src/components/Sessions/QuickActionBar.tsx | 2 +- .../Sessions/ResearchCompleteBar.tsx | 8 +- .../Sessions/SessionDragOverlay.tsx | 2 +- src/components/Sessions/SessionPane.tsx | 12 +- .../Sessions/SessionsEmptyState.tsx | 2 +- src/components/Sessions/SessionsToolbar.tsx | 7 +- src/components/Settings/AcpPermissionsTab.tsx | 42 +- src/components/Settings/AgentsTab.tsx | 20 +- src/components/Settings/GeneralTab.tsx | 18 +- src/components/Settings/GitHubTab.tsx | 32 +- src/components/Settings/GitWorktreesTab.tsx | 8 +- .../Settings/ProjectSettingsDialog.tsx | 50 +- src/components/Settings/ProjectTab.tsx | 36 +- src/components/Settings/ProjectsTab.tsx | 12 +- src/components/Settings/PromptsTab.tsx | 46 +- src/components/Settings/SettingsView.tsx | 10 +- .../Settings/TaskFileConflictDialog.tsx | 18 +- src/components/Settings/TerminalTab.tsx | 22 +- src/components/Settings/shared.tsx | 6 +- src/components/Shell/ApplicationBar.tsx | 2 +- src/components/Shell/ContinuousModeBar.tsx | 22 +- src/components/Shell/CreateProjectDialog.tsx | 6 +- .../Shell/FloatingPermissionBanner.tsx | 2 +- src/components/Shell/Sidebar.tsx | 14 +- src/components/Shell/SidebarStatusPanel.tsx | 2 +- src/components/Shell/StatusBar.tsx | 30 +- src/components/Shell/UsagePanel.tsx | 14 +- src/components/Shell/UsageProgressBar.tsx | 6 +- src/components/Shell/ViewLayout.tsx | 2 +- src/components/Shell/WelcomeScreen.tsx | 16 +- .../SkillsRules/AgentExtensionCard.tsx | 44 +- .../SkillsRules/AgentsExtensionTab.tsx | 14 +- .../SkillsRules/CreateRuleDialog.tsx | 2 +- .../SkillsRules/InstalledSkillsList.tsx | 2 +- src/components/SkillsRules/PluginsTab.tsx | 62 +- src/components/SkillsRules/RulesTreePanel.tsx | 8 +- .../SkillsRules/SkillsRulesView.tsx | 19 +- src/components/SkillsRules/SkillsTab.tsx | 4 +- .../TaskDetail/AgentActivityTab.tsx | 12 +- .../TaskDetail/CreateTaskDialog.tsx | 8 +- src/components/TaskDetail/EpicChildTasks.tsx | 18 +- src/components/TaskDetail/GitHubTab.tsx | 38 +- .../TaskDetail/SyncToGitHubDialog.tsx | 4 +- src/components/TaskDetail/TaskBody.tsx | 8 +- src/components/TaskDetail/TaskDetailView.tsx | 4 +- .../TaskDetail/TaskMarkdownEditor.tsx | 2 +- .../TaskDetail/TaskMetadataForm.tsx | 28 +- .../TaskDetail/TaskMetadataSidebar.tsx | 44 +- src/components/TaskDetail/TaskTitle.tsx | 4 +- src/components/ai-elements/commit.tsx | 12 +- src/components/ai-elements/package-info.tsx | 9 +- src/components/ai-elements/schema-display.tsx | 19 +- src/components/ai-elements/test-results.tsx | 28 +- src/components/ai-elements/tool.tsx | 10 +- src/components/ui/BranchSelect.tsx | 16 +- src/lib/taskStatusColors.ts | 40 ++ src/styles/main.css | 6 + 111 files changed, 1430 insertions(+), 1279 deletions(-) create mode 100644 src/lib/taskStatusColors.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4419117..e9eef9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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 +- **Task Status Color Module** — Centralized `taskStatusColors.ts` exports canonical dot colors, CSS colors, and labels for all task statuses, eliminating duplicate definitions across components - **Success Toasts** — Green flash notifications (3-second auto-dismiss) for confirming actions like ACP adapter installs and session renames - **Editor Detection** — Backend probes PATH for 8 known editors and exposes `detect_editors` / `open_in_editor` IPC commands - **Project File Indexing** — New `index_project_files` Rust command recursively indexes project files (skipping hidden dirs, node_modules, target, .git) for fast client-side search @@ -28,6 +33,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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 +- **Priority Badge Styling** — Compact inline priority badges with smaller font and renamed helper (`getPriorityBadgeClass`) +- **Task Card Layout** — Increased padding, improved visual hierarchy, and inline action buttons hidden by default (revealed on hover/focus) ### Fixed 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/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/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 2a01274..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)}
diff --git a/src/components/Chat/ChatInput.tsx b/src/components/Chat/ChatInput.tsx index 2d052a4..c3ec157 100644 --- a/src/components/Chat/ChatInput.tsx +++ b/src/components/Chat/ChatInput.tsx @@ -933,11 +933,11 @@ function SuggestionOverlay({ > {cmd.icon} {cmd.label} - + {cmd.description} {cmd.isAgentCommand && ( - + agent @@ -964,7 +964,7 @@ function SuggestionOverlay({ )} {file.path} {file.is_dir && ( - + dir )} diff --git a/src/components/Chat/ChatView.tsx b/src/components/Chat/ChatView.tsx index f1a34cd..8f8e7ee 100644 --- a/src/components/Chat/ChatView.tsx +++ b/src/components/Chat/ChatView.tsx @@ -573,11 +573,11 @@ const DateGroupHeader = memo(function DateGroupHeader({ !collapsed && "rotate-90", )} /> - + {label} {collapsed && ( - + {count} )} @@ -654,7 +654,7 @@ const SessionHistorySidebar = memo(function SessionHistorySidebar({ Previous Sessions {hasData && sessions.length > 0 && ( - + {sessions.length} )} @@ -713,7 +713,7 @@ const SessionHistorySidebar = memo(function SessionHistorySidebar({

@@ -845,7 +845,7 @@ const SessionHistoryItem = memo(function SessionHistoryItem({ {/* Meta row: time, mode badge, task ID */}
{session.updated_at && ( - + {formatRelativeTime(session.updated_at)} @@ -853,7 +853,7 @@ const SessionHistoryItem = memo(function SessionHistoryItem({ {modeConfig && ( @@ -861,7 +861,7 @@ const SessionHistoryItem = memo(function SessionHistoryItem({ )} {faberMeta?.taskId && ( - + {faberMeta.taskId} )} 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 }) { {/* Status dot */} -
+
{/* Priority */}
@@ -222,26 +203,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 +240,7 @@ function TreeRow({ ) : ( )} - + {mcpData.current_step != null && mcpData.total_steps != null ? `${mcpData.current_step}/${mcpData.total_steps}` : mcpData.message || "Working"} @@ -268,7 +249,7 @@ function TreeRow({ ) : isActive ? ( <> - Starting + Starting ) : null}
@@ -417,13 +398,13 @@ export default React.memo(function DependencyGraph({
{/* Tree toolbar */}
- + {stats.rootCount} root{stats.rootCount !== 1 ? "s" : ""} {stats.depCount > 0 && ( <> · - + {stats.depCount} with dependencies @@ -431,7 +412,7 @@ export default React.memo(function DependencyGraph({ {stats.blockedCount > 0 && ( <> · - + {stats.blockedCount} blocked @@ -439,13 +420,13 @@ export default React.memo(function DependencyGraph({
diff --git a/src/components/Dashboard/EmptyState.tsx b/src/components/Dashboard/EmptyState.tsx index 296459b..351a857 100644 --- a/src/components/Dashboard/EmptyState.tsx +++ b/src/components/Dashboard/EmptyState.tsx @@ -15,7 +15,7 @@ export default function EmptyState({ onNewTask }: EmptyStateProps) {

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..10c9d36 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 +217,160 @@ 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: - - {allLabels.map((label) => ( - dispatchFilter({ type: "TOGGLE_LABEL", label })} - /> - ))} - - )} + - {/* Agent toggles */} - {allAgents.length > 0 && ( - <> - - - Agent: - - {allAgents.map((agent) => ( - dispatchFilter({ type: "TOGGLE_AGENT", agent })} - /> - ))} - - )} + {/* Priority toggles — bounded, always inline */} + + Priority: + + {priorities.map((p) => ( + dispatchFilter({ type: "TOGGLE_PRIORITY", priority: p.id })} + /> + ))} - {/* Epic toggles */} - {allEpics.length > 0 && ( - <> - - - Epic: - - {allEpics.map((epic) => ( - dispatchFilter({ type: "TOGGLE_EPIC", epicId: epic.id })} + {/* Status toggles — bounded, always inline */} + + + Status: + + {STATUSES.map((s) => ( + dispatchFilter({ type: "TOGGLE_STATUS", status: s.value })} + /> + ))} + + {/* Dropdown filters — compact triggers for unbounded lists */} + {(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..4094dcb 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]} @@ -189,11 +201,11 @@ const KanbanColumn = memo(function KanbanColumn({ {/* Column header */}
- + {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,7 @@ const KanbanColumn = memo(function KanbanColumn({ {/* Card list */}
- {columnItems.map((item) => { + {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..aaaa43f 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)}
-
-
-
-
- )} - {/* Agent row */} - {task.agent && !showMcpFooter && !isEpic && ( -
- {task.agent} -
- )} - - {/* Detailed variant: progress bar */} - {isDetailed && isSessionActive && mcpData?.current_step != null && mcpData?.total_steps != null && mcpData.total_steps > 0 && ( -
-
+ {/* Progress bar */} +
-
- )} - {/* 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 - - )} -
- + {/* Step label */} + {mcpData?.current_step != null && mcpData?.total_steps != null && mcpData.total_steps > 0 && ( + + {mcpData.current_step}/{mcpData.total_steps} + + )} +
)}
); 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({
-
+
{detail.author_email}
-
+
{formatTimestamp(detail.timestamp)}
@@ -157,7 +157,7 @@ export default function CommitDetailPanel({ {detail.subject}
{detail.body && ( -
+
{detail.body}
)} @@ -166,14 +166,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 +185,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 +208,7 @@ export default function CommitDetailPanel({ className="shrink-0" style={{ color: cfg.color }} /> - + {fileName}
diff --git a/src/components/GitHub/CommitRow.tsx b/src/components/GitHub/CommitRow.tsx index 3fc01d3..19e1f18 100644 --- a/src/components/GitHub/CommitRow.tsx +++ b/src/components/GitHub/CommitRow.tsx @@ -88,7 +88,7 @@ function CommitRowInner({ {refs?.branches.map((b) => ( {b} @@ -97,7 +97,7 @@ function CommitRowInner({ {refs?.tags.map((t) => ( {t} @@ -116,10 +116,10 @@ function CommitRowInner({ className="text-muted-foreground shrink-0" /> )} - + {commit.short_hash} - + {formatRelativeTime(commit.timestamp)}
diff --git a/src/components/GitHub/GitHubView.tsx b/src/components/GitHub/GitHubView.tsx index d40651e..995a706 100644 --- a/src/components/GitHub/GitHubView.tsx +++ b/src/components/GitHub/GitHubView.tsx @@ -9,7 +9,6 @@ import { Github, Loader2, RefreshCw, - Settings, } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; @@ -23,8 +22,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"; @@ -41,10 +38,10 @@ export default function GitHubView() { 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 handleOpenSettings = useCallback(() => setSettingsOpen(true), []); + const handleOpenSettings = useCallback(() => setActiveView("settings"), [setActiveView]); // Sync status const [syncStatus, setSyncStatus] = useState(null); @@ -193,7 +190,7 @@ export default function GitHubView() { {/* Header */} - + Git @@ -209,6 +206,7 @@ export default function GitHubView() { barRadius="md" tabRadius="md" fullWidth={false} + className="p-0" > }> Changes @@ -247,7 +245,7 @@ export default function GitHubView() { {/* Pull button */} - {/* GitHub settings */} - - {/* GitHub Settings Dialog */} - - - - GitHub Settings - -
- -
-
-
- {/* Content card */}
+
{error}
)} diff --git a/src/components/GitHub/IssueDetailPanel.tsx b/src/components/GitHub/IssueDetailPanel.tsx index 608a651..e703a0f 100644 --- a/src/components/GitHub/IssueDetailPanel.tsx +++ b/src/components/GitHub/IssueDetailPanel.tsx @@ -101,7 +101,7 @@ export default function IssueDetailPanel({
{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 +145,7 @@ export default function IssueDetailPanel({
)} -
+
Opened {formatRelativeTime(detail.issue.created_at)} {detail.issue.updated_at !== detail.issue.created_at && ( @@ -160,7 +160,7 @@ export default function IssueDetailPanel({ {detail.issue.labels.map((label) => ( -
+
Description
-
+
{detail.issue.body}
@@ -187,10 +187,10 @@ export default function IssueDetailPanel({ {!detail.issue.body && (
-
+
Description
-
+
No description provided
@@ -198,13 +198,13 @@ export default function IssueDetailPanel({ {/* Comments */}
-
+
Comments ({detail.comments.length})
{detail.comments.length === 0 && ( -
+
No comments yet
)} @@ -225,15 +225,15 @@ export default function IssueDetailPanel({ ) : ( )} - + {comment.author} - + {formatRelativeTime(comment.created_at)}
{/* Comment body */} -
+
{comment.body}
diff --git a/src/components/GitHub/IssuesTab.tsx b/src/components/GitHub/IssuesTab.tsx index e97f8e9..a3f0146 100644 --- a/src/components/GitHub/IssuesTab.tsx +++ b/src/components/GitHub/IssuesTab.tsx @@ -134,7 +134,7 @@ export default function IssuesTab({ projectId, hasRemote, onOpenSettings }: Issu
-

+

{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..d942fab 100644 --- a/src/components/Launchers/LaunchSessionDialog.tsx +++ b/src/components/Launchers/LaunchSessionDialog.tsx @@ -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"} @@ -242,14 +242,14 @@ export default function LaunchSessionDialog({ {/* Worktree toggle */}

-
@@ -268,7 +268,7 @@ export default function LaunchSessionDialog({ onChange={setSelectedBranch} triggerVariant="select" /> -

+

The worktree branch will be created from this branch

@@ -284,7 +284,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..c35a5df 100644 --- a/src/components/Launchers/LaunchTaskDialog.tsx +++ b/src/components/Launchers/LaunchTaskDialog.tsx @@ -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"} @@ -280,14 +280,14 @@ export default function LaunchTaskDialog({ {/* Worktree toggle */}

-
@@ -306,7 +306,7 @@ export default function LaunchTaskDialog({ onChange={setBaseBranch} triggerVariant="select" /> -

+

The worktree branch will be created from this branch

@@ -322,7 +322,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..5781f17 100644 --- a/src/components/Review/DiffView.tsx +++ b/src/components/Review/DiffView.tsx @@ -113,8 +113,8 @@ export default function DiffView({
{feedback.text} diff --git a/src/components/Review/FileList.tsx b/src/components/Review/FileList.tsx index 257afe0..72f2f16 100644 --- a/src/components/Review/FileList.tsx +++ b/src/components/Review/FileList.tsx @@ -12,6 +12,7 @@ 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"; @@ -111,9 +112,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 +122,7 @@ function FileRow({ {/* Status badge */} (null); @@ -215,10 +217,10 @@ export default function FileList({ )} - + Committed - + {committedFiles.length}
@@ -260,10 +262,10 @@ export default function FileList({ className="size-3.5" /> )} - + Changes - + {someStaged && `${stagedFiles.length}/`} {changedFiles.length} @@ -271,7 +273,7 @@ export default function FileList({ {changesExpanded && (
{!hasChanges ? ( -
+
No uncommitted changes
) : ( @@ -310,11 +312,11 @@ export default function FileList({ : "Stage files to commit" } disabled={!someStaged || committing} - className="min-w-0 flex-1 rounded-[var(--radius-element)] border border-border bg-popover px-2 py-1 text-[11px] text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none disabled:opacity-50" + className="min-w-0 flex-1 rounded-[var(--radius-element)] border border-border bg-popover px-2 py-1 text-xs text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none disabled:opacity-50" />
{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/Sessions/QuickActionBar.tsx b/src/components/Sessions/QuickActionBar.tsx index 1f393ed..e679713 100644 --- a/src/components/Sessions/QuickActionBar.tsx +++ b/src/components/Sessions/QuickActionBar.tsx @@ -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/SessionPane.tsx b/src/components/Sessions/SessionPane.tsx index a3d9681..85c3f8a 100644 --- a/src/components/Sessions/SessionPane.tsx +++ b/src/components/Sessions/SessionPane.tsx @@ -215,7 +215,7 @@ export default React.memo(function SessionPane({ {displayName} {showSaved && ( - + Saved )} @@ -243,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} )} @@ -260,7 +260,7 @@ export default React.memo(function SessionPane({ {/* Permission badge (when ACP permission requests are pending) */} {showPermissionState && ( - + Approval needed )} @@ -289,12 +289,12 @@ export default React.memo(function SessionPane({ }} /> {showErrorState && mcpData?.error_message && ( - + {mcpData.error_message} )} {showWaitingState && mcpData?.waiting_question && ( - + {mcpData.waiting_question} )} diff --git a/src/components/Sessions/SessionsEmptyState.tsx b/src/components/Sessions/SessionsEmptyState.tsx index 18150f4..7a428ae 100644 --- a/src/components/Sessions/SessionsEmptyState.tsx +++ b/src/components/Sessions/SessionsEmptyState.tsx @@ -41,7 +41,7 @@ export default function SessionsEmptyState({
No active sessions
-
+
Start an agent session or open a plain terminal to get started.
diff --git a/src/components/Sessions/SessionsToolbar.tsx b/src/components/Sessions/SessionsToolbar.tsx index 766884a..72a1c9c 100644 --- a/src/components/Sessions/SessionsToolbar.tsx +++ b/src/components/Sessions/SessionsToolbar.tsx @@ -64,7 +64,7 @@ const SessionsToolbar = memo(function SessionsToolbar({ return ( - + Sessions @@ -85,6 +85,7 @@ const SessionsToolbar = memo(function SessionsToolbar({ align="start" barRadius="md" tabRadius="md" + className="p-0" > {MODES.map((m) => { const Icon = m.icon; @@ -103,7 +104,7 @@ const SessionsToolbar = memo(function SessionsToolbar({ )}
-

- Rules with patterns (e.g. src/**) +

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

@@ -299,13 +299,13 @@ export function AcpPermissionsTab() { >
- + {capCfg?.label ?? rule.capability} - + {pattern || "*"} - + {actionCfg.label}
@@ -320,7 +320,7 @@ export function AcpPermissionsTab() { })}
) : ( -

+

No rules configured. The default policy will be used for all requests.

)} @@ -328,11 +328,11 @@ export function AcpPermissionsTab() { {/* Add rule form */}
{/* Row 1: labels */} - Capability - + Capability + {patternConfig.label} (optional) - Action + Action {/* Row 2: controls */} @@ -404,7 +404,7 @@ export function AcpPermissionsTab() { {/* ── Default Policy ── */}

Default Policy

-

+

Fallback action when no rule matches a permission request.

{/* Description for selected option */} -

+

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

@@ -459,7 +459,7 @@ export function AcpPermissionsTab() { {/* ── Permission Timeout ── */}

Prompt Timeout

-

+

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

@@ -472,7 +472,7 @@ export function AcpPermissionsTab() { onChange={(e) => updatePermissionTimeout(parseInt(e.target.value, 10) || 120)} className={`${inputClass} w-24 h-8`} /> - seconds + seconds
@@ -491,20 +491,20 @@ export function AcpPermissionsTab() { return (
{capCfg?.label ?? entry.capability} - + {entry.detail || "\u2014"} - + {isAuto ? "auto-" : ""} {isApproved ? "approved" : "denied"} @@ -513,7 +513,7 @@ export function AcpPermissionsTab() { })}
) : ( -

+

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 c5dece8..9e4112f 100644 --- a/src/components/Settings/AgentsTab.tsx +++ b/src/components/Settings/AgentsTab.tsx @@ -175,10 +175,10 @@ function AgentCard({ agent }: { agent: AgentInfo }) { {/* Name + command */}
- + {agent.display_name} - + {agent.command}
@@ -188,7 +188,7 @@ function AgentCard({ agent }: { agent: AgentInfo }) { - + {agent.installed ? "Detected" : "Not found"} @@ -198,7 +198,7 @@ function AgentCard({ agent }: { agent: AgentInfo }) { target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()} - className="flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-medium text-primary transition-colors hover:bg-primary/20" + className="flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary transition-colors hover:bg-primary/20" > Install @@ -219,7 +219,7 @@ function AgentCard({ agent }: { agent: AgentInfo }) {
- + {agent.cli_install_hint} {agent.cli_install_url && ( @@ -244,12 +244,12 @@ function AgentCard({ agent }: { agent: AgentInfo }) { {permInfo && (
- + Permissions Security @@ -265,7 +265,7 @@ function AgentCard({ agent }: { agent: AgentInfo }) { {/* Custom flags */}
- + Custom Flags @@ -279,7 +279,7 @@ function AgentCard({ agent }: { agent: AgentInfo }) { placeholder={"e.g., --verbose --model opus"} /> -
+
Additional flags appended to every {agent.display_name} session.
@@ -287,7 +287,7 @@ function AgentCard({ agent }: { agent: AgentInfo }) { {/* Command preview + reset */}
- + {commandPreview}
@@ -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 index 655dcc0..d94e100 100644 --- a/src/components/Settings/ProjectTab.tsx +++ b/src/components/Settings/ProjectTab.tsx @@ -325,7 +325,7 @@ export function ProjectTab() {
{/* Icon */}
- + Icon
@@ -342,7 +342,7 @@ export function ProjectTab() { size="sm" onClick={handlePickIcon} leftIcon={} - className="h-6 px-2 text-[11px]" + className="h-6 px-2 text-xs" > Choose SVG @@ -352,13 +352,13 @@ export function ProjectTab() { 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"} @@ -369,7 +369,7 @@ export function ProjectTab() { {/* Color */}
- + Color
@@ -405,7 +405,7 @@ export function ProjectTab() { {/* Agent + Model row */}
- + Agent - + Transport
@@ -501,7 +501,7 @@ export function ProjectTab() { Chat
- + Pre-selects the transport mode when launching new sessions
@@ -528,7 +528,7 @@ export function ProjectTab() {
- + Define priority levels for this project. ID is stored in task files, label is shown in the UI.
@@ -635,10 +635,10 @@ export function ProjectTab() { {/* ── Danger Zone ── */}
-
+
Delete Project
-
+
Remove from Faber. Files on disk are not affected.
diff --git a/src/components/Settings/ProjectsTab.tsx b/src/components/Settings/ProjectsTab.tsx index b67dead..47abf48 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}
@@ -114,7 +114,7 @@ function ProjectRow({
-