generated from nhcarrigan/template
feat: add quick actions panel for common prompts
- Add Rust backend for managing quick actions with persistent storage - Create QuickActionsPanel component with edit/delete functionality - Add quickActions store for frontend state management - Move Actions and Snippets buttons to input controls row - Include 6 default quick actions: Review PR, Run Tests, Explain File, Fix Error, Write Tests, and Refactor - Support custom quick action creation and management Closes #15
This commit is contained in:
@@ -4,6 +4,7 @@ mod commands;
|
||||
mod config;
|
||||
mod git;
|
||||
mod notifications;
|
||||
mod quick_actions;
|
||||
mod sessions;
|
||||
mod snippets;
|
||||
mod stats;
|
||||
@@ -20,6 +21,7 @@ use commands::load_saved_achievements;
|
||||
use commands::*;
|
||||
use git::*;
|
||||
use notifications::*;
|
||||
use quick_actions::*;
|
||||
use sessions::*;
|
||||
use snippets::*;
|
||||
use tauri::Manager;
|
||||
@@ -120,6 +122,10 @@ pub fn run() {
|
||||
delete_snippet,
|
||||
get_snippet_categories,
|
||||
reset_default_snippets,
|
||||
list_quick_actions,
|
||||
save_quick_action,
|
||||
delete_quick_action,
|
||||
reset_default_quick_actions,
|
||||
git_status,
|
||||
git_diff,
|
||||
git_branches,
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
|
||||
const QUICK_ACTIONS_STORE_KEY: &str = "quick_actions";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct QuickAction {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub prompt: String,
|
||||
pub icon: String,
|
||||
pub is_default: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
fn get_default_quick_actions() -> Vec<QuickAction> {
|
||||
let now = Utc::now();
|
||||
vec![
|
||||
QuickAction {
|
||||
id: "default-review-pr".to_string(),
|
||||
name: "Review PR".to_string(),
|
||||
prompt: "Please review this pull request and provide feedback on code quality, potential issues, and suggestions for improvement.".to_string(),
|
||||
icon: "git-pull-request".to_string(),
|
||||
is_default: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
QuickAction {
|
||||
id: "default-run-tests".to_string(),
|
||||
name: "Run Tests".to_string(),
|
||||
prompt: "Please run the test suite for this project and report any failures or issues.".to_string(),
|
||||
icon: "play".to_string(),
|
||||
is_default: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
QuickAction {
|
||||
id: "default-explain-file".to_string(),
|
||||
name: "Explain File".to_string(),
|
||||
prompt: "Please explain what this file does, its purpose, and how it fits into the overall project structure.".to_string(),
|
||||
icon: "file-text".to_string(),
|
||||
is_default: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
QuickAction {
|
||||
id: "default-fix-error".to_string(),
|
||||
name: "Fix Error".to_string(),
|
||||
prompt: "I'm getting an error. Can you help me identify the cause and fix it?".to_string(),
|
||||
icon: "alert-circle".to_string(),
|
||||
is_default: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
QuickAction {
|
||||
id: "default-write-tests".to_string(),
|
||||
name: "Write Tests".to_string(),
|
||||
prompt: "Please write comprehensive unit tests for the current code with good coverage.".to_string(),
|
||||
icon: "check-square".to_string(),
|
||||
is_default: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
QuickAction {
|
||||
id: "default-refactor".to_string(),
|
||||
name: "Refactor".to_string(),
|
||||
prompt: "Please refactor this code to improve readability, maintainability, and performance.".to_string(),
|
||||
icon: "refresh-cw".to_string(),
|
||||
is_default: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn load_all_quick_actions(app: &AppHandle) -> Result<Vec<QuickAction>, String> {
|
||||
let store = app
|
||||
.store("hikari-quick-actions.json")
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
match store.get(QUICK_ACTIONS_STORE_KEY) {
|
||||
Some(value) => {
|
||||
let mut actions: Vec<QuickAction> =
|
||||
serde_json::from_value(value.clone()).map_err(|e| e.to_string())?;
|
||||
|
||||
let defaults = get_default_quick_actions();
|
||||
for default in defaults {
|
||||
if !actions.iter().any(|a| a.id == default.id) {
|
||||
actions.push(default);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(actions)
|
||||
}
|
||||
None => Ok(get_default_quick_actions()),
|
||||
}
|
||||
}
|
||||
|
||||
fn save_all_quick_actions(app: &AppHandle, actions: &[QuickAction]) -> Result<(), String> {
|
||||
let store = app
|
||||
.store("hikari-quick-actions.json")
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let value = serde_json::to_value(actions).map_err(|e| e.to_string())?;
|
||||
store.set(QUICK_ACTIONS_STORE_KEY, value);
|
||||
store.save().map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_quick_actions(app: AppHandle) -> Result<Vec<QuickAction>, String> {
|
||||
let mut actions = load_all_quick_actions(&app)?;
|
||||
|
||||
actions.sort_by(|a, b| {
|
||||
let default_cmp = b.is_default.cmp(&a.is_default);
|
||||
if default_cmp == std::cmp::Ordering::Equal {
|
||||
a.name.cmp(&b.name)
|
||||
} else {
|
||||
default_cmp
|
||||
}
|
||||
});
|
||||
|
||||
Ok(actions)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_quick_action(app: AppHandle, action: QuickAction) -> Result<(), String> {
|
||||
let mut actions = load_all_quick_actions(&app)?;
|
||||
|
||||
if let Some(existing) = actions.iter_mut().find(|a| a.id == action.id) {
|
||||
let mut updated = action;
|
||||
updated.is_default = existing.is_default;
|
||||
*existing = updated;
|
||||
} else {
|
||||
actions.push(action);
|
||||
}
|
||||
|
||||
save_all_quick_actions(&app, &actions)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_quick_action(app: AppHandle, action_id: String) -> Result<(), String> {
|
||||
let mut actions = load_all_quick_actions(&app)?;
|
||||
|
||||
if actions
|
||||
.iter()
|
||||
.any(|a| a.id == action_id && a.is_default)
|
||||
{
|
||||
return Err("Cannot delete default quick actions".to_string());
|
||||
}
|
||||
|
||||
actions.retain(|a| a.id != action_id);
|
||||
save_all_quick_actions(&app, &actions)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn reset_default_quick_actions(app: AppHandle) -> Result<(), String> {
|
||||
let mut actions = load_all_quick_actions(&app)?;
|
||||
|
||||
actions.retain(|a| !a.is_default);
|
||||
actions.extend(get_default_quick_actions());
|
||||
|
||||
save_all_quick_actions(&app, &actions)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_default_quick_actions_exist() {
|
||||
let defaults = get_default_quick_actions();
|
||||
assert!(!defaults.is_empty());
|
||||
assert!(defaults.iter().all(|a| a.is_default));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_quick_actions_have_required_fields() {
|
||||
let defaults = get_default_quick_actions();
|
||||
for action in defaults {
|
||||
assert!(!action.id.is_empty());
|
||||
assert!(!action.name.is_empty());
|
||||
assert!(!action.prompt.is_empty());
|
||||
assert!(!action.icon.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@
|
||||
} from "$lib/commands/slashCommands";
|
||||
import AttachmentPreview from "$lib/components/AttachmentPreview.svelte";
|
||||
import SnippetLibraryPanel from "$lib/components/SnippetLibraryPanel.svelte";
|
||||
import QuickActionsPanel from "$lib/components/QuickActionsPanel.svelte";
|
||||
import type { Attachment } from "$lib/types/messages";
|
||||
|
||||
const INPUT_HISTORY_KEY = "hikari-input-history";
|
||||
@@ -41,6 +42,7 @@
|
||||
let attachments = $state<Attachment[]>([]);
|
||||
let isDragging = $state(false);
|
||||
let showSnippetLibrary = $state(false);
|
||||
let showQuickActions = $state(false);
|
||||
|
||||
// Input history state
|
||||
let inputHistory = $state<string[]>([]);
|
||||
@@ -629,6 +631,42 @@ User: ${formattedMessage}`;
|
||||
userHasTyped = true;
|
||||
}
|
||||
|
||||
async function handleQuickAction(prompt: string): Promise<void> {
|
||||
// Quick actions send the prompt directly
|
||||
if (!isConnected || isSubmitting) return;
|
||||
|
||||
// Add to history
|
||||
addToHistory(prompt);
|
||||
historyIndex = -1;
|
||||
tempInput = "";
|
||||
userHasTyped = false;
|
||||
|
||||
isSubmitting = true;
|
||||
|
||||
// Reset notification state for new user message
|
||||
handleNewUserMessage();
|
||||
|
||||
claudeStore.addLine("user", prompt);
|
||||
characterState.setState("thinking");
|
||||
|
||||
try {
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
if (!conversationId) {
|
||||
throw new Error("No active conversation");
|
||||
}
|
||||
await invoke("send_prompt", {
|
||||
conversationId,
|
||||
message: prompt,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to send quick action:", error);
|
||||
claudeStore.addLine("error", `Failed to send: ${error}`);
|
||||
characterState.setTemporaryState("error", 3000);
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
// Handle command menu navigation
|
||||
if (showCommandMenu && matchingCommands.length > 0) {
|
||||
@@ -705,6 +743,50 @@ User: ${formattedMessage}`;
|
||||
|
||||
<div class="input-controls flex gap-2 mb-2">
|
||||
<MessageModeSelector />
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showQuickActions = true)}
|
||||
class="control-button"
|
||||
title="Quick Actions"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<span>Actions</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showSnippetLibrary = true)}
|
||||
class="control-button"
|
||||
title="Snippet Library"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
|
||||
<polyline points="14,2 14,8 20,8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
<line x1="10" y1="9" x2="8" y2="9" />
|
||||
</svg>
|
||||
<span>Snippets</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="input-row">
|
||||
@@ -735,29 +817,6 @@ User: ${formattedMessage}`;
|
||||
</div>
|
||||
|
||||
<div class="button-wrapper">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showSnippetLibrary = true)}
|
||||
class="attach-button"
|
||||
title="Snippet Library"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
|
||||
<polyline points="14,2 14,8 20,8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
<line x1="10" y1="9" x2="8" y2="9" />
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" onclick={handleFilePicker} class="attach-button" title="Attach files">
|
||||
<svg
|
||||
width="20"
|
||||
@@ -811,6 +870,13 @@ User: ${formattedMessage}`;
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showQuickActions}
|
||||
<QuickActionsPanel
|
||||
onClose={() => (showQuickActions = false)}
|
||||
onAction={handleQuickAction}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.input-bar {
|
||||
display: flex;
|
||||
@@ -853,6 +919,32 @@ User: ${formattedMessage}`;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.control-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.control-button:hover {
|
||||
background: var(--bg-tertiary);
|
||||
border-color: var(--accent-primary);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.control-button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { quickActionsStore, type QuickAction } from "$lib/stores/quickActions";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onAction: (prompt: string) => void;
|
||||
}
|
||||
|
||||
const { onClose, onAction }: Props = $props();
|
||||
|
||||
let editingAction = $state<QuickAction | null>(null);
|
||||
let isCreating = $state(false);
|
||||
let showDeleteConfirm = $state<string | null>(null);
|
||||
|
||||
let editName = $state("");
|
||||
let editPrompt = $state("");
|
||||
let editIcon = $state("zap");
|
||||
|
||||
const actions = $derived(quickActionsStore.actions);
|
||||
const isLoading = $derived(quickActionsStore.isLoading);
|
||||
|
||||
const availableIcons = [
|
||||
{ id: "zap", label: "Lightning" },
|
||||
{ id: "play", label: "Play" },
|
||||
{ id: "file-text", label: "File" },
|
||||
{ id: "alert-circle", label: "Alert" },
|
||||
{ id: "check-square", label: "Check" },
|
||||
{ id: "refresh-cw", label: "Refresh" },
|
||||
{ id: "git-pull-request", label: "Git PR" },
|
||||
{ id: "code", label: "Code" },
|
||||
{ id: "search", label: "Search" },
|
||||
{ id: "terminal", label: "Terminal" },
|
||||
{ id: "bug", label: "Bug" },
|
||||
{ id: "shield", label: "Shield" },
|
||||
];
|
||||
|
||||
onMount(() => {
|
||||
quickActionsStore.loadQuickActions();
|
||||
});
|
||||
|
||||
function handleAction(action: QuickAction): void {
|
||||
onAction(action.prompt);
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleStartCreate(): void {
|
||||
isCreating = true;
|
||||
editingAction = null;
|
||||
editName = "";
|
||||
editPrompt = "";
|
||||
editIcon = "zap";
|
||||
}
|
||||
|
||||
function handleStartEdit(action: QuickAction): void {
|
||||
editingAction = action;
|
||||
isCreating = false;
|
||||
editName = action.name;
|
||||
editPrompt = action.prompt;
|
||||
editIcon = action.icon;
|
||||
}
|
||||
|
||||
function handleCancelEdit(): void {
|
||||
editingAction = null;
|
||||
isCreating = false;
|
||||
}
|
||||
|
||||
async function handleSave(): Promise<void> {
|
||||
if (!editName.trim() || !editPrompt.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCreating) {
|
||||
await quickActionsStore.createQuickAction(editName.trim(), editPrompt.trim(), editIcon);
|
||||
} else if (editingAction) {
|
||||
await quickActionsStore.updateQuickAction(
|
||||
editingAction.id,
|
||||
editName.trim(),
|
||||
editPrompt.trim(),
|
||||
editIcon
|
||||
);
|
||||
}
|
||||
|
||||
handleCancelEdit();
|
||||
}
|
||||
|
||||
async function handleDelete(actionId: string): Promise<void> {
|
||||
await quickActionsStore.deleteQuickAction(actionId);
|
||||
showDeleteConfirm = null;
|
||||
}
|
||||
|
||||
function getIconSvg(icon: string): string {
|
||||
const icons: Record<string, string> = {
|
||||
"git-pull-request":
|
||||
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 3v12M18 9a3 3 0 100 6 3 3 0 000-6zm0 0V3m0 18v-6M6 21a3 3 0 100-6 3 3 0 000 6z" />',
|
||||
play: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />',
|
||||
"file-text":
|
||||
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />',
|
||||
"alert-circle":
|
||||
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />',
|
||||
"check-square":
|
||||
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />',
|
||||
"refresh-cw":
|
||||
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />',
|
||||
zap: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />',
|
||||
code: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />',
|
||||
search:
|
||||
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />',
|
||||
terminal:
|
||||
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />',
|
||||
bug: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />',
|
||||
shield:
|
||||
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />',
|
||||
};
|
||||
return icons[icon] || icons["zap"];
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||
onclick={onClose}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={(e) => e.key === "Escape" && onClose()}
|
||||
>
|
||||
<div
|
||||
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-2xl w-full max-h-[80vh] overflow-hidden flex flex-col"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-labelledby="quick-actions-title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
|
||||
<div class="flex items-center gap-3">
|
||||
{#if editingAction || isCreating}
|
||||
<button
|
||||
onclick={handleCancelEdit}
|
||||
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
aria-label="Back to list"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<h2 id="quick-actions-title" class="text-xl font-semibold text-[var(--text-primary)]">
|
||||
{#if isCreating}
|
||||
Create Quick Action
|
||||
{:else if editingAction}
|
||||
Edit Quick Action
|
||||
{:else}
|
||||
Quick Actions
|
||||
{/if}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if !editingAction && !isCreating}
|
||||
<button
|
||||
onclick={handleStartCreate}
|
||||
class="px-3 py-1.5 text-sm font-medium bg-[var(--accent-primary)] text-white rounded hover:bg-[var(--accent-primary)]/80 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
New Action
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if editingAction || isCreating}
|
||||
<div class="p-6 flex-1 overflow-y-auto">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
for="action-name"
|
||||
class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="action-name"
|
||||
type="text"
|
||||
bind:value={editName}
|
||||
placeholder="Enter action name..."
|
||||
class="w-full px-4 py-2 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="action-icon"
|
||||
class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
|
||||
>
|
||||
Icon
|
||||
</label>
|
||||
<div class="grid grid-cols-6 gap-2">
|
||||
{#each availableIcons as icon (icon.id)}
|
||||
<button
|
||||
onclick={() => (editIcon = icon.id)}
|
||||
class="p-3 rounded-lg border transition-colors {editIcon === icon.id
|
||||
? 'bg-[var(--accent-primary)]/10 border-[var(--accent-primary)] text-[var(--accent-primary)]'
|
||||
: 'bg-[var(--bg-secondary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]/50'}"
|
||||
title={icon.label}
|
||||
>
|
||||
<svg class="w-5 h-5 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{@html getIconSvg(icon.id)}
|
||||
</svg>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="action-prompt"
|
||||
class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
|
||||
>
|
||||
Prompt
|
||||
</label>
|
||||
<textarea
|
||||
id="action-prompt"
|
||||
bind:value={editPrompt}
|
||||
placeholder="Enter the prompt to send..."
|
||||
rows="4"
|
||||
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent-primary)] resize-none font-mono text-sm"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-4">
|
||||
<button
|
||||
onclick={handleCancelEdit}
|
||||
class="px-4 py-2 text-sm font-medium bg-[var(--bg-secondary)] text-[var(--text-secondary)] rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onclick={handleSave}
|
||||
disabled={!editName.trim() || !editPrompt.trim()}
|
||||
class="px-4 py-2 text-sm font-medium bg-[var(--accent-primary)] text-white rounded-lg hover:bg-[var(--accent-primary)]/80 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isCreating ? "Create" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
{#if $isLoading}
|
||||
<div class="flex items-center justify-center p-8">
|
||||
<div class="text-[var(--text-tertiary)]">Loading quick actions...</div>
|
||||
</div>
|
||||
{:else if $actions.length === 0}
|
||||
<div class="flex flex-col items-center justify-center p-8 text-center">
|
||||
<svg
|
||||
class="w-16 h-16 text-[var(--text-tertiary)] mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-[var(--text-secondary)]">No quick actions available</p>
|
||||
<button
|
||||
onclick={handleStartCreate}
|
||||
class="mt-4 px-4 py-2 text-sm font-medium bg-[var(--accent-primary)] text-white rounded-lg hover:bg-[var(--accent-primary)]/80 transition-colors"
|
||||
>
|
||||
Create your first action
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{#each $actions as action (action.id)}
|
||||
<div
|
||||
class="group relative bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg hover:border-[var(--accent-primary)]/50 transition-colors"
|
||||
>
|
||||
<button
|
||||
onclick={() => handleAction(action)}
|
||||
class="w-full p-4 text-left"
|
||||
>
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<div
|
||||
class="w-10 h-10 rounded-lg bg-[var(--accent-primary)]/10 flex items-center justify-center text-[var(--accent-primary)]"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{@html getIconSvg(action.icon)}
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-medium text-[var(--text-primary)] truncate">{action.name}</h3>
|
||||
{#if action.is_default}
|
||||
<span class="text-xs text-[var(--text-tertiary)]">Default</span>
|
||||
{:else}
|
||||
<span class="text-xs text-[var(--accent-secondary)]">Custom</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-[var(--text-tertiary)] line-clamp-2">{action.prompt}</p>
|
||||
</button>
|
||||
<div
|
||||
class="absolute top-2 right-2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<button
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleStartEdit(action);
|
||||
}}
|
||||
class="p-1.5 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] bg-[var(--bg-primary)] rounded transition-colors"
|
||||
title="Edit action"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{#if !action.is_default}
|
||||
{#if showDeleteConfirm === action.id}
|
||||
<div class="flex items-center gap-1 bg-[var(--bg-primary)] rounded p-1">
|
||||
<button
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(action.id);
|
||||
}}
|
||||
class="px-2 py-0.5 text-xs font-medium bg-red-500 text-white rounded hover:bg-red-600 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
showDeleteConfirm = null;
|
||||
}}
|
||||
class="px-2 py-0.5 text-xs font-medium bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded hover:bg-[var(--bg-secondary)] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
showDeleteConfirm = action.id;
|
||||
}}
|
||||
class="p-1.5 text-[var(--text-tertiary)] hover:text-red-400 bg-[var(--bg-primary)] rounded transition-colors"
|
||||
title="Delete action"
|
||||
>
|
||||
<svg
|
||||
class="w-3.5 h-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
[role="dialog"] {
|
||||
animation: slideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.overflow-y-auto {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-color) transparent;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb {
|
||||
background-color: var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,118 @@
|
||||
import { writable } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export interface QuickAction {
|
||||
id: string;
|
||||
name: string;
|
||||
prompt: string;
|
||||
icon: string;
|
||||
is_default: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
function createQuickActionsStore() {
|
||||
const actions = writable<QuickAction[]>([]);
|
||||
const isLoading = writable(false);
|
||||
|
||||
async function loadQuickActions(): Promise<void> {
|
||||
isLoading.set(true);
|
||||
try {
|
||||
const actionList = await invoke<QuickAction[]>("list_quick_actions");
|
||||
actions.set(actionList);
|
||||
} catch (error) {
|
||||
console.error("Failed to load quick actions:", error);
|
||||
} finally {
|
||||
isLoading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveQuickAction(action: QuickAction): Promise<boolean> {
|
||||
try {
|
||||
await invoke("save_quick_action", { action });
|
||||
await loadQuickActions();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to save quick action:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createQuickAction(
|
||||
name: string,
|
||||
prompt: string,
|
||||
icon: string
|
||||
): Promise<boolean> {
|
||||
const now = new Date().toISOString();
|
||||
const action: QuickAction = {
|
||||
id: `custom-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
name,
|
||||
prompt,
|
||||
icon,
|
||||
is_default: false,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
return saveQuickAction(action);
|
||||
}
|
||||
|
||||
async function updateQuickAction(
|
||||
id: string,
|
||||
name: string,
|
||||
prompt: string,
|
||||
icon: string
|
||||
): Promise<boolean> {
|
||||
const currentActions = await invoke<QuickAction[]>("list_quick_actions");
|
||||
const existing = currentActions.find((a) => a.id === id);
|
||||
|
||||
if (!existing) {
|
||||
console.error("Quick action not found for update");
|
||||
return false;
|
||||
}
|
||||
|
||||
const updated: QuickAction = {
|
||||
...existing,
|
||||
name,
|
||||
prompt,
|
||||
icon,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return saveQuickAction(updated);
|
||||
}
|
||||
|
||||
async function deleteQuickAction(actionId: string): Promise<boolean> {
|
||||
try {
|
||||
await invoke("delete_quick_action", { actionId });
|
||||
await loadQuickActions();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to delete quick action:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resetDefaults(): Promise<boolean> {
|
||||
try {
|
||||
await invoke("reset_default_quick_actions");
|
||||
await loadQuickActions();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to reset default quick actions:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
actions: { subscribe: actions.subscribe },
|
||||
isLoading: { subscribe: isLoading.subscribe },
|
||||
loadQuickActions,
|
||||
saveQuickAction,
|
||||
createQuickAction,
|
||||
updateQuickAction,
|
||||
deleteQuickAction,
|
||||
resetDefaults,
|
||||
};
|
||||
}
|
||||
|
||||
export const quickActionsStore = createQuickActionsStore();
|
||||
Reference in New Issue
Block a user