From acdd741c9249e6aba1a6b6c73d734934fa62baec Mon Sep 17 00:00:00 2001 From: Sander Helgesen Date: Sun, 18 Jan 2026 17:05:12 +0100 Subject: [PATCH 1/7] chore: ignore local codex artifacts and normalize lockfile --- .gitignore | 1 + package-lock.json | 14 +------------- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index a757a9427..cc60eeaaf 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ dist-ssr /release-artifacts CodexMonitor.zip .codex-worktrees/ +.codexmonitor/ diff --git a/package-lock.json b/package-lock.json index 8943044e8..5e8a8aa9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -70,7 +70,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1849,7 +1848,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz", "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1910,7 +1908,6 @@ "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", @@ -2117,8 +2114,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/acorn": { "version": "8.15.0", @@ -2126,7 +2122,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2438,7 +2433,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3120,7 +3114,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -6002,7 +5995,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6126,7 +6118,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -6136,7 +6127,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -7088,7 +7078,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7278,7 +7267,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", From f6788925947164dfd06ee20cf3d2b6bc44819173 Mon Sep 17 00:00:00 2001 From: Sander Helgesen Date: Sun, 18 Jan 2026 17:05:17 +0100 Subject: [PATCH 2/7] tauri: add prompt CRUD and directory commands --- src-tauri/src/lib.rs | 6 + src-tauri/src/prompts.rs | 325 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 324 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 435f14de4..c8eec5809 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -176,6 +176,12 @@ pub fn run() { codex::account_rate_limits, codex::skills_list, prompts::prompts_list, + prompts::prompts_create, + prompts::prompts_update, + prompts::prompts_delete, + prompts::prompts_move, + prompts::prompts_workspace_dir, + prompts::prompts_global_dir, terminal::terminal_open, terminal::terminal_write, terminal::terminal_resize, diff --git a/src-tauri/src/prompts.rs b/src-tauri/src/prompts.rs index caa09663c..d525926ff 100644 --- a/src-tauri/src/prompts.rs +++ b/src-tauri/src/prompts.rs @@ -1,8 +1,13 @@ use serde::Serialize; +use std::collections::HashMap; use std::env; use std::fs; use std::path::{Path, PathBuf}; use tokio::task; +use tauri::State; + +use crate::state::AppState; +use crate::types::WorkspaceEntry; #[derive(Serialize, Clone)] pub(crate) struct CustomPromptEntry { @@ -12,6 +17,8 @@ pub(crate) struct CustomPromptEntry { #[serde(rename = "argumentHint")] pub(crate) argument_hint: Option, pub(crate) content: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) scope: Option, } fn resolve_home_dir() -> Option { @@ -45,6 +52,55 @@ fn default_prompts_dir() -> Option { resolve_codex_home().map(|home| home.join("prompts")) } +fn workspace_parent_path( + workspaces: &HashMap, + entry: &WorkspaceEntry, +) -> Option { + entry + .parent_id + .as_ref() + .and_then(|parent_id| workspaces.get(parent_id)) + .map(|entry| entry.path.clone()) +} + +fn require_workspace_entry( + workspaces: &HashMap, + workspace_id: &str, +) -> Result { + workspaces + .get(workspace_id) + .cloned() + .ok_or_else(|| "workspace not found".to_string()) +} + +fn resolve_workspace_codex_home(entry: &WorkspaceEntry, parent_path: Option<&str>) -> Option { + if entry.kind.is_worktree() { + if let Some(parent_path) = parent_path { + let legacy_home = PathBuf::from(parent_path).join(".codexmonitor"); + if legacy_home.is_dir() { + return Some(legacy_home); + } + } + } + let legacy_home = PathBuf::from(&entry.path).join(".codexmonitor"); + if legacy_home.is_dir() { + return Some(legacy_home); + } + None +} + +fn workspace_codex_home_path_for_create( + entry: &WorkspaceEntry, + parent_path: Option<&str>, +) -> PathBuf { + if entry.kind.is_worktree() { + if let Some(parent_path) = parent_path { + return PathBuf::from(parent_path).join(".codexmonitor"); + } + } + PathBuf::from(&entry.path).join(".codexmonitor") +} + fn parse_frontmatter(content: &str) -> (Option, Option, String) { let mut segments = content.split_inclusive('\n'); let Some(first_segment) = segments.next() else { @@ -107,7 +163,51 @@ fn parse_frontmatter(content: &str) -> (Option, Option, String) (description, argument_hint, body) } -fn discover_prompts_in(dir: &Path) -> Vec { +fn build_prompt_contents( + description: Option, + argument_hint: Option, + content: String, +) -> String { + let has_meta = description.as_ref().is_some_and(|value| !value.trim().is_empty()) + || argument_hint + .as_ref() + .is_some_and(|value| !value.trim().is_empty()); + if !has_meta { + return content; + } + let mut output = String::from("---\n"); + if let Some(description) = description { + let trimmed = description.trim(); + if !trimmed.is_empty() { + output.push_str(&format!("description: \"{}\"\n", trimmed.replace('"', "\\\""))); + } + } + if let Some(argument_hint) = argument_hint { + let trimmed = argument_hint.trim(); + if !trimmed.is_empty() { + output.push_str(&format!( + "argument-hint: \"{}\"\n", + trimmed.replace('"', "\\\"") + )); + } + } + output.push_str("---\n"); + output.push_str(&content); + output +} + +fn sanitize_prompt_name(name: &str) -> Result { + let trimmed = name.trim(); + if trimmed.is_empty() { + return Err("Prompt name is required.".to_string()); + } + if trimmed.contains('/') || trimmed.contains('\\') { + return Err("Prompt name cannot include path separators.".to_string()); + } + Ok(trimmed.to_string()) +} + +fn discover_prompts_in(dir: &Path, scope: Option<&str>) -> Vec { let mut out: Vec = Vec::new(); let entries = match fs::read_dir(dir) { Ok(entries) => entries, @@ -146,6 +246,7 @@ fn discover_prompts_in(dir: &Path) -> Vec { description, argument_hint, content: body, + scope: scope.map(|value| value.to_string()), }); } @@ -154,11 +255,221 @@ fn discover_prompts_in(dir: &Path) -> Vec { } #[tauri::command] -pub(crate) async fn prompts_list(_workspace_id: String) -> Result, String> { - let Some(dir) = default_prompts_dir() else { - return Ok(Vec::new()); +pub(crate) async fn prompts_list( + state: State<'_, AppState>, + workspace_id: String, +) -> Result, String> { + let (workspace_dir, global_dir) = { + let workspaces = state.workspaces.lock().await; + let entry = workspaces.get(&workspace_id).cloned(); + let parent_path = entry + .as_ref() + .and_then(|entry| workspace_parent_path(&workspaces, entry)); + let workspace_dir = entry.as_ref().map(|entry| { + let home = resolve_workspace_codex_home(entry, parent_path.as_deref()) + .unwrap_or_else(|| { + workspace_codex_home_path_for_create(entry, parent_path.as_deref()) + }); + home.join("prompts") + }); + (workspace_dir, default_prompts_dir()) + }; + + task::spawn_blocking(move || { + let mut out = Vec::new(); + if let Some(dir) = workspace_dir { + let _ = fs::create_dir_all(&dir); + out.extend(discover_prompts_in(&dir, Some("workspace"))); + } + if let Some(dir) = global_dir { + let _ = fs::create_dir_all(&dir); + out.extend(discover_prompts_in(&dir, Some("global"))); + } + out + }) + .await + .map_err(|_| "prompt discovery failed".to_string()) +} + +#[tauri::command] +pub(crate) async fn prompts_workspace_dir( + state: State<'_, AppState>, + workspace_id: String, +) -> Result { + let dir = { + let workspaces = state.workspaces.lock().await; + let entry = require_workspace_entry(&workspaces, &workspace_id)?; + let parent_path = workspace_parent_path(&workspaces, &entry); + let home = workspace_codex_home_path_for_create(&entry, parent_path.as_deref()); + home.join("prompts") + }; + fs::create_dir_all(&dir).map_err(|err| err.to_string())?; + Ok(dir.to_string_lossy().to_string()) +} + +#[tauri::command] +pub(crate) async fn prompts_global_dir() -> Result { + let dir = default_prompts_dir().ok_or("Unable to resolve CODEX_HOME".to_string())?; + fs::create_dir_all(&dir).map_err(|err| err.to_string())?; + Ok(dir.to_string_lossy().to_string()) +} + +#[tauri::command] +pub(crate) async fn prompts_create( + state: State<'_, AppState>, + workspace_id: String, + scope: String, + name: String, + description: Option, + argument_hint: Option, + content: String, +) -> Result { + let name = sanitize_prompt_name(&name)?; + let (target_dir, resolved_scope) = { + let workspaces = state.workspaces.lock().await; + let entry = require_workspace_entry(&workspaces, &workspace_id)?; + let parent_path = workspace_parent_path(&workspaces, &entry); + match scope.as_str() { + "workspace" => { + let home = + workspace_codex_home_path_for_create(&entry, parent_path.as_deref()); + (home.join("prompts"), "workspace") + } + "global" => { + let dir = default_prompts_dir().ok_or("Unable to resolve CODEX_HOME".to_string())?; + (dir, "global") + } + _ => return Err("Invalid scope.".to_string()), + } + }; + let path = target_dir.join(format!("{name}.md")); + if path.exists() { + return Err("Prompt already exists.".to_string()); + } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|err| err.to_string())?; + } + let body = build_prompt_contents(description.clone(), argument_hint.clone(), content.clone()); + fs::write(&path, body).map_err(|err| err.to_string())?; + Ok(CustomPromptEntry { + name, + path: path.to_string_lossy().to_string(), + description, + argument_hint, + content, + scope: Some(resolved_scope.to_string()), + }) +} + +#[tauri::command] +pub(crate) async fn prompts_update( + state: State<'_, AppState>, + workspace_id: String, + path: String, + name: String, + description: Option, + argument_hint: Option, + content: String, +) -> Result { + let name = sanitize_prompt_name(&name)?; + let target_path = PathBuf::from(&path); + if !target_path.exists() { + return Err("Prompt not found.".to_string()); + } + let dir = target_path + .parent() + .ok_or("Unable to resolve prompt directory.".to_string())?; + let next_path = dir.join(format!("{name}.md")); + if next_path != target_path && next_path.exists() { + return Err("Prompt with that name already exists.".to_string()); + } + let body = build_prompt_contents(description.clone(), argument_hint.clone(), content.clone()); + fs::write(&next_path, body).map_err(|err| err.to_string())?; + if next_path != target_path { + fs::remove_file(&target_path).map_err(|err| err.to_string())?; + } + let scope = { + let workspaces = state.workspaces.lock().await; + let entry = require_workspace_entry(&workspaces, &workspace_id)?; + let parent_path = workspace_parent_path(&workspaces, &entry); + let workspace_dir = + workspace_codex_home_path_for_create(&entry, parent_path.as_deref()) + .join("prompts"); + if next_path.starts_with(&workspace_dir) { + Some("workspace".to_string()) + } else { + Some("global".to_string()) + } }; - task::spawn_blocking(move || discover_prompts_in(&dir)) - .await - .map_err(|_| "prompt discovery failed".to_string()) + Ok(CustomPromptEntry { + name, + path: next_path.to_string_lossy().to_string(), + description, + argument_hint, + content, + scope, + }) +} + +#[tauri::command] +pub(crate) async fn prompts_delete(path: String) -> Result<(), String> { + let target = PathBuf::from(path); + if !target.exists() { + return Ok(()); + } + fs::remove_file(&target).map_err(|err| err.to_string()) +} + +#[tauri::command] +pub(crate) async fn prompts_move( + state: State<'_, AppState>, + workspace_id: String, + path: String, + scope: String, +) -> Result { + let target_path = PathBuf::from(&path); + if !target_path.exists() { + return Err("Prompt not found.".to_string()); + } + let file_name = target_path + .file_name() + .and_then(|value| value.to_str()) + .ok_or("Invalid prompt path.".to_string())?; + let target_dir = { + let workspaces = state.workspaces.lock().await; + let entry = require_workspace_entry(&workspaces, &workspace_id)?; + let parent_path = workspace_parent_path(&workspaces, &entry); + match scope.as_str() { + "workspace" => workspace_codex_home_path_for_create(&entry, parent_path.as_deref()) + .join("prompts"), + "global" => default_prompts_dir().ok_or("Unable to resolve CODEX_HOME".to_string())?, + _ => return Err("Invalid scope.".to_string()), + } + }; + let next_path = target_dir.join(file_name); + if next_path == target_path { + return Err("Prompt is already in that scope.".to_string()); + } + if next_path.exists() { + return Err("Prompt with that name already exists.".to_string()); + } + if let Some(parent) = next_path.parent() { + fs::create_dir_all(parent).map_err(|err| err.to_string())?; + } + fs::rename(&target_path, &next_path).map_err(|err| err.to_string())?; + let content = fs::read_to_string(&next_path).unwrap_or_default(); + let (description, argument_hint, body) = parse_frontmatter(&content); + let name = next_path + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("") + .to_string(); + Ok(CustomPromptEntry { + name, + path: next_path.to_string_lossy().to_string(), + description, + argument_hint, + content: body, + scope: Some(scope), + }) } From 2fe449982a249225154fa7a7c531c6bd7bd22e39 Mon Sep 17 00:00:00 2001 From: Sander Helgesen Date: Sun, 18 Jan 2026 17:05:24 +0100 Subject: [PATCH 3/7] client: wire prompt IPC, types, and send-to-thread support --- .../prompts/hooks/useCustomPrompts.ts | 186 +++++++++++++++--- src/features/threads/hooks/useThreads.ts | 144 +++++++++----- src/services/tauri.ts | 63 ++++++ src/types.ts | 1 + 4 files changed, 320 insertions(+), 74 deletions(-) diff --git a/src/features/prompts/hooks/useCustomPrompts.ts b/src/features/prompts/hooks/useCustomPrompts.ts index b555f054d..fc1360c05 100644 --- a/src/features/prompts/hooks/useCustomPrompts.ts +++ b/src/features/prompts/hooks/useCustomPrompts.ts @@ -1,6 +1,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { CustomPromptOption, DebugEntry, WorkspaceInfo } from "../../../types"; -import { getPromptsList } from "../../../services/tauri"; +import { + createPrompt as createPromptService, + deletePrompt as deletePromptService, + getPromptsList, + getGlobalPromptsDir as getGlobalPromptsDirService, + getWorkspacePromptsDir as getWorkspacePromptsDirService, + movePrompt as movePromptService, + updatePrompt as updatePromptService, +} from "../../../services/tauri"; type UseCustomPromptsOptions = { activeWorkspace: WorkspaceInfo | null; @@ -15,6 +23,20 @@ export function useCustomPrompts({ activeWorkspace, onDebug }: UseCustomPromptsO const workspaceId = activeWorkspace?.id ?? null; const isConnected = Boolean(activeWorkspace?.connected); + const logPromptError = useCallback( + (idSuffix: string, label: string, error: unknown) => { + const timestamp = Date.now(); + onDebug?.({ + id: `${timestamp}-${idSuffix}`, + timestamp, + source: "error", + label, + payload: error instanceof Error ? error.message : String(error), + }); + }, + [onDebug], + ); + const refreshPrompts = useCallback(async () => { if (!workspaceId || !isConnected) { return; @@ -39,40 +61,47 @@ export function useCustomPrompts({ activeWorkspace, onDebug }: UseCustomPromptsO label: "prompts/list response", payload: response, }); - const rawPrompts = Array.isArray(response) - ? response - : Array.isArray((response as any)?.prompts) - ? (response as any).prompts - : Array.isArray((response as any)?.result?.prompts) - ? (response as any).result.prompts - : Array.isArray((response as any)?.result) - ? (response as any).result - : []; - const data: CustomPromptOption[] = rawPrompts.map((item: any) => ({ - name: String(item.name ?? ""), - path: String(item.path ?? ""), - description: item.description ? String(item.description) : undefined, - argumentHint: item.argumentHint - ? String(item.argumentHint) - : item.argument_hint - ? String(item.argument_hint) - : undefined, - content: String(item.content ?? ""), - })); + const responsePayload = response as any; + let rawPrompts: any[] = []; + if (Array.isArray(response)) { + rawPrompts = response; + } else if (Array.isArray(responsePayload?.prompts)) { + rawPrompts = responsePayload.prompts; + } else if (Array.isArray(responsePayload?.result?.prompts)) { + rawPrompts = responsePayload.result.prompts; + } else if (Array.isArray(responsePayload?.result)) { + rawPrompts = responsePayload.result; + } + const data: CustomPromptOption[] = rawPrompts.map((item: any) => { + let argumentHint: string | undefined; + if (item.argumentHint) { + argumentHint = String(item.argumentHint); + } else if (item.argument_hint) { + argumentHint = String(item.argument_hint); + } + + let scope: CustomPromptOption["scope"]; + if (item.scope === "workspace" || item.scope === "global") { + scope = item.scope; + } + + return { + name: String(item.name ?? ""), + path: String(item.path ?? ""), + description: item.description ? String(item.description) : undefined, + argumentHint, + content: String(item.content ?? ""), + scope, + }; + }); setPrompts(data); lastFetchedWorkspaceId.current = workspaceId; } catch (error) { - onDebug?.({ - id: `${Date.now()}-client-prompts-list-error`, - timestamp: Date.now(), - source: "error", - label: "prompts/list error", - payload: error instanceof Error ? error.message : String(error), - }); + logPromptError("client-prompts-list-error", "prompts/list error", error); } finally { inFlight.current = false; } - }, [isConnected, onDebug, workspaceId]); + }, [isConnected, logPromptError, onDebug, workspaceId]); useEffect(() => { if (!workspaceId || !isConnected) { @@ -89,8 +118,107 @@ export function useCustomPrompts({ activeWorkspace, onDebug }: UseCustomPromptsO [prompts], ); + const requireWorkspaceId = useCallback(() => { + if (!workspaceId) { + throw new Error("No workspace selected."); + } + return workspaceId; + }, [workspaceId]); + + const createPrompt = useCallback( + async (data: { + scope: "workspace" | "global"; + name: string; + description?: string | null; + argumentHint?: string | null; + content: string; + }) => { + const id = requireWorkspaceId(); + try { + await createPromptService(id, data); + await refreshPrompts(); + } catch (error) { + logPromptError("client-prompts-create-error", "prompts/create error", error); + throw error; + } + }, + [logPromptError, refreshPrompts, requireWorkspaceId], + ); + + const updatePrompt = useCallback( + async (data: { + path: string; + name: string; + description?: string | null; + argumentHint?: string | null; + content: string; + }) => { + const id = requireWorkspaceId(); + try { + await updatePromptService(id, data); + await refreshPrompts(); + } catch (error) { + logPromptError("client-prompts-update-error", "prompts/update error", error); + throw error; + } + }, + [logPromptError, refreshPrompts, requireWorkspaceId], + ); + + const deletePrompt = useCallback( + async (path: string) => { + try { + await deletePromptService(path); + await refreshPrompts(); + } catch (error) { + logPromptError("client-prompts-delete-error", "prompts/delete error", error); + throw error; + } + }, + [logPromptError, refreshPrompts], + ); + + const movePrompt = useCallback( + async (data: { path: string; scope: "workspace" | "global" }) => { + const id = requireWorkspaceId(); + try { + await movePromptService(id, data); + await refreshPrompts(); + } catch (error) { + logPromptError("client-prompts-move-error", "prompts/move error", error); + throw error; + } + }, + [logPromptError, refreshPrompts, requireWorkspaceId], + ); + + const getWorkspacePromptsDir = useCallback(async () => { + const id = requireWorkspaceId(); + try { + return await getWorkspacePromptsDirService(id); + } catch (error) { + logPromptError("client-prompts-dir-error", "prompts/workspace dir error", error); + throw error; + } + }, [logPromptError, requireWorkspaceId]); + + const getGlobalPromptsDir = useCallback(async () => { + try { + return await getGlobalPromptsDirService(); + } catch (error) { + logPromptError("client-prompts-global-dir-error", "prompts/global dir error", error); + throw error; + } + }, [logPromptError]); + return { prompts: promptOptions, refreshPrompts, + createPrompt, + updatePrompt, + deletePrompt, + movePrompt, + getWorkspacePromptsDir, + getGlobalPromptsDir, }; } diff --git a/src/features/threads/hooks/useThreads.ts b/src/features/threads/hooks/useThreads.ts index cfb497e8f..db4779f70 100644 --- a/src/features/threads/hooks/useThreads.ts +++ b/src/features/threads/hooks/useThreads.ts @@ -783,7 +783,8 @@ export function useThreads({ useAppServerEvents(handlers); const startThreadForWorkspace = useCallback( - async (workspaceId: string) => { + async (workspaceId: string, options?: { activate?: boolean }) => { + const shouldActivate = options?.activate !== false; onDebug?.({ id: `${Date.now()}-client-thread-start`, timestamp: Date.now(), @@ -804,7 +805,9 @@ export function useThreads({ const threadId = String(thread?.id ?? ""); if (threadId) { dispatch({ type: "ensureThread", workspaceId, threadId }); - dispatch({ type: "setActiveThreadId", workspaceId, threadId }); + if (shouldActivate) { + dispatch({ type: "setActiveThreadId", workspaceId, threadId }); + } loadedThreads.current[threadId] = true; return threadId; } @@ -1202,37 +1205,32 @@ export function useThreads({ return threadId; }, [activeWorkspace, activeThreadId, resumeThreadForWorkspace, startThreadForWorkspace]); - const sendUserMessage = useCallback( - async (text: string, images: string[] = []) => { - if (!activeWorkspace || (!text.trim() && images.length === 0)) { + const sendMessageToThread = useCallback( + async ( + workspace: WorkspaceInfo, + threadId: string, + text: string, + images: string[] = [], + options?: { skipPromptExpansion?: boolean }, + ) => { + const messageText = text.trim(); + if (!messageText && images.length === 0) { return; } - const messageText = text.trim(); - const promptExpansion = expandCustomPromptText(messageText, customPrompts); - if (promptExpansion && "error" in promptExpansion) { - if (activeThreadId) { - pushThreadErrorMessage(activeThreadId, promptExpansion.error); + let finalText = messageText; + if (!options?.skipPromptExpansion) { + const promptExpansion = expandCustomPromptText(messageText, customPrompts); + if (promptExpansion && "error" in promptExpansion) { + pushThreadErrorMessage(threadId, promptExpansion.error); safeMessageActivity(); - } else { - onDebug?.({ - id: `${Date.now()}-client-prompt-expand-error`, - timestamp: Date.now(), - source: "error", - label: "prompt/expand error", - payload: promptExpansion.error, - }); + return; } - return; - } - const finalText = promptExpansion?.expanded ?? messageText; - const threadId = await ensureThreadForActiveWorkspace(); - if (!threadId) { - return; + finalText = promptExpansion?.expanded ?? messageText; } - recordThreadActivity(activeWorkspace.id, threadId); + recordThreadActivity(workspace.id, threadId); dispatch({ type: "addUserMessage", - workspaceId: activeWorkspace.id, + workspaceId: workspace.id, threadId, text: finalText, images, @@ -1244,23 +1242,23 @@ export function useThreads({ timestamp: Date.now(), source: "client", label: "turn/start", - payload: { - workspaceId: activeWorkspace.id, - threadId, - text: finalText, - images, - model, - effort, - collaborationMode, - }, - }); + payload: { + workspaceId: workspace.id, + threadId, + text: finalText, + images, + model, + effort, + collaborationMode, + }, + }); try { const response = (await sendUserMessageService( - activeWorkspace.id, - threadId, - finalText, - { model, effort, collaborationMode, accessMode, images }, + workspace.id, + threadId, + finalText, + { model, effort, collaborationMode, accessMode, images }, )) as Record; onDebug?.({ id: `${Date.now()}-server-turn-start`, @@ -1308,22 +1306,77 @@ export function useThreads({ } }, [ - activeWorkspace, - markProcessing, - activeThreadId, - effort, - collaborationMode, accessMode, + collaborationMode, customPrompts, + effort, + markProcessing, model, onDebug, pushThreadErrorMessage, recordThreadActivity, + safeMessageActivity, + ], + ); + + const sendUserMessage = useCallback( + async (text: string, images: string[] = []) => { + if (!activeWorkspace) { + return; + } + const messageText = text.trim(); + if (!messageText && images.length === 0) { + return; + } + const promptExpansion = expandCustomPromptText(messageText, customPrompts); + if (promptExpansion && "error" in promptExpansion) { + if (activeThreadId) { + pushThreadErrorMessage(activeThreadId, promptExpansion.error); + safeMessageActivity(); + } else { + onDebug?.({ + id: `${Date.now()}-client-prompt-expand-error`, + timestamp: Date.now(), + source: "error", + label: "prompt/expand error", + payload: promptExpansion.error, + }); + } + return; + } + const finalText = promptExpansion?.expanded ?? messageText; + const threadId = await ensureThreadForActiveWorkspace(); + if (!threadId) { + return; + } + await sendMessageToThread(activeWorkspace, threadId, finalText, images, { + skipPromptExpansion: true, + }); + }, + [ + activeThreadId, + activeWorkspace, + customPrompts, ensureThreadForActiveWorkspace, + onDebug, + pushThreadErrorMessage, safeMessageActivity, + sendMessageToThread, ], ); + const sendUserMessageToThread = useCallback( + async ( + workspace: WorkspaceInfo, + threadId: string, + text: string, + images: string[] = [], + ) => { + await sendMessageToThread(workspace, threadId, text, images); + }, + [sendMessageToThread], + ); + const interruptTurn = useCallback(async () => { if (!activeWorkspace || !activeThreadId) { return; @@ -1536,6 +1589,7 @@ export function useThreads({ listThreadsForWorkspace, loadOlderThreadsForWorkspace, sendUserMessage, + sendUserMessageToThread, startReview, handleApprovalDecision, }; diff --git a/src/services/tauri.ts b/src/services/tauri.ts index d7c031d6b..13366d7d6 100644 --- a/src/services/tauri.ts +++ b/src/services/tauri.ts @@ -226,6 +226,69 @@ export async function getPromptsList(workspaceId: string) { return invoke("prompts_list", { workspaceId }); } +export async function getWorkspacePromptsDir(workspaceId: string) { + return invoke("prompts_workspace_dir", { workspaceId }); +} + +export async function getGlobalPromptsDir() { + return invoke("prompts_global_dir"); +} + +export async function createPrompt( + workspaceId: string, + data: { + scope: "workspace" | "global"; + name: string; + description?: string | null; + argumentHint?: string | null; + content: string; + }, +) { + return invoke("prompts_create", { + workspaceId, + scope: data.scope, + name: data.name, + description: data.description ?? null, + argumentHint: data.argumentHint ?? null, + content: data.content, + }); +} + +export async function updatePrompt( + workspaceId: string, + data: { + path: string; + name: string; + description?: string | null; + argumentHint?: string | null; + content: string; + }, +) { + return invoke("prompts_update", { + workspaceId, + path: data.path, + name: data.name, + description: data.description ?? null, + argumentHint: data.argumentHint ?? null, + content: data.content, + }); +} + +export async function deletePrompt(path: string) { + return invoke("prompts_delete", { path }); +} + +export async function movePrompt( + workspaceId: string, + data: { path: string; scope: "workspace" | "global" }, +) { + return invoke("prompts_move", { + workspaceId, + path: data.path, + scope: data.scope, + }); +} + export async function getAppSettings(): Promise { return invoke("get_app_settings"); } diff --git a/src/types.ts b/src/types.ts index 15ee3a8e5..aecdf9c99 100644 --- a/src/types.ts +++ b/src/types.ts @@ -260,6 +260,7 @@ export type CustomPromptOption = { description?: string; argumentHint?: string; content: string; + scope?: "workspace" | "global"; }; export type BranchInfo = { From b05596da33594595b4e48b12273b7493c6b2e644 Mon Sep 17 00:00:00 2001 From: Sander Helgesen Date: Sun, 18 Jan 2026 17:05:31 +0100 Subject: [PATCH 4/7] ui: add prompts panel, tabs, and layout wiring --- src/App.tsx | 138 ++++- .../files/components/FileTreePanel.tsx | 20 +- src/features/git/components/GitDiffPanel.tsx | 21 +- src/features/layout/components/PanelTabs.tsx | 47 ++ src/features/layout/hooks/useLayoutNodes.tsx | 58 +- .../prompts/components/PromptPanel.tsx | 585 ++++++++++++++++++ src/styles/panel-tabs.css | 47 ++ src/styles/prompts.css | 284 +++++++++ 8 files changed, 1160 insertions(+), 40 deletions(-) create mode 100644 src/features/layout/components/PanelTabs.tsx create mode 100644 src/features/prompts/components/PromptPanel.tsx create mode 100644 src/styles/panel-tabs.css create mode 100644 src/styles/prompts.css diff --git a/src/App.tsx b/src/App.tsx index e115b8737..e0d667741 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,8 @@ import "./styles/composer.css"; import "./styles/diff.css"; import "./styles/diff-viewer.css"; import "./styles/file-tree.css"; +import "./styles/panel-tabs.css"; +import "./styles/prompts.css"; import "./styles/debug.css"; import "./styles/terminal.css"; import "./styles/plan.css"; @@ -55,6 +57,7 @@ import { useLayoutMode } from "./features/layout/hooks/useLayoutMode"; import { useSidebarToggles } from "./features/layout/hooks/useSidebarToggles"; import { useTransparencyPreference } from "./features/layout/hooks/useTransparencyPreference"; import { useWindowLabel } from "./features/layout/hooks/useWindowLabel"; +import { revealItemInDir } from "@tauri-apps/plugin-opener"; import { RightPanelCollapseButton, SidebarCollapseButton, @@ -154,7 +157,9 @@ function MainApp() { const [gitPanelMode, setGitPanelMode] = useState< "diff" | "log" | "issues" | "prs" >("diff"); - const [filePanelMode, setFilePanelMode] = useState<"git" | "files">("git"); + const [filePanelMode, setFilePanelMode] = useState< + "git" | "files" | "prompts" + >("git"); const [selectedPullRequest, setSelectedPullRequest] = useState(null); const [diffSource, setDiffSource] = useState<"local" | "pr">("local"); @@ -370,7 +375,15 @@ function MainApp() { onDebug: addDebugEntry, }); const { skills } = useSkills({ activeWorkspace, onDebug: addDebugEntry }); - const { prompts } = useCustomPrompts({ activeWorkspace, onDebug: addDebugEntry }); + const { + prompts, + createPrompt, + updatePrompt, + deletePrompt, + movePrompt, + getWorkspacePromptsDir, + getGlobalPromptsDir, + } = useCustomPrompts({ activeWorkspace, onDebug: addDebugEntry }); const { files, isLoading: isFilesLoading } = useWorkspaceFiles({ activeWorkspace, onDebug: addDebugEntry, @@ -485,6 +498,7 @@ function MainApp() { listThreadsForWorkspace, loadOlderThreadsForWorkspace, sendUserMessage, + sendUserMessageToThread, startReview, handleApprovalDecision } = useThreads({ @@ -640,6 +654,114 @@ function MainApp() { }, [activeThreadId] ); + + const handleSendPrompt = useCallback( + (text: string) => { + if (!text.trim()) { + return; + } + void handleSend(text, []); + }, + [handleSend], + ); + + const handleSendPromptToNewAgent = useCallback( + async (text: string) => { + const trimmed = text.trim(); + if (!activeWorkspace || !trimmed) { + return; + } + if (!activeWorkspace.connected) { + await connectWorkspace(activeWorkspace); + } + const threadId = await startThreadForWorkspace(activeWorkspace.id, { + activate: false, + }); + if (!threadId) { + return; + } + await sendUserMessageToThread(activeWorkspace, threadId, trimmed, []); + }, + [activeWorkspace, connectWorkspace, sendUserMessageToThread, startThreadForWorkspace], + ); + + const alertError = useCallback((error: unknown) => { + alert(error instanceof Error ? error.message : String(error)); + }, []); + + const handleCreatePrompt = useCallback( + async (data: { + scope: "workspace" | "global"; + name: string; + description?: string | null; + argumentHint?: string | null; + content: string; + }) => { + try { + await createPrompt(data); + } catch (error) { + alertError(error); + } + }, + [alertError, createPrompt], + ); + + const handleUpdatePrompt = useCallback( + async (data: { + path: string; + name: string; + description?: string | null; + argumentHint?: string | null; + content: string; + }) => { + try { + await updatePrompt(data); + } catch (error) { + alertError(error); + } + }, + [alertError, updatePrompt], + ); + + const handleDeletePrompt = useCallback( + async (path: string) => { + try { + await deletePrompt(path); + } catch (error) { + alertError(error); + } + }, + [alertError, deletePrompt], + ); + + const handleMovePrompt = useCallback( + async (data: { path: string; scope: "workspace" | "global" }) => { + try { + await movePrompt(data); + } catch (error) { + alertError(error); + } + }, + [alertError, movePrompt], + ); + + const handleRevealWorkspacePrompts = useCallback(async () => { + try { + const path = await getWorkspacePromptsDir(); + await revealItemInDir(path); + } catch (error) { + alertError(error); + } + }, [alertError, getWorkspacePromptsDir]); + + const handleRevealGeneralPrompts = useCallback(async () => { + try { + const path = await getGlobalPromptsDir(); + await revealItemInDir(path); + } catch (error) { + alertError(error); + } + }, [alertError, getGlobalPromptsDir]); const isWorktreeWorkspace = activeWorkspace?.kind === "worktree"; const activeParentWorkspace = isWorktreeWorkspace ? workspaces.find((entry) => entry.id === activeWorkspace?.parentId) ?? null @@ -994,9 +1116,7 @@ function MainApp() { ) : null, filePanelMode, - onToggleFilePanel: () => { - setFilePanelMode((prev) => (prev === "git" ? "files" : "git")); - }, + onFilePanelModeChange: setFilePanelMode, fileTreeLoading: isFilesLoading, centerMode, onExitDiff: () => { @@ -1051,6 +1171,14 @@ function MainApp() { gitDiffLoading: activeDiffLoading, gitDiffError: activeDiffError, onDiffActivePathChange: handleActiveDiffPath, + onSendPrompt: handleSendPrompt, + onSendPromptToNewAgent: handleSendPromptToNewAgent, + onCreatePrompt: handleCreatePrompt, + onUpdatePrompt: handleUpdatePrompt, + onDeletePrompt: handleDeletePrompt, + onMovePrompt: handleMovePrompt, + onRevealWorkspacePrompts: handleRevealWorkspacePrompts, + onRevealGeneralPrompts: handleRevealGeneralPrompts, onSend: handleSend, onQueue: queueMessage, onStop: interruptTurn, diff --git a/src/features/files/components/FileTreePanel.tsx b/src/features/files/components/FileTreePanel.tsx index 75d2f332e..5da06985b 100644 --- a/src/features/files/components/FileTreePanel.tsx +++ b/src/features/files/components/FileTreePanel.tsx @@ -5,7 +5,6 @@ import { LogicalPosition } from "@tauri-apps/api/dpi"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { revealItemInDir } from "@tauri-apps/plugin-opener"; import { - ArrowLeftRight, ChevronsUpDown, File, FileArchive, @@ -19,6 +18,7 @@ import { Folder, Search, } from "lucide-react"; +import { PanelTabs, type PanelTabId } from "../../layout/components/PanelTabs"; type FileTreeNode = { name: string; @@ -31,7 +31,8 @@ type FileTreePanelProps = { workspacePath: string; files: string[]; isLoading: boolean; - onToggleFilePanel: () => void; + filePanelMode: PanelTabId; + onFilePanelModeChange: (mode: PanelTabId) => void; }; type FileTreeBuildNode = { @@ -177,7 +178,8 @@ export function FileTreePanel({ workspacePath, files, isLoading, - onToggleFilePanel, + filePanelMode, + onFilePanelModeChange, }: FileTreePanelProps) { const [expandedFolders, setExpandedFolders] = useState>(new Set()); const [query, setQuery] = useState(""); @@ -326,17 +328,7 @@ export function FileTreePanel({ return (