diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index df54e48..36067e4 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -82,7 +82,7 @@ fn package_name_from_spec(package: &str) -> String { /// Check whether a command is available on the system PATH. /// Uses `which` on unix and `where` on windows — lightweight and does not /// invoke the target binary itself, avoiding side-effects or slow startups. -fn is_cmd_available(cmd: &str) -> bool { +pub(crate) fn is_cmd_available(cmd: &str) -> bool { which::which(cmd).is_ok() } @@ -597,7 +597,7 @@ fn agent_local_config_path(agent_type: AgentType) -> Option { } } -fn load_agent_local_config_json(agent_type: AgentType) -> Option { +pub(crate) fn load_agent_local_config_json(agent_type: AgentType) -> Option { if agent_type == AgentType::Codex { return load_codex_local_config_json(); } @@ -979,7 +979,7 @@ fn important_env_targets(agent_type: AgentType) -> (&'static str, &'static str, } } -fn build_runtime_env_from_setting( +pub(crate) fn build_runtime_env_from_setting( agent_type: AgentType, setting: Option<&crate::db::entities::agent_setting::Model>, local_config_json: Option<&str>, diff --git a/src-tauri/src/commands/terminal.rs b/src-tauri/src/commands/terminal.rs index 5239595..e3fd0f3 100644 --- a/src-tauri/src/commands/terminal.rs +++ b/src-tauri/src/commands/terminal.rs @@ -13,7 +13,7 @@ use crate::terminal::types::TerminalInfo; /// Uses `credential.helper` with a script that calls the app binary with /// `--credential-helper`. The binary opens the DB, looks up the matching /// account, and outputs credentials. No credentials are written to disk. -fn prepare_credential_env( +pub(crate) fn prepare_credential_env( app_data_dir: &std::path::Path, ) -> Option> { // Get the path to the current running binary diff --git a/src-tauri/src/web/handlers/acp.rs b/src-tauri/src/web/handlers/acp.rs index 5488bd3..4578886 100644 --- a/src-tauri/src/web/handlers/acp.rs +++ b/src-tauri/src/web/handlers/acp.rs @@ -1,3 +1,141 @@ -// ACP (Agent Communication Protocol) web handlers. -// TODO: Implement ACP handlers for web mode. -// These require special handling for connection lifecycle and streaming events. +use axum::{extract::Extension, Json}; +use serde::Deserialize; +use tauri::Manager; + +use crate::acp::manager::ConnectionManager; +use crate::acp::registry; +use crate::acp::types::{AcpAgentInfo, AcpAgentStatus}; +use crate::app_error::AppCommandError; +use crate::commands::acp as acp_commands; +use crate::db::service::agent_setting_service; +use crate::db::AppDatabase; +use crate::models::agent::AgentType; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentTypeParams { + pub agent_type: AgentType, +} + +pub async fn acp_get_agent_status( + Extension(app): Extension, + Json(params): Json, +) -> Result, AppCommandError> { + let db = app.state::(); + let result = acp_commands::acp_get_agent_status(params.agent_type, db) + .await + .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?; + Ok(Json(result)) +} + +pub async fn acp_list_agents( + Extension(app): Extension, +) -> Result>, AppCommandError> { + let db = app.state::(); + let result = acp_commands::acp_list_agents(db) + .await + .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?; + Ok(Json(result)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpConnectParams { + pub agent_type: AgentType, + pub working_dir: Option, + pub session_id: Option, +} + +pub async fn acp_connect( + Extension(app): Extension, + Json(params): Json, +) -> Result, AppCommandError> { + let db = app.state::(); + let manager = app.state::(); + let meta = registry::get_agent_meta(params.agent_type); + + let setting = agent_setting_service::get_by_agent_type(&db.conn, params.agent_type) + .await + .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?; + let disabled = setting + .as_ref() + .map(|model| !model.enabled) + .unwrap_or(false); + if disabled { + return Err(AppCommandError::task_execution_failed(format!( + "{} is disabled in settings", + params.agent_type + ))); + } + + let local_config_json = acp_commands::load_agent_local_config_json(params.agent_type); + let mut runtime_env = acp_commands::build_runtime_env_from_setting( + params.agent_type, + setting.as_ref(), + local_config_json.as_deref(), + ); + + if params.agent_type == AgentType::OpenClaw && params.session_id.is_none() { + runtime_env.insert("OPENCLAW_RESET_SESSION".into(), "1".into()); + } + + if let registry::AgentDistribution::Npx { cmd, .. } = meta.distribution { + if !acp_commands::is_cmd_available(cmd) { + return Err(AppCommandError::task_execution_failed(format!( + "{} SDK is not installed. Please install it in Agent Settings.", + meta.name + ))); + } + } + + let connection_id = manager + .spawn_agent( + params.agent_type, + params.working_dir, + params.session_id, + runtime_env, + "web".to_string(), + app.clone(), + ) + .await + .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?; + + Ok(Json(connection_id)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpDisconnectParams { + pub connection_id: String, +} + +pub async fn acp_disconnect( + Extension(app): Extension, + Json(params): Json, +) -> Result, AppCommandError> { + let manager = app.state::(); + manager + .disconnect(¶ms.connection_id) + .await + .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?; + Ok(Json(())) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpPromptParams { + pub connection_id: String, + pub blocks: Vec, +} + +pub async fn acp_prompt( + Extension(app): Extension, + Json(params): Json, +) -> Result, AppCommandError> { + let manager = app.state::(); + manager + .send_prompt(¶ms.connection_id, params.blocks) + .await + .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?; + Ok(Json(())) +} diff --git a/src-tauri/src/web/handlers/conversations.rs b/src-tauri/src/web/handlers/conversations.rs index cbeca80..0c5e8a0 100644 --- a/src-tauri/src/web/handlers/conversations.rs +++ b/src-tauri/src/web/handlers/conversations.rs @@ -262,3 +262,21 @@ pub async fn delete_conversation( .map_err(AppCommandError::from)?; Ok(Json(())) } + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateConversationExternalIdParams { + pub conversation_id: i32, + pub external_id: String, +} + +pub async fn update_conversation_external_id( + Extension(app): Extension, + Json(params): Json, +) -> Result, AppCommandError> { + let db = app.state::(); + conversation_service::update_external_id(&db.conn, params.conversation_id, params.external_id) + .await + .map_err(AppCommandError::from)?; + Ok(Json(())) +} diff --git a/src-tauri/src/web/handlers/folder_commands.rs b/src-tauri/src/web/handlers/folder_commands.rs index eded33e..0537d4c 100644 --- a/src-tauri/src/web/handlers/folder_commands.rs +++ b/src-tauri/src/web/handlers/folder_commands.rs @@ -1,2 +1,25 @@ -// Folder commands web handlers. -// TODO: Implement folder command CRUD handlers for web mode. +use axum::{extract::Extension, Json}; +use serde::Deserialize; +use tauri::Manager; + +use crate::app_error::AppCommandError; +use crate::db::service::folder_command_service; +use crate::db::AppDatabase; +use crate::models::*; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FolderIdParams { + pub folder_id: i32, +} + +pub async fn list_folder_commands( + Extension(app): Extension, + Json(params): Json, +) -> Result>, AppCommandError> { + let db = app.state::(); + let result = folder_command_service::list_by_folder(&db.conn, params.folder_id) + .await + .map_err(AppCommandError::from)?; + Ok(Json(result)) +} diff --git a/src-tauri/src/web/handlers/folders.rs b/src-tauri/src/web/handlers/folders.rs index a7d43ad..9ea69fd 100644 --- a/src-tauri/src/web/handlers/folders.rs +++ b/src-tauri/src/web/handlers/folders.rs @@ -1,8 +1,9 @@ use axum::{extract::Extension, Json}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use tauri::Manager; use crate::app_error::AppCommandError; +use crate::commands::folders as folder_commands; use crate::db::service::folder_service; use crate::db::AppDatabase; use crate::models::*; @@ -54,5 +55,224 @@ pub async fn open_folder_window( Ok(Json(entry)) } -// TODO: Add remaining folder handlers (git operations, file operations, etc.) -// These will be added incrementally as needed. +// --- New handlers below --- + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SaveFolderOpenedConversationsParams { + pub folder_id: i32, + pub items: Vec, +} + +pub async fn save_folder_opened_conversations( + Extension(app): Extension, + Json(params): Json, +) -> Result, AppCommandError> { + let db = app.state::(); + folder_service::save_opened_conversations(&db.conn, params.folder_id, params.items) + .await + .map_err(AppCommandError::from)?; + Ok(Json(())) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PathParams { + pub path: String, +} + +pub async fn get_git_branch( + Json(params): Json, +) -> Result>, AppCommandError> { + let result = folder_commands::get_git_branch(params.path).await?; + Ok(Json(result)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetFileTreeParams { + pub path: String, + pub max_depth: Option, +} + +pub async fn get_file_tree( + Json(params): Json, +) -> Result>, AppCommandError> { + let result = folder_commands::get_file_tree(params.path, params.max_depth).await?; + Ok(Json(result)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RootPathParams { + pub root_path: String, +} + +pub async fn start_file_tree_watch( + Extension(app): Extension, + Json(params): Json, +) -> Result, AppCommandError> { + folder_commands::start_file_tree_watch(app, params.root_path).await?; + Ok(Json(())) +} + +pub async fn stop_file_tree_watch( + Json(params): Json, +) -> Result, AppCommandError> { + folder_commands::stop_file_tree_watch(params.root_path).await?; + Ok(Json(())) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenSettingsWindowParams { + pub section: Option, + pub agent_type: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SettingsNavigationResult { + pub path: String, +} + +/// Web equivalent of `open_settings_window`: returns the target navigation path. +/// The web client handles the actual navigation. +pub async fn open_settings_window( + Json(params): Json, +) -> Result, AppCommandError> { + let route = match params.section.as_deref() { + Some("appearance") => "settings/appearance", + Some("agents") => "settings/agents", + Some("mcp") => "settings/mcp", + Some("skills") => "settings/skills", + Some("shortcuts") => "settings/shortcuts", + Some("system") => "settings/system", + _ => "settings/system", + }; + + let path = if route == "settings/agents" { + if let Some(ref agent) = params.agent_type { + let trimmed = agent.trim(); + if !trimmed.is_empty() + && trimmed + .chars() + .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_') + { + format!("/{route}?agent={trimmed}") + } else { + format!("/{route}") + } + } else { + format!("/{route}") + } + } else { + format!("/{route}") + }; + + Ok(Json(SettingsNavigationResult { path })) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitStatusParams { + pub path: String, + pub show_all_untracked: Option, +} + +pub async fn git_status( + Json(params): Json, +) -> Result>, AppCommandError> { + let result = + folder_commands::git_status(params.path, params.show_all_untracked).await?; + Ok(Json(result)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReadFilePreviewParams { + pub root_path: String, + pub path: String, +} + +pub async fn read_file_preview( + Json(params): Json, +) -> Result, AppCommandError> { + let result = + folder_commands::read_file_preview(params.root_path, params.path).await?; + Ok(Json(result)) +} + +pub async fn git_list_all_branches( + Json(params): Json, +) -> Result, AppCommandError> { + let result = folder_commands::git_list_all_branches(params.path).await?; + Ok(Json(result)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitCommitBranchesParams { + pub path: String, + pub commit: String, +} + +pub async fn git_commit_branches( + Json(params): Json, +) -> Result>, AppCommandError> { + let result = + folder_commands::git_commit_branches(params.path, params.commit).await?; + Ok(Json(result)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitShowFileParams { + pub path: String, + pub file: String, + pub ref_name: Option, +} + +pub async fn git_show_file( + Json(params): Json, +) -> Result, AppCommandError> { + let result = + folder_commands::git_show_file(params.path, params.file, params.ref_name).await?; + Ok(Json(result)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitDiffParams { + pub path: String, + pub file: Option, +} + +pub async fn git_diff( + Json(params): Json, +) -> Result, AppCommandError> { + let result = folder_commands::git_diff(params.path, params.file).await?; + Ok(Json(result)) +} + +pub async fn git_list_remotes( + Json(params): Json, +) -> Result>, AppCommandError> { + let result = folder_commands::git_list_remotes(params.path).await?; + Ok(Json(result)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenCommitWindowParams { + pub folder_id: i32, +} + +/// Web equivalent of `open_commit_window`: returns the navigation path. +pub async fn open_commit_window( + Json(params): Json, +) -> Result, AppCommandError> { + Ok(Json(SettingsNavigationResult { + path: format!("/commit?folderId={}", params.folder_id), + })) +} diff --git a/src-tauri/src/web/handlers/terminal.rs b/src-tauri/src/web/handlers/terminal.rs index cb82ac2..9e32479 100644 --- a/src-tauri/src/web/handlers/terminal.rs +++ b/src-tauri/src/web/handlers/terminal.rs @@ -1,3 +1,45 @@ -// Terminal web handlers. -// TODO: Implement terminal handlers for web mode. -// Terminal I/O streams over WebSocket instead of Tauri events. +use axum::{extract::Extension, Json}; +use serde::Deserialize; +use tauri::Manager; + +use crate::app_error::AppCommandError; +use crate::commands::terminal::prepare_credential_env; +use crate::terminal::manager::{SpawnOptions, TerminalManager}; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminalSpawnParams { + pub working_dir: String, + pub initial_command: Option, +} + +pub async fn terminal_spawn( + Extension(app): Extension, + Json(params): Json, +) -> Result, AppCommandError> { + let manager = app.state::(); + let terminal_id = uuid::Uuid::new_v4().to_string(); + + let app_data_dir = app + .path() + .app_data_dir() + .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?; + + let extra_env = prepare_credential_env(&app_data_dir); + + let id = manager + .spawn_with_id( + SpawnOptions { + terminal_id, + working_dir: params.working_dir, + owner_window_label: "web".to_string(), + initial_command: params.initial_command, + extra_env, + temp_files: vec![], + }, + app.clone(), + ) + .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?; + + Ok(Json(id)) +} diff --git a/src-tauri/src/web/handlers/version_control.rs b/src-tauri/src/web/handlers/version_control.rs index f64fe32..f7ff1c6 100644 --- a/src-tauri/src/web/handlers/version_control.rs +++ b/src-tauri/src/web/handlers/version_control.rs @@ -1,2 +1,22 @@ -// Version control web handlers. -// TODO: Implement git settings and GitHub account handlers for web mode. +use axum::Json; +use serde::Deserialize; + +use crate::app_error::AppCommandError; +use crate::commands::folders as folder_commands; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitLogParams { + pub path: String, + pub limit: Option, + pub branch: Option, + pub remote: Option, +} + +pub async fn git_log( + Json(params): Json, +) -> Result, AppCommandError> { + let result = + folder_commands::git_log(params.path, params.limit, params.branch, params.remote).await?; + Ok(Json(result)) +} diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index 9ed7d79..fb4f4b4 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -35,6 +35,7 @@ pub fn build_router(app: tauri::AppHandle, token: String, static_dir: std::path: .route("/update_conversation_status", post(handlers::conversations::update_conversation_status)) .route("/update_conversation_title", post(handlers::conversations::update_conversation_title)) .route("/delete_conversation", post(handlers::conversations::delete_conversation)) + .route("/update_conversation_external_id", post(handlers::conversations::update_conversation_external_id)) // Folders .route("/load_folder_history", post(handlers::folders::load_folder_history)) .route("/get_folder", post(handlers::folders::get_folder)) @@ -42,6 +43,35 @@ pub fn build_router(app: tauri::AppHandle, token: String, static_dir: std::path: // System settings .route("/get_system_proxy_settings", post(handlers::system_settings::get_system_proxy_settings)) .route("/get_system_language_settings", post(handlers::system_settings::get_system_language_settings)) + // Folders (extended) + .route("/save_folder_opened_conversations", post(handlers::folders::save_folder_opened_conversations)) + .route("/get_git_branch", post(handlers::folders::get_git_branch)) + .route("/get_file_tree", post(handlers::folders::get_file_tree)) + .route("/start_file_tree_watch", post(handlers::folders::start_file_tree_watch)) + .route("/stop_file_tree_watch", post(handlers::folders::stop_file_tree_watch)) + .route("/open_settings_window", post(handlers::folders::open_settings_window)) + // Version control + .route("/git_log", post(handlers::version_control::git_log)) + // Folder commands + .route("/list_folder_commands", post(handlers::folder_commands::list_folder_commands)) + // Git operations + .route("/git_status", post(handlers::folders::git_status)) + .route("/git_list_all_branches", post(handlers::folders::git_list_all_branches)) + .route("/git_commit_branches", post(handlers::folders::git_commit_branches)) + .route("/git_show_file", post(handlers::folders::git_show_file)) + .route("/git_diff", post(handlers::folders::git_diff)) + .route("/git_list_remotes", post(handlers::folders::git_list_remotes)) + .route("/open_commit_window", post(handlers::folders::open_commit_window)) + // File operations + .route("/read_file_preview", post(handlers::folders::read_file_preview)) + // ACP + .route("/acp_get_agent_status", post(handlers::acp::acp_get_agent_status)) + .route("/acp_list_agents", post(handlers::acp::acp_list_agents)) + .route("/acp_connect", post(handlers::acp::acp_connect)) + .route("/acp_disconnect", post(handlers::acp::acp_disconnect)) + .route("/acp_prompt", post(handlers::acp::acp_prompt)) + // Terminal + .route("/terminal_spawn", post(handlers::terminal::terminal_spawn)) // Catch-all: return proper JSON 404 for unimplemented API endpoints .fallback(api_not_found) // Auth middleware for API routes diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index 2b83639..e6cd7ac 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -29,7 +29,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" -import { cn } from "@/lib/utils" +import { cn, randomUUID } from "@/lib/utils" import { matchShortcutEvent } from "@/lib/keyboard-shortcuts" import { useShortcutSettings } from "@/hooks/use-shortcut-settings" import { readFileBase64 } from "@/lib/api" @@ -245,7 +245,7 @@ function isTextLikeFile(file: File): boolean { function buildClipboardResourceUri(name: string): string { const normalizedName = name.trim() || "clipboard-resource" - return `clipboard://${encodeURIComponent(normalizedName)}-${crypto.randomUUID()}` + return `clipboard://${encodeURIComponent(normalizedName)}-${randomUUID()}` } function buildDataUri(base64Data: string, mimeType: string | null): string { @@ -491,7 +491,7 @@ export function MessageInput({ setAttachments((prev) => [ ...prev, ...resources.map((resource) => ({ - id: `resource-embedded:${crypto.randomUUID()}`, + id: `resource-embedded:${randomUUID()}`, type: "resource" as const, kind: "embedded" as const, uri: resource.uri, @@ -530,7 +530,7 @@ export function MessageInput({ for (const file of files) { const path = getFilePath(file) - const name = file.name || `resource-${crypto.randomUUID()}` + const name = file.name || `resource-${randomUUID()}` const mimeType = file.type || mimeTypeFromPath(name) if (path) { const uri = toFileUri(path) @@ -596,7 +596,7 @@ export function MessageInput({ : (mimeTypeFromPath(file.name) ?? "image/png") const base64Data = await blobToBase64(file) return { - id: `image:${Date.now()}:${index}:${crypto.randomUUID()}`, + id: `image:${Date.now()}:${index}:${randomUUID()}`, type: "image" as const, data: base64Data, uri: null, @@ -615,7 +615,7 @@ export function MessageInput({ paths.map(async (path, index) => { const data = await readFileBase64(path, DRAG_DROP_IMAGE_MAX_BYTES) return { - id: `image:${Date.now()}:${index}:${crypto.randomUUID()}`, + id: `image:${Date.now()}:${index}:${randomUUID()}`, type: "image" as const, data, uri: toFileUri(path), diff --git a/src/components/conversations/conversation-detail-panel.tsx b/src/components/conversations/conversation-detail-panel.tsx index d06cce9..983dab4 100644 --- a/src/components/conversations/conversation-detail-panel.tsx +++ b/src/components/conversations/conversation-detail-panel.tsx @@ -17,7 +17,7 @@ import { useAcpActions } from "@/contexts/acp-connections-context" import { useFolderContext } from "@/contexts/folder-context" import { useTabContext } from "@/contexts/tab-context" import { useSessionStats } from "@/contexts/session-stats-context" -import { cn } from "@/lib/utils" +import { cn, randomUUID } from "@/lib/utils" import { useConnectionLifecycle } from "@/hooks/use-connection-lifecycle" import { useMessageQueue, type QueuedMessage } from "@/hooks/use-message-queue" import { MessageListView } from "@/components/message/message-list-view" @@ -103,7 +103,7 @@ function buildOptimisticUserTurnFromDraft( blocks.push({ type: "text", text }) return { - id: `optimistic-${crypto.randomUUID()}`, + id: `optimistic-${randomUUID()}`, role: "user", blocks, timestamp: new Date().toISOString(), @@ -762,7 +762,7 @@ const ConversationTabView = memo(function ConversationTabView({ (answer: string) => { if (connStatus !== "connected") return const optimisticTurn: MessageTurn = { - id: `optimistic-${crypto.randomUUID()}`, + id: `optimistic-${randomUUID()}`, role: "user", blocks: [{ type: "text", text: answer }], timestamp: new Date().toISOString(), diff --git a/src/components/settings/add-git-account-dialog.tsx b/src/components/settings/add-git-account-dialog.tsx index cb6374d..6d1a8df 100644 --- a/src/components/settings/add-git-account-dialog.tsx +++ b/src/components/settings/add-git-account-dialog.tsx @@ -3,6 +3,7 @@ import { useCallback, useState } from "react" import { Eye, EyeOff } from "lucide-react" import { useTranslations } from "next-intl" +import { randomUUID } from "@/lib/utils" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { @@ -72,7 +73,7 @@ export function AddGitAccountDialog({ } const account: GitHubAccount = { - id: crypto.randomUUID(), + id: randomUUID(), server_url: trimmedUrl, username: trimmedUser, scopes: [], diff --git a/src/components/settings/add-github-account-dialog.tsx b/src/components/settings/add-github-account-dialog.tsx index 1348299..014637f 100644 --- a/src/components/settings/add-github-account-dialog.tsx +++ b/src/components/settings/add-github-account-dialog.tsx @@ -4,6 +4,7 @@ import { useCallback, useState } from "react" import { ExternalLink, Eye, EyeOff, Loader2 } from "lucide-react" import { openUrl } from "@/lib/platform" import { useTranslations } from "next-intl" +import { randomUUID } from "@/lib/utils" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { @@ -91,7 +92,7 @@ export function AddGitHubAccountDialog({ } const account: GitHubAccount = { - id: crypto.randomUUID(), + id: randomUUID(), server_url: serverUrl.trim() || "https://github.com", username: result.username ?? "unknown", scopes: result.scopes, diff --git a/src/contexts/acp-connections-context.tsx b/src/contexts/acp-connections-context.tsx index d3497f7..7a7b2e6 100644 --- a/src/contexts/acp-connections-context.tsx +++ b/src/contexts/acp-connections-context.tsx @@ -11,6 +11,7 @@ import { } from "react" import { useTranslations } from "next-intl" import { subscribe } from "@/lib/platform" +import { randomUUID } from "@/lib/utils" import { inferLiveToolName } from "@/lib/tool-call-normalization" import { acpConnect, @@ -536,7 +537,7 @@ function connectionsReducer( const updated = { ...conn, status: action.status } if (action.status === "prompting") { updated.liveMessage = { - id: crypto.randomUUID(), + id: randomUUID(), role: "assistant", content: [], startedAt: Date.now(), diff --git a/src/contexts/git-credential-context.tsx b/src/contexts/git-credential-context.tsx index fdff395..03e5697 100644 --- a/src/contexts/git-credential-context.tsx +++ b/src/contexts/git-credential-context.tsx @@ -9,6 +9,7 @@ import { useState, type ReactNode, } from "react" +import { randomUUID } from "@/lib/utils" import { ExternalLink, Eye, @@ -147,7 +148,7 @@ async function saveGenericAccount( (a) => a.username === creds.username && extractHost(a.server_url) === host ) if (!isDuplicate) { - const newId = crypto.randomUUID() + const newId = randomUUID() await saveAccountToken(newId, creds.password) await updateGitHubAccounts({ accounts: [ @@ -283,7 +284,7 @@ export function GitCredentialProvider({ children }: { children: ReactNode }) { ) if (!isDuplicate) { const newAccount = { - id: crypto.randomUUID(), + id: randomUUID(), server_url: serverUrl, username: result.username ?? "unknown", scopes: result.scopes, diff --git a/src/hooks/use-message-queue.ts b/src/hooks/use-message-queue.ts index 948b539..916d51f 100644 --- a/src/hooks/use-message-queue.ts +++ b/src/hooks/use-message-queue.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react" import type { PromptDraft } from "@/lib/types" +import { randomUUID } from "@/lib/utils" export interface QueuedMessage { id: string @@ -31,7 +32,7 @@ export function useMessageQueue(): UseMessageQueueReturn { const enqueue = useCallback((draft: PromptDraft, modeId: string | null) => { const item: QueuedMessage = { - id: crypto.randomUUID(), + id: randomUUID(), draft, modeId, } diff --git a/src/lib/api.ts b/src/lib/api.ts index 6afbd97..214fc57 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -852,7 +852,14 @@ export async function openFolderWindow(path: string): Promise { } export async function openCommitWindow(folderId: number): Promise { - return getTransport().call("open_commit_window", { folderId }) + if (getTransport().isDesktop()) { + return getTransport().call("open_commit_window", { folderId }) + } + const result = await getTransport().call<{ path: string }>( + "open_commit_window", + { folderId }, + ) + window.location.href = result.path } export type SettingsSection = @@ -871,10 +878,21 @@ export async function openSettingsWindow( section?: SettingsSection, options?: OpenSettingsWindowOptions ): Promise { - return getTransport().call("open_settings_window", { - section: section ?? null, - agentType: options?.agentType ?? null, - }) + if (getTransport().isDesktop()) { + return getTransport().call("open_settings_window", { + section: section ?? null, + agentType: options?.agentType ?? null, + }) + } + // Web mode: get navigation path from backend and navigate + const result = await getTransport().call<{ path: string }>( + "open_settings_window", + { + section: section ?? null, + agentType: options?.agentType ?? null, + }, + ) + window.location.href = result.path } export async function listOpenFolders(): Promise { diff --git a/src/lib/utils.ts b/src/lib/utils.ts index bd0c391..da1b087 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -4,3 +4,21 @@ import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +/** + * Generate a UUID v4. Uses `crypto.randomUUID()` when available (secure + * contexts), otherwise falls back to `crypto.getRandomValues()`. + */ +export function randomUUID(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID() + } + // Fallback for non-secure contexts (HTTP over LAN) + const bytes = new Uint8Array(16) + crypto.getRandomValues(bytes) + // Set version 4 and variant bits + bytes[6] = (bytes[6] & 0x0f) | 0x40 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("") + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` +}