generated from nhcarrigan/template
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
This commit is contained in:
@@ -0,0 +1,204 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Attachment } from "$lib/types/messages";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
attachments: Attachment[];
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { attachments, onRemove }: Props = $props();
|
||||||
|
|
||||||
|
function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileIcon(type: Attachment["type"]): string {
|
||||||
|
switch (type) {
|
||||||
|
case "image":
|
||||||
|
return "🖼️";
|
||||||
|
case "document":
|
||||||
|
return "📄";
|
||||||
|
default:
|
||||||
|
return "📎";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if attachments.length > 0}
|
||||||
|
<div class="attachment-preview-container">
|
||||||
|
<div class="attachment-header">
|
||||||
|
<span class="attachment-count">{attachments.length} attachment{attachments.length !== 1 ? "s" : ""}</span>
|
||||||
|
</div>
|
||||||
|
<div class="attachment-list">
|
||||||
|
{#each attachments as attachment (attachment.id)}
|
||||||
|
<div class="attachment-item" class:is-image={attachment.type === "image"}>
|
||||||
|
{#if attachment.type === "image" && attachment.previewUrl}
|
||||||
|
<div class="image-preview">
|
||||||
|
<img src={attachment.previewUrl} alt={attachment.filename} />
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="file-icon">
|
||||||
|
{getFileIcon(attachment.type)}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="attachment-info">
|
||||||
|
<span class="attachment-filename" title={attachment.filename}>
|
||||||
|
{attachment.filename}
|
||||||
|
</span>
|
||||||
|
<span class="attachment-size">
|
||||||
|
{formatFileSize(attachment.size)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="remove-button"
|
||||||
|
onclick={() => onRemove(attachment.id)}
|
||||||
|
title="Remove attachment"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.attachment-preview-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
max-width: 200px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-item.is-image {
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 4px;
|
||||||
|
max-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 110px;
|
||||||
|
max-height: 80px;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 80px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-image .attachment-info {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-filename {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-size {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-button {
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
right: -6px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s, background 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-item:hover .remove-button {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-button:hover {
|
||||||
|
background: var(--error-color, #ef4444);
|
||||||
|
border-color: var(--error-color, #ef4444);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -22,6 +22,8 @@
|
|||||||
isSlashCommand,
|
isSlashCommand,
|
||||||
type SlashCommand,
|
type SlashCommand,
|
||||||
} from "$lib/commands/slashCommands";
|
} 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 INPUT_HISTORY_KEY = "hikari-input-history";
|
||||||
const MAX_HISTORY_SIZE = 100;
|
const MAX_HISTORY_SIZE = 100;
|
||||||
@@ -33,6 +35,7 @@
|
|||||||
let showCommandMenu = $state(false);
|
let showCommandMenu = $state(false);
|
||||||
let matchingCommands = $state<SlashCommand[]>([]);
|
let matchingCommands = $state<SlashCommand[]>([]);
|
||||||
let selectedCommandIndex = $state(0);
|
let selectedCommandIndex = $state(0);
|
||||||
|
let attachments = $state<Attachment[]>([]);
|
||||||
|
|
||||||
// Input history state
|
// Input history state
|
||||||
let inputHistory = $state<string[]>([]);
|
let inputHistory = $state<string[]>([]);
|
||||||
@@ -112,6 +115,10 @@
|
|||||||
isProcessing = processing;
|
isProcessing = processing;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
claudeStore.attachments.subscribe((storedAttachments) => {
|
||||||
|
attachments = storedAttachments;
|
||||||
|
});
|
||||||
|
|
||||||
function handleInputChange() {
|
function handleInputChange() {
|
||||||
// If input is empty, allow history navigation again
|
// If input is empty, allow history navigation again
|
||||||
// Otherwise, mark that user has manually typed
|
// Otherwise, mark that user has manually typed
|
||||||
@@ -289,6 +296,10 @@ User: ${formattedMessage}`;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleRemoveAttachment(id: string) {
|
||||||
|
claudeStore.removeAttachment(id);
|
||||||
|
}
|
||||||
|
|
||||||
function handleKeyDown(event: KeyboardEvent) {
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
// Handle command menu navigation
|
// Handle command menu navigation
|
||||||
if (showCommandMenu && matchingCommands.length > 0) {
|
if (showCommandMenu && matchingCommands.length > 0) {
|
||||||
@@ -353,6 +364,8 @@ User: ${formattedMessage}`;
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form onsubmit={handleSubmit} class="input-bar">
|
<form onsubmit={handleSubmit} class="input-bar">
|
||||||
|
<AttachmentPreview {attachments} onRemove={handleRemoveAttachment} />
|
||||||
|
|
||||||
<div class="input-controls flex gap-2 mb-2">
|
<div class="input-controls flex gap-2 mb-2">
|
||||||
<MessageModeSelector />
|
<MessageModeSelector />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export const claudeStore = {
|
|||||||
isProcessing: conversationsStore.isProcessing,
|
isProcessing: conversationsStore.isProcessing,
|
||||||
grantedTools: conversationsStore.grantedTools,
|
grantedTools: conversationsStore.grantedTools,
|
||||||
pendingRetryMessage: conversationsStore.pendingRetryMessage,
|
pendingRetryMessage: conversationsStore.pendingRetryMessage,
|
||||||
|
attachments: conversationsStore.attachments,
|
||||||
|
|
||||||
// New conversation-aware subscriptions
|
// New conversation-aware subscriptions
|
||||||
conversations: conversationsStore.conversations,
|
conversations: conversationsStore.conversations,
|
||||||
@@ -67,6 +68,12 @@ export const claudeStore = {
|
|||||||
saveScrollPosition: conversationsStore.saveScrollPosition,
|
saveScrollPosition: conversationsStore.saveScrollPosition,
|
||||||
getScrollPosition: conversationsStore.getScrollPosition,
|
getScrollPosition: conversationsStore.getScrollPosition,
|
||||||
|
|
||||||
|
// Attachment management
|
||||||
|
addAttachment: conversationsStore.addAttachment,
|
||||||
|
removeAttachment: conversationsStore.removeAttachment,
|
||||||
|
clearAttachments: conversationsStore.clearAttachments,
|
||||||
|
getAttachments: conversationsStore.getAttachments,
|
||||||
|
|
||||||
getGrantedTools: (): string[] => {
|
getGrantedTools: (): string[] => {
|
||||||
let tools: string[] = [];
|
let tools: string[] = [];
|
||||||
conversationsStore.grantedTools.subscribe((t) => (tools = Array.from(t)))();
|
conversationsStore.grantedTools.subscribe((t) => (tools = Array.from(t)))();
|
||||||
@@ -86,6 +93,7 @@ export const claudeStore = {
|
|||||||
conversationsStore.setWorkingDirectory("");
|
conversationsStore.setWorkingDirectory("");
|
||||||
conversationsStore.setProcessing(false);
|
conversationsStore.setProcessing(false);
|
||||||
conversationsStore.revokeAllTools();
|
conversationsStore.revokeAllTools();
|
||||||
|
conversationsStore.clearAttachments();
|
||||||
// Also clear history restoration
|
// Also clear history restoration
|
||||||
clearHistoryRestore();
|
clearHistoryRestore();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
ConnectionStatus,
|
ConnectionStatus,
|
||||||
PermissionRequest,
|
PermissionRequest,
|
||||||
UserQuestionEvent,
|
UserQuestionEvent,
|
||||||
|
Attachment,
|
||||||
} from "$lib/types/messages";
|
} from "$lib/types/messages";
|
||||||
import type { CharacterState } from "$lib/types/states";
|
import type { CharacterState } from "$lib/types/states";
|
||||||
import { cleanupConversationTracking } from "$lib/tauri";
|
import { cleanupConversationTracking } from "$lib/tauri";
|
||||||
@@ -24,6 +25,7 @@ export interface Conversation {
|
|||||||
scrollPosition: number;
|
scrollPosition: number;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
lastActivityAt: Date;
|
lastActivityAt: Date;
|
||||||
|
attachments: Attachment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function createConversationsStore() {
|
function createConversationsStore() {
|
||||||
@@ -59,6 +61,7 @@ function createConversationsStore() {
|
|||||||
scrollPosition: -1, // -1 means "scroll to bottom" (auto-scroll)
|
scrollPosition: -1, // -1 means "scroll to bottom" (auto-scroll)
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
lastActivityAt: new Date(),
|
lastActivityAt: new Date(),
|
||||||
|
attachments: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +112,7 @@ function createConversationsStore() {
|
|||||||
);
|
);
|
||||||
const pendingQuestion = derived(activeConversation, ($conv) => $conv?.pendingQuestion || null);
|
const pendingQuestion = derived(activeConversation, ($conv) => $conv?.pendingQuestion || null);
|
||||||
const scrollPosition = derived(activeConversation, ($conv) => $conv?.scrollPosition ?? -1);
|
const scrollPosition = derived(activeConversation, ($conv) => $conv?.scrollPosition ?? -1);
|
||||||
|
const attachments = derived(activeConversation, ($conv) => $conv?.attachments || []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Expose derived stores for compatibility
|
// Expose derived stores for compatibility
|
||||||
@@ -122,6 +126,7 @@ function createConversationsStore() {
|
|||||||
grantedTools: { subscribe: grantedTools.subscribe },
|
grantedTools: { subscribe: grantedTools.subscribe },
|
||||||
pendingRetryMessage: { subscribe: pendingRetryMessage.subscribe },
|
pendingRetryMessage: { subscribe: pendingRetryMessage.subscribe },
|
||||||
scrollPosition: { subscribe: scrollPosition.subscribe },
|
scrollPosition: { subscribe: scrollPosition.subscribe },
|
||||||
|
attachments: { subscribe: attachments.subscribe },
|
||||||
|
|
||||||
// New conversation-specific stores
|
// New conversation-specific stores
|
||||||
conversations: { subscribe: conversations.subscribe },
|
conversations: { subscribe: conversations.subscribe },
|
||||||
@@ -571,6 +576,58 @@ function createConversationsStore() {
|
|||||||
return conv?.grantedTools.has(toolName) || false;
|
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
|
// Add initialization helper
|
||||||
initialize: () => {
|
initialize: () => {
|
||||||
ensureInitialized();
|
ensureInitialized();
|
||||||
|
|||||||
@@ -142,6 +142,16 @@ export interface UserQuestionEvent {
|
|||||||
|
|
||||||
export type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error";
|
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 {
|
export interface UpdateInfo {
|
||||||
current_version: string;
|
current_version: string;
|
||||||
latest_version: string;
|
latest_version: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user