From d3bab9cbab646899831d57a40e786701fa2d5e92 Mon Sep 17 00:00:00 2001 From: Hikari Date: Sat, 24 Jan 2026 13:57:51 -0800 Subject: [PATCH] feat: add attachment preview UI component (#66) - Create Attachment interface in messages.ts - Create AttachmentPreview.svelte component with: - Image thumbnails for image attachments - File icons for documents - File size display - Remove button on hover - Add attachments array to Conversation interface - Add attachment management methods to conversation store: - addAttachment, removeAttachment, clearAttachments, getAttachments - Integrate AttachmentPreview into InputBar - Clear attachments on conversation reset --- src/lib/components/AttachmentPreview.svelte | 204 ++++++++++++++++++++ src/lib/components/InputBar.svelte | 13 ++ src/lib/stores/claude.ts | 8 + src/lib/stores/conversations.ts | 57 ++++++ src/lib/types/messages.ts | 10 + 5 files changed, 292 insertions(+) create mode 100644 src/lib/components/AttachmentPreview.svelte diff --git a/src/lib/components/AttachmentPreview.svelte b/src/lib/components/AttachmentPreview.svelte new file mode 100644 index 0000000..c12d1e7 --- /dev/null +++ b/src/lib/components/AttachmentPreview.svelte @@ -0,0 +1,204 @@ + + +{#if attachments.length > 0} +
+
+ {attachments.length} attachment{attachments.length !== 1 ? "s" : ""} +
+
+ {#each attachments as attachment (attachment.id)} +
+ {#if attachment.type === "image" && attachment.previewUrl} +
+ {attachment.filename} +
+ {:else} +
+ {getFileIcon(attachment.type)} +
+ {/if} +
+ + {attachment.filename} + + + {formatFileSize(attachment.size)} + +
+ +
+ {/each} +
+
+{/if} + + diff --git a/src/lib/components/InputBar.svelte b/src/lib/components/InputBar.svelte index 7313c06..166e047 100644 --- a/src/lib/components/InputBar.svelte +++ b/src/lib/components/InputBar.svelte @@ -22,6 +22,8 @@ isSlashCommand, type SlashCommand, } from "$lib/commands/slashCommands"; + import AttachmentPreview from "$lib/components/AttachmentPreview.svelte"; + import type { Attachment } from "$lib/types/messages"; const INPUT_HISTORY_KEY = "hikari-input-history"; const MAX_HISTORY_SIZE = 100; @@ -33,6 +35,7 @@ let showCommandMenu = $state(false); let matchingCommands = $state([]); let selectedCommandIndex = $state(0); + let attachments = $state([]); // Input history state let inputHistory = $state([]); @@ -112,6 +115,10 @@ isProcessing = processing; }); + claudeStore.attachments.subscribe((storedAttachments) => { + attachments = storedAttachments; + }); + function handleInputChange() { // If input is empty, allow history navigation again // Otherwise, mark that user has manually typed @@ -289,6 +296,10 @@ User: ${formattedMessage}`; } } + function handleRemoveAttachment(id: string) { + claudeStore.removeAttachment(id); + } + function handleKeyDown(event: KeyboardEvent) { // Handle command menu navigation if (showCommandMenu && matchingCommands.length > 0) { @@ -353,6 +364,8 @@ User: ${formattedMessage}`;
+ +
diff --git a/src/lib/stores/claude.ts b/src/lib/stores/claude.ts index 7b8983e..c361e46 100644 --- a/src/lib/stores/claude.ts +++ b/src/lib/stores/claude.ts @@ -25,6 +25,7 @@ export const claudeStore = { isProcessing: conversationsStore.isProcessing, grantedTools: conversationsStore.grantedTools, pendingRetryMessage: conversationsStore.pendingRetryMessage, + attachments: conversationsStore.attachments, // New conversation-aware subscriptions conversations: conversationsStore.conversations, @@ -67,6 +68,12 @@ export const claudeStore = { saveScrollPosition: conversationsStore.saveScrollPosition, getScrollPosition: conversationsStore.getScrollPosition, + // Attachment management + addAttachment: conversationsStore.addAttachment, + removeAttachment: conversationsStore.removeAttachment, + clearAttachments: conversationsStore.clearAttachments, + getAttachments: conversationsStore.getAttachments, + getGrantedTools: (): string[] => { let tools: string[] = []; conversationsStore.grantedTools.subscribe((t) => (tools = Array.from(t)))(); @@ -86,6 +93,7 @@ export const claudeStore = { conversationsStore.setWorkingDirectory(""); conversationsStore.setProcessing(false); conversationsStore.revokeAllTools(); + conversationsStore.clearAttachments(); // Also clear history restoration clearHistoryRestore(); }, diff --git a/src/lib/stores/conversations.ts b/src/lib/stores/conversations.ts index 243d22a..7480f8c 100644 --- a/src/lib/stores/conversations.ts +++ b/src/lib/stores/conversations.ts @@ -4,6 +4,7 @@ import type { ConnectionStatus, PermissionRequest, UserQuestionEvent, + Attachment, } from "$lib/types/messages"; import type { CharacterState } from "$lib/types/states"; import { cleanupConversationTracking } from "$lib/tauri"; @@ -24,6 +25,7 @@ export interface Conversation { scrollPosition: number; createdAt: Date; lastActivityAt: Date; + attachments: Attachment[]; } function createConversationsStore() { @@ -59,6 +61,7 @@ function createConversationsStore() { scrollPosition: -1, // -1 means "scroll to bottom" (auto-scroll) createdAt: new Date(), lastActivityAt: new Date(), + attachments: [], }; } @@ -109,6 +112,7 @@ function createConversationsStore() { ); const pendingQuestion = derived(activeConversation, ($conv) => $conv?.pendingQuestion || null); const scrollPosition = derived(activeConversation, ($conv) => $conv?.scrollPosition ?? -1); + const attachments = derived(activeConversation, ($conv) => $conv?.attachments || []); return { // Expose derived stores for compatibility @@ -122,6 +126,7 @@ function createConversationsStore() { grantedTools: { subscribe: grantedTools.subscribe }, pendingRetryMessage: { subscribe: pendingRetryMessage.subscribe }, scrollPosition: { subscribe: scrollPosition.subscribe }, + attachments: { subscribe: attachments.subscribe }, // New conversation-specific stores conversations: { subscribe: conversations.subscribe }, @@ -571,6 +576,58 @@ function createConversationsStore() { return conv?.grantedTools.has(toolName) || false; }, + // Attachment management + addAttachment: (attachment: Attachment) => { + const activeId = get(activeConversationId); + if (!activeId) return; + + conversations.update((convs) => { + const conv = convs.get(activeId); + if (conv) { + conv.attachments.push(attachment); + conv.lastActivityAt = new Date(); + } + return convs; + }); + }, + + removeAttachment: (id: string) => { + const activeId = get(activeConversationId); + if (!activeId) return; + + conversations.update((convs) => { + const conv = convs.get(activeId); + if (conv) { + conv.attachments = conv.attachments.filter((a) => a.id !== id); + conv.lastActivityAt = new Date(); + } + return convs; + }); + }, + + clearAttachments: () => { + const activeId = get(activeConversationId); + if (!activeId) return; + + conversations.update((convs) => { + const conv = convs.get(activeId); + if (conv) { + conv.attachments = []; + conv.lastActivityAt = new Date(); + } + return convs; + }); + }, + + getAttachments: (): Attachment[] => { + const activeId = get(activeConversationId); + if (!activeId) return []; + + const convs = get(conversations); + const conv = convs.get(activeId); + return conv?.attachments || []; + }, + // Add initialization helper initialize: () => { ensureInitialized(); diff --git a/src/lib/types/messages.ts b/src/lib/types/messages.ts index 2985bd0..8e85797 100644 --- a/src/lib/types/messages.ts +++ b/src/lib/types/messages.ts @@ -142,6 +142,16 @@ export interface UserQuestionEvent { export type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error"; +export interface Attachment { + id: string; + filename: string; + path: string; + size: number; + type: "image" | "document" | "other"; + mimeType?: string; + previewUrl?: string; // For images, a data URL or object URL for preview +} + export interface UpdateInfo { current_version: string; latest_version: string;