diff --git a/.gitignore b/.gitignore index 98f0b98c..d16f70ec 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ dist/ # dotenv environment variables file .env +.env.local # .vscode workspace settings file (keep extensions.json and settings.json for team consistency) .vscode/launch.json diff --git a/.rules b/AGENTS.md similarity index 95% rename from .rules rename to AGENTS.md index 3375d956..315ec245 100644 --- a/.rules +++ b/AGENTS.md @@ -3,10 +3,11 @@ - Commits should have the first character uppercased - Do not prefix unused variables with an underscore, delete them instead - Do not use emojis in commit messages, logs, or documentation -- Never change the .rules file unless the user specifically asks for it +- Never change the AGENTS.md file unless the user specifically asks for it - Avoid unnecessary comments in UI components (keep code self-explanatory) - Avoid unnecessary `cn(...)` calls: use it only for conditional or merged class names; do not wrap static strings - Always use bun. +- PR descriptions should be simple, natural language, no headers or sections, just a few bullet points describing what changed and why. ## Zustand @@ -30,16 +31,19 @@ This project uses Zustand for state management with specific patterns: All theme colors are defined as CSS variables following this structure: **Variable Naming Convention:** + - Use semantic names without prefixes: `--primary-bg`, `--text`, `--accent` - No `--tw-` prefix (this was removed during standardization) - Variables are defined in `:root` with system theme fallbacks via `@media (prefers-color-scheme: dark)` **Tailwind Integration:** + - CSS variables map to Tailwind colors via `@theme inline` directive - Use pattern: `--color-{name}: var(--{name})` - Enables utilities like `bg-primary-bg`, `text-text`, `border-border` **Theme System:** + - All themes (including built-ins) are defined in JSON files in `src/extensions/themes/builtin/` - Themes override CSS variables via the Theme Registry - No CSS classes for themes - pure variable-based theming diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 120000 index 8a63b64b..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -.rules \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index cddb09ec..0b15efcb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -576,6 +576,7 @@ dependencies = [ "nucleo", "nucleo-matcher", "objc", + "percent-encoding", "portable-pty", "rand 0.8.5", "redis", diff --git a/GEMINI.md b/GEMINI.md deleted file mode 120000 index 8a63b64b..00000000 --- a/GEMINI.md +++ /dev/null @@ -1 +0,0 @@ -.rules \ No newline at end of file diff --git a/crates/ai/src/acp/bridge_init.rs b/crates/ai/src/acp/bridge_init.rs index f684de46..eb27e700 100644 --- a/crates/ai/src/acp/bridge_init.rs +++ b/crates/ai/src/acp/bridge_init.rs @@ -149,6 +149,12 @@ fn spawn_agent_process( .stdout(Stdio::piped()) .stderr(Stdio::piped()); + // Augment PATH with user's shell PATH for bundled app launches + if let Some(shell_path) = super::config::user_shell_path() { + let current = std::env::var("PATH").unwrap_or_default(); + cmd.env("PATH", format!("{current}:{shell_path}")); + } + let uses_npx_codex_adapter = binary.ends_with("npx") && config .args diff --git a/crates/ai/src/acp/config.rs b/crates/ai/src/acp/config.rs index 271110cc..e83f0a66 100644 --- a/crates/ai/src/acp/config.rs +++ b/crates/ai/src/acp/config.rs @@ -3,6 +3,8 @@ use std::{ collections::HashMap, env, fs, path::{Path, PathBuf}, + process::Command, + sync::OnceLock, time::Instant, }; use tauri::{AppHandle, Manager}; @@ -10,6 +12,26 @@ use tauri::{AppHandle, Manager}; /// Cache duration for binary detection (60 seconds) const DETECTION_CACHE_SECONDS: u64 = 60; +/// Get the user's login shell PATH. Bundled apps inherit a minimal PATH, +/// so we source the full one from the user's shell and cache it. +pub(crate) fn user_shell_path() -> Option<&'static str> { + static CACHED: OnceLock> = OnceLock::new(); + CACHED + .get_or_init(|| { + if cfg!(target_os = "windows") { + return None; + } + let shell = env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string()); + let output = Command::new(&shell) + .args(["-ilc", "echo $PATH"]) + .output() + .ok()?; + let path = String::from_utf8(output.stdout).ok()?.trim().to_string(); + if path.is_empty() { None } else { Some(path) } + }) + .as_deref() +} + /// Registry of known ACP-compatible agents #[derive(Clone)] pub struct AgentRegistry { @@ -195,11 +217,17 @@ fn find_binary(binary_name: &str) -> Option { let mut candidates: Vec = Vec::new(); - // PATH entries + // PATH entries from the current process if let Some(paths) = env::var_os("PATH") { candidates.extend(env::split_paths(&paths)); } + // Bundled apps inherit a restricted PATH. Source the user's login shell + // to get the full PATH (cached for the process lifetime). + if let Some(shell_path) = user_shell_path() { + candidates.extend(env::split_paths(&std::ffi::OsString::from(shell_path))); + } + // Common global bin locations if let Some(home) = env::var_os("HOME") { let home = PathBuf::from(home); diff --git a/crates/github/src/api.rs b/crates/github/src/api.rs index f48e2c67..3a2fb827 100644 --- a/crates/github/src/api.rs +++ b/crates/github/src/api.rs @@ -1,28 +1,50 @@ use crate::{ - cli::{get_github_username, gh_command}, + cli::{get_github_username, gh_command, resolve_gh_binary}, models::{ IssueDetails, IssueListItem, PullRequest, PullRequestComment, PullRequestDetails, PullRequestFile, WorkflowRunDetails, WorkflowRunListItem, }, }; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::path::Path; use tauri::AppHandle; -pub fn github_check_cli_auth(app: AppHandle) -> Result { +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub enum GitHubCliStatus { + Authenticated, + NotAuthenticated, + NotInstalled, +} + +pub fn github_check_cli_status(app: AppHandle) -> Result { + let binary = resolve_gh_binary(); + let exe_name = if cfg!(target_os = "windows") { + "gh.exe" + } else { + "gh" + }; + + // If resolve returned the bare name, check if it's actually reachable + if binary == exe_name + && std::process::Command::new(&binary) + .arg("--version") + .output() + .is_err() + { + return Ok(GitHubCliStatus::NotInstalled); + } + let output = gh_command(&app, None) .args(["auth", "status"]) .output() .map_err(|e| format!("Failed to execute gh command: {}", e))?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - if !stderr.trim().is_empty() { - log::warn!("GitHub CLI auth check failed: {}", stderr.trim()); - } + if output.status.success() { + Ok(GitHubCliStatus::Authenticated) + } else { + Ok(GitHubCliStatus::NotAuthenticated) } - - Ok(output.status.success()) } pub fn github_list_prs( diff --git a/crates/github/src/cli.rs b/crates/github/src/cli.rs index c4e2f85c..e28ef35d 100644 --- a/crates/github/src/cli.rs +++ b/crates/github/src/cli.rs @@ -3,11 +3,64 @@ use std::{ ffi::OsStr, path::{Path, PathBuf}, process::Command, + sync::OnceLock, }; use tauri::{AppHandle, Manager}; +/// Get the user's login shell PATH by running `$SHELL -ilc 'echo $PATH'`. +/// Cached for the lifetime of the process since the user's PATH doesn't change. +fn user_shell_path() -> Option<&'static str> { + static CACHED: OnceLock> = OnceLock::new(); + CACHED + .get_or_init(|| { + let shell = env::var("SHELL").unwrap_or_else(|_| { + if cfg!(target_os = "windows") { + return String::new(); + } + "/bin/zsh".to_string() + }); + if shell.is_empty() { + return None; + } + let output = Command::new(&shell) + .args(["-ilc", "echo $PATH"]) + .output() + .ok()?; + let path = String::from_utf8(output.stdout).ok()?.trim().to_string(); + if path.is_empty() { None } else { Some(path) } + }) + .as_deref() +} + +/// Find the `gh` binary. On bundled apps the inherited PATH is minimal, +/// so we resolve the full PATH from the user's login shell first. +pub(crate) fn resolve_gh_binary() -> String { + let exe = if cfg!(target_os = "windows") { + "gh.exe" + } else { + "gh" + }; + + // Combine current PATH with the user's shell PATH + let combined = match (env::var("PATH").ok(), user_shell_path()) { + (Some(current), Some(shell)) => format!("{current}:{shell}"), + (Some(current), None) => current, + (None, Some(shell)) => shell.to_string(), + (None, None) => String::new(), + }; + + for dir in env::split_paths(&combined) { + if dir.join(exe).exists() { + return dir.join(exe).to_string_lossy().into_owned(); + } + } + + // Fall back to bare name and let the OS try + exe.to_string() +} + pub(crate) fn gh_command(app: &AppHandle, repo_dir: Option<&Path>) -> Command { - let mut command = Command::new("gh"); + let mut command = Command::new(resolve_gh_binary()); if let Some(dir) = repo_dir { command.current_dir(dir); diff --git a/crates/github/src/lib.rs b/crates/github/src/lib.rs index 05218d50..f6b0a08e 100644 --- a/crates/github/src/lib.rs +++ b/crates/github/src/lib.rs @@ -4,10 +4,10 @@ mod models; mod serde_helpers; pub use api::{ - github_check_cli_auth, github_checkout_pr, github_get_current_user, github_get_issue_details, - github_get_pr_comments, github_get_pr_details, github_get_pr_diff, github_get_pr_files, - github_get_workflow_run_details, github_list_issues, github_list_prs, github_list_workflow_runs, - github_open_pr_in_browser, + GitHubCliStatus, github_check_cli_status, github_checkout_pr, github_get_current_user, + github_get_issue_details, github_get_pr_comments, github_get_pr_details, github_get_pr_diff, + github_get_pr_files, github_get_workflow_run_details, github_list_issues, github_list_prs, + github_list_workflow_runs, github_open_pr_in_browser, }; pub use models::{ IssueComment, IssueDetails, IssueListItem, Label, LinkedIssue, PullRequest, PullRequestAuthor, diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f2a08c62..f5c9f80f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -39,6 +39,7 @@ athas-ai = { path = "../crates/ai" } athas-github = { path = "../crates/github" } mimalloc = { version = "0.1", default-features = false } log = "0.4.27" +percent-encoding = "2" lsp-types = { version = "0.95", features = ["proposed"] } portable-pty = "0.9" regex = "1.10" diff --git a/src-tauri/src/commands/ui/window.rs b/src-tauri/src/commands/ui/window.rs index 7aa31e2d..47a83e33 100644 --- a/src-tauri/src/commands/ui/window.rs +++ b/src-tauri/src/commands/ui/window.rs @@ -396,6 +396,92 @@ pub async fn set_webview_zoom( } } +#[derive(Serialize)] +pub struct WebviewMetadata { + pub title: String, + pub favicon: Option, +} + +#[command] +pub async fn poll_webview_metadata( + app: tauri::AppHandle, + webview_label: String, +) -> Result, String> { + if let Some(webview) = app.get_webview(&webview_label) { + // Store metadata in a dedicated global (does not touch location.hash + // to avoid conflicts with the shortcut polling mechanism). + webview + .eval( + r#" + (function() { + var t = document.title || ''; + var icon = ''; + var el = document.querySelector('link[rel~="icon"]') || document.querySelector('link[rel="shortcut icon"]'); + if (el && el.href) { icon = el.href; } + window.__ATHAS_PAGE_META__ = JSON.stringify({t:t,i:icon}); + })(); + "#, + ) + .map_err(|e| format!("Failed to get metadata: {e}"))?; + + tokio::time::sleep(tokio::time::Duration::from_millis(20)).await; + + // Read back via a hash round-trip (single step) + webview + .eval( + r#" + (function() { + var m = window.__ATHAS_PAGE_META__; + window.__ATHAS_PAGE_META__ = null; + if (m) window.location.hash = '__athas_meta=' + encodeURIComponent(m); + })(); + "#, + ) + .map_err(|e| format!("Failed to read metadata: {e}"))?; + + tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; + + let url = webview + .url() + .map_err(|e| format!("Failed to get URL: {e}"))?; + let hash = url.fragment().unwrap_or(""); + + if let Some(encoded) = hash.strip_prefix("__athas_meta=") { + webview + .eval("window.location.hash = '';") + .map_err(|e| format!("Failed to clear hash: {e}"))?; + + let decoded = percent_encoding::percent_decode_str(encoded) + .decode_utf8() + .unwrap_or_default(); + + #[derive(Deserialize)] + struct Meta { + t: String, + i: String, + } + + if let Ok(meta) = serde_json::from_str::(&decoded) { + if meta.t.is_empty() { + return Ok(None); + } + return Ok(Some(WebviewMetadata { + title: meta.t, + favicon: if meta.i.is_empty() { + None + } else { + Some(meta.i) + }, + })); + } + } + + Ok(None) + } else { + Err(format!("Webview not found: {webview_label}")) + } +} + #[command] pub async fn poll_webview_shortcut( app: tauri::AppHandle, diff --git a/src-tauri/src/commands/version_control/github.rs b/src-tauri/src/commands/version_control/github.rs index 3e378c8a..16d5994b 100644 --- a/src-tauri/src/commands/version_control/github.rs +++ b/src-tauri/src/commands/version_control/github.rs @@ -15,8 +15,10 @@ where } #[tauri::command] -pub async fn github_check_cli_auth(app: tauri::AppHandle) -> Result { - run_blocking(move || athas_github::github_check_cli_auth(app)).await +pub async fn github_check_cli_auth( + app: tauri::AppHandle, +) -> Result { + run_blocking(move || athas_github::github_check_cli_status(app)).await } #[tauri::command] diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 275ed4f4..ec8957e5 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -139,6 +139,7 @@ fn main() { open_webview_devtools, set_webview_zoom, poll_webview_shortcut, + poll_webview_metadata, // File watcher commands start_watching, stop_watching, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 4fc19d94..3b81ee26 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -29,7 +29,7 @@ } ], "security": { - "csp": "default-src 'self' ipc: http://ipc.localhost; connect-src 'self' http://localhost:3000 http://127.0.0.1:3000 https://athas.dev https://*.athas.dev https://api.anthropic.com https://api.openai.com https://generativelanguage.googleapis.com https://*.githubusercontent.com; img-src 'self' asset: http://asset.localhost data: blob:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'wasm-unsafe-eval'", + "csp": "default-src 'self' ipc: http://ipc.localhost; connect-src 'self' http://localhost:3000 http://127.0.0.1:3000 https://athas.dev https://*.athas.dev https://api.anthropic.com https://api.openai.com https://generativelanguage.googleapis.com https://*.githubusercontent.com; img-src 'self' asset: http://asset.localhost https: data: blob:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'wasm-unsafe-eval'", "capabilities": ["main-capability"], "assetProtocol": { "enable": true, diff --git a/src/extensions/ui/services/ui-extension-generation-service.ts b/src/extensions/ui/services/ui-extension-generation-service.ts index 432399ad..783daaa5 100644 --- a/src/extensions/ui/services/ui-extension-generation-service.ts +++ b/src/extensions/ui/services/ui-extension-generation-service.ts @@ -1,7 +1,8 @@ import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; import { getAuthToken } from "@/features/window/services/auth-api"; +import { getApiBase } from "@/utils/api-base"; -const API_BASE = import.meta.env.VITE_API_URL || "https://athas.dev"; +const API_BASE = getApiBase(); export type UIExtensionContributionType = "sidebar" | "toolbar" | "command"; diff --git a/src/features/ai/components/input/chat-input-bar.tsx b/src/features/ai/components/input/chat-input-bar.tsx index 77a3548c..5de52f63 100644 --- a/src/features/ai/components/input/chat-input-bar.tsx +++ b/src/features/ai/components/input/chat-input-bar.tsx @@ -796,7 +796,9 @@ const AIChatInputBar = memo(function AIChatInputBar({ if (event.error === "not-allowed" || event.error === "service-not-allowed") { shouldKeepListeningRef.current = false; - toast.error("Microphone permission was denied."); + toast.error( + "Microphone access failed. Check System Settings → Privacy & Security → Microphone.", + ); return; } diff --git a/src/features/ai/components/mentions/file-mention-dropdown.tsx b/src/features/ai/components/mentions/file-mention-dropdown.tsx index 2301a31b..36ae3811 100644 --- a/src/features/ai/components/mentions/file-mention-dropdown.tsx +++ b/src/features/ai/components/mentions/file-mention-dropdown.tsx @@ -20,7 +20,7 @@ interface FileMentionDropdownProps { onSelect: (file: FileEntry) => void; } -const MAX_RESULTS = 20; +const MAX_RESULTS = 10; const ATTACHED_DROPDOWN_GAP = -1; export const FileMentionDropdown = React.memo(function FileMentionDropdown({ @@ -169,7 +169,7 @@ export const FileMentionDropdown = React.memo(function FileMentionDropdown({ transition={{ duration: 0.15, ease: "easeOut" }} className="fixed z-[10040] flex select-none flex-col overflow-hidden rounded-t-2xl rounded-b-xl border border-border/70 bg-primary-bg/98 shadow-[0_14px_32px_-26px_rgba(0,0,0,0.5)] backdrop-blur-sm" style={{ - maxHeight: `${EDITOR_CONSTANTS.BREADCRUMB_DROPDOWN_MAX_HEIGHT}px`, + maxHeight: "220px", width: `${adjustedPosition.width}px`, left: `${adjustedPosition.left}px`, top: `${adjustedPosition.top}px`, diff --git a/src/features/ai/components/messages/markdown-renderer.tsx b/src/features/ai/components/messages/markdown-renderer.tsx index 310b20d7..6a02abbd 100644 --- a/src/features/ai/components/messages/markdown-renderer.tsx +++ b/src/features/ai/components/messages/markdown-renderer.tsx @@ -423,11 +423,11 @@ function ErrorBlock({ errorData }: { errorData: string }) { }; return ( -
+
- Error - {summary} - {code ? ({code}) : null} + Error + {summary} + {code ? ({code}) : null} {normalizedDetails && ( {
{gitTabs.map((tab) => { const Icon = tab.icon; @@ -778,7 +778,7 @@ const GitView = ({ repoPath, onFileSelect, isActive }: GitViewProps) => { size="md" variant="segmented" contentLayout="stacked" - className="h-full min-h-10 min-w-0 px-2.5 py-2 transition-colors [&>div]:gap-1.5" + className="h-full min-h-10 min-w-0 rounded-lg px-2.5 py-2 transition-colors [&>div]:gap-1.5" >
diff --git a/src/features/github/components/commit-item.tsx b/src/features/github/components/commit-item.tsx index 1bdb60e7..e2230d9d 100644 --- a/src/features/github/components/commit-item.tsx +++ b/src/features/github/components/commit-item.tsx @@ -1,3 +1,4 @@ +import { openUrl } from "@tauri-apps/plugin-opener"; import { Copy, ExternalLink } from "lucide-react"; import { memo } from "react"; import { Button } from "@/ui/button"; @@ -61,7 +62,7 @@ export const CommitItem = memo(({ commit, issueBaseUrl, repoPath }: CommitItemPr {commit.url && ( + | + +
+
+ ); + } + + return ( +
+ +

GitHub CLI not authenticated

+

+ Run gh auth login in terminal +

+ +
+ ); +} diff --git a/src/features/github/components/github-issue-viewer.tsx b/src/features/github/components/github-issue-viewer.tsx index b2b77829..763dc2d9 100644 --- a/src/features/github/components/github-issue-viewer.tsx +++ b/src/features/github/components/github-issue-viewer.tsx @@ -1,4 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; +import { openUrl } from "@tauri-apps/plugin-opener"; import { Copy, ExternalLink, MessageSquare, RefreshCw } from "lucide-react"; import { memo, useCallback, useEffect, useMemo, useState } from "react"; import { useBufferStore } from "@/features/editor/stores/buffer-store"; @@ -147,7 +148,7 @@ const GitHubIssueViewer = memo(({ issueNumber, repoPath, bufferId }: GitHubIssue toast.error("Issue link is not available."); return; } - window.open(details.url, "_blank", "noopener,noreferrer"); + void openUrl(details.url); }, [details?.url]); const handleCopyIssueLink = useCallback(() => { diff --git a/src/features/github/components/github-issues-view.tsx b/src/features/github/components/github-issues-view.tsx index 4911c038..79991b3b 100644 --- a/src/features/github/components/github-issues-view.tsx +++ b/src/features/github/components/github-issues-view.tsx @@ -1,5 +1,6 @@ import { invoke } from "@tauri-apps/api/core"; import { AlertCircle, MessageSquare } from "lucide-react"; +import { GitHubCliStatusMessage } from "./github-cli-status"; import { memo, startTransition, @@ -168,11 +169,8 @@ const GitHubIssuesView = memo(({ refreshNonce = 0 }: GitHubIssuesViewProps) => { if (!isAuthenticated) { return ( -
-
- -

GitHub CLI not authenticated

-
+
+
); } diff --git a/src/features/github/components/github-prs-view.tsx b/src/features/github/components/github-prs-view.tsx index da06ad83..0e32a492 100644 --- a/src/features/github/components/github-prs-view.tsx +++ b/src/features/github/components/github-prs-view.tsx @@ -1,4 +1,5 @@ import { open } from "@tauri-apps/plugin-dialog"; +import { GitHubCliStatusMessage } from "./github-cli-status"; import { AlertCircle, Activity, @@ -401,22 +402,7 @@ const GitHubPRsView = memo(() => {
GitHub
-
- -

GitHub CLI not authenticated

-

- Run gh auth login in terminal -

- -
+
); } diff --git a/src/features/github/components/pr-viewer-header.tsx b/src/features/github/components/pr-viewer-header.tsx index 6e514537..ad837386 100644 --- a/src/features/github/components/pr-viewer-header.tsx +++ b/src/features/github/components/pr-viewer-header.tsx @@ -16,7 +16,9 @@ import type { PullRequestDetails } from "../types/github"; interface PRViewerHeaderProps { pr: PullRequestDetails; activeView: "activity" | "files"; - changesSummary: string; + changedFilesCount: number; + additions: number; + deletions: number; checksSummary: string; reviewerLogins: string[]; reviewSummary: string | null; @@ -47,7 +49,9 @@ function OverviewField({ icon, children }: OverviewFieldProps) { export function PRViewerHeader({ pr, activeView, - changesSummary, + changedFilesCount, + additions, + deletions, checksSummary, reviewerLogins, reviewSummary, @@ -154,13 +158,15 @@ export function PRViewerHeader({ variant="ghost" size="sm" active={activeView === "files"} - className="h-auto min-w-0 rounded-md px-1.5 py-1 text-left" + className="ui-text-sm h-auto min-w-0 rounded-md px-1.5 py-1 text-left" > Changes - {changesSummary} + {changedFilesCount} files + +{additions} + -{deletions} }> diff --git a/src/features/github/components/pr-viewer.tsx b/src/features/github/components/pr-viewer.tsx index 89f9673f..78dd3a22 100644 --- a/src/features/github/components/pr-viewer.tsx +++ b/src/features/github/components/pr-viewer.tsx @@ -87,13 +87,6 @@ const PRViewer = memo(({ prNumber }: PRViewerProps) => { } return; } - - if (selectedFilePath !== null) { - setSelectedFilePath(null); - } - if (activeTab === "files") { - setActiveTab("activity"); - } }, [activeTab, prBuffer?.path, selectedFilePath]); useEffect(() => { @@ -318,8 +311,11 @@ const PRViewer = memo(({ prNumber }: PRViewerProps) => { if (repoPath) { try { await checkoutPR(repoPath, prNumber); + toast.success(`Checked out PR #${prNumber}`); + window.dispatchEvent(new CustomEvent("git-status-updated")); } catch (err) { console.error("Failed to checkout PR:", err); + toast.error(err instanceof Error ? err.message : `Failed to checkout PR #${prNumber}`); } } }, [repoPath, prNumber, checkoutPR]); @@ -437,7 +433,6 @@ const PRViewer = memo(({ prNumber }: PRViewerProps) => { : pr.mergeable === "CONFLICTING" ? "Has conflicts" : "No checks reported"; - const changesSummary = `${changedFilesCount} files +${pr.additions} -${pr.deletions}`; const reviewSummary = pr.reviewDecision === "CHANGES_REQUESTED" ? "changes requested" @@ -469,7 +464,9 @@ const PRViewer = memo(({ prNumber }: PRViewerProps) => { ("github_check_cli_auth"); - if (isAuth) { + const status = await invoke("github_check_cli_auth"); + if (status === "authenticated") { const user = await invoke("github_get_current_user"); - set({ isAuthenticated: true, currentUser: user, error: null }); + set({ isAuthenticated: true, cliStatus: status, currentUser: user, error: null }); } else { - set({ isAuthenticated: false, currentUser: null }); + set({ isAuthenticated: false, cliStatus: status, currentUser: null }); } authCheckedAt = Date.now(); } catch { - set({ isAuthenticated: false, currentUser: null }); + set({ isAuthenticated: false, cliStatus: "notInstalled", currentUser: null }); authCheckedAt = Date.now(); } }, diff --git a/src/features/layout/components/empty-editor-state.tsx b/src/features/layout/components/empty-editor-state.tsx index 10133e8e..5232186f 100644 --- a/src/features/layout/components/empty-editor-state.tsx +++ b/src/features/layout/components/empty-editor-state.tsx @@ -71,6 +71,11 @@ export function EmptyEditorState() { setIsDatabaseConnectionVisible(true); }, [setIsDatabaseConnectionVisible]); + const handleNewFile = useCallback(() => { + const id = `untitled-${Date.now()}`; + openBuffer(id, "Untitled", "", false, undefined, false, true); + }, [openBuffer]); + const handleOpenFile = useCallback(async () => { try { const selected = await open({ @@ -142,6 +147,12 @@ export function EmptyEditorState() { const getContextMenuItems = useCallback((): ContextMenuItem[] => { return [ + { + id: "new-file", + label: "New File", + icon: , + onClick: handleNewFile, + }, { id: "open-folder", label: "Open Folder", @@ -181,6 +192,7 @@ export function EmptyEditorState() { }, ]; }, [ + handleNewFile, handleOpenFolder, handleOpenFile, handleOpenTerminal, @@ -190,6 +202,12 @@ export function EmptyEditorState() { ]); const actions: ActionItem[] = [ + { + id: "new-file", + label: "New File", + icon: , + action: handleNewFile, + }, { id: "folder", label: "Open Folder", diff --git a/src/features/layout/components/footer/footer.tsx b/src/features/layout/components/footer/footer.tsx index fd5b4efe..3ffda707 100644 --- a/src/features/layout/components/footer/footer.tsx +++ b/src/features/layout/components/footer/footer.tsx @@ -20,6 +20,7 @@ import { useSettingsStore } from "@/features/settings/store"; import { useAuthStore } from "@/features/window/stores/auth-store"; import Badge from "@/ui/badge"; import { Button } from "@/ui/button"; +import { getApiBase } from "@/utils/api-base"; import { cn } from "@/utils/cn"; import { useUIState } from "@/features/window/stores/ui-state-store"; import { useDesktopSignIn } from "@/features/window/hooks/use-desktop-sign-in"; @@ -152,7 +153,7 @@ const AiUsageStatusIndicator = () => { }; const openBillingDashboard = async () => { - const apiBase = import.meta.env.VITE_API_URL || "https://athas.dev"; + const apiBase = getApiBase(); const billingUrl = new URL("/dashboard/billing", apiBase).toString(); const { openUrl } = await import("@tauri-apps/plugin-opener"); await openUrl(billingUrl); diff --git a/src/features/panes/components/pane-container.tsx b/src/features/panes/components/pane-container.tsx index 9b72b29f..2490066a 100644 --- a/src/features/panes/components/pane-container.tsx +++ b/src/features/panes/components/pane-container.tsx @@ -819,7 +819,7 @@ export function PaneContainer({ pane }: PaneContainerProps) {
)} - {paneBuffers.length > 0 && } +
{(!activeBuffer || activeBuffer.type === "newTab") && !shouldRenderCarousel && ( @@ -968,7 +968,51 @@ export function PaneContainer({ pane }: PaneContainerProps) { )}
) : ( - activeBuffer && renderActiveBuffer(activeBuffer) + <> + {/* Keep terminal and webviewer buffers always mounted to preserve + PTY sessions and embedded webview state. */} + {paneBuffers + .filter( + ( + b, + ): b is + | import("../types/pane-content").TerminalContent + | import("../types/pane-content").WebViewerContent => + b.type === "terminal" || b.type === "webViewer", + ) + .map((b) => { + const isActive = b.id === activeBuffer?.id; + return ( +
+ {b.type === "terminal" ? ( + + ) : ( + + )} +
+ ); + })} + {activeBuffer && + activeBuffer.type !== "terminal" && + activeBuffer.type !== "webViewer" && + renderActiveBuffer(activeBuffer)} + )}
diff --git a/src/features/panes/types/pane-content.ts b/src/features/panes/types/pane-content.ts index aca8fa01..c75f56e7 100644 --- a/src/features/panes/types/pane-content.ts +++ b/src/features/panes/types/pane-content.ts @@ -287,6 +287,8 @@ export type OpenContentSpec = command?: string; workingDirectory?: string; remoteConnectionId?: string; + sessionId?: string; + path?: string; } | { type: "agent"; sessionId?: string } | { type: "webViewer"; url: string } diff --git a/src/features/tabs/components/tab-bar.tsx b/src/features/tabs/components/tab-bar.tsx index 9584cbdf..6f8e3068 100644 --- a/src/features/tabs/components/tab-bar.tsx +++ b/src/features/tabs/components/tab-bar.tsx @@ -1,4 +1,11 @@ -import { ArrowLeft, ArrowRight, Maximize2, Minimize2, SplitSquareHorizontal } from "lucide-react"; +import { + ArrowLeft, + ArrowRight, + Maximize2, + Minimize2, + PanelLeftClose, + SplitSquareHorizontal, +} from "lucide-react"; import { writeText } from "@tauri-apps/plugin-clipboard-manager"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; @@ -46,8 +53,14 @@ const TabBar = ({ paneId, onTabClick: externalTabClick }: TabBarProps) => { const pendingClose = useBufferStore.use.pendingClose(); const paneRoot = usePaneStore.use.root(); const fullscreenPaneId = usePaneStore.use.fullscreenPaneId(); - const { moveBufferToPane, addBufferToPane, setActivePane, splitPane, togglePaneFullscreen } = - usePaneStore.use.actions(); + const { + moveBufferToPane, + addBufferToPane, + setActivePane, + splitPane, + closePane, + togglePaneFullscreen, + } = usePaneStore.use.actions(); // Filter buffers by paneId if provided const pane = paneId ? findPaneGroup(paneRoot, paneId) : null; @@ -73,6 +86,7 @@ const TabBar = ({ paneId, onTabClick: externalTabClick }: TabBarProps) => { const canGoBack = jumpListActions.canGoBack(); const canGoForward = jumpListActions.canGoForward(); const isPaneFullscreen = paneId ? fullscreenPaneId === paneId : false; + const isInSplit = paneRoot.type === "split"; // Drag state const [dragState, setDragState] = useState<{ @@ -872,6 +886,20 @@ const TabBar = ({ paneId, onTabClick: externalTabClick }: TabBarProps) => {
+ {paneId && isInSplit && ( + + + + )} {paneId && activeBufferId && (