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:
2026-01-25 16:25:59 -08:00
committed by Naomi Carrigan
parent 87cf633564
commit 02cc8bd0d5
5 changed files with 881 additions and 23 deletions
+6
View File
@@ -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,
+191
View File
@@ -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());
}
}
}
+115 -23
View File
@@ -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;
+451
View File
@@ -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>
+118
View File
@@ -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();